Skip to content

fix(energy): refuse WHO 18 DIMENSION 52 frames outside their tested forms on every send path (four-digit year crashes F460 / F461 / 3488); Wh wording, fixture labels (Encyclopedia audit, group 4) - #157

Open
gdluck wants to merge 13 commits into
OpenWebNet-HA:masterfrom
gdluck:fix/who18-energy-builders

Conversation

@gdluck

@gdluck gdluck commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Requested by A

Summary

WHO 18 checked against the Encyclopedia functional pages (who-18-energy-management/{addressing,dimensions,zigbee-variant}.md) and the own-firmware-oracle ownd-audit-frames suite. What ships: a send-layer guard that refuses WHO 18 DIMENSION 52 frames outside their tested forms on every send path (a four-digit year crashes three emulated gateways), and documentation and golden-fixture corrections. No builder output, decoded value or log line differs from master; the actuator, series and Stop&Go decoder changes of the audit were held back pending committed plant traces.

Changes

  • Fixed, send layer (OWNd/message/energy.py, OWNd/connection/command_session.py, OWNd/transport/serial.py): is_who18_dimension_52_crash_form (alias is_who18_dimension_52_four_digit_year) follows the WHO 4 dimension 22 pattern: it detects any DIMENSION 52 chunk (per ## chunk, bytes / bytearray / memoryview decoded as latin-1) and allows only the tested forms *#18*W*52##, *#18*W*52*Wh##, *#18*W*52#YY#M##, *#18*W*52#YY#M*Wh## (ASCII YY 0..99, M 1..12). *#18*51*52#2026#10## and *#18*51*52#2026#10*100## crash the F460 020012, F461 020011 and 3488 030205 after the telegram went out (OpenWebNet-HA/own-firmware-oracle, suite ownd-audit-frames, index SHA-256 db03cc878d84d514b9e324c2d01d050264f2e4ccd0c1aad33937683c30e472ca, findings/audit-crash-frames.md; emulated firmware, harness=full, not yet replayed on physical hardware); the firmware reads the year with atoi() into one byte, so 02026, 256, leading zeros on WHO / DIMENSION, a #52 write, extra parameters, an empty WHERE, non-ASCII digits and multi-value forms are refused too. One classifier, destructive_frame_reason, now serves OWNCommandSession.send, send_to_gateway, AsyncTcpTransport.send and AsyncSerialTransport.send with reason="destructive_frame"; the serial transport previously applied only the WHO 4 guard. OWNCommand.parse stays permissive.
  • Docs, README and docs/protocol_conformance_matrix.md: WHO 18 totalizers are Wh as OWNd and MyHOME read them (the PDF labels DIMENSION 51..54 'Watt' and only 72 'Wh'); the WHO 18 catalog row no longer lists actuators.
  • Docs, energy.py docstrings: DIMENSION 1200 Time is the published report-on-change parameter; report_current_month_energy / report_partial_month_energy / report_partial_day_energy note the WHO_18.pdf forms next to the unchanged wire output; the Stop&Go range 11..19 is master's range from the oracle energy-ts10 suite (no physical Stop&Go trace is committed); set_test_frequency notes the TS10 domain.
  • Golden fixtures: *#18*1*1## / *#18*1*11*00452300## relabelled conflict grammar-only records (no DIMENSION 1 on SCS WHO 18, DIMENSION 11 is the ZigBee variant's Voltage, no kWh on the wire). Frames unchanged.

Tests: tests/test_who18_dimension52_guard.py (detector, bypass forms, buffers and chunks, all four send paths, single serialisation), tests/test_who18_audit_fixes.py, tests/typing/consumer_send.py (both guards). Full suite, ruff check, mypy OWNd tests/typing, scripts/verify_coverage.py (100 % statements and branches on the three touched modules), scripts/sync_documentation.py --check, scripts/verify_library_standards.py and tools/golden/validate_corpus.py pass. Branch merged with master after #155 and #156.

🤖 Generated with Claude Code

gdluck and others added 4 commits November 3, 2026 09:46
report 21 E8, E12.

E8: README (WHO catalog table, generated by scripts/sync_documentation.py)
and docs/protocol_conformance_matrix.md said "kWh" for the WHO 18
consumption values. Encyclopedia who-18-energy-management/dimensions.md
"DIMENSION 72 - totalizer state" gives Wh and README.md "Measurement
model" never uses kWh; the F520 capture *OpenWebNet-HA#18*51*51*23791364## is 23,791,364
Wh. ENCY_CONFIRM EA-E8: kWh is a display conversion, not a wire unit.

E12: tests/golden/frames/who18_energy.yaml labelled *OpenWebNet-HA#18*1*1##,
*OpenWebNet-HA#18*1*1*1450##, *OpenWebNet-HA#18*1*11## and *OpenWebNet-HA#18*1*11*00452300## as legrand-spec
active-power / "45,230.0 kWh" totalizer frames. dimensions.md "Identifier
table" has no DIMENSION 1 or 11 (active power is 113, the totalizer 51);
zigbee-variant.md "Dimension table" gives 11 as the ZigBee Voltage
dimension; addressing.md gives no family for WHERE 1. They stay as
`conflict` grammar-only records (OWNd parses them with message_type None);
corpus.json regenerated with tools/golden/validate_corpus.py.

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

report 21 E1, E2, E3, E4, E5, E6, E7, E9, E10, E11.

E1: OWNEnergyCommand.report_current_month_energy emits only the documented
*OpenWebNet-HA#18*WHERE*52#YY#M*Wh## (dimensions.md "DIMENSION 52 - monthly energy/unit
totalizer"; year/month default to the current month). ORACLE_CONFIRM E1:
*OpenWebNet-HA#18*51*52*100## refused on 11/12 emulated gateways, the four-digit
52#2026#10 crashes the emulated F460 020012 / F461 020011, the two-digit
*OpenWebNet-HA#18*51*52#26#9## goes out on nine (firmware oracle energy-params /
energy-ts10). The parser gives monthly_consumption year/month/value and
returns None fields (never an empty dict) for a reply without #Y#M.

E2: Stop&Go builders accept the published 1N domain, N = 1..127
(addressing.md "Published WHERE forms"; ENCY_CONFIRM EA-E2 functional-only,
OWNd narrower than every reading); OWNStopAndGoEvent.line decodes
110..1127. ORACLE_CONFIRM E2: *18*21*110##, *18*21*120##, *18*21*1127## and
*18*22*120## forwarded by the same nine gateways as *18*21*11##.

E3: set_test_frequency bound 1..180 (dimensions.md "Stop&Go self-test
interval"; ORACLE_CONFIRM E3: #212*200 nack/silent 12/12, #212*180 out on 9).

E4/E5: decoders for DIMENSION 512#M (tags 1..24 / 25), 71 (six fields),
72#Tot_N, 73 and WHATs 57/58/59/510, 71, 73[#Time], 74, 75#Tot_N
(dimensions.md "Energy Management actuator information", "DIMENSION 512 -
monthly-average hourly series"; what.md "Historical-series commands",
"Actuator commands", "Reset totalizer"); builders for the same, every frame
in the firmware oracle energy-ts10 suite (out on eight or nine gateways).

E6: DIMENSION 511 tags outside 1..24 / 25 keep message_type None
(dimensions.md "DIMENSION 511 - daily hourly series").

E7: report_partial_month_energy / report_partial_day_energy drop the
undocumented 53#M / 54#D forms (dimensions.md "DIMENSION 53 / 54").

E9: DIMENSION 1200 docstrings describe Time as the published
report-on-change parameter (dimensions.md "DIMENSION 1200").

E10: logs and the new OWNEnergyEvent.address keep the complete WHERE
(addressing.md); sensor stays for compatibility.

E11: DIMENSION 259 ("Check off/on", dimensions.md "Individual Stop&Go
flags") sets the new is_check_active, not is_tracking_active (WHAT 28/29).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… guard (decisions D20, D21, D72)

Precedent gate on report 21 E1, E3, E7:

- E1: `report_current_month_energy` without `year`/`month` builds the
  legacy `*OpenWebNet-HA#18*W*52*Wh##` again (deprecated in the docstring) because
  MyHOME `expose.py` relies on it and `tests/test_expose.py` pins
  `*OpenWebNet-HA#18*51*52*120500##`; the documented `52#YY#M` form is built when both
  are given (ORACLE_CONFIRM E1 unchanged).
- E7: `report_partial_month_energy(month=)` / `report_partial_day_energy(day=)`
  keep their optional parameters as deprecated, validated (1..12 / 1..31,
  bool/fractional/non-finite rejected, D21) and ignored on the wire;
  MyHOME `expose.py` / `tests/test_expose.py` call them.
- E3: `set_test_frequency` rejects bool / fractional days (D21) and is
  framed as the TS10_1_0_23 touchscreen extension it is (Encyclopedia
  who-18 dimensions.md "Historical touchscreen extensions"; mcp#24, D72),
  not as a WHO_18.pdf form.

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

Review follow-up on report 21 E4, E5, E6, E11:

- `_require_actuator_where` requires the documented `7N#0` form with
  N = 1..255 (a bare `7N` gets `#0` appended; `7`, `70`, `701`, `7256`
  are refused; Encyclopedia who-18 addressing.md "Published WHERE
  forms"). `_normalize_actuator_where`, shared with the `5N` readers, is
  unchanged.
- `_decode_what` guards the historical-series and force-actuator
  parameters, `_decode_monthly_average` the 512 month parameter and the
  DIMENSION 511 decoder the tag / value fields with `isdigit()`: a
  non-digit or empty value leaves the typed fields None and never raises.
- DIMENSION 259 sets `is_check_active` and keeps setting
  `is_tracking_active` as on master, so existing consumers see no silent
  None; the CHANGELOG says so.

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!

…actuator 7N#0 ranges

The Stop&Go WHERE 1N (N 1..127) and the energy actuator 7N#0 (N 1..255)
are in the official WHO 18 PDF WHERE table, not only the Encyclopedia.
Re-attribute both comments to the PDF (primary source), keeping the
Encyclopedia as the secondary reference. Comment-only; no behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck
gdluck force-pushed the fix/who18-energy-builders branch from e688c3e to 5d4f4d5 Compare November 3, 2026 22:52
…the send layer

*OpenWebNet-HA#18*WHERE*52#2026#10## and the value-field form *OpenWebNet-HA#18*WHERE*52#2026#10*100##
crash F460 020012 and F461 020011 after the telegram goes out
(findings/audit-crash-frames.md; crash_frames ledger rows); the documented
two-digit form *OpenWebNet-HA#18*WHERE*52#26#9## does not. No builder emits the four-digit
form, so the exposure is a raw frame handed to a session. Every send path now
refuses it the way it refuses the WHO 4 dimension 22 crash forms: a new
is_who18_dimension_52_four_digit_year() detector in energy.py (exported), the
refusal helper and its error take a reason string, and both send entry points
check the new detector after the dimension 22 one. OWNCommand.parse stays
permissive (decision D1). Tests: detector cases, both session entry points
refusing before any connect, and the two-digit form left alone; full suite
2923 passed, mypy clean. Found by the 2026-11-04 audit (E18-1).

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

gdluck commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

One more commit here from the 2026-11-04 cross-check, on this PR's WHO 18 ground: a DIMENSION 52 request carrying a four-digit year (*#18*WHERE*52#2026#10##, also with a value field) is now refused by every send path with reason="destructive_frame", the way the WHO 4 dimension 22 crash forms are refused. It crashes F460 020012 and F461 020011 after the telegram goes out (findings/audit-crash-frames.md, crash_frames ledger rows); the documented two-digit form 52#26#9 goes out unchanged, and no builder emits the four-digit form, so the exposure was a raw frame handed to a session. New is_who18_dimension_52_four_digit_year() detector (exported); the refusal helper and error take a reason string. OWNCommand.parse stays permissive. Full suite 2923 passed, mypy clean.

@gdluck

gdluck commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Live on the F461 2.0.11 test plant (2026-11-04):

  • The published two-digit form is accepted: *#18*51*52#26#9## -> ACK (no energy meter on the plant, so no reply, no crash).
  • The four-digit-year form this PR guards is refused before any socket opens: OWNCommandSession.send("*#18*51*52#2026#10##") returns None with reason="destructive_frame", verified against a real session object. The crash is the ledgered F460/F461 behaviour and was deliberately not fired at the plant.

The WHO 18 PDF labels DIMENSION 51..54 'Watt' (p.19-20, p.25), 511/512
'Watt/h' (p.6, p.18) and only 72 'Wh' (p.21); kWh appears nowhere, and the
Encyclopedia README says to preserve the published field semantics. The README
and conformance-matrix rows keep '(Wh)' as what OWNd and MyHOME read, say so,
and the golden corpus header no longer claims the wire values 'are Wh'.

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 auditing WHO 18 against the Encyclopedia and firmware oracle — especially catching the 4-digit-year DIMENSION 52 daemon crash on F460 / F461 and cleaning up the grammar-only golden fixture notes without deleting them.

Applying our standing rule — if we don't have real, committed on-wire plant traces from physical hardware, we don't change behaviour — please trim this PR down to the proven crash guard and documentation/fixture-label fixes.

What can stay

  1. 4-digit-year DIMENSION 52 send-layer crash guard (is_who18_dimension_52_four_digit_year): Refusing *#18*WHERE*52#YYYY#M...## with reason="destructive_frame" in the send paths, matching the existing WHO 4 DIMENSION 22 crash guard (F460 020012 / F461 020011 crash confirmed in the firmware oracle).
  2. Documentation & golden conflict relabelling: Clarifying Wh vs. the PDF's "Watt" label in README.md, docs/protocol_conformance_matrix.md, scripts/sync_documentation.py, and update_interval/stream_duration docstrings, plus relabelling the four *#18*1*1## / *#18*1*11## records in tests/golden/frames/who18_energy.yaml (corpus.json) to source: conflict with accurate mcp_notes while keeping parser roundtrip coverage intact.

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

# Symbol / File Behaviour change without real plant trace Required action
1 OWNEnergyCommand.report_partial_month_energy & report_partial_day_energy in OWNd/message/energy.py Silently drops #M and #D on the wire when callers pass month= / day= (*#18*51*53#10*300## -> *#18*51*53*300##, *#18*51*54#18*120## -> *#18*51*54*120##), deleting existing assertions in tests/test_message_protocol.py. Revert both builders to master so passing month / day preserves existing wire output; document the WHO_18.pdf scalar vs. TS10 parameterised distinction in docstrings only.
2 OWNEnergyEvent human_readable_log, DIMENSION 52 monthly_consumption, & DIMENSION 511 tag bounds in OWNd/message/energy.py Rewrites human_readable_log across every OWNEnergyEvent from Sensor {sensor} to Sensor {address}, changes monthly_consumption dict schema on parameter-less *#18*51*52*12345## (date/year/month keys), and drops DIMENSION 511 tags outside 1..25 — all without new physical meter traces. Revert existing OWNEnergyEvent log strings, DIMENSION 52 dict shape, and DIMENSION 511 behaviour to master.
3 10 new OWNEnergyCommand builders & 9 new OWNEnergyEvent decoders (DIMENSION 512, 71, 72, 73, WHAT 57..510, 71..75, is_check_active) in OWNd/message/energy.py Adds historical series and 7N#0 actuator builders/decoders (including DIMENSION 72 reset timestamp with an explicitly "inferred" 2000 + Y year width) backed only by WHO_18.pdf (ownkb:claim:c002261, c002262) and empty-bus gateway forwarding, with zero physical energy-meter or 7N#0 load-actuator bus traces. Hold back all new speculative builders and event decoders until we have real hardware captures for them.
4 OWNStopAndGoCommand / OWNStopAndGoEvent (11..1127 addressing & set_test_frequency 1..180) in OWNd/message/energy.py Widens Stop&Go addressing from 11..19 to 11..1127 (with overlapping bare-line vs. 1N normalisation where 11 means line 1 while 10 means line 10 / 110) and narrows set_test_frequency from 1..255 to 1..180 based on TS10_1_0_23 (libqtdevices) + empty-bus oracle forwarding, with zero physical Stop&Go breaker traces. Revert Stop&Go addressing and set_test_frequency range validation to master; note the WHO_18.pdf 1..127 and TS10 1..180 domains in docstrings only.

gdluck and others added 3 commits November 4, 2026 16:47
… the dimension 52 crash guard and docs

Maintainer review of OpenWebNet-HA#157: without committed on-wire plant traces from
physical hardware, runtime behaviour is not changed. Spec PDFs, Encyclopedia
prose, archived touchscreen client code and emulated-gateway verdicts are
documentation context only; the oracle crash finding justifies a
crash-prevention guard.

Kept:
- the DIMENSION 52 four-digit-year send-layer guard
  (`is_who18_dimension_52_four_digit_year`, `reason="destructive_frame"` in
  OWNd/connection/command_session.py) and tests/test_who18_dimension52_guard.py;
- the Wh-vs-'Watt' wording in README.md, docs/protocol_conformance_matrix.md
  and scripts/sync_documentation.py, the update_interval / stream_duration /
  start_sending_instant_power docstrings;
- the four `*OpenWebNet-HA#18*1*1##` / `*OpenWebNet-HA#18*1*11##` golden records relabelled
  `source: conflict` with grammar-only mcp_notes (roundtrip coverage kept).

Blocker 1 (report_partial_month_energy / report_partial_day_energy): both
builders reverted to master, so `month=` / `day=` still emit
`*OpenWebNet-HA#18*51*53#10*300##` / `*OpenWebNet-HA#18*51*54#18*120##`; master's assertions in
tests/test_message_protocol.py restored; the WHO_18.pdf scalar vs TS10
parameterised distinction is noted in the docstrings only.
`report_current_month_energy` is master's code (its `52#YY#M` two-digit-year
form and the parameter-less form predate this PR and are unchanged); its
docstring now documents the form and points at the crash guard.

Blocker 2 (OWNEnergyEvent): reverted to master in full: the human_readable_log
wording ("Sensor {sensor}"), the DIMENSION 52 `monthly_consumption` dict shape
on `*OpenWebNet-HA#18*51*52*12345##`, the DIMENSION 511 tag-bounds change, and the `address`
property and `_decode_what` / `_decode_dimension` restructuring that carried
them. tests/test_who18_auto_update_reply.py restored to master.

Blocker 3 (new builders and decoders): removed the ten OWNEnergyCommand
builders (start_daily_series, start_monthly_average_series,
get_monthly_average_consumption, enable_actuator, force_actuator,
end_actuator_forcing, reset_totalizer, request_actuator_status,
request_totalizer, request_differential_current_level), the nine
OWNEnergyEvent decoders (DIMENSION 512, 71, 72, 73; WHAT 57..510, 71, 73,
74, 75), `OWNStopAndGoEvent.is_check_active`, the `_require_actuator_where`
/ `_validate_int_range` / `stop_and_go_line` helpers, the new constants and
their exports from OWNd.message, and their tests. tests/test_who18_audit_fixes.py
keeps only the E1 builder-output pins (unchanged master output), E8, E9 and E12.

Blocker 4 (Stop&Go): `_normalize_stop_and_go_where` and
`OWNStopAndGoEvent.line` reverted to master's 11..19 addressing and
normalisation; `set_test_frequency` back to master's 1..255 without the
bool / fractional check; the WHO_18.pdf 1..127 and TS10 1..180 domains are
noted in docstrings only. tests/test_who18_stop_and_go.py restored to master.

CHANGELOG: entries for the reverted items removed; the kept entries are
reworded as documentation / fixture relabelling with no behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gdluck pushed a commit to gdluck/OWNd that referenced this pull request Nov 4, 2026
…plant traces; keep the WHERE guards and docs

Maintainer review of OpenWebNet-HA#159: without real, committed on-wire plant traces
from physical hardware, runtime behaviour does not change. Spec PDFs,
Encyclopedia prose and empty-bus gateway ACKs / echoes are documentation
context; oracle NACKs justify builder guards only.

Blocker 1 (OWNAutomationEvent WHAT 10/11/12, 13/14, DIMENSION 11):
reverted to master. `state` again carries any command WHAT (10/11/12
included), WHAT 13/14 keep `state = 13/14` with `is_opening` /
`is_closing`; no `unknown_command_code`. Removed the `step`,
`moves_to_end_point`, `priority`, `priority_bitfield`,
`priority_safety/high/medium`, `selector`, `info_name`,
`is_command_not_executed`, `target_level`, `is_go_to_level` decoders on
the event and on `OWNAutomationCommand`, the related log strings, the
module-level constants and helpers, and their tests. Kept: the module
docstring with the WHO 2 specification caveats, including the
frame-shape note (F461 2.0.11 echoes the DIMENSION 11 write form
`*michnovka#2*25*OpenWebNet-HA#11#001#1*50##`; the PDF publishes
`*michnovka#2*WHERE*OpenWebNet-HA#11#PRIORITY#LEVEL*SELECTOR##`).

Blocker 2 (report_shutter_level / DIMENSION 10): reverted to master.
`state` 10..15 and `priority` / `info` 0..255 are accepted again, the
level-255 log string is master's, DIMENSION 10 `state` outside 10..14 is
decoded as before; `tests/test_message_protocol.py` restored from master.

Blocker 3 (golden `cover.dim.write.pos.75`): restored, not deleted,
relabelled `source: conflict` with `mcp_notes` explaining that the PDF /
Encyclopedia define no DIMENSION 10 write (the position write is
DIMENSION 11) and that no committed plant trace carries the frame, so it
pins parser shape only (the pattern OpenWebNet-HA#157 used for who18_energy.yaml).
corpus.json regenerated from the YAML files (no repository tool exists;
same json.dumps(indent=2) layout as the committed file). The
`cover.dim.write.tilt.30` -> `cover.dim.write.level.30.no_priority`
relabel is kept (documentation).

Blocker 4 (WHO 3): `_LOAD_GENERAL_STATUS` in base.py, the `OWNCommand.parse`
pre-screen change and `OWNLoadCommand.general_status()` reverted to master
with their tests; `*michnovka#3*##` is rejected as before.

Blocker 5 (WHO 9): the runtime `(WHAT N, unverified name)` log suffix,
`state_name` and `is_state_name_verified` reverted; master's log branches
restored verbatim. Kept: the provenance caveat as comments on
`AUX_WHAT_NAMES` / `AUX_WHAT_NAMES_UNVERIFIED` and the `OWNAuxEvent`
docstring.

Kept: the non-empty-WHERE guards on `status`, `get_shutter_status`,
`raise_shutter`, `lower_shutter`, `stop_shutter` (`*2*1*##` and
`*michnovka#2**10##` NACKed 12/12 in the firmware oracle; `OWNCommand.parse` stays
permissive, pinned by a test) and the "Slat Tilt" / "tilt angles" removal
in README.md, docs/protocol_conformance_matrix.md,
scripts/sync_documentation.py and tests/golden/README.md. The generated
WHO 2 catalog description no longer claims advanced-command decoding.
CHANGELOG entries for the reverted items removed; the kept ones say no
behaviour change.

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

gdluck commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

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

Blocker Action taken
1. report_partial_month_energy / report_partial_day_energy Reverted to master (month= / day= still emit *#18*51*53#10*300## / *#18*51*54#18*120##); test_message_protocol.py restored; the WHO_18.pdf scalar vs TS10 parameterised distinction is in the docstrings only. The 52#YY#M builder is master's code verbatim (it already built *#18*WHERE*52#YY#M*Wh## when year and month are given and the parameter-less form otherwise; the PR had only restructured it with identical output), now with a docstring that documents the form and points at the crash guard.
2. OWNEnergyEvent Log wording back to "Sensor {sensor}"; the DIMENSION 52 monthly_consumption dict shape on the parameter-less reply and the DIMENSION 511 tag bounds back to master; the address property and the decoder refactor that only carried those changes removed; test_who18_auto_update_reply.py restored.
3. New builders / decoders The 10 OWNEnergyCommand builders and 9 OWNEnergyEvent decoders (DIMENSION 512, 71, 72, 73, WHAT 57..510, 71..75, is_check_active, the 7N#0 actuator forms, the DIMENSION 72 reset timestamp) held back with their helpers, constants, exports and tests.
4. Stop&Go _normalize_stop_and_go_where, OWNStopAndGoEvent.line and set_test_frequency back to master (11..19, 1..255); the WHO_18.pdf 1..127 and TS10 1..180 domains noted in docstrings only; test_who18_stop_and_go.py restored.

Kept, as listed under "What can stay": the is_who18_dimension_52_four_digit_year send-layer crash guard (reason="destructive_frame" in the command session and send_to_gateway, tests/test_who18_dimension52_guard.py); the Wh-vs-"Watt" wording in README, the conformance matrix, sync_documentation.py and the update_interval / stream_duration docstrings; the four *#18*1*1## / *#18*1*11## golden records relabelled source: conflict with their mcp_notes, parser roundtrip coverage unchanged. No decoded value, log line or accepted builder domain differs from master.

Committed plant evidence for the held-back decoders. Physical frames for three of them are already committed in MyHOME: the 7N#0 actuator replies *#18*73#0*113*0## .. *#18*79#0*113*0## (F454 2.0.51 sweep issue_466/myhome_sweep_F454_all_2026-11-01T18-58-06.json from MyHOME#861, MH202 mh202_sound_intercom_ducking/myhome_trace_MH202_all_2026-10-18T10-34-13.json, F461 issue_466/myhome_trace_F461_all_2026-09-28T12-42-23.json) and the F461 2.0.11 replies *#18*51*511#10#1##, *#18*51*511#10#2##, *#18*51*512#10## (f461_live_2026-10-23/myhome_trace_F461_live_2026-10-23.json). Would a separate, smaller PR re-proposing only the 7N#0, DIMENSION 511 and 512 decoders, pinned to those fixtures as golden records, be welcome, or should they wait for the MyHOME side to consume them first?

@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 four runtime blockers from #5769893949 in 37474c5..d88eed3 — the WHO 18 Encyclopedia headings ("DIMENSION 1200 - automatic active-power updates", "Measurement families and automatic updates", "Identifier table", "DIMENSION 72 - totalizer state") and golden conflict relabelling on *#18*1*1## / *#18*1*11*00452300## are well grounded.

As on #155 and #156, we ran our Round 2 / @fedem95-style verification pass so this can merge cleanly once green. Five blocking items and a few Round 2 hygiene items came up (plus a rebase onto master now that #155 and #156 have landed), followed by a direct answer to your question in #6633945785 at the bottom:


1. AsyncSerialTransport.send is unguarded (breaks the 4-send-path contract) + guard test gaps

  • CHANGELOG.md, OWNd/message/energy.py:341, and commit 37474c5 state that *#18*WHERE*52#2026#10## is refused "by every send path with reason=\"destructive_frame\", like the WHO 4 dimension 22 crash forms", yet OWNd/transport/serial.py:149-179 is untouched.
  • Because *#18*51*52#2026#10## parses as a valid OWNEnergyCommand (is_valid is True), calling await serial_transport.send("*#18*51*52#2026#10##") (or without ##, since serial.py:164 appends ## before checking is_who4_dimension_22_crash_form) writes the crash frame straight to the serial port. Please wire the WHO 18 Dimension 52 guard into AsyncSerialTransport.send alongside is_who4_dimension_22_crash_form (or share a common destructive-frame classifier between command_session.py and serial.py so they cannot drift).
  • Test gaps in tests/test_who18_dimension52_guard.py:
    • test_two_digit_year_is_not_refused_by_the_guard (lines 74-80) creates session = OWNCommandSession(gateway=_gateway()) and never calls await session.send(...) — asserting session._last_error is None on a fresh instance is a no-op.
    • Please bring test_who18_dimension52_guard.py to parity with tests/test_who4_dimension22_guard.py: actually send safe forms (*#18*51*52#26#9##, *#18*51*52#26#9*100##, *#18*51*52##, *#18*51*52*100##) through mocked OWNCommandSession.send, send_to_gateway, AsyncTcpTransport.send, and AsyncSerialTransport.send asserting writer.written == [frame.encode()]; assert refusal across all 4 entry points (including AsyncSerialTransport.send with/without ##, connect.assert_not_awaited() on disconnected send_to_gateway, and single serialization via _CountingCommand); and cover the new public export in tests/typing/ (test_typing_consumer.py).

2. Wire-escape bypasses in is_who18_dimension_52_four_digit_year (atoi(), grammar, and buffer/chunk forms)

In OpenWebNet-HA/own-firmware-oracle (findings/audit-crash-frames.md), firmware packs Y from 52#Y#M via C atoi() into a 1-byte hex slot (26 -> 1A, 9 -> 09 in `$06D1A1023350161A09\r`), whereas 2026 (0x7EA) overflows that 1-byte formatter into `$006D1A1023350167EA0\r`.

With _WHO18_DIM52_FOUR_DIGIT_YEAR = re.compile(r"^\*#18\*[^*]+\*52#[0-9]{4}#[0-9]{1,2}(?:\*[^*]*)?##$", re.ASCII) and str(frame), all of the following frames return False from is_who18_dimension_52_four_digit_year and parse as is_valid is True in OWNMessage.parse (OWNd/message/base.py:99-115), sailing past both checks onto the wire:

  1. 5-digit leading-zero year (atoi("02026") == 2026) or 3-digit overflow year (>= 256): *#18*51*52#02026#10## ([0-9]{4} misses 5 chars, while C atoi("02026") == 2026 emits the exact same `$006D1A1023350167EA0\r` crash telegram) and *#18*51*52#256#10## / *#18*51*52#999#10## (WHO_18.pdf defines Y strictly as two-digit 00..99).
  2. Leading zeros on WHO or DIMENSION (atoi("018") == 18, atoi("052") == 52): *#018*51*52#2026#10## and *#18*51*052#2026#10## (base.py uses \d+ and int("018") == 18).
  3. 3+-digit leading-zero month (atoi("010") == 10): *#18*51*52#2026#010## ([0-9]{1,2} rejects 010; base.py accepts (?:#\d+)*).
  4. Multi-value dimension reply ((?:\*\d*)+ in _DIMENSION_REQUEST_REPLY): *#18*51*52#2026#10*100*0## ((?:\*[^*]*)? matches at most one * token; base.py:109 accepts one or more).
  5. Dimension-write #52 (_DIMENSION_WRITING in base.py:113): *#18*51*#52#2026#10*100##.
  6. Extra # parameter ((?:#\d+)* in base.py:101): *#18*51*52#2026#10#1##.
  7. Empty WHERE ((?P<where>#?\d+)? is optional in base.py:100): *#18**52#2026#10## ([^*]+ requires non-empty WHERE; base.py parses it as valid OWNEnergyCommand).
  8. Non-ASCII Unicode digits (base.py \d+ has no re.ASCII): *#18*51*52#2026#10## (int("2026") == 2026 in base.py, while the guard's re.ASCII returns False).
  9. Public helper buffer / chunk / anchor contract (compare is_who4_dimension_22 / is_who4_dimension_22_crash_form in OWNd/message/heating.py:109-175): b"*#18*51*52#2026#10##" / bytearray / memoryview currently pass through str(b"...") -> "b'...'" -> False (decode (bytes, bytearray, memoryview) via latin-1); concatenated frames "*1*1*11##*#18*51*52#2026#10##" return False instead of inspecting each ##-delimited chunk; and use \Z rather than $ (which matches before trailing \n).

Please follow heating.py's two-step pattern ("the allowance is the tested forms, not the complement of the crash set"): detect any WHO 18 Dimension 52 chunk (^[^*]*\*#\s*\+?0*18\s*\*[^*]*\*\s*#?\s*\+?0*52(?:\s*\Z|[#*\s]) per ## chunk after latin-1 decode), and allow only the canonical safe forms (re.ASCII, \Z-anchored, non-empty canonical ASCII WHERE): parameter-less *#18*<where>*52\Z / *#18*<where>*52*<digits>\Z, and canonical two-digit-year *#18*<where>*52#<YY>#<M>(?:\*<digits>)?\Z with ASCII <YY> in 00..99 ([0-9]{1,2}) and <M> in 1..12 (0?[1-9]|1[0-2]). (Naming the detector is_who18_dimension_52_crash_form — keeping is_who18_dimension_52_four_digit_year as an alias if desired — also matches is_who4_dimension_22_crash_form now that 256 / 02026 / #52 are covered.)

3. Firmware-oracle provenance & missing 3488 (030205, HOMETOUCH) crash attribution

  • DESTRUCTIVE_DIM52_REASON, energy.py, and CHANGELOG.md list only F460 (020012) and F461 (020011). In OpenWebNet-HA/own-firmware-oracle (findings/audit-crash-frames.md and tools/expected_divergences.yaml, merged in own-firmware-oracle#76/#77), *#18*51*52#2026#10## and *#18*51*52#2026#10*100## also crash 3488 (030205, HOMETOUCH) (bus=[$006D1A1023350167EA0], verdict=crash). Please include 3488 (030205) alongside F460 (020012) and F461 (020011).
  • energy.py cites bare paths (findings/audit-crash-frames.md, tools/expected_divergences.yaml) while tests/golden/firmware_oracle.json does not yet contain ownd-audit-frames. Either sync firmware_oracle.json or explicitly cite the OpenWebNet-HA/own-firmware-oracle repository, suite ownd-audit-frames, its index SHA-256 (db03cc878d84d514b9e324c2d01d050264f2e4ccd0c1aad33937683c30e472ca), and the emulated firmware, harness=full, not yet replayed on physical hardware caveat as in heating.py:90, 120.

4. Leftover reverted doc claim, unbacked Stop & Go trace claim, and minor citation nits

  1. README.md:92 & scripts/sync_documentation.py:190: Both still append ", Energy Management actuators" to the WHO 18 catalog row, left over from the reverted Round 1 7N#0 actuator commands/events. Please remove ", Energy Management actuators" in both files (and keep README.md's top-level WHO catalog cell concise — Active power (W), hourly/daily/monthly consumption (Wh), Stop & Go breaker diagnostics — leaving the parenthetical PDF 'Watt' vs 'Wh' errata detail in docs/protocol_conformance_matrix.md and energy.py).
  2. Stop & Go docstrings (OWNd/message/energy.py:698-700 and 726-727): Both claim 11..19 / 1..9 is "the range covered by committed plant traces". There are zero committed physical Stop & Go (1N) device reply/event traces in OWNd or MyHOME (OWNStopAndGoEvent's own docstring at line 650 correctly notes Derived from firmware evidence (energy-ts10 suite...)). Please reword to state accurately that 11..19 (line 1..9) is master's existing range from energy-ts10, kept unchanged until a physical Stop & Go trace is committed.
  3. Minor docstring/citation nits:
    • OWNd/connection/command_session.py:278: _refuse_destructive_frame's docstring still says "Record and log the refusal of a crashing WHO 4 dimension 22 frame, never written." — update to match destructive_frame_error at line 53 ("frame known to crash a gateway").
    • tests/golden/frames/who18_energy.yaml:5 & tests/test_who18_audit_fixes.py:114: zigbee-variant.md heading is ## `DIMENSION` table (uppercase DIMENSION).
    • OWNd/message/energy.py:609: the committed MyHOME#466 sweep (myhome_sweep_F454_all_2026-09-26T16-59-13.json:28) records "firmware": "2.0.51" rather than 2.0.5.

5. Rebase onto master, CHANGELOG.md section, private audit IDs, and PR Title / Body

  • Rebase: #155 (13b1a8a) and #156 (32cca9e) have merged to master; please rebase and regenerate tests/golden/corpus.json.
  • CHANGELOG.md & private audit tags: Move the DIMENSION 52 crash guard from ### Changed to ### Fixed (matching WHO 1, WHO 4, and [2.0.0b13]), and strip internal audit tags ((2026-11-04 audit E18-1), report 21, E1..E12, decision D1) across CHANGELOG.md, tests/golden/frames/who18_energy.yaml:10, tests/test_who18_audit_fixes.py (docstring + test_e1_*..test_e12_* names), and tests/test_who18_dimension52_guard.py:21.
  • PR Title & Body: Please update both the PR title and the PR description sections (## Summary, ## Changes, ## State Diagram) so they describe only what ships in this PR rather than the reverted Round 1 features.

Re: Follow-up question (#6633945785) on 7N#0, DIMENSION 511, and DIMENSION 512

Please wait until we have real physical device reply captures before opening a decoder PR for those three:

  1. DIMENSION 511 & 512 in f461_live_2026-10-23/myhome_trace_F461_live_2026-10-23.json: As f461_live_2026-10-23/README.md (lines 25, 57, 93) documents, that bench had no WHO 18 meters (Not present: ... WHO 18 meters). Lines 2416-2494 (*#18*51*511#10#1##, *#18*51*511#10#2##, *#18*51*512#10##) are outbound client tx probes from run1_oracle_replay_readonly (replies: []), not meter replies (*#18*WHERE*511#M#D*Tag*Val## / *#18*WHERE*512#M*Tag*Val##) — so they carry no hardware evidence for reply field order, 1..24 vs 0..23 tag indexing, Tag 25 totals, or scaling (and OWNEnergyEvent on master already decodes 511, 513, 514).
  2. 7N#0 in issue_466 & mh202_sound_intercom_ducking: Those captures contain only DIMENSION 113 active-power replies (*#18*73#0*113*0## .. *#18*79#0*113*0##, *#18*71#0*113*99##) and 51/1200 queries — which master already decodes (is_actuator == True, target_address == "77#0", sensor == "77", active_power, pinned at who18_energy.yaml:138). None of them contain a WHAT 71/73/74/75 frame or a DIMENSION 71, DIMENSION 72 (Tot_N*Energy*D*M*Y*H*m), or DIMENSION 73 device reply.

Once a physical trace with actual replies from an F520 / F522 / F523 / 3522 meter or load actuator is committed, a focused PR adding those decoders will be very welcome!

gdluck and others added 2 commits November 8, 2026 21:57
…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>
@gdluck gdluck changed the title fix(energy): WHO 18 documented frame forms (52#YY#M, Stop&Go 1..127, 212 range), actuator and series decoders, Wh wording, fixture labels (Encyclopedia audit, group 4) fix(energy): refuse WHO 18 DIMENSION 52 frames outside their tested forms on every send path (four-digit year crashes F460 / F461 / 3488); Wh wording, fixture labels (Encyclopedia audit, group 4) Nov 8, 2026
@gdluck

gdluck commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. All five are in c208317, on top of a merge with master (bc2cf2d) that brings #155 and #156 in and resolves the CHANGELOG.md conflict. Each claim was reproduced on d88eed3 before changing anything.

  1. Serial send. Reproduced: with a fake writer, AsyncSerialTransport.send("*#18*51*52#2026#10##") and the same without ## both wrote the frame, _last_error None. command_session.destructive_frame_reason(frame) is now the one classifier (WHO 4 dimension 22 crash forms, WHO 18 dimension 52 crash forms, else None) behind OWNCommandSession.send, send_to_gateway and AsyncSerialTransport.send (AsyncTcpTransport.send goes through the session), so the paths cannot drift. tests/test_who18_dimension52_guard.py now mirrors test_who4_dimension22_guard.py: the four safe forms written through all four entry points (writer.written == [frame.encode()]), refusals across all four including serial with and without ##, connect.assert_not_awaited() on the session and on the one-shot helper, single serialisation via a _CountingCommand, buffers and concatenated chunks; the test that never called send is gone. On the typing contract: the file is tests/typing/consumer_send.py (checked by tests/test_typing_consumer.py and CI's mypy OWNd tests/typing); it now calls both guards with str and bytes.
  2. Bypass forms. All nine classes reproduced on d88eed3 (guard False, OWNMessage.parse is_valid True): 52#02026#10, 52#256#10, 52#999#10, *#018*, *052, #010, *100*0, *#52#2026#10*100, #10#1, the empty WHERE, the fullwidth year, bytes / bytearray / memoryview, the concatenated chunk. The detector follows heating.py's two-step pattern: _WHO18_DIMENSION_52_REGEX (^[^*]*\*#\s*\+?0*18\s*\*[^*]*\*\s*#?\s*\+?0*52(?:\s*\Z|[#*\s]), per ## chunk after latin-1 decode) and _WHO18_DIMENSION_52_SENDABLE_REGEX (^\*#18\*[0-9]+(?:#0)?\*52(?:#[0-9]{1,2}#(?:0?[1-9]|1[0-2]))?(?:\*[0-9]+)?\Z, re.ASCII); everything the first matches and the second does not is refused. Named is_who18_dimension_52_crash_form, with is_who18_dimension_52 beside it and is_who18_dimension_52_four_digit_year kept as an alias. One nuance for the record: *#18*51*52#2026#10##\n was already detected on d88eed3 ($ matched before the newline); \Z is adopted as hardening with the chunk design, not as a reproduced escape.
  3. 3488 and provenance. Confirmed in own-firmware-oracle origin/main: findings/audit-crash-frames.md line 151 and tools/expected_divergences.yaml have both four-digit-year requests as crash on the 3488 030205, rows in results/3488/030205/oracle/full/ownd-audit-frames.tsv (harness=full); the suite SHA-256 is line 11 of the findings file. tests/golden/firmware_oracle.json has no ownd-audit-frames entry, so energy.py, the reason string and the CHANGELOG cite the repository, the suite, the index SHA-256 and the emulated-firmware caveat, as heating.py does.
  4. Wording. ", Energy Management actuators" removed from README and scripts/sync_documentation.py (docs sync passes). Stop&Go: confirmed there is no committed physical 1N reply anywhere (the only *#18*11*212/250* frames in OWNd or MyHOME are the two synthetic unit-test frames in test_who18_stop_and_go.py); the three docstrings now say 11..19 is master's range from the oracle energy-ts10 suite, kept until a physical Stop&Go trace is committed. _refuse_destructive_frame's docstring says "a frame known to crash a gateway"; zigbee-variant.md is cited as "DIMENSION table" (the page's heading, checked); the F454 is 2.0.51 (myhome_sweep_F454_all_2026-09-26T16-59-13.json line 28).
  5. Merge, CHANGELOG, tags, title and body. Merged master (13b1a8a / 32cca9e); tools/golden/validate_corpus.py reports all fixtures valid and corpus.json synchronized. In [Unreleased] the DIMENSION 52 guard sits under Fixed and names the three gateways, the provenance and the refused spellings; the documentation and golden entries stay under Changed. report 21, E1..E12, audit E18-1 and decision D1 are gone from the CHANGELOG, the golden notes and both test modules (grep -ri over OWNd/, tests/, scripts/, README.md, CHANGELOG.md, docs/ is empty). Title and body now describe the guard, the Wh wording, the docstrings and the fixture relabelling only.

On 7N#0 / DIMENSION 511 / 512: agreed, the F461 bench had no WHO 18 meters and those rows are outbound probes with no replies, and the 7N#0 captures hold only 51 / 113 / 1200, which master decodes. No decoder PR until a physical capture with real meter or load-actuator replies is committed.

Run here (Python 3.14, .[test]): 3177 passed; ruff check OWNd tests scripts; mypy OWNd tests/typing clean; scripts/verify_coverage.py green with energy.py, command_session.py and serial.py at 100 % statements and branches; scripts/sync_documentation.py --check; scripts/verify_library_standards.py; tools/golden/validate_corpus.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.

Code Review & Audit Summary: APPROVED

Comprehensive audit and local verification of #157 completed against commit c208317 (fix/who18-energy-builders).

1. Protocol & Firmware Vulnerability Protection

  • Root Cause Verified: Firmware replay in own-firmware-oracle (ownd-audit-frames suite) confirms that monthly energy requests carrying four-digit years (such as *#18*51*52#2026#10## and value form *#18*51*52#2026#10*100##) crash the command translator daemons on F460 020012, F461 020011, and 3488 030205 (HOMETOUCH) due to an 8-bit buffer/slot overflow when formatting the atoi() result into the bus telegram.
  • Robust Allowlist Defense: Using the dual-regex pattern (_WHO18_DIMENSION_52_REGEX to detect any DIMENSION 52 frame, followed by _WHO18_DIMENSION_52_SENDABLE_REGEX strictly allowlisting tested canonical forms) cleanly blocks all 9 bypass classes (leading zeros, three/four-digit years, wide months, multi-value frames, writes, and fullwidth digits) while preserving permissive parsing for bus capture/diagnostics.
  • Unified Transmission Invariant: Introducing destructive_frame_reason() ensures all four send paths (OWNCommandSession.send, send_to_gateway, AsyncTcpTransport.send, and AsyncSerialTransport.send) share the identical classifier and error contract (reason="destructive_frame"). Serial transport previously allowed crashing WHO 18 frames through; this gap is now closed.
  • Pre-connection Fast-Fail: send_to_gateway() and send() validate before connecting, preventing socket creation, TLS handshakes, or slot exhaustion on single-connection gateways (MH200N, F453AV).
  • Single Serialization: Verified that strings checked by the guard are identical to the wire bytes dispatched, avoiding dual-serialization drift.

2. Documentation & Golden Corpus Accuracy

  • Energy Units (Wh): Correctly updates README, conformance matrix, and docstrings to state energy totalizers are Wh. Legrand's specification PDF erroneously labeled DIMENSION 51..54 as 'Watt' (and only 72 as 'Wh'); physical traces (*#18*51*51*23791364##) confirm cumulative Wh.
  • DIMENSION 1200: Clarified that Time is the published report-on-change interval in minutes rather than an unconditional polling duration.
  • Stop&Go Addressing: Documented master's 11..19 ($N=1..9$) domain alongside the PDF's $N=1..127$ specification, citing TS10 oracle provenance pending hardware traces.
  • Golden Fixtures: Relabelled energy.req.active_power.meter1, energy.resp.active_power.meter1.1450, energy.req.lifetime.meter1, and energy.resp.lifetime.meter1.45230 to source: conflict (grammar-only). SCS WHO 18 defines no DIMENSION 1 (active power is 113) or DIMENSION 11 (ZigBee voltage).

3. Verification & Test Results

  • Full Test Suite: 3,177 passed in 30.2s (test_who18_dimension52_guard.py and test_who18_audit_fixes.py pass 94/94 items).
  • Coverage: 100.0% statement and branch coverage maintained across OWNd/message/energy.py, OWNd/connection/command_session.py, and OWNd/transport/serial.py (7,085 / 7,085 statements covered across all 28 modules).
  • Static Analysis & Decoupling: ruff check, mypy OWNd tests/typing, verify_library_standards.py, and sync_documentation.py --check all pass clean with zero errors.
  • Golden Corpus: validate_corpus.py reports all 166 fixtures valid and synchronized.
  • Downstream MyHOME Compatibility: All 86 energy, sensor, expose, service, and dimension-guard tests in Home Assistant MyHOME execute cleanly against this branch with zero regressions.

cc @fedem95 for review. Ready to merge!

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