diff --git a/CHANGELOG.md b/CHANGELOG.md index 655b70d..0838805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to `OWNd` are documented in this file. ### 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. - **`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 (`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 624d6b7..be8acc3 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -108,6 +108,14 @@ 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) + re.compile( + r"^\*#(?P4)\*(?P#?\d+)(?P(?:#\d+)*)\*(?P14)(?P)(?P\*#\d+(?:\*\d*)*)##$" # pylint: disable=line-too-long + ), + ) """ Base class for all OWN messages """ @@ -212,6 +220,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 +255,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 a9a38f9..c35e075 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -514,8 +514,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..dd57ad2 --- /dev/null +++ b/tests/test_who4_hash_setpoint.py @@ -0,0 +1,66 @@ +"""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, 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##" + + +@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 + ], +) +def test_hash_values_elsewhere_still_do_not_parse(frame: str) -> None: + assert OWNMessage.parse(frame) is None