From 87b9c3193ef0db0d11f16f06100fa2cba836fbb8 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Sat, 6 Dec 2025 08:57:03 +0200 Subject: [PATCH] Add sensor entities to Airobot integration (#157938) --- homeassistant/components/airobot/__init__.py | 2 +- .../components/airobot/quality_scale.yaml | 4 +- homeassistant/components/airobot/sensor.py | 134 +++++++++++ homeassistant/components/airobot/strings.json | 19 ++ tests/components/airobot/conftest.py | 20 +- .../airobot/snapshots/test_sensor.ambr | 220 ++++++++++++++++++ tests/components/airobot/test_climate.py | 9 +- tests/components/airobot/test_sensor.py | 38 +++ 8 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/airobot/sensor.py create mode 100644 tests/components/airobot/snapshots/test_sensor.ambr create mode 100644 tests/components/airobot/test_sensor.py diff --git a/homeassistant/components/airobot/__init__.py b/homeassistant/components/airobot/__init__.py index fe44381f822..0b10707cba2 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] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool: diff --git a/homeassistant/components/airobot/quality_scale.yaml b/homeassistant/components/airobot/quality_scale.yaml index 1f7387ee44d..3039e69b000 100644 --- a/homeassistant/components/airobot/quality_scale.yaml +++ b/homeassistant/components/airobot/quality_scale.yaml @@ -44,7 +44,7 @@ rules: discovery: done docs-data-update: done docs-examples: todo - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done @@ -54,7 +54,7 @@ rules: comment: Single device integration, no dynamic device discovery needed. entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: todo exception-translations: done icon-translations: todo diff --git a/homeassistant/components/airobot/sensor.py b/homeassistant/components/airobot/sensor.py new file mode 100644 index 00000000000..a1bebb92324 --- /dev/null +++ b/homeassistant/components/airobot/sensor.py @@ -0,0 +1,134 @@ +"""Sensor platform for Airobot thermostat.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyairobotrest.models import ThermostatStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + EntityCategory, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import AirobotConfigEntry +from .entity import AirobotEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirobotSensorEntityDescription(SensorEntityDescription): + """Describes Airobot sensor entity.""" + + value_fn: Callable[[ThermostatStatus], StateType] + supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True + + +SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = ( + AirobotSensorEntityDescription( + key="air_temperature", + translation_key="air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.temp_air, + ), + AirobotSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.hum_air, + ), + AirobotSensorEntityDescription( + key="floor_temperature", + translation_key="floor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.temp_floor, + supported_fn=lambda status: status.has_floor_sensor, + ), + AirobotSensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.co2, + supported_fn=lambda status: status.has_co2_sensor, + ), + AirobotSensorEntityDescription( + key="air_quality_index", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.aqi, + supported_fn=lambda status: status.has_co2_sensor, + ), + AirobotSensorEntityDescription( + key="heating_uptime", + translation_key="heating_uptime", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status: status.heating_uptime, + entity_registry_enabled_default=False, + ), + AirobotSensorEntityDescription( + key="errors", + translation_key="errors", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status: status.errors, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirobotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Airobot sensor platform.""" + coordinator = entry.runtime_data + async_add_entities( + AirobotSensor(coordinator, description) + for description in SENSOR_TYPES + if description.supported_fn(coordinator.data.status) + ) + + +class AirobotSensor(AirobotEntity, SensorEntity): + """Representation of an Airobot sensor.""" + + entity_description: AirobotSensorEntityDescription + + def __init__( + self, + coordinator, + description: AirobotSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data.status) diff --git a/homeassistant/components/airobot/strings.json b/homeassistant/components/airobot/strings.json index ee5926b3c56..430994f2497 100644 --- a/homeassistant/components/airobot/strings.json +++ b/homeassistant/components/airobot/strings.json @@ -43,6 +43,25 @@ } } }, + "entity": { + "sensor": { + "air_temperature": { + "name": "Air temperature" + }, + "device_uptime": { + "name": "Device uptime" + }, + "errors": { + "name": "Error count" + }, + "floor_temperature": { + "name": "Floor temperature" + }, + "heating_uptime": { + "name": "Heating uptime" + } + } + }, "exceptions": { "authentication_failed": { "message": "Authentication failed, please reauthenticate." diff --git a/tests/components/airobot/conftest.py b/tests/components/airobot/conftest.py index f7e2020daff..278971f23dd 100644 --- a/tests/components/airobot/conftest.py +++ b/tests/components/airobot/conftest.py @@ -12,7 +12,13 @@ from pyairobotrest.models import ( import pytest from homeassistant.components.airobot.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -105,16 +111,24 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE, Platform.SENSOR] + + @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_airobot_client: AsyncMock, + platforms: list[Platform], ) -> MockConfigEntry: """Set up the Airobot integration for testing.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.airobot.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/airobot/snapshots/test_sensor.ambr b/tests/components/airobot/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e168d3718c5 --- /dev/null +++ b/tests/components/airobot/snapshots/test_sensor.ambr @@ -0,0 +1,220 @@ +# serializer version: 1 +# name: test_sensors[sensor.test_thermostat_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_thermostat_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air temperature', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_temperature', + 'unique_id': 'T01A1B2C3_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_thermostat_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Thermostat Air temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_thermostat_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensors[sensor.test_thermostat_error_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_thermostat_error_count', + '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': 'Error count', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'errors', + 'unique_id': 'T01A1B2C3_errors', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_thermostat_error_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Thermostat Error count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_thermostat_error_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.test_thermostat_heating_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_thermostat_heating_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating uptime', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_uptime', + 'unique_id': 'T01A1B2C3_heating_uptime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_thermostat_heating_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Thermostat Heating uptime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_thermostat_heating_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.38888888888889', + }) +# --- +# name: test_sensors[sensor.test_thermostat_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_thermostat_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'T01A1B2C3_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_thermostat_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test Thermostat Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_thermostat_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.0', + }) +# --- diff --git a/tests/components/airobot/test_climate.py b/tests/components/airobot/test_climate.py index 759cce090b9..a4ecd94ae05 100644 --- a/tests/components/airobot/test_climate.py +++ b/tests/components/airobot/test_climate.py @@ -17,7 +17,7 @@ from homeassistant.components.climate import ( SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, ) -from homeassistant.const import ATTR_ENTITY_ID +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 @@ -25,12 +25,19 @@ 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.CLIMATE] + + @pytest.mark.usefixtures("init_integration") async def test_climate_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, + platforms: list[Platform], ) -> None: """Test climate entities.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/airobot/test_sensor.py b/tests/components/airobot/test_sensor.py new file mode 100644 index 00000000000..66930b481da --- /dev/null +++ b/tests/components/airobot/test_sensor.py @@ -0,0 +1,38 @@ +"""Tests for the Airobot sensor platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import 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.SENSOR] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor 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_sensor_availability_without_optional_sensors( + hass: HomeAssistant, +) -> None: + """Test sensors are not created when optional hardware is not present.""" + # Default mock has no floor sensor, CO2, or AQI - they should not be created + assert hass.states.get("sensor.test_thermostat_floor_temperature") is None + assert hass.states.get("sensor.test_thermostat_carbon_dioxide") is None + assert hass.states.get("sensor.test_thermostat_air_quality_index") is None