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

Expand Down
34 changes: 34 additions & 0 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions tests/test_who4_central_mode_address.py
Original file line number Diff line number Diff line change
@@ -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##"