Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions OWNd/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ class OWNMessage:
_DIMENSION_REQUEST_REPLY = re.compile(
r"^\*#(?P<who>\d+)\*(?P<where>#?\d+)?(?P<where_param>(?:#\d+)*)?\*(?P<dimension>\d+)(?P<dimension_param>(?:#\d+)*)?(?P<dimension_value>(?:\*\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"^\*#(?P<who>4)\*(?P<where>#?\d+)(?P<where_param>(?:#\d+)*)\*(?P<dimension>14)(?P<dimension_param>)(?P<dimension_value>\*#\d+(?:\*\d*)*)##$" # pylint: disable=line-too-long
),
)

""" Base class for all OWN messages """

Expand Down Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down
4 changes: 3 additions & 1 deletion OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
66 changes: 66 additions & 0 deletions tests/test_who4_hash_setpoint.py
Original file line number Diff line number Diff line change
@@ -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
Loading