Repository navigation
climate: keep the 3550 flags through its reply, hold generic #Z frames, anchor the central echo on the write - #884
Conversation
…s, anchor the central echo on the write Live F461 2.0.11 + F455 on one bus with a BTicino 3550 (no battery), 2026-11-09: * The 3550 answers *michnovka#4*#0## with its mode, *4*21*#0## and *4*31*#0## within 4 ms. The mode / season handlers cleared the optional flags on the mode frame, so both central entities wrote battery_fault False and, 1.5 ms later, True on every reply. WHO 4 p. 24-25: the flags (22-24, 30, 31) are optional frames after the remote control frame (20 / 21), and no frame means "clear". The flag cycle now starts on 20 / 21 and clears a flag only when the burst has settled without it; a mode frame alone no longer touches the flags. * The F455 emits a generic *4*311*michnovka#4## 59-61 ms before the zone's seasonal status (*4*210*michnovka#4##, 18 of 18; the MH201 trace of OpenWebNet-HA#466 the same, before *4*110*#Z##), at every quarter-hour push of the 3550; the F461 never shows it. The F455 zone 4 entity went AUTO and back every 15 minutes. A generic 310 / 311 frame for a zone of the central is now held briefly: a seasonal status that follows replaces it, and only a generic frame with none after it is the zone's state (WHO 4 p. 9). * The central echo window starts at the write time (the written future of MyHOMEGatewayHandler.send), not at the call, so a busy command pool does not push the echo outside it; a cancelled or failed write disarms it, and a repeated command is anchored on its own write. Trace-replay tests from the captured frames: tests/test_climate_central_flags_f455_poll_2026_11_09.py.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
GreenGrassBlueOcean
left a comment
There was a problem hiding this comment.
Engineering & OpenWebNet Protocol Review
Excellent follow-up to #880. The live captures from the physical BTicino 3550 (#0) + F461 2.0.11 + F455 setup provide concrete empirical backing, and the new trace fixture central_flags_f455_poll_2026_11_09/frames.json gives clear reproduction of all three timing issues.
The full test suite (5500+ tests), 35 new test cases in test_climate_central_flags_f455_poll_2026_11_09.py, ruff check, verify_ha_standards.py (54/54 Quality Scale rules passed, PLATINUM), and sync_documentation.py --check have been verified locally. Coverage on all modified lines is 100.0%.
Key Validations
-
Central Status Flags (
_begin_central_flag_cycle/_settle_central_flags):- Protocol-correct semantics adhering to WHO 4 pp. 24–25 (the answer starts with the remote-control frame
20/21, followed by optional status frames; absence after settle means clear). - Moving away from reset-on-mode completely halts the 1.5 ms
battery_faultFalse → True oscillation. - Crucially, this also eliminates the continuous deletion and re-creation of the
central_battery_faultrepair issue every 15 minutes, removing unnecessary Home Assistant repair registry churn.
- Protocol-correct semantics adhering to WHO 4 pp. 24–25 (the answer starts with the remote-control frame
-
Generic
#ZFrame Hold (_hold_generic_zone_frame):- The 0.5 s hold window is an effective compromise. It cleanly drops the F455's 59–61 ms synthetic prelude before the 3550's seasonal status arrives (
*4*210*#4##cooling), stopping the periodic AUTO flip while still honoring central units that legitimately only report generic status frames. - Idempotently registered with
self.async_on_remove(self._cancel_generic_zone_frame).
- The 0.5 s hold window is an effective compromise. It cleanly drops the F455's 59–61 ms synthetic prelude before the 3550's seasonal status arrives (
-
Echo Window Anchored on Write (
_anchor_central_echo):- Implements the non-blocking maintainer note from #880: deadline is now dynamically anchored on the
writtenfuture resolution (written_at + _CENTRAL_ECHO_WINDOW = 0.1 s), making it resilient againstCommandWorkerPoolqueuing delays and mid-poll socket waits. - The fallback pending timeout (
_CENTRAL_ECHO_PENDING_LIMIT = 60.0 s) cleanly aligns withPACED_WRITE_TIMEOUT, and cancellation/exceptions properly disarm the echo. - Tracking
armed_atprotects against race conditions where a prior identical command's future resolves after a newer command was queued.
- Implements the non-blocking maintainer note from #880: deadline is now dynamically anchored on the
Non-blocking Notes / Observations
- Two different commands in flight: Tracking
armed_atsuccessfully protects repeated identical commands from mis-anchoring. As noted in the PR, if two different commands are issued back-to-back (e.g. OFF then AUTO), the echo of the first command will not match the second command's frame and will be processed as a bus report. Because this simply reflects the intermediate bus state before the final command settles, this is benign in practice. A small pending queue can be considered in a future refinement if rapid cross-mode commanding ever becomes an issue in the field. - Defensive guards: Home Assistant executes
@callbackmethods on a single-threaded cooperative event loop (asyncio), so timer cancellation synchronously removes the timer handle (no multi-threaded race). Nonetheless, adding an early defensive guardif not self.hass or not self.entity_id: returnat the top of_settle_central_flagsand_applyis standard HA defensive practice for edge unload scenarios. - OWNd 111/211 mapping: Leaving
*4*111*#Z##/*4*211*#Z##mapping to OWNd upstream rather than overriding it in the integration keeps component boundaries clean.
LGTM — Approved.
6c50e35
into
OpenWebNet-HA:v2-phase1-architecture
Requested by A
What is the problem
Follow-up to #880, from a live plant: a BTicino 3550 99-zone central unit (
#0) without a battery, zone 4 a fan-coil zone of the3550, and an F461 2.0.11 and an F455 on the same SCS bus, with one Home Assistant entity per gateway.
battery_faultflips False/True on every central reply (both gateways). The 3550 answers*#4*#0##with*4*210#0240*#0##,*4*21*#0##,*4*31*#0##within 4 ms (live 07:47:42 via F461, 07:48:03 via F455). The SEASON / MODE /MODE_TARGET branches called
_reset_central_status_flags()on the leading mode frame and published state, so both central entitieswrote
battery_faultFalse and, 1.3-1.8 ms later, True (WebSocketstate_changed+ REST history; the same pair at every centralupdate since 04:00). The second routing copy (climate + sensor platform) doubles it: replaying the reply on
a2f70031gives 4 False writes.The
central_battery_faultrepair issue was cleared and raised again each time.*#4*4*#14*0240*2##on both gateways), the F455 puts*4*311*#4##on its event session 59-61 ms before the zone's status*4*210*#4##. The F461 never shows a generic#Zframe and shows*#4*#4*14*0240*2##instead. OWNd maps 311 to AUTO and the zonetook it as its state: AUTO at xx:xx:02.20, COOL at 02.26 (07:45 and 08:00 captured; history shows every quarter hour).
What is the solution
climate.py):*4*20*#0##/*4*21*#0##opens a flag cycle; 22 / 23 / 24 / 30 / 31 set theirflag at once; after
_CENTRAL_FLAGS_SETTLE(1.0 s) the flags not reported are cleared (and the battery repair issue only if 31 was absent),with one state write if anything changed. A second copy of 20 / 21 does not restart an open cycle; the timer is cancelled on removal.
*4*[20-21]*#0##, then "This optional frame indicates the system status" (22-24, 30, 31). p. 5: 30"Failure discovered", 31 "Central Unit battery KO". No frame means "clear", so absence after the cycle is the clear signal, as in Thermoregulation with BTicino 3550 - Temperature and mode control from Home Assistant #819.
#Zframes wait for the zone's seasonal status:*4*310*#Z##/*4*311*#Z##(Z ≠ 0) for a zone of the central is held_GENERIC_ZONE_FRAME_HOLD(0.5 s). A mode / season frame of thezone within that time drops it; otherwise it is applied as before. WHO 4 p. 9 lists 311 ("Automatic Generic") as a monitor frame, so a
central that reports only the generic form still shows AUTO; the F455 / MH201 prelude does not.
MyHOMEGatewayHandler.send()returns thewrittenfuture.CommandWorkerPoolresolves it towritten_at(taken just before_command_session.send()) but only after that send returns, so the F455 echo (about 1 ms after the write) usually arrives first. Theecho is therefore armed without a deadline at call time; the done-callback sets
written_at + _CENTRAL_ECHO_WINDOW; cancel / exceptiondisarms it; a pending echo expires after 60 s (the pool's
PACED_WRITE_TIMEOUT); only the command armed at the same instant isanchored, so a repeated identical command keeps its own window. Without a future (test doubles) the window starts at the call, as before.
State Diagram
stateDiagram-v2 state "Central flags" as A { [*] --> Steady Steady --> Steady: mode / season frame (flags untouched) Steady --> Collecting: *4*20 or 21*#0## (timer 1 s) Collecting --> Collecting: 22/23/24/30/31 (set at once) or 2nd copy of 20/21 Collecting --> Steady: timer: clear flags not seen, write once if changed } state "Zone of the central" as B { [*] --> Idle Idle --> Held: *4*310/311*#Z## (timer 0.5 s) Held --> Idle: seasonal status (110/210/203/202, *4*0*Z##): generic dropped Held --> Idle: timer: generic applied (AUTO) }Hardware Verification
Tested on the plant above (BTicino 3550 without a battery, F461 2.0.11 + F455): this commit's content installed on a Home Assistant 2026.11.0 test instance, 30-minute read-only watch 09:03:30-09:33:30 UTC, two read-only
*#4*#0##requests sent.battery_fault, both central entitiesstate_changedevents, across 3 central replies (*4*210#0245*#0##,*4*21*#0##,*4*31*#0##)*4*311*#4##(09:15:01.349,*4*210*#4##at .410; 09:30:01.975,*4*210*#4##at 02.035)Confirmation run on the same build, 09:55-10:01 UTC, read-only (two
*#4*#0##requests, one via each gateway, plus the 10:00 quarter-hour push):*4*210#0245*#0##,*4*21*#0##,*4*31*#0##, both requestsbattery_fault, both central entities*4*311*#4##at 10:00:02.519,*4*210*#4##at 10:00:02.580 (61 ms)*4*210*#4##at 10:00:02.580,*#4*#4*14*0245*2##at 10:00:02.584The installed build differs from this commit by one number in a code comment.
Not exercised: a real battery fault clearing, a zone in automatic (program) mode, the echo window under a busy command pool.
How is it tested
tests/test_climate_central_flags_f455_poll_2026_11_09.py(new, 35 tests, trace replay from the captured frames):test_battery_flag_stays_set_through_each_central_reply(both gateways x both triggers, every frame twice),..._clears_once_a_burst_settles_without_it,test_a_mode_frame_alone_does_not_touch_the_flags,test_a_late_second_copy_of_the_remote_control_frame_keeps_the_flag,test_flag_cycle_timer_is_cancelled_on_removal.test_f455_quarter_hour_push_does_not_turn_zone_4_auto[0745/0800],test_f461_quarter_hour_push_was_already_right,test_a_seasonal_status_after_the_generic_frame_replaces_it,test_a_generic_frame_with_no_seasonal_status_is_the_zone_state, and 5 more.test_echo_while_the_command_is_still_queued_is_swallowed,test_window_is_anchored_on_the_write_time_of_a_busy_pool,test_the_same_command_twice_is_anchored_on_its_own_write, cancelled / failed / expired / no-future cases.a2f70031: 9 failed, 21 errors (the new timer / methods do not exist), 5 passed.test_climate_central_discovery.py(they let the burst settle; a lone season frame no longer clears the flags),test_climate_mode_state_2026_11_08.py(a lone 311#4 and a lone*4*303*#0##),test_climate_season_frame.py(AUTO set directly).v2-phase1-architecturehead: 5559 passed, coverage 100.0%;ruff checkclean;scripts/typing_ratchet.py0 errors (baseline 0);scripts/verify_ha_standards.pyandscripts/sync_documentation.py --checkSUCCESS (OWNd 2.0.0b13).Where are the traces
tests/fixtures/traces/central_flags_f455_poll_2026_11_09/frames.json: four windows (quarter_0745,central_reply_via_f461_0747,central_reply_via_f455_0748,quarter_0800),[time UTC, monitor, frame], from read-only*99*1##monitor sessions on the F461 2.0.11and the F455. Only frames; no MAC / IP.
*4*310/311*#Z##, 29*#4*#Z*14*T*M##; F455 379 frames,27 generic
#Z, 0*#4*#Z*14*…. Every F455*4*311*#4##(18 of 18) is followed 59-61 ms later by a seasonal zone 4 status(14 × 210, 2 × 203, 2 × 202).
issue_466/myhome_trace_MH201_all_2026-09-25T19-05-42.json):*4*311*#2##→*4*110*#2##,*4*311*#4##→*4*110*#4##(rx). MH200N + 3550 (issue_466/README.md):*4*311*#1##answered*4*111*#1##. F454 (issue_697/README.md):*4*311*#3##confirmed by*4*110*#3##.What does the Firmware Oracle / Encyclopedia say
OpenWebNet Encyclopedia (
WHO = 4) and the WHO 4 PDF:*4*[20-21]*#0##, then optional*4*what1*#0## … *4*whatn*#0##system status frames.*#4*#Z*#14*T*M##setpoint via the central, mode 1 heating, 2 conditioning.Firmware Oracle:
*4*311*#4##,*4*21*#0##,*4*31*#0##,*4*210*#4##or*#4*#4*14*0240*2##(no rows does not mean refused).*#4*#0##: NACK / out on the emulated firmwares (results/<GW>/<FW>/oracle/full/trace-corpus.tsv:50): their bus has no central unit.Final check of each claim before this PR: nothing contradicts the change.
issue_466tracewrittenfuture and 60 s limitgateway.py,gateway_sessions.pyReview
The analysis was checked by two independent model reviews (same evidence pack; the second reviewer's usable verdict came after it had seen the first, so it confirms rather than adds an independent opinion). Both agreed on the root cause of the battery flip. Both called the first fix for the F455 frame a regression for centrals that report only 311, which is why the frame is now held instead of dropped, and both flagged a re-anchoring issue for identical commands, which is fixed.
Proven on the bus: both flip sequences (two captures each, both gateways), the 18 / 18 prelude pattern on the F455 and its absence on the F461,
and that the fix removes every False / AUTO write when the same frames are replayed. Inferred: that the F455's 311 is its rendering of the 3550's
*#4*#4*14*T*2##(timing only, no SCS bytes); that the MH201 behaves the same in general (one trace); that 1 s covers every central(worst gap seen 31 ms) and 0.5 s every F455 prelude (59-61 ms on one firmware).
Risks: a central sending flags more than 1 s after 20 / 21 would keep stale flags until a complete later cycle; a seasonal frame from
another source within 0.5 s would drop a genuine 311; with two different central commands in flight, the single echo slot of #880 still
takes the first one's echo as a report.
Untested: a zone of the central in automatic (program) mode on this plant; a battery fault that clears; the echo window under a busy command pool on real hardware; other SCS-only gateways live; 4-zone centrals (
#0#N).Open question for the maintainers
OWNd maps
*4*111*#Z##/*4*211*#Z##("Automatic heating / conditioning", WHO 4 p. 9) to HEAT / COOL, so a zone of the central that is reallyin automatic shows HEAT / COOL on every gateway; the only AUTO a zone of the central ever showed came from the generic 311. This PR leaves that
mapping unchanged (the #819 tests assert it). Should 111 / 211 in the
#Zform be shown as AUTO with their season?Follow-ups (not in this PR)
Checklist
handle_event(); no newasync_dispatcher_connect.pytest,ruff,scripts/verify_ha_standards.pypass.🤖 Generated with Claude Code