1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-14 23:28:42 +00:00

Add smoke detector extended properties to homematicip_cloud (#161629)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Christian Lackas
2026-02-11 18:56:39 +01:00
committed by GitHub
parent e0e11fd99d
commit 80ebb34ad1
8 changed files with 291 additions and 3 deletions

View File

@@ -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."""

View File

@@ -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", {}))

View File

@@ -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:

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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(

View File

@@ -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