Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e608ccc
docs: WHO 13 read form in the conformance matrix; #Z#C wording in act…
Nov 3, 2026
4e1132b
fix(who13): MAC octet range, ZigBee IEEE address, four-digit year, we…
Nov 3, 2026
3e682a6
fix(gate): keep mac_address unchecked, add valid_mac_address, ASCII-o…
Nov 3, 2026
486ad26
fix(gate): keep the "Gateway broadcasting internal" log for parsed cl…
Nov 3, 2026
66e3dfe
fix(gate): validate WHO 13 day and month before the year check (revie…
Nov 3, 2026
6115b8d
docs(gateway): note that requesting WHO 13 dimensions 10/11/12 crashe…
Nov 4, 2026
4b5b8a2
review: revert the WHO 13 runtime changes without committed plant tra…
Nov 4, 2026
b6e80e2
Merge remote-tracking branch 'origin/master' into fix/docs-and-who13-…
Nov 4, 2026
8cfff16
changelog: keep the review entries under [Unreleased] after merging m…
Nov 4, 2026
95e99ee
changelog: restore the blank line before the 2.0.0b12 heading
Nov 4, 2026
4da50e3
Merge remote-tracking branch 'origin/master' into fix/docs-and-who13-…
Nov 10, 2026
e501d6a
review: WHO 13 value fields are ASCII digits only; oracle suites and …
Nov 10, 2026
6eca707
ci: cover the Dimension 22 ASCII check and drop the dead Dimension 19…
Nov 10, 2026
fb5568d
ci: keep the Dimension 19 timedelta guard (absurd magnitudes raise) a…
Nov 10, 2026
4b941a9
ci: drop the Dimension 12 except that the ASCII gate made unreachable
Nov 10, 2026
fa54e90
Merge remote-tracking branch 'origin/master' into fix/docs-and-who13-…
Nov 12, 2026
a8573f2
test: pin the golden README fixture count at 172 after #159 landed
Nov 12, 2026
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 @@ -4,6 +4,7 @@ All notable changes to `OWNd` are documented in this file.

## [Unreleased]
### Fixed
- **WHO 13 (`OWNGatewayEvent`, `OWNGatewayCommand`)**: value fields are read only when they are ASCII digits (`str.isdigit` and `int` also accept fullwidth digits, which the frame grammar's `\d` lets through). Dimensions 12, 15 and 19: `*#13**12*0*3*80*1*214*135##` decoded a MAC address, `*#13**19*1*2*3*4##` an uptime and `*#13**15*23##` an `Unknown (23)` device type; such a frame now stays an untyped event with the raw frame as its log, as the Dimension 27 allowlist already did. Clock fields (Dimensions 0, 1 and 22, replies and writes): a fullwidth digit in the time, date or timezone (`*#13**0*12*00*00*002##`, `*#13**1*03*26*10*2026##`, `*#13**0*12*00*00*001##`) raised a `ValueError` out of `date()` / `fromisoformat` through `OWNMessage.parse`, or in the timezone matched `\d` and reached `fromisoformat`; they now raise the same `ValueError("Invalid gateway time / date / datetime / timezone")` as any other malformed clock value, which the sessions catch (#144; `_gateway_timezone` matches with `re.ASCII`). Every frame made of ASCII digits decodes exactly as before; ASCII letters or signs in a value never parsed.
- **WHO 15 / WHO 25 (`OWNCENEvent`, `OWNDryContactEvent`)**: a frame without a WHAT no longer reads as an event. `OWNEvent.parse("*#15*21##")` / `OWNMessage.parse("*#15*21*1*0##")` gave an `OWNCENEvent` with `is_pressed` `True` and the log "Button None of CEN object 21 has been pressed." (a phantom short press downstream); it now has `is_pressed` `False` and a log naming the status form. `OWNEvent.parse("*#25*339##")` / `OWNMessage.parse("*#25*339*1*0##")` gave an `OWNDryContactEvent` with `is_on` `False` and `is_detection` `True` ("Sensor 39 detected OFF."); `is_on` is now `None`, `is_detection` `False`, with a log naming the form. A frame with an empty WHAT tag (`*15**21##`, `*25**339##`) is treated the same. On the command side `OWNMessage.parse("*#15*21##")` / `("*#25*339##")` stay the status requests (`OWNCenCommand`, `OWNDryContactCommand`). WHAT 31 / 32 frames and every CEN pushbutton frame decode exactly as before (`OWNAlarmEvent`, `OWNDoorEntryEvent` and `OWNIntercomEvent` already failed closed on a missing WHAT).
- **WHO 15 / WHO 25 (`OWNCenCommand`, `OWNCenPlusCommand`)**: the button argument refuses `bool` and fractional values with `ValueError`: `OWNCenCommand.press("21", True)` built `*15*01*21##` and `OWNCenPlusCommand.press("21", 1.9)` built `*25*21#1*21##` through `int()`. Integers and digit strings `0..31` are accepted as before.
- **CLI (`python -m OWNd`)**: no longer crashes at startup on Python 3.14 with `RuntimeError: There is no current event loop in thread 'MainThread'`. The entry point relied on `asyncio.get_event_loop()` creating a loop implicitly, which Python 3.14 removed; it now runs through `asyncio.run()`. A startup failure now ends the command with a traceback and a non-zero exit code instead of leaving it running until Ctrl-C: an exception during startup, a gateway that cannot be reached (after the usual 5 connection attempts: about 13 s when the port refuses the connection, up to about 63 s when the host does not answer at all and each attempt times out after 10 s with `OWNTimeoutError`) and a rejected password (at once). The first connection is opened with `connect(raise_on_error=True)`; once connected, a dropped connection is still reconnected automatically. The event session is closed on every exit path. Without `--port`, the entry point probes the network for the gateway description; when nothing answers it now exits with `OWNConnectionError` (`discovery_failed`) if no `--address` was given, or connects directly to `--address` on the default port 20000, instead of failing on an assertion in the session with no connection attempt; and when the probe did find the gateway, a `--password` / `--mac` given on the command line fills in what its description does not carry (the probe used to drop both, so a password-protected gateway was refused with `password_required`). The library API is not affected.
Expand Down
92 changes: 77 additions & 15 deletions OWNd/message/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@ def _validate_gateway_clock_values(
)


def _is_ascii_digits(value: str) -> bool:
"""Only ASCII digits are a wire value: ``str.isdigit`` also accepts fullwidth,
superscript and circled digits, which ``int`` converts (or rejects) but which
are not on the bus (Encyclopedia ``protocol/frame-syntax.md``)."""
return bool(value) and value.isascii() and value.isdigit()


def _gateway_timezone(values: list[str]) -> str:
"""Decode an optional OWN timezone, preserving unspecified local time."""
value = values[3] if len(values) > 3 else ""
if not value or value == "999":
return ""
if re.fullmatch(r"[01]\d{2}", value) is None:
# re.ASCII: a fullwidth "001" matched \d and reached fromisoformat.
if re.fullmatch(r"[01]\d{2}", value, re.ASCII) is None:
raise ValueError(f"Invalid gateway timezone: {value!r}")
sign = "+" if value[0] == "0" else "-"
return f"{sign}{value[1:]}:00"
Expand All @@ -36,7 +44,15 @@ def _gateway_timezone(values: list[str]) -> str:
# WHO 13 Dimension 15: Functional gateway model/device type (*#13**15*<TYPE>##).
# Grounded in published BTicino protocol tables, physical plant captures,
# and firmware reverse engineering (bt_device dispatch table at VA 0x02003194,
# selector r4 == 0).
# selector r4 == 0). The published table (Encyclopedia
# who-13-integration-gateway/dimensions.md "DIMENSION 15 - Device type") has
# 2, 4, 6, 7, 11 and 13 and says it is not exhaustive; the other codes come
# from firmware reverse engineering and plant captures. A code that is not in
# this table stays an unknown code (``device_type`` reads ``Unknown (N)``,
# ``device_type_code`` keeps ``N``), and a non-unique code (``200``) is not a
# product identity: ``device_type`` lists the gateways known to share it and
# WHO 1013 Dimension 1 tells them apart (capabilities.md
# "Product identification").
WHO13_DEVICE_TYPES: dict[str, str] = {
"2": "MHServer",
"4": "MH200",
Expand Down Expand Up @@ -143,6 +159,8 @@ def __init__(self, data: str) -> None:
if len(self._dimension_value) > 3 and self._dimension_value[3]
else None
)
if not all(_is_ascii_digits(v) for v in (self._hour, self._minute, self._second)):
raise ValueError(f"Invalid gateway time: {self._dimension_value!r}")
self._timezone = _gateway_timezone(self._dimension_value)
self._time = _dt.time.fromisoformat(
f"{self._hour}:{self._minute}:{self._second}{self._timezone}"
Expand All @@ -155,6 +173,8 @@ def __init__(self, data: str) -> None:
self._year = self._dimension_value[3]
self._month = self._dimension_value[2]
self._day = self._dimension_value[1]
if not all(_is_ascii_digits(v) for v in (self._year, self._month, self._day)):
raise ValueError(f"Invalid gateway date: {self._dimension_value!r}")
self._date = _dt.date(
year=int(self._year), month=int(self._month), day=int(self._day)
)
Expand All @@ -169,14 +189,17 @@ def __init__(self, data: str) -> None:
self._netmask = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}.{self._dimension_value[3]}" # pylint: disable=line-too-long
self._human_readable_log = f"Gateway's netmask is: {self._netmask}."

elif self._dimension == 12 and len(self._dimension_value) >= 6:
try:
self._mac_address = f"{int(self._dimension_value[0]):02x}:{int(self._dimension_value[1]):02x}:{int(self._dimension_value[2]):02x}:{int(self._dimension_value[3]):02x}:{int(self._dimension_value[4]):02x}:{int(self._dimension_value[5]):02x}" # pylint: disable=line-too-long
self._human_readable_log = f"Gateway's MAC address is: {self._mac_address}."
except (IndexError, TypeError, ValueError):
pass
elif (
self._dimension == 12
and len(self._dimension_value) >= 6
and all(_is_ascii_digits(v) for v in self._dimension_value[:6])
):
# Six ASCII-digit fields (checked above): int() cannot raise here;
# an empty or non-ASCII field leaves the frame untyped instead.
self._mac_address = f"{int(self._dimension_value[0]):02x}:{int(self._dimension_value[1]):02x}:{int(self._dimension_value[2]):02x}:{int(self._dimension_value[3]):02x}:{int(self._dimension_value[4]):02x}:{int(self._dimension_value[5]):02x}" # pylint: disable=line-too-long
self._human_readable_log = f"Gateway's MAC address is: {self._mac_address}."

elif self._dimension == 15 and self._dimension_value:
elif self._dimension == 15 and self._dimension_value and _is_ascii_digits(self._dimension_value[0]):
code = self._dimension_value[0]
self._device_type = WHO13_DEVICE_TYPES.get(code, f"Unknown ({code})")
self._human_readable_log = f"Gateway device type is: {self._device_type}."
Expand All @@ -190,8 +213,10 @@ def __init__(self, data: str) -> None:
elif (
self._dimension == 19
and len(self._dimension_value) >= 4
and all(self._dimension_value[:4])
and all(_is_ascii_digits(v) for v in self._dimension_value[:4])
):
# Four ASCII-digit fields: int() cannot raise, but timedelta does
# on an absurd magnitude (tests/test_message_exhaustive.py).
try:
self._uptime = _dt.timedelta(
days=int(self._dimension_value[0]),
Expand All @@ -200,7 +225,7 @@ def __init__(self, data: str) -> None:
seconds=int(self._dimension_value[3]),
)
self._human_readable_log = f"Gateway's uptime is: {self._uptime}."
except (IndexError, TypeError, ValueError):
except (OverflowError, ValueError):
pass

elif self._dimension == 22:
Expand All @@ -216,6 +241,11 @@ def __init__(self, data: str) -> None:
self._day = self._dimension_value[5]
self._month = self._dimension_value[6]
self._year = self._dimension_value[7]
if not all(
_is_ascii_digits(v)
for v in (self._hour, self._minute, self._second, self._day, self._month, self._year)
):
raise ValueError(f"Invalid gateway datetime: {self._dimension_value!r}")
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
)
Expand Down Expand Up @@ -318,7 +348,15 @@ def firmware_version(self) -> str | None:

@property
def device_type(self) -> str | None:
"""Return the decoded gateway device type string."""
"""Return the decoded gateway device type string.

``Unknown (N)`` for a Dimension 15 code outside ``WHO13_DEVICE_TYPES``
(the published table is not exhaustive and an unlisted code stays an
unknown code); the non-unique ``200`` reads as the slash-separated list
of the gateways known to share it, not as one product: identify them
through WHO 1013 Dimension 1 (``OWNGatewayDiagnosticEvent``).
``device_type_code`` keeps the raw code either way.
"""
return self._device_type

@property
Expand Down Expand Up @@ -376,6 +414,8 @@ def __init__(self, data: str) -> None:
if len(self._dimension_value) > 3 and self._dimension_value[3]
else None
)
if not all(_is_ascii_digits(v) for v in (self._hour, self._minute, self._second)):
raise ValueError(f"Invalid gateway time: {self._dimension_value!r}")
self._timezone = _gateway_timezone(self._dimension_value)
self._time = _dt.time.fromisoformat(
f"{self._hour}:{self._minute}:{self._second}{self._timezone}"
Expand All @@ -388,6 +428,8 @@ def __init__(self, data: str) -> None:
self._year = self._dimension_value[3]
self._month = self._dimension_value[2]
self._day = self._dimension_value[1]
if not all(_is_ascii_digits(v) for v in (self._year, self._month, self._day)):
raise ValueError(f"Invalid gateway date: {self._dimension_value!r}")
self._date = _dt.date(
year=int(self._year), month=int(self._month), day=int(self._day)
)
Expand All @@ -408,6 +450,11 @@ def __init__(self, data: str) -> None:
self._day = self._dimension_value[5]
self._month = self._dimension_value[6]
self._year = self._dimension_value[7]
if not all(
_is_ascii_digits(v)
for v in (self._hour, self._minute, self._second, self._day, self._month, self._year)
):
raise ValueError(f"Invalid gateway datetime: {self._dimension_value!r}")
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
)
Expand Down Expand Up @@ -619,17 +666,32 @@ def request_date(cls) -> OWNGatewayCommand:

@classmethod
def request_ip_address(cls) -> OWNGatewayCommand:
"""Request the gateway IP address (Dimension 10)."""
"""Request the gateway IP address (Dimension 10).

own-firmware-oracle ``who13-identification``: answered by 8 of the 13
emulated gateways and ACKed silently by F453, F453AV and MH200N, but
H4684 020054 and MH200 020102 NACK it and crash their worker process.
"""
return cls("*#13**10##")

@classmethod
def request_netmask(cls) -> OWNGatewayCommand:
"""Request the gateway netmask (Dimension 11)."""
"""Request the gateway netmask (Dimension 11).

own-firmware-oracle ``who13-identification``: answered by 8 of the 13
emulated gateways and ACKed silently by F453, F453AV and MH200N, but
H4684 020054 and MH200 020102 NACK it and crash their worker process.
"""
return cls("*#13**11##")

@classmethod
def request_mac_address(cls) -> OWNGatewayCommand:
"""Request the gateway MAC address (Dimension 12)."""
"""Request the gateway MAC address (Dimension 12).

own-firmware-oracle ``who13-identification``: answered by 8 of the 13
emulated gateways and ACKed silently by F453, F453AV and MH200N, but
H4684 020054 and MH200 020102 NACK it and crash their worker process.
"""
return cls("*#13**12##")

@classmethod
Expand Down
8 changes: 5 additions & 3 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,9 +1080,11 @@ def actuators_status(cls, where: str | int) -> OWNHeatingCommand:

``where`` is a zone of one or two digits (``2``, ``#2``), one actuator
(``2#1``; ``2#0`` for all actuators of the zone), the central-unit
form (``#0``, ``#0#N``) for every zone, or ``#Z#N`` (an actuator
addressed through the central unit; WHO 4 WHERE table, see
``_zone_address_and_name``). Each form may be routed through a bus interface
form (``#0``, ``#0#C``) for every zone, or ``#Z#C`` (zone ``Z``
through a four-zone central unit ``C``: Encyclopedia
who-4-temperature-control/addressing.md "Historical central-unit
variants", ``#Z#C`` is a controlled-zone address, not an actuator;
see ``_zone_address_and_name``). Each form may be routed through a bus interface
with a two-digit ``#4#NN`` suffix (``3#4#01``, ``#3#4#01``,
``2#1#4#01``) because the suffix is spec-legal grammar, but no
emulated firmware routes it across an F422: own-firmware-oracle#65
Expand Down
4 changes: 2 additions & 2 deletions docs/protocol_conformance_matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ To prevent divergence and eliminate guesswork, every protocol frame and capabili
| **4** | **Thermoregulation** | Probe queries (`21.5°C`), Setpoints, Antifreeze, Dimension 12 setpoint after offset (`-4.8°C`) | `*#4*1*0*0215##`, `*#4*6*12*1048*3##`, `*#4*1*#14*0200*1##` | Valid | Matched | **VERIFIED (100%)** |
| **5** | **Burglar Alarm** | Zone status, CU status, Silent alarms, System arm/disarm | `*5*1*1##`, `*#5*1##`, `*5*2*2##` | Valid | Matched | **VERIFIED (100%)** |
| **9** | **Auxiliary** | Relay ON/OFF commands across auxiliary channels | `*9*1*1##`, `*9*0*1##` | Valid | Matched | **VERIFIED (100%)** |
| **13** | **Gateway Mgmt** | Firmware version, Gateway internal datetime requests | `*#13*0*0##`, `*#13*0*22##` | Valid | Matched | **VERIFIED (100%)** |
| **13** | **Gateway Mgmt** | Firmware version, Gateway internal datetime requests | `*#13**0##`, `*#13**22##` | Valid | Matched | **VERIFIED (100%)** |
| **15** | **CEN Scenarios** | Press, short release, long release, extended hold | `*15*01*73##`, `*15*01#1*73##`, `*15*01#2*0110##`, `*15*01#3*0110##` | Valid | Matched | **VERIFIED (100%)** |
| **18** | **Energy Mgmt** | Instantaneous active power (W), Cumulative energy (Wh) as read by OWNd (PDF: DIMENSION 51..54 'Watt', only 72 'Wh') | `*#18*51*113##`, `*#18*51*51##`, `*#18*51*52#26#9##` | Valid | Matched | **VERIFIED (100%)** |
| **25** | **CEN+ / Dry Contacts**| Short press; start, hold and release of an extended press; WHERE `2` + object 0–2047 | `*25*21#1*21##`, `*25*22#1*21##`, `*25*23#1*21##`, `*25*24#1*21##` | Valid | Matched | **VERIFIED (100%)** |

Sources for the examples changed in [OWNd#77](https://github.com/OpenWebNet-HA/OWNd/issues/77). The MyHomeServer1 firmware, run on an emulated bus, produces a bus frame for each of them and refuses the examples they replace. The WHO 15 press and short release (`*15*01*73##`, `*15*01#1*73##`) were captured on an MH200N + MyHomeServer1 bus in [MyHOME#541](https://github.com/OpenWebNet-HA/MyHOME/issues/541). Long release and extended hold (`#2`, `#3`) rest on the firmware alone. The WHO 25 examples are an MH201 capture from [MyHOME#418](https://github.com/OpenWebNet-HA/MyHOME/issues/418). The energy request `*#18*51*52#26#9##` was accepted by a real MyHomeServer1.
Sources for the examples changed in [OWNd#77](https://github.com/OpenWebNet-HA/OWNd/issues/77). The MyHomeServer1 firmware, run on an emulated bus, produces a bus frame for each of them and refuses the examples they replace. The WHO 15 press and short release (`*15*01*73##`, `*15*01#1*73##`) were captured on an MH200N + MyHomeServer1 bus in [MyHOME#541](https://github.com/OpenWebNet-HA/MyHOME/issues/541). Long release and extended hold (`#2`, `#3`) rest on the firmware alone. The WHO 25 examples are an MH201 capture from [MyHOME#418](https://github.com/OpenWebNet-HA/MyHOME/issues/418). The energy request `*#18*51*52#26#9##` was accepted by a real MyHomeServer1. The WHO 13 reads use the published empty-`WHERE` form `*#13**DIMENSION##` (Encyclopedia who-13-integration-gateway/dimensions.md "Read form"): in own-firmware-oracle (`ownd-audit-frames` and `who13-clock`) all thirteen emulated gateways answer `*#13**0##` and `*#13**22##` with ACK and the current time, while the `WHERE 0` spelling `*#13*0*0##` this table used to show is `nack, silent` on all thirteen (`ownd-audit-frames`); `OWNGatewayCommand.request_time()` and the event keepalive emit `*#13**0##`.

---

Expand Down
Loading
Loading