From 4ae2264b43f43386322fbcea141c625c2c83d128 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 18:35:55 +0300 Subject: [PATCH 1/3] feat(lighting): WHO 1001 dimension 11 diagnostic event `*#1001*WHERE*11*##` is the actuator autodiagnostic frame a gateway pushes with `*1*19*WHERE##` when a status request reaches a faulty actuator: own-firmware-oracle what19 (input `*#1*74##`) shows it on F453, F453AV, F459, F460, F461, MH200N, MyHomeServer1 (after `*1*19*74##`) and MH202 (alone), and a physical MH200 capture agrees (findings/MH200N/what19.md, EVID-MH200-WHAT19-FAULT). OWNd returned a generic OWNEvent with who == 1001 and exposed nothing. OWNLightingDiagnosticEvent (registered for WHO 1001, exported) exposes diagnostic_mask as sent and cleared_bits (1-based from the left). The bit meanings are Open in the oracle, so nothing is interpreted. Only the single-value bit-string shape is decoded; `*#1001*74*11*101*5##` fails closed. README WHO catalog lists WHO 1001 (sync_documentation --update). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + OWNd/message/__init__.py | 2 + OWNd/message/lighting.py | 41 ++++++++++++++++ README.md | 2 +- scripts/sync_documentation.py | 6 +-- tests/test_who1001_diagnostic_event.py | 68 ++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/test_who1001_diagnostic_event.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a99e0..3228ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to `OWNd` are documented in this file. - Automatic detection of legacy pre-`09W29` BTicino `F414` coarse-only dimmers via `DIMENSION 1` / `DIMENSION 4` transition speed `240` (`0xF0`, `DIMMER_SPEED_COARSE_ONLY`) with multi-gateway plant isolation (`gateway=` scoping on `OWNMessage.parse`, `OWNEvent.parse`, `OWNEventSession.get_next`, `is_coarse_dimmer_where`, `register_coarse_dimmer_where`, `clear_coarse_dimmer_wheres`, and `OWNLightingCommand.set_brightness`) (`#134`, `MyHOME#767`). - Canonical `libqtdevices` (`TS10_1_0_23`) coarse `WHAT 2..10` $\leftrightarrow$ fine `1..100%` conversion helpers (`COARSE_PRESET_TO_PERCENT`, `brightness_preset_to_percent`, `percent_to_brightness_preset`), `OWNLightingEvent.is_coarse_dimmer`, `OWNLightingEvent.brightness_preset_percent`, and `OWNLightingCommand.set_brightness(..., coarse=..., gateway=...)` (`#134`). - `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 1001 (`OWNLightingDiagnosticEvent`)**: Actuator autodiagnostic mask (`*#1001*WHERE*11*##`, pushed with `*1*19*WHERE##` on eight emulated gateways, own-firmware-oracle `what19`, and on a physical MH200) exposed as sent (`diagnostic_mask`, `cleared_bits`); bit meanings are not known. Only the single-value mask shape is decoded. The README WHO catalog lists WHO 1001. ## [2.0.0b11] - 2026-10-20 diff --git a/OWNd/message/__init__.py b/OWNd/message/__init__.py index 99a2280..970abe5 100644 --- a/OWNd/message/__init__.py +++ b/OWNd/message/__init__.py @@ -194,6 +194,7 @@ percent_to_brightness_preset, register_coarse_dimmer_where, OWNLightingCommand, + OWNLightingDiagnosticEvent, OWNLightingEvent, ) from .load import ( @@ -257,6 +258,7 @@ "normalize_where", "OWNLightingEvent", "OWNLightingCommand", + "OWNLightingDiagnosticEvent", # Automation (WHO 2) "OWNAutomationEvent", "OWNAutomationCommand", diff --git a/OWNd/message/lighting.py b/OWNd/message/lighting.py index 0c4352f..e2e51e1 100644 --- a/OWNd/message/lighting.py +++ b/OWNd/message/lighting.py @@ -962,3 +962,44 @@ def report_brightness( register_event_parser(1, OWNLightingEvent) register_command_parser(1, OWNLightingCommand) + + +class OWNLightingDiagnosticEvent(OWNEvent): + """WHO 1001: lighting actuator autodiagnostics. + + ``*#1001*WHERE*11*##`` follows a status request to a faulty actuator + (``*#1*74##`` -> ``*1*19*74##``) on F453, F453AV, F459, F460, F461, MH200N, + MH202 and MyHomeServer1 in the firmware oracle (``what19`` suite), and on + a physical MH200 (own-firmware-oracle findings/MH200N/what19.md, capture + EVID-MH200-WHAT19-FAULT). The gateway copies the actuator's 24-bit mask + verbatim; what each bit means is not known, so the mask is exposed as + sent, with the positions of its cleared bits. + """ + + def __init__(self, data: str) -> None: + super().__init__(data) + self._diagnostic_mask: str | None = None + # Exactly one value, all bits: *#1001*74*11*101*5## (a trailing value) + # is not the mask shape the gateways send and fails closed. + value = self._dimension_value[0] if len(self._dimension_value) == 1 else "" + if self._dimension == 11 and value and set(value) <= {"0", "1"}: + self._diagnostic_mask = value + self._human_readable_log = ( + f"Light {self._where} autodiagnostic mask is {value}" + f" (cleared bits: {', '.join(map(str, self.cleared_bits or ())) or 'none'})." + ) + + @property + def diagnostic_mask(self) -> str | None: + """The DIMENSION 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)``).""" + if self._diagnostic_mask is None: + return None + return tuple(i for i, bit in enumerate(self._diagnostic_mask, 1) if bit == "0") + + +register_event_parser(1001, OWNLightingDiagnosticEvent) diff --git a/README.md b/README.md index bd86d45..f384ef6 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 Dimension 11) | `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..4929f37 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 Dimension 11)", + dispatch_who_keys=(1, 1001), ), WhoCatalogDefinition( who="**2**", diff --git a/tests/test_who1001_diagnostic_event.py b/tests/test_who1001_diagnostic_event.py new file mode 100644 index 0000000..ed2472c --- /dev/null +++ b/tests/test_who1001_diagnostic_event.py @@ -0,0 +1,68 @@ +"""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 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", + [ + "*#1001*74*11*101*5##", # a trailing value: not the mask shape + "*#1001*74*11*1011#1##", # not a plain bit string + "*#1001*74*11*10x1##", # not bits (does not parse as a reply at all) + "*#1001*74*12*5##", # another dimension + "*#1001*74*11##", # a request, no value + ], +) +def test_other_who_1001_shapes_have_no_mask(frame: str) -> None: + message = OWNMessage.parse(frame) + + if message is None: + return + 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 + + +def test_who_1001_status_request_is_not_a_diagnostic_event() -> None: + request = OWNMessage.parse("*#1001*74##") + + assert request is not None + assert request.who == 1001 From c42dc2ff9449e0a367b1e709c8633c57e1812ee0 Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 18:33:05 +0200 Subject: [PATCH 2/3] fix(lighting): diagnostic mask needs 24 bits; tests assert the parsed class `OWNLightingDiagnosticEvent` took any non-empty 0/1 string as the autodiagnostic mask although every capture (oracle `what19` on eight gateways, physical MH200) carries exactly 24 bits and the docstring said so: `*#1001*74*11*1##` reported "mask is 1 (cleared bits: none)". The mask now requires a single 24-character 0/1 value; other DIMENSION 11 value frames keep `diagnostic_mask` None. The log line computes the cleared bits directly (the `or ()` fallback could never trigger). Tests: the negative-shape test returned before asserting on three of its five frames and skipped the mask assertions for the request shape; it now asserts the exact parsed class per frame (eight frames incl. 1, 23 and 26 bit strings and a 24-character non-bit string), the two malformed frames are pinned as not parsing at all, and the status-request test asserts it is an `OWNCommand`, not a diagnostic event. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- OWNd/message/lighting.py | 16 ++++++---- tests/test_who1001_diagnostic_event.py | 41 +++++++++++++++++++------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3228ab2..83b2d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to `OWNd` are documented in this file. - Automatic detection of legacy pre-`09W29` BTicino `F414` coarse-only dimmers via `DIMENSION 1` / `DIMENSION 4` transition speed `240` (`0xF0`, `DIMMER_SPEED_COARSE_ONLY`) with multi-gateway plant isolation (`gateway=` scoping on `OWNMessage.parse`, `OWNEvent.parse`, `OWNEventSession.get_next`, `is_coarse_dimmer_where`, `register_coarse_dimmer_where`, `clear_coarse_dimmer_wheres`, and `OWNLightingCommand.set_brightness`) (`#134`, `MyHOME#767`). - Canonical `libqtdevices` (`TS10_1_0_23`) coarse `WHAT 2..10` $\leftrightarrow$ fine `1..100%` conversion helpers (`COARSE_PRESET_TO_PERCENT`, `brightness_preset_to_percent`, `percent_to_brightness_preset`), `OWNLightingEvent.is_coarse_dimmer`, `OWNLightingEvent.brightness_preset_percent`, and `OWNLightingCommand.set_brightness(..., coarse=..., gateway=...)` (`#134`). - `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 1001 (`OWNLightingDiagnosticEvent`)**: Actuator autodiagnostic mask (`*#1001*WHERE*11*##`, pushed with `*1*19*WHERE##` on eight emulated gateways, own-firmware-oracle `what19`, and on a physical MH200) exposed as sent (`diagnostic_mask`, `cleared_bits`); bit meanings are not known. Only the single-value mask shape is decoded. The README WHO catalog lists WHO 1001. +- **WHO 1001 (`OWNLightingDiagnosticEvent`)**: Actuator autodiagnostic mask (`*#1001*WHERE*11*##`, pushed with `*1*19*WHERE##` on eight emulated gateways, own-firmware-oracle `what19`, and on a physical MH200) exposed as sent (`diagnostic_mask`, `cleared_bits`) when the value is a single 24-character `0`/`1` string (the shape of every capture), `None` on a DIMENSION 11 value frame of another shape; bit meanings are not known. Only the single-value mask shape is decoded. The README WHO catalog lists WHO 1001. ## [2.0.0b11] - 2026-10-20 diff --git a/OWNd/message/lighting.py b/OWNd/message/lighting.py index e2e51e1..753a6a2 100644 --- a/OWNd/message/lighting.py +++ b/OWNd/message/lighting.py @@ -973,20 +973,26 @@ class OWNLightingDiagnosticEvent(OWNEvent): a physical MH200 (own-firmware-oracle findings/MH200N/what19.md, capture EVID-MH200-WHAT19-FAULT). The gateway copies the actuator's 24-bit mask verbatim; what each bit means is not known, so the mask is exposed as - sent, with the positions of its cleared bits. + sent, with the positions of its cleared bits. Only a single 24-character + ``0``/``1`` value is taken as a mask (every capture has that shape); a + DIMENSION 11 value frame of another shape still parses as this class, + with ``diagnostic_mask`` ``None`` and the raw value in + ``dimension_value``. """ def __init__(self, data: str) -> None: super().__init__(data) self._diagnostic_mask: str | None = None - # Exactly one value, all bits: *#1001*74*11*101*5## (a trailing value) - # is not the mask shape the gateways send and fails closed. + # 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 == 11 and value and set(value) <= {"0", "1"}: + if self._dimension == 11 and len(value) == 24 and set(value) <= {"0", "1"}: self._diagnostic_mask = value + cleared = tuple(i for i, bit in enumerate(value, 1) if bit == "0") self._human_readable_log = ( f"Light {self._where} autodiagnostic mask is {value}" - f" (cleared bits: {', '.join(map(str, self.cleared_bits or ())) or 'none'})." + f" (cleared bits: {', '.join(map(str, cleared)) or 'none'})." ) @property diff --git a/tests/test_who1001_diagnostic_event.py b/tests/test_who1001_diagnostic_event.py index ed2472c..608f827 100644 --- a/tests/test_who1001_diagnostic_event.py +++ b/tests/test_who1001_diagnostic_event.py @@ -12,7 +12,7 @@ import pytest -from OWNd.message import OWNEvent, OWNLightingDiagnosticEvent, OWNMessage +from OWNd.message import OWNCommand, OWNEvent, OWNLightingDiagnosticEvent, OWNMessage def test_lighting_autodiagnostic_mask_parses() -> None: @@ -40,20 +40,27 @@ def test_lighting_autodiagnostic_all_bits_set() -> None: @pytest.mark.parametrize( - "frame", + ("frame", "expected_class"), [ - "*#1001*74*11*101*5##", # a trailing value: not the mask shape - "*#1001*74*11*1011#1##", # not a plain bit string - "*#1001*74*11*10x1##", # not bits (does not parse as a reply at all) - "*#1001*74*12*5##", # another dimension - "*#1001*74*11##", # a request, no value + # 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) -> None: +def test_other_who_1001_shapes_have_no_mask(frame: str, expected_class: type) -> None: message = OWNMessage.parse(frame) - if message is None: - return + assert type(message) is expected_class assert message.who == 1001 if isinstance(message, OWNLightingDiagnosticEvent): assert message.diagnostic_mask is None @@ -61,8 +68,20 @@ def test_other_who_1001_shapes_have_no_mask(frame: str) -> 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 + + def test_who_1001_status_request_is_not_a_diagnostic_event() -> None: request = OWNMessage.parse("*#1001*74##") - assert request is not None + assert type(request) is OWNCommand + assert not isinstance(request, OWNLightingDiagnosticEvent) assert request.who == 1001 From 6014d036c7636d556e1f89d16b50359a1e07b017 Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 22:40:06 +0200 Subject: [PATCH 3/3] feat(lighting): WHO 1001 mask on DIMENSION 7 and 11, routed / empty / 0 WHERE, fixtures cited (review follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of 2026-10-26 20:32 on #145: - DIMENSION 7 (`BITMASK_DIA_A`, OPEN.db) carries the same 24-bit mask as DIMENSION 11: MyHOME's device_health pairs (7, 11) and the MyHomeServer1 cover diagnostic in MyHOME issue_466 sends `*#1001*0*7*1111…0111##` with the same bits as the live DIMENSION 11 reads. The decoder accepts both. - WHO 1001 joins the WHOs whose WHERE may carry `#4#II` in `OWNMessage.interface`, so `interface`, `unique_id` (`1001-74#4#01`) and the log line cover an actuator behind an F422, as for WHO 1 / 2. - The log subject handles an empty WHERE (the gateway's own diagnostic) without a double space; `cleared_bits` is stored once in `__init__`. - Docstring, README catalog row and CHANGELOG: WHO 1001 is the diagnostic WHO of lighting and automation actuators alike; the mask is pushed after `*1*19*WHERE##` (oracle what19; 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 read (live F461 / MyHomeServer1, 2026-10-24) and sent as DIMENSION 7 on WHERE 0 (issue_466). - Tests: WHERE `0`, `0014`, routed `74#4#01`, empty, the status-shaped `*1001*4*0##`, DIMENSION 7 shapes, `is_general False` pinned; the firmware-oracle conformance block asserts the class, mask and cleared bits. - Merged origin/master (050dc1d) into the branch (CHANGELOG conflict with #136 / #140 / #148 resolved, the WHO 1001 entry under "Added"). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- OWNd/message/base.py | 2 +- OWNd/message/lighting.py | 73 ++++++++++++++++------- README.md | 2 +- scripts/sync_documentation.py | 2 +- tests/test_firmware_oracle_conformance.py | 8 ++- tests/test_who1001_diagnostic_event.py | 67 +++++++++++++++++++++ 7 files changed, 128 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ffdf7e..90a89a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,13 @@ 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. -- **WHO 1001 (`OWNLightingDiagnosticEvent`)**: Actuator autodiagnostic mask (`*#1001*WHERE*11*##`, pushed with `*1*19*WHERE##` on eight emulated gateways, own-firmware-oracle `what19`, and on a physical MH200) exposed as sent (`diagnostic_mask`, `cleared_bits`) when the value is a single 24-character `0`/`1` string (the shape of every capture), `None` on a DIMENSION 11 value frame of another shape; bit meanings are not known. Only the single-value mask shape is decoded. The README WHO catalog lists WHO 1001. ## [2.0.0b11] - 2026-10-20 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 753a6a2..25161d6 100644 --- a/OWNd/message/lighting.py +++ b/OWNd/message/lighting.py @@ -965,47 +965,78 @@ def report_brightness( class OWNLightingDiagnosticEvent(OWNEvent): - """WHO 1001: lighting actuator autodiagnostics. - - ``*#1001*WHERE*11*##`` follows a status request to a faulty actuator - (``*#1*74##`` -> ``*1*19*74##``) on F453, F453AV, F459, F460, F461, MH200N, - MH202 and MyHomeServer1 in the firmware oracle (``what19`` suite), and on - a physical MH200 (own-firmware-oracle findings/MH200N/what19.md, capture - EVID-MH200-WHAT19-FAULT). The gateway copies the actuator's 24-bit mask - verbatim; what each bit means is not known, so the mask is exposed as - sent, with the positions of its cleared bits. Only a single 24-character - ``0``/``1`` value is taken as a mask (every capture has that shape); a - DIMENSION 11 value frame of another shape still parses as this class, - with ``diagnostic_mask`` ``None`` and the raw value in - ``dimension_value``. + """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 == 11 and len(value) == 24 and set(value) <= {"0", "1"}: + if ( + self._dimension in self._MASK_DIMENSIONS + and len(value) == 24 + and set(value) <= {"0", "1"} + ): self._diagnostic_mask = value - cleared = tuple(i for i, bit in enumerate(value, 1) if bit == "0") + 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"Light {self._where} autodiagnostic mask is {value}" - f" (cleared bits: {', '.join(map(str, cleared)) or 'none'})." + 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 11 bitmask as sent (``"111110111111111111110111"``).""" + """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)``).""" - if self._diagnostic_mask is None: - return None - return tuple(i for i, bit in enumerate(self._diagnostic_mask, 1) if bit == "0") + return self._cleared_bits register_event_parser(1001, OWNLightingDiagnosticEvent) diff --git a/README.md b/README.md index f384ef6..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** / **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 Dimension 11) | `OWNLightingCommand`, `OWNLightingEvent`, `OWNLightingDiagnosticEvent` | +| **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 4929f37..7a43e4a 100644 --- a/scripts/sync_documentation.py +++ b/scripts/sync_documentation.py @@ -108,7 +108,7 @@ class WhoCatalogDefinition: WhoCatalogDefinition( 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, actuator autodiagnostic masks (WHO 1001 Dimension 11)", + 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( 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 index 608f827..5f009bb 100644 --- a/tests/test_who1001_diagnostic_event.py +++ b/tests/test_who1001_diagnostic_event.py @@ -79,6 +79,73 @@ 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##")