mirror of
https://github.com/home-assistant/core.git
synced 2025-12-20 02:48:57 +00:00
Handle variable number of channels for HmIPW-DRI16 and HmIPW-DRI32 in homematicip_cloud integration (#151201)
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user