Skip to content
Merged
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 @@ -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

Expand Down
32 changes: 32 additions & 0 deletions OWNd/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ 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).
# 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"^\*#(?P<who>4)\*(?P<where>#?[0-9]+)(?P<where_param>(?:#[0-9]+)*)\*(?P<dimension>14)(?P<dimension_param>)(?P<dimension_value>\*#[01][0-9]{3}(?:\*[0-9]+)?)##$", # pylint: disable=line-too-long
re.ASCII,
),
)

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

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