Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 29 additions & 13 deletions OWNd/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 19 additions & 6 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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##)."""
Expand Down
67 changes: 65 additions & 2 deletions OWNd/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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
),
Expand All @@ -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).
"""
Expand All @@ -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,
)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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",),
)

Expand All @@ -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:
Expand All @@ -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),
)

Expand Down Expand Up @@ -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
),
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Loading
Loading