From 667520c0f3adf47c97da6e9c1fb7c33306385f70 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 16:13:22 +0200 Subject: [PATCH 1/5] fix(climate): ask silent F455 zones their state once after the startup sweep (#466) A physical F455 1.0.76 answers the single-dimension reads *#4*Z*0##, *#4*Z*14## and *#4*Z*12## in ~0.1 s and the actuator form *#4*Z#1*20## in 0.09 s, but ACKs *#4*Z## only after ~9 s (the issue_674 timeouts) and returns nothing for *#4*0## (tests/fixtures/traces/issue_466/F455_direct_requests_2026-11-06.jsonl); its standalone zone 2 reported nothing on its own in four minutes of debug log. The F455 was on neither the zone status poll nor the temperature poll, so a quiet zone had no temperature, setpoint or mode after a restart. Nothing the issue_674 fixes decided is undone: F455 zones still ask nothing on add, stay off the *#4*Z## and *#4*Z*0## polls, and the startup sweep is unchanged. Added: - SILENT_ZONE_POLL = {"F455"} and SILENT_ZONE_GRACE = 120 s (gateway.py, with silent_zone_poll_needed). - MyHOMEClimate._poll_if_silent: 120 s after the startup sweep, a zone still without a temperature (a restored one does not count) or a setpoint of its own asks *#4*Z*0##, then *#4*Z*14##, *#4*Z*12## (its reply is followed by the zone's mode frame) and *#4*Z#1*20##, each only once the previous read was answered; a refused or unanswered read ends it, so a zone without a probe costs one request. The actuator is asked last because hvac_action is derived when an actuator report finds the mode known. tests/test_f455_zone_polling.py ties the unchanged gates, the capture behind each read and the poll itself together. Squash of the three development commits of #873 (the first two added F455 to the poll tables and a ZONE_SETPOINT_POLL, the third reverted that in favour of this mechanism), rebased on #867 and its ZONE_TEMPERATURE_POLL table. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 54 +++++- custom_components/myhome/gateway.py | 17 +- tests/test_f455_zone_polling.py | 259 ++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 tests/test_f455_zone_polling.py diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 54ca170dc..ea0663cb6 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -77,7 +77,7 @@ default_known_keys, is_broadcast_where, ) -from .gateway import _ZONE_WHERE, MyHOMEGatewayHandler +from .gateway import _ZONE_WHERE, SILENT_ZONE_GRACE, MyHOMEGatewayHandler from .myhome_device import MyHOMEEntity from .poll_health import PollHealth from .thermo_switch import load_thermo_switch_subentries @@ -95,6 +95,8 @@ PLATFORM = Platform.CLIMATE PARALLEL_UPDATES = 0 +# How long one read of the silent-zone poll (#466) waits for its answer before giving up. +PACED_READ_TIMEOUT = 60.0 _FOUR_ZONE_CENTRAL = ("#0#1", "#0#2", "#0#3", "#0#4") # BTicino 3550 central unit (#0) diagnostic WHAT codes (OpenWebNet Thermoregulation v2.0.0 Table 5): @@ -1160,6 +1162,56 @@ async def async_added_to_hass(self) -> None: watch = getattr(self._gateway_handler, "watch_zone_actuators", None) if callable(watch): self.async_on_remove(watch(self._full_where)) + if ( + getattr(self._gateway_handler, "silent_zone_poll_needed", False) is True + and not self._interface_poll_refused + and not is_actuator_where(self._where) + and target_hass is not None + ): + task = target_hass.async_create_background_task( + self._poll_if_silent(), name=f"myhome_{self.entity_id}_silent_zone_poll" + ) + + def _cancel_silent_zone_poll() -> None: + task.cancel() + + self.async_on_remove(_cancel_silent_zone_poll) + + async def _poll_if_silent(self, grace: float = SILENT_ZONE_GRACE) -> None: + """Ask a zone that has not reported by itself since the startup sweep, read by read (#466). + + Nothing goes out for a zone whose temperature and setpoint are known (a restored temperature + does not count). Each read waits for the + previous one to be answered, so a refused or unanswered read (a zone without a probe) ends + it after one request. The actuator is asked last: ``hvac_action`` is derived when an + actuator report finds the mode known. + """ + wait = getattr(self._gateway_handler, "wait_for_initial_discovery", None) + if callable(wait): + await wait() + await asyncio.sleep(grace) + if ( + self.current_temperature is not None + and not self._current_temperature_restored + and self.target_temperature is not None + ): + return # the zone reported its temperature and setpoint on its own + LOGGER.debug( + "%s %s has not reported since the startup sweep; asking its state", self._gateway_handler.log_id, self._display_name + ) + for frame in ( + f"*#4*{self._full_where}*0##", + f"*#4*{self._full_where}*14##", + f"*#4*{self._full_where}*12##", + f"*#4*{self._full_where}#1*20##", + ): + written: object = await self._gateway_handler.send_status_request( + cast(OWNCommand, OWNHeatingCommand.parse(frame)) + ) + if isinstance(written, asyncio.Future): + await asyncio.wait([written], timeout=PACED_READ_TIMEOUT) + if not written.done() or written.cancelled(): + return def _refuse_interface_write(self) -> bool: """Refuse WHO 4 writes that would strip ``#4#II`` and hit local main-bus ``_where``.""" diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 81a1372f5..1b4cc0b13 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -221,6 +221,16 @@ def command_session_default(model: str | None) -> int: # 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"}) +# Gateways whose zones still ask nothing on add (the F455: #674, sixteen *#4*Z## polls timing out +# one after another), but where a zone still without a temperature or setpoint of its own +# SILENT_ZONE_GRACE seconds after the startup sweep asks once, read by read: its temperature +# (*#4*Z*0##), and only if that was answered its setpoint (*#4*Z*14##), its probe status +# (*#4*Z*12##, followed by the mode frame) and actuator 1 (*#4*Z#1*20##). A physical F455 1.0.76 +# answers each in ~0.1 s and ACKs *#4*Z## only after ~9 s +# (issue_466/F455_direct_requests_2026-11-06.jsonl); its standalone zone 2 reported nothing on its +# own in four minutes (issue_466/F455_full_debug_log_2026-11-06T16-24-46.log). +SILENT_ZONE_POLL = frozenset({"F455"}) +SILENT_ZONE_GRACE = 120.0 # 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 @@ -265,7 +275,7 @@ def command_session_default(model: str | None) -> int: # The models the tables know, longest first so ``MH200N`` is not read as ``MH200``. _ZONE_REQUEST_MODELS = sorted( KNOWN_GATEWAY_MODELS | ACTUATOR_REQUEST_REFUSED | HUMIDITY_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS) - | GENERAL_ZONE_REQUEST_REFUSED | SOUND_STATUS_REQUEST_REFUSED | ZONE_TEMPERATURE_POLL, + | GENERAL_ZONE_REQUEST_REFUSED | SOUND_STATUS_REQUEST_REFUSED | ZONE_TEMPERATURE_POLL | SILENT_ZONE_POLL, key=len, reverse=True, ) @@ -712,6 +722,11 @@ def sound_status_request_refused(self) -> bool: return True return self._model_key in SOUND_STATUS_REQUEST_REFUSED + @property + def silent_zone_poll_needed(self) -> bool: + """Whether a zone silent after the startup sweep asks its own state once (``SILENT_ZONE_POLL``).""" + return self._model_key in SILENT_ZONE_POLL + @property def zone_temperature_poll_needed(self) -> bool: """Whether each zone must ask for its temperature (``*#4*Z*0##``) itself. diff --git a/tests/test_f455_zone_polling.py b/tests/test_f455_zone_polling.py new file mode 100644 index 000000000..9f42d901d --- /dev/null +++ b/tests/test_f455_zone_polling.py @@ -0,0 +1,259 @@ +"""#466: an F455 zone that stays silent after the startup sweep asks its own state once. + +What stays as it was (#674): F455 zones ask nothing on add, the F455 stays off the zone status poll +(``*#4*Z##``) and the temperature poll, and the startup sweep is unchanged. + +What is added, backed by the physical F455 1.0.76 capture +``tests/fixtures/traces/issue_466/F455_direct_requests_2026-11-06.jsonl`` (and its debug log, where the +standalone zone 2 reported nothing on its own): SILENT_ZONE_GRACE seconds after the sweep, a zone +still without a temperature or setpoint of its own asks ``*#4*Z*0##``, then ``*#4*Z*14##``, ``*#4*Z*12##`` (followed by the +zone's mode frame) and ``*#4*Z#1*20##``, each only once the previous read was answered. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT +from OWNd.message import OWNHeatingEvent +from OWNd.profiles import get_gateway_profile + +from custom_components.myhome.climate import MyHOMEClimate +from custom_components.myhome.const import CONF_FIRMWARE +from custom_components.myhome.gateway import ( + SILENT_ZONE_GRACE, + SILENT_ZONE_POLL, + MyHOMEGatewayHandler, +) + +DIRECT = Path(__file__).resolve().parent / "fixtures/traces/issue_466/F455_direct_requests_2026-11-06.jsonl" +MAC = "00:03:50:00:11:06" +SILENT_READS = ["*#4*2*0##", "*#4*2*14##", "*#4*2*12##", "*#4*2#1*20##"] + + +def _sent() -> dict[str, dict]: + records = [json.loads(line) for line in DIRECT.read_text(encoding="utf-8").splitlines() if line.strip()] + return {r["frame"]: r for r in records if r["kind"] == "sent"} + + +def _handler(hass, model: str) -> MyHOMEGatewayHandler: + entry = MagicMock() + entry.entry_id = f"entry-{model}" + entry.data = { + CONF_HOST: "192.0.2.55", + CONF_PORT: 20000, + CONF_PASSWORD: "x", + CONF_NAME: model, + CONF_FIRMWARE: "1.0.76", + CONF_MAC: MAC, + } + entry.runtime_data = None + handler = MyHOMEGatewayHandler(hass, entry) + handler.gateway.profile = get_gateway_profile(model) + return handler + + +def _zone(hass, handler, where: str = "2") -> MyHOMEClimate: + zone = MyHOMEClimate( + hass=hass, + name=f"Zone {where}", + device_id=f"4-{where}", + who="4", + where=where, + heating=True, + cooling=True, + fan=False, + standalone=True, + central=False, + manufacturer="BTicino", + model="Heating Zone", + gateway=handler, + ) + zone.hass = hass + zone.entity_id = f"climate.zone_{where}" + zone.async_write_ha_state = MagicMock() + return zone + + +def _queued(handler) -> list[str]: + return [str(call.args[0]) for call in handler.send_status_request.await_args_list] + + +async def _spin() -> None: + """Let the poll task run up to its next wait (not ``async_block_till_done``: it waits on it).""" + for _ in range(10): + await asyncio.sleep(0) + + +def _ready(handler, side_effect=None) -> None: + handler._initial_discovery_done.set() + handler.send_status_request = AsyncMock(side_effect=side_effect) + + +# ------------------------------------------------------------------ the capture behind it + + +def test_capture_backs_each_read_of_the_silent_zone_poll() -> None: + sent = _sent() + for frame, replies in ( + ("*#4*2*0##", ["*#4*2*0*0239##"]), + ("*#4*2*14##", ["*#4*2*14*0220*3##"]), + ("*#4*2*12##", ["*#4*2*12*0220*3##", "*4*0*2##"]), + ("*#4*2#1*20##", ["*#4*2#1*20*7##"]), + ("*#4*4*0##", ["*#4*4*0*0255##"]), + ("*#4*4*14##", ["*#4*4*14*0215*3##"]), + ("*#4*4*12##", ["*#4*4*12*0225*3##", "*4*0*4##"]), + ("*#4*4#1*20##", ["*#4*4#1*20*0##"]), + ): + assert sent[frame]["ack"] is True and sent[frame]["replies"] == replies, frame + assert sent[frame]["dt"] < 0.2, frame + + +def test_capture_backs_staying_off_the_zone_status_poll() -> None: + sent = _sent() + for frame in ("*#4*2##", "*#4*4##"): + assert sent[frame]["replies"] is True and 8.5 < sent[frame]["dt"] < 9.5 + + +# ------------------------------------------------------------------ what stays as it was + + +def test_f455_keeps_the_issue_674_gates(hass) -> None: + handler = _handler(hass, "F455") + assert handler.silent_zone_poll_needed is True + assert handler.supports_zone_status is False + assert handler.zone_temperature_poll_needed is False + assert handler.general_zone_request_refused is False + assert handler.actuator_request_refused is False + assert "*#4*0##" in handler._discovery_frames() + assert SILENT_ZONE_POLL == {"F455"} and SILENT_ZONE_GRACE == 120.0 + + +@pytest.mark.parametrize("model", ["F450", "F454", "F459", "F461", "MH200", "MH200N", "MH202", "MyHomeServer1"]) +def test_other_gateways_have_no_silent_zone_poll(hass, model) -> None: + assert _handler(hass, model).silent_zone_poll_needed is False + + +async def test_f455_zone_still_asks_nothing_on_add_or_update(hass) -> None: + handler = _handler(hass, "F455") + _ready(handler) + zone = _zone(hass, handler) + assert zone._poll_on_add is False + await zone.async_update() + assert _queued(handler) == [] + + +# ------------------------------------------------------------------ the silent-zone poll + + +async def test_silent_zone_asks_temperature_setpoint_mode_and_actuator(hass) -> None: + handler = _handler(hass, "F455") + _ready(handler) + await _zone(hass, handler)._poll_if_silent(grace=0) + assert _queued(handler) == SILENT_READS + + +async def test_a_restored_temperature_still_counts_as_silent(hass) -> None: + handler = _handler(hass, "F455") + _ready(handler) + zone = _zone(hass, handler) + zone._attr_native_current_temperature = 23.9 + zone._current_temperature_restored = True + await zone._poll_if_silent(grace=0) + assert _queued(handler) == SILENT_READS + + +async def test_a_zone_that_reported_by_itself_is_not_asked(hass) -> None: + handler = _handler(hass, "F455") + _ready(handler) + zone = _zone(hass, handler) + zone.handle_event(OWNHeatingEvent("*#4*2*0*0238##")) + zone.handle_event(OWNHeatingEvent("*#4*2*14*0220*3##")) + await zone._poll_if_silent(grace=0) + assert _queued(handler) == [] + + +async def test_a_zone_with_a_temperature_but_no_setpoint_is_asked(hass) -> None: + """The live zone 4 pushed its temperature but no setpoint: it is asked like a silent one.""" + handler = _handler(hass, "F455") + _ready(handler) + zone = _zone(hass, handler, "4") + zone.handle_event(OWNHeatingEvent("*#4*4*0*0255##")) + await zone._poll_if_silent(grace=0) + assert _queued(handler) == ["*#4*4*0##", "*#4*4*14##", "*#4*4*12##", "*#4*4#1*20##"] + + +async def test_each_read_waits_for_the_previous_answer(hass) -> None: + handler = _handler(hass, "F455") + futures = [hass.loop.create_future() for _ in SILENT_READS] + _ready(handler, side_effect=futures) + task = asyncio.ensure_future(_zone(hass, handler)._poll_if_silent(grace=0)) + await _spin() + assert _queued(handler) == SILENT_READS[:1] + futures[0].set_result(1.0) # the F455 answered and ACKed + await _spin() + assert _queued(handler) == SILENT_READS[:2] + for future in futures[1:]: + future.set_result(1.0) + await _spin() + await task + assert _queued(handler) == SILENT_READS + + +async def test_a_refused_temperature_read_ends_the_poll(hass) -> None: + """A zone without a probe: the refused read cancels its future, nothing more is asked.""" + handler = _handler(hass, "F455") + refused = hass.loop.create_future() + _ready(handler, side_effect=[refused]) + task = asyncio.ensure_future(_zone(hass, handler, "3")._poll_if_silent(grace=0)) + await _spin() + refused.cancel() + await task + assert _queued(handler) == ["*#4*3*0##"] + + +async def test_the_replies_in_poll_order_give_mode_and_action(hass) -> None: + """The capture's replies, in the order the poll asks for them, fill every attribute.""" + zone = _zone(hass, _handler(hass, "F455")) + for frame in ("*#4*2*0*0239##", "*#4*2*14*0220*3##", "*#4*2*12*0220*3##", "*4*0*2##", "*#4*2#1*20*1##"): + zone.handle_event(OWNHeatingEvent(frame)) + assert zone.current_temperature == 23.9 and zone.target_temperature == 22.0 + assert zone.hvac_mode == "cool" and zone.hvac_action == "cooling" + + +async def test_added_f455_zone_schedules_the_silent_poll(hass, monkeypatch) -> None: + handler = _handler(hass, "F455") + zone = _zone(hass, handler) + names: list[str] = [] + + def _capture(coro, name=None, **_kwargs): + names.append(name) + coro.close() + return MagicMock() + + monkeypatch.setattr(hass, "async_create_background_task", _capture) + zone._poll_on_add = False + handler.watch_zone_actuators = MagicMock(return_value=lambda: None) + await zone.async_added_to_hass() + assert names == ["myhome_climate.zone_2_silent_zone_poll"] + + +async def test_added_mh202_zone_schedules_nothing(hass, monkeypatch) -> None: + handler = _handler(hass, "MH202") + zone = _zone(hass, handler, "3") + names: list[str] = [] + + def _capture(coro, name=None, **_kwargs): + names.append(name) + coro.close() + return MagicMock() + + monkeypatch.setattr(hass, "async_create_background_task", _capture) + zone._poll_on_add = False + handler.watch_zone_actuators = MagicMock(return_value=lambda: None) + await zone.async_added_to_hass() + assert names == [] From 7ef2c180bbe45f7de3ef51064f3f6e779e286116 Mon Sep 17 00:00:00 2001 From: gdluck Date: Fri, 6 Nov 2026 22:16:37 +0200 Subject: [PATCH 2/5] fix(climate): silent-zone poll reads native temperatures; cover the cancel CI on 6b8af434: the HA beta/dev lanes flagged `current_temperature` and `target_temperature` as deprecated reads inside _poll_if_silent (use the native attributes), and the strict coverage gate found the removal callback that cancels a waiting poll never executed. The poll now reads _attr_native_current_temperature / native_target_temperature, and the scheduling test runs the entity's on-remove callbacks and asserts the task is cancelled. 5407 tests, 100 % coverage, both OWNd lanes. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 4 ++-- tests/test_f455_zone_polling.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index ea0663cb6..e72359093 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -1191,9 +1191,9 @@ async def _poll_if_silent(self, grace: float = SILENT_ZONE_GRACE) -> None: await wait() await asyncio.sleep(grace) if ( - self.current_temperature is not None + self._attr_native_current_temperature is not None and not self._current_temperature_restored - and self.target_temperature is not None + and self.native_target_temperature is not None ): return # the zone reported its temperature and setpoint on its own LOGGER.debug( diff --git a/tests/test_f455_zone_polling.py b/tests/test_f455_zone_polling.py index 9f42d901d..404489676 100644 --- a/tests/test_f455_zone_polling.py +++ b/tests/test_f455_zone_polling.py @@ -230,16 +230,22 @@ async def test_added_f455_zone_schedules_the_silent_poll(hass, monkeypatch) -> N zone = _zone(hass, handler) names: list[str] = [] + task = MagicMock() + def _capture(coro, name=None, **_kwargs): names.append(name) coro.close() - return MagicMock() + return task monkeypatch.setattr(hass, "async_create_background_task", _capture) zone._poll_on_add = False handler.watch_zone_actuators = MagicMock(return_value=lambda: None) await zone.async_added_to_hass() assert names == ["myhome_climate.zone_2_silent_zone_poll"] + # Removing the entity cancels a poll still waiting out its grace period. + for remove in list(zone._on_remove or []): + remove() + task.cancel.assert_called_once() async def test_added_mh202_zone_schedules_nothing(hass, monkeypatch) -> None: From 80c5f0f2e8f0dfca4f4020748220d134867b068b Mon Sep 17 00:00:00 2001 From: gdluck Date: Fri, 6 Nov 2026 21:04:20 +0000 Subject: [PATCH 3/5] test(#466): capture what the F455 does on a zone without a probe; pin it for the silent poll A third direct run on the physical F455 1.0.76 (20:55Z, same day, same probe tool) against zone 3, an actuator channel with no probe: *#4*3*0##, *#4*3*14##, *#4*3*12## and *#4*3*19## are each NACKed only after 8.96-9.01 s with no frame, *#4*3## ACKs after 8.99 s with none, *#4*3*20## NACKs in 3 ms and *#4*3#1*20## answers *#4*3#1*20*0## in 0.09 s. The populated zones answer *#4*Z*0## in 0.10-0.13 s in the same run. This is the record behind the silent poll's "a refused read ends it" path: a probe-less zone costs one read, and that read holds a command session for ~9 s, once per zone added. The SILENT_ZONE_POLL comment, the fixture README and tests/test_f455_zone_polling.py say so and assert the timings; the trace test pins the new file's SHA-256 and checks it carries no real address. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TwNCwPHjW6sW8g1sMd4zJW --- custom_components/myhome/gateway.py | 5 +++- ...455_direct_requests_zone3_2026-11-06.jsonl | 18 ++++++++++++++ .../issue_466/README_f455_live_2026-11-06.md | 10 ++++++++ tests/test_f455_zone_polling.py | 24 ++++++++++++++++--- ...st_issue_466_f455_live_2026_11_06_trace.py | 5 +++- 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/traces/issue_466/F455_direct_requests_zone3_2026-11-06.jsonl diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 1b4cc0b13..71af53724 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -228,7 +228,10 @@ def command_session_default(model: str | None) -> int: # (*#4*Z*12##, followed by the mode frame) and actuator 1 (*#4*Z#1*20##). A physical F455 1.0.76 # answers each in ~0.1 s and ACKs *#4*Z## only after ~9 s # (issue_466/F455_direct_requests_2026-11-06.jsonl); its standalone zone 2 reported nothing on its -# own in four minutes (issue_466/F455_full_debug_log_2026-11-06T16-24-46.log). +# own in four minutes (issue_466/F455_full_debug_log_2026-11-06T16-24-46.log). On a zone without a +# probe the same F455 NACKs *#4*Z*0## (and *14##, *12##, *19##) only after ~9 s, with no frame +# (issue_466/F455_direct_requests_zone3_2026-11-06.jsonl): the one refused read that ends the poll +# holds a command session for those ~9 s, once per zone added. *#4*Z#1*20## answers in 0.09 s there. SILENT_ZONE_POLL = frozenset({"F455"}) SILENT_ZONE_GRACE = 120.0 # Zones behind an F422 bus interface (WHERE Z#4#II): zero of the twelve emulated gateways in diff --git a/tests/fixtures/traces/issue_466/F455_direct_requests_zone3_2026-11-06.jsonl b/tests/fixtures/traces/issue_466/F455_direct_requests_zone3_2026-11-06.jsonl new file mode 100644 index 000000000..697cbdfe1 --- /dev/null +++ b/tests/fixtures/traces/issue_466/F455_direct_requests_zone3_2026-11-06.jsonl @@ -0,0 +1,18 @@ +{"t": 1.02, "utc": "20:55:01", "kind": "event", "raw": "*#13**16*1*0*76##", "cls": "OWNGatewayEvent", "log": "Gateway's firmware version is: 1.0.76."} +{"t": 4.022, "utc": "20:55:04", "kind": "sent", "frame": "*#13**16##", "status": true, "ack": true, "dt": 0.004, "replies": ["*#13**16*1*0*76##"], "events": ["*#13**16*1*0*76##"]} +{"t": 15.988, "utc": "20:55:16", "kind": "sent", "frame": "*#4*3*0##", "status": true, "ack": false, "dt": 8.964, "replies": null, "events": []} +{"t": 27.987, "utc": "20:55:28", "kind": "sent", "frame": "*#4*3*14##", "status": true, "ack": false, "dt": 8.999, "replies": null, "events": []} +{"t": 39.985, "utc": "20:55:40", "kind": "sent", "frame": "*#4*3*12##", "status": true, "ack": false, "dt": 8.997, "replies": null, "events": []} +{"t": 40.073, "utc": "20:55:40", "kind": "event", "raw": "*#4*3#1*20*0##", "cls": "OWNHeatingEvent", "log": "Zone 3's actuator 1 is off."} +{"t": 43.076, "utc": "20:55:43", "kind": "sent", "frame": "*#4*3#1*20##", "status": true, "ack": true, "dt": 0.089, "replies": ["*#4*3#1*20*0##"], "events": ["*#4*3#1*20*0##"]} +{"t": 55.09, "utc": "20:55:55", "kind": "sent", "frame": "*#4*3*19##", "status": true, "ack": false, "dt": 9.014, "replies": null, "events": []} +{"t": 58.098, "utc": "20:55:58", "kind": "sent", "frame": "*#4*3*20##", "status": true, "ack": false, "dt": 0.003, "replies": null, "events": []} +{"t": 70.094, "utc": "20:56:10", "kind": "sent", "frame": "*#4*3##", "status": true, "ack": true, "dt": 8.993, "replies": true, "events": []} +{"t": 70.191, "utc": "20:56:11", "kind": "event", "raw": "*#4*2*0*0233##", "cls": "OWNHeatingEvent", "log": "Zone 2's main sensor is reporting a temperature of 23.3°C."} +{"t": 73.195, "utc": "20:56:14", "kind": "sent", "frame": "*#4*2*0##", "status": true, "ack": true, "dt": 0.098, "replies": ["*#4*2*0*0233##"], "events": ["*#4*2*0*0233##"]} +{"t": 73.282, "utc": "20:56:14", "kind": "event", "raw": "*25*31#1*344##", "cls": "OWNDryContactEvent", "log": "Sensor 44 detected ON."} +{"t": 73.322, "utc": "20:56:14", "kind": "event", "raw": "*#4*4*0*0250##", "cls": "OWNHeatingEvent", "log": "Zone 4's main sensor is reporting a temperature of 25.0°C."} +{"t": 75.263, "utc": "20:56:16", "kind": "event", "raw": "*25*32#1*344##", "cls": "OWNDryContactEvent", "log": "Sensor 44 detected OFF."} +{"t": 76.324, "utc": "20:56:17", "kind": "sent", "frame": "*#4*4*0##", "status": true, "ack": true, "dt": 0.128, "replies": ["*#4*4*0*0250##"], "events": ["*25*31#1*344##", "*#4*4*0*0250##", "*25*32#1*344##"]} +{"t": 76.415, "utc": "20:56:17", "kind": "event", "raw": "*#4*2*19*5*5##", "cls": "OWNHeatingEvent", "log": "Zone 2's cooling fan is off; heating fan is off."} +{"t": 79.417, "utc": "20:56:20", "kind": "sent", "frame": "*#4*2*19##", "status": true, "ack": true, "dt": 0.091, "replies": ["*#4*2*19*5*5##"], "events": ["*#4*2*19*5*5##"]} diff --git a/tests/fixtures/traces/issue_466/README_f455_live_2026-11-06.md b/tests/fixtures/traces/issue_466/README_f455_live_2026-11-06.md index f0c359619..82c429206 100644 --- a/tests/fixtures/traces/issue_466/README_f455_live_2026-11-06.md +++ b/tests/fixtures/traces/issue_466/README_f455_live_2026-11-06.md @@ -11,6 +11,7 @@ and the MAC of the F461 the entry names as its primary, were made synthetic (`19 |---|---| | `F455_full_debug_log_2026-11-06T16-24-46.log` | Home Assistant 2026.11.0 core log, `custom_components.myhome` at DEBUG, 18:24:46 → 18:28:51 local (16:24:46 → 16:28:51Z): reload of the F455 entry, `myhome.sweep_bus`, lights 06 / 07 / 09 on and off, dimmer 0010 plain ON / 50 % / 100 % / off, shutter open 6 s / stop / close, zone 2 setpoint 22.5 °C and back to 22.0 °C. MyHOME 2.0.0b17, OWNd 2.0.0b13. Kept as `.log`, like the issue_674 F455 log. | | `F455_direct_requests_2026-11-06.jsonl` | Two runs of a command session plus an event session opened directly on the F455 with OWNd (`tools/probe.py`), 16:18Z and 16:31Z: one record per request (`kind: sent`, ACK, elapsed seconds, collected replies) and per event-session frame (`kind: event`). Status requests only; WHO 4 dimension 22, `*#16*0*5##` and WHO 13 dimensions 10 / 11 / 12 were never sent. | +| `F455_direct_requests_zone3_2026-11-06.jsonl` | A third run of the same probe, 20:55Z to 20:56Z the same day: every single-dimension read of zone `3` (an actuator channel with no probe), `*#4*3##`, and `*#4*2*0##` / `*#4*4*0##` / `*#4*2*19##` as controls. Same record shape; status requests only. | ## What the F455 does (direct requests) @@ -32,6 +33,15 @@ and the MAC of the F461 the entry names as its primary, were made synthetic (`19 `*#18*51*51##` / `*#18*51*113##` (10.0 s each; no meter on the plant). - `*#25*344##` (the IR detector's state) is ACKed with no reply. +## What the F455 does on a zone without a probe (zone 3, direct requests) + +- `*#4*3*0##`, `*#4*3*14##`, `*#4*3*12##` and `*#4*3*19##` are each **NACKed only after 8.96–9.01 s** with no frame: + the F455 waits out its bus timeout before refusing, one second short of OWNd's 10 s window, so one such read holds a + command session for ~9 s. `*#4*3##` ACKs after 8.99 s with no frame. +- `*#4*3*20##` is NACKed in 3 ms (as on the populated zones) while `*#4*3#1*20##` answers `*#4*3#1*20*0##` in 0.09 s: + the actuator is there, the probe is not. +- The controls answer as before: `*#4*2*0##` 0.10 s, `*#4*4*0##` 0.13 s, `*#4*2*19##` 0.09 s. + ## What MyHOME does with it (debug log) - The entry is configured on a **shared bus as `standby`** of the F461 (whose entry was not loaded), diff --git a/tests/test_f455_zone_polling.py b/tests/test_f455_zone_polling.py index 404489676..13f4ef1d2 100644 --- a/tests/test_f455_zone_polling.py +++ b/tests/test_f455_zone_polling.py @@ -31,12 +31,13 @@ ) DIRECT = Path(__file__).resolve().parent / "fixtures/traces/issue_466/F455_direct_requests_2026-11-06.jsonl" +ZONE3 = Path(__file__).resolve().parent / "fixtures/traces/issue_466/F455_direct_requests_zone3_2026-11-06.jsonl" MAC = "00:03:50:00:11:06" SILENT_READS = ["*#4*2*0##", "*#4*2*14##", "*#4*2*12##", "*#4*2#1*20##"] -def _sent() -> dict[str, dict]: - records = [json.loads(line) for line in DIRECT.read_text(encoding="utf-8").splitlines() if line.strip()] +def _sent(path: Path = DIRECT) -> dict[str, dict]: + records = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] return {r["frame"]: r for r in records if r["kind"] == "sent"} @@ -113,6 +114,19 @@ def test_capture_backs_each_read_of_the_silent_zone_poll() -> None: assert sent[frame]["dt"] < 0.2, frame +def test_capture_backs_the_cost_of_a_zone_without_a_probe() -> None: + """Zone 3 has an actuator and no probe: each single-dimension read is refused only after ~9 s.""" + sent = _sent(ZONE3) + for frame in ("*#4*3*0##", "*#4*3*14##", "*#4*3*12##", "*#4*3*19##"): + assert sent[frame]["ack"] is False and sent[frame]["replies"] is None, frame + assert 8.5 < sent[frame]["dt"] < 9.5, frame # the poll's first read, and its only one + assert sent["*#4*3#1*20##"]["ack"] is True and sent["*#4*3#1*20##"]["replies"] == ["*#4*3#1*20*0##"] + assert sent["*#4*3#1*20##"]["dt"] < 0.2 + assert sent["*#4*3*20##"]["ack"] is False and sent["*#4*3*20##"]["dt"] < 0.05 + for frame in ("*#4*2*0##", "*#4*4*0##"): # the populated zones, same run + assert sent[frame]["ack"] is True and sent[frame]["dt"] < 0.2, frame + + def test_capture_backs_staying_off_the_zone_status_poll() -> None: sent = _sent() for frame in ("*#4*2##", "*#4*4##"): @@ -205,7 +219,11 @@ async def test_each_read_waits_for_the_previous_answer(hass) -> None: async def test_a_refused_temperature_read_ends_the_poll(hass) -> None: - """A zone without a probe: the refused read cancels its future, nothing more is asked.""" + """A zone without a probe: the refused read cancels its future, nothing more is asked. + + The F455 refuses ``*#4*3*0##`` only after ~9 s (F455_direct_requests_zone3_2026-11-06.jsonl), so + this one read is what a probe-less zone costs: one command session for ~9 s, once. + """ handler = _handler(hass, "F455") refused = hass.loop.create_future() _ready(handler, side_effect=[refused]) diff --git a/tests/test_issue_466_f455_live_2026_11_06_trace.py b/tests/test_issue_466_f455_live_2026_11_06_trace.py index 6255c4b80..496492c71 100644 --- a/tests/test_issue_466_f455_live_2026_11_06_trace.py +++ b/tests/test_issue_466_f455_live_2026_11_06_trace.py @@ -34,8 +34,10 @@ TRACES = Path(__file__).resolve().parent / "fixtures/traces/issue_466" LOG = TRACES / "F455_full_debug_log_2026-11-06T16-24-46.log" DIRECT = TRACES / "F455_direct_requests_2026-11-06.jsonl" +ZONE3 = TRACES / "F455_direct_requests_zone3_2026-11-06.jsonl" LOG_SHA256 = "a8c3d76cf6d753f35a33e261bead9b067a50f50b14c47493b24fad19f83c1a93" DIRECT_SHA256 = "c3250cffb7bfc5bf46693f83ab2fa9605085d63a64230917eb586e9250a0431d" +ZONE3_SHA256 = "635f160c94332dc5341788ed4735fc6226756d7f3bf2bae802b9f81581d5620e" SYNTHETIC_ADDRESS = "192.0.2.55" SYNTHETIC_MAC = "00:03:50:00:11:06" @@ -84,10 +86,11 @@ def _sent() -> dict[str, dict]: def test_fixture_provenance_is_pinned() -> None: assert hashlib.sha256(LOG.read_bytes()).hexdigest() == LOG_SHA256 assert hashlib.sha256(DIRECT.read_bytes()).hexdigest() == DIRECT_SHA256 + assert hashlib.sha256(ZONE3.read_bytes()).hexdigest() == ZONE3_SHA256 def test_no_gateway_network_identity_is_committed() -> None: - for path in (LOG, DIRECT): + for path in (LOG, DIRECT, ZONE3): text = path.read_text(encoding="utf-8") ips = set(re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", text)) macs = set(re.findall(r"(?i)\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b", text)) From 6c0917dab9127ef194f67beef2aeebc67b52ae40 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 11:16:55 +0000 Subject: [PATCH 4/5] fix(climate): silent-zone poll asks only what the zone did not say; decide it when it runs (#873 review) Review 5817546241 on 195fd13, point by point: 1. A restored setpoint or mode is not a report. async_restore_last_state now flags _target_temperature_restored and _hvac_mode_restored like the restored temperature; dimension 14 / 12 frames, mode frames and a season frame that gives a mode clear them. _silent_zone_reads() asks only for what is missing: *0 when the temperature is not live, *14 when the setpoint is not, *12 when the setpoint or the mode is not, #1*20 when no actuator state is known. 2. The gate is read inside the task, after wait_for_initial_discovery() and the grace sleep, as watch_zone_actuators does (2e647e7): the in-band identity may correct the model after the entities were added. The task is scheduled when the gateway wants the poll at add time or while the startup sweep is still pending. 3. The reads are built from zone_number(self._where) and the routing, actuator token first: *#4*2*0## for a "#2" zone, *#4*2#1#4#01*20## behind an interface. A transport error in the loop is logged (#593). 4. silent_zone_poll_needed goes through _who4_target() like humidity_request_refused (c889731) and is False for a follower without WHO 4 in delegated_whos. 5. HUMIDITY_REQUEST_REFUSED gains F455 (live 1.0.76: *#4*2*60## NACKed in 19 ms with nothing on the bus). ACTUATOR_REQUEST_REFUSED is left alone because it also gates the thermo actuator switches, whose *#4*Z#N*20## the F455 answers in 0.09 s; the new ZONE_ACTUATOR_REQUEST_REFUSED = {"F455"} drops only the zone form *#4*Z*20## (NACKed in 2-18 ms) in _send_zone_requests. Docs notes in climate.md and runtime_behaviour.md. The six humidity-gate tests that used the F455 as their unlisted model use the MH201; the #674 replay asserts zone 42's humidity keeps coming from its own pushes. 44 tests in test_f455_zone_polling.py. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TwNCwPHjW6sW8g1sMd4zJW --- custom_components/myhome/climate.py | 98 ++++++-- custom_components/myhome/gateway.py | 73 +++++- docs/configuration/climate.md | 3 +- docs/configuration/runtime_behaviour.md | 13 ++ tests/test_climate_humidity_poll_gate.py | 25 +- tests/test_f455_zone_polling.py | 286 +++++++++++++++++++++-- 6 files changed, 427 insertions(+), 71 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index e72359093..679191c28 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -1,4 +1,5 @@ import asyncio +import inspect import time from typing import Any, cast @@ -630,6 +631,10 @@ def __init__( # setpoint in force, offset included): dimension 14 and 13 frames then leave it alone. self._local_target_reported = False self._current_temperature_restored = False + # True while the setpoint / the mode are what Home Assistant saved, not what the zone + # said since the restart: the silent-zone poll (#466) does not take them for a report. + self._target_temperature_restored = False + self._hvac_mode_restored = False self._nominal_before_dim12: tuple[float | None, float] | None = None self._poll_health = PollHealth() # BTicino 3550 central unit (#0) diagnostic status flags (WHAT 20..24, 30, 31) @@ -803,6 +808,7 @@ async def async_restore_last_state(self, last_state: State | None) -> None: self._attr_hvac_mode = HVACMode.OFF except (ValueError, TypeError): self._attr_hvac_mode = HVACMode.OFF + self._hvac_mode_restored = True target_temp = last_state.attributes.get("temperature") if target_temp is not None: try: @@ -821,6 +827,7 @@ async def async_restore_last_state(self, last_state: State | None) -> None: pass self._target_temperature = shown - self._local_offset self._local_target_temperature = shown + self._target_temperature_restored = True if "program" in last_state.attributes: try: self._program = int(last_state.attributes["program"]) @@ -1162,11 +1169,20 @@ async def async_added_to_hass(self) -> None: watch = getattr(self._gateway_handler, "watch_zone_actuators", None) if callable(watch): self.async_on_remove(watch(self._full_where)) + # Whether the gateway wants the silent-zone poll (#466) is decided inside the task, + # once the startup sweep and the grace period are over: the in-band identity (WHO 13 / + # WHO 1013) can still correct the model after the entities were added, as the + # actuator request already allows for (``watch_zone_actuators``). So the task is + # scheduled when the gateway wants the poll now, or while the sweep is still to run + # and the model may still change. if ( - getattr(self._gateway_handler, "silent_zone_poll_needed", False) is True - and not self._interface_poll_refused + not self._interface_poll_refused and not is_actuator_where(self._where) and target_hass is not None + and ( + getattr(self._gateway_handler, "silent_zone_poll_needed", False) is True + or getattr(self._gateway_handler, "initial_discovery_pending", False) is True + ) ): task = target_hass.async_create_background_task( self._poll_if_silent(), name=f"myhome_{self.entity_id}_silent_zone_poll" @@ -1177,37 +1193,65 @@ def _cancel_silent_zone_poll() -> None: self.async_on_remove(_cancel_silent_zone_poll) + def _silent_zone_reads(self) -> list[str]: + """The reads of the silent-zone poll (#466) this zone still needs, in the order they go out. + + What the zone said since the restart is not asked again; a value Home Assistant restored + is. The temperature (``*0``) comes first because a zone without a probe refuses it, and + that refusal ends the poll. The setpoint (``*14``), then the setpoint in force (``*12``, + the F455 follows its reply with the zone's season frame, which gives the mode), then + actuator 1 (``#1*20``) last: ``hvac_action`` is derived when an actuator report finds the + mode known. The zone is named by its number: a ``#Z`` entry of ``myhome.yaml`` would + otherwise ask through the central unit, and an actuator behind an interface is + ``Z#N#4#II``, not ``Z#4#II#N``. + """ + zone = zone_number(self._where) + routing = f"{BUS_ROUTING}{self._interface}" if self._interface is not None else "" + temperature_known = self._attr_native_current_temperature is not None and not self._current_temperature_restored + setpoint_known = self.native_target_temperature is not None and not self._target_temperature_restored + mode_known = self._attr_hvac_mode is not None and not self._hvac_mode_restored + reads: list[str] = [] + if not temperature_known: + reads.append(f"*#4*{zone}{routing}*0##") + if not setpoint_known: + reads.append(f"*#4*{zone}{routing}*14##") + if not setpoint_known or not mode_known: + reads.append(f"*#4*{zone}{routing}*12##") + if not self._actuator_states and self._zone_active is None: + reads.append(f"*#4*{zone}#1{routing}*20##") + return reads + async def _poll_if_silent(self, grace: float = SILENT_ZONE_GRACE) -> None: """Ask a zone that has not reported by itself since the startup sweep, read by read (#466). - Nothing goes out for a zone whose temperature and setpoint are known (a restored temperature - does not count). Each read waits for the - previous one to be answered, so a refused or unanswered read (a zone without a probe) ends - it after one request. The actuator is asked last: ``hvac_action`` is derived when an - actuator report finds the mode known. + Only on a gateway in ``SILENT_ZONE_POLL`` (the one that serves WHO 4, read once the + in-band identity has had the sweep and the grace period to settle). Nothing goes out for + what the zone reported itself (``_silent_zone_reads``). Each read waits for the previous + one to be answered, so a refused or unanswered read (a zone without a probe) ends it + after one request. """ wait = getattr(self._gateway_handler, "wait_for_initial_discovery", None) if callable(wait): - await wait() + waiting = wait() + if inspect.isawaitable(waiting): + await waiting await asyncio.sleep(grace) - if ( - self._attr_native_current_temperature is not None - and not self._current_temperature_restored - and self.native_target_temperature is not None - ): - return # the zone reported its temperature and setpoint on its own + if getattr(self._gateway_handler, "silent_zone_poll_needed", False) is not True: + return # not an F455 after all, or its zones are another gateway's to ask + reads = self._silent_zone_reads() + if not reads: + return # the zone reported its temperature, setpoint, mode and an actuator on its own LOGGER.debug( - "%s %s has not reported since the startup sweep; asking its state", self._gateway_handler.log_id, self._display_name + "%s %s has not reported since the startup sweep; asking %s", self._gateway_handler.log_id, self._display_name, reads ) - for frame in ( - f"*#4*{self._full_where}*0##", - f"*#4*{self._full_where}*14##", - f"*#4*{self._full_where}*12##", - f"*#4*{self._full_where}#1*20##", - ): - written: object = await self._gateway_handler.send_status_request( - cast(OWNCommand, OWNHeatingCommand.parse(frame)) - ) + for frame in reads: + try: + written: object = await self._gateway_handler.send_status_request( + cast(OWNCommand, OWNHeatingCommand.parse(frame)) + ) + except Exception: # a transport error must not end the task silently (#593) + LOGGER.exception("%s %s: `%s` could not be sent", self._gateway_handler.log_id, self._display_name, frame) + return if isinstance(written, asyncio.Future): await asyncio.wait([written], timeout=PACED_READ_TIMEOUT) if not written.done() or written.cancelled(): @@ -1573,6 +1617,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: message.human_readable_log, ) self._target_temperature = message.set_temperature + self._target_temperature_restored = False is_protection_or_off = self._dimension_3_is_protection(message) # Dimension 14 is the commanded setpoint. The thermostat can regulate to another one # (F461: 14 = 21.0 while 12 = 23.5), so a dimension 12 report stays what is shown. @@ -1617,6 +1662,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: ) self._local_target_temperature = message.local_set_temperature self._local_target_reported = self._local_target_temperature is not None + self._target_temperature_restored = False is_protection_or_off = self._dimension_3_is_protection(message) if not is_protection_or_off: self._remember_nominal() @@ -1645,6 +1691,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: if season_mode in self._attr_hvac_modes: LOGGER.debug("%s %s", self._gateway_handler.log_id, message.human_readable_log) self._attr_hvac_mode = season_mode + self._hvac_mode_restored = False if self._attr_hvac_action == HVACAction.OFF: self._attr_hvac_action = HVACAction.IDLE else: @@ -1671,6 +1718,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: elif message.message_type == MESSAGE_TYPE_MODE: if self._central: self._reset_central_status_flags() + self._hvac_mode_restored = False # the zone itself says what it runs in prev_mode = self._attr_hvac_mode previous_season = self._season self._remember_season(message) @@ -1737,6 +1785,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: elif message.message_type == MESSAGE_TYPE_MODE_TARGET: if self._central: self._reset_central_status_flags() + self._hvac_mode_restored = False prev_mode = self._attr_hvac_mode self._remember_season(message) self._program = getattr(message, "program", None) @@ -1781,6 +1830,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._mark_actuators_off() if message.set_temperature is not None and message.set_temperature >= 0: self._target_temperature = message.set_temperature + self._target_temperature_restored = False self._show_nominal() else: self._hydrate_nominal_from_local(prev_mode) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 71af53724..a31bc4c48 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -222,16 +222,20 @@ def command_session_default(model: str | None) -> int: # listed here. ZONE_TEMPERATURE_POLL = frozenset({"F450", "F459", "H4684", "MH200", "MH201"}) # Gateways whose zones still ask nothing on add (the F455: #674, sixteen *#4*Z## polls timing out -# one after another), but where a zone still without a temperature or setpoint of its own -# SILENT_ZONE_GRACE seconds after the startup sweep asks once, read by read: its temperature -# (*#4*Z*0##), and only if that was answered its setpoint (*#4*Z*14##), its probe status -# (*#4*Z*12##, followed by the mode frame) and actuator 1 (*#4*Z#1*20##). A physical F455 1.0.76 -# answers each in ~0.1 s and ACKs *#4*Z## only after ~9 s -# (issue_466/F455_direct_requests_2026-11-06.jsonl); its standalone zone 2 reported nothing on its -# own in four minutes (issue_466/F455_full_debug_log_2026-11-06T16-24-46.log). On a zone without a -# probe the same F455 NACKs *#4*Z*0## (and *14##, *12##, *19##) only after ~9 s, with no frame +# one after another), but where a zone that has not reported its temperature, setpoint, mode and +# an actuator by itself SILENT_ZONE_GRACE seconds after the startup sweep asks for what is +# missing, read by read: its temperature (*#4*Z*0##), and only if that was answered its setpoint +# (*#4*Z*14##), its setpoint in force (*#4*Z*12##, followed by the zone's season frame) and +# actuator 1 (*#4*Z#1*20##). A value Home Assistant restored from before the restart is not a +# report. A physical F455 1.0.76 answers each in ~0.1 s and ACKs *#4*Z## only after ~9 s +# (issue_466/F455_direct_requests_2026-11-06.jsonl); its standalone zone 2 (that entry ran as a +# standby on a shared bus, so it had skipped the startup sweep) reported nothing on its own in +# four minutes (issue_466/F455_full_debug_log_2026-11-06T16-24-46.log). On a zone without a probe +# the same F455 NACKs *#4*Z*0## (and *14##, *12##, *19##) only after ~9 s, with no frame # (issue_466/F455_direct_requests_zone3_2026-11-06.jsonl): the one refused read that ends the poll # holds a command session for those ~9 s, once per zone added. *#4*Z#1*20## answers in 0.09 s there. +# The gate is read when the poll runs (the in-band identity may correct the model after the +# entities were added) and through the gateway that serves WHO 4 (``silent_zone_poll_needed``). SILENT_ZONE_POLL = frozenset({"F455"}) SILENT_ZONE_GRACE = 120.0 # Zones behind an F422 bus interface (WHERE Z#4#II): zero of the twelve emulated gateways in @@ -249,6 +253,12 @@ def command_session_default(model: str | None) -> int: # Read from the OWNd profile first (``supports_heating_actuator_status``); this table is the # fallback until the flag exists there. ACTUATOR_REQUEST_REFUSED = frozenset({"F453", "F453AV", "H4684", "MH200", "MH200N", "MH202"}) +# Gateways that refuse the zone form of that request at once but answer the actuator form +# (``*#4*Z#N*20##``): a physical F455 1.0.76 NACKed *#4*2*20##, *#4*4*20##, *#4*3*20## and +# *#4*0*20## in 2-18 ms and answered *#4*2#1*20## / *#4*4#1*20## / *#4*3#1*20## in ~0.09 s +# (issue_466/F455_direct_requests_2026-11-06.jsonl, F455_direct_requests_zone3_2026-11-06.jsonl). +# A zone's watch is not sent there; a thermo actuator switch (``Z#N``) still asks. +ZONE_ACTUATOR_REQUEST_REFUSED = frozenset({"F455"}) # One request for every zone, by dimension. The live F461 2.0.11 answered ``*#4*#0*20##`` with # the actuators of each zone (``*#4*2#1*20*0##``, ``*#4*2#2*20*5##`` after 2.1 s). # The MyHomeServer1 3.87.13 (myhomeserver1_live_2026-10-24, run2_reads_thermo_sound_alarm_lights) @@ -264,7 +274,9 @@ def command_session_default(model: str | None) -> int: # F460, F461 and MyHomeServer1 forwarded it, and the live F461 / MyHomeServer1 answer it. # Read from the OWNd profile first (``supports_heating_humidity_request``: ``True`` / ``False`` # wins, ``None`` = unverified); this table is the fallback until the flag exists there. -HUMIDITY_REQUEST_REFUSED = frozenset({"F450", "F453", "F453AV", "F454", "H4684", "MH200", "MH200N", "MH202"}) +HUMIDITY_REQUEST_REFUSED = frozenset({"F450", "F453", "F453AV", "F454", "F455", "H4684", "MH200", "MH200N", "MH202"}) +# The F455 is not in the oracle: a physical F455 1.0.76 NACKed *#4*2*60## in 19 ms with nothing on +# the bus (issue_466/F455_direct_requests_2026-11-06.jsonl, t=68.6). # Every gateway model the request tables can be keyed on: the twelve images the oracle emulates # (results/

/, refusing and forwarding alike) and the other models OWNd profiles, so a # forwarding model named with a brand prefix ("BTicino F461") is keyed as itself (``F461``) @@ -278,7 +290,8 @@ def command_session_default(model: str | None) -> int: # The models the tables know, longest first so ``MH200N`` is not read as ``MH200``. _ZONE_REQUEST_MODELS = sorted( KNOWN_GATEWAY_MODELS | ACTUATOR_REQUEST_REFUSED | HUMIDITY_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS) - | GENERAL_ZONE_REQUEST_REFUSED | SOUND_STATUS_REQUEST_REFUSED | ZONE_TEMPERATURE_POLL | SILENT_ZONE_POLL, + | GENERAL_ZONE_REQUEST_REFUSED | SOUND_STATUS_REQUEST_REFUSED | ZONE_TEMPERATURE_POLL | SILENT_ZONE_POLL + | ZONE_ACTUATOR_REQUEST_REFUSED, key=len, reverse=True, ) @@ -340,6 +353,11 @@ def _zone_request_frame(where: str, dimension: int) -> str: return f"*#4*{_validate_zone_where(where)}*{dimension}##" +def _is_zone_level_where(where: str) -> bool: + """A zone as a whole (``Z``, ``#Z``, ``Z#4#II``), not one of its actuators (``Z#N``, ``Z#N#4#II``).""" + return "#" not in where.partition(BUS_ROUTING)[0].lstrip("#") + + class MyHOMEGatewayHandler(GatewayTopologyMixin, GatewayIdentityMixin): """Manages a single MyHOME Gateway.""" @@ -676,6 +694,18 @@ async def _send_zone_requests(self) -> None: "%s Zones %s: %s refuses the actuator request; not asking", self.log_id, wheres, target.model ) continue + if dimension == 20 and target.zone_actuator_request_refused: + zones = [where for where in wheres if _is_zone_level_where(where)] + if zones: + LOGGER.debug( + "%s Zones %s: %s refuses the zone form of the actuator request; asking only actuators", + self.log_id, + zones, + target.model, + ) + wheres = [where for where in wheres if not _is_zone_level_where(where)] + if not wheres: + continue all_zones = target.all_zone_request(dimension) if all_zones is not None and any(BUS_ROUTING not in where for where in wheres): frames.append(all_zones) @@ -725,10 +755,29 @@ def sound_status_request_refused(self) -> bool: return True return self._model_key in SOUND_STATUS_REQUEST_REFUSED + @property + def zone_actuator_request_refused(self) -> bool: + """Whether this gateway refuses the zone form of the actuator request (``*#4*Z*20##``) only. + + Decided by the gateway the request goes through, like ``humidity_request_refused``. + """ + target = self._who4_target() + if target is not self: + return target.zone_actuator_request_refused + return self._model_key in ZONE_ACTUATOR_REQUEST_REFUSED + @property def silent_zone_poll_needed(self) -> bool: - """Whether a zone silent after the startup sweep asks its own state once (``SILENT_ZONE_POLL``).""" - return self._model_key in SILENT_ZONE_POLL + """Whether a zone silent after the startup sweep asks its own state once (``SILENT_ZONE_POLL``). + + Decided by the gateway the reads go through (``_who4_target``: a secondary WHO 4 is + delegated to, or the warm standby while this primary is down), like + ``humidity_request_refused``. A passive follower (a standby, or a secondary without + WHO 4) has no zones of its own to ask. + """ + if self.is_follower and 4 not in self.delegated_whos: + return False + return self._who4_target()._model_key in SILENT_ZONE_POLL @property def zone_temperature_poll_needed(self) -> bool: diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index 25dc8471e..e20231483 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -76,7 +76,8 @@ MyHOME climate entities support standard Home Assistant climate controls: * **Current Temperature**: Real-time ambient reading reported by the probe. 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)). +* **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; the F455 refuses the zone form at once but answers the actuator form `*#4*#*20##` (live F455 1.0.76), so only its thermo actuator switches ask it; 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 on an F455 (Basic Gateway)**: the F455 returns nothing to `*#4*0##`, ACKs the zone status request `*#4*##` only after about 9 s and refuses `*#4**20##` at once (live F455 1.0.76, `tests/fixtures/traces/issue_466`), so its zones ask nothing when they are added (#674). 120 s after the startup sweep, a zone that has not reported its temperature, setpoint, mode and an actuator by itself (a value restored from before the restart does not count) asks for what is still missing, one read at a time: `*#4**0##`, `*#4**14##`, `*#4**12##` (the F455 follows it with the zone's season frame) and `*#4*#1*20##`, each answered in about 0.1 s (#466). A zone without a probe (an actuator-only channel) refuses the first read after about 9 s and is not asked anything else; that one read holds a command session for those 9 s, once per zone added or reloaded. On a shared bus the gateway that serves WHO 4 decides, and a passive standby or secondary asks nothing. * **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. ### 💧 Humidity Support & Startup Behavior diff --git a/docs/configuration/runtime_behaviour.md b/docs/configuration/runtime_behaviour.md index a3e2bf4d6..46d964994 100644 --- a/docs/configuration/runtime_behaviour.md +++ b/docs/configuration/runtime_behaviour.md @@ -111,6 +111,19 @@ If the gateway rejects the OpenWebNet password during setup, the integration rai --- +## 🌡️ F455 zones ask once, 120 s after the sweep + +The F455 Basic Gateway returns nothing to `*#4*0##`, ACKs `*#4*##` only after about 9 s (sixteen of them timing out one after another was #674) and refuses `*#4**20##` at once, so its climate zones ask nothing when they are added. A standalone thermostat on it can then stay `unknown` for hours (#466). + +Behaviour (live F455 1.0.76, `tests/fixtures/traces/issue_466/F455_direct_requests_2026-11-06.jsonl`): + +- 120 s after the startup sweep, a zone that has not reported its temperature, setpoint, mode and an actuator by itself asks for what is missing, one read at a time: `*#4**0##`, `*#4**14##`, `*#4**12##` (followed by the zone's season frame) and `*#4*#1*20##`. Each is answered in about 0.1 s. A value restored from before the restart does not count as reported. +- Each read waits for the previous answer. A zone without a probe (an actuator-only channel) refuses the first read after about 9 s and is asked nothing else, so it costs one read and one command session held for those 9 s, once per zone added or reloaded (`F455_direct_requests_zone3_2026-11-06.jsonl`). +- Whether the gateway wants this is decided when the poll runs, not when the zone is added, so a model corrected in band (WHO 13 / WHO 1013) after start is honoured; on a shared bus the gateway that serves WHO 4 decides, and a passive standby or secondary asks nothing. +- The zone's actuator watch (`*#4**20##`) is not sent to an F455; thermo actuator switches (`*#4*#*20##`) still ask, and the F455 answers them. + +--- + ## 🌡️ Temperature probes (`WHERE ≥ 100`) are push-driven Slave / external probes use the `ZPP` address form (zone `Z`, probe `PP`, e.g. `101`). Devices such as a **3455** behind an **L4577** radio interface push `*#4*ZPP*0*T*3##` unsolicited every few seconds and **NACK** the explicit `*#4*ZPP*15##` poll. diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index b314610d7..64b6e54c4 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -94,7 +94,8 @@ def _sent(gateway) -> list[str]: ("F450", True), ("F453", True), ("F453AV", True), ("F454", True), ("H4684", True), ("MH200", True), ("MH200N", True), ("MH202", True), ("F459", False), ("F460", False), ("F461", False), ("MyHomeServer1", False), - ("F455", False), ("MH201", False), ("Generic", False), # not in the oracle: still asked + ("F455", True), # not in the oracle: a live F455 1.0.76 NACKed *#4*2*60## in 19 ms (issue_466) + ("MH201", False), ("Generic", False), # not in the oracle: still asked ], ) def test_humidity_request_refused_follows_the_oracle(hass, model, refused): @@ -138,7 +139,7 @@ async def test_gateway_without_the_property_is_still_asked(hass): @pytest.mark.parametrize( ("model", "refused"), - [("MH202", True), ("F454", True), ("F461", False), ("F455", False), ("MH201", False)], + [("MH202", True), ("F454", True), ("F461", False), ("F455", True), ("MH201", False)], ) def test_profile_flag_none_means_unverified_and_falls_back_to_the_table(hass, model, refused): """``None`` = unverified: a listed model is skipped by the table, an unlisted one is polled.""" @@ -147,7 +148,7 @@ def test_profile_flag_none_means_unverified_and_falls_back_to_the_table(hass, mo assert handler.humidity_request_refused is refused -@pytest.mark.parametrize(("model", "refused"), [("MH202", True), ("F455", False)]) +@pytest.mark.parametrize(("model", "refused"), [("MH202", True), ("F455", True), ("MH201", False)]) def test_legacy_profile_without_the_attribute_falls_back_to_the_table(hass, model, refused): """An OWNd release whose profiles have no such attribute at all (the pinned one) uses the table.""" handler = _handler(hass, model) @@ -157,8 +158,8 @@ def test_legacy_profile_without_the_attribute_falls_back_to_the_table(hass, mode async def test_unverified_flag_on_an_unlisted_model_still_polls(hass): - handler = _handler(hass, "F455") - handler.gateway.profile = SimpleNamespace(model_name="F455", supports_heating_humidity_request=None) + handler = _handler(hass, "MH201") + handler.gateway.profile = SimpleNamespace(model_name="MH201", supports_heating_humidity_request=None) await _humidity_zone(hass, handler).async_update() assert "*#4*2*60##" in _sent(handler) @@ -223,7 +224,7 @@ def test_ssdp_model_string_hl4684_is_keyed_as_h4684(hass, ssdp_model): ("Legrand 003535", "MH202", True), ("BTicino MH200", "MH200", True), ("BTicino MH200N", "MH200N", True), - ("Legrand 003594", "F455", False), # the F455 is not in the oracle: still asked + ("Legrand 003594", "F455", True), # not in the oracle: the live F455 NACKs the request (issue_466) ("BTicino L4684 gateway", "H4684", True), ("BTicino-L4684", "H4684", True), # hyphenated: the words are the alphanumeric runs ("F454 (003598)", "F454", True), # parenthesised catalogue number @@ -606,11 +607,11 @@ async def test_replay_myhomeserver1_run3_zone_39_request_is_answered(hass, mhs1_ assert _status_requests(gw) == ["*#4*39##", "*#4*39##", "*#4*39*60##"] # the request the capture answered -async def test_replay_f455_spontaneous_push_on_an_unlisted_gateway(hass, f455_frames): +async def test_replay_f455_spontaneous_push_on_a_gateway_that_drops_the_request(hass, f455_frames): """F455 (#674, 2026-10-09): 67 event-session frames of a plant whose ``*#4*Z##`` polls time out; - zone 42 pushes ``*#4*42*60*52##`` on its own. The F455 is not in the table: the push enables - humidity on zone 42 alone, and the next poll asks ``*#4*42*60##`` (and no ``*#4*42##``, which - the F455 does not answer). + zone 42 pushes ``*#4*42*60*52##`` on its own. The push enables humidity on zone 42 alone. The + next poll asks nothing: a live F455 1.0.76 NACKs ``*#4*Z*60##`` in 19 ms without a bus frame + (issue_466/F455_direct_requests_2026-11-06.jsonl), and it does not answer ``*#4*42##`` either. """ assert f455_frames.count("*#4*42*60*52##") == 1 assert not any("*60*" in f and not f.startswith("*#4*42*") for f in f455_frames) @@ -620,7 +621,7 @@ async def test_replay_f455_spontaneous_push_on_an_unlisted_gateway(hass, f455_fr "64", "65", # actuator zones ] gw = _live_gateway(hass, "F455", "00:03:50:00:06:74") - assert gw.humidity_request_refused is False and gw.supports_zone_status is False + assert gw.humidity_request_refused is True and gw.supports_zone_status is False entities, added = await _trace_plant(hass, gw, zones) assert _status_requests(gw) == [] # no zone status, no fan, no humidity: nothing to poll on add _replay(hass, gw.mac, f455_frames) @@ -631,4 +632,4 @@ async def test_replay_f455_spontaneous_push_on_an_unlisted_gateway(hass, f455_fr assert all(e._humidity is False and e.current_humidity is None for z, e in entities.items() if z != "42") assert entities["35"].current_temperature == 19.6 await zone42.async_update() - assert _status_requests(gw) == ["*#4*42*60##"] + assert _status_requests(gw) == [] # the humidity keeps coming from the probe's own pushes diff --git a/tests/test_f455_zone_polling.py b/tests/test_f455_zone_polling.py index 13f4ef1d2..b96b904ea 100644 --- a/tests/test_f455_zone_polling.py +++ b/tests/test_f455_zone_polling.py @@ -15,18 +15,22 @@ import asyncio import json from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.core import State from OWNd.message import OWNHeatingEvent from OWNd.profiles import get_gateway_profile from custom_components.myhome.climate import MyHOMEClimate from custom_components.myhome.const import CONF_FIRMWARE from custom_components.myhome.gateway import ( + HUMIDITY_REQUEST_REFUSED, SILENT_ZONE_GRACE, SILENT_ZONE_POLL, + ZONE_ACTUATOR_REQUEST_REFUSED, MyHOMEGatewayHandler, ) @@ -58,13 +62,14 @@ def _handler(hass, model: str) -> MyHOMEGatewayHandler: return handler -def _zone(hass, handler, where: str = "2") -> MyHOMEClimate: +def _zone(hass, handler, where: str = "2", interface: str | None = None) -> MyHOMEClimate: zone = MyHOMEClimate( hass=hass, name=f"Zone {where}", device_id=f"4-{where}", who="4", where=where, + interface=interface, heating=True, cooling=True, fan=False, @@ -84,6 +89,19 @@ def _queued(handler) -> list[str]: return [str(call.args[0]) for call in handler.send_status_request.await_args_list] +def _drain(handler) -> list[str]: + """The frames the paced sender put on the send buffer.""" + queued = [] + while not handler.send_buffer.empty(): + queued.append(str(handler.send_buffer.get_nowait()["message"])) + return queued + + +def _restored(mode: str = "heat", **attributes) -> State: + """What Home Assistant saved for zone 2 before the restart.""" + return State("climate.zone_2", mode, {"temperature": 21.0, "current_temperature": 22.1, **attributes}) + + async def _spin() -> None: """Let the poll task run up to its next wait (not ``async_block_till_done``: it waits on it).""" for _ in range(10): @@ -147,6 +165,58 @@ def test_f455_keeps_the_issue_674_gates(hass) -> None: assert SILENT_ZONE_POLL == {"F455"} and SILENT_ZONE_GRACE == 120.0 +def test_capture_backs_the_f455_refusing_the_zone_actuator_and_humidity_reads() -> None: + """``*#4*Z*20##`` and ``*#4*Z*60##`` are NACKed at once; ``*#4*Z#1*20##`` is answered.""" + sent = _sent() + for frame in ("*#4*2*20##", "*#4*4*20##", "*#4*0*20##", "*#4*2*60##"): + assert sent[frame]["ack"] is False and sent[frame]["replies"] is None and sent[frame]["dt"] < 0.05, frame + for frame in ("*#4*2#1*20##", "*#4*4#1*20##"): + assert sent[frame]["ack"] is True and sent[frame]["dt"] < 0.2, frame + assert "F455" in HUMIDITY_REQUEST_REFUSED and ZONE_ACTUATOR_REQUEST_REFUSED == {"F455"} + assert _sent(ZONE3)["*#4*3*20##"]["ack"] is False and _sent(ZONE3)["*#4*3#1*20##"]["ack"] is True + + +async def test_f455_asks_its_actuators_not_its_zones_for_dimension_20(hass, fast_bus_pacing) -> None: + """A climate zone's watch is not sent to the F455; a thermo actuator switch (``Z#N``) still asks.""" + handler = _handler(hass, "F455") + handler._initial_discovery_done.set() + assert handler.zone_actuator_request_refused is True and handler.humidity_request_refused is True + handler.watch_zone_actuators("2") + handler.watch_zone_actuators("2#1") + await hass.async_block_till_done(wait_background_tasks=True) + assert _drain(handler) == ["*#4*2#1*20##"] + assert handler._actuator_zones == {"2": 1, "2#1": 1} # kept: the model may still be corrected in band + + +async def test_a_forwarding_gateway_still_asks_its_zones_for_dimension_20(hass, fast_bus_pacing) -> None: + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + assert handler.zone_actuator_request_refused is False + handler.watch_zone_actuators("2") + await hass.async_block_till_done(wait_background_tasks=True) + assert _drain(handler) == ["*#4*2*20##"] + + +def test_the_gateway_that_serves_who_4_decides(hass, monkeypatch) -> None: + """A primary whose WHO 4 goes through an F455 (delegation, failover) polls; the reverse does not.""" + primary = _handler(hass, "F461") + other = _handler(hass, "F455") + monkeypatch.setattr(primary, "_who4_target", lambda: other) + assert primary.silent_zone_poll_needed is True + monkeypatch.setattr(other, "_who4_target", lambda: primary) + assert other.silent_zone_poll_needed is False + + +def test_a_passive_follower_polls_nothing(hass, monkeypatch) -> None: + """A standby, or a secondary without WHO 4, has no zones of its own to ask.""" + handler = _handler(hass, "F455") + monkeypatch.setattr(MyHOMEGatewayHandler, "is_follower", property(lambda self: True)) + monkeypatch.setattr(MyHOMEGatewayHandler, "delegated_whos", property(lambda self: set())) + assert handler.silent_zone_poll_needed is False + monkeypatch.setattr(MyHOMEGatewayHandler, "delegated_whos", property(lambda self: {4})) + assert handler.silent_zone_poll_needed is True + + @pytest.mark.parametrize("model", ["F450", "F454", "F459", "F461", "MH200", "MH200N", "MH202", "MyHomeServer1"]) def test_other_gateways_have_no_silent_zone_poll(hass, model) -> None: assert _handler(hass, model).silent_zone_poll_needed is False @@ -181,24 +251,205 @@ async def test_a_restored_temperature_still_counts_as_silent(hass) -> None: assert _queued(handler) == SILENT_READS -async def test_a_zone_that_reported_by_itself_is_not_asked(hass) -> None: +async def test_a_zone_that_reported_everything_by_itself_is_not_asked(hass) -> None: handler = _handler(hass, "F455") _ready(handler) zone = _zone(hass, handler) - zone.handle_event(OWNHeatingEvent("*#4*2*0*0238##")) - zone.handle_event(OWNHeatingEvent("*#4*2*14*0220*3##")) + for frame in ("*#4*2*0*0238##", "*#4*2*14*0220*3##", "*4*0*2##", "*#4*2#1*20*7##"): + zone.handle_event(OWNHeatingEvent(frame)) await zone._poll_if_silent(grace=0) assert _queued(handler) == [] -async def test_a_zone_with_a_temperature_but_no_setpoint_is_asked(hass) -> None: - """The live zone 4 pushed its temperature but no setpoint: it is asked like a silent one.""" +async def test_only_what_the_zone_did_not_report_is_asked(hass) -> None: + """The live zone 4 pushed its temperature but no setpoint: the temperature is not asked again.""" handler = _handler(hass, "F455") _ready(handler) zone = _zone(hass, handler, "4") zone.handle_event(OWNHeatingEvent("*#4*4*0*0255##")) await zone._poll_if_silent(grace=0) - assert _queued(handler) == ["*#4*4*0##", "*#4*4*14##", "*#4*4*12##", "*#4*4#1*20##"] + assert _queued(handler) == ["*#4*4*14##", "*#4*4*12##", "*#4*4#1*20##"] + + +async def test_a_zone_with_temperature_and_setpoint_still_asks_for_mode_and_actuator(hass) -> None: + """Dimension 12 brings the zone's season frame (its mode); ``hvac_action`` needs an actuator.""" + handler = _handler(hass, "F455") + _ready(handler) + zone = _zone(hass, handler) + zone.handle_event(OWNHeatingEvent("*#4*2*0*0238##")) + zone.handle_event(OWNHeatingEvent("*#4*2*14*0220*3##")) + await zone._poll_if_silent(grace=0) + assert _queued(handler) == ["*#4*2*12##", "*#4*2#1*20##"] + + +# ------------------------------------------------------------------ restored state (review of 195fd13, 1) + + +async def test_a_restored_setpoint_and_mode_do_not_count_as_reported(hass) -> None: + """After a restart the saved setpoint and mode are shown, but the zone has not said them. + + Zone 2 pushed only its temperature during the grace period: without the flags the poll + would take the restored setpoint for a report and never ask for the setpoint, mode or actuator. + """ + handler = _handler(hass, "F455") + _ready(handler) + zone = _zone(hass, handler) + await zone.async_restore_last_state(_restored("heat")) + assert zone.target_temperature == 21.0 and zone.hvac_mode == "heat" + assert zone._target_temperature_restored is True and zone._hvac_mode_restored is True + zone.handle_event(OWNHeatingEvent("*#4*2*0*0238##")) + assert zone._current_temperature_restored is False + await zone._poll_if_silent(grace=0) + assert _queued(handler) == ["*#4*2*14##", "*#4*2*12##", "*#4*2#1*20##"] + + +async def test_a_restored_zone_that_reported_nothing_is_asked_everything(hass) -> None: + handler = _handler(hass, "F455") + _ready(handler) + zone = _zone(hass, handler) + await zone.async_restore_last_state(_restored("cool")) + assert zone.current_temperature == 22.1 and zone._current_temperature_restored is True + await zone._poll_if_silent(grace=0) + assert _queued(handler) == SILENT_READS + + +@pytest.mark.parametrize( + ("frame", "cleared"), + [ + ("*#4*2*14*0220*3##", "_target_temperature_restored"), # commanded setpoint + ("*#4*2*12*0220*3##", "_target_temperature_restored"), # setpoint in force + ("*4*110*2##", "_hvac_mode_restored"), # manual heating + ("*4*210#0220*2##", "_hvac_mode_restored"), # manual cooling with its setpoint + ("*4*303*2##", "_hvac_mode_restored"), # off + ], +) +async def test_the_zone_s_own_frames_replace_the_restored_values(hass, frame, cleared) -> None: + zone = _zone(hass, _handler(hass, "F455")) + await zone.async_restore_last_state(_restored("heat")) + zone.handle_event(OWNHeatingEvent(frame)) + assert getattr(zone, cleared) is False + other = "_hvac_mode_restored" if cleared == "_target_temperature_restored" else "_target_temperature_restored" + assert getattr(zone, other) is (frame != "*4*210#0220*2##") # a mode frame with a setpoint gives both + + +async def test_a_season_frame_gives_a_mode_to_a_zone_restored_off(hass) -> None: + """The F455 follows dimension 12 with ``*4*0*Z##``; a zone restored ``off`` takes that mode.""" + zone = _zone(hass, _handler(hass, "F455")) + await zone.async_restore_last_state(_restored("off")) + zone.handle_event(OWNHeatingEvent("*4*0*2##")) + assert zone.hvac_mode == "cool" and zone._hvac_mode_restored is False + + +async def test_the_restored_setpoint_is_shown_until_the_zone_reports(hass) -> None: + """The flag changes what the poll asks, not what the entity shows (#185 for the temperature).""" + zone = _zone(hass, _handler(hass, "F455")) + await zone.async_restore_last_state(_restored("heat")) + assert zone.target_temperature == 21.0 and zone.current_temperature == 22.1 and zone.hvac_mode == "heat" + + +# ------------------------------------------------------------------ addresses (review of 195fd13, 3) + + +async def test_a_hash_zone_of_myhome_yaml_is_asked_by_its_number(hass) -> None: + """``zone: "#2"`` names zone 2; ``*#4*#2*…##`` would ask through the central unit instead.""" + handler = _handler(hass, "F455") + _ready(handler) + zone = _zone(hass, handler, "#2") + assert zone._central is False + await zone._poll_if_silent(grace=0) + assert _queued(handler) == SILENT_READS + + +async def test_behind_an_interface_the_actuator_comes_before_the_routing(hass) -> None: + """``Z#N#4#II`` is the actuator's address; ``Z#4#II#N`` is not. Only when the profile routes WHO 4.""" + handler = _handler(hass, "F455") + handler.gateway.profile = SimpleNamespace(model_name="F455", supports_interface_zone_requests=True) + _ready(handler) + zone = _zone(hass, handler, "2", interface="1") + assert zone._interface_poll_refused is False + await zone._poll_if_silent(grace=0) + assert _queued(handler) == ["*#4*2#4#01*0##", "*#4*2#4#01*14##", "*#4*2#4#01*12##", "*#4*2#1#4#01*20##"] + + +async def test_behind_an_interface_the_default_profile_asks_nothing(hass, monkeypatch) -> None: + handler = _handler(hass, "F455") + zone = _zone(hass, handler, "2", interface="01") + assert zone._interface_poll_refused is True + names: list[str] = [] + + def _capture(coro, name=None, **_kwargs): + names.append(name) + coro.close() + return MagicMock() + + monkeypatch.setattr(hass, "async_create_background_task", _capture) + handler.watch_zone_actuators = MagicMock(return_value=lambda: None) + await zone.async_added_to_hass() + assert names == [] + + +# ------------------------------------------------------------------ when the gate is read (review of 195fd13, 2) + + +async def test_the_gate_is_read_when_the_poll_runs_not_when_the_zone_is_added(hass) -> None: + """The in-band identity (WHO 13 / WHO 1013) can correct the model after the entities were added.""" + handler = _handler(hass, "F454") # the label at setup; the sweep has not run yet + assert handler.initial_discovery_pending is True + zone = _zone(hass, handler) + names: list[str] = [] + + def _capture(coro, name=None, **_kwargs): + names.append(name) + coro.close() + return MagicMock() + + hass.async_create_background_task = _capture + zone._poll_on_add = False # not the F455's constructor here: the F454 polls on add + handler.watch_zone_actuators = MagicMock(return_value=lambda: None) + await zone.async_added_to_hass() + assert names == ["myhome_climate.zone_2_silent_zone_poll"] # scheduled while the model may still change + + handler.gateway.profile = get_gateway_profile("F455") # WHO 1013 says F455 + _ready(handler) + await zone._poll_if_silent(grace=0) + assert _queued(handler) == SILENT_READS + + +async def test_a_zone_labelled_f455_but_corrected_in_band_asks_nothing(hass) -> None: + handler = _handler(hass, "F455") + zone = _zone(hass, handler) + handler.gateway.profile = get_gateway_profile("F454") # corrected before the poll runs + _ready(handler) + await zone._poll_if_silent(grace=0) + assert _queued(handler) == [] + + +async def test_a_zone_added_after_the_sweep_on_another_gateway_schedules_nothing(hass, monkeypatch) -> None: + """Once the sweep is over the model has settled: a non-F455 zone found on the bus schedules no poll.""" + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + zone = _zone(hass, handler) + names: list[str] = [] + + def _capture(coro, name=None, **_kwargs): + names.append(name) + coro.close() + return MagicMock() + + monkeypatch.setattr(hass, "async_create_background_task", _capture) + zone._poll_on_add = False + handler.watch_zone_actuators = MagicMock(return_value=lambda: None) + await zone.async_added_to_hass() + assert names == [] + + +async def test_a_transport_error_ends_the_poll_and_is_logged(hass, caplog) -> None: + """Like ``_send_zone_requests`` (#593): the task must not die with an unhandled exception.""" + handler = _handler(hass, "F455") + _ready(handler, side_effect=OSError("connection lost")) + await _zone(hass, handler)._poll_if_silent(grace=0) + assert _queued(handler) == SILENT_READS[:1] + assert "could not be sent" in caplog.text async def test_each_read_waits_for_the_previous_answer(hass) -> None: @@ -256,7 +507,7 @@ def _capture(coro, name=None, **_kwargs): return task monkeypatch.setattr(hass, "async_create_background_task", _capture) - zone._poll_on_add = False + assert zone._poll_on_add is False # the F455 constructor: nothing is asked on add (#674) handler.watch_zone_actuators = MagicMock(return_value=lambda: None) await zone.async_added_to_hass() assert names == ["myhome_climate.zone_2_silent_zone_poll"] @@ -266,18 +517,9 @@ def _capture(coro, name=None, **_kwargs): task.cancel.assert_called_once() -async def test_added_mh202_zone_schedules_nothing(hass, monkeypatch) -> None: +async def test_an_mh202_zone_s_poll_asks_nothing(hass) -> None: + """Scheduled while the sweep is pending (the model may still change), it then asks nothing.""" handler = _handler(hass, "MH202") - zone = _zone(hass, handler, "3") - names: list[str] = [] - - def _capture(coro, name=None, **_kwargs): - names.append(name) - coro.close() - return MagicMock() - - monkeypatch.setattr(hass, "async_create_background_task", _capture) - zone._poll_on_add = False - handler.watch_zone_actuators = MagicMock(return_value=lambda: None) - await zone.async_added_to_hass() - assert names == [] + _ready(handler) + await _zone(hass, handler, "3")._poll_if_silent(grace=0) + assert _queued(handler) == [] From d21fba9c1246a5f20fd1a960ed8ef544b0b06734 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 15:33:22 +0200 Subject: [PATCH 5/5] test(gateway): cover the zone-only dimension 20 batch and the delegated zone-form refusal (#873 review) Two branches of gateway.py were not exercised: `_send_zone_requests` dropping a dimension 20 batch that holds only zone-level addresses on an F455 (nothing left to ask once the zone form is refused), and `zone_actuator_request_refused` read through `_who4_target()` when another gateway serves WHO 4. Both are now asserted in test_f455_zone_polling.py, as suggested in the review. Co-Authored-By: Claude Fable 5.1 --- tests/test_f455_zone_polling.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_f455_zone_polling.py b/tests/test_f455_zone_polling.py index b96b904ea..1d075e203 100644 --- a/tests/test_f455_zone_polling.py +++ b/tests/test_f455_zone_polling.py @@ -178,6 +178,12 @@ def test_capture_backs_the_f455_refusing_the_zone_actuator_and_humidity_reads() async def test_f455_asks_its_actuators_not_its_zones_for_dimension_20(hass, fast_bus_pacing) -> None: """A climate zone's watch is not sent to the F455; a thermo actuator switch (``Z#N``) still asks.""" + only_zone = _handler(hass, "F455") + only_zone._initial_discovery_done.set() + only_zone.watch_zone_actuators("2") + await hass.async_block_till_done(wait_background_tasks=True) + assert _drain(only_zone) == [] + handler = _handler(hass, "F455") handler._initial_discovery_done.set() assert handler.zone_actuator_request_refused is True and handler.humidity_request_refused is True @@ -203,8 +209,11 @@ def test_the_gateway_that_serves_who_4_decides(hass, monkeypatch) -> None: other = _handler(hass, "F455") monkeypatch.setattr(primary, "_who4_target", lambda: other) assert primary.silent_zone_poll_needed is True + assert primary.zone_actuator_request_refused is True + monkeypatch.setattr(primary, "_who4_target", lambda: primary) monkeypatch.setattr(other, "_who4_target", lambda: primary) assert other.silent_zone_poll_needed is False + assert other.zone_actuator_request_refused is False def test_a_passive_follower_polls_nothing(hass, monkeypatch) -> None: