diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fb0fa1..90a89a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,10 @@ All notable changes to `OWNd` are documented in this file. - Canonical `libqtdevices` (`TS10_1_0_23`) coarse `WHAT 2..10` $\leftrightarrow$ fine `1..100%` conversion helpers (`COARSE_PRESET_TO_PERCENT`, `brightness_preset_to_percent`, `percent_to_brightness_preset`), `OWNLightingEvent.is_coarse_dimmer`, `OWNLightingEvent.brightness_preset_percent`, and `OWNLightingCommand.set_brightness(..., coarse=..., gateway=...)` (`#134`). - `WHAT 39` end of movement (`*1*39*WHERE##`) from WHO 1 motion detectors: `message_type` `motion_detected` with `motion` `False`, new `OWNLightingEvent.motion_end`, `is_sensor` `True`, `is_on` `None`. It was `unknown_state` `39` before (`MyHOME#777`). - **WHO 4 (`OWNHeatingCommand.actuators_status`)**: Actuator status request `*#4*WHERE*20##` for a zone (`0..99`, `#Z`), one actuator (`2#1`; `N` is one digit, `2#0` for all actuators of the zone) or `#0` / `#0#N` (central unit); three-digit secondary probes (`105`) and multi-digit actuator indices (`2#10`) are refused. A live F461 2.0.11 answers it per actuator (`*#4*2#1*20*0##`, `*#4*2#2*20*5##`). The address may carry a bus-interface suffix (`3#4#01`, `#3#4#01`, `2#1#4#01`; two-digit interface) as spec-legal grammar, but callers must gate it on the profile's `supports_interface_zone_requests` (`MyHOME#816`): own-firmware-oracle#65 shows no emulated firmware routes `*#4*2#4#01*20##` across an F422; F450, F454, F459, F460, F461 and MyHomeServer1 truncate `WHERE` at the first `#` and poll local zone 2, MH202 010024 reads `#4#01` as local zone 4 actuator 1, and F453, F453AV, H4684, MH200, MH200N drop it. The MH202 010024 crash in that suite hits the dimensionless status forms `*#4*2#1##` and `*#4*2#4#01##`, which the builder never emits (`MyHOME#792` review). +- **WHO 1001 (`OWNLightingDiagnosticEvent`)**: Actuator autodiagnostic mask of lighting and automation actuators (`*#1001*WHERE*11*##` and `*#1001*WHERE*7*##`, DIMENSIONS 11 and 7 = `BITMASK_DIA_A`), exposed as sent (`diagnostic_mask`, `cleared_bits`) when the value is a single 24-character `0`/`1` string, `None` on a DIMENSION 7 / 11 value frame of another shape; bit meanings are not known. The frame is pushed after `*1*19*WHERE##` on a faulty actuator (own-firmware-oracle `what19` on eight emulated gateways; MyHOME fixtures `issue_619` F453AV, `issue_466` / `issue_733` MH200), pushed alongside normal `*1*0/1*WHERE##` sweep replies on an MH200N (`issue_434`, `issue_746`), answered on a direct `*#1001*WHERE*11##` read (live F461 2.0.11 and MyHomeServer1 3.87.13, 2026-10-24), and sent as DIMENSION 7 on `WHERE 0` by a MyHomeServer1 cover diagnostic (`issue_466`). Routed (`74#4#01`), 4-digit, `0` and empty WHERE forms parse; `interface` / `unique_id` cover WHO 1001. The README WHO catalog lists WHO 1001. ### Changed - `OWNTransport.send()` takes the same `raise_on_error` keyword as `OWNCommandSession.send()` on `AsyncTcpTransport` and `AsyncSerialTransport`, and `OWNTransport.last_error` holds the error recorded by the last `send()`: a local refusal on both transports, and on the TCP transport everything its command session records (NACK, timeout, connection errors). Both transports now share one contract: `RuntimeError` when not connected, then a frame refused locally is logged at ERROR, kept in `last_error` and answered with `None`, or raised as `OWNProtocolError` with `raise_on_error=True`. - ### Fixed - **WHO 4 Dimension 22 is never sent**: Dimension 22 is the published split-unit IR control (`*#4*3#Z#N*22##` / `*#4*3#Z#N*#22*MOD*SP*VEL*SWING##`, WHO 4 specification pp. 66-68); in any spelling (`*#4*ZONE*22##`, `*#4*#0*22##`, leading zeros, whitespace or `+` tokens) it crashes the thermoregulation daemon (`bt_termo`) on Linux IP gateways and reboots an F461 2.0.11 about three minutes later (own-firmware-oracle `tools/expected_divergences.yaml` crash frames, live F461 2.0.11). `OWNCommandSession.send` (and so `AsyncTcpTransport.send` and `keepalive`), and `AsyncSerialTransport.send` refuse it before anything is written, with the contract above and `reason="destructive_frame"`; `OWNCommandSession.send_to_gateway` refuses it before `connect()` (no socket, no handshake), logs it at ERROR and answers `None`, or raises `OWNProtocolError` with `raise_on_error=True` (the session it would have used is discarded, so no `last_error` is observable on that path). The detector `is_who4_dimension_22()` is public, accepts `bytes`, and is anchored to the first `*` of each frame, so a mid-frame WHERE `#4` / DIMENSION `#4` such as `*#2*#4*#11#001*22##` or `*#1*14*#4*1*22##` stays sendable. Parsing is unchanged: the frame can still be read from the bus. - **`AsyncSerialTransport.send` validates outbound frames** like `OWNCommandSession.send`: an invalid or signaling frame (for example the single-`#` concatenation `*1*1*11#*#4*2*22##`) is refused with `reason="invalid_frame"` instead of being written to the port. The frame is serialised once, terminator included: the string checked is the string written. diff --git a/OWNd/message/__init__.py b/OWNd/message/__init__.py index ac0d6a1..5ccf1bb 100644 --- a/OWNd/message/__init__.py +++ b/OWNd/message/__init__.py @@ -195,6 +195,7 @@ percent_to_brightness_preset, register_coarse_dimmer_where, OWNLightingCommand, + OWNLightingDiagnosticEvent, OWNLightingEvent, ) from .load import ( @@ -258,6 +259,7 @@ "normalize_where", "OWNLightingEvent", "OWNLightingCommand", + "OWNLightingDiagnosticEvent", # Automation (WHO 2) "OWNAutomationEvent", "OWNAutomationCommand", diff --git a/OWNd/message/base.py b/OWNd/message/base.py index 624d6b7..66be307 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -298,7 +298,7 @@ def interface(self) -> str | None: """The 'where' parameter corresponding to the bus interface of the subject of this message""" return ( self._where_param[1] - if self._who in [0, 1, 2, 14, 15] + if self._who in [0, 1, 2, 14, 15, 1001] and len(self._where_param) > 1 and self._where_param[0] == "4" else None diff --git a/OWNd/message/lighting.py b/OWNd/message/lighting.py index 0c4352f..25161d6 100644 --- a/OWNd/message/lighting.py +++ b/OWNd/message/lighting.py @@ -962,3 +962,81 @@ def report_brightness( register_event_parser(1, OWNLightingEvent) register_command_parser(1, OWNLightingCommand) + + +class OWNLightingDiagnosticEvent(OWNEvent): + """WHO 1001: autodiagnostics of lighting and automation actuators. + + WHO 1001 is the diagnostic WHO of the "Light and Automation system" + (OPEN.db): the frame comes from a WHO 1 light actuator or a WHO 2 + shutter actuator alike. The 24-bit mask ``BITMASK_DIA_A`` travels as + DIMENSION 11 (``*#1001*WHERE*11*##``) or DIMENSION 7 + (``*#1001*WHERE*7*##``); the gateway copies the actuator's bits + verbatim and what each bit means is not known, so the mask is exposed as + sent, with the positions of its cleared bits. + + Where it is seen: + + - pushed after ``*1*19*WHERE##`` by a faulty actuator (firmware oracle + ``what19`` on F453, F453AV, F459, F460, F461, MH200N, MH202 and + MyHomeServer1; MyHOME fixtures ``issue_619`` F453AV 3.0.14 with five + unloaded F414 dimmers, ``issue_466`` / ``issue_733`` MH200 ``WHERE 74``; + own-firmware-oracle findings/MH200N/what19.md); + - pushed 170-210 ms after a normal ``*1*0/1*WHERE##`` sweep reply, with + no ``WHAT 19``, on an MH200N 1.1.8 (MyHOME ``issue_434``, ``issue_746``: + ``*#1001*42*11*111011111111111101101101##``); + - answered on a direct ``*#1001*WHERE*11##`` read by a healthy light + (live F461 2.0.11 and MyHomeServer1 3.87.13, 2026-10-24, also on a + group ``*#1001*0*11##`` read); + - as DIMENSION 7 on ``WHERE 0`` from a MyHomeServer1 cover diagnostic + (MyHOME ``issue_466``, ``*#1001*0*7*111111111111111101101111##``). + + Only a single 24-character ``0``/``1`` value is taken as a mask (every + capture has that shape; ``s2o_selfDiagnosticsA`` in ``libdiag.so`` + unpacks three SCS bytes); a DIMENSION 7 / 11 value frame of another shape + still parses as this class with ``diagnostic_mask`` ``None`` and the raw + value in ``dimension_value``. WHERE may be a point (``74``, ``0014``), a + point behind a bus interface (``74#4#01``, see ``interface``), ``0`` or + empty (the gateway's own diagnostic); ``is_general`` stays ``False`` on + WHO 1001 as on every WHO outside 1 and 2. + """ + + _MASK_DIMENSIONS = (7, 11) + + def __init__(self, data: str) -> None: + super().__init__(data) + self._diagnostic_mask: str | None = None + self._cleared_bits: tuple[int, ...] | None = None + # Exactly one value of exactly 24 bits, the shape every capture + # shows: *#1001*74*11*101*5## (a trailing value), a shorter or longer + # bit string and anything that is not 0/1 fail closed. + value = self._dimension_value[0] if len(self._dimension_value) == 1 else "" + if ( + self._dimension in self._MASK_DIMENSIONS + and len(value) == 24 + and set(value) <= {"0", "1"} + ): + self._diagnostic_mask = value + self._cleared_bits = tuple(i for i, bit in enumerate(value, 1) if bit == "0") + subject = ( + f"Light {self._where}{self._interface_log_text}" + if self._where + else "The lighting / automation system" + ) + self._human_readable_log = ( + f"{subject} autodiagnostic mask is {value}" + f" (cleared bits: {', '.join(map(str, self._cleared_bits)) or 'none'})." + ) + + @property + def diagnostic_mask(self) -> str | None: + """The DIMENSION 7 / 11 bitmask as sent (``"111110111111111111110111"``).""" + return self._diagnostic_mask + + @property + def cleared_bits(self) -> tuple[int, ...] | None: + """1-based positions, from the left, of the mask's ``0`` bits (``(6, 21)``).""" + return self._cleared_bits + + +register_event_parser(1001, OWNLightingDiagnosticEvent) diff --git a/README.md b/README.md index bd86d45..08f089d 100755 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ OWNd parses OpenWebNet frames and dispatches typed commands and events across th | WHO | Subsystem | Description & Capabilities | Event / Command Classes | |:---:|:---|:---|:---| | **0** | Scenarios | Basic scenario execution (1–32) from control panels, scenario module programming and memory lock states | `OWNScenarioEvent` | -| **1** | Lighting | On/off switching, dimming level (0–100%) with automatic legacy pre-09W29 F414 coarse preset fallback (speed 240 / 0xF0), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries | `OWNLightingCommand`, `OWNLightingEvent` | +| **1** / **1001** | Lighting | On/off switching, dimming level (0–100%) with automatic legacy pre-09W29 F414 coarse preset fallback (speed 240 / 0xF0), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries, actuator autodiagnostic masks (WHO 1001 Dimensions 7 and 11, from lighting and automation actuators alike) | `OWNLightingCommand`, `OWNLightingEvent`, `OWNLightingDiagnosticEvent` | | **2** | Automation | Shutters, blinds, motorized curtains, tilt angles, short & full replies | `OWNAutomationCommand`, `OWNAutomationEvent` | | **3** | Load Control | Priority forcing (`*3*2*#N##`, firmware-verified), priority states 0–3, priority status and central-unit measurement requests with typed voltage, current, power, and energy properties (`WHO_3.pdf` v1.0.0 & `bt_energia` register mappings) | `OWNLoadCommand`, `OWNLoadEvent` | | **4** | Thermoregulation / Climate | Multi-zone temperature readouts, target adjustments, HVAC modes (Heat/Cool/Auto/Off), local offsets, fan coil speeds, valve states, Central Unit 3550/4695 master coordination | `OWNHeatingCommand`, `OWNHeatingEvent` | diff --git a/scripts/sync_documentation.py b/scripts/sync_documentation.py index b456838..7a43e4a 100644 --- a/scripts/sync_documentation.py +++ b/scripts/sync_documentation.py @@ -106,10 +106,10 @@ class WhoCatalogDefinition: dispatch_who_keys=(0,), ), WhoCatalogDefinition( - who="**1**", + who="**1** / **1001**", name="Lighting", - description="On/off switching, dimming level (0–100%) with automatic legacy pre-09W29 F414 coarse preset fallback (speed 240 / 0xF0), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries", - dispatch_who_keys=(1,), + description="On/off switching, dimming level (0–100%) with automatic legacy pre-09W29 F414 coarse preset fallback (speed 240 / 0xF0), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries, actuator autodiagnostic masks (WHO 1001 Dimensions 7 and 11, from lighting and automation actuators alike)", + dispatch_who_keys=(1, 1001), ), WhoCatalogDefinition( who="**2**", diff --git a/tests/test_firmware_oracle_conformance.py b/tests/test_firmware_oracle_conformance.py index 711b4cd..6ddb5f9 100644 --- a/tests/test_firmware_oracle_conformance.py +++ b/tests/test_firmware_oracle_conformance.py @@ -23,6 +23,7 @@ OWNEvent, OWNGatewayEvent, OWNLightingCommand, + OWNLightingDiagnosticEvent, OWNLightingEvent, OWNLockCommand, OWNLockEvent, @@ -154,12 +155,13 @@ def test_emitted_own_frames_parseable_by_ownd(): # 2. Semantic verification of WHO 1001 diagnostic state response diag_msg = OWNMessage.parse("*#1001*74*11*111110111111111111110111##") - assert isinstance(diag_msg, OWNMessage) + assert isinstance(diag_msg, OWNLightingDiagnosticEvent) assert diag_msg.is_event is True assert diag_msg.who == 1001 assert diag_msg.where == "74" - assert getattr(diag_msg, "dimension", None) == 11 - assert getattr(diag_msg, "event_content", {}).get("dimension values") == ["111110111111111111110111"] + assert diag_msg.dimension == 11 + assert diag_msg.diagnostic_mask == "111110111111111111110111" + assert diag_msg.cleared_bits == (6, 21) # 3. Semantic verification of WHO 25 CEN+ push-button event cen_msg = OWNMessage.parse("*25*21#1*21##") diff --git a/tests/test_who1001_diagnostic_event.py b/tests/test_who1001_diagnostic_event.py new file mode 100644 index 0000000..5f009bb --- /dev/null +++ b/tests/test_who1001_diagnostic_event.py @@ -0,0 +1,154 @@ +"""WHO 1001 Dimension 11: lighting actuator autodiagnostic mask. + +own-firmware-oracle ``what19`` (input ``*#1*74##``): F453, F453AV, F459, F460, +F461, MH200N and MyHomeServer1 emit ``*1*19*74##`` then +``*#1001*74*11*111110111111111111110111##``; MH202 the mask only; F454, H4684 +and MH200 ``*1*19*74##`` only; F450 ``nack``/``silent``. A physical MH200 +capture (findings/MH200N/what19.md, EVID-MH200-WHAT19-FAULT) agrees. The bit +meanings are marked Open there, so nothing is interpreted. +""" + +from __future__ import annotations + +import pytest + +from OWNd.message import OWNCommand, OWNEvent, OWNLightingDiagnosticEvent, OWNMessage + + +def test_lighting_autodiagnostic_mask_parses() -> None: + message = OWNMessage.parse("*#1001*74*11*111110111111111111110111##") + + assert isinstance(message, OWNLightingDiagnosticEvent) + assert isinstance(message, OWNEvent) + assert message.who == 1001 + assert message.where == "74" + assert message.dimension == 11 + assert message.dimension_value == ["111110111111111111110111"] + assert message.diagnostic_mask == "111110111111111111110111" + assert message.cleared_bits == (6, 21) + assert message.human_readable_log == ( + "Light 74 autodiagnostic mask is 111110111111111111110111 (cleared bits: 6, 21)." + ) + + +def test_lighting_autodiagnostic_all_bits_set() -> None: + message = OWNMessage.parse("*#1001*74*11*111111111111111111111111##") + + assert isinstance(message, OWNLightingDiagnosticEvent) + assert message.cleared_bits == () + assert message.human_readable_log.endswith("(cleared bits: none).") + + +@pytest.mark.parametrize( + ("frame", "expected_class"), + [ + # A trailing value: not the mask shape. + ("*#1001*74*11*101*5##", OWNLightingDiagnosticEvent), + # Another dimension. + ("*#1001*74*12*5##", OWNLightingDiagnosticEvent), + # Not 24 bits: one bit, 23 bits, 26 bits. + ("*#1001*74*11*1##", OWNLightingDiagnosticEvent), + ("*#1001*74*11*11111011111111111111011##", OWNLightingDiagnosticEvent), + ("*#1001*74*11*00000000000000000000000000##", OWNLightingDiagnosticEvent), + # 24 characters that are not all bits. + ("*#1001*74*11*111110111111111111110112##", OWNLightingDiagnosticEvent), + # A status/dimension request (no value) is a command, not an event. + ("*#1001*74*11##", OWNCommand), + ("*#1001*74##", OWNCommand), + ], +) +def test_other_who_1001_shapes_have_no_mask(frame: str, expected_class: type) -> None: + message = OWNMessage.parse(frame) + + assert type(message) is expected_class + assert message.who == 1001 + if isinstance(message, OWNLightingDiagnosticEvent): + assert message.diagnostic_mask is None + assert message.cleared_bits is None + assert "autodiagnostic" not in message.human_readable_log + + +@pytest.mark.parametrize( + "frame", + [ + "*#1001*74*11*1011#1##", # a parameter inside the value + "*#1001*74*11*10x1##", # not digits: no OpenWebNet frame at all + ], +) +def test_malformed_who_1001_frames_do_not_parse(frame: str) -> None: + assert OWNMessage.parse(frame) is None + + +@pytest.mark.parametrize( + ("frame", "where", "interface", "unique_id", "cleared", "subject"), + [ + # DIMENSION 7 (BITMASK_DIA_A) on WHERE 0: MyHomeServer1 cover + # diagnostic (MyHOME issue_466). + ("*#1001*0*7*111111111111111101101111##", "0", None, "1001-0", (17, 20), "Light 0 "), + # Group read on WHERE 0 (live MyHomeServer1 3.87.13, 2026-10-24). + ("*#1001*0*11*111111111111111101101111##", "0", None, "1001-0", (17, 20), "Light 0 "), + # 4-digit point (MyHomeServer1 WHERE 0014). + ("*#1001*0014*11*111111111111111101101111##", "0014", None, "1001-0014", (17, 20), "Light 0014 "), + # Point behind a bus interface: interface and unique_id as for WHO 1. + ( + "*#1001*74#4#01*11*111110111111111111110111##", + "74", + "01", + "1001-74#4#01", + (6, 21), + "Light 74 on interface 01 ", + ), + # Empty WHERE: the gateway's own diagnostic. + ( + "*#1001**11*111011111111111101101101##", + "", + None, + "1001-", + (4, 17, 20, 23), + "The lighting / automation system ", + ), + ], +) +def test_where_forms_keep_the_mask( + frame: str, + where: str, + interface: str | None, + unique_id: str, + cleared: tuple[int, ...], + subject: str, +) -> None: + message = OWNMessage.parse(frame) + + assert isinstance(message, OWNLightingDiagnosticEvent) + assert message.where == where + assert message.interface == interface + assert message.unique_id == unique_id + assert message.diagnostic_mask == frame.split("*")[-1].rstrip("#") + assert message.cleared_bits == cleared + assert message.human_readable_log.startswith(subject + "autodiagnostic mask is ") + assert " " not in message.human_readable_log + # WHO 1001 is outside the WHO 1 / 2 scope of is_general: pinned, not a feature. + assert message.is_general is False + + +def test_dimension_7_takes_the_same_shape_rules_as_11() -> None: + assert OWNMessage.parse("*#1001*0*7*1##").diagnostic_mask is None + assert OWNMessage.parse("*#1001*0*7*111111111111111101101111*5##").diagnostic_mask is None + + +def test_status_shaped_who_1001_frame_has_no_mask() -> None: + """``*1001*4*0##`` (the end-of-interview marker in MyHOME issue_466).""" + message = OWNMessage.parse("*1001*4*0##") + + assert isinstance(message, OWNLightingDiagnosticEvent) + assert message.diagnostic_mask is None + assert message.cleared_bits is None + assert "autodiagnostic" not in message.human_readable_log + + +def test_who_1001_status_request_is_not_a_diagnostic_event() -> None: + request = OWNMessage.parse("*#1001*74##") + + assert type(request) is OWNCommand + assert not isinstance(request, OWNLightingDiagnosticEvent) + assert request.who == 1001