Add number entity to Airobot integration (#159595)

This commit is contained in:
mettolen
2025-12-23 14:33:46 +02:00
committed by GitHub
parent 9f54b09423
commit 2e4f95c099
7 changed files with 257 additions and 3 deletions
+1 -1
View File
@@ -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:
@@ -0,0 +1,9 @@
{
"entity": {
"number": {
"hysteresis_band": {
"default": "mdi:delta"
}
}
}
}
@@ -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()
@@ -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
@@ -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}"
}
}
}
@@ -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': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.test_thermostat_hysteresis_band',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# 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': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.test_thermostat_hysteresis_band',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.1',
})
# ---
+78
View File
@@ -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"