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 @@ -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

Expand Down
28 changes: 28 additions & 0 deletions OWNd/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ 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 = (
# *#13**27*C1#C2#..##: gateway model name as ASCII codes (live F461 2.0.11)
re.compile(
r"^\*#(?P<who>13)\*(?P<where>)(?P<where_param>)\*(?P<dimension>27)(?P<dimension_param>)(?P<dimension_value>\*\d+(?:#\d+)*)##$" # pylint: disable=line-too-long
),
)

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

Expand Down Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down
25 changes: 25 additions & 0 deletions OWNd/message/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -239,6 +240,25 @@ 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 printable
# ASCII, else model_name stays None.
codes = self._dimension_value[0].split("#")
if all(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."""
Expand Down Expand Up @@ -316,6 +336,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:
Expand Down
80 changes: 80 additions & 0 deletions tests/test_who13_model_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""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
Loading