diff --git a/custom_components/myhome/gateway_resync.py b/custom_components/myhome/gateway_resync.py index ef09cc253..743d3fe7a 100644 --- a/custom_components/myhome/gateway_resync.py +++ b/custom_components/myhome/gateway_resync.py @@ -115,7 +115,8 @@ def _normalize_ptp_where(raw_where: str | int, interface: str | None = None) -> def register_group_members(self, group_where: str, members: list[str] | set[str]) -> None: """Register the point-to-point member WHERE addresses belonging to a group.""" key = self._normalize_group_key(group_where) - cleaned = {self._normalize_ptp_where(m) for m in members if str(m).strip()} + grp_iface = key.partition(BUS_ROUTING)[2] or None + cleaned = {self._normalize_ptp_where(m, grp_iface) for m in members if str(m).strip()} if cleaned: self._group_members[key] = cleaned else: @@ -270,6 +271,7 @@ def _leading_echoes_for_area(target_area: str) -> set[str]: targets: list[str] = [] area_seed_echoes: dict[str, set[str]] = {} + group_seed_echoes: dict[str, set[str]] = {} if getattr(message, "is_group", False): group_key = self._normalize_group_key(message.group, self._message_interface(message)) members = self._group_members.get(group_key) @@ -296,6 +298,7 @@ def _leading_echoes_for_area(target_area: str) -> set[str]: len(matching_wheres), ) return + group_seed_echoes[group_key] = matching_wheres targets.append(group_key) elif getattr(message, "is_area", False): raw_where = str(message.where) @@ -328,8 +331,9 @@ def _leading_echoes_for_area(target_area: str) -> set[str]: self._resync_timers.pop(where)() self._resync_expected_on[where] = bool(msg_is_on) if where.startswith("#"): - self._resync_group_echoes[where] = 0 - self._resync_echoed_members[where] = set() + seeded = set(group_seed_echoes.get(where, set())) + self._resync_echoed_members[where] = seeded + self._resync_group_echoes[where] = len(seeded) else: self._resync_area_echoes[where] = set(area_seed_echoes.get(where, set())) diff --git a/custom_components/myhome/light_group.py b/custom_components/myhome/light_group.py index 13978e075..d6efd6f56 100644 --- a/custom_components/myhome/light_group.py +++ b/custom_components/myhome/light_group.py @@ -114,8 +114,11 @@ def __init__( self._attr_brightness = None self._attr_color_temp_kelvin = None self._attr_hs_color = None - _, _, iface = str(device_id).partition(BUS_ROUTING) - self._full_where = f"#{group}{BUS_ROUTING}{iface}" if iface else f"#{group}" + _, _, raw_iface = str(device_id).partition(BUS_ROUTING) + self._interface: str | None = ( + raw_iface.zfill(2) if raw_iface.isdigit() else (raw_iface or None) + ) + self._full_where = f"#{group}{BUS_ROUTING}{self._interface}" if self._interface else f"#{group}" # Last known brightness (0-100%), used as HSV "value" when only hue/saturation # are being set so a colour change never silently zeroes the group's level. # For groups with declared members the displayed brightness is derived from @@ -136,8 +139,13 @@ async def async_added_to_hass(self) -> None: self.async_on_remove(lambda: register_fn(self._full_where, [])) registry = er.async_get(self.hass) for w in self._declared_members: - # The entity unique_id is `{mac}-1-{w}` - unique_id = f"{self._gateway_handler.mac}-1-{w}" + member_key = ( + f"{w}{BUS_ROUTING}{self._interface}" + if self._interface and BUS_ROUTING not in str(w) + else str(w) + ) + # The entity unique_id is `{mac}-1-{w}` (or `{mac}-1-{w}#4#{iface}`) + unique_id = f"{self._gateway_handler.mac}-1-{member_key}" entity_id = registry.async_get_entity_id("light", DOMAIN, unique_id) if entity_id: self._member_entity_ids.append(entity_id) @@ -174,6 +182,15 @@ def handle_event(self, msg: Any) -> None: if not getattr(msg, "is_group", False) or str(getattr(msg, "group", "")) != str(self._group): return + msg_raw_iface = getattr(msg, "interface", None) + msg_iface = ( + msg_raw_iface.zfill(2) + if isinstance(msg_raw_iface, str) and msg_raw_iface.isdigit() + else (msg_raw_iface if isinstance(msg_raw_iface, str) and msg_raw_iface else None) + ) + if msg_iface != self._interface: + return + # Parse status from the frame (assumed mode only updates attributes) if isinstance(msg, OWNLightingEvent) and getattr(msg, "dimension", None) is None: if msg.is_on is True: diff --git a/tests/test_issue_368_resync.py b/tests/test_issue_368_resync.py index 5a8ea53d9..6cadfa513 100644 --- a/tests/test_issue_368_resync.py +++ b/tests/test_issue_368_resync.py @@ -671,6 +671,28 @@ async def test_group_resync_ignores_cross_room_and_opposite_polarity_echoes( mgr.register_group_members("6", []) assert "#6" not in mgr.group_members + # Split leading + trailing echoes for #6 = {11, 12}: + # 11 echoes ON before *1*1*#6## (seeded), duplicate 11 ON after *1*1*#6## stays 1, + # 11 flipping OFF discards the seed so 12 ON alone does not cancel, and 11 ON + # again completes {11, 12} and cancels #6. + handler.send_status_request.reset_mock() + mgr.register_group_members("#6", ["11", "12"]) + await handler._process_message(OWNMessage.parse("*1*1*11##")) + await handler._process_message(OWNMessage.parse("*1*1*#6##")) + assert "#6" in handler._resync_timers + assert mgr.resync_group_echoes["#6"] == 1 + await handler._process_message(OWNMessage.parse("*1*1*11##")) + assert "#6" in handler._resync_timers + assert mgr.resync_group_echoes["#6"] == 1 + await handler._process_message(OWNMessage.parse("*1*0*11##")) + assert mgr.resync_group_echoes["#6"] == 0 + await handler._process_message(OWNMessage.parse("*1*1*12##")) + assert "#6" in handler._resync_timers + await handler._process_message(OWNMessage.parse("*1*1*11##")) + assert "#6" not in handler._resync_timers + await _advance(hass) + handler.send_status_request.assert_not_called() + @pytest.mark.asyncio async def test_opposite_polarity_trailing_echo_does_not_cancel_area_sweep( @@ -760,20 +782,55 @@ async def test_group_resync_preserves_bus_interface_on_members_and_group_key( synthetic_msg = SimpleNamespace(where="11", where_param=["4", "02"]) assert mgr._message_interface(synthetic_msg) == "02" - # MyHOMELightGroup preserves #4# on _full_where and group_members registration + # A routed group (#6#4#01) with bare PointToPoint members (["11", "12"] from myhome.yaml) + # inherits the group's bus interface for both resync registration and entity resolution. + reg = er.async_get(hass) + m1 = reg.async_get_or_create( + "light", DOMAIN, f"{handler.mac}-1-11#4#01", config_entry=handler.config_entry + ) + m2 = reg.async_get_or_create( + "light", DOMAIN, f"{handler.mac}-1-12#4#01", config_entry=handler.config_entry + ) grp_entity = MyHOMELightGroup( hass=hass, name="Riser 01 Group 6", - device_id="1-#6#4#01", + device_id="1-#6#4#1", group=6, gateway=handler, - members=["11#4#01", "12#4#01"], + members=["11", "12"], dimmable=False, color_temp=False, rgb=False, hs=False, ) + grp_entity.hass = hass + grp_entity.entity_id = "light.riser_01_group_6" + grp_entity.async_write_ha_state = MagicMock() assert grp_entity._full_where == "#6#4#01" + await grp_entity.async_added_to_hass() + assert mgr.group_members["#6#4#01"] == {"11#4#01", "12#4#01"} + assert grp_entity._member_entity_ids == [m1.entity_id, m2.entity_id] + + # Assumed-state routed group ignores group frames from the local bus + assumed_routed = MyHOMELightGroup( + hass=hass, + name="Assumed Riser 01 Group 7", + device_id="1-#7#4#01", + group=7, + gateway=handler, + members=[], + dimmable=False, + color_temp=False, + rgb=False, + hs=False, + ) + assumed_routed.hass = hass + assumed_routed.entity_id = "light.assumed_riser_01_group_7" + assumed_routed.async_write_ha_state = MagicMock() + assumed_routed.handle_event(OWNMessage.parse("*1*1*#7##")) + assert assumed_routed.is_on is None + assumed_routed.handle_event(OWNMessage.parse("*1*1*#7#4#01##")) + assert assumed_routed.is_on is True @pytest.mark.asyncio @@ -895,20 +952,22 @@ async def _send(cmd: Any) -> None: await _run_due(clock.now + 5.0) return armed_after, sent - # 1. With the plant's two group #1 members (06, 07) registered: + # 1. With the plant's two group #1 members (06, 07) registered (and with no + # members registered, exercising the 2-distinct-address same-polarity fallback): # *1*0*#1## (9.447) follows the ON echoes of 06 (8.786) and 07 (8.898) inside the # 1.5 s leading window: those are the wrong polarity, so the OFF sweep is armed # rather than skipped, and the OFF echoes at 9.505 / 9.555 then cancel it. - armed_after, sent = await _run_timed_replay(["06", "07"]) - assert armed_after == {"*1*1*#1##": True, "*1*0*#1##": True} - assert not handler._resync_timers - # The group #1 sweeps are cancelled by the member echoes, ~50 ms apart. After - # the general *1*1*0## (10.183) and *1*0*0## (12.461) the F461 echoes 06 and - # 07 0.7 s and 1.5 s later, past the 0.5 s debounce, so both area 00 sweeps go out. - assert sent == [ - (round(10.183 + RESYNC_DEBOUNCE_S, 3), "*#1*00##"), - (round(12.461 + RESYNC_DEBOUNCE_S, 3), "*#1*00##"), - ] + for declared in (["06", "07"], []): + armed_after, sent = await _run_timed_replay(declared) + assert armed_after == {"*1*1*#1##": True, "*1*0*#1##": True} + assert not handler._resync_timers + # The group #1 sweeps are cancelled by the member echoes, ~50 ms apart. After + # the general *1*1*0## (10.183) and *1*0*0## (12.461) the F461 echoes 06 and + # 07 0.7 s and 1.5 s later, past the 0.5 s debounce, so both area 00 sweeps go out. + assert sent == [ + (round(10.183 + RESYNC_DEBOUNCE_S, 3), "*#1*00##"), + (round(12.461 + RESYNC_DEBOUNCE_S, 3), "*#1*00##"), + ] # 2. When a third member (08) is also declared in group #1 but never echoes in # the trace, the 06 and 07 echoes alone must NOT cancel the #1 sweeps.