diff --git a/homeassistant/components/airobot/__init__.py b/homeassistant/components/airobot/__init__.py index 0b10707cba2..9289ccdbfb3 100644 --- a/homeassistant/components/airobot/__init__.py +++ b/homeassistant/components/airobot/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool: diff --git a/homeassistant/components/airobot/icons.json b/homeassistant/components/airobot/icons.json new file mode 100644 index 00000000000..2ea387512e4 --- /dev/null +++ b/homeassistant/components/airobot/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "number": { + "hysteresis_band": { + "default": "mdi:delta" + } + } + } +} diff --git a/homeassistant/components/airobot/number.py b/homeassistant/components/airobot/number.py new file mode 100644 index 00000000000..8cdd0b56a4c --- /dev/null +++ b/homeassistant/components/airobot/number.py @@ -0,0 +1,99 @@ +"""Number platform for Airobot thermostat.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pyairobotrest.const import HYSTERESIS_BAND_MAX, HYSTERESIS_BAND_MIN +from pyairobotrest.exceptions import AirobotError + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AirobotConfigEntry +from .const import DOMAIN +from .coordinator import AirobotDataUpdateCoordinator +from .entity import AirobotEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirobotNumberEntityDescription(NumberEntityDescription): + """Describes Airobot number entity.""" + + value_fn: Callable[[AirobotDataUpdateCoordinator], float] + set_value_fn: Callable[[AirobotDataUpdateCoordinator, float], Awaitable[None]] + + +NUMBERS: tuple[AirobotNumberEntityDescription, ...] = ( + AirobotNumberEntityDescription( + key="hysteresis_band", + translation_key="hysteresis_band", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_min_value=HYSTERESIS_BAND_MIN / 10.0, + native_max_value=HYSTERESIS_BAND_MAX / 10.0, + native_step=0.1, + value_fn=lambda coordinator: coordinator.data.settings.hysteresis_band, + set_value_fn=lambda coordinator, value: coordinator.client.set_hysteresis_band( + value + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirobotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Airobot number platform.""" + coordinator = entry.runtime_data + async_add_entities( + AirobotNumber(coordinator, description) for description in NUMBERS + ) + + +class AirobotNumber(AirobotEntity, NumberEntity): + """Representation of an Airobot number entity.""" + + entity_description: AirobotNumberEntityDescription + + def __init__( + self, + coordinator: AirobotDataUpdateCoordinator, + description: AirobotNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.value_fn(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + try: + await self.entity_description.set_value_fn(self.coordinator, value) + except AirobotError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="set_value_failed", + translation_placeholders={"error": str(err)}, + ) from err + else: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airobot/quality_scale.yaml b/homeassistant/components/airobot/quality_scale.yaml index 4f905e892b1..b7213eb04a5 100644 --- a/homeassistant/components/airobot/quality_scale.yaml +++ b/homeassistant/components/airobot/quality_scale.yaml @@ -48,7 +48,7 @@ rules: docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: Single device integration, no dynamic device discovery needed. @@ -57,7 +57,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/airobot/strings.json b/homeassistant/components/airobot/strings.json index 430994f2497..f6808229416 100644 --- a/homeassistant/components/airobot/strings.json +++ b/homeassistant/components/airobot/strings.json @@ -44,6 +44,11 @@ } }, "entity": { + "number": { + "hysteresis_band": { + "name": "Hysteresis band" + } + }, "sensor": { "air_temperature": { "name": "Air temperature" @@ -74,6 +79,9 @@ }, "set_temperature_failed": { "message": "Failed to set temperature to {temperature}." + }, + "set_value_failed": { + "message": "Failed to set value: {error}" } } } diff --git a/tests/components/airobot/snapshots/test_number.ambr b/tests/components/airobot/snapshots/test_number.ambr new file mode 100644 index 00000000000..e98999a1563 --- /dev/null +++ b/tests/components/airobot/snapshots/test_number.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_number_entities[number.test_thermostat_hysteresis_band-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 0.5, + 'min': 0.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_thermostat_hysteresis_band', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hysteresis band', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hysteresis_band', + 'unique_id': 'T01A1B2C3_hysteresis_band', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities[number.test_thermostat_hysteresis_band-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Thermostat Hysteresis band', + 'max': 0.5, + 'min': 0.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_thermostat_hysteresis_band', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- diff --git a/tests/components/airobot/test_number.py b/tests/components/airobot/test_number.py new file mode 100644 index 00000000000..cf8d5fdc3cf --- /dev/null +++ b/tests/components/airobot/test_number.py @@ -0,0 +1,78 @@ +"""Test the Airobot number platform.""" + +from unittest.mock import AsyncMock + +from pyairobotrest.exceptions import AirobotError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.NUMBER] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the number entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_set_hysteresis_band( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, +) -> None: + """Test setting hysteresis band value.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.test_thermostat_hysteresis_band", + ATTR_VALUE: 0.3, + }, + blocking=True, + ) + + mock_airobot_client.set_hysteresis_band.assert_called_once_with(0.3) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_set_value_error( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, +) -> None: + """Test error handling when setting number value fails.""" + mock_airobot_client.set_hysteresis_band.side_effect = AirobotError("Device error") + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.test_thermostat_hysteresis_band", + ATTR_VALUE: 0.3, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == "airobot" + assert exc_info.value.translation_key == "set_value_failed"