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
This commit is contained in:
Justin Bull 2025-04-30 13:41:05 -04:00 committed by GitHub
parent 30656a4e72
commit e05f7a9633
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 98 additions and 7 deletions

View File

@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Generic from typing import Generic
from pylitterbot import LitterRobot, Robot from pylitterbot import LitterRobot, LitterRobot4, Robot
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
@ -47,6 +47,15 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, .
is_on_fn=lambda robot: robot.sleep_mode_enabled, 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 Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[Robot]( RobotBinarySensorEntityDescription[Robot](
key="power_status", key="power_status",

View File

@ -6,6 +6,9 @@
}, },
"sleep_mode": { "sleep_mode": {
"default": "mdi:sleep" "default": "mdi:sleep"
},
"hopper_connected": {
"default": "mdi:filter-check"
} }
}, },
"button": { "button": {
@ -32,6 +35,19 @@
"default": "mdi:scale" "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": { "switch": {
"night_light_mode": { "night_light_mode": {
"default": "mdi:lightbulb-off", "default": "mdi:lightbulb-off",

View File

@ -57,9 +57,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
translation_key="sleep_mode_start_time", translation_key="sleep_mode_start_time",
device_class=SensorDeviceClass.TIMESTAMP, device_class=SensorDeviceClass.TIMESTAMP,
value_fn=( value_fn=(
lambda robot: robot.sleep_mode_start_time lambda robot: (
if robot.sleep_mode_enabled robot.sleep_mode_start_time if robot.sleep_mode_enabled else None
else None )
), ),
), ),
RobotSensorEntityDescription[LitterRobot]( RobotSensorEntityDescription[LitterRobot](
@ -67,9 +67,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
translation_key="sleep_mode_end_time", translation_key="sleep_mode_end_time",
device_class=SensorDeviceClass.TIMESTAMP, device_class=SensorDeviceClass.TIMESTAMP,
value_fn=( value_fn=(
lambda robot: robot.sleep_mode_end_time lambda robot: (
if robot.sleep_mode_enabled robot.sleep_mode_end_time if robot.sleep_mode_enabled else None
else None )
), ),
), ),
RobotSensorEntityDescription[LitterRobot]( RobotSensorEntityDescription[LitterRobot](
@ -117,6 +117,24 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
), ),
], ],
LitterRobot4: [ 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]( RobotSensorEntityDescription[LitterRobot4](
key="litter_level", key="litter_level",
translation_key="litter_level", translation_key="litter_level",

View File

@ -34,6 +34,9 @@
}, },
"entity": { "entity": {
"binary_sensor": { "binary_sensor": {
"hopper_connected": {
"name": "Hopper connected"
},
"sleeping": { "sleeping": {
"name": "Sleeping" "name": "Sleeping"
}, },
@ -59,6 +62,17 @@
"food_level": { "food_level": {
"name": "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": { "last_seen": {
"name": "Last seen" "name": "Last seen"
}, },

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot
from pylitterbot.exceptions import InvalidCommandException from pylitterbot.exceptions import InvalidCommandException
from pylitterbot.robot.litterrobot4 import HopperStatus
import pytest import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -84,6 +85,15 @@ def mock_account_with_litterrobot_4() -> MagicMock:
return create_mock_account(v4=True) 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 @pytest.fixture
def mock_account_with_feederrobot() -> MagicMock: def mock_account_with_feederrobot() -> MagicMock:
"""Mock account with Feeder-Robot.""" """Mock account with Feeder-Robot."""

View File

@ -30,3 +30,18 @@ async def test_binary_sensors(
state = hass.states.get("binary_sensor.test_power_status") state = hass.states.get("binary_sensor.test_power_status")
assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PLUG assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PLUG
assert state.state == "on" 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
)

View File

@ -114,3 +114,12 @@ async def test_pet_weight_sensor(
sensor = hass.states.get("sensor.kitty_weight") sensor = hass.states.get("sensor.kitty_weight")
assert sensor.state == "9.1" assert sensor.state == "9.1"
assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS 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"