diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a99e0..33b0f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to `OWNd` are documented in this file. - 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`). - `WHAT 39` end of movement (`*1*39*WHERE##`) from WHO 1 motion detectors: `message_type` `motion_detected` with `motion` `False`, new `OWNLightingEvent.motion_end`, `is_sensor` `True`, `is_on` `None`. It was `unknown_state` `39` before (`MyHOME#777`). +- **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. ## [2.0.0b11] - 2026-10-20 diff --git a/OWNd/message/base.py b/OWNd/message/base.py index 624d6b7..51894f7 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -108,6 +108,14 @@ class OWNMessage: _DIMENSION_REQUEST_REPLY = re.compile( r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*(?P\d+)(?P(?:#\d+)*)?(?P(?:\*\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 = ( + # *#13**27*C1#C2#..##: gateway model name as ASCII codes (live F461 2.0.11) + re.compile( + r"^\*#(?P13)\*(?P)(?P)\*(?P27)(?P)(?P\*\d+(?:#\d+)*)##$" # pylint: disable=line-too-long + ), + ) """ Base class for all OWN messages """ @@ -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 ( @@ -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 ( diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 5ed5632..55707d7 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,26 @@ 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. Every code must be ASCII + # digits (``int`` would also take Unicode digits) naming a + # printable ASCII character, else model_name stays None. + codes = self._dimension_value[0].split("#") + if all(c.isascii() and c.isdigit() and 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 +337,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..826c832 --- /dev/null +++ b/tests/test_who13_model_name.py @@ -0,0 +1,90 @@ +"""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 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 + + +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#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_is_not_decoded() -> None: + """``int("٧٠")`` is 70, but the frame is not the ASCII shape the gateways send.""" + message = OWNMessage.parse("*#13**27*\u0667\u0660#\u0665\u0662##") + + assert isinstance(message, OWNGatewayEvent) + assert message.model_name is None + assert "model name" not in message.human_readable_log +