Skip to content

climate: keep the 3550 flags through its reply, hold generic #Z frames, anchor the central echo on the write - #884

Merged
GreenGrassBlueOcean merged 1 commit into
OpenWebNet-HA:v2-phase1-architecturefrom
gdluck:fix/central-battery-and-f455-poll-2026-11-09
Nov 9, 2026
Merged

GreenGrassBlueOcean merged 1 commit into
OpenWebNet-HA:v2-phase1-architecturefrom
gdluck:fix/central-battery-and-f455-poll-2026-11-09

Conversation

@gdluck

@gdluck gdluck commented Nov 9, 2026

Copy link
Copy Markdown

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 the
3550, and an F461 2.0.11 and an F455 on the same SCS bus, with one Home Assistant entity per gateway.

  1. battery_fault flips 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 entities
    wrote battery_fault False and, 1.3-1.8 ms later, True (WebSocket state_changed + REST history; the same pair at every central
    update since 04:00). The second routing copy (climate + sensor platform) doubles it: replaying the reply on a2f70031 gives 4 False writes.
    The central_battery_fault repair issue was cleared and raised again each time.
  2. The F455 zone 4 entity goes AUTO and back to COOL at every quarter hour. At each quarter-hour push of the 3550 (it starts with
    *#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 #Z frame and shows *#4*#4*14*0240*2## instead. OWNd maps 311 to AUTO and the zone
    took 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).
  3. Maintainer's non-blocking note on climate: zone/central mode state from status frames only #880 (central echo window):

    if all command workers are busy for >100 ms (e.g. mid-poll) when #0 is commanded, anchoring the 0.1 s deadline on written's
    done-callback (written.result() + _CENTRAL_ECHO_WINDOW) would make it immune to queue wait time too.

What is the solution

  1. Central flags follow the 3550's remote-control frame (climate.py):
    • Mode / season frames no longer touch the optional flags. *4*20*#0## / *4*21*#0## opens a flag cycle; 22 / 23 / 24 / 30 / 31 set their
      flag 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.
    • WHO 4 p. 24-25: the monitor answer is *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.
  2. Generic #Z frames wait for the zone's seasonal status:
    • A *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 the
      zone 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.
  3. Echo window anchored on the write (maintainer's note):
    • MyHOMEGatewayHandler.send() returns the written future. CommandWorkerPool resolves it to written_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. The
      echo is therefore armed without a deadline at call time; the done-callback sets written_at + _CENTRAL_ECHO_WINDOW; cancel / exception
      disarms it; a pending echo expires after 60 s (the pool's PACED_WRITE_TIMEOUT); only the command armed at the same instant is
      anchored, 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)
    }
Loading

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.

check before after this PR (30 min)
battery_fault, both central entities False then True within 1.3-1.8 ms at every central reply steady True: no False write in REST history or in 1110 state_changed events, across 3 central replies (*4*210#0245*#0##, *4*21*#0##, *4*31*#0##)
F455 zone 4 at the quarter-hour push cool → auto → cool every 15 minutes cool at 09:15 and 09:30, while the F455 still emits *4*311*#4## (09:15:01.349, *4*210*#4## at .410; 09:30:01.975, *4*210*#4## at 02.035)
F461 zone 4 cool, unchanged cool, unchanged
MyHOME errors in the system log none (two warnings for an unrelated gateway)

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):

check observed
central reply, both monitors *4*210#0245*#0##, *4*21*#0##, *4*31*#0##, both requests
battery_fault, both central entities True throughout, no False write
F455 at the 10:00 push *4*311*#4## at 10:00:02.519, *4*210*#4## at 10:00:02.580 (61 ms)
F461 at the 10:00 push no generic frame; *4*210*#4## at 10:00:02.580, *#4*#4*14*0245*2## at 10:00:02.584
zone 4 entities (F455 and F461) cool throughout, never auto

The 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):
    • A: 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.
    • B: 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.
    • C: 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.
    • On a2f70031: 9 failed, 21 errors (the new timer / methods do not exist), 5 passed.
  • Updated: two Thermoregulation with BTicino 3550 - Temperature and mode control from Home Assistant #819 tests in 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).
  • Full suite on the current v2-phase1-architecture head: 5559 passed, coverage 100.0%; ruff check clean; scripts/typing_ratchet.py 0 errors (baseline 0);
    scripts/verify_ha_standards.py and scripts/sync_documentation.py --check SUCCESS (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.11
    and the F455. Only frames; no MAC / IP.
  • Two-day corpus on this plant (not committed): F461 525 frames, 0 generic *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).
  • Existing fixtures: MH201 + 3550 (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

  1. OpenWebNet Encyclopedia (WHO = 4) and the WHO 4 PDF:

    • p. 5: WHAT 20 / 21 remote control, 22-24 probe flags, 30 "Failure discovered", 31 "Central Unit battery KO"; no "battery OK" code.
    • p. 24-25 (1.6.2, monitor session): *4*[20-21]*#0##, then optional *4*what1*#0## … *4*whatn*#0## system status frames.
    • p. 9 (1.4.1 / 1.4.2): zone by central unit, monitor frames 110 / 210 / 310 manual, 111 / 211 / 311 automatic (heating / conditioning / generic).
    • pp. 8, 23 (Encyclopedia guide): *#4*#Z*#14*T*M## setpoint via the central, mode 1 heating, 2 conditioning.
    • The F455's 311 before every seasonal status is firmware-specific behaviour (the spec allows 311 as a status), hence the hold instead of a drop.
  2. Firmware Oracle:

    • No row for *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.
    • The F455 is not an emulated target; its behaviour is from the live captures only.
  3. Final check of each claim before this PR: nothing contradicts the change.

    claim reference result
    Flags 22-24, 30, 31 are optional, with no "clear" code WHO 4 p. 5, p. 23-25, 55, 64 confirms
    20 / 21 are reported before the flags WHO 4 p. 23 confirms
    311 on a zone is a command and, with 310, a monitor frame WHO 4 p. 9 confirms
    3550 zone status codes carry the season WHO 4 p. 65 (110, 210, 111, 211, 103, 203, 102, 202) confirms
    F455 311 prelude live captures, 18 of 18 confirms on this F455 only; Encyclopedia silent
    MH201 311 then 110 issue_466 trace confirms the order (one trace, 2 frames)
    "Absence means clear" Thermoregulation with BTicino 3550 - Temperature and mode control from Home Assistant #819 tests existing reading, inferred; not stated in the spec
    written future and 60 s limit gateway.py, gateway_sessions.py confirms

Review

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 really
in 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 #Z form be shown as AUTO with their season?

Follow-ups (not in this PR)

  • A small queue of pending central echoes instead of the single slot (two different commands back to back).
  • WHO 4 frames reach climate entities twice (climate + sensor platform routing).

Checklist

  • PR Description: Follows the structured sections above.
  • Trace-Backed: behaviour verified against live bus traces (F461 2.0.11 + F455) and existing fixtures.
  • V2 Architecture: changes are inside handle_event(); no new async_dispatcher_connect.
  • Tests & Linters: pytest, ruff, scripts/verify_ha_standards.py pass.

🤖 Generated with Claude Code

…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-commenter

Copy link
Copy Markdown

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

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@GreenGrassBlueOcean GreenGrassBlueOcean left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  1. 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_fault False → True oscillation.
    • Crucially, this also eliminates the continuous deletion and re-creation of the central_battery_fault repair issue every 15 minutes, removing unnecessary Home Assistant repair registry churn.
  2. Generic #Z Frame 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).
  3. Echo Window Anchored on Write (_anchor_central_echo):

    • Implements the non-blocking maintainer note from #880: deadline is now dynamically anchored on the written future resolution (written_at + _CENTRAL_ECHO_WINDOW = 0.1 s), making it resilient against CommandWorkerPool queuing delays and mid-poll socket waits.
    • The fallback pending timeout (_CENTRAL_ECHO_PENDING_LIMIT = 60.0 s) cleanly aligns with PACED_WRITE_TIMEOUT, and cancellation/exceptions properly disarm the echo.
    • Tracking armed_at protects against race conditions where a prior identical command's future resolves after a newer command was queued.

Non-blocking Notes / Observations

  • Two different commands in flight: Tracking armed_at successfully 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 @callback methods 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 guard if not self.hass or not self.entity_id: return at the top of _settle_central_flags and _apply is 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.

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit 6c50e35 into OpenWebNet-HA:v2-phase1-architecture Nov 9, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants