Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ All notable changes to `OWNd` are documented in this file.
### Changed
- `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 is sent only in its published split form**: 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 `3#Z#N` crashed none of the twelve emulated gateways and ten forward them to the bus as a telegram to zone Z, actuator N (own-firmware-oracle `findings/thermo-dim22.md`; emulated firmware, not yet replayed on hardware), so their canonical spelling is sent: `Z` 1..99, `N` 1..9, a request (`*22`) or a write with exactly the four payload fields (`MOD` 0..5, `SP` three or four digits, `VEL` 0..4, `SWING` 0 or 1, each optionally empty as the touchscreen writes them). Everything else stays refused: the prefix-less `*#4*Z#N*22##` (`nack, silent` on eleven gateways, forwarded by the MH202 only), `Z` 0, leading zeros, whitespace, `+` or newline tokens, non-ASCII digits, `#Z`, `#0#C`, `Z#4#IF`, `N` 0 or two digits, a parameter after the request, a write with fewer or more than four fields or out-of-range values. `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.
- **`AsyncSerialTransport.send` validates outbound frames** like `OWNCommandSession.send`: an invalid or signaling frame (for example the single-`#` concatenation `*1*1*11#*#4*2*22##`) is refused with `reason="invalid_frame"` instead of being written to the port. The frame is serialised once, terminator included: the string checked is the string written.
- **WHO 4 central-unit builders (`OWNHeatingCommand.set_central_mode`, `set_central_temperature`, `set_central_program`, `set_central_scenario`, `set_central_weekend`, `set_central_holiday`, `set_holiday_end_date`, `set_holiday_end_time`, `set_timed_manual_end_time`)**: one shared `_validate_central_where` guard raises `ValueError` unless `where` is the central unit itself: `#0` (3550, 99 zones) or `#0#C` with `C` the 4695 unit's configured zone 1..99 or `01`..`99` (the forms MyHOME and the golden corpus use; whitespace is stripped, the stripped value is quoted). A bare zone (`*4*3101*1##`, `*#4*1*#30*...##` used to be built), a zone through the central unit (`#Z`, `#Z#C`: libqtdevices `ControlledProbeDevice` addresses, bus command byte `C2`, built by `set_mode`; the error names it), `#00`, `#0#0`, `#0#00` and three-digit values are refused. The plain zone form `*4*311*Z##` is `nack`/`silent` on all 12 emulated gateways (own-firmware-oracle `thermo-zone-mode`, `ownd-pr82-heating`) and on a live F461 2.0.11.
- **WHO 4 (`OWNHeatingEvent`)**: a Dimension 14 setpoint reply with a `#`-prefixed value (`*#4*#1*14*#0230*1##`, `MyHOME#139`) parses as a 23.0 °C target temperature instead of `None`; `dimension_value` keeps `#0230`.
Expand Down
19 changes: 10 additions & 9 deletions OWNd/connection/command_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,19 @@
OWNProtocolError,
OWNTimeoutError,
)
from ..message import OWNMessage, OWNSignaling, is_who4_dimension_22
from ..message import OWNMessage, OWNSignaling, is_who4_dimension_22_crash_form

# Why every send path refuses WHO 4 dimension 22 (see
# ``OWNd.message.is_who4_dimension_22``): the gateway never gets the frame.
# Why every send path refuses a WHO 4 dimension 22 frame without an actuator
# token (see ``OWNd.message.is_who4_dimension_22_crash_form``): the gateway
# never gets the frame.
DESTRUCTIVE_FRAME_REASON = (
"WHO 4 dimension 22 crashes the thermoregulation daemon (bt_termo) "
"on Linux IP gateways"
"WHO 4 dimension 22 without an actuator token in WHERE crashes the "
"thermoregulation daemon (bt_termo) on Linux IP gateways"
)


def destructive_frame_error(frame: str) -> OWNProtocolError:
"""The error every send path raises or records for a WHO 4 dimension 22 frame."""
"""The error every send path raises or records for a crashing WHO 4 dimension 22 frame."""
return OWNProtocolError(
f"Refusing to send `{frame}`: {DESTRUCTIVE_FRAME_REASON}",
frame=frame,
Expand Down Expand Up @@ -90,7 +91,7 @@ async def send_to_gateway(
# on a single-session gateway (MH200N, F453AV...) and no 5-attempt
# backoff on an unreachable host for a frame that is never written.
frame = str(message)
if is_who4_dimension_22(frame):
if is_who4_dimension_22_crash_form(frame):
connection._refuse_destructive_frame(
frame, message, raise_on_error=raise_on_error
)
Expand Down Expand Up @@ -248,7 +249,7 @@ async def send(
def _refuse_destructive_frame(
self, frame: str, message: str | OWNMessage, *, raise_on_error: bool
) -> None:
"""Record and log the refusal of a WHO 4 dimension 22 frame, never written.
"""Record and log the refusal of a crashing WHO 4 dimension 22 frame, never written.

``frame`` is the serialised string the error carries; ``message`` is
the caller's object, passed to the logger like every other send log
Expand Down Expand Up @@ -276,7 +277,7 @@ async def _locked_send(
# Serialised once: the frame checked is the frame parsed, raised and
# written. Log lines keep the caller's object (formatted lazily).
frame = str(message)
if is_who4_dimension_22(frame):
if is_who4_dimension_22_crash_form(frame):
self._refuse_destructive_frame(frame, message, raise_on_error=raise_on_error)
return None
msg_obj = (
Expand Down
2 changes: 2 additions & 0 deletions OWNd/message/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
_zone_state,
_zone_state_text,
is_who4_dimension_22,
is_who4_dimension_22_crash_form,
who4_temperature,
OWNHeatingCommand,
OWNHeatingEvent,
Expand Down Expand Up @@ -318,6 +319,7 @@
"_zone_state",
"_zone_state_text",
"is_who4_dimension_22",
"is_who4_dimension_22_crash_form",
"OWNHeatingEvent",
"OWNHeatingCommand",
# Burglar Alarm (WHO 5)
Expand Down
86 changes: 76 additions & 10 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,27 @@
# WHO 4 DIMENSION 22 is the published split-unit (air conditioning) IR
# control: ``*#4*3#Z#N*22##`` requests the current setting and
# ``*#4*3#Z#N*#22*MOD*SP*VEL*SWING##`` writes it (WHO 4 specification,
# pp. 66-68). On Linux IP gateways the frame kills the thermoregulation
# daemon (bt_termo) and the gateway reboots about 3 minutes later (firmware
# oracle crash_frames; live F461 2.0.11). Every send path refuses it, in any
# shape: request, write (``*#22*``) or reply, with or without a dimension
# parameter, and in any ``*#4*ZONE*22##`` spelling.
# pp. 66-68). Sent with a WHERE that carries no actuator token (a zone
# ``*#4*Z*22##``, the general ``*#4*0*22##`` or the central unit
# ``*#4*#0*22##``, read or write) the frame kills the thermoregulation
# daemon (bt_termo) on the Linux IP gateways and a live F461 2.0.11 reboots
# about 3 minutes later (own-firmware-oracle ``thermo-dim22`` suite and
# ``tools/expected_divergences.yaml`` crash frames; live F461 2.0.11).
# The published split forms ``3#Z#N`` crashed none of the twelve emulated
# gateways: 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 (``findings/thermo-dim22.md``;
# emulated firmware, ``harness=full``, not yet replayed on hardware).
#
# ``is_who4_dimension_22`` recognises every DIMENSION 22 spelling (the
# detector of OWNd 2.0.0b11); ``is_who4_dimension_22_crash_form`` is what the
# send paths refuse: every spelling except the split forms the oracle
# replayed without a crash. Untested spellings (``0ZZ`` all probes of a zone,
# ``#Z`` a zone through the central unit, ``#0#C``, ``Z#4#IF``, ``Z`` 0 (the
# general-zone sentinel bt_termo crashes on), two-digit actuator numbers,
# whitespace or ``+`` tokens, writes with fewer or more than four payload
# fields) stay refused: the allowance is the tested forms, not the
# complement of the crash set.
#
# Anchored to the first ``*`` of each ``##``-delimited frame: ``*#4*`` means
# WHO 4 only there. Mid-frame, ``*#4*`` is WHERE ``#4`` (group 4 in WHO 1, 2,
Expand All @@ -94,21 +110,71 @@
r"^[^*]*\*#\s*\+?0*4\s*\*[^*]*\*\s*#?\s*\+?0*22(?:\s*$|[#*\s])"
)

# The frames the oracle forwarded without a crash, exactly as sent: WHO ``4``
# and DIMENSION ``22`` spelled canonically, WHERE the published split form
# ``3#Z#N`` (``Z`` 1..99, ``N`` 1..9, no leading zeros, no whitespace), as a
# request (``*22`` and nothing after it) or a write with exactly the four
# published payload fields ``MOD*SP*VEL*SWING`` (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 sent ``Z`` 2 and 45
# with ``N`` 1 and the write ``1*0215*2*0``; the published ranges are
# allowed on the grammar, not on a replay of each value. ``\Z`` on both
# arms (``$`` also matches before a trailing newline); ASCII only, so a
# Unicode digit in the payload is no sendable frame. Anything else that
# ``_WHO4_DIMENSION_22_REGEX`` matches is a crash form.
_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 on lines +125 to +129

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,
)



def _decode_frame(frame: object) -> str:
return (
bytes(frame).decode("latin-1")
if isinstance(frame, (bytes, bytearray, memoryview))
else str(frame)
)


def is_who4_dimension_22(frame: object) -> bool:
"""True if ``frame`` (or any ``##``-delimited frame in it) is WHO 4 DIMENSION 22.

Every spelling, the actuator-addressed split forms included. The send
paths refuse the narrower :func:`is_who4_dimension_22_crash_form`.
``bytes``, ``bytearray`` and ``memoryview`` are decoded as latin-1 (the
wire encoding); anything else is checked through ``str()``.
"""
raw = (
bytes(frame).decode("latin-1")
if isinstance(frame, (bytes, bytearray, memoryview))
else str(frame)
)
raw = _decode_frame(frame)
return any(_WHO4_DIMENSION_22_REGEX.search(chunk) for chunk in raw.split("##"))


def is_who4_dimension_22_crash_form(frame: object) -> bool:
"""True if ``frame`` holds a WHO 4 DIMENSION 22 frame that every send path refuses.

That is every DIMENSION 22 spelling except the canonical published split
forms the firmware oracle forwarded without a crash on ten of twelve
emulated gateways: ``*#4*3#Z#N*22##`` and
``*#4*3#Z#N*#22*MOD*SP*VEL*SWING##`` with ``Z`` 1..99, ``N`` 1..9 and
exactly four payload fields (the oracle replayed ``Z`` 2 and 45, ``N`` 1;
the rest of the published ranges is allowed on the grammar). Refused: a
zone (``Z``, ``0ZZ``), the general ``0`` and ``Z`` 0, the central unit
(``#0``, ``#0#C``), a zone through it (``#Z``), an interface (``Z#4#IF``),
the prefix-less actuator form ``Z#N`` (``nack, silent`` on eleven
gateways, forwarded by the MH202 only), ``N`` 0 or two digits, a
parameter after the request (``*22*1``, ``*22#1``), a bare ``*#22``, a
write with fewer or more than four fields or out-of-range values, and
any leading-zero, whitespace, ``+``, newline or non-ASCII spelling.
Buffers are decoded like :func:`is_who4_dimension_22`.
"""
raw = _decode_frame(frame)
return any(
_WHO4_DIMENSION_22_REGEX.search(chunk)
and not _WHO4_DIMENSION_22_SENDABLE_REGEX.match(chunk)
for chunk in raw.split("##")
)


# A central-unit address: ``#0`` (3550 99-zone central unit) or ``#0#C`` (4695
# 4-zone central unit ``C``, 1..99 or ``01``..``99``: MyHOME_Suite OPEN.db
# ``#0#[ZA][ZB]``, MHCatalogue 4695 ``ZAZB_CENTRAL`` 1..99, oracle
Expand Down
5 changes: 3 additions & 2 deletions OWNd/transport/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ async def send(
"""Send one command and return its response.

Raises ``RuntimeError`` when the transport is not connected. A frame
that is refused locally (invalid, or WHO 4 dimension 22 which crashes
the gateway's thermoregulation daemon) is logged at ERROR, kept in
that is refused locally (invalid, or a WHO 4 dimension 22 frame without
an actuator token in WHERE, which crashes the gateway's
thermoregulation daemon) is logged at ERROR, kept in
``last_error`` and answered with ``None``; with ``raise_on_error`` it
raises ``OWNProtocolError`` (``reason`` ``invalid_frame`` or
``destructive_frame``) instead, like ``OWNCommandSession.send``.
Expand Down
6 changes: 3 additions & 3 deletions OWNd/transport/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
destructive_frame_error,
)
from ..exceptions import OWNError, OWNProtocolError
from ..message import OWNMessage, OWNSignaling, is_who4_dimension_22
from ..message import OWNMessage, OWNSignaling, is_who4_dimension_22_crash_form
from .base import OWNTransport

SEPARATOR = b"##"
Expand Down Expand Up @@ -163,8 +163,8 @@ async def send(
text = str(message)
if not text.endswith("##"):
text += "##"
if is_who4_dimension_22(text):
# Never written, whatever the caller: it reboots the gateway.
if is_who4_dimension_22_crash_form(text):
# Never written, whatever the caller: it crashes bt_termo.
err: OWNError = destructive_frame_error(text)
self._last_error = err
self._logger.error(
Expand Down
Loading
Loading