Skip to content

fix(heating): refuse WHO 4 DIMENSION 22 only without an actuator token in WHERE (oracle thermo-dim22) - #151

Merged
GreenGrassBlueOcean merged 1 commit into
OpenWebNet-HA:masterfrom
gdluck:fix/who4-dimension-22-guard-crash-forms
Oct 31, 2026
Merged

GreenGrassBlueOcean merged 1 commit into
OpenWebNet-HA:masterfrom
gdluck:fix/who4-dimension-22-guard-crash-forms

Conversation

@gdluck

@gdluck gdluck commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Requested by A

Summary

Before: since #136 every WHO 4 DIMENSION 22 frame is refused by every send path, in any spelling, the published split-unit forms *#4*3#Z#N*22## / *#4*3#Z#N*#22*MOD*SP*VEL*SWING## included. The evidence behind it was two F461 020011 crash frames of the same shape (*#4*2*22##, *#4*#0*22##) and the live F461 reboot, and the CHANGELOG / docstrings generalised that to "in any spelling".

After: the send paths refuse is_who4_dimension_22_crash_form(): every DIMENSION 22 spelling except the canonical published split form 3#Z#N (Z 1..99, N 1..9) as a request *22 or as a write with exactly the four payload fields MOD*SP*VEL*SWING. is_who4_dimension_22() keeps its "every spelling" meaning for callers that want the detector.

Context and how it was found. A questioned whether all DIMENSION 22 forms crash the daemon or only the zone / central one, and asked for the oracle to settle it. New suite thermo-dim22 (OpenWebNet-HA/own-firmware-oracle#68, finding findings/thermo-dim22.md): 11 forms on the twelve emulated gateways, two byte-identical runs. On the nine gateways that crash at all (F450, F453, F453AV, F454, F459, F460, F461, MH200N, MyHomeServer1) the crash set is exactly the four forms without an actuator token (*#4*2*22##, *#4*2*#22*1*0215*2*0##, *#4*#0*22##, *#4*0*22##; bt_termo SIGSEGV where attributed). No form with an actuator token crashed anywhere: *#4*3#2#1*22## and its write go out on ten of twelve gateways (F461 included) as a D1 telegram to zone 2, actuator 1, dimension 22; the prefix-less *#4*2#1*22## and the touchscreen's *#4*45#1*#22*3**1*1## are nack, silent on eleven and forwarded by the MH202 only, so this revision keeps them refused. Emulation only (harness=full), not yet replayed on hardware: no DIMENSION 22 frame was sent to a physical gateway, and the live F461 supervisor's reboot is not modelled, so this PR narrows the refusal to the evidence and nothing more.

Follows up #136 and #148 (the "in any spelling ... Linux IP gateways" wording).

Changes

  • OWNd/message/heating.py: _WHO4_DIMENSION_22_REGEX unchanged (every spelling, atoi-tolerant, anchored per ## chunk); new _WHO4_DIMENSION_22_SENDABLE_REGEX = ^\*#4\*3#[1-9][0-9]?#[1-9](?:\*22|\*#22\*[0-5]?\*(?:[0-9]{3,4})?\*[0-4]?\*[01]?)\Z (re.ASCII) and is_who4_dimension_22_crash_form(frame): a chunk the detector matches that the sendable pattern does not. Both arms end in \Z (review item 1: the write arm used to stop after *#22*, so short, long, whitespace, newline, smuggled-frame and Unicode-digit payloads passed). Z is 1..99 (item 2: Z 0 is bt_termo's general-zone sentinel and OWN-DEV-0136 gives ZAZB 01..99; 3#0#1 / 0#1 were not replayed). The prefix-less Z#N forms are refused (item 3: nack, silent on eleven gateways, MH202 only forwards them; nothing is lost on a bt_termo gateway). Allowed: 3#Z#N as *22, or *#22* with exactly four fields, MOD 0..5, SP three or four digits, VEL 0..4, SWING 0 or 1, each optionally empty as the touchscreen writes *#22*3**1*1 and *#22*0***. The oracle replayed Z 2 and 45 with N 1 and the write 1*0215*2*0; the published ranges are allowed on the grammar, and the comment says so.
  • OWNd/connection/command_session.py, OWNd/transport/serial.py, OWNd/transport/base.py: the three send paths call the crash-form detector; DESTRUCTIVE_FRAME_REASON names the WHERE condition. Contract, reason="destructive_frame", logging and send_to_gateway's refusal before connect() unchanged.
  • OWNd/message/__init__.py: exports is_who4_dimension_22_crash_form.
  • tests/test_who4_dimension22_guard.py: DIMENSION_22_FRAMES is the refused set (58 spellings: the four oracle crash forms, the untested ones, the prefix-less Z#N forms, 3#0#1, and the review's malformed writes: *#22*##, *#22*1##, *#22*1*0215##, five fields, out-of-range MOD / SP / VEL / SWING, the smuggled *#22*1#*#4*2*22##, +1, a trailing newline, \uff11); SPLIT_UNIT_FRAMES (10 canonical 3#Z#N forms incl. the touchscreen payloads and the range ends 3#1#1, 3#99#9, 5*040*4*1) asserted detector True / crash form False and written unchanged by OWNCommandSession.send (reads with is_status_request=True, writes with False), send_to_gateway (connects), AsyncTcpTransport.send (new positive case) and AsyncSerialTransport.send (all split forms plus a terminatorless *#4*3#2#1*22, written as *#4*3#2#1*22##); WHERE_HASH_4_BUILDERS uses master's #0#4 central-unit fixtures (fix(heating): set_central_mode requires a central-unit address #137).
  • CHANGELOG.md: the [Unreleased] / Fixed bullet rewritten to what is refused and what is sent, with the 3#Z#N vs Z#N oracle split and the emulation caveat.

State Diagram

stateDiagram-v2
    direction TB
    state "send paths: OWNCommandSession.send / send_to_gateway, AsyncSerialTransport.send" as Guard {
        [*] --> Chunk
        Chunk --> NotDim22 : _WHO4_DIMENSION_22_REGEX no match\n*#4*22##, *#13**22##, *#2*#4*#11#001*22##
        Chunk --> Sendable : canonical *#4*3#Z#N*22## or *#4*3#Z#N*#22*MOD*SP*VEL*SWING##\nZ 1..99, N 1..9, exactly four payload fields
        Chunk --> Refused : every other DIMENSION 22 spelling\n*#4*2*22##, *#4*0*22##, *#4*#0*22##, *#4*2*#22*...##, *#4*2#1*22##, *#4*3#0#1*22##, *#4*3#2#1*#22*1##, *#04*2#1*022##
        Refused --> [*] : OWNProtocolError reason destructive_frame, never written
        Sendable --> Validate : OWNMessage.parse / is_valid as any frame
        NotDim22 --> Validate
    }
    state "oracle thermo-dim22, 12 gateways x 2 runs" as Oracle {
        [*] --> NoToken
        NoToken --> Crash : *#4*2*22##, *#4*2*#22*1*0215*2*0##, *#4*#0*22##, *#4*0*22##\nbt_termo SIGSEGV on 9 of 12 (F461, MyHomeServer1 included)
        [*] --> Token
        Token --> Out : *#4*3#2#1*22##, *#4*3#2#1*#22*...## out on 10 of 12 (sent)\n*#4*2#1*22##, *#4*45#1*#22*3**1*1## silent on 11, out on MH202 (kept refused)
    }
Loading

Where are the traces

Subsystem Capture Plant Frames verified by this PR
WHO 4 DIMENSION 22, all forms own-firmware-oracle results/<P>/<V>/oracle/full/thermo-dim22.tsv (OpenWebNet-HA/own-firmware-oracle#68), emulated, two runs twelve emulated gateways the 11 forms of the suite; none sent to a physical gateway
Live F461 reboot on *#4*2*22## as recorded on #136 F461 2.0.11 the refused zone form; unchanged by this PR

What does the Oracle say

Frame crash out silent
*#4*2*22##, *#4*2*#22*1*0215*2*0##, *#4*#0*22##, *#4*0*22## F450, F453, F453AV, F454, F459, F460, F461, MH200N, MyHomeServer1 (9) none H4684, MH200, MH202
*#4*3#2#1*22##, *#4*3#2#1*#22*1*0215*2*0## none the 9 above plus MH202 (10) H4684, MH200
*#4*2#1*22##, *#4*2#1*#22*1*0215*2*0##, *#4*45#1*#22*3**1*1##, *#4*45#1*#22*4**1*1## none MH202 the other 11
*#4*45#1*#22*0***## none none all 12

tools/expected_divergences.yaml gains the zone write and the general read for F461 in the oracle PR. Not settled by the rows, and so still refused here: 0ZZ, #Z, #0#C, Z#4#IF, N 0, other payloads' effect on a real split unit.

Testing

  • pytest tests against this head (54b5944 on master afedabf): 2499 passed (-o asyncio_default_fixture_loop_scope=function locally, as CI sets since ci(pytest): set asyncio_default_fixture_loop_scope so pytest-asyncio 1.4.1 does not abort the suite #150).
  • ruff check OWNd tests clean; mypy OWNd clean.
  • Cross-check of the detector on 58 refused and 10 sendable spellings; the review's eight malformed-write cases and the earlier second-opinion cases are in the refused set.
  • MyHOME keeps its own _WHO4_DIMENSION_22 regex in services.py / websocket.py, which still blocks every form from the service and the websocket; it does not call OWNd's detector, so the conformance suite is unaffected. Narrowing it is MyHOME's call once this lands.

Revision 54b5944 (review 5724753021)

Rebased onto master afedabf (#137, #138, #142) as one re-authored commit. Item 1: both regex arms now end in \Z and the write arm spells the four published fields with their ranges, so the listed short / long / whitespace / newline / smuggled / Unicode payloads are refused (tests added). Item 2: Z restricted to 1..99 (3#0#1 refused and tested). Item 3: the prefix-less Z#N forms are refused again, and the code comments, docstring, tests and CHANGELOG separate 3#Z#N (10/12 out) from Z#N (11/12 nack, silent, MH202 only) and carry the emulation-only caveat. Item 4: WHERE_HASH_4_BUILDERS on master's #0#4 fixtures. Test notes: is_status_request follows the frame kind, the serial test runs every split form plus a terminatorless one, and a TCP-transport positive case mirrors the four entry points.

Companion

🤖 Generated with Claude Code

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

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

Summary

Nice separation between keeping is_who4_dimension_22() as the general atoi()-tolerant detector and routing the three send paths (OWNCommandSession.send_to_gateway, OWNCommandSession._locked_send, AsyncSerialTransport.send) through is_who4_dimension_22_crash_form(), while preserving pre-connect() fast-fail, single-serialization, reason="destructive_frame", and \Z anchoring on *22.

Requesting changes for a few safety/accuracy items before undrafting and merging:

  1. Unbounded write tail \*#22\* in _WHO4_DIMENSION_22_SENDABLE_REGEX (OWNd/message/heating.py L119–121): .match(chunk) stops immediately after *#22* with no payload pattern and no \Z anchor. Combined with OWNMessage._DIMENSION_WRITING ((?:\*\d*)+, compiled without re.ASCII), short/empty/over-wide writes (*#4*3#2#1*#22*##, *#4*3#2#1*#22*1##, *#4*3#2#1*#22*1*2*3*4*5*6##) and non-ASCII Unicode digits (*#4*3#2#1*#22*\uff11*0215*2*0##) pass both the guard and OWNMessage.parse and get written to the wire, and the public is_who4_dimension_22_crash_form() predicate returns False on #-concatenated crash frames (*#4*3#2#1*#22*1#*#4*2*22##) and trailing-newline/whitespace writes (*#4*2#1*#22*1*0215*2*0\n##).
  2. Unreplayed Z = 0 (0#N / 3#0#N) (OWNd/message/heating.py L120, SPLIT_UNIT_FRAMES): thermo-dim22 only replayed Z = 2 and Z = 45. In bt_termo, Z = 0 is the crashing general-zone sentinel (*#4*0*22## SIGSEGVs on all 9 Linux gateways), and OWN-DEV-0136 (IR Emitter Object 196) defines ZAZB = 01..99. Either restrict Z to [1-9][0-9]? (1..99) or replay 3#0#1 / 0#1 in own-firmware-oracle first.
  3. Oracle wording on prefix-less *#4*Z#N*22## (CHANGELOG.md, heating.py, test_who4_dimension22_guard.py, commit message): 10 of 12 gateways forward only 3#Z#N; prefix-less Z#N (*#4*2#1*22##, *#4*45#1*#22*3**1*1##, etc.) is nack, silent on 11 of 12 gateways (every bt_termo gateway drops it) and forwarded only by MH202 010024 (coso). Worth also confirming whether OWNd wants (?:3#)? optional or 3#Z#N only, and noting the findings/thermo-dim22.md emulation-only caveat.
  4. Rebase onto master (#137, #138, #142): heating.py and CHANGELOG.md currently conflict with master, and WHERE_HASH_4_BUILDERS in tests/test_who4_dimension22_guard.py needs master's "#0#4" central-unit fixtures from #137 ("#4" now raises ValueError on set_holiday_end_date / set_holiday_end_time / set_timed_manual_end_time).

Comment thread OWNd/message/heating.py Outdated
Comment on lines +86 to +88
# published split forms and the actuator form ``*#4*Z#N*22##`` did not crash
# any of the twelve emulated gateways: ten forward them to the bus as a
# telegram to zone Z, actuator N (``findings/thermo-dim22.md``).

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.

findings/thermo-dim22.md shows that 10 of 12 gateways forward only the 3#Z#N forms (*#4*3#2#1*22## and *#4*3#2#1*#22*1*0215*2*0##). The prefix-less Z#N forms (*#4*2#1*22##, *#4*2#1*#22*1*0215*2*0##, *#4*45#1*#22*3**1*1##, *#4*45#1*#22*4**1*1##) are nack, silent on 11 of 12 gateways (dropped by every bt_termo gateway) and forwarded only by MH202 010024 (coso), while *#4*45#1*#22*0***## is nack, silent on all 12.

Please separate 3#Z#N (10/12 out) from Z#N (11/12 nack, silent, MH202 out only) here and in the is_who4_dimension_22_crash_form docstring below — and consider whether prefix-less Z#N even needs to be opened unless MH202 / legacy touchscreen replay is an explicit goal. Worth also keeping a brief note that thermo-dim22 is emulated-firmware evidence (harness=full) and has not yet been replayed on physical hardware (findings/thermo-dim22.md).

Comment thread OWNd/message/heating.py
Comment on lines +119 to +121
_WHO4_DIMENSION_22_SENDABLE_REGEX = re.compile(
r"^\*#4\*(?:3#)?(?:0|[1-9][0-9]?)#[1-9](?:\*22\Z|\*#22\*)", re.ASCII
)

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.

Two safety issues in _WHO4_DIMENSION_22_SENDABLE_REGEX:

  1. Unbounded write tail \*#22\*: While the read arm anchors \*22\Z, the write arm stops right after *#22* under .match(chunk) with no payload pattern and no \Z anchor:

    • Untested arity / strtok risk on the wire: OWNMessage._DIMENSION_WRITING (OWNd/message/base.py) accepts (?:\*\d*)+ without checking field count, and OWNHeatingCommand has no dimension == 22 validation. As a result, "*#4*3#2#1*#22*##", "*#4*3#2#1*#22*1##", "*#4*3#2#1*#22*1*0215##", and "*#4*3#2#1*#22*1*2*3*4*5*6##" all return is_who4_dimension_22_crash_form(...) == False, parse with is_valid == True, and are written to the gateway. Every write replayed in thermo-dim22 had the 4-field MOD*SP*VEL*SWING layout; short writes risk atoi(NULL) SIGSEGV in bt_termo.
    • Non-ASCII \d bypass: Because OWNMessage._DIMENSION_WRITING is compiled without re.ASCII and .match(chunk) stops at *#22*, "*#4*3#2#1*#22*\uff11*0215*2*0##" passes both checks (is_valid == True) and writes UTF-8 \xef\xbc\x91 to the socket/serial port.
    • Public is_who4_dimension_22_crash_form() contract: Returns False on #-concatenated crash frames smuggled in a write payload ("*#4*3#2#1*#22*1#*#4*2*22##") and on write tails with trailing newlines, whitespace, or + ("*#4*2#1*#22*1*0215*2*0\n##", "*#4*3#2#1*#22* +1*0215*2*0##").
  2. Unreplayed Z = 0 (0#1 / 3#0#1): thermo-dim22 only replayed Z = 2 and Z = 45. On bt_termo, Z = 0 is the general-zone sentinel (*#4*0*22## SIGSEGVs on all 9 Linux gateways), and OWN-DEV-0136 (Object 196) restricts ZAZB to 01..99. Without an oracle row showing bt_termo routes 3#0#1 / 0#1 down the 0x80 + Z actuator branch rather than the Z == 0 branch, Z should stay [1-9][0-9]? (1..99) until 0#1 / 3#0#1 are replayed.

Suggested change
_WHO4_DIMENSION_22_SENDABLE_REGEX = re.compile(
r"^\*#4\*(?:3#)?(?:0|[1-9][0-9]?)#[1-9](?:\*22\Z|\*#22\*)", re.ASCII
)
_WHO4_DIMENSION_22_SENDABLE_REGEX = re.compile(
r"^\*#4\*(?:3#)?[1-9][0-9]?#[1-9]"
r"(?:\*22|\*#22\*[0-5]?\*(?:[0-9]{3,4})?\*[0-4]?\*[01]?)\Z",
re.ASCII,
)

Comment thread CHANGELOG.md Outdated
- `OWNTransport.send()` takes the same `raise_on_error` keyword as `OWNCommandSession.send()` on `AsyncTcpTransport` and `AsyncSerialTransport`, and `OWNTransport.last_error` holds the error recorded by the last `send()`: a local refusal on both transports, and on the TCP transport everything its command session records (NACK, timeout, connection errors). Both transports now share one contract: `RuntimeError` when not connected, then a frame refused locally is logged at ERROR, kept in `last_error` and answered with `None`, or raised as `OWNProtocolError` with `raise_on_error=True`.
### Fixed
- **WHO 4 Dimension 22 is never sent**: Dimension 22 is the published split-unit IR control (`*#4*3#Z#N*22##` / `*#4*3#Z#N*#22*MOD*SP*VEL*SWING##`, WHO 4 specification pp. 66-68); in any spelling (`*#4*ZONE*22##`, `*#4*#0*22##`, leading zeros, whitespace or `+` tokens) it crashes the thermoregulation daemon (`bt_termo`) on Linux IP gateways and reboots an F461 2.0.11 about three minutes later (own-firmware-oracle `tools/expected_divergences.yaml` crash frames, live F461 2.0.11). `OWNCommandSession.send` (and so `AsyncTcpTransport.send` and `keepalive`), and `AsyncSerialTransport.send` refuse it before anything is written, with the contract above and `reason="destructive_frame"`; `OWNCommandSession.send_to_gateway` refuses it before `connect()` (no socket, no handshake), logs it at ERROR and answers `None`, or raises `OWNProtocolError` with `raise_on_error=True` (the session it would have used is discarded, so no `last_error` is observable on that path). The detector `is_who4_dimension_22()` is public, accepts `bytes`, and is anchored to the first `*` of each frame, so a mid-frame WHERE `#4` / DIMENSION `#4` such as `*#2*#4*#11#001*22##` or `*#1*14*#4*1*22##` stays sendable. Parsing is unchanged: the frame can still be read from the bus.
- **WHO 4 Dimension 22 without an actuator token is never sent**: Dimension 22 is the published split-unit IR control (`*#4*3#Z#N*22##` / `*#4*3#Z#N*#22*MOD*SP*VEL*SWING##`, WHO 4 specification pp. 66-68). Sent to a zone (`*#4*Z*22##`, `*#4*0ZZ*22##`), to every zone (`*#4*0*22##`) or to the central unit (`*#4*#0*22##`), as a read or a write, it crashes the thermoregulation daemon (`bt_termo`) on nine of the twelve emulated Linux IP gateways and reboots an F461 2.0.11 about three minutes later (own-firmware-oracle `thermo-dim22` suite and `tools/expected_divergences.yaml` crash frames, live F461 2.0.11). The published split forms and the actuator form `*#4*Z#N*22##` crashed none of the twelve gateways (ten forward them to the bus as a telegram to zone Z, actuator N; own-firmware-oracle `findings/thermo-dim22.md`), so their canonical spellings (`Z` 0..99, `N` 1..9) are sent; every other spelling (leading zeros, whitespace or `+` tokens, `#Z`, `#0#C`, `Z#4#IF`, `N` 0 or two digits, a dimension parameter) stays refused. `OWNCommandSession.send` (and so `AsyncTcpTransport.send` and `keepalive`), and `AsyncSerialTransport.send` refuse a crash form before anything is written, with the contract above and `reason="destructive_frame"`; `OWNCommandSession.send_to_gateway` refuses it before `connect()` (no socket, no handshake), logs it at ERROR and answers `None`, or raises `OWNProtocolError` with `raise_on_error=True` (the session it would have used is discarded, so no `last_error` is observable on that path). The detector `is_who4_dimension_22()` recognises every Dimension 22 spelling; the send paths refuse `is_who4_dimension_22_crash_form()`, both public, accepting `bytes`, and anchored to the first `*` of each frame, so a mid-frame WHERE `#4` / DIMENSION `#4` such as `*#2*#4*#11#001*22##` or `*#1*14*#4*1*22##` stays sendable. Parsing is unchanged: the frame can still be read from the bus.

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.

Same factual clarification as OWNd/message/heating.py: 10 of 12 gateways forward the 3#Z#N split forms, whereas prefix-less *#4*Z#N*22## / *#4*Z#N*#22*...## is nack, silent on 11 of 12 gateways and forwarded only by MH202 010024 (and Z / write payload wording should match whatever scope is settled in _WHO4_DIMENSION_22_SENDABLE_REGEX).

Comment thread tests/test_who4_dimension22_guard.py Outdated
# through, and the string written is the string given.
session, writer = _command_session()

assert await session.send(frame, is_status_request=True) is True

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.

SPLIT_UNIT_FRAMES includes *#22*... write commands, which callers send with is_status_request=False rather than True. Passing is_status_request="*#22*" not in frame exercises the command-ACK branch of OWNCommandSession._locked_send for writes as well as the status-request branch for reads.

Please also add regression cases to DIMENSION_22_FRAMES for malformed/unbounded *#22* writes:

  • "*#4*3#2#1*#22*##", "*#4*3#2#1*#22*1##", "*#4*3#2#1*#22*1*0215##", "*#4*3#2#1*#22*1*0215*2*0*9##"
  • "*#4*3#2#1*#22*1*0215*2*0\n##", "*#4*3#2#1*#22* +1*0215*2*0##"
  • "*#4*3#2#1*#22*\uff11*0215*2*0##"
  • "*#4*3#2#1*#22*1#*#4*2*22##"

Comment thread tests/test_who4_dimension22_guard.py Outdated
assert serial_writer.written == []


@pytest.mark.parametrize("frame", SPLIT_UNIT_FRAMES[:2])

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.

Minor test gaps here:

  1. Why slice SPLIT_UNIT_FRAMES[:2] instead of parametrizing over all SPLIT_UNIT_FRAMES?
  2. Adding a terminatorless split-unit case (e.g. "*#4*3#2#1*22") would directly verify that AsyncSerialTransport.send appends "##" before checking is_who4_dimension_22_crash_form(text) and writes b"*#4*3#2#1*22##".
  3. Adding one AsyncTcpTransport.send("*#4*3#2#1*22##") positive test would mirror the refusal tests across all four send entry points (OWNCommandSession.send, send_to_gateway, AsyncTcpTransport.send, AsyncSerialTransport.send).

…m 3#Z#N

The firmware oracle's thermo-dim22 suite (12 emulated gateways, two
byte-identical runs) crashes bt_termo only on the forms whose WHERE has no
actuator token: *michnovka#4*Z*22##, *michnovka#4*0*22##, *michnovka#4*#0*22## and the zone write.
The published split forms *michnovka#4*3#Z#N*22## / *michnovka#4*3#Z#N*OpenWebNet-HA#22*MOD*SP*VEL*SWING##
crashed none of the twelve and ten forward them to the bus as a telegram
to zone Z, actuator N; the prefix-less Z#N forms are nack, silent on
eleven and forwarded by the MH202 only, so they stay refused.

is_who4_dimension_22() keeps recognising every DIMENSION 22 spelling; the
send paths now refuse is_who4_dimension_22_crash_form(), which lets through
only the canonical 3#Z#N spelling (Z 1..99, N 1..9) as a request or as a
write with exactly the four published payload fields, anchored with \Z and
ASCII-only, and keeps every untested spelling refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gdluck
gdluck force-pushed the fix/who4-dimension-22-guard-crash-forms branch from b73e723 to 54b5944 Compare October 31, 2026 20:39
@gdluck

gdluck commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Revision 54b5944, one re-authored commit on master afedabf (review 5724753021):

  1. Write tail: both arms end in \Z and the write arm spells the four published fields, ^\*#4\*3#[1-9][0-9]?#[1-9](?:\*22|\*#22\*[0-5]?\*(?:[0-9]{3,4})?\*[0-4]?\*[01]?)\Z with re.ASCII; every payload you listed (*#22*##, *#22*1##, *#22*1*0215##, five fields, +1, trailing newline, \uff11, the smuggled *#22*1#*#4*2*22##) is refused and in DIMENSION_22_FRAMES, plus out-of-range MOD / SP / VEL / SWING.
  2. Z is [1-9][0-9]?; 3#0#1 refused and tested.
  3. Prefix-less Z#N refused again (silent on 11 of 12, MH202 only forwards it; nothing lost on a bt_termo gateway). Comments, docstring, tests, commit message and CHANGELOG now separate 3#Z#N (10/12 out) from Z#N and carry the emulated-firmware caveat.
  4. Rebase: onto afedabf (fix(heating): set_central_mode requires a central-unit address #137, fix(message): parse '#'-prefixed dimension 14 setpoint values #138, fix(profiles): drop WHO 16 from the F461 and MyHomeServer1 profiles #142); WHERE_HASH_4_BUILDERS uses the #0#4 fixtures.

Tests: is_status_request follows the frame kind, the serial test runs every split form plus a terminatorless *#4*3#2#1*22 (written as *#4*3#2#1*22##), and an AsyncTcpTransport.send positive case mirrors the four entry points. 2499 passed, ruff and mypy clean. Left as a draft until you say the scope (3#Z#N only) is what OWNd wants.

@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 on 54b5944 — all four review items are cleanly resolved (\Z-anchored re.ASCII 4-field write pattern + malformed/Unicode/smuggled regression cases, Z restricted to 1..99, 3#Z#N vs Z#N oracle breakdown with the harness=full emulation caveat, and clean rebase onto afedabf with full 4-entry-point test coverage).

Confirming on scope: 3#Z#N only is what OWNd wants. It matches the published WHO 4 specification (pp. 66–68), is the form forwarded by 10 of 12 gateways, and loses nothing on MH202 since MH202 emits byte-identical D1 telegrams for 3#Z#N and Z#N.

(Optional cosmetic nit if you touch the branch, totally fine as-is otherwise: DESTRUCTIVE_FRAME_REASON in OWNd/connection/command_session.py, OWNd/transport/base.py, the module docstring of tests/test_who4_dimension22_guard.py, and the PR title still carry v1's "without an actuator token in WHERE" wording rather than the narrowed 3#Z#N split-form scope.)

Feel free to mark ready for review whenever you'd like this merged!

@GreenGrassBlueOcean
GreenGrassBlueOcean marked this pull request as ready for review October 31, 2026 22:07
@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit 87d6a88 into OpenWebNet-HA:master Oct 31, 2026
8 checks passed
GreenGrassBlueOcean pushed a commit that referenced this pull request Nov 2, 2026
OWNd/__init__.py: __version__ = "2.0.0b12". CHANGELOG.md: [Unreleased]
promoted to [2.0.0b12] - 2026-11-01 (every PR merged since v2.0.0-b11:
#134, #135, #136, #137, #138, #139, #140, #142, #145, #147, #148, #150,
#151) with a fresh empty [Unreleased] header.

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