From e70d896e1bfe56cfd6aa90cb85200e022ec92474 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 22 Feb 2021 11:53:57 -0700 Subject: [PATCH] Add litterrobot integration (#45886) --- CODEOWNERS | 1 + .../components/litterrobot/__init__.py | 54 ++++++++ .../components/litterrobot/config_flow.py | 51 +++++++ homeassistant/components/litterrobot/const.py | 2 + homeassistant/components/litterrobot/hub.py | 122 +++++++++++++++++ .../components/litterrobot/manifest.json | 8 ++ .../components/litterrobot/strings.json | 20 +++ .../litterrobot/translations/en.json | 20 +++ .../components/litterrobot/vacuum.py | 127 ++++++++++++++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/litterrobot/__init__.py | 1 + tests/components/litterrobot/common.py | 24 ++++ tests/components/litterrobot/conftest.py | 35 +++++ .../litterrobot/test_config_flow.py | 92 +++++++++++++ tests/components/litterrobot/test_init.py | 20 +++ tests/components/litterrobot/test_vacuum.py | 92 +++++++++++++ 18 files changed, 676 insertions(+) create mode 100644 homeassistant/components/litterrobot/__init__.py create mode 100644 homeassistant/components/litterrobot/config_flow.py create mode 100644 homeassistant/components/litterrobot/const.py create mode 100644 homeassistant/components/litterrobot/hub.py create mode 100644 homeassistant/components/litterrobot/manifest.json create mode 100644 homeassistant/components/litterrobot/strings.json create mode 100644 homeassistant/components/litterrobot/translations/en.json create mode 100644 homeassistant/components/litterrobot/vacuum.py create mode 100644 tests/components/litterrobot/__init__.py create mode 100644 tests/components/litterrobot/common.py create mode 100644 tests/components/litterrobot/conftest.py create mode 100644 tests/components/litterrobot/test_config_flow.py create mode 100644 tests/components/litterrobot/test_init.py create mode 100644 tests/components/litterrobot/test_vacuum.py diff --git a/CODEOWNERS b/CODEOWNERS index a9d4ce63209..b20b489ac6d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py new file mode 100644 index 00000000000..bf43d5c465e --- /dev/null +++ b/homeassistant/components/litterrobot/__init__.py @@ -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 diff --git a/homeassistant/components/litterrobot/config_flow.py b/homeassistant/components/litterrobot/config_flow.py new file mode 100644 index 00000000000..d6c92d8dad6 --- /dev/null +++ b/homeassistant/components/litterrobot/config_flow.py @@ -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 + ) diff --git a/homeassistant/components/litterrobot/const.py b/homeassistant/components/litterrobot/const.py new file mode 100644 index 00000000000..5ac889d9b73 --- /dev/null +++ b/homeassistant/components/litterrobot/const.py @@ -0,0 +1,2 @@ +"""Constants for the Litter-Robot integration.""" +DOMAIN = "litterrobot" diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/hub.py new file mode 100644 index 00000000000..0d0559140c7 --- /dev/null +++ b/homeassistant/components/litterrobot/hub.py @@ -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, + ) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json new file mode 100644 index 00000000000..1c6ac7274bf --- /dev/null +++ b/homeassistant/components/litterrobot/manifest.json @@ -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"] +} diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json new file mode 100644 index 00000000000..96dc8b371d1 --- /dev/null +++ b/homeassistant/components/litterrobot/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/litterrobot/translations/en.json b/homeassistant/components/litterrobot/translations/en.json new file mode 100644 index 00000000000..b3fc76ae458 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/en.json @@ -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" + } + } + } + } +} diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py new file mode 100644 index 00000000000..a57c1ffead5 --- /dev/null +++ b/homeassistant/components/litterrobot/vacuum.py @@ -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, + } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8e8949e5788..36c22262ef4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -121,6 +121,7 @@ FLOWS = [ "kulersky", "life360", "lifx", + "litterrobot", "local_ip", "locative", "logi_circle", diff --git a/requirements_all.txt b/requirements_all.txt index b98cbde46bc..55ea1510b59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1beff34323..b590a8c50b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/litterrobot/__init__.py b/tests/components/litterrobot/__init__.py new file mode 100644 index 00000000000..a7267365100 --- /dev/null +++ b/tests/components/litterrobot/__init__.py @@ -0,0 +1 @@ +"""Tests for the Litter-Robot Component.""" diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py new file mode 100644 index 00000000000..ed893a3a756 --- /dev/null +++ b/tests/components/litterrobot/common.py @@ -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", +} diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py new file mode 100644 index 00000000000..2f967d266bc --- /dev/null +++ b/tests/components/litterrobot/conftest.py @@ -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 diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py new file mode 100644 index 00000000000..fd88595d37e --- /dev/null +++ b/tests/components/litterrobot/test_config_flow.py @@ -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"} diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py new file mode 100644 index 00000000000..1d0ed075cc7 --- /dev/null +++ b/tests/components/litterrobot/test_init.py @@ -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] == {} diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py new file mode 100644 index 00000000000..b47eff64e13 --- /dev/null +++ b/tests/components/litterrobot/test_vacuum.py @@ -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()