Add litterrobot integration (#45886)

This commit is contained in:
Nathan Spencer
2021-02-22 11:53:57 -07:00
committed by GitHub
parent 668574c48f
commit e70d896e1b
18 changed files with 676 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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(
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
)

View File

@ -0,0 +1,2 @@
"""Constants for the Litter-Robot integration."""
DOMAIN = "litterrobot"

View File

@ -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}"
@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",
"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,
)

View File

@ -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"]
}

View File

@ -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%]"
}
}
}

View File

@ -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"
}
}
}
}
}

View File

@ -0,0 +1,127 @@
"""Support for Litter-Robot "Vacuum"."""
from pylitterbot import Robot
from homeassistant.components.vacuum import (
STATE_CLEANING,
STATE_DOCKED,
STATE_ERROR,
SUPPORT_SEND_COMMAND,
SUPPORT_START,
SUPPORT_STATE,
SUPPORT_STATUS,
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
VacuumEntity,
)
from homeassistant.const import STATE_OFF
from .const import DOMAIN
from .hub import LitterRobotEntity
SUPPORT_LITTERROBOT = (
SUPPORT_SEND_COMMAND
| SUPPORT_START
| SUPPORT_STATE
| SUPPORT_STATUS
| SUPPORT_TURN_OFF
| SUPPORT_TURN_ON
)
TYPE_LITTER_BOX = "Litter Box"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Litter-Robot cleaner using config entry."""
hub = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for robot in hub.account.robots:
entities.append(LitterRobotCleaner(robot, TYPE_LITTER_BOX, hub))
if entities:
async_add_entities(entities, True)
class LitterRobotCleaner(LitterRobotEntity, VacuumEntity):
"""Litter-Robot "Vacuum" Cleaner."""
@property
def supported_features(self):
"""Flag cleaner robot features that are supported."""
return SUPPORT_LITTERROBOT
@property
def state(self):
"""Return the state of the cleaner."""
switcher = {
Robot.UnitStatus.CCP: STATE_CLEANING,
Robot.UnitStatus.EC: STATE_CLEANING,
Robot.UnitStatus.CCC: STATE_DOCKED,
Robot.UnitStatus.CST: STATE_DOCKED,
Robot.UnitStatus.DF1: STATE_DOCKED,
Robot.UnitStatus.DF2: STATE_DOCKED,
Robot.UnitStatus.RDY: STATE_DOCKED,
Robot.UnitStatus.OFF: STATE_OFF,
}
return switcher.get(self.robot.unit_status, STATE_ERROR)
@property
def error(self):
"""Return the error associated with the current state, if any."""
return self.robot.unit_status.value
@property
def status(self):
"""Return the status of the cleaner."""
return f"{self.robot.unit_status.value}{' (Sleeping)' if self.robot.is_sleeping else ''}"
async def async_turn_on(self, **kwargs):
"""Turn the cleaner on, starting a clean cycle."""
await self.perform_action_and_refresh(self.robot.set_power_status, True)
async def async_turn_off(self, **kwargs):
"""Turn the unit off, stopping any cleaning in progress as is."""
await self.perform_action_and_refresh(self.robot.set_power_status, False)
async def async_start(self):
"""Start a clean cycle."""
await self.perform_action_and_refresh(self.robot.start_cleaning)
async def async_send_command(self, command, params=None, **kwargs):
"""Send command.
Available commands:
- reset_waste_drawer
* params: none
- set_sleep_mode
* params:
- enabled: bool
- sleep_time: str (optional)
"""
if command == "reset_waste_drawer":
# Normally we need to request a refresh of data after a command is sent.
# However, the API for resetting the waste drawer returns a refreshed
# data set for the robot. Thus, we only need to tell hass to update the
# state of devices associated with this robot.
await self.robot.reset_waste_drawer()
self.hub.coordinator.async_set_updated_data(True)
elif command == "set_sleep_mode":
await self.perform_action_and_refresh(
self.robot.set_sleep_mode,
params.get("enabled"),
self.parse_time_at_default_timezone(params.get("sleep_time")),
)
else:
raise NotImplementedError()
@property
def device_state_attributes(self):
"""Return device specific state attributes."""
return {
"clean_cycle_wait_time_minutes": self.robot.clean_cycle_wait_time_minutes,
"is_sleeping": self.robot.is_sleeping,
"power_status": self.robot.power_status,
"unit_status_code": self.robot.unit_status.name,
"last_seen": self.robot.last_seen,
}

View File

@ -121,6 +121,7 @@ FLOWS = [
"kulersky",
"life360",
"lifx",
"litterrobot",
"local_ip",
"locative",
"logi_circle",

View File

@ -1503,6 +1503,9 @@ pylibrespot-java==0.1.0
# homeassistant.components.litejet
pylitejet==0.1
# homeassistant.components.litterrobot
pylitterbot==2021.2.5
# homeassistant.components.loopenergy
pyloopenergy==0.2.1

View File

@ -793,6 +793,9 @@ pylibrespot-java==0.1.0
# homeassistant.components.litejet
pylitejet==0.1
# homeassistant.components.litterrobot
pylitterbot==2021.2.5
# homeassistant.components.lutron_caseta
pylutron-caseta==0.9.0

View File

@ -0,0 +1 @@
"""Tests for the Litter-Robot Component."""

View File

@ -0,0 +1,24 @@
"""Common utils for Litter-Robot tests."""
from homeassistant.components.litterrobot import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
BASE_PATH = "homeassistant.components.litterrobot"
CONFIG = {DOMAIN: {CONF_USERNAME: "user@example.com", CONF_PASSWORD: "password"}}
ROBOT_NAME = "Test"
ROBOT_SERIAL = "LR3C012345"
ROBOT_DATA = {
"powerStatus": "AC",
"lastSeen": "2021-02-01T15:30:00.000000",
"cleanCycleWaitTimeMinutes": "7",
"unitStatus": "RDY",
"litterRobotNickname": ROBOT_NAME,
"cycleCount": "15",
"panelLockActive": "0",
"cyclesAfterDrawerFull": "0",
"litterRobotSerial": ROBOT_SERIAL,
"cycleCapacity": "30",
"litterRobotId": "a0123b4567cd8e",
"nightLightActive": "1",
"sleepModeActive": "112:50:19",
}

View File

@ -0,0 +1,35 @@
"""Configure pytest for Litter-Robot tests."""
from unittest.mock import AsyncMock, MagicMock
from pylitterbot import Robot
import pytest
from homeassistant.components import litterrobot
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .common import ROBOT_DATA
def create_mock_robot(hass):
"""Create a mock Litter-Robot device."""
robot = Robot(data=ROBOT_DATA)
robot.start_cleaning = AsyncMock()
robot.set_power_status = AsyncMock()
robot.reset_waste_drawer = AsyncMock()
robot.set_sleep_mode = AsyncMock()
return robot
@pytest.fixture()
def mock_hub(hass):
"""Mock a Litter-Robot hub."""
hub = MagicMock(
hass=hass,
account=MagicMock(),
logged_in=True,
coordinator=MagicMock(spec=DataUpdateCoordinator),
spec=litterrobot.LitterRobotHub,
)
hub.coordinator.last_update_success = True
hub.account.robots = [create_mock_robot(hass)]
return hub

View File

@ -0,0 +1,92 @@
"""Test the Litter-Robot config flow."""
from unittest.mock import patch
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
from homeassistant import config_entries, setup
from .common import CONF_USERNAME, CONFIG, DOMAIN
async def test_form(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.litterrobot.config_flow.LitterRobotHub.login",
return_value=True,
), patch(
"homeassistant.components.litterrobot.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.litterrobot.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG[DOMAIN]
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == CONFIG[DOMAIN][CONF_USERNAME]
assert result2["data"] == CONFIG[DOMAIN]
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(hass):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.litterrobot.config_flow.LitterRobotHub.login",
side_effect=LitterRobotLoginException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG[DOMAIN]
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.litterrobot.config_flow.LitterRobotHub.login",
side_effect=LitterRobotException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG[DOMAIN]
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_unknown_error(hass):
"""Test we handle unknown error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.litterrobot.config_flow.LitterRobotHub.login",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG[DOMAIN]
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"}

View File

@ -0,0 +1,20 @@
"""Test Litter-Robot setup process."""
from homeassistant.components import litterrobot
from homeassistant.setup import async_setup_component
from .common import CONFIG
from tests.common import MockConfigEntry
async def test_unload_entry(hass):
"""Test being able to unload an entry."""
entry = MockConfigEntry(
domain=litterrobot.DOMAIN,
data=CONFIG[litterrobot.DOMAIN],
)
entry.add_to_hass(hass)
assert await async_setup_component(hass, litterrobot.DOMAIN, {}) is True
assert await litterrobot.async_unload_entry(hass, entry)
assert hass.data[litterrobot.DOMAIN] == {}

View File

@ -0,0 +1,92 @@
"""Test the Litter-Robot vacuum entity."""
from datetime import timedelta
from unittest.mock import patch
import pytest
from homeassistant.components import litterrobot
from homeassistant.components.litterrobot.hub import REFRESH_WAIT_TIME
from homeassistant.components.vacuum import (
ATTR_PARAMS,
DOMAIN as PLATFORM_DOMAIN,
SERVICE_SEND_COMMAND,
SERVICE_START,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_DOCKED,
)
from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID
from homeassistant.util.dt import utcnow
from .common import CONFIG
from tests.common import MockConfigEntry, async_fire_time_changed
ENTITY_ID = "vacuum.test_litter_box"
async def setup_hub(hass, mock_hub):
"""Load the Litter-Robot vacuum platform with the provided hub."""
hass.config.components.add(litterrobot.DOMAIN)
entry = MockConfigEntry(
domain=litterrobot.DOMAIN,
data=CONFIG[litterrobot.DOMAIN],
)
with patch.dict(hass.data, {litterrobot.DOMAIN: {entry.entry_id: mock_hub}}):
await hass.config_entries.async_forward_entry_setup(entry, PLATFORM_DOMAIN)
await hass.async_block_till_done()
async def test_vacuum(hass, mock_hub):
"""Tests the vacuum entity was set up."""
await setup_hub(hass, mock_hub)
vacuum = hass.states.get(ENTITY_ID)
assert vacuum is not None
assert vacuum.state == STATE_DOCKED
assert vacuum.attributes["is_sleeping"] is False
@pytest.mark.parametrize(
"service,command,extra",
[
(SERVICE_START, "start_cleaning", None),
(SERVICE_TURN_OFF, "set_power_status", None),
(SERVICE_TURN_ON, "set_power_status", None),
(
SERVICE_SEND_COMMAND,
"reset_waste_drawer",
{ATTR_COMMAND: "reset_waste_drawer"},
),
(
SERVICE_SEND_COMMAND,
"set_sleep_mode",
{
ATTR_COMMAND: "set_sleep_mode",
ATTR_PARAMS: {"enabled": True, "sleep_time": "22:30"},
},
),
],
)
async def test_commands(hass, mock_hub, service, command, extra):
"""Test sending commands to the vacuum."""
await setup_hub(hass, mock_hub)
vacuum = hass.states.get(ENTITY_ID)
assert vacuum is not None
assert vacuum.state == STATE_DOCKED
data = {ATTR_ENTITY_ID: ENTITY_ID}
if extra:
data.update(extra)
await hass.services.async_call(
PLATFORM_DOMAIN,
service,
data,
blocking=True,
)
future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME)
async_fire_time_changed(hass, future)
getattr(mock_hub.account.robots[0], command).assert_called_once()