Skip to content

climate: zone/central mode state from status frames only - #880

Merged
GreenGrassBlueOcean merged 7 commits into
OpenWebNet-HA:v2-phase1-architecturefrom
gdluck:fix/climate-mode-state-2026-11-08
Nov 9, 2026
Merged

GreenGrassBlueOcean merged 7 commits into
OpenWebNet-HA:v2-phase1-architecturefrom
gdluck:fix/climate-mode-state-2026-11-08

Conversation

@gdluck

@gdluck gdluck commented Nov 8, 2026 •

Copy link
Copy Markdown

Requested by A

What is the problem

On a test plant (one SCS bus, a BTicino 3550 99-zone central unit #0, zone 2 a standalone thermostat, zone 4 a zone of the 3550), reached at the same time through an F461 2.0.11 and an F455, every climate.set_hvac_mode call returned HTTP 200 with no NACK, yet Home Assistant showed the wrong mode:

  1. F455: a standalone zone that heats shows AUTO. About 1.3 s after each status report of zone 2, the F455 delivers *4*311*#2## on the event session (8 of 8 reports; never on the F461 monitoring the same bus). OWNd reads WHERE #2 as zone 2, and WHAT 311 makes it AUTO. The F455 never polls *#4*2##, so nothing corrects it.
  2. Standalone HEAT ↔ COOL is not shown, and the two gateways disagree. The thermostat answers the setpoint write *#4*2*#14*0265*2## with *4*0*2## (it now runs in conditioning). That frame is handled as season only, so the zone stays HEAT until it goes through OFF. When the owner switched zone 2 to cool from the F455's card, the F461 on the same bus received the same *4*0*2## and its card kept HEAT.
  3. Central HEAT / COOL shows OFF, then sticks on the second press. *4*1*#0## / *4*0*#0## only change the season. The 3550 answers *4*102*#0## / *4*202*#0##, antifreeze / thermal protection (zone 4 then reports a 7.0 / 35.0 °C setpoint). These frames are decoded as OFF, after HA had already written HEAT / COOL optimistically. On the second press the season does not change, the 3550 answers nothing, and the optimistic HEAT / COOL stays while the plant is still in protection.
  4. F455: its own central command is taken as the 3550's state. The F455 hands *4*0*#0## back 1 ms after sending it. As a season frame it reset the central's status flags (the battery_fault the 3550 had just reported with *4*31*#0## went false) and turned hvac_action from OFF to IDLE. The F461 does not echo it: the same 3550 then showed OFF on the F461 entity and IDLE on the F455 one.
  5. HEAT on a zone of the central in the conditioning season, and AUTO on a standalone zone, are silent no-ops. The 3550 does not answer *#4*#4*#14*0205*1## while in conditioning. Nothing answers *4*311*#2## for the standalone zone 2. Both calls returned HTTP 200.

What is the solution

In climate.py, plus central-unit detection in where_grammar.py / gateway_events.py. The mode shown comes from the frames the plant sends, not from Home Assistant's own command.

  • A central unit is detected only from its own reports. #0 / #0#N, a #Z dimension, or a #Z zone status that carries its season (110/210/111/211/103/203/102/202, WHO 4 p. 65 §1.9.4, p. 9). The generic *4*311*#Z## / *4*310*#Z## no longer mark the plant: the F455 makes up *4*311*#2## for a standalone thermostat. Before, any #Z frame set has_central_unit (unchanged since before this PR), so a standalone-only F455 plant was taken for one with a central unit (review P0). The live 3550 is still found from *4*210*#4##, which it also sends in its quarter-hour push.

  • Standalone zones ignore the #Z form for their mode. For a zone that is standalone (configured, or no central unit known), a MODE / MODE_TARGET / SEASON frame whose raw WHERE starts with # does not change it. Zones of a central unit keep every #Z state: the 3550 itself reports *4*210*#4## / *4*203*#4## in that form (WHO 4 p. 65).

  • A standalone zone's plain season frame is its mode. A standalone thermostat has no program: in the plain Z form it reports its mode only as *4*1*Z## / *4*0*Z## (heating / conditioning), protection or off (WHO 4 p. 63). So that frame sets HEAT / COOL whoever changed the zone: Home Assistant, the other gateway, a touch screen or the thermostat itself. Both gateways' entities then agree, and a stale AUTO restored after a restart is replaced. OFF and protection still come from their own frames (303, 102, 202). Zones of a central unit keep the season-only handling (libqtdevices does the same).

  • Central 102 / 202 are protection, not OFF. The central keeps the season's mode (HEAT for 102, COOL for 202) and exposes central_operation: antifreeze | thermal_protection; it is cleared by any other mode report. HEAT / COOL on #0 are no longer set optimistically: the entity waits for the 3550's answer. OFF and AUTO are unchanged.

  • The central ignores the gateway's echo of HA's own command. A frame equal to the central command HA just sent, arriving within 100 ms, is dropped: every copy inside that window, and the same frame object afterwards. The F455 echo comes after 1 ms; the 3550's first answer came after 121 ms (*4*203*#0## to *4*303*#0##). A failed send disarms it. An identical frame after the window is handled normally.

  • Central OFF / AUTO clear central_operation; OFF sets hvac_action OFF. HEAT / COOL wait for the 3550's answer only on #0: a 4-zone central (#0#1..#0#4), which is not polled, keeps the mode it was set to.

  • central_operation survives a restart, and *4*302*#0## (protection, generic, p. 5) is the protection of the season already known (our reading: p. 64 lists only 102 / 202 for the central).

  • An AUTO the zone itself reported (Dimension 7, automatic context, e.g. MyHomeServer1 schedules) is not cancelled by the plain season frame that follows. A restored AUTO is still replaced.

  • Refuse instead of a silent no-op, both with a translated ServiceValidationError and no frame sent:

    • HEAT on a zone of a central unit whose reported season is conditioning, or COOL in heating (zone_heat_in_conditioning_season / zone_cool_in_heating_season, no untranslated season word in fr / it / nl). There is no restriction while the season has not been reported since start-up (a restored season is not enough), or for standalone zones.
    • AUTO on a standalone zone of a plant that has a central unit. On a plant without one, the existing setpoint fallback is unchanged.

State Diagram

flowchart TD
    F[WHO 4 frame for this entity] --> C{central unit #0?}
    C -- yes --> E{equal to HA's own command<br/>sent < 100 ms ago, or the same<br/>echo object again?}
    E -- yes --> X[drop: gateway echo]
    E -- no --> P{WHAT 102 / 202?}
    P -- yes --> PR[HEAT / COOL + central_operation<br/>antifreeze / thermal_protection<br/>302: per known season]
    P -- no --> M[existing mode / season handling]
    C -- no --> S{standalone zone?}
    S -- yes --> Z{mode / season frame<br/>in #Z form?}
    Z -- yes --> X2[drop: not this thermostat's state]
    Z -- no --> W{plain-Z season frame?}
    W -- yes --> SM[HEAT / COOL: the thermostat's mode]
    W -- yes, zone reported AUTO --> M
    W -- no --> M
    S -- no --> M
Loading

Service side: HEAT / COOL on #0 send the season command and wait for the 3550's answer; HEAT in conditioning / COOL in heating on a zone of the central (season reported) and AUTO on a standalone zone with a central unit raise ServiceValidationError without sending.


Hardware Verification

The table below is for f5248f9. The review follow-up 1a56d3c is covered by trace replays only; it has not been installed on the test Home Assistant yet.

Test plant: an F461 2.0.11 and an F455 on one SCS bus with a BTicino 3550 99-zone central unit. Home Assistant 2026.11.0 with this branch installed, 2026-11-08 15:45-16:02 UTC; the previous build was put back afterwards.

problem call result
5 zone 4 (3550, conditioning) HEAT, on the F461 and on the F455 ServiceValidationError (HTTP 400), nothing sent, stays COOL
5 zone 2 (standalone) AUTO, on the F461 and on the F455 ServiceValidationError (HTTP 400), nothing sent, stays HEAT
1 F455 zone 2 HEAT heat at +15 s although the F455 delivered *4*311*#2## at +1.4 s (it showed AUTO before the test)
2 F461 zone 2 COOL, then HEAT cool at +0.5 s from *4*0*2##, then heat from *4*1*2##, no OFF round trip
3 F461 central HEAT twice, then COOL *4*102*#0## -> heat + central_operation: antifreeze; the second press got no answer and stayed heat + antifreeze; *4*202*#0## -> cool + thermal_protection; battery_fault true throughout. The F455 central showed the same from the same 3550 frames
4 F455 central COOL twice first build: failed. The echo reached the entity twice, because the climate and sensor platforms both route WHO 4 frames: the first copy was dropped, the second reset battery_fault. Fixed by recognising the echo object (test covers the double delivery). Redeployed: tx *4*0*#0## -> rx *4*0*#0## 1 ms later, entity untouched (battery_fault true, last_updated unchanged), twice
restart after both restarts both centrals show cool + thermal_protection + battery_fault true from the 3550's startup answer

The plant was restored to its state before the test (zone 2 heat 26.5, zone 4 manual cool 20.5).

Owner check, 16:50: zone 2 switched to cool from the F455's card. The F455 card showed cool, the F461 card kept heat although it received the same *4*0*2## (F461 rx *#4*2*#14*0265*2## 16:50:09.071, *4*0*2## 16:50:09.340). With the plain-season-frame rule installed and HA restarted (17:14 UTC), both zone 2 entities show cool / 28.0 and all six climate entities agree across the two gateways.

After a restart with this branch, the F455 zone 2 also came back from a saved AUTO and showed heat from its *4*1*2## (16:39 UTC); the following *4*311*#2## was ignored.


How is it tested

tests/test_climate_mode_state_2026_11_08.py replays the frames above (tests/fixtures/traces/climate_mode_2026_11_08/frames.json, WHO 4 frames only, no addresses):

  • fail before / pass after: 11 tests fail without this PR and pass with it:
    • test_f455_trailing_311_does_not_turn_the_standalone_zone_auto (before: auto == heat)
    • test_standalone_heat_to_cool_shows_cool_from_the_thermostat_answer (before: heat == cool)
    • test_central_heat_shows_the_antifreeze_the_3550_answers (before: off == cool)
    • test_central_protection_does_not_switch_the_zones_off
    • test_central_operation_clears_on_a_manual_mode_report
    • test_f455_echo_of_our_central_command_changes_nothing
    • test_only_one_echo_is_dropped_and_only_while_expected
    • test_heat_on_a_zone_of_the_central_in_conditioning_is_refused
    • test_auto_on_a_standalone_zone_of_a_plant_with_a_central_is_refused
    • test_restored_auto_on_a_standalone_zone_is_replaced_by_its_season_frame (before: auto == heat)
    • test_unsolicited_season_frame_is_the_standalone_zone_mode
    • test_both_gateways_show_cool_after_the_f455_switches_the_standalone_zone (replays the F455 and F461 frames of the owner's 16:50 switch)
  • guards that pass on both: a zone of the central still takes *4*210*#4##, *4*311*#4## and *4*203*#4##; central OFF / AUTO stay immediate; no season restriction when the season is unknown, only restored, or the zone is standalone; the no-central AUTO setpoint fallback; a zone of the central keeps its mode (restored or AUTO) on a season frame.

Review follow-up (1a56d3c), all in the same file: test_f455_synthetic_311_does_not_make_a_standalone_only_plant_central (zero-config, through GatewayEventDispatcher + handle_event), test_auto_fallback_survives_the_f455_trailing_311, test_central_unit_detection_by_frame[...] (10 frames), test_zone_of_the_central_found_from_the_3550_quarter_hour_push, test_identical_central_answer_after_the_echo_window_is_a_report, test_echo_delivered_as_two_copies_changes_nothing, test_failed_send_disarms_the_echo, test_four_zone_central_keeps_the_mode_it_is_set_to[heat/cool], test_myhomeserver1_schedule_auto_survives_the_trailing_season_frame, test_restored_auto_then_schedule_auto_is_kept, test_central_operation_is_restored, test_central_generic_protection_follows_the_season[...], test_season_errors_are_translated_without_a_season_placeholder[strings/en/fr/it/nl], test_cool_on_a_zone_of_the_central_in_heating_is_refused. 23 cases fail on f5248f9 and pass on 1a56d3c.

The branch carries a merge of v2-phase1-architecture (for #873, which also changed climate.py; one adjacent line, both kept).

Existing tests that asserted the old central behaviour are updated: 102 / 202 shown as OFF (test_climate_central_season_699.py, and the F453AV / F454 trace replays test_issue_619_who4_trace.py::test_f453av_antifreeze_on_replay, ::test_f453av_one_zone_to_cool_replay, test_issue_629_f454_central_unit_trace.py::test_f454_central_unit_plain_status_replay, which now also check that a zone the central put in protection is still OFF, #383), and HEAT / COOL on #0 set optimistically (test_climate_central_discovery.py, test_issue_303.py, test_phase2_architecture.py, where the 3550's answer is now fed in).

Full suite at 1a56d3c (PYTHONPATH=. pytest tests --cov=custom_components.myhome, HA 2026.10.4, OWNd 2.0.0b13): 5546 passed, 1 skipped, coverage 100.0% (f5248f9: 5515 passed; base be78bd7: 5494 passed). An earlier local run showed 272 failures on both branches: an editable install of another checkout put a path-hook entry on sys.path that HA's custom-component loader cannot scan; with that entry removed for the test run, everything passes. That had hidden the three trace replays CI caught.
ruff check clean. mypy --strict: climate.py and the new test are clean (only the pinned OWNd's missing third-party stubs, as on base); scripts/typing_ratchet.py: 0 errors, baseline 0.
scripts/verify_ha_standards.py: all standards passed. scripts/sync_documentation.py --check: in sync.


Where are the traces

  • tests/fixtures/traces/climate_mode_2026_11_08/frames.json: WHO 4 frames only, no addresses, as Home Assistant saw them on the F461 and the F455 (2026-11-08 UTC).

F455, zone 2 heat (standalone):

14:26:29.114 tx *#4*2*#14*0265*1##
14:26:29.209 rx *4*1*2##
14:26:29.259 rx *#4*2*12*0265*3##
14:26:30.119 rx *#4*2*14*0265*3##
14:26:30.439 rx *#4*2*13*00##
14:26:30.519 rx *4*311*#2##        <- F455 only; HA: heat at +0.5 s, auto at +2 s

F461, zone 2 heat -> cool:

14:09:07.158 tx *#4*2*#14*0265*2##
14:09:07.255 rx *4*0*2##           <- HA stays heat

F461, central heat, twice:

14:24:48.141 tx *4*1*#0##
14:24:48.374 rx *4*102*#0##   14:24:48.376 rx *4*21*#0##   14:24:48.377 rx *4*31*#0##
14:24:48.975 rx *4*102*4##    14:24:49.023 rx *#4*4*12*0070*3##   <- HA: off
14:25:08.340 tx *4*1*#0##          <- no answer; HA: heat, plant still in antifreeze

F455, central cool, second press:

14:35:47.471 tx *4*0*#0##
14:35:47.472 rx *4*0*#0##          <- its own frame; battery_fault cleared, action OFF -> IDLE

F461, zone 4 (a zone of the 3550, plant in conditioning):

14:19:50.544 tx *#4*#4*#14*0205*1##   <- no answer (twice)
14:20:35.911 tx *4*311*#4##
14:20:36.015 rx *4*210*#4##           <- the 3550's answer, in the #Z form

What does the Firmware Oracle / Encyclopedia say

  1. OpenWebNet spec (Legrand WHO 4, docs/who-pdf), claim by claim:
claim spec verdict
#0 = central unit, #1..#99 = zone via central unit p. 6 (WHERE table) agrees
*4*311*#Z## sets zone Z to automatic through the central unit p. 9 §1.4.2 agrees
*4*what*#Z## (110/210/111/211/103/203/102/202) is the zone's mode reported by the central unit p. 65 §1.9.4; p. 9 monitor agrees
*4*0*Z## / *4*1*Z## is the zone operation mode (season) p. 63 §1.9.2; p. 16 agrees
*#4*Z*#14*T*M##, M = 1 heating, 2 conditioning p. 8 §1.4.1 agrees
WHAT 102 antifreeze, 202 thermal protection, distinct from 103/203/303 OFF p. 5 (WHAT table); p. 64 §1.9.3 agrees
WHAT 0 / 1 = conditioning / heating mode p. 5 (WHAT table) agrees
WHAT 20/21 remote control, 31 central unit battery KO p. 5; p. 64 §1.9.3 agrees
*4*110#T*#0## manual heating of the central unit p. 23 §1.6.1; p. 64 agrees
*4*1*#0## / *4*0*#0## as a central command, and the 3550 answering it with 102 / 202 §1.6 lists no season command for #0 not covered (live 3550 behaviour)
F455 delivering *4*311*#Z## after a zone status, and echoing HA's own #0 command — not covered (firmware-specific: F455, not seen on the F461)
A standalone thermostat answers a mode 1 / 2 setpoint write with *4*0/1*Z## — not covered (live F461 plant)
A standalone thermostat reports its mode in the plain Z form only as 0 / 1 / 102 / 202 / 303 p. 63 §1.9.2 agrees
A zone of the central does not take a heating setpoint while the central is in conditioning not stated for #14; p. 10 says it for antifreeze ("If Central Unit is set up in Conditioning mode, this command does not run"), p. 29/30 for the programs not covered (live 3550 behaviour)

No row differs from the spec. The behaviours the spec does not describe are scoped to the gateways and plant they were seen on.
2. Firmware Oracle (own-firmware-oracle, 12 emulated firmwares, harness=full and harness=plant:f461-bench, 2026-11-08; A = ACK, N = NACK; out = put on the bus, silent = nothing on the bus). Emitted proves, silent does not; the fixtures have no central unit, so no reply from a 3550 can appear.

frame F450 F453 F453AV F454 F459 MH200N MH202 F460 020012, F461 020011, MyHomeServer1 028206 H4684 020054, MH200 020102
*4*1*#0##, *4*0*#0##, *4*1*#0#1## A / out N / out N / silent
*4*102*#0##, *4*202*#0##, *4*303*#0## A / out N / out A / out
*4*311*#2##, *4*311*#2#1## A / out N / out A / out
*4*311*2## N / silent (12/12) N / silent N / silent
*#4*2*#14*0265*1## / *2## A / out (F453, F453AV, MH200N: N / silent) full: N / out; plant: A / out N / silent
*#4*#0##, *#4*#0#1##, *#4*#2## N / out (MH202: A / out) N / out N / out
  • No emulated gateway emits *4*WHAT*#Z## as a status, and none answers *4*1*#0## with 102 / 202. Under the plant fixture the standalone zone answers *#4*2## with 0, 12, *4*1*2##, 14, 13 (MH202: 14, 12, *4*1*2##, 13, then 0). After the off / protection writes, the 7 gateways that put them on the bus show *4*303*2## in place of the season frame; F453, F453AV, H4684, MH200 and MH200N dropped the writes and still show *4*1*2##. The emulated thermostat answers 102 / 202 / 302 on the plain zone with *4*303*2## (a plant-model answer, not firmware). The F455's synthetic *4*311*#Z## and its echo are therefore firmware-specific (F455 is not an oracle target).
  • Compared with the review's table: same results, except *4*311*#2## on H4684 / MH200, which is ACK + out here under both harnesses. *4*311*#0## and *4*303*#0#1## were not in this run.
  • The existing OWNd note stands: plain-zone *4*311*Z## is nack / silent on all 12 (thermo-zone-mode, ownd-pr82-heating), which is why AUTO addresses a zone through the central unit (#Z).
  1. Machine KB references cited in the review (not resolvable in our environment, not re-verified): ownkb:claim:c003328–c003333 (incl. c003330, WHAT 302 generic protection), ownkb:chunk:r000592, ownkb:chunk:r000595.

Follow-ups (not in this PR)

  • F455 zone status poll. Enabling *#4*Z## on the F455 needs a live check that the F455 answers it; see fix(climate): ask silent F455 zones their state once after startup (#466) #873.
  • F455 light confirmations. On the same plant the F455 drops the echoes of back-to-back light commands sent in the same millisecond, so some lights stay off in HA while the actuators switched. Separate issue.
  • OWNd. Expose the raw WHERE form (#Z vs Z) as a flag on OWNHeatingEvent, so integrations don't re-parse where. This PR checks the raw where in MyHOME, which works with the pinned 2.0.0b13.
  • Central season change (not agreed, not done). Following the season command with the previous operating mode (program 1101 / 2101 or a manual setpoint), so HEAT / COOL on #0 does not leave the plant in protection; or a season select. Needs a live check on the 3550 first.
  • 4695 (#0#C, #Z#C) report routing. HEAT / COOL on #0#1..#0#4 now keep the mode set, but there is no 4695 trace for its reports.
  • Merge commit c45d0df. Squash-merging leaves it out of history; a rebase needs a force-push (git rebase origin/v2-phase1-architecture && git push --force-with-lease), which the owner can run.
  • WHO 4 frames reach a climate entity twice. The climate and sensor platforms both route WHO 4 frames to the shared router, so every handler runs twice per frame (seen live, 2026-11-08). The handlers are idempotent apart from the echo this PR handles; deduplicating the route belongs in a separate change.

Checklist

  • PR Description: Follows the structured sections above.
  • Trace-Backed: behaviour verified against live F461 / F455 frames (fixture above) and on the live plant.
  • V2 Architecture: only handle_event() changes; no new async_dispatcher_connect.
  • Tests & Linters: new tests pass, full suite 5546 passed / 1 skipped with 100% coverage, ruff clean, scripts/typing_ratchet.py 0 errors, scripts/verify_ha_standards.py and scripts/sync_documentation.py --check pass.
  • Spec check: every frame / WHAT / WHERE in the diff cites WHO 4 pages (table above); behaviour the spec does not cover is labelled firmware- or plant-specific.
  • Firmware Oracle: 12-gateway verdicts above.

🤖 Generated with Claude Code
https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq

gdluck and others added 6 commits November 8, 2026 18:17
Live F461 2.0.11 + F455 on one bus with a BTicino 3550 central unit (#0),
zone 2 standalone and zone 4 a zone of the 3550 (2026-11-08): every
set_hvac_mode returned HTTP 200, but the mode shown was wrong.

- Standalone zones ignore mode / season frames in the #Z form (zone via
  the central unit, WHO 4 p. 6, 9, 65): the F455 emits *4*311*#2## after
  each status report of zone 2, which showed the heating zone as AUTO.
  Zones of a central unit keep every #Z state the 3550 reports.
- A standalone zone takes HEAT / COOL from its *4*1*Z## / *4*0*Z## answer
  to Home Assistant's own mode 1 / 2 setpoint write (*michnovka#4*2*OpenWebNet-HA#14*0265*2## ->
  *4*0*2##); unsolicited season frames stay season-only.
- Central 102 / 202 are antifreeze / thermal protection in their season
  (WHO 4 p. 5, 64), shown as HEAT / COOL with a central_operation
  attribute, not OFF. HEAT / COOL on #0 only change the season, so the
  mode shown waits for the 3550's answer instead of being set
  optimistically.
- The central drops the gateway's echo of Home Assistant's own command
  (the F455 returns *4*0*#0## 1 ms after sending it), which reset the
  3550's battery flag and turned the action OFF -> IDLE. The same echo
  object can arrive twice (climate and sensor platforms), so it is
  recognised by identity.
- HEAT in conditioning / COOL in heating on a zone of a central unit with
  a reported season, and AUTO on a standalone zone of a plant with a
  central unit, raise ServiceValidationError instead of a silent no-op.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq
…d mode

A standalone thermostat reports its mode in the plain Z form only as season
(*4*1*Z## / *4*0*Z##), protection or off (WHO 4 p. 63), never as automatic.
While its mode is still the one Home Assistant restored, that season frame
is the mode it runs in.

Live F455, 2026-11-08: after a restart zone 2 came back as AUTO, saved
earlier from the F455's own *4*311*#2## (now ignored for standalone zones).
The zone then reported *4*1*2##, which was taken as season only, so the
stale AUTO stayed until a mode write. Zones of a central unit, and zones
that have reported since start-up, are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq
A standalone thermostat has no program: in the plain Z form it reports its
mode only as *4*1*Z## / *4*0*Z## (heating / conditioning), protection or
off (WHO 4 p. 63). So that frame now sets HEAT / COOL whoever changed the
zone, instead of only right after this entity's own setpoint write or while
its mode is the restored one. OFF and protection still come from their own
frames; zones of a central unit keep the season-only handling.

Live, 2026-11-08: the zone 2 thermostat switched to cool from the F455's
entity (*michnovka#4*2*OpenWebNet-HA#14*0265*2## -> *4*0*2##). The F461 on the same bus saw the
same *4*0*2## but, not having sent the write, kept HEAT.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq
…eplays

The F453AV (OpenWebNet-HA#619) and F454 (OpenWebNet-HA#629) trace replays asserted the old reading of
*4*102*#0## / *4*202*#0## as OFF. The central unit now shows the season's
mode with central_operation antifreeze / thermal_protection (WHO 4 p. 5,
p. 64). The OpenWebNet-HA#619 replays also check that a zone the central put in
protection is still OFF (OpenWebNet-HA#383).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq
*4*303*#0## then *4*102*#0## (live 3550 answers): the central shows HEAT
with central_operation antifreeze, and its action leaves OFF. Restores the
100 % coverage of climate.py.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq
@codecov-commenter

Copy link
Copy Markdown

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

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@gdluck

gdluck commented Nov 8, 2026 •

Copy link
Copy Markdown
Author

Fixing this bug:

https://drive.proton.me/urls/B2V3JXKBE0#Fl3mos3k2aqQ

Can you please review this fix? Drop me an email if you want to test live on test plant homeassistant.

@gdluck
gdluck marked this pull request as ready for review November 8, 2026 17:57

@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

Great live-bus root-cause investigation on the dual F461 2.0.11 + F455 + BTicino 3550 (#0) setup — the captured frames.json trace makes all five failure modes crystal clear, and ruff, verify_ha_standards.py, sync_documentation.py --check, and OWNd==2.0.0b13 compatibility all pass cleanly.

However, exercising the state machine against zero-config standalone F455 plants (standalone=None), non-echoing gateways (F461/F454/MH200N), 4-zone central units (#0#1..#0#4, BTicino 4695), and MyHomeServer1 Dimension 7 schedules reveals four functional regressions (P0/P1) and three P2 gaps that should be addressed before merging.


Summary of Findings

  1. [P0 — Critical] F455's synthetic *4*311*#Z## still poisons gateway.has_central_unit = True (defeating F1/F2/F4/F5 on standalone-only F455 plants):
    • Both GatewayEventDispatcher.process_message (gateway_events.py:309–310) and MyHOMEClimate.handle_event (climate.py:1674–1675) set has_central_unit = True before _is_central_addressed_mode_frame (climate.py:1678).
    • On a zero-config standalone F455 plant (standalone=None, has_central_unit=False), replaying f455_zone2_after_restart sets has_central_unit = True on *4*311*#2##, immediately flipping zone2._standalone to False so *4*311*#2## is not dropped (hvac_mode becomes AUTO), future *4*0*2## frames are ignored, writes address #2 instead of 2, and set_hvac_mode(COOL) raises ServiceValidationError("zone_mode_outside_central_season") on a plant with no central unit.
    • Even with standalone: true in myhome.yaml (has_central_unit=False), replaying f455_zone2_after_restart poisons has_central_unit = True and breaks the standalone AUTO setpoint fallback (climate.py:1453 raises auto_standalone_zone instead of sending *#4*2*#14*0265*3##).
  2. [P1 — High] Central OFF / AUTO leaves stale central_operation & hvac_action, and _is_own_central_echo swallows the 3550's real *4*303*#0## / *4*311*#0## reply:
    • _central_echo is armed for OFF and AUTO as well as HEAT/COOL (and without try/except around await send(command)), while the optimistic OFF/AUTO branch (climate.py:1434–1441) neither clears self._central_operation = None nor sets self._attr_hvac_action = HVACAction.OFF on OFF.
    • On non-echoing gateways (F461, F460, F454, F453AV, MH200N, MH202, MyHomeServer1), when the 3550 replies *4*303*#0## or *4*311*#0## ~230 ms later, _is_own_central_echo drops the real status frame — leaving central_operation stuck at "antifreeze"/"thermal_protection", hvac_action not OFF, and skipping _reset_central_status_flags() so cleared battery_fault / failure_detected stay True.
  3. [P1 — High] Early return on central HEAT / COOL breaks 4-zone central units (#0#1..#0#4, BTicino 4695):
    • Entities at #0#1..#0#4 have self._central = True (climate.py:571), are not polled (climate.py:910–914), and _zone_route_keys routes #0#1 events to ['1', '#0', '1'] (never '#0#1'). Returning early at climate.py:1433 leaves cu_4695.hvac_mode stuck at None forever on HEAT/COOL.
  4. [P1 — High] MyHomeServer1 Dimension 7 automatic mode (*#4*Z*7*3*...##) is overwritten by trailing *4*1*Z## / *4*0*Z##:
    • MyHomeServer1 runs Home + Control schedules without #0 (has_central_unit == False → _standalone == True). When bt_thermo_manager emits *#4*1*7*3*1*0210## (HVACMode.AUTO) followed ~80 ms later by *4*1*1##, standalone_mode at climate.py:1787 clobbers HVACMode.AUTO → HVACMode.HEAT.
  5. [P2 — Medium] async_restore_last_state omits central_operation (restores heat/cool + sibling #0 diagnostic flags across restarts without central_operation), and _CENTRAL_PROTECTION omits WHAT 302 (Generic protection, ownkb:claim:c003330).
  6. [P2 — Medium] Untranslated {season} placeholder in fr.json, it.json, and nl.json: self._season injects raw English "conditioning" / "cooling" / "heating" into localized exception strings.

Firmware Oracle & Encyclopedia Verification (openwebnet-mcp)

Please also add the 12-gateway QEMU Firmware Oracle verdicts (lookup_firmware_verdict) and Machine KB references (ownkb:claim:c003328–c003333, ownkb:chunk:r000592, r000595) to ## What does the Firmware Oracle / Encyclopedia say:

Frame Meaning (Machine KB) F450 / F453 / F453AV / F454 / F459 / MH200N / MH202 F460 020012 / F461 020011 / MyHomeServer1 028206 H4684 020054 / MH200 020102
*4*1*#0## Central heating season ACK + OUT ($06D1000302C1F000000) NACK (*#*0##) + OUT (transmits bus telegram despite session NACK) NACK (*#*0##) + SILENT (Drop)
*4*0*#0## Central conditioning season ACK + OUT ($06D1000302C1F100000) NACK (*#*0##) + OUT ($006D1000302C1F100000) NACK (*#*0##) + SILENT (Drop)
*4*311*#0## Central automatic ACK + OUT ($06D1000302C20000000) NACK (*#*0##) + OUT ($006D1000302C20000000) NACK (*#*0##) + SILENT (Drop)
*4*303*#0## Central generic OFF ACK + OUT ($06D1000302C10600000) NACK (*#*0##) + OUT ($006D1000302C10600000) ACK + OUT ($06D1000302C10600000)
*4*303*#0#1## 4-zone central Z1 OFF ACK + OUT ($06D1010302C10600000) NACK (*#*0##) + OUT ACK + OUT (truncates #0#1 → 00)
*4*311*#2## Zone 2 automatic via central ACK + OUT ($06D1020302C12000000) NACK (*#*0##) + OUT NACK (*#*0##) + SILENT (Drop)

(Note: F461 020011 returning NACK on the command session while still putting $006D1000302C1F0... / F1... on the SCS bus explains why gateway.send() succeeds on the live F461 and receives the 3550's *4*102*#0## / *4*202*#0## reply 233 ms later; meanwhile H4684 and MH200 silently drop *4*1*#0##, *4*0*#0##, and *4*311*#0##.)

Minor Hygiene

  • When taking the PR out of Draft, please rebase onto v2-phase1-architecture to drop merge commit c45d0df4 and tick the completed items in ## Checklist.

Inline comments with reproduction details and suggested fixes are attached below.

attrs["Int"] = self._interface
if self._central:
if self._central_operation is not None:
attrs["central_operation"] = self._central_operation

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.

[P2 — Medium] central_operation is not restored in async_restore_last_state (and consider WHAT 302)

async_restore_last_state (lines 804–819) restores all sibling #0 attributes (remote_control, at_least_one_probe_*, failure_detected, battery_fault) plus hvac_mode (heat/cool) and season, but omits central_operation. After a Home Assistant restart, a central unit saved in antifreeze/thermal protection comes back as active heat/cool without central_operation until #0 broadcasts again.

Please restore central_operation when last_state.attributes.get("central_operation") in ("antifreeze", "thermal_protection") alongside remote_control.

Also consider WHAT 302 (Generic protection, Encyclopedia ownkb:claim:c003330): currently *4*302*#0## sets _central_operation = None and HVACMode.OFF even when self._season is already known.

Comment on lines +1426 to 1434
self._central_echo = (str(command), time.monotonic() + _CENTRAL_ECHO_WINDOW)
await self._gateway_handler.send(command)
if hvac_mode in (HVACMode.HEAT, HVACMode.COOL):
# *4*1*#0## / *4*0*#0## only change the season (WHO 4 p. 5). The 3550
# answers with the operating mode it is in, e.g. *4*102*#0## antifreeze,
# or nothing when the season does not change (live F461 and F455,
# 2026-11-08), so the mode shown comes from that answer.
return
self._attr_hvac_mode = hvac_mode

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.

[P1 — High] Two bugs in async_set_hvac_mode for self._central:

  1. OFF / AUTO leaves stale central_operation & hvac_action, and _is_own_central_echo drops the 3550's genuine *4*303*#0## / *4*311*#0## reply:

    • self._central_echo (line 1426) is armed for all central modes (OFF → *4*303*#0##, AUTO → *4*311*#0##, HEAT → *4*1*#0##, COOL → *4*0*#0##), and without try/except around await self._gateway_handler.send(command).
    • On OFF / AUTO, line 1434 optimistically sets self._attr_hvac_mode = hvac_mode without clearing self._central_operation = None and without setting self._attr_hvac_action = HVACAction.OFF (on OFF).
    • Unlike *4*1*#0## / *4*0*#0##, on OFF and AUTO the 3550's real status broadcast can be the exact same frame (*4*303*#0## / *4*311*#0##) ~230 ms later. On non-echoing gateways (F461, F460, F454, F453AV, MH200N, MH202, MyHomeServer1), _is_own_central_echo consumes and drops the 3550's real reply:
      • central_operation remains "antifreeze" / "thermal_protection" while hvac_mode is off or auto;
      • hvac_action stays IDLE (not OFF) when switched off;
      • _reset_central_status_flags() never runs at the start of the 3550's burst, so resolved battery_fault / failure_detected flags stay stuck True (notice test_climate_central_discovery.py:342 had to manually clear cu._central_echo = None because of this).
    • Suggested fix: Only arm _central_echo for season-only commands (*4*0*#0##, *4*1*#0##) — whose echo hits MESSAGE_TYPE_SEASON — clear _central_echo = None if send() raises, and clear self._central_operation = None (+ self._attr_hvac_action = HVACAction.OFF on OFF) on the optimistic OFF/AUTO branch.
  2. Early return at line 1433 breaks 4-zone central units (#0#1..#0#4, BTicino 4695):

    • Entities at #0#1..#0#4 have self._central = True (line 571), do not support status polling (lines 910–914), and _zone_route_keys routes *4*102*#0#1## / *4*110*#0#1## to ['1', '#0', '1'] (never '#0#1'). Returning early here leaves cu_4695.hvac_mode stuck at None forever on HEAT/COOL.
    • Suggested fix: Scope the wait-for-3550-reply early return to self._where.removeprefix("4-") in ("#0", "0").

self._gateway_handler.has_central_unit = True
if self._poll_health.frame_seen():
self._clear_unresponsive_issue()
if self._is_central_addressed_mode_frame(message):

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.

[P0 — Critical] F455's synthetic *4*311*#Z## still poisons has_central_unit = True before _is_central_addressed_mode_frame runs

Both GatewayEventDispatcher.process_message (gateway_events.py:309–310) and lines 1674–1675 right above execute:

if is_central_thermo_where(message):
    self._gateway_handler.has_central_unit = True

before _is_central_addressed_mode_frame(message) at line 1678.

Because F455 fabricates *4*311*#2## after every standalone zone status report (even on plants with no central unit at all):

  1. Zero-config / bus-discovered standalone zone on F455 (standalone=None, has_central_unit=False):
    • Replaying f455_zone2_after_restart sets gateway.has_central_unit = True at line 1675 (and gateway_events.py:310).
    • Four lines later at line 1678, self._standalone (line 1143) is now False, so _is_central_addressed_mode_frame returns False!
    • Zone 2 flips to HVACMode.AUTO, stops taking its mode from *4*0*2## / *4*1*2##, addresses #2 instead of 2 on writes, and calling async_set_hvac_mode(HVACMode.COOL) raises ServiceValidationError("zone_mode_outside_central_season") claiming the zone follows a central unit that does not exist.
  2. Even with standalone: true in myhome.yaml (has_central_unit=False):
    • Replaying f455_zone2_after_restart mutates gateway.has_central_unit = True at line 1675 (and gateway_events.py:310), so async_set_hvac_mode(HVACMode.AUTO) (line 1453) loses the *#4*2*#14*0265*3## setpoint fallback and raises ServiceValidationError("auto_standalone_zone").

Suggested fix:

  • Check _is_central_addressed_mode_frame(message) before if is_central_thermo_where(message): self._gateway_handler.has_central_unit = True in handle_event, and
  • Guard GatewayEventDispatcher.process_message (gateway_events.py:309–310) (and handle_event) so F455's synthetic *4*311*#Z## does not promote has_central_unit = True on its own (e.g., on F455 require #0 / #0#N or a #Z frame other than synthetic 311, or ignore #Z when the zone is configured standalone=True).

season_mode = HVACMode.HEAT if message.season == SEASON_HEATING else HVACMode.COOL
prev_mode = self._attr_hvac_mode
if self._central or self._attr_hvac_mode in (None, HVACMode.OFF):
if self._central or standalone_mode or self._attr_hvac_mode in (None, HVACMode.OFF):

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.

[P1 — High] MyHomeServer1 Dimension 7 automatic mode (*#4*Z*7*3*...##) is overwritten by trailing *4*1*Z## / *4*0*Z##

On MyHomeServer1 (bt_thermo_manager / Home + Control), weekly schedules are run by the gateway itself without a #0 central unit (has_central_unit == False across all MyHomeServer1 traces), so unconfigured zones have self._standalone == True.

When MyHomeServer1 reports a scheduled zone via Dimension 7 in the automatic context (*#4*1*7*3*1*0210##, *#4*1*7*3*3## comfort, *#4*1*7*3*4## eco → _apply_zone_state sets _attr_hvac_mode = HVACMode.AUTO) followed ~80 ms later by *4*1*1## / *4*0*1##, standalone_mode here unconditionally overwrites HVACMode.AUTO with HVACMode.HEAT / HVACMode.COOL.

Suggested fix: Track when the zone's active mode came from Dimension 7 (MESSAGE_TYPE_ZONE_STATE, e.g. self._zone_state_reported = True) so *4*1*Z## / *4*0*Z## does not clobber a non-restored HVACMode.AUTO set by Dimension 7, while still updating standalone SCS thermostats (which never emit Dimension 7 automatic).

"message": "{name} est un thermostat autonome : la centrale n'a pas de programme pour lui"
},
"zone_mode_outside_central_season": {
"message": "{name} suit la centrale, qui est en saison {season} : changez la saison sur la centrale"

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.

[P2 — Medium] Raw English {season} ("conditioning", "cooling", "heating") leaked into localized fr / it / nl error messages

climate.py:1511 passes "season": self._season, which holds raw internal English strings ("conditioning", "cooling", "heating"), producing mixed-language UI errors in French, Italian, and Dutch:

  • fr.json: "...qui est en saison conditioning : changez la saison sur la centrale"
  • it.json: "...che è nella stagione conditioning: cambia la stagione sulla centrale"
  • nl.json: "...die in het seizoen conditioning staat: wijzig het seizoen op de centrale"

Splitting into two translation keys (zone_heat_in_conditioning_season and zone_cool_in_heating_season taking only {name}) avoids raw English enum leakage and reads naturally across all four languages.

Comment on lines +323 to +328
async def test_auto_on_a_standalone_zone_without_a_central_keeps_the_setpoint_fallback() -> None:
gateway = _gateway(has_central_unit=False)
zone2 = _climate(gateway, "2", standalone=True)
zone2._target_temperature = 26.5
await zone2.async_set_hvac_mode(HVACMode.AUTO)
assert _sent(gateway) == ["*#4*2*#14*0265*3##"]

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.

Test coverage gap (masks P0): All standalone zone tests in this file pass standalone=True explicitly, and this fallback test sets zone2._target_temperature = 26.5 without replaying f455_zone2_after_restart first.

Please add:

  1. A zero-config F455 test (standalone=None, has_central_unit=False) replaying f455_zone2_after_restart through GatewayEventDispatcher.process_message + zone2.handle_event, asserting has_central_unit stays False, zone2.hvac_mode == HVACMode.HEAT, and await zone2.async_set_hvac_mode(HVACMode.COOL) sends *#4*2*#14*0265*2## instead of raising zone_mode_outside_central_season.
  2. Replaying f455_zone2_after_restart before await zone2.async_set_hvac_mode(HVACMode.AUTO) in this test so the *#4*2*#14*0265*3## fallback is verified after F455's trailing *4*311*#2##.

…, echo, 4-zone, schedules)

- A central unit is known from #0 / #0#N frames, a #Z dimension, or a #Z
  zone status with its season (WHO 4 p. 65, p. 9: 110/210/111/211/103/203/
  102/202), in the dispatcher and in the climate entity. The generic
  *4*311*#Z## / *4*310*#Z## no longer count: the F455 makes up *4*311*#2##
  for a standalone thermostat, so a standalone-only plant was taken for one
  with a central unit. The live 3550 is still found from its *4*210*michnovka#4##.
- The central's echo drop applies only within 100 ms of the command (F455
  echo after 1 ms; the 3550's first answer after 121 ms), to every copy of
  the frame inside that window; a failed send disarms it.
- Central OFF / AUTO clear central_operation, OFF sets the action to off.
- HEAT / COOL wait for the 3550's answer only on #0; a 4-zone central
  (#0#1..#0#4), which is not polled, keeps the mode it was set to.
- A standalone zone's plain season frame no longer cancels an AUTO the zone
  itself reported (Dimension 7 automatic context); Dimension 7 also ends the
  restored-mode state.
- central_operation is restored after a restart; *4*302*#0## is the
  protection of the season the plant is in (WHO 4 p. 5).
- The season errors are two translation keys without a season placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gdluck

gdluck commented Nov 8, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review. Every item reproduces in code; two need a narrower reading of the evidence. All fixed in 1a56d3c, with trace-replay tests in tests/test_climate_mode_state_2026_11_08.py (23 cases fail on f5248f9 and pass on 1a56d3c; the remaining new cases are guards that pass on both, e.g. a real *4*210*#4## still marks a central).

item verdict what changed
P0 F455 *4*311*#Z## sets has_central_unit correct. The #Z detection predates this PR (gateway_events.py:310 and the handle_event check are unchanged from v2-phase1-architecture); the new auto_standalone_zone / season errors are what made it matter. New where_grammar.is_central_unit_report(), used in the dispatcher and in handle_event: #0 / #0#N, a #Z dimension, or a #Z zone status with its season (110/210/111/211/103/203/102/202, WHO 4 p. 65 §1.9.4 and p. 9). The generic 310/311 no longer count. We did not go "#0 only": the live 3550's quarter-hour push has no #0 frame, only *4*210*#4##, so a zero-config central plant would no longer be found. Caveat: p. 9 (l. 338-346) also lists 311 "Automatic Generic" as a monitor frame for #Z, so a central that reports only generic 311 is not detected from it. It is still found from #0, a dimension, or any seasonal status. Tests: test_f455_synthetic_311_does_not_make_a_standalone_only_plant_central (your zero-config scenario, through GatewayEventDispatcher + handle_event), test_auto_fallback_survives_the_f455_trailing_311, test_central_unit_detection_by_frame[...], test_zone_of_the_central_found_from_the_3550_quarter_hour_push.
P1a stale central_operation / hvac_action; echo swallowing the 3550's reply state part correct; identical-reply part partly correct. The 3550 never answered with the identical frame here: live F461, tx *4*303*#0## at 14:22:46.998 got rx *4*203*#0## 121 ms later; p. 64 §1.9.3 lists no 303 / 311 / 0 / 1 among the central's status WHATs. But a 2 s text match could swallow any identical frame (e.g. from another client), so we narrowed it. Window 2 s → 100 ms (F455 echo after 1 ms, the 3550's first answer after 121 ms). Every copy of the frame inside the window is the echo, so a second routing that hands over a different object no longer gets through. send() raising disarms it. Optimistic OFF / AUTO clear central_operation; OFF sets hvac_action OFF. We kept arming for OFF / AUTO too: on the F455 the echo of *4*303*#0## would otherwise reset the status flags exactly as in problem 4. Tests: test_identical_central_answer_after_the_echo_window_is_a_report, test_echo_delivered_as_two_copies_changes_nothing, test_failed_send_disarms_the_echo, test_only_one_echo_is_dropped_and_only_while_expected (updated).
P1b early return for #0#1..#0#4 correct The wait-for-answer return is scoped to #0; a 4-zone central keeps the mode it was set to. Test: test_four_zone_central_keeps_the_mode_it_is_set_to[heat/cool]. Report routing of #0#C / #Z#C is unchanged and still has no 4695 trace (follow-up).
P1c MyHomeServer1 Dimension 7 AUTO overwritten correct in code, not reproduced on our plant. Read-only MyHomeServer1 diagnostics (1348 WHO 4 frames) contain 217 Dimension 7 frames, all context 1, none automatic. A standalone zone's plain season frame no longer cancels an AUTO the zone itself reported. A restored AUTO is still replaced, and Dimension 7 now ends the restored-mode state. Tests: test_myhomeserver1_schedule_auto_survives_the_trailing_season_frame (also checks manual context then *4*0*1## → COOL), test_restored_auto_then_schedule_auto_is_kept.
P2a restore central_operation; WHAT 302 correct Restored when antifreeze / thermal_protection. *4*302*#0## (p. 5: protection, generic) maps to the protection of the season already known; with no known season the handling is unchanged. This mapping is our reading of "generic": p. 64 lists only 102 / 202 for the central, and we have no live 302 on #0. Tests: test_central_operation_is_restored, test_central_generic_protection_follows_the_season[...].
P2b {season} untranslated correct Split into zone_heat_in_conditioning_season / zone_cool_in_heating_season (only {name}), in strings + en/fr/it/nl. Tests: test_season_errors_are_translated_without_a_season_placeholder[...], test_cool_on_a_zone_of_the_central_in_heating_is_refused.

Oracle table. We re-ran the frames on all 12 emulated firmwares (own-oracle, harness=full and harness=plant:f461-bench); the results are now in the PR body. Your rows match ours except *4*311*#2## on H4684 020054: we get ACK + out under both harnesses, not NACK + silent (MH200 020102 is also ACK + out). *4*311*#0## and *4*303*#0#1## were not in our run, so those rows are yours, not re-verified. None of the 12 emulated gateways emits *4*WHAT*#Z## as a status, and none answers *4*1*#0## with 102 / 202 (the fixtures have no central unit). F455 is not an oracle target. We could not resolve the Machine KB IDs (ownkb:claim:c003328–c003333, r000592, r000595) in this environment, so they are cited in the body as yours.

Rebase. Dropping merge commit c45d0df needs a force-push, which this setup does not do. Squash-merging leaves it out of v2-phase1-architecture history; or the owner can rebase (command in the PR body notes). Checklist items are ticked where true.

Full suite: 5546 passed, 1 skipped, coverage 100.0% (climate.py, gateway_events.py, where_grammar.py 100%). ruff check clean, scripts/typing_ratchet.py 0 errors, verify_ha_standards.py and sync_documentation.py --check pass. Not yet verified on the test Home Assistant.


Generated by Claude Code

@gdluck
gdluck marked this pull request as draft November 8, 2026 19:27
@gdluck
gdluck marked this pull request as ready for review November 8, 2026 20:05

@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.

All 6 findings from #pullrequestreview-5819030895 (P0, P1a, P1b, P1c, P2a, P2b) are cleanly resolved in 1a56d3c7:

  • P0 (has_central_unit detection): where_grammar.is_central_unit_report() cleanly restricts central-unit detection to #0 / #0#N, #Z dimensions, and seasonal #Z zone status reports (110/210/111/211/103/203/102/202), preventing F455's synthetic *4*311*#2## from poisoning standalone-only plants while still detecting a 3550 from #Z reports (e.g. *4*210*#4##) before _is_central_addressed_mode_frame runs.
  • P1a (_CENTRAL_ECHO_WINDOW = 0.1 & central OFF/AUTO): OFF / AUTO clear central_operation (OFF sets hvac_action = OFF), failed sends disarm _central_echo, all copies within the 100 ms window are dropped, and real 3550 replies (>= 121 ms) pass through on non-echoing gateways.
  • P1b (#0#1..#0#4 4-zone centrals): Scoped wait-for-reply early return to ("#0", "0") so 4-zone central entities keep optimistic HEAT/COOL and notify subordinate zones.
  • P1c (MyHomeServer1 Dimension 7 AUTO): _apply_zone_state clears _hvac_mode_restored = False, and standalone plain-Z season frames preserve zone-reported AUTO while still replacing stale restored AUTO after restart.
  • P2a (central_operation restore & *4*302*#0##): Validated restore in async_restore_last_state and season-aware generic protection mapping (102 vs 202).
  • P2b (Translations): Split into zone_heat_in_conditioning_season and zone_cool_in_heating_season ({name} only) across strings.json and en/fr/it/nl.

Also re-checked the Firmware Oracle note: confirmed *4*311*#1## (thermo-zone-mode) is indeed ACK + OUT on H4684 020054 and MH200 020102 (whereas *4*0*#0## in thermo-central-mode is NACK + SILENT on those two).

Minor non-blocking note (optional future hardening): MyHOMEGatewayHandler.send() enqueues onto CommandWorkerPool.send_buffer and returns written: asyncio.Future[float] (which resolves to written_at once written to _command_session). On an idle LAN F455 dequeue + connect is ~3 ms (well within 0.1 s), though 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. Not a blocker here.

LGTM — ready to Squash and merge (to keep merge commit c45d0df4 out of history).

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit a2f7003 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