Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*<mask>##` and `*#1001*WHERE*7*<mask>##`, 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.
Expand Down
2 changes: 2 additions & 0 deletions OWNd/message/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
percent_to_brightness_preset,
register_coarse_dimmer_where,
OWNLightingCommand,
OWNLightingDiagnosticEvent,
OWNLightingEvent,
)
from .load import (
Expand Down Expand Up @@ -258,6 +259,7 @@
"normalize_where",
"OWNLightingEvent",
"OWNLightingCommand",
"OWNLightingDiagnosticEvent",
# Automation (WHO 2)
"OWNAutomationEvent",
"OWNAutomationCommand",
Expand Down
2 changes: 1 addition & 1 deletion OWNd/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions OWNd/message/lighting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*<mask>##``) or DIMENSION 7
(``*#1001*WHERE*7*<mask>##``); 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)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
6 changes: 3 additions & 3 deletions scripts/sync_documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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**",
Expand Down
8 changes: 5 additions & 3 deletions tests/test_firmware_oracle_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
OWNEvent,
OWNGatewayEvent,
OWNLightingCommand,
OWNLightingDiagnosticEvent,
OWNLightingEvent,
OWNLockCommand,
OWNLockEvent,
Expand Down Expand Up @@ -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##")
Expand Down
154 changes: 154 additions & 0 deletions tests/test_who1001_diagnostic_event.py
Original file line number Diff line number Diff line change
@@ -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
Loading