From 80ebb34ad1b3fccecf655e442df43f91fe396ad5 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Wed, 11 Feb 2026 18:56:39 +0100 Subject: [PATCH] Add smoke detector extended properties to homematicip_cloud (#161629) Co-authored-by: Joost Lekkerkerker --- .../homematicip_cloud/binary_sensor.py | 20 ++++ .../components/homematicip_cloud/helpers.py | 13 +++ .../components/homematicip_cloud/sensor.py | 105 ++++++++++++++++- .../components/homematicip_cloud/strings.json | 15 +++ .../fixtures/homematicip_cloud.json | 8 +- .../homematicip_cloud/test_binary_sensor.py | 21 ++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_sensor.py | 110 ++++++++++++++++++ 8 files changed, 291 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index b020a7e7473..6b8aa341dda 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -42,6 +42,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP +from .helpers import smoke_detector_channel_data_exists ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode" ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" @@ -125,6 +126,8 @@ async def async_setup_entry( entities.append(HomematicipPresenceDetector(hap, device)) if isinstance(device, SmokeDetector): entities.append(HomematicipSmokeDetector(hap, device)) + if smoke_detector_channel_data_exists(device, "chamberDegraded"): + entities.append(HomematicipSmokeDetectorChamberDegraded(hap, device)) if isinstance(device, WaterSensor): entities.append(HomematicipWaterDetector(hap, device)) if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)): @@ -322,6 +325,23 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): return False +class HomematicipSmokeDetectorChamberDegraded( + HomematicipGenericEntity, BinarySensorEntity +): + """Representation of the HomematicIP smoke detector chamber health.""" + + _attr_device_class = BinarySensorDeviceClass.PROBLEM + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize smoke detector chamber health sensor.""" + super().__init__(hap, device, post="Chamber Degraded") + + @property + def is_on(self) -> bool: + """Return true if smoke chamber is degraded.""" + return self._device.chamberDegraded + + class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP water detector.""" diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 9959b993a6c..041b6eb54d8 100644 --- a/homeassistant/components/homematicip_cloud/helpers.py +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -59,3 +59,16 @@ def get_channels_from_device(device: Device, channel_type: FunctionalChannelType for ch in device.functionalChannels if ch.functionalChannelType == channel_type ] + + +def smoke_detector_channel_data_exists(device: Device, field: str) -> bool: + """Check if a smoke detector's channel payload contains a specific field. + + The library always initializes device attributes with defaults, so hasattr + cannot distinguish between actual API data and defaults. This checks the + raw channel payload to determine if the field was actually sent by the API. + """ + channels = get_channels_from_device( + device, FunctionalChannelType.SMOKE_DETECTOR_CHANNEL + ) + return bool(channels and field in getattr(channels[0], "_rawJSONData", {})) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 57195afbdc6..1ab2e8ef1f0 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime from typing import Any from homematicip.base.enums import FunctionalChannelType, ValveState @@ -27,6 +29,7 @@ from homematicip.device import ( PassageDetector, PresenceDetectorIndoor, RoomControlDeviceAnalog, + SmokeDetector, SwitchMeasuring, TemperatureDifferenceSensor2, TemperatureHumiditySensorDisplay, @@ -43,6 +46,7 @@ from homematicip.device import ( from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.const import ( @@ -65,7 +69,70 @@ from homeassistant.helpers.typing import StateType from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP -from .helpers import get_channels_from_device +from .helpers import get_channels_from_device, smoke_detector_channel_data_exists + + +@dataclass(frozen=True, kw_only=True) +class HmipSmokeDetectorSensorDescription(SensorEntityDescription): + """Describes HmIP smoke detector sensor entity.""" + + value_fn: Callable[[SmokeDetector], StateType | datetime] + channel_field: str # Field name in the raw channel payload + + +SMOKE_DETECTOR_SENSORS: tuple[HmipSmokeDetectorSensorDescription, ...] = ( + HmipSmokeDetectorSensorDescription( + key="dirt_level", + translation_key="smoke_detector_dirt_level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + channel_field="dirtLevel", + value_fn=lambda d: ( + round(d.dirtLevel * 100, 1) if d.dirtLevel is not None else None + ), + ), + HmipSmokeDetectorSensorDescription( + key="smoke_alarm_counter", + translation_key="smoke_detector_alarm_counter", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + channel_field="smokeAlarmCounter", + value_fn=lambda d: d.smokeAlarmCounter, + ), + HmipSmokeDetectorSensorDescription( + key="smoke_test_counter", + translation_key="smoke_detector_test_counter", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + channel_field="smokeTestCounter", + value_fn=lambda d: d.smokeTestCounter, + ), + HmipSmokeDetectorSensorDescription( + key="last_smoke_alarm", + translation_key="smoke_detector_last_alarm", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + channel_field="lastSmokeAlarmTimestamp", + value_fn=lambda d: ( + datetime.fromtimestamp(d.lastSmokeAlarmTimestamp / 1000, tz=UTC) + if d.lastSmokeAlarmTimestamp + else None + ), + ), + HmipSmokeDetectorSensorDescription( + key="last_smoke_test", + translation_key="smoke_detector_last_test", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + channel_field="lastSmokeTestTimestamp", + value_fn=lambda d: ( + datetime.fromtimestamp(d.lastSmokeTestTimestamp / 1000, tz=UTC) + if d.lastSmokeTestTimestamp + else None + ), + ), +) ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE = "acceleration_sensor_trigger_angle" @@ -289,6 +356,15 @@ async def async_setup_entry( and getattr(channel, "valvePosition", None) is not None ) + # Handle smoke detector extended sensors (e.g., HmIP-SWSD-2) + entities.extend( + HmipSmokeDetectorSensor(hap, device, description) + for device in hap.home.devices + if isinstance(device, SmokeDetector) + for description in SMOKE_DETECTOR_SENSORS + if smoke_detector_channel_data_exists(device, description.channel_field) + ) + async_add_entities(entities) @@ -936,6 +1012,33 @@ class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEnt return state_attr +class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity): + """Sensor for HomematicIP smoke detector extended properties.""" + + entity_description: HmipSmokeDetectorSensorDescription + + def __init__( + self, + hap: HomematicipHAP, + device: SmokeDetector, + description: HmipSmokeDetectorSensorDescription, + ) -> None: + """Initialize the smoke detector sensor.""" + super().__init__(hap, device, post=description.key) + self.entity_description = description + self._sensor_unique_id = f"{device.id}_{description.key}" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._sensor_unique_id + + @property + def native_value(self) -> StateType | datetime: + """Return the sensor value.""" + return self.entity_description.value_fn(self._device) + + def _get_wind_direction(wind_direction_degree: float) -> str: """Convert wind direction degree to named direction.""" if 11.25 <= wind_direction_degree < 33.75: diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 4121f8aef77..e165a0b9c91 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -29,6 +29,21 @@ }, "entity": { "sensor": { + "smoke_detector_alarm_counter": { + "name": "Alarm counter" + }, + "smoke_detector_dirt_level": { + "name": "Dirt level" + }, + "smoke_detector_last_alarm": { + "name": "Last alarm" + }, + "smoke_detector_last_test": { + "name": "Last test" + }, + "smoke_detector_test_counter": { + "name": "Test counter" + }, "tilt_state": { "state": { "neutral": "Neutral", diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index 5369b2fb7bb..a7969f84c9b 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -5377,7 +5377,13 @@ ], "index": 1, "label": "", - "smokeDetectorAlarmType": "IDLE_OFF" + "smokeDetectorAlarmType": "IDLE_OFF", + "chamberDegraded": false, + "dirtLevel": 0.15, + "smokeAlarmCounter": 2, + "smokeTestCounter": 5, + "lastSmokeAlarmTimestamp": 1704067200000, + "lastSmokeTestTimestamp": 1706745600000 } }, "homeId": "00000000-0000-0000-0000-000000000001", diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index 1f58a7f7f40..9aa912c22f3 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -328,6 +328,27 @@ async def test_hmip_smoke_detector( assert ha_state.state == STATE_OFF +async def test_hmip_smoke_detector_chamber_degraded( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipSmokeDetectorChamberDegraded.""" + entity_id = "binary_sensor.rauchwarnmelder_chamber_degraded" + entity_name = "Rauchwarnmelder Chamber Degraded" + device_model = "HmIP-SWSD" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Rauchwarnmelder"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == STATE_OFF + await async_manipulate_test_data(hass, hmip_device, "chamberDegraded", True) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_ON + + async def test_hmip_water_detector( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 09770277709..7481c0d09b3 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 342 + assert len(mock_hap.hmip_device_by_entity_id) == 343 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 825f3ab042d..2c5a1ffdeaf 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -2,6 +2,7 @@ from homematicip.base.enums import ValveState +from homeassistant.components.homematicip_cloud import DOMAIN from homeassistant.components.homematicip_cloud.entity import ( ATTR_CONFIG_PENDING, ATTR_DEVICE_OVERHEATED, @@ -39,6 +40,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics @@ -861,3 +863,111 @@ async def test_hmip_water_valve_water_volume_since_open( assert ha_state.state == "67.0" assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfVolume.LITERS assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING + + +async def test_hmip_smoke_detector_dirt_level( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipSmokeDetectorDirtLevel.""" + entity_id = "sensor.rauchwarnmelder_dirt_level" + entity_name = "Rauchwarnmelder dirt_level" + device_model = "HmIP-SWSD" + + # Pre-register the entity as enabled before platform loads + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "3014F7110000000000000018_dirt_level", + suggested_object_id="rauchwarnmelder_dirt_level", + disabled_by=None, + ) + + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Rauchwarnmelder"] + ) + + # Need to wait for entity to be added after enabling + await hass.async_block_till_done() + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "15.0" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + await async_manipulate_test_data(hass, hmip_device, "dirtLevel", 0.25) + ha_state = hass.states.get(entity_id) + assert ha_state.state == "25.0" + + +async def test_hmip_smoke_detector_alarm_counter( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test HomematicipSmokeDetectorAlarmCounter.""" + entity_id = "sensor.rauchwarnmelder_smoke_alarm_counter" + entity_name = "Rauchwarnmelder smoke_alarm_counter" + device_model = "HmIP-SWSD" + + # Pre-register the entity as enabled before platform loads + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "3014F7110000000000000018_smoke_alarm_counter", + suggested_object_id="rauchwarnmelder_smoke_alarm_counter", + disabled_by=None, + ) + + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Rauchwarnmelder"] + ) + + await hass.async_block_till_done() + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "2" + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING + + await async_manipulate_test_data(hass, hmip_device, "smokeAlarmCounter", 3) + ha_state = hass.states.get(entity_id) + assert ha_state.state == "3" + + +async def test_hmip_smoke_detector_test_counter( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test HomematicipSmokeDetectorTestCounter.""" + entity_id = "sensor.rauchwarnmelder_smoke_test_counter" + entity_name = "Rauchwarnmelder smoke_test_counter" + device_model = "HmIP-SWSD" + + # Pre-register the entity as enabled before platform loads + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "3014F7110000000000000018_smoke_test_counter", + suggested_object_id="rauchwarnmelder_smoke_test_counter", + disabled_by=None, + ) + + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Rauchwarnmelder"] + ) + + await hass.async_block_till_done() + + ha_state, _hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "5" + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING