From 0b298273de547f7dcfb7874182c6630ad3449984 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 18:30:33 +0300 Subject: [PATCH 1/3] fix(heating): set_central_mode requires a central-unit address Central-unit WHATs (311, 1, 303, ...) address the central unit by API contract (OWNd#77 fix 3): `where` must be `#0`, `#0#N` or `#Z`; `#Z#N` and plain zones are refused with ValueError. 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 a live F461 2.0.11 NACKs `*4*311*2##` while it ACKs `*4*311*#2##`. The default `#0` is unchanged. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + OWNd/message/heating.py | 18 +++++++++++ tests/test_who4_central_mode_address.py | 43 +++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 tests/test_who4_central_mode_address.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 655b70d..e0a779f 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#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 diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index a9a38f9..414c47d 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -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): @@ -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, diff --git a/tests/test_who4_central_mode_address.py b/tests/test_who4_central_mode_address.py new file mode 100644 index 0000000..5f13dab --- /dev/null +++ b/tests/test_who4_central_mode_address.py @@ -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##" From e8e20fb6959ebc9f39511f74a86cb4e018de68ce Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 18:27:39 +0200 Subject: [PATCH 2/3] fix(heating): set_central_mode accepts #Z#C and bounds the central address The guard refused `#Z#C` and its docstring said it is not a central-unit form, but `set_mode("23#1")` in the same file builds exactly `*4*311*#23#1##`, the frame libqtdevices sends, and the firmware oracle `thermo-ts10` suite shows all 12 emulated gateways forward it. The regex was also unbounded (`#999`, `#0#99`, `#01` built frames) while the sibling `_WHO4_WHERE_REGEX` (#140) is bounded, and it did not strip whitespace while `actuators_status` does. `_WHO4_CENTRAL_WHERE_REGEX` is now `#(?:0(?:#[1-4])?|[1-9][0-9]?(?:#[1-9])?)`: `#0`, `#0#1..#0#4`, `#1..#99`, `#Z#1..#Z#9`; `where` is stripped before the match and quoted in the error. Tests: `#23#1`, `#1#2`, `#0#4`, `#99`, padded `#0` accepted with their log line; `#01`, `#100`, `#999`, `#0#0`, `#0#5`, `#0#99`, `#1#0`, `#1#10`, `#1#2#3`, `None`, `True`, `-1` refused; `set_mode("23#1")` and `set_central_mode("#23#1")` build one frame. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- OWNd/message/heating.py | 27 +++++++---- tests/test_who4_central_mode_address.py | 63 +++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a779f..f3836d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +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. +- **WHO 4 (`OWNHeatingCommand.set_central_mode`)**: raises `ValueError` unless `where` is a central-unit `#` address (`#0`, `#0#N` with `N` 1..4, `#Z` with `Z` 1..99, `#Z#C` with a one-digit suffix `C` 1..9 as in libqtdevices' `*4*311*#23#1##`, the frame `set_mode("23#1")` already builds; out-of-range digits such as `#999` or `#0#99` and a leading zero such as `#01` 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 414c47d..989c3fd 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -110,9 +110,15 @@ def is_who4_dimension_22(frame: object) -> bool: # 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]+)") +# a 4-zone central unit, ``N`` 1..4), ``#Z`` (zone 1..99 addressed through +# the central unit) or ``#Z#C`` (zone Z with a one-digit probe / actuator +# suffix, addressed through the central unit: 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``). Bounded like ``_WHO4_WHERE_REGEX``: ``#999`` +# and ``#0#99`` are out of range, ``#01`` carries a leading zero. ASCII +# digits only. +_WHO4_CENTRAL_WHERE_REGEX = re.compile(r"#(?:0(?:#[1-4])?|[1-9][0-9]?(?:#[1-9])?)", re.ASCII) def who4_temperature(raw: str) -> float | None: @@ -1089,17 +1095,22 @@ def set_central_mode( 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 + ``where`` must be a central-unit address (``#0``, ``#0#N`` with ``N`` + 1..4, ``#Z`` with ``Z`` 1..99, ``#Z#C`` with a one-digit suffix + ``C`` 1..9, 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). Out-of-range digits (``#999``, + ``#0#99``) and a leading zero (``#01``) 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. """ - if not _WHO4_CENTRAL_WHERE_REGEX.fullmatch(str(where)): + 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}" + f"Central unit mode needs a central unit address such as '#0': {where!r}" ) mode_map = { CLIMATE_MODE_OFF: 303, diff --git a/tests/test_who4_central_mode_address.py b/tests/test_who4_central_mode_address.py index 5f13dab..1be322b 100644 --- a/tests/test_who4_central_mode_address.py +++ b/tests/test_who4_central_mode_address.py @@ -15,7 +15,36 @@ @pytest.mark.parametrize( "where", - ["1", "2", "0", "23#1", "", "#", "##0", "#0*22", "#a", "#0\n", "#\u0661", "#1#2", "#00#1", "#0#"], + [ + "1", + "2", + "0", + "23#1", + "", + " ", + "#", + "##0", + "#0*22", + "#a", + "#0\n1", + "#\u0661", + "#00#1", + "#0#", + # Bounded like ``_WHO4_WHERE_REGEX``: zones 1..99 without a leading + # zero, 4-zone central zones 1..4, probes 1..9. + "#01", + "#100", + "#999", + "#0#0", + "#0#5", + "#0#99", + "#1#0", + "#1#10", + "#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"): @@ -33,10 +62,38 @@ def test_set_central_mode_refuses_a_plain_zone_for_every_mode(mode: str) -> None @pytest.mark.parametrize( ("where", "frame"), - [("#0", "*4*311*#0##"), ("#0#1", "*4*311*#0#1##"), ("#23", "*4*311*#23##")], + [ + ("#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##"), + # 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: - assert str(OWNHeatingCommand.set_central_mode(where, CLIMATE_MODE_AUTO)) == frame + 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: From 406d155852869ce6bb925377ab024c2af3e9d92f Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 26 Oct 2026 21:26:28 +0200 Subject: [PATCH 3/3] fix(heating): set_central_mode selectors are 1..99, not 1..4 / 1..9 (review of #148) The previous revision bounded `#0#C` to 1..4 and `#Z#C` to 1..9, reading `#0#N` as "zone N of a 4-zone central unit". `#0#C` is the 4-zone unit's configured zone selector, 1..99 (01..99): MyHOME_Suite OPEN.db `#0#[ZA][ZB]`, MHCatalogue 4695 `ZAZB_CENTRAL` 1..99, and the oracle `thermo-central-mode` rows (`*4*0*#0#2##` -> `D1 02 ...`, a two-nibble central address byte); `#Z#C` is zone Z controlled through 4-zone central unit C, also 1..99 (BtExperience parseControlUnit4 / parseZone4). Two-digit virtual spellings (`#01`, `#0#05`) are the same `[ZA][ZB]` convention as the plain `01` and are accepted. Only `#00`, `#0#0`, `#0#00` and three-digit values are refused. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- OWNd/message/heating.py | 35 ++++++++++++++----------- tests/test_who4_central_mode_address.py | 23 +++++++++++----- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3836d9..a85b651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +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` with `N` 1..4, `#Z` with `Z` 1..99, `#Z#C` with a one-digit suffix `C` 1..9 as in libqtdevices' `*4*311*#23#1##`, the frame `set_mode("23#1")` already builds; out-of-range digits such as `#999` or `#0#99` and a leading zero such as `#01` 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. +- **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 989c3fd..f0fbb9f 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -109,16 +109,20 @@ 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, ``N`` 1..4), ``#Z`` (zone 1..99 addressed through -# the central unit) or ``#Z#C`` (zone Z with a one-digit probe / actuator -# suffix, addressed through the central unit: the form ``set_mode("23#1")`` +# 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``). Bounded like ``_WHO4_WHERE_REGEX``: ``#999`` -# and ``#0#99`` are out of range, ``#01`` carries a leading zero. ASCII -# digits only. -_WHO4_CENTRAL_WHERE_REGEX = re.compile(r"#(?:0(?:#[1-4])?|[1-9][0-9]?(?:#[1-9])?)", re.ASCII) +# ``_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: @@ -1095,13 +1099,14 @@ def set_central_mode( 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`` with ``N`` - 1..4, ``#Z`` with ``Z`` 1..99, ``#Z#C`` with a one-digit suffix - ``C`` 1..9, 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). Out-of-range digits (``#999``, - ``#0#99``) and a leading zero (``#01``) raise too. The plain zone - form ``*4*311*Z##`` is ``nack``/``silent`` on + ``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 diff --git a/tests/test_who4_central_mode_address.py b/tests/test_who4_central_mode_address.py index 1be322b..0c513e6 100644 --- a/tests/test_who4_central_mode_address.py +++ b/tests/test_who4_central_mode_address.py @@ -30,16 +30,18 @@ "#\u0661", "#00#1", "#0#", - # Bounded like ``_WHO4_WHERE_REGEX``: zones 1..99 without a leading - # zero, 4-zone central zones 1..4, probes 1..9. - "#01", + # 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#5", - "#0#99", + "#0#00", + "#0#100", + "#0#999", "#1#0", - "#1#10", + "#1#00", + "#1#100", "#1#2#3", None, True, @@ -75,6 +77,15 @@ def test_set_central_mode_refuses_a_plain_zone_for_every_mode(mode: str) -> None # 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##"),