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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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).

### 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`.
Expand Down
58 changes: 58 additions & 0 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ def is_who4_dimension_22(frame: object) -> bool:
return any(_WHO4_DIMENSION_22_REGEX.search(chunk) for chunk in raw.split("##"))


# WHO 4 WHERE for an actuator status request: a zone of one or two digits
# (``0..99``: ``Z``, ``#Z``, ``0`` / ``#0`` for every zone or the central
# unit), optionally one actuator digit (``Z#N``, ``#0#N``, ``#Z#N``; ``N`` is
# ``1..9``, ``0`` for all actuators of the zone: ``2#0``, ``0#0``), optionally
# a bus-interface suffix of exactly two digits (``#4#NN``: ``3#4#01``,
# ``#3#4#01``, ``2#1#4#01``). Three-digit addresses are secondary probes
# (``PZZ``, ``101..899``: no actuators, NACK on an F461) and the actuator
# index is one digit in the WHO 4 WHERE table, so both are refused the way
# ``get_fan_speed`` bounds its zone to ``1..99``. ASCII digits only; no ``*``, so no dimension can be
# injected through the address.
_WHO4_WHERE_REGEX = re.compile(r"#?[0-9]{1,2}(?:#[0-9])?(?:#4#[0-9]{2})?", re.ASCII)


def who4_temperature(raw: str) -> float | None:
"""Decode WHO 4 temperature ``SXXX`` (tenths of a degree)."""
if not _WHO4_TEMP_REGEX.match(raw):
Expand Down Expand Up @@ -892,6 +905,51 @@ def valves_status(cls, where: str | int) -> OWNHeatingCommand:
message._human_readable_log = f"Requesting climate valve status update for {message._where}{message._interface_log_text}."
return message

@classmethod
def actuators_status(cls, where: str | int) -> OWNHeatingCommand:
"""Request actuator status (``*#4*WHERE*20##``), one reply per actuator.

``where`` is a zone of one or two digits (``2``, ``#2``), one actuator
(``2#1``; ``2#0`` for all actuators of the zone), the central-unit
form (``#0``, ``#0#N``) for every zone, or ``#Z#N`` (an actuator
addressed through the central unit; WHO 4 WHERE table, see
``_zone_address_and_name``). Each form may be routed through a bus interface
with a two-digit ``#4#NN`` suffix (``3#4#01``, ``#3#4#01``,
``2#1#4#01``) because the suffix is spec-legal grammar, but no
emulated firmware routes it across an F422: own-firmware-oracle#65
(``thermo-zone-requests``, decoded ``scs_bus_hex``) shows that F450,
F454, F459, F460, F461 and MyHomeServer1 truncate ``WHERE`` at the
first ``#`` and answer ``*#4*2#4#01*20##`` (also ``*#4*2#4#12*20##``
and ``*#4*2#1#4#01*20##``) with the bytes of the unqualified
``*#4*2*20##``, polling local main-bus zone 2; MH202 010024 reads
the trailing ``#4#01`` as local zone 4, actuator 1 (and rejects
``#4#12``, actuator 12 > 9); F453, F453AV, H4684, MH200 and MH200N
drop the frame. Callers must therefore gate ``#4#NN`` polling on the
gateway profile's ``supports_interface_zone_requests`` (MyHOME#816)
instead of expecting the interface to be honoured. The MH202 010024
thermoregulation daemon crashes (``SIGSEGV``) on the dimensionless
status forms ``*#4*Z#N##`` (``N`` 1..9) and ``*#4*Z#4#01..09##``
(``thermo_open2scs+0x394``); this builder only emits the dimension-20
form.
A live F461 2.0.11 (2026-10-23) answers ``*#4*2*20##`` with
``*#4*2#1*20*0##`` (valve, off) on the command session and
``*#4*2#2*20*5##`` (fan coil, off) on the event session, and
``*#4*#0*20##`` with both; a live MyHomeServer1 3.87.13 answers
``*#4*35*20##`` with ``*#4*35#1*20*0##``. Whitespace around ``where``
is stripped. Anything else (``*``, an empty string, a three-digit
secondary probe such as ``105``, a two-digit actuator such as
``2#10``, a one- or three-digit interface such as ``3#4#1`` or
``3#4#001``) raises ``ValueError``. The log line carries the whole
address as written (``2#1#4#01``), not only the zone the parser
keeps in ``where``.
"""
where_str = str(where).strip() if where is not None else ""
if not _WHO4_WHERE_REGEX.fullmatch(where_str):
raise ValueError(f"Invalid zone address: {where}")
message = cls(f"*#4*{where_str}*20##")
message._human_readable_log = f"Requesting climate actuator status update for {where_str}."
return message

@classmethod
def get_temperature(cls, where: str | int) -> OWNHeatingCommand:
message = cls(f"*#4*{where}*0##")
Expand Down
166 changes: 166 additions & 0 deletions tests/test_who4_actuators_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""``OWNHeatingCommand.actuators_status`` builds DIMENSION 20 (``*#4*WHERE*20##``).

A live F461 2.0.11 (2026-10-23, MyHOME#799 ``f461_live_2026-10-23`` run 1)
answers ``*#4*2*20##`` with ``*#4*2#1*20*0##`` on the command session and
``*#4*2#2*20*5##`` on the event session, one frame per actuator, and
``*#4*#0*20##`` with both on the command session. A live MyHomeServer1
3.87.13 (MyHOME#799 ``myhomeserver1_live_2026-10-24`` run 2) answers
``*#4*35*20##`` with ``*#4*35#1*20*0##``.

Dimension 20 values (WHO 4 spec, ``OWNHeatingEvent``): 0 off, 1 on, 2 opened,
3 closed, 4 stop, 5 fan coil off, 6-8 fan speeds, 9 fan auto;
``_actuator_active_states`` is ``1, 2, 6, 7, 8, 9``, so value 5 is inactive.

Bus-interface routed addresses (``Z#4#NN``, ``#Z#4#NN``, ``Z#N#4#NN``) are
accepted since the MyHOME#792 review as spec-legal grammar, not because any
firmware honours them: own-firmware-oracle#65 (``thermo-zone-requests``,
decoded ``scs_bus_hex``) shows the seven ``out`` images emit unrouted local-bus
queries (``$04A9023010`` / ``$004A9023010`` for local zone 2 on F450, F454,
F459, F460, F461, MyHomeServer1; ``$0499843110`` for local zone 4 actuator 1
on MH202 010024) instead of forwarding ``#4#NN`` across an F422, and F453,
F453AV, H4684, MH200, MH200N drop the frame. MyHOME#816 therefore withholds
``#4#NN`` WHO 4 status polls unless ``supports_interface_zone_requests`` is
``True``. The MH202 010024 daemon crashes on the dimensionless forms
``*#4*Z#N##`` (``N`` 1..9) and ``*#4*Z#4#01..09##``, which this builder never
emits: it only builds the dimension-20 form.
"""

from __future__ import annotations

import re

import pytest

from OWNd.message import OWNHeatingCommand, OWNHeatingEvent, OWNMessage

# WHO 4 DIMENSION 22 crashes the gateway's thermoregulation daemon; the
# builder must not let an address smuggle it in.
_DIMENSION_22 = re.compile(r"\*#4\*[^*]*\*#?0*22(?:[#*]|$)")


@pytest.mark.parametrize(
("where", "frame"),
[
("2", "*#4*2*20##"),
(2, "*#4*2*20##"),
("#2", "*#4*#2*20##"),
("2#1", "*#4*2#1*20##"),
("#0", "*#4*#0*20##"),
("#0#1", "*#4*#0#1*20##"),
# ``#Z#N``: actuator N of zone Z addressed through the central unit
# (WHO 4 WHERE table; ``_zone_address_and_name`` documents ``#Z#C``).
# Accepted on purpose; not seen live (the plant has no central unit).
("#2#1", "*#4*#2#1*20##"),
("22", "*#4*22*20##"),
# ``Z#0`` / ``0#0``: all actuators of a zone / of every zone
# (Encyclopedia WHERE table; the forms F453, F453AV and MH200N put on
# the SCS bus in own-firmware-oracle ``thermo-zone-requests``).
("2#0", "*#4*2#0*20##"),
("0#0", "*#4*0#0*20##"),
("0", "*#4*0*20##"),
("99", "*#4*99*20##"),
("#0#4", "*#4*#0#4*20##"),
# Bus-interface routed forms (``#4#NN``, two-digit interface): zone,
# zone through the central unit, one actuator of a zone.
("3#4#01", "*#4*3#4#01*20##"),
(" 3#4#01 ", "*#4*3#4#01*20##"),
("#3#4#01", "*#4*#3#4#01*20##"),
("2#1#4#01", "*#4*2#1#4#01*20##"),
("12#4#15", "*#4*12#4#15*20##"),
],
)
def test_actuators_status_builds_dimension_20(where: str | int, frame: str) -> None:
command = OWNHeatingCommand.actuators_status(where)

assert str(command) == frame
assert command.is_dimension_request
assert command.dimension == 20
assert _DIMENSION_22.search(str(command)) is None

parsed = OWNMessage.parse(frame)
assert isinstance(parsed, OWNHeatingCommand)
assert str(parsed) == frame
assert parsed.dimension == 20
assert parsed.where == command.where
# ``where`` keeps only the token before the first ``#``; the actuator
# and interface tokens live in ``where_param`` and must round-trip too.
assert parsed.where_param == command.where_param
assert command.human_readable_log == (
f"Requesting climate actuator status update for {str(where).strip()}."
)


@pytest.mark.parametrize(
"where",
[
"",
" ",
"2*22",
"2*22#",
"#0*22",
"2##",
"*",
"a",
"##0",
"2#1#4",
"٢",
None,
# Zones are 0..99: three digits address a secondary probe (``PZZ``,
# no actuators, NACK on an F461) or nothing at all.
"105",
"101",
"899",
"001",
"99999",
"#105",
"105#4#01",
# The actuator index is one digit (``0..9``).
"2#10",
"2#999",
"#0#10",
"2#10#4#01",
# ``bool`` is an ``int`` subclass; negative numbers are not zones.
True,
False,
-1,
# Routing suffix: the interface is exactly two digits after ``#4#``.
"3#4#1",
"3#4#001",
"#3#4#1",
"#3#4#001",
"2#1#4#1",
"2#1#4#001",
"3#4#",
"3#4#0a",
"3#4#01#",
"3#3#01",
"3#4#01#4#01",
"3#4#01*22",
"*#4#01",
],
)
def test_actuators_status_refuses_bad_addresses(where: object) -> None:
with pytest.raises(ValueError):
OWNHeatingCommand.actuators_status(where) # type: ignore[arg-type]


@pytest.mark.parametrize(
("reply", "actuator", "active", "fan_on"),
[
("*#4*2#1*20*0##", "1", False, None),
# value 5 = fan coil OFF: ``_fan_mode = 5 - 5 = 0`` -> fan_on False,
# is_active False (master behaviour, pinned unchanged).
("*#4*2#2*20*5##", "2", False, False),
("*#4*35#1*20*0##", "1", False, None),
],
)
def test_actuators_status_replies_from_the_f461(
reply: str, actuator: str, active: bool, fan_on: bool | None
) -> None:
event = OWNMessage.parse(reply)

assert isinstance(event, OWNHeatingEvent)
assert event.zone == int(reply.split("*")[2].split("#")[0])
assert event._actuator == actuator
assert event.is_active() is active
assert event.fan_on is fan_on
Loading