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 @@ -17,6 +17,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`), and `AsyncSerialTransport.send` refuse it before anything is written, with the contract above and `reason="destructive_frame"`; `OWNCommandSession.send_to_gateway` refuses it before `connect()` (no socket, no handshake), logs it at ERROR and answers `None`, or raises `OWNProtocolError` with `raise_on_error=True` (the session it would have used is discarded, so no `last_error` is observable on that path). 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 central-unit builders (`OWNHeatingCommand.set_central_mode`, `set_central_temperature`, `set_central_program`, `set_central_scenario`, `set_central_weekend`, `set_central_holiday`, `set_holiday_end_date`, `set_holiday_end_time`, `set_timed_manual_end_time`)**: one shared `_validate_central_where` guard raises `ValueError` unless `where` is the central unit itself: `#0` (3550, 99 zones) or `#0#C` with `C` the 4695 unit's configured zone 1..99 or `01`..`99` (the forms MyHOME and the golden corpus use; whitespace is stripped, the stripped value is quoted). A bare zone (`*4*3101*1##`, `*#4*1*#30*...##` used to be built), a zone through the central unit (`#Z`, `#Z#C`: libqtdevices `ControlledProbeDevice` addresses, bus command byte `C2`, built by `set_mode`; the error names it), `#00`, `#0#0`, `#0#00` and three-digit values are 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
59 changes: 59 additions & 0 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,39 @@ 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`` (3550 99-zone central unit) or ``#0#C`` (4695
# 4-zone central unit ``C``, 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``), the two forms MyHOME
# and the golden corpus use. ``#Z`` (zone 1..99) and ``#Z#C`` (zone Z under
# 4-zone central unit C) are zone / probe addresses, not central-unit ones:
# libqtdevices ``ControlledProbeDevice`` sends them with bus command byte
# ``C2`` (``*4*311*#23#1##`` -> ``D1 01 03 02 C2 17 00 00``), whereas the
# central unit (``ThermalDevice``) is command byte ``C1``; ``set_mode`` builds
# them (see ``_zone_address_and_name``). ``#00``, ``#0#0`` and ``#0#00`` name
# no 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]?))?", re.ASCII)


def _validate_central_where(where: object, purpose: str = "Central unit command") -> str:
"""Return ``where`` stripped when it addresses a central unit, else raise.

Shared by every ``OWNHeatingCommand`` central-unit builder (the ones that
default to ``where="#0"``): ``#0`` and ``#0#C`` are accepted, whitespace
around them is stripped; a bare zone (``1``), a zone through the central
unit (``#1``, ``#23#1``, which ``set_mode`` builds), ``#00``, ``#0#0``,
three-digit values and non-strings raise ``ValueError`` naming
``purpose`` and quoting the stripped value.
"""
text = str(where).strip() if where is not None else ""
if not _WHO4_CENTRAL_WHERE_REGEX.fullmatch(text):
raise ValueError(
f"{purpose} needs a central unit address such as '#0' or '#0#1' "
f"(a zone '#Z' or '#Z#C' is addressed with set_mode): {text!r}"
)
return text


# 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
Expand Down Expand Up @@ -1140,7 +1173,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`` is the central unit itself: ``#0`` (3550, 99 zones) or
``#0#C`` with ``C`` the 4695 unit's configured zone 1..99 or
``01``..``99`` (the forms MyHOME and the golden corpus use);
whitespace around it is stripped. Central-unit WHATs address the
central unit by API contract (OWNd#77 fix 3). A zone addressed
through the central unit, ``#Z`` or ``#Z#C`` (zone ``Z`` under
4-zone central unit ``C``, bus command byte ``C2`` from libqtdevices'
``ControlledProbeDevice``), is a zone mode: ``set_mode`` builds it
(``set_mode("23#1", CLIMATE_MODE_AUTO)`` -> ``*4*311*#23#1##``), and
this builder raises ``ValueError`` for it, as it does for a bare zone,
``#00``, ``#0#0``, ``#0#00`` and three-digit values (``#0#100``).
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 = _validate_central_where(where, "Central unit mode")
mode_map = {
CLIMATE_MODE_OFF: 303,
CLIMATE_MODE_HEAT: 1,
Expand All @@ -1163,6 +1214,7 @@ def set_central_temperature(
cls, where: str = "#0", temperature: float = 20.0, mode: str = CLIMATE_MODE_HEAT
) -> OWNHeatingCommand:
"""Set master setpoint temperature on Central Unit."""
where = _validate_central_where(where, "Central unit setpoint")
temp_val = float(temperature)
if not math.isfinite(temp_val):
raise ValueError("temperature must be finite")
Expand Down Expand Up @@ -1194,6 +1246,7 @@ def set_central_program(
BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setWeekProgram)
sends *4*3100+prog*WHERE##.
"""
where = _validate_central_where(where, "Central unit weekly program")
try:
prog_num = int(program)
except (ValueError, TypeError):
Expand All @@ -1218,6 +1271,7 @@ def set_central_scenario(
BTicino client (libqtdevices TS10_1_0_23 ThermalDevice99Zones::setScenario)
sends *4*3200+scen*WHERE##.
"""
where = _validate_central_where(where, "Central unit scenario")
try:
scen_num = int(scenario)
except (ValueError, TypeError):
Expand Down Expand Up @@ -1275,6 +1329,7 @@ def set_central_weekend(
BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setWeekendDateTime)
sends *4*315#3100+prog*WHERE##.
"""
where = _validate_central_where(where, "Central unit weekend mode")
try:
prog_num = int(program)
except (ValueError, TypeError):
Expand All @@ -1299,6 +1354,7 @@ def set_central_holiday(
BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setHolidayDateTime)
sends *4*33000+days#3100+prog*WHERE##.
"""
where = _validate_central_where(where, "Central unit holiday mode")
try:
d = int(days)
except (ValueError, TypeError):
Expand Down Expand Up @@ -1330,6 +1386,7 @@ def set_holiday_end_date(
BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setHolidayEndDate)
sends *#4*WHERE*#30*DD*MM*YYYY##.
"""
where = _validate_central_where(where, "Central unit holiday end date")
try:
d, m, y = int(day), int(month), int(year)
except (ValueError, TypeError):
Expand All @@ -1351,6 +1408,7 @@ def set_holiday_end_time(
BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setHolidayEndTime)
sends *#4*WHERE*#31*HH*MM##.
"""
where = _validate_central_where(where, "Central unit holiday end time")
try:
h, m = int(hour), int(minute)
except (ValueError, TypeError):
Expand All @@ -1372,6 +1430,7 @@ def set_timed_manual_end_time(
BTicino client (libqtdevices TS10_1_0_23 ThermalDevice4Zones::setEndTime)
sends *#4*WHERE*#32*HH*MM##.
"""
where = _validate_central_where(where, "Central unit timed manual end time")
try:
h, m = int(hour), int(minute)
except (ValueError, TypeError):
Expand Down
142 changes: 142 additions & 0 deletions tests/test_who4_central_mode_address.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""``set_central_mode`` only on the central unit's own address.

Central-unit WHATs address the central unit by API contract (OWNd#77 fix 3):
``#0`` (3550, 99 zones) or ``#0#C`` (4695, ``C`` 1..99 / ``01``..``99``), the
forms MyHOME and the golden corpus use. 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##``. ``#Z`` and ``#Z#C`` are zone /
probe addresses (libqtdevices ``ControlledProbeDevice``, bus command byte
``C2``) that ``set_mode`` builds; ``set_central_mode`` refuses them.
"""

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 through the central unit, ``#Z`` and ``#Z#C`` (zone Z under
# 4-zone central unit C): ``set_mode`` builds them, see below.
"#1",
"#01",
"#2",
"#23",
"#99",
"#23#1",
"#1#2",
"#23#12",
# 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",
"#0#1#2",
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``: the 3550 99-zone central unit (golden corpus
# ``thermo.cmd.central.mode.*.cu99``).
("#0", "*4*311*#0##"),
# ``#0#C``: the 4695 4-zone central unit ``C``, 1..99 or 01..99
# (MyHOME_Suite ``#0#[ZA][ZB]``, MHCatalogue 4695 ``ZAZB_CENTRAL``,
# golden corpus ``#0#1``).
("#0#1", "*4*311*#0#1##"),
("#0#4", "*4*311*#0#4##"),
("#0#5", "*4*311*#0#5##"),
("#0#05", "*4*311*#0#05##"),
("#0#12", "*4*311*#0#12##"),
("#0#99", "*4*311*#0#99##"),
# Surrounding whitespace is stripped, as ``actuators_status`` does.
(" #0 ", "*4*311*#0##"),
("#0\n", "*4*311*#0##"),
(" #0#1\t", "*4*311*#0#1##"),
],
)
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)."
)


@pytest.mark.parametrize(
("zone", "frame"),
[
# ``#Z#C``: zone 23 under 4-zone central unit 1, the probe address
# libqtdevices ``ControlledProbeDevice`` sends with bus command byte
# ``C2`` (``D1 01 03 02 C2 17 00 00``, zone ``0x17`` = 23).
("23#1", "*4*311*#23#1##"),
("23", "*4*311*#23##"),
(1, "*4*311*#1##"),
],
)
def test_set_central_mode_refuses_what_set_mode_builds(zone: str | int, frame: str) -> None:
"""A zone through the central unit is ``set_mode``'s address, not this one's."""
zone_command = OWNHeatingCommand.set_mode(zone, CLIMATE_MODE_AUTO)
assert zone_command is not None
assert str(zone_command) == frame
zone_where = frame.split("*")[3]
assert zone_where.startswith("#")

with pytest.raises(ValueError, match="set_mode") as excinfo:
OWNHeatingCommand.set_central_mode(zone_where, CLIMATE_MODE_AUTO)
assert repr(zone_where) in str(excinfo.value)
assert "Central unit mode needs a central unit address" in str(excinfo.value)


def test_set_central_mode_error_quotes_the_stripped_address() -> None:
with pytest.raises(ValueError, match="central unit") as excinfo:
OWNHeatingCommand.set_central_mode(" 1 ", CLIMATE_MODE_AUTO)
assert str(excinfo.value).endswith(": '1'")


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
Loading