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#N`, `#Z`; `#Z#N` is refused); 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
18 changes: 18 additions & 0 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ 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#N`` (zone N of
# a 4-zone central unit), ``#Z`` (zone Z addressed through the central
# unit). ``#Z#N`` is not a central-unit form and is refused.
_WHO4_CENTRAL_WHERE_REGEX = re.compile(r"#(?:0#[0-9]+|[0-9]+)")


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 +1088,19 @@ 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#N``, ``#Z``):
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##``. ``*4*1*Z##`` and ``*4*303*Z##`` are
forwarded by some gateways, but as zone commands, not central ones.
"""
if not _WHO4_CENTRAL_WHERE_REGEX.fullmatch(str(where)):
raise ValueError(
f"Central unit mode needs a central unit address such as '#0': {where}"
)
mode_map = {
CLIMATE_MODE_OFF: 303,
CLIMATE_MODE_HEAT: 1,
Expand Down
43 changes: 43 additions & 0 deletions tests/test_who4_central_mode_address.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""``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\n", "#\u0661", "#1#2", "#00#1", "#0#"],
)
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##"), ("#23", "*4*311*#23##")],
)
def test_set_central_mode_keeps_central_addresses(where: str, frame: str) -> None:
assert str(OWNHeatingCommand.set_central_mode(where, CLIMATE_MODE_AUTO)) == frame


def test_set_central_mode_default_is_the_central_unit() -> None:
assert str(OWNHeatingCommand.set_central_mode(mode=CLIMATE_MODE_HEAT)) == "*4*1*#0##"
Loading