diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7778d..c35b874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/OWNd/connection/command_session.py b/OWNd/connection/command_session.py index 7cff5d5..83d3ca3 100644 --- a/OWNd/connection/command_session.py +++ b/OWNd/connection/command_session.py @@ -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, @@ -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 ) @@ -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 @@ -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 = ( diff --git a/OWNd/message/__init__.py b/OWNd/message/__init__.py index 5ccf1bb..ed32610 100644 --- a/OWNd/message/__init__.py +++ b/OWNd/message/__init__.py @@ -172,6 +172,7 @@ _zone_state, _zone_state_text, is_who4_dimension_22, + is_who4_dimension_22_crash_form, who4_temperature, OWNHeatingCommand, OWNHeatingEvent, @@ -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) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 6bf0aa3..d87fed8 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -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, @@ -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, +) + + +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 diff --git a/OWNd/transport/base.py b/OWNd/transport/base.py index cae1a48..073f59b 100644 --- a/OWNd/transport/base.py +++ b/OWNd/transport/base.py @@ -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``. diff --git a/OWNd/transport/serial.py b/OWNd/transport/serial.py index b962427..bf10462 100644 --- a/OWNd/transport/serial.py +++ b/OWNd/transport/serial.py @@ -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"##" @@ -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( diff --git a/tests/test_who4_dimension22_guard.py b/tests/test_who4_dimension22_guard.py index d0a2565..df188b3 100644 --- a/tests/test_who4_dimension22_guard.py +++ b/tests/test_who4_dimension22_guard.py @@ -1,9 +1,16 @@ -"""WHO 4 DIMENSION 22 never leaves OWNd. - -``*#4*ZONE*22##`` kills the gateway's thermoregulation daemon (bt_termo) and -reboots an F461 about 3 minutes later (firmware oracle crash_frames; live F461 -2.0.11). The frames below are test inputs only: every send path must refuse -them before anything is written. +"""WHO 4 DIMENSION 22 without an actuator token never leaves OWNd. + +``*#4*ZONE*22##``, ``*#4*0*22##`` and ``*#4*#0*22##`` (read or write) kill the +gateway's thermoregulation daemon (bt_termo) on nine of the twelve emulated +gateways and reboot an F461 about 3 minutes later (own-firmware-oracle +``thermo-dim22`` suite and ``expected_divergences.yaml`` crash frames; live +F461 2.0.11). The published split forms ``*#4*3#Z#N*22##`` crashed none of +them (ten forward them to the bus; the prefix-less ``Z#N`` forms are +``nack, silent`` on eleven and forwarded by the MH202 only, so they stay +refused), so the canonical ``3#Z#N`` spellings with the four-field write +payload are sent; every other spelling stays refused. Emulated firmware, +not yet replayed on hardware. The frames below are test inputs only: +nothing here reaches a gateway. """ from __future__ import annotations @@ -26,27 +33,43 @@ OWNSoundCommand, OWNSoundDiffusionCommand, is_who4_dimension_22, + is_who4_dimension_22_crash_form, ) from OWNd.transport.serial import AsyncSerialTransport from OWNd.transport.tcp import AsyncTcpTransport +# Refused by every send path: WHERE without an actuator token (the four +# oracle crash forms and their spellings), and every DIMENSION 22 form the +# oracle did not replay. DIMENSION_22_FRAMES = ( "*#4*2*22##", "*#4*#0*22##", "*#4*#1*22##", - "*#4*2#1*22##", + "*#4*0*22##", + "*#4*001*22##", "*#4**22##", "*#4*2*22#1##", "*#4*2*#22*1##", + "*#4*2*#22*1*0215*2*0##", "*#4*2*22*1##", "*#04*2*022##", "*1*1*11##*#4*2*22##", - # Published split-unit forms (WHO 4 specification, pp. 66-68). - "*#4*3#2#1*22##", - "*#4*3#2#1*#22*0***##", - "*#4*45#1*#22*3**1*1##", + # Not replayed: central unit with a 4-zone selector, an interface, all + # actuators of a zone (``N`` 0), two-digit or zero-padded tokens, a + # dimension parameter after 22, and the all-zones actuator form. + "*#4*#0#1*22##", + "*#4*2#4#01*22##", + "*#4*2#0*22##", + "*#4*3#2#0*22##", + "*#4*02#1*22##", + "*#4*2#10*22##", + "*#4*2#1*22#1##", + "*#4*2#1*22*1##", + "*#4*2#1*#22##", + "*#4*0#0*22##", # Tokens the gateway's atoi() still reads as 4 / 22: whitespace, sign, - # missing terminator (the serial transport appends ``##``). + # missing terminator (the serial transport appends ``##``), also with an + # actuator token in WHERE (the oracle sent the canonical spelling only). "*#4*2*22 ", "*#4*2*22 ##", "*#4*2* 22##", @@ -54,6 +77,60 @@ "*# 4*2*22##", "*#+4*2*22##", "*#4*2*22", + "*#4* 2#1*22##", + "*#04*2#1*022##", + "*#4*2#1* 22##", + "*#4*2#1*+22##", + "*#4*2#1*22 ", + "*#4*2#1*22\n", + "*#4*2#1*22\n##", + # Prefix-less actuator forms: ``nack, silent`` on eleven gateways and + # forwarded by the MH202 only, so not opened (findings/thermo-dim22.md). + "*#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##", + "*#4*45#1*#22*0***##", + "*#4*0#1*22##", + "*#4*99#9*22##", + # Zone 0 with the split prefix: bt_termo's general-zone sentinel, not + # replayed. + "*#4*3#0#1*22##", + # Writes with the wrong arity or out-of-range fields (every replayed + # write had MOD*SP*VEL*SWING), a smuggled crash frame, whitespace, + # a trailing newline and a Unicode digit in the payload. + "*#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*6*0215*2*0##", + "*#4*3#2#1*#22*1*02150*2*0##", + "*#4*3#2#1*#22*1*0215*5*0##", + "*#4*3#2#1*#22*1*0215*2*2##", + "*#4*3#2#1*#22*1#*#4*2*22##", + "*#4*3#2#1*#22* +1*0215*2*0##", + "*#4*3#2#1*#22*1*0215*2*0\n##", + "*#4*3#2#1*#22*\uff11*0215*2*0##", + "*#4*3#2#1*22*1##", + "*#4*3#2#1*22#1##", +) + +# Sent: the canonical published split forms the oracle forwarded without a +# crash on ten of twelve emulated gateways (WHO 4 specification pp. 66-68; +# ``findings/thermo-dim22.md``, emulated firmware, not yet replayed on +# hardware): ``3#Z#N`` with ``Z`` 1..99, ``N`` 1..9, a request or a write +# with exactly the four payload fields, each optionally empty. +SPLIT_UNIT_FRAMES = ( + "*#4*3#2#1*22##", + "*#4*3#2#1*#22*1*0215*2*0##", + "*#4*3#2#1*#22*0***##", + "*#4*3#2#1*#22*3**1*1##", + "*#4*3#45#1*#22*3**1*1##", + "*#4*3#45#1*#22*4**1*1##", + "*#4*3#45#1*#22*0***##", + "*#4*3#1#1*22##", + "*#4*3#99#9*22##", + "*#4*3#99#9*#22*5*040*4*1##", ) # Zone 22, other dimensions, dimension 22 of other WHOs, and mid-frame @@ -144,13 +221,21 @@ def _command_session() -> tuple[OWNCommandSession, _Writer]: @pytest.mark.parametrize("frame", DIMENSION_22_FRAMES) -def test_detector_flags_every_dimension_22_form(frame: str) -> None: +def test_detector_flags_every_crash_form(frame: str) -> None: + assert is_who4_dimension_22(frame) is True + assert is_who4_dimension_22_crash_form(frame) is True + + +@pytest.mark.parametrize("frame", SPLIT_UNIT_FRAMES) +def test_detector_recognises_split_forms_but_does_not_refuse_them(frame: str) -> None: assert is_who4_dimension_22(frame) is True + assert is_who4_dimension_22_crash_form(frame) is False @pytest.mark.parametrize("frame", SAFE_FRAMES) def test_detector_leaves_other_frames_alone(frame: str) -> None: assert is_who4_dimension_22(frame) is False + assert is_who4_dimension_22_crash_form(frame) is False @pytest.mark.parametrize( @@ -160,7 +245,14 @@ def test_detector_leaves_other_frames_alone(frame: str) -> None: ) def test_detector_decodes_buffers(frame: object) -> None: assert is_who4_dimension_22(frame) is True + assert is_who4_dimension_22_crash_form(frame) is True assert is_who4_dimension_22(b"*#4*#4*#30*22*10*2026##") is False + assert is_who4_dimension_22_crash_form(b"*#4*3#2#1*22##") is False + assert is_who4_dimension_22(memoryview(b"*#4*3#2#1*22##")) is True + # Chunked: a split form after another frame is still not a crash form + # (the send paths refuse the double frame as invalid instead). + assert is_who4_dimension_22_crash_form("*1*1*11##*#4*3#2#1*22##") is False + assert is_who4_dimension_22_crash_form("*#4*3#2#1*22##*#4*2*22##") is True @pytest.mark.parametrize( @@ -175,6 +267,7 @@ def test_detector_leaves_where_hash_4_builders_alone( assert str(command) == expected assert command.is_valid assert is_who4_dimension_22(command) is False + assert is_who4_dimension_22_crash_form(command) is False assert is_who4_dimension_22(str(command)) is False @@ -214,7 +307,7 @@ async def test_command_session_refuses_raw_string(frame: str) -> None: [ OWNCommand.parse("*#4*2*22##"), OWNMessage.parse("*#4*#0*22##"), - OWNHeatingCommand("*#4*2#1*22##"), + OWNHeatingCommand("*#4*2*#22*1*0215*2*0##"), ], ids=["OWNCommand.parse", "OWNMessage.parse", "OWNHeatingCommand"], ) @@ -321,7 +414,7 @@ async def test_command_session_refuses_before_reconnecting() -> None: @pytest.mark.parametrize( "message", - ["*#4*2*22##", OWNHeatingCommand("*#4*3#2#1*22##")], + ["*#4*2*22##", OWNHeatingCommand("*#4*0*22##")], ids=["str", "OWNHeatingCommand"], ) @pytest.mark.asyncio @@ -376,6 +469,41 @@ async def test_command_session_still_sends_zone_22(frame: str) -> None: assert writer.written == [frame.encode()] +@pytest.mark.parametrize("frame", SPLIT_UNIT_FRAMES) +@pytest.mark.asyncio +async def test_command_session_sends_split_unit_forms(frame: str) -> None: + # The parser accepts them (``OWNHeatingCommand``), the guard lets them + # through, and the string written is the string given. + session, writer = _command_session() + + # Reads go through the status-request branch, writes through the + # command-ACK branch of ``_locked_send``, as callers send them. + is_status_request = "*#22*" not in frame + assert await session.send(frame, is_status_request=is_status_request) is True + assert writer.written == [frame.encode()] + assert session.last_error is None + + +@pytest.mark.asyncio +async def test_send_to_gateway_connects_for_split_unit_forms() -> None: + gateway = OWNGateway({"address": "192.0.2.1", "port": 20000, "password": "12345"}) + + with ( + patch.object( + OWNCommandSession, + "connect", + new_callable=AsyncMock, + return_value={"Success": True}, + ) as connect, + patch.object( + OWNCommandSession, "_locked_send", new_callable=AsyncMock, return_value=True + ) as send, + ): + assert await OWNCommandSession.send_to_gateway("*#4*3#2#1*22##", gateway) is True + connect.assert_awaited_once() + send.assert_awaited_once_with("*#4*3#2#1*22##", False, raise_on_error=False) + + def _serial_transport() -> tuple[AsyncSerialTransport, _Writer]: transport = AsyncSerialTransport(port="/dev/null") writer = _Writer() @@ -439,7 +567,7 @@ async def test_serial_transport_refuses_built_message() -> None: transport, writer = _serial_transport() with pytest.raises(OWNProtocolError, match="dimension 22"): - await transport.send(OWNHeatingCommand("*#4*2#1*22##"), raise_on_error=True) + await transport.send(OWNHeatingCommand("*#4*#0*22##"), raise_on_error=True) assert writer.written == [] @@ -493,6 +621,40 @@ async def test_atoi_prefix_spellings_are_refused_as_invalid(frame: str) -> None: assert serial_writer.written == [] +@pytest.mark.asyncio +async def test_tcp_transport_sends_split_unit_form() -> None: + # The fourth send entry point: the TCP transport hands the frame to its + # command session, which writes it. + gateway = OWNGateway({"address": "192.0.2.1", "port": 20000, "password": "12345"}) + transport = AsyncTcpTransport(gateway=gateway) + session, writer = _command_session() + transport._command_session = session + + assert await transport.send("*#4*3#2#1*22##", is_status_request=True) is True + assert writer.written == [b"*#4*3#2#1*22##"] + assert transport.last_error is None + + +@pytest.mark.parametrize("frame", [*SPLIT_UNIT_FRAMES, "*#4*3#2#1*22"]) +@pytest.mark.asyncio +async def test_serial_transport_sends_split_unit_forms(frame: str) -> None: + # Every split form, plus one without a terminator: the serial transport + # appends ``##`` before the guard runs and writes the terminated frame. + transport, writer = _serial_transport() + + async def ack() -> None: + await asyncio.sleep(0) + assert transport._pending_future is not None + transport._pending_future.set_result(True) + + task = asyncio.create_task(ack()) + assert await transport.send(frame, raise_on_error=True) is True + await task + assert transport.last_error is None + expected = frame if frame.endswith("##") else frame + "##" + assert writer.written == [expected.encode()] + + @pytest.mark.asyncio async def test_serial_transport_not_connected_comes_first() -> None: # Same ordering as AsyncTcpTransport: an unconnected transport is a