Skip to content

Commit 79c7ad7

Browse files
authored
Handle variable number of channels for HmIPW-DRI16 and HmIPW-DRI32 in homematicip_cloud integration (home-assistant#151201)
1 parent 704d4c8 commit 79c7ad7

File tree

10 files changed

+179
-63
lines changed

10 files changed

+179
-63
lines changed

homeassistant/components/homematicip_cloud/binary_sensor.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
8+
from homematicip.base.functionalChannels import MultiModeInputChannel
89
from homematicip.device import (
910
AccelerationSensor,
1011
ContactInterface,
@@ -87,8 +88,11 @@ async def async_setup_entry(
8788
entities.append(HomematicipTiltVibrationSensor(hap, device))
8889
if isinstance(device, WiredInput32):
8990
entities.extend(
90-
HomematicipMultiContactInterface(hap, device, channel=channel)
91-
for channel in range(1, 33)
91+
HomematicipMultiContactInterface(
92+
hap, device, channel_real_index=channel.index
93+
)
94+
for channel in device.functionalChannels
95+
if isinstance(channel, MultiModeInputChannel)
9296
)
9397
elif isinstance(device, FullFlushContactInterface6):
9498
entities.extend(
@@ -227,21 +231,24 @@ def __init__(
227231
device,
228232
channel=1,
229233
is_multi_channel=True,
234+
channel_real_index=None,
230235
) -> None:
231236
"""Initialize the multi contact entity."""
232237
super().__init__(
233-
hap, device, channel=channel, is_multi_channel=is_multi_channel
238+
hap,
239+
device,
240+
channel=channel,
241+
is_multi_channel=is_multi_channel,
242+
channel_real_index=channel_real_index,
234243
)
235244

236245
@property
237246
def is_on(self) -> bool | None:
238247
"""Return true if the contact interface is on/open."""
239-
if self._device.functionalChannels[self._channel].windowState is None:
248+
channel = self.get_channel_or_raise()
249+
if channel.windowState is None:
240250
return None
241-
return (
242-
self._device.functionalChannels[self._channel].windowState
243-
!= WindowState.CLOSED
244-
)
251+
return channel.windowState != WindowState.CLOSED
245252

246253

247254
class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensorEntity):

homeassistant/components/homematicip_cloud/cover.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,19 +283,23 @@ def current_cover_position(self) -> int | None:
283283
@property
284284
def is_closed(self) -> bool | None:
285285
"""Return if the cover is closed."""
286-
return self.functional_channel.doorState == DoorState.CLOSED
286+
channel = self.get_channel_or_raise()
287+
return channel.doorState == DoorState.CLOSED
287288

288289
async def async_open_cover(self, **kwargs: Any) -> None:
289290
"""Open the cover."""
290-
await self.functional_channel.async_send_door_command(DoorCommand.OPEN)
291+
channel = self.get_channel_or_raise()
292+
await channel.async_send_door_command(DoorCommand.OPEN)
291293

292294
async def async_close_cover(self, **kwargs: Any) -> None:
293295
"""Close the cover."""
294-
await self.functional_channel.async_send_door_command(DoorCommand.CLOSE)
296+
channel = self.get_channel_or_raise()
297+
await channel.async_send_door_command(DoorCommand.CLOSE)
295298

296299
async def async_stop_cover(self, **kwargs: Any) -> None:
297300
"""Stop the cover."""
298-
await self.functional_channel.async_send_door_command(DoorCommand.STOP)
301+
channel = self.get_channel_or_raise()
302+
await channel.async_send_door_command(DoorCommand.STOP)
299303

300304

301305
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):

homeassistant/components/homematicip_cloud/entity.py

Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import contextlib
56
import logging
67
from typing import Any
78

@@ -84,15 +85,27 @@ def __init__(
8485
post: str | None = None,
8586
channel: int | None = None,
8687
is_multi_channel: bool | None = False,
88+
channel_real_index: int | None = None,
8789
) -> None:
8890
"""Initialize the generic entity."""
8991
self._hap = hap
9092
self._home: AsyncHome = hap.home
9193
self._device = device
9294
self._post = post
9395
self._channel = channel
96+
97+
# channel_real_index represents the actual index of the devices channel.
98+
# Accessing a functionalChannel by the channel parameter or array index is unreliable,
99+
# because the functionalChannels array is sorted as strings, not numbers.
100+
# For example, channels are ordered as: 1, 10, 11, 12, 2, 3, ...
101+
# Using channel_real_index ensures you reference the correct channel.
102+
self._channel_real_index: int | None = channel_real_index
103+
94104
self._is_multi_channel = is_multi_channel
95-
self.functional_channel = self.get_current_channel()
105+
self.functional_channel = None
106+
with contextlib.suppress(ValueError):
107+
self.functional_channel = self.get_current_channel()
108+
96109
# Marker showing that the HmIP device hase been removed.
97110
self.hmip_device_removed = False
98111

@@ -101,17 +114,20 @@ def device_info(self) -> DeviceInfo | None:
101114
"""Return device specific attributes."""
102115
# Only physical devices should be HA devices.
103116
if isinstance(self._device, Device):
117+
device_id = str(self._device.id)
118+
home_id = str(self._device.homeId)
119+
104120
return DeviceInfo(
105121
identifiers={
106122
# Serial numbers of Homematic IP device
107-
(DOMAIN, self._device.id)
123+
(DOMAIN, device_id)
108124
},
109125
manufacturer=self._device.oem,
110126
model=self._device.modelType,
111127
name=self._device.label,
112128
sw_version=self._device.firmwareVersion,
113129
# Link to the homematic ip access point.
114-
via_device=(DOMAIN, self._device.homeId),
130+
via_device=(DOMAIN, home_id),
115131
)
116132
return None
117133

@@ -185,25 +201,31 @@ def _async_device_removed(self, *args, **kwargs) -> None:
185201
def name(self) -> str:
186202
"""Return the name of the generic entity."""
187203

188-
name = None
204+
name = ""
189205
# Try to get a label from a channel.
190-
if hasattr(self._device, "functionalChannels"):
206+
functional_channels = getattr(self._device, "functionalChannels", None)
207+
if functional_channels and self.functional_channel:
191208
if self._is_multi_channel:
192-
name = self._device.functionalChannels[self._channel].label
193-
elif len(self._device.functionalChannels) > 1:
194-
name = self._device.functionalChannels[1].label
209+
label = getattr(self.functional_channel, "label", None)
210+
if label:
211+
name = str(label)
212+
elif len(functional_channels) > 1:
213+
label = getattr(functional_channels[1], "label", None)
214+
if label:
215+
name = str(label)
195216

196217
# Use device label, if name is not defined by channel label.
197218
if not name:
198-
name = self._device.label
219+
name = self._device.label or ""
199220
if self._post:
200221
name = f"{name} {self._post}"
201222
elif self._is_multi_channel:
202-
name = f"{name} Channel{self._channel}"
223+
name = f"{name} Channel{self.get_channel_index()}"
203224

204225
# Add a prefix to the name if the homematic ip home has a name.
205-
if name and self._home.name:
206-
name = f"{self._home.name} {name}"
226+
home_name = getattr(self._home, "name", None)
227+
if name and home_name:
228+
name = f"{home_name} {name}"
207229

208230
return name
209231

@@ -217,9 +239,7 @@ def unique_id(self) -> str:
217239
"""Return a unique ID."""
218240
unique_id = f"{self.__class__.__name__}_{self._device.id}"
219241
if self._is_multi_channel:
220-
unique_id = (
221-
f"{self.__class__.__name__}_Channel{self._channel}_{self._device.id}"
222-
)
242+
unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}"
223243

224244
return unique_id
225245

@@ -254,12 +274,65 @@ def extra_state_attributes(self) -> dict[str, Any]:
254274
return state_attr
255275

256276
def get_current_channel(self) -> FunctionalChannel:
257-
"""Return the FunctionalChannel for device."""
258-
if hasattr(self._device, "functionalChannels"):
259-
if self._is_multi_channel:
260-
return self._device.functionalChannels[self._channel]
277+
"""Return the FunctionalChannel for the device.
278+
279+
Resolution priority:
280+
1. For multi-channel entities with a real index, find channel by index match.
281+
2. For multi-channel entities without a real index, use the provided channel position.
282+
3. For non multi-channel entities with >1 channels, use channel at position 1
283+
(index 0 is often a meta/service channel in HmIP).
284+
Raises ValueError if no suitable channel can be resolved.
285+
"""
286+
functional_channels = getattr(self._device, "functionalChannels", None)
287+
if not functional_channels:
288+
raise ValueError(
289+
f"Device {getattr(self._device, 'id', 'unknown')} has no functionalChannels"
290+
)
291+
292+
# Multi-channel handling
293+
if self._is_multi_channel:
294+
# Prefer real index mapping when provided to avoid ordering issues.
295+
if self._channel_real_index is not None:
296+
for channel in functional_channels:
297+
if channel.index == self._channel_real_index:
298+
return channel
299+
raise ValueError(
300+
f"Real channel index {self._channel_real_index} not found for device "
301+
f"{getattr(self._device, 'id', 'unknown')}"
302+
)
303+
# Fallback: positional channel (already sorted as strings upstream).
304+
if self._channel is not None and 0 <= self._channel < len(
305+
functional_channels
306+
):
307+
return functional_channels[self._channel]
308+
raise ValueError(
309+
f"Channel position {self._channel} invalid for device "
310+
f"{getattr(self._device, 'id', 'unknown')} (len={len(functional_channels)})"
311+
)
261312

262-
if len(self._device.functionalChannels) > 1:
263-
return self._device.functionalChannels[1]
313+
# Single-channel / non multi-channel entity: choose second element if available
314+
if len(functional_channels) > 1:
315+
return functional_channels[1]
316+
return functional_channels[0]
264317

265-
return None
318+
def get_channel_index(self) -> int:
319+
"""Return the correct channel index for this entity.
320+
321+
Prefers channel_real_index if set, otherwise returns channel.
322+
This ensures the correct channel is used even if the functionalChannels list is not numerically ordered.
323+
"""
324+
if self._channel_real_index is not None:
325+
return self._channel_real_index
326+
327+
if self._channel is not None:
328+
return self._channel
329+
330+
return 1
331+
332+
def get_channel_or_raise(self) -> FunctionalChannel:
333+
"""Return the FunctionalChannel or raise an error if not found."""
334+
if not self.functional_channel:
335+
raise ValueError(
336+
f"No functional channel found for device {getattr(self._device, 'id', 'unknown')}"
337+
)
338+
return self.functional_channel

homeassistant/components/homematicip_cloud/event.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ def __init__(
9292
async def async_added_to_hass(self) -> None:
9393
"""Register callbacks."""
9494
await super().async_added_to_hass()
95-
self.functional_channel.add_on_channel_event_handler(self._async_handle_event)
95+
96+
channel = self.get_channel_or_raise()
97+
channel.add_on_channel_event_handler(self._async_handle_event)
9698

9799
@callback
98100
def _async_handle_event(self, *args, **kwargs) -> None:

homeassistant/components/homematicip_cloud/light.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -134,49 +134,49 @@ def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> N
134134
@property
135135
def is_on(self) -> bool:
136136
"""Return true if light is on."""
137-
return self.functional_channel.on
137+
channel = self.get_channel_or_raise()
138+
return channel.on
138139

139140
@property
140141
def brightness(self) -> int | None:
141142
"""Return the current brightness."""
142-
return int(self.functional_channel.dimLevel * 255.0)
143+
channel = self.get_channel_or_raise()
144+
return int(channel.dimLevel * 255.0)
143145

144146
@property
145147
def hs_color(self) -> tuple[float, float] | None:
146148
"""Return the hue and saturation color value [float, float]."""
147-
if (
148-
self.functional_channel.hue is None
149-
or self.functional_channel.saturationLevel is None
150-
):
149+
channel = self.get_channel_or_raise()
150+
if channel.hue is None or channel.saturationLevel is None:
151151
return None
152152
return (
153-
self.functional_channel.hue,
154-
self.functional_channel.saturationLevel * 100.0,
153+
channel.hue,
154+
channel.saturationLevel * 100.0,
155155
)
156156

157157
async def async_turn_on(self, **kwargs: Any) -> None:
158158
"""Turn the light on."""
159-
159+
channel = self.get_channel_or_raise()
160160
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
161161
hue = hs_color[0] % 360.0
162162
saturation = hs_color[1] / 100.0
163163
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
164164

165165
if ATTR_HS_COLOR not in kwargs:
166-
hue = self.functional_channel.hue
167-
saturation = self.functional_channel.saturationLevel
166+
hue = channel.hue
167+
saturation = channel.saturationLevel
168168

169169
if ATTR_BRIGHTNESS not in kwargs:
170170
# If no brightness is set, use the current brightness
171-
dim_level = self.functional_channel.dimLevel or 1.0
172-
173-
await self.functional_channel.set_hue_saturation_dim_level_async(
171+
dim_level = channel.dimLevel or 1.0
172+
await channel.set_hue_saturation_dim_level_async(
174173
hue=hue, saturation_level=saturation, dim_level=dim_level
175174
)
176175

177176
async def async_turn_off(self, **kwargs: Any) -> None:
178177
"""Turn the light off."""
179-
await self.functional_channel.set_switch_state_async(on=False)
178+
channel = self.get_channel_or_raise()
179+
await channel.set_switch_state_async(on=False)
180180

181181

182182
class HomematicipLightMeasuring(HomematicipLight):

homeassistant/components/homematicip_cloud/sensor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,8 @@ def __init__(
307307
@property
308308
def native_value(self) -> float | None:
309309
"""Return the state."""
310-
return self.functional_channel.waterFlow
310+
channel = self.get_channel_or_raise()
311+
return channel.waterFlow
311312

312313

313314
class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity):

homeassistant/components/homematicip_cloud/switch.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,18 @@ def __init__(
113113
@property
114114
def is_on(self) -> bool:
115115
"""Return true if switch is on."""
116-
return self.functional_channel.on
116+
channel = self.get_channel_or_raise()
117+
return channel.on
117118

118119
async def async_turn_on(self, **kwargs: Any) -> None:
119120
"""Turn the switch on."""
120-
await self.functional_channel.async_turn_on()
121+
channel = self.get_channel_or_raise()
122+
await channel.async_turn_on()
121123

122124
async def async_turn_off(self, **kwargs: Any) -> None:
123125
"""Turn the switch off."""
124-
await self.functional_channel.async_turn_off()
126+
channel = self.get_channel_or_raise()
127+
await channel.async_turn_off()
125128

126129

127130
class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):

homeassistant/components/homematicip_cloud/valve.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,16 @@ def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None:
4747

4848
async def async_open_valve(self) -> None:
4949
"""Open the valve."""
50-
await self.functional_channel.set_watering_switch_state_async(True)
50+
channel = self.get_channel_or_raise()
51+
await channel.set_watering_switch_state_async(True)
5152

5253
async def async_close_valve(self) -> None:
5354
"""Close valve."""
54-
await self.functional_channel.set_watering_switch_state_async(False)
55+
channel = self.get_channel_or_raise()
56+
await channel.set_watering_switch_state_async(False)
5557

5658
@property
5759
def is_closed(self) -> bool:
5860
"""Return if the valve is closed."""
59-
return self.functional_channel.wateringActive is False
61+
channel = self.get_channel_or_raise()
62+
return channel.wateringActive is False

0 commit comments

Comments
 (0)