From 4cbefbc8384b9cb739b6c39c932b18bd3d07cec7 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 15:34:28 +0300 Subject: [PATCH 01/11] fix(climate): do not poll humidity (dimension 60) where the emulated firmware did not forward it 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 | 24 ++++- docs/configuration/climate.md | 2 +- tests/test_climate_humidity_poll_gate.py | 131 +++++++++++++++++++++++ 5 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/test_climate_humidity_poll_gate.py diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index d353fa028..f97b9620d 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)**: 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). +- **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); zone humidity is not polled on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202, where the emulated firmware did not forward `*#4**60##` (own-firmware-oracle PR #60, unpublished; an OWNd profile flag `supports_heating_humidity_request` overrides the table); 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 e9bed63dc..eaca11856 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -591,6 +591,16 @@ async def async_update(self) -> None: cast(OWNCommand, OWNHeatingCommand.parse(f"*#4*{self._full_where}*11##")) ) if self._humidity: + if getattr(self._gateway_handler, "humidity_request_refused", False) is True: + # The emulated firmware of F450, F453, F453AV, F454, H4684, MH200, MH200N and + # MH202 did not forward the request in a real-daemon run (own-firmware-oracle + # PR #60, unpublished); the zone's own pushes keep the humidity fresh there. + LOGGER.debug( + "%s Zone %s: this gateway refuses humidity requests; waiting for its pushes", + self._gateway_handler.log_id, + self._full_where, + ) + return await self._gateway_handler.send_status_request( cast(OWNCommand, OWNHeatingCommand.parse(f"*#4*{self._full_where}*60##")) ) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 373298237..e953bdc7b 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -172,8 +172,17 @@ def command_session_default(model: str | None) -> int: "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) +# Gateways whose emulated firmware did not forward the zone humidity request (``*#4*Z*60##``: +# nack and nothing on the bus) in the own-firmware-oracle thermo-zone-requests run of the open +# PR OpenWebNet-HA/own-firmware-oracle#60 (unpublished verdict, not a published row); F459, +# F460, F461 and MyHomeServer1 forwarded it, and the live F461 / MyHomeServer1 answer it. +# Read from the OWNd profile first (``supports_heating_humidity_request``: ``True`` / ``False`` +# wins, ``None`` = unverified); this table is the fallback until the flag exists there. +HUMIDITY_REQUEST_REFUSED = frozenset({"F450", "F453", "F453AV", "F454", "H4684", "MH200", "MH200N", "MH202"}) +# The models the tables know, longest first so ``MH200N`` is not read as ``MH200``. +_ZONE_REQUEST_MODELS = sorted( + ACTUATOR_REQUEST_REFUSED | HUMIDITY_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS), key=len, reverse=True +) # A WHO 4 zone address: ``Z``, ``#Z``, ``Z#N`` (actuator), each optionally behind an @@ -517,6 +526,17 @@ async def _send_zone_requests(self) -> None: 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 humidity_request_refused(self) -> bool: + """Whether this gateway drops humidity requests (``*#4*Z*60##``) instead of forwarding them. + + The OWNd profile flag wins when it exists; otherwise the oracle-derived table. + """ + supported = getattr(getattr(self.gateway, "profile", None), "supports_heating_humidity_request", None) + if isinstance(supported, bool): + return not supported + return self._model_key in HUMIDITY_REQUEST_REFUSED + @property def profile(self) -> Any: """Return gateway profile.""" diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index f92b0d081..1e8e6c22f 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -65,7 +65,7 @@ Climate zones equipped with integrated humidity sensors (such as BTicino Living * **Dynamic Auto-Detection**: When a climate zone emits a humidity reading on the SCS bus, the integration dynamically enables humidity tracking on that entity. * **State Restoration Across Restarts**: The zone remembers that it supports humidity across Home Assistant restarts and restores its last known humidity value immediately. -* **Targeted Startup Query**: After startup (once the initial discovery sweep finishes), only climate zones that support humidity are queried with `*#4**60##` to refresh the current reading. This avoids unnecessary bus traffic on zones without humidity sensors, and works across both software gateways (MyHomeServer1) and physical SCS gateways (such as F461, F454, F455, MH200N). +* **Targeted Startup Query**: After startup (once the initial discovery sweep finishes), only climate zones that support humidity are queried with `*#4**60##` to refresh the current reading. This avoids unnecessary bus traffic on zones without humidity sensors. A live F461 and a live MyHomeServer1 answer the request (F459 and F460 forward it in the firmware emulation); on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202 the emulated firmware did not forward it (own-firmware-oracle PR #60, not yet published), so until the OWNd gateway profile states `supports_heating_humidity_request` their zones are not queried and show the humidity their probe pushes. * **Optional YAML Configuration**: In advanced setups using legacy `/config/myhome.yaml`, humidity polling can be explicitly pre-enabled on a zone by setting `humidity: true` under the zone configuration. --- diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py new file mode 100644 index 000000000..81f34e128 --- /dev/null +++ b/tests/test_climate_humidity_poll_gate.py @@ -0,0 +1,131 @@ +"""Zone humidity (dimension 60) is not polled on gateways whose firmware drops the request. + +own-firmware-oracle thermo-zone-requests suite (open PR OpenWebNet-HA/own-firmware-oracle#60, +an unpublished real-daemon verdict): ``*#4*2*60##`` and ``*#4*0*60##`` were NACKed with nothing +on the bus by the emulated F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202, and +forwarded by F459, F460, F461 and MyHomeServer1. The OWNd profile flag wins when it is a bool. +A live F461 2.0.11 answered ``*#4*2*60##`` with ``*#4*2*60*39##``. On the refusing gateways the +request only costs a command-session round trip; the zone's own pushes keep the humidity fresh. +""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT +from 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:02:57" + + +def _entry(model: str) -> MagicMock: + entry = MagicMock() + entry.entry_id = f"entry-{model}" + entry.data = { + CONF_HOST: "192.0.2.57", 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) + handler.send_status_request = AsyncMock() + return handler + + +def _humidity_zone(hass, gateway) -> MyHOMEClimate: + zone = MyHOMEClimate( + hass=hass, name="Zone 2", device_id="4-2", who="4", where="2", + heating=True, cooling=True, fan=False, humidity=True, standalone=True, central=False, + manufacturer="BTicino", model="Heating Zone", gateway=gateway, + ) + zone.hass = hass + zone.entity_id = "climate.zone_2" + zone.async_write_ha_state = MagicMock() + return zone + + +def _sent(gateway) -> list[str]: + return [str(call.args[0]) for call in gateway.send_status_request.await_args_list] + + +@pytest.mark.parametrize( + ("model", "refused"), + [ + ("F450", True), ("F453", True), ("F453AV", True), ("F454", True), ("H4684", True), + ("MH200", True), ("MH200N", True), ("MH202", True), + ("F459", False), ("F460", False), ("F461", False), ("MyHomeServer1", False), + ("F455", False), ("MH201", False), ("Generic", False), # not in the oracle: still asked + ], +) +def test_humidity_request_refused_follows_the_oracle(hass, model, refused): + assert _handler(hass, model).humidity_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_humidity_request=flag + ) + assert handler.humidity_request_refused is (not flag) + + +async def test_f461_zone_still_asks_for_its_humidity(hass): + handler = _handler(hass, "F461") + await _humidity_zone(hass, handler).async_update() + assert _sent(handler) == ["*#4*2##", "*#4*2*60##"] + + +@pytest.mark.parametrize("model", ["F454", "MH202"]) +async def test_refusing_gateway_zone_does_not_ask(hass, model): + handler = _handler(hass, model) + await _humidity_zone(hass, handler).async_update() + assert _sent(handler) == [] + + +async def test_gateway_without_the_property_is_still_asked(hass): + gateway = MagicMock() + gateway.mac = MAC + gateway.send_status_request = AsyncMock() + gateway.has_central_unit = False + gateway.supports_zone_status = True + del gateway.humidity_request_refused + await _humidity_zone(hass, gateway).async_update() + assert _sent(gateway) == ["*#4*2##", "*#4*2*60##"] + + +@pytest.mark.parametrize( + ("model", "refused"), + [("MH202", True), ("F454", True), ("F461", False), ("F455", False), ("MH201", False)], +) +def test_profile_flag_none_means_unverified_and_falls_back_to_the_table(hass, model, refused): + """``None`` = unverified: a listed model is skipped by the table, an unlisted one is polled.""" + handler = _handler(hass, model) + handler.gateway.profile = SimpleNamespace(model_name=model, supports_heating_humidity_request=None) + assert handler.humidity_request_refused is refused + + +@pytest.mark.parametrize(("model", "refused"), [("MH202", True), ("F455", False)]) +def test_legacy_profile_without_the_attribute_falls_back_to_the_table(hass, model, refused): + """An OWNd release whose profiles have no such attribute at all (the pinned one) uses the table.""" + handler = _handler(hass, model) + handler.gateway.profile = SimpleNamespace(model_name=model) + assert not hasattr(handler.gateway.profile, "supports_heating_humidity_request") + assert handler.humidity_request_refused is refused + + +async def test_unverified_flag_on_an_unlisted_model_still_polls(hass): + handler = _handler(hass, "F455") + handler.gateway.profile = SimpleNamespace(model_name="F455", supports_heating_humidity_request=None) + await _humidity_zone(hass, handler).async_update() + assert "*#4*2*60##" in _sent(handler) From 12e26345d83104822394fe097862fac8b77be628 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 22:15:13 +0300 Subject: [PATCH 02/11] fix(gateway): _model_key via isinstance(GenericGatewayProfile) and substring lookup (align with #792) _model_key compared the profile's model_name with "Generic" instead of its class: get_gateway_profile("Legrand MH200N") is a GenericGatewayProfile named "Legrand MH200N", so the key became "LEGRANDMH200N" and missed HUMIDITY_REQUEST_REFUSED, and the zone was polled on a gateway whose emulated firmware does not forward *#4*Z*60##. The key is now taken from a known profile's name, otherwise from the configured model, in which a known model is looked up as a substring (longest first, so MH200N is not read as MH200), like command_session_limit and supports_zone_status do. Same helper and the same change as #792 (review 5639409551, finding 5), so the two branches no longer differ in what _model_key returns. Fail-before / pass-after on tests/test_climate_humidity_poll_gate.py: 3 of the 7 new cases fail on the previous helper (Legrand MH200N, BTicino F453AV, the vendor-prefixed MH200N zone poll), 36 of 36 pass with the change. Co-Authored-By: Claude Fable 5.1 --- tests/test_climate_humidity_poll_gate.py | 42 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index 81f34e128..48dda0a48 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -14,11 +14,11 @@ import pytest from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT -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 -from custom_components.myhome.gateway import MyHOMEGatewayHandler +from custom_components.myhome.gateway import HUMIDITY_REQUEST_REFUSED, MyHOMEGatewayHandler MAC = "00:03:50:00:02:57" @@ -129,3 +129,41 @@ async def test_unverified_flag_on_an_unlisted_model_still_polls(hass): handler.gateway.profile = SimpleNamespace(model_name="F455", supports_heating_humidity_request=None) await _humidity_zone(hass, handler).async_update() assert "*#4*2*60##" in _sent(handler) + + +# ------------------------------------------------------------------ gateway: profile class and model lookup (#792 review, 5) + +def test_a_generic_profile_with_a_vendor_prefixed_name_still_finds_its_table(hass): + """``get_gateway_profile("BTicino F461")`` is a GenericGatewayProfile named "BTicino F461".""" + handler = _handler(hass, "BTicino F461") + assert isinstance(handler.gateway.profile, GenericGatewayProfile) + # HUMIDITY_REQUEST_REFUSED names no forwarding model, so the configured name is kept (#792's + # all-zone table adds F461 to the lookup); either way the key is not a refusing model. + assert "F461" in handler._model_key + assert handler._model_key not in HUMIDITY_REQUEST_REFUSED + assert handler.humidity_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.humidity_request_refused is refused + + +def test_a_generic_profile_reads_the_configured_model_not_its_own_name(hass): + """``get_gateway_profile("Generic")`` names nothing: the configured model decides, and an unknown one is polled.""" + handler = _handler(hass, "Generic") + assert isinstance(handler.gateway.profile, GenericGatewayProfile) + assert handler._model_key == "GENERIC" + assert handler.humidity_request_refused is False + + +async def test_vendor_prefixed_refusing_gateway_zone_does_not_ask(hass): + handler = _handler(hass, "Legrand MH200N") + await _humidity_zone(hass, handler).async_update() + assert _sent(handler) == [] From 61603fd97685b547a25bac60b8751ebdc30b61a5 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 12:44:10 +0200 Subject: [PATCH 03/11] docs(climate): cite the merged thermo-zone-requests finding for the humidity poll gate own-firmware-oracle PR #60 merged (main 52d354b, 2026-10-25): the table's source is now findings/thermo-zone-requests.md on main, a firmware_observed verdict on emulated firmware, no longer an unpublished run of an open PR. Comments, docs, release note and the test docstring say so; no behaviour change. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- custom_components/myhome/climate.py | 4 ++-- custom_components/myhome/gateway.py | 4 ++-- docs/configuration/climate.md | 2 +- tests/test_climate_humidity_poll_gate.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index f97b9620d..c730aab7e 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)**: 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); zone humidity is not polled on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202, where the emulated firmware did not forward `*#4**60##` (own-firmware-oracle PR #60, unpublished; an OWNd profile flag `supports_heating_humidity_request` overrides the table); 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); zone humidity is not polled on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202, where the emulated firmware did not forward `*#4**60##` (own-firmware-oracle `findings/thermo-zone-requests.md`, emulated firmware; an OWNd profile flag `supports_heating_humidity_request` overrides the table); 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 eaca11856..6eb0a0b56 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -593,8 +593,8 @@ async def async_update(self) -> None: if self._humidity: if getattr(self._gateway_handler, "humidity_request_refused", False) is True: # The emulated firmware of F450, F453, F453AV, F454, H4684, MH200, MH200N and - # MH202 did not forward the request in a real-daemon run (own-firmware-oracle - # PR #60, unpublished); the zone's own pushes keep the humidity fresh there. + # MH202 did not forward the request (own-firmware-oracle thermo-zone-requests + # finding, emulated firmware); the zone's own pushes keep the humidity fresh there. LOGGER.debug( "%s Zone %s: this gateway refuses humidity requests; waiting for its pushes", self._gateway_handler.log_id, diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index e953bdc7b..e9d2717f4 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -173,8 +173,8 @@ def command_session_default(model: str | None) -> int: "MYHOMESERVER1": {20: "*#4*#0*20##"}, } # Gateways whose emulated firmware did not forward the zone humidity request (``*#4*Z*60##``: -# nack and nothing on the bus) in the own-firmware-oracle thermo-zone-requests run of the open -# PR OpenWebNet-HA/own-firmware-oracle#60 (unpublished verdict, not a published row); F459, +# nack and nothing on the bus) in the own-firmware-oracle thermo-zone-requests suite +# (findings/thermo-zone-requests.md on main, ``firmware_observed``, emulated firmware); F459, # F460, F461 and MyHomeServer1 forwarded it, and the live F461 / MyHomeServer1 answer it. # Read from the OWNd profile first (``supports_heating_humidity_request``: ``True`` / ``False`` # wins, ``None`` = unverified); this table is the fallback until the flag exists there. diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index 1e8e6c22f..c61641373 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -65,7 +65,7 @@ Climate zones equipped with integrated humidity sensors (such as BTicino Living * **Dynamic Auto-Detection**: When a climate zone emits a humidity reading on the SCS bus, the integration dynamically enables humidity tracking on that entity. * **State Restoration Across Restarts**: The zone remembers that it supports humidity across Home Assistant restarts and restores its last known humidity value immediately. -* **Targeted Startup Query**: After startup (once the initial discovery sweep finishes), only climate zones that support humidity are queried with `*#4**60##` to refresh the current reading. This avoids unnecessary bus traffic on zones without humidity sensors. A live F461 and a live MyHomeServer1 answer the request (F459 and F460 forward it in the firmware emulation); on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202 the emulated firmware did not forward it (own-firmware-oracle PR #60, not yet published), so until the OWNd gateway profile states `supports_heating_humidity_request` their zones are not queried and show the humidity their probe pushes. +* **Targeted Startup Query**: After startup (once the initial discovery sweep finishes), only climate zones that support humidity are queried with `*#4**60##` to refresh the current reading. This avoids unnecessary bus traffic on zones without humidity sensors. A live F461 and a live MyHomeServer1 answer the request (F459 and F460 forward it in the firmware emulation); on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202 the emulated firmware did not forward it (own-firmware-oracle `findings/thermo-zone-requests.md`, a `firmware_observed` verdict), so until the OWNd gateway profile states `supports_heating_humidity_request` their zones are not queried and show the humidity their probe pushes. * **Optional YAML Configuration**: In advanced setups using legacy `/config/myhome.yaml`, humidity polling can be explicitly pre-enabled on a zone by setting `humidity: true` under the zone configuration. --- diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index 48dda0a48..8b0adfb99 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -1,7 +1,7 @@ """Zone humidity (dimension 60) is not polled on gateways whose firmware drops the request. -own-firmware-oracle thermo-zone-requests suite (open PR OpenWebNet-HA/own-firmware-oracle#60, -an unpublished real-daemon verdict): ``*#4*2*60##`` and ``*#4*0*60##`` were NACKed with nothing +own-firmware-oracle thermo-zone-requests suite (``findings/thermo-zone-requests.md`` on main, +a ``firmware_observed`` verdict on emulated firmware): ``*#4*2*60##`` and ``*#4*0*60##`` were NACKed with nothing on the bus by the emulated F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202, and forwarded by F459, F460, F461 and MyHomeServer1. The OWNd profile flag wins when it is a bool. A live F461 2.0.11 answered ``*#4*2*60##`` with ``*#4*2*60*39##``. On the refusing gateways the From c1fa94595074d21e94adfb010bf639c98c915bf4 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 19:05:19 +0200 Subject: [PATCH 04/11] fix(gateway): humidity_request_refused follows WHO 4 delegation and warm-standby failover `send_status_request` routes a WHO 4 request through the secondary gateway WHO 4 is delegated to, or through the warm standby while the primary is disconnected, but `humidity_request_refused` judged the primary's own model: a primary MH202 / F454 delegating WHO 4 to an F461 / MyHomeServer1 skipped `*#4*Z*60##` although the request would have gone through the forwarding gateway, and a primary F461 failed over to a standby MH202 / F454 sent it into the refusing standby (review 5644219403, finding 1). The property now asks the same `_who4_target()` helper #792 adds (copied verbatim: `_delegated_target(probe) or _failover_target(probe) or self` on a fixed WHO 4 probe) and returns that gateway's verdict when it is not this one. Thirteen test cases: delegation to a forwarding / refusing secondary, failover to a refusing / forwarding standby (both directions, F461 / MyHomeServer1 against MH202 / F454), the secondary preferred to the standby, the standalone case unchanged, and two cases through the real topology lookup (a configured secondary with WHO 4 delegated and a configured standby, connected or not). 13 fail before, 49 pass after. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 8 +- tests/test_climate_humidity_poll_gate.py | 109 ++++++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index e9d2717f4..09782a9c4 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -530,8 +530,14 @@ async def _send_zone_requests(self) -> None: def humidity_request_refused(self) -> bool: """Whether this gateway drops humidity requests (``*#4*Z*60##``) instead of forwarding them. - The OWNd profile flag wins when it exists; otherwise the oracle-derived table. + The request goes where ``send_status_request`` sends it: a secondary gateway WHO 4 is + delegated to, or the warm standby while this primary is down, decides with its own + profile and table. The OWNd profile flag wins when it exists; otherwise the + oracle-derived table. """ + target = self._who4_target() + if target is not self: + return target.humidity_request_refused supported = getattr(getattr(self.gateway, "profile", None), "supports_heating_humidity_request", None) if isinstance(supported, bool): return not supported diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index 8b0adfb99..e92163c29 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -17,7 +17,13 @@ from OWNd.profiles import GenericGatewayProfile, get_gateway_profile from custom_components.myhome.climate import MyHOMEClimate -from custom_components.myhome.const import CONF_FIRMWARE +from custom_components.myhome.const import ( + CONF_FIRMWARE, + ROLE_PRIMARY, + ROLE_SECONDARY, + ROLE_STANDBY, + TOPOLOGY_SHARED, +) from custom_components.myhome.gateway import HUMIDITY_REQUEST_REFUSED, MyHOMEGatewayHandler MAC = "00:03:50:00:02:57" @@ -167,3 +173,104 @@ async def test_vendor_prefixed_refusing_gateway_zone_does_not_ask(hass): handler = _handler(hass, "Legrand MH200N") await _humidity_zone(hass, handler).async_update() assert _sent(handler) == [] + + +# ------------------------------------------------------------------ gateway: WHO 4 delegation and warm standby (review 5644219403, 1) + +@pytest.mark.parametrize(("primary_model", "secondary_model"), [("MH202", "F461"), ("F454", "MyHomeServer1")]) +async def test_who4_delegated_to_a_forwarding_secondary_is_asked(hass, primary_model, secondary_model): + """The request goes through the secondary WHO 4 is delegated to: its table decides, not the primary's.""" + primary = _handler(hass, primary_model) + secondary = _handler(hass, secondary_model) + primary._delegated_target = MagicMock(return_value=secondary) + assert primary._who4_target() is secondary + assert primary.humidity_request_refused is False + await _humidity_zone(hass, primary).async_update() + assert "*#4*2*60##" in _sent(primary) # send_status_request itself routes it to the secondary + + +@pytest.mark.parametrize(("primary_model", "secondary_model"), [("F461", "MH202"), ("MyHomeServer1", "F454")]) +async def test_who4_delegated_to_a_refusing_secondary_is_not_asked(hass, primary_model, secondary_model): + primary = _handler(hass, primary_model) + secondary = _handler(hass, secondary_model) + primary._delegated_target = MagicMock(return_value=secondary) + assert primary.humidity_request_refused is True + await _humidity_zone(hass, primary).async_update() + assert "*#4*2*60##" not in _sent(primary) + + +@pytest.mark.parametrize(("primary_model", "standby_model"), [("F461", "MH202"), ("MyHomeServer1", "F454")]) +async def test_failover_to_a_refusing_standby_does_not_ask(hass, primary_model, standby_model): + """A primary F461 failed over to a standby MH202: *#4*Z*60## would be NACKed by the MH202.""" + primary = _handler(hass, primary_model) + standby = _handler(hass, standby_model) + primary._failover_target = MagicMock(return_value=standby) + assert primary._who4_target() is standby + assert primary.humidity_request_refused is True + await _humidity_zone(hass, primary).async_update() + assert "*#4*2*60##" not in _sent(primary) + + +@pytest.mark.parametrize(("primary_model", "standby_model"), [("MH202", "F461"), ("F454", "MyHomeServer1")]) +async def test_failover_to_a_forwarding_standby_still_asks(hass, primary_model, standby_model): + primary = _handler(hass, primary_model) + standby = _handler(hass, standby_model) + primary._failover_target = MagicMock(return_value=standby) + assert primary.humidity_request_refused is False + await _humidity_zone(hass, primary).async_update() + assert "*#4*2*60##" in _sent(primary) + + +def test_a_delegated_secondary_is_preferred_to_the_standby(hass): + """``send_status_request`` asks the delegated secondary first, the standby only without one.""" + primary = _handler(hass, "F461") + secondary = _handler(hass, "MH202") + standby = _handler(hass, "MyHomeServer1") + primary._delegated_target = MagicMock(return_value=secondary) + primary._failover_target = MagicMock(return_value=standby) + assert primary._who4_target() is secondary + assert primary.humidity_request_refused is True + + +@pytest.mark.parametrize(("model", "refused"), [("MH202", True), ("F461", False)]) +def test_without_delegation_or_failover_the_gateway_decides_itself(hass, model, refused): + """A standalone gateway (no secondary, no standby) is its own WHO 4 target: nothing changes.""" + handler = _handler(hass, model) + assert handler._who4_target() is handler + assert handler.humidity_request_refused is refused + + +async def test_a_configured_secondary_decides_for_the_primary_it_serves(hass): + """The real topology lookup: a secondary entry with WHO 4 delegated, connected, is the target.""" + from tests.test_multi_gateway import _create_mock_gateway + + _, primary = _create_mock_gateway(hass, "00:03:50:00:02:57", name="MH202", topology=TOPOLOGY_SHARED, role=ROLE_PRIMARY) + _, secondary = _create_mock_gateway( + hass, "00:03:50:00:02:58", name="F461", topology=TOPOLOGY_SHARED, role=ROLE_SECONDARY, + primary_gateway="00:03:50:00:02:57", delegated_whos=[4], + ) + primary.gateway.profile = get_gateway_profile("MH202") + secondary.gateway.profile = get_gateway_profile("F461") + assert primary.humidity_request_refused is True # the secondary is not connected yet + secondary.is_connected = True + assert primary._who4_target() is secondary + assert primary.humidity_request_refused is False + + +async def test_a_configured_standby_decides_while_the_primary_is_down(hass): + from tests.test_multi_gateway import _create_mock_gateway + + _, primary = _create_mock_gateway(hass, "00:03:50:00:02:57", name="F461", topology=TOPOLOGY_SHARED, role=ROLE_PRIMARY) + _, standby = _create_mock_gateway( + hass, "00:03:50:00:02:59", name="MH202", topology=TOPOLOGY_SHARED, role=ROLE_STANDBY, + primary_gateway="00:03:50:00:02:57", + ) + primary.gateway.profile = get_gateway_profile("F461") + standby.gateway.profile = get_gateway_profile("MH202") + standby.is_connected = True + assert primary.is_connected is False + assert primary._who4_target() is standby + assert primary.humidity_request_refused is True + primary.is_connected = True # back: its own table again + assert primary._who4_target() is primary + assert primary.humidity_request_refused is False From ad631e7b0fb74cb632c2c5fabbf6041b82504867 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 19:06:20 +0200 Subject: [PATCH 05/11] fix(gateway): key _model_key on every known gateway model, forwarding models included `_ZONE_REQUEST_MODELS` was built from `HUMIDITY_REQUEST_REFUSED` alone, so a forwarding model behind a brand prefix was not reduced to its model: `_model_key` of "BTicino F461" was "BTICINOF461", against the property's own docstring and against #792, whose lookup (`ACTUATOR_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS)`) knows F461 and MyHomeServer1 but not F450 and F454 (review 5644219403, finding 2). `KNOWN_GATEWAY_MODELS` now lists every model the tables can be keyed on: the twelve images the oracle emulates (refusing and forwarding) and the other models OWNd profiles (F452, F452V, F455, H4890, MH201), and `_ZONE_REQUEST_MODELS` is that set united with the refusing table, longest first as before. The test at the `BTicino F461` case asserts `_model_key == "F461"`, and every known model is checked behind a brand prefix (9 of the 17 fail on the previous lookup, the forwarding ones). 67 pass after. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 14 +++++++++++++- tests/test_climate_humidity_poll_gate.py | 24 ++++++++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 09782a9c4..f28c489a7 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -179,9 +179,21 @@ def command_session_default(model: str | None) -> int: # Read from the OWNd profile first (``supports_heating_humidity_request``: ``True`` / ``False`` # wins, ``None`` = unverified); this table is the fallback until the flag exists there. HUMIDITY_REQUEST_REFUSED = frozenset({"F450", "F453", "F453AV", "F454", "H4684", "MH200", "MH200N", "MH202"}) +# Every gateway model the request tables can be keyed on: the twelve images the oracle emulates +# (results/

/, refusing and forwarding alike) and the other models OWNd profiles, so a +# forwarding model named with a brand prefix ("BTicino F461") is keyed as itself (``F461``) +# and not left as the whole string. The same key on every table of #792 / #793 / #794. +KNOWN_GATEWAY_MODELS = frozenset( + { + "F450", "F452", "F452V", "F453", "F453AV", "F454", "F455", "F459", "F460", "F461", + "H4684", "H4890", "MH200", "MH200N", "MH201", "MH202", "MYHOMESERVER1", + } +) # The models the tables know, longest first so ``MH200N`` is not read as ``MH200``. _ZONE_REQUEST_MODELS = sorted( - ACTUATOR_REQUEST_REFUSED | HUMIDITY_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS), key=len, reverse=True + KNOWN_GATEWAY_MODELS | ACTUATOR_REQUEST_REFUSED | HUMIDITY_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS), + key=len, + reverse=True, ) diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index e92163c29..0cb17fefa 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -24,7 +24,11 @@ ROLE_STANDBY, TOPOLOGY_SHARED, ) -from custom_components.myhome.gateway import HUMIDITY_REQUEST_REFUSED, MyHOMEGatewayHandler +from custom_components.myhome.gateway import ( + HUMIDITY_REQUEST_REFUSED, + KNOWN_GATEWAY_MODELS, + MyHOMEGatewayHandler, +) MAC = "00:03:50:00:02:57" @@ -143,13 +147,25 @@ def test_a_generic_profile_with_a_vendor_prefixed_name_still_finds_its_table(has """``get_gateway_profile("BTicino F461")`` is a GenericGatewayProfile named "BTicino F461".""" handler = _handler(hass, "BTicino F461") assert isinstance(handler.gateway.profile, GenericGatewayProfile) - # HUMIDITY_REQUEST_REFUSED names no forwarding model, so the configured name is kept (#792's - # all-zone table adds F461 to the lookup); either way the key is not a refusing model. - assert "F461" in handler._model_key + assert handler._model_key == "F461" # the same key as #792's tables give it assert handler._model_key not in HUMIDITY_REQUEST_REFUSED assert handler.humidity_request_refused is False +@pytest.mark.parametrize("model", sorted(KNOWN_GATEWAY_MODELS)) +def test_every_known_model_is_keyed_as_itself_behind_a_brand_prefix(hass, model): + """Forwarding models included: the key is the model, whatever table it is looked up in.""" + handler = _handler(hass, f"BTicino {model}") + handler.gateway.profile = GenericGatewayProfile(model_name=f"BTicino {model}") + assert handler._model_key == model + assert handler.humidity_request_refused is (model in HUMIDITY_REQUEST_REFUSED) + + +def test_the_oracle_s_twelve_images_are_known_models(): + assert HUMIDITY_REQUEST_REFUSED <= KNOWN_GATEWAY_MODELS + assert {"F459", "F460", "F461", "MYHOMESERVER1"} <= KNOWN_GATEWAY_MODELS + + @pytest.mark.parametrize( ("model", "key", "refused"), [("Legrand MH200N", "MH200N", True), ("MH200", "MH200", True), ("BTicino F453AV", "F453AV", True), ("Unknown", "UNKNOWN", False)], From 054102291332cc9196f208d1e8bf89ed9ff1497d Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 19:08:01 +0200 Subject: [PATCH 06/11] fix(gateway): read SSDP HL4684 and brand-prefixed aliases as their model before the table lookup `manifest.json` matches the H4684 over SSDP as `modelName: HL4684`, a spelling OWNd has no alias for (it knows l4684, n4684, nt4684, lgrh4684), and an SSDP identity is never overwritten by WHO 13 / 1013. "H4684" is not a substring of "HL4684" or of "BTICINOL4684", so an SSDP-discovered or brand-prefixed H4684 missed the humidity table and was polled (review 5644219403, finding 3). `_model_key` now resolves a brand-prefixed or catalogue string word by word through OWNd's own aliases (`"BTicino L4684"` -> H4684, `"Legrand 003598"` -> F454, `"Legrand 003565"` -> MH200N, kept distinct from MH200), maps the SSDP spelling `HL4684` (and `HC4684`) to `H4684` the way #794 does, and only then falls back to the longest-first substring lookup. #794's two `_model_key` tests are copied as they are (`test_aliased_model_strings_follow_their_profile`, `test_ssdp_model_string_hl4684_is_keyed_as_h4684`); eleven brand-prefixed and catalogue strings and two humidity zones (`HL4684`, `BTicino L4684`) are added. 12 fail before, 85 pass after. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 36 +++++++++++++++-- tests/test_climate_humidity_poll_gate.py | 51 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index f28c489a7..d0e9f7f19 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -195,6 +195,28 @@ def command_session_default(model: str | None) -> int: key=len, reverse=True, ) +# SSDP announces the H4684 as "HL4684" (manifest.json), which OWNd does not alias to its H4684 +# profile yet (it knows h4684, l4684, n4684, nt4684, lgrh4684; #794 maps the same spelling), and +# "HC4684" is the other prefixed spelling of the same product: keyed on the oracle's product name. +_MODEL_SPELLINGS = {"HL4684": "H4684", "HC4684": "H4684"} + + +def _alnum(name: object) -> str: + """A model string reduced to its upper-case letters and digits (``"BTicino F461"`` -> ``BTICINOF461``).""" + return "".join(character for character in str(name).upper() if character.isalnum()) + + +def _resolved_model(word: str) -> str | None: + """The model OWNd resolves one word of a model string to, or ``None`` for a word it does not know. + + ``L4684`` -> ``H4684``, ``003598`` -> ``F454``, ``03565`` -> ``MH200N`` (OWNd's aliases and + catalogue item numbers); the spellings OWNd has no alias for are mapped first. + """ + key = _MODEL_SPELLINGS.get(_alnum(word), word) + profile = get_gateway_profile(key) + if isinstance(profile, GenericGatewayProfile): + return None + return _alnum(profile.model_name) # A WHO 4 zone address: ``Z``, ``#Z``, ``Z#N`` (actuator), each optionally behind an @@ -426,15 +448,23 @@ def _model_key(self) -> str: """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). + ``get_gateway_profile("BTicino F461")`` returns) falls back to the configured name. + There a brand-prefixed alias or catalogue number is resolved word by word through + OWNd (``"BTicino L4684"`` -> ``H4684``, ``"Legrand 003598"`` -> ``F454``), the SSDP + spelling ``HL4684`` is read as ``H4684``, and otherwise a known model is looked up as + a substring, longest first (``supports_zone_status`` style), so ``MH200N`` stays + distinct from ``MH200``. """ 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()) + for word in str(name).split(): + resolved = _resolved_model(word) + if resolved is not None: + return resolved + normalized = _MODEL_SPELLINGS.get(_alnum(name), _alnum(name)) return next((model for model in _ZONE_REQUEST_MODELS if model in normalized), normalized) def _who4_target(self) -> MyHOMEGatewayHandler: diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index 0cb17fefa..e2e634958 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -166,6 +166,57 @@ def test_the_oracle_s_twelve_images_are_known_models(): assert {"F459", "F460", "F461", "MYHOMESERVER1"} <= KNOWN_GATEWAY_MODELS +# ------------------------------------------------------------------ gateway: SSDP and aliased model strings (#794, review 5644219403, 3) + +@pytest.mark.parametrize( + ("model", "profile_model"), + [("L4684", "H4684"), ("NT4684", "H4684"), ("003598", "F454"), ("03535", "MH202")], +) +def test_aliased_model_strings_follow_their_profile(hass, model, profile_model): + """An OWNd alias (L4684, NT4684) or MyHOME_Suite item number (003598, 03535) is keyed on its model.""" + handler = _handler(hass, model) + assert handler.gateway.profile.model_name == profile_model + assert handler._model_key == profile_model + + +def test_ssdp_model_string_hl4684_is_keyed_as_h4684(hass): + """SSDP announces the H4684 as ``HL4684`` (manifest.json); OWNd has no alias for it yet.""" + handler = _handler(hass, "HL4684") + assert handler.gateway.profile.model_name == "HL4684" # the Generic profile keeps the SSDP string + assert handler._model_key == "H4684" + + +@pytest.mark.parametrize( + ("model", "key", "refused"), + [ + ("HL4684", "H4684", True), # the SSDP modelName, never overwritten by WHO 13 / 1013 + ("HC4684", "H4684", True), + ("BTicino L4684", "H4684", True), # OWNd aliases the word, not the brand-prefixed string + ("BTicino HL4684", "H4684", True), + ("Legrand 003598", "F454", True), # the MyHOME_Suite item number of the F454 + ("Legrand 003565", "MH200N", True), # ... of the MH200N: not collapsed to MH200 + ("Legrand 003535", "MH202", True), + ("BTicino MH200", "MH200", True), + ("BTicino MH200N", "MH200N", True), + ("Legrand 003594", "F455", False), # the F455 is not in the oracle: still asked + ("BTicino L4684 gateway", "H4684", True), + ], +) +def test_brand_prefixed_aliases_are_resolved_before_the_table_lookup(hass, model, key, refused): + handler = _handler(hass, model) + assert isinstance(handler.gateway.profile, GenericGatewayProfile), model + assert handler._model_key == key + assert handler.humidity_request_refused is refused + + +@pytest.mark.parametrize("model", ["HL4684", "BTicino L4684"]) +async def test_an_ssdp_or_brand_prefixed_h4684_zone_does_not_ask_for_humidity(hass, model): + """The zone status poll (``*#4*2##``) is ``supports_zone_status``'s business (#794); dimension 60 is gated here.""" + handler = _handler(hass, model) + await _humidity_zone(hass, handler).async_update() + assert "*#4*2*60##" not in _sent(handler) + + @pytest.mark.parametrize( ("model", "key", "refused"), [("Legrand MH200N", "MH200N", True), ("MH200", "MH200", True), ("BTicino F453AV", "F453AV", True), ("Unknown", "UNKNOWN", False)], From fe7d91f188d68a1fa34c546328f00f5e24156691 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 19:09:13 +0200 Subject: [PATCH 07/11] fix(climate): leave async_update before _poll_health when the humidity poll is refused The early return of `async_update` tested `not self._humidity`, so a humidity zone on an MH202 / F454 (no zone status request, no fan) still ran `_poll_health.should_skip`, and could log and raise the unresponsive-zone repair, for a poll that was never going to be sent (review 5644219403, finding 4). `wants_humidity = self._humidity and humidity_request_refused is not True` is computed once and used both in the early return and in the send branch; the DEBUG line for a refused request stays. `_poll_on_add` is untouched (`_enable_humidity` still sets it, so the first update runs after the identity settled). Tests: the humidity zone of a refusing SCS gateway never reaches `_poll_health` (MH202, F454, Legrand MH200N), an F450 (refuses dimension 60, answers `*#4*Z##`) still sends and counts its zone poll, an F461 zone goes through `_poll_health` and is held back when it skips, and `_poll_on_add` after `_enable_humidity`. 3 fail before, 92 pass after. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 24 +++++----- tests/test_climate_humidity_poll_gate.py | 58 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 6eb0a0b56..7ddab83bd 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -558,11 +558,15 @@ async def async_update(self) -> None: # bus captures confirm querying #0#1 times out on plants without a physical 4-zone unit (#629). # They receive setpoints via commands (*#4*#0#1*#14*T*M##), broadcast events, or restored state. return + # Humidity is asked only where the gateway forwards the request: the emulated firmware + # of F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202 did not (own-firmware-oracle + # thermo-zone-requests finding, emulated firmware); the zone's own pushes keep it fresh there. + wants_humidity = self._humidity and getattr(self._gateway_handler, "humidity_request_refused", False) is not True if ( not self._central and not self._gateway_supports_zone_status and not self._fan - and not self._humidity + and not wants_humidity ): # Physical SCS gateways (F455, F454, MH200N, MH202, etc.) do not support # dimension-less status requests (*#4*Z##) for regular zones (1-99). The request @@ -590,20 +594,16 @@ async def async_update(self) -> None: await self._gateway_handler.send_status_request( cast(OWNCommand, OWNHeatingCommand.parse(f"*#4*{self._full_where}*11##")) ) - if self._humidity: - if getattr(self._gateway_handler, "humidity_request_refused", False) is True: - # The emulated firmware of F450, F453, F453AV, F454, H4684, MH200, MH200N and - # MH202 did not forward the request (own-firmware-oracle thermo-zone-requests - # finding, emulated firmware); the zone's own pushes keep the humidity fresh there. - LOGGER.debug( - "%s Zone %s: this gateway refuses humidity requests; waiting for its pushes", - self._gateway_handler.log_id, - self._full_where, - ) - return + if wants_humidity: await self._gateway_handler.send_status_request( cast(OWNCommand, OWNHeatingCommand.parse(f"*#4*{self._full_where}*60##")) ) + elif self._humidity: + LOGGER.debug( + "%s Zone %s: this gateway refuses humidity requests; waiting for its pushes", + self._gateway_handler.log_id, + self._full_where, + ) @callback def _poll_answered(self, written: asyncio.Future[float], frames_before: int) -> None: diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index e2e634958..7a922ae3d 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -341,3 +341,61 @@ async def test_a_configured_standby_decides_while_the_primary_is_down(hass): primary.is_connected = True # back: its own table again assert primary._who4_target() is primary assert primary.humidity_request_refused is False + + +# ------------------------------------------------------------------ climate: nothing to send, nothing to count (review 5644219403, 4) + +@pytest.mark.parametrize("model", ["MH202", "F454", "Legrand MH200N"]) +async def test_a_refusing_scs_gateway_s_humidity_zone_never_reaches_poll_health(hass, model): + """No zone status (``*#4*Z##``), no fan, humidity refused: ``async_update`` returns before ``_poll_health``.""" + handler = _handler(hass, model) + zone = _humidity_zone(hass, handler) + zone._poll_health.should_skip = MagicMock(side_effect=AssertionError("should_skip must not run")) + zone._raise_unresponsive_issue = MagicMock() + await zone.async_update() + assert _sent(handler) == [] + zone._raise_unresponsive_issue.assert_not_called() + + +async def test_a_refusing_gateway_with_zone_status_still_counts_its_zone_poll(hass): + """An F450 refuses dimension 60 but answers ``*#4*Z##``: that poll is still sent and counted.""" + handler = _handler(hass, "F450") + assert handler.supports_zone_status is True + zone = _humidity_zone(hass, handler) + zone._poll_health.should_skip = MagicMock(return_value=False) + await zone.async_update() + zone._poll_health.should_skip.assert_called_once() + assert _sent(handler) == ["*#4*2##"] + + +async def test_a_forwarding_gateway_s_humidity_zone_goes_through_poll_health(hass): + handler = _handler(hass, "F461") + zone = _humidity_zone(hass, handler) + zone._poll_health.should_skip = MagicMock(return_value=False) + await zone.async_update() + zone._poll_health.should_skip.assert_called_once() + assert _sent(handler) == ["*#4*2##", "*#4*2*60##"] + + +async def test_a_skipping_zone_on_a_refusing_gateway_is_still_held_back(hass): + """``should_skip`` still rules where a request would be sent (an F461 zone that stopped answering).""" + handler = _handler(hass, "F461") + zone = _humidity_zone(hass, handler) + zone._poll_health.should_skip = MagicMock(return_value=True) + zone._raise_unresponsive_issue = MagicMock() + await zone.async_update() + assert _sent(handler) == [] + zone._raise_unresponsive_issue.assert_called_once() + + +def test_humidity_enabled_later_still_polls_on_add_on_a_refusing_gateway(hass): + """``_poll_on_add`` is untouched: the first update runs so the identity can settle before the gate decides.""" + handler = _handler(hass, "MH202") + zone = MyHOMEClimate( + hass=hass, name="Zone 2", device_id="4-2", who="4", where="2", + heating=True, cooling=True, fan=False, humidity=False, standalone=True, central=False, + manufacturer="BTicino", model="Heating Zone", gateway=handler, + ) + assert zone._poll_on_add is False + zone._enable_humidity() + assert zone._poll_on_add is True From fee4bf6436c87c3ee5880c2f73b0cafd3c21e722 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 19:10:11 +0200 Subject: [PATCH 08/11] test(climate): replay the captured humidity frames of the F461 and MyHomeServer1 traces The PR body said the captured frames were inlined in the test module, while the module only asserted the outgoing request strings (review 5644219403, finding 4.2). The module now loads the two committed traces the way the other trace modules do and replays them through `MyHOMEClimate`: `f461_live_2026-10-23` run 2 (`*#4*2*60##` ACKed with `*#4*2*60*39##` in 0.14 s, echoed on the event session) and run 6 (three spontaneous `*#4*2*60*38##` pushes, fed to a zone on a refusing MH202 to show that the pushes enable and refresh humidity without a poll), and `myhomeserver1_live_2026-10-24` run 3 (`*#4*39*60##` ACKed with `*#4*39*60*51##`). Five cases, 97 pass. Co-Authored-By: Claude Fable 5.1 --- tests/test_climate_humidity_poll_gate.py | 108 +++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index 7a922ae3d..931931198 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -6,14 +6,22 @@ forwarded by F459, F460, F461 and MyHomeServer1. The OWNd profile flag wins when it is a bool. A live F461 2.0.11 answered ``*#4*2*60##`` with ``*#4*2*60*39##``. On the refusing gateways the request only costs a command-session round trip; the zone's own pushes keep the humidity fresh. + +The "captured frames" section replays rows of two committed traces (the loaders the other +trace tests use): ``f461_live_2026-10-23`` (``*#4*2*60##`` answered ``*#4*2*60*39##`` on the +command session, echoed on the event session, and three spontaneous ``*#4*2*60*38##`` pushes) +and ``myhomeserver1_live_2026-10-24`` (``*#4*39*60##`` answered ``*#4*39*60*51##``). """ from __future__ import annotations +import json +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT +from OWNd.message import OWNHeatingEvent from OWNd.profiles import GenericGatewayProfile, get_gateway_profile from custom_components.myhome.climate import MyHOMEClimate @@ -31,6 +39,12 @@ ) MAC = "00:03:50:00:02:57" +TRACES = Path(__file__).resolve().parent / "fixtures" / "traces" +F461_LIVE = TRACES / "f461_live_2026-10-23" / "myhome_trace_F461_live_2026-10-23.json" +MHS1_LIVE = TRACES / "myhomeserver1_live_2026-10-24" / "myhome_trace_MyHomeServer1_live_2026-10-24.json" +F461_RUN2 = "run2_reviewer_reads" +F461_RUN6 = "run6_event_session_ha_fixed_integration" +MHS1_RUN3 = "run3_reads_lights_addresses_humidity" def _entry(model: str) -> MagicMock: @@ -399,3 +413,97 @@ def test_humidity_enabled_later_still_polls_on_add_on_a_refusing_gateway(hass): assert zone._poll_on_add is False zone._enable_humidity() assert zone._poll_on_add is True + + +# ------------------------------------------------------------------ captured frames (review 5644219403, 4.2) + +@pytest.fixture(scope="module") +def f461_rows() -> list[dict]: + assert F461_LIVE.is_file(), F461_LIVE + return json.loads(F461_LIVE.read_text(encoding="utf-8"))["rows"] + + +@pytest.fixture(scope="module") +def mhs1_rows() -> list[dict]: + assert MHS1_LIVE.is_file(), MHS1_LIVE + return json.loads(MHS1_LIVE.read_text(encoding="utf-8"))["rows"] + + +def _result(rows: list[dict], run: str, frame: str) -> dict: + found = [r for r in rows if r["dir"] == "result" and r["run"] == run and r["frame"] == frame] + assert len(found) == 1, (run, frame, len(found)) + return found[0] + + +def _evt(rows: list[dict], run: str, frame: str) -> list[dict]: + return [r for r in rows if r["run"] == run and r["dir"] == "rx" and r["session"] == "evt" and r["frame"] == frame] + + +def test_f461_capture_answers_the_humidity_request(f461_rows): + """F461 2.0.11, zone 2: ``*#4*2*60##`` is ACKed with ``*#4*2*60*39##`` in 0.14 s, echoed on the event session.""" + result = _result(f461_rows, F461_RUN2, "*#4*2*60##") + assert result["ack"] is True + assert result["replies"] == ["*#4*2*60*39##"] + sent = next(r for r in f461_rows if r["run"] == F461_RUN2 and r["dir"] == "tx" and r["frame"] == "*#4*2*60##") + assert 0 < result["t"] - sent["t"] < 0.5 + assert len(_evt(f461_rows, F461_RUN2, "*#4*2*60*39##")) == 2 # one echo per request of the run + assert len(_evt(f461_rows, F461_RUN6, "*#4*2*60*38##")) == 3 # spontaneous pushes, 30 minutes of HA + + +async def test_captured_f461_reply_and_pushes_update_the_zone(hass, f461_rows): + """The captured reply and the three spontaneous pushes replayed through the entity.""" + handler = _handler(hass, "F461") + zone = _humidity_zone(hass, handler) + await zone.async_update() + assert _sent(handler) == ["*#4*2##", "*#4*2*60##"] + for frame in _result(f461_rows, F461_RUN2, "*#4*2*60##")["replies"]: + zone.handle_event(OWNHeatingEvent(frame)) + assert zone.current_humidity == 39.0 + for row in _evt(f461_rows, F461_RUN6, "*#4*2*60*38##"): + zone.handle_event(OWNHeatingEvent(row["frame"])) + assert zone.current_humidity == 38.0 + + +async def test_captured_pushes_keep_a_refusing_gateway_s_zone_fresh_without_a_poll(hass, f461_rows): + """What the gate relies on: the probe's own ``*#4*Z*60*HH##`` pushes, here the F461's, enable and refresh humidity.""" + handler = _handler(hass, "MH202") + zone = MyHOMEClimate( + hass=hass, name="Zone 2", device_id="4-2", who="4", where="2", + heating=True, cooling=True, fan=False, humidity=False, standalone=True, central=False, + manufacturer="BTicino", model="Heating Zone", gateway=handler, + ) + zone.hass = hass + zone.entity_id = "climate.zone_2" + zone.async_write_ha_state = MagicMock() + assert zone.current_humidity is None + for row in _evt(f461_rows, F461_RUN6, "*#4*2*60*38##"): + zone.handle_event(OWNHeatingEvent(row["frame"])) + assert zone._humidity is True + assert zone.current_humidity == 38.0 + await zone.async_update() + assert _sent(handler) == [] # humidity enabled by the push, still not polled on the MH202 + + +def test_myhomeserver1_capture_answers_the_humidity_request(mhs1_rows): + """MyHomeServer1 3.87.13, zone 39: ``*#4*39*60##`` is ACKed with ``*#4*39*60*51##`` in 0.2 s.""" + result = _result(mhs1_rows, MHS1_RUN3, "*#4*39*60##") + assert result["ack"] is True + assert result["replies"] == ["*#4*39*60*51##"] + assert len(_evt(mhs1_rows, MHS1_RUN3, "*#4*39*60*51##")) == 1 + + +async def test_captured_myhomeserver1_reply_updates_zone_39(hass, mhs1_rows): + handler = _handler(hass, "MyHomeServer1") + zone = MyHOMEClimate( + hass=hass, name="Zone 39", device_id="4-39", who="4", where="39", + heating=True, cooling=True, fan=False, humidity=True, standalone=True, central=False, + manufacturer="BTicino", model="Heating Zone", gateway=handler, + ) + zone.hass = hass + zone.entity_id = "climate.zone_39" + zone.async_write_ha_state = MagicMock() + await zone.async_update() + assert _sent(handler) == ["*#4*39##", "*#4*39*60##"] + for frame in _result(mhs1_rows, MHS1_RUN3, "*#4*39*60##")["replies"]: + zone.handle_event(OWNHeatingEvent(frame)) + assert zone.current_humidity == 51.0 From 28dfe0465e0311ff7412165a0ae5962b884324bf Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 20:23:33 +0200 Subject: [PATCH 09/11] fix(gateway): read the words of a model string as alphanumeric runs in _model_key (#793 review, 2) _model_key resolved the configured model word by word on whitespace, so a hyphenated or parenthesised string ("BTicino-L4684") was one word OWNd does not know, and the substring fallback left it unresolved (L4684 is an alias, not a table key). The words are now re.findall(r"[A-Za-z0-9]+", name), so "BTicino-L4684" -> H4684 and "F454 (003598)" / "Legrand (003598)" -> F454 through _resolved_model, as the review asked. Fail-before / pass-after on tests/test_climate_humidity_poll_gate.py: of the five new strings "BTicino-L4684" fails on the whitespace split (the others already resolved through the alnum-normalised word or the substring lookup); 102 pass with the change. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway.py | 5 +++-- tests/test_climate_humidity_poll_gate.py | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index d0e9f7f19..7ee672e46 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -453,14 +453,15 @@ def _model_key(self) -> str: OWNd (``"BTicino L4684"`` -> ``H4684``, ``"Legrand 003598"`` -> ``F454``), the SSDP spelling ``HL4684`` is read as ``H4684``, and otherwise a known model is looked up as a substring, longest first (``supports_zone_status`` style), so ``MH200N`` stays - distinct from ``MH200``. + distinct from ``MH200``. The words are the alphanumeric runs of the string, so + ``"BTicino-L4684"`` and ``"F454 (003598)"`` resolve too. """ 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 - for word in str(name).split(): + for word in re.findall(r"[A-Za-z0-9]+", str(name)): resolved = _resolved_model(word) if resolved is not None: return resolved diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index 931931198..1ff9f1ec9 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -214,6 +214,11 @@ def test_ssdp_model_string_hl4684_is_keyed_as_h4684(hass): ("BTicino MH200N", "MH200N", True), ("Legrand 003594", "F455", False), # the F455 is not in the oracle: still asked ("BTicino L4684 gateway", "H4684", True), + ("BTicino-L4684", "H4684", True), # hyphenated: the words are the alphanumeric runs + ("F454 (003598)", "F454", True), # parenthesised catalogue number + ("Legrand (003598)", "F454", True), + ("BTicino-F461", "F461", False), + ("MyHomeServer1 (3.87.13)", "MYHOMESERVER1", False), ], ) def test_brand_prefixed_aliases_are_resolved_before_the_table_lookup(hass, model, key, refused): From 5c9c9e1db559f5f8b14530829e4d86b15aea8f84 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 20:24:17 +0200 Subject: [PATCH 10/11] docs(climate): do not claim the refusing gateways push humidity upward (#793 review, 3) The comment in async_update, docs/configuration/climate.md and the test module docstring said the zones of F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202 "show the humidity their probe pushes" / "the zone's own pushes keep it fresh". No physical trace shows those gateways translating a probe's humidity into *#4*Z*60*HH## on the event session (only the F461, the MyHomeServer1 and the F455 of #674 have dimension-60 traces). They now say only that any *#4**60*## event the gateway delivers still updates current_humidity without a poll; the DEBUG line of a refused request says the same. No behaviour change. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 7 +++++-- docs/configuration/climate.md | 2 +- tests/test_climate_humidity_poll_gate.py | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 7ddab83bd..a454a1454 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -560,7 +560,9 @@ async def async_update(self) -> None: return # Humidity is asked only where the gateway forwards the request: the emulated firmware # of F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202 did not (own-firmware-oracle - # thermo-zone-requests finding, emulated firmware); the zone's own pushes keep it fresh there. + # thermo-zone-requests finding, emulated firmware). Whether those gateways put the probe's + # humidity on the event session is not traced; any *#4**60*## delivered there + # still updates current_humidity (handle_event) without a poll. wants_humidity = self._humidity and getattr(self._gateway_handler, "humidity_request_refused", False) is not True if ( not self._central @@ -600,9 +602,10 @@ async def async_update(self) -> None: ) elif self._humidity: LOGGER.debug( - "%s Zone %s: this gateway refuses humidity requests; waiting for its pushes", + "%s Zone %s: this gateway refuses humidity requests; not asked (a *#4*%s*60*HH## event still updates it)", self._gateway_handler.log_id, self._full_where, + self._full_where, ) @callback diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index c61641373..37eab75be 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -65,7 +65,7 @@ Climate zones equipped with integrated humidity sensors (such as BTicino Living * **Dynamic Auto-Detection**: When a climate zone emits a humidity reading on the SCS bus, the integration dynamically enables humidity tracking on that entity. * **State Restoration Across Restarts**: The zone remembers that it supports humidity across Home Assistant restarts and restores its last known humidity value immediately. -* **Targeted Startup Query**: After startup (once the initial discovery sweep finishes), only climate zones that support humidity are queried with `*#4**60##` to refresh the current reading. This avoids unnecessary bus traffic on zones without humidity sensors. A live F461 and a live MyHomeServer1 answer the request (F459 and F460 forward it in the firmware emulation); on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202 the emulated firmware did not forward it (own-firmware-oracle `findings/thermo-zone-requests.md`, a `firmware_observed` verdict), so until the OWNd gateway profile states `supports_heating_humidity_request` their zones are not queried and show the humidity their probe pushes. +* **Targeted Startup Query**: After startup (once the initial discovery sweep finishes), only climate zones that support humidity are queried with `*#4**60##` to refresh the current reading. This avoids unnecessary bus traffic on zones without humidity sensors. A live F461 and a live MyHomeServer1 answer the request (F459 and F460 forward it in the firmware emulation); on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202 the emulated firmware did not forward it (own-firmware-oracle `findings/thermo-zone-requests.md`, a `firmware_observed` verdict), so until the OWNd gateway profile states `supports_heating_humidity_request` their zones are not queried. Whether those gateways put a probe's humidity on the event session has not been traced; any `*#4**60*##` event the gateway delivers still updates `current_humidity` without polling. * **Optional YAML Configuration**: In advanced setups using legacy `/config/myhome.yaml`, humidity polling can be explicitly pre-enabled on a zone by setting `humidity: true` under the zone configuration. --- diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index 1ff9f1ec9..b42f7ecaa 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -5,7 +5,9 @@ on the bus by the emulated F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202, and forwarded by F459, F460, F461 and MyHomeServer1. The OWNd profile flag wins when it is a bool. A live F461 2.0.11 answered ``*#4*2*60##`` with ``*#4*2*60*39##``. On the refusing gateways the -request only costs a command-session round trip; the zone's own pushes keep the humidity fresh. +request only costs a command-session round trip; whether they put the probe's humidity on the +event session is not traced, and any ``*#4**60*##`` delivered there still updates +``current_humidity`` without a poll. The "captured frames" section replays rows of two committed traces (the loaders the other trace tests use): ``f461_live_2026-10-23`` (``*#4*2*60##`` answered ``*#4*2*60*39##`` on the From b0bc3d12ccf3d459d957e95cd73fd256eb2d313d Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 20:26:20 +0200 Subject: [PATCH 11/11] test(climate): replay each humidity capture on its own gateway through the climate platform (#793 review, 4) The captured-frames section fed the F461's spontaneous *#4*2*60*38## pushes (f461_live_2026-10-23, run6) into an MH202 handler and pre-built MyHOMEClimate with humidity=True, bypassing discovery and _enable_humidity(). That test is gone from the trace section; the MH202 case is now a unit test in the "nothing to send" section with an inline frame, framed as integration-side delivery (no trace claim). The replays go through climate.async_setup_entry and async_dispatcher_send(hass, f"myhome_message_{mac}", ...) with the helpers of tests/test_climate_actuator_status.py (#792), each capture on the model it was recorded on, with a real handler's supports_zone_status and humidity_request_refused on the trace gateway: - F461 run6: the 171 event-session frames; the three *#4*2*60*38## pushes enable humidity on a zone configured without it (38.0), the next poll then asks *#4*2##, *#4*2*60##. Run2: *#4*2*60## ACKed with *#4*2*60*39## in 0.143 s (one echo on the event session), *#4*0*60## answered by zone 2 in 2.128 s; the echo sets 39.0. - MyHomeServer1 run2: *#4*35*60## NACKed in 2.128 s with nothing on the event session; *#4*0*60## ACKed in 0.499 s, 17 zones answer; on an 18-zone plant the 17 take their value, zone 35 stays without humidity and its poll asks *#4*35## only. Run3: *#4*39*60## ACKed with *#4*39*60*51## in 0.204 s; 51.0. - F455 (#674): the 67 frames on a 23-zone plant (the README's zones); the spontaneous *#4*42*60*52## enables humidity on zone 42 alone (52.0), the other 22 zones stay without, and the next poll asks *#4*42*60## and no *#4*42## (the F455 is not in the table and does not answer zone status). 103 pass (the five replays and the MH202 unit test replace the five previous trace cases). Co-Authored-By: Claude Fable 5.1 --- tests/test_climate_humidity_poll_gate.py | 259 ++++++++++++++++------- 1 file changed, 186 insertions(+), 73 deletions(-) diff --git a/tests/test_climate_humidity_poll_gate.py b/tests/test_climate_humidity_poll_gate.py index b42f7ecaa..28878886c 100644 --- a/tests/test_climate_humidity_poll_gate.py +++ b/tests/test_climate_humidity_poll_gate.py @@ -9,10 +9,13 @@ event session is not traced, and any ``*#4**60*##`` delivered there still updates ``current_humidity`` without a poll. -The "captured frames" section replays rows of two committed traces (the loaders the other -trace tests use): ``f461_live_2026-10-23`` (``*#4*2*60##`` answered ``*#4*2*60*39##`` on the -command session, echoed on the event session, and three spontaneous ``*#4*2*60*38##`` pushes) -and ``myhomeserver1_live_2026-10-24`` (``*#4*39*60##`` answered ``*#4*39*60*51##``). +The "live trace replays" section replays three committed captures, each on the gateway model +it was recorded on, through ``climate.async_setup_entry`` and ``myhome_message_`` the way +``tests/test_climate_actuator_status.py`` does: ``f461_live_2026-10-23`` (run6: three +spontaneous ``*#4*2*60*38##`` pushes; run2: ``*#4*2*60##`` answered ``*#4*2*60*39##``), +``myhomeserver1_live_2026-10-24`` (run2: ``*#4*35*60##`` NACKed, ``*#4*0*60##`` answered by 17 +zones; run3: ``*#4*39*60##`` answered ``*#4*39*60*51##``) and ``issue_674`` (an F455, not in the +table, pushing ``*#4*42*60*52##`` on its own). No capture of the eight refusing models exists. """ from __future__ import annotations @@ -39,13 +42,16 @@ KNOWN_GATEWAY_MODELS, MyHOMEGatewayHandler, ) +from tests.test_climate_actuator_status import _evt_between, _replay, _trace_gateway, _trace_plant MAC = "00:03:50:00:02:57" TRACES = Path(__file__).resolve().parent / "fixtures" / "traces" F461_LIVE = TRACES / "f461_live_2026-10-23" / "myhome_trace_F461_live_2026-10-23.json" MHS1_LIVE = TRACES / "myhomeserver1_live_2026-10-24" / "myhome_trace_MyHomeServer1_live_2026-10-24.json" +F455_LIVE = TRACES / "issue_674" / "myhome_trace_f455_climate_timeouts_2026-10-09.json" F461_RUN2 = "run2_reviewer_reads" F461_RUN6 = "run6_event_session_ha_fixed_integration" +MHS1_RUN2 = "run2_reads_thermo_sound_alarm_lights" MHS1_RUN3 = "run3_reads_lights_addresses_humidity" @@ -409,6 +415,28 @@ async def test_a_skipping_zone_on_a_refusing_gateway_is_still_held_back(hass): zone._raise_unresponsive_issue.assert_called_once() +async def test_a_humidity_event_delivered_on_a_refusing_gateway_enables_humidity_but_not_the_poll(hass): + """Integration side only: a ``*#4*Z*60*HH##`` event that reaches the entity (shared-bus delivery, + or a zone pre-enabled with ``humidity: true``) is taken as on any gateway, and the MH202 zone + still does not ask ``*#4*2*60##``. No trace claims an MH202 emits such a frame. + """ + handler = _handler(hass, "MH202") + zone = MyHOMEClimate( + hass=hass, name="Zone 2", device_id="4-2", who="4", where="2", + heating=True, cooling=True, fan=False, humidity=False, standalone=True, central=False, + manufacturer="BTicino", model="Heating Zone", gateway=handler, + ) + zone.hass = hass + zone.entity_id = "climate.zone_2" + zone.async_write_ha_state = MagicMock() + assert zone._humidity is False and zone.current_humidity is None + zone.handle_event(OWNHeatingEvent("*#4*2*60*45##")) + assert zone._humidity is True + assert zone.current_humidity == 45.0 + await zone.async_update() + assert _sent(handler) == [] # humidity known, dimension 60 still not asked on the MH202 + + def test_humidity_enabled_later_still_polls_on_add_on_a_refusing_gateway(hass): """``_poll_on_add`` is untouched: the first update runs so the identity can settle before the gate decides.""" handler = _handler(hass, "MH202") @@ -422,95 +450,180 @@ def test_humidity_enabled_later_still_polls_on_add_on_a_refusing_gateway(hass): assert zone._poll_on_add is True -# ------------------------------------------------------------------ captured frames (review 5644219403, 4.2) +# ------------------------------------------------------------------ live trace replays through the climate platform (review 5644470090, 2) @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_LIVE.is_file(), F461_LIVE return json.loads(F461_LIVE.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_LIVE.is_file(), MHS1_LIVE return json.loads(MHS1_LIVE.read_text(encoding="utf-8"))["rows"] +@pytest.fixture(scope="module") +def f455_frames() -> list[str]: + """The 67 event-session frames of the F455 capture of #674 (2026-10-09), in order.""" + assert F455_LIVE.is_file(), F455_LIVE + archive = json.loads(F455_LIVE.read_text(encoding="utf-8")) + assert archive["gateway"]["model"] == "F455" + frames = [row["raw"] for row in archive["frames"] if row["direction"] == "rx"] + assert len(frames) == archive["total_rx_frames"] == 67 + return frames + + def _result(rows: list[dict], run: str, frame: str) -> dict: found = [r for r in rows if r["dir"] == "result" and r["run"] == run and r["frame"] == frame] assert len(found) == 1, (run, frame, len(found)) return found[0] -def _evt(rows: list[dict], run: str, frame: str) -> list[dict]: - return [r for r in rows if r["run"] == run and r["dir"] == "rx" and r["session"] == "evt" and r["frame"] == frame] - - -def test_f461_capture_answers_the_humidity_request(f461_rows): - """F461 2.0.11, zone 2: ``*#4*2*60##`` is ACKed with ``*#4*2*60*39##`` in 0.14 s, echoed on the event session.""" +def _live_gateway(hass, model: str, mac: str) -> MagicMock: + """``_trace_gateway`` with the two verdicts a real handler of ``model`` gives the climate platform.""" + gw = _trace_gateway(model, mac) + handler = _handler(hass, model) + gw.supports_zone_status = handler.supports_zone_status + gw.humidity_request_refused = handler.humidity_request_refused + return gw + + +def _status_requests(gw: MagicMock) -> list[str]: + return [str(call.args[0]) for call in gw.send_status_request.await_args_list] + + +async def test_replay_f461_run6_pushes_enable_humidity_and_the_zone_is_then_asked(hass, f461_rows): + """F461 2.0.11, run6 (1770 s of event session while the fixed integration ran): the thermostat + pushed ``*#4*2*60*38##`` three times on its own. Zone 2 is configured without humidity; the + first push enables it at 38.0 %, and the next poll asks ``*#4*2*60##`` because the F461 + forwards the request (it is not in the table). + """ + gw = _live_gateway(hass, "F461", "00:03:50:00:10:23") + assert gw.humidity_request_refused is False and gw.supports_zone_status is True + entities, added = await _trace_plant(hass, gw, ["2"]) + zone2 = entities["2"] + assert zone2._humidity is False and zone2.current_humidity is None + assert _status_requests(gw) == ["*#4*2##"] # the poll on add: no dimension 60 yet + frames = [r["frame"] for r in f461_rows if r["run"] == F461_RUN6 and r["dir"] == "rx" and r["session"] == "evt"] + assert len(frames) == 171 + seen: list[tuple[bool, float | None]] = [] + for raw in frames: + _replay(hass, gw.mac, [raw]) + if raw == "*#4*2*60*38##": + seen.append((zone2._humidity, zone2.current_humidity)) + await hass.async_block_till_done() + assert len(added) == 1 # nothing was discovered besides the configured zone + assert seen == [(True, 38.0)] * 3 + await zone2.async_update() + assert _status_requests(gw) == ["*#4*2##", "*#4*2##", "*#4*2*60##"] + + +async def test_replay_f461_run2_request_is_answered_and_updates_the_zone(hass, f461_rows): + """F461 2.0.11, run2: ``*#4*2*60##`` ACKed with ``*#4*2*60*39##`` in 0.14 s, echoed once on the + event session; the general ``*#4*0*60##`` was answered by zone 2 alone, in 2.1 s. + """ result = _result(f461_rows, F461_RUN2, "*#4*2*60##") assert result["ack"] is True assert result["replies"] == ["*#4*2*60*39##"] - sent = next(r for r in f461_rows if r["run"] == F461_RUN2 and r["dir"] == "tx" and r["frame"] == "*#4*2*60##") - assert 0 < result["t"] - sent["t"] < 0.5 - assert len(_evt(f461_rows, F461_RUN2, "*#4*2*60*39##")) == 2 # one echo per request of the run - assert len(_evt(f461_rows, F461_RUN6, "*#4*2*60*38##")) == 3 # spontaneous pushes, 30 minutes of HA - - -async def test_captured_f461_reply_and_pushes_update_the_zone(hass, f461_rows): - """The captured reply and the three spontaneous pushes replayed through the entity.""" - handler = _handler(hass, "F461") - zone = _humidity_zone(hass, handler) - await zone.async_update() - assert _sent(handler) == ["*#4*2##", "*#4*2*60##"] - for frame in _result(f461_rows, F461_RUN2, "*#4*2*60##")["replies"]: - zone.handle_event(OWNHeatingEvent(frame)) - assert zone.current_humidity == 39.0 - for row in _evt(f461_rows, F461_RUN6, "*#4*2*60*38##"): - zone.handle_event(OWNHeatingEvent(row["frame"])) - assert zone.current_humidity == 38.0 - - -async def test_captured_pushes_keep_a_refusing_gateway_s_zone_fresh_without_a_poll(hass, f461_rows): - """What the gate relies on: the probe's own ``*#4*Z*60*HH##`` pushes, here the F461's, enable and refresh humidity.""" - handler = _handler(hass, "MH202") - zone = MyHOMEClimate( - hass=hass, name="Zone 2", device_id="4-2", who="4", where="2", - heating=True, cooling=True, fan=False, humidity=False, standalone=True, central=False, - manufacturer="BTicino", model="Heating Zone", gateway=handler, - ) - zone.hass = hass - zone.entity_id = "climate.zone_2" - zone.async_write_ha_state = MagicMock() - assert zone.current_humidity is None - for row in _evt(f461_rows, F461_RUN6, "*#4*2*60*38##"): - zone.handle_event(OWNHeatingEvent(row["frame"])) - assert zone._humidity is True - assert zone.current_humidity == 38.0 - await zone.async_update() - assert _sent(handler) == [] # humidity enabled by the push, still not polled on the MH202 - - -def test_myhomeserver1_capture_answers_the_humidity_request(mhs1_rows): - """MyHomeServer1 3.87.13, zone 39: ``*#4*39*60##`` is ACKed with ``*#4*39*60*51##`` in 0.2 s.""" + assert result["reply_time_s"] == 0.143 + general = _result(f461_rows, F461_RUN2, "*#4*0*60##") + assert general["ack"] is True and general["replies"] == ["*#4*2*60*39##"] + assert general["reply_time_s"] == 2.128 + echoes = _evt_between(f461_rows, F461_RUN2, "*#4*2*60##") + assert echoes == ["*#4*2*60*39##"] + gw = _live_gateway(hass, "F461", "00:03:50:00:10:23") + entities, added = await _trace_plant(hass, gw, ["2"]) + zone2 = entities["2"] + _replay(hass, gw.mac, echoes) + await hass.async_block_till_done() + assert len(added) == 1 + assert zone2._humidity is True + assert zone2.current_humidity == 39.0 + await zone2.async_update() + assert _status_requests(gw) == ["*#4*2##", "*#4*2##", "*#4*2*60##"] # the request the capture answered + + +async def test_replay_myhomeserver1_run2_general_humidity_and_the_unprobed_zone(hass, mhs1_rows): + """MyHomeServer1 3.87.13, run2: ``*#4*35*60##`` NACKed after 2.1 s with nothing on the event + session; ``*#4*0*60##`` ACKed in 0.5 s and answered by 17 zones. Replayed on an 18-zone plant + (the 17 and zone 35): the 17 take their value, zone 35 never gets humidity and is therefore + never asked ``*#4*35*60##`` by the integration (the capture's NACK was a manual read). + """ + nack = _result(mhs1_rows, MHS1_RUN2, "*#4*35*60##") + assert nack["ack"] is False and nack["replies"] == [] + assert nack["reply_time_s"] == 2.128 + assert _evt_between(mhs1_rows, MHS1_RUN2, "*#4*35*60##") == [] + general = _result(mhs1_rows, MHS1_RUN2, "*#4*0*60##") + assert general["ack"] is True and general["reply_time_s"] == 0.499 + burst = _evt_between(mhs1_rows, MHS1_RUN2, "*#4*0*60##") + assert len(burst) == 17 + assert burst[:8] == general["replies"] # the eight the command session handed over with the ACK + humidity = {f.split("*")[2]: float(f.split("*")[4].rstrip("#")) for f in burst} + assert len(humidity) == 17 and "35" not in humidity + assert humidity["39"] == 51.0 and humidity["70"] == 69.0 + zones = sorted([*humidity, "35"], key=int) + gw = _live_gateway(hass, "MyHomeServer1", "00:03:50:00:20:24") + assert gw.humidity_request_refused is False and gw.supports_zone_status is True + entities, added = await _trace_plant(hass, gw, zones) + assert all(e._humidity is False for e in entities.values()) + _replay(hass, gw.mac, burst) + await hass.async_block_till_done() + assert len(added) == 18 # no zone discovered beyond the configured ones + for z, value in humidity.items(): + assert entities[z]._humidity is True and entities[z].current_humidity == value, z + zone35 = entities["35"] + assert zone35._humidity is False and zone35.current_humidity is None + gw.send_status_request.reset_mock() + await zone35.async_update() + assert _status_requests(gw) == ["*#4*35##"] # no dimension 60 for a zone that never reported it + + +async def test_replay_myhomeserver1_run3_zone_39_request_is_answered(hass, mhs1_rows): + """MyHomeServer1 3.87.13, run3: ``*#4*39*60##`` ACKed with ``*#4*39*60*51##`` in 0.2 s.""" result = _result(mhs1_rows, MHS1_RUN3, "*#4*39*60##") - assert result["ack"] is True - assert result["replies"] == ["*#4*39*60*51##"] - assert len(_evt(mhs1_rows, MHS1_RUN3, "*#4*39*60*51##")) == 1 - - -async def test_captured_myhomeserver1_reply_updates_zone_39(hass, mhs1_rows): - handler = _handler(hass, "MyHomeServer1") - zone = MyHOMEClimate( - hass=hass, name="Zone 39", device_id="4-39", who="4", where="39", - heating=True, cooling=True, fan=False, humidity=True, standalone=True, central=False, - manufacturer="BTicino", model="Heating Zone", gateway=handler, - ) - zone.hass = hass - zone.entity_id = "climate.zone_39" - zone.async_write_ha_state = MagicMock() - await zone.async_update() - assert _sent(handler) == ["*#4*39##", "*#4*39*60##"] - for frame in _result(mhs1_rows, MHS1_RUN3, "*#4*39*60##")["replies"]: - zone.handle_event(OWNHeatingEvent(frame)) - assert zone.current_humidity == 51.0 + assert result["ack"] is True and result["replies"] == ["*#4*39*60*51##"] + assert result["reply_time_s"] == 0.204 + echoes = _evt_between(mhs1_rows, MHS1_RUN3, "*#4*39*60##") + assert echoes == ["*#4*39*60*51##"] + gw = _live_gateway(hass, "MyHomeServer1", "00:03:50:00:20:24") + entities, added = await _trace_plant(hass, gw, ["39"]) + zone39 = entities["39"] + assert _status_requests(gw) == ["*#4*39##"] + _replay(hass, gw.mac, echoes) + await hass.async_block_till_done() + assert len(added) == 1 + assert zone39._humidity is True and zone39.current_humidity == 51.0 + await zone39.async_update() + assert _status_requests(gw) == ["*#4*39##", "*#4*39##", "*#4*39*60##"] # the request the capture answered + + +async def test_replay_f455_spontaneous_push_on_an_unlisted_gateway(hass, f455_frames): + """F455 (#674, 2026-10-09): 67 event-session frames of a plant whose ``*#4*Z##`` polls time out; + zone 42 pushes ``*#4*42*60*52##`` on its own. The F455 is not in the table: the push enables + humidity on zone 42 alone, and the next poll asks ``*#4*42*60##`` (and no ``*#4*42##``, which + the F455 does not answer). + """ + assert f455_frames.count("*#4*42*60*52##") == 1 + assert not any("*60*" in f and not f.startswith("*#4*42*") for f in f455_frames) + zones = [ + "6", "7", "8", "9", "14", "15", "16", "17", "21", "26", "27", "28", "29", "34", "35", "36", "37", # heating + "42", "43", "45", "46", # peripheral zones of the README + "64", "65", # actuator zones + ] + gw = _live_gateway(hass, "F455", "00:03:50:00:06:74") + assert gw.humidity_request_refused is False and gw.supports_zone_status is False + entities, added = await _trace_plant(hass, gw, zones) + assert _status_requests(gw) == [] # no zone status, no fan, no humidity: nothing to poll on add + _replay(hass, gw.mac, f455_frames) + await hass.async_block_till_done() + assert len(added) == len(zones) # every frame belongs to a configured zone + zone42 = entities["42"] + assert zone42._humidity is True and zone42.current_humidity == 52.0 + assert all(e._humidity is False and e.current_humidity is None for z, e in entities.items() if z != "42") + assert entities["35"].current_temperature == 19.6 + await zone42.async_update() + assert _status_requests(gw) == ["*#4*42*60##"]