1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 23:53:49 +01:00

Add parallel-updates and action-exceptions for Whisker (#165433)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
David Bishop
2026-03-13 10:33:42 -07:00
committed by GitHub
parent 57c49d0c48
commit 356de12bce
17 changed files with 181 additions and 17 deletions

View File

@@ -20,6 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class RobotBinarySensorEntityDescription(

View File

@@ -14,7 +14,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -71,6 +73,7 @@ class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity):
entity_description: RobotButtonEntityDescription[_WhiskerEntityT]
@whisker_command
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.robot)

View File

@@ -2,11 +2,14 @@
from __future__ import annotations
from typing import Generic, TypeVar
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Concatenate, Generic, TypeVar
from pylitterbot import Pet, Robot
from pylitterbot.exceptions import LitterRobotException
from pylitterbot.robot import EVENT_UPDATE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -17,6 +20,26 @@ from .coordinator import LitterRobotDataUpdateCoordinator
_WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet)
def whisker_command[_WhiskerEntityT2: LitterRobotEntity, **_P](
func: Callable[Concatenate[_WhiskerEntityT2, _P], Awaitable[None]],
) -> Callable[Concatenate[_WhiskerEntityT2, _P], Coroutine[Any, Any, None]]:
"""Wrap a Whisker command to handle exceptions."""
async def handler(
self: _WhiskerEntityT2, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except LitterRobotException as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"error": str(ex)},
) from ex
return handler
def get_device_info(whisker_entity: Robot | Pet) -> DeviceInfo:
"""Get device info for a robot or pet."""
if isinstance(whisker_entity, Robot):

View File

@@ -23,7 +23,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: done
@@ -32,7 +32,7 @@ rules:
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo

View File

@@ -15,7 +15,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
from .entity import LitterRobotEntity, _WhiskerEntityT
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
_CastTypeT = TypeVar("_CastTypeT", int, float, str)
@@ -154,6 +156,7 @@ class LitterRobotSelectEntity(
"""Return the selected entity option to represent the entity state."""
return str(self.entity_description.current_fn(self.robot))
@whisker_command
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.select_fn(self.robot, option)

View File

@@ -23,6 +23,8 @@ from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
PARALLEL_UPDATES = 0
def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str:
"""Return a gauge icon valid identifier."""

View File

@@ -195,6 +195,14 @@
}
}
},
"exceptions": {
"command_failed": {
"message": "An error occurred while communicating with the device: {error}"
},
"firmware_update_failed": {
"message": "Unable to start firmware update on {name}"
}
},
"issues": {
"deprecated_entity": {
"description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",

View File

@@ -25,7 +25,9 @@ from homeassistant.helpers.issue_registry import (
from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -135,10 +137,12 @@ class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
"""Return true if switch is on."""
return self.entity_description.value_fn(self.robot)
@whisker_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.robot, True)
@whisker_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.robot, False)

View File

@@ -16,7 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -74,6 +76,7 @@ class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity):
"""Return the value reported by the time."""
return self.entity_description.value_fn(self.robot)
@whisker_command
async def async_set_value(self, value: time) -> None:
"""Update the current value."""
await self.entity_description.set_fn(self.robot, value)

View File

@@ -17,8 +17,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity
from .entity import LitterRobotEntity, whisker_command
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(days=1)
@@ -80,11 +83,15 @@ class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):
latest_version = self.robot.firmware
self._attr_latest_version = latest_version
@whisker_command
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
if await self.robot.has_firmware_update(True):
if not await self.robot.update_firmware():
message = f"Unable to start firmware update on {self.robot.name}"
raise HomeAssistantError(message)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_update_failed",
translation_placeholders={"name": self.robot.name},
)

View File

@@ -19,7 +19,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity
from .entity import LitterRobotEntity, whisker_command
PARALLEL_UPDATES = 1
LITTER_BOX_STATUS_STATE_MAP = {
LitterBoxStatus.CLEAN_CYCLE: VacuumActivity.CLEANING,
@@ -66,15 +68,18 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity):
"""Return the state of the cleaner."""
return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR)
@whisker_command
async def async_start(self) -> None:
"""Start a clean cycle."""
await self.robot.set_power_status(True)
await self.robot.start_cleaning()
@whisker_command
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
await self.robot.set_power_status(False)
@whisker_command
async def async_set_sleep_mode(
self, enabled: bool, start_time: str | None = None
) -> None:

View File

@@ -3,10 +3,12 @@
from unittest.mock import MagicMock
from freezegun import freeze_time
import pytest
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .conftest import setup_integration
@@ -42,3 +44,18 @@ async def test_button(
state = hass.states.get(BUTTON_ENTITY)
assert state
assert state.state == "2021-11-15T10:37:00+00:00"
async def test_button_command_exception(
hass: HomeAssistant, mock_account_with_side_effects: MagicMock
) -> None:
"""Test that LitterRobotException is wrapped in HomeAssistantError."""
await setup_integration(hass, mock_account_with_side_effects, BUTTON_DOMAIN)
with pytest.raises(HomeAssistantError, match="Invalid command: oops"):
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: BUTTON_ENTITY},
blocking=True,
)

View File

@@ -13,7 +13,7 @@ from homeassistant.components.select import (
)
from homeassistant.const import ATTR_ENTITY_ID, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from .conftest import setup_integration
@@ -112,3 +112,18 @@ async def test_litterrobot_4_select(
)
assert getattr(robot, robot_command).call_count == count + 1
async def test_select_command_exception(
hass: HomeAssistant, mock_account_with_side_effects: MagicMock
) -> None:
"""Test that LitterRobotException is wrapped in HomeAssistantError."""
await setup_integration(hass, mock_account_with_side_effects, SELECT_DOMAIN)
with pytest.raises(HomeAssistantError, match="Invalid command: oops"):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: SELECT_ENTITY_ID, ATTR_OPTION: "7"},
blocking=True,
)

View File

@@ -13,6 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from .conftest import setup_integration
@@ -135,3 +136,18 @@ async def test_litterrobot_4_deprecated_switch(
)
is not None
) is expected_issue
async def test_switch_command_exception(
hass: HomeAssistant, mock_account_with_side_effects: MagicMock
) -> None:
"""Test that LitterRobotException is wrapped in HomeAssistantError."""
await setup_integration(hass, mock_account_with_side_effects, SWITCH_DOMAIN)
with pytest.raises(HomeAssistantError, match="Invalid command: oops"):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: NIGHT_LIGHT_MODE_ENTITY_ID},
blocking=True,
)

View File

@@ -8,9 +8,10 @@ from unittest.mock import MagicMock
from pylitterbot import LitterRobot3
import pytest
from homeassistant.components.time import DOMAIN as TIME_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import setup_integration
@@ -31,8 +32,23 @@ async def test_sleep_mode_start_time(
robot: LitterRobot3 = mock_account.robots[0]
await hass.services.async_call(
TIME_DOMAIN,
"set_value",
{ATTR_ENTITY_ID: SLEEP_START_TIME_ENTITY_ID, "time": time(23, 0)},
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: SLEEP_START_TIME_ENTITY_ID, ATTR_TIME: time(23, 0)},
blocking=True,
)
robot.set_sleep_mode.assert_awaited_once_with(True, time(23, 0))
async def test_time_command_exception(
hass: HomeAssistant, mock_account_with_side_effects: MagicMock
) -> None:
"""Test that LitterRobotException is wrapped in HomeAssistantError."""
await setup_integration(hass, mock_account_with_side_effects, TIME_DOMAIN)
with pytest.raises(HomeAssistantError, match="Invalid command: oops"):
await hass.services.async_call(
TIME_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: SLEEP_START_TIME_ENTITY_ID, ATTR_TIME: time(23, 0)},
blocking=True,
)

View File

@@ -3,6 +3,7 @@
from unittest.mock import AsyncMock, MagicMock
from pylitterbot import LitterRobot4
from pylitterbot.exceptions import InvalidCommandException
import pytest
from homeassistant.components.litterrobot.update import RELEASE_URL
@@ -75,7 +76,7 @@ async def test_robot_with_update(
robot.update_firmware = AsyncMock(return_value=False)
with pytest.raises(HomeAssistantError):
with pytest.raises(HomeAssistantError, match="Unable to start firmware update"):
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
@@ -114,3 +115,26 @@ async def test_robot_with_update_already_in_progress(
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_update_command_exception(
hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock
) -> None:
"""Test that LitterRobotException is wrapped in HomeAssistantError."""
robot: LitterRobot4 = mock_account_with_litterrobot_4.robots[0]
robot.has_firmware_update = AsyncMock(return_value=True)
robot.get_latest_firmware = AsyncMock(return_value=NEW_FIRMWARE)
await setup_integration(hass, mock_account_with_litterrobot_4, UPDATE_DOMAIN)
robot.has_firmware_update = AsyncMock(
side_effect=InvalidCommandException("Invalid command: oops")
)
with pytest.raises(HomeAssistantError, match="Invalid command: oops"):
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)

View File

@@ -17,6 +17,7 @@ from homeassistant.components.vacuum import (
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from .common import DOMAIN, VACUUM_ENTITY_ID
@@ -156,3 +157,18 @@ async def test_commands(
getattr(mock_account.robots[0], command).assert_called_once()
assert set(issue_registry.issues.keys()) == issues
async def test_vacuum_command_exception(
hass: HomeAssistant, mock_account_with_side_effects: MagicMock
) -> None:
"""Test that LitterRobotException is wrapped in HomeAssistantError."""
await setup_integration(hass, mock_account_with_side_effects, VACUUM_DOMAIN)
with pytest.raises(HomeAssistantError, match="Invalid command: oops"):
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_START,
{ATTR_ENTITY_ID: VACUUM_ENTITY_ID},
blocking=True,
)