From 938b98bad5d0192bcf35ca15e68ebe52c764ab5f Mon Sep 17 00:00:00 2001 From: gdluck Date: Wed, 28 Oct 2026 18:01:43 +0200 Subject: [PATCH] fix(heating): central-unit builders take #0 / #0#C only, through one shared guard `set_central_mode("1", mode)` built `*4*311*1##`, which is nack/silent on all 12 emulated gateways and on a live F461 2.0.11: central-unit WHATs address the central unit by API contract (OWNd#77 fix 3). The builder now requires a central-unit address and the contract is shared by every central-unit builder. - `_WHO4_CENTRAL_WHERE_REGEX = #0(?:#(?:0[1-9]|[1-9][0-9]?))?` (ASCII): `#0` (3550, 99 zones) or `#0#C` with C = 1..99 / 01..99 (4695, the unit's configured zone, MyHOME_Suite OPEN.db `#0#[ZA][ZB]` and MHCatalogue 4695 `ZAZB_CENTRAL`; oracle thermo-central-mode `*4*0*#0#2##` -> `D1 02`). `#Z` and `#Z#C` are zone / probe addresses (ControlledProbeDevice, bus command byte C2) handled by `set_mode`, which already builds `*4*311*#23##` and `*4*311*#23#1##`; they are refused with a ValueError that names `set_mode`. - One `_validate_central_where(where, purpose)` (strip, fullmatch, quoted error) is the first statement of all nine central-unit builders: 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. Signatures and frames are unchanged; each refuses a bare `1`, `#1`, `#23#1`, `#00`, `#0#0`, `#0#100`, None and True, and accepts `#0`, `#0#1`, `#0#12`, `#0#05` (whitespace stripped). - Tests: tests/test_who4_central_mode_address.py (49 cases, incl. "set_central_mode refuses what set_mode builds"), tests/test_who4_central_unit_where.py (every builder, 120 cases); the dimension-22 guard fixtures use `#0#4` instead of the no-longer-valid `#4`. Reviews 5642254816-era (OWNd#77), 5684988147 (#137) and the #148 review on the selector bound are folded in; squashed on master 4213cab as asked. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + OWNd/message/heating.py | 59 +++++++++ tests/test_who4_central_mode_address.py | 142 +++++++++++++++++++++ tests/test_who4_central_unit_where.py | 157 ++++++++++++++++++++++++ tests/test_who4_dimension22_guard.py | 13 +- 5 files changed, 366 insertions(+), 6 deletions(-) create mode 100644 tests/test_who4_central_mode_address.py create mode 100644 tests/test_who4_central_unit_where.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a89a8..be1a392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index e7890cc..f214663 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -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 @@ -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, @@ -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") @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): diff --git a/tests/test_who4_central_mode_address.py b/tests/test_who4_central_mode_address.py new file mode 100644 index 0000000..531fee7 --- /dev/null +++ b/tests/test_who4_central_mode_address.py @@ -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##" diff --git a/tests/test_who4_central_unit_where.py b/tests/test_who4_central_unit_where.py new file mode 100644 index 0000000..7f70c46 --- /dev/null +++ b/tests/test_who4_central_unit_where.py @@ -0,0 +1,157 @@ +"""Every central-unit builder shares one ``where`` contract. + +``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`` and +``set_timed_manual_end_time`` default to ``where="#0"`` and log "Setting +Central Unit ...": they accept the central unit itself, ``#0`` (3550) or +``#0#C`` (4695, ``C`` 1..99 / ``01``..``99``), and refuse a bare zone +(``*4*3101*1##`` used to be built), a zone through the central unit (``#Z``, +``#Z#C``: ``set_mode``'s addresses, bus command byte ``C2``), ``#00``, +``#0#0``, three digits and non-strings through ``_validate_central_where``. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from OWNd.message import CLIMATE_MODE_HEAT, OWNHeatingCommand +from OWNd.message.heating import _validate_central_where + +# (builder name, builder taking only ``where``, frame with ``{where}``, purpose) +CENTRAL_UNIT_BUILDERS: tuple[tuple[str, Callable[[str], OWNHeatingCommand], str, str], ...] = ( + ( + "set_central_mode", + lambda where: OWNHeatingCommand.set_central_mode(where, CLIMATE_MODE_HEAT), + "*4*1*{where}##", + "Central unit mode", + ), + ( + "set_central_temperature", + lambda where: OWNHeatingCommand.set_central_temperature(where, 21.0, CLIMATE_MODE_HEAT), + "*#4*{where}*#14*0210*1##", + "Central unit setpoint", + ), + ( + "set_central_program", + lambda where: OWNHeatingCommand.set_central_program(where, 1), + "*4*3101*{where}##", + "Central unit weekly program", + ), + ( + "set_central_scenario", + lambda where: OWNHeatingCommand.set_central_scenario(where, 1), + "*4*3201*{where}##", + "Central unit scenario", + ), + ( + "set_central_weekend", + lambda where: OWNHeatingCommand.set_central_weekend(where, 2), + "*4*315#3102*{where}##", + "Central unit weekend mode", + ), + ( + "set_central_holiday", + lambda where: OWNHeatingCommand.set_central_holiday(where, days=3, program=2), + "*4*33003#3102*{where}##", + "Central unit holiday mode", + ), + ( + "set_holiday_end_date", + lambda where: OWNHeatingCommand.set_holiday_end_date(where, 29, 8, 2026), + "*#4*{where}*#30*29*08*2026##", + "Central unit holiday end date", + ), + ( + "set_holiday_end_time", + lambda where: OWNHeatingCommand.set_holiday_end_time(where, 23, 8), + "*#4*{where}*#31*23*08##", + "Central unit holiday end time", + ), + ( + "set_timed_manual_end_time", + lambda where: OWNHeatingCommand.set_timed_manual_end_time(where, 13, 5), + "*#4*{where}*#32*13*05##", + "Central unit timed manual end time", + ), +) +BUILDER_IDS = [name for name, _, _, _ in CENTRAL_UNIT_BUILDERS] + +ACCEPTED_WHERES = ["#0", "#0#1", "#0#12", "#0#05"] +REFUSED_WHERES = ["1", "#1", "#23#1", "#00", "#0#0", "#0#100", None, True] + + +@pytest.mark.parametrize("where", ACCEPTED_WHERES) +@pytest.mark.parametrize(("name", "build", "template", "purpose"), CENTRAL_UNIT_BUILDERS, ids=BUILDER_IDS) +def test_central_unit_builders_accept_the_central_unit( + name: str, + build: Callable[[str], OWNHeatingCommand], + template: str, + purpose: str, + where: str, +) -> None: + command = build(where) + + assert str(command) == template.format(where=where) + assert command.is_valid + assert command.human_readable_log.startswith(f"Setting Central Unit {where} ") + + +@pytest.mark.parametrize("where", REFUSED_WHERES, ids=repr) +@pytest.mark.parametrize(("name", "build", "template", "purpose"), CENTRAL_UNIT_BUILDERS, ids=BUILDER_IDS) +def test_central_unit_builders_refuse_zones_and_junk( + name: str, + build: Callable[[str], OWNHeatingCommand], + template: str, + purpose: str, + where: str, +) -> None: + with pytest.raises(ValueError, match="central unit") as excinfo: + build(where) + + message = str(excinfo.value) + assert message.startswith(f"{purpose} needs a central unit address such as '#0' or '#0#1'") + assert "set_mode" in message + shown = "" if where is None else str(where) + assert message.endswith(f": {shown!r}") + + +@pytest.mark.parametrize(("name", "build", "template", "purpose"), CENTRAL_UNIT_BUILDERS, ids=BUILDER_IDS) +def test_central_unit_builders_strip_whitespace( + name: str, + build: Callable[[str], OWNHeatingCommand], + template: str, + purpose: str, +) -> None: + command = build(" #0#1\n") + + assert str(command) == template.format(where="#0#1") + assert "Setting Central Unit #0#1 " in command.human_readable_log + + +@pytest.mark.parametrize(("name", "build", "template", "purpose"), CENTRAL_UNIT_BUILDERS, ids=BUILDER_IDS) +def test_central_unit_builders_default_to_hash_zero( + name: str, + build: Callable[[str], OWNHeatingCommand], + template: str, + purpose: str, +) -> None: + builder = getattr(OWNHeatingCommand, name) + default_frame = str(builder()) if name != "set_central_mode" else str(builder(mode=CLIMATE_MODE_HEAT)) + assert "*#0" in default_frame + + +@pytest.mark.parametrize( + ("where", "expected"), + [("#0", "#0"), ("#0#1", "#0#1"), ("#0#99", "#0#99"), ("#0#01", "#0#01"), (" #0 ", "#0")], +) +def test_validate_central_where_returns_the_stripped_address(where: str, expected: str) -> None: + assert _validate_central_where(where) == expected + + +@pytest.mark.parametrize("where", ["1", "#1", "#23#1", "#00", "#0#0", "#0#100", "#0#1#2", b"#0", None, True, 0]) +def test_validate_central_where_default_purpose(where: object) -> None: + with pytest.raises(ValueError, match="^Central unit command needs a central unit address"): + _validate_central_where(where) diff --git a/tests/test_who4_dimension22_guard.py b/tests/test_who4_dimension22_guard.py index 546a776..d0a2565 100644 --- a/tests/test_who4_dimension22_guard.py +++ b/tests/test_who4_dimension22_guard.py @@ -91,7 +91,8 @@ "*1*1*11#*#4*2*22##", ) -# Builders whose WHERE is group / central-unit zone ``#4`` and whose first +# Builders whose WHERE is group ``#4`` (or 4-zone central unit ``#0#4``, the +# only ``#``-address the WHO 4 central-unit builders take) and whose first # value is 22: the unanchored detector used to refuse all of them. WHERE_HASH_4_BUILDERS = ( (lambda: OWNAutomationCommand.set_shutter_level("#4", 22), "*#2*#4*#11#001*22##"), @@ -104,13 +105,13 @@ (lambda: OWNSoundDiffusionCommand.set_volume("#4", 22), "*#22*#4*#1*22##"), (lambda: OWNSoundDiffusionCommand.set_preset("#4", 22), "*#22*#4*#6*22##"), ( - lambda: OWNHeatingCommand.set_holiday_end_date("#4", 22, 10, 2026), - "*#4*#4*#30*22*10*2026##", + lambda: OWNHeatingCommand.set_holiday_end_date("#0#4", 22, 10, 2026), + "*#4*#0#4*#30*22*10*2026##", ), - (lambda: OWNHeatingCommand.set_holiday_end_time("#4", 22, 30), "*#4*#4*#31*22*30##"), + (lambda: OWNHeatingCommand.set_holiday_end_time("#0#4", 22, 30), "*#4*#0#4*#31*22*30##"), ( - lambda: OWNHeatingCommand.set_timed_manual_end_time("#4", 22, 0), - "*#4*#4*#32*22*00##", + lambda: OWNHeatingCommand.set_timed_manual_end_time("#0#4", 22, 0), + "*#4*#0#4*#32*22*00##", ), )