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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user