From 2b40da4b4b165d9bb23cd8abe31f082d852e462e Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:56:56 +0100 Subject: [PATCH 1/3] fix(climate): gate unrouted WHO 4 #4#II and actuator status polls --- .github/release-notes/2.0.0b16.md | 4 +- custom_components/myhome/climate.py | 69 ++++++---- custom_components/myhome/gateway.py | 45 ++++-- custom_components/myhome/where_grammar.py | 15 ++ docs/configuration/climate.md | 5 +- tests/test_climate_actuator_status.py | 144 +++++++++++++++++++- tests/test_startup_requests_per_firmware.py | 65 ++++----- 7 files changed, 272 insertions(+), 75 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 889c7c69..044566f6 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -21,7 +21,7 @@ See the cover docs for the details and the known limits. - **`myhome.send_message` and `myhome.sync_time` reject unknown keys.** A misspelt key such as `gw_mac` or a missing `message` raises a validation error instead of sending the frame to the default gateway; both services get text selectors in the visual editor (#189). - **Climate zones show the setpoint in force (dimension 12).** When the thermostat regulates to another setpoint than the commanded one (dimension 14; 23.5 in force while 21.0 commanded on an F461), the entity and `local_target_temperature` now show the one in force instead of the commanded value plus the knob offset. Known limit: once a zone has reported dimension 12, a dimension 14 that is not the echo of a Home Assistant write does not move the slider until the next dimension 12 report or poll (see Known Limitations). -- **Startup requests the gateway firmware drops are no longer sent.** `*#4*0##` is left out on F450, F454, F459, F460, F461, H4684, MH200, MH201, MH202 and MyHomeServer1, `*#16*0*5##` on F450, F459, F460, F461, MH202 and MyHomeServer1 (own-firmware-oracle verdicts; the MH201 from a physical sweep trace). On the SCS-only models among them (F450, F454, F459, H4684, MH200, MH201, MH202), each climate zone asks for its temperature with `*#4**0##` after the sweep instead (a zone behind an F422 interface is left to report by itself on the F450, F454, F459 and MH202, whose firmware drops that request too), and a restored `current_temperature` is flagged `current_temperature_restored` until the zone reports (#185, #257, #674). `myhome.sweep_bus` leaves the same two requests out unless called with `force: true`. +- **Startup requests the gateway firmware drops are no longer sent.** `*#4*0##` is left out on F450, F454, F459, F460, F461, H4684, MH200, MH201, MH202 and MyHomeServer1, `*#16*0*5##` on F450, F459, F460, F461, MH202 and MyHomeServer1 (own-firmware-oracle verdicts; the MH201 from a physical sweep trace). On the SCS-only models among them (F450, F454, F459, H4684, MH200, MH201, MH202), each local climate zone asks for its temperature with `*#4**0##` after the sweep instead (zones behind an `#4#II` interface are withheld across all gateways unless `supports_interface_zone_requests = True` on the OWNd profile, since zero of 12 oracle firmwares route WHO 4 across `#4#II`), and a restored `current_temperature` is flagged `current_temperature_restored` until the zone reports (#185, #257, #674). `myhome.sweep_bus` leaves the same two requests out unless called with `force: true`. - **`myhome.sync_time` refuses time zones with a half-hour offset** (e.g. `Asia/Kolkata`, UTC+5:30) with a translated error. The WHO 13 time-zone field carries whole hours only; b15 (on OWNd 2.0.0b10) set the gateway clock with a truncated offset without saying so (#764). - **Group, area and general bus events only for frames that carry a state.** A relative dim step (`*1*30*#1##` / `*1*31*#1##`, `*1*30#2#100*#1##`) no longer fires `myhome_group_light_event` / `myhome_area_light_event` / `myhome_general_light_event` as `on`, no longer switches an assumed-state light group or a point-to-point light on, and its member re-sync accepts both on and off echoes; a WHO 2 `DIMENSION 10` frame on a group, area or general address (`*#2*#5*10*10*50*001*0##`) no longer fires `myhome_group_automation_event` / `myhome_area_automation_event` / `myhome_general_automation_event` as `stop` (nor the gateway's *Centralized shutter stop* trigger). This is a defensive guard matching WHO 1, not a readback a shutter sends: a physical shutter reports `DIMENSION 10` on its own point-to-point address, and the Dimension 11 level echo a gateway really puts on the event session (`*#2*#5*#11#001#1*50##`) never fired an event. On/off and open/close/stop command frames, including the advanced `WHAT 10` / `11` / `12`, fire as before; the WHO 2 scope events are now documented in the cover docs (#367). - **`advanced: true` on a standard F411 shutter now logs a startup warning.** Those actuators have no position reporting, so the position stayed at 50 % and calibration was refused; the error now says to remove `advanced: true` (#761, #762). @@ -35,7 +35,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); `where: "0004"` in `myhome.yaml` is resolved to `04`, the spelling the gateways accept and report, with a one-time warning, and registry entries created under the 4-digit spelling are moved to it; the duplicate entity and device that bus discovery had created under `04` are removed, their area, labels, icon and custom name carried over to the configured ones where those have none, and a light that bus discovery had created at the address of a WHO 1 switch or impulse cover goes with its lock / unlock buttons; the `A` / `PL` state attributes of such a light, switch or cover read `0` / `4` instead of `00` / `04`, as on bus-discovered entities (#147; the `0809` of #135 is a point of area 8, not of area 0, and stays as typed); 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 `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). +- **Thermoregulation (WHO 4)**: each local 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); zones behind an `#4#II` interface and actuator-shaped `Z#N` addresses are withheld from WHO 4 status and dimension polling across all gateways unless the OWNd profile sets `supports_interface_zone_requests = True` (0 of 12 oracle firmwares route WHO 4 across `#4#II`, preventing main-bus local-zone cross-polling on H4684/MH200/F450/F454/F459/F460/F461/MyHomeServer1, false `unresponsive` repairs on F460/F461/MyHomeServer1, and the `MH202` `1.0.24` `coso` `SIGSEGV` crash on `*#4*Z#4#01##` / `*#4*Z#N##`); 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 a522ffae..be2f490c 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -83,6 +83,7 @@ from .thermo_switch import load_thermo_switch_subentries from .where_grammar import ( canonical_thermo_zone, + is_actuator_where, is_probe, is_pump, parse_thermo_actuator_where, @@ -604,12 +605,16 @@ def __init__( self._attr_hvac_mode: HVACMode | None = None self._attr_hvac_action: HVACAction | None = None + supports_status = self._gateway_supports_zone_status and not is_actuator_where(self._where) if ( - not self._central - and not self._gateway_supports_zone_status - and not self._polls_temperature - and not self._fan - and not self._humidity + self._interface_poll_refused + or ( + not self._central + and not supports_status + and not self._polls_temperature + and not self._fan + and not self._humidity + ) ): self._poll_on_add = False elif self._central and self._where not in ("#0", "0"): @@ -629,15 +634,17 @@ def _temperature_poll_wanted(self) -> bool: @property def _interface_poll_refused(self) -> bool: - """Whether the per-zone temperature poll is withheld because the zone sits behind an F422. + """Whether WHO 4 polls are withheld because the zone sits behind an ``#4#II`` interface. - The firmware drops *#4*Z#4#II*0## before the bus on these gateways (own-firmware-oracle - PR #60: nack/silent on F450, F454, F459 and MH202 among the SCS-only models; the MH202 - even crashes on *#4*Z#4#II##), so the zone waits for the bus to report instead. + Zero of the twelve emulated gateways in own-firmware-oracle (``findings/thermo-zone-requests.md``) + emit ``$06EC`` / ``$06E4`` interface-routed SCS telegrams for WHO 4: gateways either NACK/drop + them, truncate ``Z#4#II`` at ``#`` and cross-poll local zone ``Z`` on the main bus, or on + MH202 ``010024`` misparse trailing ``#4#01`` as local zone 4 actuator 1 and crash ``coso`` + with ``SIGSEGV`` on ``*#4*Z#4#01##`` (including while the profile is still ``Generic``). """ gateway = getattr(self, "_gateway_handler", None) return ( - self._temperature_poll_wanted + not self._central and self._interface is not None and getattr(gateway, "interface_zone_request_refused", False) is True ) @@ -648,7 +655,11 @@ def _polls_temperature(self) -> bool: A pure predicate: ``async_update`` logs (once) when it withholds the poll. """ - return self._temperature_poll_wanted and not self._interface_poll_refused + return ( + self._temperature_poll_wanted + and not self._interface_poll_refused + and not is_actuator_where(self._where) + ) def _enable_fan_mode(self) -> None: """Dynamically enable fan mode support if not already enabled.""" @@ -656,7 +667,7 @@ def _enable_fan_mode(self) -> None: return if not self._fan: self._fan = True - self._poll_on_add = True + self._poll_on_add = not self._interface_poll_refused self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._attr_fan_modes = ["auto", "low", "medium", "high"] if self._attr_fan_mode is None: @@ -680,7 +691,7 @@ def _enable_humidity(self) -> None: return if not self._humidity: self._humidity = True - self._poll_on_add = True + self._poll_on_add = not self._interface_poll_refused @property def extra_state_attributes(self) -> dict[str, Any]: @@ -824,20 +835,24 @@ async def async_update(self) -> None: # 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 self._interface_poll_refused and not self._interface_poll_skip_logged: - # The temperature poll is withheld (the fan / humidity requests below may still go out). - LOGGER.debug( - "%s %s is behind interface %s and %s firmware drops `*#4*%s*0##`; waiting for the zone to report", - self._gateway_handler.log_id, - self._display_name, - self._interface, - self._gateway_handler.model, - self._full_where, - ) - self._interface_poll_skip_logged = True + supports_status = self._gateway_supports_zone_status and not is_actuator_where(self._where) + if self._interface_poll_refused: + if ( + supports_status or self._temperature_poll_wanted or self._fan or wants_humidity + ) and not self._interface_poll_skip_logged: + LOGGER.debug( + "%s %s is behind interface %s and %s does not route WHO 4 requests across `#4#%s`; waiting for the zone to report", + self._gateway_handler.log_id, + self._display_name, + self._interface, + self._gateway_handler.model, + self._interface, + ) + self._interface_poll_skip_logged = True + return if ( not self._central - and not self._gateway_supports_zone_status + and not supports_status and not self._polls_temperature and not self._fan and not wants_humidity @@ -860,7 +875,7 @@ async def async_update(self) -> None: LOGGER.debug("%s %s did not answer its last polls; not asking again yet", self._gateway_handler.log_id, self._display_name) self._raise_unresponsive_issue() return - if self._central or self._gateway_supports_zone_status: + if self._central or supports_status: # A 99-zone central unit (#0) answers the plain status request (*#4*#0##) within ~0.17 s, # reporting its mode via *4*202*#0## (conditional OFF) and operational flags (#629). Dimension 14 # (*#4*#0*14##) is NACKed. Only an F454 has been captured so far, so the poll is informational: @@ -976,6 +991,8 @@ def _ask_fan_coil_sides(self) -> None: def _request_zone_dimension(self, dimension: int) -> bool: """Queue ``*#4*Z*DIM##`` through the gateway's paced sender; False on an older handler.""" + if self._interface_poll_refused: + return False request = getattr(self._gateway_handler, "request_zone_dimension", None) if not callable(request): return False diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index d04e0b31..9da1c294 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -219,14 +219,14 @@ def command_session_default(model: str | None) -> int: # $00499013000 in trace-corpus.tsv; MyHomeServer1 replies from its cache, #649), so their zones # keep the zone status poll and are not listed here. ZONE_TEMPERATURE_POLL = frozenset({"F450", "F454", "F459", "H4684", "MH200", "MH201", "MH202"}) -# A zone behind an F422 bus interface (WHERE Z#4#II) cannot be polled on these gateways: the -# firmware drops *#4*Z#4#II*0## (and *#4*Z#4#II##) before the bus, nack/silent on ten of the -# twelve emulated images; only H4684 and MH200 forward them (own-firmware-oracle PR #60, -# thermo-zone-requests suite, not published yet). Read from the OWNd profile first -# (``supports_interface_zone_requests``); this table is the fallback until the flag exists there. -INTERFACE_ZONE_REQUEST_REFUSED = frozenset( - {"F450", "F453", "F453AV", "F454", "F459", "F460", "F461", "MH200N", "MH202", "MYHOMESERVER1"} -) +# Zones behind an F422 bus interface (WHERE Z#4#II): zero of the twelve emulated gateways in +# own-firmware-oracle (findings/thermo-zone-requests.md) emit $06EC / $06E4 interface-routed SCS +# frames for WHO 4. Gateways either NACK/drop Z#4#II queries, truncate Z#4#II at # and cross-poll +# local main-bus zone Z (F450, F454, F459, F460, F461, H4684, MH200, MyHomeServer1), or on MH202 +# 010024 misparse trailing #4#01 as local zone 4 actuator 1 and crash coso with SIGSEGV on +# *#4*Z#4#01## (including while the profile is still Generic). ``interface_zone_request_refused`` +# therefore defaults to True across all models unless ``profile.supports_interface_zone_requests`` +# is explicitly True. # 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`` @@ -631,6 +631,19 @@ async def _send_zone_requests(self) -> None: 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 target.interface_zone_request_refused: + routed = [where for where in wheres if BUS_ROUTING in where] + if routed: + LOGGER.debug( + "%s Zones %s: %s does not route WHO 4 dimension %s behind an interface; not asking", + self.log_id, + routed, + target.model, + dimension, + ) + wheres = [where for where in wheres if BUS_ROUTING not in where] + if not wheres: + continue if dimension == 20 and target.actuator_request_refused: LOGGER.debug( "%s Zones %s: %s refuses the actuator request; not asking", self.log_id, wheres, target.model @@ -703,11 +716,23 @@ def zone_temperature_poll_needed(self) -> bool: @property def interface_zone_request_refused(self) -> bool: - """Whether this gateway drops the status requests of a zone behind an F422 (``*#4*Z#4#II*0##``).""" + """Whether WHO 4 requests behind an ``#4#II`` interface are dropped, misrouted, or crash the gateway. + + Zero of the twelve emulated gateways in own-firmware-oracle (``findings/thermo-zone-requests.md``) + emit ``$06EC`` / ``$06E4`` interface-routed SCS frames for WHO 4: gateways either NACK/drop them, + truncate ``Z#4#II`` at ``#`` and cross-poll local zone ``Z`` on the main bus, or on MH202 ``010024`` + misparse trailing ``#4#01`` as local zone 4 actuator 1 and crash ``coso`` with ``SIGSEGV`` on + ``*#4*Z#4#01##`` (including before WHO 13 settles while the profile is still ``Generic``). + Delegates through ``_who4_target()``; an explicit bool ``supports_interface_zone_requests`` on the + OWNd profile overrides this default. + """ + target = self._who4_target() + if target is not self: + return target.interface_zone_request_refused supported = getattr(getattr(self.gateway, "profile", None), "supports_interface_zone_requests", None) if isinstance(supported, bool): return not supported - return self._model_key in INTERFACE_ZONE_REQUEST_REFUSED + return True @property def profile(self) -> Any: diff --git a/custom_components/myhome/where_grammar.py b/custom_components/myhome/where_grammar.py index c15e898f..3fe25ebf 100644 --- a/custom_components/myhome/where_grammar.py +++ b/custom_components/myhome/where_grammar.py @@ -198,6 +198,7 @@ def _sound_step_delta(self: Any) -> int | None: "area_of_where", "canonical_thermo_zone", "expose_message_where", + "is_actuator_where", "is_apl_address", "is_probe", "is_pump", @@ -216,6 +217,20 @@ def _sound_step_delta(self: Any) -> int | None: ] +def is_actuator_where(where: str) -> bool: + """Whether ``where`` addresses a WHO 4 actuator (``Z#N`` / ``0#N``) rather than a zone or central unit. + + ``#0#1``..``#0#4`` is a 4-zone central unit, not an actuator; ``Z#4#II`` carries + bus routing. Dimensionless ``*#4*Z#N##`` NACKs on nine gateways, cross-polls local + zone ``Z`` on H4684/MH200, and crashes ``coso`` with ``SIGSEGV`` on MH202 ``010024``. + """ + clean = (where or "").strip().removeprefix("4-") + if clean.startswith("#0#") or "#4#" in clean: + return False + zone, sep, act = clean.removeprefix("#").partition("#") + return bool(sep) and zone.isdigit() and act.isdigit() and 0 <= int(act) <= 9 + + def zone_number(where: str) -> str: """The zone without a legacy ``4-`` prefix or ``#``: ``"#0"`` -> ``"0"``, ``"4-12"`` -> ``"12"``.""" return where.removeprefix("4-").replace("#", "") diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index 5e5af429..da47248d 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -56,9 +56,10 @@ MyHOME climate entities support standard Home Assistant climate controls: * **Target Temperature**: Adjustable setpoint slider in 0.5 °C increments. The slider shows the setpoint the thermostat regulates to (dimension 12, knob offset included) whenever the zone has reported it; the commanded setpoint (dimension 14) is only shown until then. On an F461 the two can differ (21.0 commanded, 23.5 in force on a live capture), and the attribute `local_target_temperature` carries the same value. When the zone leaves `off`, the value reported while it was off is taken as the protection setpoint (#383), so the commanded setpoint is shown until the zone reports dimension 12 again; after a `set_temperature` call the next report, dimension 12 or 14, is shown. The value restored after a restart of Home Assistant already carries the saved `local_offset`, so the first dimension 13 report does not add it again. * **Current Temperature**: Real-time ambient reading reported by the probe. - On SCS-only gateways whose firmware drops the general request `*#4*0##` (F450, F454, F459, H4684, MH200, MH201, MH202), each zone asks for its own temperature with `*#4**0##` after the startup sweep, so a standalone thermostat is not `unknown` until it next reports (#185). F460, F461 and MyHomeServer1 drop `*#4*0##` as well but answer the zone status request `*#4*##` themselves (the F460 and F461 expand it into the four dimension requests, the MyHomeServer1 replies from its cache), so their zones keep that poll. A zone behind an F422 bus interface (`WHERE = #4#`) is not polled on the F450, F454, F459 and MH202: their firmware drops `*#4**0##` for such a zone before it reaches the bus (own-firmware-oracle PR #60), so the zone shows its restored reading until it reports by itself; the H4684, MH200 and MH201 poll it. After a restart the last reading is shown with the attribute `current_temperature_restored: true` until the zone reports again. + On SCS-only gateways whose firmware drops the general request `*#4*0##` (F450, F454, F459, H4684, MH200, MH201, MH202), each local zone asks for its own temperature with `*#4**0##` after the startup sweep, so a standalone thermostat is not `unknown` until it next reports (#185). F460, F461 and MyHomeServer1 drop `*#4*0##` as well but answer the zone status request `*#4*##` themselves (the F460 and F461 expand it into the four dimension requests, the MyHomeServer1 replies from its cache), so their local zones keep that poll. A zone behind an F422 bus interface (`WHERE = #4#`) is never polled unless the OWNd profile sets `supports_interface_zone_requests = True` (see below), so it shows its restored reading until it reports by itself. After a restart the last reading is shown with the attribute `current_temperature_restored: true` until the zone reports again. * **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 or a MyHomeServer1) once the startup sweep is over and again after the event session reconnects, so `hvac_action` (`heating` / `cooling` / `idle`) is known right after start instead of after the first actuator switch. The attribute `actuators` lists each actuator as `on` or `off` (`{"1": "off", "2": "off"}`) and stays while the zone is off (every actuator `off`). F453, F453AV, H4684, MH200, MH200N and MH202 drop the request (firmware oracle), so their zones are not asked and wait for the actuators' own frames; on a shared bus the gateway that serves WHO 4 (delegated secondary or warm standby) decides. Secondary probes (`WHERE >= 100`) are never asked. Probe-less `F430/2` / `F430/4` relays (such as bathroom towel warmers that only emit Dimension 20 actuator status frames without a temperature probe) do not create phantom `climate` entities and can be controlled as `switch` entities via **Add thermo actuator / towel-warmer switch (WHO 4)** (see [Switches & Relays](switches.md)). +* **HVAC Action and `actuators`**: a local zone asks for the state of its actuators (`*#4**20##`, one `*#4*#0*20##` for every local zone on an F461 or a MyHomeServer1) once the startup sweep is over and again after the event session reconnects, so `hvac_action` (`heating` / `cooling` / `idle`) is known right after start instead of after the first actuator switch. The attribute `actuators` lists each actuator as `on` or `off` (`{"1": "off", "2": "off"}`) and stays while the zone is off (every actuator `off`). F453, F453AV, H4684, MH200, MH200N and MH202 drop the request (firmware oracle), so their zones are not asked and wait for the actuators' own frames; on a shared bus the gateway that serves WHO 4 (delegated secondary or warm standby) decides. Secondary probes (`WHERE >= 100`) are never asked. Probe-less `F430/2` / `F430/4` relays (such as bathroom towel warmers that only emit Dimension 20 actuator status frames without a temperature probe) do not create phantom `climate` entities and can be controlled as `switch` entities via **Add thermo actuator / towel-warmer switch (WHO 4)** (see [Switches & Relays](switches.md)). +* **Zones Behind an `#4#II` Interface (`F422`)**: across all twelve emulated gateway firmwares in `own-firmware-oracle` (`findings/thermo-zone-requests.md`), **zero** gateways emit `$06EC`/`$06E4` interface-routed SCS telegrams for `WHO = 4`: gateways either NACK/drop `#4#II` queries, truncate `Z#4#II` at `#` and cross-poll local zone `Z` on the main bus (`F450`, `F454`, `F459`, `F460`, `F461`, `H4684`, `MH200`, `MyHomeServer1`), or on `MH202` (`1.0.24`) misparse trailing `#4#01` as local zone 4 actuator 1 and crash `coso` (`SIGSEGV`) on `*#4*Z#4#01##`. Unless an `OWNd` gateway profile explicitly declares `supports_interface_zone_requests = True`, zones configured with an `#4#II` interface are never polled (`*#4*Z#4#II##`, `*0##`, `*11##`, `*19##`, `*20##`, `*60##`) and rely safely on incoming event-session broadcasts. Dimensionless actuator status polls (`*#4*Z#N##`) are likewise never sent. ### 💧 Humidity Support & Startup Behavior diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 9678abfe..90a5b06f 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -167,12 +167,20 @@ async def test_f461_asks_every_zone_s_actuators_with_one_frame(hass, fast_bus_pa assert _drain(handler) == ["*#4*#0*20##"] -async def test_routed_zone_is_asked_on_its_own(hass, fast_bus_pacing): +async def test_routed_zone_is_withheld_by_default_and_asked_when_profile_opts_in(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##"] + + handler.gateway.profile = SimpleNamespace( + model_name=handler.gateway.profile.model_name, supports_interface_zone_requests=True + ) + handler.request_zone_dimension("2", 20) + handler.request_zone_dimension("3#4#01", 20) + await _settle(hass) assert _drain(handler) == ["*#4*#0*20##", "*#4*3#4#01*20##"] @@ -550,14 +558,21 @@ def test_a_plain_or_routed_zone_address_is_accepted(hass, where): 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.""" +async def test_a_one_digit_interface_is_normalized_to_two_digits_and_gated(hass, fast_bus_pacing): + """``3#4#1`` is normalized to ``3#4#01``, withheld by default, and sent only when the profile opts in.""" 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) == [] + + handler.gateway.profile = SimpleNamespace( + model_name=handler.gateway.profile.model_name, supports_interface_zone_requests=True + ) + handler.request_zone_dimension("3#4#1", 20) + await _settle(hass) assert _drain(handler) == ["*#4*3#4#01*20##"] unwatch() assert handler._actuator_zones == {} @@ -720,6 +735,9 @@ async def test_zones_discovered_after_the_sweep_share_one_all_zone_request(hass, """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.gateway.profile = SimpleNamespace( + model_name=handler.gateway.profile.model_name, supports_interface_zone_requests=True + ) handler._initial_discovery_done.set() handler.watch_zone_actuators("2") await asyncio.sleep(0.05) @@ -1082,3 +1100,123 @@ async def test_replay_myhomeserver1_all_zone_actuator_burst_through_the_climate_ ] 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] + + +# ------------------------------------------------------------------ WHO 4 #4#II non-routing & MH202 crash guards + +@pytest.mark.parametrize( + "model", + [ + "F450", "F453", "F453AV", "F454", "F459", "F460", "F461", + "H4684", "MH200", "MH200N", "MH202", "MyHomeServer1", "Generic", + ], +) +def test_interface_zone_request_refused_defaults_true_across_fleet(hass, model): + """0 of 12 oracle gateways route WHO 4 across #4#II, and Generic may be an unidentified MH202.""" + handler = _handler(hass, model) + assert handler.interface_zone_request_refused is True + + +@pytest.mark.parametrize(("flag", "expected"), [(True, False), (False, True), (None, True)]) +def test_interface_zone_request_refused_respects_profile_flag_and_delegation(hass, flag, expected): + primary = _handler(hass, "F461") + secondary = _handler(hass, "F454") + secondary.gateway.profile = SimpleNamespace( + model_name="F454", supports_interface_zone_requests=flag + ) + primary._delegated_target = MagicMock(return_value=secondary) + assert primary.interface_zone_request_refused is expected + + +async def test_routed_zone_on_generic_or_f461_never_polls_status_or_raises_unresponsive(hass, fast_bus_pacing): + """Prevents Generic->MH202 coso SIGSEGV on *#4*2#4#01## and false unresponsive repair on F461.""" + for model in ("Generic", "F461"): + handler = _handler(hass, model) + handler._initial_discovery_done.set() + handler.send_status_request = AsyncMock() + zone = MyHOMEClimate( + hass=hass, + name="Zone 2 Bus 01", + device_id="4-2#4#01", + who="4", + where="2", + interface="01", + 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_bus_01" + zone.async_write_ha_state = MagicMock() + assert zone._poll_on_add is False + zone._enable_fan_mode() + zone._enable_humidity() + assert zone._poll_on_add is False + zone._ask_fan_coil_sides() + assert zone._fan_coil_sides_asked_at is None + await zone.async_added_to_hass() + await zone.async_update() + await zone.async_update() + await _settle(hass) + handler.send_status_request.assert_not_awaited() + assert zone._poll_health.unresponsive is False + assert "unresponsive" not in zone.extra_state_attributes + + +def test_is_actuator_where_distinguishes_actuators_from_central_and_routed(): + from custom_components.myhome.where_grammar import is_actuator_where + + assert is_actuator_where("2#1") is True + assert is_actuator_where("#2#9") is True + assert is_actuator_where("4-2#0") is True + assert is_actuator_where("0#0") is True + assert is_actuator_where("0#1") is True + assert is_actuator_where("#0") is False + assert is_actuator_where("#0#1") is False + assert is_actuator_where("4-#0#4") is False + assert is_actuator_where("2") is False + assert is_actuator_where("2#4#01") is False + assert is_actuator_where("2#1#4#01") is False + + +async def test_actuator_where_never_sends_dimensionless_status_request(hass): + """Dimensionless *#4*2#1## NACKs on 9 gateways and crashes coso with SIGSEGV on MH202 010024.""" + for model in ("Generic", "F461", "MH202"): + handler = _handler(hass, model) + handler.send_status_request = AsyncMock() + zone = MyHOMEClimate( + hass=hass, + name="Zone 2 Act 1", + device_id="4-2#1", + who="4", + where="2#1", + heating=True, + cooling=False, + fan=False, + humidity=False, + standalone=True, + central=False, + manufacturer="BTicino", + model="Heating Zone", + gateway=handler, + ) + zone.hass = hass + zone.entity_id = "climate.zone_2_act_1" + zone.async_write_ha_state = MagicMock() + assert zone._poll_on_add is False + assert zone._polls_temperature is False + await zone.async_update() + handler.send_status_request.assert_not_awaited() + + # Even if fan is enabled dynamically, *#4*2#1## is still suppressed in async_update + zone._enable_fan_mode() + await zone.async_update() + sent = [str(c.args[0]) for c in handler.send_status_request.await_args_list] + assert "*#4*2#1##" not in sent + assert "*#4*2#1*0##" not in sent diff --git a/tests/test_startup_requests_per_firmware.py b/tests/test_startup_requests_per_firmware.py index a94599dd..0b98b754 100644 --- a/tests/test_startup_requests_per_firmware.py +++ b/tests/test_startup_requests_per_firmware.py @@ -62,10 +62,10 @@ def _handler(hass, model: str) -> MyHOMEGatewayHandler: return MyHOMEGatewayHandler(hass, _entry(model)) -def _zone(hass, handler, where="3", **kwargs) -> MyHOMEClimate: +def _zone(hass, handler, where="3", fan=False, **kwargs) -> MyHOMEClimate: zone = MyHOMEClimate( hass=hass, name=f"Zone {where}", device_id=f"4-{where}", who="4", where=where, - heating=True, cooling=False, fan=False, standalone=True, central=False, + heating=True, cooling=False, fan=fan, standalone=True, central=False, manufacturer="BTicino", model="Heating Zone", gateway=handler, **kwargs, ) zone.hass = hass @@ -160,7 +160,7 @@ def test_ssdp_model_string_hl4684_is_keyed_as_h4684(hass, ssdp_model): assert handler.supports_zone_status is False assert handler.general_zone_request_refused is True assert handler.sound_status_request_refused is False - assert handler.interface_zone_request_refused is False + assert handler.interface_zone_request_refused is True @pytest.mark.parametrize("flag", [True, False]) @@ -208,9 +208,12 @@ async def test_scs_only_zone_asks_for_its_temperature(hass, model): assert [str(call.args[0]) for call in handler.send_status_request.await_args_list] == ["*#4*3*0##"] -@pytest.mark.parametrize("model", ["F450", "F454", "F459", "MH202"]) -async def test_interface_zone_is_not_polled_where_the_firmware_drops_the_request(hass, model, caplog): - """*#4*Z#4#II*0## is nack/silent on these SCS-only gateways (own-firmware-oracle PR #60). +@pytest.mark.parametrize( + "model", + ["F450", "F454", "F459", "MH202", "H4684", "MH200", "MH201", "F460", "F461", "MyHomeServer1", "Generic"], +) +async def test_interface_zone_is_not_polled_across_any_unrouted_gateway(hass, model, caplog): + """0 of 12 oracle gateways route WHO 4 across #4#II (findings/thermo-zone-requests.md). ``_polls_temperature`` is a pure predicate: construction and reading it log nothing; the one-shot DEBUG line is written by ``async_update`` when it actually withholds the poll. @@ -219,7 +222,7 @@ async def test_interface_zone_is_not_polled_where_the_firmware_drops_the_request handler.send_status_request = AsyncMock() def skipped() -> list: - return [record for record in caplog.records if "drops `*#4*3#4#01*0##`" in record.message] + return [record for record in caplog.records if "does not route WHO 4 requests across `#4#01`" in record.message] with caplog.at_level("DEBUG", logger="custom_components.myhome"): zone = _zone(hass, handler, interface="01") @@ -236,47 +239,45 @@ def skipped() -> list: assert len(skipped()) == 1 # once per entity -async def test_withheld_interface_poll_is_logged_even_when_humidity_keeps_the_update_going(hass, caplog): - """A humidity zone behind an F422 on an F459 (forwards *60##, drops Z#4#II requests) still sends its - *60## request; the withheld *0## is logged once.""" +async def test_withheld_interface_poll_also_withholds_humidity_and_fan_cross_polls(hass, caplog): + """On F459, *#4*3#4#01*60## truncates at # and cross-polls local main-bus zone 3 ($06D102030308000000); + all WHO 4 polls on a routed zone are withheld and logged once.""" handler = _handler(hass, "F459") handler.send_status_request = AsyncMock() - zone = _zone(hass, handler, interface="01", humidity=True) + zone = _zone(hass, handler, interface="01", humidity=True, fan=True) assert zone._polls_temperature is False and zone._interface_poll_refused is True + assert zone._poll_on_add is False with caplog.at_level("DEBUG", logger="custom_components.myhome"): await zone.async_update() await zone.async_update() - sent = [str(call.args[0]) for call in handler.send_status_request.await_args_list] - assert sent == ["*#4*3#4#01*60##", "*#4*3#4#01*60##"] - assert len([record for record in caplog.records if "drops `*#4*3#4#01*0##`" in record.message]) == 1 - - -@pytest.mark.parametrize("model", ["H4684", "MH200", "MH201"]) -async def test_interface_zone_is_polled_where_the_firmware_forwards_the_request(hass, model): - """H4684 and MH200 forward *#4*Z#4#II*0## (own-firmware-oracle PR #60); the MH201 is not in the oracle.""" - handler = _handler(hass, model) - handler.send_status_request = AsyncMock() - zone = _zone(hass, handler, interface="01") - assert zone._poll_on_add is True - await zone.async_update() - assert [str(call.args[0]) for call in handler.send_status_request.await_args_list] == ["*#4*3#4#01*0##"] + handler.send_status_request.assert_not_awaited() + assert len([record for record in caplog.records if "does not route WHO 4 requests across `#4#01`" in record.message]) == 1 @pytest.mark.parametrize( - ("model", "flag", "expected"), - [("MH202", True, True), ("H4684", False, False)], + ("model", "flag", "expected_temp", "expected_frames"), + [ + ("MH202", True, True, ["*#4*3#4#01*0##"]), + ("H4684", True, True, ["*#4*3#4#01*0##"]), + ("H4684", False, False, []), + ("F461", True, False, ["*#4*3#4#01##"]), + ], ) -async def test_profile_flag_wins_over_the_interface_zone_table(hass, model, flag, expected): - """An OWNd profile that states ``supports_interface_zone_requests`` is believed over the table.""" +async def test_profile_flag_wins_over_the_interface_zone_default(hass, model, flag, expected_temp, expected_frames): + """An OWNd profile that states ``supports_interface_zone_requests`` overrides the fleet default.""" handler = _handler(hass, model) handler.gateway.profile = SimpleNamespace( model_name=handler.gateway.profile.model_name, supports_interface_zone_requests=flag, supports_who=lambda who: True, ) + handler.send_status_request = AsyncMock() assert handler.interface_zone_request_refused is (not flag) zone = _zone(hass, handler, interface="01") - assert zone._polls_temperature is expected + assert zone._polls_temperature is expected_temp + assert zone._poll_on_add is flag + await zone.async_update() + assert [str(call.args[0]) for call in handler.send_status_request.await_args_list] == expected_frames async def test_f455_zone_still_sends_nothing(hass, caplog): @@ -284,11 +285,11 @@ async def test_f455_zone_still_sends_nothing(hass, caplog): handler.send_status_request = AsyncMock() zone = _zone(hass, handler, interface="01") assert zone._poll_on_add is False - assert zone._interface_poll_refused is False # no temperature poll wanted, so nothing is "withheld" + assert zone._interface_poll_refused is True with caplog.at_level("DEBUG", logger="custom_components.myhome"): await zone.async_update() handler.send_status_request.assert_not_awaited() - assert not [record for record in caplog.records if "firmware drops" in record.message] + assert not [record for record in caplog.records if "does not route WHO 4 requests" in record.message] async def test_central_unit_is_not_a_temperature_poll(hass): From f7962ac57b925ef681454eb0df2875a5f6a4a2f3 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:33:01 +0100 Subject: [PATCH 2/3] fix(climate): close inline #4#II, routed central, and 3-token actuator poll gaps --- custom_components/myhome/climate.py | 42 +++++--- custom_components/myhome/discovery.py | 14 ++- custom_components/myhome/where_grammar.py | 19 ++-- docs/configuration/climate.md | 2 +- tests/test_climate_actuator_status.py | 118 +++++++++++++++++++--- tests/test_platform_discovery.py | 2 + 6 files changed, 156 insertions(+), 41 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index be2f490c..4aff4673 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -509,6 +509,11 @@ def __init__( interface: str | None = None, humidity: bool = False, ) -> None: + if interface is None and BUS_ROUTING in where: + base_where, _, inline_interface = where.rpartition(BUS_ROUTING) + if inline_interface.isdigit(): + where = base_where + interface = inline_interface super().__init__( hass=hass, name=name, @@ -605,12 +610,12 @@ def __init__( self._attr_hvac_mode: HVACMode | None = None self._attr_hvac_action: HVACAction | None = None - supports_status = self._gateway_supports_zone_status and not is_actuator_where(self._where) if ( self._interface_poll_refused + or is_actuator_where(self._where) or ( not self._central - and not supports_status + and not self._gateway_supports_zone_status and not self._polls_temperature and not self._fan and not self._humidity @@ -644,8 +649,7 @@ def _interface_poll_refused(self) -> bool: """ gateway = getattr(self, "_gateway_handler", None) return ( - not self._central - and self._interface is not None + (self._interface is not None or BUS_ROUTING in self._full_where) and getattr(gateway, "interface_zone_request_refused", False) is True ) @@ -667,7 +671,7 @@ def _enable_fan_mode(self) -> None: return if not self._fan: self._fan = True - self._poll_on_add = not self._interface_poll_refused + self._poll_on_add = not self._interface_poll_refused and not is_actuator_where(self._where) self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._attr_fan_modes = ["auto", "low", "medium", "high"] if self._attr_fan_mode is None: @@ -691,7 +695,7 @@ def _enable_humidity(self) -> None: return if not self._humidity: self._humidity = True - self._poll_on_add = not self._interface_poll_refused + self._poll_on_add = not self._interface_poll_refused and not is_actuator_where(self._where) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -834,11 +838,19 @@ async def async_update(self) -> None: # 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. + if is_actuator_where(self._where): + # Per-actuator and split addresses (Z#N, Z#0, 0#N, 0#0, 3#Z#N) are not zone probes: + # *#4*Z#N## crashes MH202 coso with SIGSEGV (NACK on 9 others, local-zone cross-poll + # on H4684/MH200), and *0## / *11## / *60## either NACK or truncate onto zone Z. + return wants_humidity = self._humidity and getattr(self._gateway_handler, "humidity_request_refused", False) is not True - supports_status = self._gateway_supports_zone_status and not is_actuator_where(self._where) if self._interface_poll_refused: if ( - supports_status or self._temperature_poll_wanted or self._fan or wants_humidity + self._central + or self._gateway_supports_zone_status + or self._temperature_poll_wanted + or self._fan + or wants_humidity ) and not self._interface_poll_skip_logged: LOGGER.debug( "%s %s is behind interface %s and %s does not route WHO 4 requests across `#4#%s`; waiting for the zone to report", @@ -852,7 +864,7 @@ async def async_update(self) -> None: return if ( not self._central - and not supports_status + and not self._gateway_supports_zone_status and not self._polls_temperature and not self._fan and not wants_humidity @@ -875,7 +887,7 @@ async def async_update(self) -> None: LOGGER.debug("%s %s did not answer its last polls; not asking again yet", self._gateway_handler.log_id, self._display_name) self._raise_unresponsive_issue() return - if self._central or supports_status: + if self._central or self._gateway_supports_zone_status: # A 99-zone central unit (#0) answers the plain status request (*#4*#0##) within ~0.17 s, # reporting its mode via *4*202*#0## (conditional OFF) and operational flags (#629). Dimension 14 # (*#4*#0*14##) is NACKed. Only an F454 has been captured so far, so the poll is informational: @@ -891,8 +903,8 @@ async def async_update(self) -> None: # (F461 2.0.11, f461_live_2026-10-23 rows 521-525: *#4*2*0## -> *#4*2*0*0239## + ACK in # 0.119 s), which keeps a single-worker MH200 / MH201 queue short. *#4*0## is refused on # these gateways, so this is the zone's only way to a temperature after a restart. A - # zone behind an F422 is only polled where the firmware forwards Z#4#II requests - # (_polls_temperature). + # zone behind an #4#II interface is withheld above unless the OWNd profile explicitly + # sets supports_interface_zone_requests = True (_polls_temperature). # No _poll_answered callback on purpose: the oracle's *#4*1*0## NACK on 11 of 12 images # is the bus TX timeout of an unpopulated zone (the QEMU images have no thermostat # wired; the live F461 NACKs the unpopulated zone 1 the same way after 2.07 s, rows @@ -991,7 +1003,7 @@ def _ask_fan_coil_sides(self) -> None: def _request_zone_dimension(self, dimension: int) -> bool: """Queue ``*#4*Z*DIM##`` through the gateway's paced sender; False on an older handler.""" - if self._interface_poll_refused: + if self._interface_poll_refused or is_actuator_where(self._where): return False request = getattr(self._gateway_handler, "request_zone_dimension", None) if not callable(request): @@ -1089,10 +1101,10 @@ async def async_added_to_hass(self) -> None: # 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 zone_requests: + if zone_requests and self._where.removeprefix("4-").removeprefix("#").count("#") < 2: # 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. + # probe (WHERE >= 100) or 3-token split address (3#Z#N) is not a Dimension 20 target. 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/discovery.py b/custom_components/myhome/discovery.py index bd9abba7..ffa01634 100644 --- a/custom_components/myhome/discovery.py +++ b/custom_components/myhome/discovery.py @@ -65,8 +65,10 @@ def from_device_id(cls, device_id: str, key_suffix: str = "") -> Address: device_id = str(device_id) if key_suffix and device_id.endswith(key_suffix): device_id = device_id[: -len(key_suffix)] - where, _, interface = device_id.partition(BUS_ROUTING) - return cls(where, interface or None, key_suffix) + base, routing, interface = device_id.rpartition(BUS_ROUTING) + if routing and interface.isdigit(): + return cls(base, interface.zfill(2), key_suffix) + return cls(device_id, None, key_suffix) @classmethod def from_message(cls, message: Any) -> Address | None: @@ -80,8 +82,12 @@ def from_message(cls, message: Any) -> Address | None: def from_config(cls, dev_id: str, cfg: dict[str, Any]) -> Address: """Address of a ``myhome.yaml`` device: ``where`` (``zone`` for heating), else the key.""" interface = cfg.get(CONF_BUS_INTERFACE) or cfg.get("bus_interface") or cfg.get("interface") - where = cfg.get(CONF_WHERE, cfg.get(CONF_ZONE, dev_id)) - return cls(str(where), str(interface) if interface else None) + where = str(cfg.get(CONF_WHERE, cfg.get(CONF_ZONE, dev_id))) + if not interface: + base, routing, inline_iface = where.rpartition(BUS_ROUTING) + if routing and inline_iface.isdigit(): + return cls(base, inline_iface.zfill(2)) + return cls(where, str(interface) if interface else None) @property def key(self) -> str: diff --git a/custom_components/myhome/where_grammar.py b/custom_components/myhome/where_grammar.py index 3fe25ebf..eaedeafd 100644 --- a/custom_components/myhome/where_grammar.py +++ b/custom_components/myhome/where_grammar.py @@ -218,17 +218,24 @@ def _sound_step_delta(self: Any) -> int | None: def is_actuator_where(where: str) -> bool: - """Whether ``where`` addresses a WHO 4 actuator (``Z#N`` / ``0#N``) rather than a zone or central unit. + """Whether ``where`` addresses a WHO 4 actuator or split unit rather than a zone or central unit. - ``#0#1``..``#0#4`` is a 4-zone central unit, not an actuator; ``Z#4#II`` carries - bus routing. Dimensionless ``*#4*Z#N##`` NACKs on nine gateways, cross-polls local + Covers ``Z#N`` / ``Z#0`` / ``0#N`` / ``0#0`` and 3-token ``3#Z#N`` / ``A#B#C`` addresses + (with or without a trailing ``#4#II`` interface suffix). ``#0#1``..``#0#4`` is a 4-zone + central unit, not an actuator, and ``Z#4#II`` is a routed zone. Dimensionless ``*#4*Z#N##`` + (and 3-token ``A#B#C`` with ``1 <= C <= 9``) NACKs on nine gateways, cross-polls local zone ``Z`` on H4684/MH200, and crashes ``coso`` with ``SIGSEGV`` on MH202 ``010024``. """ clean = (where or "").strip().removeprefix("4-") - if clean.startswith("#0#") or "#4#" in clean: + base, routing, iface = clean.rpartition("#4#") + if routing and iface.isdigit(): + clean = base + if clean.startswith("#0#"): return False - zone, sep, act = clean.removeprefix("#").partition("#") - return bool(sep) and zone.isdigit() and act.isdigit() and 0 <= int(act) <= 9 + parts = clean.removeprefix("#").split("#") + if len(parts) in (2, 3): + return all(part.isdigit() for part in parts) and 0 <= int(parts[-1]) <= 9 + return False def zone_number(where: str) -> str: diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index da47248d..480986e9 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -59,7 +59,7 @@ MyHOME climate entities support standard Home Assistant climate controls: On SCS-only gateways whose firmware drops the general request `*#4*0##` (F450, F454, F459, H4684, MH200, MH201, MH202), each local zone asks for its own temperature with `*#4**0##` after the startup sweep, so a standalone thermostat is not `unknown` until it next reports (#185). F460, F461 and MyHomeServer1 drop `*#4*0##` as well but answer the zone status request `*#4*##` themselves (the F460 and F461 expand it into the four dimension requests, the MyHomeServer1 replies from its cache), so their local zones keep that poll. A zone behind an F422 bus interface (`WHERE = #4#`) is never polled unless the OWNd profile sets `supports_interface_zone_requests = True` (see below), so it shows its restored reading until it reports by itself. After a restart the last reading is shown with the attribute `current_temperature_restored: true` until the zone reports again. * **Current Humidity**: Ambient relative humidity percentage for zones equipped with humidity sensors (e.g. Living Now thermostats or humidity probes). * **HVAC Action and `actuators`**: a local zone asks for the state of its actuators (`*#4**20##`, one `*#4*#0*20##` for every local zone on an F461 or a MyHomeServer1) once the startup sweep is over and again after the event session reconnects, so `hvac_action` (`heating` / `cooling` / `idle`) is known right after start instead of after the first actuator switch. The attribute `actuators` lists each actuator as `on` or `off` (`{"1": "off", "2": "off"}`) and stays while the zone is off (every actuator `off`). F453, F453AV, H4684, MH200, MH200N and MH202 drop the request (firmware oracle), so their zones are not asked and wait for the actuators' own frames; on a shared bus the gateway that serves WHO 4 (delegated secondary or warm standby) decides. Secondary probes (`WHERE >= 100`) are never asked. Probe-less `F430/2` / `F430/4` relays (such as bathroom towel warmers that only emit Dimension 20 actuator status frames without a temperature probe) do not create phantom `climate` entities and can be controlled as `switch` entities via **Add thermo actuator / towel-warmer switch (WHO 4)** (see [Switches & Relays](switches.md)). -* **Zones Behind an `#4#II` Interface (`F422`)**: across all twelve emulated gateway firmwares in `own-firmware-oracle` (`findings/thermo-zone-requests.md`), **zero** gateways emit `$06EC`/`$06E4` interface-routed SCS telegrams for `WHO = 4`: gateways either NACK/drop `#4#II` queries, truncate `Z#4#II` at `#` and cross-poll local zone `Z` on the main bus (`F450`, `F454`, `F459`, `F460`, `F461`, `H4684`, `MH200`, `MyHomeServer1`), or on `MH202` (`1.0.24`) misparse trailing `#4#01` as local zone 4 actuator 1 and crash `coso` (`SIGSEGV`) on `*#4*Z#4#01##`. Unless an `OWNd` gateway profile explicitly declares `supports_interface_zone_requests = True`, zones configured with an `#4#II` interface are never polled (`*#4*Z#4#II##`, `*0##`, `*11##`, `*19##`, `*20##`, `*60##`) and rely safely on incoming event-session broadcasts. Dimensionless actuator status polls (`*#4*Z#N##`) are likewise never sent. +* **Zones Behind an `#4#II` Interface (`F422`)**: across all twelve emulated gateway firmwares in `own-firmware-oracle` (`findings/thermo-zone-requests.md`), **zero** gateways emit `$06EC`/`$06E4` interface-routed SCS telegrams for `WHO = 4`: gateways either NACK/drop `#4#II` queries, truncate `Z#4#II` at `#` and cross-poll local zone `Z` on the main bus (`F450`, `F454`, `F459`, `F460`, `F461`, `H4684`, `MH200`, `MyHomeServer1`), or on `MH202` (`1.0.24`) misparse trailing `#4#01` as local zone 4 actuator 1 and crash `coso` (`SIGSEGV`) on `*#4*Z#4#01##`. Unless an `OWNd` gateway profile explicitly declares `supports_interface_zone_requests = True`, zones configured with an `#4#II` interface are never polled (`*#4*Z#4#II##`, `*0##`, `*11##`, `*19##`, `*20##`, `*60##`) and rely safely on incoming event-session broadcasts. Dimensionless and zone-dimension polls on actuator or split addresses (`Z#N`, `Z#0`, `0#N`, `3#Z#N`) are likewise never sent. ### 💧 Humidity Support & Startup Behavior diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index 90a5b06f..a64e342b 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -1173,31 +1173,44 @@ def test_is_actuator_where_distinguishes_actuators_from_central_and_routed(): from custom_components.myhome.where_grammar import is_actuator_where assert is_actuator_where("2#1") is True + assert is_actuator_where("2#4") is True assert is_actuator_where("#2#9") is True assert is_actuator_where("4-2#0") is True assert is_actuator_where("0#0") is True assert is_actuator_where("0#1") is True + assert is_actuator_where("2#1#4#01") is True + assert is_actuator_where("2#4#4#01") is True + assert is_actuator_where("3#2#1") is True + assert is_actuator_where("3#2#1#4#01") is True assert is_actuator_where("#0") is False assert is_actuator_where("#0#1") is False assert is_actuator_where("4-#0#4") is False + assert is_actuator_where("#0#4#01") is False + assert is_actuator_where("#0#1#4#01") is False + assert is_actuator_where("#4#01") is False assert is_actuator_where("2") is False assert is_actuator_where("2#4#01") is False - assert is_actuator_where("2#1#4#01") is False -async def test_actuator_where_never_sends_dimensionless_status_request(hass): - """Dimensionless *#4*2#1## NACKs on 9 gateways and crashes coso with SIGSEGV on MH202 010024.""" - for model in ("Generic", "F461", "MH202"): +@pytest.mark.parametrize("where", ["2#1", "3#2#1", "2#1#4#01"]) +async def test_actuator_where_never_sends_status_or_zone_dimension_polls(hass, fast_bus_pacing, where): + """Actuator and 3-token split addresses suppress *#4*W## (MH202 SIGSEGV), *0##, *11##, *19## and *60##.""" + for model in ("Generic", "F461", "F459", "MH202", "H4684"): handler = _handler(hass, model) + handler._initial_discovery_done.set() + if "#4#" in where: + handler.gateway.profile = SimpleNamespace( + model_name=model, supports_interface_zone_requests=True + ) handler.send_status_request = AsyncMock() zone = MyHOMEClimate( hass=hass, - name="Zone 2 Act 1", - device_id="4-2#1", + name=f"Actuator {where}", + device_id=f"4-{where}", who="4", - where="2#1", + where=where, heating=True, - cooling=False, + cooling=True, fan=False, humidity=False, standalone=True, @@ -1207,16 +1220,91 @@ async def test_actuator_where_never_sends_dimensionless_status_request(hass): gateway=handler, ) zone.hass = hass - zone.entity_id = "climate.zone_2_act_1" + zone.entity_id = "climate.zone_act" zone.async_write_ha_state = MagicMock() assert zone._poll_on_add is False assert zone._polls_temperature is False - await zone.async_update() - handler.send_status_request.assert_not_awaited() - - # Even if fan is enabled dynamically, *#4*2#1## is still suppressed in async_update zone._enable_fan_mode() + zone._enable_humidity() + assert zone._poll_on_add is False + zone._ask_fan_coil_sides() + assert zone._fan_coil_sides_asked_at is None + await zone.async_added_to_hass() await zone.async_update() + await _settle(hass) sent = [str(c.args[0]) for c in handler.send_status_request.await_args_list] - assert "*#4*2#1##" not in sent - assert "*#4*2#1*0##" not in sent + if where == "3#2#1" or model in ("MH202", "H4684"): + expected: list[str] = [] + elif where == "2#1" and model == "F461": + expected = ["*#4*#0*20##"] + else: + expected = [f"*#4*{where}*20##"] + assert sent == expected + + +async def test_inline_routed_where_and_routed_central_withhold_polls(hass, fast_bus_pacing, caplog): + """Inline ``where='2#4#01'`` (interface=None) and routed central ``#0#4#01`` both withhold WHO 4 polls.""" + for model in ("Generic", "F461", "F459", "H4684", "MH202"): + handler = _handler(hass, model) + handler._initial_discovery_done.set() + handler.send_status_request = AsyncMock() + + inline_zone = MyHOMEClimate( + hass=hass, + name="Inline Zone 2 Bus 01", + device_id="4-2#4#01", + who="4", + where="2#4#1", + heating=True, + cooling=True, + fan=True, + humidity=True, + standalone=True, + central=False, + manufacturer="BTicino", + model="Heating Zone", + gateway=handler, + ) + inline_zone.hass = hass + inline_zone.entity_id = "climate.inline_zone_2_bus_01" + inline_zone.async_write_ha_state = MagicMock() + assert (inline_zone._where, inline_zone._interface, inline_zone._full_where) == ("2", "01", "2#4#01") + assert inline_zone._interface_poll_refused is True + assert inline_zone._poll_on_add is False + await inline_zone.async_added_to_hass() + await inline_zone.async_update() + + routed_central = MyHOMEClimate( + hass=hass, + name="Central Bus 01", + device_id="4-#0#4#01", + who="4", + where="#0#4#01", + heating=True, + cooling=True, + fan=False, + humidity=False, + standalone=False, + central=True, + manufacturer="BTicino", + model="Central Unit (3550)", + gateway=handler, + ) + routed_central.hass = hass + routed_central.entity_id = "climate.central_bus_01" + routed_central.async_write_ha_state = MagicMock() + assert (routed_central._where, routed_central._interface, routed_central._full_where) == ( + "#0", "01", "#0#4#01" + ) + assert routed_central._central is True + assert routed_central._interface_poll_refused is True + assert routed_central._poll_on_add is False + caplog.clear() + with caplog.at_level("DEBUG", logger="custom_components.myhome"): + await routed_central.async_added_to_hass() + await routed_central.async_update() + await routed_central.async_update() + await _settle(hass) + handler.send_status_request.assert_not_awaited() + assert _drain(handler) == [] + assert len([r for r in caplog.records if "does not route WHO 4 requests across `#4#01`" in r.message]) == 1 diff --git a/tests/test_platform_discovery.py b/tests/test_platform_discovery.py index 0c125a6e..f02c6406 100644 --- a/tests/test_platform_discovery.py +++ b/tests/test_platform_discovery.py @@ -37,7 +37,9 @@ def test_address_forms_follow_openwebnet_where_conventions(): assert (legacy.clean_where, legacy.clean_key) == ("12", "12") zone = Address.from_device_id("3#16", key_suffix="#16") assert (zone.where, zone.key) == ("3", "3#16") + assert Address.from_device_id("2#4#4#01") == Address("2#4", "01") assert Address.from_config("kitchen", {"where": "12", "bus_interface": "02"}).key == "12#4#02" + assert Address.from_config("upstairs", {"zone": "2#4#1"}) == Address("2", "01") assert Address.from_config("12", {}).where == "12" # the yaml key is the WHERE when omitted assert Address.from_message(MagicMock(where="", interface=None)) is None assert Address.from_message(OWNEvent.parse("*1*1*0311#4#01##")) == Address("0311", "01") From 595c508bfaccd6a56165b4adfd8a5cce873726ca Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:45:59 +0100 Subject: [PATCH 3/3] fix(climate): close #0#2..#0#4, inline #4#II write/echo, and _ZONE_WHERE audit gaps --- .github/release-notes/2.0.0b16.md | 2 +- custom_components/myhome/climate.py | 57 ++++-- custom_components/myhome/discovery.py | 4 +- custom_components/myhome/gateway.py | 8 +- custom_components/myhome/gateway_events.py | 18 ++ custom_components/myhome/where_grammar.py | 14 +- docs/configuration/climate.md | 2 +- tests/test_climate_actuator_status.py | 213 +++++++++++++++++++- tests/test_platform_discovery.py | 3 + tests/test_startup_requests_per_firmware.py | 7 +- 10 files changed, 288 insertions(+), 40 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 044566f6..e3edf39b 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -35,7 +35,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); `where: "0004"` in `myhome.yaml` is resolved to `04`, the spelling the gateways accept and report, with a one-time warning, and registry entries created under the 4-digit spelling are moved to it; the duplicate entity and device that bus discovery had created under `04` are removed, their area, labels, icon and custom name carried over to the configured ones where those have none, and a light that bus discovery had created at the address of a WHO 1 switch or impulse cover goes with its lock / unlock buttons; the `A` / `PL` state attributes of such a light, switch or cover read `0` / `4` instead of `00` / `04`, as on bus-discovered entities (#147; the `0809` of #135 is a point of area 8, not of area 0, and stays as typed); 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 local 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); zones behind an `#4#II` interface and actuator-shaped `Z#N` addresses are withheld from WHO 4 status and dimension polling across all gateways unless the OWNd profile sets `supports_interface_zone_requests = True` (0 of 12 oracle firmwares route WHO 4 across `#4#II`, preventing main-bus local-zone cross-polling on H4684/MH200/F450/F454/F459/F460/F461/MyHomeServer1, false `unresponsive` repairs on F460/F461/MyHomeServer1, and the `MH202` `1.0.24` `coso` `SIGSEGV` crash on `*#4*Z#4#01##` / `*#4*Z#N##`); 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 local 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); zones behind an `#4#II` interface withhold WHO 4 status/dimension polls and outbound climate writes, and actuator-shaped `Z#N` / `3#Z#N` addresses are withheld from WHO 4 status and zone-dimension polling across all gateways unless the OWNd profile sets `supports_interface_zone_requests = True` (0 of 12 oracle firmwares route WHO 4 across `#4#II`, preventing main-bus local-zone cross-polling or writes on H4684/MH200/F450/F454/F459/F460/F461/MyHomeServer1, false `unresponsive` repairs on F460/F461/MyHomeServer1, and the `MH202` `1.0.24` `coso` `SIGSEGV` crash on `*#4*Z#4#01##` / `*#4*Z#N##`); 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 4aff4673..ded5e06c 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -77,7 +77,7 @@ default_known_keys, is_broadcast_where, ) -from .gateway import MyHOMEGatewayHandler +from .gateway import _ZONE_WHERE, MyHOMEGatewayHandler from .myhome_device import MyHOMEEntity from .poll_health import PollHealth from .thermo_switch import load_thermo_switch_subentries @@ -95,6 +95,8 @@ PLATFORM = Platform.CLIMATE PARALLEL_UPDATES = 0 +_FOUR_ZONE_CENTRAL = ("#0#1", "#0#2", "#0#3", "#0#4") + # A dimension 12 frame and the frame that turns the zone OFF follow within ~0.1 s (#454, #383) _PROTECTION_FRAME_WINDOW = 2.0 _ZONE_CONTEXT_MODES = {"heating": HVACMode.HEAT, "cooling": HVACMode.COOL, "automatic": HVACMode.AUTO} @@ -238,17 +240,22 @@ def is_thermo_switch_ghost(entry: er.RegistryEntry, address: Address) -> bool: def build(ctx: DeviceContext) -> MyHOMEClimate: where, interface = ctx.address.where, ctx.address.interface + clean_where = where.removeprefix("4-") zone = zone_number(where) cfg = _zone_config(configured, ctx.address, ctx.key) or ctx.cfg suffix = f"{zone}I{interface}" if interface else zone - is_central = zone in ("0", "01") or where in ("#0", "#0#1") + is_central = clean_where in ("0", "#0", *_FOUR_ZONE_CENTRAL) if ctx.source != "bus": is_central = cfg.get(CONF_CENTRAL, is_central) default_name = f"Central Unit {suffix}" if is_central and ctx.source != "bus" else f"Climate Zone {suffix}" registry_name = getattr(ctx.registry_entry, "name", None) name = cfg.get(CONF_NAME) or (registry_name if isinstance(registry_name, str) else None) or default_name default_model = ( - "Central Unit (3550)" if where == "#0" else "Central Unit (4695)" if where == "#0#1" else "Heating Zone" + "Central Unit (3550)" + if clean_where == "#0" + else "Central Unit (4695)" + if clean_where in _FOUR_ZONE_CENTRAL + else "Heating Zone" ) entity = MyHOMEClimate( hass=hass, @@ -355,7 +362,7 @@ def duplicate_climate(entry: er.RegistryEntry, ctx: DeviceContext) -> bool: def known_keys(ctx: DeviceContext) -> list[str]: keys = [*default_known_keys(ctx), ctx.address.where, ctx.address.clean_where, ctx.config_id or ""] - if not ctx.address.interface: + if not ctx.address.interface and "#" not in ctx.address.clean_where.removeprefix("#"): keys.append(zone_number(ctx.address.where)) return [k for k in keys if k] @@ -375,7 +382,8 @@ def _zone_config(configured: dict[str, Any], address: Address, key: str) -> dict A routed zone only matches interface-qualified spellings: zone 1 exists on every bus, so the bare ones name the local bus's zone (#408). """ - where, zone = address.where, zone_number(address.where) + where, clean = address.where, address.clean_where + zone = zone_number(where) if "#" not in clean.removeprefix("#") else clean if address.interface: routing = f"{BUS_ROUTING}{address.interface}" return config_for(configured, address, key, f"4-{zone}{routing}", f"4-{key}", f"#{zone}{routing}") @@ -511,7 +519,7 @@ def __init__( ) -> None: if interface is None and BUS_ROUTING in where: base_where, _, inline_interface = where.rpartition(BUS_ROUTING) - if inline_interface.isdigit(): + if base_where and inline_interface.isdigit() and 1 <= len(inline_interface) <= 2: where = base_where interface = inline_interface super().__init__( @@ -534,7 +542,7 @@ def __init__( ) self._interface_poll_skip_logged = False - self._central = True if self._where in ("#0", "#0#1") else central + self._central = True if self._where.removeprefix("4-") in ("#0", *_FOUR_ZONE_CENTRAL) else central # None: not configured. A zone then follows the plant, see the standalone property. self._standalone_config = False if self._central else standalone if self._central: @@ -622,7 +630,7 @@ def __init__( ) ): self._poll_on_add = False - elif self._central and self._where not in ("#0", "0"): + elif self._central and self._where.removeprefix("4-") not in ("#0", "0"): self._poll_on_add = False @property @@ -828,8 +836,8 @@ async def async_restore_last_state(self, last_state: State | None) -> None: async def async_update(self) -> None: """Request status update from gateway, unless the zone has stopped answering.""" - if self._central and self._where not in ("#0", "0"): - # 4-zone central units (#0#1) do not participate in point-to-point status polling; + if self._central and self._where.removeprefix("4-") not in ("#0", "0"): + # 4-zone central units (#0#1..#0#4) do not participate in point-to-point status polling; # 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 @@ -852,13 +860,14 @@ async def async_update(self) -> None: or self._fan or wants_humidity ) and not self._interface_poll_skip_logged: + iface = self._interface or self._full_where.rpartition(BUS_ROUTING)[2] LOGGER.debug( "%s %s is behind interface %s and %s does not route WHO 4 requests across `#4#%s`; waiting for the zone to report", self._gateway_handler.log_id, self._display_name, - self._interface, + iface, self._gateway_handler.model, - self._interface, + iface, ) self._interface_poll_skip_logged = True return @@ -1101,7 +1110,7 @@ async def async_added_to_hass(self) -> None: # 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 zone_requests and self._where.removeprefix("4-").removeprefix("#").count("#") < 2: + if zone_requests and _ZONE_WHERE.match(self._full_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. A secondary # probe (WHERE >= 100) or 3-token split address (3#Z#N) is not a Dimension 20 target. @@ -1109,6 +1118,22 @@ async def async_added_to_hass(self) -> None: if callable(watch): self.async_on_remove(watch(self._full_where)) + def _refuse_interface_write(self) -> bool: + """Refuse WHO 4 writes that would strip ``#4#II`` and hit local main-bus ``_where``.""" + if not self._interface_poll_refused: + return False + iface = self._interface or self._full_where.rpartition(BUS_ROUTING)[2] + LOGGER.warning( + "%s %s is behind interface %s and %s does not route WHO 4 across `#4#%s`; not writing to local `%s`", + self._gateway_handler.log_id, + self._display_name, + iface, + self._gateway_handler.model, + iface, + self._where, + ) + return True + @callback def _handle_central_mode_update(self, master_mode: HVACMode) -> None: """Update subordinate zone mode when central unit changes seasonal mode.""" @@ -1135,6 +1160,8 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: } speed_code = fan_mode_map.get(str(fan_mode).lower()) if speed_code is not None: + if self._refuse_interface_write(): + return # OWNd raises for a central unit or general zone: the firmware forwards # *#4*Z*#11*S## only for a plain zone 1..99 (OWNd#77, A2). try: @@ -1178,6 +1205,8 @@ def target_temperature(self) -> float | None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" + if self._refuse_interface_write(): + return if self._central: mode_map = { HVACMode.OFF: "off", @@ -1269,6 +1298,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" + if self._refuse_interface_write(): + return target_temperature = float( kwargs.get("temperature", self._local_target_temperature) # type: ignore[arg-type] ) - self._local_offset diff --git a/custom_components/myhome/discovery.py b/custom_components/myhome/discovery.py index ffa01634..8d2476c4 100644 --- a/custom_components/myhome/discovery.py +++ b/custom_components/myhome/discovery.py @@ -66,7 +66,7 @@ def from_device_id(cls, device_id: str, key_suffix: str = "") -> Address: if key_suffix and device_id.endswith(key_suffix): device_id = device_id[: -len(key_suffix)] base, routing, interface = device_id.rpartition(BUS_ROUTING) - if routing and interface.isdigit(): + if routing and base and interface.isdigit() and 1 <= len(interface) <= 2: return cls(base, interface.zfill(2), key_suffix) return cls(device_id, None, key_suffix) @@ -85,7 +85,7 @@ def from_config(cls, dev_id: str, cfg: dict[str, Any]) -> Address: where = str(cfg.get(CONF_WHERE, cfg.get(CONF_ZONE, dev_id))) if not interface: base, routing, inline_iface = where.rpartition(BUS_ROUTING) - if routing and inline_iface.isdigit(): + if routing and base and inline_iface.isdigit() and 1 <= len(inline_iface) <= 2: return cls(base, inline_iface.zfill(2)) return cls(where, str(interface) if interface else None) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 9da1c294..45142dd5 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -291,10 +291,10 @@ def _resolved_model(word: str) -> str | None: return _alnum(profile.model_name) -# A WHO 4 zone address: ``Z``, ``#Z``, ``Z#N`` (actuator), each optionally behind an -# interface of exactly two digits (``Z#4#01``): the grammar of OWNd#140's builder +# A WHO 4 zone address: ``Z`` (0..99), ``#Z``, ``Z#N`` (actuator 0..9), each optionally behind +# an 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}})?$") +_ZONE_WHERE = re.compile(rf"^#?[0-9]{{1,2}}(?:#[0-9])?(?:{re.escape(BUS_ROUTING)}[0-9]{{2}})?$") def _validate_zone_where(where: str) -> str: @@ -306,7 +306,7 @@ def _validate_zone_where(where: str) -> str: 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) + zone, routing, interface = (where or "").rpartition(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 ""): diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 2d8a74bf..07f1e416 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -31,6 +31,7 @@ ) from .const import ( + BUS_ROUTING, CONF_LONG_PRESS, CONF_LONG_PRESS_REPEAT, CONF_LONG_RELEASE, @@ -43,6 +44,7 @@ DOMAIN, LOGGER, ) +from .where_grammar import where_param if TYPE_CHECKING: from .gateway import MyHOMEGatewayHandler @@ -424,8 +426,24 @@ async def process_message(self, message: Any) -> None: elif isinstance(message, OWNHeatingCommand) and message.dimension is not None and message.dimension == 14 and self._is_active_for_who(4): if not getattr(self.handler, "supports_zone_status", True): return + iface = getattr(message, "interface", None) + raw_tail = where_param(message) + tail = raw_tail if isinstance(raw_tail, list) else [] + has_interface = (isinstance(iface, str) and bool(iface)) or (len(tail) > 1 and tail[0] == "4") + if has_interface: + if getattr(self.handler, "interface_zone_request_refused", False): + return + iface_str: str | None = (iface if isinstance(iface, str) and iface else str(tail[1])).zfill(2) + base_tail = tail[:-2] if len(tail) >= 2 and tail[-2] == "4" else [] + else: + iface_str = None + base_tail = tail where_str = cast(str, message.where) where = where_str[1:] if where_str.startswith("#") else where_str + if where == "0" or base_tail: + return + if iface_str is not None: + where = f"{where}{BUS_ROUTING}{iface_str}" self._logger.debug( "%s Received heating command, sending query to zone %s", self.handler.log_id, diff --git a/custom_components/myhome/where_grammar.py b/custom_components/myhome/where_grammar.py index eaedeafd..4990d7c3 100644 --- a/custom_components/myhome/where_grammar.py +++ b/custom_components/myhome/where_grammar.py @@ -222,15 +222,19 @@ def is_actuator_where(where: str) -> bool: Covers ``Z#N`` / ``Z#0`` / ``0#N`` / ``0#0`` and 3-token ``3#Z#N`` / ``A#B#C`` addresses (with or without a trailing ``#4#II`` interface suffix). ``#0#1``..``#0#4`` is a 4-zone - central unit, not an actuator, and ``Z#4#II`` is a routed zone. Dimensionless ``*#4*Z#N##`` - (and 3-token ``A#B#C`` with ``1 <= C <= 9``) NACKs on nine gateways, cross-polls local - zone ``Z`` on H4684/MH200, and crashes ``coso`` with ``SIGSEGV`` on MH202 ``010024``. + central unit, not an actuator, and ``Z#4#II`` is a routed zone (note that a 1-digit + shorthand ``3#4#1``..``3#4#9`` is normalized as zone 3 behind interface ``01``..``09``; + wire frames always write two-digit ``#4#01``..``#4#15``). Dimensionless ``*#4*Z#N##`` + NACKs on nine gateways, cross-polls local zone ``Z`` on H4684/MH200, and crashes ``coso`` + with ``SIGSEGV`` on MH202 ``010024`` (where ``libthermo.so`` ``device_recognition`` also + classifies non-``#`` 3-token ``A#B#C`` with ``1 <= B <= 99`` and ``1 <= C <= 9`` as local + actuator ``B#C``). """ clean = (where or "").strip().removeprefix("4-") base, routing, iface = clean.rpartition("#4#") - if routing and iface.isdigit(): + if routing and iface.isdigit() and 1 <= len(iface) <= 2: clean = base - if clean.startswith("#0#"): + if clean in ("#0#1", "#0#2", "#0#3", "#0#4"): return False parts = clean.removeprefix("#").split("#") if len(parts) in (2, 3): diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index 480986e9..c8190cea 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -59,7 +59,7 @@ MyHOME climate entities support standard Home Assistant climate controls: On SCS-only gateways whose firmware drops the general request `*#4*0##` (F450, F454, F459, H4684, MH200, MH201, MH202), each local zone asks for its own temperature with `*#4**0##` after the startup sweep, so a standalone thermostat is not `unknown` until it next reports (#185). F460, F461 and MyHomeServer1 drop `*#4*0##` as well but answer the zone status request `*#4*##` themselves (the F460 and F461 expand it into the four dimension requests, the MyHomeServer1 replies from its cache), so their local zones keep that poll. A zone behind an F422 bus interface (`WHERE = #4#`) is never polled unless the OWNd profile sets `supports_interface_zone_requests = True` (see below), so it shows its restored reading until it reports by itself. After a restart the last reading is shown with the attribute `current_temperature_restored: true` until the zone reports again. * **Current Humidity**: Ambient relative humidity percentage for zones equipped with humidity sensors (e.g. Living Now thermostats or humidity probes). * **HVAC Action and `actuators`**: a local zone asks for the state of its actuators (`*#4**20##`, one `*#4*#0*20##` for every local zone on an F461 or a MyHomeServer1) once the startup sweep is over and again after the event session reconnects, so `hvac_action` (`heating` / `cooling` / `idle`) is known right after start instead of after the first actuator switch. The attribute `actuators` lists each actuator as `on` or `off` (`{"1": "off", "2": "off"}`) and stays while the zone is off (every actuator `off`). F453, F453AV, H4684, MH200, MH200N and MH202 drop the request (firmware oracle), so their zones are not asked and wait for the actuators' own frames; on a shared bus the gateway that serves WHO 4 (delegated secondary or warm standby) decides. Secondary probes (`WHERE >= 100`) are never asked. Probe-less `F430/2` / `F430/4` relays (such as bathroom towel warmers that only emit Dimension 20 actuator status frames without a temperature probe) do not create phantom `climate` entities and can be controlled as `switch` entities via **Add thermo actuator / towel-warmer switch (WHO 4)** (see [Switches & Relays](switches.md)). -* **Zones Behind an `#4#II` Interface (`F422`)**: across all twelve emulated gateway firmwares in `own-firmware-oracle` (`findings/thermo-zone-requests.md`), **zero** gateways emit `$06EC`/`$06E4` interface-routed SCS telegrams for `WHO = 4`: gateways either NACK/drop `#4#II` queries, truncate `Z#4#II` at `#` and cross-poll local zone `Z` on the main bus (`F450`, `F454`, `F459`, `F460`, `F461`, `H4684`, `MH200`, `MyHomeServer1`), or on `MH202` (`1.0.24`) misparse trailing `#4#01` as local zone 4 actuator 1 and crash `coso` (`SIGSEGV`) on `*#4*Z#4#01##`. Unless an `OWNd` gateway profile explicitly declares `supports_interface_zone_requests = True`, zones configured with an `#4#II` interface are never polled (`*#4*Z#4#II##`, `*0##`, `*11##`, `*19##`, `*20##`, `*60##`) and rely safely on incoming event-session broadcasts. Dimensionless and zone-dimension polls on actuator or split addresses (`Z#N`, `Z#0`, `0#N`, `3#Z#N`) are likewise never sent. +* **Zones Behind an `#4#II` Interface (`F422`)**: across all twelve emulated gateway firmwares in `own-firmware-oracle` (`findings/thermo-zone-requests.md`), **zero** gateways emit `$06EC`/`$06E4` interface-routed SCS telegrams for `WHO = 4`: gateways either NACK/drop `#4#II` queries, truncate `Z#4#II` at `#` and cross-poll local zone `Z` on the main bus (`F450`, `F454`, `F459`, `F460`, `F461`, `H4684`, `MH200`, `MyHomeServer1`), or on `MH202` (`1.0.24`) misparse trailing `#4#01` as local zone 4 actuator 1 and crash `coso` (`SIGSEGV`) on `*#4*Z#4#01##`. Unless an `OWNd` gateway profile explicitly declares `supports_interface_zone_requests = True`, zones configured with an `#4#II` interface are passive: neither polls (`*#4*Z#4#II##`, `*0##`, `*11##`, `*19##`, `*20##`, `*60##`) nor writes that would strip `#4#II` onto local main-bus zone `Z` are sent, while incoming event-session broadcasts update state normally (note that 1-digit inline shorthand `3#4#1`..`3#4#9` is normalized as zone `3` behind interface `01`..`09`; wire frames always use two-digit `#4#01`..`#4#15`). Dimensionless and zone-dimension polls on actuator or split addresses (`Z#N`, `Z#0`, `0#N`, `3#Z#N`) are likewise never sent. ### 💧 Humidity Support & Startup Behavior diff --git a/tests/test_climate_actuator_status.py b/tests/test_climate_actuator_status.py index a64e342b..19679a8e 100644 --- a/tests/test_climate_actuator_status.py +++ b/tests/test_climate_actuator_status.py @@ -541,7 +541,13 @@ 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", "3#4#001"]) +@pytest.mark.parametrize( + "where", + [ + "", "2*22", "2 ", "a", "*#0", "2#1*20", "#", "2##", "##2", "2#", + "2#4#", "2#1#2#3", "3#4#001", "105", "001", "2#10", "#4#1", "#4#01", + ], +) 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") @@ -551,7 +557,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", "0#0"]) +@pytest.mark.parametrize("where", ["2", "#2", "01", "3#4#01", "#3#4#01", "2#1#4#01", "2#4#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) @@ -559,12 +565,13 @@ def test_a_plain_or_routed_zone_address_is_accepted(hass, where): async def test_a_one_digit_interface_is_normalized_to_two_digits_and_gated(hass, fast_bus_pacing): - """``3#4#1`` is normalized to ``3#4#01``, withheld by default, and sent only when the profile opts in.""" + """``3#4#1`` and ``2#4#4#1`` normalize trailing ``#4#1`` to ``#4#01``, withheld by default, and sent only when the profile opts in.""" 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)} + unwatch_act = handler.watch_zone_actuators("2#4#4#1") + assert handler._actuator_zones == {"3#4#01": 1, "2#4#4#01": 1} + assert handler._zone_requests == {("3#4#01", 20), ("2#4#4#01", 20)} await _settle(hass) assert _drain(handler) == [] @@ -572,9 +579,11 @@ async def test_a_one_digit_interface_is_normalized_to_two_digits_and_gated(hass, model_name=handler.gateway.profile.model_name, supports_interface_zone_requests=True ) handler.request_zone_dimension("3#4#1", 20) + handler.request_zone_dimension("2#4#4#1", 20) await _settle(hass) - assert _drain(handler) == ["*#4*3#4#01*20##"] + assert _drain(handler) == ["*#4*2#4#4#01*20##", "*#4*3#4#01*20##"] unwatch() + unwatch_act() assert handler._actuator_zones == {} @@ -1178,18 +1187,29 @@ def test_is_actuator_where_distinguishes_actuators_from_central_and_routed(): assert is_actuator_where("4-2#0") is True assert is_actuator_where("0#0") is True assert is_actuator_where("0#1") is True + assert is_actuator_where("#0#0") is True + assert is_actuator_where("#0#9") is True assert is_actuator_where("2#1#4#01") is True assert is_actuator_where("2#4#4#01") is True + assert is_actuator_where("2#4#4#1") is True assert is_actuator_where("3#2#1") is True assert is_actuator_where("3#2#1#4#01") is True assert is_actuator_where("#0") is False assert is_actuator_where("#0#1") is False + assert is_actuator_where("#0#2") is False + assert is_actuator_where("#0#3") is False assert is_actuator_where("4-#0#4") is False + assert is_actuator_where("#0#99") is False assert is_actuator_where("#0#4#01") is False assert is_actuator_where("#0#1#4#01") is False assert is_actuator_where("#4#01") is False assert is_actuator_where("2") is False + assert is_actuator_where("2#10") is False assert is_actuator_where("2#4#01") is False + assert is_actuator_where("2#4#100") is False + assert is_actuator_where("3#4#1") is False + assert is_actuator_where("") is False + assert is_actuator_where("4-") is False @pytest.mark.parametrize("where", ["2#1", "3#2#1", "2#1#4#01"]) @@ -1242,12 +1262,13 @@ async def test_actuator_where_never_sends_status_or_zone_dimension_polls(hass, f assert sent == expected -async def test_inline_routed_where_and_routed_central_withhold_polls(hass, fast_bus_pacing, caplog): - """Inline ``where='2#4#01'`` (interface=None) and routed central ``#0#4#01`` both withhold WHO 4 polls.""" +async def test_inline_routed_where_and_routed_central_withhold_polls_and_writes(hass, fast_bus_pacing, caplog): + """Inline ``where='2#4#01'`` (interface=None) and routed central ``#0#4#01`` withhold WHO 4 polls and local-bus writes.""" for model in ("Generic", "F461", "F459", "H4684", "MH202"): handler = _handler(hass, model) handler._initial_discovery_done.set() handler.send_status_request = AsyncMock() + handler.send = AsyncMock() inline_zone = MyHOMEClimate( hass=hass, @@ -1271,8 +1292,6 @@ async def test_inline_routed_where_and_routed_central_withhold_polls(hass, fast_ assert (inline_zone._where, inline_zone._interface, inline_zone._full_where) == ("2", "01", "2#4#01") assert inline_zone._interface_poll_refused is True assert inline_zone._poll_on_add is False - await inline_zone.async_added_to_hass() - await inline_zone.async_update() routed_central = MyHOMEClimate( hass=hass, @@ -1299,12 +1318,184 @@ async def test_inline_routed_where_and_routed_central_withhold_polls(hass, fast_ assert routed_central._central is True assert routed_central._interface_poll_refused is True assert routed_central._poll_on_add is False + caplog.clear() with caplog.at_level("DEBUG", logger="custom_components.myhome"): + await inline_zone.async_added_to_hass() + await inline_zone.async_update() + await inline_zone.async_update() await routed_central.async_added_to_hass() await routed_central.async_update() await routed_central.async_update() + await inline_zone.async_set_temperature(temperature=21.0) + await inline_zone.async_set_hvac_mode(HVACMode.OFF) + await inline_zone.async_set_fan_mode("high") + await routed_central.async_set_hvac_mode(HVACMode.OFF) + await routed_central.async_set_temperature(temperature=20.0) await _settle(hass) handler.send_status_request.assert_not_awaited() + handler.send.assert_not_awaited() assert _drain(handler) == [] - assert len([r for r in caplog.records if "does not route WHO 4 requests across `#4#01`" in r.message]) == 1 + assert len([r for r in caplog.records if "does not route WHO 4 requests across `#4#01`" in r.message]) == 2 + assert len([r for r in caplog.records if "not writing to local" in r.message and r.levelname == "WARNING"]) == 5 + + +@pytest.mark.parametrize("where", ["#0#1", "#0#2", "#0#3", "#0#4", "4-#0#4"]) +async def test_four_zone_central_channels_1_to_4_are_central_and_never_polled(hass, fast_bus_pacing, where): + """4-zone central unit channels (#0#1..#0#4) are central even when restored without central=True.""" + for model in ("Generic", "F461", "F454", "MH202"): + handler = _handler(hass, model) + handler._initial_discovery_done.set() + handler.send_status_request = AsyncMock() + cu = MyHOMEClimate( + hass=hass, + name=f"Central {where}", + device_id=f"4-{where.removeprefix('4-')}", + who="4", + where=where, + heating=True, + cooling=True, + fan=False, + humidity=False, + standalone=None, + central=False, + manufacturer="BTicino", + model="Heating Zone", + gateway=handler, + ) + cu.hass = hass + cu.entity_id = "climate.cu_4zone" + cu.async_write_ha_state = MagicMock() + assert cu._central is True + assert cu._poll_on_add is False + await cu.async_added_to_hass() + await cu.async_update() + await _settle(hass) + handler.send_status_request.assert_not_awaited() + assert handler._actuator_zones == {} + + +async def test_four_zone_central_configured_channels_use_4695_model_and_skip_polls(hass, fast_bus_pacing): + """Configuring #0#2..#0#4 sets Central Unit (4695), default Central Unit name, and _central=True via climate.build().""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + from custom_components.myhome.climate import async_setup_entry + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.0.2.44", + CONF_PORT: 20000, + CONF_PASSWORD: "x", + CONF_NAME: "F461", + CONF_FIRMWARE: "2.0.11", + CONF_MAC: MAC, + }, + ) + entry.add_to_hass(hass) + handler = MyHOMEGatewayHandler(hass, entry) + handler._initial_discovery_done.set() + handler.send_status_request = AsyncMock() + runtime = attach_runtime(hass, entry, MAC, handler) + runtime.platforms["climate"] = { + "central_2": {CONF_WHERE: "#0#2"}, + "central_3": {CONF_WHERE: "#0#3"}, + "central_4": {CONF_WHERE: "#0#4"}, + "local_02": {CONF_WHERE: "02"}, + } + + added: list[MyHOMEClimate] = [] + await async_setup_entry(hass, entry, lambda entities: added.extend(entities)) + assert len(added) == 4 + by_where = {entity._where: entity for entity in added} + for ch in ("#0#2", "#0#3", "#0#4"): + ent = by_where[ch] + assert ent._central is True + assert ent._model == "Central Unit (4695)" + assert ent._device_name.startswith("Central Unit ") + assert ent._poll_on_add is False + ent.hass = hass + ent.entity_id = f"climate.central_{ch[-1]}" + ent.async_write_ha_state = MagicMock() + await ent.async_added_to_hass() + await ent.async_update() + # Zero-padded local zone "02" (zone_number == "02") must stay a regular Heating Zone, not central. + assert by_where["02"]._central is False + assert by_where["02"]._model == "Heating Zone" + await _settle(hass) + handler.send_status_request.assert_not_awaited() + + +@pytest.mark.parametrize("where", ["2#4#100", "3#4#001", "#4#01"]) +async def test_malformed_inline_routing_does_not_crash_added_to_hass_or_poll(hass, fast_bus_pacing, caplog, where): + """3-digit or bare #4# strings do not split into an invalid _interface that crashes watch_zone_actuators.""" + handler = _handler(hass, "F461") + handler._initial_discovery_done.set() + handler.send_status_request = AsyncMock() + zone = MyHOMEClimate( + hass=hass, + name=f"Zone {where}", + device_id=f"4-{where}", + who="4", + where=where, + 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_malformed" + zone.async_write_ha_state = MagicMock() + assert zone._interface is None + assert zone._interface_poll_refused is True + assert zone._poll_on_add is False + with caplog.at_level("DEBUG", logger="custom_components.myhome"): + await zone.async_added_to_hass() + await zone.async_update() + await _settle(hass) + handler.send_status_request.assert_not_awaited() + assert handler._actuator_zones == {} + + +async def test_gateway_events_dim14_echo_guards_routed_central_and_actuator_addresses(hass): + """Event-session Dimension 14 echoes do not cross-poll local zone Z on #4#II or send *#4*0## on #0.""" + from OWNd.message import OWNCommand + + handler = _handler(hass, "F461") + handler.send_status_request = AsyncMock() + dispatcher = handler._event_dispatcher + + for blocked in ( + "*#4*2#4#01*#14*0210*3##", + "*#4*#0*#14*0200*1##", + "*#4*#0#1*#14*0200*1##", + "*#4*#2#1*#14*0210*3##", + ): + msg = OWNCommand.parse(blocked) + assert msg is not None + await dispatcher.process_message(msg) + handler.send_status_request.assert_not_awaited() + + local_msg = OWNCommand.parse("*#4*#3*#14*0210*3##") + assert local_msg is not None + await dispatcher.process_message(local_msg) + assert [str(c.args[0]) for c in handler.send_status_request.await_args_list] == ["*#4*3##"] + + handler.send_status_request.reset_mock() + handler.gateway.profile = SimpleNamespace( + model_name="F461", supports_interface_zone_requests=True + ) + routed_actuator_msg = OWNCommand.parse("*#4*2#1#4#01*#14*0210*3##") + assert routed_actuator_msg is not None + await dispatcher.process_message(routed_actuator_msg) + handler.send_status_request.assert_not_awaited() + + routed_msg = OWNCommand.parse("*#4*2#4#1*#14*0210*3##") + assert routed_msg is not None + await dispatcher.process_message(routed_msg) + assert [str(c.args[0]) for c in handler.send_status_request.await_args_list] == ["*#4*2#4#01##"] diff --git a/tests/test_platform_discovery.py b/tests/test_platform_discovery.py index f02c6406..17476c6d 100644 --- a/tests/test_platform_discovery.py +++ b/tests/test_platform_discovery.py @@ -38,8 +38,11 @@ def test_address_forms_follow_openwebnet_where_conventions(): zone = Address.from_device_id("3#16", key_suffix="#16") assert (zone.where, zone.key) == ("3", "3#16") assert Address.from_device_id("2#4#4#01") == Address("2#4", "01") + assert Address.from_device_id("#4#01") == Address("#4#01", None) + assert Address.from_device_id("2#4#100") == Address("2#4#100", None) assert Address.from_config("kitchen", {"where": "12", "bus_interface": "02"}).key == "12#4#02" assert Address.from_config("upstairs", {"zone": "2#4#1"}) == Address("2", "01") + assert Address.from_config("malformed", {"zone": "2#4#100"}) == Address("2#4#100", None) assert Address.from_config("12", {}).where == "12" # the yaml key is the WHERE when omitted assert Address.from_message(MagicMock(where="", interface=None)) is None assert Address.from_message(OWNEvent.parse("*1*1*0311#4#01##")) == Address("0311", "01") diff --git a/tests/test_startup_requests_per_firmware.py b/tests/test_startup_requests_per_firmware.py index 0b98b754..e9c8d8d5 100644 --- a/tests/test_startup_requests_per_firmware.py +++ b/tests/test_startup_requests_per_firmware.py @@ -239,10 +239,11 @@ def skipped() -> list: assert len(skipped()) == 1 # once per entity -async def test_withheld_interface_poll_also_withholds_humidity_and_fan_cross_polls(hass, caplog): +@pytest.mark.parametrize("model", ["F459", "F453", "F453AV", "MH200N"]) +async def test_withheld_interface_poll_also_withholds_humidity_and_fan_cross_polls(hass, caplog, model): """On F459, *#4*3#4#01*60## truncates at # and cross-polls local main-bus zone 3 ($06D102030308000000); - all WHO 4 polls on a routed zone are withheld and logged once.""" - handler = _handler(hass, "F459") + on F453/F453AV/MH200N, fan=True would otherwise poll *11##; all WHO 4 polls on a routed zone are withheld and logged once.""" + handler = _handler(hass, model) handler.send_status_request = AsyncMock() zone = _zone(hass, handler, interface="01", humidity=True, fan=True) assert zone._polls_temperature is False and zone._interface_poll_refused is True