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 13 (`OWNGatewayEvent`, `OWNGatewayCommand`)**: clock echoes and replies carrying a timezone the gateways ACK and echo but OWNd cannot map, sign + HHMM (`0530`) or a negative offset of 24 h or more (`124`), parse with `timezone` `None` (unknown) and `raw_timezone` as sent instead of raising `ValueError` (own-firmware-oracle `who13-clock`). `200`, `099`, `2000` and the like are still rejected.

## [2.0.0b11] - 2026-10-20

Expand Down
50 changes: 41 additions & 9 deletions OWNd/message/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,36 @@ def _validate_gateway_clock_values(
)


def _gateway_timezone(values: list[str]) -> str:
"""Decode an optional OWN timezone, preserving unspecified local time."""
# Timezone values the gateways accept and echo but that are not the documented
# `SHH` form (own-firmware-oracle `who13-clock`): a four-digit value with a
# sign digit (`0530`, written and echoed by nine gateways, stored by the F450)
# and a three-digit negative offset of 24 h or more (`124`, ACKed by eleven).
# `0530` and `124` are the two values measured; the classes are bounded by the
# sign digit and the length, and by the first offset `datetime.timezone` cannot
# hold (`-24:00`). Their layout and meaning are unverified, so they decode to
# None (unknown), never to "" (local time); `raw_timezone` keeps the value.
_UNMAPPED_TIMEZONE_REGEX = re.compile(r"[01][0-9]{3}|1(?:2[4-9]|[3-9][0-9])")


def _gateway_timezone(values: list[str]) -> str | None:
"""Decode an optional OWN timezone, preserving unspecified local time.

Returns ``""`` for an absent or ``999`` (unconfigured) value, an ISO
offset (``"+02:00"``) for the documented ``SHH`` form, ``None`` for the
shapes gateways echo but OWNd cannot map (see ``_UNMAPPED_TIMEZONE_REGEX``),
and raises ``ValueError`` for everything else: a shape that is not
``SHH`` (``200``, ``2000``, ``+02``) and a positive ``SHH`` whose hour is
24 or more (``024``..``099``), which ``datetime`` cannot hold either (the
negative ones, ``124``..``199``, are the unmapped class above).
"""
value = values[3] if len(values) > 3 else ""
if not value or value == "999":
return ""
if re.fullmatch(r"[01]\d{2}", value) is None:
if _UNMAPPED_TIMEZONE_REGEX.fullmatch(value):
return None
if re.fullmatch(r"[01][0-9]{2}", value, re.ASCII) is None:
raise ValueError(f"Invalid gateway timezone: {value!r}")
if int(value[1:]) > 23:
raise ValueError(f"Invalid gateway timezone: {value!r}")
sign = "+" if value[0] == "0" else "-"
return f"{sign}{value[1:]}:00"
Expand Down Expand Up @@ -144,7 +168,7 @@ def __init__(self, data: str) -> None:
)
self._timezone = _gateway_timezone(self._dimension_value)
self._time = _dt.time.fromisoformat(
f"{self._hour}:{self._minute}:{self._second}{self._timezone}"
f"{self._hour}:{self._minute}:{self._second}{self._timezone or ''}"
)
self._human_readable_log = (
f"Gateway's internal time is: {self._time}."
Expand Down Expand Up @@ -216,7 +240,7 @@ def __init__(self, data: str) -> None:
self._month = self._dimension_value[6]
self._year = self._dimension_value[7]
self._datetime = _dt.datetime.fromisoformat(
f"{self._year}-{self._month}-{self._day}T{self._hour}:{self._minute}:{self._second}{self._timezone}" # pylint: disable=line-too-long
f"{self._year}-{self._month}-{self._day}T{self._hour}:{self._minute}:{self._second}{self._timezone or ''}" # pylint: disable=line-too-long
)
self._date = self._datetime.date()
self._time = (
Expand Down Expand Up @@ -256,7 +280,11 @@ def datetime(self) -> _dt.datetime | None:

@property
def timezone(self) -> str | None:
"""Return the decoded timezone offset string (e.g. '+02:00', '-05:00', or '')."""
"""Return the decoded timezone offset (``'+02:00'``, ``''`` for local time).

``None`` means the gateway sent a timezone OWNd cannot map (``0530``,
``124``); ``raw_timezone`` keeps it as sent.
"""
return self._timezone

@property
Expand Down Expand Up @@ -351,7 +379,7 @@ def __init__(self, data: str) -> None:
)
self._timezone = _gateway_timezone(self._dimension_value)
self._time = _dt.time.fromisoformat(
f"{self._hour}:{self._minute}:{self._second}{self._timezone}"
f"{self._hour}:{self._minute}:{self._second}{self._timezone or ''}"
)
self._human_readable_log = (
f"Gateway broadcasting internal time: {self._time}."
Expand Down Expand Up @@ -382,7 +410,7 @@ def __init__(self, data: str) -> None:
self._month = self._dimension_value[6]
self._year = self._dimension_value[7]
self._datetime = _dt.datetime.fromisoformat(
f"{self._year}-{self._month}-{self._day}T{self._hour}:{self._minute}:{self._second}{self._timezone}" # pylint: disable=line-too-long
f"{self._year}-{self._month}-{self._day}T{self._hour}:{self._minute}:{self._second}{self._timezone or ''}" # pylint: disable=line-too-long
)
self._date = self._datetime.date()
self._time = (
Expand Down Expand Up @@ -411,7 +439,11 @@ def datetime(self) -> _dt.datetime | None:

@property
def timezone(self) -> str | None:
"""Return the decoded timezone offset string (e.g. '+02:00', '-05:00', or '')."""
"""Return the decoded timezone offset (``'+02:00'``, ``''`` for local time).

``None`` means the gateway sent a timezone OWNd cannot map (``0530``,
``124``); ``raw_timezone`` keeps it as sent.
"""
return self._timezone

@property
Expand Down
148 changes: 148 additions & 0 deletions tests/test_who13_clock_echo_timezones.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""WHO 13 clock echoes and replies with timezone values OWNd cannot map.

own-firmware-oracle ``who13-clock``: ``*#13**#0*14*30*00*0530##`` is ACKed and
echoed on the event session by F450, F453, F453AV, F460, F461, H4684, MH200,
MH200N and MH202 (F454 and F459 NACK it; MyHomeServer1 is silent);
``…*124##`` is ACKed by those nine plus F454 (no echo) and F459.
``who13-clock-readback``: the F450 reports the stored ``0530`` on later reads.
OWNd raised ``ValueError`` on both; the exception left the event session.

The values' layout and meaning are unverified, so ``timezone`` is ``None``
(unknown), never ``""`` (local time); ``raw_timezone`` keeps the value. The
accepted classes are bounded: a four-digit value with a sign digit
(``0000``..``1999``), and ``124``..``199`` (the first negative offset
``datetime.timezone`` cannot hold is ``-24:00``). Only ``0530`` and ``124``
were measured. MyHOME ``tests/test_gateway_regressions.py``
requires ``099`` and ``200`` to stay invalid.
"""

from __future__ import annotations

import datetime as dt

import pytest

from OWNd.message import OWNGatewayCommand, OWNGatewayEvent, OWNMessage


@pytest.mark.parametrize(
("frame", "raw_timezone"),
[
("*#13**#0*14*30*00*0530##", "0530"),
("*#13**#0*14*30*00*124##", "124"),
("*#13**#22*14*30*00*0530*03*21*10*2026##", "0530"),
("*#13**#22*14*30*00*124*03*21*10*2026##", "124"),
],
)
def test_clock_echo_with_unmapped_timezone_parses(frame: str, raw_timezone: str) -> None:
message = OWNMessage.parse(frame)

assert isinstance(message, OWNGatewayCommand)
assert message.raw_timezone == raw_timezone
assert message.timezone is None
assert message.is_timezone_unconfigured is False
assert message.time is not None
assert message.time.replace(tzinfo=None) == dt.time(14, 30, 0)
assert message.time.tzinfo is None


def test_clock_echo_with_unmapped_timezone_keeps_the_date() -> None:
message = OWNMessage.parse("*#13**#22*14*30*00*0530*03*21*10*2026##")

assert isinstance(message, OWNGatewayCommand)
assert message.date == dt.date(2026, 10, 21)
assert message.datetime == dt.datetime(2026, 10, 21, 14, 30, 0)


@pytest.mark.parametrize("raw", ["0530", "124"])
def test_clock_reply_with_unmapped_timezone_parses(raw: str) -> None:
"""The F450 read-back of a stored ``0530`` (``who13-clock-readback``)."""
message = OWNMessage.parse(f"*#13**0*14*30*00*{raw}##")

assert isinstance(message, OWNGatewayEvent)
assert message.raw_timezone == raw
assert message.timezone is None
assert message.time == dt.time(14, 30, 0)


@pytest.mark.parametrize("raw", ["0530", "124"])
def test_datetime_reply_with_unmapped_timezone_parses(raw: str) -> None:
message = OWNMessage.parse(f"*#13**22*14*30*00*{raw}*03*21*10*2026##")

assert isinstance(message, OWNGatewayEvent)
assert message.timezone is None
assert message.raw_timezone == raw
assert message.datetime == dt.datetime(2026, 10, 21, 14, 30, 0)


@pytest.mark.parametrize("raw", ["0000", "1999", "1240", "125", "199"])
def test_same_classes_at_their_boundaries_are_unmapped(raw: str) -> None:
"""Members of the two classes that were not measured: still ``None``."""
message = OWNMessage.parse(f"*#13**0*14*30*00*{raw}##")

assert isinstance(message, OWNGatewayEvent)
assert message.timezone is None


@pytest.mark.parametrize(
"raw",
[
"200", # MyHOME test_gateway_regressions: must stay invalid
"099", # MyHOME test_gateway_regressions: must stay invalid
"024", # +24:00 is not a datetime.timezone offset
"2",
"12",
"2000", # no sign digit
"9999",
"2400",
"05300", # five digits
"00530",
],
)
def test_other_malformed_timezones_are_still_rejected(raw: str) -> None:
with pytest.raises(ValueError):
OWNMessage.parse(f"*#13**0*14*30*00*{raw}##")


@pytest.mark.parametrize(
("raw", "offset"),
[
("000", "+00:00"),
("001", "+01:00"),
("002", "+02:00"),
("102", "-02:00"),
("013", "+13:00"),
("123", "-23:00"), # the last offset datetime.timezone can hold
],
)
def test_whole_hour_timezones_still_decode(raw: str, offset: str) -> None:
message = OWNMessage.parse(f"*#13**0*14*30*00*{raw}##")

assert isinstance(message, OWNGatewayEvent)
assert message.timezone == offset


@pytest.mark.parametrize("raw", ["999", ""])
def test_unset_timezone_is_still_local_time(raw: str) -> None:
message = OWNMessage.parse(f"*#13**0*14*30*00*{raw}##")

assert isinstance(message, OWNGatewayEvent)
assert message.timezone == ""
assert message.is_timezone_unconfigured is (raw == "999")


@pytest.mark.parametrize("raw", ["024", "050", "099", "200", "2000", "+02", "02", "0\u0662\u0663"])
def test_gateway_timezone_helper_raises_itself(raw: str) -> None:
"""The helper refuses the value; nothing is left for ``time.fromisoformat``."""
from OWNd.message.gateway import _gateway_timezone

with pytest.raises(ValueError, match="Invalid gateway timezone"):
_gateway_timezone(["12", "00", "00", raw])


@pytest.mark.parametrize(("raw", "offset"), [("000", "+00:00"), ("023", "+23:00"), ("123", "-23:00")])
def test_gateway_timezone_helper_keeps_whole_hours(raw: str, offset: str) -> None:
from OWNd.message.gateway import _gateway_timezone

assert _gateway_timezone(["12", "00", "00", raw]) == offset

Loading