Skip to content

fix(climate): derive WHO=4 local knob offset from paired Dimension 12/14 frames when Dimension 13 is absent (#466) - #890

Open
GreenGrassBlueOcean wants to merge 1 commit into
v2-phase1-architecturefrom
fix/who4-setpoint-local-offset
Open

GreenGrassBlueOcean wants to merge 1 commit into
v2-phase1-architecturefrom
fix/who4-setpoint-local-offset

Conversation

@GreenGrassBlueOcean

@GreenGrassBlueOcean GreenGrassBlueOcean commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

What is the problem

In #466 (https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-6364592170), @andrea-mag reported that when setting the target temperature of a WHO=4 heating 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):

  • Setting 24.0 °C in Home Assistant caused the entity's displayed target temperature to drop back to 21.0 °C (-3 °C lower than requested) on Zone 88, Zone 44, and Zone 55 (and -1 °C on Zone 33, -2 °C on Zone 22).
  • Setting 10.5 °C on Zone 88 caused the displayed target temperature to jump to 7.5 °C.

Root Cause (from the 4 live bus traces captured in #888)

  1. No Dimension 13 (*#4*Z*13*OO##) frames on H/L4684:
    • H/L4684 rejects zone status polls (*#4*Z## -> NACK *#*0##), so poll_zones_on_startup is False.
    • On H/L4684, the 3550 central unit's 15-minute periodic zone sweep only surfaces Dimension 0 (*#4*Z*0*TTTT##), Dimension 14 (*#4*Z*14*TTTT*3##), and Dimension 20 (*#4*Z#1*20*S##) on the event session (whereas on F461 / F455, the 3550's unsolicited quarter-hour burst delivers 0, 12, *4*1*Z##, 14, and 13).
    • Across all 4 traces in 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-mag in #466 (comment) to test whether a direct point-to-point *#4*Z*13## query is answered by his H/L4684 + 3550). Consequently, self._local_offset remained 0 and knob_pos remained "UNKNOWN".
  2. Every setpoint write echoes both Dimension 12 (effective setpoint in force) and Dimension 14 (nominal setpoint):
    • When Home Assistant writes *#4*#88*#14*0105*3## (09-11-53.json frames 97–102), async_set_temperature computes target_temperature = requested - self._local_offset (10.5 - 0 = 10.5).
    • The probe/central unit applies the physical knob offset (-3 °C) and broadcasts the burst:
      • [98] *#4*88*0*0210## (Dimension 0: measured temperature 21.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 frames 104–108, 113–117, and 132–136)
      • [102] *4*110*#88## (manual positioning active)
    • Across the user's zones in 09-13-19.json:
      • Zone 88: 12 = 0235 (23.5 °C), 14 = 0265 (26.5 °C) -> 12 - 14 = -3 °C
      • Zone 33 (frames 96–100): 12 = 0260 (26.0 °C), 14 = 0270 (27.0 °C) -> 12 - 14 = -1 °C
      • Zone 22 (frames 108–112): 12 = 0240 (24.0 °C), 14 = 0260 (26.0 °C) -> 12 - 14 = -2 °C
      • Zone 44 (frames 124–128): 12 = 0210 (21.0 °C), 14 = 0240 (24.0 °C) -> 12 - 14 = -3 °C
      • Zone 55 (frames 131–135): 12 = 0200 (20.0 °C), 14 = 0230 (23.0 °C) -> 12 - 14 = -3 °C
    • Because self._local_offset stayed 0:
      1. When Dimension 14 was omitted in the write reply (09-11-53.json frames 104–108, 113–117, 132–136; 09-13-19.json frames 136–141), self._target_temperature was overwritten with self._local_target_temperature - 0 (7.5 °C instead of 10.5 °C), losing the nominal setpoint.
      2. Subsequent climate.set_temperature calls did not compensate for the -3 °C knob offset (requested - self._local_offset sent 24.0 °C instead of 27.0 °C), so the zone regulated to 21.0 °C (24.0 - 3.0) instead of the user's requested 24.0 °C.

What is the solution

  1. Derive _local_offset and _knob_pos from paired Dimension 12 and Dimension 14 frames when Dimension 13 is absent (_derive_local_offset):
    • Whenever self._local_offset_reported is False (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 both Dimension 12 (_local_target_temperature) and Dimension 14 (_target_temperature) arrive within the same burst window (_PROTECTION_FRAME_WINDOW = 2.0s), or Dimension 12 arrives within 2.0s of a Home Assistant setpoint write (_written_target_temperature, covering H/L4684 bursts where the trailing Dimension 14 echo is omitted), compute diff = round(local_target - nominal_target, 1).
    • If diff is an exact integer in the WHO=4 local knob range (-3 <= diff <= +3), update self._local_offset = int(diff) and self._knob_pos ("-3" .. "+3", or "0" when resetting a previously derived nonzero offset back to center). When diff == 0 and self._local_offset == 0 on a zone that never reported Dimension 13, leave _knob_pos as "UNKNOWN" so standalone zones with 12 == 14 are not mislabeled as having a knob at 0.
    • Non-integer differences (such as F461 standalone Zone 2 where 12 = 23.5 °C and 14 = 21.0 °C, diff = +2.5 °C) and out-of-range differences (such as protection 7.0 °C vs comfort 21.0 °C, diff = -14.0 °C) are ignored.
    • If a gateway ever reports Dimension 13 (self._local_offset_reported = True), explicit Dimension 13 remains authoritative and disables derived-offset inference.
  2. Preserve nominal _target_temperature across both startup orderings and Dimension 12-only echoes (_reconcile_local_target, _hydrate_nominal_from_local):
    • In _reconcile_local_target(), reconcile _local_offset from _written_target_temperature (if within _PROTECTION_FRAME_WINDOW) or a preceding Dimension 14 in the same burst (clearing _dim12_at and _dim14_at in both branches so a trailing Dimension 14 in a write burst does not re-run inference) before computing self._target_temperature = self._local_target_temperature - self._local_offset.
    • In _hydrate_nominal_from_local(), when Dimension 14 already arrived before Dimension 12 in the same startup burst (0 -> 14 -> 12 -> *4*1*Z##), keep self._target_temperature from Dimension 14 intact (21.0 °C on F461) and only run _derive_local_offset.
  3. Reset burst timestamps on new burst boundaries or OFF transitions:
    • Reset _dim12_at and _dim14_at when a new Dimension 0 (MESSAGE_TYPE_MAIN_TEMPERATURE) burst starts, when Dimension 13 is received, when restoring nominal before protection (_restore_nominal_before_protection), or when _show_nominal() runs on leaving OFF.

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" --> KeepOffset
Loading

Hardware Verification

Verified against the 4 live hardware bus traces captured by @andrea-mag on 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

  • Added 5 new test functions (11 test cases) in tests/test_climate_setpoint_in_force.py and updated test_dimension_14_before_dimension_12_shows_the_setpoint_in_force to assert zone._target_temperature == 21.0:
    • test_hl4684_zone_88_setpoint_sequence_derives_offset_and_compensates_writes: replays the exact Zone 88 frame sequence from 09-11-53.json (the quarter-hour sweep on frames 36–37, 45, first write 10.5 °C on frames 97–102, second write 24.0 °C -> 0270 omitting Dimension 14 on frames 104–108, third write 23.0 °C -> 0260 omitting Dimension 14 on frames 113–117, fifth write 23.5 °C -> 0265 on frames 126–131, and sixth write 27.0 °C -> 0300 omitting Dimension 14 on frames 132–136).
    • test_hl4684_write_omitting_dimension_14_derives_offset_from_written_nominal: replays the Dimension 12-only write echo on Zone 55 from 09-13-19.json (frames 136–141) on a fresh zone whose offset is still 0, verifying _local_offset == -3 and _target_temperature == 24.0 are derived from _written_target_temperature.
    • test_hl4684_captured_zone_bursts_derive_knob_offsets: parametrized over the verbatim 09-13-19.json bursts for Zone 33 (frames 96–100, offset -1), Zone 22 (frames 108–112, offset -2), Zone 44 (frames 124–128, offset -3), and Zone 55 (frames 131–135, offset -3).
    • test_derived_offset_handles_both_frame_orders_and_signs: verifies 14 -> 12, 14 -> *4*1*Z## -> 12, 12 -> 14, and that 12 == 14 on a fresh zone without Dimension 13 leaves knob_pos == "UNKNOWN".
    • test_derived_nonzero_offset_resets_to_zero_when_knob_returned_to_center: verifies that once a nonzero offset was derived, a subsequent 12 == 14 burst resets local_offset == 0 and knob_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 °C or separated beyond _PROTECTION_FRAME_WINDOW (2.0s) do not derive an offset.
  • Validation suite in WSL (Python 3.14.7, Home Assistant 2026.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 in custom_components/myhome/climate.py)
    • ruff check custom_components tests scripts: All checks passed (0 violations)
    • python scripts/verify_ha_standards.py: SUCCESS
    • python scripts/sync_documentation.py --check: SUCCESS

Where are the traces

Archived in #888 under tests/fixtures/traces/issue_466/:

What does the Firmware Oracle / Encyclopedia say

Queried via openwebnet-mcp:

  • Encyclopedia KB (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).
  • Firmware Oracle (lookup_firmware_verdict):
    • *#4*#23#1*#14*0250*3## (thermo-ts10, closest replayed Dimension 14 write in own-firmware-oracle): emits a physical SCS bus telegram ($06D1...) on OUT (Bus) across all emulated gateways (ACK (*#*1##) on 9 products: F450, F453, F453AV, F454, F459, H4684, MH200, MH200N, MH202; NACK (*#*0##) on F460, F461, MyHomeServer1, and 3488 030205).
    • *#4*1*13## (trace-corpus, direct Dimension 13 status request): puts $1090012D0D6C on OUT (Bus) (ACK (*#*1##) on F450, F453, F453AV, F454, F459, H4684, MH200, MH200N, MH202; answered with *#4*1*13*00## + ACK by F461 when zone 1 is cached).

Checklist

  • Code follows Home Assistant Platinum Quality Scale standards
  • OpenWebNet frames verified against openwebnet-mcp (Grammar, Encyclopedia KB, Firmware Oracle)
  • Unit tests added/updated in tests/test_climate_setpoint_in_force.py
  • ruff, verify_ha_standards.py, and sync_documentation.py --check pass cleanly

Fixes #466

@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 10, 2026 •

Copy link
Copy Markdown

Checked 29bef17e against the #888 captures, the oracle index, the Encyclopedia WHO 4 pages and our F461 2.0.11 + 3550 plant logs, which do carry Dimension 13.

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 *#4*Z*13*OO##, the 7 others had no Dimension 13 yet; 15 write -> Dimension 12 pairs, 15 agree, including a run where the knob was turned through 02 01 00 01 02 03 02 01 00 11 00 11. Sign matches dimensions.md §DIMENSION 12 (12 = 14 + offset).

Points to fix

  1. Oracle citation. *#4*#1*#14*0200*3## has never been replayed. The closest Dimension 14 write in the index, *#4*#23#1*#14*0250*3## (thermo-ts10), is ACK on 9 products and NACK on F460, F461 and MyHomeServer1 (bus telegram on all 12).
  2. Read instead of infer? The H4684 forwards *#4*1*13## to the bus (own-firmware-oracle results/H4684/020054/oracle/full/trace-corpus.tsv), the 08-54-06 capture of the same plant shows per-zone dimension reads answered in 80-100 ms (*#4*33*11## -> *#4*33*11*2## for all six zones), and an F461 + 3550 plant answers *#4*2*13## with *#4*2*13*00##. A *#4*Z*13## read per zone (the fix(climate): ask silent F455 zones their state once after startup (#466) #873 _silent_zone_reads pattern) would give the knob code directly, cover Local OFF (4) / Local protection (5), and compensate the first write after a restart, which the derivation cannot (zone 88's 10.5 -> 7.5 in the capture). No capture asks Dimension 13 on an H/L4684 yet; one read would settle it.
  3. Nominal on F461 startup. _hydrate_nominal_from_local now reconciles whenever _local_target_reported, so in the order 0 -> 14 (21.0) -> 12 (23.5) -> *4*1*2## the nominal becomes 23.5 where the base kept 21.0 ("the commanded setpoint is kept", test_live_f461_poll_reply_shows_the_setpoint_in_force). test_dimension_14_before_dimension_12_shows_the_setpoint_in_force asserts only the shown value; adding assert zone._target_temperature == 21.0 there shows the change.
  4. Minor. The written path of _reconcile_local_target does not clear _dim12_at / _dim14_at, so the Dimension 14 echo derives a second time; _dim14_at is recorded for a Dimension 14 received while the mode is still unknown (protection values are out of range, so harmless today); a derived 12 == 14 sets knob_pos to "0" without a report.
  5. Tests vs fixtures. The zone 88 docstring cites frames 82-84 / 102-107 of 09-11-53 (those are WHO 3 frames; the first write is 97-102) and feeds a sweep *#4*88*0*0244## / *#4*88*14*0105*3## that is in no capture (the real one is 0210 / 0170, frames 36-37); the parametrised bursts change the Dimension 0 values (33: 0225 vs 0218, 22: 0221 vs 0223, 44: 0221 vs 0238, 55: 0217 vs 0221). The 12/14 values are verbatim, so the tests are right about the rule, just not replays.
  6. Wording. "The 3550 sweep only broadcasts Dimension 0, 14, 20" is what the H/L4684 shows; on an F461 + 3550 the unsolicited quarter-hour burst is 0, 12, *4*1*Z##, 14, 13.

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 f19adc57 vs 29bef17e). Zone 2 standalone, zones 3 / 4 behind the 3550, +0.5 °C and back per zone, then *#4*Z##: frames and attributes identical between base and PR on both gateways, no errors. Both gateways deliver Dimension 13 (the *#4*Z## reply is 0, 12, *4*0*Z##, 14, 13, and the 3550's quarter-hour burst carries *#4*Z*#14*T*2##, 12, season, 0, 14, 13 per zone), so the derivation never decides here; where it fires before the first Dimension 13 it derives 0 = the truth. The F461 echoes a standalone-zone write as 12, season, 0 only (the "omitted Dimension 14" shape), the F455 as 12, season, 0, 0, 12, season, 14, 13.

@GreenGrassBlueOcean
GreenGrassBlueOcean force-pushed the fix/who4-setpoint-local-offset branch from 29bef17 to 9bd6ff8 Compare November 12, 2026 10:38
@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review @gdluck — all 6 points have been addressed in 9bd6ff8a:

  1. Oracle citation: Updated the PR description to cite the actual replayed Dimension 14 write entry in own-firmware-oracle/results/mcp_index.json (*#4*#23#1*#14*0250*3##, thermo-ts10: OUT (Bus) across all emulated gateways, ACK on 9 products and NACK on F460, F461, MyHomeServer1, and 3488 030205) along with *#4*1*13## (trace-corpus).
  2. Direct *#4*Z*13## query on H/L4684 + 3550: Asked @andrea-mag in #466 (comment) to run *#4*88*13##, *#4*33*13##, *#4*22*13##, *#4*11*13## via myhome.send_raw_frames so we can verify whether the 3550 answers direct Dimension 13 reads through H/L4684.
  3. Preserve nominal _target_temperature on F461 startup (0 -> 14 -> 12 -> *4*1*2##): Updated _hydrate_nominal_from_local so that when Dimension 14 already arrived in the same startup burst (self._written_target_temperature is None and self._target_temperature is not None and self._dim14_at is not None and now - self._dim14_at <= _PROTECTION_FRAME_WINDOW), it keeps self._target_temperature (21.0 °C) and only runs _derive_local_offset. Added assert zone._target_temperature == 21.0 to test_dimension_14_before_dimension_12_shows_the_setpoint_in_force.
  4. State-tracking fixes:
    • _reconcile_local_target now clears self._dim12_at = None and self._dim14_at = None in the written branch as well, so a trailing Dimension 14 echo does not call _derive_local_offset a second time.
    • _derive_local_offset now guards self._attr_hvac_mode in (None, HVACMode.OFF).
    • _derive_local_offset now returns False when offset == 0 and self._local_offset == 0, keeping knob_pos = "UNKNOWN" on a fresh zone without Dimension 13 when 12 == 14, while still resetting local_offset = 0 and knob_pos = "0" if a previously derived nonzero offset returns to 0 (test_derived_nonzero_offset_resets_to_zero_when_knob_returned_to_center).
  5. Exact #888 trace fixtures & frame indices: Updated test_hl4684_zone_88_setpoint_sequence_derives_offset_and_compensates_writes, test_hl4684_write_omitting_dimension_14_derives_offset_from_written_nominal, and test_hl4684_captured_zone_bursts_derive_knob_offsets to use the verbatim Dimension 0 frames (0210, 0218, 0223, 0238, 0221) and exact frame numbers from 09-11-53.json (36–37, 45, 97–102, 104–108, 113–117, 126–131, 132–136) and 09-13-19.json (96–100, 108–112, 124–128, 131–135, 136–141).
  6. H/L4684 sweep wording: Updated the test docstrings and PR description to clarify that it is specifically on H/L4684 that the 3550's quarter-hour sweep only surfaces Dimension 0, 14, and 20 on the event session (whereas F461 / F455 deliver 0, 12, season, 14, and 13).

This branch has not been deployed

No deployments
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