diff --git a/CHANGELOG.md b/CHANGELOG.md index 655b70d..949a52f 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 (`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 a9a38f9..e7890cc 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -109,6 +109,19 @@ def is_who4_dimension_22(frame: object) -> bool: return any(_WHO4_DIMENSION_22_REGEX.search(chunk) for chunk in raw.split("##")) +# 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) + + 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 +905,51 @@ 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 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 + (``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 + ``*#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 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 {where_str}." + 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..41fc95f --- /dev/null +++ b/tests/test_who4_actuators_status.py @@ -0,0 +1,166 @@ +"""``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. + +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 + +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; ``_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##"), + (" 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: + 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 + # ``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( + "where", + [ + "", + " ", + "2*22", + "2*22#", + "#0*22", + "2##", + "*", + "a", + "##0", + "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", + "#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): + 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