Skip to content

fix(heating): WHO 4 decoder no longer raises on empty tags or a wildcard WHERE, reads ASCII digits only; README / conformance-matrix / docstring corrections (Encyclopedia audit, group 1) - #156

Merged
GreenGrassBlueOcean merged 14 commits into
OpenWebNet-HA:masterfrom
gdluck:fix/who4-addressing-modes-dim19
Nov 8, 2026

Conversation

@gdluck

@gdluck gdluck commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Requested by A

Summary

WHO 4 checked against the Encyclopedia functional pages (who-4-temperature-control/{addressing,dimensions,what}.md). What ships is small: the WHO 4 event decoder no longer raises out of OWNMessage.parse / OWNEvent.parse on frames the grammar accepts but it could not read, its integer fields are read only when they are ASCII digits, and three documentation errors are corrected. No decoded value, log line or accepted builder domain differs from master for any well-formed ASCII frame; the address-class, operating-mode, DIMENSION 15 / 19 / 22 and builder-domain findings of the audit stay documentation only (no committed plant trace carries them) and are described in the OWNHeatingEvent class docstring.

Changes

  • Fixed, WHO 4 events (OWNd/message/heating.py): OWNMessage.parse / OWNEvent.parse return an untyped event instead of raising for an empty DIMENSION 11 / 13 / 60 value (*#4*1*11*##, *#4*1*60*##: ValueError; *#4*1*11##, *#4*1*13##, *#4*1*60##: IndexError), an empty DIMENSION 15 sensor slot (*#4*1*15**##, *#4*1*15**0215##, *#4*1*15#1**0215##: ValueError from int('')) and the wildcard WHERE (*4*1**##, *4*303**##: ValueError from int('*'); the event is zone 0, as a frame without a WHERE already was). The integer fields of DIMENSION 11, 15, 19, 20, 30, 31 and 32 are read only when they are ASCII digits (_WHO4_INT_REGEX, like the DIMENSION 60 humidity value): *#4*1*11*3*## decoded fan_speed 3 before, it is untyped now.
  • Docs, README (WHO 4 quick-start): mode="heating" / "cooling" replaced by CLIMATE_MODE_HEAT / CLIMATE_MODE_COOL, which set_central_mode accepts; the feature list names the builders that exist (set_central_program, set_central_scenario, set_central_weekend, set_central_holiday, set_holiday_end_date, set_holiday_end_time) instead of three that do not.
  • Docs, docs/protocol_conformance_matrix.md §3.4, tests/golden/README.md and one golden mcp_notes: DIMENSION 12 is the setpoint after the local offset, stored in local_set_temperature, not a measured temperature (who-4-temperature-control/dimensions.md "DIMENSION 12 - complete probe status"); DIMENSION 20 value 9 is "fan-coil is on". Frames unchanged.
  • Docs, OWNHeatingEvent docstring: the published WHERE forms (0, 1..99 master probe, 001..099 all probes of a zone, PZZ, #0, #1..#99), Z#N from "Actuator addressing", #0#C / #Z#C / N00 from the MyHOME_Suite and historical sections; zone / unique_id unchanged for every form; dimension writes go to OWNHeatingCommand and stay untyped on the event path, as on master.

Tests: tests/test_who4_audit_fixes.py (empty-tag forms through both parse entry points, wildcard WHERE, fullwidth digits for every integer dimension, the ASCII frames MyHOME's traces rely on, the set_temperature clamp MyHOME pins). Full suite, ruff check, mypy OWNd tests/typing, scripts/verify_coverage.py (100 % statements and branches on heating.py), scripts/sync_documentation.py --check and scripts/verify_library_standards.py pass. Branch merged with master after #155.

🤖 Generated with Claude Code

gdluck and others added 4 commits November 3, 2026 09:51
…imension 19/22

Report 21 H1, H2, H3, H4, H5, H6, H7, H8, H9, H13, H14, H15, H16, H17,
H18, H19 (docstring note for H10).

OWNHeatingEvent keeps the WHERE in its address class instead of reducing it
to one integer zone (Encyclopedia who-4-temperature-control/addressing.md,
"Published functional WHERE forms", "MyHOME_Suite address rules", "Actuator
addressing", "Historical central-unit variants"): new scope, zone_number,
central_unit, probe_number, actuator, interface and address properties;
unique_id carries the class (4-001 is not 4-1, 4-0 is not 4-#0, 4-#0#2 is
the 4-zone central unit 2, 4-2#1 one actuator). #0#C is the central unit C,
not zone C (ENCY_CONFIRM TC-H1: OPEN.db rule 1004 four-zone control unit
#0#[ZA][ZB]; ORACLE_CONFIRM H1: *4*303*#0#1## is a central-unit telegram with
selector byte 01, distinct from michnovka#1 and #0 on ten of twelve gateways). 3#Z#N
under dimension 22 is zone Z, actuator N, and the MOD*SP*VEL*SWING reply is
decoded (dimensions.md "DIMENSION 22 - split control"; parse only, the send
guard is unchanged). A trailing michnovka#4#NN is a bus interface, not actuator 4.

WHAT decoding follows what.md: 102/202/302 are the protection states
(mode "protection", operating_mode antifreeze / thermal_protection /
protection; ENCY_CONFIRM TC-H5), 103/203/303 off; x10 manual, x11
automatic, x12 timed manual, x15 daily holiday and x3DDD vacation (mode
"holiday"), x1PP program, x2SS scenario, 3000 / 3000#P cancel vacation,
3100 / 3200 restore, WHAT 40 release local probe adjustment, each with its
own operating_mode value and log line.

Dimension 19 values 14/15/16 are fan-coil OFF states (dimensions.md
"DIMENSION 19", functional-page-only evidence, ENCY_CONFIRM TC-H8), not fan
speeds 9..11; dimension 20 value 9 logs "fan-coil is on"; dimension 15
reads the three-digit value of the documented test report
*michnovka#4*11*15*1*123*1111## (unsigned tenths, an inference); empty dimension 11
/ 60 values no longer raise out of OWNMessage.parse; dimension writes on the
event path (OpenWebNet-HA#14, #0, OpenWebNet-HA#11, OpenWebNet-HA#12, OpenWebNet-HA#13) are not typed as state replies, as
dimension 7/30/31/32 writes already were.

Builders: set_mode / set_temperature / set_timed_manual refuse three-digit
probe-class addresses (001, 105, OpenWebNet-HA#105; ORACLE_CONFIRM H19) instead of
rewriting them to michnovka#1 / OpenWebNet-HA#105, the fan builders raise "Invalid zone number"
for them; turn_off("0", standalone=True) keeps the literal 0 (ORACLE_CONFIRM
H3: *4*303*0## is nack on 12/12, #0 is the central unit); set_temperature
raises for a temperature outside 5.0..40.0 (what.md "Zone setup commands",
T 0050..0400) instead of clamping; set_holiday_end_date validates the
calendar date (dimensions.md "DIMENSION 30 - holiday end"; ORACLE_CONFIRM
H18: 31 February is nack on 12/12). #0#C builders log "central unit #0#C".

Existing tests that pinned the old readings are updated; the new
tests/test_who4_audit_fixes.py pins every row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Report 21 H11, H12.

README: set_central_mode takes CLIMATE_MODE_HEAT / CLIMATE_MODE_COOL /
CLIMATE_MODE_OFF ("heat" / "cool" / "off"), not "heating" / "cooling"
(the quick-start raised ValueError), and the feature list named three
builders that do not exist (set_central_antifreeze,
set_central_thermal_protection, set_central_off); it now lists the
central-unit builders that do.

docs/protocol_conformance_matrix.md, tests/golden/README.md and the
mcp_notes of golden fixture thermo.resp.probe.status.neg_temp (frame and
values unchanged; corpus.json regenerated with tools/golden/validate_corpus.py):
dimension 12 is the complete probe status, the setpoint after the local
offset in 0020..0430 (Encyclopedia who-4-temperature-control/dimensions.md,
"DIMENSION 12 - complete probe status"; ENCY_CONFIRM TC-H12), not a
measured temperature, and OWNd stores it in local_set_temperature, never
in _measured_temperature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… (decisions D39, D40, D54, D67)

Precedent gate on report 21 H1, H5, H6, H17, H19: MyHOME's pinned suite
(v2-phase1-architecture, run as OWNd's myhome-conformance CI does) failed
44 tests on the worker's commit. The audit fixes are kept and the finer
information stays in the new properties; the attribute values MyHOME
asserts are restored:

- H1: `#0#C` keeps `zone == C` (MyHOME `_zone_route_keys`, its
  `test_issue_431`, `test_climate_central_discovery`,
  `test_climate_routing_invariants`); `scope` `central`, `central_unit`
  and `unique_id` `4-#0#C` carry the central-unit identity.
- H5: 102 / 202 / 302 keep `mode` `off` and the log line "mode is set to
  'off'" (MyHOME `test_message_edges`, the F461 and MyHomeServer1 live
  traces, `test_issue_429`); `operating_mode` says antifreeze /
  thermal_protection / protection.
- H6: x15 and x3DDD keep `mode` heat / cool / auto (MyHOME
  `test_message_edges`, `test_climate_holiday_program`); `operating_mode`
  says holiday / vacation. `CLIMATE_MODE_PROTECTION` /
  `CLIMATE_MODE_HOLIDAY` are dropped (no longer produced).
- H17: the Dimension 14 write echo on the event path keeps its
  `target_temperature` type (MyHOME `test_legacy_platforms` replays
  `*michnovka#4*01*OpenWebNet-HA#14*0220*1##` through `OWNEvent.parse` and expects the target).
- H19: `set_temperature` clamps to 5.0..40.0 again (MyHOME
  `test_heating.py::test_set_temperature_clamp_low/_high`); the
  probe-class address refusal stays.

Evidence: Encyclopedia who-4-temperature-control/addressing.md and
what.md as in the worker's commit; MyHOME pins per DECISIONS_REGISTER D39
(dual-OWNd tests), D40 (mode table), D54 (climate.accept), D67 (#845
replay).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ntral setpoint target documented (review follow-up)

Review follow-up on report 21 H1, H2, H13, H19 (branch
fix/who4-addressing-modes-dim19):

- unique_id: the id is unchanged; the claim is corrected. Distinct per
  probe / actuator / central-unit class (`4-1` vs `4-001`, `4-0` vs
  `4-#0`, `4-#0#C`, `4-Z#N`, `4-3#Z#N`), shared per zone for the forms
  MyHOME routes on: `#Z` and `#Z#C` give the `4-Z` of the master probe
  `Z` (`*4*311*michnovka#1##` and `*4*311*1##` are both `4-1`, `michnovka#1#4#01` and
  `1#4#01` both `4-1#4#01`). CHANGELOG, the class and property docstrings
  and a new test say so (DECISIONS_REGISTER D39: MyHOME zone routing).
- Builders refuse the actuator form `0#N` (actuator N of zone 0 in the
  Dimension 20 grammar, Encyclopedia addressing.md "Actuator forms";
  SCOPE_ACTUATOR on the event side) with `ValueError("... '0#N' is
  actuator N of zone 0 ...")` instead of retargeting it to the 4-zone
  central unit `#0#N`; `#0#N` itself still builds. Test added.
- `set_temperature("#0" / "#0#C" / 0)`: master (fb0acd7) already accepted
  them and built the central-unit setpoint `*michnovka#4*#0*OpenWebNet-HA#14*TTTT*M##`; kept
  (D5: central-unit setpoints have `set_central_temperature`, same
  frame) and documented in the docstring with what.md "Zone setup
  commands" (`michnovka#1..OpenWebNet-HA#99`). Test pins the equality with
  `set_central_temperature`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@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 and others added 2 commits November 3, 2026 19:51
…nsion 14, stop reading unpublished Dimension 19 values as fan speeds

Lab audit 2026-11-03, rows L-A1, L-A2, L-A3 (FIX_LAB_2026-11-03.md).

L-A1: `*michnovka#4*Z*2*T##` / `*michnovka#4*Z*3*T##` decode as `protection_setpoint`
(MESSAGE_TYPE_PROTECTION_SETPOINT, thermal protection, cooling side) and
`antifreeze_setpoint` (MESSAGE_TYPE_ANTIFREEZE_SETPOINT), `who4_temperature`
units, logs "Zone 2's thermal protection setpoint is 35.0°C." / "Zone 2's
antifreeze setpoint is 7.0°C." (were the raw frame, no property). Neither
dimension is in the published WHO 4 document (Encyclopedia
who-4-temperature-control/dimensions.md lists no Dimension 2 or 3; Dimension
7 is the protection mode). Live F461 2.0.11, zone 2 KW4691
(reports/data/live_f418_0010_2026-11-03/lab_probe_output.txt):
`*michnovka#4*2*2##` -> `*michnovka#4*2*2*0350##`, `*michnovka#4*2*3##` -> `*michnovka#4*2*3*0070##` (BTicino
factory defaults 35.0 °C / 7.0 °C); lab_probe_writes_output.txt:
`*michnovka#4*2*michnovka#2*0340##` and `*michnovka#4*2*michnovka#3*0060##` ACKed and read back, `*michnovka#4*2*michnovka#3*0999##`
NACKed (read back 0070). Oracle suite lab-2026-11-03: `*michnovka#4*2*2##` and
`*michnovka#4*2*3##` are forwarded to the bus (dimension bytes 0C / 0F) by 10 of the 12
emulated gateways (refused on 2; replayed: F450, F453, F453AV, F454, F459, F460, F461, H4684, MH200,
MH200N, MH202, MyHomeServer1; only the MH202 ACKs, the emulated zone does
not answer). No builders: reads and writes stay raw commands (D1).

L-A2: a Dimension 14 reply outside the published `0050..0400`
(dimensions.md "DIMENSION 14", page 18) keeps `set_temperature` (D1) but logs
"... is set to 99.9°C (outside the published 5.0..40.0 °C range)."; the
write echo on the event path is flagged the same way. Live F461 2.0.11:
`*michnovka#4*2*OpenWebNet-HA#14*0999*3##` NACKed (read back 0230), `*michnovka#4*2*OpenWebNet-HA#14*0400*3##` applied,
`*michnovka#4*2*OpenWebNet-HA#14*0049*3##` ACKed and stored as 0045 (4.5 °C, below the published
bound), so such a reply can be a real value. Oracle: `*michnovka#4*2*OpenWebNet-HA#14*0999*3##` is
`nack, silent` on 12 of 12; `*michnovka#4*2*OpenWebNet-HA#14*0200*3##` goes out on 7
of the 12 (4 ACK, 3 forward and NACK on the empty bus; 5 refuse every
Dimension 14 write form).
`OWNHeatingCommand.set_temperature` keeps clamping to 5.0..40.0 instead of
raising (D20/D21 would raise, but the register pins the clamp: gate commit
33a8fff, CHANGELOG "MyHOME relies on the clamp",
test_h19_set_temperature_clamps_out_of_range_values); the docstring records
the live NACK / 4.5 evidence.

L-A3: Dimension 19 CV / HV outside `0..8` and `14..16` (dimensions.md
"DIMENSION 19") are not fan speeds: `*michnovka#4*1*19*9*0##` logs "Zone 1's cooling
valve reports the unpublished valve value 9; heating valve is off." with
is_cooling() None, cooling_fan_on / cooling_fan_speed None and is_active()
None (was "cooling fan is on at speed 4", cooling_fan_on True); the heating
side likewise; an active published side still makes the zone active.
Published values and 14 / 15 / 16 are unchanged (live `*michnovka#4*2*19##` ->
`*michnovka#4*2*19*5*0##`, fan-coil off; oracle: the read goes out on 12 of 12).

Tests: tests/test_who4_lab_2026_11_03.py (OWNMessage.parse / OWNEvent.parse
symmetry, D13). MyHOME v2-phase1-architecture suite against this branch:
5187 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck

gdluck commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Added c650aee + 7100cd2 (normal pushes): three rows from a spec-vs-code lab audit (2026-11-03), each confirmed on the live F461 2.0.11 test plant (zone 2, a KW4691) and replayed on the 12 emulated gateways (lab-2026-11-03 suite on our side, reports/data/oracle_lab_2026-11-03/).

  • Dimensions 2 and 3 decoded as protection_setpoint / antifreeze_setpoint (MESSAGE_TYPE_PROTECTION_SETPOINT, MESSAGE_TYPE_ANTIFREEZE_SETPOINT). They are not in the published WHO 4 document (the Encyclopedia has only the Dimension 7 protection mode); the live zone answered *#4*2*2## with 0350 and *#4*2*3## with 0070 (the factory defaults 35.0 / 7.0 °C), applied the writes #2*0340 and #3*0060 and NACKed #3*0999; the oracle forwards both reads to the bus on 10 of 12 gateways. Decoder only, no builders; typed like Dimension 12 (type set, value None when the field is empty).
  • Dimension 14 outside 5.0..40.0 °C keeps the value and logs it as outside the published range. The builder keeps clamping (the earlier review pinned that, MyHOME relies on it); its docstring records that the live gateway NACKed 99.9, applied 40.0 and stored 4.9 as 4.5, and that the oracle refuses 0999 before the bus on 12 of 12.
  • Dimension 19 values outside 0..8 and 14..16 are no longer read as fan speeds (*#4*1*19*9*0## used to log "fan is on at speed 4"): that side logs "unpublished valve value 9" and its is_cooling / is_heating is None; is_active() is None when the other side is idle (MyHOME casts it with bool()). Values 5, 6..8 and 14..16 unchanged.

Checks: pytest 2956 passed with 100 % statement and branch coverage on heating.py, ruff, mypy, verify_library_standards.py, sync_documentation.py --check green; MyHOME suite with this branch on PYTHONPATH: 5187 passed, 1 deselected.

…addressing"

Comment only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck
gdluck marked this pull request as ready for review November 4, 2026 13:45

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

Thanks for the detailed WHO 4 audit and for checking MyHOME against the branch.

Following our standing rule — if we don't have real, committed on-wire plant traces from physical hardware, we don't change behaviour — this PR currently bundles too many doc-driven and inferred runtime changes alongside valid crash-safety and documentation fixes.

What can stay

  1. Malformed / truncated dimension parser crash-guards: Preventing uncaught IndexError / ValueError in OWNHeatingEvent on empty or non-digit dimension tags (*#4*1*11*##, *#4*1*20*##, *#4*1*30*##, *#4*1*60*##, non-digit DIMENSION 12 / 13 / 14).
  2. Pure documentation & golden metadata fixes: Updating README.md (CLIMATE_MODE_* constants), docs/protocol_conformance_matrix.md §3.4, tests/golden/README.md, and tests/golden/frames/who04_thermo.yaml (mcp_notes) to clarify that DIMENSION 12 is complete probe status (setpoint after local offset) rather than measured temperature.

Blockers — Behaviour changes without committed plant traces (please revert)

# Symbol / File Behaviour change without real plant trace Required action
1 OWNHeatingEvent.unique_id & OWNHeatingEvent.actuator in OWNd/message/heating.py Changes unique_id across central units (#0#1: "4-1" -> "4-#0#1"), actuators (2#1: "4-2" -> "4-2#1", 0#2: "4-#0" -> "4-0#2"), general (0: "4-#0" -> "4-0"), and probes (001: "4-1" -> "4-001", 000: "4-#0" -> "4-000"), and changes actuator return type from str ("1") to int | None (1 / None), forcing edits to existing assertions in test_heating.py, test_message_protocol.py, test_issue_77_firmware_findings.py, and test_who4_actuators_status.py. Revert both unique_id and actuator to master — both are public API contracts.
2 OWNHeatingEvent DIMENSION 19 (14, 15, 16 and 9..13, 17+) in OWNd/message/heating.py Maps 14 / 15 / 16 to fan-coil OFF and drops 9..13, 17+ to None. As the PR description notes, 14 / 15 / 16 rest on Encyclopedia prose (ownkb:claim:c003275 / c003276, "information supplied by the MyHOME team") with zero committed on-wire plant traces in OWNd or MyHOME and zero firmware-oracle data. Revert DIMENSION 19 decoding changes until a real fan-coil bus capture showing 14 / 15 / 16 is committed.
3 DIMENSION 15 3-digit temperature (123 -> 12.3 °C) & DIMENSION 22 split-unit decoder in OWNd/message/heating.py Decodes 3-digit DIMENSION 15 temperatures (explicitly noted as "an inference", rewriting test_invalid_temperature_width_and_sign_return_none in tests/test_heating.py which pinned 3-digit values returning None) and adds DIMENSION 22 split_control decoding with zero physical split-unit captures. Revert both 3-digit DIMENSION 15 decoding and DIMENSION 22 split_control.
4 DIMENSION_WRITING message_type suppression, human_readable_log rewrites & speculative mode/address properties in OWNd/message/heating.py Strips message_type on DIMENSION_WRITING frames on the event path (except DIMENSION 14) and alters human_readable_log across manual/auto/holiday/vacation/program/scenario events, central units, DIMENSION 20 value 9 ("Auto" -> "fan-coil is on"), and out-of-range DIMENSION 14. Revert runtime message_type and human_readable_log changes on valid frames; keep address-class / mode distinctions as docstring documentation until needed by trace-backed MyHOME behaviour.
5 OWNHeatingCommand builder regressions (turn_off("0", standalone=True), rejected WHERE forms, and uncommitted DIMENSION 2 / 3 bench refs) in OWNd/message/heating.py turn_off("0", standalone=True) now emits *4*303*0## instead of *4*303*#0## — *4*303*0## is NACKed 12/12 in the firmware oracle, whereas *4*303*#0## is accepted! Zone builders also newly raise ValueError on 0#N, 001, 105, #105. Finally, DIMENSION 2 / 3 protection/antifreeze setpoint builders/decoders cite uncommitted local reports/data/live_f461_allprs_2026-11-04/ files. Revert the turn_off and WHERE-validation changes; hold back DIMENSION 2 / 3 builders/decoders until the raw KW4691 on-wire capture is committed as a repository fixture.

gdluck and others added 4 commits November 4, 2026 16:48
… the heating parser guards and docs

Maintainer review of OpenWebNet-HA#156: without committed on-wire plant traces from
physical hardware, runtime behaviour is not changed. Spec PDFs,
Encyclopedia prose, archived client code and empty-bus gateway ACKs are
documentation context only; oracle crash findings justify crash guards.

Blocker 1 (unique_id / actuator): OWNHeatingEvent.unique_id and
.actuator reverted to master (#0#1 -> "4-1", 2#1 -> "4-2", 0#2 -> "4-#0",
0 -> "4-#0", 001 -> "4-1", 000 -> "4-#0"; actuator is the str "1" fallback
again). The _Who4Address class, scope / zone_number / central_unit /
probe_number / interface / address properties, the SCOPE_* and
OPERATING_MODE_* constants and operating_mode were removed with it (no
runtime effect allowed). Master's assertions restored in test_heating.py,
test_message_protocol.py, test_issue_77_firmware_findings.py,
test_who4_actuators_status.py, test_firmware_contract.py and the other
touched test files.

Blocker 2 (DIMENSION 19): the 14/15/16 fan-coil OFF mapping and the
9..13 / 17+ -> None decoding reverted to master.

Blocker 3 (DIMENSION 15 / 22): the three-digit DIMENSION 15 decoding
reverted (master's test_invalid_temperature_width_and_sign_return_none
restored); the DIMENSION 22 split_control decoder, SPLIT_* constants and
MESSAGE_TYPE_SPLIT_CONTROL removed.

Blocker 4 (message_type / log lines): the DIMENSION_WRITING message_type
suppression and every human_readable_log rewrite on valid frames
(manual / auto / holiday / vacation / program / scenario, central units,
DIMENSION 20 value 9, out-of-range DIMENSION 14, WHAT 40 / 3000 / 3100 /
3200) reverted to master.

Blocker 5 (builders / Dimensions 2 and 3): turn_off("0", standalone=True)
emits *4*303*#0## again; the ValueError rejections of 0#N, 001, 105,
OpenWebNet-HA#105 and the calendar-date check of set_holiday_end_date reverted; the
DIMENSION 2 / 3 protection and antifreeze setpoint decoders, their
constants and exports, and tests/test_who4_lab_2026_11_03.py removed,
with every citation of reports/data/....

Kept: the crash guards for empty or missing DIMENSION 11 / 13 / 60
values (*michnovka#4*1*11*##, *michnovka#4*1*60*##, *michnovka#4*1*11##, *michnovka#4*1*13##, *michnovka#4*1*60##
raised ValueError / IndexError out of the parser; now untyped) and the
documentation and golden metadata fixes (README CLIMATE_MODE_*
quick-start and builder list, docs/protocol_conformance_matrix.md 3.4,
tests/golden/README.md, who04_thermo.yaml / corpus.json mcp_notes:
DIMENSION 12 is the setpoint after local offset). The CHANGELOG keeps only
those two entries, worded as no behaviour change on valid frames.
tests/test_who4_audit_fixes.py keeps the H9 guard tests, the master
clamp tests and the MyHOME legacy-frame table with master's ids.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck

gdluck commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Applied in 80af0dc (pushed; the description now carries the same follow-up table at the top).

Blocker Action taken
1. unique_id / actuator Reverted to master (#0#1 -> 4-1, 2#1 -> 4-2, 0#2 / 0 / 000 -> 4-#0, 001 -> 4-1; actuator is the str "1" again); master's assertions restored in test_heating.py, test_message_protocol.py, test_issue_77_firmware_findings.py, test_who4_actuators_status.py and the other touched test files.
2. DIMENSION 19 The 14 / 15 / 16 fan-coil OFF mapping and the 9..13 / 17+ -> None change reverted; master decoding restored.
3. DIMENSION 15 / 22 The 3-digit DIMENSION 15 decoding reverted (test_invalid_temperature_width_and_sign_return_none restored); the DIMENSION 22 split_control decoder, its constants and MESSAGE_TYPE_SPLIT_CONTROL removed.
4. message_type / logs The DIMENSION_WRITING message_type suppression and every human_readable_log rewrite on valid frames reverted to master. The address-class / mode prose was tied to the removed properties, so no runtime or docstring trace of it remains in heating.py.
5. Builders turn_off("0", standalone=True) emits *4*303*#0## again; the ValueError rejections of 0#N, 001, 105, #105 reverted; the DIMENSION 2 / 3 builders, decoders, exports and tests/test_who4_lab_2026_11_03.py removed, with every reports/data/... citation.

Also reverted under the same rule, although not listed: the new read-only address properties (scope, zone_number, central_unit, probe_number, interface, address, operating_mode and their constants) and the set_holiday_end_date calendar check, both documentation-derived.

Update (2026-11-04, 16:2xZ, 6e8c49c): the address-class / mode distinctions the review said may stay as documentation are now an OWNHeatingEvent class docstring (no runtime effect); the first follow-up had dropped that prose together with the properties.

Kept, as listed under "What can stay": the parser crash-guards for empty DIMENSION 11 / 13 / 60 values (*#4*1*11*## and *#4*1*60*## raised ValueError out of OWNMessage.parse on master, *#4*1*11## / *#4*1*13## / *#4*1*60## raised IndexError; *#4*1*20*## / *#4*1*30*## and non-digit DIMENSION 12 / 13 / 14 values already did not raise, the new test pins all of them); the README CLIMATE_MODE_* fix, the conformance-matrix §3.4 and golden mcp_notes wording on DIMENSION 12. No decoded value, log line or accepted builder domain differs from master.

Committed plant evidence. None of the reverted decoders is backed by a committed trace: the MyHOME fixtures carry no DIMENSION 19 value 14..16, no 3-digit DIMENSION 15, no DIMENSION 22 event and no #0#1 / #Z#C WHERE. The one relevant physical frame, the F461 echo *4*303*#0## (MyHOME tests/fixtures/traces/f461_live_2026-10-15/), confirms master's turn_off("0", standalone=True) form. A live F461 2.0.11 plant is available for captures: if a committed capture of the DIMENSION 19 / 15 / 22 replies or the central-unit addressing would make any of these items reviewable again, say which frames; whatever the plant has will be captured and committed as a MyHOME trace fixture first.


Update (2026-11-04, 16:07Z): New committed plant trace for the WHO 4 items here: MyHOME#865 (tests/fixtures/traces/f461_live_2026-11-04/, full custom_components.myhome DEBUG log and the bus-monitor sweep export of the physical F461 2.0.11, captured on the released OWNd 2.0.0b13, SHA-256 pinned by tests/test_f461_live_2026_11_04_trace.py). Zone 2 (standalone thermostat, no central unit) answers every per-dimension read (0, 12, 13, 14, 19 *#4*2*19*5*0##, 20, 60) and the single actuator read *#4*2#1*20##; *#4*2*11## is NACKed on the command session while *#4*2*11*0## still arrives on the event session; the central-unit forms *#4*#2##, *#4*#0##, *#4*#0*19## are NACKed; setpoint writes *#4*2*#14*0245*M## are answered by *#4*2*12*0245*3## plus the mode event (*4*1*2## after a heat / generic write, *4*0*2## after a cool write) and *4*303*2## is echoed. None of it supports the reverted decoders (no 14..16 on dimension 19, no 3-digit dimension 15, no dimension 22 event, no #Z#C addressing); it does pin the F461's answers to the forms master already builds. Say if any specific WHO 4 capture on this plant would make an item reviewable again.

@gdluck
gdluck marked this pull request as draft November 4, 2026 15:15
@gdluck
gdluck marked this pull request as ready for review November 4, 2026 16:45

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

Thanks @gdluck for cleanly reverting all five runtime blockers from #5769893204 in 80af0dc..6e8c49c — the README.md CLIMATE_MODE_* / central-unit builder fixes, docs/protocol_conformance_matrix.md §3.4, and the golden DIMENSION 12 metadata note all match who-4-temperature-control/dimensions.md ("DIMENSION 12 - complete probe status").

As on #155, we ran one extra @fedem95-style verification pass while @fedem95 is away so this can merge cleanly once green. Five items came up (plus a quick rebase/merge with master now that #155 has landed):


1. Remaining uncaught ValueError crashes in OWNMessage.parse / OWNEvent.parse + vacuous test cases

Two grammar-accepted WHO 4 shapes still raise uncaught ValueError out of OWNMessage.parse / OWNEvent.parse / OWNHeatingEvent:

  • DIMENSION 15 empty sensor slot (*#4*1*15**##, *#4*1*15**0215##, *#4*1*15#1**0215##): base.py's _DIMENSION_REQUEST_REPLY ((?:\*\d*)+) parses dimension_value as ["", ""] / ["", "0215"] (len >= 2), and OWNd/message/heating.py:673-674 calls int(self._dimension_value[0]) -> ValueError: invalid literal for int() with base 10: ''.
  • Star WHERE (*4*1**##, *4*303**##): base.py's _STATUS admits (?P<where>\*|#?\d+) across all WHOs, so OWNMessage.parse("*4*1**##") dispatches _where = "*" to OWNHeatingEvent, where OWNd/message/heating.py:339-342 calls int("*") -> ValueError: invalid literal for int() with base 10: '*'.
  • Why tests/test_who4_audit_fixes.py:60-82 missed them: the five *x## frames (*#4*1*12*x##, *#4*1*13*x##, *#4*1*14*x##, *#4*1*19*x*0##, *#4*1*20*x##) fail base.py's regex completely (is_valid is False, dimension is None), so none of those elif self._dimension == ... branches run and event.message_type is None for all 12 parametrized frames (leaving "local_offset", "local_target_temperature", "target_temperature" on line 82 dead). Please replace/supplement them with the grammar-accepted empty-tag forms through OWNMessage.parse / OWNEvent.parse (*#4*1*12*##, *#4*1*13*##, *#4*1*14*##, *#4*1*15*##, *#4*1*15**##, *#4*1*15**0215##, *#4*1*19*##, *#4*1*19*5*##, *#4*1*19**5##, *4*1**##).

2. str.isdigit() Unicode digit leak in DIMENSION 11 (and 15 / 19 / 20 / 30 / 31 / 32) vs ASCII-only _WHO4_HUMIDITY_REGEX

  • On heating.py:52, DIMENSION 60 uses re.ASCII (_WHO4_HUMIDITY_REGEX = re.compile(r"[0-9]+(?:\.[0-9]+)?", re.ASCII)), so *#4*1*60*45## fails closed (None).
  • On heating.py:594, the new DIMENSION 11 guard uses self._dimension_value[0].isdigit(). Because base.py's _DIMENSION_REQUEST_REPLY has no re.ASCII, OWNMessage.parse("*#4*1*11*3*##") decodes fullwidth Unicode "3" as fan_speed == 3 (and likewise in DIMENSION 15 / 19 / 20 / 30 / 31 / 32, e.g. *#4*1*19*5*0##, *#4*#0*30*01*02*2026##; plus "²".isdigit() is True while int("²") raises ValueError).
  • Using an ASCII-only [0-9]+ check (matching _WHO1004_COUNT_REGEX in the same module and _int_field in #155) makes all WHO 4 integer dimension reads fail closed on non-ASCII wire bytes without changing any valid ASCII frame.

3. Three factual fixes in the OWNHeatingEvent docstring (OWNd/message/heating.py:316-332)

  1. Non-existent public actuator attribute: Line 326 states `zone`, `unique_id` and `actuator` keep their long-standing values for every form — on master and on this branch, OWNHeatingEvent has no public actuator property (hasattr(OWNHeatingEvent("*#4*2#1*20*1##"), "actuator") is False; only private self._actuator exists on DIMENSION 20).
  2. Dimension-write message_type claim: Lines 330–331 state "a dimension write frame keeps the same message_type as the matching read." OWNMessage.parse routes dimension writes to OWNHeatingCommand (no message_type), and on OWNEvent.parse / OWNHeatingEvent, DIMENSION 7 explicitly checks self._message_type != "DIMENSION_WRITING" (line 571: *#4*1*#7*1*1*0200## has message_type is None vs read "zone_state"), and DIMENSION 30 / 31 / 32 also skip DIMENSION_WRITING.
  3. who-4-temperature-control/addressing.md attribution: "Published functional WHERE forms" defines 0 (all probes), 1..99 (master probe of zone 1..99, including in central-unit plants — not only "a stand-alone zone probe"), and 001..099 (all probes in zone — omitted here even though 1 vs 001 is the page's primary contrast), alongside PZZ, #0, and #1..#99; #0#C, #Z#C, and external probe x00 (1..9) come from "MyHOME_Suite address rules" and "Historical central-unit variants", not the published WHERE table.

4. Remove private audit references (report 21, H9, H11, H12) & CHANGELOG.md section

  • CHANGELOG.md:8-9 still cites ; report 21 H9, ; report 21 H11, and ; report 21 H12 (and bare dimensions.md "DIMENSION 12" instead of who-4-temperature-control/dimensions.md "DIMENSION 12 - complete probe status"), and lists the parser crash fix under ### Changed rather than ### Fixed (note #155 just merged to master, so rebasing/merging master will resolve the CHANGELOG.md / README.md conflict at the same time).
  • tests/test_who4_audit_fixes.py still has (report 21, row H9) (line 1), # --- H9: ... (line 41), and test_h9_* function names.

5. Update the PR Title and PR Body to match the actual diff

  • Both the PR title (fix(heating): WHO 4 WHERE address classes, operating_mode, dimension 19 OFF states, parse robustness, builder validation (Encyclopedia audit, group 1)) and the PR body sections below the follow-up table (## Summary, ## Changes, ## State Diagram, ## Also in this diff referencing Dimension 2 / 3, tests/test_who4_lab_2026_11_03.py, L-A1..L-A3, H1..H19, decisions D39..D67) still describe the reverted features and files rather than what this PR now ships.

gdluck and others added 2 commits November 8, 2026 18:07
…t and wildcard WHERE no longer raise; docstring and test wording

Review 5818563137 on OpenWebNet-HA#156, five items (the master merge with OpenWebNet-HA#155 is the commit before):

1. Two grammar-accepted shapes still raised ValueError out of OWNMessage.parse /
   OWNEvent.parse: an empty DIMENSION 15 sensor slot (`*michnovka#4*1*15**##`,
   `*michnovka#4*1*15**0215##`, `*michnovka#4*1*15#1**0215##`: int('') on the slot) and the wildcard
   WHERE `*4*1**##` / `*4*303**##` (int('*') for the zone). The slot is read with
   `_ascii_int` (None = no reading, frame typed as master typed DIMENSION 15 with
   every value None); a WHERE that is not digits gives zone 0, as a frame without a
   WHERE already did. The five `*michnovka#4*1*12*x##`-style test frames fail the grammar
   entirely (is_valid False, dimension None), so that test asserted nothing about the
   decoders; it is replaced by the grammar-accepted empty-tag forms through both parse
   entry points, plus the wildcard WHERE cases, and one test that records why the
   rejected frames are not a crash test.
2. `str.isdigit()` is true for fullwidth digits and base.py's `\d` lets them through:
   `*michnovka#4*1*11*3*##` decoded fan_speed 3, `*michnovka#4*#0*30*01*02*2026##` a date. DIMENSION
   11, 15, 19, 20, 30, 31 and 32 now read their integer fields through `_WHO4_INT_REGEX`
   (`[0-9]+`, re.ASCII), as the DIMENSION 60 humidity value already did; a non-ASCII
   field leaves the frame untyped with the raw log. No ASCII frame changes.
3. OWNHeatingEvent docstring: no public `actuator` attribute is claimed (the number is
   private to the DIMENSION 20 log); a dimension write goes to OWNHeatingCommand via
   OWNMessage.parse and stays untyped via OWNEvent.parse (DIMENSION 7 / 30 / 31 / 32
   skip DIMENSION_WRITING), as on master; the WHERE forms are attributed to the right
   sections of who-4-temperature-control/addressing.md ("Published functional WHERE
   forms": 0, 1..99 master probe in any plant, 001..099 all probes of a zone, PZZ, #0,
   michnovka#1..OpenWebNet-HA#99; "Actuator addressing": Z#N; "MyHOME_Suite address rules" / "Historical
   central-unit variants": #0#C, #Z#C, N00).
4. The audit-row references (report 21, H9, H11, H12) are gone from the test module
   and, in the merge commit, from CHANGELOG.md, where the crash guard now sits under
   Fixed and the DIMENSION 12 note cites "DIMENSION 12 - complete probe status".
5. PR title and body rewritten to what ships (on GitHub).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck gdluck changed the title fix(heating): WHO 4 WHERE address classes, operating_mode, dimension 19 OFF states, parse robustness, builder validation (Encyclopedia audit, group 1) fix(heating): WHO 4 decoder no longer raises on empty tags or a wildcard WHERE, reads ASCII digits only; README / conformance-matrix / docstring corrections (Encyclopedia audit, group 1) Nov 8, 2026
@gdluck

gdluck commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. All five are in 887a5ad, on top of a merge with master (820d367) that brings #155 in and resolves the CHANGELOG.md conflict. Each claim was reproduced on 6e8c49c first.

  1. Crashes. Reproduced: the three empty-slot DIMENSION 15 frames raised ValueError: invalid literal for int() with base 10: '' and *4*1**## / *4*303**## raised ... '*', through both OWNMessage.parse and OWNEvent.parse. The sensor slot is read with a new _ascii_int (None = no reading; the frame stays typed as master typed DIMENSION 15, every value None), and a WHERE that is not digits gives zone 0, as a frame without a WHERE already did (unique_id 4-#0). Also reproduced that the five *#4*1*12*x## frames fail the grammar entirely (is_valid False, dimension None, OWNMessage.parse → None), so that test asserted nothing about the decoders: it is replaced by test_empty_tag_dimension_values_parse_without_raising (the ten grammar-accepted forms you listed plus *#4*1*20*## / *#4*1*30*##, through both entry points, pinning the message_type master gives each), test_wildcard_where_does_not_raise, and one small test that records why the rejected frames are not a crash test.
  2. ASCII digits. Reproduced: *#4*1*11*3*## → fan_speed 3, *#4*1*19*5*0## typed hvac_action, *#4*#0*30*01*02*2026## → (1, 2, 2026), likewise DIMENSION 20 / 31 / 32 and the DIMENSION 15 sensor slot, while *#4*1*60*45## already failed closed. DIMENSION 11, 15, 19, 20, 30, 31 and 32 now go through _WHO4_INT_REGEX = re.compile(r"[0-9]+", re.ASCII) (_ascii_int / _ascii_ints), matching _WHO4_HUMIDITY_REGEX and _WHO1004_COUNT_REGEX; a non-ASCII field leaves the frame untyped with the raw log. Test: test_fullwidth_digits_fail_closed (eight frames) plus test_ascii_frames_still_decode_the_same_values.
  3. Docstring. Confirmed hasattr(OWNHeatingEvent("*#4*2#1*20*1##"), "actuator") is False, OWNMessage.parse("*#4*1*#7*1*1*0200##") is an OWNHeatingCommand and via OWNEvent.parse its message_type is None against zone_state for the read. The docstring now says the actuator number is private to the DIMENSION 20 log, describes the write routing as it is, and attributes the WHERE forms to the sections of who-4-temperature-control/addressing.md that define them: "Published functional WHERE forms" (0, 1..99 master probe in any plant, 001..099 all probes of a zone with the 1 vs 001 contrast, PZZ, #0, #1..#99), "Actuator addressing" (Z#N), "MyHOME_Suite address rules" / "Historical central-unit variants" (#0#C, #Z#C, N00). Checked against the page in the Encyclopedia checkout.
  4. References and CHANGELOG. report 21 / H9 / H11 / H12 are gone from the test module and the changelog (grep -ri "report 21" over OWNd/, tests/, CHANGELOG.md, README.md, docs/ is empty). In the merge, [Unreleased] keeps fix(who1): crash guards for empty DIMENSION values, non-empty WHERE on WHO 1 / WHO 17 builders, DIMENSION 4 status_level / status_speed #155's Added / Changed / Fixed sections; the WHO 4 crash guard sits under Fixed (now also naming the DIMENSION 15 slot, the wildcard WHERE and the ASCII rule) and the README / conformance-matrix note under Changed, citing who-4-temperature-control/dimensions.md "DIMENSION 12 - complete probe status".
  5. Title and body rewritten to what ships: the crash guards, the ASCII rule, the README / matrix / golden-note corrections and the docstring; the reverted features, Dimension 2 / 3, test_who4_lab_2026_11_03.py, the row ids and decision numbers are no longer mentioned.

Run here (Python 3.14, .[test]): 3083 passed; ruff check OWNd tests; mypy OWNd tests/typing clean; scripts/verify_coverage.py green with heating.py at 100 % statements and branches; scripts/sync_documentation.py --check; scripts/verify_library_standards.py.


Generated by Claude Code

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

LGTM — all five items from #5818563137 (DIMENSION 15 empty sensor slot & wildcard WHERE crash guards, _WHO4_INT_REGEX ASCII-only integer fields across DIMENSION 11 / 15 / 19 / 20 / 30 / 31 / 32, OWNHeatingEvent docstring accuracy and who-4-temperature-control/addressing.md attribution, CHANGELOG.md / test cleanup, and PR title/description update) are cleanly resolved in 887a5ad. Thanks @gdluck!

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit 32cca9e into OpenWebNet-HA:master Nov 8, 2026
8 checks passed
gdluck pushed a commit to gdluck/OWNd that referenced this pull request Nov 8, 2026
…dimension 52 guard allows only the tested forms; 3488 and oracle provenance; wording

Review 5819194700 on OpenWebNet-HA#157, five items (the merge with master, OpenWebNet-HA#155 and OpenWebNet-HA#156, is the
commit before):

1. AsyncSerialTransport.send applied only the WHO 4 dimension 22 guard, so
   `*OpenWebNet-HA#18*51*52#2026#10##` (with or without `##`) was written to the serial port.
   command_session.destructive_frame_reason() is now the one classifier behind
   OWNCommandSession.send, send_to_gateway and AsyncSerialTransport.send
   (AsyncTcpTransport.send goes through the session), returning the reason or None.
   tests/test_who18_dimension52_guard.py mirrors tests/test_who4_dimension22_guard.py:
   safe forms written through all four entry points, refusals across all four
   (serial with and without `##`), connect.assert_not_awaited() for the session and
   the one-shot helper, single serialisation, buffers and concatenated chunks; the
   test that never sent is gone. tests/typing/consumer_send.py exercises both guards.
2. The regex `52#[0-9]{4}#` was the complement of two crash rows, not the tested
   forms: `52#02026#10` and `52#256#10` reach the same one-byte atoi() formatter,
   and `*OpenWebNet-HA#18*`, `*052`, `OpenWebNet-HA#10`, `*OpenWebNet-HA#52` writes, extra `#` parameters, an empty WHERE,
   fullwidth digits and multi-value replies all parsed as valid and passed the guard.
   is_who18_dimension_52 / is_who18_dimension_52_crash_form follow heating.py's
   two-step pattern (detect any DIMENSION 52 chunk after latin-1 decoding; allow only
   `*OpenWebNet-HA#18*W*52`, `*OpenWebNet-HA#18*W*52*Wh`, `*OpenWebNet-HA#18*W*52#YY#M`, `*OpenWebNet-HA#18*W*52#YY#M*Wh` with ASCII YY
   0..99 and M 1..12, `\Z` anchors). is_who18_dimension_52_four_digit_year stays as an
   alias.
3. The 3488 030205 (HOMETOUCH) crash rows are cited with the F460 and F461 ones, with
   the oracle provenance (repository, suite ownd-audit-frames, index SHA-256, emulated
   firmware caveat), in energy.py, the reason string and CHANGELOG.
4. README and scripts/sync_documentation.py no longer list "Energy Management
   actuators"; the Stop&Go docstrings say 11..19 is master's range from the oracle
   energy-ts10 suite (no physical Stop&Go trace is committed); _refuse_destructive_frame's
   docstring, the zigbee-variant.md heading ("DIMENSION table") and the F454 firmware
   (2.0.51) are corrected.
5. Audit tags (report 21, E1..E12, decision D1) removed from the tests and golden
   notes; the CHANGELOG entry sits under Fixed (merge commit); PR title and body are
   rewritten on GitHub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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