diff --git a/CHANGELOG.md b/CHANGELOG.md index be1a392..b4a75d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to `OWNd` are documented in this file. - **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. - **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`. ## [2.0.0b11] - 2026-10-20 diff --git a/OWNd/message/base.py b/OWNd/message/base.py index 66be307..9d74b1e 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -108,6 +108,18 @@ class OWNMessage: _DIMENSION_REQUEST_REPLY = re.compile( r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*(?P\d+)(?P(?:#\d+)*)?(?P(?:\*\d*)+)##$" # pylint: disable=line-too-long ) # *#WHO*WHERE*DIMENSION*VAL1*VALn## + # Dimension replies with a '#' inside a value, accepted only in the shapes + # gateways are known to send; the WHO decoder reads the value. + _HASH_VALUE_REPLIES = ( + # *#4*WHERE*14*#TTTT*M##: setpoint with a '#' prefix (MyHOME#139). + # Exactly the dimension 14 shape: one '#', a four-digit temperature + # (first digit 0 or 1, as _WHO4_TEMP_REGEX) and at most one mode + # value; ASCII digits only, so non-ASCII wire bytes stay unparsed. + re.compile( + r"^\*#(?P4)\*(?P#?[0-9]+)(?P(?:#[0-9]+)*)\*(?P14)(?P)(?P\*#[01][0-9]{3}(?:\*[0-9]+)?)##$", # pylint: disable=line-too-long + re.ASCII, + ), + ) """ Base class for all OWN messages """ @@ -212,6 +224,25 @@ def __init__(self, data: str) -> None: self._dimension_param = (match.group("dimension_param") or "").split("#")[1:] self._dimension_value = match.group("dimension_value").split("*")[1:] + elif match := self._match_hash_value_reply(self._raw): + self._is_valid_message = True + self._match = match + self._family = "EVENT" + self._message_type = "DIMENSION_REQUEST_REPLY" + self._who = int(match.group("who")) + self._where = match.group("where") + self._where_param = match.group("where_param").split("#")[1:] + self._dimension = int(match.group("dimension")) + self._dimension_param = (match.group("dimension_param") or "").split("#")[1:] + self._dimension_value = match.group("dimension_value").split("*")[1:] + + @classmethod + def _match_hash_value_reply(cls, data: str) -> re.Match[str] | None: + for pattern in cls._HASH_VALUE_REPLIES: + if match := pattern.match(data): + return match + return None + @classmethod def parse(cls, data: str, *, gateway: Any = None) -> OWNMessage | None: if ( @@ -228,6 +259,7 @@ def parse(cls, data: str, *, gateway: Any = None) -> OWNMessage | None: or cls._ALARM_EMPTY_WHERE.match(data) or cls._DOOR_ENTRY_SHORT.match(data) or cls._DIMENSION_REQUEST_REPLY.match(data) + or cls._match_hash_value_reply(data) ): return OWNEvent.parse(data, gateway=gateway) if ( diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index f214663..6bf0aa3 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -560,8 +560,10 @@ def __init__(self, data: str) -> None: elif self._dimension == 14: # Set temperature self._type = MESSAGE_TYPE_TARGET_TEMPERATURE + # Some central units prefix the value with '#' + # (*#4*#1*14*#0230*1##, MyHOME#139); the value is the same. self._set_temperature = ( - who4_temperature(self._dimension_value[0]) + who4_temperature(self._dimension_value[0].removeprefix("#")) if self._dimension_value else None ) diff --git a/tests/test_who4_hash_setpoint.py b/tests/test_who4_hash_setpoint.py new file mode 100644 index 0000000..a2a6001 --- /dev/null +++ b/tests/test_who4_hash_setpoint.py @@ -0,0 +1,91 @@ +"""WHO 4 Dimension 14 setpoint replies with a ``#``-prefixed value (MyHOME#139). + +``*#4*#1*14*#0230*1##`` is the frame a user's central unit sent in MyHOME#139 +("Data received is not a message"); no oracle suite and no live capture of +ours contains it. Only this shape is accepted; a ``#`` in any other value +still does not parse. +""" + +from __future__ import annotations + +import pytest + +from OWNd.message import MESSAGE_TYPE_TARGET_TEMPERATURE, OWNEvent, OWNHeatingEvent, OWNMessage + + +def test_hash_prefixed_setpoint_parses_as_dimension_14() -> None: + message = OWNMessage.parse("*#4*#1*14*#0230*1##") + + assert isinstance(message, OWNHeatingEvent) + assert message.is_dimension_reply + assert message.dimension == 14 + assert message.zone == 1 + assert message.message_type == MESSAGE_TYPE_TARGET_TEMPERATURE + assert message.set_temperature == 23.0 + assert message.dimension_value == ["#0230", "1"] + assert message.dimension_param == [] + assert str(message) == "*#4*#1*14*#0230*1##" + # The '#' never leaks into the log line. + assert message.human_readable_log == "Zone 1's target temperature is set to 23.0°C." + + +def test_event_parse_matches_message_parse() -> None: + """``OWNEvent.parse`` (the event session's entry point) gives the same event as + ``OWNMessage.parse``; on master it returned an invalid event (``zone 0``).""" + event = OWNEvent.parse("*#4*#1*14*#0230*1##") + message = OWNMessage.parse("*#4*#1*14*#0230*1##") + + assert isinstance(event, OWNHeatingEvent) + assert event.is_valid + assert event.zone == 1 + assert event.message_type == MESSAGE_TYPE_TARGET_TEMPERATURE + assert event.set_temperature == 23.0 + assert event.human_readable_log == message.human_readable_log + assert str(event) == str(message) + + +@pytest.mark.parametrize( + ("frame", "zone", "setpoint"), + [ + ("*#4*#1*14*#0230*1##", 1, 23.0), + ("*#4*1*14*#0215*3##", 1, 21.5), + ("*#4*#0#2*14*#0200*1##", 2, 20.0), + ], +) +def test_hash_prefixed_setpoint_zone_forms(frame: str, zone: int, setpoint: float) -> None: + message = OWNMessage.parse(frame) + + assert isinstance(message, OWNHeatingEvent) + assert message.zone == zone + assert message.set_temperature == setpoint + + +def test_plain_setpoint_is_unchanged() -> None: + message = OWNMessage.parse("*#4*#1*14*0230*1##") + + assert isinstance(message, OWNHeatingEvent) + assert message.set_temperature == 23.0 + assert message.dimension_value == ["0230", "1"] + + +@pytest.mark.parametrize( + "frame", + [ + "*#4*#1*12*#0230*1##", # '#' value accepted for dimension 14 only + "*#1*11*1*#5##", # not for WHO 1 + "*#4*#1*14*0230*#1##", # only the setpoint may carry it + "*#4*#1*14*##0230*1##", # one '#', not two + "*#4*#1*14*#02#30*1##", # no '#' inside the value + "*#4*#1*14*#*1##", # a bare '#' is not a value + # The dimension 14 shape only: one or two values, a four-digit + # temperature starting with 0 or 1, ASCII digits. + "*#4*#1*14*#0230*##", # empty trailing field + "*#4*#1*14*#0230*1*2*3##", # too many values + "*#4*#1*14*#99999*1##", # not a temperature + "*#4*#1*14*#230*1##", # three digits + "*#4*#1*14*#\u0660\u0662\u0663\u0660*1##", # non-ASCII digits are not wire bytes + "*#4*#\u0661*14*#0230*1##", + ], +) +def test_hash_values_elsewhere_still_do_not_parse(frame: str) -> None: + assert OWNMessage.parse(frame) is None