Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions custom_components/myhome/gateway_resync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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()))

Expand Down
25 changes: 21 additions & 4 deletions custom_components/myhome/light_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
87 changes: 73 additions & 14 deletions tests/test_issue_368_resync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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#<interface> 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
Expand Down Expand Up @@ -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.
Expand Down
Loading