Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ homeassistant/components/launch_library/* @ludeeus
homeassistant/components/lcn/* @alengwenus
homeassistant/components/life360/* @pnbruckner
homeassistant/components/linux_battery/* @fabaff
homeassistant/components/litterrobot/* @natekspencer
homeassistant/components/local_ip/* @issacg
homeassistant/components/logger/* @home-assistant/core
homeassistant/components/logi_circle/* @evanjd
Expand Down
54 changes: 54 additions & 0 deletions homeassistant/components/litterrobot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""The Litter-Robot integration."""
import asyncio

from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

from .const import DOMAIN
from .hub import LitterRobotHub

PLATFORMS = ["vacuum"]


async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Litter-Robot component."""
hass.data.setdefault(DOMAIN, {})

return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Litter-Robot from a config entry."""
hub = hass.data[DOMAIN][entry.entry_id] = LitterRobotHub(hass, entry.data)
try:
await hub.login(load_robots=True)
except LitterRobotLoginException:
return False
except LitterRobotException as ex:
raise ConfigEntryNotReady from ex

for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
51 changes: 51 additions & 0 deletions homeassistant/components/litterrobot/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Config flow for Litter-Robot integration."""
import logging

from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME

from .const import DOMAIN # pylint:disable=unused-import
from .hub import LitterRobotHub

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
)


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Litter-Robot."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}

if user_input is not None:
for entry in self._async_current_entries():
if entry.data[CONF_USERNAME] == user_input[CONF_USERNAME]:
return self.async_abort(reason="already_configured")

hub = LitterRobotHub(self.hass, user_input)
try:
await hub.login()
return self.async_create_entry(
Comment thread
natekspencer marked this conversation as resolved.
title=user_input[CONF_USERNAME], data=user_input
)
except LitterRobotLoginException:
errors["base"] = "invalid_auth"
except LitterRobotException:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
2 changes: 2 additions & 0 deletions homeassistant/components/litterrobot/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Constants for the Litter-Robot integration."""
DOMAIN = "litterrobot"
122 changes: 122 additions & 0 deletions homeassistant/components/litterrobot/hub.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""A wrapper 'hub' for the Litter-Robot API and base entity for common attributes."""
from datetime import time, timedelta
import logging
from types import MethodType
from typing import Any, Optional

from pylitterbot import Account, Robot
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException

from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
import homeassistant.util.dt as dt_util

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

REFRESH_WAIT_TIME = 12
UPDATE_INTERVAL = 10


class LitterRobotHub:
"""A Litter-Robot hub wrapper class."""

def __init__(self, hass: HomeAssistant, data: dict):
"""Initialize the Litter-Robot hub."""
self._data = data
self.account = None
self.logged_in = False

async def _async_update_data():
"""Update all device states from the Litter-Robot API."""
await self.account.refresh_robots()
return True

self.coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=DOMAIN,
update_method=_async_update_data,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)

async def login(self, load_robots: bool = False):
"""Login to Litter-Robot."""
self.logged_in = False
self.account = Account()
try:
await self.account.connect(
username=self._data[CONF_USERNAME],
password=self._data[CONF_PASSWORD],
load_robots=load_robots,
)
self.logged_in = True
return self.logged_in
except LitterRobotLoginException as ex:
_LOGGER.error("Invalid credentials")
raise ex
except LitterRobotException as ex:
_LOGGER.error("Unable to connect to Litter-Robot API")
raise ex


class LitterRobotEntity(CoordinatorEntity):
"""Generic Litter-Robot entity representing common data and methods."""

def __init__(self, robot: Robot, entity_type: str, hub: LitterRobotHub):
"""Pass coordinator to CoordinatorEntity."""
super().__init__(hub.coordinator)
self.robot = robot
self.entity_type = entity_type if entity_type else ""
self.hub = hub

@property
def name(self):
"""Return the name of this entity."""
return f"{self.robot.name} {self.entity_type}"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be a trailing space here if the entity type is an empty string.


@property
def unique_id(self):
"""Return a unique ID."""
return f"{self.robot.serial}-{self.entity_type}"

@property
def device_info(self):
"""Return the device information for a Litter-Robot."""
model = "Litter-Robot 3 Connect"
if not self.robot.serial.startswith("LR3C"):
model = "Other Litter-Robot Connected Device"
return {
"identifiers": {(DOMAIN, self.robot.serial)},
"name": self.robot.name,
"manufacturer": "Litter-Robot",
Comment thread
natekspencer marked this conversation as resolved.
"model": model,
}

async def perform_action_and_refresh(self, action: MethodType, *args: Any):
"""Perform an action and initiates a refresh of the robot data after a few seconds."""
await action(*args)
async_call_later(
self.hass, REFRESH_WAIT_TIME, self.hub.coordinator.async_request_refresh
)

@staticmethod
def parse_time_at_default_timezone(time_str: str) -> Optional[time]:
"""Parse a time string and add default timezone."""
parsed_time = dt_util.parse_time(time_str)

if parsed_time is None:
return None

return time(
hour=parsed_time.hour,
minute=parsed_time.minute,
second=parsed_time.second,
tzinfo=dt_util.DEFAULT_TIME_ZONE,
)
8 changes: 8 additions & 0 deletions homeassistant/components/litterrobot/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"domain": "litterrobot",
"name": "Litter-Robot",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/litterrobot",
"requirements": ["pylitterbot==2021.2.5"],
"codeowners": ["@natekspencer"]
}
20 changes: 20 additions & 0 deletions homeassistant/components/litterrobot/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
20 changes: 20 additions & 0 deletions homeassistant/components/litterrobot/translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"password": "Password",
"username": "Username"
}
}
}
}
}
Loading