diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 03ab02429bb..fe6f6978014 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -21,6 +21,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.LAWN_MOWER, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index ec11ef92d08..2ecbf9c198a 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -8,6 +8,11 @@ "default": "mdi:debug-step-into" } }, + "number": { + "cutting_height": { + "default": "mdi:grass" + } + }, "select": { "headlight_mode": { "default": "mdi:car-light-high" diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py new file mode 100644 index 00000000000..8745b93479d --- /dev/null +++ b/homeassistant/components/husqvarna_automower/number.py @@ -0,0 +1,95 @@ +"""Creates the number entities for the mower.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Any + +from aioautomower.exceptions import ApiException +from aioautomower.model import MowerAttributes +from aioautomower.session import AutomowerSession + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class AutomowerNumberEntityDescription(NumberEntityDescription): + """Describes Automower number entity.""" + + exists_fn: Callable[[MowerAttributes], bool] = lambda _: True + value_fn: Callable[[MowerAttributes], int] + set_value_fn: Callable[[AutomowerSession, str, float], Awaitable[Any]] + + +NUMBER_TYPES: tuple[AutomowerNumberEntityDescription, ...] = ( + AutomowerNumberEntityDescription( + key="cutting_height", + translation_key="cutting_height", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_min_value=1, + native_max_value=9, + exists_fn=lambda data: data.cutting_height is not None, + value_fn=lambda data: data.cutting_height, + set_value_fn=lambda session, mower_id, cheight: session.set_cutting_height( + mower_id, int(cheight) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up number platform.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AutomowerNumberEntity(mower_id, coordinator, description) + for mower_id in coordinator.data + for description in NUMBER_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + + +class AutomowerNumberEntity(AutomowerBaseEntity, NumberEntity): + """Defining the AutomowerNumberEntity with AutomowerNumberEntityDescription.""" + + entity_description: AutomowerNumberEntityDescription + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + description: AutomowerNumberEntityDescription, + ) -> None: + """Set up AutomowerNumberEntity.""" + super().__init__(mower_id, coordinator) + self.entity_description = description + self._attr_unique_id = f"{mower_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the state of the number.""" + return self.entity_description.value_fn(self.mower_attributes) + + async def async_set_native_value(self, value: float) -> None: + """Change to new number value.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.api, self.mower_id, value + ) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 0a2d3685c6e..b4c1c97cd68 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -37,6 +37,11 @@ "name": "Returning to dock" } }, + "number": { + "cutting_height": { + "name": "Cutting height" + } + }, "select": { "headlight_mode": { "name": "Headlight mode", diff --git a/tests/components/husqvarna_automower/snapshots/test_number.ambr b/tests/components/husqvarna_automower/snapshots/test_number.ambr new file mode 100644 index 00000000000..a5479345bd1 --- /dev/null +++ b/tests/components/husqvarna_automower/snapshots/test_number.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_snapshot_number[number.test_mower_1_cutting_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_mower_1_cutting_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cutting height', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cutting_height', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_cutting_height', + 'unit_of_measurement': None, + }) +# --- +# name: test_snapshot_number[number.test_mower_1_cutting_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Cutting height', + 'max': 9, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_mower_1_cutting_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py new file mode 100644 index 00000000000..abf56df1c0b --- /dev/null +++ b/tests/components/husqvarna_automower/test_number.py @@ -0,0 +1,77 @@ +"""Tests for number platform.""" + +from unittest.mock import AsyncMock, patch + +from aioautomower.exceptions import ApiException +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_commands( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test number commands.""" + entity_id = "number.test_mower_1_cutting_height" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + domain="number", + service="set_value", + target={"entity_id": entity_id}, + service_data={"value": "3"}, + blocking=True, + ) + mocked_method = mock_automower_client.set_cutting_height + assert len(mocked_method.mock_calls) == 1 + + mocked_method.side_effect = ApiException("Test error") + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + domain="number", + service="set_value", + target={"entity_id": entity_id}, + service_data={"value": "3"}, + blocking=True, + ) + assert ( + str(exc_info.value) + == "Command couldn't be sent to the command queue: Test error" + ) + assert len(mocked_method.mock_calls) == 2 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_snapshot_number( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test states of the number entity.""" + with patch( + "homeassistant.components.husqvarna_automower.PLATFORMS", + [Platform.NUMBER], + ): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")