Skip to content

feat(climate): request actuator status (dimension 20) on add and after reconnect, expose actuators, derive hvac_action - #792

Merged
GreenGrassBlueOcean merged 16 commits into
OpenWebNet-HA:v2-phase1-architecturefrom
gdluck:feat/climate-actuator-status
Oct 25, 2026
Merged

GreenGrassBlueOcean merged 16 commits into
OpenWebNet-HA:v2-phase1-architecturefrom
gdluck:feat/climate-actuator-status

Conversation

@gdluck

@gdluck gdluck commented Oct 24, 2026 •

Copy link
Copy Markdown

Requested by A

What is the problem

A zone's hvac_action stayed unknown after start until one of its actuators happened to switch, because nothing ever asked for the actuator status (WHO 4 dimension 20): the zone poll *#4*Z## does not include it (on the live F461 it answers dimensions 0, 12, 14, 13 and the season only). The entity sat at hvac_action: None until *#4*2*20## was sent by hand through myhome.send_message, after which it switched to idle at once. This PR asks for the actuators once the startup sweep is over and again after every event-session reconnect, exposes the per-actuator state as the actuators attribute (#25), and so derives hvac_action right after start (#44).

Refs #25, #44, #785. #785 (MyHomeServer1, F430/2 relays on zones 7/8 without a probe discovered as climate entities the user cannot switch) is not solved here: the maintainers asked the reporter for a Home Touch trace first, but dimension 20 is exactly the state that reveals such probe-less actuator zones, and that follow-up needs the actuator state this PR provides.

The per-gateway refusal table is backed by the published thermo-actuator rows for *#4*1*20## and *#4*#0*20##; the per-zone confirmation for the other zone address forms is the thermo-zone-requests finding of OpenWebNet-HA/own-firmware-oracle#60, merged on main (52d354b) as findings/thermo-zone-requests.md + oracle/cases/thermo-zone-requests.cases (the per-gateway results/<P>/<V>/oracle/full/thermo-zone-requests.tsv rows follow the next oracle CI run on main).


What is the solution

  1. gateway.py: watch_zone_actuators(where) returns the callback that stops watching. It queues a dimension 20 request through the new request_zone_dimension(where, dimension) and the _send_zone_requests() background task, which waits for the startup sweep (wait_for_initial_discovery) and sends through the existing send_paced. Zones added in the same pass share one run. After an event-session reconnect (once the sweep is done) every watched zone is asked again; close_listener cancels the task and drops the queue.
  2. One request for every local zone on the F461 and the MyHomeServer1 (ALL_ZONE_REQUESTS: *#4*#0*20##); zones behind an interface (Z + BUS_ROUTING + if) are asked one by one. Elsewhere each zone sends *#4*Z*20##. Once the startup sweep is over, every batch waits ZONE_REQUEST_COALESCE (0.1 s) for the zones discovered or registered right after it, so dynamic discovery does not start one all-zone sweep per zone.
  3. actuator_request_refused reads getattr(profile, "supports_heating_actuator_status", None) first (True / False wins) and falls back to ACTUATOR_REQUEST_REFUSED (F453, F453AV, H4684, MH200, MH200N, MH202) when the flag is None or absent, the MyHOME fix(gateway): close idle command sessions before the gateway drops them #381 pattern. The flag belongs in OWNd's profiles; no OWNd issue exists for it yet, and a follow-up OWNd profiles PR should carry it once feat(heating): OWNHeatingCommand.actuators_status(where) builder for *#4*WHERE*20## OWNd#140 lands (feat(oracle): add thermo-zone-requests suite (per-zone polls, actuators, humidity, interface form) own-firmware-oracle#60 is merged). The decision is taken when the batch is sent, inside _send_zone_requests() after wait_for_initial_discovery() (so the WHO 13 / WHO 1013 identity has settled), and on the gateway that serves WHO 4 (_who4_target(): the delegated secondary or the warm standby, resolved with the same _delegated_target / _failover_target that send_status_request uses, which send_paced sends through); watch_zone_actuators always ref-counts the zone. _model_key uses isinstance(profile, GenericGatewayProfile) like command_session_limit and looks a known model up as a substring of the configured name (BTicino F461 -> F461, Legrand MH200N -> MH200N). Refused gateways are not asked and wait for the actuators' own frames (DEBUG).
  4. request_zone_dimension(..., 22) raises ValueError: *#4*Z*22## kills the F461 thermostat handler and reboots the gateway (upstream fixture f461_live_2026-10-15, probe_dim22_confirm). An address outside the zone grammar Z, #Z, Z#N, each optionally behind a two-digit interface (Z#4#01; a one-digit interface is written with two, 3#4#1 -> 3#4#01, the spelling of the bus frames, the configuration validator and feat(heating): OWNHeatingCommand.actuators_status(where) builder for *#4*WHERE*20## OWNd#140's builder) is refused right away too (empty, 2*22, which would build the dimension 22 write *#4*2*22*20##, 2##, #); watch_zone_actuators validates before counting the zone, and the per-zone frame builder validates again inside _send_zone_requests(), where a ValueError is logged and the other zones of the batch are still sent.
  5. climate.py: each non-central zone that is not a secondary probe (WHERE >= 100, push-only) calls watch_zone_actuators in async_added_to_hass and unwatches on remove. Dimension 20 fills the per-actuator _actuator_states (dict[str, bool], exposed as extra_state_attributes["actuators"], on / off); dimension 19 sets the zone-level _zone_active (bool | None) and never appears in actuators. Whichever arrived last drives hvac_action (a dimension 19 report with both sides off also marks the known actuators off); OFF sets every known actuator to off instead of clearing, so the attribute stays through an OFF / HEAT round trip. When a dimension 20 reply first reveals a fan coil (value 5..8) and dimension 19 has not been seen, the zone queues one *#4*Z*19## through request_zone_dimension and does not poll the fan speed (*#4*Z*11##, a 2.1 s NACK in the valve's season) while the answer is pending (FAN_COIL_SIDES_WAIT, 30 s, counted from the moment the request can leave the gateway, not while the startup sweep holds it back; an answer that never comes does not disable the poll for good). On add, a configured fan coil asks before super().async_added_to_hass() polls and a restored fan_mode without a side asks at the end of async_restore_last_state(), so the poll on add never sends *#4*Z*11## blindly. Because the entity is not polled afterwards (should_poll is False), the dimension 19 reply that resolves the side in the fan coil's season queues *#4*Z*11## itself (dimension 20 value 5, fan off, carries no speed, and fan_mode would otherwise keep the synthetic auto); so does a fan coil showing up once the side is already known, and so does the season frame (*4*0*Z## / *4*1*Z##) when it moves a zone with known sides from the valve's season to the fan coil's at runtime (only a change of side counts: the frame repeats with every poll, and OWNd's conditioning is a restored state's cooling).
  6. Temporary raw frame strings: ALL_ZONE_REQUESTS and _zone_request_frame(where, dimension) are built as strings and parsed with the existing OWNCommand.parse in the paced sender, because the pinned OWNd has no builder. feat(heating): OWNHeatingCommand.actuators_status(where) builder for *#4*WHERE*20## OWNd#140 adds OWNHeatingCommand.actuators_status(where); the strings are to be replaced by that builder once it is released on PyPI (the per-model knowledge then also moves to OWNd). feat(heating): OWNHeatingCommand.actuators_status(where) builder for *#4*WHERE*20## OWNd#140's _WHO4_WHERE_REGEX is being extended to the #4# routed forms (3#4#01, #3#4#01, 2#1#4#01) in a companion commit, so the builder accepts every address this PR sends.
  7. Docs (docs/configuration/climate.md) and the b16 release-notes delta.

Review follow-up (review 5639409551)

One commit per finding, each with cases that fail before and pass after it:

Finding Commit What changed
1. Dimension 11 NACKs on mixed valve + fan-coil zones 394a5bef, 3cc1f226 (coverage case), 841132d3 (pending request expires) one-shot *#4*Z*19## when dimension 20 first reveals a fan coil (and on add for a restored fan_mode without a side); no *#4*Z*11## while it is pending; after 30 s without an answer the zone polls as before
2. _actuator_states mixed dimension 19 and 20 a24ad505 dimension 20 dict (actuators) kept apart from the dimension 19 zone bool; last source wins; OFF keeps the attribute with every actuator off; no valve key
3. Companion contract and where validation 90c50398, 6b254b66 (zone grammar, validate before counting) invalid where refused in request_zone_dimension and watch_zone_actuators; per-zone frame builder inside the guarded loop, one bad zone is logged and the batch goes on; OpenWebNet-HA/OWNd#140 regex extended to #4# routed forms (companion commit)
4. Refusal checked before the identity settled b0edd03e watch_zone_actuators always ref-counts; actuator_request_refused evaluated in _send_zone_requests after wait_for_initial_discovery (startup and reconnect)
5. Profile class, WHO 4 target, probes, MyHomeServer1 6f267d05 _model_key by isinstance(GenericGatewayProfile) + substring lookup; refusal / all-zone tables read on the WHO 4 target gateway (delegated or standby); no watch for WHERE >= 100; MYHOMESERVER1 in ALL_ZONE_REQUESTS (myhomeserver1_live_2026-10-24: 0.39 s, 43 actuator frames; emulated 028206 forwards it)
6. BUS_ROUTING, coalescing, oracle cases 865bade6, 6b254b66 (every batch) BUS_ROUTING from const.py; ZONE_REQUEST_COALESCE 0.1 s once the sweep is over, on every batch. The *#4*Z#0*20## / *#4*0#0*20## cases belong to OpenWebNet-HA/own-firmware-oracle#60 (merged) and are not part of this repository

An independent second review of the reworked diff (findings 1-6 as context) confirmed the six points as addressed and raised the three follow-ups folded into 841132d3 and 6b254b66; its remaining remarks (a per-actuator dimension 20 report overriding a dimension 19 "zone active" when the dict is incomplete, an actuator report after OFF showing heating, the default actuator id 1) are the pre-existing semantics (the all-zone request fills the dict at start; OFF with a physically open actuator is real; OWNd itself names a dimension 20 frame without parameter actuator 1) and are left as they are.

Draft #793 carries the same _model_key helper and receives the same change separately.

Review follow-up (review 5639881123)

One commit per item, cases that fail before and pass after it:

Item Commit What changed
1. _ask_fan_coil_sides() after the poll on add; the 30 s wait during the sweep; no *#4*Z*11## once dimension 19 answers 7f2c34ac the side is asked before super().async_added_to_hass() (configured fan coil) and at the end of async_restore_last_state() (restored fan_mode without fan_coil_*), zones only; a request queued while initial_discovery_pending / sweep_active holds the zone requests back is marked held and its wait starts at the first check after the hold; a dimension 19 reply that resolves the side with self._fan and self._season_has_fan_coil() queues request_zone_dimension(where, 11), as does a fan coil revealed (dimension 20) once the side is already known. test_component_climate.py::test_climate_fan_mode_and_attributes asserted the blind *#4*5*11## on add and now expects the dimension 19 request
2. Three tests race the 0.14 s wait_for_initial_discovery() timeout 9dba0566 BUS_QUIET_CAP = 5.0 in test_a_gateway_identified_late_as_supported_is_asked, test_a_gateway_identified_late_as_refusing_is_not_asked and test_the_startup_run_does_not_wait_the_coalesce_window, as in test_actuators_are_asked_after_the_startup_sweep
3. #4# interface digits differ from OpenWebNet-HA/OWNd#140 6670a359 _validate_zone_where returns the address with a two-digit interface (3#4#1 -> 3#4#01), request_zone_dimension / watch_zone_actuators store that form (one watch count per zone), _ZONE_WHERE tightened to {2}; _calling_zones and the entity's _full_where spell the interface the same way, so OWNHeatingCommand.actuators_status is a drop-in swap. The test that accepted 3#4#1 raw now expects 3#4#01 and *#4*3#4#01*20##; 3#4#001 is refused
4. send_paced([]) on refusing gateways 0e44bcd7 if not frames: continue before send_paced; an MH202 watching a zone never awaits send_paced and _sweep_idle stays set

An independent second review of these four commits (the review as context) agreed with items 2, 3 and 4 and raised two gaps in item 1 that are folded into 7f2c34ac: the held request's stamp was only refreshed when something checked the entity during the hold (a 45 s sweep with no poll meanwhile would still have expired the wait at the first check after it; the held flag fixes that), and a dimension 19 that arrives before the fan coil's dimension 20 left fan_mode on auto for good (the side was known, so nothing asked dimension 11 when the fan showed up; the fan coil revealed after its side now asks).

Review follow-up (review 5642758778)

One commit per item, on top of the rebase onto v2-phase1-architecture 089fef51:

  1. Runtime seasonal transition (9352638f): the season handler (MESSAGE_TYPE_SEASON, and the MESSAGE_TYPE_MODE fallback of older OWNd releases) queues the one-shot request_zone_dimension(where, 11) when a zone with known sides moves from the valve's season to the fan coil's, the way the dimension 19 / 20 paths do; only a change of side counts (the frame repeats with every poll; conditioning and a restored cooling are one side), never for the first season after add (the poll on add asked the speed when the sides were known, dimension 19 asks it once they become known), nothing with unknown sides, nothing on a transition to the valve's season. Seven cases: both directions, the repeated frames, the valve's season, unknown sides, two valves, restored sides with and without a season, the older-OWNd mode fallback; four fail before the change.
  2. Trace replays through MyHOMEClimate (4a41da61): tests/test_climate_actuator_status.py now loads the committed archives of test(trace): ingest live F461 2.0.11 2026-10-23 capture and MyHomeServer1 3.87.13 reads behind OWNd #136-#142 and MyHOME #791-#798 #799 and runs them through the climate platform (async_setup_entry, bus discovery on, frames delivered on myhome_message_<mac> as the gateway handler does). f461_live_2026-10-23 / run6_event_session_ha_fixed_integration: all 171 event-session frames; hvac_action unknown through the five frames of the first *#4*2## reply, idle with actuators {"1": "off"} on *#4*2#1*20*0## and {"1": "off", "2": "off"} on *#4*2#2*20*5##, unchanged through the six reconnect sweeps (seven polls; the fan frame repeated once and reversed once; the hashed *#4*2#1*#20*0## echo parses as a command and is dropped), 24.1 / 23.5 °C, heating, humidity 38, one *#4*2*19##, no *#4*2*11##. myhomeserver1_live_2026-10-24 / run2_reads_thermo_sound_alarm_lights: the 43 actuator frames after *#4*#0*20## on the 34 configured zones 35-70; every zone idle with its actuators from the Z#1 / Z#2 frames, the five fan coils (47, 48, 57, 60, 70) ask dimension 19 once each in the plant's order, and the zone-0 pump frames 0#1..0#4 discover no zone, are watched by nothing and are asked for nothing. Both fail on v2-phase1-architecture.
  3. The OWNd-master GenericGatewayProfile.command_session_idle_timeout heads-up (tests/test_gateway.py::test_issue_254_mh201_idle_disconnect_and_reconnection_e2e) is for the OWNd pin bump, not this PR: this branch keeps the 2.0.0b11 behaviour and the suite below runs against a plain OWNd master checkout as well.
  4. The 2026-10-25 actuator-switching capture (*#4*2#1*20*1## on the 23.5 → 26.0 °C setpoint step, #issuecomment-6395632510) is ingested as its own trace PR, test(trace): ingest the F461 and MyHomeServer1 captures of 2026-10-25 (actuator status, fan speed and humidity per zone) #815, together with a status-reads-only MyHomeServer1 3.87.13 capture of the same day (36 zones: the *#4*#0*20## burst, dimension 19 / 20 / 11 / 60 per zone, a 600 s listen with the house's spontaneous valve reports).

Section 3 of the review (emulating the plant in own-firmware-oracle: *#4*#0*20## / Z#0 dispatch, season-gated dimension 11, closed-loop actuator switching) is a separate oracle follow-up and is not part of this PR.


Companion PR: OpenWebNet-HA/OWNd#140 (draft): OWNHeatingCommand.actuators_status(where) builder for *#4*WHERE*20##. This PR does not depend on it (strings until the release), and the builder replaces the strings afterwards.


State Diagram

stateDiagram-v2
    direction TB

    state "1. Before: nobody asks for dimension 20" as Before {
        [*] --> ActionUnknown : zone added, *#4*2## polled\n, dimensions 0, 12, 14, 13 and the season only
        ActionUnknown --> ActionKnown : an actuator switches by itself\n*#4*2#1*20*1##
    }

    state "2. After: gateway.watch_zone_actuators(where)" as After {
        [*] --> Queued : zone ref-counted, where 20 queued\nnot for a central unit or a secondary probe WHERE 100 and up
        Queued --> Sending : wait_for_initial_discovery\nplus a 0.1 s coalesce window once the sweep is over
        Sending --> Refused : the WHO 4 target gateway, delegated or standby, refuses dimension 20\nprofile flag False or model in ACTUATOR_REQUEST_REFUSED\nnothing sent, wait for the actuators own frames
        Sending --> Asked : F461 and MyHomeServer1 local zones *#4*#0*20##\nelsewhere or behind an interface *#4*Z*20##\nan invalid zone address is logged and the batch goes on
        Asked --> ActuatorsKnown : *#4*2#1*20*0##, *#4*2#2*20*5##\nper actuator dict drives hvac_action, exposed as actuators
        ActuatorsKnown --> SideAsked : a fan coil showed up and dimension 19 is unknown\n*#4*2*19## once, no *#4*2*11## meanwhile, the 30 s wait starts once the sweep is over
        SideAsked --> ActuatorsKnown : *#4*2*19*5*0## tells which side is the fan coil\nin the fan coil's season *#4*2*11## is queued for the speed
        ActuatorsKnown --> ZoneKnown : a dimension 19 frame arrives last\nzone level bool drives hvac_action, both sides off marks the actuators off
        ZoneKnown --> ActuatorsKnown : a dimension 20 frame arrives last
        ActuatorsKnown --> Off : zone or central OFF\nevery known actuator off, the attribute stays
        Off --> ActuatorsKnown : HEAT, COOL or AUTO again
        ActuatorsKnown --> Queued : event session reconnects
        Refused --> Queued : event session reconnects, the identity is read again
        ActuatorsKnown --> [*] : entity removed, unwatch or close_listener
    }
Loading

Hardware Verification

  • F461 firmware 2.0.11, standalone zone 2 (actuator 1 heating valve, actuator 2 fan coil), 2026-10-23 (read-only probe) and 2026-10-24 (local Home Assistant): the per-zone *#4*2*20## is ACKed in 111 ms with *#4*2#1*20*0## on the command session and *#4*2#2*20*5## on the event session; the all-zone *#4*#0*20## returns both on the command session after 2.1 s. With this PR the plant sends one *#4*#0*20## after the sweep, the entity reports hvac_action: idle and actuators: {"1": "off", "2": "off"} without any manual frame, also after a restart and after a reconnect.
  • MyHomeServer1 firmware 3.87.13 (the oracle emulates 2.82.06), 2026-10-24, status reads only: *#4*35*20## → *#4*35#1*20*0## (189 ms); *#4*#0*20## ACKed in 0.39 s, the command session collected 8 frames and the event session all 43 distinct actuator frames (zones 0, 35–53, 55, 57–70: 34 Z#1, 5 Z#2 fan coils, 0#1–0#4), 44 with the zone-35 reply. MyHomeServer1 is not in the refused set and this live unit answers both forms.

Live retest (2026-10-24)

Consensus label: confirmed for the standalone zone-2 path. At every start: tx *#4*#0*20## → rx *#4*2#1*20*0##, *#4*2#2*20*5## (2.1 s after the preceding *#4*2*60##); hvac_action: idle and actuators: {"1": "off", "2": "off"} already at the first state read after startup (None on the base build). The per-zone *#4*2*20## form was not sent on this plant (the F461 uses the all-zone form); other models were not tested live.


How is it tested

  • tests/test_climate_actuator_status.py (99 cases: 28 before the first review, 78 before the second, 90 before the third): two actuators off give idle and the actuators attribute; one of two on gives heating, then idle; no report means no attribute; the zone watches on add and unwatches on remove; a central unit does not watch; the F461 sends one *#4*#0*20## for two zones; a routed zone is asked separately; an F454 is asked per zone; the six refusing models send nothing but keep the zone recorded; the refusal table and the profile-flag override; the request waits for the startup sweep; a reconnect asks again, not after unwatch; two entities on one zone unwatch separately; close_listener drops waiting requests; dimension 22 is refused; a transport error is logged and the next request still goes out.
  • Review follow-up cases: (1) a fan coil revealed by dimension 20 queues *#4*2*19## once; no *#4*2*11## in heating season on the F461 sequence, neither while dimension 19 is pending nor after *#4*2*19*5*0##, and *#4*2*11## once the season is cooling; a restored fan mode asks on add; a handler without request_zone_dimension keeps polling; known sides without a season still poll; an unanswered request expires and the next fan coil report may ask again. (2) *#4*2*19*0*1## then *#4*2#1*20*0## goes idle (no stuck heating), the reverse order too; valve never appears in actuators; the attribute survives *4*303*2## / *4*1*2## and a central OFF. (3) twelve invalid addresses refused at once (also by watch_zone_actuators, without a phantom count), nine plain and routed ones accepted, one rejected zone in a batch is logged and the others are sent. (4) a Generic entry identified as MH202 before the sweep ends is not asked (nor on reconnect); an MH200N corrected to F461 is asked (and on reconnect). (5) get_gateway_profile("BTicino F461") keys F461; longest-model lookup (Legrand MH200N -> MH200N); WHO 4 delegated to an F454 secondary asks the F454; failover to an MH202 standby sends nothing; failover to an F454 standby asks per zone; a 105 probe never watches; MyHomeServer1 sends one *#4*#0*20##. (6) zones registered 50 ms apart after the sweep share one *#4*#0*20##; the startup run does not wait the coalesce window; zones arriving while a batch is on the bus share the next batch.
  • Second review cases (review 5639881123): (1) the restored fan mode and the configured fan coil send *#4*2## only on add, *#4*2*11## is not sent before *#4*2*19##; a state restored through async_restore_last_state asks dimension 19 before the poll; restored sides are not asked again; a dimension 19 reply in the fan coil's season asks dimension 11 once, in the valve's season never; a zone-level report revealing the fan coil asks the speed; a fan coil revealed after its side asks the speed; a sweep longer than the wait keeps the request pending, checked during the hold or only after it, and the wait starts once the sweep is over. (3) watch_zone_actuators("3#4#1") counts 3#4#01, sends *#4*3#4#01*20## and unwatches it; *#4*3#4#1*14*0215## and interface="1" both give 3#4#01; 3#4#001 is refused. (4) an MH202 watching a zone never awaits send_paced. Fail-before / pass-after on c653002f: eight of the item 1 cases, two of item 3 and the item 4 case fail; all 90 pass with the four commits.
  • Fail-before / pass-after (first review): on fa97b2fd 47 of the 50 new cases fail (3 parametrised sanity cases of finding 5 pass on both); all 78 pass with the commits. tests/test_f461_live_plant.py::test_fan_speed_is_not_polled_until_the_sides_are_known replaces the case that asserted the blind *#4*2*11## poll finding 1 removes.
  • Third review cases (review 5642758778): the seven season-transition cases and the two trace replays above (fail-before: four of the seven on b61bb3e0, both replays on v2-phase1-architecture).
  • Full suite against the PyPI-pinned OWNd behaviour (plain upstream OWNd master, f04d8aa, on PYTHONPATH): 4658 passed, 1 skipped, 100.0 % statement coverage (16331 statements); rebased onto v2-phase1-architecture 089fef51.
  • ruff check ., scripts/typing_ratchet.py (0 strict errors), scripts/verify_ha_standards.py, scripts/sync_documentation.py --check, scripts/manage_translations.py check: all pass.

Where are the traces

Trace PR: #799 (merged, ad8431ff).

Subsystem Capture Plant Frames verified by this PR
WHO 4 dimension 20, per-zone and all-zone request tests/fixtures/traces/f461_live_2026-10-23/myhome_trace_F461_live_2026-10-23.json, run run1_oracle_replay_readonly F461 2.0.11, standalone zone 2 *#4*2*20## ACK 111 ms → *#4*2#1*20*0## (command session), *#4*2#2*20*5## (event session); *#4*#0*20## 2.1 s → both on the command session
Actuator frames after each zone poll with this PR same archive, run6_event_session_ha_fixed_integration F461 2.0.11, local Home Assistant with this branch the two actuator frames within 3 s of every *#4*2## burst (the integration sent *#4*#0*20## after each poll); six reconnect sweeps
The manual request that showed the gap same archive, run7_localha_bus_monitor F461 2.0.11, unpatched branch a hand-sent *#4*2*20## and its replies
Per-zone and all-zone forms on a second gateway family tests/fixtures/traces/myhomeserver1_live_2026-10-24/myhome_trace_MyHomeServer1_live_2026-10-24.json, run run2_reads_thermo_sound_alarm_lights MyHomeServer1 3.87.13 *#4*35*20## → *#4*35#1*20*0##; *#4*#0*20## → 43 distinct actuator frames on the event session, 8 on the command session (Z#1, Z#2, 0#N reply forms)
Dimension 22 (never requested) upstream fixture tests/fixtures/traces/f461_live_2026-10-15/, probe_dim22_confirm F461 2.0.11 *#4*2*22## ×3 and *#4*#0*22## ×2 followed by the gateway reboot

The frames are also inlined in tests/test_climate_actuator_status.py, and the same module replays run6_event_session_ha_fixed_integration and the *#4*#0*20## burst of run2_reads_thermo_sound_alarm_lights from the archives through the climate platform.


What does the Firmware Oracle / Encyclopedia say

  1. OpenWebNet Encyclopedia (functional/who-4-temperature-control/dimensions.md, addressing.md): DIMENSION 20 "actuator status" is read-only; request *#4*Z#N*20##, reply *#4*Z#N*20*VALUE## with VALUE 0..8 as the valve table plus 9 = fan-coil ON; Z#0 addresses all actuators of a zone and 0#0 all actuators. The bare zone form *#4*Z*20## and the central-unit-scope form *#4*#0*20## used here are not in that grammar; both are answered on the live F461 and MyHomeServer1 above, and the oracle rows below show which firmware forwards them.

  2. Firmware Oracle (results/<P>/<V>/oracle/full/thermo-actuator.tsv, published at results/mcp_index.json 53f9514):

    Frame nack, silent (dropped) nack, out (forwarded to the bus)
    *#4*1*20## F453 010100, F453AV 030014, H4684 020054, MH200 020102, MH200N 010108, MH202 010024 F450 020010, F454 020051, F459 020105, F460 020012, F461 020011, MyHomeServer1 028206
    *#4*#0*20## H4684, MH200, MH202 F450, F453, F453AV, F454, F459, F460, F461, MH200N, MyHomeServer1

    The refusal table of this PR is the *#4*1*20## row.

  3. findings/thermo-zone-requests.md (feat(oracle): add thermo-zone-requests suite (per-zone polls, actuators, humidity, interface form) own-firmware-oracle#60, merged on main 52d354b; firmware_observed, harness=full, 12 images; the results/<P>/<V>/oracle/full/thermo-zone-requests.tsv rows follow the next oracle CI run on main), the rows this PR relies on by model:

    Frame nack, silent (dropped) nack, out / ack, out (forwarded to the bus)
    *#4*2*20## (per zone) F453 010100, F453AV 030014, H4684 020054, MH200 020102, MH200N 010108, MH202 010024 F450 020010, F454 020051, F459 020105, F460 020012, F461 020011, MyHomeServer1 028206
    *#4*#0*20## (all zones) H4684, MH200, MH202 F450, F453, F453AV, F454, F459, F460, F461, MH200N, MyHomeServer1
    *#4*2#1*20## / *#4*2#2*20## (per actuator) H4684, MH200 the other ten (MH202 ACKs them itself)
    *#4*2#4#01*20## (zone behind an interface) F453, F453AV, H4684, MH200, MH200N F450, F454, F459, F460, F461, MH202, MyHomeServer1
    *#4*2*19## (the fan coil side request of this PR) none all twelve (MH202 ACKs it itself)

    The per-zone row matches the *#4*1*20## row of the published thermo-actuator suite model for model, so the six refused models and the F461 / MyHomeServer1 all-zone form rest on both suites.


Checklist

  • PR Description: Follows the structured sections above (What is the problem, What is the solution, State Diagram, How is it tested, Where are the traces, What does the Firmware Oracle / Encyclopedia say).
  • Trace-Backed (if protocol/device change): Behavior is verified against real OpenWebNet bus traces or gateway firmware fixtures rather than guessed.
  • V2 Architecture: New or modified entities implement handle_event() and do not manually subscribe via async_dispatcher_connect.
  • Tests & Linters: pytest, ruff, and scripts/verify_ha_standards.py pass.

🤖 Generated with Claude Code

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
… archive timing artefacts, README wording, loud pending-decoder skip)

Review 5638684839 on OpenWebNet-HA#799, items 2-5 (item 1, the real MAC suffixes in the
test modules, was fixed in the rewritten history before this commit).

Tier 1: _iter_all_trace_records also reads a derived_trace_archive's rows
(dir tx / rx), so the requests of the six archives are checked. The oracle
index at 53f9514 has no stimulus, direct or canonical, for 115 of them
(dimension 60 humidity, WHO 13 dimensions 10-12 / 19 / 27, *#1*#1##, WHO 14
status, report-form writes, WHO 6 / 8 on the F461, ...): they are listed in
PHYSICAL_ONLY_TX_FRAMES with the capture and the live reply per frame, and a
new test fails when an entry leaves the corpus or when the oracle grows a
verdict for it. No other tier changed; rows add no rx frame to tier 4.

Archives: both regenerated. Command-session rx rows sit right after their
request at its t with t_estimated true (the session returns the replies as a
batch with the ACK / NACK); result.events_after holds only the event-session
frames that arrived while the request was pending; t and frames[].timestamp
are one monotonic axis across runs (capture.runs[].t_offset). Said in
capture.notes and the READMEs. The trace tests read the chronological rows
for the frames that arrived after the ACK; the facts are unchanged.

README f461_live_2026-10-23: the refusal sentence matches run 1 (*michnovka#5*#1##
13 ms, *OpenWebNet-HA#25*21## 950 ms in run 1 and 5-6 ms in runs 8 / 8b); the run 6 bullet
says the actuator frames followed each *michnovka#4*2## because the patched
integration (OpenWebNet-HA#792) sent *michnovka#4*#0*20## after every poll.

pending_decoder_rx: assert parsed is None instead of continue, so the entries
fail loudly once the OWNd pin carries the dimension 27 decoder (OWNd#139).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck
gdluck force-pushed the feat/climate-actuator-status branch from dde2161 to c892706 Compare October 24, 2026 15:54
@gdluck
gdluck force-pushed the feat/climate-actuator-status branch from c892706 to fa97b2f Compare October 24, 2026 18:03

@GreenGrassBlueOcean GreenGrassBlueOcean left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit of PR #792 (fa97b2fd)

Branch / Base: gdluck:feat/climate-actuator-status (fa97b2fd) → v2-phase1-architecture (974306b5) — Draft, 5 files (+394 / -1)
Verification Run:

  • GitHub CI (fa97b2fd): All 17 check-runs completed success (OWNd pinned/latest/dev, pytest + 100% coverage gate, mypy-ratchet, validate, HA Container & Compatibility stable/beta/dev, quality_scale.yaml).
  • Local WSL Verification (OWNd==2.0.0b11): ruff check, verify_ha_standards.py, and sync_documentation.py (--check) all pass; tests/test_climate_actuator_status.py passes 28/28 with 100% diff coverage; existing test_climate.py and test_trace_oracle_validation.py pass 102/102.
  • PR Standards: Follows .github/pull_request_template.md (PR #778 shape), traces are merged via #799, and the latest push properly wraps send_paced in try / except Exception with LOGGER.exception so _send_zone_requests() survives transport errors (#593).

Findings (Ordered by Severity)

1. [High — Bus NACK Regression] Polling Dimension 20 without Dimension 19 causes 2.1 s *#4*Z*11## NACKs on mixed valve + fan-coil zones

  • Where: MyHOMEClimate.handle_event, MyHOMEClimate._season_has_fan_coil, MyHOMEClimate.async_update.
  • What happens:
    1. On the author's F461 2.0.11 plant (zone 2: actuator 1 heating valve, actuator 2 cooling fan coil, running in heating season *4*1*2##), startup now sends *#4*#0*20## and receives *#4*2#1*20*0## and *#4*2#2*20*5##.
    2. OWNHeatingEvent("*#4*2#2*20*5##") sets fan_on = False (isinstance(..., bool) is True), which calls _enable_fan_mode() (self._fan = True, adding FAN_MODE and fan_mode: "auto").
    3. Because nothing ever queries Dimension 19 (*#4*2*19##), self._fan_coil_sides remains {}. _season_has_fan_coil() therefore returns True even in heating season.
    4. In f461_live_2026-10-23 (run1_oracle_replay_readonly), *#4*2*11## in heating season NACKs after 2.083 s, whereas *#4*2*19## ACKs in 0.110 s with *#4*2*19*5*0## (cooling: True, heating: False).
    5. Consequently, every subsequent async_update() (on setpoint command echo, reconnect poll, or next HA restart when async_restore_last_state() restores fan_mode while _fan_coil_sides is still {}) sends *#4*2*11## and eats a 2.1 s NACK. Worse, on F454 / F455 (_gateway_supports_zone_status == False), flipping self._fan = True bypasses the early-return guard at climate.py:526-531.
  • Recommendation: When a Dimension 20 reply first reveals a fan coil (is_fan and not self._fan_coil_sides), queue a one-shot request_zone_dimension(self._full_where, 19) (own-firmware-oracle#60 shows *#4*2*19## is forwarded on all 12 gateway firmwares) so _fan_coil_sides is populated before async_update() polls Dimension 11.

2. [High — State Corruption & Attribute Leak] self._actuator_states mixes Dimension 19 ("valve") and Dimension 20 ("1", "2")

  • Where: MyHOMEClimate.handle_event and MyHOMEClimate.extra_state_attributes (climate.py:445-448 in PR).
  • What happens:
    • Dimension 19 frames (*#4*Z*19*C*H## — present in myhome_trace_f455_climate_timeouts_2026-10-09.json, myhome_trace_MyHomeServer1_live_2026-10-07.json, and myhome_trace_F461_live_2026-10-15.json) fall through actuator_id to "valve". Because PR #792 now actively requests Dimension 20 on F454, F455, MyHomeServer1, and F461, zones on plants emitting Dimension 19 will hold both "valve" and "1", "2" in self._actuator_states:
      1. Stuck hvac_action: If Dimension 19 recorded self._actuator_states["valve"] = True and the actuator later closes via Dimension 20 (*#4*2#1*20*0##) without a new Dimension 19 frame, _actuator_states is {"valve": True, "1": False, "2": False} → any(self._actuator_states.values()) stays True and hvac_action stays permanently stuck in heating/cooling. (And vice versa if "1": True is followed only by *#4*2*19*0*0##.)
      2. Attribute pollution: extra_state_attributes["actuators"] exposes {"1": "off", "2": "off", "valve": "on"}, leaking the synthetic "valve" key into user-facing state attributes.
      3. Disappearing attribute on OFF: Turning a zone or central unit OFF (climate.py:640, 1036, 1090) calls self._actuator_states.clear(). The actuators attribute disappears while OFF and stays missing when the zone returns to HEAT/AUTO until an actuator switches or the session reconnects.
  • Recommendation: Separate Dimension 20 per-actuator states (dict[str, bool], exposed in actuators) from the Dimension 19 zone-level fallback (bool | None), let whichever arrived most recently (or Dim 20 once known, updated on OFF to {k: False for k in ...}) drive any_active, and never expose "valve" in extra_state_attributes["actuators"].

3. [High — Companion PR Contract Mismatch] OWNd#140 raises ValueError on routed zones (3#4#01) that PR #792 sends and tests

  • Where: gateway.py:431-434 and test_routed_zone_is_asked_on_its_own in PR #792 vs OWNHeatingCommand.actuators_status in OpenWebNet-HA/OWNd#140.
  • What happens:
    • PR #792 explicitly supports F422 interface-routed zones (watch_zone_actuators("3#4#01") → *#4*3#4#01*20##, backed by own-firmware-oracle#60 showing 8 of 12 gateways forward *#4*2#4#01*20##), and Solution #6 states the raw format string f"*#4*{where}*{dimension}##" will be replaced by OWNHeatingCommand.actuators_status(where) from OWNd#140.
    • However, OWNd#140 validates where with _WHO4_WHERE_REGEX = re.compile(r"#?[0-9]+(?:#[0-9]+)?"), which rejects all 3-part # addresses (OWNHeatingCommand.actuators_status("3#4#01") raises ValueError: Invalid zone address: 3#4#01).
    • Furthermore, in _send_zone_requests(), frame construction sits outside the try: await self.send_paced(frames) guard — so once switched to OWNHeatingCommand.actuators_status(where), one 3#4#01 zone will raise an uncaught ValueError, kill _send_zone_requests(), and drop every other pending zone request in the batch.
    • Conversely, while raw strings are used today, request_zone_dimension(where, dimension) checks dimension == 22 but does not reject * in where (e.g., where="2*22" produces *#4*2*22*20##, which OWNCommand.parse parses as a Dimension 22 write command!).
  • Recommendation:
    1. Extend _WHO4_WHERE_REGEX in OWNd#140 before release to allow bus-interface routing (?:#4#[0-9]{2})? (e.g. 3#4#01, #3#4#01, 2#1#4#01).
    2. Reject invalid where (at minimum "*" in where or not where) right inside request_zone_dimension() now, and keep builder calls inside the try / except when OWNd#140 is adopted.

4. [Medium — Startup Race] watch_zone_actuators() checks actuator_request_refused before in-band gateway identity settles

  • Where: gateway.py:384-393, 421-438, 488-489 in PR #792 vs GatewayIdentityMixin._resolve_identity (and companion MyHOME#804).
  • What happens:
    • Restored/YAML climate entities run async_added_to_hass() → watch_zone_actuators() during platform setup, before initial_discovery() completes and often before WHO=13 dim 15 / WHO=1013 dim 1 (_apply_model) settles or corrects self.gateway.model_name and self.gateway.profile.
    • Direction A (Generic → MH202/MH200N): A manually added "Generic" gateway (especially relevant with MyHOME#804) has actuator_request_refused == False when entities are added, so zones enter _actuator_zones and (where, 20) is queued. Once WHO=13/1013 identifies the gateway as MH202 or MH200N, neither _send_zone_requests() nor _on_event_connection_state_change() re-checks self.actuator_request_refused, so refused *#4*Z*20## frames are sent after startup and on every reconnect.
    • Direction B (misconfigured MH200N → corrected to F461/F454): watch_zone_actuators() returns lambda: None early without recording where in _actuator_zones, permanently losing actuator polling until the entry is reloaded.
  • Recommendation: Always ref-count where in self._actuator_zones inside watch_zone_actuators(), and evaluate self.actuator_request_refused inside _send_zone_requests() (after wait_for_initial_discovery(), when identity has settled) when dimension == 20.

5. [Medium — Gateway Profile, Topology & Sibling-PR Edge Cases]

  • _model_key with GenericGatewayProfile (gateway.py:362-366): OWNd.profiles.get_gateway_profile("BTicino F461") returns GenericGatewayProfile(model_name="BTicino F461"). Checking profile_model != "Generic" rather than not isinstance(profile, GenericGatewayProfile) (compare command_session_limit at gateway.py:128) produces "BTICINOF461", missing both ALL_ZONE_REQUESTS["F461"] and ACTUATOR_REQUEST_REFUSED (unlike gateway_model_family or substring matching in supports_zone_status). Note also that draft PR #793 adds an identical _model_key helper at the same lines in gateway.py and edits the same bullet in .github/release-notes/2.0.0b16.md (guaranteed merge conflict).
  • Shared-bus WHO 4 delegation / warm-standby failover (gateway.py:430 vs send_status_request): send_status_request routes WHO 4 frames through _delegated_target / _failover_target, whereas actuator_request_refused and all_zone_request(20) inspect self rather than the active WHO 4 target gateway (e.g., primary F461 failed over to standby MH202 will send *#4*#0*20## to the MH202, which drops it; primary MH202 delegating WHO 4 to secondary F454 will skip actuator requests).
  • Receive-only secondary probes (WHERE >= 100): docs/configuration/climate.md documents 3-digit secondary probes (105, etc.) as push-only (*#4*105*...## NACKs in 2.1 s in f461_live_2026-10-23). Guarding watch_zone_actuators in climate.py:637 against 3-digit secondary probe addresses (not just not self._central) avoids polling any legacy-configured secondary probe entity.
  • MyHomeServer1 omitted from ALL_ZONE_REQUESTS (gateway.py:162): In myhomeserver1_live_2026-10-24 (3.87.13), *#4*#0*20## returned all 34 zones (43 actuator frames on the event session) in 0.39 s, and emulated MyHomeServer1 028206 also forwards *#4*#0*20## (out). Asking 34 zones individually via send_paced takes ~32 s (34 × (0.19 s + 0.75 s BUS_QUIET_PERIOD)) on startup and every reconnect. Either add "MYHOMESERVER1": {20: "*#4*#0*20##"} or document in a code comment why MyHomeServer1 is intentionally kept per-zone.

6. [Low — Protocol / Oracle Gap & Minor Nits]

  • Missing Encyclopedia wildcard forms in own-firmware-oracle#60: While F453, F453AV, and MH200N drop *#4*Z*20##, the oracle shows all three forward *#4*#0*20## and *#4*Z#N*20## (and MH202 ACKs+forwards *#4*Z#N*20##). Meanwhile, the Encyclopedia's official all-actuators grammar *#4*Z#0*20## and *#4*0#0*20## (ownkb:chunk:r000585) is not tested in own-firmware-oracle#60. Adding down *#4*2#0*20## and down *#4*0#0*20## to thermo-zone-requests.cases before CI runs it would settle whether F453 / F453AV / MH200N / MH202 can actually query actuators via Z#0 or #0 instead of refusing them altogether.
  • Hardcoded "#4#" (gateway.py:431, 433): Prefer importing BUS_ROUTING from const.py.
  • Coalescing window after initial_discovery (gateway.py:424): await asyncio.sleep(0) coalesces entities registered in the same event-loop tick (restored/YAML entities), but when zones are dynamically discovered post-startup over ~1–2 s of incoming bus frames, the first zone triggers *#4*#0*20## on F461 immediately and zones arriving 50 ms later trigger a duplicate *#4*#0*20## sweep. A tiny coalesce delay (e.g. 0.05–0.1 s when _initial_discovery_done is already set) avoids back-to-back #0*20 sweeps during dynamic discovery.

@gdluck
gdluck force-pushed the feat/climate-actuator-status branch from fa97b2f to 6b254b6 Compare October 24, 2026 19:06
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…n coil (OpenWebNet-HA#792 review, 1)

A fan coil reported by *michnovka#4*Z#N*20*5..8## enabled the fan mode without telling
which side of the zone it is on; with _fan_coil_sides empty,
_season_has_fan_coil() said yes in both seasons and every async_update() sent
*michnovka#4*Z*11##, which the live F461 2.0.11 NACKs after 2.1 s in the valve's season
(f461_live_2026-10-23, run1_oracle_replay_readonly: *michnovka#4*2*11## NACK 2.083 s,
*michnovka#4*2*19## ACK 0.110 s with *michnovka#4*2*19*5*0##).

The zone now queues one *michnovka#4*Z*19## through request_zone_dimension() when a
dimension 20 reply first reveals a fan coil (and on add when a restored fan mode
has no side), and does not poll the fan speed while that answer is pending; once
dimension 19 answers, the poll follows the fan coil's season as before. Every
firmware of the oracle forwards *michnovka#4*Z*19## (own-firmware-oracle#60). A gateway
handler without request_zone_dimension keeps the old behaviour.

Review 5639409551 on OpenWebNet-HA#792, finding 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
… zone state (OpenWebNet-HA#792 review, 2)

_actuator_states mixed the per-actuator reports of dimension 20 ("1", "2") with
the zone-level valves status of dimension 19 under a synthetic "valve" key. With
dimension 20 now requested on every forwarding gateway, zones whose plant also
emits dimension 19 (F455, MyHomeServer1 and F461 traces) held both: a stale
"valve": True kept hvac_action in heating after *michnovka#4*2#1*20*0## (and the other
way round), the "valve" key leaked into the actuators attribute, and switching
the zone OFF cleared the dict so the attribute vanished until an actuator
switched again.

Dimension 20 keeps the per-actuator dict (exposed as "actuators"); dimension 19
sets a zone-level bool fallback; whichever arrived last drives hvac_action, and a
dimension 19 report with both sides off marks the known actuators off. OFF
(zone, central, mode frame) sets every known actuator to False instead of
clearing, so the attribute stays through an OFF/HEAT round trip. The OWNd#116
workaround applies to dimension 19 only.

Review 5639409551 on OpenWebNet-HA#792, finding 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…m dropping the batch (OpenWebNet-HA#792 review, 3)

request_zone_dimension() refused dimension 22 but took any where: "2*22" with
dimension 20 built *michnovka#4*2*22*20##, which OWNCommand.parse reads as a dimension 22
write, the frame that reboots the F461. The frames were also built outside the
try/except around send_paced(), so once the raw string is replaced by the
OWNd#140 builder (OWNHeatingCommand.actuators_status raises ValueError on an
address it does not accept) one zone would have ended the task and dropped every
other pending zone of the batch.

A zone address is now validated right away in request_zone_dimension() (digits
and "#" only, not empty) and again by the per-zone frame builder inside
_send_zone_requests(), where a ValueError is logged and the other zones are still
sent. Routed addresses (3#4#01, #3#4#01, 2#1#4#01) stay accepted; OWNd#140's
_WHO4_WHERE_REGEX is being extended to the same "michnovka#4#" routed forms in a
companion commit.

Review 5639409551 on OpenWebNet-HA#792, finding 3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…t when the zone is added (OpenWebNet-HA#792 review, 4)

watch_zone_actuators() checked actuator_request_refused while the climate
platform was being set up, before initial_discovery() and often before the
in-band identity (WHO 13 dimension 15 / WHO 1013 dimension 1, _apply_model) had
settled or corrected the configured model. A "Generic" entry later identified as
MH202 / MH200N kept sending the refused *michnovka#4*Z*20## after startup and on every
reconnect; a misconfigured MH200N corrected to an F461 / F454 had returned a
no-op callback without recording the zone, so its actuators were never asked
until the entry was reloaded.

The zone is now always ref-counted in _actuator_zones, and the refusal is
evaluated inside _send_zone_requests() after wait_for_initial_discovery(), for
every dimension 20 batch (startup and reconnect alike), so both directions
follow the settled identity.

Review 5639409551 on OpenWebNet-HA#792, finding 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…he WHO 4 target gateway (OpenWebNet-HA#792 review, 5)

Four edge cases of the per-model tables and the zone watch:

- _model_key compared the profile's model_name with "Generic" instead of its
  class: get_gateway_profile("BTicino F461") is a GenericGatewayProfile named
  "BTicino F461", so the key became "BTICINOF461" and missed both
  ALL_ZONE_REQUESTS and ACTUATOR_REQUEST_REFUSED. The key is now taken from a
  known profile's name, otherwise from the configured model, and a known model
  is looked up as a substring (longest first), like command_session_limit and
  supports_zone_status do.
- The refusal and the all-zone form were read from this handler while
  send_status_request routes WHO 4 through the delegated secondary or the warm
  standby. _who4_target() resolves the same target (_delegated_target /
  _failover_target) and its tables decide: a primary F461 failed over to an
  MH202 sends nothing, failed over to an F454 asks per zone; an MH202
  delegating WHO 4 to an F454 asks the F454.
- A secondary probe (WHERE >= 100, push-only; *michnovka#4*105*...## NACKs in 2.1 s on
  the F461, docs/configuration/climate.md) no longer registers a zone watch.
- MyHomeServer1 gets the all-zone form: in myhomeserver1_live_2026-10-24
  (3.87.13, run2_reads_thermo_sound_alarm_lights) *michnovka#4*#0*20## was ACKed in
  0.39 s with 43 actuator frames for 34 zones, where the per-zone requests take
  about 32 s through the paced sender; the emulated 028206 forwards it ("out").

Review 5639409551 on OpenWebNet-HA#792, finding 5. Draft OpenWebNet-HA#793 carries the same _model_key
helper and needs the same change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…after the sweep (OpenWebNet-HA#792 review, 6)

The interface marker was the literal "michnovka#4#" twice in _send_zone_requests();
const.BUS_ROUTING names it. After the startup sweep, asyncio.sleep(0) only
coalesced zones registered in the same event-loop tick: zones discovered from
incoming frames arrive over 1-2 s, and on an F461 each one started its own
*michnovka#4*#0*20## sweep a few tens of ms after the previous one. A request queued
once initial discovery is already done now waits ZONE_REQUEST_COALESCE (0.1 s)
for the others; requests queued before the sweep ended keep joining it without
an extra delay.

Review 5639409551 on OpenWebNet-HA#792, finding 6 (the oracle-case gap for *michnovka#4*Z#0*20## and
*michnovka#4*0#0*20## is for own-firmware-oracle#60, not this repository).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
OpenWebNet-HA#792 review, 1)

The split of _season_has_fan_coil() left the "sides known, season unknown"
branch without a case; it keeps asking *michnovka#4*Z*11## as before.

Review 5639409551 on OpenWebNet-HA#792, finding 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…blocking the fan speed poll (OpenWebNet-HA#792 review, 1)

The one-shot *michnovka#4*Z*19## flag was cleared only by a dimension 19 answer. A
request the gateway refuses, or one dropped with the event session before it was
sent (close_listener clears the queue and a reconnect queues dimension 20 only),
would have left the fan speed poll disabled for good. The flag is now the time of
the request: after FAN_COIL_SIDES_WAIT (30 s, the live F461 answers in 0.11 s)
the zone polls as before and a later fan coil report may ask again.

Review 5639409551 on OpenWebNet-HA#792, finding 1 (cross-check follow-up).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…oalesce every batch (OpenWebNet-HA#792 review, 3, 6)

Three follow-ups on the review's findings 3 and 6:

- The zone address check was a character whitelist: "#", "2##" or "2#1#2#3"
  passed and built malformed frames. A regex now accepts Z, #Z, Z#N and the
  same behind an interface (Z#4#if, BUS_ROUTING), the grammar OWNd#140's
  builder is being extended to.
- watch_zone_actuators() counted the zone before request_zone_dimension()
  could reject it, leaving a phantom entry that every reconnect retried; the
  address is validated first.
- The coalesce window applied to the first batch only: a zone registered while
  a batch was on the bus started its own batch at once. Every iteration of the
  send loop after the first now waits ZONE_REQUEST_COALESCE.

Review 5639409551 on OpenWebNet-HA#792, findings 3 and 6 (cross-check follow-up).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck

gdluck commented Oct 24, 2026

Copy link
Copy Markdown
Author

Thanks for the audit; all six findings are addressed on head 6b254b6 (rebased on the current base, still draft). One commit per finding, 50 new test cases (47 fail on the previous head), full suite 4576 passed at 100 % statement coverage, ruff / typing ratchet / HA standards / docs sync clean. A second-opinion review of the diff raised nine further points; four were folded in (noted below), five were argued and left, with the reasons in the PR body.

  1. Dimension 11 NACKs (High). When a dimension 20 reply first reveals a fan coil and _fan_coil_sides is empty, the zone queues one *#4*Z*19## through request_zone_dimension (also on add when a restored fan_mode has no side) and does not poll *#4*Z*11## while that answer is pending (30 s expiry, so an unanswered request never disables the fan poll for good; second-opinion point). Tests: the F461 sequence in heating season sends no *#4*2*11## before or after *#4*2*19*5*0##, sends it once the season is cooling, and requests *#4*2*19## exactly once. (394a5bef, 3cc1f226, 841132d3)
  2. State corruption / attribute leak (High). Dimension 20 keeps a per-actuator dict[str, bool] exposed as actuators; dimension 19 sets a zone-level bool | None; the most recent source drives hvac_action (a dimension 19 "both sides off" also marks the known actuators off); valve never appears in the attribute; OFF (zone, central, mode frame) sets the known actuators to off instead of clearing them. Tests: *#4*2*19*0*1## then *#4*2#1*20*0## → idle and the reverse, attribute content, OFF/HEAT round trip, central OFF. (a24ad505)
  3. Companion contract (High). request_zone_dimension rejects invalid where immediately (grammar Z, #Z, Z#N, each optionally #4#if; 2*22, empty, 2##, #, 2#1#2#3 refused), watch_zone_actuators validates before it counts, and the per-zone frame construction sits inside the guarded loop so one rejected zone is logged and the rest of the batch still goes out. feat(heating): OWNHeatingCommand.actuators_status(where) builder for *#4*WHERE*20## OWNd#140 now accepts the #4# routed forms (Z#4#NN, #Z#4#NN, Z#N#4#NN, two-digit interface; 18 new cases, pushed as 307dcc9). (90c50398, 6b254b66)
  4. Startup race (Medium). watch_zone_actuators always ref-counts the zone; actuator_request_refused is evaluated inside _send_zone_requests after wait_for_initial_discovery(), for the startup batch and for every reconnect. Tests: a Generic entry identified as MH202 before the sweep ends is not asked (nor on reconnect); an MH200N corrected to F461 is asked (and on reconnect). (b0edd03e)
  5. Profile / topology / probes / MyHomeServer1 (Medium). _model_key uses isinstance(profile, GenericGatewayProfile) like command_session_limit, with a longest-first substring lookup (get_gateway_profile("BTicino F461") → F461); the refusal and the all-zone decision go through the active WHO 4 target (_delegated_target / _failover_target, the same routing send_status_request and send_paced use): F461 → MH202 standby sends nothing, F461 → F454 standby asks per zone, MH202 delegating to an F454 asks the F454; WHERE >= 100 probes never watch; "MYHOMESERVER1": {20: "*#4*#0*20##"} added, citing the merged myhomeserver1_live_2026-10-24 fixture (0.39 s, 43 actuator frames for 34 zones) and the emulated 028206 forwarding. fix(climate): do not poll humidity (dimension 60) where the emulated firmware did not forward it #793 receives the same _model_key change in a separate commit so the two drafts stop diverging there. (6f267d05)
  6. Nits (Low). BUS_ROUTING from const.py; ZONE_REQUEST_COALESCE (0.1 s) applied once the sweep is over and on every later batch, so dynamically discovered zones and zones arriving mid-batch share one *#4*#0*20##. The *#4*Z#0*20## / *#4*0#0*20## Encyclopedia forms are being added to the thermo-zone-requests suite of feat(oracle): add thermo-zone-requests suite (per-zone polls, actuators, humidity, interface form) own-firmware-oracle#60 and run on the twelve emulated firmwares; I will post the verdicts there and here when the run completes. (865bade6, 6b254b66)

Second-opinion points argued and left: the WHO 4 target used for the policy is the one send_paced transmits through (the delegation/failover tests drain the secondary's buffer); a per-actuator report overriding a dimension-19 "active" is the most-recent-source rule you asked for, and the all-zone request fills the dict at start; an actuator report after OFF showing heating is pre-existing and a real signal; the default actuator id 1 follows OWNd's own parser; climate state is kept across a reconnect until the refresh answers.

gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…bstring lookup (align with OpenWebNet-HA#792)

_model_key compared the profile's model_name with "Generic" instead of its
class: get_gateway_profile("Legrand MH200N") is a GenericGatewayProfile named
"Legrand MH200N", so the key became "LEGRANDMH200N" and missed
HUMIDITY_REQUEST_REFUSED, and the zone was polled on a gateway whose emulated
firmware does not forward *michnovka#4*Z*60##. The key is now taken from a known
profile's name, otherwise from the configured model, in which a known model is
looked up as a substring (longest first, so MH200N is not read as MH200), like
command_session_limit and supports_zone_status do.

Same helper and the same change as OpenWebNet-HA#792 (review 5639409551, finding 5), so the
two branches no longer differ in what _model_key returns.

Fail-before / pass-after on tests/test_climate_humidity_poll_gate.py: 3 of the
7 new cases fail on the previous helper (Legrand MH200N, BTicino F453AV, the
vendor-prefixed MH200N zone poll), 36 of 36 pass with the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck

gdluck commented Oct 24, 2026

Copy link
Copy Markdown
Author

Oracle outcome for finding 6, now in OpenWebNet-HA/own-firmware-oracle#60 (commit ac17203, two added cases, 12 emulated gateways, two runs each byte-identical):

Frame nack / out (forwarded) nack / silent (dropped)
*#4*2#0*20## F450, F453, F453AV, F454, F459, F460, F461, MH200N, MyHomeServer1 H4684, MH200, MH202
*#4*0#0*20## same nine same three

Both forms behave exactly like *#4*#0*20## on every gateway; *#4*0#0*20## even produces the same SCS bytes as *#4*#0*20##, and *#4*2#0*20## is addressed to zone 2 (address byte 82 on F453 / F453AV / MH200N, 02 on the other six). So F453, F453AV and MH200N, which drop the per-zone *#4*Z*20##, do forward the all-actuators forms Z#0 and 0#0; the MH202 drops every all-actuators form and forwards only *#4*Z#N*20## (plain and routed). The PR's fallback table and ALL_ZONE_REQUESTS stay as they are for now (the refused set is about the per-zone request); using Z#0 as the fallback on F453 / F453AV / MH200N is a possible follow-up once those rows are published. A counting slip in the oracle findings was corrected on the way (*#4*2#4#01*20## is forwarded by seven gateways, not eight).

@GreenGrassBlueOcean GreenGrassBlueOcean left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Audit of PR #792 (6b254b66)

Thanks for the clean commit-by-commit rework on 6b254b66 and for running the wildcard cases in OpenWebNet-HA/own-firmware-oracle#60 (ac17203).

Resolved from review 5639409551:

  • Finding 2 (_actuator_states vs Dim 19): Resolved — per-actuator _actuator_states (actuators attribute) and zone-level _zone_active / _active_source are cleanly separated, "valve" never leaks into actuators, and _mark_actuators_off() preserves known keys across OFF transitions.
  • Finding 4 (Startup identity race): Resolved — watch_zone_actuators unconditionally ref-counts _actuator_zones and evaluates target.actuator_request_refused inside _send_zone_requests() after wait_for_initial_discovery().
  • Finding 5 (GenericGatewayProfile, WHO 4 routing, probes, MyHomeServer1): Resolved — _model_key checks not isinstance(profile, GenericGatewayProfile) with longest-first _ZONE_REQUEST_MODELS lookup, _who4_target() follows _delegated_target / _failover_target, not is_probe(self._where) guards watch_zone_actuators, and "MYHOMESERVER1" uses *#4*#0*20##.
  • Finding 6 (BUS_ROUTING, coalescing, oracle wildcard cases): Resolved — BUS_ROUTING imported from const.py, ZONE_REQUEST_COALESCE coalesces post-sweep and mid-batch arrivals, and *#4*2#0*20## / *#4*0#0*20## are recorded in own-firmware-oracle#60.

Remaining / Newly Uncovered Issues to Address

1. [Medium — Bug] _ask_fan_coil_sides() runs after super().async_added_to_hass()'s initial poll, and deferred *#4*Z*11## never runs once Dimension 19 answers (_attr_should_poll = False)

  • Ordering on add (custom_components/myhome/climate.py:695-706): await super().async_added_to_hass() runs at line 695 before if self._fan: self._ask_fan_coil_sides() at lines 703-706. Inside MyHOMEEntity.async_added_to_hass() (myhome_device.py:264-285), async_restore_last_state() runs first, and whenever initial_discovery_pending and sweep_active are False (dynamically discovered zone, reloaded/re-enabled entity, or inside test_restored_fan_mode_without_the_side_asks_for_it_on_add at tests/test_climate_actuator_status.py:334-342), line 285 immediately awaits self.async_update() while _fan_coil_sides_asked_at is still None — sending ['*#4*2##', '*#4*2*11##'] before _ask_fan_coil_sides() runs (assert "*#4*2*11##" not in _status_frames(zone) currently fails in test_restored_fan_mode_without_the_side_asks_for_it_on_add). Also note that when initial_discovery_pending is True, stamping _fan_coil_sides_asked_at = time.monotonic() before wait_for_initial_discovery() means a busy startup sweep lasting longer than FAN_COIL_SIDES_WAIT (30.0 s) expires the wait before *#4*Z*19## is ever put on the wire.
  • Missing deferred Dimension 11 poll (custom_components/myhome/climate.py:1210-1215, myhome_device.py:93): MyHOMEEntity sets self._attr_should_poll = False, so async_update() runs only once on add (the tests in test_climate_actuator_status.py and test_f461_live_plant.py masked this by calling await zone.async_update() manually after feeding *#4*2*19*5*0##). When self._fan is first enabled after async_update() by *#4*2#2*20*5## (or when async_update() skipped *#4*Z*11## while Dimension 19 was pending), receiving *#4*2*19*5*0## in the fan coil's season (_season_has_fan_coil() == True) clears _fan_coil_sides_asked_at = None without ever requesting dimension 11 (*#4*Z*11##). Because Dimension 20 value 5 (fan off) carries no configured speed, fan_mode stays stuck on the synthetic default "auto" instead of reading the thermostat's actual speed (low / medium / high / auto).
  • Recommendation:
    1. Call _ask_fan_coil_sides() before await super().async_added_to_hass() (for fan=True in __init__) and at the end of async_restore_last_state() (for restored fan_mode without fan_coil_*, guarded by not self._central and not is_probe(self._where)), and avoid expiring FAN_COIL_SIDES_WAIT while initial_discovery_pending / sweep_active is still holding back _send_zone_requests().
    2. When dimension == 19 arrives in handle_event and resolves unknown/pending _fan_coil_sides with self._fan and self._season_has_fan_coil(), queue request_zone_dimension(self._full_where, 11).

2. [Medium — Test Failure / Flake] Two new gateway tests race wait_for_initial_discovery()'s 0.14 s timeout under fast_bus_pacing

  • Where: tests/test_climate_actuator_status.py:442-450, 460-469, 572-581.
  • What happens: fast_bus_pacing (tests/conftest.py:529-533) sets PACED_WRITE_TIMEOUT = 0.01, BUS_QUIET_CAP = 0.01, and BUS_QUIET_PERIOD = 0.005, so wait_for_initial_discovery() computes timeout = 7 * (0.01 + 0.01) = 0.14 s. F461.initial_discovery() then runs 12 timer sleeps across 6 unwritten discovery frames (~0.10–0.16 s). On WSL2 (and any CI runner with ~12–15 ms timer slack), wait_for_initial_discovery() times out on frame 5 (*#18*51*51##), _send_zone_requests() enqueues *#4*#0*20## before *#18*51*113##, and both test_a_gateway_identified_late_as_supported_is_asked and test_the_startup_run_does_not_wait_the_coalesce_window fail with AssertionError: assert '*#18*51*113##' == '*#4*#0*20##' (test_a_gateway_identified_late_as_refusing_is_not_asked silently hits the same premature timeout).
  • Recommendation: Apply the same monkeypatch.setattr("custom_components.myhome.gateway.BUS_QUIET_CAP", 5.0) guard already used in test_actuators_are_asked_after_the_startup_sweep (line 204) to those three tests.

3. [Low — Companion Contract Mismatch] #4# interface digit count differs between MyHOME#792 ({1,2}) and OWNd#140 ({2})

  • Where: custom_components/myhome/gateway.py:181, 189 and tests/test_climate_actuator_status.py:421 vs OpenWebNet-HA/OWNd#140 (307dcc9, OWNd/message/heating.py:83, tests/test_who4_actuators_status.py:92).
  • What happens: _ZONE_WHERE here accepts [0-9]{1,2}, test_a_plain_or_routed_zone_address_is_accepted explicitly asserts "3#4#1" is accepted, and _validate_zone_where's docstring says OWNHeatingCommand.actuators_status in OWNd#140 "rejects the same". However, OWNd#140 uses (?:#4#[0-9]{2})? and explicitly asserts actuators_status("3#4#1") raises ValueError. Since _calling_zones (climate.py:238) keeps raw str(tail[1]) without zfill(2), please align the two regexes (relaxing OWNd#140 to {1,2} or normalizing + tightening both to {2}) so swapping _zone_request_frame for OWNHeatingCommand.actuators_status does not break "3#4#1".

4. [Low — Nit] Skip send_paced when frames is empty

  • Where: custom_components/myhome/gateway.py:508-510.
  • What happens: When every pending item in _send_zone_requests() is skipped (which happens on every startup and reconnect for MH202, MH200N, MH200, F453, F453AV, H4684), frames is [], yet await self.send_paced([]) still runs _begin_sweep() / _end_sweep() (_sweep_idle.clear() → _sweep_idle.set()). Guarding if not frames: continue avoids empty sweep transitions.

Comment thread custom_components/myhome/climate.py Outdated
if self._fan:
# Restored (or configured) fan mode without the fan coil's side: ask for it
# before the first fan speed poll.
self._ask_fan_coil_sides()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await super().async_added_to_hass() at line 695 runs async_restore_last_state() and — whenever initial_discovery_pending and sweep_active are False — immediately awaits self.async_update() (myhome_device.py:264-285) before self._ask_fan_coil_sides() is called here. As a result, async_update() sees _fan_coil_sides_asked_at is None and sends *#4*Z*11## right before Dimension 19 is asked (including in test_restored_fan_mode_without_the_side_asks_for_it_on_add).

Calling _ask_fan_coil_sides() before await super().async_added_to_hass() (when self._fan was enabled in __init__) and at the end of async_restore_last_state() (when self._fan was restored without fan_coil_*) ensures _fan_coil_sides_asked_at is set before async_update() runs. Also consider not starting the FAN_COIL_SIDES_WAIT clock before wait_for_initial_discovery() has finished, so a >30 s startup sweep cannot expire the wait before *#4*Z*19## is sent.

"cooling": isinstance(getattr(message, "cooling_fan_on", None), bool),
"heating": isinstance(getattr(message, "fan_on", None), bool),
}
self._fan_coil_sides_asked_at = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because MyHOMEEntity sets self._attr_should_poll = False (myhome_device.py:93), async_update() is not called periodically after startup. When self._fan was enabled by Dimension 20 after async_update() already ran (or async_update() skipped *#4*Z*11## while _fan_coil_sides_asked_at was pending), receiving Dimension 19 here in the fan coil's season (self._season_has_fan_coil() == True) clears _fan_coil_sides_asked_at without ever querying Dimension 11 — leaving fan_mode stuck on the synthetic "auto" default when Dimension 20 reported 5 (fan off).

If dimension == 19 transitions _fan_coil_sides from unknown/pending to known and self._fan and self._season_has_fan_coil(), please queue request_zone_dimension(self._full_where, 11).

Comment thread custom_components/myhome/gateway.py Outdated

# A WHO 4 zone address: ``Z``, ``#Z``, ``Z#N`` (actuator), each optionally behind an
# interface (``Z#4#if``); the grammar OWNd#140's builder is being extended to.
_ZONE_WHERE = re.compile(rf"^#?[0-9]+(?:#[0-9]+)?(?:{re.escape(BUS_ROUTING)}[0-9]{{1,2}})?$")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ZONE_WHERE allows 1-or-2 interface digits ([0-9]{1,2}) and line 421 of tests/test_climate_actuator_status.py asserts "3#4#1" is accepted, whereas OpenWebNet-HA/OWNd#140 (307dcc9, OWNd/message/heating.py:83) requires two digits (?:#4#[0-9]{2})? and asserts actuators_status("3#4#1") raises ValueError. Please align the two so replacing _zone_request_frame with OWNHeatingCommand.actuators_status is a drop-in swap.

except ValueError as err:
LOGGER.warning("%s Zone %s: dimension %s not requested: %s", self.log_id, where, dimension, err)
try:
await self.send_paced(frames)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: on gateways in ACTUATOR_REQUEST_REFUSED, every startup and reconnect batch leaves frames == [], and await self.send_paced([]) still toggles _begin_sweep() / _end_sweep(). Guarding if not frames: continue avoids the no-op sweep toggle.

zone._gateway_handler.supports_zone_status = True
zone._enable_fan_mode() # what a restored ``fan_mode`` attribute does
await zone.async_added_to_hass()
zone._gateway_handler.request_zone_dimension.assert_called_once_with("2", 19)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert "*#4*2*11##" not in _status_frames(zone) currently fails right here because super().async_added_to_hass() already awaited zone.async_update() before _ask_fan_coil_sides() ran — good regression assertion to add alongside request_zone_dimension.

handler.gateway.profile = get_gateway_profile("F461")
await handler.initial_discovery()
await _settle(hass)
assert _drain(handler)[-1] == "*#4*#0*20##"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under fast_bus_pacing (PACED_WRITE_TIMEOUT = 0.01, BUS_QUIET_CAP = 0.01), wait_for_initial_discovery() sets a 7 * 0.02 = 0.14 s timeout while F461.initial_discovery() sleeps 12 times across 6 unwritten frames (~0.10–0.16 s). In WSL2 (and under ~12–15 ms timer slack) wait_for_initial_discovery() times out on frame 5 (*#18*51*51##), enqueueing *#4*#0*20## before *#18*51*113## and failing both this test and test_the_startup_run_does_not_wait_the_coalesce_window at line 580 (and silently timing out test_a_gateway_identified_late_as_refusing_is_not_asked at line 448).

Adding monkeypatch.setattr("custom_components.myhome.gateway.BUS_QUIET_CAP", 5.0) — as already done in test_actuators_are_asked_after_the_startup_sweep at line 204 — makes all three deterministic.

@gdluck
gdluck force-pushed the feat/climate-actuator-status branch from 6b254b6 to 0e44bcd Compare October 24, 2026 21:10
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…n coil (OpenWebNet-HA#792 review, 1)

A fan coil reported by *michnovka#4*Z#N*20*5..8## enabled the fan mode without telling
which side of the zone it is on; with _fan_coil_sides empty,
_season_has_fan_coil() said yes in both seasons and every async_update() sent
*michnovka#4*Z*11##, which the live F461 2.0.11 NACKs after 2.1 s in the valve's season
(f461_live_2026-10-23, run1_oracle_replay_readonly: *michnovka#4*2*11## NACK 2.083 s,
*michnovka#4*2*19## ACK 0.110 s with *michnovka#4*2*19*5*0##).

The zone now queues one *michnovka#4*Z*19## through request_zone_dimension() when a
dimension 20 reply first reveals a fan coil (and on add when a restored fan mode
has no side), and does not poll the fan speed while that answer is pending; once
dimension 19 answers, the poll follows the fan coil's season as before. Every
firmware of the oracle forwards *michnovka#4*Z*19## (own-firmware-oracle#60). A gateway
handler without request_zone_dimension keeps the old behaviour.

Review 5639409551 on OpenWebNet-HA#792, finding 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
… zone state (OpenWebNet-HA#792 review, 2)

_actuator_states mixed the per-actuator reports of dimension 20 ("1", "2") with
the zone-level valves status of dimension 19 under a synthetic "valve" key. With
dimension 20 now requested on every forwarding gateway, zones whose plant also
emits dimension 19 (F455, MyHomeServer1 and F461 traces) held both: a stale
"valve": True kept hvac_action in heating after *michnovka#4*2#1*20*0## (and the other
way round), the "valve" key leaked into the actuators attribute, and switching
the zone OFF cleared the dict so the attribute vanished until an actuator
switched again.

Dimension 20 keeps the per-actuator dict (exposed as "actuators"); dimension 19
sets a zone-level bool fallback; whichever arrived last drives hvac_action, and a
dimension 19 report with both sides off marks the known actuators off. OFF
(zone, central, mode frame) sets every known actuator to False instead of
clearing, so the attribute stays through an OFF/HEAT round trip. The OWNd#116
workaround applies to dimension 19 only.

Review 5639409551 on OpenWebNet-HA#792, finding 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…m dropping the batch (OpenWebNet-HA#792 review, 3)

request_zone_dimension() refused dimension 22 but took any where: "2*22" with
dimension 20 built *michnovka#4*2*22*20##, which OWNCommand.parse reads as a dimension 22
write, the frame that reboots the F461. The frames were also built outside the
try/except around send_paced(), so once the raw string is replaced by the
OWNd#140 builder (OWNHeatingCommand.actuators_status raises ValueError on an
address it does not accept) one zone would have ended the task and dropped every
other pending zone of the batch.

A zone address is now validated right away in request_zone_dimension() (digits
and "#" only, not empty) and again by the per-zone frame builder inside
_send_zone_requests(), where a ValueError is logged and the other zones are still
sent. Routed addresses (3#4#01, #3#4#01, 2#1#4#01) stay accepted; OWNd#140's
_WHO4_WHERE_REGEX is being extended to the same "michnovka#4#" routed forms in a
companion commit.

Review 5639409551 on OpenWebNet-HA#792, finding 3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…t when the zone is added (OpenWebNet-HA#792 review, 4)

watch_zone_actuators() checked actuator_request_refused while the climate
platform was being set up, before initial_discovery() and often before the
in-band identity (WHO 13 dimension 15 / WHO 1013 dimension 1, _apply_model) had
settled or corrected the configured model. A "Generic" entry later identified as
MH202 / MH200N kept sending the refused *michnovka#4*Z*20## after startup and on every
reconnect; a misconfigured MH200N corrected to an F461 / F454 had returned a
no-op callback without recording the zone, so its actuators were never asked
until the entry was reloaded.

The zone is now always ref-counted in _actuator_zones, and the refusal is
evaluated inside _send_zone_requests() after wait_for_initial_discovery(), for
every dimension 20 batch (startup and reconnect alike), so both directions
follow the settled identity.

Review 5639409551 on OpenWebNet-HA#792, finding 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 24, 2026
…he WHO 4 target gateway (OpenWebNet-HA#792 review, 5)

Four edge cases of the per-model tables and the zone watch:

- _model_key compared the profile's model_name with "Generic" instead of its
  class: get_gateway_profile("BTicino F461") is a GenericGatewayProfile named
  "BTicino F461", so the key became "BTICINOF461" and missed both
  ALL_ZONE_REQUESTS and ACTUATOR_REQUEST_REFUSED. The key is now taken from a
  known profile's name, otherwise from the configured model, and a known model
  is looked up as a substring (longest first), like command_session_limit and
  supports_zone_status do.
- The refusal and the all-zone form were read from this handler while
  send_status_request routes WHO 4 through the delegated secondary or the warm
  standby. _who4_target() resolves the same target (_delegated_target /
  _failover_target) and its tables decide: a primary F461 failed over to an
  MH202 sends nothing, failed over to an F454 asks per zone; an MH202
  delegating WHO 4 to an F454 asks the F454.
- A secondary probe (WHERE >= 100, push-only; *michnovka#4*105*...## NACKs in 2.1 s on
  the F461, docs/configuration/climate.md) no longer registers a zone watch.
- MyHomeServer1 gets the all-zone form: in myhomeserver1_live_2026-10-24
  (3.87.13, run2_reads_thermo_sound_alarm_lights) *michnovka#4*#0*20## was ACKed in
  0.39 s with 43 actuator frames for 34 zones, where the per-zone requests take
  about 32 s through the paced sender; the emulated 028206 forwards it ("out").

Review 5639409551 on OpenWebNet-HA#792, finding 5. Draft OpenWebNet-HA#793 carries the same _model_key
helper and needs the same change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck and others added 3 commits October 25, 2026 12:42
…pacing (OpenWebNet-HA#792 review, 2)

`fast_bus_pacing` makes `wait_for_initial_discovery()` give up after 7 * 0.02 s while
`F461.initial_discovery()` sleeps twelve times over its six unwritten frames: with a
dozen milliseconds of timer slack (WSL2, loaded CI runners) the zone requests were queued
before `*OpenWebNet-HA#18*51*113##` and `test_a_gateway_identified_late_as_supported_is_asked` and
`test_the_startup_run_does_not_wait_the_coalesce_window` failed on the frame order
(`test_a_gateway_identified_late_as_refusing_is_not_asked` timed out silently). The three
tests raise `BUS_QUIET_CAP` to 5 s as `test_actuators_are_asked_after_the_startup_sweep`
does: a quiet mock bus never waits for the cap, it only sizes the wait for the sweep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d#140's builder requires (OpenWebNet-HA#792 review, 3)

`_ZONE_WHERE` accepted a one- or two-digit interface (`3#4#1`, `3#4#01`) while the
companion builder `OWNHeatingCommand.actuators_status` (OpenWebNet-HA/OWNd#140, 307dcc9)
requires exactly two (`michnovka#4#[0-9]{2}`) and rejects `3#4#1`, so swapping the string builder
for it would have broken a zone discovered from a frame that spells its interface with one
digit. The address is normalised before it is validated: `_validate_zone_where` returns the
address with a two-digit interface (`3#4#1` -> `3#4#01`), `request_zone_dimension` and
`watch_zone_actuators` store that form (one watch count per zone, whichever spelling asked),
and `_ZONE_WHERE` is tightened to `{2}`. In `climate.py`, `_calling_zones` and the entity's
`_full_where` spell the interface the way the bus frames and the configuration validator
(`BusInterface`, `zfill(2)`) do, so a routed zone has one address everywhere.

Tests: two fail before, pass after (`watch_zone_actuators("3#4#1")` counts `3#4#01` and
sends `*michnovka#4*3#4#01*20##`, unwatch removes it; `*michnovka#4*3#4#1*14*0215##` and `interface="1"`
both give `3#4#01`). `3#4#001` joins the refused addresses; `3#4#1` leaves the list of
addresses accepted as they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ed (OpenWebNet-HA#792 review, 4)

On a gateway that refuses the actuator request (F453, F453AV, H4684, MH200, MH200N,
MH202) every startup and reconnect batch of `_send_zone_requests()` ended with an empty
`frames` list, and `send_paced([])` still ran `_begin_sweep()` / `_end_sweep()`: the sweep
flags toggled for nothing. An empty batch now goes back to the loop.

Test: one fails before, passes after (an MH202 watching a zone never awaits `send_paced`
and `_sweep_idle` stays set).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 25, 2026
…bstring lookup (align with OpenWebNet-HA#792)

_model_key compared the profile's model_name with "Generic" instead of its
class: get_gateway_profile("Legrand MH200N") is a GenericGatewayProfile named
"Legrand MH200N", so the key became "LEGRANDMH200N" and missed
HUMIDITY_REQUEST_REFUSED, and the zone was polled on a gateway whose emulated
firmware does not forward *michnovka#4*Z*60##. The key is now taken from a known
profile's name, otherwise from the configured model, in which a known model is
looked up as a substring (longest first, so MH200N is not read as MH200), like
command_session_limit and supports_zone_status do.

Same helper and the same change as OpenWebNet-HA#792 (review 5639409551, finding 5), so the
two branches no longer differ in what _model_key returns.

Fail-before / pass-after on tests/test_climate_humidity_poll_gate.py: 3 of the
7 new cases fail on the previous helper (Legrand MH200N, BTicino F453AV, the
vendor-prefixed MH200N zone poll), 36 of 36 pass with the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck and others added 2 commits October 25, 2026 13:11
…'s side at runtime (OpenWebNet-HA#792 review, 1)

A mixed valve + fan-coil zone that booted in the valve's season skipped
*michnovka#4*Z*11## (a 2.1 s NACK on the F461 there). The entity is not polled
after add (should_poll is False), so when the season frame *4*0*Z## /
*4*1*Z## later switched the zone to the fan coil's season nothing asked
the speed and fan_mode kept the synthetic "auto" until a user command or
a reconnect (review 5642758778, first note).

The season handler (MESSAGE_TYPE_SEASON, and the MESSAGE_TYPE_MODE
fallback of older OWNd releases) now queues the one-shot
request_zone_dimension(11) the way the dimension 19 / 20 paths do, once:
only a change of side counts (the frame repeats with every zone poll and
OWNd spells the cooling season "conditioning" where a restored state
says "cooling"), only with known sides, and never for the first season
after add (the poll on add asked the speed itself when the sides were
known; dimension 19 asks it once they become known). A transition to the
valve's season requests nothing.

Tests: seven cases in tests/test_climate_actuator_status.py (both
directions, the repeat frames, the valve's season, unknown sides, no fan
coil, restored sides with and without a season, the older-OWNd mode
fallback); four fail before the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rough the climate platform (OpenWebNet-HA#792 review, 2)

The 90 cases of tests/test_climate_actuator_status.py fed inline frame
tuples; the committed trace archives of OpenWebNet-HA#799 were only pinned as facts
(tests/test_f461_live_2026_10_23_trace.py replays run4b, which has no
dimension 20 frame). Two replays now run the real captures through the
climate platform (async_setup_entry, bus discovery on, the frames
delivered on myhome_message_<mac> as the gateway handler does):

- f461_live_2026-10-23, run6_event_session_ha_fixed_integration: the 171
  event-session frames of the 1770 s run while the fixed integration ran.
  hvac_action stays unknown through the five frames of the first *michnovka#4*2##
  reply, is idle with actuators {"1": "off"} on *michnovka#4*2#1*20*0## and
  {"1": "off", "2": "off"} on *michnovka#4*2#2*20*5## (the answer to *michnovka#4*#0*20##),
  and stays so through the six reconnect sweeps (seven polls, the fan
  frame repeated once and reversed once, the hashed *michnovka#4*2#1*OpenWebNet-HA#20*0## echo
  dropped as a command); 24.1 / 23.5 degrees, heating, humidity 38; the
  fan coil asked its side once and no *michnovka#4*2*11## went out in heating.
- myhomeserver1_live_2026-10-24, run2_reads_thermo_sound_alarm_lights:
  the 43 actuator frames that follow *michnovka#4*#0*20## on the 34-zone plant.
  Every configured zone (35..70) ends idle with its actuators filled from
  the Z#1 / Z#2 frames, the five fan coils (47, 48, 57, 60, 70) ask
  dimension 19 once each in the order the plant answered, and the zone-0
  pump frames 0#1..0#4 discover no zone, are watched by nothing and are
  asked for nothing.

Both fail on v2-phase1-architecture (nothing watches the zones, no
actuators attribute) and pass with this branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck
gdluck force-pushed the feat/climate-actuator-status branch from 0e44bcd to 4a41da6 Compare October 25, 2026 11:17
@gdluck

gdluck commented Oct 25, 2026

Copy link
Copy Markdown
Author

Thanks for the audit. Oracle #60 merged at 10:33 UTC, so this is rebased on the current base (089fef5) with the two notes addressed; head 4a41da6, still draft until CI confirms, then I mark it ready as you asked.

  1. Runtime seasonal transition (9352638): the season frame now queues the one-shot *#4*Z*11## when a zone with known sides moves from the valve's season to the fan coil's, the same way the dimension 19 / 20 paths do. Only a change of side counts: repeated season frames, conditioning against a restored cooling, the first season after add and a transition to the valve's season request nothing; unknown sides are left to the pending dimension 19. Seven cases, four fail before. One corner case accepted and noted: a zone restored in heating with fan coils on both sides whose plant is meanwhile in cooling asks dimension 11 twice at startup (poll on add, then the first season frame), the second read reflecting the real season.
  2. Trace replays through MyHOMEClimate (4a41da6): tests/test_climate_actuator_status.py now replays f461_live_2026-10-23 run run6_event_session_ha_fixed_integration (171 frames: hvac_action None through the five poll frames, idle with {"1": "off"} on *#4*2#1*20*0##, {"1": "off", "2": "off"} on *#4*2#2*20*5##, unchanged through the reconnect sweeps, the hashed *#4*2#1*#20*0## dropped, exactly [("2", 19)] requested) and the 43-frame *#4*#0*20## burst of myhomeserver1_live_2026-10-24 run run2_reads_thermo_sound_alarm_lights through the real climate platform (34 configured zones 35..70 each idle with their actuators, {"1", "2"} on 47 / 48 / 57 / 60 / 70, dimension 19 asked once per fan coil in bus order, len(added) == 34: the 0#1..0#4 pump frames discover no phantom zone, no request for zone 0). Both fail on the base branch.
  3. Body: the "(unpublished)" wording is gone; feat(oracle): add thermo-zone-requests suite (per-zone polls, actuators, humidity, interface form) own-firmware-oracle#60 is cited as merged on main (52d354b, findings/thermo-zone-requests.md and the .cases file) with the per-model rows this PR relies on, and a note that the per-gateway TSV rows follow the next oracle CI run on main.
  4. The command_session_idle_timeout heads-up is noted for the OWNd pin bump, not this PR. The 2026-10-25 actuator-switching capture (and a MyHomeServer1 read-only capture of the same day, 36 zones) is being ingested as its own trace PR, following the one-trace-PR-per-capture convention; I will link it here. Section 3 (emulating the plant in oracle/plant/) is a separate oracle follow-up.

Checks: ruff, typing ratchet (0 strict errors), 4658 passed / 1 skipped at 100 % statement coverage against plain OWNd master, verify_ha_standards, sync_documentation --check and manage_translations check clean; test_climate_actuator_status.py 99 cases (90 before). An independent second-opinion review of the diff raised the snapshot order in the mode fallback and a too-wide frame filter in the F461 replay; both folded in.

@gdluck
gdluck marked this pull request as ready for review October 25, 2026 11:30
@gdluck

gdluck commented Oct 25, 2026

Copy link
Copy Markdown
Author

All checks green on 4a41da6 and OpenWebNet-HA/own-firmware-oracle#60 is merged; marking ready for review as asked.

@gdluck

gdluck commented Oct 25, 2026

Copy link
Copy Markdown
Author

The trace PR for the 2026-10-25 captures is #815 (F461 actuator switching and a MyHomeServer1 36-zone read-only capture, both replayed through the climate entity); the body's item 4 now links it.

gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 25, 2026
…arm-standby failover

`send_status_request` routes a WHO 4 request through the secondary gateway WHO 4 is
delegated to, or through the warm standby while the primary is disconnected, but
`humidity_request_refused` judged the primary's own model: a primary MH202 / F454
delegating WHO 4 to an F461 / MyHomeServer1 skipped `*michnovka#4*Z*60##` although the
request would have gone through the forwarding gateway, and a primary F461 failed
over to a standby MH202 / F454 sent it into the refusing standby (review
5644219403, finding 1).

The property now asks the same `_who4_target()` helper OpenWebNet-HA#792 adds (copied verbatim:
`_delegated_target(probe) or _failover_target(probe) or self` on a fixed WHO 4
probe) and returns that gateway's verdict when it is not this one. Thirteen test
cases: delegation to a forwarding / refusing secondary, failover to a refusing /
forwarding standby (both directions, F461 / MyHomeServer1 against MH202 / F454),
the secondary preferred to the standby, the standalone case unchanged, and two
cases through the real topology lookup (a configured secondary with WHO 4
delegated and a configured standby, connected or not). 13 fail before, 49 pass
after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 25, 2026
… models included

`_ZONE_REQUEST_MODELS` was built from `HUMIDITY_REQUEST_REFUSED` alone, so a
forwarding model behind a brand prefix was not reduced to its model: `_model_key`
of "BTicino F461" was "BTICINOF461", against the property's own docstring and
against OpenWebNet-HA#792, whose lookup (`ACTUATOR_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS)`)
knows F461 and MyHomeServer1 but not F450 and F454 (review 5644219403, finding 2).

`KNOWN_GATEWAY_MODELS` now lists every model the tables can be keyed on: the
twelve images the oracle emulates (refusing and forwarding) and the other models
OWNd profiles (F452, F452V, F455, H4890, MH201), and `_ZONE_REQUEST_MODELS` is
that set united with the refusing table, longest first as before. The test at the
`BTicino F461` case asserts `_model_key == "F461"`, and every known model is
checked behind a brand prefix (9 of the 17 fail on the previous lookup, the
forwarding ones). 67 pass after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 25, 2026
…; drop the OWNd#138 attribution

Review of OpenWebNet-HA#815 (5644290768): the replay tests asserted the whole
_actuator_states dict including the base branch's "valve" key, which OpenWebNet-HA#792
moves to _zone_active; they now assert the per-actuator keys only. The
'OpenWebNet-HA#20' write-form echoes are not covered by OpenWebNet-HA/OWNd#138 (dimension
14 values only), so that attribution is removed from the READMEs and tests.
The F461 tx check accepts is_dimension_request beside the archived
is_status_request value, so a later OWNd classification cannot fail the
conformance gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@GreenGrassBlueOcean GreenGrassBlueOcean left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final Approval Audit of PR #792 (4a41da61)

All prior review items (5639409551, 5639881123) and both follow-up notes from 5642758778 are cleanly addressed, OpenWebNet-HA/own-firmware-oracle#60 (52d354bb) is merged, and the branch is rebased cleanly onto v2-phase1-architecture (089fef51). Ready to land.

1. Verification Summary

  • GitHub CI (4a41da61): 17 / 17 check-runs success (test-coverage 100%, Run Pytest Suite & Coverage (Python 3.14), OWNd pinned/latest/dev, mypy-ratchet, validate, HA Compatibility & Container stable/beta/dev, Audit quality_scale.yaml, workflow-lint).
  • Local WSL Verification (Python 3.14.7, HA 2026.10.1, pinned OWNd==2.0.0b11): 614 / 614 climate, gateway, F461, and oracle-validation tests pass with 100.0% diff statement coverage across custom_components/myhome/climate.py and custom_components/myhome/gateway.py; ruff check, scripts/verify_ha_standards.py, scripts/sync_documentation.py --check, scripts/manage_translations.py check, and scripts/typing_ratchet.py (0 strict errors) all pass.

2. Review of Follow-up Commits (0e44bcd7 → 4a41da61)

  1. Runtime seasonal transition (9352638f): _season_side() and _follow_season_change() in custom_components/myhome/climate.py handle runtime transitions from a valve season to a fan-coil season across both MESSAGE_TYPE_SEASON and the legacy MESSAGE_TYPE_MODE (*4*0*Z## / *4*1*Z##) fallback (capturing previous_season before _remember_season()). Normalizes cooling / conditioning to a single side, ignores repeated poll season frames and initial None → season startup, and is covered by 7 dedicated test cases.
  2. Real-plant trace replays (4a41da61): tests/test_climate_actuator_status.py now replays f461_live_2026-10-23 (run6_event_session_ha_fixed_integration, 171 event-session frames across 7 sweeps) and myhomeserver1_live_2026-10-24 (run2_reads_thermo_sound_alarm_lights, 43-frame *#4*#0*20## burst across 34 configured zones plus 0#1..0#4 pumps) directly through climate.async_setup_entry and myhome_message_<mac>, verifying hvac_action: idle, actuators population, one-shot dimension 19 queries on the 5 fan-coil zones, and zero phantom zone creation/polling on 0#1..0#4. Companion 2026-10-25 active switching captures are cleanly tracked in #815.

3. Post-Merge Coordination

  • #793 shares _model_key in custom_components/myhome/gateway.py and the WHO 4 bullet in .github/release-notes/2.0.0b16.md and will need a rebase onto v2-phase1-architecture after this merge.
  • _zone_request_frame / ALL_ZONE_REQUESTS are aligned ({2} #4# interface digits) for a drop-in swap to OWNHeatingCommand.actuators_status once OpenWebNet-HA/OWNd#140 is released on PyPI.

Great work across the hardware captures, oracle verification, and edge-case test suite!

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit 2ece474 into OpenWebNet-HA:v2-phase1-architecture Oct 25, 2026
17 checks passed
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 25, 2026
…bstring lookup (align with OpenWebNet-HA#792)

_model_key compared the profile's model_name with "Generic" instead of its
class: get_gateway_profile("Legrand MH200N") is a GenericGatewayProfile named
"Legrand MH200N", so the key became "LEGRANDMH200N" and missed
HUMIDITY_REQUEST_REFUSED, and the zone was polled on a gateway whose emulated
firmware does not forward *michnovka#4*Z*60##. The key is now taken from a known
profile's name, otherwise from the configured model, in which a known model is
looked up as a substring (longest first, so MH200N is not read as MH200), like
command_session_limit and supports_zone_status do.

Same helper and the same change as OpenWebNet-HA#792 (review 5639409551, finding 5), so the
two branches no longer differ in what _model_key returns.

Fail-before / pass-after on tests/test_climate_humidity_poll_gate.py: 3 of the
7 new cases fail on the previous helper (Legrand MH200N, BTicino F453AV, the
vendor-prefixed MH200N zone poll), 36 of 36 pass with the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 25, 2026
…arm-standby failover

`send_status_request` routes a WHO 4 request through the secondary gateway WHO 4 is
delegated to, or through the warm standby while the primary is disconnected, but
`humidity_request_refused` judged the primary's own model: a primary MH202 / F454
delegating WHO 4 to an F461 / MyHomeServer1 skipped `*michnovka#4*Z*60##` although the
request would have gone through the forwarding gateway, and a primary F461 failed
over to a standby MH202 / F454 sent it into the refusing standby (review
5644219403, finding 1).

The property now asks the same `_who4_target()` helper OpenWebNet-HA#792 adds (copied verbatim:
`_delegated_target(probe) or _failover_target(probe) or self` on a fixed WHO 4
probe) and returns that gateway's verdict when it is not this one. Thirteen test
cases: delegation to a forwarding / refusing secondary, failover to a refusing /
forwarding standby (both directions, F461 / MyHomeServer1 against MH202 / F454),
the secondary preferred to the standby, the standalone case unchanged, and two
cases through the real topology lookup (a configured secondary with WHO 4
delegated and a configured standby, connected or not). 13 fail before, 49 pass
after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 25, 2026
… models included

`_ZONE_REQUEST_MODELS` was built from `HUMIDITY_REQUEST_REFUSED` alone, so a
forwarding model behind a brand prefix was not reduced to its model: `_model_key`
of "BTicino F461" was "BTICINOF461", against the property's own docstring and
against OpenWebNet-HA#792, whose lookup (`ACTUATOR_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS)`)
knows F461 and MyHomeServer1 but not F450 and F454 (review 5644219403, finding 2).

`KNOWN_GATEWAY_MODELS` now lists every model the tables can be keyed on: the
twelve images the oracle emulates (refusing and forwarding) and the other models
OWNd profiles (F452, F452V, F455, H4890, MH201), and `_ZONE_REQUEST_MODELS` is
that set united with the refusing table, longest first as before. The test at the
`BTicino F461` case asserts `_model_key == "F461"`, and every known model is
checked behind a brand prefix (9 of the 17 fail on the previous lookup, the
forwarding ones). 67 pass after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 25, 2026
…h the climate platform (OpenWebNet-HA#793 review, 4)

The captured-frames section fed the F461's spontaneous *michnovka#4*2*60*38## pushes
(f461_live_2026-10-23, run6) into an MH202 handler and pre-built MyHOMEClimate
with humidity=True, bypassing discovery and _enable_humidity(). That test is
gone from the trace section; the MH202 case is now a unit test in the
"nothing to send" section with an inline frame, framed as integration-side
delivery (no trace claim).

The replays go through climate.async_setup_entry and
async_dispatcher_send(hass, f"myhome_message_{mac}", ...) with the helpers of
tests/test_climate_actuator_status.py (OpenWebNet-HA#792), each capture on the model it was
recorded on, with a real handler's supports_zone_status and
humidity_request_refused on the trace gateway:

- F461 run6: the 171 event-session frames; the three *michnovka#4*2*60*38## pushes
  enable humidity on a zone configured without it (38.0), the next poll then
  asks *michnovka#4*2##, *michnovka#4*2*60##. Run2: *michnovka#4*2*60## ACKed with *michnovka#4*2*60*39## in
  0.143 s (one echo on the event session), *michnovka#4*0*60## answered by zone 2 in
  2.128 s; the echo sets 39.0.
- MyHomeServer1 run2: *michnovka#4*35*60## NACKed in 2.128 s with nothing on the event
  session; *michnovka#4*0*60## ACKed in 0.499 s, 17 zones answer; on an 18-zone plant
  the 17 take their value, zone 35 stays without humidity and its poll asks
  *michnovka#4*35## only. Run3: *michnovka#4*39*60## ACKed with *michnovka#4*39*60*51## in 0.204 s; 51.0.
- F455 (OpenWebNet-HA#674): the 67 frames on a 23-zone plant (the README's zones); the
  spontaneous *michnovka#4*42*60*52## enables humidity on zone 42 alone (52.0), the
  other 22 zones stay without, and the next poll asks *michnovka#4*42*60## and no
  *michnovka#4*42## (the F455 is not in the table and does not answer zone status).

103 pass (the five replays and the MH202 unit test replace the five previous
trace cases).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GreenGrassBlueOcean pushed a commit that referenced this pull request Oct 25, 2026
… (actuator status, fan speed and humidity per zone)

Two physical captures behind #792 (actuator status at startup, hvac_action from
dimension 20) and #793 (humidity gate), asked for in review 5642758778 of #792.

f461_actuators_live_2026-10-25: a local Home Assistant at #792's head restarted
with an event-only listener on the F461 2.0.11 / KW4691 zone 2 plant. The
startup sweep is followed by *#4*#0*20##, answered by both actuators within
0.13 s (ACK after 2.13 s); climate.set_temperature 26.0 makes the valve report
itself on 2.2 s after the dimension 12 echo (*#4*2#1*#20*1##, then
*#4*2#1*20*1##), the restore to 23.5 makes it report itself off 2.1 s after the
echo; the fan coil stays 5 / off. Every request is Home Assistant's, from its
debug log, placed on the listener clock (source: home_assistant, t_estimated).
The replay through the climate entity gives hvac_action idle -> heating -> idle
and 23.5 -> 26.0 -> 23.5; the actuators attribute is asserted only where the
entity exposes it.

myhomeserver1_live_2026-10-25: status reads only on the MyHomeServer1 3.87.13
plant (zones 35-70): *#4*#0*20## ACKed in 0.50 s ahead of a 1.9 s burst of 43
distinct actuator frames (34 Z#1, 5 Z#2, the pumps 0#1..0#4, every one off);
per zone *#4*Z*19## / *#4*Z*20## ACK on 34 zones and NACK on 54 / 56,
*#4*Z*11## NACK on all 36 (the fan-speed dimension is refused on a valve zone),
*#4*Z*60## ACK on the 17 zones with a probe and NACK on 19, *#4*0*60## answers
the same 17; a 600 s listen with the house's own traffic. The replay of zones 35
and 47 through the climate entity gives idle with every actuator off; the pump
frames have no route key and reach no zone.

The 34 per-zone *#4*Z*60## reads join PHYSICAL_ONLY_TX_FRAMES (no oracle
stimulus for a per-zone humidity read). Every rx frame parses in the pinned
OWNd; the '#20' command forms parse as OWNHeatingCommand (OpenWebNet-HA/OWNd#138
decodes them, both are accepted). Synthetic MACs 00:03:50:00:10:25 /
00:03:50:00:10:26, documentation IPs; no log, no local path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck

gdluck commented Oct 25, 2026

Copy link
Copy Markdown
Author

The oracle companion for section 3 of review 5642758778 is OpenWebNet-HA/own-firmware-oracle#64: oracle/plant/ now answers the all-zone *#4*#0*20## (and Z#0) with every zone's actuators, season-gates dimension 11 (the emulated F461 NACKs it in heating like the KW4691 and answers it in cooling) and answers dimension 60 from the fixture, and switches the active season's actuator on a setpoint write; plant-f461-bench.seq carries the new steps and the refreshed TSV is compared row by row with the physical captures in its body.

@gdluck

gdluck commented Oct 26, 2026

Copy link
Copy Markdown
Author

Confirming on my own plant (BTicino F461 2.0.11, one standalone KW4691 zone with a valve actuator and a fan coil, Home Assistant 2026.10.4, OWNd 2.0.0b11), running the current v2-phase1-architecture head (f4a5a90):

  • Before this PR the thermostat card stayed in the orange heat colour with no hvac_action after every restart, because nothing asked the gateway for the actuators and the F461 only broadcasts them by itself about every 61 minutes (first *#4*2#1*20*0## 56 minutes after a 21:05 start on the previous build).
  • With feat(climate): request actuator status (dimension 20) on add and after reconnect, expose actuators, derive hvac_action #792, right after the startup sweep the integration sent *#4*#0*20##, both actuators answered within a second (*#4*2#1*20*0##, *#4*2#2*20*5##), and the entity showed hvac_action: idle with actuators: {"1": "off", "2": "off"} about 12 seconds after the restart; the icon changed to the idle colour at once.

So the actuator state is known at start now, no waiting for the hourly broadcast. Thanks for the quick reviews on this one.

GreenGrassBlueOcean pushed a commit to OpenWebNet-HA/own-firmware-oracle that referenced this pull request Oct 26, 2026
…Z#0 form

Section 3 of the OpenWebNet-HA/MyHOME#792 review asked the virtual plant
to answer the all-zones actuator request, which the F461 2.0.11 and the
MyHomeServer1 put on the bus as B9 00 30 10 (the published F461 full-harness
row shows $004B9003010 followed by a NACK on the empty bus). Every
ThermoZoneDevice now answers that telegram for itself with the same
per-actuator B7 <80+Z> 3<A> <state> frames it gives *#4*Z*20## (A9 <Z> 30 10),
and also the A9 <80+Z> 30 10 form the F453-class gateways write for
*#4*Z#0*20##. The per-actuator encoding moved into actuator_frame() /
actuator_frames() so the three queries share one path. Unit test: single
zone, two-zone plant answering in zone order, a plant without zones staying
silent, and the PIC wrapper.

Physical anchor: f461_actuators_live_2026-10-25 (MyHOME fixture), where both
actuators of zone 2 answered *#4*#0*20## within 130 ms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants