From 26d462768accb41896003a98dcce082676d6996f Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 15:34:27 +0300 Subject: [PATCH 01/16] feat(climate): request the zone actuators (dimension 20) on add and after reconnect, expose actuators A zone's hvac_action stayed unknown after start until one of its actuators happened to switch: nothing asked for dimension 20 (#44) and the per-actuator state was not exposed (#25). On a live F461 2.0.11 *#4*2*20## and the all-zone *#4*#0*20## are answered with *#4*2#1*20*0##, *#4*2#2*20*5## and the entity turns idle at once. The gateway asks for the watched zones' actuators once the startup sweep is over and again after every event-session reconnect, through the paced sender; the F461 gets one *#4*#0*20## for its local zones, zones behind an interface and other gateways are asked per zone. Gateways whose firmware drops the request (own-firmware-oracle thermo-actuator rows for *#4*1*20##: F453, F453AV, H4684, MH200, MH200N, MH202) are not asked; the OWNd profile flag supports_heating_actuator_status wins over that table when present. Dimension 22 is never requested (it reboots the F461). The climate entity watches on add, unwatches on remove and lists each actuator in the new `actuators` attribute. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- custom_components/myhome/climate.py | 10 + custom_components/myhome/gateway.py | 100 +++++++++ docs/configuration/climate.md | 1 + tests/test_climate_actuator_status.py | 282 ++++++++++++++++++++++++++ 5 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 tests/test_climate_actuator_status.py diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 6632155f8..d353fa028 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -31,7 +31,7 @@ See the cover docs for the details and the known limits. - **Expose Home Assistant to the bus**: a declarative `expose` engine projects HA entities and the clock onto free SCS addresses: virtual WHO 1 / WHO 2 actuators that a wall control can drive (mirroring HA state back to its LEDs), WHO 4 virtual temperature probes, WHO 18 virtual energy meters and the WHO 13 bus clock. Custom actions run as Home Assistant scripts (#750). - **Covers**: `class:` in `myhome.yaml` now reaches WHO 2 covers (a `curtain` is no longer shown as a shutter; #39); Venetian blind slat tilt as timed slat pulses (`slat_tilt`, `slat_time`) (#508); impulse garage and gate covers on WHO 1 relays with optional safety approval, door-entry strike locks and doorbell events (#667); native broadcast for general covers behind an interface (#679); a cover stop overtakes other queued frames (#694); cover kinematics split into pluggable strategies (#739). - **Lighting**: 2-digit Area 0 point addresses `01`–`09` (#713); ON dimmers on a fresh install are probed for brightness support (#742); timed turn-on uses the OWNd builders (#659); lights the startup sweep already reported are not polled again (#701); area and group re-syncs only skip once every member has echoed, counting only the members' own echoes in the commanded state (#740, #749). -- **Thermoregulation (WHO 4)**: season frames, a generic setpoint mode and the central unit's season (#657, #725); holiday and vacation program attributes (#689); zone humidity remembered and queried at startup (#712); F461 zones polled, AUTO on standalone zones, fan coils per season, season kept across restarts (#714, #715); `hvac_action` derived from the season when the actuator carries no heating flag (#721); standalone default follows the plant (#698); 10 s command timeouts on the F455 removed (#675); migrated to the `native_*` temperature API (#665). +- **Thermoregulation (WHO 4)**: each zone asks for its actuators (dimension 20) after the startup sweep and after a reconnect, so `hvac_action` is known at start; the new `actuators` attribute lists them (#25, #44); season frames, a generic setpoint mode and the central unit's season (#657, #725); holiday and vacation program attributes (#689); zone humidity remembered and queried at startup (#712); F461 zones polled, AUTO on standalone zones, fan coils per season, season kept across restarts (#714, #715); `hvac_action` derived from the season when the actuator carries no heating flag (#721); standalone default follows the plant (#698); 10 s command timeouts on the F455 removed (#675); migrated to the `native_*` temperature API (#665). - **Sound system**: tuner frequency, preset and RDS read from WHO 22, with optional per-frequency pictures (#695); sound source commands and wall transport steps (#651); amplifiers no longer woken by volume status reports (#670); one decision engine for the power state (#683); an idle decoder can be claimed from a parked group (#723). - **Scenarios**: WHO 25 Scenario Plus commands and events (#671); WHO 17 scene events (#656). - **Gateways**: H4684 / LGRH4684 and MH201 identification (#652, #722); kernel and distribution in the diagnostic sweep (#711); user commands go ahead of status polls (#685); one frame scheduler with per-target ordering (#738); the HA web session is used for discovery (#757). diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index f148e3e24..68f50f70a 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -445,6 +445,10 @@ def extra_state_attributes(self) -> dict[str, Any]: attrs["fan_mode"] = self._attr_fan_mode if self._running_fan_speed is not None: attrs["running_fan_speed"] = self._running_fan_speed + if self._actuator_states: + attrs["actuators"] = { + actuator: "on" if active else "off" for actuator, active in sorted(self._actuator_states.items()) + } if self._fan_coil_sides: attrs["fan_coil_heating"] = self._fan_coil_sides.get("heating", False) attrs["fan_coil_cooling"] = self._fan_coil_sides.get("cooling", False) @@ -633,6 +637,12 @@ async def async_added_to_hass(self) -> None: ) ) await super().async_added_to_hass() + if not self._central: + # hvac_action needs the actuators (dimension 20); without asking, it stays unknown + # until one of them switches. The gateway asks again after a reconnect. + watch = getattr(self._gateway_handler, "watch_zone_actuators", None) + if callable(watch): + self.async_on_remove(watch(self._full_where)) @callback def _handle_central_mode_update(self, master_mode: HVACMode) -> None: diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 8c267560a..e16334da0 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -151,6 +151,15 @@ def command_session_default(model: str | None) -> int: (18, "*#18*51*51##"), (18, "*#18*51*113##"), ) +# Gateways whose firmware drops the per-zone actuator request (``*#4*Z*20##``) instead of +# forwarding it to the bus: own-firmware-oracle ``results/

//oracle/full/thermo-actuator.tsv`` +# (``*#4*1*20##`` "silent" on these six, "out" on F450, F454, F459, F460, F461, MyHomeServer1). +# 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"}) +# 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). +ALL_ZONE_REQUESTS: dict[str, dict[int, str]] = {"F461": {20: "*#4*#0*20##"}} class MyHOMEGatewayHandler(GatewayTopologyMixin, GatewayIdentityMixin): @@ -229,6 +238,11 @@ def __init__( self._sweep_idle: asyncio.Event = asyncio.Event() self._sweep_idle.set() self._last_event_frame_at: float = 0.0 + # Heating zones whose actuators are asked after the startup sweep and every reconnect, + # and the (zone, dimension) requests waiting for the next paced run. + self._actuator_zones: dict[str, int] = {} + self._zone_requests: set[tuple[str, int]] = set() + self._zone_request_task: asyncio.Task[None] | None = None @property def send_buffer(self) -> FrameScheduler | asyncio.Queue[Any]: @@ -344,6 +358,85 @@ def supports_zone_status(self) -> bool: ) ) + @property + def _model_key(self) -> str: + """The gateway model the request tables are keyed on (``F461``, ``MYHOMESERVER1``).""" + profile_model = getattr(getattr(self.gateway, "profile", None), "model_name", None) + name = profile_model if isinstance(profile_model, str) and profile_model != "Generic" else self.model + return "".join(character for character in str(name).upper() if character.isalnum()) + + @property + def actuator_request_refused(self) -> bool: + """Whether this gateway drops the zone actuator request (``*#4*Z*20##``). + + The OWNd profile flag wins when it exists; otherwise the oracle-derived table. + """ + supported = getattr(getattr(self.gateway, "profile", None), "supports_heating_actuator_status", None) + if isinstance(supported, bool): + return not supported + return self._model_key in ACTUATOR_REQUEST_REFUSED + + def all_zone_request(self, dimension: int) -> str | None: + """The single request this gateway answers with every zone's ``dimension``, if any.""" + return ALL_ZONE_REQUESTS.get(self._model_key, {}).get(dimension) + + @callback + def watch_zone_actuators(self, where: str) -> CALLBACK_TYPE: + """Ask for a heating zone's actuators (dimension 20) now and after every reconnect. + + Returns the callback that stops it. Gateways that refuse the request are not asked. + """ + if self.actuator_request_refused: + LOGGER.debug("%s Zone %s: %s refuses the actuator request; not asking", self.log_id, where, self.model) + return lambda: None + self._actuator_zones[where] = self._actuator_zones.get(where, 0) + 1 + self.request_zone_dimension(where, 20) + + @callback + def _unwatch() -> None: + count = self._actuator_zones.get(where, 0) - 1 + if count > 0: + self._actuator_zones[where] = count + else: + self._actuator_zones.pop(where, None) + + return _unwatch + + @callback + def request_zone_dimension(self, where: str, dimension: int) -> None: + """Ask a heating zone for one dimension once the startup sweep is over. + + Zones asking together share one paced run; where the gateway answers one request + for every zone (``all_zone_request``), that frame replaces the local zones' own. + """ + if dimension == 22: + # *#4*Z*22## kills the F461 thermostat handler and reboots the gateway. + raise ValueError("WHO 4 dimension 22 is never requested") + self._zone_requests.add((where, dimension)) + if self._zone_request_task is None or self._zone_request_task.done(): + self._zone_request_task = self.hass.async_create_background_task( + self._send_zone_requests(), name=f"myhome_{self.mac}_zone_requests" + ) + + async def _send_zone_requests(self) -> None: + """Send the waiting zone requests through the paced sender, until none is left.""" + await self.wait_for_initial_discovery() + await asyncio.sleep(0) # zones added in the same pass join this run + while self._zone_requests: + pending, self._zone_requests = self._zone_requests, set() + frames: list[str] = [] + for dimension in sorted({dim for _where, dim in pending}): + wheres = sorted(where for where, dim in pending if dim == dimension) + all_zones = self.all_zone_request(dimension) + if all_zones is not None and any("#4#" not in where for where in wheres): + frames.append(all_zones) + wheres = [where for where in wheres if "#4#" in where] # behind an interface + frames.extend(f"*#4*{where}*{dimension}##" for where in wheres) + try: + await self.send_paced(frames) + except Exception: # a transport error must not end the task silently (#593) + LOGGER.exception("%s Zone requests %s could not be sent", self.log_id, frames) + @property def profile(self) -> Any: """Return gateway profile.""" @@ -391,6 +484,9 @@ def _on_event_connection_state_change(self, connected: bool) -> None: sync_fn = getattr(expose_mgr, "async_sync_all", None) if inspect.iscoroutinefunction(sync_fn): self.hass.async_create_task(sync_fn()) + # Actuators that switched while the session was down sent their frame to nobody. + for where in list(self._actuator_zones): + self.request_zone_dimension(where, 20) return self._event_session_ready.clear() @@ -589,6 +685,10 @@ async def close_listener(self) -> bool: if self._unavailable_timer is not None: self._unavailable_timer() self._resync_manager.cancel_all() + if self._zone_request_task is not None: + self._zone_request_task.cancel() + self._zone_request_task = None + self._zone_requests.clear() self._unavailable_timer = None self.is_connected = False self._available = False diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index 54acf5554..c45aaea73 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -57,6 +57,7 @@ MyHOME climate entities support standard Home Assistant climate controls: 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. * **Current Temperature**: Real-time ambient reading reported by the probe. * **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 zone asks for the state of its actuators (`*#4**20##`, one `*#4*#0*20##` for every local zone on an F461) 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"}`). 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. ### 💧 Humidity Support & Startup Behavior diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py new file mode 100644 index 000000000..623dc9e69 --- /dev/null +++ b/tests/test_climate_actuator_status.py @@ -0,0 +1,282 @@ +"""The actuators (dimension 20) of a heating zone are asked for on add and after a reconnect. + +Without a dimension 20 request a zone's ``hvac_action`` stayed unknown until an actuator +happened to switch (#44); the per-actuator state was not exposed (#25). On a live F461 +2.0.11 plant (standalone zone 2, actuator 1 heating valve, actuator 2 fan coil) ``*#4*2*20##`` +and the all-zone ``*#4*#0*20##`` are answered with ``*#4*2#1*20*0##`` and +``*#4*2#2*20*5##`` (2.1 s), after which the entity reports ``idle``. + +Gateways that drop ``*#4*Z*20##`` (own-firmware-oracle ``thermo-actuator`` rows for +``*#4*1*20##``: F453, F453AV, H4684, MH200, MH200N, MH202 "silent") are never asked. +""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.components.climate import HVACAction +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 MyHOMEGatewayHandler + +MAC = "00:03:50:00:00:44" + +F461_POLL_REPLY = ( + "*#4*2*0*0239##", + "*#4*2*12*0235*3##", + "*4*1*2##", + "*#4*2*14*0210*3##", + "*#4*2*13*00##", +) + + +def _zone(hass, where="2", gateway=None, **kwargs): + if gateway is None: + gateway = MagicMock() + gateway.mac = MAC + gateway.send = AsyncMock() + gateway.send_status_request = AsyncMock() + gateway.has_central_unit = False + 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=gateway, **kwargs, + ) + zone.hass = hass + zone.entity_id = f"climate.zone_{where}" + zone.async_write_ha_state = MagicMock() + return zone + + +def _feed(zone, *frames): + for frame in frames: + zone.handle_event(OWNHeatingEvent(frame)) + + +def _entry(model: str) -> MagicMock: + entry = MagicMock() + entry.entry_id = f"entry-{model}" + entry.data = { + CONF_HOST: "192.0.2.44", CONF_PORT: 20000, CONF_PASSWORD: "x", + CONF_NAME: model, CONF_FIRMWARE: "2.0.11", CONF_MAC: MAC, + } + entry.runtime_data = None + return entry + + +def _handler(hass, model: str) -> MyHOMEGatewayHandler: + handler = MyHOMEGatewayHandler(hass, _entry(model)) + handler.gateway.profile = get_gateway_profile(model) + return handler + + +def _drain(handler: MyHOMEGatewayHandler) -> list[str]: + queued = [] + while not handler.send_buffer.empty(): + queued.append(str(handler.send_buffer.get_nowait()["message"])) + return queued + + +async def _settle(hass) -> None: + await hass.async_block_till_done(wait_background_tasks=True) + + +# ------------------------------------------------------------------ entity: hvac_action and actuators + +async def test_two_actuators_off_make_the_zone_idle(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + assert zone.hvac_action is None + _feed(zone, "*#4*2#1*20*0##", "*#4*2#2*20*5##") + assert zone.hvac_action == HVACAction.IDLE + assert zone.extra_state_attributes["actuators"] == {"1": "off", "2": "off"} + + +async def test_one_open_actuator_of_two_heats(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + _feed(zone, "*#4*2#2*20*5##", "*#4*2#1*20*1##") + assert zone.hvac_action == HVACAction.HEATING + assert zone.extra_state_attributes["actuators"] == {"1": "on", "2": "off"} + _feed(zone, "*#4*2#1*20*0##") + assert zone.hvac_action == HVACAction.IDLE + + +async def test_no_actuator_report_means_no_actuators_attribute(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + assert "actuators" not in zone.extra_state_attributes + + +async def test_zone_asks_for_its_actuators_when_added(hass): + zone = _zone(hass) + unwatch = MagicMock() + zone._gateway_handler.watch_zone_actuators = MagicMock(return_value=unwatch) + zone._gateway_handler.initial_discovery_pending = False + zone._gateway_handler.sweep_active = False + zone._gateway_handler.supports_zone_status = True + await zone.async_added_to_hass() + zone._gateway_handler.watch_zone_actuators.assert_called_once_with("2") + zone._call_on_remove_callbacks() # what removing the entity runs + unwatch.assert_called_once() + + +async def test_central_unit_does_not_ask_for_actuators(hass): + gateway = MagicMock() + gateway.mac = MAC + gateway.send_status_request = AsyncMock() + gateway.watch_zone_actuators = MagicMock() + central = MyHOMEClimate( + hass=hass, name="Central", device_id="4-#0", who="4", where="#0", + heating=True, cooling=True, fan=False, standalone=False, central=True, + manufacturer="BTicino", model="Central Unit", gateway=gateway, + ) + central.hass = hass + central.entity_id = "climate.central" + central.async_write_ha_state = MagicMock() + await central.async_added_to_hass() + gateway.watch_zone_actuators.assert_not_called() + + +# ------------------------------------------------------------------ gateway: the request per profile + +async def test_f461_asks_every_zone_s_actuators_with_one_frame(hass, fast_bus_pacing): + handler = _handler(hass, "F461") + handler._initial_discovery_done.set() + handler.watch_zone_actuators("2") + handler.watch_zone_actuators("5") + await _settle(hass) + assert _drain(handler) == ["*#4*#0*20##"] + + +async def test_routed_zone_is_asked_on_its_own(hass, fast_bus_pacing): + handler = _handler(hass, "F461") + handler._initial_discovery_done.set() + handler.watch_zone_actuators("2") + handler.watch_zone_actuators("3#4#01") + await _settle(hass) + assert _drain(handler) == ["*#4*#0*20##", "*#4*3#4#01*20##"] + + +async def test_forwarding_gateway_asks_each_zone(hass, fast_bus_pacing): + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + handler.watch_zone_actuators("2") + handler.watch_zone_actuators("1") + await _settle(hass) + assert _drain(handler) == ["*#4*1*20##", "*#4*2*20##"] + + +@pytest.mark.parametrize("model", ["MH202", "MH200", "MH200N", "F453", "F453AV", "H4684"]) +async def test_gateway_that_refuses_the_request_is_not_asked(hass, fast_bus_pacing, model): + handler = _handler(hass, model) + handler._initial_discovery_done.set() + handler.watch_zone_actuators("2") + await _settle(hass) + assert _drain(handler) == [] + assert handler._actuator_zones == {} + + +@pytest.mark.parametrize( + ("model", "refused"), + [("F461", False), ("F454", False), ("MyHomeServer1", False), ("MH202", True), ("F453", True), ("Generic", False)], +) +def test_refusal_table_matches_the_oracle(hass, model, refused): + assert _handler(hass, model).actuator_request_refused is refused + + +@pytest.mark.parametrize("flag", [True, False]) +def test_profile_flag_wins_over_the_table(hass, flag): + """An OWNd profile that states the flag is believed, whatever the fallback table says.""" + handler = _handler(hass, "MH202" if flag else "F461") + handler.gateway.profile = SimpleNamespace(model_name=handler.gateway.profile.model_name, supports_heating_actuator_status=flag) + assert handler.actuator_request_refused is (not flag) + + +async def test_actuators_are_asked_after_the_startup_sweep(hass, fast_bus_pacing, monkeypatch): + # A quiet bus never waits for the cap; it only sizes how long a request waits for the sweep. + monkeypatch.setattr("custom_components.myhome.gateway.BUS_QUIET_CAP", 5.0) + handler = _handler(hass, "F461") + handler.watch_zone_actuators("2") + await hass.async_block_till_done() + assert _drain(handler) == [] # waiting for the sweep + await handler.initial_discovery() + await _settle(hass) + queued = _drain(handler) + assert queued[-1] == "*#4*#0*20##" + assert queued.count("*#4*#0*20##") == 1 + + +async def test_reconnect_asks_the_actuators_again(hass, fast_bus_pacing): + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + handler.is_connected = True + unwatch = handler.watch_zone_actuators("2") + await _settle(hass) + assert _drain(handler) == ["*#4*2*20##"] + + handler._on_event_connection_state_change(True) # already connected: nothing new + await _settle(hass) + assert _drain(handler) == [] + + handler._on_event_connection_state_change(False) + handler._on_event_connection_state_change(True) + await _settle(hass) + assert _drain(handler) == ["*#4*2*20##"] + + unwatch() + handler._on_event_connection_state_change(False) + handler._on_event_connection_state_change(True) + await _settle(hass) + assert _drain(handler) == [] + await handler.close_listener() + + +async def test_two_entities_on_one_zone_unwatch_separately(hass, fast_bus_pacing): + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + first = handler.watch_zone_actuators("2") + second = handler.watch_zone_actuators("2") + await _settle(hass) + assert _drain(handler) == ["*#4*2*20##"] + first() + assert handler._actuator_zones == {"2": 1} + second() + assert handler._actuator_zones == {} + + +async def test_close_listener_drops_waiting_requests(hass, fast_bus_pacing): + handler = _handler(hass, "F454") + handler.watch_zone_actuators("2") # waits for the sweep + await hass.async_block_till_done() + assert handler._zone_requests == {("2", 20)} + await handler.close_listener() + assert handler._zone_requests == set() + assert handler._zone_request_task is None + + +def test_dimension_22_is_never_requested(hass): + handler = _handler(hass, "F454") + with pytest.raises(ValueError): + handler.request_zone_dimension("2", 22) + assert handler._zone_requests == set() + + +async def test_a_transport_error_is_logged_and_the_next_request_still_goes_out(hass, fast_bus_pacing, caplog): + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + handler.send_paced = AsyncMock(side_effect=OSError("socket closed")) + handler.watch_zone_actuators("2") + await _settle(hass) + assert "Zone requests ['*#4*2*20##'] could not be sent" in caplog.text + assert handler._zone_request_task is not None and handler._zone_request_task.done() + assert handler._zone_request_task.exception() is None + handler.send_paced = AsyncMock() + handler.request_zone_dimension("2", 20) + await _settle(hass) + handler.send_paced.assert_awaited_once_with(["*#4*2*20##"]) From 0afbb1619e501e316d7307b0866dd36d29ec6167 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:34:53 +0300 Subject: [PATCH 02/16] fix(climate): ask dimension 19 once a dimension 20 reply reveals a fan coil (#792 review, 1) A fan coil reported by *#4*Z#N*20*5..8## enabled the fan mode without telling which side of the zone it is on; with _fan_coil_sides empty, _season_has_fan_coil() said yes in both seasons and every async_update() sent *#4*Z*11##, which the live F461 2.0.11 NACKs after 2.1 s in the valve's season (f461_live_2026-10-23, run1_oracle_replay_readonly: *#4*2*11## NACK 2.083 s, *#4*2*19## ACK 0.110 s with *#4*2*19*5*0##). The zone now queues one *#4*Z*19## through request_zone_dimension() when a dimension 20 reply first reveals a fan coil (and on add when a restored fan mode has no side), and does not poll the fan speed while that answer is pending; once dimension 19 answers, the poll follows the fan coil's season as before. Every firmware of the oracle forwards *#4*Z*19## (own-firmware-oracle#60). A gateway handler without request_zone_dimension keeps the old behaviour. Review 5639409551 on #792, finding 1. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 34 ++++++++++++++- tests/test_climate_actuator_status.py | 59 +++++++++++++++++++++++++++ tests/test_f461_live_plant.py | 16 +++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 68f50f70a..f530fa666 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -362,6 +362,9 @@ def __init__( # (*#4*Z*19*COOLING*HEATING##: a value of 5..8 on a side is a fan coil, 0..4 a valve). # Empty until a dimension 19 frame has been seen. self._fan_coil_sides: dict[str, bool] = {} + # The one-shot *#4*Z*19## asked when a fan coil shows up before dimension 19 did: + # True while it is waiting for its answer, so the fan speed is not polled blindly. + self._fan_coil_sides_asked: bool = False if fan and not self._central: self._enable_fan_mode() @@ -589,11 +592,32 @@ def _season_has_fan_coil(self) -> bool: other one. Until dimension 19 has told which side is which, or while the season is unknown, ask anyway. """ - if not self._fan_coil_sides or self._season is None: + if not self._fan_coil_sides: + # Dimension 19 is on its way (asked once a fan coil showed up in dimension 20): + # on the live F461 it answers in 0.11 s, while *#4*Z*11## in the valve's season + # is NACKed after 2.1 s. Do not ask blindly while the answer is pending. + return not self._fan_coil_sides_asked + if self._season is None: return True side = "heating" if self._season == SEASON_HEATING else "cooling" return self._fan_coil_sides.get(side, False) + def _ask_fan_coil_sides(self) -> None: + """Ask once which side of the zone is the fan coil (``*#4*Z*19##``). + + A fan coil revealed by an actuator report (dimension 20, value 5..8) does not tell its + side; without dimension 19 the fan speed poll (``*#4*Z*11##``) would be sent in the + valve's season too and NACKed after 2.1 s on the F461. Every gateway firmware of the + oracle forwards ``*#4*Z*19##`` (own-firmware-oracle thermo-zone-requests). + """ + if self._fan_coil_sides or self._fan_coil_sides_asked: + return + request = getattr(self._gateway_handler, "request_zone_dimension", None) + if not callable(request): + return + self._fan_coil_sides_asked = True + request(self._full_where, 19) + @property def _standalone(self) -> bool: """Whether setpoints and modes are addressed as ``Z`` (thermostat alone) or ``#Z``. @@ -643,6 +667,10 @@ 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 self._fan: + # Restored (or configured) fan mode without the fan coil's side: ask for it + # before the first fan speed poll. + self._ask_fan_coil_sides() @callback def _handle_central_mode_update(self, master_mode: HVACMode) -> None: @@ -1151,12 +1179,16 @@ def handle_event(self, message: OWNHeatingEvent) -> None: "cooling": isinstance(getattr(message, "cooling_fan_on", None), bool), "heating": isinstance(getattr(message, "fan_on", None), bool), } + self._fan_coil_sides_asked = False # Actuator status (Dimension 20) with values >= 5 reports fancoil fan status in OWNd. is_fan = isinstance(getattr(message, "fan_speed", None), int) or isinstance( getattr(message, "fan_on", None), bool ) if is_fan: self._enable_fan_mode() + if getattr(message, "dimension", None) == 20: + # The actuator report revealed a fan coil; ask which side it is on. + self._ask_fan_coil_sides() speed = getattr(message, "fan_speed", None) if speed == 1: self._running_fan_speed = "low" diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 623dc9e69..6778498c8 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -280,3 +280,62 @@ async def test_a_transport_error_is_logged_and_the_next_request_still_goes_out(h handler.request_zone_dimension("2", 20) await _settle(hass) handler.send_paced.assert_awaited_once_with(["*#4*2*20##"]) + + +# ------------------------------------------------------------------ entity: dimension 19 before the fan speed poll (review 5639409551, 1) + +def _status_frames(zone) -> list[str]: + return [str(call.args[0]) for call in zone._gateway_handler.send_status_request.await_args_list] + + +async def test_fan_coil_revealed_by_dimension_20_asks_its_side_once(hass): + """``*#4*2#2*20*5##`` reveals a fan coil: one ``*#4*2*19##`` is queued, not a second one.""" + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#1*20*0##") + zone._gateway_handler.request_zone_dimension.assert_not_called() + _feed(zone, "*#4*2#2*20*5##", "*#4*2#2*20*5##") + zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) + + +async def test_no_fan_speed_poll_in_the_valve_s_season(hass): + """F461 zone 2 in heating season (heating valve, cooling fan coil): ``*#4*2*11##`` NACKs in 2.1 s.""" + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#1*20*0##", "*#4*2#2*20*5##") + await zone.async_update() # dimension 19 still on its way + assert "*#4*2*11##" not in _status_frames(zone) + _feed(zone, "*#4*2*19*5*0##") # cooling: fan coil, heating: valve + assert zone.extra_state_attributes["fan_coil_cooling"] is True + assert zone.extra_state_attributes["fan_coil_heating"] is False + await zone.async_update() + assert "*#4*2*11##" not in _status_frames(zone) + _feed(zone, "*4*0*2##") # cooling season: the fan coil's side + await zone.async_update() + assert _status_frames(zone)[-1] == "*#4*2*11##" + + +async def test_fan_coil_side_is_asked_again_only_if_the_first_answer_never_came(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##", "*#4*2*19*5*0##") + assert zone._fan_coil_sides_asked is False + _feed(zone, "*#4*2#2*20*5##") # the side is known: nothing more to ask + zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) + + +async def test_restored_fan_mode_without_the_side_asks_for_it_on_add(hass): + zone = _zone(hass) + zone._gateway_handler.initial_discovery_pending = False + zone._gateway_handler.sweep_active = False + zone._gateway_handler.supports_zone_status = True + zone._enable_fan_mode() # what a restored ``fan_mode`` attribute does + await zone.async_added_to_hass() + zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) + + +async def test_a_gateway_without_zone_requests_keeps_polling_the_fan_speed(hass): + """An older gateway handler (no ``request_zone_dimension``) behaves as before.""" + zone = _zone(hass) + del zone._gateway_handler.request_zone_dimension + _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##") + assert zone._fan_coil_sides_asked is False + await zone.async_update() + assert _status_frames(zone)[-1] == "*#4*2*11##" diff --git a/tests/test_f461_live_plant.py b/tests/test_f461_live_plant.py index cb6993a6a..2a69005d0 100644 --- a/tests/test_f461_live_plant.py +++ b/tests/test_f461_live_plant.py @@ -311,11 +311,25 @@ async def test_legacy_ownd_mode_event_extracts_season_fallback(hass) -> None: assert zone._season == "cooling" -async def test_fan_speed_is_polled_while_the_sides_are_unknown(hass) -> None: +async def test_fan_speed_is_not_polled_until_the_sides_are_known(hass) -> None: + """A fan coil revealed by dimension 20 is asked its side (*#4*2*19##) first. + + In the capture (``probe_fancoil``) *#4*2*11## NACKs in heating season on this zone + (heating valve, cooling fan coil), so the poll waits for the dimension 19 answer. + """ gw = _gateway() zone = (await _plant(hass, gw))["2"] zone.handle_event(OWNEvent.parse("*#4*2#2*20*5##")) zone.handle_event(OWNEvent.parse("*4*1*2##")) + gw.request_zone_dimension.assert_called_once_with("2", 19) + gw.sent.clear() + await zone.async_update() + assert gw.sent == ["*#4*2##"] + zone.handle_event(OWNEvent.parse("*#4*2*19*5*0##")) + gw.sent.clear() + await zone.async_update() + assert gw.sent == ["*#4*2##"] + zone.handle_event(OWNEvent.parse("*4*0*2##")) gw.sent.clear() await zone.async_update() assert gw.sent == ["*#4*2##", "*#4*2*11##"] From f992e8fc6a4441345097720c527d05535bea98c0 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:37:59 +0300 Subject: [PATCH 03/16] fix(climate): keep dimension 20 actuators apart from the dimension 19 zone state (#792 review, 2) _actuator_states mixed the per-actuator reports of dimension 20 ("1", "2") with the zone-level valves status of dimension 19 under a synthetic "valve" key. With dimension 20 now requested on every forwarding gateway, zones whose plant also emits dimension 19 (F455, MyHomeServer1 and F461 traces) held both: a stale "valve": True kept hvac_action in heating after *#4*2#1*20*0## (and the other way round), the "valve" key leaked into the actuators attribute, and switching the zone OFF cleared the dict so the attribute vanished until an actuator switched again. Dimension 20 keeps the per-actuator dict (exposed as "actuators"); dimension 19 sets a zone-level bool fallback; whichever arrived last drives hvac_action, and a dimension 19 report with both sides off marks the known actuators off. OFF (zone, central, mode frame) sets every known actuator to False instead of clearing, so the attribute stays through an OFF/HEAT round trip. The OWNd#116 workaround applies to dimension 19 only. Review 5639409551 on #792, finding 2. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 72 ++++++++++++++++++--------- tests/test_climate_actuator_status.py | 57 ++++++++++++++++++++- 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index f530fa666..c839ffff6 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -357,7 +357,13 @@ def __init__( self._attr_fan_mode: str | None = None self._attr_fan_modes: list[str] | None = None self._running_fan_speed: str | None = None + # Per-actuator state from dimension 20 (*#4*Z#N*20*V##), exposed as ``actuators``. self._actuator_states: dict[str, bool] = {} + # Zone-level fallback from dimension 19 (*#4*Z*19*C*H##): whether any valve or fan + # coil of the zone is active; None until seen. Whichever of the two arrived last + # drives hvac_action, so a stale one cannot keep the zone "heating". + self._zone_active: bool | None = None + self._active_source: str | None = None # "actuators" (dimension 20) or "zone" (19) # Which side of the zone drives a fan coil, from dimension 19 # (*#4*Z*19*COOLING*HEATING##: a value of 5..8 on a side is a fan coil, 0..4 a valve). # Empty until a dimension 19 frame has been seen. @@ -618,6 +624,18 @@ def _ask_fan_coil_sides(self) -> None: self._fan_coil_sides_asked = True request(self._full_where, 19) + def _any_actuator_active(self) -> bool: + """Whether the most recent actuator report (dimension 20 or 19) shows activity.""" + if self._active_source == "zone": + return bool(self._zone_active) + return any(self._actuator_states.values()) + + def _mark_actuators_off(self) -> None: + """The zone was switched OFF: every known actuator is off, the attribute stays.""" + self._actuator_states = dict.fromkeys(self._actuator_states, False) + if self._zone_active is not None: + self._zone_active = False + @property def _standalone(self) -> bool: """Whether setpoints and modes are addressed as ``Z`` (thermostat alone) or ``#Z``. @@ -678,7 +696,7 @@ def _handle_central_mode_update(self, master_mode: HVACMode) -> None: if master_mode == HVACMode.OFF: self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = HVACAction.OFF - self._actuator_states.clear() + self._mark_actuators_off() elif master_mode in (HVACMode.HEAT, HVACMode.COOL): if self._attr_hvac_mode != HVACMode.OFF: self._attr_hvac_mode = master_mode @@ -909,7 +927,7 @@ def _apply_zone_state(self, message: OWNHeatingEvent) -> None: self._restore_nominal_before_protection() self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = HVACAction.OFF - self._actuator_states.clear() + self._mark_actuators_off() self._holiday_days = None self._program = None self._scenario = None @@ -1095,7 +1113,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._restore_nominal_before_protection() self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = HVACAction.OFF - self._actuator_states.clear() + self._mark_actuators_off() if ( prev_mode == HVACMode.OFF and self._attr_hvac_mode != HVACMode.OFF @@ -1149,7 +1167,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._restore_nominal_before_protection() self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = HVACAction.OFF - self._actuator_states.clear() + self._mark_actuators_off() if message.set_temperature is not None and message.set_temperature >= 0: self._target_temperature = message.set_temperature self._show_nominal() @@ -1201,28 +1219,34 @@ def handle_event(self, message: OWNHeatingEvent) -> None: else: self._running_fan_speed = None - actuator_id = str( - getattr(message, "actuator", None) - or getattr(message, "_actuator", None) - or ( - message._where_param[0] - if getattr(message, "_where_param", None) - else "valve" - ) - ) is_active = bool(message.is_active()) - if getattr(message, "dimension", None) == 19: - # OWNd <= 2.0.0b11 clears _is_active when a fan-coil side is off (value 5), - # even if the other side's valve is open (*#4*2*19*5*1##, OWNd#116). - is_active = bool( - is_active - or message.is_heating() - or message.is_cooling() - or getattr(message, "cooling_fan_on", None) - or getattr(message, "fan_on", None) + dimension = getattr(message, "dimension", None) + if dimension == 20: + actuator_id = str( + getattr(message, "actuator", None) + or getattr(message, "_actuator", None) + or (message._where_param[0] if getattr(message, "_where_param", None) else "1") ) - self._actuator_states[actuator_id] = is_active - any_active = any(self._actuator_states.values()) + self._actuator_states[actuator_id] = is_active + self._active_source = "actuators" + else: + # Dimension 19 (valves status) is the zone as a whole. OWNd <= 2.0.0b11 clears + # _is_active when a fan-coil side is off (value 5), even if the other side's + # valve is open (*#4*2*19*5*1##, OWNd#116). + if dimension == 19: + is_active = bool( + is_active + or message.is_heating() + or message.is_cooling() + or getattr(message, "cooling_fan_on", None) + or getattr(message, "fan_on", None) + ) + self._zone_active = is_active + self._active_source = "zone" + if not self._zone_active: + # Neither side is active: every actuator of the zone is off. + self._actuator_states = dict.fromkeys(self._actuator_states, False) + any_active = self._any_actuator_active() if any_active: if self._heating and self._cooling: diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 6778498c8..7ace80eaf 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from homeassistant.components.climate import HVACAction +from homeassistant.components.climate import HVACAction, HVACMode 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 @@ -339,3 +339,58 @@ async def test_a_gateway_without_zone_requests_keeps_polling_the_fan_speed(hass) assert zone._fan_coil_sides_asked is False await zone.async_update() assert _status_frames(zone)[-1] == "*#4*2*11##" + + +# ------------------------------------------------------------------ entity: dimension 20 and 19 kept apart (review 5639409551, 2) + +async def test_zone_level_report_then_actuator_off_does_not_stick_heating(hass): + """``*#4*2*19*0*1##`` (heating valve open) must not pin ``heating`` once ``*#4*2#1*20*0##`` says off.""" + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2*19*0*1##") + assert zone.hvac_action == HVACAction.HEATING + assert "actuators" not in zone.extra_state_attributes # dimension 19 is the zone, not an actuator + _feed(zone, "*#4*2#1*20*0##") + assert zone.hvac_action == HVACAction.IDLE + assert zone.extra_state_attributes["actuators"] == {"1": "off"} + + +async def test_actuator_on_then_zone_level_all_off_goes_idle(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#1*20*1##") + assert zone.hvac_action == HVACAction.HEATING + _feed(zone, "*#4*2*19*0*0##") + assert zone.hvac_action == HVACAction.IDLE + assert zone.extra_state_attributes["actuators"] == {"1": "off"} + _feed(zone, "*#4*2*19*0*1##") + assert zone.hvac_action == HVACAction.HEATING + assert zone.extra_state_attributes["actuators"] == {"1": "off"} # which actuator is unknown + + +async def test_valve_never_appears_among_the_actuators(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2*19*5*1##", "*#4*2#1*20*1##", "*#4*2#2*20*5##") + assert zone.extra_state_attributes["actuators"] == {"1": "on", "2": "off"} + assert zone._zone_active is True + + +async def test_actuators_attribute_survives_off_and_heat(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#1*20*1##", "*#4*2#2*20*5##") + assert zone.hvac_action == HVACAction.HEATING + _feed(zone, "*4*303*2##") + assert zone.hvac_action == HVACAction.OFF + assert zone.extra_state_attributes["actuators"] == {"1": "off", "2": "off"} + _feed(zone, "*4*1*2##") + assert zone.hvac_action == HVACAction.IDLE + assert zone.extra_state_attributes["actuators"] == {"1": "off", "2": "off"} + _feed(zone, "*#4*2#1*20*1##") + assert zone.hvac_action == HVACAction.HEATING + + +async def test_central_off_keeps_the_actuators_attribute(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#1*20*1##", "*#4*2*19*0*1##") + zone._handle_central_mode_update(HVACMode.OFF) + assert zone.hvac_action == HVACAction.OFF + assert zone.extra_state_attributes["actuators"] == {"1": "off"} + assert zone._zone_active is False From afb43934e1c85bd935f9d83135bbe839b49e8d98 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:38:51 +0300 Subject: [PATCH 04/16] fix(gateway): refuse invalid zone addresses and keep one bad zone from dropping the batch (#792 review, 3) request_zone_dimension() refused dimension 22 but took any where: "2*22" with dimension 20 built *#4*2*22*20##, which OWNCommand.parse reads as a dimension 22 write, the frame that reboots the F461. The frames were also built outside the try/except around send_paced(), so once the raw string is replaced by the OWNd#140 builder (OWNHeatingCommand.actuators_status raises ValueError on an address it does not accept) one zone would have ended the task and dropped every other pending zone of the batch. A zone address is now validated right away in request_zone_dimension() (digits and "#" only, not empty) and again by the per-zone frame builder inside _send_zone_requests(), where a ValueError is logged and the other zones are still sent. Routed addresses (3#4#01, #3#4#01, 2#1#4#01) stay accepted; OWNd#140's _WHO4_WHERE_REGEX is being extended to the same "#4#" routed forms in a companion commit. Review 5639409551 on #792, finding 3. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 25 ++++++++++++++++++++- tests/test_climate_actuator_status.py | 31 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index e16334da0..18d47d395 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -162,6 +162,23 @@ def command_session_default(model: str | None) -> int: ALL_ZONE_REQUESTS: dict[str, dict[int, str]] = {"F461": {20: "*#4*#0*20##"}} +def _validate_zone_where(where: str) -> None: + """Reject a WHO 4 zone address that cannot go into ``*#4*WHERE*DIM##`` as it is. + + Anything but digits and ``#`` would change the frame's grammar: ``2*22`` would turn a + dimension 20 request into ``*#4*2*22*20##``, a dimension 22 write. The builder of + OWNd#140 (``OWNHeatingCommand.actuators_status``) rejects the same addresses. + """ + if not where or not all(character.isdigit() or character == "#" for character in where): + raise ValueError(f"Invalid zone address: {where!r}") + + +def _zone_request_frame(where: str, dimension: int) -> str: + """The dimension request of one zone (``*#4*WHERE*DIM##``), validated like a builder.""" + _validate_zone_where(where) + return f"*#4*{where}*{dimension}##" + + class MyHOMEGatewayHandler(GatewayTopologyMixin, GatewayIdentityMixin): """Manages a single MyHOME Gateway.""" @@ -412,6 +429,7 @@ def request_zone_dimension(self, where: str, dimension: int) -> None: if dimension == 22: # *#4*Z*22## kills the F461 thermostat handler and reboots the gateway. raise ValueError("WHO 4 dimension 22 is never requested") + _validate_zone_where(where) self._zone_requests.add((where, dimension)) if self._zone_request_task is None or self._zone_request_task.done(): self._zone_request_task = self.hass.async_create_background_task( @@ -431,7 +449,12 @@ async def _send_zone_requests(self) -> None: if all_zones is not None and any("#4#" not in where for where in wheres): frames.append(all_zones) wheres = [where for where in wheres if "#4#" in where] # behind an interface - frames.extend(f"*#4*{where}*{dimension}##" for where in wheres) + for where in wheres: + # One zone the builder rejects must not drop the others of the batch. + try: + frames.append(_zone_request_frame(where, dimension)) + except ValueError as err: + LOGGER.warning("%s Zone %s: dimension %s not requested: %s", self.log_id, where, dimension, err) try: await self.send_paced(frames) except Exception: # a transport error must not end the task silently (#593) diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 7ace80eaf..17024701a 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -394,3 +394,34 @@ async def test_central_off_keeps_the_actuators_attribute(hass): assert zone.hvac_action == HVACAction.OFF assert zone.extra_state_attributes["actuators"] == {"1": "off"} assert zone._zone_active is False + + +# ------------------------------------------------------------------ gateway: invalid zone addresses (review 5639409551, 3) + +@pytest.mark.parametrize("where", ["", "2*22", "2 ", "a", "*#0", "2#1*20"]) +def test_an_invalid_zone_address_is_refused_at_once(hass, where): + """``2*22`` would make ``*#4*2*22*20##``, a dimension 22 write that reboots the F461.""" + handler = _handler(hass, "F454") + with pytest.raises(ValueError, match="Invalid zone address"): + handler.request_zone_dimension(where, 20) + assert handler._zone_requests == set() + assert handler._zone_request_task is None + + +@pytest.mark.parametrize("where", ["2", "#2", "01", "3#4#01", "#3#4#01", "2#1#4#01", "2#1"]) +def test_a_plain_or_routed_zone_address_is_accepted(hass, where): + handler = _handler(hass, "F454") + handler.request_zone_dimension(where, 20) + assert handler._zone_requests == {(where, 20)} + + +async def test_one_rejected_zone_does_not_drop_the_batch(hass, fast_bus_pacing, caplog): + """A zone the frame builder rejects is logged and the other zones are still asked.""" + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + handler.watch_zone_actuators("1") + handler._zone_requests.add(("2*22", 20)) # what a builder would raise on + handler.watch_zone_actuators("3") + await _settle(hass) + assert _drain(handler) == ["*#4*1*20##", "*#4*3*20##"] + assert "Zone 2*22: dimension 20 not requested: Invalid zone address: '2*22'" in caplog.text From 2e647e77a2534b507a70a7c459a99e654aadb685 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:39:51 +0300 Subject: [PATCH 05/16] fix(gateway): decide the actuator request refusal when it is sent, not when the zone is added (#792 review, 4) watch_zone_actuators() checked actuator_request_refused while the climate platform was being set up, before initial_discovery() and often before the in-band identity (WHO 13 dimension 15 / WHO 1013 dimension 1, _apply_model) had settled or corrected the configured model. A "Generic" entry later identified as MH202 / MH200N kept sending the refused *#4*Z*20## after startup and on every reconnect; a misconfigured MH200N corrected to an F461 / F454 had returned a no-op callback without recording the zone, so its actuators were never asked until the entry was reloaded. The zone is now always ref-counted in _actuator_zones, and the refusal is evaluated inside _send_zone_requests() after wait_for_initial_discovery(), for every dimension 20 batch (startup and reconnect alike), so both directions follow the settled identity. Review 5639409551 on #792, finding 4. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 12 ++++++--- tests/test_climate_actuator_status.py | 39 ++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 18d47d395..25a06f080 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -401,11 +401,10 @@ def all_zone_request(self, dimension: int) -> str | None: def watch_zone_actuators(self, where: str) -> CALLBACK_TYPE: """Ask for a heating zone's actuators (dimension 20) now and after every reconnect. - Returns the callback that stops it. Gateways that refuse the request are not asked. + Returns the callback that stops it. The zone is always recorded: whether the gateway + refuses the request is decided when it is sent, once the in-band identity (WHO 13 / + WHO 1013) has settled, so a model corrected after the entities were added is honoured. """ - if self.actuator_request_refused: - LOGGER.debug("%s Zone %s: %s refuses the actuator request; not asking", self.log_id, where, self.model) - return lambda: None self._actuator_zones[where] = self._actuator_zones.get(where, 0) + 1 self.request_zone_dimension(where, 20) @@ -445,6 +444,11 @@ async def _send_zone_requests(self) -> None: frames: list[str] = [] for dimension in sorted({dim for _where, dim in pending}): wheres = sorted(where for where, dim in pending if dim == dimension) + if dimension == 20 and self.actuator_request_refused: + LOGGER.debug( + "%s Zones %s: %s refuses the actuator request; not asking", self.log_id, wheres, self.model + ) + continue all_zones = self.all_zone_request(dimension) if all_zones is not None and any("#4#" not in where for where in wheres): frames.append(all_zones) diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 17024701a..de12e3b6f 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -179,7 +179,7 @@ async def test_gateway_that_refuses_the_request_is_not_asked(hass, fast_bus_paci handler.watch_zone_actuators("2") await _settle(hass) assert _drain(handler) == [] - assert handler._actuator_zones == {} + assert handler._actuator_zones == {"2": 1} # kept: the model may still be corrected in band @pytest.mark.parametrize( @@ -425,3 +425,40 @@ async def test_one_rejected_zone_does_not_drop_the_batch(hass, fast_bus_pacing, await _settle(hass) assert _drain(handler) == ["*#4*1*20##", "*#4*3*20##"] assert "Zone 2*22: dimension 20 not requested: Invalid zone address: '2*22'" in caplog.text + + +# ------------------------------------------------------------------ gateway: identity settles after the entities were added (review 5639409551, 4) + +async def test_a_gateway_identified_late_as_refusing_is_not_asked(hass, fast_bus_pacing): + """Entities are added as ``Generic``; WHO 13 / WHO 1013 then says MH202 before the sweep ends.""" + handler = _handler(hass, "Generic") + handler.watch_zone_actuators("2") + await hass.async_block_till_done() + handler.gateway.profile = get_gateway_profile("MH202") # what _apply_model does + await handler.initial_discovery() + await _settle(hass) + assert "*#4*2*20##" not in _drain(handler) + assert handler._actuator_zones == {"2": 1} + + handler.is_connected = True + handler._on_event_connection_state_change(False) + handler._on_event_connection_state_change(True) # reconnect: still refused + await _settle(hass) + assert _drain(handler) == [] + + +async def test_a_gateway_identified_late_as_supported_is_asked(hass, fast_bus_pacing): + """Entities are added as a misconfigured MH200N; WHO 13 then corrects it to F461.""" + handler = _handler(hass, "MH200N") + handler.watch_zone_actuators("2") + await hass.async_block_till_done() + handler.gateway.profile = get_gateway_profile("F461") + await handler.initial_discovery() + await _settle(hass) + assert _drain(handler)[-1] == "*#4*#0*20##" + + handler.is_connected = True + handler._on_event_connection_state_change(False) + handler._on_event_connection_state_change(True) + await _settle(hass) + assert _drain(handler) == ["*#4*#0*20##"] From c8897315bfbb77735be3907ca6b4a392d7585e4d Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:41:44 +0300 Subject: [PATCH 06/16] fix(gateway): read the request tables through the profile class and the WHO 4 target gateway (#792 review, 5) Four edge cases of the per-model tables and the zone watch: - _model_key compared the profile's model_name with "Generic" instead of its class: get_gateway_profile("BTicino F461") is a GenericGatewayProfile named "BTicino F461", so the key became "BTICINOF461" and missed both ALL_ZONE_REQUESTS and ACTUATOR_REQUEST_REFUSED. The key is now taken from a known profile's name, otherwise from the configured model, and a known model is looked up as a substring (longest first), like command_session_limit and supports_zone_status do. - The refusal and the all-zone form were read from this handler while send_status_request routes WHO 4 through the delegated secondary or the warm standby. _who4_target() resolves the same target (_delegated_target / _failover_target) and its tables decide: a primary F461 failed over to an MH202 sends nothing, failed over to an F454 asks per zone; an MH202 delegating WHO 4 to an F454 asks the F454. - A secondary probe (WHERE >= 100, push-only; *#4*105*...## NACKs in 2.1 s on the F461, docs/configuration/climate.md) no longer registers a zone watch. - MyHomeServer1 gets the all-zone form: in myhomeserver1_live_2026-10-24 (3.87.13, run2_reads_thermo_sound_alarm_lights) *#4*#0*20## was ACKed in 0.39 s with 43 actuator frames for 34 zones, where the per-zone requests take about 32 s through the paced sender; the emulated 028206 forwards it ("out"). Review 5639409551 on #792, finding 5. Draft #793 carries the same _model_key helper and needs the same change. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 5 +- custom_components/myhome/gateway.py | 45 ++++++++++++--- docs/configuration/climate.md | 2 +- tests/test_climate_actuator_status.py | 81 ++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index c839ffff6..8393dbed1 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -679,9 +679,10 @@ async def async_added_to_hass(self) -> None: ) ) await super().async_added_to_hass() - if not self._central: + if not self._central and not is_probe(self._where): # hvac_action needs the actuators (dimension 20); without asking, it stays unknown - # until one of them switches. The gateway asks again after a reconnect. + # until one of them switches. The gateway asks again after a reconnect. A secondary + # probe (WHERE >= 100) is push-only: *#4*105*...## NACKs in 2.1 s on the F461. watch = getattr(self._gateway_handler, "watch_zone_actuators", None) if callable(watch): self.async_on_remove(watch(self._full_where)) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 25a06f080..95fbc6f55 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -159,7 +159,15 @@ def command_session_default(model: str | None) -> int: ACTUATOR_REQUEST_REFUSED = frozenset({"F453", "F453AV", "H4684", "MH200", "MH200N", "MH202"}) # 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). -ALL_ZONE_REQUESTS: dict[str, dict[int, str]] = {"F461": {20: "*#4*#0*20##"}} +# The MyHomeServer1 3.87.13 (myhomeserver1_live_2026-10-24, run2_reads_thermo_sound_alarm_lights) +# answered it in 0.39 s with 43 actuator frames for its 34 zones, where asking each zone +# through the paced sender takes about 32 s; the emulated 028206 forwards it ("out"). +ALL_ZONE_REQUESTS: dict[str, dict[int, str]] = { + "F461": {20: "*#4*#0*20##"}, + "MYHOMESERVER1": {20: "*#4*#0*20##"}, +} +# The models the two tables know, longest first so ``MH200N`` is not read as ``MH200``. +_ZONE_REQUEST_MODELS = sorted(ACTUATOR_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS), key=len, reverse=True) def _validate_zone_where(where: str) -> None: @@ -377,10 +385,30 @@ def supports_zone_status(self) -> bool: @property def _model_key(self) -> str: - """The gateway model the request tables are keyed on (``F461``, ``MYHOMESERVER1``).""" - profile_model = getattr(getattr(self.gateway, "profile", None), "model_name", None) - name = profile_model if isinstance(profile_model, str) and profile_model != "Generic" else self.model - return "".join(character for character in str(name).upper() if character.isalnum()) + """The gateway model the request tables are keyed on (``F461``, ``MYHOMESERVER1``). + + A known OWNd profile names the model; a ``GenericGatewayProfile`` (also what + ``get_gateway_profile("BTicino F461")`` returns) falls back to the configured name, + in which a known model is looked up as a substring (``supports_zone_status`` style). + """ + profile = getattr(self.gateway, "profile", None) + if profile is not None and not isinstance(profile, GenericGatewayProfile): + name = getattr(profile, "model_name", None) or self.model + else: + name = self.model + normalized = "".join(character for character in str(name).upper() if character.isalnum()) + return next((model for model in _ZONE_REQUEST_MODELS if model in normalized), normalized) + + def _who4_target(self) -> MyHOMEGatewayHandler: + """The gateway a WHO 4 status request goes through, the way ``send_status_request`` routes it. + + On a shared bus WHO 4 may be delegated to a secondary gateway, or served by the warm + standby while this primary is down: that gateway's refusal and all-zone tables apply. + """ + probe = OWNCommand.parse("*#4*#0*20##") + if probe is None: # pragma: no cover - a fixed, well-formed frame + return self + return self._delegated_target(probe) or self._failover_target(probe) or self @property def actuator_request_refused(self) -> bool: @@ -442,14 +470,15 @@ async def _send_zone_requests(self) -> None: while self._zone_requests: pending, self._zone_requests = self._zone_requests, set() frames: list[str] = [] + target = self._who4_target() for dimension in sorted({dim for _where, dim in pending}): wheres = sorted(where for where, dim in pending if dim == dimension) - if dimension == 20 and self.actuator_request_refused: + if dimension == 20 and target.actuator_request_refused: LOGGER.debug( - "%s Zones %s: %s refuses the actuator request; not asking", self.log_id, wheres, self.model + "%s Zones %s: %s refuses the actuator request; not asking", self.log_id, wheres, target.model ) continue - all_zones = self.all_zone_request(dimension) + all_zones = target.all_zone_request(dimension) if all_zones is not None and any("#4#" not in where for where in wheres): frames.append(all_zones) wheres = [where for where in wheres if "#4#" in where] # behind an interface diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index c45aaea73..f92b0d081 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -57,7 +57,7 @@ MyHOME climate entities support standard Home Assistant climate controls: 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. * **Current Temperature**: Real-time ambient reading reported by the probe. * **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 zone asks for the state of its actuators (`*#4**20##`, one `*#4*#0*20##` for every local zone on an F461) 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"}`). 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. +* **HVAC Action and `actuators`**: a 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. ### 💧 Humidity Support & Startup Behavior diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index de12e3b6f..ec8257773 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -18,7 +18,7 @@ from homeassistant.components.climate import HVACAction, HVACMode 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 OWNd.profiles import GenericGatewayProfile, get_gateway_profile from custom_components.myhome.climate import MyHOMEClimate from custom_components.myhome.const import CONF_FIRMWARE @@ -462,3 +462,82 @@ async def test_a_gateway_identified_late_as_supported_is_asked(hass, fast_bus_pa handler._on_event_connection_state_change(True) await _settle(hass) assert _drain(handler) == ["*#4*#0*20##"] + + +# ------------------------------------------------------------------ gateway: profile, topology and probes (review 5639409551, 5) + +def test_a_generic_profile_with_a_vendor_prefixed_name_still_finds_its_tables(hass): + """``get_gateway_profile("BTicino F461")`` is a GenericGatewayProfile named "BTicino F461".""" + handler = _handler(hass, "BTicino F461") + assert isinstance(handler.gateway.profile, GenericGatewayProfile) + assert handler._model_key == "F461" + assert handler.all_zone_request(20) == "*#4*#0*20##" + assert handler.actuator_request_refused is False + + +@pytest.mark.parametrize( + ("model", "key", "refused"), + [("Legrand MH200N", "MH200N", True), ("MH200", "MH200", True), ("BTicino F453AV", "F453AV", True), ("Unknown", "UNKNOWN", False)], +) +def test_model_key_reads_the_longest_known_model(hass, model, key, refused): + handler = _handler(hass, model) + handler.gateway.profile = GenericGatewayProfile(model_name=model) + assert handler._model_key == key + assert handler.actuator_request_refused is refused + + +async def test_who4_delegated_to_a_secondary_uses_the_secondary_s_tables(hass, fast_bus_pacing): + """A primary MH202 (refuses) delegating WHO 4 to a secondary F454: the F454 is asked, per zone.""" + primary = _handler(hass, "MH202") + secondary = _handler(hass, "F454") + primary._delegated_target = MagicMock(return_value=secondary) + primary._initial_discovery_done.set() + primary.watch_zone_actuators("2") + await _settle(hass) + assert _drain(primary) == [] + assert _drain(secondary) == ["*#4*2*20##"] + + +async def test_failover_to_a_standby_that_refuses_sends_nothing(hass, fast_bus_pacing): + """A primary F461 failed over to a standby MH202: *#4*#0*20## would be dropped by the MH202.""" + primary = _handler(hass, "F461") + standby = _handler(hass, "MH202") + primary._failover_target = MagicMock(return_value=standby) + primary._initial_discovery_done.set() + primary.watch_zone_actuators("2") + await _settle(hass) + assert _drain(primary) == [] + assert _drain(standby) == [] + + +async def test_failover_to_a_forwarding_standby_asks_per_zone(hass, fast_bus_pacing): + """A primary F461 failed over to a standby F454: the F454 form, not the F461's all-zone one.""" + primary = _handler(hass, "F461") + standby = _handler(hass, "F454") + primary._failover_target = MagicMock(return_value=standby) + primary._initial_discovery_done.set() + primary.watch_zone_actuators("2") + primary.watch_zone_actuators("5") + await _settle(hass) + assert _drain(primary) == [] + assert _drain(standby) == ["*#4*2*20##", "*#4*5*20##"] + + +async def test_a_secondary_probe_is_never_asked_for_actuators(hass): + """WHERE >= 100 is a push-only secondary probe (*#4*105*...## NACKs in 2.1 s on the F461).""" + probe = _zone(hass, where="105") + probe._gateway_handler.initial_discovery_pending = False + probe._gateway_handler.sweep_active = False + probe._gateway_handler.supports_zone_status = True + await probe.async_added_to_hass() + probe._gateway_handler.watch_zone_actuators.assert_not_called() + + +async def test_myhomeserver1_asks_every_zone_s_actuators_with_one_frame(hass, fast_bus_pacing): + """MyHomeServer1 3.87.13 answered *#4*#0*20## in 0.39 s with 43 actuator frames (34 zones).""" + handler = _handler(hass, "MyHomeServer1") + handler._initial_discovery_done.set() + handler.watch_zone_actuators("35") + handler.watch_zone_actuators("36") + await _settle(hass) + assert _drain(handler) == ["*#4*#0*20##"] From d206fbbb9bf1b4df611aabd9bea5e9d64f35d0c2 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:42:35 +0300 Subject: [PATCH 07/16] refactor(gateway): use BUS_ROUTING and coalesce zone requests queued after the sweep (#792 review, 6) The interface marker was the literal "#4#" twice in _send_zone_requests(); const.BUS_ROUTING names it. After the startup sweep, asyncio.sleep(0) only coalesced zones registered in the same event-loop tick: zones discovered from incoming frames arrive over 1-2 s, and on an F461 each one started its own *#4*#0*20## sweep a few tens of ms after the previous one. A request queued once initial discovery is already done now waits ZONE_REQUEST_COALESCE (0.1 s) for the others; requests queued before the sweep ended keep joining it without an extra delay. Review 5639409551 on #792, finding 6 (the oracle-case gap for *#4*Z#0*20## and *#4*0#0*20## is for own-firmware-oracle#60, not this repository). Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 14 +++++++++++--- tests/test_climate_actuator_status.py | 28 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 95fbc6f55..5ef420654 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -28,6 +28,7 @@ from .bus_monitor import BusMonitor from .const import ( + BUS_ROUTING, CONF_DEVICE_TYPE, CONF_FIRMWARE, CONF_MANUFACTURER, @@ -142,6 +143,10 @@ def command_session_default(model: str | None) -> int: BUS_QUIET_PERIOD = 0.75 BUS_QUIET_CAP = 15.0 PACED_WRITE_TIMEOUT = 60.0 +# How long a zone request waits for others once the startup sweep is over: zones discovered +# from incoming frames arrive over 1-2 s, and each would otherwise start its own all-zone +# sweep (*#4*#0*20##) on an F461. +ZONE_REQUEST_COALESCE = 0.1 DISCOVERY_REQUESTS: tuple[tuple[int, str], ...] = ( (1, "*#1*0##"), (2, "*#2*0##"), @@ -465,8 +470,11 @@ def request_zone_dimension(self, where: str, dimension: int) -> None: async def _send_zone_requests(self) -> None: """Send the waiting zone requests through the paced sender, until none is left.""" + discovered = self._initial_discovery_done.is_set() await self.wait_for_initial_discovery() - await asyncio.sleep(0) # zones added in the same pass join this run + # Zones added in the same pass join this run; after the sweep, zones discovered from + # the bus over the next tenth of a second do too instead of each sweeping on its own. + await asyncio.sleep(ZONE_REQUEST_COALESCE if discovered else 0) while self._zone_requests: pending, self._zone_requests = self._zone_requests, set() frames: list[str] = [] @@ -479,9 +487,9 @@ async def _send_zone_requests(self) -> None: ) continue all_zones = target.all_zone_request(dimension) - if all_zones is not None and any("#4#" not in where for where in wheres): + if all_zones is not None and any(BUS_ROUTING not in where for where in wheres): frames.append(all_zones) - wheres = [where for where in wheres if "#4#" in where] # behind an interface + wheres = [where for where in wheres if BUS_ROUTING in where] # behind an interface for where in wheres: # One zone the builder rejects must not drop the others of the batch. try: diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index ec8257773..f687e019d 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -541,3 +542,30 @@ async def test_myhomeserver1_asks_every_zone_s_actuators_with_one_frame(hass, fa handler.watch_zone_actuators("36") await _settle(hass) assert _drain(handler) == ["*#4*#0*20##"] + + +# ------------------------------------------------------------------ gateway: coalescing after the sweep (review 5639409551, 6) + +async def test_zones_discovered_after_the_sweep_share_one_all_zone_request(hass, fast_bus_pacing, monkeypatch): + """Dynamically discovered zones arrive a few tens of ms apart: one *#4*#0*20##, not one each.""" + monkeypatch.setattr("custom_components.myhome.gateway.ZONE_REQUEST_COALESCE", 0.2) + handler = _handler(hass, "F461") + handler._initial_discovery_done.set() + handler.watch_zone_actuators("2") + await asyncio.sleep(0.05) + handler.watch_zone_actuators("5") + await asyncio.sleep(0.05) + handler.watch_zone_actuators("7#4#01") + await _settle(hass) + assert _drain(handler) == ["*#4*#0*20##", "*#4*7#4#01*20##"] + + +async def test_the_startup_run_does_not_wait_the_coalesce_window(hass, fast_bus_pacing, monkeypatch): + """Requests queued before the sweep ended already waited for it: no extra delay.""" + monkeypatch.setattr("custom_components.myhome.gateway.ZONE_REQUEST_COALESCE", 5.0) + handler = _handler(hass, "F461") + handler.watch_zone_actuators("2") + await hass.async_block_till_done() + await handler.initial_discovery() + await _settle(hass) + assert _drain(handler)[-1] == "*#4*#0*20##" From 667db95ae6795e8aaf7a39cbf76a159273acee93 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:49:01 +0300 Subject: [PATCH 08/16] test(climate): cover the fan speed poll with known sides and no season (#792 review, 1) The split of _season_has_fan_coil() left the "sides known, season unknown" branch without a case; it keeps asking *#4*Z*11## as before. Review 5639409551 on #792, finding 1. Co-Authored-By: Claude Fable 5.1 --- tests/test_climate_actuator_status.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index f687e019d..8bb2babb7 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -314,6 +314,15 @@ async def test_no_fan_speed_poll_in_the_valve_s_season(hass): assert _status_frames(zone)[-1] == "*#4*2*11##" +async def test_known_sides_without_a_season_still_poll_the_fan_speed(hass): + """The side is known but the season is not yet: ask, as before.""" + zone = _zone(hass) + _feed(zone, "*#4*2#2*20*5##", "*#4*2*19*5*0##") + assert zone._season is None + await zone.async_update() + assert _status_frames(zone)[-1] == "*#4*2*11##" + + async def test_fan_coil_side_is_asked_again_only_if_the_first_answer_never_came(hass): zone = _zone(hass) _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##", "*#4*2*19*5*0##") From 05a2fa1beb4f148f6d20984a1da54d7fba8cac0d Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:59:59 +0300 Subject: [PATCH 09/16] fix(climate): let the pending dimension 19 request expire instead of blocking the fan speed poll (#792 review, 1) The one-shot *#4*Z*19## flag was cleared only by a dimension 19 answer. A request the gateway refuses, or one dropped with the event session before it was sent (close_listener clears the queue and a reconnect queues dimension 20 only), would have left the fan speed poll disabled for good. The flag is now the time of the request: after FAN_COIL_SIDES_WAIT (30 s, the live F461 answers in 0.11 s) the zone polls as before and a later fan coil report may ask again. Review 5639409551 on #792, finding 1 (cross-check follow-up). Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 8393dbed1..6fb47f5f5 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -87,6 +87,10 @@ _ZONE_STATES_ON = ("setpoint", "comfort", "eco") _ZONE_STATES_OFF = ("protection", "off") +# How long the fan speed poll waits for the one-shot dimension 19 answer (*#4*Z*19##, +# 0.11 s on the live F461) before asking as it did before. +FAN_COIL_SIDES_WAIT = 30.0 + # Mode digit of a setpoint write (*#4*W*#14*TTTT*M##). Legrand WHO 4 p. 8 and # p. 23 define M = 1 heating, 2 conditioning, 3 generic; BTicino's own client # always writes 3 (libqtdevices probe_device.cpp:122, thermal_device.cpp:134) @@ -368,9 +372,9 @@ def __init__( # (*#4*Z*19*COOLING*HEATING##: a value of 5..8 on a side is a fan coil, 0..4 a valve). # Empty until a dimension 19 frame has been seen. self._fan_coil_sides: dict[str, bool] = {} - # The one-shot *#4*Z*19## asked when a fan coil shows up before dimension 19 did: - # True while it is waiting for its answer, so the fan speed is not polled blindly. - self._fan_coil_sides_asked: bool = False + # When the one-shot *#4*Z*19## was asked (a fan coil showed up before dimension 19 + # did): while its answer is pending the fan speed is not polled blindly. + self._fan_coil_sides_asked_at: float | None = None if fan and not self._central: self._enable_fan_mode() @@ -602,7 +606,7 @@ def _season_has_fan_coil(self) -> bool: # Dimension 19 is on its way (asked once a fan coil showed up in dimension 20): # on the live F461 it answers in 0.11 s, while *#4*Z*11## in the valve's season # is NACKed after 2.1 s. Do not ask blindly while the answer is pending. - return not self._fan_coil_sides_asked + return not self._fan_coil_sides_pending() if self._season is None: return True side = "heating" if self._season == SEASON_HEATING else "cooling" @@ -616,14 +620,24 @@ def _ask_fan_coil_sides(self) -> None: valve's season too and NACKed after 2.1 s on the F461. Every gateway firmware of the oracle forwards ``*#4*Z*19##`` (own-firmware-oracle thermo-zone-requests). """ - if self._fan_coil_sides or self._fan_coil_sides_asked: + if self._fan_coil_sides or self._fan_coil_sides_pending(): return request = getattr(self._gateway_handler, "request_zone_dimension", None) if not callable(request): return - self._fan_coil_sides_asked = True + self._fan_coil_sides_asked_at = time.monotonic() request(self._full_where, 19) + def _fan_coil_sides_pending(self) -> bool: + """Whether the one-shot dimension 19 request is still waiting for its answer. + + An answer that never comes (request refused, session lost before it was sent) must + not disable the fan speed poll for good: after ``FAN_COIL_SIDES_WAIT`` the zone is + back to asking as before, and a later fan coil report may ask dimension 19 again. + """ + asked_at = self._fan_coil_sides_asked_at + return asked_at is not None and time.monotonic() - asked_at < FAN_COIL_SIDES_WAIT + def _any_actuator_active(self) -> bool: """Whether the most recent actuator report (dimension 20 or 19) shows activity.""" if self._active_source == "zone": @@ -1198,7 +1212,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: "cooling": isinstance(getattr(message, "cooling_fan_on", None), bool), "heating": isinstance(getattr(message, "fan_on", None), bool), } - self._fan_coil_sides_asked = False + self._fan_coil_sides_asked_at = None # Actuator status (Dimension 20) with values >= 5 reports fancoil fan status in OWNd. is_fan = isinstance(getattr(message, "fan_speed", None), int) or isinstance( getattr(message, "fan_on", None), bool From cfbdeb044dbc81098b7ddca55288070c942c0194 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:59:59 +0300 Subject: [PATCH 10/16] fix(gateway): validate the zone grammar before counting a watch and coalesce every batch (#792 review, 3, 6) Three follow-ups on the review's findings 3 and 6: - The zone address check was a character whitelist: "#", "2##" or "2#1#2#3" passed and built malformed frames. A regex now accepts Z, #Z, Z#N and the same behind an interface (Z#4#if, BUS_ROUTING), the grammar OWNd#140's builder is being extended to. - watch_zone_actuators() counted the zone before request_zone_dimension() could reject it, leaving a phantom entry that every reconnect retried; the address is validated first. - The coalesce window applied to the first batch only: a zone registered while a batch was on the bus started its own batch at once. Every iteration of the send loop after the first now waits ZONE_REQUEST_COALESCE. Review 5639409551 on #792, findings 3 and 6 (cross-check follow-up). Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 25 +++++++++----- tests/test_climate_actuator_status.py | 50 ++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 5ef420654..88d5a1c4c 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -4,6 +4,7 @@ import asyncio import collections import inspect +import re import time from collections.abc import Iterable from typing import Any @@ -175,14 +176,19 @@ def command_session_default(model: str | None) -> int: _ZONE_REQUEST_MODELS = sorted(ACTUATOR_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS), key=len, reverse=True) +# A WHO 4 zone address: ``Z``, ``#Z``, ``Z#N`` (actuator), each optionally behind an +# interface (``Z#4#if``); the grammar OWNd#140's builder is being extended to. +_ZONE_WHERE = re.compile(rf"^#?[0-9]+(?:#[0-9]+)?(?:{re.escape(BUS_ROUTING)}[0-9]{{1,2}})?$") + + def _validate_zone_where(where: str) -> None: """Reject a WHO 4 zone address that cannot go into ``*#4*WHERE*DIM##`` as it is. - Anything but digits and ``#`` would change the frame's grammar: ``2*22`` would turn a - dimension 20 request into ``*#4*2*22*20##``, a dimension 22 write. The builder of - OWNd#140 (``OWNHeatingCommand.actuators_status``) rejects the same addresses. + Anything else would change the frame's grammar: ``2*22`` would turn a dimension 20 + request into ``*#4*2*22*20##``, a dimension 22 write; ``2##`` ends the frame early. + The builder of OWNd#140 (``OWNHeatingCommand.actuators_status``) rejects the same. """ - if not where or not all(character.isdigit() or character == "#" for character in where): + if not _ZONE_WHERE.match(where or ""): raise ValueError(f"Invalid zone address: {where!r}") @@ -438,6 +444,7 @@ def watch_zone_actuators(self, where: str) -> CALLBACK_TYPE: refuses the request is decided when it is sent, once the in-band identity (WHO 13 / WHO 1013) has settled, so a model corrected after the entities were added is honoured. """ + _validate_zone_where(where) # before the zone is counted: no phantom watch self._actuator_zones[where] = self._actuator_zones.get(where, 0) + 1 self.request_zone_dimension(where, 20) @@ -470,12 +477,14 @@ def request_zone_dimension(self, where: str, dimension: int) -> None: async def _send_zone_requests(self) -> None: """Send the waiting zone requests through the paced sender, until none is left.""" - discovered = self._initial_discovery_done.is_set() + coalesce = self._initial_discovery_done.is_set() await self.wait_for_initial_discovery() - # Zones added in the same pass join this run; after the sweep, zones discovered from - # the bus over the next tenth of a second do too instead of each sweeping on its own. - await asyncio.sleep(ZONE_REQUEST_COALESCE if discovered else 0) while self._zone_requests: + # Zones added in the same pass join this run; after the sweep, zones discovered + # from the bus over the next tenth of a second do too instead of each sweeping on + # its own, and so do zones that arrived while the previous batch was being sent. + await asyncio.sleep(ZONE_REQUEST_COALESCE if coalesce else 0) + coalesce = True pending, self._zone_requests = self._zone_requests, set() frames: list[str] = [] target = self._who4_target() diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 8bb2babb7..dd2667cb2 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -326,7 +326,7 @@ async def test_known_sides_without_a_season_still_poll_the_fan_speed(hass): async def test_fan_coil_side_is_asked_again_only_if_the_first_answer_never_came(hass): zone = _zone(hass) _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##", "*#4*2*19*5*0##") - assert zone._fan_coil_sides_asked is False + assert zone._fan_coil_sides_asked_at is None _feed(zone, "*#4*2#2*20*5##") # the side is known: nothing more to ask zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) @@ -346,7 +346,7 @@ async def test_a_gateway_without_zone_requests_keeps_polling_the_fan_speed(hass) zone = _zone(hass) del zone._gateway_handler.request_zone_dimension _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##") - assert zone._fan_coil_sides_asked is False + assert zone._fan_coil_sides_asked_at is None await zone.async_update() assert _status_frames(zone)[-1] == "*#4*2*11##" @@ -408,7 +408,7 @@ async def test_central_off_keeps_the_actuators_attribute(hass): # ------------------------------------------------------------------ gateway: invalid zone addresses (review 5639409551, 3) -@pytest.mark.parametrize("where", ["", "2*22", "2 ", "a", "*#0", "2#1*20"]) +@pytest.mark.parametrize("where", ["", "2*22", "2 ", "a", "*#0", "2#1*20", "#", "2##", "##2", "2#", "2#4#", "2#1#2#3"]) def test_an_invalid_zone_address_is_refused_at_once(hass, where): """``2*22`` would make ``*#4*2*22*20##``, a dimension 22 write that reboots the F461.""" handler = _handler(hass, "F454") @@ -418,7 +418,7 @@ def test_an_invalid_zone_address_is_refused_at_once(hass, where): assert handler._zone_request_task is None -@pytest.mark.parametrize("where", ["2", "#2", "01", "3#4#01", "#3#4#01", "2#1#4#01", "2#1"]) +@pytest.mark.parametrize("where", ["2", "#2", "01", "3#4#01", "#3#4#01", "2#1#4#01", "2#1", "0#0", "3#4#1"]) def test_a_plain_or_routed_zone_address_is_accepted(hass, where): handler = _handler(hass, "F454") handler.request_zone_dimension(where, 20) @@ -578,3 +578,45 @@ async def test_the_startup_run_does_not_wait_the_coalesce_window(hass, fast_bus_ await handler.initial_discovery() await _settle(hass) assert _drain(handler)[-1] == "*#4*#0*20##" + + +# ------------------------------------------------------------------ cross-check follow-ups + +async def test_an_unanswered_dimension_19_does_not_disable_the_fan_speed_poll_for_good(hass, monkeypatch): + """The one-shot request may be refused or lost with the session: after the wait, ask as before.""" + monkeypatch.setattr("custom_components.myhome.climate.FAN_COIL_SIDES_WAIT", 0.5) + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##") + await zone.async_update() + assert "*#4*2*11##" not in _status_frames(zone) # pending + zone._fan_coil_sides_asked_at -= 1.0 # the wait is over, no answer came + await zone.async_update() + assert _status_frames(zone)[-1] == "*#4*2*11##" + _feed(zone, "*#4*2#2*20*5##") # the next fan coil report may ask again + assert zone._gateway_handler.request_zone_dimension.call_count == 2 + + +def test_an_invalid_zone_is_not_watched_either(hass): + handler = _handler(hass, "F454") + with pytest.raises(ValueError, match="Invalid zone address"): + handler.watch_zone_actuators("2*22") + assert handler._actuator_zones == {} + + +async def test_zones_arriving_during_a_batch_are_coalesced_into_the_next_one(hass, fast_bus_pacing, monkeypatch): + """A zone registered while a batch is on the bus, and one 50 ms later, share the next batch.""" + monkeypatch.setattr("custom_components.myhome.gateway.ZONE_REQUEST_COALESCE", 0.2) + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + sent: list[list[str]] = [] + + async def _send(frames): + sent.append(list(frames)) + if len(sent) == 1: + handler.watch_zone_actuators("5") + hass.loop.call_later(0.05, handler.watch_zone_actuators, "7") + + handler.send_paced = _send + handler.watch_zone_actuators("2") + await _settle(hass) + assert sent == [["*#4*2*20##"], ["*#4*5*20##", "*#4*7*20##"]] From 516e3e00bd129f6843c75d877d61de0b77704c54 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:46:38 +0300 Subject: [PATCH 11/16] fix(climate): ask the fan coil's side before the poll on add and the fan speed once it is known (#792 review, 1) `super().async_added_to_hass()` restores the state and, once the startup sweep is over, polls the zone right away; `_ask_fan_coil_sides()` ran after it, so the poll on add saw no pending dimension 19 and sent `*#4*Z*11##` blindly (NACKed after 2.1 s in the valve's season on the F461). The side is now asked before `super().async_added_to_hass()` for a configured fan coil, and at the end of `async_restore_last_state()` for a restored fan mode without `fan_coil_*` (zones only: no central unit, no secondary probe). The entity is not polled after add (`should_poll` is False), so a dimension 19 reply that resolves the side in the fan coil's season now queues `*#4*Z*11##` through the gateway's paced sender: dimension 20 value 5 (fan off) carries no speed and `fan_mode` stayed on the synthetic `auto` until a poll that never came. The same when the fan coil shows up (dimension 20) once the side is already known from an earlier dimension 19, and when a dimension 19 itself reveals the fan coil; a known side in the valve's season asks nothing. The `FAN_COIL_SIDES_WAIT` clock no longer runs while `initial_discovery_pending` or `sweep_active` holds the zone requests back: a startup sweep longer than 30 s expired the wait before `*#4*Z*19##` was ever on the wire. A request queued during a hold is marked held; the wait starts at the first check after the hold is over (nothing checks the entity during a hold, so the stamp taken then would be stale). Tests: eight fail before, pass after (the restored fan mode and the configured fan coil send `*#4*2##` only, `*#4*2*11##` is not sent before `*#4*2*19##`; a restored state through `async_restore_last_state` asks dimension 19 before the poll; a dimension 19 reply in the fan coil's season asks dimension 11 once, in the valve's season never; a zone-level report revealing the fan coil asks the speed; a fan coil revealed after its side asks the speed; a sweep longer than the wait keeps the request pending, checked during the hold or only after it, and the wait starts once the sweep is over). Restored sides are not asked again. `tests/test_component_climate.py::test_climate_fan_mode_and_attributes` asserted the blind `*#4*5*11##` on add and now expects the dimension 19 request instead. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 66 ++++++++++++-- tests/test_climate_actuator_status.py | 124 ++++++++++++++++++++++++-- tests/test_component_climate.py | 10 +-- 3 files changed, 181 insertions(+), 19 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 6fb47f5f5..4236c4bf1 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -375,6 +375,8 @@ def __init__( # When the one-shot *#4*Z*19## was asked (a fan coil showed up before dimension 19 # did): while its answer is pending the fan speed is not polled blindly. self._fan_coil_sides_asked_at: float | None = None + # The dimension 19 request was queued while a sweep held the zone requests back. + self._fan_coil_sides_held = False if fan and not self._central: self._enable_fan_mode() @@ -535,6 +537,10 @@ async def async_restore_last_state(self, last_state: State | None) -> None: self._attr_current_humidity = float(last_state.attributes["current_humidity"]) except (ValueError, TypeError): pass + if self._fan and not self._central and not is_probe(self._where): + # A restored fan mode without the fan coil's side: ask for it now, before + # ``async_added_to_hass`` polls the zone, or the poll asks ``*#4*Z*11##`` blindly. + self._ask_fan_coil_sides() async def async_update(self) -> None: """Request status update from gateway, unless the zone has stopped answering.""" @@ -622,11 +628,17 @@ def _ask_fan_coil_sides(self) -> None: """ if self._fan_coil_sides or self._fan_coil_sides_pending(): return + if self._request_zone_dimension(19): + self._fan_coil_sides_asked_at = time.monotonic() + self._fan_coil_sides_held = self._zone_requests_held() + + def _request_zone_dimension(self, dimension: int) -> bool: + """Queue ``*#4*Z*DIM##`` through the gateway's paced sender; False on an older handler.""" request = getattr(self._gateway_handler, "request_zone_dimension", None) if not callable(request): - return - self._fan_coil_sides_asked_at = time.monotonic() - request(self._full_where, 19) + return False + request(self._full_where, dimension) + return True def _fan_coil_sides_pending(self) -> bool: """Whether the one-shot dimension 19 request is still waiting for its answer. @@ -634,9 +646,28 @@ def _fan_coil_sides_pending(self) -> bool: An answer that never comes (request refused, session lost before it was sent) must not disable the fan speed poll for good: after ``FAN_COIL_SIDES_WAIT`` the zone is back to asking as before, and a later fan coil report may ask dimension 19 again. + The wait starts once the request can leave the gateway: while the startup sweep (or + another paced sweep) holds the zone requests back, nothing has been asked yet, and a + request queued during the hold starts its wait at the first check after it. """ asked_at = self._fan_coil_sides_asked_at - return asked_at is not None and time.monotonic() - asked_at < FAN_COIL_SIDES_WAIT + if asked_at is None: + return False + if self._fan_coil_sides_held: + if self._zone_requests_held(): + return True + self._fan_coil_sides_held = False + self._fan_coil_sides_asked_at = time.monotonic() + return True + return time.monotonic() - asked_at < FAN_COIL_SIDES_WAIT + + def _zone_requests_held(self) -> bool: + """Whether the gateway is still holding the zone requests back (startup or paced sweep).""" + handler = self._gateway_handler + return ( + getattr(handler, "initial_discovery_pending", False) is True + or getattr(handler, "sweep_active", False) is True + ) def _any_actuator_active(self) -> bool: """Whether the most recent actuator report (dimension 20 or 19) shows activity.""" @@ -692,18 +723,20 @@ async def async_added_to_hass(self) -> None: self._handle_central_mode_update, ) ) + zone_requests = not self._central and not is_probe(self._where) + if zone_requests and self._fan: + # A configured fan coil without its side: ask for it before the poll on add + # (inside ``super().async_added_to_hass()``), which must not ask ``*#4*Z*11##`` + # blindly. A restored fan mode asks at the end of ``async_restore_last_state``. + self._ask_fan_coil_sides() await super().async_added_to_hass() - if not self._central and not is_probe(self._where): + if zone_requests: # hvac_action needs the actuators (dimension 20); without asking, it stays unknown # until one of them switches. The gateway asks again after a reconnect. A secondary # probe (WHERE >= 100) is push-only: *#4*105*...## NACKs in 2.1 s on the F461. watch = getattr(self._gateway_handler, "watch_zone_actuators", None) if callable(watch): self.async_on_remove(watch(self._full_where)) - if self._fan: - # Restored (or configured) fan mode without the fan coil's side: ask for it - # before the first fan speed poll. - self._ask_fan_coil_sides() @callback def _handle_central_mode_update(self, master_mode: HVACMode) -> None: @@ -1207,16 +1240,20 @@ def handle_event(self, message: OWNHeatingEvent) -> None: ) # Valves status (dimension 19): OWNd sets cooling_fan_on / fan_on only for a # fan-coil value (5..8) on that side, so their presence tells which side is a fan coil. + sides_resolved = False if getattr(message, "dimension", None) == 19: + sides_resolved = not self._fan_coil_sides self._fan_coil_sides = { "cooling": isinstance(getattr(message, "cooling_fan_on", None), bool), "heating": isinstance(getattr(message, "fan_on", None), bool), } self._fan_coil_sides_asked_at = None + self._fan_coil_sides_held = False # Actuator status (Dimension 20) with values >= 5 reports fancoil fan status in OWNd. is_fan = isinstance(getattr(message, "fan_speed", None), int) or isinstance( getattr(message, "fan_on", None), bool ) + fan_enabled_now = is_fan and not self._fan if is_fan: self._enable_fan_mode() if getattr(message, "dimension", None) == 20: @@ -1233,6 +1270,17 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._running_fan_speed = "off" else: self._running_fan_speed = None + if ( + (sides_resolved or (fan_enabled_now and self._fan_coil_sides)) + and self._fan + and self._season_has_fan_coil() + ): + # The entity is not polled after add (``should_poll`` is False) and the poll on + # add skipped ``*#4*Z*11##`` while the side was unknown or pending, or the fan + # coil showed up (dimension 20) once the side was already known: ask for the + # configured speed now, or ``fan_mode`` keeps the synthetic ``auto`` (dimension 20 + # value 5, fan off, carries no speed). + self._request_zone_dimension(11) is_active = bool(message.is_active()) dimension = getattr(message, "dimension", None) diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index dd2667cb2..8328432be 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -13,11 +13,12 @@ import asyncio from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call import pytest from homeassistant.components.climate import HVACAction, HVACMode 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 GenericGatewayProfile, get_gateway_profile @@ -36,7 +37,7 @@ ) -def _zone(hass, where="2", gateway=None, **kwargs): +def _zone(hass, where="2", gateway=None, fan=False, **kwargs): if gateway is None: gateway = MagicMock() gateway.mac = MAC @@ -45,7 +46,7 @@ def _zone(hass, where="2", gateway=None, **kwargs): gateway.has_central_unit = False 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, + heating=True, cooling=True, fan=fan, standalone=True, central=False, manufacturer="BTicino", model="Heating Zone", gateway=gateway, **kwargs, ) zone.hass = hass @@ -331,14 +332,127 @@ async def test_fan_coil_side_is_asked_again_only_if_the_first_answer_never_came( zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) -async def test_restored_fan_mode_without_the_side_asks_for_it_on_add(hass): - zone = _zone(hass) +def _added_at_once(zone) -> None: + """A zone added once the sweep is over polls right away (``async_update`` on add).""" zone._gateway_handler.initial_discovery_pending = False zone._gateway_handler.sweep_active = False zone._gateway_handler.supports_zone_status = True + + +async def test_restored_fan_mode_without_the_side_asks_for_it_on_add(hass): + """Dimension 19 is queued before the poll on add, so the poll does not ask ``*#4*2*11##`` blindly.""" + zone = _zone(hass) + _added_at_once(zone) zone._enable_fan_mode() # what a restored ``fan_mode`` attribute does await zone.async_added_to_hass() zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) + assert _status_frames(zone) == ["*#4*2##"] + assert "*#4*2*11##" not in _status_frames(zone) + + +async def test_restoring_the_fan_mode_asks_the_side_before_the_first_poll(hass): + """``async_restore_last_state`` runs inside ``async_added_to_hass``, right before the poll.""" + zone = _zone(hass) + _added_at_once(zone) + zone.async_get_last_state = AsyncMock(return_value=State("climate.zone_2", "heat", {"fan_mode": "low"})) + await zone.async_added_to_hass() + assert zone.fan_mode == "low" + zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) + assert "*#4*2*11##" not in _status_frames(zone) + + +async def test_restored_sides_are_not_asked_again(hass): + zone = _zone(hass) + _added_at_once(zone) + zone.async_get_last_state = AsyncMock( + return_value=State("climate.zone_2", "cool", {"fan_mode": "auto", "fan_coil_cooling": True, "fan_coil_heating": False, "season": "cooling"}) + ) + await zone.async_added_to_hass() + zone._gateway_handler.request_zone_dimension.assert_not_called() + assert _status_frames(zone)[-1] == "*#4*2*11##" # the side is known and it is the fan coil's season + + +async def test_a_configured_fan_coil_asks_the_side_before_the_first_poll(hass): + """``fan: true`` in the configuration: the side is asked before ``super().async_added_to_hass()`` polls.""" + zone = _zone(hass, fan=True) + _added_at_once(zone) + await zone.async_added_to_hass() + zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) + assert "*#4*2*11##" not in _status_frames(zone) + + +async def test_dimension_19_in_the_fan_coil_s_season_asks_the_fan_speed_once(hass): + """The entity is not polled after add: the speed must be asked when the side becomes known. + + Dimension 20 value 5 (fan off) carries no speed, so ``fan_mode`` would stay on the + synthetic ``auto`` until the next poll, which never comes (``should_poll`` is False). + """ + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*4*0*2##", "*#4*2#2*20*5##") # cooling season, fan coil revealed + assert zone.fan_mode == "auto" + zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19) + _feed(zone, "*#4*2*19*5*0##", "*#4*2*19*5*0##") # cooling: fan coil (the season's side) + assert zone._gateway_handler.request_zone_dimension.call_args_list[1:] == [call("2", 11)] + _feed(zone, "*#4*2*11*2##") + assert zone.fan_mode == "medium" + + +async def test_dimension_19_in_the_valve_s_season_does_not_ask_the_fan_speed(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##", "*#4*2*19*5*0##") # heating season, cooling fan coil + assert [c.args for c in zone._gateway_handler.request_zone_dimension.call_args_list] == [("2", 19)] + + +async def test_dimension_19_revealing_the_fan_coil_itself_asks_the_fan_speed(hass): + """A zone-level report (``*#4*2*19*0*5##``) is the first sign of a fan coil: side and speed in one go.""" + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2*19*0*5##") # heating season, heating fan coil + assert zone._fan is True + assert [c.args for c in zone._gateway_handler.request_zone_dimension.call_args_list] == [("2", 11)] + + +async def test_a_fan_coil_revealed_after_its_side_asks_the_fan_speed(hass): + """Dimension 19 before dimension 20: the side is known when the fan coil shows up; ask the speed then.""" + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*4*0*2##", "*#4*2*19*5*0##") # cooling season; the valve side's report + assert zone._fan is False + zone._gateway_handler.request_zone_dimension.assert_not_called() + _feed(zone, "*#4*2#2*20*5##", "*#4*2#2*20*5##") + assert [c.args for c in zone._gateway_handler.request_zone_dimension.call_args_list] == [("2", 11)] + + +async def test_a_request_held_by_the_sweep_starts_its_wait_at_the_first_check_after_it(hass, monkeypatch): + """Nothing checks the entity during the hold: the stale stamp must not count as the wait.""" + monkeypatch.setattr("custom_components.myhome.climate.FAN_COIL_SIDES_WAIT", 0.5) + zone = _zone(hass) + zone._gateway_handler.initial_discovery_pending = True + zone._gateway_handler.sweep_active = False + _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##") + zone._fan_coil_sides_asked_at -= 1.0 # a sweep longer than the wait, no poll meanwhile + zone._gateway_handler.initial_discovery_pending = False + await zone.async_update() + assert "*#4*2*11##" not in _status_frames(zone) # the wait starts at this check + zone._fan_coil_sides_asked_at -= 1.0 + await zone.async_update() + assert _status_frames(zone)[-1] == "*#4*2*11##" + + +async def test_a_long_startup_sweep_does_not_expire_the_fan_coil_side_wait(hass, monkeypatch): + """The request waits for the startup sweep; the wait for its answer starts once the sweep is over.""" + monkeypatch.setattr("custom_components.myhome.climate.FAN_COIL_SIDES_WAIT", 0.5) + zone = _zone(hass) + zone._gateway_handler.initial_discovery_pending = True + zone._gateway_handler.sweep_active = False + _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##") + zone._fan_coil_sides_asked_at -= 1.0 # a sweep longer than the wait + await zone.async_update() + assert "*#4*2*11##" not in _status_frames(zone) # still pending: nothing has left the gateway + zone._gateway_handler.initial_discovery_pending = False + await zone.async_update() + assert "*#4*2*11##" not in _status_frames(zone) # the wait starts now + zone._fan_coil_sides_asked_at -= 1.0 + await zone.async_update() + assert _status_frames(zone)[-1] == "*#4*2*11##" async def test_a_gateway_without_zone_requests_keeps_polling_the_fan_speed(hass): diff --git a/tests/test_component_climate.py b/tests/test_component_climate.py index a5fe64476..e03fb4098 100644 --- a/tests/test_component_climate.py +++ b/tests/test_component_climate.py @@ -673,13 +673,13 @@ async def test_climate_fan_mode_and_attributes(hass): climate_fancoil.handle_event(event) assert climate_fancoil.fan_mode == "auto" - # Test async_added_to_hass immediately sends status requests (general + Dimension 11 fan status) + # async_added_to_hass sends the general status request at once; the fan speed (dimension 11) + # waits for dimension 19 (which side is the fan coil), asked through the gateway's paced sender climate_fancoil.async_on_remove = MagicMock() await climate_fancoil.async_added_to_hass() - assert gateway.send_status_request.await_count == 2 - sent_requests = [str(call[0][0]) for call in gateway.send_status_request.call_args_list] - assert "*#4*5##" in sent_requests - assert "*#4*5*11##" in sent_requests + assert gateway.send_status_request.await_count == 1 + assert [str(call[0][0]) for call in gateway.send_status_request.call_args_list] == ["*#4*5##"] + gateway.request_zone_dimension.assert_any_call("5", 19) async def test_climate_central_fan_mode_guard(hass): From 5bd28f2548b29867191ad1c655a1f14a0b18d7b2 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:47:14 +0300 Subject: [PATCH 12/16] test(climate): keep the startup sweep from timing out under fast bus pacing (#792 review, 2) `fast_bus_pacing` makes `wait_for_initial_discovery()` give up after 7 * 0.02 s while `F461.initial_discovery()` sleeps twelve times over its six unwritten frames: with a dozen milliseconds of timer slack (WSL2, loaded CI runners) the zone requests were queued before `*#18*51*113##` and `test_a_gateway_identified_late_as_supported_is_asked` and `test_the_startup_run_does_not_wait_the_coalesce_window` failed on the frame order (`test_a_gateway_identified_late_as_refusing_is_not_asked` timed out silently). The three tests raise `BUS_QUIET_CAP` to 5 s as `test_actuators_are_asked_after_the_startup_sweep` does: a quiet mock bus never waits for the cap, it only sizes the wait for the sweep. Co-Authored-By: Claude Fable 5.1 --- tests/test_climate_actuator_status.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 8328432be..1aa63605d 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -553,8 +553,9 @@ async def test_one_rejected_zone_does_not_drop_the_batch(hass, fast_bus_pacing, # ------------------------------------------------------------------ gateway: identity settles after the entities were added (review 5639409551, 4) -async def test_a_gateway_identified_late_as_refusing_is_not_asked(hass, fast_bus_pacing): +async def test_a_gateway_identified_late_as_refusing_is_not_asked(hass, fast_bus_pacing, monkeypatch): """Entities are added as ``Generic``; WHO 13 / WHO 1013 then says MH202 before the sweep ends.""" + monkeypatch.setattr("custom_components.myhome.gateway.BUS_QUIET_CAP", 5.0) # the wait for the sweep must not time out handler = _handler(hass, "Generic") handler.watch_zone_actuators("2") await hass.async_block_till_done() @@ -571,8 +572,9 @@ async def test_a_gateway_identified_late_as_refusing_is_not_asked(hass, fast_bus assert _drain(handler) == [] -async def test_a_gateway_identified_late_as_supported_is_asked(hass, fast_bus_pacing): +async def test_a_gateway_identified_late_as_supported_is_asked(hass, fast_bus_pacing, monkeypatch): """Entities are added as a misconfigured MH200N; WHO 13 then corrects it to F461.""" + monkeypatch.setattr("custom_components.myhome.gateway.BUS_QUIET_CAP", 5.0) # the wait for the sweep must not time out handler = _handler(hass, "MH200N") handler.watch_zone_actuators("2") await hass.async_block_till_done() @@ -685,6 +687,7 @@ async def test_zones_discovered_after_the_sweep_share_one_all_zone_request(hass, async def test_the_startup_run_does_not_wait_the_coalesce_window(hass, fast_bus_pacing, monkeypatch): """Requests queued before the sweep ended already waited for it: no extra delay.""" + monkeypatch.setattr("custom_components.myhome.gateway.BUS_QUIET_CAP", 5.0) # the wait for the sweep must not time out monkeypatch.setattr("custom_components.myhome.gateway.ZONE_REQUEST_COALESCE", 5.0) handler = _handler(hass, "F461") handler.watch_zone_actuators("2") From 7b42cc7f747869e4b3001896634456a1ffdabf5a Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:53:16 +0300 Subject: [PATCH 13/16] fix(gateway): write a routed zone's interface with two digits, as OWNd#140's builder requires (#792 review, 3) `_ZONE_WHERE` accepted a one- or two-digit interface (`3#4#1`, `3#4#01`) while the companion builder `OWNHeatingCommand.actuators_status` (OpenWebNet-HA/OWNd#140, 307dcc9) requires exactly two (`#4#[0-9]{2}`) and rejects `3#4#1`, so swapping the string builder for it would have broken a zone discovered from a frame that spells its interface with one digit. The address is normalised before it is validated: `_validate_zone_where` returns the address with a two-digit interface (`3#4#1` -> `3#4#01`), `request_zone_dimension` and `watch_zone_actuators` store that form (one watch count per zone, whichever spelling asked), and `_ZONE_WHERE` is tightened to `{2}`. In `climate.py`, `_calling_zones` and the entity's `_full_where` spell the interface the way the bus frames and the configuration validator (`BusInterface`, `zfill(2)`) do, so a routed zone has one address everywhere. Tests: two fail before, pass after (`watch_zone_actuators("3#4#1")` counts `3#4#01` and sends `*#4*3#4#01*20##`, unwatch removes it; `*#4*3#4#1*14*0215##` and `interface="1"` both give `3#4#01`). `3#4#001` joins the refused addresses; `3#4#1` leaves the list of addresses accepted as they are. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 13 ++++++++++-- custom_components/myhome/gateway.py | 29 ++++++++++++++++----------- tests/test_climate_actuator_status.py | 28 +++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 4236c4bf1..6d5f01ac3 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -225,6 +225,14 @@ def _bus_zone(message: Any) -> int | None: return zone +def _two_digit_interface(interface: str | None) -> str | None: + """The F422 interface as the bus and the configuration validator spell it (``01``).""" + if interface is None: + return None + interface = str(interface) + return interface.zfill(2) if interface.isdigit() else interface + + def _calling_zones(message: Any) -> tuple[list[str], str | None]: """Zones a heating frame concerns, and the F422 interface it came through.""" raw_where = getattr(message, "where", None) @@ -236,6 +244,7 @@ def _calling_zones(message: Any) -> tuple[list[str], str | None]: tail = where_param(message) if not interface and len(tail) > 1 and tail[0] == "4": interface = str(tail[1]) + interface = _two_digit_interface(interface) zones: list[str] = [] # WHERE ``#`` names actuator of the zone (``*#4*2#1*20*1##`` @@ -325,9 +334,9 @@ def __init__( if hass is not None: self.hass = hass - self._interface = interface + self._interface = _two_digit_interface(interface) self._full_where = ( - f"{self._where}#4#{self._interface}" if self._interface is not None else self._where + f"{self._where}{BUS_ROUTING}{self._interface}" if self._interface is not None else self._where ) self._central = True if self._where in ("#0", "#0#1") else central diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 88d5a1c4c..598716d12 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -177,25 +177,31 @@ def command_session_default(model: str | None) -> int: # A WHO 4 zone address: ``Z``, ``#Z``, ``Z#N`` (actuator), each optionally behind an -# interface (``Z#4#if``); the grammar OWNd#140's builder is being extended to. -_ZONE_WHERE = re.compile(rf"^#?[0-9]+(?:#[0-9]+)?(?:{re.escape(BUS_ROUTING)}[0-9]{{1,2}})?$") +# interface of exactly two digits (``Z#4#01``): the grammar of OWNd#140's builder +# (``OWNHeatingCommand.actuators_status``). +_ZONE_WHERE = re.compile(rf"^#?[0-9]+(?:#[0-9]+)?(?:{re.escape(BUS_ROUTING)}[0-9]{{2}})?$") -def _validate_zone_where(where: str) -> None: - """Reject a WHO 4 zone address that cannot go into ``*#4*WHERE*DIM##`` as it is. +def _validate_zone_where(where: str) -> str: + """The WHO 4 zone address as it goes into ``*#4*WHERE*DIM##``, or ``ValueError``. - Anything else would change the frame's grammar: ``2*22`` would turn a dimension 20 - request into ``*#4*2*22*20##``, a dimension 22 write; ``2##`` ends the frame early. - The builder of OWNd#140 (``OWNHeatingCommand.actuators_status``) rejects the same. + A one-digit interface is written with two (``3#4#1`` -> ``3#4#01``), as the frames on + the wire and the configuration validator spell it. Anything else would change the + frame's grammar: ``2*22`` would turn a dimension 20 request into ``*#4*2*22*20##``, a + dimension 22 write; ``2##`` ends the frame early. The builder of OWNd#140 + (``OWNHeatingCommand.actuators_status``) accepts and rejects the same addresses. """ + zone, routing, interface = (where or "").partition(BUS_ROUTING) + if routing and len(interface) == 1 and interface.isdigit(): + where = f"{zone}{routing}{interface.zfill(2)}" if not _ZONE_WHERE.match(where or ""): raise ValueError(f"Invalid zone address: {where!r}") + return where def _zone_request_frame(where: str, dimension: int) -> str: """The dimension request of one zone (``*#4*WHERE*DIM##``), validated like a builder.""" - _validate_zone_where(where) - return f"*#4*{where}*{dimension}##" + return f"*#4*{_validate_zone_where(where)}*{dimension}##" class MyHOMEGatewayHandler(GatewayTopologyMixin, GatewayIdentityMixin): @@ -444,7 +450,7 @@ def watch_zone_actuators(self, where: str) -> CALLBACK_TYPE: refuses the request is decided when it is sent, once the in-band identity (WHO 13 / WHO 1013) has settled, so a model corrected after the entities were added is honoured. """ - _validate_zone_where(where) # before the zone is counted: no phantom watch + where = _validate_zone_where(where) # before the zone is counted: no phantom watch self._actuator_zones[where] = self._actuator_zones.get(where, 0) + 1 self.request_zone_dimension(where, 20) @@ -468,8 +474,7 @@ def request_zone_dimension(self, where: str, dimension: int) -> None: if dimension == 22: # *#4*Z*22## kills the F461 thermostat handler and reboots the gateway. raise ValueError("WHO 4 dimension 22 is never requested") - _validate_zone_where(where) - self._zone_requests.add((where, dimension)) + self._zone_requests.add((_validate_zone_where(where), dimension)) if self._zone_request_task is None or self._zone_request_task.done(): self._zone_request_task = self.hass.async_create_background_task( self._send_zone_requests(), name=f"myhome_{self.mac}_zone_requests" diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 1aa63605d..d6fa2738f 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -22,8 +22,9 @@ from OWNd.message import OWNHeatingEvent from OWNd.profiles import GenericGatewayProfile, get_gateway_profile -from custom_components.myhome.climate import MyHOMEClimate +from custom_components.myhome.climate import MyHOMEClimate, _zone_address from custom_components.myhome.const import CONF_FIRMWARE +from custom_components.myhome.discovery import Address from custom_components.myhome.gateway import MyHOMEGatewayHandler MAC = "00:03:50:00:00:44" @@ -522,7 +523,7 @@ async def test_central_off_keeps_the_actuators_attribute(hass): # ------------------------------------------------------------------ gateway: invalid zone addresses (review 5639409551, 3) -@pytest.mark.parametrize("where", ["", "2*22", "2 ", "a", "*#0", "2#1*20", "#", "2##", "##2", "2#", "2#4#", "2#1#2#3"]) +@pytest.mark.parametrize("where", ["", "2*22", "2 ", "a", "*#0", "2#1*20", "#", "2##", "##2", "2#", "2#4#", "2#1#2#3", "3#4#001"]) def test_an_invalid_zone_address_is_refused_at_once(hass, where): """``2*22`` would make ``*#4*2*22*20##``, a dimension 22 write that reboots the F461.""" handler = _handler(hass, "F454") @@ -532,13 +533,34 @@ def test_an_invalid_zone_address_is_refused_at_once(hass, where): assert handler._zone_request_task is None -@pytest.mark.parametrize("where", ["2", "#2", "01", "3#4#01", "#3#4#01", "2#1#4#01", "2#1", "0#0", "3#4#1"]) +@pytest.mark.parametrize("where", ["2", "#2", "01", "3#4#01", "#3#4#01", "2#1#4#01", "2#1", "0#0"]) def test_a_plain_or_routed_zone_address_is_accepted(hass, where): handler = _handler(hass, "F454") handler.request_zone_dimension(where, 20) assert handler._zone_requests == {(where, 20)} +async def test_a_one_digit_interface_is_asked_with_two_digits(hass, fast_bus_pacing): + """``3#4#1`` is zone 3 behind interface 01: the frame says ``#4#01``, as OWNd#140's builder requires.""" + handler = _handler(hass, "F454") + handler._initial_discovery_done.set() + unwatch = handler.watch_zone_actuators("3#4#1") + assert handler._actuator_zones == {"3#4#01": 1} + assert handler._zone_requests == {("3#4#01", 20)} + await _settle(hass) + assert _drain(handler) == ["*#4*3#4#01*20##"] + unwatch() + assert handler._actuator_zones == {} + + +def test_a_routed_frame_names_its_interface_with_two_digits(hass): + """A one-digit interface in a WHO 4 frame and in the configuration both make ``Z#4#0N``.""" + assert _zone_address(OWNHeatingEvent("*#4*3#4#1*14*0215##")) == Address("3", "01") + assert _zone_address(OWNHeatingEvent("*#4*3#4#01*14*0215##")) == Address("3", "01") + assert _zone(hass, where="3", interface="1")._full_where == "3#4#01" + assert _zone(hass, where="3", interface="01")._full_where == "3#4#01" + + async def test_one_rejected_zone_does_not_drop_the_batch(hass, fast_bus_pacing, caplog): """A zone the frame builder rejects is logged and the other zones are still asked.""" handler = _handler(hass, "F454") From b61bb3e054376a6ad6bdb66682529fdbb04d7124 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:53:59 +0300 Subject: [PATCH 14/16] fix(gateway): skip the paced sender when every zone request was dropped (#792 review, 4) On a gateway that refuses the actuator request (F453, F453AV, H4684, MH200, MH200N, MH202) every startup and reconnect batch of `_send_zone_requests()` ended with an empty `frames` list, and `send_paced([])` still ran `_begin_sweep()` / `_end_sweep()`: the sweep flags toggled for nothing. An empty batch now goes back to the loop. Test: one fails before, passes after (an MH202 watching a zone never awaits `send_paced` and `_sweep_idle` stays set). Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 2 ++ tests/test_climate_actuator_status.py | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 598716d12..373298237 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -510,6 +510,8 @@ async def _send_zone_requests(self) -> None: frames.append(_zone_request_frame(where, dimension)) except ValueError as err: LOGGER.warning("%s Zone %s: dimension %s not requested: %s", self.log_id, where, dimension, err) + if not frames: + continue # every item skipped (refusing gateway): no empty sweep try: await self.send_paced(frames) except Exception: # a transport error must not end the task silently (#593) diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index d6fa2738f..c9fe582bd 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -573,6 +573,19 @@ async def test_one_rejected_zone_does_not_drop_the_batch(hass, fast_bus_pacing, assert "Zone 2*22: dimension 20 not requested: Invalid zone address: '2*22'" in caplog.text +async def test_a_refusing_gateway_does_not_run_an_empty_sweep(hass, fast_bus_pacing, monkeypatch): + """Every item skipped: no ``send_paced([])``, so the sweep flags are not toggled for nothing.""" + handler = _handler(hass, "MH202") + handler._initial_discovery_done.set() + send_paced = AsyncMock(wraps=handler.send_paced) + monkeypatch.setattr(handler, "send_paced", send_paced) + handler.watch_zone_actuators("2") + await _settle(hass) + send_paced.assert_not_awaited() + assert handler._sweep_idle.is_set() + assert _drain(handler) == [] + + # ------------------------------------------------------------------ gateway: identity settles after the entities were added (review 5639409551, 4) async def test_a_gateway_identified_late_as_refusing_is_not_asked(hass, fast_bus_pacing, monkeypatch): From 9352638f3284632fff1e5427de2c92045229cd4d Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 12:47:26 +0200 Subject: [PATCH 15/16] fix(climate): ask the fan speed when the season moves to the fan coil's side at runtime (#792 review, 1) A mixed valve + fan-coil zone that booted in the valve's season skipped *#4*Z*11## (a 2.1 s NACK on the F461 there). The entity is not polled after add (should_poll is False), so when the season frame *4*0*Z## / *4*1*Z## later switched the zone to the fan coil's season nothing asked the speed and fan_mode kept the synthetic "auto" until a user command or a reconnect (review 5642758778, first note). The season handler (MESSAGE_TYPE_SEASON, and the MESSAGE_TYPE_MODE fallback of older OWNd releases) now queues the one-shot request_zone_dimension(11) the way the dimension 19 / 20 paths do, once: only a change of side counts (the frame repeats with every zone poll and OWNd spells the cooling season "conditioning" where a restored state says "cooling"), only with known sides, and never for the first season after add (the poll on add asked the speed itself when the sides were known; dimension 19 asks it once they become known). A transition to the valve's season requests nothing. Tests: seven cases in tests/test_climate_actuator_status.py (both directions, the repeat frames, the valve's season, unknown sides, no fan coil, restored sides with and without a season, the older-OWNd mode fallback); four fail before the change. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 31 ++++++++ tests/test_climate_actuator_status.py | 103 ++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 6d5f01ac3..e9bed63dc 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -627,6 +627,33 @@ def _season_has_fan_coil(self) -> bool: side = "heating" if self._season == SEASON_HEATING else "cooling" return self._fan_coil_sides.get(side, False) + @staticmethod + def _season_side(season: str | None) -> str | None: + """The side of the zone a season runs on (``heating`` / ``cooling``), None when unknown.""" + if season is None: + return None + return "heating" if season == SEASON_HEATING else "cooling" + + def _follow_season_change(self, previous: str | None) -> None: + """Ask the fan speed once the season moves to the fan coil's side at runtime. + + A mixed zone (F461 plant: heating valve, cooling fan coil) that booted in the valve's + season skipped ``*#4*Z*11##`` (NACKed after 2.1 s there). The entity is not polled + afterwards (``should_poll`` is False), so when the season frame (``*4*0*Z##`` / + ``*4*1*Z##``) switches the zone to the fan coil's season the one-shot request is + queued here, as the dimension 19 / 20 paths do; ``fan_mode`` would otherwise keep + the synthetic ``auto`` until a command or a reconnect. The frame repeats with every + zone poll and OWNd spells the cooling season ``conditioning`` where a restored + state says ``cooling``, so only a change of side counts. The first season after a + start (``None`` -> a season) is the poll on add, which asked the speed itself when + the sides were known, and dimension 19 asks it once they become known. + """ + side = self._season_side(self._season) + if side is None or self._season_side(previous) in (None, side): + return + if self._fan and self._fan_coil_sides.get(side, False): + self._request_zone_dimension(11) + def _ask_fan_coil_sides(self) -> None: """Ask once which side of the zone is the fan coil (``*#4*Z*19##``). @@ -1094,6 +1121,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: # zone that was OFF or not yet known is running again, so it takes # the season's mode. For a central unit the season is the plant # mode Home Assistant shows. + previous_season = self._season self._season = message.season season_mode = HVACMode.HEAT if message.season == SEASON_HEATING else HVACMode.COOL prev_mode = self._attr_hvac_mode @@ -1122,8 +1150,10 @@ def handle_event(self, message: OWNHeatingEvent) -> None: f"myhome_central_mode_{self._gateway_handler.mac}", self._attr_hvac_mode, ) + self._follow_season_change(previous_season) elif message.message_type == MESSAGE_TYPE_MODE: prev_mode = self._attr_hvac_mode + previous_season = self._season self._remember_season(message) # OWNd releases without MESSAGE_TYPE_SEASON decode the zone operation mode # frame *4*1*Z## / *4*0*Z## as a plain heat / cool mode; it still tells the @@ -1131,6 +1161,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: raw_what = str(message).split("*")[2] if str(message).startswith("*4*") else "" if raw_what in ("0", "1") and not self._central: self._season = SEASON_HEATING if raw_what == "1" else "cooling" + self._follow_season_change(previous_season) self._program = getattr(message, "program", None) self._holiday_days = getattr(message, "holiday_days", None) self._scenario = getattr(message, "scenario", None) diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index c9fe582bd..a60865a8d 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -772,3 +772,106 @@ async def _send(frames): handler.watch_zone_actuators("2") await _settle(hass) assert sent == [["*#4*2*20##"], ["*#4*5*20##", "*#4*7*20##"]] + + +# ------------------------------------------------------------------ entity: runtime season transition (review 5642758778) + +def _dimension_requests(zone) -> list[tuple]: + return [c.args for c in zone._gateway_handler.request_zone_dimension.call_args_list] + + +async def test_season_moving_to_the_fan_coil_s_side_asks_the_fan_speed_once(hass): + """Booted in heating (valve), the zone switches to cooling (fan coil) without a reload. + + The entity is not polled after add: nothing would ask ``*#4*2*11##`` and ``fan_mode`` + would keep the synthetic ``auto`` until a command or a reconnect. + """ + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##", "*#4*2*19*5*0##") # heating season, cooling fan coil + assert _dimension_requests(zone) == [("2", 19)] + assert zone.fan_mode == "auto" + _feed(zone, "*4*0*2##") # the plant switches to the cooling season + assert _dimension_requests(zone) == [("2", 19), ("2", 11)] + _feed(zone, "*4*0*2##", "*#4*2*12*0235*3##", "*4*0*2##") # the next polls repeat the season + assert _dimension_requests(zone) == [("2", 19), ("2", 11)] + _feed(zone, "*#4*2*11*2##") + assert zone.fan_mode == "medium" + _feed(zone, "*4*1*2##") # back to the valve's season: nothing to ask + assert _dimension_requests(zone) == [("2", 19), ("2", 11)] + + +async def test_season_moving_to_the_heating_fan_coil_asks_the_fan_speed(hass): + """The other direction: cooling valve, heating fan coil, booted in cooling.""" + zone = _zone(hass) + _feed(zone, "*#4*2*0*0239##", "*4*0*2##", "*#4*2*19*0*5##") # cooling season; dimension 19 reveals a heating fan coil + assert zone.extra_state_attributes["fan_coil_heating"] is True + assert zone.extra_state_attributes["fan_coil_cooling"] is False + assert _dimension_requests(zone) == [] + _feed(zone, "*4*1*2##") + assert _dimension_requests(zone) == [("2", 11)] + _feed(zone, "*4*1*2##", "*4*0*2##", "*4*1*2##") # repeat, the valve's season, the fan coil's again + assert _dimension_requests(zone) == [("2", 11), ("2", 11)] + + +async def test_season_change_with_unknown_sides_asks_nothing(hass): + """Dimension 19 has not answered: the side is not known, the pending request decides later.""" + zone = _zone(hass, fan=True) + _feed(zone, *F461_POLL_REPLY) + _feed(zone, "*4*0*2##", "*4*1*2##") + zone._gateway_handler.request_zone_dimension.assert_not_called() + assert zone.fan_mode == "auto" + + +async def test_season_change_without_a_fan_coil_asks_nothing(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#1*20*0##", "*#4*2#2*20*0##") # two valves + _feed(zone, "*4*0*2##", "*4*1*2##") + zone._gateway_handler.request_zone_dimension.assert_not_called() + assert zone.fan_mode is None + + +async def test_the_first_season_after_add_does_not_ask_twice(hass): + """Restored sides, no season: the poll on add already asked ``*#4*2*11##``.""" + zone = _zone(hass) + _added_at_once(zone) + zone.async_get_last_state = AsyncMock( + return_value=State("climate.zone_2", "cool", {"fan_mode": "auto", "fan_coil_cooling": True, "fan_coil_heating": False}) + ) + await zone.async_added_to_hass() + assert _status_frames(zone) == ["*#4*2##", "*#4*2*11##"] + _feed(zone, "*#4*2*0*0239##", "*4*0*2##") # the poll's reply names the cooling season + zone._gateway_handler.request_zone_dimension.assert_not_called() + + +async def test_a_restored_cooling_season_spelled_conditioning_by_ownd_is_not_a_change(hass): + """The restored attribute says ``cooling``, OWNd decodes ``*4*0*2##`` as ``conditioning``.""" + zone = _zone(hass) + _added_at_once(zone) + zone.async_get_last_state = AsyncMock( + return_value=State("climate.zone_2", "cool", {"fan_mode": "low", "fan_coil_cooling": True, "fan_coil_heating": False, "season": "cooling"}) + ) + await zone.async_added_to_hass() + assert _status_frames(zone)[-1] == "*#4*2*11##" + _feed(zone, "*4*0*2##") + zone._gateway_handler.request_zone_dimension.assert_not_called() + _feed(zone, "*4*1*2##", "*4*0*2##") # a real round trip through the valve's season + assert _dimension_requests(zone) == [("2", 11)] + + +async def test_a_season_decoded_as_a_mode_by_an_older_ownd_asks_the_fan_speed_too(hass): + """OWNd releases without ``MESSAGE_TYPE_SEASON`` deliver ``*4*0*2##`` as a plain mode.""" + from OWNd.message import CLIMATE_MODE_COOL, MESSAGE_TYPE_MODE + + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY, "*#4*2#2*20*5##", "*#4*2*19*5*0##") + assert _dimension_requests(zone) == [("2", 19)] + legacy = MagicMock() + legacy.message_type = MESSAGE_TYPE_MODE + legacy.mode = CLIMATE_MODE_COOL + legacy.__str__ = MagicMock(return_value="*4*0*2##") + legacy.human_readable_log = "Zone 2 mode cool" + zone.handle_event(legacy) + assert zone._season == "cooling" + assert _dimension_requests(zone) == [("2", 19), ("2", 11)] + zone.handle_event(legacy) + assert _dimension_requests(zone) == [("2", 19), ("2", 11)] From 4a41da61934ee8920ac94b75fd652f6471f9da70 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 12:50:54 +0200 Subject: [PATCH 16/16] test(climate): replay the F461 and MyHomeServer1 actuator captures through the climate platform (#792 review, 2) The 90 cases of tests/test_climate_actuator_status.py fed inline frame tuples; the committed trace archives of #799 were only pinned as facts (tests/test_f461_live_2026_10_23_trace.py replays run4b, which has no dimension 20 frame). Two replays now run the real captures through the climate platform (async_setup_entry, bus discovery on, the frames delivered on myhome_message_ as the gateway handler does): - f461_live_2026-10-23, run6_event_session_ha_fixed_integration: the 171 event-session frames of the 1770 s run while the fixed integration ran. hvac_action stays unknown through the five frames of the first *#4*2## reply, is idle with actuators {"1": "off"} on *#4*2#1*20*0## and {"1": "off", "2": "off"} on *#4*2#2*20*5## (the answer to *#4*#0*20##), and stays so through the six reconnect sweeps (seven polls, the fan frame repeated once and reversed once, the hashed *#4*2#1*#20*0## echo dropped as a command); 24.1 / 23.5 degrees, heating, humidity 38; the fan coil asked its side once and no *#4*2*11## went out in heating. - myhomeserver1_live_2026-10-24, run2_reads_thermo_sound_alarm_lights: the 43 actuator frames that follow *#4*#0*20## on the 34-zone plant. Every configured zone (35..70) ends idle with its actuators filled from the Z#1 / Z#2 frames, the five fan coils (47, 48, 57, 60, 70) ask dimension 19 once each in the order the plant answered, and the zone-0 pump frames 0#1..0#4 discover no zone, are watched by nothing and are asked for nothing. Both fail on v2-phase1-architecture (nothing watches the zones, no actuators attribute) and pass with this branch. Co-Authored-By: Claude Fable 5.1 --- tests/test_climate_actuator_status.py | 213 +++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 3 deletions(-) diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index a60865a8d..9678abfef 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -12,20 +12,30 @@ from __future__ import annotations import asyncio +import json +from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, call +from unittest.mock import AsyncMock, MagicMock, call, 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 homeassistant.core import State -from OWNd.message import OWNHeatingEvent +from homeassistant.helpers.dispatcher import async_dispatcher_send +from OWNd.message import OWNHeatingEvent, OWNMessage from OWNd.profiles import GenericGatewayProfile, get_gateway_profile from custom_components.myhome.climate import MyHOMEClimate, _zone_address -from custom_components.myhome.const import CONF_FIRMWARE +from custom_components.myhome.const import ( + CONF_ENTITY, + CONF_FIRMWARE, + CONF_PLATFORMS, + CONF_WHERE, + DOMAIN, +) from custom_components.myhome.discovery import Address from custom_components.myhome.gateway import MyHOMEGatewayHandler +from tests.conftest import attach_platform, attach_runtime MAC = "00:03:50:00:00:44" @@ -875,3 +885,200 @@ async def test_a_season_decoded_as_a_mode_by_an_older_ownd_asks_the_fan_speed_to assert _dimension_requests(zone) == [("2", 19), ("2", 11)] zone.handle_event(legacy) assert _dimension_requests(zone) == [("2", 19), ("2", 11)] + + +# ------------------------------------------------------------------ live trace replays through MyHOMEClimate (review 5642758778, 2) + +TRACES = Path(__file__).resolve().parent / "fixtures" / "traces" +F461_TRACE = TRACES / "f461_live_2026-10-23" / "myhome_trace_F461_live_2026-10-23.json" +F461_RUN6 = "run6_event_session_ha_fixed_integration" +MHS1_TRACE = TRACES / "myhomeserver1_live_2026-10-24" / "myhome_trace_MyHomeServer1_live_2026-10-24.json" +MHS1_RUN2 = "run2_reads_thermo_sound_alarm_lights" + + +@pytest.fixture(scope="module") +def f461_rows() -> list[dict]: + """The chronological rows of the 2026-10-23 F461 2.0.11 live capture (#799).""" + assert F461_TRACE.is_file(), F461_TRACE + return json.loads(F461_TRACE.read_text(encoding="utf-8"))["rows"] + + +@pytest.fixture(scope="module") +def mhs1_rows() -> list[dict]: + """The chronological rows of the 2026-10-24 MyHomeServer1 3.87.13 live capture (#799).""" + assert MHS1_TRACE.is_file(), MHS1_TRACE + return json.loads(MHS1_TRACE.read_text(encoding="utf-8"))["rows"] + + +def _evt(rows: list[dict], run: str) -> list[str]: + return [r["frame"] for r in rows if r["run"] == run and r["dir"] == "rx" and r["session"] == "evt"] + + +def _evt_between(rows: list[dict], run: str, frame: str) -> list[str]: + """Event-session frames from the request ``frame`` of ``run`` until the next request of that run.""" + run_rows = [r for r in rows if r["run"] == run] + start = next(i for i, r in enumerate(run_rows) if r["dir"] == "tx" and r["frame"] == frame) + out: list[str] = [] + for r in run_rows[start + 1 :]: + if r["dir"] == "tx": + break + if r["dir"] == "rx" and r["session"] == "evt": + out.append(r["frame"]) + return out + + +def _trace_gateway(model: str, mac: str) -> MagicMock: + gw = MagicMock() + gw.mac = mac + gw.unique_id = mac + gw.bus_group = mac + gw.primary_gateway_mac = None + gw.delegated_whos = set() + gw.delegated_away_whos = set() + gw.log_id = f"[{model} live]" + gw.model = model + gw.has_central_unit = False + gw.supports_zone_status = True + gw.initial_discovery_pending = False + gw.sweep_active = False + gw.is_connected = True + gw.profile = get_gateway_profile(model) + gw._gateway = SimpleNamespace(profile=gw.profile) + gw.send = AsyncMock() + gw.send_status_request = AsyncMock() + gw.watch_zone_actuators = MagicMock(return_value=MagicMock()) + gw.request_zone_dimension = MagicMock() + return gw + + +async def _trace_plant(hass, gw: MagicMock, zones: list[str]) -> tuple[dict[str, MyHOMEClimate], list]: + """The climate platform of ``gw`` with ``zones`` configured, bus discovery on; the added entities too.""" + from custom_components.myhome.climate import async_setup_entry as climate_setup + + 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 zones + }, + } + hass.data = {DOMAIN: {gw.mac: {CONF_ENTITY: gw, CONF_PLATFORMS: platforms_cfg}}} + entry = MagicMock() + entry.data = {"mac": gw.mac} + entry.entry_id = f"trace_{gw.model}" + entry.options = {} + attach_runtime(hass, entry, gw.mac, gw) + added: list = [] + 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, added.extend) + entities = {} + for ent in added: + 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) + await ent.async_added_to_hass() + entities[ent._where] = ent + assert sorted(entities) == sorted(zones) + return entities, added + + +def _replay(hass, mac: str, frames: list[str]) -> list[OWNMessage]: + """Deliver the frames the way the gateway handler does (``myhome_message_``).""" + delivered = [] + for raw in frames: + msg = OWNMessage.parse(raw) + assert msg is not None, raw + async_dispatcher_send(hass, f"myhome_message_{mac}", msg) + delivered.append(msg) + return delivered + + +async def test_replay_f461_run6_through_the_climate_entity(hass, f461_rows): + """1770 s of event session while the fixed integration ran: idle after each poll's actuator pair. + + Every ``*#4*2##`` reply (dimensions 0, 12, season, 14, 13) is followed about 2.2 s later + by ``*#4*2#1*20*0##`` + ``*#4*2#2*20*5##``, the answer to the integration's + ``*#4*#0*20##``. Before the first pair ``hvac_action`` is unknown; from it on the zone is + ``idle`` with ``actuators`` ``{"1": "off", "2": "off"}``, through six more polls. + """ + gw = _trace_gateway("F461", "00:03:50:00:10:23") + entities, added = await _trace_plant(hass, gw, ["2"]) + zone2 = entities["2"] + assert zone2.hvac_action is None and "actuators" not in zone2.extra_state_attributes + gw.watch_zone_actuators.assert_called_once_with("2") + frames = _evt(f461_rows, F461_RUN6) + assert len(frames) == 171 + seen: list[tuple[str, HVACAction | None, dict | None]] = [] + for raw in frames: + _replay(hass, gw.mac, [raw]) + if raw.startswith(("*#4*2*", "*#4*2#", "*4*1*2##", "*4*0*2##")): # zone 2 only: run6 has no other zone + seen.append((raw, zone2.hvac_action, dict(zone2.extra_state_attributes.get("actuators") or {}) or None)) + await hass.async_block_till_done() + assert len(added) == 1 # nothing was discovered besides the configured zone + assert seen[:7] == [ + ("*#4*2*0*0241##", None, None), + ("*#4*2*12*0235*3##", None, None), + ("*4*1*2##", None, None), + ("*#4*2*14*0235*3##", None, None), + ("*#4*2*13*00##", None, None), + ("*#4*2#1*20*0##", HVACAction.IDLE, {"1": "off"}), + ("*#4*2#2*20*5##", HVACAction.IDLE, {"1": "off", "2": "off"}), + ] + polls = [f for f, _, _ in seen if f == "*#4*2*13*00##"] + assert len(polls) == 7 # the first start and six reconnect sweeps + fan_frames = [f for f, _, _ in seen if f == "*#4*2#2*20*5##"] + assert len(fan_frames) == 8 # one per poll, repeated once (891.963 s and 892.007 s), reversed once (2043 s) + assert all(action == HVACAction.IDLE and acts == {"1": "off", "2": "off"} for _, action, acts in seen[7:]) + # the hashed write echo parses as a command, not an event: the platform drops it, the state stays + assert seen.count(("*#4*2#1*#20*0##", HVACAction.IDLE, {"1": "off", "2": "off"})) == 1 + assert zone2.hvac_action == HVACAction.IDLE + assert zone2.extra_state_attributes["actuators"] == {"1": "off", "2": "off"} + assert zone2.current_temperature == 24.1 + assert zone2.target_temperature == 23.5 + assert zone2.hvac_mode == HVACMode.HEAT + assert zone2.extra_state_attributes["season"] == "heating" + assert zone2.current_humidity == 38 + # the fan coil revealed by *#4*2#2*20*5## asked its side once; heating season: no *#4*2*11## + assert zone2.fan_mode == "auto" + assert [c.args for c in gw.request_zone_dimension.call_args_list] == [("2", 19)] + assert [str(c.args[0]) for c in gw.send_status_request.await_args_list] == ["*#4*2##"] + + +async def test_replay_myhomeserver1_all_zone_actuator_burst_through_the_climate_entities(hass, mhs1_rows): + """``*#4*#0*20##`` on a 34-zone plant: 43 actuator frames in 0.39 s, the zone-0 pumps among them. + + Every configured zone ends with its ``actuators`` filled from the ``Z#1`` / ``Z#2`` frames + and ``hvac_action: idle``; the five fan coils ask their side once each; the ``0#1``..``0#4`` + pump frames discover no zone, are watched by no entity and asked for nothing. + """ + burst = _evt_between(mhs1_rows, MHS1_RUN2, "*#4*#0*20##") + assert len(burst) == 43 + zones = sorted({f.split("*")[2].split("#")[0] for f in burst if not f.startswith("*#4*0#")}, key=int) + assert len(zones) == 34 and zones[0] == "35" and zones[-1] == "70" + gw = _trace_gateway("MyHomeServer1", "00:03:50:00:20:24") + entities, added = await _trace_plant(hass, gw, zones) + assert sorted(c.args[0] for c in gw.watch_zone_actuators.call_args_list) == zones + assert all(e.hvac_action is None for e in entities.values()) + _replay(hass, gw.mac, burst) + await hass.async_block_till_done() + assert len(added) == 34 # the pump frames 0#1..0#4 discovered no phantom zone + assert "0" not in entities and "#0" not in entities + fan_coils = {"47", "48", "57", "60", "70"} + for z, ent in entities.items(): + expected = {"1": "off", "2": "off"} if z in fan_coils else {"1": "off"} + assert ent.extra_state_attributes["actuators"] == expected, z + assert ent.hvac_action == HVACAction.IDLE, z + assert (ent.fan_mode == "auto") is (z in fan_coils), z + # the fan coils ask dimension 19 once each, in the order the plant answered + assert [c.args for c in gw.request_zone_dimension.call_args_list] == [ + ("48", 19), ("47", 19), ("60", 19), ("70", 19), ("57", 19), + ] + assert gw.watch_zone_actuators.call_count == 34 + assert [str(c.args[0]) for c in gw.send_status_request.await_args_list] == [f"*#4*{z}##" for z in zones]