diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb1ee4..c3cd475 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. - **WHO 15 / WHO 25 (`OWNCENEvent`, `OWNDryContactEvent`)**: a frame without a WHAT no longer reads as an event. `OWNEvent.parse("*#15*21##")` / `OWNMessage.parse("*#15*21*1*0##")` gave an `OWNCENEvent` with `is_pressed` `True` and the log "Button None of CEN object 21 has been pressed." (a phantom short press downstream); it now has `is_pressed` `False` and a log naming the status form. `OWNEvent.parse("*#25*339##")` / `OWNMessage.parse("*#25*339*1*0##")` gave an `OWNDryContactEvent` with `is_on` `False` and `is_detection` `True` ("Sensor 39 detected OFF."); `is_on` is now `None`, `is_detection` `False`, with a log naming the form. A frame with an empty WHAT tag (`*15**21##`, `*25**339##`) is treated the same. On the command side `OWNMessage.parse("*#15*21##")` / `("*#25*339##")` stay the status requests (`OWNCenCommand`, `OWNDryContactCommand`). WHAT 31 / 32 frames and every CEN pushbutton frame decode exactly as before (`OWNAlarmEvent`, `OWNDoorEntryEvent` and `OWNIntercomEvent` already failed closed on a missing WHAT). - **WHO 15 / WHO 25 (`OWNCenCommand`, `OWNCenPlusCommand`)**: the button argument refuses `bool` and fractional values with `ValueError`: `OWNCenCommand.press("21", True)` built `*15*01*21##` and `OWNCenPlusCommand.press("21", 1.9)` built `*25*21#1*21##` through `int()`. Integers and digit strings `0..31` are accepted as before. - **CLI (`python -m OWNd`)**: no longer crashes at startup on Python 3.14 with `RuntimeError: There is no current event loop in thread 'MainThread'`. The entry point relied on `asyncio.get_event_loop()` creating a loop implicitly, which Python 3.14 removed; it now runs through `asyncio.run()`. A startup failure now ends the command with a traceback and a non-zero exit code instead of leaving it running until Ctrl-C: an exception during startup, a gateway that cannot be reached (after the usual 5 connection attempts: about 13 s when the port refuses the connection, up to about 63 s when the host does not answer at all and each attempt times out after 10 s with `OWNTimeoutError`) and a rejected password (at once). The first connection is opened with `connect(raise_on_error=True)`; once connected, a dropped connection is still reconnected automatically. The event session is closed on every exit path. Without `--port`, the entry point probes the network for the gateway description; when nothing answers it now exits with `OWNConnectionError` (`discovery_failed`) if no `--address` was given, or connects directly to `--address` on the default port 20000, instead of failing on an assertion in the session with no connection attempt; and when the probe did find the gateway, a `--password` / `--mac` given on the command line fills in what its description does not carry (the probe used to drop both, so a password-protected gateway was refused with `password_required`). The library API is not affected. diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 743fc9b..78daadb 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" @@ -36,7 +44,15 @@ def _gateway_timezone(values: list[str]) -> str: # 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", @@ -143,6 +159,8 @@ def __init__(self, data: str) -> None: if len(self._dimension_value) > 3 and self._dimension_value[3] else None ) + if not all(_is_ascii_digits(v) for v in (self._hour, self._minute, self._second)): + raise ValueError(f"Invalid gateway time: {self._dimension_value!r}") self._timezone = _gateway_timezone(self._dimension_value) self._time = _dt.time.fromisoformat( f"{self._hour}:{self._minute}:{self._second}{self._timezone}" @@ -155,6 +173,8 @@ def __init__(self, data: str) -> None: self._year = self._dimension_value[3] self._month = self._dimension_value[2] self._day = self._dimension_value[1] + if not all(_is_ascii_digits(v) for v in (self._year, self._month, self._day)): + raise ValueError(f"Invalid gateway date: {self._dimension_value!r}") self._date = _dt.date( year=int(self._year), month=int(self._month), day=int(self._day) ) @@ -169,14 +189,17 @@ def __init__(self, data: str) -> None: self._netmask = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}.{self._dimension_value[3]}" # pylint: disable=line-too-long self._human_readable_log = f"Gateway's netmask is: {self._netmask}." - elif self._dimension == 12 and len(self._dimension_value) >= 6: - try: - self._mac_address = f"{int(self._dimension_value[0]):02x}:{int(self._dimension_value[1]):02x}:{int(self._dimension_value[2]):02x}:{int(self._dimension_value[3]):02x}:{int(self._dimension_value[4]):02x}:{int(self._dimension_value[5]):02x}" # pylint: disable=line-too-long - self._human_readable_log = f"Gateway's MAC address is: {self._mac_address}." - except (IndexError, TypeError, ValueError): - pass + elif ( + self._dimension == 12 + and len(self._dimension_value) >= 6 + and all(_is_ascii_digits(v) for v in self._dimension_value[:6]) + ): + # Six ASCII-digit fields (checked above): int() cannot raise here; + # an empty or non-ASCII field leaves the frame untyped instead. + self._mac_address = f"{int(self._dimension_value[0]):02x}:{int(self._dimension_value[1]):02x}:{int(self._dimension_value[2]):02x}:{int(self._dimension_value[3]):02x}:{int(self._dimension_value[4]):02x}:{int(self._dimension_value[5]):02x}" # pylint: disable=line-too-long + self._human_readable_log = f"Gateway's MAC address is: {self._mac_address}." - elif self._dimension == 15 and self._dimension_value: + elif self._dimension == 15 and self._dimension_value and _is_ascii_digits(self._dimension_value[0]): code = self._dimension_value[0] self._device_type = WHO13_DEVICE_TYPES.get(code, f"Unknown ({code})") self._human_readable_log = f"Gateway device type is: {self._device_type}." @@ -190,8 +213,10 @@ def __init__(self, data: str) -> None: elif ( self._dimension == 19 and len(self._dimension_value) >= 4 - and all(self._dimension_value[:4]) + and all(_is_ascii_digits(v) for v in self._dimension_value[:4]) ): + # Four ASCII-digit fields: int() cannot raise, but timedelta does + # on an absurd magnitude (tests/test_message_exhaustive.py). try: self._uptime = _dt.timedelta( days=int(self._dimension_value[0]), @@ -200,7 +225,7 @@ def __init__(self, data: str) -> None: seconds=int(self._dimension_value[3]), ) self._human_readable_log = f"Gateway's uptime is: {self._uptime}." - except (IndexError, TypeError, ValueError): + except (OverflowError, ValueError): pass elif self._dimension == 22: @@ -216,6 +241,11 @@ def __init__(self, data: str) -> None: self._day = self._dimension_value[5] self._month = self._dimension_value[6] self._year = self._dimension_value[7] + if not all( + _is_ascii_digits(v) + for v in (self._hour, self._minute, self._second, self._day, self._month, self._year) + ): + raise ValueError(f"Invalid gateway datetime: {self._dimension_value!r}") self._datetime = _dt.datetime.fromisoformat( f"{self._year}-{self._month}-{self._day}T{self._hour}:{self._minute}:{self._second}{self._timezone}" # pylint: disable=line-too-long ) @@ -318,7 +348,15 @@ def firmware_version(self) -> str | None: @property def device_type(self) -> str | None: - """Return the decoded gateway device type string.""" + """Return the decoded gateway device type string. + + ``Unknown (N)`` for a Dimension 15 code outside ``WHO13_DEVICE_TYPES`` + (the published table is not exhaustive and an unlisted code stays an + unknown code); the non-unique ``200`` reads as the slash-separated list + of the gateways known to share it, not as one product: identify them + through WHO 1013 Dimension 1 (``OWNGatewayDiagnosticEvent``). + ``device_type_code`` keeps the raw code either way. + """ return self._device_type @property @@ -376,6 +414,8 @@ def __init__(self, data: str) -> None: if len(self._dimension_value) > 3 and self._dimension_value[3] else None ) + if not all(_is_ascii_digits(v) for v in (self._hour, self._minute, self._second)): + raise ValueError(f"Invalid gateway time: {self._dimension_value!r}") self._timezone = _gateway_timezone(self._dimension_value) self._time = _dt.time.fromisoformat( f"{self._hour}:{self._minute}:{self._second}{self._timezone}" @@ -388,6 +428,8 @@ def __init__(self, data: str) -> None: self._year = self._dimension_value[3] self._month = self._dimension_value[2] self._day = self._dimension_value[1] + if not all(_is_ascii_digits(v) for v in (self._year, self._month, self._day)): + raise ValueError(f"Invalid gateway date: {self._dimension_value!r}") self._date = _dt.date( year=int(self._year), month=int(self._month), day=int(self._day) ) @@ -408,6 +450,11 @@ def __init__(self, data: str) -> None: self._day = self._dimension_value[5] self._month = self._dimension_value[6] self._year = self._dimension_value[7] + if not all( + _is_ascii_digits(v) + for v in (self._hour, self._minute, self._second, self._day, self._month, self._year) + ): + raise ValueError(f"Invalid gateway datetime: {self._dimension_value!r}") self._datetime = _dt.datetime.fromisoformat( f"{self._year}-{self._month}-{self._day}T{self._hour}:{self._minute}:{self._second}{self._timezone}" # pylint: disable=line-too-long ) @@ -619,17 +666,32 @@ def request_date(cls) -> OWNGatewayCommand: @classmethod def request_ip_address(cls) -> OWNGatewayCommand: - """Request the gateway IP address (Dimension 10).""" + """Request the gateway IP address (Dimension 10). + + own-firmware-oracle ``who13-identification``: answered by 8 of the 13 + emulated gateways and ACKed silently by F453, F453AV and MH200N, but + H4684 020054 and MH200 020102 NACK it and crash their worker process. + """ return cls("*#13**10##") @classmethod def request_netmask(cls) -> OWNGatewayCommand: - """Request the gateway netmask (Dimension 11).""" + """Request the gateway netmask (Dimension 11). + + own-firmware-oracle ``who13-identification``: answered by 8 of the 13 + emulated gateways and ACKed silently by F453, F453AV and MH200N, but + H4684 020054 and MH200 020102 NACK it and crash their worker process. + """ return cls("*#13**11##") @classmethod def request_mac_address(cls) -> OWNGatewayCommand: - """Request the gateway MAC address (Dimension 12).""" + """Request the gateway MAC address (Dimension 12). + + own-firmware-oracle ``who13-identification``: answered by 8 of the 13 + emulated gateways and ACKed silently by F453, F453AV and MH200N, but + H4684 020054 and MH200 020102 NACK it and crash their worker process. + """ return cls("*#13**12##") @classmethod diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 4c97b46..357234b 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -1080,9 +1080,11 @@ def actuators_status(cls, where: str | int) -> OWNHeatingCommand: ``where`` is a zone of one or two digits (``2``, ``#2``), one actuator (``2#1``; ``2#0`` for all actuators of the zone), the central-unit - form (``#0``, ``#0#N``) for every zone, or ``#Z#N`` (an actuator - addressed through the central unit; WHO 4 WHERE table, see - ``_zone_address_and_name``). Each form may be routed through a bus interface + form (``#0``, ``#0#C``) for every zone, or ``#Z#C`` (zone ``Z`` + through a four-zone central unit ``C``: Encyclopedia + who-4-temperature-control/addressing.md "Historical central-unit + variants", ``#Z#C`` is a controlled-zone address, not an actuator; + see ``_zone_address_and_name``). Each form may be routed through a bus interface with a two-digit ``#4#NN`` suffix (``3#4#01``, ``#3#4#01``, ``2#1#4#01``) because the suffix is spec-legal grammar, but no emulated firmware routes it across an F422: own-firmware-oracle#65 diff --git a/docs/protocol_conformance_matrix.md b/docs/protocol_conformance_matrix.md index be73a6b..8214de3 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, Dimension 12 setpoint after offset (`-4.8°C`) | `*#4*1*0*0215##`, `*#4*6*12*1048*3##`, `*#4*1*#14*0200*1##` | Valid | Matched | **VERIFIED (100%)** | | **5** | **Burglar Alarm** | Zone status, CU status, Silent alarms, System arm/disarm | `*5*1*1##`, `*#5*1##`, `*5*2*2##` | Valid | Matched | **VERIFIED (100%)** | | **9** | **Auxiliary** | Relay ON/OFF commands across auxiliary channels | `*9*1*1##`, `*9*0*1##` | Valid | Matched | **VERIFIED (100%)** | -| **13** | **Gateway Mgmt** | Firmware version, Gateway internal datetime requests | `*#13*0*0##`, `*#13*0*22##` | Valid | Matched | **VERIFIED (100%)** | +| **13** | **Gateway Mgmt** | Firmware version, Gateway internal datetime requests | `*#13**0##`, `*#13**22##` | Valid | Matched | **VERIFIED (100%)** | | **15** | **CEN Scenarios** | Press, short release, long release, extended hold | `*15*01*73##`, `*15*01#1*73##`, `*15*01#2*0110##`, `*15*01#3*0110##` | Valid | Matched | **VERIFIED (100%)** | | **18** | **Energy Mgmt** | Instantaneous active power (W), Cumulative energy (Wh) as read by OWNd (PDF: DIMENSION 51..54 'Watt', only 72 'Wh') | `*#18*51*113##`, `*#18*51*51##`, `*#18*51*52#26#9##` | Valid | Matched | **VERIFIED (100%)** | | **25** | **CEN+ / Dry Contacts**| Short press; start, hold and release of an extended press; WHERE `2` + object 0–2047 | `*25*21#1*21##`, `*25*22#1*21##`, `*25*23#1*21##`, `*25*24#1*21##` | Valid | Matched | **VERIFIED (100%)** | -Sources for the examples changed in [OWNd#77](https://github.com/OpenWebNet-HA/OWNd/issues/77). The MyHomeServer1 firmware, run on an emulated bus, produces a bus frame for each of them and refuses the examples they replace. The WHO 15 press and short release (`*15*01*73##`, `*15*01#1*73##`) were captured on an MH200N + MyHomeServer1 bus in [MyHOME#541](https://github.com/OpenWebNet-HA/MyHOME/issues/541). Long release and extended hold (`#2`, `#3`) rest on the firmware alone. The WHO 25 examples are an MH201 capture from [MyHOME#418](https://github.com/OpenWebNet-HA/MyHOME/issues/418). The energy request `*#18*51*52#26#9##` was accepted by a real MyHomeServer1. +Sources for the examples changed in [OWNd#77](https://github.com/OpenWebNet-HA/OWNd/issues/77). The MyHomeServer1 firmware, run on an emulated bus, produces a bus frame for each of them and refuses the examples they replace. The WHO 15 press and short release (`*15*01*73##`, `*15*01#1*73##`) were captured on an MH200N + MyHomeServer1 bus in [MyHOME#541](https://github.com/OpenWebNet-HA/MyHOME/issues/541). Long release and extended hold (`#2`, `#3`) rest on the firmware alone. The WHO 25 examples are an MH201 capture from [MyHOME#418](https://github.com/OpenWebNet-HA/MyHOME/issues/418). The energy request `*#18*51*52#26#9##` was accepted by a real MyHomeServer1. The WHO 13 reads use the published empty-`WHERE` form `*#13**DIMENSION##` (Encyclopedia who-13-integration-gateway/dimensions.md "Read form"): in own-firmware-oracle (`ownd-audit-frames` and `who13-clock`) all thirteen emulated gateways answer `*#13**0##` and `*#13**22##` with ACK and the current time, while the `WHERE 0` spelling `*#13*0*0##` this table used to show is `nack, silent` on all thirteen (`ownd-audit-frames`); `OWNGatewayCommand.request_time()` and the event keepalive emit `*#13**0##`. --- diff --git a/tests/golden/README.md b/tests/golden/README.md index 2cd6532..a3cedbc 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 (166 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 fb7cacd..2e2a3ff 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 44cbb69..7ca26dd 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_message_protocol.py b/tests/test_message_protocol.py index c3cc5dd..861fc28 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -265,6 +265,47 @@ def test_malformed_gateway_clock_is_rejected(frame: str) -> None: OWNMessage.parse(frame) +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); + the builders keep their own "Setting gateway ..." text. No behaviour change. + """ + assert ( + OWNMessage.parse("*#13**#0*11*30*00*002##").human_readable_log + == "Gateway broadcasting internal time: 11:30:00+02:00." + ) + assert ( + OWNMessage.parse("*#13**#1*03*07*10*2026##").human_readable_log + == "Gateway broadcasting internal date: 2026-10-07." + ) + assert ( + OWNMessage.parse("*#13**#22*17*33*54*002*05*11*09*2026##").human_readable_log + == "Gateway broadcasting internal datetime: 2026-09-11 17:33:54+02:00." + ) + assert ( + OWNMessage.parse("*#13**#0*22*50*00*##").human_readable_log + == "Gateway broadcasting internal time: 22:50:00." + ) + 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_time_command_has_no_empty_trailing_value() -> None: message = OWNGatewayCommand.set_time_to_now("UTC") diff --git a/tests/test_who13_ascii_values.py b/tests/test_who13_ascii_values.py new file mode 100644 index 0000000..de8f85e --- /dev/null +++ b/tests/test_who13_ascii_values.py @@ -0,0 +1,86 @@ +"""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*\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: + with pytest.raises(ValueError, match="Invalid gateway (date|time|datetime|timezone)"): + 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_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*##"): + 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 diff --git a/tests/test_who2_audit_fixes.py b/tests/test_who2_audit_fixes.py index d810518..85670fd 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)