From 215247a2d11eca829e55362f698c0bb6c4c68599 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 15:36:02 +0300 Subject: [PATCH 1/4] feat(heating): add OWNHeatingCommand.actuators_status builder for dimension 20 OWNd had valves_status() for dimension 19 (`*#4*ZONE*19##`) but no builder for the actuator status request `*#4*WHERE*20##`, although the gateways answer it: on the live F461 2.0.11 (2026-10-23, MyHOME#799) `*#4*2*20##` returned `*#4*2#1*20*0##` (valve, off) on the command session and `*#4*2#2*20*5##` (fan coil, off) on the event session, one frame per actuator, and `*#4*#0*20##` returned both; a live MyHomeServer1 3.87.13 answered `*#4*35*20##` with `*#4*35#1*20*0##`. MyHOME needs the request to fill hvac_action at startup instead of waiting for a spontaneous actuator frame. actuators_status(where) accepts a zone (`2`, `#2`), one actuator (`2#1`), a central-unit form (`#0`, `#0#1`) or `#Z#N` (WHO 4 WHERE table; pinned by a test): the address must fully match `#?[0-9]+(?:#[0-9]+)?`, so `*`, whitespace, Unicode digits and anything else raise ValueError and no dimension can be injected through it. Value 5 in a reply is fan coil off (master `_actuator_active_states`), pinned unchanged. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + OWNd/message/heating.py | 27 +++++++++ tests/test_who4_actuators_status.py | 87 +++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 tests/test_who4_actuators_status.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 655b70d..6672529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ 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, one actuator (`2#1`) or `#0` (all zones); a live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). ### 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`. diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index a9a38f9..a453708 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -109,6 +109,12 @@ def is_who4_dimension_22(frame: object) -> bool: return any(_WHO4_DIMENSION_22_REGEX.search(chunk) for chunk in raw.split("##")) +# A zone (``2``, ``#2``), an actuator of a zone (``2#1``), or a central unit +# (``#0``, ``#0#1``). ASCII digits only; no ``*``, so no dimension can be +# injected through the address. +_WHO4_WHERE_REGEX = re.compile(r"#?[0-9]+(?:#[0-9]+)?") + + def who4_temperature(raw: str) -> float | None: """Decode WHO 4 temperature ``SXXX`` (tenths of a degree).""" if not _WHO4_TEMP_REGEX.match(raw): @@ -892,6 +898,27 @@ def valves_status(cls, where: str | int) -> OWNHeatingCommand: message._human_readable_log = f"Requesting climate valve status update for {message._where}{message._interface_log_text}." return message + @classmethod + def actuators_status(cls, where: str | int) -> OWNHeatingCommand: + """Request actuator status (``*#4*WHERE*20##``), one reply per actuator. + + ``where`` is a zone (``2``, ``#2``), one actuator (``2#1``), the + central-unit form (``#0``, ``#0#N``) for every zone, or ``#Z#N`` (an + actuator addressed through the central unit; WHO 4 WHERE table, see + ``_normalize_zone``). A live F461 2.0.11 (2026-10-23) answers + ``*#4*2*20##`` with ``*#4*2#1*20*0##`` (valve, off) on the command + session and ``*#4*2#2*20*5##`` (fan coil, off) on the event session, + and ``*#4*#0*20##`` with both; a live MyHomeServer1 3.87.13 answers + ``*#4*35*20##`` with ``*#4*35#1*20*0##``. Whitespace around ``where`` + is stripped. Anything else raises ``ValueError``. + """ + where_str = str(where).strip() if where is not None else "" + if not _WHO4_WHERE_REGEX.fullmatch(where_str): + raise ValueError(f"Invalid zone address: {where}") + message = cls(f"*#4*{where_str}*20##") + message._human_readable_log = f"Requesting climate actuator status update for {message._where}{message._interface_log_text}." + return message + @classmethod def get_temperature(cls, where: str | int) -> OWNHeatingCommand: message = cls(f"*#4*{where}*0##") diff --git a/tests/test_who4_actuators_status.py b/tests/test_who4_actuators_status.py new file mode 100644 index 0000000..eefb674 --- /dev/null +++ b/tests/test_who4_actuators_status.py @@ -0,0 +1,87 @@ +"""``OWNHeatingCommand.actuators_status`` builds DIMENSION 20 (``*#4*WHERE*20##``). + +A live F461 2.0.11 (2026-10-23, MyHOME#799 ``f461_live_2026-10-23`` run 1) +answers ``*#4*2*20##`` with ``*#4*2#1*20*0##`` on the command session and +``*#4*2#2*20*5##`` on the event session, one frame per actuator, and +``*#4*#0*20##`` with both on the command session. A live MyHomeServer1 +3.87.13 (MyHOME#799 ``myhomeserver1_live_2026-10-24`` run 2) answers +``*#4*35*20##`` with ``*#4*35#1*20*0##``. + +Dimension 20 values (WHO 4 spec, ``OWNHeatingEvent``): 0 off, 1 on, 2 opened, +3 closed, 4 stop, 5 fan coil off, 6-8 fan speeds, 9 fan auto; +``_actuator_active_states`` is ``1, 2, 6, 7, 8, 9``, so value 5 is inactive. +""" + +from __future__ import annotations + +import re + +import pytest + +from OWNd.message import OWNHeatingCommand, OWNHeatingEvent, OWNMessage + +# WHO 4 DIMENSION 22 crashes the gateway's thermoregulation daemon; the +# builder must not let an address smuggle it in. +_DIMENSION_22 = re.compile(r"\*#4\*[^*]*\*#?0*22(?:[#*]|$)") + + +@pytest.mark.parametrize( + ("where", "frame"), + [ + ("2", "*#4*2*20##"), + (2, "*#4*2*20##"), + ("#2", "*#4*#2*20##"), + ("2#1", "*#4*2#1*20##"), + ("#0", "*#4*#0*20##"), + ("#0#1", "*#4*#0#1*20##"), + # ``#Z#N``: actuator N of zone Z addressed through the central unit + # (WHO 4 WHERE table; ``_normalize_zone`` documents ``#Z#C``). Accepted + # on purpose; not seen live (the plant has no central unit). + ("#2#1", "*#4*#2#1*20##"), + ("22", "*#4*22*20##"), + ], +) +def test_actuators_status_builds_dimension_20(where: str | int, frame: str) -> None: + command = OWNHeatingCommand.actuators_status(where) + + assert str(command) == frame + assert command.is_dimension_request + assert command.dimension == 20 + assert _DIMENSION_22.search(str(command)) is None + + parsed = OWNMessage.parse(frame) + assert isinstance(parsed, OWNHeatingCommand) + assert str(parsed) == frame + assert parsed.dimension == 20 + assert parsed.where == command.where + + +@pytest.mark.parametrize( + "where", + ["", " ", "2*22", "2*22#", "#0*22", "2##", "*", "a", "##0", "2#1#4", "٢", None], +) +def test_actuators_status_refuses_bad_addresses(where: object) -> None: + with pytest.raises(ValueError): + OWNHeatingCommand.actuators_status(where) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("reply", "actuator", "active", "fan_on"), + [ + ("*#4*2#1*20*0##", "1", False, None), + # value 5 = fan coil OFF: ``_fan_mode = 5 - 5 = 0`` -> fan_on False, + # is_active False (master behaviour, pinned unchanged). + ("*#4*2#2*20*5##", "2", False, False), + ("*#4*35#1*20*0##", "1", False, None), + ], +) +def test_actuators_status_replies_from_the_f461( + reply: str, actuator: str, active: bool, fan_on: bool | None +) -> None: + event = OWNMessage.parse(reply) + + assert isinstance(event, OWNHeatingEvent) + assert event.zone == int(reply.split("*")[2].split("#")[0]) + assert event._actuator == actuator + assert event.is_active() is active + assert event.fan_on is fan_on From e3b600df64105cdd96a0d61e09a7f2e2fbf47fcb Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:33:51 +0300 Subject: [PATCH 2/4] feat(heating): actuators_status accepts bus-interface routed zone addresses (review follow-up) The MyHOME#792 review (finding 3) points out that MyHOME sends the actuator request for zones behind an F422 bus interface as `*#4*3#4#01*20##`, `*#4*#3#4#01*20##` or `*#4*2#1#4#01*20##`, while `OWNHeatingCommand.actuators_status` rejected every three-part address with `ValueError`. The `_WHO4_WHERE_REGEX` now allows an optional `#4#NN` routing suffix (interface of exactly two digits) after the zone, the central-unit zone or the actuator: `Z#4#NN`, `#Z#4#NN`, `Z#N#4#NN`. `#0`, `#0#N`, `Z`, `Z#N` and `#Z` are unchanged; `*`, an empty string, a one-digit (`3#4#1`) or three-digit (`3#4#001`) interface, a suffix that is not `#4#`, a double suffix and a dimension smuggled after the suffix stay refused. Evidence: own-firmware-oracle#60 (`thermo-zone-requests`) shows `*#4*2#4#01*20##` forwarded by F450, F454, F459, F460, F461, MH202 and MyHomeServer1 and dropped by F453, F453AV, H4684, MH200 and MH200N. The frame that crashes the MH202 thermoregulation daemon in that suite is the routed zone status without a dimension, `*#4*2#4#01##`, which this builder never emits: it only builds the dimension-20 form. Tests: 5 accepted routed forms (fail before: `ValueError`) and 13 refused forms added to `tests/test_who4_actuators_status.py`; on the previous tip 5 fail / 36 pass, now 41 pass. Full suite 1948 passed, 1 skipped, 100 % statement coverage; ruff, mypy (`OWNd tests/typing`), `sync_documentation.py --check` and `verify_library_standards.py` pass. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- OWNd/message/heating.py | 25 ++++++++++++---- tests/test_who4_actuators_status.py | 44 ++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6672529..610724a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ 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, one actuator (`2#1`) or `#0` (all zones); a live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). +- **WHO 4 (`OWNHeatingCommand.actuators_status`)**: Actuator status request `*#4*WHERE*20##` for a zone, one actuator (`2#1`) or `#0` (all zones); a live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). The address may be routed through a bus interface (`3#4#01`, `#3#4#01`, `2#1#4#01`; two-digit interface): own-firmware-oracle#60 shows `*#4*2#4#01*20##` forwarded by F450, F454, F459, F460, F461, MH202 and MyHomeServer1 (dropped by F453, F453AV, H4684, MH200, MH200N); the MH202 crash in that suite is the routed zone status without a dimension (`*#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`. diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index a453708..022aa15 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -112,7 +112,10 @@ def is_who4_dimension_22(frame: object) -> bool: # A zone (``2``, ``#2``), an actuator of a zone (``2#1``), or a central unit # (``#0``, ``#0#1``). ASCII digits only; no ``*``, so no dimension can be # injected through the address. -_WHO4_WHERE_REGEX = re.compile(r"#?[0-9]+(?:#[0-9]+)?") +# WHO 4 WHERE for a status request: ``Z``, ``#Z``, ``Z#N``, ``#0``, ``#0#N``, +# ``#Z#N``, each optionally routed through a bus interface (``#4#NN``, the +# interface is exactly two digits: ``3#4#01``, ``#3#4#01``, ``2#1#4#01``). +_WHO4_WHERE_REGEX = re.compile(r"#?[0-9]+(?:#[0-9]+)?(?:#4#[0-9]{2})?") def who4_temperature(raw: str) -> float | None: @@ -905,12 +908,22 @@ def actuators_status(cls, where: str | int) -> OWNHeatingCommand: ``where`` is a zone (``2``, ``#2``), one actuator (``2#1``), the central-unit form (``#0``, ``#0#N``) for every zone, or ``#Z#N`` (an actuator addressed through the central unit; WHO 4 WHERE table, see - ``_normalize_zone``). A live F461 2.0.11 (2026-10-23) answers - ``*#4*2*20##`` with ``*#4*2#1*20*0##`` (valve, off) on the command - session and ``*#4*2#2*20*5##`` (fan coil, off) on the event session, - and ``*#4*#0*20##`` with both; a live MyHomeServer1 3.87.13 answers + ``_normalize_zone``). Each form may be routed through a bus interface + with a two-digit ``#4#NN`` suffix (``3#4#01``, ``#3#4#01``, + ``2#1#4#01``): own-firmware-oracle#60 (``thermo-zone-requests``) shows + ``*#4*2#4#01*20##`` forwarded by F450, F454, F459, F460, F461, MH202 + and MyHomeServer1 and dropped by F453, F453AV, H4684, MH200 and + MH200N. The frame that crashes the MH202 thermoregulation daemon in + that suite is the routed zone status without a dimension + (``*#4*2#4#01##``); this builder only emits the dimension-20 form. + A live F461 2.0.11 (2026-10-23) answers ``*#4*2*20##`` with + ``*#4*2#1*20*0##`` (valve, off) on the command session and + ``*#4*2#2*20*5##`` (fan coil, off) on the event session, and + ``*#4*#0*20##`` with both; a live MyHomeServer1 3.87.13 answers ``*#4*35*20##`` with ``*#4*35#1*20*0##``. Whitespace around ``where`` - is stripped. Anything else raises ``ValueError``. + is stripped. Anything else (``*``, an empty string, a one- or + three-digit interface such as ``3#4#1`` or ``3#4#001``) raises + ``ValueError``. """ where_str = str(where).strip() if where is not None else "" if not _WHO4_WHERE_REGEX.fullmatch(where_str): diff --git a/tests/test_who4_actuators_status.py b/tests/test_who4_actuators_status.py index eefb674..b0cf5a3 100644 --- a/tests/test_who4_actuators_status.py +++ b/tests/test_who4_actuators_status.py @@ -10,6 +10,14 @@ Dimension 20 values (WHO 4 spec, ``OWNHeatingEvent``): 0 off, 1 on, 2 opened, 3 closed, 4 stop, 5 fan coil off, 6-8 fan speeds, 9 fan auto; ``_actuator_active_states`` is ``1, 2, 6, 7, 8, 9``, so value 5 is inactive. + +Bus-interface routed addresses (``Z#4#NN``, ``#Z#4#NN``, ``Z#N#4#NN``; MyHOME +sends them for zones behind an F422) are accepted since the MyHOME#792 review: +own-firmware-oracle#60 (``thermo-zone-requests``) shows ``*#4*2#4#01*20##`` +forwarded by F450, F454, F459, F460, F461, MH202 and MyHomeServer1 and dropped +by F453, F453AV, H4684, MH200 and MH200N. The frame that crashes the MH202 +daemon there is ``*#4*2#4#01##`` (zone status, no dimension), which this +builder never emits: it only builds the dimension-20 form. """ from __future__ import annotations @@ -39,6 +47,13 @@ # on purpose; not seen live (the plant has no central unit). ("#2#1", "*#4*#2#1*20##"), ("22", "*#4*22*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##"), + (" 3#4#01 ", "*#4*3#4#01*20##"), + ("#3#4#01", "*#4*#3#4#01*20##"), + ("2#1#4#01", "*#4*2#1#4#01*20##"), + ("12#4#15", "*#4*12#4#15*20##"), ], ) def test_actuators_status_builds_dimension_20(where: str | int, frame: str) -> None: @@ -58,7 +73,34 @@ def test_actuators_status_builds_dimension_20(where: str | int, frame: str) -> N @pytest.mark.parametrize( "where", - ["", " ", "2*22", "2*22#", "#0*22", "2##", "*", "a", "##0", "2#1#4", "٢", None], + [ + "", + " ", + "2*22", + "2*22#", + "#0*22", + "2##", + "*", + "a", + "##0", + "2#1#4", + "٢", + None, + # Routing suffix: the interface is exactly two digits after ``#4#``. + "3#4#1", + "3#4#001", + "#3#4#1", + "#3#4#001", + "2#1#4#1", + "2#1#4#001", + "3#4#", + "3#4#0a", + "3#4#01#", + "3#3#01", + "3#4#01#4#01", + "3#4#01*22", + "*#4#01", + ], ) def test_actuators_status_refuses_bad_addresses(where: object) -> None: with pytest.raises(ValueError): From d8bbbaed5d41242e7f445c9c9c12117d2c6d02da Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 14:53:24 +0200 Subject: [PATCH 3/4] docs(heating): actuators_status docstrings follow own-firmware-oracle#65 (review follow-up) The `actuators_status` docstring, the test module docstring and the CHANGELOG entry said own-firmware-oracle#60 showed `*#4*2#4#01*20##` "forwarded by F450, F454, F459, F460, F461, MH202 and MyHomeServer1". own-firmware-oracle#65 decodes the SCS bytes of those `out` verdicts: the six Linux gateways truncate WHERE at the first `#` and poll local zone 2 (the bytes of `*#4*2*20##`), MH202 010024 reads the trailing `#4#01` as local zone 4 actuator 1 and rejects `#4#12`, and F453, F453AV, H4684, MH200 and MH200N drop all three routed forms. Nothing routes `#4#NN` across an F422, so the text now says callers must gate it on the profile's `supports_interface_zone_requests` (MyHOME#816), and the MH202 crash note covers the plain per-actuator status `*#4*Z#N##` as well as `*#4*Z#4#01..09##`. Grammar and behaviour unchanged. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- OWNd/message/heating.py | 22 ++++++++++++++++------ tests/test_who4_actuators_status.py | 19 ++++++++++++------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 610724a..bb70445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ 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, one actuator (`2#1`) or `#0` (all zones); a live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). The address may be routed through a bus interface (`3#4#01`, `#3#4#01`, `2#1#4#01`; two-digit interface): own-firmware-oracle#60 shows `*#4*2#4#01*20##` forwarded by F450, F454, F459, F460, F461, MH202 and MyHomeServer1 (dropped by F453, F453AV, H4684, MH200, MH200N); the MH202 crash in that suite is the routed zone status without a dimension (`*#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, one actuator (`2#1`) or `#0` (all zones); 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`. diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 022aa15..a37f205 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -910,12 +910,22 @@ def actuators_status(cls, where: str | int) -> OWNHeatingCommand: actuator addressed through the central unit; WHO 4 WHERE table, see ``_normalize_zone``). Each form may be routed through a bus interface with a two-digit ``#4#NN`` suffix (``3#4#01``, ``#3#4#01``, - ``2#1#4#01``): own-firmware-oracle#60 (``thermo-zone-requests``) shows - ``*#4*2#4#01*20##`` forwarded by F450, F454, F459, F460, F461, MH202 - and MyHomeServer1 and dropped by F453, F453AV, H4684, MH200 and - MH200N. The frame that crashes the MH202 thermoregulation daemon in - that suite is the routed zone status without a dimension - (``*#4*2#4#01##``); this builder only emits the dimension-20 form. + ``2#1#4#01``) because the suffix is spec-legal grammar, but no + emulated firmware routes it across an F422: own-firmware-oracle#65 + (``thermo-zone-requests``, decoded ``scs_bus_hex``) shows that F450, + F454, F459, F460, F461 and MyHomeServer1 truncate ``WHERE`` at the + first ``#`` and answer ``*#4*2#4#01*20##`` (also ``*#4*2#4#12*20##`` + and ``*#4*2#1#4#01*20##``) with the bytes of the unqualified + ``*#4*2*20##``, polling local main-bus zone 2; MH202 010024 reads + the trailing ``#4#01`` as local zone 4, actuator 1 (and rejects + ``#4#12``, actuator 12 > 9); F453, F453AV, H4684, MH200 and MH200N + drop the frame. Callers must therefore gate ``#4#NN`` polling on the + gateway profile's ``supports_interface_zone_requests`` (MyHOME#816) + instead of expecting the interface to be honoured. The MH202 010024 + thermoregulation daemon crashes (``SIGSEGV``) on the dimensionless + status forms ``*#4*Z#N##`` (``N`` 1..9) and ``*#4*Z#4#01..09##`` + (``thermo_open2scs+0x394``); this builder only emits the dimension-20 + form. A live F461 2.0.11 (2026-10-23) answers ``*#4*2*20##`` with ``*#4*2#1*20*0##`` (valve, off) on the command session and ``*#4*2#2*20*5##`` (fan coil, off) on the event session, and diff --git a/tests/test_who4_actuators_status.py b/tests/test_who4_actuators_status.py index b0cf5a3..494f54d 100644 --- a/tests/test_who4_actuators_status.py +++ b/tests/test_who4_actuators_status.py @@ -11,13 +11,18 @@ 3 closed, 4 stop, 5 fan coil off, 6-8 fan speeds, 9 fan auto; ``_actuator_active_states`` is ``1, 2, 6, 7, 8, 9``, so value 5 is inactive. -Bus-interface routed addresses (``Z#4#NN``, ``#Z#4#NN``, ``Z#N#4#NN``; MyHOME -sends them for zones behind an F422) are accepted since the MyHOME#792 review: -own-firmware-oracle#60 (``thermo-zone-requests``) shows ``*#4*2#4#01*20##`` -forwarded by F450, F454, F459, F460, F461, MH202 and MyHomeServer1 and dropped -by F453, F453AV, H4684, MH200 and MH200N. The frame that crashes the MH202 -daemon there is ``*#4*2#4#01##`` (zone status, no dimension), which this -builder never emits: it only builds the dimension-20 form. +Bus-interface routed addresses (``Z#4#NN``, ``#Z#4#NN``, ``Z#N#4#NN``) are +accepted since the MyHOME#792 review as spec-legal grammar, not because any +firmware honours them: own-firmware-oracle#65 (``thermo-zone-requests``, +decoded ``scs_bus_hex``) shows the seven ``out`` images emit unrouted local-bus +queries (``$04A9023010`` / ``$004A9023010`` for local zone 2 on F450, F454, +F459, F460, F461, MyHomeServer1; ``$0499843110`` for local zone 4 actuator 1 +on MH202 010024) instead of forwarding ``#4#NN`` across an F422, and F453, +F453AV, H4684, MH200, MH200N drop the frame. MyHOME#816 therefore withholds +``#4#NN`` WHO 4 status polls unless ``supports_interface_zone_requests`` is +``True``. The MH202 010024 daemon crashes on the dimensionless forms +``*#4*Z#N##`` (``N`` 1..9) and ``*#4*Z#4#01..09##``, which this builder never +emits: it only builds the dimension-20 form. """ from __future__ import annotations From 0099a228e7b890deed859deb9568555d6d4f2f34 Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 17:08:11 +0200 Subject: [PATCH 4/4] fix(heating): bound actuators_status addresses, log the full WHERE (review follow-up) Review of 2026-10-26 14:57 on #140: - `_WHO4_WHERE_REGEX` is bounded to `#?[0-9]{1,2}(?:#[0-9])?(?:#4#[0-9]{2})?`: zones are 0..99, the actuator index is one digit (`2#0` / `0#0` for all actuators), the interface stays two digits. Three-digit secondary probes (`105`, `101..899`), `001`, `99999` and multi-digit actuators (`2#10`) now raise `ValueError`, like `get_fan_speed` bounds its zone. - The log line uses the address as written (`2#1#4#01`) instead of `message._where` (which keeps only the zone) and the `_interface_log_text` that is always empty for WHO 4. - Docstrings point at `_zone_address_and_name` (there is no `_normalize_zone` in OWNd). - Tests: the round trip also asserts `where_param` and `human_readable_log`; accepted `2#0`, `0#0`, `0`, `99`, `#0#4`; refused `105`, `101`, `899`, `001`, `99999`, `#105`, `105#4#01`, `2#10`, `2#999`, `#0#10`, `2#10#4#01`, `True`, `False`, `-1`. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- OWNd/message/heating.py | 36 ++++++++++++++++++----------- tests/test_who4_actuators_status.py | 36 +++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb70445..949a52f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ 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, one actuator (`2#1`) or `#0` (all zones); 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`. diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index a37f205..e7890cc 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -109,13 +109,17 @@ def is_who4_dimension_22(frame: object) -> bool: return any(_WHO4_DIMENSION_22_REGEX.search(chunk) for chunk in raw.split("##")) -# A zone (``2``, ``#2``), an actuator of a zone (``2#1``), or a central unit -# (``#0``, ``#0#1``). ASCII digits only; no ``*``, so no dimension can be +# 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. -# WHO 4 WHERE for a status request: ``Z``, ``#Z``, ``Z#N``, ``#0``, ``#0#N``, -# ``#Z#N``, each optionally routed through a bus interface (``#4#NN``, the -# interface is exactly two digits: ``3#4#01``, ``#3#4#01``, ``2#1#4#01``). -_WHO4_WHERE_REGEX = re.compile(r"#?[0-9]+(?:#[0-9]+)?(?:#4#[0-9]{2})?") +_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: @@ -905,10 +909,11 @@ def valves_status(cls, where: str | int) -> OWNHeatingCommand: def actuators_status(cls, where: str | int) -> OWNHeatingCommand: """Request actuator status (``*#4*WHERE*20##``), one reply per actuator. - ``where`` is a zone (``2``, ``#2``), one actuator (``2#1``), the - central-unit form (``#0``, ``#0#N``) for every zone, or ``#Z#N`` (an - actuator addressed through the central unit; WHO 4 WHERE table, see - ``_normalize_zone``). Each form may be routed through a bus interface + ``where`` is a zone of one or two digits (``2``, ``#2``), one actuator + (``2#1``; ``2#0`` for all actuators of the zone), the central-unit + form (``#0``, ``#0#N``) for every zone, or ``#Z#N`` (an actuator + addressed through the central unit; WHO 4 WHERE table, see + ``_zone_address_and_name``). Each form may be routed through a bus interface with a two-digit ``#4#NN`` suffix (``3#4#01``, ``#3#4#01``, ``2#1#4#01``) because the suffix is spec-legal grammar, but no emulated firmware routes it across an F422: own-firmware-oracle#65 @@ -931,15 +936,18 @@ def actuators_status(cls, where: str | int) -> OWNHeatingCommand: ``*#4*2#2*20*5##`` (fan coil, off) on the event session, and ``*#4*#0*20##`` with both; a live MyHomeServer1 3.87.13 answers ``*#4*35*20##`` with ``*#4*35#1*20*0##``. Whitespace around ``where`` - is stripped. Anything else (``*``, an empty string, a one- or - three-digit interface such as ``3#4#1`` or ``3#4#001``) raises - ``ValueError``. + 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 + address as written (``2#1#4#01``), not only the zone the parser + keeps in ``where``. """ where_str = str(where).strip() if where is not None else "" if not _WHO4_WHERE_REGEX.fullmatch(where_str): raise ValueError(f"Invalid zone address: {where}") message = cls(f"*#4*{where_str}*20##") - message._human_readable_log = f"Requesting climate actuator status update for {message._where}{message._interface_log_text}." + message._human_readable_log = f"Requesting climate actuator status update for {where_str}." return message @classmethod diff --git a/tests/test_who4_actuators_status.py b/tests/test_who4_actuators_status.py index 494f54d..41fc95f 100644 --- a/tests/test_who4_actuators_status.py +++ b/tests/test_who4_actuators_status.py @@ -48,10 +48,18 @@ ("#0", "*#4*#0*20##"), ("#0#1", "*#4*#0#1*20##"), # ``#Z#N``: actuator N of zone Z addressed through the central unit - # (WHO 4 WHERE table; ``_normalize_zone`` documents ``#Z#C``). Accepted - # on purpose; not seen live (the plant has no central unit). + # (WHO 4 WHERE table; ``_zone_address_and_name`` documents ``#Z#C``). + # Accepted on purpose; not seen live (the plant has no central unit). ("#2#1", "*#4*#2#1*20##"), ("22", "*#4*22*20##"), + # ``Z#0`` / ``0#0``: all actuators of a zone / of every zone + # (Encyclopedia WHERE table; the forms F453, F453AV and MH200N put on + # the SCS bus in own-firmware-oracle ``thermo-zone-requests``). + ("2#0", "*#4*2#0*20##"), + ("0#0", "*#4*0#0*20##"), + ("0", "*#4*0*20##"), + ("99", "*#4*99*20##"), + ("#0#4", "*#4*#0#4*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##"), @@ -74,6 +82,12 @@ def test_actuators_status_builds_dimension_20(where: str | int, frame: str) -> N assert str(parsed) == frame assert parsed.dimension == 20 assert parsed.where == command.where + # ``where`` keeps only the token before the first ``#``; the actuator + # and interface tokens live in ``where_param`` and must round-trip too. + assert parsed.where_param == command.where_param + assert command.human_readable_log == ( + f"Requesting climate actuator status update for {str(where).strip()}." + ) @pytest.mark.parametrize( @@ -91,6 +105,24 @@ def test_actuators_status_builds_dimension_20(where: str | int, frame: str) -> N "2#1#4", "٢", None, + # Zones are 0..99: three digits address a secondary probe (``PZZ``, + # no actuators, NACK on an F461) or nothing at all. + "105", + "101", + "899", + "001", + "99999", + "#105", + "105#4#01", + # The actuator index is one digit (``0..9``). + "2#10", + "2#999", + "#0#10", + "2#10#4#01", + # ``bool`` is an ``int`` subclass; negative numbers are not zones. + True, + False, + -1, # Routing suffix: the interface is exactly two digits after ``#4#``. "3#4#1", "3#4#001",