Resolve the HomematicIP tilt channel by type instead of by position (#180807)

This commit is contained in:
Christian Lackas
2026-09-12 10:15:01 +02:00
committed by GitHub
parent 5f15d9de8e
commit 626d48147c
8 changed files with 223 additions and 20 deletions
@@ -6,6 +6,7 @@ from typing import Any, override
from homematicip.base.enums import (
BinaryBehaviorType,
FunctionalChannelType,
LockState,
SmokeDetectorAlarmType,
WindowState,
@@ -48,7 +49,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
from .helpers import get_channel_index_by_type, smoke_detector_channel_data_exists
ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode"
ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position"
@@ -427,7 +428,14 @@ class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the tilt vibration sensor."""
super().__init__(hap, device, feature_id="tilt_vibration")
super().__init__(
hap,
device,
feature_id="tilt_vibration",
channel_real_index=get_channel_index_by_type(
device, FunctionalChannelType.TILT_VIBRATION_SENSOR_CHANNEL
),
)
class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity):
@@ -372,8 +372,7 @@ class HomematicipGenericEntity(Entity):
"""Return the FunctionalChannel for the device.
Resolution priority:
1. For multi-channel entities with a real index, find
channel by index match.
1. With a real index, find channel by index match.
2. For multi-channel entities without a real index, use
the provided channel position.
3. For non multi-channel entities with >1 channels, use
@@ -388,20 +387,20 @@ class HomematicipGenericEntity(Entity):
" has no functionalChannels"
)
# Prefer real index mapping when provided to avoid ordering issues.
if self._channel_real_index is not None:
for channel in functional_channels:
if channel.index == self._channel_real_index:
return channel
raise ValueError(
f"Real channel index"
f" {self._channel_real_index}"
" not found for device"
f" {getattr(self._device, 'id', 'unknown')}"
)
# Multi-channel handling
if self._is_multi_channel:
# Prefer real index mapping when provided to avoid
# ordering issues.
if self._channel_real_index is not None:
for channel in functional_channels:
if channel.index == self._channel_real_index:
return channel
raise ValueError(
f"Real channel index"
f" {self._channel_real_index}"
" not found for device"
f" {getattr(self._device, 'id', 'unknown')}"
)
# Fallback: positional channel (already sorted as strings upstream).
if self._channel is not None and 0 <= self._channel < len(
functional_channels
@@ -61,6 +61,14 @@ def get_channels_from_device(device: Device, channel_type: FunctionalChannelType
]
def get_channel_index_by_type(
device: Device, channel_type: FunctionalChannelType
) -> int | None:
"""Return the index of the device's first channel of the given type."""
channels = get_channels_from_device(device, channel_type)
return channels[0].index if channels else None
def smoke_detector_channel_data_exists(device: Device, field: str) -> bool:
"""Check if a smoke detector's channel payload contains a specific field.
@@ -67,7 +67,11 @@ from homeassistant.helpers.typing import StateType
from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry, HomematicipHAP
from .helpers import get_channels_from_device, smoke_detector_channel_data_exists
from .helpers import (
get_channel_index_by_type,
get_channels_from_device,
smoke_detector_channel_data_exists,
)
@dataclass(frozen=True, kw_only=True)
@@ -144,6 +148,8 @@ class HmipSensorDescription[_DeviceT: Device](SensorEntityDescription):
extra_attrs_fn: Callable[[_DeviceT], dict[str, Any]] | None = None
icon_fn: Callable[[_DeviceT], str] | None = None
channel: int
# for devices whose channel does not sit at the position `channel` names
channel_type: FunctionalChannelType | None = None
ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position"
@@ -393,6 +399,20 @@ TILT_ANGLE_DESC = HmipSensorDescription[Device](
state_class=SensorStateClass.MEASUREMENT_ANGLE,
channel_value_fn=lambda channel: getattr(channel, "absoluteAngle", None),
channel=1,
channel_type=FunctionalChannelType.TILT_VIBRATION_SENSOR_CHANNEL,
)
# Only the ELV-SH-TACO carries a temperature channel next to the tilt channel.
TILT_TEMPERATURE_DESC = HmipSensorDescription[Device](
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=_temperature_value,
extra_attrs_fn=_temperature_extras,
exists_fn=lambda d: hasattr(d, "actualTemperature"),
channel=1,
channel_type=FunctionalChannelType.TEMPERATURE_SENSOR_CHANNEL,
)
@@ -433,7 +453,7 @@ SENSOR_DESCRIPTIONS_BY_DEVICE: dict[
TEMPERATURE_EXTERNAL_CH2_DESC,
TEMPERATURE_EXTERNAL_DELTA_DESC,
),
TiltVibrationSensor: (TILT_ANGLE_DESC,),
TiltVibrationSensor: (TILT_ANGLE_DESC, TILT_TEMPERATURE_DESC),
WeatherSensor: (
TEMPERATURE_DESC,
HUMIDITY_DESC,
@@ -721,7 +741,15 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the tilt sensor device."""
super().__init__(hap, device, post="Tilt State", feature_id="tilt_state")
super().__init__(
hap,
device,
post="Tilt State",
feature_id="tilt_state",
channel_real_index=get_channel_index_by_type(
device, FunctionalChannelType.TILT_VIBRATION_SENSOR_CHANNEL
),
)
@property
@override
@@ -1054,11 +1082,17 @@ class HomematicipSensor[_DeviceT: Device](HomematicipGenericEntity, SensorEntity
description: HmipSensorDescription[_DeviceT],
) -> None:
"""Initialize the described sensor."""
channel_real_index = (
get_channel_index_by_type(device, description.channel_type)
if description.channel_type is not None
else None
)
super().__init__(
hap,
device,
feature_id=description.key,
channel=description.channel,
channel_real_index=channel_real_index,
use_description_name=True,
)
self.entity_description = description
@@ -665,6 +665,93 @@
"type": "TILT_VIBRATION_SENSOR",
"updateState": "UP_TO_DATE"
},
"3014F711000000000000TACO": {
"availableFirmwareVersion": "1.2.4",
"connectionType": "HMIP_RF",
"firmwareVersion": "1.2.4",
"firmwareVersionInteger": 66052,
"functionalChannels": {
"0": {
"busConfigMismatch": null,
"coProFaulty": false,
"coProRestartNeeded": false,
"coProUpdateFailure": false,
"configPending": false,
"deviceId": "3014F711000000000000TACO",
"deviceOverheated": false,
"deviceOverloaded": false,
"devicePowerFailureDetected": false,
"deviceUndervoltage": false,
"dutyCycle": false,
"functionalChannelType": "DEVICE_BASE",
"groupIndex": 0,
"groups": [],
"index": 0,
"label": "",
"lowBat": false,
"multicastRoutingEnabled": false,
"powerShortCircuit": null,
"routerModuleEnabled": false,
"routerModuleSupported": false,
"rssiDeviceValue": -80,
"rssiPeerValue": null,
"shortCircuitDataLine": null,
"supportedOptionalFeatures": {
"IFeatureDeviceTemperatureOutOfRange": true,
"IFeatureRssiValue": true,
"IOptionalFeatureDutyCycle": true,
"IOptionalFeatureLowBat": true
},
"temperatureOutOfRange": false,
"unreach": false
},
"1": {
"actualTemperature": 22.9,
"channelRole": "WEATHER_SENSOR",
"deviceId": "3014F711000000000000TACO",
"functionalChannelType": "TEMPERATURE_SENSOR_CHANNEL",
"groupIndex": 1,
"groups": [],
"index": 1,
"label": ""
},
"2": {
"absoluteAngle": 92,
"accelerationSensorEventFilterPeriod": 3.0,
"accelerationSensorMode": "ANY_MOTION",
"accelerationSensorNeutralPosition": "HORIZONTAL",
"accelerationSensorSecondTriggerAngle": 75,
"accelerationSensorSensitivity": "SENSOR_RANGE_2G",
"accelerationSensorTriggerAngle": 20,
"accelerationSensorTriggered": true,
"channelRole": "ACCELERATION_SENSOR",
"deviceId": "3014F711000000000000TACO",
"functionalChannelType": "TILT_VIBRATION_SENSOR_CHANNEL",
"groupIndex": 2,
"groups": [],
"index": 2,
"label": "",
"supportedOptionalFeatures": {
"IOptionalFeatureTiltDetection": true
},
"tiltState": "NON_NEUTRAL",
"tiltVisualization": "GENERIC"
}
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F711000000000000TACO",
"label": "Wassertemperatursensor",
"lastStatusUpdate": 1598610615630,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
"modelId": 546,
"modelType": "ELV-SH-TACO",
"oem": "eQ-3",
"permanentlyReachable": false,
"serializedGlobalTradeItemNumber": "3014F711000000000000TACO",
"type": "TEMPERATURE_TILT_VIBRATION_SENSOR",
"updateState": "UP_TO_DATE"
},
"3014F711000WIREDSWITCH8": {
"availableFirmwareVersion": "0.0.0",
"connectionType": "HMIP_WIRED",
@@ -32,6 +32,7 @@ from homeassistant.components.homematicip_cloud.entity import (
)
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN
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
@@ -228,6 +229,31 @@ async def test_hmip_tilt_vibration_sensor(
assert len(hmip_device.mock_calls) == service_call_counter + 2
async def test_hmip_temperature_tilt_vibration_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
default_mock_hap_factory: HomeFactory,
) -> None:
"""Test the ELV-SH-TACO, whose tilt channel sits at index 2, not 1."""
entity_id = "binary_sensor.wassertemperatursensor_moving"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=["Wassertemperatursensor"]
)
ha_state, hmip_device = get_and_check_entity_basics(
hass, mock_hap, entity_id, "Wassertemperatursensor Moving", "ELV-SH-TACO"
)
assert ha_state.state == STATE_ON
entity = entity_registry.async_get(entity_id)
assert entity.unique_id == "3014F711000000000000TACO_2_tilt_vibration"
await async_manipulate_test_data(
hass, hmip_device, "accelerationSensorTriggered", False
)
assert hass.states.get(entity_id).state == STATE_OFF
async def test_hmip_contact_interface(
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
) -> None:
@@ -23,7 +23,7 @@ async def test_hmip_load_all_supported_devices(
test_devices=None, test_groups=None
)
assert len(mock_hap.hmip_device_by_entity_id) == 385
assert len(mock_hap.hmip_device_by_entity_id) == 390
async def test_hmip_remove_device(
@@ -822,6 +822,47 @@ async def test_hmip_tilt_vibration_sensor_tilt_angle(
assert ha_state.state == "89"
async def test_hmip_temperature_tilt_vibration_sensor(
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
) -> None:
"""Test the ELV-SH-TACO, whose tilt channel sits at index 2, not 1."""
device_model = "ELV-SH-TACO"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=["Wassertemperatursensor"]
)
ha_state, hmip_device = get_and_check_entity_basics(
hass,
mock_hap,
"sensor.wassertemperatursensor_tilt_state",
"Wassertemperatursensor Tilt State",
device_model,
)
assert ha_state.state == "non_neutral"
await async_manipulate_test_data(hass, hmip_device, "tiltState", "TILTED", 2)
ha_state = hass.states.get("sensor.wassertemperatursensor_tilt_state")
assert ha_state.state == "tilted"
ha_state, _ = get_and_check_entity_basics(
hass,
mock_hap,
"sensor.wassertemperatursensor_tilt_angle",
"Wassertemperatursensor Tilt angle",
device_model,
)
assert ha_state.state == "92"
ha_state, _ = get_and_check_entity_basics(
hass,
mock_hap,
"sensor.wassertemperatursensor_temperature",
"Wassertemperatursensor Temperature",
device_model,
)
assert ha_state.state == "22.9"
async def test_hmip_absolute_humidity_sensor(
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
) -> None: