Repository navigation
fix(climate): derive WHO=4 local knob offset from paired Dimension 12/14 frames when Dimension 13 is absent (#466) - #890
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Checked The identity holds on hardware. Replaying the rule over our F461 + 3550 logs (Dimension 12/14 pairs within 2 s, whole-degree difference in -3..+3): 228 pairs, 221 agree with the zone's last Points to fix
Plant check (2026-11-10, local HA, F461 2.0.11 and F455 1.0.76 on the same SCS bus with a 3550; base |
…/14 frames when Dimension 13 is absent (#466)
29bef17 to
9bd6ff8
Compare
|
Thanks for the detailed review @gdluck — all 6 points have been addressed in
|
What is the problem
In #466 (
https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-6364592170),@andrea-magreported that when setting the target temperature of aWHO=4heating zone from Home Assistant on an H/L4684 (FW 6.0.11) plant with a 3550 central unit and physical probes with a local ±3 °C offset knob (Dimension 13):24.0 °Cin Home Assistant caused the entity's displayed target temperature to drop back to21.0 °C(-3 °Clower than requested) on Zone88, Zone44, and Zone55(and-1 °Con Zone33,-2 °Con Zone22).10.5 °Con Zone88caused the displayed target temperature to jump to7.5 °C.Root Cause (from the 4 live bus traces captured in #888)
Dimension 13(*#4*Z*13*OO##) frames on H/L4684:H/L4684rejects zone status polls (*#4*Z##-> NACK*#*0##), sopoll_zones_on_startupisFalse.H/L4684, the3550central unit's 15-minute periodic zone sweep only surfacesDimension 0(*#4*Z*0*TTTT##),Dimension 14(*#4*Z*14*TTTT*3##), andDimension 20(*#4*Z#1*20*S##) on the event session (whereas onF461/F455, the3550's unsolicited quarter-hour burst delivers0,12,*4*1*Z##,14, and13).tests/fixtures/traces/issue_466/myhome_trace_HL4684_all_2026-11-*.json(453 frames total),Dimension 13(*#4*Z*13*OO##) is never emitted on the event session (we also asked@andrea-magin #466 (comment) to test whether a direct point-to-point*#4*Z*13##query is answered by hisH/L4684+3550). Consequently,self._local_offsetremained0andknob_posremained"UNKNOWN".Dimension 12(effective setpoint in force) andDimension 14(nominal setpoint):*#4*#88*#14*0105*3##(09-11-53.jsonframes97–102),async_set_temperaturecomputestarget_temperature = requested - self._local_offset(10.5 - 0 = 10.5).-3 °C) and broadcasts the burst:[98] *#4*88*0*0210##(Dimension 0: measured temperature21.0 °C)[99] *#4*88*12*0075*3##(Dimension 12: effective setpoint in force =10.5 + (-3.0) = 7.5 °C)[100] *4*1*88##(heating season/mode)[101] *#4*88*14*0105*3##(Dimension 14: nominal setpoint =10.5 °C; omitted on frames104–108,113–117, and132–136)[102] *4*110*#88##(manual positioning active)09-13-19.json:12 = 0235(23.5 °C),14 = 0265(26.5 °C) ->12 - 14 = -3 °Cframes 96–100):12 = 0260(26.0 °C),14 = 0270(27.0 °C) ->12 - 14 = -1 °Cframes 108–112):12 = 0240(24.0 °C),14 = 0260(26.0 °C) ->12 - 14 = -2 °Cframes 124–128):12 = 0210(21.0 °C),14 = 0240(24.0 °C) ->12 - 14 = -3 °Cframes 131–135):12 = 0200(20.0 °C),14 = 0230(23.0 °C) ->12 - 14 = -3 °Cself._local_offsetstayed0:Dimension 14was omitted in the write reply (09-11-53.jsonframes104–108,113–117,132–136;09-13-19.jsonframes136–141),self._target_temperaturewas overwritten withself._local_target_temperature - 0(7.5 °Cinstead of10.5 °C), losing the nominal setpoint.climate.set_temperaturecalls did not compensate for the-3 °Cknob offset (requested - self._local_offsetsent24.0 °Cinstead of27.0 °C), so the zone regulated to21.0 °C(24.0 - 3.0) instead of the user's requested24.0 °C.What is the solution
_local_offsetand_knob_posfrom pairedDimension 12andDimension 14frames whenDimension 13is absent (_derive_local_offset):self._local_offset_reportedisFalse(no explicit*#4*Z*13*OO##frame has been received on the bus) and the zone is known to be active (self._attr_hvac_mode not in (None, HVACMode.OFF)), if bothDimension 12(_local_target_temperature) andDimension 14(_target_temperature) arrive within the same burst window (_PROTECTION_FRAME_WINDOW = 2.0s), orDimension 12arrives within2.0sof a Home Assistant setpoint write (_written_target_temperature, covering H/L4684 bursts where the trailingDimension 14echo is omitted), computediff = round(local_target - nominal_target, 1).diffis an exact integer in the WHO=4 local knob range (-3 <= diff <= +3), updateself._local_offset = int(diff)andself._knob_pos("-3" .. "+3", or"0"when resetting a previously derived nonzero offset back to center). Whendiff == 0andself._local_offset == 0on a zone that never reportedDimension 13, leave_knob_posas"UNKNOWN"so standalone zones with12 == 14are not mislabeled as having a knob at0.F461standalone Zone 2 where12 = 23.5 °Cand14 = 21.0 °C,diff = +2.5 °C) and out-of-range differences (such as protection7.0 °Cvs comfort21.0 °C,diff = -14.0 °C) are ignored.Dimension 13(self._local_offset_reported = True), explicitDimension 13remains authoritative and disables derived-offset inference._target_temperatureacross both startup orderings andDimension 12-only echoes (_reconcile_local_target,_hydrate_nominal_from_local):_reconcile_local_target(), reconcile_local_offsetfrom_written_target_temperature(if within_PROTECTION_FRAME_WINDOW) or a precedingDimension 14in the same burst (clearing_dim12_atand_dim14_atin both branches so a trailingDimension 14in a write burst does not re-run inference) before computingself._target_temperature = self._local_target_temperature - self._local_offset._hydrate_nominal_from_local(), whenDimension 14already arrived beforeDimension 12in the same startup burst (0 -> 14 -> 12 -> *4*1*Z##), keepself._target_temperaturefromDimension 14intact (21.0 °ConF461) and only run_derive_local_offset._dim12_atand_dim14_atwhen a newDimension 0(MESSAGE_TYPE_MAIN_TEMPERATURE) burst starts, whenDimension 13is received, when restoring nominal before protection (_restore_nominal_before_protection), or when_show_nominal()runs on leavingOFF.State Diagram
flowchart TD Write["async_set_temperature(T_req)<br/>writes T_nom = T_req - _local_offset<br/>records _written_target_temperature = (T_nom, now)"] --> BusBurst["Bus emits zone burst:<br/>*#4*Z*0*...## (resets burst window)<br/>*#4*Z*12*T_local*3##<br/>*4*1*Z##<br/>(*#4*Z*14*T_nom*3## optional)"] BusBurst --> Check13{"_local_offset_reported<br/>(Dimension 13 seen)<br/>or mode in (None, OFF)?"} Check13 -- "Yes" --> Keep13["Keep explicit Dimension 13 offset<br/>target_temperature = T_local"] Check13 -- "No" --> PairCheck{"Paired with Dimension 14<br/>or _written_target_temperature<br/>within 2.0s?"} PairCheck -- "Yes" --> DiffCheck{"diff = T_local - T_nom<br/>integer in [-3, +3]?"} DiffCheck -- "Yes (and diff != 0 or _local_offset != 0)" --> Derive["Update _local_offset = int(diff)<br/>_knob_pos = '-3'..'+3' or '0'<br/>_target_temperature = T_local - _local_offset"] DiffCheck -- "No (e.g. +2.5 or -14.0)" --> KeepOffset["Keep existing _local_offset / _target_temperature"] PairCheck -- "No" --> KeepOffsetHardware Verification
Verified against the 4 live hardware bus traces captured by
@andrea-magon BTicino H/L4684 (FW 6.0.11) + 3550 Central Unit in #466 / #888:tests/fixtures/traces/issue_466/myhome_trace_HL4684_all_2026-11-07T09-11-53.json(175 frames)tests/fixtures/traces/issue_466/myhome_trace_HL4684_all_2026-11-07T09-13-19.json(167 frames)How is it tested
tests/test_climate_setpoint_in_force.pyand updatedtest_dimension_14_before_dimension_12_shows_the_setpoint_in_forceto assertzone._target_temperature == 21.0:test_hl4684_zone_88_setpoint_sequence_derives_offset_and_compensates_writes: replays the exact Zone88frame sequence from09-11-53.json(the quarter-hour sweep on frames36–37, 45, first write10.5 °Con frames97–102, second write24.0 °C->0270omittingDimension 14on frames104–108, third write23.0 °C->0260omittingDimension 14on frames113–117, fifth write23.5 °C->0265on frames126–131, and sixth write27.0 °C->0300omittingDimension 14on frames132–136).test_hl4684_write_omitting_dimension_14_derives_offset_from_written_nominal: replays theDimension 12-only write echo on Zone55from09-13-19.json(frames 136–141) on a fresh zone whose offset is still0, verifying_local_offset == -3and_target_temperature == 24.0are derived from_written_target_temperature.test_hl4684_captured_zone_bursts_derive_knob_offsets: parametrized over the verbatim09-13-19.jsonbursts for Zone33(frames 96–100, offset-1), Zone22(frames 108–112, offset-2), Zone44(frames 124–128, offset-3), and Zone55(frames 131–135, offset-3).test_derived_offset_handles_both_frame_orders_and_signs: verifies14 -> 12,14 -> *4*1*Z## -> 12,12 -> 14, and that12 == 14on a fresh zone withoutDimension 13leavesknob_pos == "UNKNOWN".test_derived_nonzero_offset_resets_to_zero_when_knob_returned_to_center: verifies that once a nonzero offset was derived, a subsequent12 == 14burst resetslocal_offset == 0andknob_pos == "0".test_explicit_dimension_13_prevents_derived_offset_override: verifies that once*#4*Z*13*OO##is seen, derived offset inference never overrides it.test_out_of_range_or_stale_dimension_pairs_do_not_derive_offset: verifies that differences outside-3..+3 °Cor separated beyond_PROTECTION_FRAME_WINDOW(2.0s) do not derive an offset.Python 3.14.7, Home Assistant2026.10.1):pytest tests/test_climate_setpoint_in_force.py tests/test_component_climate.py tests/test_startup_requests_per_firmware.py: 138 passed (100% coverage of all changed lines incustom_components/myhome/climate.py)ruff check custom_components tests scripts: All checks passed (0 violations)python scripts/verify_ha_standards.py: SUCCESSpython scripts/sync_documentation.py --check: SUCCESSWhere are the traces
Archived in #888 under
tests/fixtures/traces/issue_466/:myhome_trace_HL4684_all_2026-11-07T09-11-53.json(175 frames — Zone 88 quarter-hour sweep at frames36–37, 45and setpoint writes at frames97–136, Zone 33 at frames140–144)myhome_trace_HL4684_all_2026-11-07T09-13-19.json(167 frames — Zone 88 at frames62–95, Zone 33 at frames96–107, Zone 22 at frames108–123, Zone 44 at frames124–130, Zone 55 at frames131–141)What does the Firmware Oracle / Encyclopedia say
Queried via
openwebnet-mcp:who-0004-dim-12,who-0004-dim-13,who-0004-dim-14):Dimension 12(*#4*where*12*T*M##): Temp. Regolazione Locale / Effective Setpoint (T_nominal + ΔT_local_offset).Dimension 13(*#4*where*13*OL##): Local Offset (Knob) (00=0,01..03=+1..+3 °C,11..13=-1..-3 °C,04=OFF,05=PROTECTION).Dimension 14(*#4*where*14*T*M##/*#4*where*#14*T*M##): Set-point Temperature (nominal setpoint without local knob offset).lookup_firmware_verdict):*#4*#23#1*#14*0250*3##(thermo-ts10, closest replayedDimension 14write inown-firmware-oracle): emits a physical SCS bus telegram ($06D1...) onOUT (Bus)across all emulated gateways (ACK (*#*1##)on 9 products:F450,F453,F453AV,F454,F459,H4684,MH200,MH200N,MH202;NACK (*#*0##)onF460,F461,MyHomeServer1, and3488030205).*#4*1*13##(trace-corpus, directDimension 13status request): puts$1090012D0D6ConOUT (Bus)(ACK (*#*1##)onF450,F453,F453AV,F454,F459,H4684,MH200,MH200N,MH202; answered with*#4*1*13*00##+ACKbyF461when zone 1 is cached).Checklist
openwebnet-mcp(Grammar, Encyclopedia KB, Firmware Oracle)tests/test_climate_setpoint_in_force.pyruff,verify_ha_standards.py, andsync_documentation.py --checkpass cleanlyFixes #466