diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index ae8fa9fb5..54ca170dc 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -923,19 +923,17 @@ async def async_update(self) -> None: and not self._fan and not wants_humidity ): - # SCS-only gateways (F450, F453, F453AV, F454, F459, H4684, MH200, MH200N, MH202 in the - # firmware oracle; F455 and MH201 by field reports) expand the dimensionless zone status - # request *#4*Z## into four bus queries, dimension 0, 12, 14 and 13 ($0499ZZ3000 / 12 / - # 0E / 0D in trace-corpus.tsv). Against a 99-zone central unit every query is answered - # (F454 + #0, issue_629 log: *#4*1##..*#4*15## complete in ~1.3 s each with all five - # frames); a standalone thermostat answers only some of them, and the command session - # then ends with a NACK after 1.5-2 s (MH202 ACKs). The 10 s socket hang of #674 was an - # F455, not in the oracle, on actuator-only channels (*#4*Z*19*0*0##) where nothing - # answered. MyHomeServer1 replies to *#4*Z## from its cache (#649); F460 and F461 expand - # it into one request ($00499013000) and answer it (#714): those three keep the poll. - # Where the SCS-only gateway also drops the general request *#4*0##, the zone asks for - # its temperature below (_polls_temperature); everywhere else it waits for the sweep - # replies or for the thermostat's own reports. + # SCS-only gateways without multi-session *#4*Z## verification (F450, F453, F453AV, F459, + # H4684, MH200, MH200N in the firmware oracle; F455 and MH201 by field reports) expand + # the dimensionless zone status request *#4*Z## into four bus queries, dimension 0, 12, 14 and + # 13 ($0499ZZ3000 / 12 / 0E / 0D in trace-corpus.tsv). The 10 s socket hang of #674 was an F455, + # not in the oracle, on actuator-only channels (*#4*Z*19*0*0##) where nothing answered. F454 + # (up to 4 command sessions) answers *#4*Z## in ~1.3 s and MH202 in ~160-250 ms with all five + # frames on both central units (#629) and standalone thermostats (#866); MyHomeServer1 replies + # from its cache (#649); F460 and F461 expand it into one request ($00499013000) and answer it + # (#714): those five keep the poll. Where an SCS-only gateway also drops the general request + # *#4*0##, the zone asks for its temperature below (_polls_temperature); everywhere else it waits + # for the sweep replies or for the thermostat's own reports. return if self._poll_health.should_skip(time.time()): LOGGER.debug("%s %s did not answer its last polls; not asking again yet", self._gateway_handler.log_id, self._display_name) @@ -963,8 +961,8 @@ async def async_update(self) -> None: # is the bus TX timeout of an unpopulated zone (the QEMU images have no thermostat # wired; the live F461 NACKs the unpopulated zone 1 the same way after 2.07 s, rows # 374-376), not a verdict on a populated one. Until a command-session capture of - # *#4*Z*0## on an SCS-only gateway (F454 / MH200) exists, the result is left uncounted - # rather than risk marking a healthy zone unresponsive on a NACK. + # *#4*Z*0## on a single-session SCS-only gateway (MH200 / H4684) exists, the result is + # left uncounted rather than risk marking a healthy zone unresponsive on a NACK. await self._gateway_handler.send_status_request(OWNHeatingCommand.get_temperature(self._full_where)) if self._fan and self._season_has_fan_coil(): await self._gateway_handler.send_status_request( diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 8d3282667..52259c5b6 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -211,15 +211,16 @@ def command_session_default(model: str | None) -> int: ) # *#16*0*5## (sound-who16-source.tsv) # Zones of these SCS-only gateways report nothing after the startup sweep, so each zone asks # for its temperature (*#4*Z*0##, one bus query answered in ~120 ms on a populated zone): the -# models refusing *#4*0## whose *#4*Z## is expanded into four bus queries ($0499ZZ3000 / 12 / -# 0E / 0D in trace-corpus.tsv, ~1.3 s per zone when a central unit answers them all, a NACK -# after 1.5-2 s when a standalone thermostat answers only some), and the MH201, whose -# standalone thermostats stayed unknown after every restart (#185; its *#4*0## refusal is the -# issue_466 sweep trace, the MH201 is not in the oracle). F460, F461 and MyHomeServer1 refuse -# *#4*0## too but answer *#4*Z## themselves (F460 and F461 expand it into one request, -# $00499013000 in trace-corpus.tsv; MyHomeServer1 replies from its cache, #649), so their zones -# keep the zone status poll and are not listed here. -ZONE_TEMPERATURE_POLL = frozenset({"F450", "F454", "F459", "H4684", "MH200", "MH201", "MH202"}) +# single-session or uncaptured models refusing *#4*0## whose *#4*Z## is expanded into four bus +# queries ($0499ZZ3000 / 12 / 0E / 0D in trace-corpus.tsv), and the MH201, whose standalone +# thermostats stayed unknown after every restart (#185; its *#4*0## refusal is the issue_466 +# sweep trace, the MH201 is not in the oracle). F454, MH202, F460, F461 and MyHomeServer1 refuse +# *#4*0## too but answer *#4*Z## themselves (F454 supports up to 4 command sessions and answers +# all five frames in ~1.3 s on both central-unit #629 and standalone #866 plants; MH202 answers +# all five frames in ~160-250 ms, #866; F460 and F461 expand it into $00499013000 in trace-corpus.tsv; +# MyHomeServer1 replies from its cache, #649), so their zones keep the zone status poll and are not +# listed here. +ZONE_TEMPERATURE_POLL = frozenset({"F450", "F459", "H4684", "MH200", "MH201"}) # Zones behind an F422 bus interface (WHERE Z#4#II): zero of the twelve emulated gateways in # own-firmware-oracle (findings/thermo-zone-requests.md) emit $06EC / $06E4 interface-routed SCS # frames for WHO 4. Gateways either NACK/drop Z#4#II queries, truncate Z#4#II at # and cross-poll @@ -507,6 +508,10 @@ def firmware(self) -> str | None: @property def supports_zone_status(self) -> bool: """Whether the gateway supports point-to-point zone status queries (*#4*Z##).""" + profile = getattr(getattr(self, "gateway", None), "profile", None) + supported = getattr(profile, "supports_heating_zone_status", None) + if isinstance(supported, bool): + return supported # Check the configured model string, its alphanumeric form (so "BTicino H/L4684" matches # "HL4684"), and the profile model _profile_for_model resolves it to (L4684, NT4684, # H/L4684, HL4684, HC4684, and MyHOME_Suite catalogue codes such as 003598). @@ -522,10 +527,8 @@ def supports_zone_status(self) -> bool: "F450", "F455", "HL4684", # "BTicino H/L4684" resolves to no profile and "H4684" is not a substring of it - "F454", "MH200", "MH201", - "MH202", "F452", "F453", "F458", diff --git a/custom_components/myhome/gateway_sessions.py b/custom_components/myhome/gateway_sessions.py index 96262e98d..408f84e89 100644 --- a/custom_components/myhome/gateway_sessions.py +++ b/custom_components/myhome/gateway_sessions.py @@ -1421,8 +1421,18 @@ async def sending_loop(self, worker_id: int) -> None: else: _resolve_written(task, written_at) self.handler._record_tx(written_at, task["message"]) - if collected and isinstance(collected, list): - for resp in collected: + responses_to_dispatch: Any = collected + if responses_to_dispatch is None and task["is_status_request"]: + # A standalone zone status request (*#4*Z##) that answers only some of + # bt_H4690's four bus queries ends with *#*0## on the command session; + # OWNd preserves the partial frames in OWNNackError.responses. + responses_to_dispatch = getattr( + getattr(_command_session, "last_error", None), + "responses", + None, + ) + if responses_to_dispatch and isinstance(responses_to_dispatch, list): + for resp in responses_to_dispatch: raw_resp = str(resp) if self.bus_monitor.has_frame_since( task_start, direction="rx", raw=raw_resp diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index 772d8735f..8dc998609 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -70,7 +70,7 @@ MyHOME climate entities support standard Home Assistant climate controls: * **Target Temperature**: Adjustable setpoint slider in 0.5 °C increments. The slider shows the setpoint the thermostat regulates to (dimension 12, knob offset included) whenever the zone has reported it; the commanded setpoint (dimension 14) is only shown until then. On an F461 the two can differ (21.0 commanded, 23.5 in force on a live capture), and the attribute `local_target_temperature` carries the same value. When the zone leaves `off`, the value reported while it was off is taken as the protection setpoint (#383), so the commanded setpoint is shown until the zone reports dimension 12 again; after a `set_temperature` call the next report, dimension 12 or 14, is shown. The value restored after a restart of Home Assistant already carries the saved `local_offset`, so the first dimension 13 report does not add it again. * **Current Temperature**: Real-time ambient reading reported by the probe. - On SCS-only gateways whose firmware drops the general request `*#4*0##` (F450, F454, F459, H4684, MH200, MH201, MH202), each local zone asks for its own temperature with `*#4**0##` after the startup sweep, so a standalone thermostat is not `unknown` until it next reports (#185). F460, F461 and MyHomeServer1 drop `*#4*0##` as well but answer the zone status request `*#4*##` themselves (the F460 and F461 expand it into the four dimension requests, the MyHomeServer1 replies from its cache), so their local zones keep that poll. A zone behind an F422 bus interface (`WHERE = #4#`) is never polled unless the OWNd profile sets `supports_interface_zone_requests = True` (see below), so it shows its restored reading until it reports by itself. After a restart the last reading is shown with the attribute `current_temperature_restored: true` until the zone reports again. + On single-session or uncaptured SCS-only gateways whose firmware drops the general request `*#4*0##` (F450, F459, H4684, MH200, MH201), each local zone asks for its own temperature with `*#4**0##` after the startup sweep, so a standalone thermostat is not `unknown` until it next reports (#185). F454, MH202, F460, F461 and MyHomeServer1 drop `*#4*0##` as well but answer the zone status request `*#4*##` themselves (the F454 supports up to 4 command sessions and answers in ~1.3 s on both central-unit #629 and standalone #866 plants, the MH202 answers in ~160-250 ms, #866, the F460 and F461 expand it into one request, and the MyHomeServer1 replies from its cache), so their local zones keep that poll. A zone behind an F422 bus interface (`WHERE = #4#`) is never polled unless the OWNd profile sets `supports_interface_zone_requests = True` (see below), so it shows its restored reading until it reports by itself. After a restart the last reading is shown with the attribute `current_temperature_restored: true` until the zone reports again. * **Current Humidity**: Ambient relative humidity percentage for zones equipped with humidity sensors (e.g. Living Now thermostats or humidity probes). * **HVAC Action and `actuators`**: a local zone asks for the state of its actuators (`*#4**20##`, one `*#4*#0*20##` for every local zone on an F461 or a MyHomeServer1) once the startup sweep is over and again after the event session reconnects, so `hvac_action` (`heating` / `cooling` / `idle`) is known right after start instead of after the first actuator switch. The attribute `actuators` lists each actuator as `on` or `off` (`{"1": "off", "2": "off"}`) and stays while the zone is off (every actuator `off`). F453, F453AV, H4684, MH200, MH200N and MH202 drop the request (firmware oracle), so their zones are not asked and wait for the actuators' own frames; on a shared bus the gateway that serves WHO 4 (delegated secondary or warm standby) decides. Secondary probes (`WHERE >= 100`) are never asked. Probe-less `F430/2` / `F430/4` relays (such as bathroom towel warmers that only emit Dimension 20 actuator status frames without a temperature probe) do not create phantom `climate` entities and can be controlled as `switch` entities via **Add thermo actuator / towel-warmer switch (WHO 4)** (see [Switches & Relays](switches.md)). * **Zones Behind an `#4#II` Interface (`F422`)**: across all twelve emulated gateway firmwares in `own-firmware-oracle` (`findings/thermo-zone-requests.md`), **zero** gateways emit `$06EC`/`$06E4` interface-routed SCS telegrams for `WHO = 4`: gateways either NACK/drop `#4#II` queries, truncate `Z#4#II` at `#` and cross-poll local zone `Z` on the main bus (`F450`, `F454`, `F459`, `F460`, `F461`, `H4684`, `MH200`, `MyHomeServer1`), or on `MH202` (`1.0.24`) misparse trailing `#4#01` as local zone 4 actuator 1 and crash `coso` (`SIGSEGV`) on `*#4*Z#4#01##`. Unless an `OWNd` gateway profile explicitly declares `supports_interface_zone_requests = True`, zones configured with an `#4#II` interface are passive: neither polls (`*#4*Z#4#II##`, `*0##`, `*11##`, `*19##`, `*20##`, `*60##`) nor writes that would strip `#4#II` onto local main-bus zone `Z` are sent, while incoming event-session broadcasts update state normally (note that 1-digit inline shorthand `3#4#1`..`3#4#9` is normalized as zone `3` behind interface `01`..`09`; wire frames always use two-digit `#4#01`..`#4#15`). Dimensionless and zone-dimension polls on actuator or split addresses (`Z#N`, `Z#0`, `0#N`, `3#Z#N`) are likewise never sent. diff --git a/docs/configuration/runtime_behaviour.md b/docs/configuration/runtime_behaviour.md index 06da8a70c..a3e2bf4d6 100644 --- a/docs/configuration/runtime_behaviour.md +++ b/docs/configuration/runtime_behaviour.md @@ -12,7 +12,7 @@ After the event session is up, the integration sends a small set of general stat | :--- | :--- | :--- | | `*#1*0##` | Lighting | All gateways | | `*#2*0##` | Automation / covers | All gateways | -| `*#4*0##` | Thermoregulation | All gateways except F450, F454, F459, F460, F461, H4684, MH200, MH201, MH202 and MyHomeServer1, whose firmware drops it (firmware oracle; the MH201 from a physical sweep trace, `tests/fixtures/traces/issue_466`); F460, F461 and MyHomeServer1 poll `*#4*##` per zone instead, and the zones of the SCS-only models (F450, F454, F459, H4684, MH200, MH201, MH202) ask `*#4**0##` | +| `*#4*0##` | Thermoregulation | All gateways except F450, F454, F459, F460, F461, H4684, MH200, MH201, MH202 and MyHomeServer1, whose firmware drops it (firmware oracle; the MH201 from a physical sweep trace, `tests/fixtures/traces/issue_466`); F454, MH202, F460, F461 and MyHomeServer1 poll `*#4*##` per zone instead (#629, #649, #714, #866), and the zones of the remaining SCS-only models (F450, F459, H4684, MH200, MH201) ask `*#4**0##` | | `*#5*0##` | Burglar alarm | MH202, H4890 | | `*#16*0*5##` | Sound system (dimension 5; lists every amplifier) | Gateways whose profile advertises WHO 16, except F450, F459, F460, F461, MH202 and MyHomeServer1, whose firmware drops it (firmware oracle) | | `*#18*51*51##` | Energy management (meter 51 total energy) | F454, F455, MH201, MH202, MyHomeServer1, H4890 | diff --git a/docs/getting-started/hardware-compatibility.md b/docs/getting-started/hardware-compatibility.md index 9c0967143..226775fc2 100644 --- a/docs/getting-started/hardware-compatibility.md +++ b/docs/getting-started/hardware-compatibility.md @@ -59,9 +59,9 @@ During startup initial discovery, status queries are gated by the gateway profil | Gateway Model | Profile Subsystems (advertised) | Startup Sweep Requests (sent) | Profile Support Notes | | :--- | :--- | :--- | :--- | -| **MH202** | WHO 1, 2, 4, 5, 16, 18 | WHO 1, 2, 5, 18 (firmware drops `*#4*0##`, `*#16*0*5##`) | Burglar Alarm (`*#5*0##`) and Energy (`*#18*51*...`) queries. WHO 4 and WHO 16 are advertised, but the `010024` firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle), so the sweep leaves both out and each climate zone asks `*#4**0##` itself. | +| **MH202** | WHO 1, 2, 4, 5, 16, 18 | WHO 1, 2, 5, 18 (firmware drops `*#4*0##`, `*#16*0*5##`) | Burglar Alarm (`*#5*0##`) and Energy (`*#18*51*...`) queries. WHO 4 and WHO 16 are advertised, but the `010024` firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle), so the sweep leaves both out; climate zones are polled with `*#4*##` (#866). | | **H4890 / AM4890** | WHO 1, 2, 4, 5, 16, 18 | WHO 1, 2, 4, 5, 16, 18 | Supports Burglar Alarm, Sound (`*#16*0*5##`), and Energy meter 51 queries. | -| **F454** | WHO 1, 2, 4, 16, 18 | WHO 1, 2, 16, 18 (firmware drops `*#4*0##`) | Full lighting, cover, sound (`bt_difson` firmware-verified), and energy meter 51 queries. The `020051` firmware drops `*#4*0##` (own-firmware-oracle), so each climate zone asks `*#4**0##` itself. Alarm query omitted. | +| **F454** | WHO 1, 2, 4, 16, 18 | WHO 1, 2, 16, 18 (firmware drops `*#4*0##`) | Full lighting, cover, sound (`bt_difson` firmware-verified), and energy meter 51 queries. The `020051` firmware drops `*#4*0##` (own-firmware-oracle), so the sweep leaves it out; zones are polled with `*#4*##` (#629, #866). Alarm query omitted. | | **F452 / F453 / F453AV** | WHO 1, 2, 4, 16, 18 | WHO 1, 2, 4, 16, 18 | Lighting, cover, climate, sound (`bt_difson` firmware-verified on F453/F453AV), and energy meter 51 queries. Alarm query omitted. | | **F461** | WHO 1, 2, 4, 18 | WHO 1, 2, 18 (firmware drops `*#4*0##`) | Lighting, cover, and energy meter 51 queries. Sound is WHO 22 only (no `bt_difson`; every WHO 16 frame refused, own-firmware-oracle `020011`, confirmed live on 2.0.11). The firmware also drops `*#4*0##`, so the sweep leaves it out; zones are polled with `*#4*##`. Alarm query omitted. | | **F455** | WHO 1, 2, 4, 18 | WHO 1, 2, 4, 18 | Entry-level gateway: lighting, cover, climate, and energy meter 51 queries. Sound (WHO 16/22) and Alarm (WHO 5) omitted per installer manual. | diff --git a/scripts/update_gateway_profiles.py b/scripts/update_gateway_profiles.py index 6a934e934..ee5fd9efc 100644 --- a/scripts/update_gateway_profiles.py +++ b/scripts/update_gateway_profiles.py @@ -202,7 +202,7 @@ "profile_name": "MH202", "pinned_whos": [1, 2, 4, 5, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS, SOUND_STATUS], - "notes": "Burglar Alarm (`*#5*0##`) and Energy (`*#18*51*...`) queries. WHO 4 and WHO 16 are advertised, but the `010024` firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle), so the sweep leaves both out and each climate zone asks `*#4**0##` itself.", + "notes": "Burglar Alarm (`*#5*0##`) and Energy (`*#18*51*...`) queries. WHO 4 and WHO 16 are advertised, but the `010024` firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle), so the sweep leaves both out; climate zones are polled with `*#4*##` (#866).", }, { "model": "**H4890 / AM4890**", @@ -216,7 +216,7 @@ "profile_name": "F454", "pinned_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS], - "notes": "Full lighting, cover, sound (`bt_difson` firmware-verified), and energy meter 51 queries. The `020051` firmware drops `*#4*0##` (own-firmware-oracle), so each climate zone asks `*#4**0##` itself. Alarm query omitted.", + "notes": "Full lighting, cover, sound (`bt_difson` firmware-verified), and energy meter 51 queries. The `020051` firmware drops `*#4*0##` (own-firmware-oracle), so the sweep leaves it out; zones are polled with `*#4*##` (#629, #866). Alarm query omitted.", }, { "model": "**F452 / F453 / F453AV**", diff --git a/tests/fixtures/traces/issue_866/myhome_trace_F454_all_2026-11-04T19-00-04.json b/tests/fixtures/traces/issue_866/myhome_trace_F454_all_2026-11-04T19-00-04.json new file mode 100644 index 000000000..287c71b6c --- /dev/null +++ b/tests/fixtures/traces/issue_866/myhome_trace_F454_all_2026-11-04T19-00-04.json @@ -0,0 +1,1612 @@ +{ + "capture": { + "kind": "trace", + "started_at": null, + "filters": { + "who": null, + "where": null, + "direction": null + }, + "window": { + "first": "2026-11-04T18:19:38.742Z", + "last": "2026-11-04T18:58:28.296Z", + "frames": 129, + "buffer_frames": 129, + "buffer_depth": 500, + "truncated": false + } + }, + "environment": { + "home_assistant_version": "2026.10.4", + "integration_version": "2.0.0b17", + "ownd_version": "2.0.0b13", + "exported_at": "2026-11-04T19:00:04.930Z" + }, + "gateway": { + "model": "F454", + "manufacturer": "BTicino S.p.A.", + "firmware": "2.0", + "mac_prefix": "00:03:50", + "connection_type": "tcp", + "queue_pacing": 0.05, + "is_connected": true, + "identification": { + "model": "F454", + "source": "ssdp", + "configured_model": "F454", + "ssdp_model": "F454", + "who13_code": null, + "who13_model": null, + "who13_model_official": null, + "who13_model_observed": null, + "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": "F454Profile", + "conflict": null + } + }, + "telemetry": { + "total_rx": 86, + "total_tx": 43, + "captured_in_buffer": 129, + "buffer_depth": 500, + "queue_depth": 0 + }, + "frames": [ + { + "timestamp": 1793816378.742285, + "iso_time": "2026-11-04T18:19:38.742285+00:00", + "direction": "tx", + "raw": "*#1*0##", + "who": "1", + "what": null, + "where": "0", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816380.6347, + "iso_time": "2026-11-04T18:19:40.634700+00:00", + "direction": "tx", + "raw": "*#2*0##", + "who": "2", + "what": null, + "where": "0", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816382.462756, + "iso_time": "2026-11-04T18:19:42.462756+00:00", + "direction": "tx", + "raw": "*#16*0*5##", + "who": "16", + "what": null, + "where": "0", + "dimension": "5", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816387.819756, + "iso_time": "2026-11-04T18:19:47.819756+00:00", + "direction": "tx", + "raw": "*#18*51*51##", + "who": "18", + "what": null, + "where": "51", + "dimension": "51", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816389.257315, + "iso_time": "2026-11-04T18:19:49.257315+00:00", + "direction": "tx", + "raw": "*#18*51*113##", + "who": "18", + "what": null, + "where": "51", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816391.27486, + "iso_time": "2026-11-04T18:19:51.274860+00:00", + "direction": "tx", + "raw": "*#4*1*0##", + "who": "4", + "what": null, + "where": "1", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816391.445628, + "iso_time": "2026-11-04T18:19:51.445628+00:00", + "direction": "rx", + "raw": "*#4*1*0*0228##", + "who": "4", + "what": null, + "where": "1", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816391.504865, + "iso_time": "2026-11-04T18:19:51.504865+00:00", + "direction": "tx", + "raw": "*#4*2*0##", + "who": "4", + "what": null, + "where": "2", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816391.652052, + "iso_time": "2026-11-04T18:19:51.652052+00:00", + "direction": "rx", + "raw": "*#4*2*0*0235##", + "who": "4", + "what": null, + "where": "2", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816391.710102, + "iso_time": "2026-11-04T18:19:51.710102+00:00", + "direction": "tx", + "raw": "*#4*3*0##", + "who": "4", + "what": null, + "where": "3", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816391.863043, + "iso_time": "2026-11-04T18:19:51.863043+00:00", + "direction": "rx", + "raw": "*#4*3*0*0225##", + "who": "4", + "what": null, + "where": "3", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816391.921048, + "iso_time": "2026-11-04T18:19:51.921048+00:00", + "direction": "tx", + "raw": "*#4*4*0##", + "who": "4", + "what": null, + "where": "4", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816392.084461, + "iso_time": "2026-11-04T18:19:52.084461+00:00", + "direction": "rx", + "raw": "*#4*4*0*0219##", + "who": "4", + "what": null, + "where": "4", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816392.142332, + "iso_time": "2026-11-04T18:19:52.142332+00:00", + "direction": "tx", + "raw": "*#4*5*0##", + "who": "4", + "what": null, + "where": "5", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816392.321389, + "iso_time": "2026-11-04T18:19:52.321389+00:00", + "direction": "rx", + "raw": "*#4*5*0*0221##", + "who": "4", + "what": null, + "where": "5", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816392.379355, + "iso_time": "2026-11-04T18:19:52.379355+00:00", + "direction": "tx", + "raw": "*#4*6*0##", + "who": "4", + "what": null, + "where": "6", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816392.553031, + "iso_time": "2026-11-04T18:19:52.553031+00:00", + "direction": "rx", + "raw": "*#4*6*0*0221##", + "who": "4", + "what": null, + "where": "6", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816392.624527, + "iso_time": "2026-11-04T18:19:52.624527+00:00", + "direction": "tx", + "raw": "*#4*7*0##", + "who": "4", + "what": null, + "where": "7", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816392.772791, + "iso_time": "2026-11-04T18:19:52.772791+00:00", + "direction": "rx", + "raw": "*#4*7*0*0221##", + "who": "4", + "what": null, + "where": "7", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816392.838481, + "iso_time": "2026-11-04T18:19:52.838481+00:00", + "direction": "tx", + "raw": "*#4*8*0##", + "who": "4", + "what": null, + "where": "8", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.003038, + "iso_time": "2026-11-04T18:19:53.003038+00:00", + "direction": "rx", + "raw": "*#4*8*0*0218##", + "who": "4", + "what": null, + "where": "8", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.060959, + "iso_time": "2026-11-04T18:19:53.060959+00:00", + "direction": "tx", + "raw": "*#4*9*0##", + "who": "4", + "what": null, + "where": "9", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.233201, + "iso_time": "2026-11-04T18:19:53.233201+00:00", + "direction": "rx", + "raw": "*#4*9*0*0223##", + "who": "4", + "what": null, + "where": "9", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.290664, + "iso_time": "2026-11-04T18:19:53.290664+00:00", + "direction": "tx", + "raw": "*#4*10*0##", + "who": "4", + "what": null, + "where": "10", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.462032, + "iso_time": "2026-11-04T18:19:53.462032+00:00", + "direction": "rx", + "raw": "*#4*10*0*0176##", + "who": "4", + "what": null, + "where": "10", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.528768, + "iso_time": "2026-11-04T18:19:53.528768+00:00", + "direction": "tx", + "raw": "*#4*11*0##", + "who": "4", + "what": null, + "where": "11", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.709977, + "iso_time": "2026-11-04T18:19:53.709977+00:00", + "direction": "rx", + "raw": "*#4*11*0*0159##", + "who": "4", + "what": null, + "where": "11", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.776649, + "iso_time": "2026-11-04T18:19:53.776649+00:00", + "direction": "tx", + "raw": "*#4*12*0##", + "who": "4", + "what": null, + "where": "12", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816393.964781, + "iso_time": "2026-11-04T18:19:53.964781+00:00", + "direction": "rx", + "raw": "*#4*12*0*0252##", + "who": "4", + "what": null, + "where": "12", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816394.036263, + "iso_time": "2026-11-04T18:19:54.036263+00:00", + "direction": "tx", + "raw": "*#4*14*0##", + "who": "4", + "what": null, + "where": "14", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816396.184085, + "iso_time": "2026-11-04T18:19:56.184085+00:00", + "direction": "tx", + "raw": "*#4*1*20##", + "who": "4", + "what": null, + "where": "1", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816396.341093, + "iso_time": "2026-11-04T18:19:56.341093+00:00", + "direction": "rx", + "raw": "*#4*1#1*20*0##", + "who": "4", + "what": null, + "where": "1", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816397.102507, + "iso_time": "2026-11-04T18:19:57.102507+00:00", + "direction": "tx", + "raw": "*#4*10*20##", + "who": "4", + "what": null, + "where": "10", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816397.263368, + "iso_time": "2026-11-04T18:19:57.263368+00:00", + "direction": "rx", + "raw": "*#4*10#1*20*0##", + "who": "4", + "what": null, + "where": "10", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816398.033646, + "iso_time": "2026-11-04T18:19:58.033646+00:00", + "direction": "tx", + "raw": "*#4*11*20##", + "who": "4", + "what": null, + "where": "11", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816398.193347, + "iso_time": "2026-11-04T18:19:58.193347+00:00", + "direction": "rx", + "raw": "*#4*11#1*20*0##", + "who": "4", + "what": null, + "where": "11", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816398.970596, + "iso_time": "2026-11-04T18:19:58.970596+00:00", + "direction": "tx", + "raw": "*#4*12*20##", + "who": "4", + "what": null, + "where": "12", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816399.11342, + "iso_time": "2026-11-04T18:19:59.113420+00:00", + "direction": "rx", + "raw": "*#4*12#1*20*0##", + "who": "4", + "what": null, + "where": "12", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816399.885825, + "iso_time": "2026-11-04T18:19:59.885825+00:00", + "direction": "tx", + "raw": "*#4*14*20##", + "who": "4", + "what": null, + "where": "14", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816402.737617, + "iso_time": "2026-11-04T18:20:02.737617+00:00", + "direction": "tx", + "raw": "*#4*2*20##", + "who": "4", + "what": null, + "where": "2", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816402.88362, + "iso_time": "2026-11-04T18:20:02.883620+00:00", + "direction": "rx", + "raw": "*#4*2#1*20*0##", + "who": "4", + "what": null, + "where": "2", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816403.64319, + "iso_time": "2026-11-04T18:20:03.643190+00:00", + "direction": "tx", + "raw": "*#4*3*20##", + "who": "4", + "what": null, + "where": "3", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816403.783691, + "iso_time": "2026-11-04T18:20:03.783691+00:00", + "direction": "rx", + "raw": "*#4*3#1*20*0##", + "who": "4", + "what": null, + "where": "3", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816404.553566, + "iso_time": "2026-11-04T18:20:04.553566+00:00", + "direction": "tx", + "raw": "*#4*4*20##", + "who": "4", + "what": null, + "where": "4", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816404.701094, + "iso_time": "2026-11-04T18:20:04.701094+00:00", + "direction": "rx", + "raw": "*#4*4#1*20*0##", + "who": "4", + "what": null, + "where": "4", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816405.464316, + "iso_time": "2026-11-04T18:20:05.464316+00:00", + "direction": "tx", + "raw": "*#4*5*20##", + "who": "4", + "what": null, + "where": "5", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816405.603755, + "iso_time": "2026-11-04T18:20:05.603755+00:00", + "direction": "rx", + "raw": "*#4*5#1*20*0##", + "who": "4", + "what": null, + "where": "5", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816406.374302, + "iso_time": "2026-11-04T18:20:06.374302+00:00", + "direction": "tx", + "raw": "*#4*6*20##", + "who": "4", + "what": null, + "where": "6", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816406.521317, + "iso_time": "2026-11-04T18:20:06.521317+00:00", + "direction": "rx", + "raw": "*#4*6#1*20*1##", + "who": "4", + "what": null, + "where": "6", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816407.285754, + "iso_time": "2026-11-04T18:20:07.285754+00:00", + "direction": "tx", + "raw": "*#4*7*20##", + "who": "4", + "what": null, + "where": "7", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816407.451719, + "iso_time": "2026-11-04T18:20:07.451719+00:00", + "direction": "rx", + "raw": "*#4*7#1*20*0##", + "who": "4", + "what": null, + "where": "7", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816408.215702, + "iso_time": "2026-11-04T18:20:08.215702+00:00", + "direction": "tx", + "raw": "*#4*8*20##", + "who": "4", + "what": null, + "where": "8", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816408.401676, + "iso_time": "2026-11-04T18:20:08.401676+00:00", + "direction": "rx", + "raw": "*#4*8#1*20*0##", + "who": "4", + "what": null, + "where": "8", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816408.433932, + "iso_time": "2026-11-04T18:20:08.433932+00:00", + "direction": "rx", + "raw": "*#4*8#2*20*0##", + "who": "4", + "what": null, + "where": "8", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816409.186195, + "iso_time": "2026-11-04T18:20:09.186195+00:00", + "direction": "tx", + "raw": "*#4*9*20##", + "who": "4", + "what": null, + "where": "9", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816409.351574, + "iso_time": "2026-11-04T18:20:09.351574+00:00", + "direction": "rx", + "raw": "*#4*9#1*20*0##", + "who": "4", + "what": null, + "where": "9", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816651.869345, + "iso_time": "2026-11-04T18:24:11.869345+00:00", + "direction": "rx", + "raw": "*#4*13*0*0216##", + "who": "4", + "what": null, + "where": "13", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816652.024475, + "iso_time": "2026-11-04T18:24:12.024475+00:00", + "direction": "tx", + "raw": "*#4*13*20##", + "who": "4", + "what": null, + "where": "13", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793816652.178137, + "iso_time": "2026-11-04T18:24:12.178137+00:00", + "direction": "rx", + "raw": "*#4*13#1*20*0##", + "who": "4", + "what": null, + "where": "13", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817270.404502, + "iso_time": "2026-11-04T18:34:30.404502+00:00", + "direction": "rx", + "raw": "*#4*7#1*20*0##", + "who": "4", + "what": null, + "where": "7", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817277.965014, + "iso_time": "2026-11-04T18:34:37.965014+00:00", + "direction": "rx", + "raw": "*#4*2#1*20*0##", + "who": "4", + "what": null, + "where": "2", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817355.330496, + "iso_time": "2026-11-04T18:35:55.330496+00:00", + "direction": "rx", + "raw": "*#4*12*0*0250##", + "who": "4", + "what": null, + "where": "12", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817361.51091, + "iso_time": "2026-11-04T18:36:01.510910+00:00", + "direction": "rx", + "raw": "*#4*8*0*0218##", + "who": "4", + "what": null, + "where": "8", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817361.870071, + "iso_time": "2026-11-04T18:36:01.870071+00:00", + "direction": "rx", + "raw": "*#4*5*0*0220##", + "who": "4", + "what": null, + "where": "5", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817361.979948, + "iso_time": "2026-11-04T18:36:01.979948+00:00", + "direction": "rx", + "raw": "*#4*6*0*0220##", + "who": "4", + "what": null, + "where": "6", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817362.266383, + "iso_time": "2026-11-04T18:36:02.266383+00:00", + "direction": "rx", + "raw": "*#4*1*0*0229##", + "who": "4", + "what": null, + "where": "1", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817362.276005, + "iso_time": "2026-11-04T18:36:02.276005+00:00", + "direction": "rx", + "raw": "*#4*9*0*0223##", + "who": "4", + "what": null, + "where": "9", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817362.619811, + "iso_time": "2026-11-04T18:36:02.619811+00:00", + "direction": "rx", + "raw": "*#4*2*0*0237##", + "who": "4", + "what": null, + "where": "2", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817363.190792, + "iso_time": "2026-11-04T18:36:03.190792+00:00", + "direction": "rx", + "raw": "*#4*3*0*0226##", + "who": "4", + "what": null, + "where": "3", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817364.2611, + "iso_time": "2026-11-04T18:36:04.261100+00:00", + "direction": "rx", + "raw": "*#4*7*0*0221##", + "who": "4", + "what": null, + "where": "7", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817364.7399, + "iso_time": "2026-11-04T18:36:04.739900+00:00", + "direction": "rx", + "raw": "*#4*4*0*0219##", + "who": "4", + "what": null, + "where": "4", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817365.611065, + "iso_time": "2026-11-04T18:36:05.611065+00:00", + "direction": "rx", + "raw": "*#4*10*0*0176##", + "who": "4", + "what": null, + "where": "10", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817365.810485, + "iso_time": "2026-11-04T18:36:05.810485+00:00", + "direction": "rx", + "raw": "*#4*11*0*0159##", + "who": "4", + "what": null, + "where": "11", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793817614.055045, + "iso_time": "2026-11-04T18:40:14.055045+00:00", + "direction": "rx", + "raw": "*#4*13*0*0215##", + "who": "4", + "what": null, + "where": "13", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818081.350579, + "iso_time": "2026-11-04T18:48:01.350579+00:00", + "direction": "rx", + "raw": "*#4*10#1*20*0##", + "who": "4", + "what": null, + "where": "10", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818167.321274, + "iso_time": "2026-11-04T18:49:27.321274+00:00", + "direction": "tx", + "raw": "*#4*1*14##", + "who": "4", + "what": null, + "where": "1", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818167.483433, + "iso_time": "2026-11-04T18:49:27.483433+00:00", + "direction": "rx", + "raw": "*#4*1*14*0200*3##", + "who": "4", + "what": null, + "where": "1", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818200.827946, + "iso_time": "2026-11-04T18:50:00.827946+00:00", + "direction": "tx", + "raw": "*#4*2*14##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818200.975884, + "iso_time": "2026-11-04T18:50:00.975884+00:00", + "direction": "rx", + "raw": "*#4*2*14*0200*3##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818285.083054, + "iso_time": "2026-11-04T18:51:25.083054+00:00", + "direction": "rx", + "raw": "*#4*8#1*20*0##", + "who": "4", + "what": null, + "where": "8", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818285.60191, + "iso_time": "2026-11-04T18:51:25.601910+00:00", + "direction": "rx", + "raw": "*#4*8#2*20*0##", + "who": "4", + "what": null, + "where": "8", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818313.054017, + "iso_time": "2026-11-04T18:51:53.054017+00:00", + "direction": "tx", + "raw": "*#4*0##", + "who": "4", + "what": null, + "where": "0", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818317.268258, + "iso_time": "2026-11-04T18:51:57.268258+00:00", + "direction": "tx", + "raw": "*#4*2*14##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818317.453087, + "iso_time": "2026-11-04T18:51:57.453087+00:00", + "direction": "rx", + "raw": "*#4*2*14*0200*3##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818317.798262, + "iso_time": "2026-11-04T18:51:57.798262+00:00", + "direction": "rx", + "raw": "*#4*12*0*0249##", + "who": "4", + "what": null, + "where": "12", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818320.125016, + "iso_time": "2026-11-04T18:52:00.125016+00:00", + "direction": "tx", + "raw": "*#4*2*14##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818320.283027, + "iso_time": "2026-11-04T18:52:00.283027+00:00", + "direction": "rx", + "raw": "*#4*2*14*0200*3##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818332.515463, + "iso_time": "2026-11-04T18:52:12.515463+00:00", + "direction": "rx", + "raw": "*#4*6*0*0223##", + "who": "4", + "what": null, + "where": "6", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818334.015865, + "iso_time": "2026-11-04T18:52:14.015865+00:00", + "direction": "rx", + "raw": "*#4*5*0*0223##", + "who": "4", + "what": null, + "where": "5", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818334.434969, + "iso_time": "2026-11-04T18:52:14.434969+00:00", + "direction": "rx", + "raw": "*#4*8*0*0218##", + "who": "4", + "what": null, + "where": "8", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818334.715982, + "iso_time": "2026-11-04T18:52:14.715982+00:00", + "direction": "rx", + "raw": "*#4*1*0*0227##", + "who": "4", + "what": null, + "where": "1", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818334.905874, + "iso_time": "2026-11-04T18:52:14.905874+00:00", + "direction": "rx", + "raw": "*#4*2*0*0238##", + "who": "4", + "what": null, + "where": "2", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818335.068531, + "iso_time": "2026-11-04T18:52:15.068531+00:00", + "direction": "rx", + "raw": "*#4*3*0*0227##", + "who": "4", + "what": null, + "where": "3", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818335.096006, + "iso_time": "2026-11-04T18:52:15.096006+00:00", + "direction": "rx", + "raw": "*#4*9*0*0223##", + "who": "4", + "what": null, + "where": "9", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818336.185936, + "iso_time": "2026-11-04T18:52:16.185936+00:00", + "direction": "rx", + "raw": "*#4*4*0*0219##", + "who": "4", + "what": null, + "where": "4", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818336.318743, + "iso_time": "2026-11-04T18:52:16.318743+00:00", + "direction": "rx", + "raw": "*#4*7*0*0221##", + "who": "4", + "what": null, + "where": "7", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818336.34607, + "iso_time": "2026-11-04T18:52:16.346070+00:00", + "direction": "rx", + "raw": "*#4*10*0*0176##", + "who": "4", + "what": null, + "where": "10", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818336.61614, + "iso_time": "2026-11-04T18:52:16.616140+00:00", + "direction": "rx", + "raw": "*#4*11*0*0159##", + "who": "4", + "what": null, + "where": "11", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818362.27884, + "iso_time": "2026-11-04T18:52:42.278840+00:00", + "direction": "tx", + "raw": "*#4*0##", + "who": "4", + "what": null, + "where": "0", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818395.829492, + "iso_time": "2026-11-04T18:53:15.829492+00:00", + "direction": "rx", + "raw": "*#4*13#1*20*0##", + "who": "4", + "what": null, + "where": "13", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818401.78336, + "iso_time": "2026-11-04T18:53:21.783360+00:00", + "direction": "tx", + "raw": "*#4*2*14##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818401.938001, + "iso_time": "2026-11-04T18:53:21.938001+00:00", + "direction": "rx", + "raw": "*#4*2*14*0200*3##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818415.062404, + "iso_time": "2026-11-04T18:53:35.062404+00:00", + "direction": "tx", + "raw": "*#4*2##", + "who": "4", + "what": null, + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818415.695316, + "iso_time": "2026-11-04T18:53:35.695316+00:00", + "direction": "rx", + "raw": "*#4*2*0*0238##", + "who": "4", + "what": null, + "where": "2", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818416.01578, + "iso_time": "2026-11-04T18:53:36.015780+00:00", + "direction": "rx", + "raw": "*#4*2*12*0230*3##", + "who": "4", + "what": null, + "where": "2", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818416.035945, + "iso_time": "2026-11-04T18:53:36.035945+00:00", + "direction": "rx", + "raw": "*4*1*2##", + "who": "4", + "what": "1", + "where": "2", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818416.324466, + "iso_time": "2026-11-04T18:53:36.324466+00:00", + "direction": "rx", + "raw": "*#4*2*14*0200*3##", + "who": "4", + "what": null, + "where": "2", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818416.394475, + "iso_time": "2026-11-04T18:53:36.394475+00:00", + "direction": "rx", + "raw": "*#4*2*13*00##", + "who": "4", + "what": null, + "where": "2", + "dimension": "13", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818441.335603, + "iso_time": "2026-11-04T18:54:01.335603+00:00", + "direction": "tx", + "raw": "*#4*1##", + "who": "4", + "what": null, + "where": "1", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818441.963426, + "iso_time": "2026-11-04T18:54:01.963426+00:00", + "direction": "rx", + "raw": "*#4*1*0*0227##", + "who": "4", + "what": null, + "where": "1", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818442.25733, + "iso_time": "2026-11-04T18:54:02.257330+00:00", + "direction": "rx", + "raw": "*#4*1*12*0220*3##", + "who": "4", + "what": null, + "where": "1", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818442.281966, + "iso_time": "2026-11-04T18:54:02.281966+00:00", + "direction": "rx", + "raw": "*4*1*1##", + "who": "4", + "what": "1", + "where": "1", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818442.577002, + "iso_time": "2026-11-04T18:54:02.577002+00:00", + "direction": "rx", + "raw": "*#4*1*14*0200*3##", + "who": "4", + "what": null, + "where": "1", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818442.660932, + "iso_time": "2026-11-04T18:54:02.660932+00:00", + "direction": "rx", + "raw": "*#4*1*13*00##", + "who": "4", + "what": null, + "where": "1", + "dimension": "13", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818445.880375, + "iso_time": "2026-11-04T18:54:05.880375+00:00", + "direction": "tx", + "raw": "*#4*3##", + "who": "4", + "what": null, + "where": "3", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818446.512246, + "iso_time": "2026-11-04T18:54:06.512246+00:00", + "direction": "rx", + "raw": "*#4*3*0*0227##", + "who": "4", + "what": null, + "where": "3", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818446.812149, + "iso_time": "2026-11-04T18:54:06.812149+00:00", + "direction": "rx", + "raw": "*#4*3*12*0220*3##", + "who": "4", + "what": null, + "where": "3", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818446.834702, + "iso_time": "2026-11-04T18:54:06.834702+00:00", + "direction": "rx", + "raw": "*4*1*3##", + "who": "4", + "what": "1", + "where": "3", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818447.121932, + "iso_time": "2026-11-04T18:54:07.121932+00:00", + "direction": "rx", + "raw": "*#4*3*14*0200*3##", + "who": "4", + "what": null, + "where": "3", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818447.20123, + "iso_time": "2026-11-04T18:54:07.201230+00:00", + "direction": "rx", + "raw": "*#4*3*13*00##", + "who": "4", + "what": null, + "where": "3", + "dimension": "13", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818450.488107, + "iso_time": "2026-11-04T18:54:10.488107+00:00", + "direction": "tx", + "raw": "*#4*4##", + "who": "4", + "what": null, + "where": "4", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818451.112454, + "iso_time": "2026-11-04T18:54:11.112454+00:00", + "direction": "rx", + "raw": "*#4*4*0*0219##", + "who": "4", + "what": null, + "where": "4", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818451.432755, + "iso_time": "2026-11-04T18:54:11.432755+00:00", + "direction": "rx", + "raw": "*#4*4*12*0215*3##", + "who": "4", + "what": null, + "where": "4", + "dimension": "12", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818451.439515, + "iso_time": "2026-11-04T18:54:11.439515+00:00", + "direction": "rx", + "raw": "*4*1*4##", + "who": "4", + "what": "1", + "where": "4", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818451.72143, + "iso_time": "2026-11-04T18:54:11.721430+00:00", + "direction": "rx", + "raw": "*#4*4*14*0200*3##", + "who": "4", + "what": null, + "where": "4", + "dimension": "14", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818451.800081, + "iso_time": "2026-11-04T18:54:11.800081+00:00", + "direction": "rx", + "raw": "*#4*4*13*00##", + "who": "4", + "what": null, + "where": "4", + "dimension": "13", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818473.323814, + "iso_time": "2026-11-04T18:54:33.323814+00:00", + "direction": "rx", + "raw": "*#4*4#1*20*0##", + "who": "4", + "what": null, + "where": "4", + "dimension": "20", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818576.188791, + "iso_time": "2026-11-04T18:56:16.188791+00:00", + "direction": "rx", + "raw": "*#4*13*0*0215##", + "who": "4", + "what": null, + "where": "13", + "dimension": "0", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1793818708.296491, + "iso_time": "2026-11-04T18:58:28.296491+00:00", + "direction": "rx", + "raw": "*#4*5#1*20*0##", + "who": "4", + "what": null, + "where": "5", + "dimension": "20", + "is_ack": false, + "is_nack": false + } + ] +} \ No newline at end of file diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index 981903a82..b314610d7 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -117,12 +117,12 @@ async def test_f461_zone_still_asks_for_its_humidity(hass): assert _sent(handler) == ["*#4*2##", "*#4*2*60##"] -@pytest.mark.parametrize("model", ["F454", "MH202"]) -async def test_refusing_gateway_zone_does_not_ask(hass, model): - """No ``*60##``; the SCS-only zone's own temperature poll (``*#4*Z*0##``, #794) still goes out.""" +@pytest.mark.parametrize(("model", "expected"), [("F454", ["*#4*2##"]), ("MH202", ["*#4*2##"]), ("MH200", ["*#4*2*0##"])]) +async def test_refusing_gateway_zone_does_not_ask(hass, model, expected): + """No ``*60##``; the zone's own status (F454, #866) or temperature (MH202, #794) poll still goes out.""" handler = _handler(hass, model) await _humidity_zone(hass, handler).async_update() - assert _sent(handler) == ["*#4*2*0##"] + assert _sent(handler) == expected async def test_gateway_without_the_property_is_still_asked(hass): @@ -425,7 +425,7 @@ async def test_a_humidity_event_delivered_on_a_refusing_gateway_enables_humidity or a zone pre-enabled with ``humidity: true``) is taken as on any gateway, and the MH202 zone still does not ask ``*#4*2*60##``. No trace claims an MH202 emits such a frame. """ - handler = _handler(hass, "MH202") + handler = _handler(hass, "MH200") zone = MyHOMEClimate( hass=hass, name="Zone 2", device_id="4-2", who="4", where="2", heating=True, cooling=True, fan=False, humidity=False, standalone=True, central=False, @@ -439,12 +439,12 @@ async def test_a_humidity_event_delivered_on_a_refusing_gateway_enables_humidity assert zone._humidity is True assert zone.current_humidity == 45.0 await zone.async_update() - assert _sent(handler) == ["*#4*2*0##"] # humidity known, dimension 60 still not asked on the MH202; its temperature poll (#794) is + assert _sent(handler) == ["*#4*2*0##"] # humidity known, dimension 60 still not asked on the MH200; its temperature poll (#794) is def test_humidity_enabled_later_still_polls_on_add_on_a_refusing_gateway(hass): """``_poll_on_add`` is untouched: the first update runs so the identity can settle before the gate decides.""" - handler = _handler(hass, "MH202") + handler = _handler(hass, "MH200") zone = MyHOMEClimate( hass=hass, name="Zone 2", device_id="4-2", who="4", where="2", heating=True, cooling=True, fan=False, humidity=False, standalone=True, central=False, diff --git a/tests/test_f461_live_plant.py b/tests/test_f461_live_plant.py index 2a69005d0..c7a14e4de 100644 --- a/tests/test_f461_live_plant.py +++ b/tests/test_f461_live_plant.py @@ -134,7 +134,7 @@ def test_f461_supports_zone_status_and_f455_does_not() -> None: from custom_components.myhome.gateway import MyHOMEGatewayHandler handler = MagicMock(spec=MyHOMEGatewayHandler) - for model, expected in (("F461", True), ("f461", True), ("F455", False), ("F454", False), ("MH200N", False), ("MyHomeServer1", True)): + for model, expected in (("F461", True), ("f461", True), ("F455", False), ("F454", True), ("MH200N", False), ("MyHomeServer1", True)): handler.model = model assert MyHOMEGatewayHandler.supports_zone_status.fget(handler) is expected, model diff --git a/tests/test_issue_466_hl4684_trace.py b/tests/test_issue_466_hl4684_trace.py index 35ef3bfc1..38534f6d7 100644 --- a/tests/test_issue_466_hl4684_trace.py +++ b/tests/test_issue_466_hl4684_trace.py @@ -101,10 +101,8 @@ def test_hl4684_trace_fixture_integrity_and_parser_coverage() -> None: "H4684", "L4684", "BTicino H/L4684", - "003598", # F454 "003594", # F455 "003565", # MH200N - "003535", # MH202 "573992", # F453AV ], ) @@ -126,6 +124,32 @@ def test_scs_gateway_aliases_disable_point_to_point_zone_status( assert handler.supports_zone_status is False +@pytest.mark.parametrize( + "model", + [ + "003598", # F454 + "003535", # MH202 (#866) + "03535", # MH202 alternate catalogue code (#866) + ], +) +def test_catalogue_codes_enable_point_to_point_zone_status( + hass: HomeAssistant, model: str +) -> None: + """F454 and MH202 catalogue codes enable *#4*Z## zone polling (#866).""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.0.2.10", + CONF_PORT: 20000, + CONF_PASSWORD: "12345", + CONF_MAC: "00:03:50:aa:bb:cc", + CONF_NAME: model, + }, + ) + handler = MyHOMEGatewayHandler(hass, entry) + assert handler.supports_zone_status is True + + def test_hl4684_ssdp_profile_identity_and_fw6_who22(hass: HomeAssistant) -> None: """SSDP H/L4684 binds H4684Profile, dynamically enables WHO 22 on FW >= 6.0.0, and corroborates code 23.""" # Start without CONF_FIRMWARE so we also verify dynamic profile refresh on *#13**16*6*0*11##! diff --git a/tests/test_issue_674_f455_climate_trace.py b/tests/test_issue_674_f455_climate_trace.py index 016b1120b..03dbde22c 100644 --- a/tests/test_issue_674_f455_climate_trace.py +++ b/tests/test_issue_674_f455_climate_trace.py @@ -5,8 +5,8 @@ gateway (issue #674) replay cleanly through the event dispatcher without exceptions. 2. All thermoregulation entities (temperatures, setpoints, offsets, valve states, humidity) are accurately populated from the spontaneous WHO 4 bus sweep telemetry. -3. Physical gateways (F455, F454, MH200N, MH202, etc.) correctly report - `supports_zone_status is False`, whereas MyHomeServer1 reports `True`. +3. Gateways without verified multi-session *#4*Z## support (F455, MH200N, MH202, etc.) + report `supports_zone_status is False`, whereas F454 and MyHomeServer1 report `True`. 4. Regular climate zones on physical gateways have `_poll_on_add is False` and skip sending bare status requests (*#4*Z##) in `async_update()`, completely eliminating the 10-second command session timeout loop experienced in issue #674. @@ -198,25 +198,18 @@ def test_gateway_supports_zone_status_property(hass: HomeAssistant) -> None: handler = MyHOMEGatewayHandler(hass, config_entry) - # 1. F455 - handler.gateway.model_name = "F455" - assert handler.supports_zone_status is False - - # 2. F454 - handler.gateway.model_name = "F454" - assert handler.supports_zone_status is False - - # 3. MH200N - handler.gateway.model_name = "MH200N" - assert handler.supports_zone_status is False - - # 4. MH202 - handler.gateway.model_name = "MH202" - assert handler.supports_zone_status is False + from OWNd.profiles import get_gateway_profile - # 5. MyHomeServer1 - handler.gateway.model_name = "MyHomeServer1" - assert handler.supports_zone_status is True + for model, expected in ( + ("F455", False), + ("F454", True), + ("MH200N", False), + ("MH202", True), + ("MyHomeServer1", True), + ): + handler.gateway.model_name = model + handler.gateway.profile = get_gateway_profile(model) + assert handler.supports_zone_status is expected, model def test_issue_674_timeout_frames_analysis() -> None: diff --git a/tests/test_issue_866_f454_zone_status_trace.py b/tests/test_issue_866_f454_zone_status_trace.py new file mode 100644 index 000000000..9948eb9c1 --- /dev/null +++ b/tests/test_issue_866_f454_zone_status_trace.py @@ -0,0 +1,385 @@ +"""Tests for #866: BTicino F454 FW 2.0 standalone thermostat trace replay & *#4*Z## zone status poll. + +Verifies from the live F454 FW 2.0 trace captured on @xduss's 13-zone standalone plant +(tests/fixtures/traces/issue_866/myhome_trace_F454_all_2026-11-04T19-00-04.json): +1. F454 drops the general heating status sweep (*#4*0##) on live hardware (both manual + sweeps at 18:51:53 and 18:52:42 returned zero zone frames), matching own-firmware-oracle + F454 020051 (trace-corpus.tsv). +2. Standalone thermostats (no #0 central unit) answer per-zone status queries (*#4*1##..*#4*4##) + in ~1.31-1.33 s with all five frames: Dimension 0 temperature, Dimension 12 effective + setpoint, *4*1*Z## heating mode, Dimension 14 manual setpoint, and Dimension 13 local offset. +3. F454 and its Legrand catalogue code 003598 set supports_zone_status = True and + zone_temperature_poll_needed = False, polling *#4*Z## on add so cold-start standalone + zones initialize hvac_mode and target_temperature (preserving Dimension 12 over trailing + Dimension 14) without a #0 central unit, and dynamically discovered zones (zone 13) also + poll *#4*13## immediately on add. +4. CommandWorkerPool harvests partial OWNNackError.responses when a status request ends with + *#*0## after partial replies, hydrating any answered dimensions and counting the zone as + answered in PollHealth. +""" +from __future__ import annotations + +import asyncio +import json +import time +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT +from OWNd.connection import OWNGateway +from OWNd.exceptions import OWNNackError +from OWNd.message import OWNHeatingCommand, OWNMessage + +from custom_components.myhome.bus_monitor import BusMonitor +from custom_components.myhome.climate import MyHOMEClimate +from custom_components.myhome.climate import async_setup_entry as climate_setup +from custom_components.myhome.const import ( + CONF_ENTITY, + CONF_FIRMWARE, + CONF_PLATFORMS, + CONF_WHERE, + DOMAIN, +) +from custom_components.myhome.gateway import ( + MyHOMEGatewayHandler, + command_session_default, + command_session_limit, +) +from custom_components.myhome.gateway_sessions import ( + COMMAND_SESSION_IDLE_TIMEOUT, + CommandWorkerPool, +) +from tests.conftest import attach_platform, attach_runtime +from tests.test_climate_actuator_status import _replay, _trace_gateway, _trace_plant + +MAC = "00:03:50:00:08:66" +TRACE_FILE = ( + Path(__file__).resolve().parent + / "fixtures" + / "traces" + / "issue_866" + / "myhome_trace_F454_all_2026-11-04T19-00-04.json" +) + + +@pytest.fixture(scope="module") +def issue_866_archive() -> dict[str, Any]: + """Load the authentic 129-frame F454 FW 2.0 trace archive from issue #866.""" + assert TRACE_FILE.is_file(), f"Missing trace fixture: {TRACE_FILE}" + return json.loads(TRACE_FILE.read_text(encoding="utf-8")) + + +def _entry(model: str, firmware: str = "2.0") -> MagicMock: + entry = MagicMock() + entry.entry_id = f"entry-866-{model}" + entry.data = { + CONF_HOST: "192.0.2.86", + CONF_PORT: 20000, + CONF_PASSWORD: "x", + CONF_NAME: model, + CONF_FIRMWARE: firmware, + CONF_MAC: MAC, + } + entry.runtime_data = None + return entry + + +def _handler(hass, model: str = "F454") -> MyHOMEGatewayHandler: + handler = MyHOMEGatewayHandler(hass, _entry(model)) + handler.send_status_request = AsyncMock() + return handler + + +def _live_f454_gateway(hass, mac: str = MAC) -> MagicMock: + gw = _trace_gateway("F454", mac) + real = _handler(hass, "F454") + gw.supports_zone_status = real.supports_zone_status + gw.zone_temperature_poll_needed = real.zone_temperature_poll_needed + gw.humidity_request_refused = real.humidity_request_refused + gw.interface_zone_request_refused = real.interface_zone_request_refused + return gw + + +def _status_requests(gw: MagicMock) -> list[str]: + return [str(call.args[0]) for call in gw.send_status_request.await_args_list] + + +def test_issue_866_trace_archive_metadata_and_who4_invariants( + issue_866_archive: dict[str, Any], +) -> None: + """Verify live F454 FW 2.0 trace structure, *#4*0## silence, and 5-frame *#4*Z## replies.""" + gw = issue_866_archive["gateway"] + assert gw["model"] == "F454" + assert gw["firmware"] == "2.0" + assert gw["identification"]["profile"] == "F454Profile" + + frames = issue_866_archive["frames"] + assert len(frames) == 129 + assert issue_866_archive["telemetry"]["total_rx"] == 86 + assert issue_866_archive["telemetry"]["total_tx"] == 43 + + # Pure standalone thermostat plant: zero #0 central-unit frames anywhere in the capture + assert not any(f.get("where") == "#0" or "*#0#" in f["raw"] for f in frames) + + # Both manual *#4*0## sweeps produced zero immediate replies (dropped by F454 firmware) + sweep_indices = [ + idx for idx, f in enumerate(frames) if f["direction"] == "tx" and f["raw"] == "*#4*0##" + ] + assert len(sweep_indices) == 2 + for idx in sweep_indices: + next_frame = frames[idx + 1] + assert next_frame["timestamp"] - frames[idx]["timestamp"] > 4.0 + + # Manual *#4*Z## status polls for zones 2, 1, 3, 4 each answered all 5 frames in ~1.31-1.34 s + expected_replies = { + "2": ["*#4*2*0*0238##", "*#4*2*12*0230*3##", "*4*1*2##", "*#4*2*14*0200*3##", "*#4*2*13*00##"], + "1": ["*#4*1*0*0227##", "*#4*1*12*0220*3##", "*4*1*1##", "*#4*1*14*0200*3##", "*#4*1*13*00##"], + "3": ["*#4*3*0*0227##", "*#4*3*12*0220*3##", "*4*1*3##", "*#4*3*14*0200*3##", "*#4*3*13*00##"], + "4": ["*#4*4*0*0219##", "*#4*4*12*0215*3##", "*4*1*4##", "*#4*4*14*0200*3##", "*#4*4*13*00##"], + } + for zone, expected in expected_replies.items(): + tx_idx = next( + i for i, f in enumerate(frames) if f["direction"] == "tx" and f["raw"] == f"*#4*{zone}##" + ) + replies = frames[tx_idx + 1 : tx_idx + 6] + assert [r["raw"] for r in replies] == expected + elapsed = replies[-1]["timestamp"] - frames[tx_idx]["timestamp"] + assert 1.2 < elapsed < 1.5, (zone, elapsed) + + +@pytest.mark.parametrize("model", ["F454", "BTicino F454", "003598"]) +async def test_f454_and_003598_enable_zone_status_and_skip_general_and_temp_polls( + hass, model: str +) -> None: + """F454 (up to 4 command sessions) polls *#4*Z## per zone instead of *#4*0## or *#4*Z*0##.""" + handler = _handler(hass, model) + assert handler._model_key == "F454" + assert handler.supports_zone_status is True + assert handler.general_zone_request_refused is True + assert "*#4*0##" not in handler._discovery_frames() + assert handler.zone_temperature_poll_needed is False + assert handler.actuator_request_refused is False + assert handler.humidity_request_refused is True + assert command_session_default("F454") == 1 + assert command_session_limit("F454") == 4 + + zone = MyHOMEClimate( + hass=hass, + name="Zone 1", + device_id="4-1", + who="4", + where="1", + heating=True, + cooling=False, + fan=False, + standalone=True, + central=False, + manufacturer="BTicino", + model="F454", + gateway=handler, + ) + zone.hass = hass + zone.entity_id = "climate.zone_1" + zone.async_write_ha_state = MagicMock() + + assert zone._poll_on_add is True + assert zone._polls_temperature is False + await zone.async_update() + assert _status_requests(handler) == ["*#4*1##"] + + +async def test_issue_866_cold_start_replay_hydrates_standalone_zones_and_preserves_dim12_setpoint( + hass, issue_866_archive: dict[str, Any] +) -> None: + """Replay all 86 RX frames from a cold start (no RestoreEntity, no #0 central unit).""" + gw = _live_f454_gateway(hass) + # Match @xduss's startup configuration: zones 1..12 + unpopulated 14 configured; + # zone 13 was unconfigured and discovered dynamically from *#4*13*0*0216## at 18:24:11. + configured_zones = [*(str(z) for z in range(1, 13)), "14"] + platforms_cfg = { + "climate": { + f"climate_zone_{z}": { + CONF_WHERE: z, + "who": "4", + "zone": z, + "name": f"Climate Zone {z}", + "heat": True, + "cool": True, + "standalone": True, + "central": False, + "manufacturer": "BTicino", + "model": gw.model, + } + for z in configured_zones + }, + } + hass.data = {DOMAIN: {gw.mac: {CONF_ENTITY: gw, CONF_PLATFORMS: platforms_cfg}}} + entry = MagicMock() + entry.data = {"mac": gw.mac} + entry.entry_id = "trace_F454_866" + entry.options = {} + attach_runtime(hass, entry, gw.mac, gw) + + entities: dict[str, MyHOMEClimate] = {} + added: list[MyHOMEClimate] = [] + pending_tasks: list[asyncio.Task[None]] = [] + + def _add_entities(new_entities: list[MyHOMEClimate], update_before_add: bool = False) -> None: + for ent in new_entities: + ent.hass = hass + ent.entity_id = f"climate.{ent._device_id}" + ent.async_write_ha_state = MagicMock() + ent.async_schedule_update_ha_state = MagicMock() + attach_platform(ent, entry) + added.append(ent) + entities[ent._where] = ent + pending_tasks.append(hass.async_create_task(ent.async_added_to_hass())) + + with ( + patch("custom_components.myhome.discovery.er.async_entries_for_config_entry", return_value=[]), + patch("custom_components.myhome.discovery.er.async_get", return_value=MagicMock()), + ): + await climate_setup(hass, entry, _add_entities) + await asyncio.gather(*pending_tasks) + pending_tasks.clear() + + # Every configured zone immediately polls *#4*Z## (never *#4*Z*0##) after setup + initial_polls = _status_requests(gw) + assert initial_polls == [f"*#4*{z}##" for z in configured_zones] + gw.send_status_request.reset_mock() + + rx_frames = [f["raw"] for f in issue_866_archive["frames"] if f["direction"] == "rx"] + assert len(rx_frames) == 86 + for raw in rx_frames: + _replay(hass, gw.mac, [raw]) + if pending_tasks: + await asyncio.gather(*pending_tasks) + pending_tasks.clear() + await hass.async_block_till_done() + + # Zone 13 was dynamically discovered from *#4*13*0*0216## and immediately polled *#4*13## on add + assert len(added) == len(configured_zones) + 1 + zone13 = entities["13"] + assert _status_requests(gw) == ["*#4*13##"] + assert zone13.current_temperature == 21.5 + assert zone13.extra_state_attributes["actuators"] == {"1": "off"} + + # Zones 1..4 received full 5-frame *#4*Z## replies: hvac_mode is HEAT without any #0 central + # unit, and Dimension 12 effective setpoint wins over trailing Dimension 14 (20.0 °C manual). + expected_hydrated = { + "1": (22.7, 22.0), + "2": (23.8, 23.0), + "3": (22.7, 22.0), + "4": (21.9, 21.5), + } + for z, (curr_temp, target_temp) in expected_hydrated.items(): + entity = entities[z] + assert entity.hvac_mode == HVACMode.HEAT, z + assert entity.current_temperature == curr_temp, z + assert entity.target_temperature == target_temp, z + assert entity.hvac_action == HVACAction.IDLE, z + assert entity.extra_state_attributes["actuators"] == {"1": "off"}, z + assert entity.extra_state_attributes["knob_pos"] == "0", z + assert entity.extra_state_attributes["local_offset"] == 0.0, z + + # Zone 6 reported active heating actuator (*#4*6#1*20*1##); Zone 8 reported dual actuators + assert entities["6"].current_temperature == 22.3 + assert entities["6"].extra_state_attributes["actuators"] == {"1": "on"} + assert entities["8"].current_temperature == 21.8 + assert entities["8"].extra_state_attributes["actuators"] == {"1": "off", "2": "off"} + + # Unpopulated Zone 14 never answered any bus query + assert entities["14"].current_temperature is None + assert entities["14"].target_temperature is None + assert entities["14"].hvac_mode is None + + +async def test_partial_nack_status_request_harvests_ownd_responses_and_counts_zone_answered( + hass, +) -> None: + """When *#4*Z## ends with *#*0## after partial frames, OWNNackError.responses still hydrate the zone.""" + handler = MagicMock(spec=MyHOMEGatewayHandler) + handler.log_id = "[F454:00:03:50:00:08:66]" + handler.mac = MAC + handler.unique_id = MAC + handler.bus_group = MAC + handler.primary_gateway_mac = None + handler.delegated_whos = set() + handler.delegated_away_whos = set() + handler.bus_monitor = BusMonitor() + handler.gateway = MagicMock(spec=OWNGateway) + handler.gateway.profile = MagicMock() + handler.gateway.profile.max_queue_size = 250 + handler.gateway.profile.command_queue_delay = 0 + handler.command_session_idle_timeout = COMMAND_SESSION_IDLE_TIMEOUT + handler.hass = hass + handler.is_connected = True + handler.initial_discovery_pending = False + handler.sweep_active = False + handler.has_central_unit = False + handler.supports_zone_status = True + handler.zone_temperature_poll_needed = False + handler.humidity_request_refused = True + handler.interface_zone_request_refused = True + handler.watch_zone_actuators = MagicMock(return_value=MagicMock()) + handler.request_zone_dimension = MagicMock() + handler._record_tx = MagicMock() + handler._bridge_to_primary = MagicMock() + handler._exposed_wheres = {} + + handler.model = "F454" + pool = CommandWorkerPool(handler) + pool._event_session_ready.set() + handler.send_status_request = AsyncMock() + entities, _ = await _trace_plant(hass, handler, ["5"]) + zone5 = entities["5"] + handler.send_status_request = pool.send_status_request + + already_seen = OWNMessage.parse("*#4*5*0*0220##") + mode_reply = OWNMessage.parse("*4*1*5##") + setpoint_reply = OWNMessage.parse("*#4*5*12*0215*3##") + assert already_seen is not None and mode_reply is not None and setpoint_reply is not None + + session = MagicMock() + session.connect = AsyncMock(return_value={"Success": True}) + session.close = AsyncMock() + session._stream_reader = object() + session._stream_writer = object() + + async def partial_nack_send(message: Any, is_status_request: bool = False) -> None: + # Simulate *#4*5*0*0220## already seen on the event session during the request window, + # while *4*1*5## and *#4*5*12*0215*3## arrived only on the command session before *#*0##. + handler.bus_monitor.record_frame(direction="rx", raw="*#4*5*0*0220##", parsed=already_seen) + session.last_error = OWNNackError( + f"Command failed with NACK: {message}", + frame=str(message), + responses=[already_seen, mode_reply, setpoint_reply], + ) + return None + + session.send = AsyncMock(side_effect=partial_nack_send) + + with patch("custom_components.myhome.gateway.OWNCommandSession", return_value=session): + worker = asyncio.create_task(pool.sending_loop(0)) + pool.sending_workers = [worker] + try: + frames_before = zone5._poll_health.frames + written = await pool.send_status_request(OWNHeatingCommand.status("5")) + assert isinstance(written, asyncio.Future) + written.add_done_callback(lambda f: zone5._poll_answered(f, frames_before)) + await asyncio.sleep(0.05) + await hass.async_block_till_done() + + # Write future is cancelled on NACK, yet partial frames hydrated mode & setpoint + assert written.cancelled() is True + assert zone5.hvac_mode == HVACMode.HEAT + assert zone5.target_temperature == 21.5 + assert zone5._poll_health.frames > frames_before + assert zone5._poll_health.should_skip(time.time()) is False + handler._bridge_to_primary.assert_called() + finally: + pool.close() + await worker diff --git a/tests/test_startup_requests_per_firmware.py b/tests/test_startup_requests_per_firmware.py index e9c8d8d5e..a75048fa1 100644 --- a/tests/test_startup_requests_per_firmware.py +++ b/tests/test_startup_requests_per_firmware.py @@ -138,16 +138,21 @@ async def test_sweep_bus_leaves_out_the_refused_requests_unless_forced(hass, att @pytest.mark.parametrize( - ("model", "profile_model"), - [("L4684", "H4684"), ("NT4684", "H4684"), ("003598", "F454"), ("03535", "MH202")], + ("model", "profile_model", "zone_status", "temp_poll"), + [ + ("L4684", "H4684", False, True), + ("NT4684", "H4684", False, True), + ("003598", "F454", True, False), + ("03535", "MH202", True, False), + ], ) -def test_aliased_model_strings_follow_their_profile(hass, model, profile_model): - """OWNd resolves these spellings to a profile; the SCS-only gate follows the profile's model name.""" +def test_aliased_model_strings_follow_their_profile(hass, model, profile_model, zone_status, temp_poll): + """OWNd resolves these spellings to a profile; the zone-status and temperature gates follow it.""" handler = _handler(hass, model) assert handler.gateway.profile.model_name == profile_model assert handler._model_key == profile_model - assert handler.supports_zone_status is False - assert handler.zone_temperature_poll_needed is True + assert handler.supports_zone_status is zone_status + assert handler.zone_temperature_poll_needed is temp_poll @pytest.mark.parametrize("ssdp_model", ["H/L4684", "HL4684", "HC4684"]) @@ -176,6 +181,18 @@ def test_profile_flag_for_the_general_heating_status_wins(hass, flag): assert ("*#4*0##" in handler._discovery_frames()) is flag +@pytest.mark.parametrize("flag", [True, False]) +def test_profile_flag_for_heating_zone_status_wins(hass, flag): + """An OWNd profile that states ``supports_heating_zone_status`` is believed over the fallback list.""" + handler = _handler(hass, "MH200" if flag else "F461") + handler.gateway.profile = SimpleNamespace( + model_name=handler.gateway.profile.model_name, + supports_heating_zone_status=flag, + supports_who=lambda who: True, + ) + assert handler.supports_zone_status is flag + + def test_profile_without_who_16_skips_the_sound_request(hass): handler = _handler(hass, "F454") handler.gateway.profile = SimpleNamespace(model_name="F454", supports_who=lambda who: who != 16) @@ -188,17 +205,17 @@ def test_profile_without_who_16_skips_the_sound_request(hass): @pytest.mark.parametrize( ("model", "expected"), [ - ("MH201", True), ("MH202", True), ("F450", True), ("F454", True), ("MH200", True), ("H4684", True), + ("MH201", True), ("F450", True), ("MH200", True), ("H4684", True), ("HL4684", True), ("H/L4684", True), ("F459", True), ("F455", False), ("F453", False), ("MH200N", False), # unknown, or the sweep answers - ("F460", False), ("F461", False), ("MyHomeServer1", False), ("Generic", False), # polled with *#4*Z## + ("F454", False), ("MH202", False), ("F460", False), ("F461", False), ("MyHomeServer1", False), ("Generic", False), # polled with *#4*Z## ], ) def test_which_gateways_need_a_zone_temperature_poll(hass, model, expected): assert _handler(hass, model).zone_temperature_poll_needed is expected -@pytest.mark.parametrize("model", ["MH201", "MH202"]) +@pytest.mark.parametrize("model", ["MH201", "MH200"]) async def test_scs_only_zone_asks_for_its_temperature(hass, model): handler = _handler(hass, model) handler.send_status_request = AsyncMock() @@ -258,7 +275,7 @@ async def test_withheld_interface_poll_also_withholds_humidity_and_fan_cross_pol @pytest.mark.parametrize( ("model", "flag", "expected_temp", "expected_frames"), [ - ("MH202", True, True, ["*#4*3#4#01*0##"]), + ("MH202", True, False, ["*#4*3#4#01##"]), ("H4684", True, True, ["*#4*3#4#01*0##"]), ("H4684", False, False, []), ("F461", True, False, ["*#4*3#4#01##"]), @@ -305,7 +322,7 @@ async def test_central_unit_is_not_a_temperature_poll(hass): def test_bus_discovered_zone_keeps_its_poll_until_a_temperature_arrives(hass): - polled = _zone(hass, _handler(hass, "MH202")) + polled = _zone(hass, _handler(hass, "MH200")) assert polled._polls_temperature is True assert message_has_state(polled, OWNHeatingEvent("*#4*3*14*0210*3##")) is False assert message_has_state(polled, OWNHeatingEvent("*#4*3*0*0215##")) is True @@ -316,7 +333,7 @@ def test_bus_discovered_zone_keeps_its_poll_until_a_temperature_arrives(hass): # ------------------------------------------------------------------ restored temperature async def test_current_temperature_is_restored_and_flagged_until_the_bus_reports(hass): - zone = _zone(hass, _handler(hass, "MH202")) + zone = _zone(hass, _handler(hass, "MH200")) await zone.async_restore_last_state( State("climate.zone_3", "heat", {"temperature": 21.0, "current_temperature": 23.9}) ) @@ -328,7 +345,7 @@ async def test_current_temperature_is_restored_and_flagged_until_the_bus_reports async def test_a_malformed_restored_temperature_is_ignored(hass): - zone = _zone(hass, _handler(hass, "MH202")) + zone = _zone(hass, _handler(hass, "MH200")) await zone.async_restore_last_state(State("climate.zone_3", "heat", {"current_temperature": "warm"})) assert zone.current_temperature is None assert "current_temperature_restored" not in zone.extra_state_attributes @@ -337,7 +354,7 @@ async def test_a_malformed_restored_temperature_is_ignored(hass): @pytest.mark.parametrize( ("model", "flag", "expected"), [ - ("F454", True, False), # listed in the fallback table, but the profile says the sweep is answered + ("MH200", True, False), # listed in the fallback table, but the profile says the sweep is answered ("MH201", True, False), # the #185 field-report entry yields to a stated profile flag too ("F453", False, True), # not in the table; the profile says the sweep is refused ("F461", False, False), # polled with *#4*Z##: never a temperature poll @@ -411,7 +428,7 @@ def test_f461_capture_answers_the_populated_zone_and_nacks_the_unpopulated_one(f async def test_captured_f461_dimension_0_reply_clears_a_restored_temperature(hass, f461_rows): """The request an SCS-only zone 2 sends is the captured *#4*2*0##; the captured reply replaces the restored value.""" run = "run1_oracle_replay_readonly" - handler = _handler(hass, "MH202") + handler = _handler(hass, "MH200") handler.send_status_request = AsyncMock() zone = _zone(hass, handler, where="2") await zone.async_restore_last_state(State("climate.zone_2", "heat", {"current_temperature": 21.0})) @@ -430,7 +447,7 @@ async def test_captured_f461_dimension_0_reply_clears_a_restored_temperature(has async def test_dimension_0_poll_result_is_not_counted_against_the_zone(hass): """No _poll_answered on *#4*Z*0##: a NACK (cancelled write) must not mark a healthy SCS zone unresponsive.""" - handler = _handler(hass, "F454") + handler = _handler(hass, "MH200") written: asyncio.Future[float] = asyncio.get_running_loop().create_future() written.cancel() handler.send_status_request = AsyncMock(return_value=written)