From 3ffe8a86cf9db5b9703abbe4afe6b10688a41e31 Mon Sep 17 00:00:00 2001 From: gdluck Date: Thu, 29 Oct 2026 09:15:26 +0200 Subject: [PATCH] feat(gateway): WHO 13 dimension 27 model-name decoder *#13**27*C1#C2#...## is the gateway model name as '#'-separated ASCII codes: 70#52#54#49 is "F461" on a live F461 2.0.11 and 77#121#...#49 is "MyHomeServer1" on a live MyHomeServer1 3.87.13 (MyHOME#799 traces). OWNGatewayEvent.model_name decodes that single '#'-separated value; the '*'-separated form and a single code stay undecoded. The frame pattern joins _HASH_VALUE_REPLIES bound to ASCII digits with an explicit (?:#[0-9]+)+ grammar, so non-ASCII digit codes are no frame at all. OWNEvent.parse and OWNMessage.parse agree. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + OWNd/message/base.py | 11 +++ OWNd/message/gateway.py | 27 ++++++++ tests/test_who13_model_name.py | 122 +++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 tests/test_who13_model_name.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a75d5..4b7447e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `OWNd` are documented in this file. ## [Unreleased] ### Added +- **WHO 13 (`OWNGatewayEvent.model_name`)**: Dimension 27 model name reply (`*#13**27*70#52#54#49##` = `F461` on a live F461 2.0.11; `77#121#…#49` = `MyHomeServer1` on a live MyHomeServer1 3.87.13, `MyHOME#799`) parses instead of returning `None`; `human_readable_log` reads "Gateway's model name is: F461.". Only the single `#`-separated value is decoded. Non-ASCII digit codes are not a frame (`OWNMessage.parse` returns `None`). - **WHO 1 (`OWNLightingCommand`, `OWNLightingEvent`)**: - Automatic detection of legacy pre-`09W29` BTicino `F414` coarse-only dimmers via `DIMENSION 1` / `DIMENSION 4` transition speed `240` (`0xF0`, `DIMMER_SPEED_COARSE_ONLY`) with multi-gateway plant isolation (`gateway=` scoping on `OWNMessage.parse`, `OWNEvent.parse`, `OWNEventSession.get_next`, `is_coarse_dimmer_where`, `register_coarse_dimmer_where`, `clear_coarse_dimmer_wheres`, and `OWNLightingCommand.set_brightness`) (`#134`, `MyHOME#767`). - Canonical `libqtdevices` (`TS10_1_0_23`) coarse `WHAT 2..10` $\leftrightarrow$ fine `1..100%` conversion helpers (`COARSE_PRESET_TO_PERCENT`, `brightness_preset_to_percent`, `percent_to_brightness_preset`), `OWNLightingEvent.is_coarse_dimmer`, `OWNLightingEvent.brightness_preset_percent`, and `OWNLightingCommand.set_brightness(..., coarse=..., gateway=...)` (`#134`). diff --git a/OWNd/message/base.py b/OWNd/message/base.py index 9d74b1e..88db23f 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -119,6 +119,17 @@ class OWNMessage: r"^\*#(?P4)\*(?P#?[0-9]+)(?P(?:#[0-9]+)*)\*(?P14)(?P)(?P\*#[01][0-9]{3}(?:\*[0-9]+)?)##$", # pylint: disable=line-too-long re.ASCII, ), + # *#13**27*C1#C2#..##: gateway model name as '#'-separated ASCII codes + # (live F461 2.0.11 and MyHomeServer1 3.87.13, MyHOME#799). The '#' + # grammar is explicit ((?:#[0-9]+)+): every known BTicino / Legrand + # model string has more than one character, and a single code + # (*#13**27*70##) is indistinguishable from a plain numeric reply, + # which _DIMENSION_REQUEST_REPLY already parses. ASCII digits only, + # at most three per code (printable ASCII is 32..126). + re.compile( + r"^\*#(?P13)\*(?P)(?P)\*(?P27)(?P)(?P\*[0-9]{1,3}(?:#[0-9]{1,3})+)##$", # pylint: disable=line-too-long + re.ASCII, + ), ) """ Base class for all OWN messages """ diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 5ed5632..743fc9b 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -130,6 +130,7 @@ def __init__(self, data: str) -> None: self._kernel_version = None self._distribution_version = None + self._model_name: str | None = None _validate_gateway_clock_values(self._dimension, self._dimension_value) @@ -239,6 +240,27 @@ def __init__(self, data: str) -> None: f"Gateway's distribution version is: {self._distribution_version}." ) + elif ( + self._dimension == 27 + and len(self._dimension_value) == 1 + and "#" in self._dimension_value[0] + ): + # Model name as one '#'-separated value of ASCII codes: + # *#13**27*70#52#54#49## is "F461" (live F461 2.0.11, 2026-10-23), + # *#13**27*77#121#...#49## is "MyHomeServer1" (live 3.87.13, + # 2026-10-24; MyHOME#799). Only that shape is decoded: the general + # '*'-separated form (*#13**27*70*52*54*49##) and a single code + # without '#' are not a model name. The frame pattern + # (``_HASH_VALUE_REPLIES``, ASCII digits only) admits the shape; + # every code must name a printable ASCII character, else + # model_name stays None. + codes = self._dimension_value[0].split("#") + if all(32 <= int(c) < 127 for c in codes): + self._model_name = "".join(chr(int(c)) for c in codes) + self._human_readable_log = ( + f"Gateway's model name is: {self._model_name}." + ) + @property def time(self) -> _dt.time | None: """Return the decoded gateway internal time.""" @@ -316,6 +338,11 @@ def distribution_version(self) -> str | None: """Return the decoded gateway distribution version string.""" return self._distribution_version + @property + def model_name(self) -> str | None: + """Return the gateway model name of a Dimension 27 reply (e.g. ``"F461"``).""" + return self._model_name + class OWNGatewayCommand(OWNCommand): def __init__(self, data: str) -> None: diff --git a/tests/test_who13_model_name.py b/tests/test_who13_model_name.py new file mode 100644 index 0000000..64e356d --- /dev/null +++ b/tests/test_who13_model_name.py @@ -0,0 +1,122 @@ +"""WHO 13 Dimension 27: gateway model name as ``#``-separated ASCII codes. + +Live F461 2.0.11 (2026-10-23): ``*#13**27##`` -> ``*#13**27*70#52#54#49##`` +("F461"). Live MyHomeServer1 3.87.13 (2026-10-24): +``*#13**27*77#121#72#111#109#101#83#101#114#118#101#114#49##`` +("MyHomeServer1"). Both are in the MyHOME#799 trace fixtures. No oracle suite +sends ``*#13**27##``. +""" + +from __future__ import annotations + +import pytest + +from OWNd.message import OWNEvent, OWNGatewayCommand, OWNGatewayEvent, OWNMessage + + +@pytest.mark.parametrize( + ("frame", "value", "name"), + [ + ("*#13**27*70#52#54#49##", "70#52#54#49", "F461"), + ( + "*#13**27*77#121#72#111#109#101#83#101#114#118#101#114#49##", + "77#121#72#111#109#101#83#101#114#118#101#114#49", + "MyHomeServer1", + ), + ], +) +def test_model_name_reply_parses(frame: str, value: str, name: str) -> None: + message = OWNMessage.parse(frame) + + assert isinstance(message, OWNGatewayEvent) + assert message.is_dimension_reply + assert message.dimension == 27 + assert message.dimension_value == [value] + assert message.dimension_param == [] + assert message.model_name == name + assert message.human_readable_log == f"Gateway's model name is: {name}." + assert str(message) == frame + + +@pytest.mark.parametrize( + "frame", + [ + "*#13**27*70#52#54#49##", + "*#13**27*77#121#72#111#109#101#83#101#114#118#101#114#49##", + "*#13**27*70#7#300##", + "*#13**27*70*52*54*49##", + ], +) +def test_event_parse_matches_message_parse(frame: str) -> None: + """The event session (``OWNEvent.parse``) decodes the same object as ``OWNMessage.parse``.""" + event = OWNEvent.parse(frame) + message = OWNMessage.parse(frame) + + assert isinstance(event, OWNGatewayEvent) + assert type(event) is type(message) + assert str(event) == str(message) == frame + assert event.dimension_value == message.dimension_value + assert event.model_name == message.model_name + assert event.human_readable_log == message.human_readable_log + + +def test_model_name_with_non_printable_codes_is_not_decoded() -> None: + message = OWNMessage.parse("*#13**27*70#7#300##") + + assert isinstance(message, OWNGatewayEvent) + assert message.model_name is None + + +@pytest.mark.parametrize( + "frame", + [ + "*#13**27*70*52*54*49##", # the general '*'-separated form is not a name + "*#13**27*70##", # a single code without '#' is not a name + ], +) +def test_other_dimension_27_shapes_are_not_decoded(frame: str) -> None: + message = OWNMessage.parse(frame) + + assert isinstance(message, OWNGatewayEvent) + assert message.model_name is None + assert "model name" not in message.human_readable_log + + +def test_model_name_request_is_a_gateway_command() -> None: + message = OWNMessage.parse("*#13**27##") + + assert isinstance(message, OWNGatewayCommand) + assert message.is_dimension_request + + +@pytest.mark.parametrize( + "frame", + [ + "*#13**27*70#52#54#49*1##", # a second value after the codes + "*#13**27*70#1234##", # a code longer than three digits is no character + "*#13**27*70#" + "9" * 5000 + "##", # never reaches int() on a huge string + "*#13**27*70#52*54#49##", # mixed separators + "*#13**12*70#52##", # another dimension with a '#' value + "*#1*11*1*#5##", # not for WHO 1 + ], +) +def test_hash_values_elsewhere_still_do_not_parse(frame: str) -> None: + assert OWNMessage.parse(frame) is None + + +def test_model_name_with_unicode_digit_codes_does_not_parse() -> None: + """``int("٧٠")`` is 70, but non-ASCII digits are not OpenWebNet wire bytes. + + ``OWNMessage.parse`` has no pattern for the frame and returns ``None``; + the event session's ``OWNEvent.parse`` builds its usual invalid event for + an unmatched WHO 13 frame (as for any unparsed frame), with no name. + """ + frame = "*#13**27*\u0667\u0660#\u0665\u0662##" + + assert OWNMessage.parse(frame) is None + + event = OWNEvent.parse(frame) + assert isinstance(event, OWNGatewayEvent) + assert not event.is_valid + assert event.model_name is None + assert "model name" not in event.human_readable_log