diff --git a/CHANGELOG.md b/CHANGELOG.md index 655b70d..a85b651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to `OWNd` are documented in this file. ### 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`), `OWNCommandSession.send_to_gateway` (before `connect()`: no socket, no handshake) and `AsyncSerialTransport.send` refuse it before anything is written, with the contract above and `reason="destructive_frame"`. 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 4 (`OWNHeatingCommand.set_central_mode`)**: raises `ValueError` unless `where` is a central-unit `#` address (`#0`; `#0#C` with `C` the 4-zone unit's configured zone 1..99 or `01`..`99`; `#Z` with `Z` 1..99 or `01`..`99`; `#Z#C` with central unit `C` 1..99 as in libqtdevices' `*4*311*#23#1##`, the frame `set_mode("23#1")` already builds; `#00`, `#0#0`, `#0#00` and three-digit values are refused, whitespace is stripped); the plain zone form `*4*311*Z##` is `nack`/`silent` on all 12 emulated gateways (own-firmware-oracle `thermo-zone-mode`, `ownd-pr82-heating`) and on a live F461 2.0.11. ## [2.0.0b11] - 2026-10-20 diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index a9a38f9..f0fbb9f 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -109,6 +109,22 @@ def is_who4_dimension_22(frame: object) -> bool: return any(_WHO4_DIMENSION_22_REGEX.search(chunk) for chunk in raw.split("##")) +# A central-unit address: ``#0`` (99-zone central unit), ``#0#C`` (a 4-zone +# central unit, ``C`` its configured zone 1..99 or ``01``..``99``: MyHOME_Suite +# OPEN.db ``#0#[ZA][ZB]``, MHCatalogue 4695 ``ZAZB_CENTRAL`` 1..99, oracle +# ``thermo-central-mode`` ``*4*0*#0#2##`` -> ``D1 02``), ``#Z`` (zone 1..99 or +# ``01``..``99`` addressed through the central unit) or ``#Z#C`` (zone Z +# controlled through 4-zone central unit C, 1..99: the form ``set_mode("23#1")`` +# builds and libqtdevices sends as ``*4*311*#23#1##``, forwarded by all 12 +# emulated gateways in the firmware oracle ``thermo-ts10`` suite; see +# ``_zone_address_and_name``). ``#00``, ``#0#0`` and ``#0#00`` name no zone or +# central unit, and three digits are out of range. ASCII digits only. +_WHO4_CENTRAL_WHERE_REGEX = re.compile( + r"#(?:0(?:#(?:0[1-9]|[1-9][0-9]?))?|(?:0[1-9]|[1-9][0-9]?)(?:#(?:0[1-9]|[1-9][0-9]?))?)", + 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): @@ -1082,7 +1098,25 @@ def set_central_mode( would also switch the plant to summer. 311 is the generic automatic command libqtdevices sends to zones; on ``#0`` it puts every zone back on the central unit's program. + + ``where`` must be a central-unit address (``#0``; ``#0#C`` with + ``C`` the 4-zone unit's configured zone 1..99 or ``01``..``99``; + ``#Z`` with ``Z`` 1..99 or ``01``..``99``; ``#Z#C`` with central + unit ``C`` 1..99, the form ``set_mode`` builds for ``23#1``; + whitespace around it is stripped): central-unit WHATs address the + central unit by API contract (OWNd#77 fix 3). ``#00``, ``#0#0``, + ``#0#00`` and three-digit values (``#999``, ``#0#100``) raise too. + The plain zone form ``*4*311*Z##`` is ``nack``/``silent`` on + all 12 emulated gateways (firmware oracle ``thermo-zone-mode`` and + ``ownd-pr82-heating``), and a live F461 2.0.11 NACKs ``*4*311*2##`` + while it ACKs ``*4*311*#2##``. ``*4*1*Z##`` and ``*4*303*Z##`` are + forwarded by some gateways, but as zone commands, not central ones. """ + where = str(where).strip() if where is not None else "" + if not _WHO4_CENTRAL_WHERE_REGEX.fullmatch(where): + raise ValueError( + f"Central unit mode needs a central unit address such as '#0': {where!r}" + ) mode_map = { CLIMATE_MODE_OFF: 303, CLIMATE_MODE_HEAT: 1, diff --git a/tests/test_who4_central_mode_address.py b/tests/test_who4_central_mode_address.py new file mode 100644 index 0000000..0c513e6 --- /dev/null +++ b/tests/test_who4_central_mode_address.py @@ -0,0 +1,111 @@ +"""``set_central_mode`` only on a central-unit ``#`` address. + +Central-unit WHATs address the central unit by API contract (OWNd#77 fix 3). +The plain zone form ``*4*311*Z##`` is ``nack``/``silent`` on all 12 emulated +gateways (firmware oracle ``thermo-zone-mode`` and ``ownd-pr82-heating``), and +a live F461 2.0.11 NACKs ``*4*311*2##`` while it ACKs ``*4*311*#2##``. +""" + +from __future__ import annotations + +import pytest + +from OWNd.message import CLIMATE_MODE_AUTO, CLIMATE_MODE_HEAT, CLIMATE_MODE_OFF, OWNHeatingCommand + + +@pytest.mark.parametrize( + "where", + [ + "1", + "2", + "0", + "23#1", + "", + " ", + "#", + "##0", + "#0*22", + "#a", + "#0\n1", + "#\u0661", + "#00#1", + "#0#", + # Zones and central selectors are 1..99 (or 01..99); ``#00``, ``#0#0`` + # and ``#0#00`` name nothing, three digits are out of range. + "#00", + "#100", + "#999", + "#0#0", + "#0#00", + "#0#100", + "#0#999", + "#1#0", + "#1#00", + "#1#100", + "#1#2#3", + None, + True, + -1, + ], +) +def test_set_central_mode_requires_a_central_address(where: str) -> None: + with pytest.raises(ValueError, match="central unit"): + OWNHeatingCommand.set_central_mode(where, CLIMATE_MODE_AUTO) + + +@pytest.mark.parametrize( + "mode", [CLIMATE_MODE_AUTO, CLIMATE_MODE_HEAT, CLIMATE_MODE_OFF] +) +def test_set_central_mode_refuses_a_plain_zone_for_every_mode(mode: str) -> None: + """The guard is per address, not per WHAT: 311, 1 and 303 alike.""" + with pytest.raises(ValueError, match="central unit"): + OWNHeatingCommand.set_central_mode("1", mode) + + +@pytest.mark.parametrize( + ("where", "frame"), + [ + ("#0", "*4*311*#0##"), + ("#0#1", "*4*311*#0#1##"), + ("#0#4", "*4*311*#0#4##"), + ("#1", "*4*311*#1##"), + ("#23", "*4*311*#23##"), + ("#99", "*4*311*#99##"), + # ``#Z#C``: probe C of zone Z under the central unit, the address + # ``set_mode("23#1")`` builds and libqtdevices sends + # (``*4*311*#23#1##``, forwarded by all 12 emulated gateways in the + # firmware oracle ``thermo-ts10`` suite). + ("#23#1", "*4*311*#23#1##"), + ("#1#2", "*4*311*#1#2##"), + # ``#0#C``: the 4-zone central unit's configured zone, 1..99 or 01..99 + # (MyHOME_Suite ``#0#[ZA][ZB]``, MHCatalogue 4695 ``ZAZB_CENTRAL``). + ("#0#5", "*4*311*#0#5##"), + ("#0#05", "*4*311*#0#05##"), + ("#0#12", "*4*311*#0#12##"), + ("#0#99", "*4*311*#0#99##"), + # Two-digit virtual spellings of zones and central units. + ("#01", "*4*311*#01##"), + ("#23#12", "*4*311*#23#12##"), + # Surrounding whitespace is stripped, as ``actuators_status`` does. + (" #0 ", "*4*311*#0##"), + ("#0\n", "*4*311*#0##"), + ], +) +def test_set_central_mode_keeps_central_addresses(where: str, frame: str) -> None: + command = OWNHeatingCommand.set_central_mode(where, CLIMATE_MODE_AUTO) + + assert str(command) == frame + assert command.human_readable_log == ( + f"Setting Central Unit {where.strip()} mode to 'auto' (code 311)." + ) + + +def test_set_central_mode_agrees_with_set_mode_on_probe_addresses() -> None: + """``set_mode("23#1")`` and ``set_central_mode("#23#1")`` build one frame.""" + assert str(OWNHeatingCommand.set_mode("23#1", CLIMATE_MODE_AUTO)) == str( + OWNHeatingCommand.set_central_mode("#23#1", CLIMATE_MODE_AUTO) + ) + + +def test_set_central_mode_default_is_the_central_unit() -> None: + assert str(OWNHeatingCommand.set_central_mode(mode=CLIMATE_MODE_HEAT)) == "*4*1*#0##"