Repository navigation
feat(climate): request actuator status (dimension 20) on add and after reconnect, expose actuators, derive hvac_action - #792
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… 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>
dde2161 to
c892706
Compare
c892706 to
fa97b2f
Compare
There was a problem hiding this comment.
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 completedsuccess(OWNdpinned/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.pypasses 28/28 with 100% diff coverage; existing test_climate.py andtest_trace_oracle_validation.pypass 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_pacedintry / except ExceptionwithLOGGER.exceptionso_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:
- On the author's F461
2.0.11plant (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##. OWNHeatingEvent("*#4*2#2*20*5##")setsfan_on = False(isinstance(..., bool)isTrue), which calls_enable_fan_mode()(self._fan = True, addingFAN_MODEandfan_mode: "auto").- Because nothing ever queries Dimension 19 (
*#4*2*19##),self._fan_coil_sidesremains{}._season_has_fan_coil()therefore returnsTrueeven in heating season. - In
f461_live_2026-10-23(run1_oracle_replay_readonly),*#4*2*11##in heating season NACKs after2.083 s, whereas*#4*2*19##ACKs in0.110 swith*#4*2*19*5*0##(cooling: True, heating: False). - Consequently, every subsequent
async_update()(on setpoint command echo, reconnect poll, or next HA restart whenasync_restore_last_state()restoresfan_modewhile_fan_coil_sidesis still{}) sends*#4*2*11##and eats a 2.1 s NACK. Worse, onF454/F455(_gateway_supports_zone_status == False), flippingself._fan = Truebypasses the early-return guard atclimate.py:526-531.
- On the author's F461
- Recommendation: When a Dimension 20 reply first reveals a fan coil (
is_fan and not self._fan_coil_sides), queue a one-shotrequest_zone_dimension(self._full_where, 19)(own-firmware-oracle#60shows*#4*2*19##is forwarded on all 12 gateway firmwares) so_fan_coil_sidesis populated beforeasync_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_eventandMyHOMEClimate.extra_state_attributes(climate.py:445-448in PR). - What happens:
- Dimension 19 frames (
*#4*Z*19*C*H##— present inmyhome_trace_f455_climate_timeouts_2026-10-09.json,myhome_trace_MyHomeServer1_live_2026-10-07.json, andmyhome_trace_F461_live_2026-10-15.json) fall throughactuator_idto"valve". Because PR #792 now actively requests Dimension 20 onF454,F455,MyHomeServer1, andF461, zones on plants emitting Dimension 19 will hold both"valve"and"1","2"inself._actuator_states:- Stuck
hvac_action: If Dimension 19 recordedself._actuator_states["valve"] = Trueand the actuator later closes via Dimension 20 (*#4*2#1*20*0##) without a new Dimension 19 frame,_actuator_statesis{"valve": True, "1": False, "2": False}→any(self._actuator_states.values())staysTrueandhvac_actionstays permanently stuck inheating/cooling. (And vice versa if"1": Trueis followed only by*#4*2*19*0*0##.) - Attribute pollution:
extra_state_attributes["actuators"]exposes{"1": "off", "2": "off", "valve": "on"}, leaking the synthetic"valve"key into user-facing state attributes. - Disappearing attribute on
OFF: Turning a zone or central unitOFF(climate.py:640,1036,1090) callsself._actuator_states.clear(). Theactuatorsattribute disappears whileOFFand stays missing when the zone returns toHEAT/AUTOuntil an actuator switches or the session reconnects.
- Stuck
- Dimension 19 frames (
- Recommendation: Separate Dimension 20 per-actuator states (
dict[str, bool], exposed inactuators) from the Dimension 19 zone-level fallback (bool | None), let whichever arrived most recently (or Dim 20 once known, updated onOFFto{k: False for k in ...}) driveany_active, and never expose"valve"inextra_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-434andtest_routed_zone_is_asked_on_its_ownin PR #792 vsOWNHeatingCommand.actuators_statusinOpenWebNet-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 byown-firmware-oracle#60showing 8 of 12 gateways forward*#4*2#4#01*20##), and Solution #6 states the raw format stringf"*#4*{where}*{dimension}##"will be replaced byOWNHeatingCommand.actuators_status(where)fromOWNd#140. - However,
OWNd#140validateswherewith_WHO4_WHERE_REGEX = re.compile(r"#?[0-9]+(?:#[0-9]+)?"), which rejects all 3-part#addresses (OWNHeatingCommand.actuators_status("3#4#01")raisesValueError: Invalid zone address: 3#4#01). - Furthermore, in
_send_zone_requests(), frame construction sits outside thetry: await self.send_paced(frames)guard — so once switched toOWNHeatingCommand.actuators_status(where), one3#4#01zone will raise an uncaughtValueError, 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)checksdimension == 22but does not reject*inwhere(e.g.,where="2*22"produces*#4*2*22*20##, whichOWNCommand.parseparses as a Dimension 22 write command!).
- PR #792 explicitly supports F422 interface-routed zones (
- Recommendation:
- Extend
_WHO4_WHERE_REGEXinOWNd#140before release to allow bus-interface routing(?:#4#[0-9]{2})?(e.g.3#4#01,#3#4#01,2#1#4#01). - Reject invalid
where(at minimum"*" in where or not where) right insiderequest_zone_dimension()now, and keep builder calls inside thetry / exceptwhenOWNd#140is adopted.
- Extend
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-489in PR #792 vsGatewayIdentityMixin._resolve_identity(and companionMyHOME#804). - What happens:
- Restored/YAML climate entities run
async_added_to_hass()→watch_zone_actuators()during platform setup, beforeinitial_discovery()completes and often before WHO=13 dim 15 / WHO=1013 dim 1 (_apply_model) settles or correctsself.gateway.model_nameandself.gateway.profile. - Direction A (
Generic→MH202/MH200N): A manually added"Generic"gateway (especially relevant withMyHOME#804) hasactuator_request_refused == Falsewhen entities are added, so zones enter_actuator_zonesand(where, 20)is queued. Once WHO=13/1013 identifies the gateway asMH202orMH200N, neither_send_zone_requests()nor_on_event_connection_state_change()re-checksself.actuator_request_refused, so refused*#4*Z*20##frames are sent after startup and on every reconnect. - Direction B (misconfigured
MH200N→ corrected toF461/F454):watch_zone_actuators()returnslambda: Noneearly without recordingwherein_actuator_zones, permanently losing actuator polling until the entry is reloaded.
- Restored/YAML climate entities run
- Recommendation: Always ref-count
whereinself._actuator_zonesinsidewatch_zone_actuators(), and evaluateself.actuator_request_refusedinside_send_zone_requests()(afterwait_for_initial_discovery(), when identity has settled) whendimension == 20.
5. [Medium — Gateway Profile, Topology & Sibling-PR Edge Cases]
_model_keywithGenericGatewayProfile(gateway.py:362-366):OWNd.profiles.get_gateway_profile("BTicino F461")returnsGenericGatewayProfile(model_name="BTicino F461"). Checkingprofile_model != "Generic"rather thannot isinstance(profile, GenericGatewayProfile)(comparecommand_session_limitatgateway.py:128) produces"BTICINOF461", missing bothALL_ZONE_REQUESTS["F461"]andACTUATOR_REQUEST_REFUSED(unlikegateway_model_familyor substring matching insupports_zone_status). Note also that draft PR #793 adds an identical_model_keyhelper 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:430vssend_status_request):send_status_requestroutes WHO 4 frames through_delegated_target/_failover_target, whereasactuator_request_refusedandall_zone_request(20)inspectselfrather than the active WHO 4 target gateway (e.g., primaryF461failed over to standbyMH202will send*#4*#0*20##to theMH202, which drops it; primaryMH202delegating WHO 4 to secondaryF454will 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 inf461_live_2026-10-23). Guardingwatch_zone_actuatorsinclimate.py:637against 3-digit secondary probe addresses (not justnot self._central) avoids polling any legacy-configured secondary probe entity. MyHomeServer1omitted fromALL_ZONE_REQUESTS(gateway.py:162): Inmyhomeserver1_live_2026-10-24(3.87.13),*#4*#0*20##returned all 34 zones (43 actuator frames on the event session) in0.39 s, and emulatedMyHomeServer1 028206also forwards*#4*#0*20##(out). Asking 34 zones individually viasend_pacedtakes~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 whyMyHomeServer1is intentionally kept per-zone.
6. [Low — Protocol / Oracle Gap & Minor Nits]
- Missing Encyclopedia wildcard forms in
own-firmware-oracle#60: WhileF453,F453AV, andMH200Ndrop*#4*Z*20##, the oracle shows all three forward*#4*#0*20##and*#4*Z#N*20##(andMH202ACKs+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 inown-firmware-oracle#60. Addingdown *#4*2#0*20##anddown *#4*0#0*20##tothermo-zone-requests.casesbefore CI runs it would settle whetherF453/F453AV/MH200N/MH202can actually query actuators viaZ#0or#0instead of refusing them altogether. - Hardcoded
"#4#"(gateway.py:431, 433): Prefer importingBUS_ROUTINGfrom 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 swhen_initial_discovery_doneis already set) avoids back-to-back#0*20sweeps during dynamic discovery.
fa97b2f to
6b254b6
Compare
…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>
… 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>
…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>
…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>
…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>
…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>
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>
…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>
…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>
|
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.
Second-opinion points argued and left: the WHO 4 target used for the policy is the one |
…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>
|
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):
Both forms behave exactly like |
GreenGrassBlueOcean
left a comment
There was a problem hiding this comment.
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_statesvs Dim 19): Resolved — per-actuator_actuator_states(actuatorsattribute) and zone-level_zone_active/_active_sourceare cleanly separated,"valve"never leaks intoactuators, and_mark_actuators_off()preserves known keys acrossOFFtransitions. - Finding 4 (Startup identity race): Resolved —
watch_zone_actuatorsunconditionally ref-counts_actuator_zonesand evaluatestarget.actuator_request_refusedinside_send_zone_requests()afterwait_for_initial_discovery(). - Finding 5 (
GenericGatewayProfile, WHO 4 routing, probes,MyHomeServer1): Resolved —_model_keychecksnot isinstance(profile, GenericGatewayProfile)with longest-first_ZONE_REQUEST_MODELSlookup,_who4_target()follows_delegated_target/_failover_target,not is_probe(self._where)guardswatch_zone_actuators, and"MYHOMESERVER1"uses*#4*#0*20##. - Finding 6 (
BUS_ROUTING, coalescing, oracle wildcard cases): Resolved —BUS_ROUTINGimported fromconst.py,ZONE_REQUEST_COALESCEcoalesces post-sweep and mid-batch arrivals, and*#4*2#0*20##/*#4*0#0*20##are recorded inown-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 line695beforeif self._fan: self._ask_fan_coil_sides()at lines703-706. InsideMyHOMEEntity.async_added_to_hass()(myhome_device.py:264-285),async_restore_last_state()runs first, and wheneverinitial_discovery_pendingandsweep_activeareFalse(dynamically discovered zone, reloaded/re-enabled entity, or insidetest_restored_fan_mode_without_the_side_asks_for_it_on_addattests/test_climate_actuator_status.py:334-342), line285immediately awaitsself.async_update()while_fan_coil_sides_asked_atis stillNone— sending['*#4*2##', '*#4*2*11##']before_ask_fan_coil_sides()runs (assert "*#4*2*11##" not in _status_frames(zone)currently fails intest_restored_fan_mode_without_the_side_asks_for_it_on_add). Also note that wheninitial_discovery_pendingisTrue, stamping_fan_coil_sides_asked_at = time.monotonic()beforewait_for_initial_discovery()means a busy startup sweep lasting longer thanFAN_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):MyHOMEEntitysetsself._attr_should_poll = False, soasync_update()runs only once on add (the tests intest_climate_actuator_status.pyandtest_f461_live_plant.pymasked this by callingawait zone.async_update()manually after feeding*#4*2*19*5*0##). Whenself._fanis first enabled afterasync_update()by*#4*2#2*20*5##(or whenasync_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 = Nonewithout ever requesting dimension11(*#4*Z*11##). Because Dimension 20 value5(fan off) carries no configured speed,fan_modestays stuck on the synthetic default"auto"instead of reading the thermostat's actual speed (low/medium/high/auto). - Recommendation:
- Call
_ask_fan_coil_sides()beforeawait super().async_added_to_hass()(forfan=Truein__init__) and at the end ofasync_restore_last_state()(for restoredfan_modewithoutfan_coil_*, guarded bynot self._central and not is_probe(self._where)), and avoid expiringFAN_COIL_SIDES_WAITwhileinitial_discovery_pending/sweep_activeis still holding back_send_zone_requests(). - When
dimension == 19arrives inhandle_eventand resolves unknown/pending_fan_coil_sideswithself._fan and self._season_has_fan_coil(), queuerequest_zone_dimension(self._full_where, 11).
- Call
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) setsPACED_WRITE_TIMEOUT = 0.01,BUS_QUIET_CAP = 0.01, andBUS_QUIET_PERIOD = 0.005, sowait_for_initial_discovery()computestimeout = 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 bothtest_a_gateway_identified_late_as_supported_is_askedandtest_the_startup_run_does_not_wait_the_coalesce_windowfail withAssertionError: assert '*#18*51*113##' == '*#4*#0*20##'(test_a_gateway_identified_late_as_refusing_is_not_askedsilently hits the same premature timeout). - Recommendation: Apply the same
monkeypatch.setattr("custom_components.myhome.gateway.BUS_QUIET_CAP", 5.0)guard already used intest_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, 189andtests/test_climate_actuator_status.py:421vs OpenWebNet-HA/OWNd#140 (307dcc9,OWNd/message/heating.py:83,tests/test_who4_actuators_status.py:92). - What happens:
_ZONE_WHEREhere accepts[0-9]{1,2},test_a_plain_or_routed_zone_address_is_acceptedexplicitly asserts"3#4#1"is accepted, and_validate_zone_where's docstring saysOWNHeatingCommand.actuators_statusinOWNd#140"rejects the same". However,OWNd#140uses(?:#4#[0-9]{2})?and explicitly assertsactuators_status("3#4#1")raisesValueError. Since_calling_zones(climate.py:238) keeps rawstr(tail[1])withoutzfill(2), please align the two regexes (relaxingOWNd#140to{1,2}or normalizing + tightening both to{2}) so swapping_zone_request_frameforOWNHeatingCommand.actuators_statusdoes 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 forMH202,MH200N,MH200,F453,F453AV,H4684),framesis[], yetawait self.send_paced([])still runs_begin_sweep()/_end_sweep()(_sweep_idle.clear()→_sweep_idle.set()). Guardingif not frames: continueavoids empty sweep transitions.
| 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() |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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).
|
|
||
| # 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}})?$") |
There was a problem hiding this comment.
_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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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##" |
There was a problem hiding this comment.
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.
6b254b6 to
0e44bcd
Compare
…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>
… 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>
…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>
…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>
…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>
…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>
…fan speed once it is known (OpenWebNet-HA#792 review, 1) `super().async_added_to_hass()` restores the state and, once the startup sweep is over, polls the zone right away; `_ask_fan_coil_sides()` ran after it, so the poll on add saw no pending dimension 19 and sent `*michnovka#4*Z*11##` blindly (NACKed after 2.1 s in the valve's season on the F461). The side is now asked before `super().async_added_to_hass()` for a configured fan coil, and at the end of `async_restore_last_state()` for a restored fan mode without `fan_coil_*` (zones only: no central unit, no secondary probe). The entity is not polled after add (`should_poll` is False), so a dimension 19 reply that resolves the side in the fan coil's season now queues `*michnovka#4*Z*11##` through the gateway's paced sender: dimension 20 value 5 (fan off) carries no speed and `fan_mode` stayed on the synthetic `auto` until a poll that never came. The same when the fan coil shows up (dimension 20) once the side is already known from an earlier dimension 19, and when a dimension 19 itself reveals the fan coil; a known side in the valve's season asks nothing. The `FAN_COIL_SIDES_WAIT` clock no longer runs while `initial_discovery_pending` or `sweep_active` holds the zone requests back: a startup sweep longer than 30 s expired the wait before `*michnovka#4*Z*19##` was ever on the wire. A request queued during a hold is marked held; the wait starts at the first check after the hold is over (nothing checks the entity during a hold, so the stamp taken then would be stale). Tests: eight fail before, pass after (the restored fan mode and the configured fan coil send `*michnovka#4*2##` only, `*michnovka#4*2*11##` is not sent before `*michnovka#4*2*19##`; a restored state through `async_restore_last_state` asks dimension 19 before the poll; a dimension 19 reply in the fan coil's season asks dimension 11 once, in the valve's season never; a zone-level report revealing the fan coil asks the speed; a fan coil revealed after its side asks the speed; a sweep longer than the wait keeps the request pending, checked during the hold or only after it, and the wait starts once the sweep is over). Restored sides are not asked again. `tests/test_component_climate.py::test_climate_fan_mode_and_attributes` asserted the blind `*michnovka#4*5*11##` on add and now expects the dimension 19 request instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…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>
…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>
…'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>
0e44bcd to
4a41da6
Compare
|
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.
Checks: ruff, typing ratchet (0 strict errors), 4658 passed / 1 skipped at 100 % statement coverage against plain OWNd |
|
All checks green on 4a41da6 and OpenWebNet-HA/own-firmware-oracle#60 is merged; marking ready for review as asked. |
|
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. |
…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>
… 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>
…; 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
left a comment
There was a problem hiding this comment.
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-runssuccess(test-coverage100%,Run Pytest Suite & Coverage (Python 3.14),OWNdpinned/latest/dev,mypy-ratchet,validate, HA Compatibility & Containerstable/beta/dev,Audit quality_scale.yaml,workflow-lint). - Local WSL Verification (
Python 3.14.7, HA2026.10.1, pinnedOWNd==2.0.0b11): 614 / 614 climate, gateway, F461, and oracle-validation tests pass with 100.0% diff statement coverage acrosscustom_components/myhome/climate.pyandcustom_components/myhome/gateway.py;ruff check,scripts/verify_ha_standards.py,scripts/sync_documentation.py --check,scripts/manage_translations.py check, andscripts/typing_ratchet.py(0strict errors) all pass.
2. Review of Follow-up Commits (0e44bcd7 → 4a41da61)
- Runtime seasonal transition (
9352638f):_season_side()and_follow_season_change()incustom_components/myhome/climate.pyhandle runtime transitions from a valve season to a fan-coil season across bothMESSAGE_TYPE_SEASONand the legacyMESSAGE_TYPE_MODE(*4*0*Z##/*4*1*Z##) fallback (capturingprevious_seasonbefore_remember_season()). Normalizescooling/conditioningto a single side, ignores repeated poll season frames and initialNone → seasonstartup, and is covered by 7 dedicated test cases. - Real-plant trace replays (
4a41da61):tests/test_climate_actuator_status.pynow replaysf461_live_2026-10-23(run6_event_session_ha_fixed_integration, 171 event-session frames across 7 sweeps) andmyhomeserver1_live_2026-10-24(run2_reads_thermo_sound_alarm_lights, 43-frame*#4*#0*20##burst across 34 configured zones plus0#1..0#4pumps) directly throughclimate.async_setup_entryandmyhome_message_<mac>, verifyinghvac_action: idle,actuatorspopulation, one-shotdimension 19queries on the 5 fan-coil zones, and zero phantom zone creation/polling on0#1..0#4. Companion 2026-10-25 active switching captures are cleanly tracked in #815.
3. Post-Merge Coordination
- #793 shares
_model_keyincustom_components/myhome/gateway.pyand the WHO 4 bullet in.github/release-notes/2.0.0b16.mdand will need a rebase ontov2-phase1-architectureafter this merge. _zone_request_frame/ALL_ZONE_REQUESTSare aligned ({2}#4#interface digits) for a drop-in swap toOWNHeatingCommand.actuators_statusonceOpenWebNet-HA/OWNd#140is released on PyPI.
Great work across the hardware captures, oracle verification, and edge-case test suite!
2ece474
into
OpenWebNet-HA:v2-phase1-architecture
…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>
…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>
… 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>
…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>
… (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>
|
The oracle companion for section 3 of review 5642758778 is OpenWebNet-HA/own-firmware-oracle#64: |
Requested by A
What is the problem
A zone's
hvac_actionstayed 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 athvac_action: Noneuntil*#4*2*20##was sent by hand throughmyhome.send_message, after which it switched toidleat 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 theactuatorsattribute (#25), and so deriveshvac_actionright 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-actuatorrows for*#4*1*20##and*#4*#0*20##; the per-zone confirmation for the other zone address forms is thethermo-zone-requestsfinding of OpenWebNet-HA/own-firmware-oracle#60, merged onmain(52d354b) asfindings/thermo-zone-requests.md+oracle/cases/thermo-zone-requests.cases(the per-gatewayresults/<P>/<V>/oracle/full/thermo-zone-requests.tsvrows follow the next oracle CI run onmain).What is the solution
gateway.py:watch_zone_actuators(where)returns the callback that stops watching. It queues a dimension 20 request through the newrequest_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 existingsend_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_listenercancels the task and drops the queue.send_pacedruns insidetry/except ExceptionwithLOGGER.exception(the hot-path guard of refactor(repairs): track device faults per gateway and raise them as self-clearing repair issues #593): a transport error is logged and the task goes on with the next batch instead of dying silently.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 waitsZONE_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.actuator_request_refusedreadsgetattr(profile, "supports_heating_actuator_status", None)first (True/Falsewins) and falls back toACTUATOR_REQUEST_REFUSED(F453, F453AV, H4684, MH200, MH200N, MH202) when the flag isNoneor 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()afterwait_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_targetthatsend_status_requestuses, whichsend_pacedsends through);watch_zone_actuatorsalways ref-counts the zone._model_keyusesisinstance(profile, GenericGatewayProfile)likecommand_session_limitand 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).request_zone_dimension(..., 22)raisesValueError:*#4*Z*22##kills the F461 thermostat handler and reboots the gateway (upstream fixturef461_live_2026-10-15,probe_dim22_confirm). An address outside the zone grammarZ,#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_actuatorsvalidates before counting the zone, and the per-zone frame builder validates again inside_send_zone_requests(), where aValueErroris logged and the other zones of the batch are still sent.climate.py: each non-central zone that is not a secondary probe (WHERE >= 100, push-only) callswatch_zone_actuatorsinasync_added_to_hassand unwatches on remove. Dimension 20 fills the per-actuator_actuator_states(dict[str, bool], exposed asextra_state_attributes["actuators"],on/off); dimension 19 sets the zone-level_zone_active(bool | None) and never appears inactuators. Whichever arrived last driveshvac_action(a dimension 19 report with both sides off also marks the known actuators off); OFF sets every known actuator tooffinstead 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##throughrequest_zone_dimensionand 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 beforesuper().async_added_to_hass()polls and a restoredfan_modewithout a side asks at the end ofasync_restore_last_state(), so the poll on add never sends*#4*Z*11##blindly. Because the entity is not polled afterwards (should_pollis 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, andfan_modewould otherwise keep the syntheticauto); 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'sconditioningis a restored state'scooling).ALL_ZONE_REQUESTSand_zone_request_frame(where, dimension)are built as strings and parsed with the existingOWNCommand.parsein the paced sender, because the pinned OWNd has no builder. feat(heating): OWNHeatingCommand.actuators_status(where) builder for *#4*WHERE*20## OWNd#140 addsOWNHeatingCommand.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_REGEXis 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.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:
394a5bef,3cc1f226(coverage case),841132d3(pending request expires)*#4*Z*19##when dimension 20 first reveals a fan coil (and on add for a restoredfan_modewithout a side); no*#4*Z*11##while it is pending; after 30 s without an answer the zone polls as before_actuator_statesmixed dimension 19 and 20a24ad505actuators) kept apart from the dimension 19 zone bool; last source wins; OFF keeps the attribute with every actuatoroff; novalvekeywherevalidation90c50398,6b254b66(zone grammar, validate before counting)whererefused inrequest_zone_dimensionandwatch_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)b0edd03ewatch_zone_actuatorsalways ref-counts;actuator_request_refusedevaluated in_send_zone_requestsafterwait_for_initial_discovery(startup and reconnect)6f267d05_model_keybyisinstance(GenericGatewayProfile)+ substring lookup; refusal / all-zone tables read on the WHO 4 target gateway (delegated or standby); no watch forWHERE >= 100;MYHOMESERVER1inALL_ZONE_REQUESTS(myhomeserver1_live_2026-10-24: 0.39 s, 43 actuator frames; emulated 028206 forwards it)BUS_ROUTING, coalescing, oracle cases865bade6,6b254b66(every batch)BUS_ROUTINGfromconst.py;ZONE_REQUEST_COALESCE0.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 repositoryAn 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
841132d3and6b254b66; 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 showingheating, the default actuator id1) 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 actuator1) and are left as they are.Draft #793 carries the same
_model_keyhelper and receives the same change separately.Review follow-up (review 5639881123)
One commit per item, cases that fail before and pass after it:
_ask_fan_coil_sides()after the poll on add; the 30 s wait during the sweep; no*#4*Z*11##once dimension 19 answers7f2c34acsuper().async_added_to_hass()(configured fan coil) and at the end ofasync_restore_last_state()(restoredfan_modewithoutfan_coil_*), zones only; a request queued whileinitial_discovery_pending/sweep_activeholds 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 withself._fan and self._season_has_fan_coil()queuesrequest_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_attributesasserted the blind*#4*5*11##on add and now expects the dimension 19 requestwait_for_initial_discovery()timeout9dba0566BUS_QUIET_CAP = 5.0intest_a_gateway_identified_late_as_supported_is_asked,test_a_gateway_identified_late_as_refusing_is_not_askedandtest_the_startup_run_does_not_wait_the_coalesce_window, as intest_actuators_are_asked_after_the_startup_sweep#4#interface digits differ from OpenWebNet-HA/OWNd#1406670a359_validate_zone_wherereturns the address with a two-digit interface (3#4#1->3#4#01),request_zone_dimension/watch_zone_actuatorsstore that form (one watch count per zone),_ZONE_WHEREtightened to{2};_calling_zonesand the entity's_full_wherespell the interface the same way, soOWNHeatingCommand.actuators_statusis a drop-in swap. The test that accepted3#4#1raw now expects3#4#01and*#4*3#4#01*20##;3#4#001is refusedsend_paced([])on refusing gateways0e44bcd7if not frames: continuebeforesend_paced; an MH202 watching a zone never awaitssend_pacedand_sweep_idlestays setAn 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 leftfan_modeonautofor 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-architecture089fef51:9352638f): the season handler (MESSAGE_TYPE_SEASON, and theMESSAGE_TYPE_MODEfallback of older OWNd releases) queues the one-shotrequest_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;conditioningand a restoredcoolingare 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.MyHOMEClimate(4a41da61):tests/test_climate_actuator_status.pynow 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 onmyhome_message_<mac>as the gateway handler does).f461_live_2026-10-23/run6_event_session_ha_fixed_integration: all 171 event-session frames;hvac_actionunknown through the five frames of the first*#4*2##reply,idlewithactuators{"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 zoneidlewith itsactuatorsfrom theZ#1/Z#2frames, the five fan coils (47, 48, 57, 60, 70) ask dimension 19 once each in the plant's order, and the zone-0 pump frames0#1..0#4discover no zone, are watched by nothing and are asked for nothing. Both fail onv2-phase1-architecture.GenericGatewayProfile.command_session_idle_timeoutheads-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 the2.0.0b11behaviour and the suite below runs against a plain OWNdmastercheckout as well.*#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#0dispatch, 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 }Hardware Verification
*#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 reportshvac_action: idleandactuators: {"1": "off", "2": "off"}without any manual frame, also after a restart and after a reconnect.*#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: 34Z#1, 5Z#2fan 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: idleandactuators: {"1": "off", "2": "off"}already at the first state read after startup (Noneon 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 giveidleand theactuatorsattribute; one of two on givesheating, thenidle; 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_listenerdrops waiting requests; dimension 22 is refused; a transport error is logged and the next request still goes out.*#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 withoutrequest_zone_dimensionkeeps 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##goesidle(no stuckheating), the reverse order too;valvenever appears inactuators; the attribute survives*4*303*2##/*4*1*2##and a central OFF. (3) twelve invalid addresses refused at once (also bywatch_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) aGenericentry 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")keysF461; 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; a105probe 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.*#4*2##only on add,*#4*2*11##is not sent before*#4*2*19##; a state restored throughasync_restore_last_stateasks 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")counts3#4#01, sends*#4*3#4#01*20##and unwatches it;*#4*3#4#1*14*0215##andinterface="1"both give3#4#01;3#4#001is refused. (4) an MH202 watching a zone never awaitssend_paced. Fail-before / pass-after onc653002f: eight of the item 1 cases, two of item 3 and the item 4 case fail; all 90 pass with the four commits.fa97b2fd47 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_knownreplaces the case that asserted the blind*#4*2*11##poll finding 1 removes.b61bb3e0, both replays onv2-phase1-architecture).master, f04d8aa, onPYTHONPATH): 4658 passed, 1 skipped, 100.0 % statement coverage (16331 statements); rebased ontov2-phase1-architecture089fef51.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).tests/fixtures/traces/f461_live_2026-10-23/myhome_trace_F461_live_2026-10-23.json, runrun1_oracle_replay_readonly*#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 sessionrun6_event_session_ha_fixed_integration*#4*2##burst (the integration sent*#4*#0*20##after each poll); six reconnect sweepsrun7_localha_bus_monitor*#4*2*20##and its repliestests/fixtures/traces/myhomeserver1_live_2026-10-24/myhome_trace_MyHomeServer1_live_2026-10-24.json, runrun2_reads_thermo_sound_alarm_lights*#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#Nreply forms)tests/fixtures/traces/f461_live_2026-10-15/,probe_dim22_confirm*#4*2*22##×3 and*#4*#0*22##×2 followed by the gateway rebootThe frames are also inlined in
tests/test_climate_actuator_status.py, and the same module replaysrun6_event_session_ha_fixed_integrationand the*#4*#0*20##burst ofrun2_reads_thermo_sound_alarm_lightsfrom the archives through the climate platform.What does the Firmware Oracle / Encyclopedia say
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##withVALUE0..8as the valve table plus9= fan-coil ON;Z#0addresses all actuators of a zone and0#0all 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.Firmware Oracle (
results/<P>/<V>/oracle/full/thermo-actuator.tsv, published atresults/mcp_index.json53f9514):*#4*1*20##010100, F453AV030014, H4684020054, MH200020102, MH200N010108, MH202010024020010, F454020051, F459020105, F460020012, F461020011, MyHomeServer1028206*#4*#0*20##The refusal table of this PR is the
*#4*1*20##row.findings/thermo-zone-requests.md(feat(oracle): add thermo-zone-requests suite (per-zone polls, actuators, humidity, interface form) own-firmware-oracle#60, merged onmain52d354b;firmware_observed,harness=full, 12 images; theresults/<P>/<V>/oracle/full/thermo-zone-requests.tsvrows follow the next oracle CI run onmain), the rows this PR relies on by model:*#4*2*20##(per zone)010100, F453AV030014, H4684020054, MH200020102, MH200N010108, MH202010024020010, F454020051, F459020105, F460020012, F461020011, MyHomeServer1028206*#4*#0*20##(all zones)*#4*2#1*20##/*#4*2#2*20##(per actuator)*#4*2#4#01*20##(zone behind an interface)*#4*2*19##(the fan coil side request of this PR)The per-zone row matches the
*#4*1*20##row of the publishedthermo-actuatorsuite model for model, so the six refused models and the F461 / MyHomeServer1 all-zone form rest on both suites.Checklist
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).handle_event()and do not manually subscribe viaasync_dispatcher_connect.pytest,ruff, andscripts/verify_ha_standards.pypass.🤖 Generated with Claude Code