From e608ccc3632bab55a4a43c8fe2bb87420f1d5dd1 Mon Sep 17 00:00:00 2001 From: gdluck Date: Tue, 3 Nov 2026 09:40:00 +0200 Subject: [PATCH 01/14] docs: WHO 13 read form in the conformance matrix; #Z#C wording in actuators_status report 21 G5 and the #140 leftover. docs/protocol_conformance_matrix.md showed `*#13*0*0##` / `*#13*0*22##` (WHERE 0) as the verified WHO 13 examples. The published read form has an empty WHERE, `*#13**DIMENSION##` (Encyclopedia who-13-integration-gateway/dimensions.md "Read form", capabilities.md "Command-session behavior"); the review-session oracle run of 2026-11-03 (audit_ency/ORACLE_CONFIRM.md row G5) has `*#13*0*0##` nack, silent on all twelve emulated gateways and `*#13**0##` answered on all twelve, and the builders (`request_time`, the event keepalive) already emit the empty form. OWNHeatingCommand.actuators_status called `#Z#N` "an actuator addressed through the central unit"; per who-4-temperature-control/addressing.md "Historical central-unit variants" the form is `#Z#C`, zone Z through a four-zone central unit C. Wording only; the accepted grammar is unchanged. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 +++ OWNd/message/heating.py | 8 +++++--- docs/protocol_conformance_matrix.md | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cd8538..9a95e576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ All notable changes to `OWNd` are documented in this file. - **WHO 16 (`OWNSoundCommand`, `OWNSoundEvent`)**: FM tuner manual frequency steps `step_frequency_up` / `step_frequency_down` (`*16*5001..5015*WHERE##` / `*16*5101..5115*WHERE##`, step 1..15; Encyclopedia `ownkb:chunk:r000371`, `ownkb:claim:c002096` / `c002098`; `5000` / `5100` stay the automatic seeks) and `store_station` (`*#16*WHERE*#10*STATION##`, station 1..5 only, the `bt_difson` ceiling of `ownkb:claim:c002128`; stations 6..15 go through WHO 22 `store_preset`). `*16*5001*101##`, `*16*5101*101##` and `*#16*101*#10*5##` are ACKed and put on the bus (`$0494018FA1`, `$0494018FB1`, `$0493018FD4`) by F453, F453AV, F454, H4684, MH200 and MH200N in the firmware oracle and NACKed by F450, F459, F460, F461, MH202 and MyHomeServer1; the tests pin all three rows. `OWNSoundEvent.frequency_step_forward` / `frequency_step_backward` decode WHAT 5001..5015 / 5101..5115. Dimension 10 is write-only (`bt_difson` never emits it, `ownkb:claim:c002135`): only the Dimension 7 station report reads into `track` (physical F500 `*#16*101*7*0*5##`, H/L4684 6.0.11, `MyHOME#839`). - **WHO 22 (`OWNSoundDiffusionCommand`, `OWNSoundDiffusionEvent`)**: Tuner builders `seek_up` (`*22*5*WHERE##`), `seek_down` (`*22*6*WHERE##`), `step_frequency_up` (`*22*5#STEP*WHERE##`) and `step_frequency_down` (`*22*6#STEP*WHERE##`), STEP 1..15 (Encyclopedia `ownkb:chunk:r000478`-`r000480`, `ownkb:claim:c002729`), WHERE defaulting to `6`; `*22*5*2#1##`, `*22*6*2#1##`, `*22*5#1*2#1##` and `*22*6#1*2#1##` are ACKed and put on the bus (`$0494018FA0` / `FB0` / `FA1` / `FB1`) by F453, F453AV, F454, F459, F460, F461, MH200N and MyHomeServer1 in the firmware oracle and join the WHO 22 oracle conformance rows; WHAT 7 / 8 are not emitted (`bt_multi` NACKs them as not implemented) and `*22*7*2#1##` / `*22*8*2#1##` are not in the oracle index. The event gains `is_seek_up` / `is_seek_down` (bare WHAT 5 / 6), `frequency_step` (1..15, `None` outside) with `is_step_frequency_up` / `is_step_frequency_down` (WHAT `5#STEP` / `6#STEP`), log lines for all four, and `is_preset_cleared`: `True` for a Dimension 11 report with an explicit preset `0` (`*#22*5#2#1*11*1*10530*0##`, defensive parsing of the spec-shaped frame), where `preset` stays `None` as for a frame without a preset field; `*#22*5#2#1*11*1*10530*5##` is the physical station-5 report of an F500 (H/L4684 6.0.11, `MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-48-15.json`). +### Changed +- **Docs**: `docs/protocol_conformance_matrix.md` shows the WHO 13 read form `*#13**0##` / `*#13**22##` (empty `WHERE`, dimensions.md "Read form") instead of `*#13*0*0##` / `*#13*0*22##`, which is `nack, silent` on all twelve emulated gateways (report 21 G5). `OWNHeatingCommand.actuators_status` docstring: `#Z#C` is zone `Z` through a four-zone central unit `C` (who-4-temperature-control/addressing.md "Historical central-unit variants"), not an actuator (#140 leftover). + ## [2.0.0b12] - 2026-11-01 ### Added diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 37f97d4b..e956408a 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -1012,9 +1012,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 diff --git a/docs/protocol_conformance_matrix.md b/docs/protocol_conformance_matrix.md index 46088ca2..c86c5548 100644 --- a/docs/protocol_conformance_matrix.md +++ b/docs/protocol_conformance_matrix.md @@ -31,12 +31,12 @@ To prevent divergence and eliminate guesswork, every protocol frame and capabili | **4** | **Thermoregulation** | Probe queries (`21.5°C`), Setpoints, Antifreeze, Negative Temp (`-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 (kWh) | `*#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"): on the twelve emulated gateways `*#13**0##` is answered with `*#13**0*H*M*S*T##` by all twelve, while the `WHERE 0` spelling `*#13*0*0##` this table used to show is `nack, silent` on all twelve (review-session oracle run of 2026-11-03, `audit_ency/ORACLE_CONFIRM.md` row G5; `OWNGatewayCommand.request_time()` and the event keepalive emit `*#13**0##`). --- From 4e1132b5fc0830197db8464218336a5197aafe05 Mon Sep 17 00:00:00 2001 From: gdluck Date: Tue, 3 Nov 2026 09:45:47 +0200 Subject: [PATCH 02/14] fix(who13): MAC octet range, ZigBee IEEE address, four-digit year, weekday, tz 100 and log wording report 21 G2, G3, G4, G6, G7, G8, G9, G10. G1 not changed: settled by OWNd#144 (closed 2026-10-28), master keeps raising ValueError for out-of-table clock values and the sessions catch it. G2 / G3: Dimension 12 decodes six decimal octets in 0..255 only (Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"); an octet above 255 or an eight-value reply (the ZigBee interface IEEE address, zigbee-network-management.md "DIMENSION 12 - Interface IEEE address", ENCY_CONFIRM ZB-108) leaves mac_address None with the values raw in dimension_value. G4 / G10: a Dimension 1 / 22 year must be four digits (dimensions.md "DIMENSION 1 - Date"): `*#13**1*04*15*10*26##` stays a valid message as on master, with date None instead of 0026-10-15. New `weekday` property, 0..6 from `00..06`, None outside, date unchanged and no calendar cross-check (the TS10 client writes a fixed `00`, ENCY_CONFIRM G10). G7: `T` 100 (GMT-0) reads `+00:00` like `000`. G6: a parsed write frame `*#13**#0/#1/#22*...##` logs "Setting gateway time/date to: ..." (dimensions.md "Write form": client -> gateway writes, ENCY_CONFIRM G6); the reply form built by report_* keeps "broadcasting". G8: an addressed Dimension 16 reply (`*#13*123400#9*16*1*2*3##`) logs "Product 123400#9's firmware version" (zigbee-network-management.md "DIMENSION 16 and 17", ENCY_CONFIRM ZB-109/110); firmware_version and where are unchanged. G9: device_type docstring and the WHO13_DEVICE_TYPES comment say that an unlisted code stays `Unknown (N)` and that the non-unique 200 is a list of sharers, not a product identity (capabilities.md "Product identification", dimensions.md "DIMENSION 15 - Device type"). Tests: tests/test_message_protocol.py (tz 100 and 0530 still raising, year / weekday, write-frame logs, MAC range, eight-value dimension 12, addressed dimension 16). The pinned-raise tests of tests/test_message_edges.py and test_malformed_gateway_clock_is_rejected are untouched. Golden fixtures unchanged. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 + OWNd/message/gateway.py | 164 ++++++++++++++++++++++++++++----- tests/test_message_protocol.py | 145 +++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a95e576..191ca5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to `OWNd` are documented in this file. - **WHO 22 (`OWNSoundDiffusionCommand`, `OWNSoundDiffusionEvent`)**: Tuner builders `seek_up` (`*22*5*WHERE##`), `seek_down` (`*22*6*WHERE##`), `step_frequency_up` (`*22*5#STEP*WHERE##`) and `step_frequency_down` (`*22*6#STEP*WHERE##`), STEP 1..15 (Encyclopedia `ownkb:chunk:r000478`-`r000480`, `ownkb:claim:c002729`), WHERE defaulting to `6`; `*22*5*2#1##`, `*22*6*2#1##`, `*22*5#1*2#1##` and `*22*6#1*2#1##` are ACKed and put on the bus (`$0494018FA0` / `FB0` / `FA1` / `FB1`) by F453, F453AV, F454, F459, F460, F461, MH200N and MyHomeServer1 in the firmware oracle and join the WHO 22 oracle conformance rows; WHAT 7 / 8 are not emitted (`bt_multi` NACKs them as not implemented) and `*22*7*2#1##` / `*22*8*2#1##` are not in the oracle index. The event gains `is_seek_up` / `is_seek_down` (bare WHAT 5 / 6), `frequency_step` (1..15, `None` outside) with `is_step_frequency_up` / `is_step_frequency_down` (WHAT `5#STEP` / `6#STEP`), log lines for all four, and `is_preset_cleared`: `True` for a Dimension 11 report with an explicit preset `0` (`*#22*5#2#1*11*1*10530*0##`, defensive parsing of the spec-shaped frame), where `preset` stays `None` as for a frame without a preset field; `*#22*5#2#1*11*1*10530*5##` is the physical station-5 report of an F500 (H/L4684 6.0.11, `MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-48-15.json`). ### Changed +- **WHO 13 (`OWNGatewayEvent.mac_address`)**: Dimension 12 decodes only six decimal octets in `0..255` (Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"): `*#13**12*0*3*80*1*2*300##` reads `None` instead of `00:03:50:01:02:12c`, and an eight-value reply (`*#13**12*0*3*80*1*2*3*4*5##`, the ZigBee interface IEEE address of zigbee-network-management.md "DIMENSION 12 - Interface IEEE address") is no longer truncated to a six-octet "MAC": `mac_address` is `None`, the values stay in `dimension_value` and the log says so (report 21 G2, G3). +- **WHO 13 (`OWNGatewayEvent.date`, `OWNGatewayCommand.date`, new `weekday`)**: a Dimension 1 / 22 year must be four digits (dimensions.md "DIMENSION 1 - Date"): `*#13**1*04*15*10*26##` stays a valid message, as before, but reads `date` `None` instead of `0026-10-15` (report 21 G4). New `weekday` property (`0` Sunday .. `6` Saturday, `None` outside `00..06`; not checked against the calendar date, the touchscreen client writes a fixed `00`; `date` unchanged) (report 21 G10). Out-of-table values (hour `25`, month `13`, a time zone that is not `[01]HH`, a truncated payload) keep raising `ValueError` from `OWNMessage.parse` as before: settled by OWNd#144 (closed 2026-10-28), the sessions catch it. +- **WHO 13 (`timezone`)**: `T` `100` (sign digit `1`, offset `00`, GMT-0) reads `+00:00` like `000` instead of `-00:00`; the `tzinfo` was already UTC (report 21 G7). +- **WHO 13 logs**: a parsed write frame (`*#13**#0*11*30*00*002##`, `*#13**#1*...##`, `*#13**#22*...##`, the touchscreen-originated clock writes of dimensions.md "Write form") logs "Setting gateway time to: ..." / "Setting gateway date to: ..." instead of "Gateway broadcasting internal ..."; the reply form built by `report_*` keeps the "broadcasting" text (report 21 G6). An addressed Dimension 16 reply (`*#13*123400#9*16*1*2*3##`, a ZigBee product per zigbee-network-management.md "DIMENSION 16 and 17") logs "Product 123400#9's firmware version is: 1.2.3." instead of "Gateway's firmware version"; `firmware_version` and `where` are unchanged (report 21 G8). The `device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (report 21 G9). - **Docs**: `docs/protocol_conformance_matrix.md` shows the WHO 13 read form `*#13**0##` / `*#13**22##` (empty `WHERE`, dimensions.md "Read form") instead of `*#13*0*0##` / `*#13*0*22##`, which is `nack, silent` on all twelve emulated gateways (report 21 G5). `OWNHeatingCommand.actuators_status` docstring: `#Z#C` is zone `Z` through a four-zone central unit `C` (who-4-temperature-control/addressing.md "Historical central-unit variants"), not an actuator (#140 leftover). ## [2.0.0b12] - 2026-11-01 diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 743fc9bf..9eb1607c 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -23,20 +23,58 @@ def _validate_gateway_clock_values( def _gateway_timezone(values: list[str]) -> str: - """Decode an optional OWN timezone, preserving unspecified local time.""" + """Decode an optional OWN timezone, preserving unspecified local time. + + ``T`` is sign + two-digit hour offset (Encyclopedia + who-13-integration-gateway/dimensions.md "DIMENSION 0 - Time": ``001`` is + GMT+1, ``102`` GMT-2); ``100`` is GMT-0 and reads ``+00:00`` like ``000``. + """ 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: raise ValueError(f"Invalid gateway timezone: {value!r}") + if value[1:] == "00": + return "+00:00" sign = "+" if value[0] == "0" else "-" return f"{sign}{value[1:]}:00" +def _gateway_weekday(value: str) -> int | None: + """``W`` as 0..6 (``00`` Sunday .. ``06`` Saturday), ``None`` outside the table. + + Not checked against the calendar date: the touchscreen client writes a + fixed ``00`` (dimensions.md "Historical touchscreen platform properties"). + """ + if value.isdigit() and 0 <= int(value) <= 6: + return int(value) + return None + + +def _gateway_date(year: str, month: str, day: str) -> _dt.date | None: + """Decode ``D*M*Y``; ``None`` when ``Y`` is not four digits (dimensions.md "DIMENSION 1 - Date"). + + A two-digit year (``*#13**1*04*15*10*26##``) is not year 0026; the frame + stays valid with the raw values and ``date`` ``None``. Out-of-range day + and month values keep raising ``ValueError`` as before (OWNd#144). + """ + if re.fullmatch(r"\d{4}", year) is None: + return None + return _dt.date(year=int(year), month=int(month), day=int(day)) + + # WHO 13 Dimension 15: Functional gateway model/device type (*#13**15*##). # 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", @@ -114,6 +152,7 @@ def __init__(self, data: str) -> None: self._second = None self._timezone = None self._raw_timezone = None + self._weekday: int | None = None self._time = None self._date = None @@ -152,15 +191,15 @@ def __init__(self, data: str) -> None: ) elif self._dimension == 1: + self._weekday = _gateway_weekday(self._dimension_value[0]) self._year = self._dimension_value[3] self._month = self._dimension_value[2] self._day = self._dimension_value[1] - self._date = _dt.date( - year=int(self._year), month=int(self._month), day=int(self._day) - ) - self._human_readable_log = ( - f"Gateway's internal date is: {self._date}." - ) + self._date = _gateway_date(self._year, self._month, self._day) + if self._date is not None: + self._human_readable_log = ( + f"Gateway's internal date is: {self._date}." + ) elif self._dimension == 10 and len(self._dimension_value) >= 4: self._ip_address = 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 IP address is: {self._ip_address}." @@ -169,12 +208,25 @@ 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 + elif self._dimension == 12: + # Six decimal octets, hexadecimal only at presentation (Encyclopedia + # dimensions.md "DIMENSION 12 - MAC address"). Eight values are the + # ZigBee interface IEEE address (zigbee-network-management.md + # "DIMENSION 12 - Interface IEEE address"), not a MAC: the values + # stay raw in ``dimension_value`` and ``mac_address`` is ``None``, + # as it is for an octet outside 0..255 or an empty field. + octets = self._dimension_value + if len(octets) == 6 and all( + o.isdigit() and int(o) <= 255 for o in octets + ): + self._mac_address = ":".join(f"{int(o):02x}" for o in octets) self._human_readable_log = f"Gateway's MAC address is: {self._mac_address}." - except (IndexError, TypeError, ValueError): - pass + elif len(octets) == 8: + self._human_readable_log = ( + "Gateway reported an eight-value dimension 12 " + "(ZigBee interface IEEE address, not a MAC address): " + f"{'*'.join(octets)}." + ) elif self._dimension == 15 and self._dimension_value: code = self._dimension_value[0] @@ -183,9 +235,19 @@ def __init__(self, data: str) -> None: elif self._dimension == 16 and len(self._dimension_value) >= 3: self._firmware_version = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}" # pylint: disable=line-too-long - self._human_readable_log = ( - f"Gateway's firmware version is: {self._firmware_version}." - ) + if self._where: + # ``*#13*PRODUCT00#9*16*V*R*B##`` is the firmware of the + # addressed ZigBee product, not of the gateway (Encyclopedia + # zigbee-network-management.md "DIMENSION 16 and 17 - + # Firmware and hardware versions"); ``where`` tells. + product = self._where + "".join(f"#{p}" for p in self._where_param) + self._human_readable_log = ( + f"Product {product}'s firmware version is: {self._firmware_version}." + ) + else: + self._human_readable_log = ( + f"Gateway's firmware version is: {self._firmware_version}." + ) elif ( self._dimension == 19 @@ -213,9 +275,14 @@ def __init__(self, data: str) -> None: else None ) self._timezone = _gateway_timezone(self._dimension_value) + self._weekday = _gateway_weekday(self._dimension_value[4]) self._day = self._dimension_value[5] self._month = self._dimension_value[6] self._year = self._dimension_value[7] + if _gateway_date(self._year, self._month, self._day) is None: + # Two-digit year: the frame stays valid, raw values kept, + # typed properties None (dimensions.md "DIMENSION 1 - Date"). + return 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 ) @@ -271,6 +338,16 @@ def date(self) -> _dt.date | None: """Return the decoded gateway internal date.""" return self._date + @property + def weekday(self) -> int | None: + """Day of week of a Dimension 1 / 22 payload: ``0`` Sunday .. ``6`` Saturday. + + ``None`` outside ``00..06`` (Encyclopedia dimensions.md "DIMENSION 1 - + Date"); not checked against the calendar date, since the touchscreen + client writes a fixed ``00``. + """ + return self._weekday + @property def datetime(self) -> _dt.datetime | None: """Return the decoded gateway internal datetime.""" @@ -318,7 +395,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 @@ -356,6 +441,7 @@ def __init__(self, data: str) -> None: self._second = None self._timezone = None self._raw_timezone = None + self._weekday: int | None = None self._time = None self._date = None @@ -381,19 +467,19 @@ def __init__(self, data: str) -> None: f"{self._hour}:{self._minute}:{self._second}{self._timezone}" ) self._human_readable_log = ( - f"Gateway broadcasting internal time: {self._time}." + f"{self._clock_log_verb} time{self._clock_log_to}: {self._time}." ) elif self._dimension == 1: + self._weekday = _gateway_weekday(self._dimension_value[0]) self._year = self._dimension_value[3] self._month = self._dimension_value[2] self._day = self._dimension_value[1] - self._date = _dt.date( - year=int(self._year), month=int(self._month), day=int(self._day) - ) - self._human_readable_log = ( - f"Gateway broadcasting internal date: {self._date}." - ) + self._date = _gateway_date(self._year, self._month, self._day) + if self._date is not None: + self._human_readable_log = ( + f"{self._clock_log_verb} date{self._clock_log_to}: {self._date}." + ) elif self._dimension == 22: self._hour = self._dimension_value[0] @@ -405,9 +491,14 @@ def __init__(self, data: str) -> None: else None ) self._timezone = _gateway_timezone(self._dimension_value) + self._weekday = _gateway_weekday(self._dimension_value[4]) self._day = self._dimension_value[5] self._month = self._dimension_value[6] self._year = self._dimension_value[7] + if _gateway_date(self._year, self._month, self._day) is None: + # Two-digit year: the frame stays valid, raw values kept, + # typed properties None (dimensions.md "DIMENSION 1 - Date"). + return 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 ) @@ -418,7 +509,8 @@ def __init__(self, data: str) -> None: else self._datetime.time() ) self._human_readable_log = ( - f"Gateway broadcasting internal datetime: {self._datetime}." + f"{self._clock_log_verb} {'time' if self.is_dimension_writing else 'datetime'}" + f"{self._clock_log_to}: {self._datetime}." ) @property @@ -431,6 +523,16 @@ def date(self) -> _dt.date | None: """Return the decoded gateway internal date.""" return self._date + @property + def weekday(self) -> int | None: + """Day of week of a Dimension 1 / 22 payload: ``0`` Sunday .. ``6`` Saturday. + + ``None`` outside ``00..06`` (Encyclopedia dimensions.md "DIMENSION 1 - + Date"); not checked against the calendar date, since the touchscreen + client writes a fixed ``00``. + """ + return self._weekday + @property def datetime(self) -> _dt.datetime | None: """Return the decoded gateway internal datetime.""" @@ -451,6 +553,18 @@ def is_timezone_unconfigured(self) -> bool: """Return True if the gateway reported an unconfigured timezone (sentinel 999).""" return self._raw_timezone == "999" + @property + def _clock_log_verb(self) -> str: + # ``*#13**#0*...##`` / ``#1`` / ``#22`` are client -> gateway writes + # (Encyclopedia dimensions.md "Write form"; the touchscreen clock + # writes of "Historical touchscreen platform properties"); the reply + # form ``*#13**0*...##`` built by ``report_*`` reports the clock. + return "Setting gateway" if self.is_dimension_writing else "Gateway broadcasting internal" + + @property + def _clock_log_to(self) -> str: + return " to" if self.is_dimension_writing else "" + @property def is_clock_query(self) -> bool: """True if this message is a gateway clock/date/datetime query (Dimensions 0, 1, 22).""" diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index c3cc5dd5..a4b9af6d 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -265,6 +265,151 @@ def test_malformed_gateway_clock_is_rejected(frame: str) -> None: OWNMessage.parse(frame) +def test_gateway_timezone_100_is_gmt_plus_zero() -> None: + """``T`` ``100`` (sign digit 1, offset 00) is GMT-0, read as ``+00:00`` like ``000``. + + Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 0 - Time" + (report 21 G7). Out-of-table time zones keep raising (OWNd#144). + """ + for suffix in ("000", "100"): + message = OWNMessage.parse(f"*#13**0*12*30*00*{suffix}##") + assert isinstance(message, OWNGatewayEvent) + assert message.timezone == "+00:00" + assert message.raw_timezone == suffix + assert message.time == datetime.time(12, 30, tzinfo=datetime.timezone.utc) + assert OWNGatewayEvent("*#13**0*12*30*00*001##").timezone == "+01:00" + assert OWNGatewayEvent("*#13**0*12*30*00*102##").timezone == "-02:00" + with pytest.raises(ValueError, match="Invalid gateway timezone: '0530'"): + OWNMessage.parse("*#13**0*12*30*00*0530##") + + +def test_gateway_date_year_must_be_four_digits_and_weekday_00_to_06() -> None: + """A two-digit ``Y`` is not year 0026; ``W`` outside 00..06 reads ``None``. + + Encyclopedia dimensions.md "DIMENSION 1 - Date" (report 21 G4, G10). The + two-digit-year frame was a valid message on master and stays one: raw + values kept, ``date`` ``None``, no raise. Out-of-range day / month values + keep raising as before (OWNd#144). + """ + short_year = OWNMessage.parse("*#13**1*04*15*10*26##") + assert isinstance(short_year, OWNGatewayEvent) + assert short_year.is_valid + assert short_year.date is None + assert short_year.weekday == 4 + assert short_year.dimension_value == ["04", "15", "10", "26"] + assert short_year.human_readable_log == "*#13**1*04*15*10*26##" + + short_year_22 = OWNMessage.parse("*#13**22*16*37*03*001*05*25*09*26##") + assert isinstance(short_year_22, OWNGatewayEvent) + assert short_year_22.datetime is None and short_year_22.date is None + assert short_year_22.time is None + assert short_year_22.timezone == "+01:00" + assert short_year_22.weekday == 5 + + weekday_7 = OWNMessage.parse("*#13**1*07*15*10*2026##") + assert isinstance(weekday_7, OWNGatewayEvent) + assert weekday_7.date == datetime.date(2026, 10, 15) + assert weekday_7.weekday is None + + sunday = OWNMessage.parse("*#13**1*00*25*10*2026##") + assert isinstance(sunday, OWNGatewayEvent) + assert sunday.weekday == 0 + assert sunday.date == datetime.date(2026, 10, 25) + + combined = OWNMessage.parse("*#13**22*16*37*03*001*05*25*09*2026##") + assert isinstance(combined, OWNGatewayEvent) + assert combined.weekday == 5 + assert OWNMessage.parse("*#13**22*16*37*03*001*9*25*09*2026##").weekday is None + assert OWNMessage.parse("*#13**22*12*14*58*001**15*04*2026##").weekday is None + assert OWNMessage.parse("*#13**0*12*30*00##").weekday is None + + written = OWNMessage.parse("*#13**#1*03*07*10*2026##") + assert isinstance(written, OWNGatewayCommand) + assert written.weekday == 3 + written_short = OWNGatewayCommand("*#13**#1*03*07*10*26##") + assert written_short.date is None and written_short.weekday == 3 + written_short_22 = OWNGatewayCommand("*#13**#22*17*33*54*002*05*11*09*26##") + assert written_short_22.datetime is None and written_short_22.weekday == 5 + + with pytest.raises(ValueError, match="month must be in 1..12"): + OWNMessage.parse("*#13**1*00*32*13*2024##") + with pytest.raises(ValueError, match="year must be between 2000 and 2099"): + OWNGatewayCommand.set_date(datetime.date(26, 10, 15)) + + +def test_gateway_write_frame_logs_setting_not_broadcasting() -> None: + """``*#13**#0*...##`` / ``#1`` / ``#22`` are client writes (dimensions.md "Write form", G6).""" + assert ( + OWNMessage.parse("*#13**#0*11*30*00*002##").human_readable_log + == "Setting gateway time to: 11:30:00+02:00." + ) + assert ( + OWNMessage.parse("*#13**#1*03*07*10*2026##").human_readable_log + == "Setting gateway date to: 2026-10-07." + ) + assert ( + OWNMessage.parse("*#13**#22*17*33*54*002*05*11*09*2026##").human_readable_log + == "Setting gateway time to: 2026-09-11 17:33:54+02:00." + ) + assert ( + OWNMessage.parse("*#13**#0*13*12*11**##").human_readable_log + == "Setting gateway time to: 13:12:11." + ) + # The reply form built by report_* keeps reporting the clock. + assert ( + OWNGatewayCommand("*#13**0*20*54*00*002##").human_readable_log + == "Gateway broadcasting internal time: 20:54:00+02:00." + ) + assert ( + OWNGatewayCommand("*#13**1*04*15*10*2026##").human_readable_log + == "Gateway broadcasting internal date: 2026-10-15." + ) + assert ( + OWNGatewayCommand("*#13**22*16*37*03*001*05*25*09*2026##").human_readable_log + == "Gateway broadcasting internal datetime: 2026-09-25 16:37:03+01:00." + ) + assert ( + OWNGatewayCommand.set_time(datetime.time(11, 30, tzinfo=datetime.timezone.utc)).human_readable_log + == "Setting gateway time to: 11:30:00+00:00." + ) + + +def test_gateway_mac_address_octets_are_range_checked() -> None: + """Six decimal octets 0..255 (dimensions.md "DIMENSION 12 - MAC address", G2).""" + assert OWNGatewayEvent("*#13**12*0*3*80*0*4*97##").mac_address == "00:03:50:00:04:61" + assert OWNGatewayEvent("*#13**12*0*3*80*0*4*255##").mac_address == "00:03:50:00:04:ff" + too_big = OWNGatewayEvent("*#13**12*0*3*80*1*2*300##") + assert too_big.mac_address is None + assert too_big.dimension_value == ["0", "3", "80", "1", "2", "300"] + assert too_big.human_readable_log == "*#13**12*0*3*80*1*2*300##" + assert OWNGatewayEvent("*#13**12*0*3*80*1*2##").mac_address is None + assert OWNGatewayEvent("*#13**12*0*3*80*1*2*3*4##").mac_address is None + + +def test_gateway_dimension_12_with_eight_values_is_not_a_mac() -> None: + """Eight values are the ZigBee interface IEEE address (zigbee-network-management.md, G3).""" + message = OWNMessage.parse("*#13**12*0*3*80*1*2*3*4*5##") + assert isinstance(message, OWNGatewayEvent) + assert message.mac_address is None + assert message.dimension_value == ["0", "3", "80", "1", "2", "3", "4", "5"] + assert message.human_readable_log == ( + "Gateway reported an eight-value dimension 12 " + "(ZigBee interface IEEE address, not a MAC address): 0*3*80*1*2*3*4*5." + ) + + +def test_gateway_addressed_dimension_16_is_a_product_firmware() -> None: + """``*#13*PRODUCT00#9*16*V*R*B##`` is the product's firmware, not the gateway's (G8).""" + message = OWNMessage.parse("*#13*123400#9*16*1*2*3##") + assert isinstance(message, OWNGatewayEvent) + assert message.where == "123400" + assert message.firmware_version == "1.2.3" + assert message.human_readable_log == "Product 123400#9's firmware version is: 1.2.3." + gateway = OWNMessage.parse("*#13**16*2*0*11##") + assert isinstance(gateway, OWNGatewayEvent) + assert gateway.human_readable_log == "Gateway's firmware version is: 2.0.11." + + def test_gateway_time_command_has_no_empty_trailing_value() -> None: message = OWNGatewayCommand.set_time_to_now("UTC") From 3e682a683b850b76c9cbdc4d89ecb7e773c94b93 Mon Sep 17 00:00:00 2001 From: gdluck Date: Tue, 3 Nov 2026 10:18:06 +0200 Subject: [PATCH 03/14] fix(gate): keep mac_address unchecked, add valid_mac_address, ASCII-only WHO 13 helpers (decisions D59, D12, D14) report 21 G2, G3 (precedent gate). MyHOME#804 pins that OWNGatewayEvent.mac_address formats any six values without a 0..255 range check (tests/test_config_flow_identify.py: an octet 256 reads "100" and the config flow refuses it itself), so mac_address keeps that behaviour and the range-checked value moves to the new valid_mac_address property (six decimal ASCII octets in 0..255, Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"; None for the eight-value ZigBee interface IEEE address of zigbee-network-management.md). _gateway_weekday, _gateway_date and the octet check take ASCII digits only (OWNd#139 rule: non-ASCII digits never decode). The docs-only CHANGELOG bullet is dropped (OWNd#143: no CHANGELOG entry when no runtime behaviour changed). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 +-- OWNd/message/gateway.py | 43 +++++++++++++++++++++++++--------- tests/test_message_protocol.py | 39 ++++++++++++++++++++++++------ 3 files changed, 65 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 191ca5e6..143fd9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,10 @@ All notable changes to `OWNd` are documented in this file. - **WHO 22 (`OWNSoundDiffusionCommand`, `OWNSoundDiffusionEvent`)**: Tuner builders `seek_up` (`*22*5*WHERE##`), `seek_down` (`*22*6*WHERE##`), `step_frequency_up` (`*22*5#STEP*WHERE##`) and `step_frequency_down` (`*22*6#STEP*WHERE##`), STEP 1..15 (Encyclopedia `ownkb:chunk:r000478`-`r000480`, `ownkb:claim:c002729`), WHERE defaulting to `6`; `*22*5*2#1##`, `*22*6*2#1##`, `*22*5#1*2#1##` and `*22*6#1*2#1##` are ACKed and put on the bus (`$0494018FA0` / `FB0` / `FA1` / `FB1`) by F453, F453AV, F454, F459, F460, F461, MH200N and MyHomeServer1 in the firmware oracle and join the WHO 22 oracle conformance rows; WHAT 7 / 8 are not emitted (`bt_multi` NACKs them as not implemented) and `*22*7*2#1##` / `*22*8*2#1##` are not in the oracle index. The event gains `is_seek_up` / `is_seek_down` (bare WHAT 5 / 6), `frequency_step` (1..15, `None` outside) with `is_step_frequency_up` / `is_step_frequency_down` (WHAT `5#STEP` / `6#STEP`), log lines for all four, and `is_preset_cleared`: `True` for a Dimension 11 report with an explicit preset `0` (`*#22*5#2#1*11*1*10530*0##`, defensive parsing of the spec-shaped frame), where `preset` stays `None` as for a frame without a preset field; `*#22*5#2#1*11*1*10530*5##` is the physical station-5 report of an F500 (H/L4684 6.0.11, `MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-48-15.json`). ### Changed -- **WHO 13 (`OWNGatewayEvent.mac_address`)**: Dimension 12 decodes only six decimal octets in `0..255` (Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"): `*#13**12*0*3*80*1*2*300##` reads `None` instead of `00:03:50:01:02:12c`, and an eight-value reply (`*#13**12*0*3*80*1*2*3*4*5##`, the ZigBee interface IEEE address of zigbee-network-management.md "DIMENSION 12 - Interface IEEE address") is no longer truncated to a six-octet "MAC": `mac_address` is `None`, the values stay in `dimension_value` and the log says so (report 21 G2, G3). +- **WHO 13 (`OWNGatewayEvent.valid_mac_address`)**: new property with Dimension 12 as six decimal octets in `0..255` (Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"), else `None`: `*#13**12*0*3*80*1*2*300##` reads `None`, and so does the eight-value reply `*#13**12*0*3*80*1*2*3*4*5##` (the ZigBee interface IEEE address of zigbee-network-management.md "DIMENSION 12 - Interface IEEE address"), whose log now says it is not a MAC address. `mac_address` keeps formatting the first six values without a range check (`100:03:50:00:07:77` for an octet `256`), the behaviour MyHOME#804 relies on (report 21 G2, G3). - **WHO 13 (`OWNGatewayEvent.date`, `OWNGatewayCommand.date`, new `weekday`)**: a Dimension 1 / 22 year must be four digits (dimensions.md "DIMENSION 1 - Date"): `*#13**1*04*15*10*26##` stays a valid message, as before, but reads `date` `None` instead of `0026-10-15` (report 21 G4). New `weekday` property (`0` Sunday .. `6` Saturday, `None` outside `00..06`; not checked against the calendar date, the touchscreen client writes a fixed `00`; `date` unchanged) (report 21 G10). Out-of-table values (hour `25`, month `13`, a time zone that is not `[01]HH`, a truncated payload) keep raising `ValueError` from `OWNMessage.parse` as before: settled by OWNd#144 (closed 2026-10-28), the sessions catch it. - **WHO 13 (`timezone`)**: `T` `100` (sign digit `1`, offset `00`, GMT-0) reads `+00:00` like `000` instead of `-00:00`; the `tzinfo` was already UTC (report 21 G7). - **WHO 13 logs**: a parsed write frame (`*#13**#0*11*30*00*002##`, `*#13**#1*...##`, `*#13**#22*...##`, the touchscreen-originated clock writes of dimensions.md "Write form") logs "Setting gateway time to: ..." / "Setting gateway date to: ..." instead of "Gateway broadcasting internal ..."; the reply form built by `report_*` keeps the "broadcasting" text (report 21 G6). An addressed Dimension 16 reply (`*#13*123400#9*16*1*2*3##`, a ZigBee product per zigbee-network-management.md "DIMENSION 16 and 17") logs "Product 123400#9's firmware version is: 1.2.3." instead of "Gateway's firmware version"; `firmware_version` and `where` are unchanged (report 21 G8). The `device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (report 21 G9). -- **Docs**: `docs/protocol_conformance_matrix.md` shows the WHO 13 read form `*#13**0##` / `*#13**22##` (empty `WHERE`, dimensions.md "Read form") instead of `*#13*0*0##` / `*#13*0*22##`, which is `nack, silent` on all twelve emulated gateways (report 21 G5). `OWNHeatingCommand.actuators_status` docstring: `#Z#C` is zone `Z` through a four-zone central unit `C` (who-4-temperature-control/addressing.md "Historical central-unit variants"), not an actuator (#140 leftover). ## [2.0.0b12] - 2026-11-01 diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 9eb1607c..ad3e4698 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -46,7 +46,7 @@ def _gateway_weekday(value: str) -> int | None: Not checked against the calendar date: the touchscreen client writes a fixed ``00`` (dimensions.md "Historical touchscreen platform properties"). """ - if value.isdigit() and 0 <= int(value) <= 6: + if re.fullmatch(r"[0-9]{1,2}", value) is not None and int(value) <= 6: return int(value) return None @@ -58,7 +58,7 @@ def _gateway_date(year: str, month: str, day: str) -> _dt.date | None: stays valid with the raw values and ``date`` ``None``. Out-of-range day and month values keep raising ``ValueError`` as before (OWNd#144). """ - if re.fullmatch(r"\d{4}", year) is None: + if re.fullmatch(r"[0-9]{4}", year) is None: return None return _dt.date(year=int(year), month=int(month), day=int(day)) @@ -161,6 +161,7 @@ def __init__(self, data: str) -> None: self._ip_address = None self._netmask = None self._mac_address = None + self._valid_mac_address: str | None = None self._device_type = None self._firmware_version = None @@ -208,18 +209,26 @@ 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: - # Six decimal octets, hexadecimal only at presentation (Encyclopedia - # dimensions.md "DIMENSION 12 - MAC address"). Eight values are the - # ZigBee interface IEEE address (zigbee-network-management.md - # "DIMENSION 12 - Interface IEEE address"), not a MAC: the values - # stay raw in ``dimension_value`` and ``mac_address`` is ``None``, - # as it is for an octet outside 0..255 or an empty field. + elif self._dimension == 12 and len(self._dimension_value) >= 6: + # ``mac_address`` keeps formatting the first six values as hex + # without a range check (MyHOME#804: the integration validates the + # value itself and its tests pin that an octet ``256`` reads + # ``100``). ``valid_mac_address`` is the checked form: exactly six + # decimal ASCII octets in 0..255 (Encyclopedia dimensions.md + # "DIMENSION 12 - MAC address"), else ``None``. Eight values are + # the ZigBee interface IEEE address (zigbee-network-management.md + # "DIMENSION 12 - Interface IEEE address"), not a MAC: the log + # says so and the values stay raw in ``dimension_value``. octets = self._dimension_value + try: + self._mac_address = ":".join(f"{int(o):02x}" for o in octets[:6]) + except (TypeError, ValueError): + pass if len(octets) == 6 and all( - o.isdigit() and int(o) <= 255 for o in octets + re.fullmatch(r"[0-9]{1,3}", o) is not None and int(o) <= 255 + for o in octets ): - self._mac_address = ":".join(f"{int(o):02x}" for o in octets) + self._valid_mac_address = self._mac_address self._human_readable_log = f"Gateway's MAC address is: {self._mac_address}." elif len(octets) == 8: self._human_readable_log = ( @@ -383,6 +392,18 @@ def netmask(self) -> str | None: """Return the decoded gateway netmask.""" return self._netmask + @property + def valid_mac_address(self) -> str | None: + """Dimension 12 as a MAC address, or ``None`` when it is not one. + + Six decimal ASCII octets in ``0..255`` (Encyclopedia + who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"); + ``None`` for an octet outside the range, a seven-value reply or the + eight-value ZigBee interface IEEE address. ``mac_address`` keeps the + unchecked formatting of the first six values. + """ + return self._valid_mac_address + @property def mac_address(self) -> str | None: """Return the decoded gateway MAC address.""" diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index a4b9af6d..e8022bf2 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -375,22 +375,47 @@ def test_gateway_write_frame_logs_setting_not_broadcasting() -> None: def test_gateway_mac_address_octets_are_range_checked() -> None: - """Six decimal octets 0..255 (dimensions.md "DIMENSION 12 - MAC address", G2).""" - assert OWNGatewayEvent("*#13**12*0*3*80*0*4*97##").mac_address == "00:03:50:00:04:61" - assert OWNGatewayEvent("*#13**12*0*3*80*0*4*255##").mac_address == "00:03:50:00:04:ff" + """``valid_mac_address`` is six decimal octets 0..255 (dimensions.md "DIMENSION 12 - MAC address", G2). + + ``mac_address`` keeps the unchecked formatting MyHOME pins (MyHOME#804: + an octet ``256`` reads ``100`` and the integration refuses it itself). + """ + good = OWNGatewayEvent("*#13**12*0*3*80*0*4*97##") + assert good.mac_address == "00:03:50:00:04:61" + assert good.valid_mac_address == "00:03:50:00:04:61" + assert good.human_readable_log == "Gateway's MAC address is: 00:03:50:00:04:61." + assert OWNGatewayEvent("*#13**12*0*3*80*0*4*255##").valid_mac_address == "00:03:50:00:04:ff" too_big = OWNGatewayEvent("*#13**12*0*3*80*1*2*300##") - assert too_big.mac_address is None + assert too_big.mac_address == "00:03:50:01:02:12c" + assert too_big.valid_mac_address is None assert too_big.dimension_value == ["0", "3", "80", "1", "2", "300"] assert too_big.human_readable_log == "*#13**12*0*3*80*1*2*300##" - assert OWNGatewayEvent("*#13**12*0*3*80*1*2##").mac_address is None - assert OWNGatewayEvent("*#13**12*0*3*80*1*2*3*4##").mac_address is None + assert OWNGatewayEvent("*#13**12*256*3*80*0*7*119##").mac_address == "100:03:50:00:07:77" + short = OWNGatewayEvent("*#13**12*0*3*80*1*2##") + assert short.mac_address is None + assert short.valid_mac_address is None + seven = OWNGatewayEvent("*#13**12*0*3*80*1*2*3*4##") + assert seven.mac_address == "00:03:50:01:02:03" + assert seven.valid_mac_address is None + empty = OWNGatewayEvent("*#13**12*0*3*80*1*2*##") + assert empty.mac_address is None + assert empty.valid_mac_address is None + + +def test_gateway_who13_decoders_take_ascii_digits_only() -> None: + """Non-ASCII digits never make a checked MAC, a weekday or a date (OWNd#139 rule).""" + arabic_zero = OWNGatewayEvent("*#13**12*\u0660*3*80*1*2*3##") + assert arabic_zero.valid_mac_address is None + assert OWNGatewayEvent("*#13**1*\u0660\u0660*15*10*2026##").weekday is None + assert OWNGatewayEvent("*#13**1*00*15*10*\u0662026##").date is None def test_gateway_dimension_12_with_eight_values_is_not_a_mac() -> None: """Eight values are the ZigBee interface IEEE address (zigbee-network-management.md, G3).""" message = OWNMessage.parse("*#13**12*0*3*80*1*2*3*4*5##") assert isinstance(message, OWNGatewayEvent) - assert message.mac_address is None + assert message.mac_address == "00:03:50:01:02:03" + assert message.valid_mac_address is None assert message.dimension_value == ["0", "3", "80", "1", "2", "3", "4", "5"] assert message.human_readable_log == ( "Gateway reported an eight-value dimension 12 " From 486ad26c31746677e58232a476a1823d176bb259 Mon Sep 17 00:00:00 2001 From: gdluck Date: Tue, 3 Nov 2026 10:28:46 +0200 Subject: [PATCH 04/14] fix(gate): keep the "Gateway broadcasting internal" log for parsed clock writes (MyHOME pin) report 21 G6 (precedent gate). MyHOME pins the log of the write form *#13**#0*22*50*00*## as "Gateway broadcasting internal time: 22:50:00." (tests/test_f461_who13_clock_tz_live_2026_10_26_trace.py::test_written_strings_fail_before_pass_after), so the "Setting gateway ... to" wording is not applied to parsed frames; the set_* builders keep their own "Setting gateway ..." text as on master. CHANGELOG bullet trimmed accordingly. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- OWNd/message/gateway.py | 19 +++---------------- tests/test_message_protocol.py | 21 +++++++++++++-------- 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 143fd9b6..00e947a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to `OWNd` are documented in this file. - **WHO 13 (`OWNGatewayEvent.valid_mac_address`)**: new property with Dimension 12 as six decimal octets in `0..255` (Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"), else `None`: `*#13**12*0*3*80*1*2*300##` reads `None`, and so does the eight-value reply `*#13**12*0*3*80*1*2*3*4*5##` (the ZigBee interface IEEE address of zigbee-network-management.md "DIMENSION 12 - Interface IEEE address"), whose log now says it is not a MAC address. `mac_address` keeps formatting the first six values without a range check (`100:03:50:00:07:77` for an octet `256`), the behaviour MyHOME#804 relies on (report 21 G2, G3). - **WHO 13 (`OWNGatewayEvent.date`, `OWNGatewayCommand.date`, new `weekday`)**: a Dimension 1 / 22 year must be four digits (dimensions.md "DIMENSION 1 - Date"): `*#13**1*04*15*10*26##` stays a valid message, as before, but reads `date` `None` instead of `0026-10-15` (report 21 G4). New `weekday` property (`0` Sunday .. `6` Saturday, `None` outside `00..06`; not checked against the calendar date, the touchscreen client writes a fixed `00`; `date` unchanged) (report 21 G10). Out-of-table values (hour `25`, month `13`, a time zone that is not `[01]HH`, a truncated payload) keep raising `ValueError` from `OWNMessage.parse` as before: settled by OWNd#144 (closed 2026-10-28), the sessions catch it. - **WHO 13 (`timezone`)**: `T` `100` (sign digit `1`, offset `00`, GMT-0) reads `+00:00` like `000` instead of `-00:00`; the `tzinfo` was already UTC (report 21 G7). -- **WHO 13 logs**: a parsed write frame (`*#13**#0*11*30*00*002##`, `*#13**#1*...##`, `*#13**#22*...##`, the touchscreen-originated clock writes of dimensions.md "Write form") logs "Setting gateway time to: ..." / "Setting gateway date to: ..." instead of "Gateway broadcasting internal ..."; the reply form built by `report_*` keeps the "broadcasting" text (report 21 G6). An addressed Dimension 16 reply (`*#13*123400#9*16*1*2*3##`, a ZigBee product per zigbee-network-management.md "DIMENSION 16 and 17") logs "Product 123400#9's firmware version is: 1.2.3." instead of "Gateway's firmware version"; `firmware_version` and `where` are unchanged (report 21 G8). The `device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (report 21 G9). +- **WHO 13 logs**: an addressed Dimension 16 reply (`*#13*123400#9*16*1*2*3##`, a ZigBee product per zigbee-network-management.md "DIMENSION 16 and 17") logs "Product 123400#9's firmware version is: 1.2.3." instead of "Gateway's firmware version"; `firmware_version` and `where` are unchanged (report 21 G8). The `device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (report 21 G9). ## [2.0.0b12] - 2026-11-01 diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index ad3e4698..cac0f7d0 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -488,7 +488,7 @@ def __init__(self, data: str) -> None: f"{self._hour}:{self._minute}:{self._second}{self._timezone}" ) self._human_readable_log = ( - f"{self._clock_log_verb} time{self._clock_log_to}: {self._time}." + f"Gateway broadcasting internal time: {self._time}." ) elif self._dimension == 1: @@ -499,7 +499,7 @@ def __init__(self, data: str) -> None: self._date = _gateway_date(self._year, self._month, self._day) if self._date is not None: self._human_readable_log = ( - f"{self._clock_log_verb} date{self._clock_log_to}: {self._date}." + f"Gateway broadcasting internal date: {self._date}." ) elif self._dimension == 22: @@ -530,8 +530,7 @@ def __init__(self, data: str) -> None: else self._datetime.time() ) self._human_readable_log = ( - f"{self._clock_log_verb} {'time' if self.is_dimension_writing else 'datetime'}" - f"{self._clock_log_to}: {self._datetime}." + f"Gateway broadcasting internal datetime: {self._datetime}." ) @property @@ -574,18 +573,6 @@ def is_timezone_unconfigured(self) -> bool: """Return True if the gateway reported an unconfigured timezone (sentinel 999).""" return self._raw_timezone == "999" - @property - def _clock_log_verb(self) -> str: - # ``*#13**#0*...##`` / ``#1`` / ``#22`` are client -> gateway writes - # (Encyclopedia dimensions.md "Write form"; the touchscreen clock - # writes of "Historical touchscreen platform properties"); the reply - # form ``*#13**0*...##`` built by ``report_*`` reports the clock. - return "Setting gateway" if self.is_dimension_writing else "Gateway broadcasting internal" - - @property - def _clock_log_to(self) -> str: - return " to" if self.is_dimension_writing else "" - @property def is_clock_query(self) -> bool: """True if this message is a gateway clock/date/datetime query (Dimensions 0, 1, 22).""" diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index e8022bf2..2f2e47d8 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -337,25 +337,30 @@ def test_gateway_date_year_must_be_four_digits_and_weekday_00_to_06() -> None: OWNGatewayCommand.set_date(datetime.date(26, 10, 15)) -def test_gateway_write_frame_logs_setting_not_broadcasting() -> None: - """``*#13**#0*...##`` / ``#1`` / ``#22`` are client writes (dimensions.md "Write form", G6).""" +def test_gateway_parsed_clock_frames_keep_the_broadcasting_log() -> None: + """Parsed write and reply frames keep the historical log text. + + MyHOME pins "Gateway broadcasting internal time: 22:50:00." for the write + form ``*#13**#0*22*50*00*##`` (tests/test_f461_who13_clock_tz_live_2026_10_26_trace.py), + so the report 21 G6 wording change is not applied to parsed frames; the + builders keep their own "Setting gateway ..." text. + """ assert ( OWNMessage.parse("*#13**#0*11*30*00*002##").human_readable_log - == "Setting gateway time to: 11:30:00+02:00." + == "Gateway broadcasting internal time: 11:30:00+02:00." ) assert ( OWNMessage.parse("*#13**#1*03*07*10*2026##").human_readable_log - == "Setting gateway date to: 2026-10-07." + == "Gateway broadcasting internal date: 2026-10-07." ) assert ( OWNMessage.parse("*#13**#22*17*33*54*002*05*11*09*2026##").human_readable_log - == "Setting gateway time to: 2026-09-11 17:33:54+02:00." + == "Gateway broadcasting internal datetime: 2026-09-11 17:33:54+02:00." ) assert ( - OWNMessage.parse("*#13**#0*13*12*11**##").human_readable_log - == "Setting gateway time to: 13:12:11." + OWNMessage.parse("*#13**#0*22*50*00*##").human_readable_log + == "Gateway broadcasting internal time: 22:50:00." ) - # The reply form built by report_* keeps reporting the clock. assert ( OWNGatewayCommand("*#13**0*20*54*00*002##").human_readable_log == "Gateway broadcasting internal time: 20:54:00+02:00." From 66e3dfe652720c0720ae48500e75a5481688c23a Mon Sep 17 00:00:00 2001 From: gdluck Date: Tue, 3 Nov 2026 11:00:57 +0200 Subject: [PATCH 05/14] fix(gate): validate WHO 13 day and month before the year check (review follow-up) report 21 G4. _gateway_date validated day / month only for a four-digit year, so *#13**1*04*99*32*26## parsed with date None where master raised on month 99 / day 32 (OWNd#144: out-of-table clock values keep raising inside parse). Day and month are now validated first exactly as on master (against a leap year, so 29 February passes), for dimension 1 and dimension 22 on both OWNGatewayEvent and OWNGatewayCommand; only a well-formed date with a non-four-digit year gives date None (Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 1 - Date"). Tests cover both orders. Co-Authored-By: Claude Fable 5.1 --- OWNd/message/gateway.py | 12 +++++++++--- tests/test_message_protocol.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index cac0f7d0..5c098770 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -54,11 +54,17 @@ def _gateway_weekday(value: str) -> int | None: def _gateway_date(year: str, month: str, day: str) -> _dt.date | None: """Decode ``D*M*Y``; ``None`` when ``Y`` is not four digits (dimensions.md "DIMENSION 1 - Date"). - A two-digit year (``*#13**1*04*15*10*26##``) is not year 0026; the frame - stays valid with the raw values and ``date`` ``None``. Out-of-range day - and month values keep raising ``ValueError`` as before (OWNd#144). + Day and month are validated first, exactly as on master: an out-of-range + value (``*#13**1*04*99*32*26##``, month ``13``, day ``32``) raises + ``ValueError`` whatever the year looks like (OWNd#144, caught by the + sessions). Only a well-formed date whose ``Y`` is not four digits + (``*#13**1*04*15*10*26##``: not year 0026) gives ``None``; the frame stays + valid with the raw values. """ if re.fullmatch(r"[0-9]{4}", year) is None: + # Validate D and M against a leap year so 29 February still passes; + # 30 February, month 13 or day 32 raise as on master. + _dt.date(year=2000, month=int(month), day=int(day)) return None return _dt.date(year=int(year), month=int(month), day=int(day)) diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index 2f2e47d8..28cb629b 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -283,6 +283,42 @@ def test_gateway_timezone_100_is_gmt_plus_zero() -> None: OWNMessage.parse("*#13**0*12*30*00*0530##") +@pytest.mark.parametrize( + "frame", + [ + "*#13**1*04*99*32*26##", # month 99 and day 32, two-digit year + "*#13**1*04*32*10*26##", # day 32, two-digit year + "*#13**1*04*15*13*26##", # month 13, two-digit year + "*#13**1*04*30*02*26##", # 30 February, two-digit year + "*#13**1*04*32*10*2026##", # day 32, four-digit year (master) + "*#13**22*17*33*54*002*05*32*13*26##", # dimension 22, day 32 / month 13, two-digit year + "*#13**22*17*33*54*002*05*31*04*2026##", # dimension 22, 31 April, four-digit year + ], +) +def test_gateway_day_and_month_are_validated_before_the_year(frame: str) -> None: + """Out-of-range D / M raise as on master whatever the year looks like (review follow-up, OWNd#144).""" + with pytest.raises(ValueError): + OWNGatewayEvent(frame) + with pytest.raises(ValueError): + OWNGatewayCommand(frame.replace("*#13**", "*#13**#", 1)) + with pytest.raises(ValueError): + OWNMessage.parse(frame) + + +@pytest.mark.parametrize( + ("frame", "command"), + [ + ("*#13**1*04*29*02*26##", "*#13**#1*04*29*02*26##"), # 29 February is a valid D / M pair + ("*#13**22*17*33*54*002*05*11*09*26##", "*#13**#22*17*33*54*002*05*11*09*26##"), + ], +) +def test_gateway_well_formed_date_with_short_year_reads_none(frame: str, command: str) -> None: + event = OWNGatewayEvent(frame) + assert event.is_valid is True and event.date is None and event.datetime is None + cmd = OWNGatewayCommand(command) + assert cmd.is_valid is True and cmd.date is None and cmd.datetime is None + + def test_gateway_date_year_must_be_four_digits_and_weekday_00_to_06() -> None: """A two-digit ``Y`` is not year 0026; ``W`` outside 00..06 reads ``None``. From 6115b8d8b43ef96f8f2accd7e2598179057b6f97 Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 4 Nov 2026 02:00:19 +0200 Subject: [PATCH 06/14] docs(gateway): note that requesting WHO 13 dimensions 10/11/12 crashes H4684 and MH200 The firmware oracle shows *#13**10##, *#13**11## and *#13**12## (IP address, netmask, MAC) as nack/crash on H4684 020054 and MH200 020102 and as ack/out on the other ten emulated gateways (findings/who13-identification.md). OWNd builds no request for them; the decoders only read replies seen on the bus. No send-layer guard is added on purpose: the frames are legitimate on most gateways, so a refusal would need the gateway model at send time. Recorded in gateway.py and the CHANGELOG (2026-11-04 audit G1). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + OWNd/message/gateway.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e947a6..ecd14e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to `OWNd` are documented in this file. - **WHO 22 (`OWNSoundDiffusionCommand`, `OWNSoundDiffusionEvent`)**: Tuner builders `seek_up` (`*22*5*WHERE##`), `seek_down` (`*22*6*WHERE##`), `step_frequency_up` (`*22*5#STEP*WHERE##`) and `step_frequency_down` (`*22*6#STEP*WHERE##`), STEP 1..15 (Encyclopedia `ownkb:chunk:r000478`-`r000480`, `ownkb:claim:c002729`), WHERE defaulting to `6`; `*22*5*2#1##`, `*22*6*2#1##`, `*22*5#1*2#1##` and `*22*6#1*2#1##` are ACKed and put on the bus (`$0494018FA0` / `FB0` / `FA1` / `FB1`) by F453, F453AV, F454, F459, F460, F461, MH200N and MyHomeServer1 in the firmware oracle and join the WHO 22 oracle conformance rows; WHAT 7 / 8 are not emitted (`bt_multi` NACKs them as not implemented) and `*22*7*2#1##` / `*22*8*2#1##` are not in the oracle index. The event gains `is_seek_up` / `is_seek_down` (bare WHAT 5 / 6), `frequency_step` (1..15, `None` outside) with `is_step_frequency_up` / `is_step_frequency_down` (WHAT `5#STEP` / `6#STEP`), log lines for all four, and `is_preset_cleared`: `True` for a Dimension 11 report with an explicit preset `0` (`*#22*5#2#1*11*1*10530*0##`, defensive parsing of the spec-shaped frame), where `preset` stays `None` as for a frame without a preset field; `*#22*5#2#1*11*1*10530*5##` is the physical station-5 report of an F500 (H/L4684 6.0.11, `MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-48-15.json`). ### Changed +- **WHO 13 (Dimensions 10 / 11 / 12)**: `gateway.py` documents that requesting the IP address, netmask and MAC (`*#13**10##`, `*#13**11##`, `*#13**12##`) makes the gateway daemon exit on H4684 `020054` and MH200 `020102` in the firmware oracle (`nack/crash`; `firmware_observed` under QEMU emulation, not confirmed on physical hardware; `findings/who13-identification.md`), while the other eleven emulated gateways ACK them (eight with a telegram on the bus, F453 / F453AV / MH200N silently); OWNd builds no request for them and deliberately applies no send-layer guard, since the frames are legitimate on most gateways and a refusal would need the gateway model at send time (2026-11-04 audit G1). - **WHO 13 (`OWNGatewayEvent.valid_mac_address`)**: new property with Dimension 12 as six decimal octets in `0..255` (Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"), else `None`: `*#13**12*0*3*80*1*2*300##` reads `None`, and so does the eight-value reply `*#13**12*0*3*80*1*2*3*4*5##` (the ZigBee interface IEEE address of zigbee-network-management.md "DIMENSION 12 - Interface IEEE address"), whose log now says it is not a MAC address. `mac_address` keeps formatting the first six values without a range check (`100:03:50:00:07:77` for an octet `256`), the behaviour MyHOME#804 relies on (report 21 G2, G3). - **WHO 13 (`OWNGatewayEvent.date`, `OWNGatewayCommand.date`, new `weekday`)**: a Dimension 1 / 22 year must be four digits (dimensions.md "DIMENSION 1 - Date"): `*#13**1*04*15*10*26##` stays a valid message, as before, but reads `date` `None` instead of `0026-10-15` (report 21 G4). New `weekday` property (`0` Sunday .. `6` Saturday, `None` outside `00..06`; not checked against the calendar date, the touchscreen client writes a fixed `00`; `date` unchanged) (report 21 G10). Out-of-table values (hour `25`, month `13`, a time zone that is not `[01]HH`, a truncated payload) keep raising `ValueError` from `OWNMessage.parse` as before: settled by OWNd#144 (closed 2026-10-28), the sessions catch it. - **WHO 13 (`timezone`)**: `T` `100` (sign digit `1`, offset `00`, GMT-0) reads `+00:00` like `000` instead of `-00:00`; the `tzinfo` was already UTC (report 21 G7). diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 5c098770..bd574e79 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -207,6 +207,16 @@ def __init__(self, data: str) -> None: self._human_readable_log = ( f"Gateway's internal date is: {self._date}." ) + # Requesting Dimensions 10, 11 and 12 (``*#13**10##``, ``*#13**11##``, + # ``*#13**12##``: IP address, netmask, MAC) makes the gateway daemon exit on + # H4684 020054 and MH200 020102 in the firmware oracle (``nack/crash``; + # ``firmware_observed`` under QEMU emulation, not confirmed on physical + # hardware; findings/who13-identification.md). The other eleven emulated + # gateways ACK them, eight with a telegram on the bus and three silently + # (F453, F453AV, MH200N). OWNd builds no request for them and applies no + # send-layer guard: the frames are legitimate on most gateways, so a refusal + # would need the gateway model at send time. These branches only decode + # replies seen on the bus (2026-11-04 audit G1). elif self._dimension == 10 and len(self._dimension_value) >= 4: self._ip_address = 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 IP address is: {self._ip_address}." From 4b5b8a2f17b4e2e67f6dfbbf8dd5d2527fd561ba Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 4 Nov 2026 16:46:20 +0200 Subject: [PATCH 07/14] review: revert the WHO 13 runtime changes without committed plant traces; keep the conformance-matrix and docstring fixes Maintainer review of #161: documentation and conformance-matrix fixes stay; runtime decoding, log strings and new properties derived from the documentation alone are reverted to master, since no committed on-wire plant traces back them. Blocker 1 (_gateway_date, Dimensions 1 and 22): reverted to master. A two-digit year (*#13**1*04*15*10*26##, *#13**22*...*26##, and the write frames) reads a datetime.date / datetime again instead of None; the _gateway_date helper is gone. Blocker 2 (_gateway_timezone): reverted to master. T "100" reads "-00:00" again, not "+00:00"; the added docstring is gone. Blocker 3: reverted the eight-value Dimension 12 "ZigBee interface IEEE address" log, the addressed Dimension 16 "Product X's firmware version" log, the valid_mac_address and weekday properties (event and command), the _gateway_weekday helper, the Dimension 12 octet range check and the Dimension 10/11/12 crash comment. Dimension 12 decoding is master's again (unchecked six-octet formatting, "Gateway's MAC address is" log). Kept: docs/protocol_conformance_matrix.md WHO 13 read examples *#13**0## / *#13**22## (G5); the OWNHeatingCommand.actuators_status docstring describing #Z#C as zone Z through a four-zone central unit C; the OWNGatewayEvent.device_type docstring and the WHO13_DEVICE_TYPES comment clarifications (G9). Tests: removed the eight tests pinning the reverted behaviour from tests/test_message_protocol.py; kept test_gateway_parsed_clock_frames_keep_the_broadcasting_log, which pins master's unchanged log text. CHANGELOG [Unreleased]: the five WHO 13 "Changed" entries are replaced by one documentation-only entry that claims no behaviour change. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 +- OWNd/message/gateway.py | 160 ++++-------------------------- tests/test_message_protocol.py | 174 +-------------------------------- 3 files changed, 22 insertions(+), 318 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd14e4c..cdf39e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,7 @@ All notable changes to `OWNd` are documented in this file. - **WHO 22 (`OWNSoundDiffusionCommand`, `OWNSoundDiffusionEvent`)**: Tuner builders `seek_up` (`*22*5*WHERE##`), `seek_down` (`*22*6*WHERE##`), `step_frequency_up` (`*22*5#STEP*WHERE##`) and `step_frequency_down` (`*22*6#STEP*WHERE##`), STEP 1..15 (Encyclopedia `ownkb:chunk:r000478`-`r000480`, `ownkb:claim:c002729`), WHERE defaulting to `6`; `*22*5*2#1##`, `*22*6*2#1##`, `*22*5#1*2#1##` and `*22*6#1*2#1##` are ACKed and put on the bus (`$0494018FA0` / `FB0` / `FA1` / `FB1`) by F453, F453AV, F454, F459, F460, F461, MH200N and MyHomeServer1 in the firmware oracle and join the WHO 22 oracle conformance rows; WHAT 7 / 8 are not emitted (`bt_multi` NACKs them as not implemented) and `*22*7*2#1##` / `*22*8*2#1##` are not in the oracle index. The event gains `is_seek_up` / `is_seek_down` (bare WHAT 5 / 6), `frequency_step` (1..15, `None` outside) with `is_step_frequency_up` / `is_step_frequency_down` (WHAT `5#STEP` / `6#STEP`), log lines for all four, and `is_preset_cleared`: `True` for a Dimension 11 report with an explicit preset `0` (`*#22*5#2#1*11*1*10530*0##`, defensive parsing of the spec-shaped frame), where `preset` stays `None` as for a frame without a preset field; `*#22*5#2#1*11*1*10530*5##` is the physical station-5 report of an F500 (H/L4684 6.0.11, `MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-48-15.json`). ### Changed -- **WHO 13 (Dimensions 10 / 11 / 12)**: `gateway.py` documents that requesting the IP address, netmask and MAC (`*#13**10##`, `*#13**11##`, `*#13**12##`) makes the gateway daemon exit on H4684 `020054` and MH200 `020102` in the firmware oracle (`nack/crash`; `firmware_observed` under QEMU emulation, not confirmed on physical hardware; `findings/who13-identification.md`), while the other eleven emulated gateways ACK them (eight with a telegram on the bus, F453 / F453AV / MH200N silently); OWNd builds no request for them and deliberately applies no send-layer guard, since the frames are legitimate on most gateways and a refusal would need the gateway model at send time (2026-11-04 audit G1). -- **WHO 13 (`OWNGatewayEvent.valid_mac_address`)**: new property with Dimension 12 as six decimal octets in `0..255` (Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"), else `None`: `*#13**12*0*3*80*1*2*300##` reads `None`, and so does the eight-value reply `*#13**12*0*3*80*1*2*3*4*5##` (the ZigBee interface IEEE address of zigbee-network-management.md "DIMENSION 12 - Interface IEEE address"), whose log now says it is not a MAC address. `mac_address` keeps formatting the first six values without a range check (`100:03:50:00:07:77` for an octet `256`), the behaviour MyHOME#804 relies on (report 21 G2, G3). -- **WHO 13 (`OWNGatewayEvent.date`, `OWNGatewayCommand.date`, new `weekday`)**: a Dimension 1 / 22 year must be four digits (dimensions.md "DIMENSION 1 - Date"): `*#13**1*04*15*10*26##` stays a valid message, as before, but reads `date` `None` instead of `0026-10-15` (report 21 G4). New `weekday` property (`0` Sunday .. `6` Saturday, `None` outside `00..06`; not checked against the calendar date, the touchscreen client writes a fixed `00`; `date` unchanged) (report 21 G10). Out-of-table values (hour `25`, month `13`, a time zone that is not `[01]HH`, a truncated payload) keep raising `ValueError` from `OWNMessage.parse` as before: settled by OWNd#144 (closed 2026-10-28), the sessions catch it. -- **WHO 13 (`timezone`)**: `T` `100` (sign digit `1`, offset `00`, GMT-0) reads `+00:00` like `000` instead of `-00:00`; the `tzinfo` was already UTC (report 21 G7). -- **WHO 13 logs**: an addressed Dimension 16 reply (`*#13*123400#9*16*1*2*3##`, a ZigBee product per zigbee-network-management.md "DIMENSION 16 and 17") logs "Product 123400#9's firmware version is: 1.2.3." instead of "Gateway's firmware version"; `firmware_version` and `where` are unchanged (report 21 G8). The `device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (report 21 G9). +- **Documentation only, no behaviour change**: `docs/protocol_conformance_matrix.md` shows the WHO 13 reads in the published empty-`WHERE` form (`*#13**0##`, `*#13**22##`) instead of `*#13*0*0##` / `*#13*0*22##`, the form `OWNGatewayCommand.request_time()` and the keepalive already emit; the `OWNHeatingCommand.actuators_status` docstring describes `#Z#C` as zone `Z` through a four-zone central unit `C` (a controlled-zone address, not an actuator); the `OWNGatewayEvent.device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (2026-11-04 audit G5, G9). ## [2.0.0b12] - 2026-11-01 diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index bd574e79..205b6af3 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -23,52 +23,16 @@ def _validate_gateway_clock_values( def _gateway_timezone(values: list[str]) -> str: - """Decode an optional OWN timezone, preserving unspecified local time. - - ``T`` is sign + two-digit hour offset (Encyclopedia - who-13-integration-gateway/dimensions.md "DIMENSION 0 - Time": ``001`` is - GMT+1, ``102`` GMT-2); ``100`` is GMT-0 and reads ``+00:00`` like ``000``. - """ + """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: raise ValueError(f"Invalid gateway timezone: {value!r}") - if value[1:] == "00": - return "+00:00" sign = "+" if value[0] == "0" else "-" return f"{sign}{value[1:]}:00" -def _gateway_weekday(value: str) -> int | None: - """``W`` as 0..6 (``00`` Sunday .. ``06`` Saturday), ``None`` outside the table. - - Not checked against the calendar date: the touchscreen client writes a - fixed ``00`` (dimensions.md "Historical touchscreen platform properties"). - """ - if re.fullmatch(r"[0-9]{1,2}", value) is not None and int(value) <= 6: - return int(value) - return None - - -def _gateway_date(year: str, month: str, day: str) -> _dt.date | None: - """Decode ``D*M*Y``; ``None`` when ``Y`` is not four digits (dimensions.md "DIMENSION 1 - Date"). - - Day and month are validated first, exactly as on master: an out-of-range - value (``*#13**1*04*99*32*26##``, month ``13``, day ``32``) raises - ``ValueError`` whatever the year looks like (OWNd#144, caught by the - sessions). Only a well-formed date whose ``Y`` is not four digits - (``*#13**1*04*15*10*26##``: not year 0026) gives ``None``; the frame stays - valid with the raw values. - """ - if re.fullmatch(r"[0-9]{4}", year) is None: - # Validate D and M against a leap year so 29 February still passes; - # 30 February, month 13 or day 32 raise as on master. - _dt.date(year=2000, month=int(month), day=int(day)) - return None - return _dt.date(year=int(year), month=int(month), day=int(day)) - - # WHO 13 Dimension 15: Functional gateway model/device type (*#13**15*##). # Grounded in published BTicino protocol tables, physical plant captures, # and firmware reverse engineering (bt_device dispatch table at VA 0x02003194, @@ -158,7 +122,6 @@ def __init__(self, data: str) -> None: self._second = None self._timezone = None self._raw_timezone = None - self._weekday: int | None = None self._time = None self._date = None @@ -167,7 +130,6 @@ def __init__(self, data: str) -> None: self._ip_address = None self._netmask = None self._mac_address = None - self._valid_mac_address: str | None = None self._device_type = None self._firmware_version = None @@ -198,25 +160,15 @@ def __init__(self, data: str) -> None: ) elif self._dimension == 1: - self._weekday = _gateway_weekday(self._dimension_value[0]) self._year = self._dimension_value[3] self._month = self._dimension_value[2] self._day = self._dimension_value[1] - self._date = _gateway_date(self._year, self._month, self._day) - if self._date is not None: - self._human_readable_log = ( - f"Gateway's internal date is: {self._date}." - ) - # Requesting Dimensions 10, 11 and 12 (``*#13**10##``, ``*#13**11##``, - # ``*#13**12##``: IP address, netmask, MAC) makes the gateway daemon exit on - # H4684 020054 and MH200 020102 in the firmware oracle (``nack/crash``; - # ``firmware_observed`` under QEMU emulation, not confirmed on physical - # hardware; findings/who13-identification.md). The other eleven emulated - # gateways ACK them, eight with a telegram on the bus and three silently - # (F453, F453AV, MH200N). OWNd builds no request for them and applies no - # send-layer guard: the frames are legitimate on most gateways, so a refusal - # would need the gateway model at send time. These branches only decode - # replies seen on the bus (2026-11-04 audit G1). + self._date = _dt.date( + year=int(self._year), month=int(self._month), day=int(self._day) + ) + self._human_readable_log = ( + f"Gateway's internal date is: {self._date}." + ) elif self._dimension == 10 and len(self._dimension_value) >= 4: self._ip_address = 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 IP address is: {self._ip_address}." @@ -226,32 +178,11 @@ def __init__(self, data: str) -> None: self._human_readable_log = f"Gateway's netmask is: {self._netmask}." elif self._dimension == 12 and len(self._dimension_value) >= 6: - # ``mac_address`` keeps formatting the first six values as hex - # without a range check (MyHOME#804: the integration validates the - # value itself and its tests pin that an octet ``256`` reads - # ``100``). ``valid_mac_address`` is the checked form: exactly six - # decimal ASCII octets in 0..255 (Encyclopedia dimensions.md - # "DIMENSION 12 - MAC address"), else ``None``. Eight values are - # the ZigBee interface IEEE address (zigbee-network-management.md - # "DIMENSION 12 - Interface IEEE address"), not a MAC: the log - # says so and the values stay raw in ``dimension_value``. - octets = self._dimension_value try: - self._mac_address = ":".join(f"{int(o):02x}" for o in octets[:6]) - except (TypeError, ValueError): - pass - if len(octets) == 6 and all( - re.fullmatch(r"[0-9]{1,3}", o) is not None and int(o) <= 255 - for o in octets - ): - self._valid_mac_address = self._mac_address + 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 len(octets) == 8: - self._human_readable_log = ( - "Gateway reported an eight-value dimension 12 " - "(ZigBee interface IEEE address, not a MAC address): " - f"{'*'.join(octets)}." - ) + except (IndexError, TypeError, ValueError): + pass elif self._dimension == 15 and self._dimension_value: code = self._dimension_value[0] @@ -260,19 +191,9 @@ def __init__(self, data: str) -> None: elif self._dimension == 16 and len(self._dimension_value) >= 3: self._firmware_version = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}" # pylint: disable=line-too-long - if self._where: - # ``*#13*PRODUCT00#9*16*V*R*B##`` is the firmware of the - # addressed ZigBee product, not of the gateway (Encyclopedia - # zigbee-network-management.md "DIMENSION 16 and 17 - - # Firmware and hardware versions"); ``where`` tells. - product = self._where + "".join(f"#{p}" for p in self._where_param) - self._human_readable_log = ( - f"Product {product}'s firmware version is: {self._firmware_version}." - ) - else: - self._human_readable_log = ( - f"Gateway's firmware version is: {self._firmware_version}." - ) + self._human_readable_log = ( + f"Gateway's firmware version is: {self._firmware_version}." + ) elif ( self._dimension == 19 @@ -300,14 +221,9 @@ def __init__(self, data: str) -> None: else None ) self._timezone = _gateway_timezone(self._dimension_value) - self._weekday = _gateway_weekday(self._dimension_value[4]) self._day = self._dimension_value[5] self._month = self._dimension_value[6] self._year = self._dimension_value[7] - if _gateway_date(self._year, self._month, self._day) is None: - # Two-digit year: the frame stays valid, raw values kept, - # typed properties None (dimensions.md "DIMENSION 1 - Date"). - return 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 ) @@ -363,16 +279,6 @@ def date(self) -> _dt.date | None: """Return the decoded gateway internal date.""" return self._date - @property - def weekday(self) -> int | None: - """Day of week of a Dimension 1 / 22 payload: ``0`` Sunday .. ``6`` Saturday. - - ``None`` outside ``00..06`` (Encyclopedia dimensions.md "DIMENSION 1 - - Date"); not checked against the calendar date, since the touchscreen - client writes a fixed ``00``. - """ - return self._weekday - @property def datetime(self) -> _dt.datetime | None: """Return the decoded gateway internal datetime.""" @@ -408,18 +314,6 @@ def netmask(self) -> str | None: """Return the decoded gateway netmask.""" return self._netmask - @property - def valid_mac_address(self) -> str | None: - """Dimension 12 as a MAC address, or ``None`` when it is not one. - - Six decimal ASCII octets in ``0..255`` (Encyclopedia - who-13-integration-gateway/dimensions.md "DIMENSION 12 - MAC address"); - ``None`` for an octet outside the range, a seven-value reply or the - eight-value ZigBee interface IEEE address. ``mac_address`` keeps the - unchecked formatting of the first six values. - """ - return self._valid_mac_address - @property def mac_address(self) -> str | None: """Return the decoded gateway MAC address.""" @@ -478,7 +372,6 @@ def __init__(self, data: str) -> None: self._second = None self._timezone = None self._raw_timezone = None - self._weekday: int | None = None self._time = None self._date = None @@ -508,15 +401,15 @@ def __init__(self, data: str) -> None: ) elif self._dimension == 1: - self._weekday = _gateway_weekday(self._dimension_value[0]) self._year = self._dimension_value[3] self._month = self._dimension_value[2] self._day = self._dimension_value[1] - self._date = _gateway_date(self._year, self._month, self._day) - if self._date is not None: - self._human_readable_log = ( - f"Gateway broadcasting internal date: {self._date}." - ) + self._date = _dt.date( + year=int(self._year), month=int(self._month), day=int(self._day) + ) + self._human_readable_log = ( + f"Gateway broadcasting internal date: {self._date}." + ) elif self._dimension == 22: self._hour = self._dimension_value[0] @@ -528,14 +421,9 @@ def __init__(self, data: str) -> None: else None ) self._timezone = _gateway_timezone(self._dimension_value) - self._weekday = _gateway_weekday(self._dimension_value[4]) self._day = self._dimension_value[5] self._month = self._dimension_value[6] self._year = self._dimension_value[7] - if _gateway_date(self._year, self._month, self._day) is None: - # Two-digit year: the frame stays valid, raw values kept, - # typed properties None (dimensions.md "DIMENSION 1 - Date"). - return 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 ) @@ -559,16 +447,6 @@ def date(self) -> _dt.date | None: """Return the decoded gateway internal date.""" return self._date - @property - def weekday(self) -> int | None: - """Day of week of a Dimension 1 / 22 payload: ``0`` Sunday .. ``6`` Saturday. - - ``None`` outside ``00..06`` (Encyclopedia dimensions.md "DIMENSION 1 - - Date"); not checked against the calendar date, since the touchscreen - client writes a fixed ``00``. - """ - return self._weekday - @property def datetime(self) -> _dt.datetime | None: """Return the decoded gateway internal datetime.""" diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index 28cb629b..861fc28f 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -265,121 +265,12 @@ def test_malformed_gateway_clock_is_rejected(frame: str) -> None: OWNMessage.parse(frame) -def test_gateway_timezone_100_is_gmt_plus_zero() -> None: - """``T`` ``100`` (sign digit 1, offset 00) is GMT-0, read as ``+00:00`` like ``000``. - - Encyclopedia who-13-integration-gateway/dimensions.md "DIMENSION 0 - Time" - (report 21 G7). Out-of-table time zones keep raising (OWNd#144). - """ - for suffix in ("000", "100"): - message = OWNMessage.parse(f"*#13**0*12*30*00*{suffix}##") - assert isinstance(message, OWNGatewayEvent) - assert message.timezone == "+00:00" - assert message.raw_timezone == suffix - assert message.time == datetime.time(12, 30, tzinfo=datetime.timezone.utc) - assert OWNGatewayEvent("*#13**0*12*30*00*001##").timezone == "+01:00" - assert OWNGatewayEvent("*#13**0*12*30*00*102##").timezone == "-02:00" - with pytest.raises(ValueError, match="Invalid gateway timezone: '0530'"): - OWNMessage.parse("*#13**0*12*30*00*0530##") - - -@pytest.mark.parametrize( - "frame", - [ - "*#13**1*04*99*32*26##", # month 99 and day 32, two-digit year - "*#13**1*04*32*10*26##", # day 32, two-digit year - "*#13**1*04*15*13*26##", # month 13, two-digit year - "*#13**1*04*30*02*26##", # 30 February, two-digit year - "*#13**1*04*32*10*2026##", # day 32, four-digit year (master) - "*#13**22*17*33*54*002*05*32*13*26##", # dimension 22, day 32 / month 13, two-digit year - "*#13**22*17*33*54*002*05*31*04*2026##", # dimension 22, 31 April, four-digit year - ], -) -def test_gateway_day_and_month_are_validated_before_the_year(frame: str) -> None: - """Out-of-range D / M raise as on master whatever the year looks like (review follow-up, OWNd#144).""" - with pytest.raises(ValueError): - OWNGatewayEvent(frame) - with pytest.raises(ValueError): - OWNGatewayCommand(frame.replace("*#13**", "*#13**#", 1)) - with pytest.raises(ValueError): - OWNMessage.parse(frame) - - -@pytest.mark.parametrize( - ("frame", "command"), - [ - ("*#13**1*04*29*02*26##", "*#13**#1*04*29*02*26##"), # 29 February is a valid D / M pair - ("*#13**22*17*33*54*002*05*11*09*26##", "*#13**#22*17*33*54*002*05*11*09*26##"), - ], -) -def test_gateway_well_formed_date_with_short_year_reads_none(frame: str, command: str) -> None: - event = OWNGatewayEvent(frame) - assert event.is_valid is True and event.date is None and event.datetime is None - cmd = OWNGatewayCommand(command) - assert cmd.is_valid is True and cmd.date is None and cmd.datetime is None - - -def test_gateway_date_year_must_be_four_digits_and_weekday_00_to_06() -> None: - """A two-digit ``Y`` is not year 0026; ``W`` outside 00..06 reads ``None``. - - Encyclopedia dimensions.md "DIMENSION 1 - Date" (report 21 G4, G10). The - two-digit-year frame was a valid message on master and stays one: raw - values kept, ``date`` ``None``, no raise. Out-of-range day / month values - keep raising as before (OWNd#144). - """ - short_year = OWNMessage.parse("*#13**1*04*15*10*26##") - assert isinstance(short_year, OWNGatewayEvent) - assert short_year.is_valid - assert short_year.date is None - assert short_year.weekday == 4 - assert short_year.dimension_value == ["04", "15", "10", "26"] - assert short_year.human_readable_log == "*#13**1*04*15*10*26##" - - short_year_22 = OWNMessage.parse("*#13**22*16*37*03*001*05*25*09*26##") - assert isinstance(short_year_22, OWNGatewayEvent) - assert short_year_22.datetime is None and short_year_22.date is None - assert short_year_22.time is None - assert short_year_22.timezone == "+01:00" - assert short_year_22.weekday == 5 - - weekday_7 = OWNMessage.parse("*#13**1*07*15*10*2026##") - assert isinstance(weekday_7, OWNGatewayEvent) - assert weekday_7.date == datetime.date(2026, 10, 15) - assert weekday_7.weekday is None - - sunday = OWNMessage.parse("*#13**1*00*25*10*2026##") - assert isinstance(sunday, OWNGatewayEvent) - assert sunday.weekday == 0 - assert sunday.date == datetime.date(2026, 10, 25) - - combined = OWNMessage.parse("*#13**22*16*37*03*001*05*25*09*2026##") - assert isinstance(combined, OWNGatewayEvent) - assert combined.weekday == 5 - assert OWNMessage.parse("*#13**22*16*37*03*001*9*25*09*2026##").weekday is None - assert OWNMessage.parse("*#13**22*12*14*58*001**15*04*2026##").weekday is None - assert OWNMessage.parse("*#13**0*12*30*00##").weekday is None - - written = OWNMessage.parse("*#13**#1*03*07*10*2026##") - assert isinstance(written, OWNGatewayCommand) - assert written.weekday == 3 - written_short = OWNGatewayCommand("*#13**#1*03*07*10*26##") - assert written_short.date is None and written_short.weekday == 3 - written_short_22 = OWNGatewayCommand("*#13**#22*17*33*54*002*05*11*09*26##") - assert written_short_22.datetime is None and written_short_22.weekday == 5 - - with pytest.raises(ValueError, match="month must be in 1..12"): - OWNMessage.parse("*#13**1*00*32*13*2024##") - with pytest.raises(ValueError, match="year must be between 2000 and 2099"): - OWNGatewayCommand.set_date(datetime.date(26, 10, 15)) - - def test_gateway_parsed_clock_frames_keep_the_broadcasting_log() -> None: """Parsed write and reply frames keep the historical log text. MyHOME pins "Gateway broadcasting internal time: 22:50:00." for the write - form ``*#13**#0*22*50*00*##`` (tests/test_f461_who13_clock_tz_live_2026_10_26_trace.py), - so the report 21 G6 wording change is not applied to parsed frames; the - builders keep their own "Setting gateway ..." text. + form ``*#13**#0*22*50*00*##`` (tests/test_f461_who13_clock_tz_live_2026_10_26_trace.py); + the builders keep their own "Setting gateway ..." text. No behaviour change. """ assert ( OWNMessage.parse("*#13**#0*11*30*00*002##").human_readable_log @@ -415,67 +306,6 @@ def test_gateway_parsed_clock_frames_keep_the_broadcasting_log() -> None: ) -def test_gateway_mac_address_octets_are_range_checked() -> None: - """``valid_mac_address`` is six decimal octets 0..255 (dimensions.md "DIMENSION 12 - MAC address", G2). - - ``mac_address`` keeps the unchecked formatting MyHOME pins (MyHOME#804: - an octet ``256`` reads ``100`` and the integration refuses it itself). - """ - good = OWNGatewayEvent("*#13**12*0*3*80*0*4*97##") - assert good.mac_address == "00:03:50:00:04:61" - assert good.valid_mac_address == "00:03:50:00:04:61" - assert good.human_readable_log == "Gateway's MAC address is: 00:03:50:00:04:61." - assert OWNGatewayEvent("*#13**12*0*3*80*0*4*255##").valid_mac_address == "00:03:50:00:04:ff" - too_big = OWNGatewayEvent("*#13**12*0*3*80*1*2*300##") - assert too_big.mac_address == "00:03:50:01:02:12c" - assert too_big.valid_mac_address is None - assert too_big.dimension_value == ["0", "3", "80", "1", "2", "300"] - assert too_big.human_readable_log == "*#13**12*0*3*80*1*2*300##" - assert OWNGatewayEvent("*#13**12*256*3*80*0*7*119##").mac_address == "100:03:50:00:07:77" - short = OWNGatewayEvent("*#13**12*0*3*80*1*2##") - assert short.mac_address is None - assert short.valid_mac_address is None - seven = OWNGatewayEvent("*#13**12*0*3*80*1*2*3*4##") - assert seven.mac_address == "00:03:50:01:02:03" - assert seven.valid_mac_address is None - empty = OWNGatewayEvent("*#13**12*0*3*80*1*2*##") - assert empty.mac_address is None - assert empty.valid_mac_address is None - - -def test_gateway_who13_decoders_take_ascii_digits_only() -> None: - """Non-ASCII digits never make a checked MAC, a weekday or a date (OWNd#139 rule).""" - arabic_zero = OWNGatewayEvent("*#13**12*\u0660*3*80*1*2*3##") - assert arabic_zero.valid_mac_address is None - assert OWNGatewayEvent("*#13**1*\u0660\u0660*15*10*2026##").weekday is None - assert OWNGatewayEvent("*#13**1*00*15*10*\u0662026##").date is None - - -def test_gateway_dimension_12_with_eight_values_is_not_a_mac() -> None: - """Eight values are the ZigBee interface IEEE address (zigbee-network-management.md, G3).""" - message = OWNMessage.parse("*#13**12*0*3*80*1*2*3*4*5##") - assert isinstance(message, OWNGatewayEvent) - assert message.mac_address == "00:03:50:01:02:03" - assert message.valid_mac_address is None - assert message.dimension_value == ["0", "3", "80", "1", "2", "3", "4", "5"] - assert message.human_readable_log == ( - "Gateway reported an eight-value dimension 12 " - "(ZigBee interface IEEE address, not a MAC address): 0*3*80*1*2*3*4*5." - ) - - -def test_gateway_addressed_dimension_16_is_a_product_firmware() -> None: - """``*#13*PRODUCT00#9*16*V*R*B##`` is the product's firmware, not the gateway's (G8).""" - message = OWNMessage.parse("*#13*123400#9*16*1*2*3##") - assert isinstance(message, OWNGatewayEvent) - assert message.where == "123400" - assert message.firmware_version == "1.2.3" - assert message.human_readable_log == "Product 123400#9's firmware version is: 1.2.3." - gateway = OWNMessage.parse("*#13**16*2*0*11##") - assert isinstance(gateway, OWNGatewayEvent) - assert gateway.human_readable_log == "Gateway's firmware version is: 2.0.11." - - def test_gateway_time_command_has_no_empty_trailing_value() -> None: message = OWNGatewayCommand.set_time_to_now("UTC") From 8cfff16eca94072c80a42845e19b8fb4b79af4c8 Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 4 Nov 2026 16:49:25 +0200 Subject: [PATCH 08/14] changelog: keep the review entries under [Unreleased] after merging master --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 021216c9..bdb9b53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,15 @@ All notable changes to `OWNd` are documented in this file. ## [Unreleased] +### Changed +- **Documentation only, no behaviour change**: `docs/protocol_conformance_matrix.md` shows the WHO 13 reads in the published empty-`WHERE` form (`*#13**0##`, `*#13**22##`) instead of `*#13*0*0##` / `*#13*0*22##`, the form `OWNGatewayCommand.request_time()` and the keepalive already emit; the `OWNHeatingCommand.actuators_status` docstring describes `#Z#C` as zone `Z` through a four-zone central unit `C` (a controlled-zone address, not an actuator); the `OWNGatewayEvent.device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (2026-11-04 audit G5, G9). + ## [2.0.0b13] - 2026-11-02 ### Added - **WHO 1004 (`OWNHeatingDiagnosticEvent`, `OWNHeatingDiagnosticCommand`)**: Thermoregulation autodiagnostics (Encyclopedia Machine KB `ownkb:chunk:r000204`-`r000209`, `ownkb:claim:c004423`, `ownkb:claim:c004429`-`c004448`; `libdiag.so` `OPN_OpenScsDiagTermoTr`, `libwhere.so` `OPN_ThermoWhereTransl`), registered for WHO 1004 and exported from `OWNd.message`. The event follows the `OPN_ThermoWhereTransl` WHERE grammar (`scope` `central` for `#0` / `#0#C`, `zone` for `#Z` / `#Z#C`, `probe` for `Z` 1..99, `slave_probe` for `NZZ` 101..899 with `probe_number`, `external_probe` for `100..900`, `actuator` for `Z#N` with `actuator_number`; `zone_number`; anything else has no scope), the 24-bit mask of Dimensions 7, 8, 11 and 12 on the central unit and the addressed devices and the 16-bit mask of Dimensions 21 and 22 on a zone (`diagnostic_mask`, `cleared_bits`, `has_fault`; `1` is healthy, `0` active; the WHO_4 v2.0.0 pp. 69-74 bit conditions are noted, the mask stays raw) and the Dimension 23 fault-count summary of the central unit (`*#1004*#0*23*NO_RESPONSE*FAULTS##`: `non_responding_units`, `faulty_units`); any other value shape fails closed with the raw values in `dimension_value`. `unique_id` carries the full WHERE (`1004-#0#2`, `1004-#66#2`, `1004-66#1`) since WHO 1004 is not in `OWNMessage.interface` (`#0#C` is a central unit, not a bus interface). The builders emit only the keys `libdiag.so` registers: `request_central_diagnostic` (`*#1004*#0*7##`, `dimension` 7 / 8 / 11 / 12), `request_device_diagnostic` (`Z`, `NZZ`, `N00`, `Z#N`), `request_zones_with_failures` (`*#1004*#0*20##`), `request_zone_diagnostic` (`*#1004*#Z*21##`; `#0`, `#0#C`, `1..99`, `#Z`, `#Z#C`) and `request_fault_counts` (`*#1004*#0*23##`); there is no `*22##` request (Dimension 22 is pushed by the gateway only). The physical frames are the first WHO 1004 frames captured on a bus (`*#1004*66*11*111111111111111111111111##`, `*#1004*#66*22*1111111111111111##`, `*#1004*#0*11*111111111111111111111111##`), pushed by an H/L4684 6.0.11 with a 3550 central unit once the F430/4 channel `66#1` of zone 66 came back online (`MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-30-19.json`); none is in the firmware oracle index. `*#1004*#66*22*...##` is not a WHO 4 Dimension 22 crash form (the guard anchors on WHO 4; pinned by tests). The README WHO catalog lists WHO 1004 under WHO 4. - **WHO 16 (`OWNSoundCommand`, `OWNSoundEvent`)**: FM tuner manual frequency steps `step_frequency_up` / `step_frequency_down` (`*16*5001..5015*WHERE##` / `*16*5101..5115*WHERE##`, step 1..15; Encyclopedia `ownkb:chunk:r000371`, `ownkb:claim:c002096` / `c002098`; `5000` / `5100` stay the automatic seeks) and `store_station` (`*#16*WHERE*#10*STATION##`, station 1..5 only, the `bt_difson` ceiling of `ownkb:claim:c002128`; stations 6..15 go through WHO 22 `store_preset`). `*16*5001*101##`, `*16*5101*101##` and `*#16*101*#10*5##` are ACKed and put on the bus (`$0494018FA1`, `$0494018FB1`, `$0493018FD4`) by F453, F453AV, F454, H4684, MH200 and MH200N in the firmware oracle and NACKed by F450, F459, F460, F461, MH202 and MyHomeServer1; the tests pin all three rows. `OWNSoundEvent.frequency_step_forward` / `frequency_step_backward` decode WHAT 5001..5015 / 5101..5115. Dimension 10 is write-only (`bt_difson` never emits it, `ownkb:claim:c002135`): only the Dimension 7 station report reads into `track` (physical F500 `*#16*101*7*0*5##`, H/L4684 6.0.11, `MyHOME#839`). - **WHO 22 (`OWNSoundDiffusionCommand`, `OWNSoundDiffusionEvent`)**: Tuner builders `seek_up` (`*22*5*WHERE##`), `seek_down` (`*22*6*WHERE##`), `step_frequency_up` (`*22*5#STEP*WHERE##`) and `step_frequency_down` (`*22*6#STEP*WHERE##`), STEP 1..15 (Encyclopedia `ownkb:chunk:r000478`-`r000480`, `ownkb:claim:c002729`), WHERE defaulting to `6`; `*22*5*2#1##`, `*22*6*2#1##`, `*22*5#1*2#1##` and `*22*6#1*2#1##` are ACKed and put on the bus (`$0494018FA0` / `FB0` / `FA1` / `FB1`) by F453, F453AV, F454, F459, F460, F461, MH200N and MyHomeServer1 in the firmware oracle and join the WHO 22 oracle conformance rows; WHAT 7 / 8 are not emitted (`bt_multi` NACKs them as not implemented) and `*22*7*2#1##` / `*22*8*2#1##` are not in the oracle index. The event gains `is_seek_up` / `is_seek_down` (bare WHAT 5 / 6), `frequency_step` (1..15, `None` outside) with `is_step_frequency_up` / `is_step_frequency_down` (WHAT `5#STEP` / `6#STEP`), log lines for all four, and `is_preset_cleared`: `True` for a Dimension 11 report with an explicit preset `0` (`*#22*5#2#1*11*1*10530*0##`, defensive parsing of the spec-shaped frame), where `preset` stays `None` as for a frame without a preset field; `*#22*5#2#1*11*1*10530*5##` is the physical station-5 report of an F500 (H/L4684 6.0.11, `MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-48-15.json`). - -### Changed -- **Documentation only, no behaviour change**: `docs/protocol_conformance_matrix.md` shows the WHO 13 reads in the published empty-`WHERE` form (`*#13**0##`, `*#13**22##`) instead of `*#13*0*0##` / `*#13*0*22##`, the form `OWNGatewayCommand.request_time()` and the keepalive already emit; the `OWNHeatingCommand.actuators_status` docstring describes `#Z#C` as zone `Z` through a four-zone central unit `C` (a controlled-zone address, not an actuator); the `OWNGatewayEvent.device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (2026-11-04 audit G5, G9). - ## [2.0.0b12] - 2026-11-01 ### Added From 95e99eea569904ecb1a6a4e1190cff5f3bbaf0d3 Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 4 Nov 2026 16:51:15 +0200 Subject: [PATCH 09/14] changelog: restore the blank line before the 2.0.0b12 heading --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdb9b53c..e358e45a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to `OWNd` are documented in this file. - **WHO 1004 (`OWNHeatingDiagnosticEvent`, `OWNHeatingDiagnosticCommand`)**: Thermoregulation autodiagnostics (Encyclopedia Machine KB `ownkb:chunk:r000204`-`r000209`, `ownkb:claim:c004423`, `ownkb:claim:c004429`-`c004448`; `libdiag.so` `OPN_OpenScsDiagTermoTr`, `libwhere.so` `OPN_ThermoWhereTransl`), registered for WHO 1004 and exported from `OWNd.message`. The event follows the `OPN_ThermoWhereTransl` WHERE grammar (`scope` `central` for `#0` / `#0#C`, `zone` for `#Z` / `#Z#C`, `probe` for `Z` 1..99, `slave_probe` for `NZZ` 101..899 with `probe_number`, `external_probe` for `100..900`, `actuator` for `Z#N` with `actuator_number`; `zone_number`; anything else has no scope), the 24-bit mask of Dimensions 7, 8, 11 and 12 on the central unit and the addressed devices and the 16-bit mask of Dimensions 21 and 22 on a zone (`diagnostic_mask`, `cleared_bits`, `has_fault`; `1` is healthy, `0` active; the WHO_4 v2.0.0 pp. 69-74 bit conditions are noted, the mask stays raw) and the Dimension 23 fault-count summary of the central unit (`*#1004*#0*23*NO_RESPONSE*FAULTS##`: `non_responding_units`, `faulty_units`); any other value shape fails closed with the raw values in `dimension_value`. `unique_id` carries the full WHERE (`1004-#0#2`, `1004-#66#2`, `1004-66#1`) since WHO 1004 is not in `OWNMessage.interface` (`#0#C` is a central unit, not a bus interface). The builders emit only the keys `libdiag.so` registers: `request_central_diagnostic` (`*#1004*#0*7##`, `dimension` 7 / 8 / 11 / 12), `request_device_diagnostic` (`Z`, `NZZ`, `N00`, `Z#N`), `request_zones_with_failures` (`*#1004*#0*20##`), `request_zone_diagnostic` (`*#1004*#Z*21##`; `#0`, `#0#C`, `1..99`, `#Z`, `#Z#C`) and `request_fault_counts` (`*#1004*#0*23##`); there is no `*22##` request (Dimension 22 is pushed by the gateway only). The physical frames are the first WHO 1004 frames captured on a bus (`*#1004*66*11*111111111111111111111111##`, `*#1004*#66*22*1111111111111111##`, `*#1004*#0*11*111111111111111111111111##`), pushed by an H/L4684 6.0.11 with a 3550 central unit once the F430/4 channel `66#1` of zone 66 came back online (`MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-30-19.json`); none is in the firmware oracle index. `*#1004*#66*22*...##` is not a WHO 4 Dimension 22 crash form (the guard anchors on WHO 4; pinned by tests). The README WHO catalog lists WHO 1004 under WHO 4. - **WHO 16 (`OWNSoundCommand`, `OWNSoundEvent`)**: FM tuner manual frequency steps `step_frequency_up` / `step_frequency_down` (`*16*5001..5015*WHERE##` / `*16*5101..5115*WHERE##`, step 1..15; Encyclopedia `ownkb:chunk:r000371`, `ownkb:claim:c002096` / `c002098`; `5000` / `5100` stay the automatic seeks) and `store_station` (`*#16*WHERE*#10*STATION##`, station 1..5 only, the `bt_difson` ceiling of `ownkb:claim:c002128`; stations 6..15 go through WHO 22 `store_preset`). `*16*5001*101##`, `*16*5101*101##` and `*#16*101*#10*5##` are ACKed and put on the bus (`$0494018FA1`, `$0494018FB1`, `$0493018FD4`) by F453, F453AV, F454, H4684, MH200 and MH200N in the firmware oracle and NACKed by F450, F459, F460, F461, MH202 and MyHomeServer1; the tests pin all three rows. `OWNSoundEvent.frequency_step_forward` / `frequency_step_backward` decode WHAT 5001..5015 / 5101..5115. Dimension 10 is write-only (`bt_difson` never emits it, `ownkb:claim:c002135`): only the Dimension 7 station report reads into `track` (physical F500 `*#16*101*7*0*5##`, H/L4684 6.0.11, `MyHOME#839`). - **WHO 22 (`OWNSoundDiffusionCommand`, `OWNSoundDiffusionEvent`)**: Tuner builders `seek_up` (`*22*5*WHERE##`), `seek_down` (`*22*6*WHERE##`), `step_frequency_up` (`*22*5#STEP*WHERE##`) and `step_frequency_down` (`*22*6#STEP*WHERE##`), STEP 1..15 (Encyclopedia `ownkb:chunk:r000478`-`r000480`, `ownkb:claim:c002729`), WHERE defaulting to `6`; `*22*5*2#1##`, `*22*6*2#1##`, `*22*5#1*2#1##` and `*22*6#1*2#1##` are ACKed and put on the bus (`$0494018FA0` / `FB0` / `FA1` / `FB1`) by F453, F453AV, F454, F459, F460, F461, MH200N and MyHomeServer1 in the firmware oracle and join the WHO 22 oracle conformance rows; WHAT 7 / 8 are not emitted (`bt_multi` NACKs them as not implemented) and `*22*7*2#1##` / `*22*8*2#1##` are not in the oracle index. The event gains `is_seek_up` / `is_seek_down` (bare WHAT 5 / 6), `frequency_step` (1..15, `None` outside) with `is_step_frequency_up` / `is_step_frequency_down` (WHAT `5#STEP` / `6#STEP`), log lines for all four, and `is_preset_cleared`: `True` for a Dimension 11 report with an explicit preset `0` (`*#22*5#2#1*11*1*10530*0##`, defensive parsing of the spec-shaped frame), where `preset` stays `None` as for a frame without a preset field; `*#22*5#2#1*11*1*10530*5##` is the physical station-5 report of an F500 (H/L4684 6.0.11, `MyHOME#839`, fixture `issue_466` `myhome_trace_HL4684_all_2026-11-01T11-48-15.json`). + ## [2.0.0b12] - 2026-11-01 ### Added From e501d6a93e0952894d4a14168ef03a8c174925f5 Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 11 Nov 2026 00:18:27 +0200 Subject: [PATCH 10/14] review: WHO 13 value fields are ASCII digits only; oracle suites and 13-gateway counts in the matrix; Dimension 10/11/12 request caveats; F461 and H/L4684 golden replies Review round 2 on #161 (GreenGrassBlueOcean, 2026-11-10): - gateway.py: Dimensions 1, 12, 15 and 19 read their value fields only when they are ASCII digits (_is_ascii_digits); fullwidth digits used to decode a MAC, an uptime or an Unknown (..) device type, or raise from date() / fromisoformat. _gateway_timezone matches with re.ASCII so a fullwidth timezone raises the same ValueError as any malformed clock value (#144). The review's empty-token cases (dims 1 / 22 weekday, 12, 15, 19) never raised on master; pinned by tests/test_who13_ascii_values.py. - request_ip_address / request_netmask / request_mac_address docstrings note the who13-identification verdicts (8 of 13 answer; F453, F453AV, MH200N ACK silently; H4684 020054 and MH200 020102 NACK and crash their worker). - docs/protocol_conformance_matrix.md cites own-firmware-oracle by suite (ownd-audit-frames, who13-clock, who13-clock-readback) and the 13-gateway counts; the private worksheet path is gone. - CHANGELOG: the documentation bullet is gone (D14); a Fixed entry covers the ASCII hardening only. - Golden: six community-plant-capture records (F461 2.0.11 Dimensions 0 and 22 with the 999 timezone field, 16, 23, 24; H/L4684 6.0.11 Dimension 15 code 23) from MyHOME's committed fixtures; corpus 172 frames; README count. - origin/master (1cb2dc1) merged in. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +- OWNd/message/gateway.py | 59 +++++++++++-- docs/protocol_conformance_matrix.md | 2 +- tests/golden/README.md | 4 +- tests/golden/corpus.json | 118 +++++++++++++++++++++++++ tests/golden/frames/who13_gateway.yaml | 77 ++++++++++++++++ tests/test_who13_ascii_values.py | 79 +++++++++++++++++ 7 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 tests/test_who13_ascii_values.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 037047f2..ae030222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - **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. ### Added @@ -19,9 +20,6 @@ All notable changes to `OWNd` are documented in this file. - **WHO 1 (`OWNLightingEvent`)**: `OWNMessage.parse` no longer raises on a DIMENSION reply with an empty or short value list: `*#1*12*1*##`, `*#1*12*4*##`, `*#1*12*1*150*##`, `*#1*12*4*150*##`, `*#1*12*2*##`, `*#1*12*5*##`, `*#1*12*6*##`, `*#1*12*7*##`, `*#1*12*14*##` (`ValueError` from `int('')`) and `*#1*12*2*1*2##`, `*#1*12*7*0*1##` (`IndexError`) now return the event with the property `None` and a log saying the value is missing or the report incomplete (Encyclopedia `protocol/frame-syntax.md` "Delimiters and empty tags": "Tags can be empty ... A tokenizer must preserve that empty field"; `dimensions.md` "DIMENSION 2 - temporization": `HOURS*MINUTES*SECONDS`). A value field is read only when it is ASCII digits (`str.isdigit` also accepts fullwidth, superscript and circled digits), in DIMENSION 12 too (`*#1*12*12*100*50*50##` is not a colour). DIMENSION 8 and 9 replies were never affected: they fall through to the unknown-dimension log. - **WHO 4 (`OWNHeatingEvent`)**: `OWNMessage.parse` / `OWNEvent.parse` no longer raise on a WHO 4 frame the grammar accepts but the decoder could not read: an empty DIMENSION 11 / 13 / 60 value (`*#4*1*11*##`, `*#4*1*60*##` raised `ValueError`; `*#4*1*11##`, `*#4*1*13##`, `*#4*1*60##` raised `IndexError`), an empty DIMENSION 15 sensor slot (`*#4*1*15**##`, `*#4*1*15**0215##`, `*#4*1*15#1**0215##` raised `ValueError` from `int('')`) and the wildcard WHERE (`*4*1**##`, `*4*303**##` raised `ValueError` from `int('*')`; the event is zone 0, as a frame without a WHERE already was). Such a frame stays untyped with `fan_speed` / `fan_on` / `main_humidity` / `secondary_temperature` `None`. The integer fields of DIMENSION 11, 15, 19, 20, 30, 31 and 32 are read only when they are ASCII digits, as the DIMENSION 60 humidity value already was (`str.isdigit` also accepts fullwidth digits: `*#4*1*11*3*##` decoded `fan_speed` 3, `*#4*#0*30*01*02*2026##` a date; both are untyped now). Crash guards only: every well-formed ASCII frame decodes exactly as before (same `message_type`, property values and log lines). -### Changed -- **Documentation only, no behaviour change**: `docs/protocol_conformance_matrix.md` shows the WHO 13 reads in the published empty-`WHERE` form (`*#13**0##`, `*#13**22##`) instead of `*#13*0*0##` / `*#13*0*22##`, the form `OWNGatewayCommand.request_time()` and the keepalive already emit; the `OWNHeatingCommand.actuators_status` docstring describes `#Z#C` as zone `Z` through a four-zone central unit `C` (a controlled-zone address, not an actuator); the `OWNGatewayEvent.device_type` docstring and the `WHO13_DEVICE_TYPES` comment say that an unlisted Dimension 15 code stays `Unknown (N)` and that the non-unique `200` is a list of sharers, not a product identity (2026-11-04 audit G5, G9). - ## [2.0.0b13] - 2026-11-02 ### Added diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 205b6af3..a1b55a2a 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -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" @@ -151,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}" @@ -163,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) ) @@ -177,14 +189,18 @@ 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: + elif ( + self._dimension == 12 + and len(self._dimension_value) >= 6 + and all(_is_ascii_digits(v) for v in 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 == 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}." @@ -198,7 +214,7 @@ 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]) ): try: self._uptime = _dt.timedelta( @@ -224,6 +240,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 ) @@ -392,6 +413,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}" @@ -404,6 +427,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) ) @@ -424,6 +449,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 ) @@ -635,17 +665,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 diff --git a/docs/protocol_conformance_matrix.md b/docs/protocol_conformance_matrix.md index 7a74b4bb..2cfd8719 100644 --- a/docs/protocol_conformance_matrix.md +++ b/docs/protocol_conformance_matrix.md @@ -36,7 +36,7 @@ To prevent divergence and eliminate guesswork, every protocol frame and capabili | **18** | **Energy Mgmt** | Instantaneous active power (W), Cumulative energy (kWh) | `*#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. The WHO 13 reads use the published empty-`WHERE` form `*#13**DIMENSION##` (Encyclopedia who-13-integration-gateway/dimensions.md "Read form"): on the twelve emulated gateways `*#13**0##` is answered with `*#13**0*H*M*S*T##` by all twelve, while the `WHERE 0` spelling `*#13*0*0##` this table used to show is `nack, silent` on all twelve (review-session oracle run of 2026-11-03, `audit_ency/ORACLE_CONFIRM.md` row G5; `OWNGatewayCommand.request_time()` and the event keepalive emit `*#13**0##`). +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##`. --- diff --git a/tests/golden/README.md b/tests/golden/README.md index 9996ed55..1c1a1fef 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -21,7 +21,7 @@ Every other source (`legrand-spec`, `encyclopedia`, `openwebnet4j`, `public-read - A `builder:` block proves the builder matches the fixture, not that either is right. When both are written in the same change from the same reading, the test only compares the code with itself. Prefer builder parity against a capture or an independent source such as `openwebnet4j`. - When a capture arrives for a frame that so far exists only as a spec-derived fixture, add the capture and keep the spec entry only if it agrees. -## Supported Subsystems Catalog (118 Fixtures) +## Supported Subsystems Catalog (172 Fixtures) - **Signaling (`who00_signaling.yaml`)**: Gateway ACK (`*#*1##`) and NACK (`*#*0##`). - **WHO=0 Scenarios (`who00_scenario.yaml`)**: Basic scenario execution and stop. @@ -30,7 +30,7 @@ Every other source (`legrand-spec`, `encyclopedia`, `openwebnet4j`, `public-read - **WHO=4 Thermoregulation (`who04_thermo.yaml`)**: Measured temperature queries (`21.5°C`), setpoint writes, Antifreeze/Protection modes, and a signed dimension 12 complete-probe-status fixture (setpoint after local offset, `-4.8°C`; not a measured temperature). Two MyHomeServer1 + Home+Control plants (MyHOME#429) add dimension 7, which carries the zone state and setpoint on those plants and is not in `WHO_4 2.pdf`: cooling and heating setpoints, protection (the zone is OFF: `*4*202*Z##` in cooling, `*4*102*Z##` antifreeze at 7.0 °C in heating), the 35.0 °C protection value sent just before a real setpoint, MyHomeServer1's `*#4*Z*#7*...##` program writes, and the dimension 5 re-assert. - **WHO=5 Burglar Alarm (`who05_alarm.yaml`)**: Status requests and silent alarm events across zones and central units. - **WHO=9 Auxiliary (`who09_auxiliary.yaml`)**: Activation and deactivation of AUX relay channels. -- **WHO=13 Gateway Management (`who13_gateway.yaml`)**: Firmware versions and gateway internal datetime responses. +- **WHO=13 Gateway Management (`who13_gateway.yaml`)**: Firmware versions and gateway internal datetime responses; physical F461 2.0.11 replies with the timezone field `999` (Dimensions 0 and 22, decoded as naive time) and its Dimension 16 / 23 / 24 versions (MyHOME#865), and the H/L4684 6.0.11 Dimension 15 code `23` (MyHOME#466). - **WHO=15 CEN Pushbuttons (`who15_cen.yaml`)**: Pressure, short release, long release and extended pressure (`*15*BUTTON[#1|#2|#3]*WHERE##`), with a four-digit source address captured on an MH200N + MyHomeServer1 bus (`0512`, MyHOME#624). - **WHO=18 Energy Management (`who18_energy.yaml`)**: Instantaneous active power and cumulative energy totalizers. - **WHO=25 CEN+ / Dry Contacts (`who25_cen_plus.yaml`)**: CEN+ short press, hold start, hold repeat and release (`*25*21..24#PUSHBUTTON*2OBJECT##`, captured on an MH201) and a dry-contact event captured on an H4890 (`*25*31#1*339##`; on the bus, dry-contact WHEREs start with `3`). diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index e366e66e..fd055142 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -2772,6 +2772,124 @@ "roundtrip": true, "_file": "who13_gateway.yaml" }, + { + "id": "gateway.resp.internal.time.f461.999", + "frame": "*#13**0*17*45*24*999##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 0, + "dimension_values": [ + "17", + "45", + "24", + "999" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Gateway Management: BTicino F461 FW 2.0.11 answers *#13**0## with the time and the timezone field 999 (no timezone configured), decoded as a naive 17:45:24 (MyHOME#865, tests/fixtures/traces/f461_live_2026-11-04/myhome_sweep_F461_all_2026-11-04T15-45-56.json)", + "roundtrip": true, + "_file": "who13_gateway.yaml" + }, + { + "id": "gateway.resp.internal.datetime.f461.999", + "frame": "*#13**22*22*59*16*999*01*26*10*2026##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 22, + "dimension_values": [ + "22", + "59", + "16", + "999", + "01", + "26", + "10", + "2026" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Gateway Management: BTicino F461 FW 2.0.11 reports Dimension 22 with the timezone field 999 (no timezone configured), weekday Monday (01) 2026-10-26 22:59:16, decoded as a naive datetime (MyHOME, tests/fixtures/traces/f461_who13_clock_tz_live_2026-10-26/myhome_trace_F461_who13_clock_tz_live_2026-10-26.json)", + "roundtrip": true, + "_file": "who13_gateway.yaml" + }, + { + "id": "gateway.resp.firmware.version.f461", + "frame": "*#13**16*2*0*11##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 16, + "dimension_values": [ + "2", + "0", + "11" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Gateway Management: BTicino F461 reports firmware 2.0.11 on Dimension 16 (MyHOME#865, f461_live_2026-11-04 sweep)", + "roundtrip": true, + "_file": "who13_gateway.yaml" + }, + { + "id": "gateway.resp.kernel.version.f461", + "frame": "*#13**23*5*10*35##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 23, + "dimension_values": [ + "5", + "10", + "35" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Gateway Management: BTicino F461 FW 2.0.11 reports kernel 5.10.35 on Dimension 23 (MyHOME#865, f461_live_2026-11-04 sweep)", + "roundtrip": true, + "_file": "who13_gateway.yaml" + }, + { + "id": "gateway.resp.distribution.version.f461", + "frame": "*#13**24*2*26*4##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 24, + "dimension_values": [ + "2", + "26", + "4" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Gateway Management: BTicino F461 FW 2.0.11 reports distribution 2.26.4 on Dimension 24 (MyHOME#865, f461_live_2026-11-04 sweep)", + "roundtrip": true, + "_file": "who13_gateway.yaml" + }, + { + "id": "gateway.resp.model.device_type.hl4684.23", + "frame": "*#13**15*23##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 15, + "dimension_values": [ + "23" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Gateway Management: BTicino H/L4684 FW 6.0.11 answers *#13**15## with code 23 (MyHOME#466, tests/fixtures/traces/issue_466/config_entry-myhome_HL4684_fw6_0_11.json); decoded through WHO13_DEVICE_TYPES", + "roundtrip": true, + "_file": "who13_gateway.yaml" + }, { "id": "cen.event.press.btn2.11", "frame": "*15*02*11##", diff --git a/tests/golden/frames/who13_gateway.yaml b/tests/golden/frames/who13_gateway.yaml index 44cbb690..7ca26dd4 100644 --- a/tests/golden/frames/who13_gateway.yaml +++ b/tests/golden/frames/who13_gateway.yaml @@ -265,3 +265,80 @@ mcp_notes: "Gateway Diagnostic: Gateway reports object model 26 (LGRH4684 Legrand color touch screen, verified from TiDisplayColor firmware)" roundtrip: true +- id: gateway.resp.internal.time.f461.999 + frame: "*#13**0*17*45*24*999##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 0 + dimension_values: ["17", "45", "24", "999"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino F461 FW 2.0.11 answers *#13**0## with the time and the timezone field 999 (no timezone configured), decoded as a naive 17:45:24 (MyHOME#865, tests/fixtures/traces/f461_live_2026-11-04/myhome_sweep_F461_all_2026-11-04T15-45-56.json)" + roundtrip: true + +- id: gateway.resp.internal.datetime.f461.999 + frame: "*#13**22*22*59*16*999*01*26*10*2026##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 22 + dimension_values: ["22", "59", "16", "999", "01", "26", "10", "2026"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino F461 FW 2.0.11 reports Dimension 22 with the timezone field 999 (no timezone configured), weekday Monday (01) 2026-10-26 22:59:16, decoded as a naive datetime (MyHOME, tests/fixtures/traces/f461_who13_clock_tz_live_2026-10-26/myhome_trace_F461_who13_clock_tz_live_2026-10-26.json)" + roundtrip: true + +- id: gateway.resp.firmware.version.f461 + frame: "*#13**16*2*0*11##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 16 + dimension_values: ["2", "0", "11"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino F461 reports firmware 2.0.11 on Dimension 16 (MyHOME#865, f461_live_2026-11-04 sweep)" + roundtrip: true + +- id: gateway.resp.kernel.version.f461 + frame: "*#13**23*5*10*35##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 23 + dimension_values: ["5", "10", "35"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino F461 FW 2.0.11 reports kernel 5.10.35 on Dimension 23 (MyHOME#865, f461_live_2026-11-04 sweep)" + roundtrip: true + +- id: gateway.resp.distribution.version.f461 + frame: "*#13**24*2*26*4##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 24 + dimension_values: ["2", "26", "4"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino F461 FW 2.0.11 reports distribution 2.26.4 on Dimension 24 (MyHOME#865, f461_live_2026-11-04 sweep)" + roundtrip: true + +- id: gateway.resp.model.device_type.hl4684.23 + frame: "*#13**15*23##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 15 + dimension_values: ["23"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino H/L4684 FW 6.0.11 answers *#13**15## with code 23 (MyHOME#466, tests/fixtures/traces/issue_466/config_entry-myhome_HL4684_fw6_0_11.json); decoded through WHO13_DEVICE_TYPES" + roundtrip: true diff --git a/tests/test_who13_ascii_values.py b/tests/test_who13_ascii_values.py new file mode 100644 index 00000000..7b0b6d3a --- /dev/null +++ b/tests/test_who13_ascii_values.py @@ -0,0 +1,79 @@ +"""WHO 13 value fields are ASCII digits only; clock values keep raising as before (OWNd#161 review). + +``str.isdigit`` and ``int`` accept fullwidth digits, which are not on the bus +(Encyclopedia ``protocol/frame-syntax.md``). Dimensions 12 / 15 / 19 with such a +field now stay untyped (raw frame as log), as the Dimension 27 allowlist already +did; a fullwidth clock field raises the same ``ValueError`` as any malformed clock +value (#144: the sessions catch it). Every ASCII frame decodes exactly as before. +""" + +from __future__ import annotations + +import datetime + +import pytest + +from OWNd.message import OWNGatewayEvent, OWNMessage + + +@pytest.mark.parametrize( + ("frame", "attribute"), + [ + ("*#13**12*0*3*80*1*214*135##", "mac_address"), + ("*#13**15*23##", "device_type"), + ("*#13**19*1*2*3*4##", "uptime"), + ], +) +def test_fullwidth_digits_leave_the_dimension_untyped(frame: str, attribute: str) -> None: + message = OWNMessage.parse(frame) + assert isinstance(message, OWNGatewayEvent) + assert getattr(message, attribute) is None + assert message.human_readable_log == frame + + +@pytest.mark.parametrize( + "frame", + [ + "*#13**1*03*26*10*\uff12\uff10\uff12\uff16##", + "*#13**#1*03*26*10*\uff12\uff10\uff12\uff16##", + "*#13**0*\uff11\uff12*00*00*002##", + "*#13**#0*\uff11\uff12*00*00*002##", + "*#13**0*12*00*00*0\uff10\uff11##", + "*#13**22*12*00*00*0\uff10\uff11*01*01*01*2026##", + "*#13**22*12*00*00*001*01*\uff10\uff11*2026##", + "*#13**#22*12*00*00*001*01*\uff10\uff11*2026##", + ], +) +def test_fullwidth_clock_fields_raise_the_malformed_clock_error(frame: str) -> None: + # A fullwidth digit in the Dimension 22 date part is split differently by the frame + # grammar and fails the value-count check instead; still the same ValueError family. + with pytest.raises(ValueError, match="Invalid gateway (date|time|datetime|timezone)|requires 8 values"): + OWNMessage.parse(frame) + + +def test_ascii_frames_decode_as_before() -> None: + assert OWNMessage.parse("*#13**12*0*3*80*1*214*135##").mac_address == "00:03:50:01:d6:87" + assert OWNMessage.parse("*#13**15*23##").device_type == "H4684" + assert OWNMessage.parse("*#13**15*200##").device_type.startswith("F454 /") + assert OWNMessage.parse("*#13**19*1*2*3*4##").uptime == datetime.timedelta(days=1, hours=2, minutes=3, seconds=4) + assert OWNMessage.parse("*#13**1*03*26*10*2026##").human_readable_log == "Gateway's internal date is: 2026-10-26." + assert OWNMessage.parse("*#13**0*17*45*24*999##").human_readable_log == "Gateway's internal time is: 17:45:24." + assert OWNMessage.parse("*#13**22*22*59*16*999*01*26*10*2026##").human_readable_log == "Gateway's internal datetime is: 2026-10-26 22:59:16." + assert OWNMessage.parse("*#13**0*20*54*00*002##").timezone == "+02:00" + + +def test_ascii_non_digit_tokens_never_reach_the_decoder() -> None: + """The frame grammar admits digit tokens only, so an ASCII letter or sign is no frame at all.""" + for frame in ("*#13**15*abc##", "*#13**19* 1*2*3*4##", "*#13**19*+1*2*3*4##"): + assert OWNMessage.parse(frame) is None + + +def test_empty_tokens_do_not_raise() -> None: + """Empty value tokens never raised on master (the review's cases 1-4); pinned so it stays that way.""" + for frame in ("*#13**12*0**80*1*214*135##", "*#13**19**0*0*0##", "*#13**15*##"): + message = OWNMessage.parse(frame) + assert isinstance(message, OWNGatewayEvent) + assert message.human_readable_log == frame + for frame in ("*#13**1**01*01*2026##", "*#13**22*12*00*00*001**01*01*2026##"): + message = OWNMessage.parse(frame) + assert isinstance(message, OWNGatewayEvent) # the weekday field is not decoded From 6eca707f019cf656fa83896bcfc12d41c9bed4df Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 11 Nov 2026 00:53:07 +0200 Subject: [PATCH 11/14] ci: cover the Dimension 22 ASCII check and drop the dead Dimension 19 except The coverage gate (100 % on OWNd/) flagged gateway.py 200-201 (an except that ASCII-gated int() can never reach) and 247 / 456 (the Dimension 22 raise, which the tests missed with seven-value frames). The test frames now carry the eight Dimension 22 values with a fullwidth hour, year or day. Co-Authored-By: Claude Fable 5.1 --- OWNd/message/gateway.py | 18 ++++++++---------- tests/test_who13_ascii_values.py | 9 ++++----- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index a1b55a2a..84c8b75a 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -216,16 +216,14 @@ def __init__(self, data: str) -> None: and len(self._dimension_value) >= 4 and all(_is_ascii_digits(v) for v in self._dimension_value[:4]) ): - try: - self._uptime = _dt.timedelta( - days=int(self._dimension_value[0]), - hours=int(self._dimension_value[1]), - minutes=int(self._dimension_value[2]), - seconds=int(self._dimension_value[3]), - ) - self._human_readable_log = f"Gateway's uptime is: {self._uptime}." - except (IndexError, TypeError, ValueError): - pass + # Four ASCII-digit fields: int() cannot raise here. + self._uptime = _dt.timedelta( + days=int(self._dimension_value[0]), + hours=int(self._dimension_value[1]), + minutes=int(self._dimension_value[2]), + seconds=int(self._dimension_value[3]), + ) + self._human_readable_log = f"Gateway's uptime is: {self._uptime}." elif self._dimension == 22: self._hour = self._dimension_value[0] diff --git a/tests/test_who13_ascii_values.py b/tests/test_who13_ascii_values.py index 7b0b6d3a..5a82e3d5 100644 --- a/tests/test_who13_ascii_values.py +++ b/tests/test_who13_ascii_values.py @@ -40,14 +40,13 @@ def test_fullwidth_digits_leave_the_dimension_untyped(frame: str, attribute: str "*#13**#0*\uff11\uff12*00*00*002##", "*#13**0*12*00*00*0\uff10\uff11##", "*#13**22*12*00*00*0\uff10\uff11*01*01*01*2026##", - "*#13**22*12*00*00*001*01*\uff10\uff11*2026##", - "*#13**#22*12*00*00*001*01*\uff10\uff11*2026##", + "*#13**22*\uff11\uff12*00*00*001*01*01*01*2026##", + "*#13**22*12*00*00*001*01*01*01*\uff12\uff10\uff12\uff16##", + "*#13**#22*12*00*00*001*01*\uff10\uff11*01*2026##", ], ) def test_fullwidth_clock_fields_raise_the_malformed_clock_error(frame: str) -> None: - # A fullwidth digit in the Dimension 22 date part is split differently by the frame - # grammar and fails the value-count check instead; still the same ValueError family. - with pytest.raises(ValueError, match="Invalid gateway (date|time|datetime|timezone)|requires 8 values"): + with pytest.raises(ValueError, match="Invalid gateway (date|time|datetime|timezone)"): OWNMessage.parse(frame) From fb5568d41d18949a480af8c4a05360b4a687fd43 Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 11 Nov 2026 00:55:39 +0200 Subject: [PATCH 12/14] ci: keep the Dimension 19 timedelta guard (absurd magnitudes raise) and cover it tests/test_message_exhaustive.py feeds *#13**19*99999999999999999999*0*0*0##: int() of ASCII digits is fine but timedelta raises on the magnitude, so the except stays (OverflowError, ValueError) and a test covers it. Co-Authored-By: Claude Fable 5.1 --- OWNd/message/gateway.py | 20 ++++++++++++-------- tests/test_who13_ascii_values.py | 8 ++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 84c8b75a..a66b85fd 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -216,14 +216,18 @@ def __init__(self, data: str) -> None: and len(self._dimension_value) >= 4 and all(_is_ascii_digits(v) for v in self._dimension_value[:4]) ): - # Four ASCII-digit fields: int() cannot raise here. - self._uptime = _dt.timedelta( - days=int(self._dimension_value[0]), - hours=int(self._dimension_value[1]), - minutes=int(self._dimension_value[2]), - seconds=int(self._dimension_value[3]), - ) - self._human_readable_log = f"Gateway's uptime is: {self._uptime}." + # 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]), + hours=int(self._dimension_value[1]), + minutes=int(self._dimension_value[2]), + seconds=int(self._dimension_value[3]), + ) + self._human_readable_log = f"Gateway's uptime is: {self._uptime}." + except (OverflowError, ValueError): + pass elif self._dimension == 22: self._hour = self._dimension_value[0] diff --git a/tests/test_who13_ascii_values.py b/tests/test_who13_ascii_values.py index 5a82e3d5..de8f85ec 100644 --- a/tests/test_who13_ascii_values.py +++ b/tests/test_who13_ascii_values.py @@ -67,6 +67,14 @@ def test_ascii_non_digit_tokens_never_reach_the_decoder() -> None: assert OWNMessage.parse(frame) is None +def test_absurd_uptime_magnitude_stays_untyped() -> None: + """ASCII digits pass the gate, but timedelta refuses the magnitude: no crash, no uptime.""" + message = OWNMessage.parse("*#13**19*99999999999999999999*0*0*0##") + assert isinstance(message, OWNGatewayEvent) + assert message.uptime is None + assert message.human_readable_log == "*#13**19*99999999999999999999*0*0*0##" + + def test_empty_tokens_do_not_raise() -> None: """Empty value tokens never raised on master (the review's cases 1-4); pinned so it stays that way.""" for frame in ("*#13**12*0**80*1*214*135##", "*#13**19**0*0*0##", "*#13**15*##"): From 4b941a97abf6ff7677a406df49b1d15d68c34791 Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 11 Nov 2026 00:56:56 +0200 Subject: [PATCH 13/14] ci: drop the Dimension 12 except that the ASCII gate made unreachable With six ASCII-digit fields int() cannot raise; the empty-token case that used to hit this except now leaves the frame untyped before the branch. Co-Authored-By: Claude Fable 5.1 --- OWNd/message/gateway.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index a66b85fd..78daadbb 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -194,11 +194,10 @@ def __init__(self, data: str) -> None: and len(self._dimension_value) >= 6 and all(_is_ascii_digits(v) for v in 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 + # 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 and _is_ascii_digits(self._dimension_value[0]): code = self._dimension_value[0] From a8573f252e438184f089f5f219b0f6479c6c26cc Mon Sep 17 00:00:00 2001 From: gdluck Date: Thu, 12 Nov 2026 15:00:09 +0200 Subject: [PATCH 14/14] test: pin the golden README fixture count at 172 after #159 landed #159 (merged in c546a2e's history) pins the golden README count and the corpus length at 166 in test_golden_corpus_dimension_10_write_is_conflict_and_no_tilt; this PR adds six WHO 13 records, so the pin becomes 172 (tests/golden/README.md and corpus.json already say 172). Co-Authored-By: Claude --- tests/test_who2_audit_fixes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_who2_audit_fixes.py b/tests/test_who2_audit_fixes.py index d810518b..85670fd7 100644 --- a/tests/test_who2_audit_fixes.py +++ b/tests/test_who2_audit_fixes.py @@ -84,7 +84,7 @@ def test_golden_corpus_dimension_10_write_is_conflict_and_no_tilt() -> None: golden_readme = (REPO_ROOT / "tests" / "golden" / "README.md").read_text(encoding="utf-8") match = re.search(r"## Supported Subsystems Catalog \((\d+) Fixtures\)", golden_readme) assert match is not None - assert int(match.group(1)) == len(frames) == 166 + assert int(match.group(1)) == len(frames) == 172 @pytest.mark.parametrize("builder", BUILDERS)