1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 10:59:24 +00:00

Handle variable number of channels for HmIPW-DRI16 and HmIPW-DRI32 in homematicip_cloud integration (#151201)

This commit is contained in:
hahn-th
2025-11-23 17:53:05 +01:00
committed by GitHub
parent 704d4c896d
commit 79c7ad7646
10 changed files with 179 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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