From 531189f92b1646433bac640a2ff6e93ae3d0362f Mon Sep 17 00:00:00 2001 From: Legendberg <200012211+Legendberg@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:23:08 -0700 Subject: [PATCH] Add Litter-Robot 5 night light (#174508) Co-authored-by: Legendberg Co-authored-by: Claude Fable 5 --- .../components/litterrobot/__init__.py | 1 + .../components/litterrobot/icons.json | 5 + homeassistant/components/litterrobot/light.py | 109 +++++++++++++ .../components/litterrobot/select.py | 3 +- .../components/litterrobot/strings.json | 5 + tests/components/litterrobot/conftest.py | 1 + .../litterrobot/snapshots/test_light.ambr | 74 +++++++++ tests/components/litterrobot/test_light.py | 147 ++++++++++++++++++ tests/components/litterrobot/test_select.py | 28 +++- 9 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/litterrobot/light.py create mode 100644 tests/components/litterrobot/snapshots/test_light.ambr create mode 100644 tests/components/litterrobot/test_light.py diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 510da8435057..07e6921fcd30 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -28,6 +28,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.LIGHT, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 484568be55ec..a6e6a9cb3683 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -34,6 +34,11 @@ "default": "mdi:delete-variant" } }, + "light": { + "night_light": { + "default": "mdi:lightbulb-night" + } + }, "select": { "brightness_level": { "default": "mdi:lightbulb-question", diff --git a/homeassistant/components/litterrobot/light.py b/homeassistant/components/litterrobot/light.py new file mode 100644 index 000000000000..996b6b644bea --- /dev/null +++ b/homeassistant/components/litterrobot/light.py @@ -0,0 +1,109 @@ +"""Support for Litter-Robot night light.""" + +from typing import Any, override + +from pylitterbot import LitterRobot5 +from pylitterbot.robot.litterrobot4 import NightLightMode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LitterRobotConfigEntry +from .entity import LitterRobotEntity, whisker_command + +PARALLEL_UPDATES = 1 + +NIGHT_LIGHT_DESCRIPTION = LightEntityDescription( + key="night_light", + translation_key="night_light", +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LitterRobotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Litter-Robot night light using config entry.""" + coordinator = entry.runtime_data + known_robots: set[str] = set() + + def _check_robots() -> None: + all_robots = [ + robot + for robot in coordinator.account.robots + if isinstance(robot, LitterRobot5) + ] + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + async_add_entities( + LitterRobotNightLight(robot, coordinator, NIGHT_LIGHT_DESCRIPTION) + for robot in all_robots + if robot.serial in new_robots + ) + + _check_robots() + entry.async_on_unload(coordinator.async_add_listener(_check_robots)) + + +class LitterRobotNightLight(LitterRobotEntity[LitterRobot5], LightEntity): + """Representation of the night light on a Litter-Robot 5.""" + + _attr_color_mode = ColorMode.RGB + _attr_supported_color_modes = {ColorMode.RGB} + + @property + @override + def is_on(self) -> bool: + """Return whether the night light is on (any mode other than off).""" + mode = self.robot.night_light_mode + return mode is not None and mode is not NightLightMode.OFF + + @property + @override + def brightness(self) -> int: + """Return the brightness of the night light, scaled to 0-255.""" + return round(self.robot.night_light_brightness * 255 / 100) + + @property + @override + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the color of the night light.""" + return self.robot.night_light_rgb_color + + @whisker_command + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the night light, applying any requested color or brightness.""" + mode = self.robot.night_light_mode + brightness = self.robot.night_light_brightness + color = self.robot.night_light_rgb_color or (255, 255, 255) + + if ATTR_RGB_COLOR in kwargs: + color = kwargs[ATTR_RGB_COLOR] + + if ATTR_BRIGHTNESS in kwargs: + brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255) + # Preserve the auto mode when the light is already on; otherwise switch on. + if mode is None or mode is NightLightMode.OFF: + mode = NightLightMode.ON + + # The API replaces the entire settings object, so send every field. + await self.robot.set_night_light_settings( + mode=mode, brightness=brightness, color=color + ) + + @whisker_command + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the night light.""" + await self.robot.set_night_light_settings(mode=NightLightMode.OFF) diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index e377795652a2..2a8bc4253ae0 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -166,7 +166,8 @@ class LitterRobotSelectEntity( @override def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - return str(self.entity_description.current_fn(self.robot)) + option = self.entity_description.current_fn(self.robot) + return None if option is None else str(option) @whisker_command @override diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 9b0774d0afc7..6ea5e36566f5 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -81,6 +81,11 @@ "name": "Reset waste drawer" } }, + "light": { + "night_light": { + "name": "Night light" + } + }, "select": { "brightness_level": { "name": "Panel brightness", diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index fc75ca374287..c7d2b417cfe5 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -51,6 +51,7 @@ def create_mock_robot( robot.change_filter = AsyncMock(side_effect=side_effect) robot.set_night_light_brightness = AsyncMock(side_effect=side_effect) robot.set_night_light_mode = AsyncMock(side_effect=side_effect) + robot.set_night_light_settings = AsyncMock(side_effect=side_effect) robot.set_panel_brightness = AsyncMock(side_effect=side_effect) elif v4: robot = LitterRobot4(data={**ROBOT_4_DATA, **robot_data}, account=account) diff --git a/tests/components/litterrobot/snapshots/test_light.ambr b/tests/components/litterrobot/snapshots/test_light.ambr new file mode 100644 index 000000000000..e5436ff49bfa --- /dev/null +++ b/tests/components/litterrobot/snapshots/test_light.ambr @@ -0,0 +1,74 @@ +# serializer version: 1 +# name: test_all_entities[light.test_night_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_night_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Night light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night light', + 'platform': 'litterrobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'night_light', + 'unique_id': 'LR5C010001-night_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.test_night_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 128, + : , + : 'Test Night light', + : tuple( + 0.0, + 0.0, + ), + : tuple( + 255, + 255, + 255, + ), + : list([ + , + ]), + : , + : tuple( + 0.323, + 0.329, + ), + }), + 'context': , + 'entity_id': 'light.test_night_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/litterrobot/test_light.py b/tests/components/litterrobot/test_light.py new file mode 100644 index 000000000000..0765dd738844 --- /dev/null +++ b/tests/components/litterrobot/test_light.py @@ -0,0 +1,147 @@ +"""Test the Litter-Robot light entity.""" + +from unittest.mock import MagicMock, patch + +from pylitterbot.exceptions import InvalidCommandException +from pylitterbot.robot.litterrobot4 import NightLightMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import create_mock_account, setup_integration + +from tests.common import snapshot_platform + +NIGHT_LIGHT_ENTITY_ID = "light.test_night_light" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_account_with_litterrobot_5: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test the Litter-Robot 5 light entities.""" + with patch("homeassistant.components.litterrobot.PLATFORMS", [Platform.LIGHT]): + entry = await setup_integration(hass, mock_account_with_litterrobot_5) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_turn_on_brightness( + hass: HomeAssistant, mock_account_with_litterrobot_5: MagicMock +) -> None: + """Test setting the night light brightness, preserving mode and color.""" + await setup_integration(hass, mock_account_with_litterrobot_5, LIGHT_DOMAIN) + + robot = mock_account_with_litterrobot_5.robots[0] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: NIGHT_LIGHT_ENTITY_ID, ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + robot.set_night_light_settings.assert_awaited_once_with( + mode=NightLightMode.AUTO, brightness=100, color=(255, 255, 255) + ) + + +async def test_turn_on_color( + hass: HomeAssistant, mock_account_with_litterrobot_5: MagicMock +) -> None: + """Test setting the night light color, preserving mode and brightness.""" + await setup_integration(hass, mock_account_with_litterrobot_5, LIGHT_DOMAIN) + + robot = mock_account_with_litterrobot_5.robots[0] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: NIGHT_LIGHT_ENTITY_ID, ATTR_RGB_COLOR: (255, 0, 0)}, + blocking=True, + ) + robot.set_night_light_settings.assert_awaited_once_with( + mode=NightLightMode.AUTO, brightness=50, color=(255, 0, 0) + ) + + +async def test_turn_on_from_off_switches_on(hass: HomeAssistant) -> None: + """Test turning on a night light that is off switches the mode to on.""" + mock_account = create_mock_account( + robot_data={ + "nightLightSettings": { + "brightness": 50, + "color": "#FFFFFF", + "mode": "OFF", + } + }, + v5=True, + ) + await setup_integration(hass, mock_account, LIGHT_DOMAIN) + + entity = hass.states.get(NIGHT_LIGHT_ENTITY_ID) + assert entity + assert entity.state == "off" + + robot = mock_account.robots[0] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: NIGHT_LIGHT_ENTITY_ID}, + blocking=True, + ) + robot.set_night_light_settings.assert_awaited_once_with( + mode=NightLightMode.ON, brightness=50, color=(255, 255, 255) + ) + + +async def test_turn_off( + hass: HomeAssistant, mock_account_with_litterrobot_5: MagicMock +) -> None: + """Test turning off the night light.""" + await setup_integration(hass, mock_account_with_litterrobot_5, LIGHT_DOMAIN) + + robot = mock_account_with_litterrobot_5.robots[0] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: NIGHT_LIGHT_ENTITY_ID}, + blocking=True, + ) + robot.set_night_light_settings.assert_awaited_once_with(mode=NightLightMode.OFF) + + +async def test_command_exception(hass: HomeAssistant) -> None: + """Test that a LitterRobotException is wrapped in HomeAssistantError.""" + mock_account = create_mock_account( + side_effect=InvalidCommandException("Invalid command: oops"), v5=True + ) + await setup_integration(hass, mock_account, LIGHT_DOMAIN) + + with pytest.raises(HomeAssistantError, match="Invalid command: oops"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: NIGHT_LIGHT_ENTITY_ID}, + blocking=True, + ) + + +async def test_litter_robot_4_has_no_light( + hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock +) -> None: + """Test that a Litter-Robot 4 creates no light entities.""" + await setup_integration(hass, mock_account_with_litterrobot_4, LIGHT_DOMAIN) + + assert not hass.states.async_entity_ids(LIGHT_DOMAIN) diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 411bedf35121..2a963e9f090e 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -12,12 +12,12 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, EntityCategory +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import setup_integration +from .conftest import create_mock_account, setup_integration SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" @@ -202,3 +202,27 @@ async def test_litterrobot_5_panel_brightness( ) assert robot.set_panel_brightness.call_count == count + 1 + + +async def test_globe_brightness_unmapped_level(hass: HomeAssistant) -> None: + """A brightness matching no level leaves the select unknown, not "None". + + The LR5 firmware accepts any 0-100 brightness, so a value set outside the + discrete levels has no matching option. current_option must return None so + the entity reads as unknown, rather than the string "None". + """ + mock_account = create_mock_account( + robot_data={ + "nightLightSettings": { + "brightness": 60, + "color": "#FFFFFF", + "mode": "Auto", + } + }, + v5=True, + ) + await setup_integration(hass, mock_account, SELECT_DOMAIN) + + select = hass.states.get("select.test_globe_brightness") + assert select + assert select.state == STATE_UNKNOWN