From 5b588fc0a7101196bd2508974d285d38c9ffb538 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 18:35:51 +0300 Subject: [PATCH 1/2] fix(gateway): WHO 13 clock echoes with timezone 0530/124 no longer raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit own-firmware-oracle who13-clock: `*#13**#0*14*30*00*0530##` is ACKed and echoed on the event session by nine gateways (F454, F459 NACK; MyHomeServer1 silent), `…*124##` by eleven, and the F450 reports the stored `0530` on later reads (who13-clock-readback). `_gateway_timezone` raised ValueError on both and the exception left the event session. The two measured values define two bounded classes, a four-digit value with a sign digit and a three-digit negative offset of 24 h or more. Their layout and meaning are unverified, so `timezone` is None (unknown), never "" (local time), `raw_timezone` keeps the value, and the time / datetime are naive. `200`, `099`, `2000`, `05300` and the like still raise (MyHOME tests/test_gateway_regressions.py pins `099` and `200`). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + OWNd/message/gateway.py | 43 ++++++-- tests/test_who13_clock_echo_timezones.py | 131 +++++++++++++++++++++++ 3 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 tests/test_who13_clock_echo_timezones.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 655b70d..ff384a5 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 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 diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 5ed5632..15bcf5e 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -22,11 +22,30 @@ 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 (``200``, ``099``, ``2000``). + """ value = values[3] if len(values) > 3 else "" if not value or value == "999": return "" + if _UNMAPPED_TIMEZONE_REGEX.fullmatch(value): + return None if re.fullmatch(r"[01]\d{2}", value) is None: raise ValueError(f"Invalid gateway timezone: {value!r}") sign = "+" if value[0] == "0" else "-" @@ -144,7 +163,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}." @@ -216,7 +235,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 = ( @@ -256,7 +275,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 @@ -351,7 +374,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}." @@ -382,7 +405,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 = ( @@ -411,7 +434,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 diff --git a/tests/test_who13_clock_echo_timezones.py b/tests/test_who13_clock_echo_timezones.py new file mode 100644 index 0000000..f06b379 --- /dev/null +++ b/tests/test_who13_clock_echo_timezones.py @@ -0,0 +1,131 @@ +"""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") From da77a53ab54497d5fe6ccb259f96c6228aa3961b Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 18:40:22 +0200 Subject: [PATCH 2/2] fix(gateway): _gateway_timezone refuses +24..+99 itself, ASCII digits only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper's docstring said it raises ValueError for 099, but it returned "+99:00" and the error came from time.fromisoformat in the caller with a timedelta message. It now refuses a positive SHH whose hour is 24 or more (024..099) with its own message, and the shape check is ASCII-bound so a Unicode digit such as "0٢٣" no longer slips through to fromisoformat. Whole-frame behaviour is unchanged (those frames already raised); the docstring states the three classes exactly and two helper-level tests pin them. Co-Authored-By: Claude Fable 5.1 --- OWNd/message/gateway.py | 9 +++++++-- tests/test_who13_clock_echo_timezones.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 15bcf5e..be2b19c 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -39,14 +39,19 @@ def _gateway_timezone(values: list[str]) -> str | None: 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 (``200``, ``099``, ``2000``). + 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 _UNMAPPED_TIMEZONE_REGEX.fullmatch(value): return None - if re.fullmatch(r"[01]\d{2}", value) is 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" diff --git a/tests/test_who13_clock_echo_timezones.py b/tests/test_who13_clock_echo_timezones.py index f06b379..82baace 100644 --- a/tests/test_who13_clock_echo_timezones.py +++ b/tests/test_who13_clock_echo_timezones.py @@ -129,3 +129,20 @@ def test_unset_timezone_is_still_local_time(raw: str) -> None: 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 +