diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 11119fbca7ba..5fe9605f80c5 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -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): diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 4ac44c80c11d..78627a16cf3a 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -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 diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 151abf3f63a1..7320f988b077 100644 --- a/homeassistant/components/homematicip_cloud/helpers.py +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -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. diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index c73cdada998c..f74b499d4d0e 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -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 diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index cfa932c3890c..e3a10f5551db 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -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", diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index b94f9ad9c9a6..ddf03a299dbd 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -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: diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 0dc457147e3d..364ff675300b 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -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( diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 94d587916484..14f5cfed2710 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -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: