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 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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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