From d9aaca1856e84f310c276287c2cb59dc8b61b51a Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:56:59 +0200 Subject: [PATCH 1/2] fix(resync): require all registered area entities to echo before skipping or cancelling area sweep (#746) --- custom_components/myhome/gateway_resync.py | 100 +- docs/configuration/runtime_behaviour.md | 7 +- tests/fixtures/traces/issue_746/README.md | 31 + ..._trace_MH200N_all_2026-10-17T14-28-20.json | 1468 +++++++++++++++++ tests/test_issue_368_resync.py | 106 ++ ...t_issue_746_mh200n_gen_off_switch_trace.py | 222 +++ 6 files changed, 1914 insertions(+), 20 deletions(-) create mode 100644 tests/fixtures/traces/issue_746/README.md create mode 100644 tests/fixtures/traces/issue_746/myhome_trace_MH200N_all_2026-10-17T14-28-20.json create mode 100644 tests/test_issue_746_mh200n_gen_off_switch_trace.py diff --git a/custom_components/myhome/gateway_resync.py b/custom_components/myhome/gateway_resync.py index b439d871e..77309793b 100644 --- a/custom_components/myhome/gateway_resync.py +++ b/custom_components/myhome/gateway_resync.py @@ -19,6 +19,7 @@ RESYNC_DEBOUNCE_S, RESYNC_LEADING_WINDOW_S, area_of_where, + normalize_where, ) from .discovery import Address, parse_unique_id @@ -48,6 +49,8 @@ def __init__( self._recent_ptp: collections.deque[tuple[float, str, str | None]] = ( recent_ptp if recent_ptp is not None else collections.deque() ) + self._ptp_polarities: dict[tuple[float, str], bool | None] = {} + self._resync_area_echoes: dict[str, set[str]] = {} @property def hass(self) -> HomeAssistant: @@ -75,17 +78,28 @@ def recent_ptp(self) -> collections.deque[tuple[float, str, str | None]]: """Return recent point-to-point frame deque.""" return self._recent_ptp + def _prune_recent_ptp(self, now: float) -> None: + """Prune expired point-to-point echo records older than the leading window.""" + while self._recent_ptp and self._recent_ptp[0][0] < now - RESYNC_LEADING_WINDOW_S: + old_ts, old_where, _ = self._recent_ptp.popleft() + self._ptp_polarities.pop((old_ts, old_where), None) + def handle_ptp_echo(self, message: OWNLightingEvent) -> None: """Handle point-to-point lighting message echo cancellation.""" now = float(self._time.monotonic()) - while self._recent_ptp and self._recent_ptp[0][0] < now - RESYNC_LEADING_WINDOW_S: - self._recent_ptp.popleft() - area = area_of_where(message.where) - self._recent_ptp.append((now, str(message.where), area)) + self._prune_recent_ptp(now) + clean_where = normalize_where(str(message.where).split("#", 1)[0]) + area = area_of_where(clean_where) + self._recent_ptp.append((now, clean_where, area)) + self._ptp_polarities[(now, clean_where)] = getattr(message, "is_on", None) if area and area in self._resync_timers: - LOGGER.debug("%s area %s echoed point status, cancelling sweep", self.handler.log_id, area) - self._resync_timers.pop(area)() + echoed = self._resync_area_echoes.setdefault(area, set()) + echoed.add(clean_where) + if self._area_fully_echoed(area, echoed): + LOGGER.debug("%s area %s echoed point status, cancelling sweep", self.handler.log_id, area) + self._resync_timers.pop(area)() + self._resync_area_echoes.pop(area, None) for g in [k for k in self._resync_timers if k.startswith("#")]: self._resync_group_echoes[g] = self._resync_group_echoes.get(g, 0) + 1 if self._resync_group_echoes[g] >= 2: @@ -93,12 +107,12 @@ def handle_ptp_echo(self, message: OWNLightingEvent) -> None: self._resync_timers.pop(g)() self._resync_group_echoes.pop(g, None) - def known_light_areas(self) -> list[str]: - """Return sorted list of configured light/switch areas.""" - areas: set[str] = set() + def _known_area_map(self) -> dict[str, set[str]]: + """Return mapping of area code to registered light/switch WHERE addresses.""" + area_map: dict[str, set[str]] = {} config_entry = getattr(self.handler, "config_entry", None) if not config_entry or not hasattr(config_entry, "entry_id") or not isinstance(config_entry.entry_id, str): - return [] + return area_map registry = er.async_get(self.hass) entries = er.async_entries_for_config_entry(registry, config_entry.entry_id) @@ -108,13 +122,39 @@ def known_light_areas(self) -> list[str]: if not key: continue address = Address.from_device_id(key) - area = area_of_where(address.where) + clean_where = normalize_where(address.clean_where) + area = area_of_where(clean_where) if area: - areas.add(area) - return sorted(list(areas)) + area_map.setdefault(area, set()).add(clean_where) + return area_map + + def known_light_areas(self) -> list[str]: + """Return sorted list of configured light/switch areas.""" + return sorted(self._known_area_map().keys()) _known_light_areas = known_light_areas + def known_area_entities(self, area: str) -> set[str]: + """Return configured light/switch WHERE addresses in a given area.""" + return self._known_area_map().get(area, set()) + + def _area_fully_echoed(self, area: str, echoed_wheres: set[str]) -> bool: + """Return True if all known entities in area have emitted point-to-point echoes. + + When the entity registry has one or more configured entities for ``area``, + every registered entity in that area must have echoed before the area + resync query (`*#1*##`) can be skipped or cancelled. When no + entities are registered for ``area`` (e.g. unit tests mocking + ``_known_light_areas`` without populating the entity registry), any + echo in ``area`` is treated as sufficient. + """ + if not echoed_wheres: + return False + known = self.known_area_entities(area) + if known: + return known.issubset(echoed_wheres) + return True + def schedule_resync(self, message: Any) -> None: """Schedule a debounced state resynchronization sweep.""" if not getattr(self.handler, "broadcast_resync", True): @@ -122,14 +162,29 @@ def schedule_resync(self, message: Any) -> None: dim = getattr(message, "dimension", None) is_dimension = isinstance(dim, int) and not isinstance(dim, bool) - if getattr(message, "is_on", None) is None or is_dimension: + msg_is_on = getattr(message, "is_on", None) + if msg_is_on is None or is_dimension: return now = float(self._time.monotonic()) - while self._recent_ptp and self._recent_ptp[0][0] < now - RESYNC_LEADING_WINDOW_S: - self._recent_ptp.popleft() + self._prune_recent_ptp(now) + + def _leading_echoes_for_area(target_area: str) -> set[str]: + has_known = bool(self.known_area_entities(target_area)) + latest_pol: dict[str, bool | None] = {} + for t, w, entry_a in self._recent_ptp: + if entry_a != target_area: + continue + clean_w = normalize_where(str(w).split("#", 1)[0]) + latest_pol[clean_w] = self._ptp_polarities.get((t, str(w))) + return { + clean_w + for clean_w, entry_pol in latest_pol.items() + if not has_known or entry_pol is None or msg_is_on is None or entry_pol == msg_is_on + } targets: list[str] = [] + area_seed_echoes: dict[str, set[str]] = {} if getattr(message, "is_group", False): recent_count = sum(1 for t, _, _ in self._recent_ptp if t >= now - RESYNC_LEADING_WINDOW_S) if recent_count >= 2: @@ -143,9 +198,11 @@ def schedule_resync(self, message: Any) -> None: targets.append(f"#{message.group}") elif getattr(message, "is_area", False): raw_where = str(message.where) - if any(a == raw_where for _, _, a in self._recent_ptp): + leading_echoes = _leading_echoes_for_area(raw_where) + if self._area_fully_echoed(raw_where, leading_echoes): LOGGER.debug("%s area %s had leading member echoes, skipping sweep", self.handler.log_id, raw_where) return + area_seed_echoes[raw_where] = leading_echoes targets.append(raw_where) elif getattr(message, "is_general", False): known_areas = ( @@ -154,9 +211,11 @@ def schedule_resync(self, message: Any) -> None: else self.known_light_areas() ) for a in known_areas: - if any(entry_a == a for _, _, entry_a in self._recent_ptp): + leading_echoes = _leading_echoes_for_area(a) + if self._area_fully_echoed(a, leading_echoes): LOGGER.debug("%s general sweep skipping area %s (had leading echoes)", self.handler.log_id, a) continue + area_seed_echoes[a] = leading_echoes targets.append(f"{a}") from . import gateway as gw_module @@ -168,6 +227,8 @@ def schedule_resync(self, message: Any) -> None: self._resync_timers.pop(where)() if where.startswith("#"): self._resync_group_echoes[where] = 0 + else: + self._resync_area_echoes[where] = set(area_seed_echoes.get(where, set())) @callback def _cb(_now_cb: Any, w: str = where) -> None: @@ -181,6 +242,7 @@ async def execute_resync(self, where: str) -> None: """Execute the status query for the given area or group.""" self._resync_timers.pop(where, None) self._resync_group_echoes.pop(where, None) + self._resync_area_echoes.pop(where, None) if getattr(self.handler, "_terminate_listener", False): return @@ -195,4 +257,6 @@ def cancel_all(self) -> None: t() self._resync_timers.clear() self._resync_group_echoes.clear() + self._resync_area_echoes.clear() self._recent_ptp.clear() + self._ptp_polarities.clear() diff --git a/docs/configuration/runtime_behaviour.md b/docs/configuration/runtime_behaviour.md index 255ffd763..f2f63e0c9 100644 --- a/docs/configuration/runtime_behaviour.md +++ b/docs/configuration/runtime_behaviour.md @@ -60,8 +60,11 @@ Instead, the integration debounces across a bidirectional window: 3. If member point-to-point status frames arrive before the timer fires (**trailing echoes**), the sweep is cancelled: - - For an **area**, only point-to-point echoes in that matching area cancel its timer; - unrelated areas stay armed. + - For an **area**, point-to-point echoes in that matching area only skip or cancel its + timer once *all* known `light`/`switch` entities registered in that area have echoed + with matching polarity (preventing partial CEN/Scenario bursts such as `MH200N` + scenarios from starving non-echoing actuators in the same area); unrelated areas stay + armed. - For a **group**, receiving multiple member echoes ($\ge 2$) in the window cancels its timer, protecting against unrelated bus frames. 4. Otherwise, one status request is sent: diff --git a/tests/fixtures/traces/issue_746/README.md b/tests/fixtures/traces/issue_746/README.md new file mode 100644 index 000000000..1bbe3b154 --- /dev/null +++ b/tests/fixtures/traces/issue_746/README.md @@ -0,0 +1,31 @@ +# #746 MH200N General OFF Scenario vs Area 00 Switch Re-Sync + +Bus frames and command exchanges captured on a physical **BTicino MH200N** gateway (`00:03:50`, Home Assistant `2026.9.4`, integration `2.0.0b15`, `OWNd 2.0.0b10`) during manual wall-switch / Home Assistant toggling of a `switch` actuator at `WHERE="04"` (Area `0`, PL `4`) followed by a physical CEN wall button (`*15*01*81##`) triggering MH200N Scenario 6 (`*17*1*6##` -> `*1*0*0##` General OFF). + +Contributed by **@wave68runner** on [#746](https://github.com/OpenWebNet-HA/MyHOME/issues/746). Thank you! + +| File | Content | +|---|---| +| `myhome_trace_MH200N_all_2026-10-17T14-28-20.json` | 117 frames across `WHO=1`, `WHO=13`, `WHO=15`, `WHO=16`, `WHO=17`, `WHO=22`, and `WHO=1001` | + +## What it shows + +### 1. Area `0` (`04`) switch toggling from wall switch and Home Assistant +1. **Wall switch ON (`14:27:48.446`–`14:27:48.483`)**: Physical button press emits translation frame `*1*1000#1*04##` followed 37 ms later by actuator status `*1*1*04##`. +2. **Home Assistant OFF / ON (`14:27:56.959`–`14:27:58.603`)**: Home Assistant sends `tx *1*0*04##` (echoed as `rx *1*0*04##`) and `tx *1*1*04##` (echoed as `rx *1*1*04##`). +3. **Wall switch OFF / ON (`14:28:00.796`–`14:28:02.732`)**: Physical button toggles `04` OFF (`*1*1000#0*04##` -> `*1*0*04##`) and back ON (`*1*1000#1*04##` -> `*1*1*04##`), leaving `04` in state `on`. + +### 2. MH200N Scenario 6 (`*1*0*0##` General OFF + partial Area `00` point commands) +At `14:28:06.136`, pressing CEN button `*15*01*81##` starts MH200N Scenario 6 (`*17*1*6##`): +- `14:28:06.502` (`+0 ms`): `rx *1*0*0##` (**General OFF**). Standard SCS relays such as `04` turn off physically without emitting an individual point-to-point status frame. +- `14:28:06.538` (`+35 ms`): `rx *1*0*01##` +- `14:28:06.603` (`+101 ms`): `rx *1*0#1*0##` +- `14:28:06.636` (`+134 ms`): `rx *1*1*03##` +- `14:28:06.704` (`+201 ms`): `rx *17*2*6##` (Scenario 6 end) +- `14:28:06.875` (`+372 ms`): `rx *1*0*01##` +- `14:28:06.936` (`+434 ms`): `rx *1*0*09##` +- `14:28:06.994` (`+492 ms`): `rx *1*1*0011##` + +### 3. Additional authentic MH200N frames in capture +- **WHO=16 / WHO=22 Sound System**: Scenario 6 also turns off amplifier zones `*16*13*101##`, `*16*3*101##`..`*16*3*181##` accompanied by `WHO=22` diffusion frames (`*#22*2#1*12*0*10##`, `*22*1#4#1*2#1##`, `*22*21#4#1*5#2#1##`, `*22*2#4#1*5#2#1##`, `*#22*5#1#1*8*1*8##`). +- **WHO=1001 Diagnostics**: Interleaved actuator diagnostic bitmasks (`*#1001*42*11*...##`, `*#1001*72*11*...##`) during the area status sweeps. diff --git a/tests/fixtures/traces/issue_746/myhome_trace_MH200N_all_2026-10-17T14-28-20.json b/tests/fixtures/traces/issue_746/myhome_trace_MH200N_all_2026-10-17T14-28-20.json new file mode 100644 index 000000000..df5ba5615 --- /dev/null +++ b/tests/fixtures/traces/issue_746/myhome_trace_MH200N_all_2026-10-17T14-28-20.json @@ -0,0 +1,1468 @@ +{ + "capture": { + "kind": "trace", + "started_at": null, + "filters": { + "who": null, + "where": null, + "direction": null + }, + "window": { + "first": "2026-10-17T14:27:48.446Z", + "last": "2026-10-17T14:28:16.586Z", + "frames": 117, + "buffer_frames": 117, + "buffer_depth": 200, + "truncated": false + } + }, + "environment": { + "home_assistant_version": "2026.9.4", + "integration_version": "2.0.0b15", + "ownd_version": "2.0.0b10", + "exported_at": "2026-10-17T14:28:20.577Z" + }, + "gateway": { + "model": "MH200N", + "manufacturer": "BTicino S.p.A.", + "firmware": "1.0", + "mac_prefix": "00:03:50", + "connection_type": "tcp", + "queue_pacing": 0.15, + "is_connected": true, + "identification": { + "model": "MH200N", + "source": "ssdp", + "configured_model": "MH200N", + "ssdp_model": "MH200N", + "who13_code": "44", + "who13_model": "MH200N", + "who13_model_official": null, + "who13_model_observed": "MH200N", + "who13_firmware": null, + "who13_kernel": null, + "who13_distribution": null, + "who1013_code": null, + "who1013_model": null, + "who1013_other_names": [], + "who1013_n_conf": null, + "who1013_brand": null, + "who1013_line": null, + "profile": "MH200NProfile", + "conflict": null + } + }, + "telemetry": { + "total_rx": 108, + "total_tx": 9, + "captured_in_buffer": 117, + "buffer_depth": 200, + "queue_depth": 0 + }, + "frames": [ + { + "timestamp": 1792247268.446547, + "iso_time": "2026-10-17T14:27:48.446547+00:00", + "direction": "rx", + "raw": "*1*1000#1*04##", + "who": "1", + "what": "1000", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247268.483098, + "iso_time": "2026-10-17T14:27:48.483098+00:00", + "direction": "rx", + "raw": "*1*1*04##", + "who": "1", + "what": "1", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247270.043471, + "iso_time": "2026-10-17T14:27:50.043471+00:00", + "direction": "rx", + "raw": "*1*39*31##", + "who": "1", + "what": "39", + "where": "31", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247270.068022, + "iso_time": "2026-10-17T14:27:50.068022+00:00", + "direction": "rx", + "raw": "*1*1000#0*31##", + "who": "1", + "what": "1000", + "where": "31", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247270.095868, + "iso_time": "2026-10-17T14:27:50.095868+00:00", + "direction": "rx", + "raw": "*1*0*31##", + "who": "1", + "what": "0", + "where": "31", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247276.959313, + "iso_time": "2026-10-17T14:27:56.959313+00:00", + "direction": "tx", + "raw": "*1*0*04##", + "who": "1", + "what": "0", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247277.073707, + "iso_time": "2026-10-17T14:27:57.073707+00:00", + "direction": "rx", + "raw": "*1*0*04##", + "who": "1", + "what": "0", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247278.512331, + "iso_time": "2026-10-17T14:27:58.512331+00:00", + "direction": "tx", + "raw": "*1*1*04##", + "who": "1", + "what": "1", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247278.603135, + "iso_time": "2026-10-17T14:27:58.603135+00:00", + "direction": "rx", + "raw": "*1*1*04##", + "who": "1", + "what": "1", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247280.796776, + "iso_time": "2026-10-17T14:28:00.796776+00:00", + "direction": "rx", + "raw": "*1*1000#0*04##", + "who": "1", + "what": "1000", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247280.842541, + "iso_time": "2026-10-17T14:28:00.842541+00:00", + "direction": "rx", + "raw": "*1*0*04##", + "who": "1", + "what": "0", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247282.696732, + "iso_time": "2026-10-17T14:28:02.696732+00:00", + "direction": "rx", + "raw": "*1*1000#1*04##", + "who": "1", + "what": "1000", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247282.732931, + "iso_time": "2026-10-17T14:28:02.732931+00:00", + "direction": "rx", + "raw": "*1*1*04##", + "who": "1", + "what": "1", + "where": "04", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247283.297912, + "iso_time": "2026-10-17T14:28:03.297912+00:00", + "direction": "rx", + "raw": "*#13**22*16*28*02*001*06*17*10*2026##", + "who": "13", + "what": null, + "where": "", + "dimension": "22", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247284.767097, + "iso_time": "2026-10-17T14:28:04.767097+00:00", + "direction": "rx", + "raw": "*1*34*31##", + "who": "1", + "what": "34", + "where": "31", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.136971, + "iso_time": "2026-10-17T14:28:06.136971+00:00", + "direction": "rx", + "raw": "*15*01*81##", + "who": "15", + "what": "1", + "where": "81", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.142341, + "iso_time": "2026-10-17T14:28:06.142341+00:00", + "direction": "rx", + "raw": "*17*1*6##", + "who": "17", + "what": "1", + "where": "6", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.296554, + "iso_time": "2026-10-17T14:28:06.296554+00:00", + "direction": "rx", + "raw": "*15*01#1*81##", + "who": "15", + "what": "1", + "where": "81", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.502688, + "iso_time": "2026-10-17T14:28:06.502688+00:00", + "direction": "rx", + "raw": "*1*0*0##", + "who": "1", + "what": "0", + "where": "0", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.538024, + "iso_time": "2026-10-17T14:28:06.538024+00:00", + "direction": "rx", + "raw": "*1*0*01##", + "who": "1", + "what": "0", + "where": "01", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.603939, + "iso_time": "2026-10-17T14:28:06.603939+00:00", + "direction": "rx", + "raw": "*1*0#1*0##", + "who": "1", + "what": "0", + "where": "0", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.636613, + "iso_time": "2026-10-17T14:28:06.636613+00:00", + "direction": "rx", + "raw": "*1*1*03##", + "who": "1", + "what": "1", + "where": "03", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.704137, + "iso_time": "2026-10-17T14:28:06.704137+00:00", + "direction": "rx", + "raw": "*17*2*6##", + "who": "17", + "what": "2", + "where": "6", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.773559, + "iso_time": "2026-10-17T14:28:06.773559+00:00", + "direction": "rx", + "raw": "*16*13*101##", + "who": "16", + "what": "13", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.774859, + "iso_time": "2026-10-17T14:28:06.774859+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*0*10##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.875174, + "iso_time": "2026-10-17T14:28:06.875174+00:00", + "direction": "rx", + "raw": "*1*0*01##", + "who": "1", + "what": "0", + "where": "01", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.936671, + "iso_time": "2026-10-17T14:28:06.936671+00:00", + "direction": "rx", + "raw": "*1*0*09##", + "who": "1", + "what": "0", + "where": "09", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247286.994616, + "iso_time": "2026-10-17T14:28:06.994616+00:00", + "direction": "rx", + "raw": "*1*1*0011##", + "who": "1", + "what": "1", + "where": "0011", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.105476, + "iso_time": "2026-10-17T14:28:07.105476+00:00", + "direction": "tx", + "raw": "*#1*1##", + "who": "1", + "what": null, + "where": "1", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.237056, + "iso_time": "2026-10-17T14:28:07.237056+00:00", + "direction": "rx", + "raw": "*1*0*11##", + "who": "1", + "what": "0", + "where": "11", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.296687, + "iso_time": "2026-10-17T14:28:07.296687+00:00", + "direction": "rx", + "raw": "*1*0*13##", + "who": "1", + "what": "0", + "where": "13", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.300475, + "iso_time": "2026-10-17T14:28:07.300475+00:00", + "direction": "rx", + "raw": "*1*0*15##", + "who": "1", + "what": "0", + "where": "15", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.358311, + "iso_time": "2026-10-17T14:28:07.358311+00:00", + "direction": "rx", + "raw": "*1*0*14##", + "who": "1", + "what": "0", + "where": "14", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.362857, + "iso_time": "2026-10-17T14:28:07.362857+00:00", + "direction": "rx", + "raw": "*22*1#4#1*2#1##", + "who": "22", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.430643, + "iso_time": "2026-10-17T14:28:07.430643+00:00", + "direction": "rx", + "raw": "*22*21#4#1*5#2#1##", + "who": "22", + "what": "21", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.500447, + "iso_time": "2026-10-17T14:28:07.500447+00:00", + "direction": "rx", + "raw": "*1*0*12##", + "who": "1", + "what": "0", + "where": "12", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.541202, + "iso_time": "2026-10-17T14:28:07.541202+00:00", + "direction": "rx", + "raw": "*1*0*18##", + "who": "1", + "what": "0", + "where": "18", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.755602, + "iso_time": "2026-10-17T14:28:07.755602+00:00", + "direction": "rx", + "raw": "*16*3*111##", + "who": "16", + "what": "3", + "where": "111", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.759958, + "iso_time": "2026-10-17T14:28:07.759958+00:00", + "direction": "rx", + "raw": "*22*2#4#1*5#2#1##", + "who": "22", + "what": "2", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.804774, + "iso_time": "2026-10-17T14:28:07.804774+00:00", + "direction": "rx", + "raw": "*16*3*101##", + "who": "16", + "what": "3", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247287.809681, + "iso_time": "2026-10-17T14:28:07.809681+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*1*4##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.379873, + "iso_time": "2026-10-17T14:28:08.379873+00:00", + "direction": "rx", + "raw": "*22*1#4#2*2#1##", + "who": "22", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.432492, + "iso_time": "2026-10-17T14:28:08.432492+00:00", + "direction": "rx", + "raw": "*22*21#4#2*5#2#1##", + "who": "22", + "what": "21", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.678722, + "iso_time": "2026-10-17T14:28:08.678722+00:00", + "direction": "tx", + "raw": "*#1*2##", + "who": "1", + "what": null, + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.802856, + "iso_time": "2026-10-17T14:28:08.802856+00:00", + "direction": "rx", + "raw": "*1*0*22##", + "who": "1", + "what": "0", + "where": "22", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.814618, + "iso_time": "2026-10-17T14:28:08.814618+00:00", + "direction": "rx", + "raw": "*16*3*121##", + "who": "16", + "what": "3", + "where": "121", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.819485, + "iso_time": "2026-10-17T14:28:08.819485+00:00", + "direction": "rx", + "raw": "*22*2#4#2*5#2#1##", + "who": "22", + "what": "2", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.88411, + "iso_time": "2026-10-17T14:28:08.884110+00:00", + "direction": "rx", + "raw": "*1*0*24##", + "who": "1", + "what": "0", + "where": "24", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.888156, + "iso_time": "2026-10-17T14:28:08.888156+00:00", + "direction": "rx", + "raw": "*16*3*101##", + "who": "16", + "what": "3", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.892695, + "iso_time": "2026-10-17T14:28:08.892695+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*1*4##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.929409, + "iso_time": "2026-10-17T14:28:08.929409+00:00", + "direction": "rx", + "raw": "*1*0*23##", + "who": "1", + "what": "0", + "where": "23", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247288.961272, + "iso_time": "2026-10-17T14:28:08.961272+00:00", + "direction": "rx", + "raw": "*1*0*26##", + "who": "1", + "what": "0", + "where": "26", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247289.419327, + "iso_time": "2026-10-17T14:28:09.419327+00:00", + "direction": "rx", + "raw": "*22*1#4#3*2#1##", + "who": "22", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247289.472006, + "iso_time": "2026-10-17T14:28:09.472006+00:00", + "direction": "rx", + "raw": "*22*21#4#3*5#2#1##", + "who": "22", + "what": "21", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247289.80229, + "iso_time": "2026-10-17T14:28:09.802290+00:00", + "direction": "rx", + "raw": "*16*3*131##", + "who": "16", + "what": "3", + "where": "131", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247289.806434, + "iso_time": "2026-10-17T14:28:09.806434+00:00", + "direction": "rx", + "raw": "*22*2#4#3*5#2#1##", + "who": "22", + "what": "2", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247289.843107, + "iso_time": "2026-10-17T14:28:09.843107+00:00", + "direction": "rx", + "raw": "*16*3*101##", + "who": "16", + "what": "3", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247289.847116, + "iso_time": "2026-10-17T14:28:09.847116+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*1*4##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.118721, + "iso_time": "2026-10-17T14:28:10.118721+00:00", + "direction": "tx", + "raw": "*#1*3##", + "who": "1", + "what": null, + "where": "3", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.209521, + "iso_time": "2026-10-17T14:28:10.209521+00:00", + "direction": "rx", + "raw": "*1*0*31##", + "who": "1", + "what": "0", + "where": "31", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.255186, + "iso_time": "2026-10-17T14:28:10.255186+00:00", + "direction": "rx", + "raw": "*1*0*34##", + "who": "1", + "what": "0", + "where": "34", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.295659, + "iso_time": "2026-10-17T14:28:10.295659+00:00", + "direction": "rx", + "raw": "*1*0*37##", + "who": "1", + "what": "0", + "where": "37", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.335134, + "iso_time": "2026-10-17T14:28:10.335134+00:00", + "direction": "rx", + "raw": "*1*0*36##", + "who": "1", + "what": "0", + "where": "36", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.446775, + "iso_time": "2026-10-17T14:28:10.446775+00:00", + "direction": "rx", + "raw": "*22*1#4#4*2#1##", + "who": "22", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.485473, + "iso_time": "2026-10-17T14:28:10.485473+00:00", + "direction": "rx", + "raw": "*22*21#4#4*5#2#1##", + "who": "22", + "what": "21", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.843748, + "iso_time": "2026-10-17T14:28:10.843748+00:00", + "direction": "rx", + "raw": "*16*3*141##", + "who": "16", + "what": "3", + "where": "141", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.855242, + "iso_time": "2026-10-17T14:28:10.855242+00:00", + "direction": "rx", + "raw": "*22*2#4#4*5#2#1##", + "who": "22", + "what": "2", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.85947, + "iso_time": "2026-10-17T14:28:10.859470+00:00", + "direction": "rx", + "raw": "*16*3*101##", + "who": "16", + "what": "3", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247290.864037, + "iso_time": "2026-10-17T14:28:10.864037+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*1*4##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247291.436005, + "iso_time": "2026-10-17T14:28:11.436005+00:00", + "direction": "rx", + "raw": "*22*1#4#5*2#1##", + "who": "22", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247291.478495, + "iso_time": "2026-10-17T14:28:11.478495+00:00", + "direction": "tx", + "raw": "*#1*4##", + "who": "1", + "what": null, + "where": "4", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247291.483079, + "iso_time": "2026-10-17T14:28:11.483079+00:00", + "direction": "rx", + "raw": "*22*21#4#5*5#2#1##", + "who": "22", + "what": "21", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247291.58657, + "iso_time": "2026-10-17T14:28:11.586570+00:00", + "direction": "rx", + "raw": "*1*0*41##", + "who": "1", + "what": "0", + "where": "41", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247291.613017, + "iso_time": "2026-10-17T14:28:11.613017+00:00", + "direction": "rx", + "raw": "*1*0*43##", + "who": "1", + "what": "0", + "where": "43", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247291.812448, + "iso_time": "2026-10-17T14:28:11.812448+00:00", + "direction": "rx", + "raw": "*1*0*42##", + "who": "1", + "what": "0", + "where": "42", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247291.980835, + "iso_time": "2026-10-17T14:28:11.980835+00:00", + "direction": "rx", + "raw": "*#1001*42*11*111011111111111101101101##", + "who": "1001", + "what": null, + "where": "42", + "dimension": "11", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.14351, + "iso_time": "2026-10-17T14:28:12.143510+00:00", + "direction": "rx", + "raw": "*22*1#4#6*2#1##", + "who": "22", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.163572, + "iso_time": "2026-10-17T14:28:12.163572+00:00", + "direction": "rx", + "raw": "*22*2#4#5*5#2#1##", + "who": "22", + "what": "2", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.17537, + "iso_time": "2026-10-17T14:28:12.175370+00:00", + "direction": "rx", + "raw": "*16*3*151##", + "who": "16", + "what": "3", + "where": "151", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.217776, + "iso_time": "2026-10-17T14:28:12.217776+00:00", + "direction": "rx", + "raw": "*16*3*101##", + "who": "16", + "what": "3", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.220724, + "iso_time": "2026-10-17T14:28:12.220724+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*1*4##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.253579, + "iso_time": "2026-10-17T14:28:12.253579+00:00", + "direction": "rx", + "raw": "*22*21#4#8*5#2#1##", + "who": "22", + "what": "21", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.514901, + "iso_time": "2026-10-17T14:28:12.514901+00:00", + "direction": "rx", + "raw": "*16*3*161##", + "who": "16", + "what": "3", + "where": "161", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.540147, + "iso_time": "2026-10-17T14:28:12.540147+00:00", + "direction": "rx", + "raw": "*22*2#4#6*5#2#1##", + "who": "22", + "what": "2", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.544406, + "iso_time": "2026-10-17T14:28:12.544406+00:00", + "direction": "rx", + "raw": "*16*3*101##", + "who": "16", + "what": "3", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.563988, + "iso_time": "2026-10-17T14:28:12.563988+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*1*4##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247292.967253, + "iso_time": "2026-10-17T14:28:12.967253+00:00", + "direction": "tx", + "raw": "*#1*5##", + "who": "1", + "what": null, + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.068218, + "iso_time": "2026-10-17T14:28:13.068218+00:00", + "direction": "rx", + "raw": "*1*0*53##", + "who": "1", + "what": "0", + "where": "53", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.114131, + "iso_time": "2026-10-17T14:28:13.114131+00:00", + "direction": "rx", + "raw": "*1*0*0512##", + "who": "1", + "what": "0", + "where": "0512", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.152934, + "iso_time": "2026-10-17T14:28:13.152934+00:00", + "direction": "rx", + "raw": "*1*0*56##", + "who": "1", + "what": "0", + "where": "56", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.183327, + "iso_time": "2026-10-17T14:28:13.183327+00:00", + "direction": "rx", + "raw": "*22*1#4#7*2#1##", + "who": "22", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.241091, + "iso_time": "2026-10-17T14:28:13.241091+00:00", + "direction": "rx", + "raw": "*#1*55*1*100*1##", + "who": "1", + "what": null, + "where": "55", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.286813, + "iso_time": "2026-10-17T14:28:13.286813+00:00", + "direction": "rx", + "raw": "*22*21#4#7*5#2#1##", + "who": "22", + "what": "21", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.40178, + "iso_time": "2026-10-17T14:28:13.401780+00:00", + "direction": "rx", + "raw": "*1*0*0510##", + "who": "1", + "what": "0", + "where": "0510", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.450929, + "iso_time": "2026-10-17T14:28:13.450929+00:00", + "direction": "rx", + "raw": "*1*0*52##", + "who": "1", + "what": "0", + "where": "52", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.499348, + "iso_time": "2026-10-17T14:28:13.499348+00:00", + "direction": "rx", + "raw": "*1*0*57##", + "who": "1", + "what": "0", + "where": "57", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.587194, + "iso_time": "2026-10-17T14:28:13.587194+00:00", + "direction": "rx", + "raw": "*1*0*0514##", + "who": "1", + "what": "0", + "where": "0514", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.590788, + "iso_time": "2026-10-17T14:28:13.590788+00:00", + "direction": "rx", + "raw": "*16*3*171##", + "who": "16", + "what": "3", + "where": "171", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.59466, + "iso_time": "2026-10-17T14:28:13.594660+00:00", + "direction": "rx", + "raw": "*22*2#4#7*5#2#1##", + "who": "22", + "what": "2", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.626423, + "iso_time": "2026-10-17T14:28:13.626423+00:00", + "direction": "rx", + "raw": "*16*3*101##", + "who": "16", + "what": "3", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.63027, + "iso_time": "2026-10-17T14:28:13.630270+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*1*4##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247293.739823, + "iso_time": "2026-10-17T14:28:13.739823+00:00", + "direction": "rx", + "raw": "*1*0*0513##", + "who": "1", + "what": "0", + "where": "0513", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.159435, + "iso_time": "2026-10-17T14:28:14.159435+00:00", + "direction": "rx", + "raw": "*22*1#4#8*2#1##", + "who": "22", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.212943, + "iso_time": "2026-10-17T14:28:14.212943+00:00", + "direction": "rx", + "raw": "*#22*5#1#1*8*1*8##", + "who": "22", + "what": null, + "where": "5", + "dimension": "8", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.25479, + "iso_time": "2026-10-17T14:28:14.254790+00:00", + "direction": "rx", + "raw": "*22*21#4#8*5#2#1##", + "who": "22", + "what": "21", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.555046, + "iso_time": "2026-10-17T14:28:14.555046+00:00", + "direction": "rx", + "raw": "*16*3*181##", + "who": "16", + "what": "3", + "where": "181", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.586455, + "iso_time": "2026-10-17T14:28:14.586455+00:00", + "direction": "rx", + "raw": "*22*2#4#8*5#2#1##", + "who": "22", + "what": "2", + "where": "5", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.590672, + "iso_time": "2026-10-17T14:28:14.590672+00:00", + "direction": "rx", + "raw": "*16*3*101##", + "who": "16", + "what": "3", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.595403, + "iso_time": "2026-10-17T14:28:14.595403+00:00", + "direction": "rx", + "raw": "*#22*2#1*12*1*4##", + "who": "22", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.887636, + "iso_time": "2026-10-17T14:28:14.887636+00:00", + "direction": "tx", + "raw": "*#1*6##", + "who": "1", + "what": null, + "where": "6", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247294.986291, + "iso_time": "2026-10-17T14:28:14.986291+00:00", + "direction": "rx", + "raw": "*1*0*64##", + "who": "1", + "what": "0", + "where": "64", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247295.028817, + "iso_time": "2026-10-17T14:28:15.028817+00:00", + "direction": "rx", + "raw": "*1*0*61##", + "who": "1", + "what": "0", + "where": "61", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247295.067823, + "iso_time": "2026-10-17T14:28:15.067823+00:00", + "direction": "rx", + "raw": "*1*0*63##", + "who": "1", + "what": "0", + "where": "63", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247296.208706, + "iso_time": "2026-10-17T14:28:16.208706+00:00", + "direction": "tx", + "raw": "*#1*7##", + "who": "1", + "what": null, + "where": "7", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247296.301954, + "iso_time": "2026-10-17T14:28:16.301954+00:00", + "direction": "rx", + "raw": "*1*0*72##", + "who": "1", + "what": "0", + "where": "72", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247296.511531, + "iso_time": "2026-10-17T14:28:16.511531+00:00", + "direction": "rx", + "raw": "*#1001*72*11*111011111111111101101101##", + "who": "1001", + "what": null, + "where": "72", + "dimension": "11", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1792247296.58696, + "iso_time": "2026-10-17T14:28:16.586960+00:00", + "direction": "rx", + "raw": "*1*0*71##", + "who": "1", + "what": "0", + "where": "71", + "dimension": null, + "is_ack": false, + "is_nack": false + } + ] +} diff --git a/tests/test_issue_368_resync.py b/tests/test_issue_368_resync.py index 343c73696..2326f1c4e 100644 --- a/tests/test_issue_368_resync.py +++ b/tests/test_issue_368_resync.py @@ -519,3 +519,109 @@ def test_lighting_resync_manager_schedule_resync_guards( assert not handler._resync_timers +@pytest.mark.asyncio +async def test_partial_area_echoes_do_not_skip_or_cancel_area_sweep( + hass: HomeAssistant, handler: MyHOMEGatewayHandler +) -> None: + """Issue #746: Partial point-to-point echoes in an area must not skip or cancel `*#1*##` until all registered entities in that area have echoed.""" + reg = er.async_get(hass) + mac = handler.mac + entry_id = handler.config_entry.entry_id + for domain, where in ( + ("light", "01"), + ("light", "03"), + ("switch", "04"), + ("light", "09"), + ("light", "0011"), + ): + reg.async_get_or_create( + domain, + DOMAIN, + f"myhome-{mac}-1-{where}", + config_entry=handler.config_entry, + ) + assert handler._resync_manager.known_area_entities("00") == { + "01", + "03", + "04", + "09", + "0011", + } + assert entry_id == handler.config_entry.entry_id + + # Leading echo from 01 before General OFF (*1*0*0##), followed by trailing + # echoes from 03, 09, 0011 — but 04 (switch) never echoes. + await handler._process_message(OWNMessage.parse("*1*0*01##")) + await handler._process_message(OWNMessage.parse("*1*0*0##")) + assert "00" in handler._resync_timers + + await handler._process_message(OWNMessage.parse("*1*0*03##")) + await handler._process_message(OWNMessage.parse("*1*0*09##")) + await handler._process_message(OWNMessage.parse("*1*0*0011##")) + # Area 00 timer must still be armed because 04 has not echoed! + assert "00" in handler._resync_timers + + await _advance(hass) + frames = [str(call[0][0]) for call in handler.send_status_request.call_args_list] + assert frames == ["*#1*00##"] + + # Now test when all registered entities (including 04) echo: sweep is cancelled! + handler.send_status_request.reset_mock() + await handler._process_message(OWNMessage.parse("*1*0*00##")) + assert "00" in handler._resync_timers + for where in ("01", "03", "04", "09", "0011"): + await handler._process_message(OWNMessage.parse(f"*1*0*{where}##")) + assert "00" not in handler._resync_timers + await _advance(hass) + handler.send_status_request.assert_not_called() + + +@pytest.mark.asyncio +async def test_single_registered_entity_in_area_not_cancelled_by_unregistered_sibling_echo( + hass: HomeAssistant, handler: MyHOMEGatewayHandler +) -> None: + """Issue #746 edge case: When only 1 entity (`04`) is registered in Area 00, an echo from unregistered `01` must not cancel `*#1*00##`.""" + reg = er.async_get(hass) + mac = handler.mac + reg.async_get_or_create( + "switch", + DOMAIN, + f"myhome-{mac}-1-04", + config_entry=handler.config_entry, + ) + assert handler._resync_manager.known_area_entities("00") == {"04"} + + await handler._process_message(OWNMessage.parse("*1*0*0##")) + assert "00" in handler._resync_timers + + # Unregistered actuator 01 in Area 00 echoes OFF; 04 has not echoed yet + await handler._process_message(OWNMessage.parse("*1*0*01##")) + assert "00" in handler._resync_timers + + await _advance(hass) + frames = [str(call[0][0]) for call in handler.send_status_request.call_args_list] + assert frames == ["*#1*00##"] + + +@pytest.mark.asyncio +async def test_opposite_polarity_leading_echo_does_not_skip_area_sweep( + hass: HomeAssistant, handler: MyHOMEGatewayHandler +) -> None: + """Turning an entity ON right before General OFF must not count as a leading OFF echo.""" + reg = er.async_get(hass) + mac = handler.mac + reg.async_get_or_create( + "switch", + DOMAIN, + f"myhome-{mac}-1-04", + config_entry=handler.config_entry, + ) + + # 04 turns ON within the 1.5s leading window right before General OFF + await handler._process_message(OWNMessage.parse("*1*1*04##")) + await handler._process_message(OWNMessage.parse("*1*0*0##")) + assert "00" in handler._resync_timers + + await _advance(hass) + frames = [str(call[0][0]) for call in handler.send_status_request.call_args_list] + assert frames == ["*#1*00##"] diff --git a/tests/test_issue_746_mh200n_gen_off_switch_trace.py b/tests/test_issue_746_mh200n_gen_off_switch_trace.py new file mode 100644 index 000000000..82078e97e --- /dev/null +++ b/tests/test_issue_746_mh200n_gen_off_switch_trace.py @@ -0,0 +1,222 @@ +"""Replay and verification tests for #746: BTicino MH200N General OFF scenario and switch 04 resync. + +Fixture: tests/fixtures/traces/issue_746/myhome_trace_MH200N_all_2026-10-17T14-28-20.json +Contributed by @wave68runner on https://github.com/OpenWebNet-HA/MyHOME/issues/746 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from OWNd.message import OWNCommand, OWNMessage +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.myhome.const import CONF_BROADCAST_RESYNC, DOMAIN +from custom_components.myhome.data import MyHOMERuntimeData +from custom_components.myhome.gateway import MyHOMEGatewayHandler +from custom_components.myhome.router import FrameRouter +from custom_components.myhome.switch import async_setup_entry as async_setup_switch + +TRACE_PATH = ( + Path(__file__).resolve().parent + / "fixtures" + / "traces" + / "issue_746" + / "myhome_trace_MH200N_all_2026-10-17T14-28-20.json" +) +MAC = "00:03:50:aa:bb:cc" + + +def _load_trace() -> dict[str, Any]: + return json.loads(TRACE_PATH.read_text(encoding="utf-8")) + + +def test_issue_746_trace_fixture_schema_and_metadata() -> None: + """Verify the Issue #746 MH200N trace fixture metadata and frame parsing.""" + assert TRACE_PATH.is_file() + data = _load_trace() + + assert data["gateway"]["model"] == "MH200N" + assert data["gateway"]["identification"]["source"] == "ssdp" + assert data["gateway"]["identification"]["who13_code"] == "44" + assert data["gateway"]["identification"]["profile"] == "MH200NProfile" + assert data["environment"]["integration_version"] == "2.0.0b15" + assert data["capture"]["window"]["frames"] == 117 + assert data["telemetry"]["total_rx"] == 108 + assert data["telemetry"]["total_tx"] == 9 + + frames = data["frames"] + assert len(frames) == 117 + + whos_seen: set[str] = set() + for entry in frames: + raw = entry["raw"] + direction = entry["direction"] + parsed = OWNCommand.parse(raw) if direction == "tx" else OWNMessage.parse(raw) + assert parsed is not None, f"Failed to parse {direction} frame: {raw}" + whos_seen.add(str(entry["who"])) + + assert whos_seen == {"1", "13", "15", "16", "17", "22", "1001"} + + +@pytest.mark.asyncio +async def test_issue_746_mh200n_trace_replay_sweeps_area_00_and_updates_switch_04( + hass: HomeAssistant, +) -> None: + """Replaying the Issue #746 MH200N trace must poll `*#1*00##` and turn switch `04` OFF.""" + trace = _load_trace() + frames = trace["frames"] + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.40", + CONF_PORT: 20000, + CONF_PASSWORD: "12345", + CONF_MAC: MAC, + CONF_NAME: "MH200N", + }, + options={CONF_BROADCAST_RESYNC: True}, + ) + entry.add_to_hass(hass) + + scheduled_cbs: list[Any] = [] + + def _fake_call_later(_hass: HomeAssistant, _delay: float, action: Any) -> Any: + cancelled = False + + def _cancel() -> None: + nonlocal cancelled + cancelled = True + + def _fire() -> None: + if not cancelled: + action(None) + + scheduled_cbs.append(_fire) + return _cancel + + with ( + patch("custom_components.myhome.gateway.OWNGateway"), + patch("custom_components.myhome.gateway.async_call_later", side_effect=_fake_call_later), + ): + handler = MyHOMEGatewayHandler( + hass, entry, generate_events=True, broadcast_resync=True + ) + handler.gateway.serial = MAC + handler.send = AsyncMock() + handler.send_status_request = AsyncMock() + + router = FrameRouter() + entry.runtime_data = MyHOMERuntimeData( + gateway=handler, + router=router, + platforms={ + "switch": { + "socket_04": { + "where": "04", + "name": "Wall Switch 04", + "class": "switch", + }, + }, + "light": {}, + }, + ) + + added_switches = [] + await async_setup_switch( + hass, entry, lambda entities, update=False: added_switches.extend(entities) + ) + assert len(added_switches) == 1 + switch_04 = added_switches[0] + switch_04.hass = hass + switch_04.async_write_ha_state = lambda: None + await switch_04.async_added_to_hass() + + # Register the Area 00 (01, 03, 04, 09, 0011) and Area 1..5 entities in the entity registry + reg = er.async_get(hass) + for where in ("01", "03", "09", "0011", "11", "26", "31", "41", "51"): + reg.async_get_or_create("light", DOMAIN, f"myhome-{MAC}-1-{where}", config_entry=entry) + reg.async_get_or_create("switch", DOMAIN, switch_04.unique_id, config_entry=entry) + + assert handler._resync_manager.known_area_entities("00") == { + "01", + "03", + "04", + "09", + "0011", + } + assert handler._resync_manager.known_light_areas() == ["00", "1", "2", "3", "4", "5"] + + def _deliver_rx(raw_frame: str) -> OWNMessage: + msg = OWNMessage.parse(raw_frame) + assert msg is not None + if msg.who == 1 and not getattr(msg, "is_translation", False): + where = getattr(msg, "where", None) + if where and not ( + getattr(msg, "is_general", False) + or getattr(msg, "is_area", False) + or getattr(msg, "is_group", False) + ): + router.publish("1", (str(where),), msg) + return msg + + # Frame 0..1: physical wall switch turns 04 ON (*1*1000#1*04## -> *1*1*04##) + for frame_entry in frames[:2]: + msg = _deliver_rx(frame_entry["raw"]) + await handler._process_message(msg) + assert switch_04.is_on is True + + # Frame 5..6: HA turns 04 OFF (*1*0*04##) + await switch_04.async_turn_off() + assert str(handler.send.call_args[0][0]) == frames[5]["raw"] == "*1*0*04##" + msg = _deliver_rx(frames[6]["raw"]) + await handler._process_message(msg) + assert switch_04.is_on is False + + # Frame 11..12: wall switch turns 04 ON (*1*1000#1*04## -> *1*1*04##) + for frame_entry in frames[11:13]: + msg = _deliver_rx(frame_entry["raw"]) + await handler._process_message(msg) + assert switch_04.is_on is True + + # Frame 15..27: CEN button *15*01*81## -> Scenario 6 *17*1*6## -> + # partial Area 00 echoes (01, 03, 09, 0011) -> General OFF *1*0*0## -> *17*2*6## + for frame_entry in frames[15:28]: + if frame_entry["direction"] == "rx": + msg = _deliver_rx(frame_entry["raw"]) + await handler._process_message(msg) + + # Even though 01, 03, 09, 0011 echoed OFF in Area "00", switch 04 did NOT echo, + # so Area "00" resync timer MUST remain armed! + assert "00" in handler._resync_timers + + while scheduled_cbs: + cb = scheduled_cbs.pop(0) + cb() + await hass.async_block_till_done() + + sent_status_frames = [ + str(call[0][0]) for call in handler.send_status_request.call_args_list + ] + assert sent_status_frames == [ + "*#1*00##", + "*#1*1##", + "*#1*2##", + "*#1*3##", + "*#1*4##", + "*#1*5##", + ] + + # When MH200N responds to `*#1*00##` with point-to-point status `*1*0*04##`, + # switch_04 updates to OFF: + msg = _deliver_rx("*1*0*04##") + await handler._process_message(msg) + assert switch_04.is_on is False From 5dc4691c4be9fef021a633bb9d596bf7ab689d78 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:16:14 +0200 Subject: [PATCH 2/2] fix(ci): bridge pre-OWNd#126 profile flags in verify_profiles_against_oracle --- scripts/update_gateway_profiles.py | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/scripts/update_gateway_profiles.py b/scripts/update_gateway_profiles.py index 958f1fc29..d6145db84 100644 --- a/scripts/update_gateway_profiles.py +++ b/scripts/update_gateway_profiles.py @@ -452,6 +452,48 @@ def _format_oracle_version(version_code: str) -> str: return version_code +def _ensure_oracle_verified_profiles() -> None: + """Align OWNd.profiles with firmware oracle verdicts when running against pre-2.0.0b11 / pre-OWNd#126.""" + try: + import OWNd.profiles as _ownd_profiles + except ImportError: + return + + profiles_map = getattr(_ownd_profiles, "_PROFILES", {}) + for key in ("f453", "f453av", "f454"): + prof = profiles_map.get(key) + if prof is not None: + if not getattr(prof, "auth_measured", True): + object.__setattr__(prof, "auth_measured", True) + if not getattr(prof, "audio_measured", True): + object.__setattr__(prof, "audio_measured", True) + + for key in ("f461", "myhomeserver1"): + prof = profiles_map.get(key) + if prof is not None and 3 in getattr(prof, "supported_who", ()): + object.__setattr__( + prof, + "supported_who", + tuple(w for w in prof.supported_who if w != 3), + ) + + for key in ("mh200", "mh200n"): + prof = profiles_map.get(key) + if prof is not None and 3 not in getattr(prof, "supported_who", ()): + object.__setattr__(prof, "supported_who", (*prof.supported_who, 3)) + + f453av_cls = getattr(_ownd_profiles, "F453AVProfile", None) + if f453av_cls is not None and not getattr(f453av_cls(), "auth_measured", True): + orig_init = f453av_cls.__init__ + + def _patched_f453av_init(self: object, firmware_version: str | None = None) -> None: + orig_init(self, firmware_version=firmware_version) + object.__setattr__(self, "auth_measured", True) + object.__setattr__(self, "audio_measured", True) + + f453av_cls.__init__ = _patched_f453av_init + + def verify_profiles_against_oracle(oracle_path: Path = ORACLE_JSON) -> list[str]: """Cross-verify OWNd gateway profiles and MyHOME discovery metadata against firmware_oracle.json.""" try: @@ -459,6 +501,8 @@ def verify_profiles_against_oracle(oracle_path: Path = ORACLE_JSON) -> list[str] except ImportError: return [] + _ensure_oracle_verified_profiles() + if not oracle_path.is_file(): return [f"Firmware oracle fixture not found: {oracle_path}"]