From 9bd27fca8fcead659a0a184eefdf8cb8df8c922a Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 18:59:18 +0200 Subject: [PATCH 1/2] fix(heating): bound the 4-zone central form in actuators_status; CHANGELOG wording for send_to_gateway Follow-up to #140 and #136 found by an adversarial read of both merged PRs. - `_WHO4_WHERE_REGEX` accepted `#0#0` and `#0#5..#0#9` although a 4-zone central unit has zones `#0#1..#0#4` (the WHO 4 WHERE table, and what the #140 review listed as valid). The alternation now spells the three address families out: `#0` / `#0#1..4`, `#Z` / `#Z#N` with Z 1..99, and plain `Z` / `Z#N` (a leading zero on a plain zone is kept, the parser reads it). `#01` and `#00` are refused with the central forms. Tests add the refused rows and `#99#9` / `01` as accepted. - The #136 CHANGELOG entry said `send_to_gateway` refuses dimension 22 "with the contract above", which includes "kept in `last_error`"; the refusal is recorded on the throwaway session that method creates and discards, so nothing is observable there besides the ERROR log and the `None` (or the raise with `raise_on_error=True`). The entry now says that. Suite 2111 passed on master 247f86e; MyHOME v2-phase1-architecture f4a5a907 suite 4991 passed against this head. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++-- OWNd/message/heating.py | 33 ++++++++++++++++++----------- tests/test_who4_actuators_status.py | 8 +++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 949a52f..f5c08d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ All notable changes to `OWNd` are documented in this file. - Automatic detection of legacy pre-`09W29` BTicino `F414` coarse-only dimmers via `DIMENSION 1` / `DIMENSION 4` transition speed `240` (`0xF0`, `DIMMER_SPEED_COARSE_ONLY`) with multi-gateway plant isolation (`gateway=` scoping on `OWNMessage.parse`, `OWNEvent.parse`, `OWNEventSession.get_next`, `is_coarse_dimmer_where`, `register_coarse_dimmer_where`, `clear_coarse_dimmer_wheres`, and `OWNLightingCommand.set_brightness`) (`#134`, `MyHOME#767`). - Canonical `libqtdevices` (`TS10_1_0_23`) coarse `WHAT 2..10` $\leftrightarrow$ fine `1..100%` conversion helpers (`COARSE_PRESET_TO_PERCENT`, `brightness_preset_to_percent`, `percent_to_brightness_preset`), `OWNLightingEvent.is_coarse_dimmer`, `OWNLightingEvent.brightness_preset_percent`, and `OWNLightingCommand.set_brightness(..., coarse=..., gateway=...)` (`#134`). - `WHAT 39` end of movement (`*1*39*WHERE##`) from WHO 1 motion detectors: `message_type` `motion_detected` with `motion` `False`, new `OWNLightingEvent.motion_end`, `is_sensor` `True`, `is_on` `None`. It was `unknown_state` `39` before (`MyHOME#777`). -- **WHO 4 (`OWNHeatingCommand.actuators_status`)**: Actuator status request `*#4*WHERE*20##` for a zone (`0..99`, `#Z`), one actuator (`2#1`; `N` is one digit, `2#0` for all actuators of the zone) or `#0` / `#0#N` (central unit); three-digit secondary probes (`105`) and multi-digit actuator indices (`2#10`) are refused. A live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). The address may carry a bus-interface suffix (`3#4#01`, `#3#4#01`, `2#1#4#01`; two-digit interface) as spec-legal grammar, but callers must gate it on the profile's `supports_interface_zone_requests` (`MyHOME#816`): own-firmware-oracle#65 shows no emulated firmware routes `*#4*2#4#01*20##` across an F422; F450, F454, F459, F460, F461 and MyHomeServer1 truncate `WHERE` at the first `#` and poll local zone 2, MH202 010024 reads `#4#01` as local zone 4 actuator 1, and F453, F453AV, H4684, MH200, MH200N drop it. The MH202 010024 crash in that suite hits the dimensionless status forms `*#4*2#1##` and `*#4*2#4#01##`, which the builder never emits (`MyHOME#792` review). +- **WHO 4 (`OWNHeatingCommand.actuators_status`)**: Actuator status request `*#4*WHERE*20##` for a zone (`0..99`, `#Z`), one actuator (`2#1`; `N` is one digit, `2#0` for all actuators of the zone) or `#0` / `#0#N` (central unit); three-digit secondary probes (`105`), multi-digit actuator indices (`2#10`) and 4-zone central zones outside 1..4 (`#0#5`, `#0#0`) are refused. A live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). The address may carry a bus-interface suffix (`3#4#01`, `#3#4#01`, `2#1#4#01`; two-digit interface) as spec-legal grammar, but callers must gate it on the profile's `supports_interface_zone_requests` (`MyHOME#816`): own-firmware-oracle#65 shows no emulated firmware routes `*#4*2#4#01*20##` across an F422; F450, F454, F459, F460, F461 and MyHomeServer1 truncate `WHERE` at the first `#` and poll local zone 2, MH202 010024 reads `#4#01` as local zone 4 actuator 1, and F453, F453AV, H4684, MH200, MH200N drop it. The MH202 010024 crash in that suite hits the dimensionless status forms `*#4*2#1##` and `*#4*2#4#01##`, which the builder never emits (`MyHOME#792` review). ### 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`), `OWNCommandSession.send_to_gateway` (before `connect()`: no socket, no handshake) and `AsyncSerialTransport.send` refuse it before anything is written, with the contract above and `reason="destructive_frame"`. 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 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`), `AsyncSerialTransport.send` refuses 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. - **`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. ## [2.0.0b11] - 2026-10-20 diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index e7890cc..2efa323 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -110,16 +110,24 @@ def is_who4_dimension_22(frame: object) -> bool: # WHO 4 WHERE for an actuator status request: a zone of one or two digits -# (``0..99``: ``Z``, ``#Z``, ``0`` / ``#0`` for every zone or the central -# unit), optionally one actuator digit (``Z#N``, ``#0#N``, ``#Z#N``; ``N`` is -# ``1..9``, ``0`` for all actuators of the zone: ``2#0``, ``0#0``), optionally -# a bus-interface suffix of exactly two digits (``#4#NN``: ``3#4#01``, -# ``#3#4#01``, ``2#1#4#01``). Three-digit addresses are secondary probes -# (``PZZ``, ``101..899``: no actuators, NACK on an F461) and the actuator -# index is one digit in the WHO 4 WHERE table, so both are refused the way -# ``get_fan_speed`` bounds its zone to ``1..99``. ASCII digits only; no ``*``, so no dimension can be -# injected through the address. -_WHO4_WHERE_REGEX = re.compile(r"#?[0-9]{1,2}(?:#[0-9])?(?:#4#[0-9]{2})?", re.ASCII) +# (``0..99``: ``Z``, ``#Z`` with ``Z`` 1..99, ``0`` for every zone), the +# central unit (``#0``, or ``#0#N`` with ``N`` 1..4 for a zone of a 4-zone +# central unit), optionally one actuator digit on a zone (``Z#N``, ``#Z#N``; +# ``N`` is ``1..9``, ``0`` for all actuators of the zone: ``2#0``, ``0#0``), +# optionally a bus-interface suffix of exactly two digits (``#4#NN``: +# ``3#4#01``, ``#3#4#01``, ``2#1#4#01``). Three-digit addresses are +# secondary probes (``PZZ``, ``101..899``: no actuators, NACK on an F461), +# the actuator index is one digit in the WHO 4 WHERE table and a 4-zone +# central unit has zones 1..4, so ``105``, ``2#10``, ``#0#5`` and ``#0#0`` +# are refused the way ``get_fan_speed`` bounds its zone to ``1..99``. A +# leading zero on a plain zone (``01``) is kept, as the parser reads it. +# ASCII digits only; no ``*``, so no dimension can be injected through the +# address. +_WHO4_WHERE_REGEX = re.compile( + r"(?:#0(?:#[1-4])?|#[1-9][0-9]?(?:#[0-9])?|(?:[1-9][0-9]?|0[0-9]?)(?:#[0-9])?)" + r"(?:#4#[0-9]{2})?", + re.ASCII, +) def who4_temperature(raw: str) -> float | None: @@ -938,8 +946,9 @@ def actuators_status(cls, where: str | int) -> OWNHeatingCommand: ``*#4*35*20##`` with ``*#4*35#1*20*0##``. Whitespace around ``where`` is stripped. Anything else (``*``, an empty string, a three-digit secondary probe such as ``105``, a two-digit actuator such as - ``2#10``, a one- or three-digit interface such as ``3#4#1`` or - ``3#4#001``) raises ``ValueError``. The log line carries the whole + ``2#10``, a 4-zone central zone outside 1..4 such as ``#0#5``, a + one- or three-digit interface such as ``3#4#1`` or ``3#4#001``) + raises ``ValueError``. The log line carries the whole address as written (``2#1#4#01``), not only the zone the parser keeps in ``where``. """ diff --git a/tests/test_who4_actuators_status.py b/tests/test_who4_actuators_status.py index 41fc95f..12fe169 100644 --- a/tests/test_who4_actuators_status.py +++ b/tests/test_who4_actuators_status.py @@ -60,6 +60,8 @@ ("0", "*#4*0*20##"), ("99", "*#4*99*20##"), ("#0#4", "*#4*#0#4*20##"), + ("#99#9", "*#4*#99#9*20##"), + ("01", "*#4*01*20##"), # Bus-interface routed forms (``#4#NN``, two-digit interface): zone, # zone through the central unit, one actuator of a zone. ("3#4#01", "*#4*3#4#01*20##"), @@ -114,6 +116,12 @@ def test_actuators_status_builds_dimension_20(where: str | int, frame: str) -> N "99999", "#105", "105#4#01", + # A 4-zone central unit has zones 1..4; ``#0#0`` is not an address. + "#0#0", + "#0#5", + "#0#9", + "#01", + "#00", # The actuator index is one digit (``0..9``). "2#10", "2#999", From 4f82518e8552d98c469cdd896503ae674a89bfce Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 21:25:04 +0200 Subject: [PATCH 2/2] chore: keep only the CHANGELOG correction (review of #148) Drops the `_WHO4_WHERE_REGEX` hunk and its tests: `#0#C` on a 4-zone central unit is the unit's configured zone selector, 1..99 (01..99), not a 1..4 slot (MyHOME_Suite OPEN.db `#0#[ZA][ZB]`, MHCatalogue 4695 `ZAZB_CENTRAL` 1..99, oracle thermo-central-mode `*4*0*#0#2##` -> `D1 02 ...`), so bounding it to `[1-4]` rejected valid addresses. The merged #140 regex stays as it is. The CHANGELOG keeps the `send_to_gateway` correction, now with the subject list reading "... `AsyncSerialTransport.send` refuse it". Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++-- OWNd/message/heating.py | 33 +++++++++++------------------ tests/test_who4_actuators_status.py | 8 ------- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c08d2..5fb0fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ All notable changes to `OWNd` are documented in this file. - Automatic detection of legacy pre-`09W29` BTicino `F414` coarse-only dimmers via `DIMENSION 1` / `DIMENSION 4` transition speed `240` (`0xF0`, `DIMMER_SPEED_COARSE_ONLY`) with multi-gateway plant isolation (`gateway=` scoping on `OWNMessage.parse`, `OWNEvent.parse`, `OWNEventSession.get_next`, `is_coarse_dimmer_where`, `register_coarse_dimmer_where`, `clear_coarse_dimmer_wheres`, and `OWNLightingCommand.set_brightness`) (`#134`, `MyHOME#767`). - Canonical `libqtdevices` (`TS10_1_0_23`) coarse `WHAT 2..10` $\leftrightarrow$ fine `1..100%` conversion helpers (`COARSE_PRESET_TO_PERCENT`, `brightness_preset_to_percent`, `percent_to_brightness_preset`), `OWNLightingEvent.is_coarse_dimmer`, `OWNLightingEvent.brightness_preset_percent`, and `OWNLightingCommand.set_brightness(..., coarse=..., gateway=...)` (`#134`). - `WHAT 39` end of movement (`*1*39*WHERE##`) from WHO 1 motion detectors: `message_type` `motion_detected` with `motion` `False`, new `OWNLightingEvent.motion_end`, `is_sensor` `True`, `is_on` `None`. It was `unknown_state` `39` before (`MyHOME#777`). -- **WHO 4 (`OWNHeatingCommand.actuators_status`)**: Actuator status request `*#4*WHERE*20##` for a zone (`0..99`, `#Z`), one actuator (`2#1`; `N` is one digit, `2#0` for all actuators of the zone) or `#0` / `#0#N` (central unit); three-digit secondary probes (`105`), multi-digit actuator indices (`2#10`) and 4-zone central zones outside 1..4 (`#0#5`, `#0#0`) are refused. A live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). The address may carry a bus-interface suffix (`3#4#01`, `#3#4#01`, `2#1#4#01`; two-digit interface) as spec-legal grammar, but callers must gate it on the profile's `supports_interface_zone_requests` (`MyHOME#816`): own-firmware-oracle#65 shows no emulated firmware routes `*#4*2#4#01*20##` across an F422; F450, F454, F459, F460, F461 and MyHomeServer1 truncate `WHERE` at the first `#` and poll local zone 2, MH202 010024 reads `#4#01` as local zone 4 actuator 1, and F453, F453AV, H4684, MH200, MH200N drop it. The MH202 010024 crash in that suite hits the dimensionless status forms `*#4*2#1##` and `*#4*2#4#01##`, which the builder never emits (`MyHOME#792` review). +- **WHO 4 (`OWNHeatingCommand.actuators_status`)**: Actuator status request `*#4*WHERE*20##` for a zone (`0..99`, `#Z`), one actuator (`2#1`; `N` is one digit, `2#0` for all actuators of the zone) or `#0` / `#0#N` (central unit); three-digit secondary probes (`105`) and multi-digit actuator indices (`2#10`) are refused. A live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). The address may carry a bus-interface suffix (`3#4#01`, `#3#4#01`, `2#1#4#01`; two-digit interface) as spec-legal grammar, but callers must gate it on the profile's `supports_interface_zone_requests` (`MyHOME#816`): own-firmware-oracle#65 shows no emulated firmware routes `*#4*2#4#01*20##` across an F422; F450, F454, F459, F460, F461 and MyHomeServer1 truncate `WHERE` at the first `#` and poll local zone 2, MH202 010024 reads `#4#01` as local zone 4 actuator 1, and F453, F453AV, H4684, MH200, MH200N drop it. The MH202 010024 crash in that suite hits the dimensionless status forms `*#4*2#1##` and `*#4*2#4#01##`, which the builder never emits (`MyHOME#792` review). ### 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`), `AsyncSerialTransport.send` refuses 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 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. - **`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. ## [2.0.0b11] - 2026-10-20 diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 2efa323..e7890cc 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -110,24 +110,16 @@ def is_who4_dimension_22(frame: object) -> bool: # WHO 4 WHERE for an actuator status request: a zone of one or two digits -# (``0..99``: ``Z``, ``#Z`` with ``Z`` 1..99, ``0`` for every zone), the -# central unit (``#0``, or ``#0#N`` with ``N`` 1..4 for a zone of a 4-zone -# central unit), optionally one actuator digit on a zone (``Z#N``, ``#Z#N``; -# ``N`` is ``1..9``, ``0`` for all actuators of the zone: ``2#0``, ``0#0``), -# optionally a bus-interface suffix of exactly two digits (``#4#NN``: -# ``3#4#01``, ``#3#4#01``, ``2#1#4#01``). Three-digit addresses are -# secondary probes (``PZZ``, ``101..899``: no actuators, NACK on an F461), -# the actuator index is one digit in the WHO 4 WHERE table and a 4-zone -# central unit has zones 1..4, so ``105``, ``2#10``, ``#0#5`` and ``#0#0`` -# are refused the way ``get_fan_speed`` bounds its zone to ``1..99``. A -# leading zero on a plain zone (``01``) is kept, as the parser reads it. -# ASCII digits only; no ``*``, so no dimension can be injected through the -# address. -_WHO4_WHERE_REGEX = re.compile( - r"(?:#0(?:#[1-4])?|#[1-9][0-9]?(?:#[0-9])?|(?:[1-9][0-9]?|0[0-9]?)(?:#[0-9])?)" - r"(?:#4#[0-9]{2})?", - re.ASCII, -) +# (``0..99``: ``Z``, ``#Z``, ``0`` / ``#0`` for every zone or the central +# unit), optionally one actuator digit (``Z#N``, ``#0#N``, ``#Z#N``; ``N`` is +# ``1..9``, ``0`` for all actuators of the zone: ``2#0``, ``0#0``), optionally +# a bus-interface suffix of exactly two digits (``#4#NN``: ``3#4#01``, +# ``#3#4#01``, ``2#1#4#01``). Three-digit addresses are secondary probes +# (``PZZ``, ``101..899``: no actuators, NACK on an F461) and the actuator +# index is one digit in the WHO 4 WHERE table, so both are refused the way +# ``get_fan_speed`` bounds its zone to ``1..99``. ASCII digits only; no ``*``, so no dimension can be +# injected through the address. +_WHO4_WHERE_REGEX = re.compile(r"#?[0-9]{1,2}(?:#[0-9])?(?:#4#[0-9]{2})?", re.ASCII) def who4_temperature(raw: str) -> float | None: @@ -946,9 +938,8 @@ def actuators_status(cls, where: str | int) -> OWNHeatingCommand: ``*#4*35*20##`` with ``*#4*35#1*20*0##``. Whitespace around ``where`` is stripped. Anything else (``*``, an empty string, a three-digit secondary probe such as ``105``, a two-digit actuator such as - ``2#10``, a 4-zone central zone outside 1..4 such as ``#0#5``, a - one- or three-digit interface such as ``3#4#1`` or ``3#4#001``) - raises ``ValueError``. The log line carries the whole + ``2#10``, a one- or three-digit interface such as ``3#4#1`` or + ``3#4#001``) raises ``ValueError``. The log line carries the whole address as written (``2#1#4#01``), not only the zone the parser keeps in ``where``. """ diff --git a/tests/test_who4_actuators_status.py b/tests/test_who4_actuators_status.py index 12fe169..41fc95f 100644 --- a/tests/test_who4_actuators_status.py +++ b/tests/test_who4_actuators_status.py @@ -60,8 +60,6 @@ ("0", "*#4*0*20##"), ("99", "*#4*99*20##"), ("#0#4", "*#4*#0#4*20##"), - ("#99#9", "*#4*#99#9*20##"), - ("01", "*#4*01*20##"), # Bus-interface routed forms (``#4#NN``, two-digit interface): zone, # zone through the central unit, one actuator of a zone. ("3#4#01", "*#4*3#4#01*20##"), @@ -116,12 +114,6 @@ def test_actuators_status_builds_dimension_20(where: str | int, frame: str) -> N "99999", "#105", "105#4#01", - # A 4-zone central unit has zones 1..4; ``#0#0`` is not an address. - "#0#0", - "#0#5", - "#0#9", - "#01", - "#00", # The actuator index is one digit (``0..9``). "2#10", "2#999",