From 33dcde7de1b79e0d6a4245b3af165eceda78bfc5 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 18 Dec 2025 18:00:58 +0100 Subject: [PATCH] Add sensor platform for AirPatrol (#158726) --- homeassistant/components/airpatrol/climate.py | 10 -- homeassistant/components/airpatrol/const.py | 2 +- homeassistant/components/airpatrol/entity.py | 12 +- homeassistant/components/airpatrol/sensor.py | 89 ++++++++++++++ .../airpatrol/snapshots/test_sensor.ambr | 110 ++++++++++++++++++ tests/components/airpatrol/test_climate.py | 13 +++ tests/components/airpatrol/test_sensor.py | 55 +++++++++ 7 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/airpatrol/sensor.py create mode 100644 tests/components/airpatrol/snapshots/test_sensor.ambr create mode 100644 tests/components/airpatrol/test_sensor.py diff --git a/homeassistant/components/airpatrol/climate.py b/homeassistant/components/airpatrol/climate.py index ff5abd98103..711c2655e98 100644 --- a/homeassistant/components/airpatrol/climate.py +++ b/homeassistant/components/airpatrol/climate.py @@ -88,21 +88,11 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity): super().__init__(coordinator, unit_id) self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}" - @property - def climate_data(self) -> dict[str, Any]: - """Return the climate data.""" - return self.device_data.get("climate") or {} - @property def params(self) -> dict[str, Any]: """Return the current parameters for the climate entity.""" return self.climate_data.get("ParametersData") or {} - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and bool(self.climate_data) - @property def current_humidity(self) -> float | None: """Return the current humidity.""" diff --git a/homeassistant/components/airpatrol/const.py b/homeassistant/components/airpatrol/const.py index 9bcc9451b5b..b390f5eec21 100644 --- a/homeassistant/components/airpatrol/const.py +++ b/homeassistant/components/airpatrol/const.py @@ -10,7 +10,7 @@ from homeassistant.const import Platform DOMAIN = "airpatrol" LOGGER = logging.getLogger(__package__) -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=1) AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError) diff --git a/homeassistant/components/airpatrol/entity.py b/homeassistant/components/airpatrol/entity.py index 73c7b75a220..0f4e14c0086 100644 --- a/homeassistant/components/airpatrol/entity.py +++ b/homeassistant/components/airpatrol/entity.py @@ -38,7 +38,17 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]): """Return the device data.""" return self.coordinator.data[self._unit_id] + @property + def climate_data(self) -> dict[str, Any]: + """Return the climate data for this unit.""" + return self.device_data["climate"] + @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self._unit_id in self.coordinator.data + return ( + super().available + and self._unit_id in self.coordinator.data + and "climate" in self.device_data + and self.climate_data is not None + ) diff --git a/homeassistant/components/airpatrol/sensor.py b/homeassistant/components/airpatrol/sensor.py new file mode 100644 index 00000000000..f25c045599a --- /dev/null +++ b/homeassistant/components/airpatrol/sensor.py @@ -0,0 +1,89 @@ +"""Sensors for AirPatrol integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AirPatrolConfigEntry +from .coordinator import AirPatrolDataUpdateCoordinator +from .entity import AirPatrolEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirPatrolSensorEntityDescription(SensorEntityDescription): + """Describes AirPatrol sensor entity.""" + + data_field: str + + +SENSOR_DESCRIPTIONS = ( + AirPatrolSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + data_field="RoomTemp", + ), + AirPatrolSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + data_field="RoomHumidity", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirPatrolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AirPatrol sensors.""" + coordinator = config_entry.runtime_data + units = coordinator.data + + async_add_entities( + AirPatrolSensor(coordinator, unit_id, description) + for unit_id, unit in units.items() + for description in SENSOR_DESCRIPTIONS + if "climate" in unit and unit["climate"] is not None + ) + + +class AirPatrolSensor(AirPatrolEntity, SensorEntity): + """AirPatrol sensor entity.""" + + entity_description: AirPatrolSensorEntityDescription + + def __init__( + self, + coordinator: AirPatrolDataUpdateCoordinator, + unit_id: str, + description: AirPatrolSensorEntityDescription, + ) -> None: + """Initialize AirPatrol sensor.""" + super().__init__(coordinator, unit_id) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-{unit_id}-{description.key}" + ) + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + if value := self.climate_data.get(self.entity_description.data_field): + return float(value) + return None diff --git a/tests/components/airpatrol/snapshots/test_sensor.ambr b/tests/components/airpatrol/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3a142cf34f6 --- /dev/null +++ b/tests/components/airpatrol/snapshots/test_sensor.ambr @@ -0,0 +1,110 @@ +# serializer version: 1 +# name: test_sensor_with_climate_data[sensor.living_room_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.living_room_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': 'airpatrol', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_user_id-test_unit_001-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_with_climate_data[sensor.living_room_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'living room Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.0', + }) +# --- +# name: test_sensor_with_climate_data[sensor.living_room_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.living_room_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': 'Temperature', + 'platform': 'airpatrol', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_user_id-test_unit_001-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_with_climate_data[sensor.living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'living room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- diff --git a/tests/components/airpatrol/test_climate.py b/tests/components/airpatrol/test_climate.py index cb3390cbecb..198c1152c06 100644 --- a/tests/components/airpatrol/test_climate.py +++ b/tests/components/airpatrol/test_climate.py @@ -1,7 +1,9 @@ """Test the AirPatrol climate platform.""" +from collections.abc import Generator from datetime import timedelta from typing import Any +from unittest.mock import patch from airpatrol.api import AirPatrolAPI from freezegun.api import FrozenDateTimeFactory @@ -32,6 +34,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -44,6 +47,16 @@ from tests.common import ( ) +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override the platforms to load for airpatrol.""" + with patch( + "homeassistant.components.airpatrol.PLATFORMS", + [Platform.CLIMATE], + ): + yield + + @pytest.mark.parametrize( "climate_data", [ diff --git a/tests/components/airpatrol/test_sensor.py b/tests/components/airpatrol/test_sensor.py new file mode 100644 index 00000000000..67ff9919cf6 --- /dev/null +++ b/tests/components/airpatrol/test_sensor.py @@ -0,0 +1,55 @@ +"""Test the AirPatrol sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from airpatrol.api import AirPatrolAPI +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override the platforms to load for airpatrol.""" + with patch( + "homeassistant.components.airpatrol.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +async def test_sensor_with_climate_data( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor entities are created with climate data.""" + await snapshot_platform( + hass, + entity_registry, + snapshot, + load_integration.entry_id, + ) + + +@pytest.mark.parametrize( + "climate_data", + [ + None, + ], +) +async def test_sensor_with_no_climate_data( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + entity_registry: er.EntityRegistry, +) -> None: + """Test no sensor entities are created when no climate data is present.""" + assert len(entity_registry.entities) == 0