Repository navigation
climate: zone/central mode state from status frames only - #880
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
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. |
GreenGrassBlueOcean
left a comment
There was a problem hiding this comment.
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
- [P0 — Critical] F455's synthetic
*4*311*#Z##still poisonsgateway.has_central_unit = True(defeating F1/F2/F4/F5 on standalone-only F455 plants):- Both
GatewayEventDispatcher.process_message(gateway_events.py:309–310) andMyHOMEClimate.handle_event(climate.py:1674–1675) sethas_central_unit = Truebefore_is_central_addressed_mode_frame(climate.py:1678). - On a zero-config standalone F455 plant (
standalone=None,has_central_unit=False), replayingf455_zone2_after_restartsetshas_central_unit = Trueon*4*311*#2##, immediately flippingzone2._standalonetoFalseso*4*311*#2##is not dropped (hvac_modebecomesAUTO), future*4*0*2##frames are ignored, writes address#2instead of2, andset_hvac_mode(COOL)raisesServiceValidationError("zone_mode_outside_central_season")on a plant with no central unit. - Even with
standalone: trueinmyhome.yaml(has_central_unit=False), replayingf455_zone2_after_restartpoisonshas_central_unit = Trueand breaks the standaloneAUTOsetpoint fallback (climate.py:1453raisesauto_standalone_zoneinstead of sending*#4*2*#14*0265*3##).
- Both
- [P1 — High] Central
OFF/AUTOleaves stalecentral_operation&hvac_action, and_is_own_central_echoswallows the 3550's real*4*303*#0##/*4*311*#0##reply:_central_echois armed forOFFandAUTOas well asHEAT/COOL(and withouttry/exceptaroundawait send(command)), while the optimisticOFF/AUTObranch (climate.py:1434–1441) neither clearsself._central_operation = Nonenor setsself._attr_hvac_action = HVACAction.OFFonOFF.- 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_echodrops the real status frame — leavingcentral_operationstuck at"antifreeze"/"thermal_protection",hvac_actionnotOFF, and skipping_reset_central_status_flags()so clearedbattery_fault/failure_detectedstayTrue.
- [P1 — High] Early return on central
HEAT/COOLbreaks 4-zone central units (#0#1..#0#4, BTicino 4695):- Entities at
#0#1..#0#4haveself._central = True(climate.py:571), are not polled (climate.py:910–914), and_zone_route_keysroutes#0#1events to['1', '#0', '1'](never'#0#1'). Returning early atclimate.py:1433leavescu_4695.hvac_modestuck atNoneforever onHEAT/COOL.
- Entities at
- [P1 — High]
MyHomeServer1Dimension 7automaticmode (*#4*Z*7*3*...##) is overwritten by trailing*4*1*Z##/*4*0*Z##:MyHomeServer1runs Home + Control schedules without#0(has_central_unit == False→_standalone == True). Whenbt_thermo_manageremits*#4*1*7*3*1*0210##(HVACMode.AUTO) followed ~80 ms later by*4*1*1##,standalone_modeatclimate.py:1787clobbersHVACMode.AUTO→HVACMode.HEAT.
- [P2 — Medium]
async_restore_last_stateomitscentral_operation(restoresheat/cool+ sibling#0diagnostic flags across restarts withoutcentral_operation), and_CENTRAL_PROTECTIONomitsWHAT 302(Generic protection,ownkb:claim:c003330). - [P2 — Medium] Untranslated
{season}placeholder infr.json,it.json, andnl.json:self._seasoninjects 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-architectureto drop merge commitc45d0df4and 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 |
There was a problem hiding this comment.
[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.
| 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 |
There was a problem hiding this comment.
[P1 — High] Two bugs in async_set_hvac_mode for self._central:
-
OFF/AUTOleaves stalecentral_operation&hvac_action, and_is_own_central_echodrops 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 withouttry/exceptaroundawait self._gateway_handler.send(command).- On
OFF/AUTO,line 1434optimistically setsself._attr_hvac_mode = hvac_modewithout clearingself._central_operation = Noneand without settingself._attr_hvac_action = HVACAction.OFF(onOFF). - Unlike
*4*1*#0##/*4*0*#0##, onOFFandAUTOthe 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_echoconsumes and drops the 3550's real reply:central_operationremains"antifreeze"/"thermal_protection"whilehvac_modeisofforauto;hvac_actionstaysIDLE(notOFF) when switchedoff;_reset_central_status_flags()never runs at the start of the 3550's burst, so resolvedbattery_fault/failure_detectedflags stay stuckTrue(noticetest_climate_central_discovery.py:342had to manually clearcu._central_echo = Nonebecause of this).
- Suggested fix: Only arm
_central_echofor season-only commands (*4*0*#0##,*4*1*#0##) — whose echo hitsMESSAGE_TYPE_SEASON— clear_central_echo = Noneifsend()raises, and clearself._central_operation = None(+self._attr_hvac_action = HVACAction.OFFonOFF) on the optimisticOFF/AUTObranch.
-
Early return at
line 1433breaks 4-zone central units (#0#1..#0#4, BTicino 4695):- Entities at
#0#1..#0#4haveself._central = True(line 571), do not support status polling (lines 910–914), and_zone_route_keysroutes*4*102*#0#1##/*4*110*#0#1##to['1', '#0', '1'](never'#0#1'). Returning early here leavescu_4695.hvac_modestuck atNoneforever onHEAT/COOL. - Suggested fix: Scope the wait-for-3550-reply early return to
self._where.removeprefix("4-") in ("#0", "0").
- Entities at
| 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): |
There was a problem hiding this comment.
[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 = Truebefore _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):
- Zero-config / bus-discovered standalone zone on F455 (
standalone=None,has_central_unit=False):- Replaying
f455_zone2_after_restartsetsgateway.has_central_unit = Trueatline 1675(andgateway_events.py:310). - Four lines later at
line 1678,self._standalone(line 1143) is nowFalse, so_is_central_addressed_mode_framereturnsFalse! - Zone 2 flips to
HVACMode.AUTO, stops taking its mode from*4*0*2##/*4*1*2##, addresses#2instead of2on writes, and callingasync_set_hvac_mode(HVACMode.COOL)raisesServiceValidationError("zone_mode_outside_central_season")claiming the zone follows a central unit that does not exist.
- Replaying
- Even with
standalone: trueinmyhome.yaml(has_central_unit=False):- Replaying
f455_zone2_after_restartmutatesgateway.has_central_unit = Trueatline 1675(andgateway_events.py:310), soasync_set_hvac_mode(HVACMode.AUTO)(line 1453) loses the*#4*2*#14*0265*3##setpoint fallback and raisesServiceValidationError("auto_standalone_zone").
- Replaying
Suggested fix:
- Check
_is_central_addressed_mode_frame(message)beforeif is_central_thermo_where(message): self._gateway_handler.has_central_unit = Trueinhandle_event, and - Guard
GatewayEventDispatcher.process_message(gateway_events.py:309–310) (andhandle_event) so F455's synthetic*4*311*#Z##does not promotehas_central_unit = Trueon its own (e.g., on F455 require#0/#0#Nor a#Zframe other than synthetic311, or ignore#Zwhen the zone is configuredstandalone=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): |
There was a problem hiding this comment.
[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" |
There was a problem hiding this comment.
[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.
| 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##"] |
There was a problem hiding this comment.
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:
- A zero-config F455 test (
standalone=None,has_central_unit=False) replayingf455_zone2_after_restartthroughGatewayEventDispatcher.process_message+zone2.handle_event, assertinghas_central_unitstaysFalse,zone2.hvac_mode == HVACMode.HEAT, andawait zone2.async_set_hvac_mode(HVACMode.COOL)sends*#4*2*#14*0265*2##instead of raisingzone_mode_outside_central_season. - Replaying
f455_zone2_after_restartbeforeawait 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>
|
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
Oracle table. We re-ran the frames on all 12 emulated firmwares (own-oracle, Rebase. Dropping merge commit c45d0df needs a force-push, which this setup does not do. Squash-merging leaves it out of Full suite: 5546 passed, 1 skipped, coverage 100.0% (climate.py, gateway_events.py, where_grammar.py 100%). Generated by Claude Code |
GreenGrassBlueOcean
left a comment
There was a problem hiding this comment.
All 6 findings from #pullrequestreview-5819030895 (P0, P1a, P1b, P1c, P2a, P2b) are cleanly resolved in 1a56d3c7:
- P0 (
has_central_unitdetection):where_grammar.is_central_unit_report()cleanly restricts central-unit detection to#0/#0#N,#Zdimensions, and seasonal#Zzone 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#Zreports (e.g.*4*210*#4##) before_is_central_addressed_mode_frameruns. - P1a (
_CENTRAL_ECHO_WINDOW = 0.1& centralOFF/AUTO):OFF/AUTOclearcentral_operation(OFFsetshvac_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#44-zone centrals): Scoped wait-for-reply early return to("#0", "0")so 4-zone central entities keep optimisticHEAT/COOLand notify subordinate zones. - P1c (
MyHomeServer1Dimension 7AUTO):_apply_zone_stateclears_hvac_mode_restored = False, and standalone plain-Zseason frames preserve zone-reportedAUTOwhile still replacing stale restoredAUTOafter restart. - P2a (
central_operationrestore &*4*302*#0##): Validated restore inasync_restore_last_stateand season-aware generic protection mapping (102vs202). - P2b (Translations): Split into
zone_heat_in_conditioning_seasonandzone_cool_in_heating_season({name}only) acrossstrings.jsonanden/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).
a2f7003
into
OpenWebNet-HA:v2-phase1-architecture
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, everyclimate.set_hvac_modecall returned HTTP 200 with no NACK, yet Home Assistant showed the wrong mode:*4*311*#2##on the event session (8 of 8 reports; never on the F461 monitoring the same bus). OWNd reads WHERE#2as zone 2, and WHAT 311 makes it AUTO. The F455 never polls*#4*2##, so nothing corrects it.*#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.*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*0*#0##back 1 ms after sending it. As a season frame it reset the central's status flags (thebattery_faultthe 3550 had just reported with*4*31*#0##went false) and turnedhvac_actionfrom 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.*#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 inwhere_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#Zdimension, or a#Zzone 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#Zframe sethas_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
#Zform 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#Zstate: 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
Zform 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/202are protection, not OFF. The central keeps the season's mode (HEAT for 102, COOL for 202) and exposescentral_operation: antifreeze | thermal_protection; it is cleared by any other mode report. HEAT / COOL on#0are 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 setshvac_actionOFF. 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_operationsurvives 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
ServiceValidationErrorand no frame sent: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.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 --> MService side: HEAT / COOL on
#0send 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 raiseServiceValidationErrorwithout 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.
*4*311*#2##at +1.4 s (it showed AUTO before the test)*4*0*2##, then heat from*4*1*2##, no OFF round trip*4*102*#0##-> heat +central_operation: antifreeze; the second press got no answer and stayed heat + antifreeze;*4*202*#0##-> cool +thermal_protection;battery_faulttrue throughout. The F455 central showed the same from the same 3550 framesbattery_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_faulttrue,last_updatedunchanged), twicethermal_protection+battery_faulttrue from the 3550's startup answerThe 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.pyreplays the frames above (tests/fixtures/traces/climate_mode_2026_11_08/frames.json, WHO 4 frames only, no addresses):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_offtest_central_operation_clears_on_a_manual_mode_reporttest_f455_echo_of_our_central_command_changes_nothingtest_only_one_echo_is_dropped_and_only_while_expectedtest_heat_on_a_zone_of_the_central_in_conditioning_is_refusedtest_auto_on_a_standalone_zone_of_a_plant_with_a_central_is_refusedtest_restored_auto_on_a_standalone_zone_is_replaced_by_its_season_frame(before:auto == heat)test_unsolicited_season_frame_is_the_standalone_zone_modetest_both_gateways_show_cool_after_the_f455_switches_the_standalone_zone(replays the F455 and F461 frames of the owner's 16:50 switch)*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, throughGatewayEventDispatcher+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 changedclimate.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 replaystest_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#0set 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 onsys.paththat 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 checkclean.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):
F461, zone 2 heat -> cool:
F461, central heat, twice:
F455, central cool, second press:
F461, zone 4 (a zone of the 3550, plant in conditioning):
What does the Firmware Oracle / Encyclopedia say
docs/who-pdf), claim by claim:#0= central unit,#1..#99= zone via central unit*4*311*#Z##sets zone Z to automatic through the central unit*4*what*#Z##(110/210/111/211/103/203/102/202) is the zone's mode reported by the central unit*4*0*Z##/*4*1*Z##is the zone operation mode (season)*#4*Z*#14*T*M##, M = 1 heating, 2 conditioning*4*110#T*#0##manual heating of the central unit*4*1*#0##/*4*0*#0##as a central command, and the 3550 answering it with 102 / 202#0*4*311*#Z##after a zone status, and echoing HA's own#0command*4*0/1*Z##Zform only as 0 / 1 / 102 / 202 / 303#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 programsNo 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=fullandharness=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.*4*1*#0##,*4*0*#0##,*4*1*#0#1##*4*102*#0##,*4*202*#0##,*4*303*#0##*4*311*#2##,*4*311*#2#1##*4*311*2##*#4*2*#14*0265*1##/*2##*#4*#0##,*#4*#0#1##,*#4*#2##*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##with0, 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).*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.*4*311*Z##isnack/silenton all 12 (thermo-zone-mode,ownd-pr82-heating), which is why AUTO addresses a zone through the central unit (#Z).ownkb:claim:c003328–c003333(incl.c003330, WHAT 302 generic protection),ownkb:chunk:r000592,ownkb:chunk:r000595.Follow-ups (not in this PR)
*#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.#ZvsZ) as a flag onOWNHeatingEvent, so integrations don't re-parsewhere. This PR checks the rawwherein MyHOME, which works with the pinned 2.0.0b13.#0does not leave the plant in protection; or a season select. Needs a live check on the 3550 first.#0#C,#Z#C) report routing. HEAT / COOL on#0#1..#0#4now keep the mode set, but there is no 4695 trace for its reports.git rebase origin/v2-phase1-architecture && git push --force-with-lease), which the owner can run.Checklist
handle_event()changes; no newasync_dispatcher_connect.ruffclean,scripts/typing_ratchet.py0 errors,scripts/verify_ha_standards.pyandscripts/sync_documentation.py --checkpass.🤖 Generated with Claude Code
https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq