diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 9c0e5620022..b020a7e7473 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from homematicip.base.enums import SmokeDetectorAlarmType, WindowState +from homematicip.base.functionalChannels import MultiModeInputChannel from homematicip.device import ( AccelerationSensor, ContactInterface, @@ -87,8 +88,11 @@ async def async_setup_entry( entities.append(HomematicipTiltVibrationSensor(hap, device)) if isinstance(device, WiredInput32): entities.extend( - HomematicipMultiContactInterface(hap, device, channel=channel) - for channel in range(1, 33) + HomematicipMultiContactInterface( + hap, device, channel_real_index=channel.index + ) + for channel in device.functionalChannels + if isinstance(channel, MultiModeInputChannel) ) elif isinstance(device, FullFlushContactInterface6): entities.extend( @@ -227,21 +231,24 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt device, channel=1, is_multi_channel=True, + channel_real_index=None, ) -> None: """Initialize the multi contact entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + channel_real_index=channel_real_index, ) @property def is_on(self) -> bool | None: """Return true if the contact interface is on/open.""" - if self._device.functionalChannels[self._channel].windowState is None: + channel = self.get_channel_or_raise() + if channel.windowState is None: return None - return ( - self._device.functionalChannels[self._channel].windowState - != WindowState.CLOSED - ) + return channel.windowState != WindowState.CLOSED class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensorEntity): diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index e846a360d39..8a3abb5156c 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -283,19 +283,23 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" - return self.functional_channel.doorState == DoorState.CLOSED + channel = self.get_channel_or_raise() + return channel.doorState == DoorState.CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self.functional_channel.async_send_door_command(DoorCommand.OPEN) + channel = self.get_channel_or_raise() + await channel.async_send_door_command(DoorCommand.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self.functional_channel.async_send_door_command(DoorCommand.CLOSE) + channel = self.get_channel_or_raise() + await channel.async_send_door_command(DoorCommand.CLOSE) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.functional_channel.async_send_door_command(DoorCommand.STOP) + channel = self.get_channel_or_raise() + await channel.async_send_door_command(DoorCommand.STOP) class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 41ccbb4b060..81f2c7e8c7e 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import logging from typing import Any @@ -84,6 +85,7 @@ class HomematicipGenericEntity(Entity): post: str | None = None, channel: int | None = None, is_multi_channel: bool | None = False, + channel_real_index: int | None = None, ) -> None: """Initialize the generic entity.""" self._hap = hap @@ -91,8 +93,19 @@ class HomematicipGenericEntity(Entity): self._device = device self._post = post self._channel = channel + + # channel_real_index represents the actual index of the devices channel. + # Accessing a functionalChannel by the channel parameter or array index is unreliable, + # because the functionalChannels array is sorted as strings, not numbers. + # For example, channels are ordered as: 1, 10, 11, 12, 2, 3, ... + # Using channel_real_index ensures you reference the correct channel. + self._channel_real_index: int | None = channel_real_index + self._is_multi_channel = is_multi_channel - self.functional_channel = self.get_current_channel() + self.functional_channel = None + with contextlib.suppress(ValueError): + self.functional_channel = self.get_current_channel() + # Marker showing that the HmIP device hase been removed. self.hmip_device_removed = False @@ -101,17 +114,20 @@ class HomematicipGenericEntity(Entity): """Return device specific attributes.""" # Only physical devices should be HA devices. if isinstance(self._device, Device): + device_id = str(self._device.id) + home_id = str(self._device.homeId) + return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device - (DOMAIN, self._device.id) + (DOMAIN, device_id) }, manufacturer=self._device.oem, model=self._device.modelType, name=self._device.label, sw_version=self._device.firmwareVersion, # Link to the homematic ip access point. - via_device=(DOMAIN, self._device.homeId), + via_device=(DOMAIN, home_id), ) return None @@ -185,25 +201,31 @@ class HomematicipGenericEntity(Entity): def name(self) -> str: """Return the name of the generic entity.""" - name = None + name = "" # Try to get a label from a channel. - if hasattr(self._device, "functionalChannels"): + functional_channels = getattr(self._device, "functionalChannels", None) + if functional_channels and self.functional_channel: if self._is_multi_channel: - name = self._device.functionalChannels[self._channel].label - elif len(self._device.functionalChannels) > 1: - name = self._device.functionalChannels[1].label + label = getattr(self.functional_channel, "label", None) + if label: + name = str(label) + elif len(functional_channels) > 1: + label = getattr(functional_channels[1], "label", None) + if label: + name = str(label) # Use device label, if name is not defined by channel label. if not name: - name = self._device.label + name = self._device.label or "" if self._post: name = f"{name} {self._post}" elif self._is_multi_channel: - name = f"{name} Channel{self._channel}" + name = f"{name} Channel{self.get_channel_index()}" # Add a prefix to the name if the homematic ip home has a name. - if name and self._home.name: - name = f"{self._home.name} {name}" + home_name = getattr(self._home, "name", None) + if name and home_name: + name = f"{home_name} {name}" return name @@ -217,9 +239,7 @@ class HomematicipGenericEntity(Entity): """Return a unique ID.""" unique_id = f"{self.__class__.__name__}_{self._device.id}" if self._is_multi_channel: - unique_id = ( - f"{self.__class__.__name__}_Channel{self._channel}_{self._device.id}" - ) + unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}" return unique_id @@ -254,12 +274,65 @@ class HomematicipGenericEntity(Entity): return state_attr def get_current_channel(self) -> FunctionalChannel: - """Return the FunctionalChannel for device.""" - if hasattr(self._device, "functionalChannels"): - if self._is_multi_channel: - return self._device.functionalChannels[self._channel] + """Return the FunctionalChannel for the device. - if len(self._device.functionalChannels) > 1: - return self._device.functionalChannels[1] + Resolution priority: + 1. For multi-channel entities 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 channel at position 1 + (index 0 is often a meta/service channel in HmIP). + Raises ValueError if no suitable channel can be resolved. + """ + functional_channels = getattr(self._device, "functionalChannels", None) + if not functional_channels: + raise ValueError( + f"Device {getattr(self._device, 'id', 'unknown')} has no functionalChannels" + ) - return None + # 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 {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 + ): + return functional_channels[self._channel] + raise ValueError( + f"Channel position {self._channel} invalid for device " + f"{getattr(self._device, 'id', 'unknown')} (len={len(functional_channels)})" + ) + + # Single-channel / non multi-channel entity: choose second element if available + if len(functional_channels) > 1: + return functional_channels[1] + return functional_channels[0] + + def get_channel_index(self) -> int: + """Return the correct channel index for this entity. + + Prefers channel_real_index if set, otherwise returns channel. + This ensures the correct channel is used even if the functionalChannels list is not numerically ordered. + """ + if self._channel_real_index is not None: + return self._channel_real_index + + if self._channel is not None: + return self._channel + + return 1 + + def get_channel_or_raise(self) -> FunctionalChannel: + """Return the FunctionalChannel or raise an error if not found.""" + if not self.functional_channel: + raise ValueError( + f"No functional channel found for device {getattr(self._device, 'id', 'unknown')}" + ) + return self.functional_channel diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index 101c3e3015a..f98b078ab73 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -92,7 +92,9 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() - self.functional_channel.add_on_channel_event_handler(self._async_handle_event) + + channel = self.get_channel_or_raise() + channel.add_on_channel_event_handler(self._async_handle_event) @callback def _async_handle_event(self, *args, **kwargs) -> None: diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 1e602cd09c2..e8b0681d059 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -134,49 +134,49 @@ class HomematicipLightHS(HomematicipGenericEntity, LightEntity): @property def is_on(self) -> bool: """Return true if light is on.""" - return self.functional_channel.on + channel = self.get_channel_or_raise() + return channel.on @property def brightness(self) -> int | None: """Return the current brightness.""" - return int(self.functional_channel.dimLevel * 255.0) + channel = self.get_channel_or_raise() + return int(channel.dimLevel * 255.0) @property def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" - if ( - self.functional_channel.hue is None - or self.functional_channel.saturationLevel is None - ): + channel = self.get_channel_or_raise() + if channel.hue is None or channel.saturationLevel is None: return None return ( - self.functional_channel.hue, - self.functional_channel.saturationLevel * 100.0, + channel.hue, + channel.saturationLevel * 100.0, ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - + channel = self.get_channel_or_raise() hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0)) hue = hs_color[0] % 360.0 saturation = hs_color[1] / 100.0 dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2) if ATTR_HS_COLOR not in kwargs: - hue = self.functional_channel.hue - saturation = self.functional_channel.saturationLevel + hue = channel.hue + saturation = channel.saturationLevel if ATTR_BRIGHTNESS not in kwargs: # If no brightness is set, use the current brightness - dim_level = self.functional_channel.dimLevel or 1.0 - - await self.functional_channel.set_hue_saturation_dim_level_async( + dim_level = channel.dimLevel or 1.0 + await channel.set_hue_saturation_dim_level_async( hue=hue, saturation_level=saturation, dim_level=dim_level ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self.functional_channel.set_switch_state_async(on=False) + channel = self.get_channel_or_raise() + await channel.set_switch_state_async(on=False) class HomematicipLightMeasuring(HomematicipLight): diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 588e67bac95..57195afbdc6 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -307,7 +307,8 @@ class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity): @property def native_value(self) -> float | None: """Return the state.""" - return self.functional_channel.waterFlow + channel = self.get_channel_or_raise() + return channel.waterFlow class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity): diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 5da2989f93f..8f01c65589c 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -113,15 +113,18 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): @property def is_on(self) -> bool: """Return true if switch is on.""" - return self.functional_channel.on + channel = self.get_channel_or_raise() + return channel.on async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.functional_channel.async_turn_on() + channel = self.get_channel_or_raise() + await channel.async_turn_on() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.functional_channel.async_turn_off() + channel = self.get_channel_or_raise() + await channel.async_turn_off() class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): diff --git a/homeassistant/components/homematicip_cloud/valve.py b/homeassistant/components/homematicip_cloud/valve.py index aaeaa3c565c..a97ec157d17 100644 --- a/homeassistant/components/homematicip_cloud/valve.py +++ b/homeassistant/components/homematicip_cloud/valve.py @@ -47,13 +47,16 @@ class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity): async def async_open_valve(self) -> None: """Open the valve.""" - await self.functional_channel.set_watering_switch_state_async(True) + channel = self.get_channel_or_raise() + await channel.set_watering_switch_state_async(True) async def async_close_valve(self) -> None: """Close valve.""" - await self.functional_channel.set_watering_switch_state_async(False) + channel = self.get_channel_or_raise() + await channel.set_watering_switch_state_async(False) @property def is_closed(self) -> bool: """Return if the valve is closed.""" - return self.functional_channel.wateringActive is False + channel = self.get_channel_or_raise() + return channel.wateringActive is False diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index ab5e61c19fa..d5083290bbd 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -63,12 +63,25 @@ async def async_manipulate_test_data( new_value: Any, channel: int = 1, fire_device: HomeMaticIPObject | None = None, + channel_real_index: int | None = None, ): """Set new value on hmip device.""" if channel == 1: setattr(hmip_device, attribute, new_value) - if hasattr(hmip_device, "functionalChannels"): - functional_channel = hmip_device.functionalChannels[channel] + + channels = getattr(hmip_device, "functionalChannels", None) + if channels: + if channel_real_index is not None: + functional_channel = next( + (ch for ch in channels if ch.index == channel_real_index), + None, + ) + assert functional_channel is not None, ( + f"No functional channel with index {channel_real_index} found in hmip_device.functionalChannels" + ) + else: + functional_channel = channels[channel] + setattr(functional_channel, attribute, new_value) fire_target = hmip_device if fire_device is None else fire_device diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index 90af26bee55..1f58a7f7f40 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -565,8 +565,8 @@ async def test_hmip_multi_contact_interface( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: """Test HomematicipMultiContactInterface.""" - entity_id = "binary_sensor.wired_eingangsmodul_32_fach_channel5" - entity_name = "Wired Eingangsmodul – 32-fach Channel5" + entity_id = "binary_sensor.wired_eingangsmodul_32_fach_channel10" + entity_name = "Wired Eingangsmodul – 32-fach Channel10" device_model = "HmIPW-DRI32" mock_hap = await default_mock_hap_factory.async_get_mock_hap( test_devices=["Wired Eingangsmodul – 32-fach", "Licht Flur"] @@ -578,15 +578,25 @@ async def test_hmip_multi_contact_interface( assert ha_state.state == STATE_OFF await async_manipulate_test_data( - hass, hmip_device, "windowState", WindowState.OPEN, channel=5 + hass, hmip_device, "windowState", WindowState.OPEN, channel_real_index=10 ) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON - await async_manipulate_test_data(hass, hmip_device, "windowState", None, channel=5) + await async_manipulate_test_data( + hass, hmip_device, "windowState", None, channel_real_index=10 + ) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNKNOWN + # Test channel 32 of device + entity_id = "binary_sensor.wired_eingangsmodul_32_fach_channel32" + entity_name = "Wired Eingangsmodul – 32-fach Channel32" + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + assert ha_state.state == STATE_OFF + ha_state, hmip_device = get_and_check_entity_basics( hass, mock_hap,