From e05f7a9633a35b5b7c547ba7f66b7a2e1bfca148 Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Wed, 30 Apr 2025 13:41:05 -0400 Subject: [PATCH] Expose LitterHopper status for LR4 (#143684) * Expose LitterHopper status for LR4 * Proper naming and icons * Add simple tests * fix test: lowercase enabled * over-torque, not OT * Don't use icon_fn for simple state map * short not Short * Better state names --- .../components/litterrobot/binary_sensor.py | 11 ++++++- .../components/litterrobot/icons.json | 16 ++++++++++ .../components/litterrobot/sensor.py | 30 +++++++++++++++---- .../components/litterrobot/strings.json | 14 +++++++++ tests/components/litterrobot/conftest.py | 10 +++++++ .../litterrobot/test_binary_sensor.py | 15 ++++++++++ tests/components/litterrobot/test_sensor.py | 9 ++++++ 7 files changed, 98 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index ca9af22f1e9..d4df011d0aa 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic -from pylitterbot import LitterRobot, Robot +from pylitterbot import LitterRobot, LitterRobot4, Robot from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -47,6 +47,15 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . is_on_fn=lambda robot: robot.sleep_mode_enabled, ), ), + LitterRobot4: ( + RobotBinarySensorEntityDescription[LitterRobot4]( + key="hopper_connected", + translation_key="hopper_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + is_on_fn=lambda robot: not robot.is_hopper_removed, + ), + ), Robot: ( # type: ignore[type-abstract] # only used for isinstance check RobotBinarySensorEntityDescription[Robot]( key="power_status", diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index ba3df2114b7..163ad80c0a8 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -6,6 +6,9 @@ }, "sleep_mode": { "default": "mdi:sleep" + }, + "hopper_connected": { + "default": "mdi:filter-check" } }, "button": { @@ -32,6 +35,19 @@ "default": "mdi:scale" } }, + "sensor": { + "hopper_status": { + "default": "mdi:filter", + "state": { + "disabled": "mdi:filter-remove", + "empty": "mdi:filter-minus-outline", + "enabled": "mdi:filter-check", + "motor_disconnected": "mdi:engine-off", + "motor_fault_short": "mdi:flash-off", + "motor_ot_amps": "mdi:flash-alert" + } + } + }, "switch": { "night_light_mode": { "default": "mdi:lightbulb-off", diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index a638f24cf2a..cdd9a1c08a5 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -57,9 +57,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { translation_key="sleep_mode_start_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=( - lambda robot: robot.sleep_mode_start_time - if robot.sleep_mode_enabled - else None + lambda robot: ( + robot.sleep_mode_start_time if robot.sleep_mode_enabled else None + ) ), ), RobotSensorEntityDescription[LitterRobot]( @@ -67,9 +67,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { translation_key="sleep_mode_end_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=( - lambda robot: robot.sleep_mode_end_time - if robot.sleep_mode_enabled - else None + lambda robot: ( + robot.sleep_mode_end_time if robot.sleep_mode_enabled else None + ) ), ), RobotSensorEntityDescription[LitterRobot]( @@ -117,6 +117,24 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { ), ], LitterRobot4: [ + RobotSensorEntityDescription[LitterRobot4]( + key="hopper_status", + translation_key="hopper_status", + device_class=SensorDeviceClass.ENUM, + options=[ + "enabled", + "disabled", + "motor_fault_short", + "motor_ot_amps", + "motor_disconnected", + "empty", + ], + value_fn=( + lambda robot: ( + status.name.lower() if (status := robot.hopper_status) else None + ) + ), + ), RobotSensorEntityDescription[LitterRobot4]( key="litter_level", translation_key="litter_level", diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index c791629fa32..ba5472918d3 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -34,6 +34,9 @@ }, "entity": { "binary_sensor": { + "hopper_connected": { + "name": "Hopper connected" + }, "sleeping": { "name": "Sleeping" }, @@ -59,6 +62,17 @@ "food_level": { "name": "Food level" }, + "hopper_status": { + "name": "Hopper status", + "state": { + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]", + "motor_fault_short": "Motor shorted", + "motor_ot_amps": "Motor overtorqued", + "motor_disconnected": "Motor disconnected", + "empty": "Empty" + } + }, "last_seen": { "name": "Last seen" }, diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index d22c4b2ec49..a6058c75bca 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot from pylitterbot.exceptions import InvalidCommandException +from pylitterbot.robot.litterrobot4 import HopperStatus import pytest from homeassistant.core import HomeAssistant @@ -84,6 +85,15 @@ def mock_account_with_litterrobot_4() -> MagicMock: return create_mock_account(v4=True) +@pytest.fixture +def mock_account_with_litterhopper() -> MagicMock: + """Mock account with LitterHopper attached to Litter-Robot 4.""" + return create_mock_account( + robot_data={"hopperStatus": HopperStatus.ENABLED, "isHopperRemoved": False}, + v4=True, + ) + + @pytest.fixture def mock_account_with_feederrobot() -> MagicMock: """Mock account with Feeder-Robot.""" diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index 3fe72aef7e3..a8da7e53d9f 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -30,3 +30,18 @@ async def test_binary_sensors( state = hass.states.get("binary_sensor.test_power_status") assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PLUG assert state.state == "on" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_litterhopper_binary_sensors( + hass: HomeAssistant, + mock_account_with_litterhopper: MagicMock, +) -> None: + """Tests LitterHopper-specific binary sensors.""" + await setup_integration(hass, mock_account_with_litterhopper, BINARY_SENSOR_DOMAIN) + + state = hass.states.get("binary_sensor.test_hopper_connected") + assert state.state == "on" + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY + ) diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index e290d96fcf4..bbc6274e56b 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -114,3 +114,12 @@ async def test_pet_weight_sensor( sensor = hass.states.get("sensor.kitty_weight") assert sensor.state == "9.1" assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS + + +async def test_litterhopper_sensor( + hass: HomeAssistant, mock_account_with_litterhopper: MagicMock +) -> None: + """Tests LitterHopper sensors.""" + await setup_integration(hass, mock_account_with_litterhopper, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.test_hopper_status") + assert sensor.state == "enabled"