Skip to content
Merged
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 @@ -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`).
Expand Down
11 changes: 11 additions & 0 deletions OWNd/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ class OWNMessage:
r"^\*#(?P<who>4)\*(?P<where>#?[0-9]+)(?P<where_param>(?:#[0-9]+)*)\*(?P<dimension>14)(?P<dimension_param>)(?P<dimension_value>\*#[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"^\*#(?P<who>13)\*(?P<where>)(?P<where_param>)\*(?P<dimension>27)(?P<dimension_param>)(?P<dimension_value>\*[0-9]{1,3}(?:#[0-9]{1,3})+)##$", # pylint: disable=line-too-long
re.ASCII,
),
)

""" Base class for all OWN messages """
Expand Down
27 changes: 27 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,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."""
Expand Down Expand Up @@ -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:
Expand Down
122 changes: 122 additions & 0 deletions tests/test_who13_model_name.py
Original file line number Diff line number Diff line change
@@ -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
Loading