diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cd475..db693e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ All notable changes to `OWNd` are documented in this file. - **WHO 4 (`OWNHeatingEvent`)**: `OWNMessage.parse` / `OWNEvent.parse` no longer raise on a WHO 4 frame the grammar accepts but the decoder could not read: an empty DIMENSION 11 / 13 / 60 value (`*#4*1*11*##`, `*#4*1*60*##` raised `ValueError`; `*#4*1*11##`, `*#4*1*13##`, `*#4*1*60##` raised `IndexError`), an empty DIMENSION 15 sensor slot (`*#4*1*15**##`, `*#4*1*15**0215##`, `*#4*1*15#1**0215##` raised `ValueError` from `int('')`) and the wildcard WHERE (`*4*1**##`, `*4*303**##` raised `ValueError` from `int('*')`; the event is zone 0, as a frame without a WHERE already was). Such a frame stays untyped with `fan_speed` / `fan_on` / `main_humidity` / `secondary_temperature` `None`. The integer fields of DIMENSION 11, 15, 19, 20, 30, 31 and 32 are read only when they are ASCII digits, as the DIMENSION 60 humidity value already was (`str.isdigit` also accepts fullwidth digits: `*#4*1*11*3*##` decoded `fan_speed` 3, `*#4*#0*30*01*02*2026##` a date; both are untyped now). Crash guards only: every well-formed ASCII frame decodes exactly as before (same `message_type`, property values and log lines). - **WHO 18 (send layer, `is_who18_dimension_52_crash_form`)**: a DIMENSION 52 request that is not one of the tested safe forms is refused by every send path (`OWNCommandSession.send`, `send_to_gateway`, `AsyncTcpTransport.send`, `AsyncSerialTransport.send`) with `reason="destructive_frame"`, like the WHO 4 dimension 22 crash forms: `*#18*51*52#2026#10##` and `*#18*51*52#2026#10*100##` crash the F460 `020012`, F461 `020011` and 3488 `030205` (HOMETOUCH) after the telegram went out (OpenWebNet-HA/own-firmware-oracle, suite `ownd-audit-frames`, index SHA-256 `db03cc878d84d514b9e324c2d01d050264f2e4ccd0c1aad33937683c30e472ca`, `findings/audit-crash-frames.md`; emulated firmware, `harness=full`, not yet replayed on physical hardware). The firmware reads the year with `atoi()` into one byte, so every spelling it reads as a wide year is refused too (`52#02026#10`, `52#256#10`, `*#018*`, `*052`, `#010`, a `#52` write, an extra `#` parameter, an empty WHERE, a non-ASCII digit, more than one value field); only `*#18*WHERE*52##`, `*#18*WHERE*52*Wh##`, `*#18*WHERE*52#YY#M##` and `*#18*WHERE*52#YY#M*Wh##` with a one- or two-digit year and a month 1..12 go out. `bytes` / `bytearray` / `memoryview` and concatenated `##` chunks are checked like the WHO 4 guard. No builder emits a refused form and `OWNCommand.parse` stays permissive. `is_who18_dimension_52_four_digit_year` remains as an alias. +### Added +- **WHO 4 (`OWNHeatingEvent.actuator`, `OWNHeatingCommand.actuator`, `OWNMessage.interface`)**: Public `actuator` property on Dimension 20 status events (`*#4*Z#N*20*VAL##`), Dimension 20 requests (`*#4*Z#N*20##`) and `MyHomeServer1` Dimension 20 writes (`*#4*Z#N*#20*VAL##`), plus WHO 4 `#4#IF` bus-interface extraction in `OWNMessage.interface` and `OWNMessage.event_content` (`MyHOME#863`). Disambiguates actuator `#4` (`30#4`, `len == 1`) from F422 bus routing (`30#4#01`, `len == 2`, actuator `"1"`; `30#4#4#01`, `len == 3`, actuator `"4"` on interface `"01"`), `#0#4` (4-zone central unit 4) from `#0#4#01` (`#0` on interface `"01"`), and leaves WHO 4 Dimension 22 split addresses (`3#4#1*22##`) unrouted. +- **Profiles (`GatewayProfile` WHO 4 request capability flags)**: Declarative `bool | None = None` fields `supports_general_heating_status` (`*#4*0##`), `supports_heating_zone_status` (`*#4*Z##`), `supports_heating_actuator_status` (`*#4*Z*20##`), `supports_heating_humidity_request` (`*#4*Z*60##`), and `supports_interface_zone_requests` (`*#4*Z#4#II...##`) populated from `own-firmware-oracle` verdicts and live hardware traces (`MyHOME#466`, `#629`, `#674`, `#799`, `#866`), with `F454Profile(supports_heating_zone_status=True, ...)` and `MH202Profile(supports_heating_zone_status=True, ...)` verified on both central-unit (`#629`) and standalone room-thermostat (`#866`) plants. + ## [2.0.0b13] - 2026-11-02 ### Added diff --git a/OWNd/message/base.py b/OWNd/message/base.py index 2a67759..57486e9 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -353,16 +353,33 @@ def where(self) -> str | None: """The 'where' ID of the subject of this message""" return self._where # [1:] if self._where.startswith('#') else self._where + def _interface_and_where_params(self) -> tuple[str | None, list[str]]: + """Split ``_where_param`` into ``(interface, remaining_where_params)``. + + On WHO 0, 1, 2, 14, 15 and 1001, ``#4#IF`` starts at index 0. + On WHO 4 (excluding Dimension 22 split-unit ``3#Z#N`` addresses where + ``Z`` can be ``4``), ``#4#IF`` sits either after a bare zone/central + address (``Z#4#IF``, length 2) or after an actuator/central-unit index + (``Z#N#4#IF`` / ``#0#C#4#IF``, length 3), so an actuator ``4`` address + ``Z#4`` (length 1) is never misread as an incomplete interface token. + """ + if ( + self._who in (0, 1, 2, 14, 15, 1001) + and len(self._where_param) > 1 + and self._where_param[0] == "4" + ): + return self._where_param[1], self._where_param[2:] + if self._who == 4 and self._dimension != 22: + if len(self._where_param) == 2 and self._where_param[0] == "4": + return self._where_param[1], [] + if len(self._where_param) == 3 and self._where_param[1] == "4": + return self._where_param[2], [self._where_param[0]] + return None, self._where_param + @property 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, 1001] - and len(self._where_param) > 1 - and self._where_param[0] == "4" - else None - ) + return self._interface_and_where_params()[0] @property def what(self) -> int | None: @@ -420,12 +437,11 @@ def event_content(self) -> dict[str, Any]: } if self._where: _event.update({"where": self._where}) - if self.interface: - _event.update({"interface": self.interface}) - if self._where_param and len(self._where_param) > 2: - _event.update({"where parameters": self._where_param[2:]}) - elif self._where_param: - _event.update({"where parameters": self._where_param}) + interface, where_params = self._interface_and_where_params() + if interface: + _event.update({"interface": interface}) + if where_params: + _event.update({"where parameters": where_params}) # Explicit None checks: 0 is meaningful (what=0 is OFF, dimension=0 # is temperature) and must not be dropped from the event payload. if self._what is not None: diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 357234b..8338b76 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -360,15 +360,17 @@ def __init__(self, data: str) -> None: self._type = None where = self._where or "0" + _, where_params = self._interface_and_where_params() # The frame grammar admits the wildcard WHERE ``*`` for every WHO # (``*4*1**##``); it is no zone, so the event is zone 0, as a frame # without a WHERE already was, instead of raising ValueError from int(). zone_digits = where[1:] if where.startswith("#") else where self._zone = _ascii_int(zone_digits) or 0 # ``#0#N`` is zone N of a 4-zone central unit; ``0#N`` is actuator N - # of zone 0 (a pump the zones share), not zone N. - if self._zone == 0 and self._where_param and where.startswith("#"): - self._zone = int(self._where_param[0]) + # of zone 0 (a pump the zones share), not zone N; ``#0#4#IF`` is ``#0`` + # behind interface ``IF`` (``where_params`` is empty). + if self._zone == 0 and where_params and where.startswith("#"): + self._zone = int(where_params[0]) self._sensor = None if self._zone > 99: self._sensor = int(str(self._zone)[:1]) @@ -789,9 +791,7 @@ def __init__(self, data: str) -> None: ): # Actuator status self._type = MESSAGE_TYPE_ACTION self._is_active = self._dimension_value[0] in _actuator_active_states - self._actuator = ( - self._where_param[0] if self._where_param else "1" - ) + self._actuator = where_params[0] if where_params else "1" _value = int(self._dimension_value[0]) if _value == 0: self._human_readable_log = ( @@ -904,6 +904,11 @@ def message_type(self) -> str | None: def zone(self) -> int: return self._zone + @property + def actuator(self) -> str | None: + """Actuator index of a WHO 4 Dimension 20 status event, else ``None``.""" + return self._actuator + @property def mode(self) -> str | None: return self._mode_name @@ -1601,6 +1606,14 @@ def central_status(cls, where: str = "#0") -> OWNHeatingCommand: message._human_readable_log = f"Requesting Central Unit {where} status." return message + @property + def actuator(self) -> str | None: + """Actuator index of a WHO 4 Dimension 20 request or write, else ``None``.""" + if self._dimension != 20: + return None + _, where_params = self._interface_and_where_params() + return where_params[0] if where_params else "1" + @property def is_temperature_query(self) -> bool: """True if this message is a temperature dimension query (*#4*WHERE*0##).""" diff --git a/OWNd/profiles.py b/OWNd/profiles.py index 511d482..6fa12c1 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -96,6 +96,19 @@ class GatewayProfile: # WHO 18 reply-form frames (*#18*W*DIM*VAL##). True means acknowledged on # the command session, not that the value is shown to reach the bus. supports_who18_response_injection: bool | None = None + # WHO 4 thermoregulation request capabilities (None = unverified; True/False + # from own-firmware-oracle suites and live hardware traces): + # - supports_general_heating_status: general sweep *#4*0## (trace-corpus.tsv). + # - supports_heating_zone_status: per-zone status *#4*Z## (thermo-zone-requests, + # live multi-session traces MyHOME#629, #799, #866). + # - supports_heating_actuator_status: per-zone *#4*Z*20## (thermo-actuator.tsv). + # - supports_heating_humidity_request: per-zone *#4*Z*60## (thermo-zone-requests). + # - supports_interface_zone_requests: WHO 4 behind #4#II (thermo-zone-requests). + supports_general_heating_status: bool | None = None + supports_heating_zone_status: bool | None = None + supports_heating_actuator_status: bool | None = None + supports_heating_humidity_request: bool | None = None + supports_interface_zone_requests: bool | None = None supported_who: tuple[int, ...] = DEFAULT_SUPPORTED_WHO extra_features: tuple[str, ...] = () firmware_version: str | None = field(default=None, kw_only=True) @@ -282,6 +295,10 @@ def __init__(self) -> None: super().__init__( model_name="F453", command_queue_delay=0.15, + supports_general_heating_status=True, + supports_heating_actuator_status=False, + supports_heating_humidity_request=False, + supports_interface_zone_requests=False, ) @@ -328,6 +345,10 @@ def __init__( model_name="F453AV", firmware_version=fw_str, command_queue_delay=0.15, + supports_general_heating_status=True, + supports_heating_actuator_status=False, + supports_heating_humidity_request=False, + supports_interface_zone_requests=False, supported_who=( DEFAULT_SUPPORTED_WHO if has_cen_plus else _DEFAULT_WITHOUT_CEN_PLUS ), @@ -338,9 +359,12 @@ def __init__( class F454Profile(GatewayProfile): """The F454 Web Server (Legrand 003598). - Verified by the firmware oracle (``F454 020051``, ``results/F454/020051/oracle/full/``): + Verified by the firmware oracle (``F454 020051``, ``results/F454/020051/oracle/full/``) + and live hardware traces (``F454`` FW 1.0.34 in MyHOME#629, ``F454`` FW 2.0 in MyHOME#866): packages ``openserver`` (HMAC-SHA2), ``bt_fls`` (WHO 1/2), ``bt_energia`` (WHO 3), - ``bt_H4690`` (WHO 4), ``bt_cen`` (WHO 15/25), ``bt_difson`` (WHO 16, answering + ``bt_H4690`` (WHO 4: drops ``*#4*0##`` and ``*#4*Z*60##``, expands ``*#4*Z##`` into four + SCS bus queries answered in ~1.3 s by both central-unit and standalone room thermostats, + and forwards ``*#4*Z*20##``), ``bt_cen`` (WHO 15/25), ``bt_difson`` (WHO 16, answering ``*#16*0*5##`` and 69 other WHO 16 requests with ``ack``/``out``), ``bt_multi`` (WHO 22), and ``bt_energy`` (WHO 18). """ @@ -354,6 +378,11 @@ def __init__(self) -> None: supports_hmac=True, supports_native_transitions=True, supports_extended_frames=True, + supports_general_heating_status=False, + supports_heating_zone_status=True, + supports_heating_actuator_status=True, + supports_heating_humidity_request=False, + supports_interface_zone_requests=False, ) @@ -437,6 +466,11 @@ def __init__(self) -> None: supports_dimension_response_injection=False, supports_who4_command_injection=True, supports_who18_response_injection=True, + supports_general_heating_status=False, + supports_heating_zone_status=True, + supports_heating_actuator_status=True, + supports_heating_humidity_request=True, + supports_interface_zone_requests=False, supported_who=tuple( w for w in DEFAULT_SUPPORTED_WHO @@ -480,6 +514,10 @@ def __init__(self) -> None: max_queue_size=100, event_keepalive_interval=90, supports_energy_instant_power=False, + supports_general_heating_status=False, + supports_heating_actuator_status=False, + supports_heating_humidity_request=False, + supports_interface_zone_requests=False, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, @@ -521,6 +559,10 @@ def __init__(self) -> None: event_keepalive_interval=90, supports_energy_instant_power=False, supports_audio=True, + supports_general_heating_status=True, + supports_heating_actuator_status=False, + supports_heating_humidity_request=False, + supports_interface_zone_requests=False, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, @@ -545,6 +587,7 @@ def __init__(self) -> None: max_queue_size=100, audio_measured=False, supports_extended_frames=True, + supports_general_heating_status=False, extra_features=("Clock diagnostics",), ) @@ -562,6 +605,12 @@ class MH202Profile(GatewayProfile): every WHO 16 command: a live MH202 with an F441M matrix relays WHO 16 state frames (``*#16*41*1*1##``, ``*#16*41*1*6##``; MyHOME#669 trace), and ``supported_who`` also decides which gateway owns those events. + + WHO 4 per-zone status queries (``*#4*Z##``) are acknowledged by the + firmware oracle (``MH202 010024`` returns ``ack/out``, putting the 4 SCS + queries on the bus) and confirmed live on hardware (``MH202`` FW 1.0 in + MyHOME#866 comment 6641631764, answering all 5 frames across zones 1..5 + in 160–250 ms). """ def __init__(self) -> None: @@ -572,6 +621,11 @@ def __init__(self) -> None: audio_measured=False, supports_hmac=True, supports_extended_frames=True, + supports_general_heating_status=False, + supports_heating_zone_status=True, + supports_heating_actuator_status=False, + supports_heating_humidity_request=False, + supports_interface_zone_requests=False, supported_who=(*DEFAULT_SUPPORTED_WHO, WHO_ALARM), ) @@ -650,6 +704,10 @@ def __init__( super().__init__( model_name="H4684", firmware_version=fw_str, + supports_general_heating_status=False, + supports_heating_actuator_status=False, + supports_heating_humidity_request=False, + supports_interface_zone_requests=False, supported_who=( (*base_who, WHO_SOUND_DIFFUSION) if has_who22 else base_who ), @@ -685,6 +743,11 @@ def __init__(self) -> None: supports_hmac=True, supports_native_transitions=True, supports_extended_frames=True, + supports_general_heating_status=False, + supports_heating_zone_status=True, + supports_heating_actuator_status=True, + supports_heating_humidity_request=True, + supports_interface_zone_requests=False, supported_who=tuple( w for w in DEFAULT_SUPPORTED_WHO diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 74caf21..03cb326 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -711,3 +711,59 @@ def test_who16_stays_on_gateways_that_forward_it(model: str) -> None: state frames from an F441M (``*#16*41*1*1##``, MyHOME#669), so it keeps owning WHO 16 events.""" assert get_gateway_profile(model).supports_who(WHO_SOUND) + + +@pytest.mark.parametrize( + ( + "model", + "general_status", + "zone_status", + "actuator_status", + "humidity_request", + "interface_requests", + ), + [ + # Oracle + live multi-session captures (MyHOME#629, #799, #866) + ("F454", False, True, True, False, False), + ("003598", False, True, True, False, False), + ("F461", False, True, True, True, False), + ("MyHomeServer1", False, True, True, True, False), + ("MHS1", False, True, True, True, False), + ("MH202", False, True, False, False, False), + ("03535", False, True, False, False, False), + ("003535", False, True, False, False, False), + # Gateways supporting general sweep *#4*0## (zone_status left None / unverified) + ("F453", True, None, False, False, False), + ("F453AV", True, None, False, False, False), + ("MH200N", True, None, False, False, False), + # Single-session / unmeasured in WHO 4 oracle (zone_status left None for caller policy) + ("F455", None, None, None, None, None), + # Single-session / scenario gateways in oracle (zone_status left None for caller policy) + ("H4684", False, None, False, False, False), + ("H/L4684", False, None, False, False, False), + ("MH200", False, None, False, False, False), + # Live MH201 sweep (#466: *#4*0## gets no zone reply; not in oracle) + ("MH201", False, None, None, None, None), + # Unmeasured in WHO 4 oracle / captures + ("F452", None, None, None, None, None), + ("F452V", None, None, None, None, None), + ("H4890", None, None, None, None, None), + ("UnknownModel", None, None, None, None, None), + ], +) +def test_who4_heating_capability_flags( + model: str, + general_status: bool | None, + zone_status: bool | None, + actuator_status: bool | None, + humidity_request: bool | None, + interface_requests: bool | None, +) -> None: + """Verify declarative WHO 4 request capability flags across gateway profiles.""" + profile = get_gateway_profile(model) + assert profile.supports_general_heating_status is general_status + assert profile.supports_heating_zone_status is zone_status + assert profile.supports_heating_actuator_status is actuator_status + assert profile.supports_heating_humidity_request is humidity_request + assert profile.supports_interface_zone_requests is interface_requests + diff --git a/tests/test_who4_actuators_status.py b/tests/test_who4_actuators_status.py index 41fc95f..258acdc 100644 --- a/tests/test_who4_actuators_status.py +++ b/tests/test_who4_actuators_status.py @@ -162,5 +162,105 @@ def test_actuators_status_replies_from_the_f461( assert isinstance(event, OWNHeatingEvent) assert event.zone == int(reply.split("*")[2].split("#")[0]) assert event._actuator == actuator + assert event.actuator == actuator assert event.is_active() is active assert event.fan_on is fan_on + + +@pytest.mark.parametrize( + ("frame", "expected_zone", "expected_actuator", "expected_interface", "expected_where_params"), + [ + # Bare zone (implicit actuator 1) + ("*#4*30*20*1##", 30, "1", None, None), + # Explicit actuator 1..9, including actuator #4 (MyHOME#863) + ("*#4*30#1*20*1##", 30, "1", None, ["1"]), + ("*#4*30#4*20*1##", 30, "4", None, ["4"]), + ("*#4*30#4*20*0##", 30, "4", None, ["4"]), + # Zone behind F422 interface #4#IF (implicit actuator 1) + ("*#4*30#4#01*20*1##", 30, "1", "01", None), + ("*#4*0#4#01*20*1##", 0, "1", "01", None), + # Explicit actuator behind F422 interface #4#IF, including actuator #4 + ("*#4*2#1#4#01*20*0##", 2, "1", "01", ["1"]), + ("*#4*30#4#4#01*20*1##", 30, "4", "01", ["4"]), + ("*#4*30#4#4#4*20*1##", 30, "4", "4", ["4"]), + ], +) +def test_who4_dim20_actuator_and_interface_disambiguation( + frame: str, + expected_zone: int, + expected_actuator: str, + expected_interface: str | None, + expected_where_params: list[str] | None, +) -> None: + """Disambiguate WHO 4 actuator #4 (Z#4) from F422 bus routing (#4#IF).""" + event = OWNMessage.parse(frame) + + assert isinstance(event, OWNHeatingEvent) + assert event.zone == expected_zone + assert event.actuator == expected_actuator + assert event.interface == expected_interface + content = event.event_content + assert content.get("interface") == expected_interface + assert content.get("where parameters") == expected_where_params + if expected_zone > 0: + assert f"Zone {expected_zone}'s actuator {expected_actuator} is " in event.human_readable_log + + +@pytest.mark.parametrize( + ("frame", "expected_actuator", "expected_interface"), + [ + ("*#4*30*20##", "1", None), + ("*#4*30#4*20##", "4", None), + ("*#4*30#4#01*20##", "1", "01"), + ("*#4*30#4#4#01*20##", "4", "01"), + # MyHomeServer1 Dimension 20 write frames (*#4*Z#N*#20*VAL##, MyHOME#863) + ("*#4*30*#20*1##", "1", None), + ("*#4*30#4*#20*1##", "4", None), + ("*#4*30#4#01*#20*0##", "1", "01"), + ("*#4*30#4#4#01*#20*1##", "4", "01"), + # Non-Dimension-20 WHO 4 commands have no actuator + ("*#4*30*0##", None, None), + ("*#4*30*#14*0200*3##", None, None), + ("*#4*3#4#1*22##", None, None), + ("*#4*3#4#1*#22*1*0215*2*0##", None, None), + ], +) +def test_who4_command_actuator_and_dim22_split_non_collision( + frame: str, + expected_actuator: str | None, + expected_interface: str | None, +) -> None: + """OWNHeatingCommand exposes .actuator on Dim 20 and keeps Dim 22 split 3#4#1 unrouted.""" + cmd = OWNMessage.parse(frame) + + assert isinstance(cmd, OWNHeatingCommand) + assert cmd.actuator == expected_actuator + assert cmd.interface == expected_interface + turn_off = OWNHeatingCommand.turn_off(30, standalone=True) + assert turn_off is not None and turn_off.actuator is None + + +def test_who4_non_dim20_events_and_central_interface_disambiguation() -> None: + """Non-Dim-20 events keep actuator None; #0#4 stays zone 4 while #0#4#01 stays #0 on IF 01.""" + temp_evt = OWNMessage.parse("*#4*30#4#01*0*0215##") + assert isinstance(temp_evt, OWNHeatingEvent) + assert temp_evt.zone == 30 + assert temp_evt.actuator is None + assert temp_evt.interface == "01" + + central_4 = OWNMessage.parse("*4*101*#0#4##") + assert isinstance(central_4, OWNHeatingEvent) + assert central_4.zone == 4 + assert central_4.interface is None + + central_if = OWNMessage.parse("*4*1*#0#4#01##") + assert isinstance(central_if, OWNHeatingEvent) + assert central_if.zone == 0 + assert central_if.interface == "01" + + central_4_if = OWNMessage.parse("*4*101*#0#4#4#01##") + assert isinstance(central_4_if, OWNHeatingEvent) + assert central_4_if.zone == 4 + assert central_4_if.interface == "01" + assert central_4_if.event_content.get("where parameters") == ["4"] +