From 853b23d9468d13555039f7a04059520bd778487c Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:34:51 +0200 Subject: [PATCH 1/6] fix(#77): review follow-ups for fan zone, capped brightness log, WHO 25 commands Three small findings from the audit of OWNd#82: - set_fan_speed takes the zone from the first WHERE field. "#23#1" built zone 1 (the last field). libqtdevices sends the plain zone for a probe addressed #23#1: *#4*23*#11*3##. "23#1" now gives zone 23 instead of "Invalid zone address". #0 and #0#N are still refused. - set_brightness logs the level it sends: 120 logs "brightness to 100% (requested 120%)" instead of "120%". The frame is unchanged (*#1*W*#1*200*S##). - The WHO 25 command parser follows the event parser's rule from fix 9: only WHAT 31/32 (and *#25* status requests) are OWNDryContactCommand, 21..28 stay OWNCenPlusCommand, and any other numeric WHAT is a plain OWNCommand. A non-numeric WHAT stays a dry contact, as in the event parser. Co-Authored-By: Claude Opus 5.5 --- OWNd/message/cen.py | 3 ++ OWNd/message/heating.py | 9 ++-- OWNd/message/lighting.py | 8 ++-- tests/test_issue_77_firmware_findings.py | 53 ++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/OWNd/message/cen.py b/OWNd/message/cen.py index 70f01c3..2dfeb71 100644 --- a/OWNd/message/cen.py +++ b/OWNd/message/cen.py @@ -296,6 +296,9 @@ def _parse_who25_command(data: str) -> OWNCommand: what_code = None if what_code is not None and 21 <= what_code <= 28: return OWNCenPlusCommand(data) + # Same rule as _parse_who25_event: only WHAT 31/32 are dry contacts. + if what_code is not None and what_code not in (31, 32): + return OWNCommand(data) return OWNDryContactCommand(data) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index b5338a3..d3580e4 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -690,12 +690,11 @@ def set_fan_speed( raise ValueError( f"Fan speed cannot be set on central unit or general zone: {where}" ) + # The zone is the first field: a probe or actuator address such as + # ``#23#1`` or ``23#1`` still sets the fan coil of zone 23, the plain + # address libqtdevices sends (``*#4*23*#11*3##`` for probe ``#23#1``). try: - zone_number = ( - int(where_str.split("#")[-1]) - if where_str.startswith("#") - else int(where) - ) + zone_number = int(where_str.lstrip("#").split("#")[0]) except (ValueError, TypeError): raise ValueError(f"Invalid zone address: {where}") if not (1 <= zone_number <= 99): diff --git a/OWNd/message/lighting.py b/OWNd/message/lighting.py index 409ab08..0fe5472 100644 --- a/OWNd/message/lighting.py +++ b/OWNd/message/lighting.py @@ -417,12 +417,14 @@ def set_brightness( return cls.switch_off( where, transition_speed if transition_speed > 0 else None ) - command_level = min(int(_level), 100) + 100 + level = min(int(_level), 100) + command_level = level + 100 message = cls(f"*#1*{where}*#1*{command_level}*{transition_speed}##") + capped = f" (requested {_level}%)" if int(_level) > 100 else "" message._human_readable_log = ( - f"Setting light {message._where}{message._interface_log_text} brightness to {_level}% with transition speed {transition_speed}." # pylint: disable=line-too-long + f"Setting light {message._where}{message._interface_log_text} brightness to {level}%{capped} with transition speed {transition_speed}." # pylint: disable=line-too-long if transition_speed > 0 - else f"Setting light {message._where}{message._interface_log_text} brightness to {_level}%." + else f"Setting light {message._where}{message._interface_log_text} brightness to {level}%{capped}." ) return message diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index 6b98e75..6f6a19c 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -29,8 +29,10 @@ CLIMATE_MODE_COOL, CLIMATE_MODE_HEAT, CLIMATE_MODE_OFF, + OWNCenPlusCommand, OWNCENPlusEvent, OWNCommand, + OWNDryContactCommand, OWNDryContactEvent, OWNEnergyCommand, OWNEvent, @@ -143,6 +145,17 @@ def test_fix2_fan_speed_refuses_input_that_is_not_a_zone_or_speed() -> None: OWNHeatingCommand.set_fan_speed(1, "invalid") # type: ignore[arg-type] +@pytest.mark.parametrize("where", ["#23#1", "23#1", "#23#2"]) +def test_fix2_fan_speed_takes_the_zone_from_the_first_field(where: str) -> None: + """BTicino client: a probe addressed ``#23#1`` sets its fan coil as zone 23. + + libqtdevices TS10_1_0_23 ``ControlledProbeDevice::setFancoilSpeed`` writes + the plain zone, test ``sendSetFancoilSpeed``: ``*#4*23*#11*3##``. Taking the + last ``#`` field instead built zone 1 (review of OWNd#82, finding 3). + """ + assert str(OWNHeatingCommand.set_fan_speed(where, 3)) == "*#4*23*#11*3##" + + # ── Fix 3: AUTO on a standalone zone ──────────────────────────────────────── @@ -279,6 +292,22 @@ def test_fix7_brightness_never_writes_level_100(level: int) -> None: assert frame == f"*#1*31*#1*{min(level, 100) + 100}*5##" +@pytest.mark.parametrize( + ("level", "transition", "logged"), + [ + (120, 0, "brightness to 100% (requested 120%)."), + (120, 5, "brightness to 100% (requested 120%) with transition speed 5."), + (100, 0, "brightness to 100%."), + (50, 0, "brightness to 50%."), + ], +) +def test_fix7_log_shows_the_level_that_is_sent(level: int, transition: int, logged: str) -> None: + """Code: a capped level is logged as sent, with the request alongside (review of OWNd#82, finding 5).""" + command = OWNLightingCommand.set_brightness("31", level, transition) + + assert command.human_readable_log.endswith(logged) + + # ── Fix 8: interface for WHO 0 and WHO 14 ─────────────────────────────────── @@ -326,6 +355,30 @@ def test_fix9_captured_who25_messages_keep_their_class(frame: str, cls: type) -> assert isinstance(OWNEvent.parse(frame), cls) +@pytest.mark.parametrize( + ("frame", "cls"), + [ + ("*25*11#121*10##", OWNCommand), + ("*25*12*131##", OWNCommand), + ("*25*31#1*339##", OWNDryContactCommand), + ("*25*32#1*33##", OWNDryContactCommand), + ("*#25*331##", OWNDryContactCommand), + ("*25*21#1*21##", OWNCenPlusCommand), + ("*25*bad*21##", OWNDryContactCommand), + ], +) +def test_fix9_command_parser_uses_the_same_who25_rule(frame: str, cls: type) -> None: + """Code: the command parser follows the event parser (review of OWNd#82, finding 4). + + Only WHAT 31/32 (and ``*#25*`` status requests) are dry contacts; 21..28 are + CEN+; any other numeric WHAT is a plain command. A WHAT that is not a number + stays a dry contact, as in the event parser. + """ + command = OWNCommand.parse(frame) + + assert type(command) is cls + + # ── Fix 10: conformance matrix ────────────────────────────────────────────── _MATRIX = REPO_ROOT / "docs" / "protocol_conformance_matrix.md" From 6021f2a263199069fcb06a0a52bf87992270c17c Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:49:58 +0200 Subject: [PATCH 2/6] test(golden): correct fixtures that the firmware data and captures contradict Found while checking the golden corpus against the OWNd#77 firmware data (gdluck's sweeps and gateway probe, own-firmware-oracle) and the MyHOME plant traces. None of the community captures disagreed; these document- derived fixtures did. Each corrected entry names its new source, as the golden README requires. - thermo.cmd.mode.antifreeze.zone1: *4*110*1## -> *4*102*#1##, and thermo.cmd.mode.protection.zone1: *4*111*1## -> *4*302*#1##. 110 and 111 are manual and automatic heating (Encyclopedia what.md, libqtdevices WIN_MANUAL / WIN_AUTOMATIC); the firmware drops 110 and treats 111 as automatic. The Encyclopedia zone-setup table gives *4*102*#WHERE## and *4*302*#WHERE##, and libqtdevices sends 302 to #zone. - cover.cmd.up.bus.21 / cover.cmd.stop.bus.21: interface 1 -> 01. The firmware refuses a one-digit interface (au2.own) and the Encyclopedia WHO 2 grammar is INTERFACE = [0-1][1-9]. - dry_contact.event.on.15 -> dry_contact.event.on.339, the H4890 capture *25*31#1*339## (MyHOME#466): every captured dry contact has a WHERE that starts with 3, as in the firmware's bt_luci rule. - thermo.cmd.central.status.cu99 is now the F454 capture *#4*#0## (status() builder, MyHOME#629). The old *#4*#0*14## stays as thermo.cmd.central.status.cu99.dim14_legacy so central_status() keeps a parity check, with the note that gateways refuse it. Mirrors MyHOME's golden copy. - cen.event.extended_press.btn1.0001 keeps its frame but notes the dispute (the spec allows 0001, bt_luci refuses it). New capture cen.event.extended_press.btn3.0512 (*15*03#3*0512##, MH200N + MyHomeServer1, MyHOME#624) with OWNCenCommand builder parity. SOURCE.yaml lists the three new captures; README and corpus.json follow (117 fixtures). Co-Authored-By: Claude Opus 5.5 --- tests/golden/README.md | 8 +- tests/golden/SOURCE.yaml | 18 +++++ tests/golden/corpus.json | 95 ++++++++++++++++------- tests/golden/frames/who02_automation.yaml | 20 ++--- tests/golden/frames/who04_thermo.yaml | 37 ++++++--- tests/golden/frames/who15_cen.yaml | 17 +++- tests/golden/frames/who25_cen_plus.yaml | 10 +-- 7 files changed, 147 insertions(+), 58 deletions(-) diff --git a/tests/golden/README.md b/tests/golden/README.md index 759a830..edd1bae 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -20,19 +20,19 @@ Every other source (`legrand-spec`, `encyclopedia`, `openwebnet4j`, `public-read - A `builder:` block proves the builder matches the fixture, not that either is right. When both are written in the same change from the same reading, the test only compares the code with itself. Prefer builder parity against a capture or an independent source such as `openwebnet4j`. - When a capture arrives for a frame that so far exists only as a spec-derived fixture, add the capture and keep the spec entry only if it agrees. -## Supported Subsystems Catalog (105 Fixtures) +## Supported Subsystems Catalog (117 Fixtures) - **Signaling (`who00_signaling.yaml`)**: Gateway ACK (`*#*1##`) and NACK (`*#*0##`). - **WHO=0 Scenarios (`who00_scenario.yaml`)**: Basic scenario execution and stop. - **WHO=1 Lighting (`who01_lighting.yaml`)**: Point-to-point ON/OFF, status requests, local bus routing (`0311#4#01`), group broadcast, speed transitions (`*1*1#5*12##`), and dimension writes. -- **WHO=2 Automation (`who02_automation.yaml`)**: Shutter UP/DOWN/STOP, private bus routing (`21#4#1`), absolute position percentages, and slat tilt angles. Records tagged `scope` pin OWNd's general / area / group classification; an MH201 capture (MyHOME#433) adds an area-1 stop echo (`*2*0*1##`) and the per-actuator end-of-run stop that closes a general UP: a scope command never gets a stop of its own (WHO_2.pdf §3.0.1). A group run from the MyHOME MH201 plant fixture (`*2*0*#1##`, `*2*2*#1##`) shows the same rule for groups, with each position-reporting actuator also echoing the command right away. +- **WHO=2 Automation (`who02_automation.yaml`)**: Shutter UP/DOWN/STOP, local bus routing (`21#4#01`; the firmware refuses a one-digit interface), absolute position percentages, and slat tilt angles. Records tagged `scope` pin OWNd's general / area / group classification; an MH201 capture (MyHOME#433) adds an area-1 stop echo (`*2*0*1##`) and the per-actuator end-of-run stop that closes a general UP: a scope command never gets a stop of its own (WHO_2.pdf §3.0.1). A group run from the MyHOME MH201 plant fixture (`*2*0*#1##`, `*2*2*#1##`) shows the same rule for groups, with each position-reporting actuator also echoing the command right away. - **WHO=4 Thermoregulation (`who04_thermo.yaml`)**: Measured temperature queries (`21.5°C`), setpoint writes, Antifreeze/Protection modes, and negative temperature probe status (`-4.8°C`). Two MyHomeServer1 + Home+Control plants (MyHOME#429) add dimension 7, which carries the zone state and setpoint on those plants and is not in `WHO_4 2.pdf`: cooling and heating setpoints, protection (the zone is OFF: `*4*202*Z##` in cooling, `*4*102*Z##` antifreeze at 7.0 °C in heating), the 35.0 °C protection value sent just before a real setpoint, MyHomeServer1's `*#4*Z*#7*...##` program writes, and the dimension 5 re-assert. - **WHO=5 Burglar Alarm (`who05_alarm.yaml`)**: Status requests and silent alarm events across zones and central units. - **WHO=9 Auxiliary (`who09_auxiliary.yaml`)**: Activation and deactivation of AUX relay channels. - **WHO=13 Gateway Management (`who13_gateway.yaml`)**: Firmware versions and gateway internal datetime responses. -- **WHO=15 CEN Pushbuttons (`who15_cen.yaml`)**: Pressure, short release, long release and extended pressure (`*15*BUTTON[#1|#2|#3]*WHERE##`). +- **WHO=15 CEN Pushbuttons (`who15_cen.yaml`)**: Pressure, short release, long release and extended pressure (`*15*BUTTON[#1|#2|#3]*WHERE##`), with a four-digit source address captured on an MH200N + MyHomeServer1 bus (`0512`, MyHOME#624). - **WHO=18 Energy Management (`who18_energy.yaml`)**: Instantaneous active power and cumulative energy totalizers. -- **WHO=25 CEN+ / Dry Contacts (`who25_cen_plus.yaml`)**: CEN+ short press, hold start, hold repeat and release (`*25*21..24#PUSHBUTTON*2OBJECT##`, captured on an MH201) and a dry-contact event (`*25*31#1*WHERE##`). +- **WHO=25 CEN+ / Dry Contacts (`who25_cen_plus.yaml`)**: CEN+ short press, hold start, hold repeat and release (`*25*21..24#PUSHBUTTON*2OBJECT##`, captured on an MH201) and a dry-contact event captured on an H4890 (`*25*31#1*339##`; on the bus, dry-contact WHEREs start with `3`). ## Directory Structure diff --git a/tests/golden/SOURCE.yaml b/tests/golden/SOURCE.yaml index 8e833ed..0f411fc 100644 --- a/tests/golden/SOURCE.yaml +++ b/tests/golden/SOURCE.yaml @@ -127,6 +127,24 @@ authorities: date: "2026-10-01" gateway: "Legrand F455 Basic Gateway, firmware 1.0.86" devices: "Seven lights/dimmers (addresses 11-17), physical pushbuttons with command translations (1000#0/1) and dimmer telemetry reporting OFF in answer to dimension 1 query (*#1*13*4*100*2##)" + - name: "Physical Bus Capture (BTicino H4890, WHO 25 dry contacts)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-5846053726" + author: "@nicolacavallo84" + date: "2026-09-26" + gateway: "BTicino H4890 touch screen, firmware 4.0.15" + devices: "Dry contacts reported as *25*31#1*33N## / *25*32#1*33N## (WHERE 33, 331-339)" + - name: "Physical Bus Capture (BTicino F454, WHO 4 99-zone central unit)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/629#issuecomment-5993412070" + author: "@ricdijk" + date: "2026-10-05" + gateway: "BTicino F454, firmware 1.0.34" + devices: "99-zone central unit #0 with zones 1-7 and 11-15: *#4*#0## answered within 0.13 s (*4*202*#0##, *4*21*#0##, *4*22*#0##, *4*24*#0##); *#4*#0*14## not answered" + - name: "Physical Bus Capture (MH200N + MyHomeServer1 on one bus, WHO 15 four-digit CEN address)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/624#issuecomment-6002817949" + author: "@wave68runner" + date: "2026-10-05" + gateway: "BTicino MH200N (fw 1.0 as reported over SSDP) and MyHomeServer1 on the same SCS bus" + devices: "CEN unit 0512 (A 5, PL 12): press, short release, hold (#3 repeated) and long release, emitted identically by both gateways" role: "Provides authentic on-wire bus frames and status replies captured from live physical hardware." community_capture_cen_plus: diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index d142942..a396e46 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -634,42 +634,42 @@ }, { "id": "cover.cmd.up.bus.21", - "frame": "*2*1*21#4#1##", + "frame": "*2*1*21#4#01##", "direction": "command", "who": 2, "what": 1, "where": "21", - "interface": "1", - "source": "legrand-spec", + "interface": "01", + "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Automation: UP at address 21 on private SCS bus 1", + "mcp_notes": "Automation: UP at address 21 on local bus interface 01 (Encyclopedia functional/who-2-automation/addressing.md: APL#4#INTERFACE, INTERFACE = [0-1][1-9]). Corrected from *2*1*21#4#1##: the MyHomeServer1 firmware refuses a one-digit interface (*2*1*31#4#1## NACK, *2*1*31#4#01## ACK, au2.own, OWNd#77) and so does the MH200N emulation; every interface capture in this file uses two digits", "roundtrip": true, "builder": { "class": "OWNAutomationCommand", "method": "raise_shutter", "args": [ - "21#4#1" + "21#4#01" ] }, "_file": "who02_automation.yaml" }, { "id": "cover.cmd.stop.bus.21", - "frame": "*2*0*21#4#1##", + "frame": "*2*0*21#4#01##", "direction": "command", "who": 2, "what": 0, "where": "21", - "interface": "1", - "source": "legrand-spec", + "interface": "01", + "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Automation: STOP at address 21 on private SCS bus 1", + "mcp_notes": "Automation: STOP at address 21 on local bus interface 01 (Encyclopedia who-2-automation/addressing.md, INTERFACE = [0-1][1-9]). Corrected from *2*0*21#4#1##, a one-digit interface the firmware refuses (au2.own, OWNd#77)", "roundtrip": true, "builder": { "class": "OWNAutomationCommand", "method": "stop_shutter", "args": [ - "21#4#1" + "21#4#01" ] }, "_file": "who02_automation.yaml" @@ -1160,27 +1160,27 @@ }, { "id": "thermo.cmd.mode.antifreeze.zone1", - "frame": "*4*110*1##", + "frame": "*4*102*#1##", "direction": "command", "who": 4, - "what": 110, - "where": "1", - "source": "legrand-spec", + "what": 102, + "where": "#1", + "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set Zone 1 to Antifreeze protection mode", + "mcp_notes": "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. The judge's description of 102 is not used", "roundtrip": true, "_file": "who04_thermo.yaml" }, { "id": "thermo.cmd.mode.protection.zone1", - "frame": "*4*111*1##", + "frame": "*4*302*#1##", "direction": "command", "who": 4, - "what": 111, - "where": "1", - "source": "legrand-spec", + "what": 302, + "where": "#1", + "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set Zone 1 to Thermal Protection mode", + "mcp_notes": "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). The judge's description of 302 (manual cooling setpoint) is wrong", "roundtrip": true, "_file": "who04_thermo.yaml" }, @@ -1294,6 +1294,26 @@ }, { "id": "thermo.cmd.central.status.cu99", + "frame": "*#4*#0##", + "direction": "status_request", + "who": 4, + "what": null, + "where": "#0", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Request 99-zone Central Unit plain status: an F454 (FW 1.0.34) answered within 0.13 s with *4*202*#0##, *4*21*#0##, *4*22*#0##, *4*24*#0## (MyHOME tests/fixtures/traces/issue_629/myhome_trace_F454_all_2026-10-05T11-14-00.json, MyHOME#629). Mirrors the entry in MyHOME's golden copy", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "status", + "args": [ + "#0" + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.cmd.central.status.cu99.dim14_legacy", "frame": "*#4*#0*14##", "direction": "dimension_request", "who": 4, @@ -1303,7 +1323,7 @@ "dimension_values": [], "source": "legrand-spec", "mcp_valid": true, - "mcp_notes": "Request 99-zone Central Unit status", + "mcp_notes": "Request 99-zone Central Unit status on dimension 14, what central_status() builds. Gateways refuse it: an F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (gdluck's gateway probe, OWNd#77) answer *#*0##. Kept so builder parity still covers central_status(); poll the central unit with status('#0') instead. Mirrors the entry in MyHOME's golden copy", "roundtrip": true, "builder": { "class": "OWNHeatingCommand", @@ -1874,8 +1894,29 @@ "where": "0001", "source": "openwebnet4j", "mcp_valid": true, - "mcp_notes": "CEN: Button 1 extended pressure on module 0001", + "mcp_notes": "CEN: Button 1 extended pressure on module 0001. Disputed: the Encyclopedia CEN table allows [00][01-15] (functional/who-15-cen/README.md), but the MyHomeServer1 firmware refuses WHERE 0001 (*15*01*0001## produces no bus frame, au1.tsv, OWNd#77; four digits only when A or PL is 10 or more). No capture either way; see cen.event.extended_press.btn3.0512 for a captured four-digit address", + "roundtrip": true, + "_file": "who15_cen.yaml" + }, + { + "id": "cen.event.extended_press.btn3.0512", + "frame": "*15*03#3*0512##", + "direction": "event", + "who": 15, + "what": "03#3", + "where": "0512", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "CEN: extended pressure on button 3 of source 0512 (A 5, PL 12: four digits because PL is 10 or more). Captured on an MH200N and a MyHomeServer1 on the same bus, which emit the same WHERE with its leading zero (MyHOME tests/fixtures/traces/issue_624/, MyHOME#624). The full press: *15*03*0512##, *15*03#3*0512## (repeated while held), *15*03#2*0512##", "roundtrip": true, + "builder": { + "class": "OWNCenCommand", + "method": "start_long_press", + "args": [ + "0512", + 3 + ] + }, "_file": "who15_cen.yaml" }, { @@ -2039,15 +2080,15 @@ "_file": "who25_cen_plus.yaml" }, { - "id": "dry_contact.event.on.15", - "frame": "*25*31#1*15##", + "id": "dry_contact.event.on.339", + "frame": "*25*31#1*339##", "direction": "event", "who": 25, "what": "31#1", - "where": "15", - "source": "encyclopedia", + "where": "339", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Dry contact 15 ON / IR detection; #1 marks an event, #0 a state reply (Encyclopedia functional/who-25-transversal/dry-contact-ir.md @c57dcec)", + "mcp_notes": "Dry contact ON / IR detection at WHERE 339; #1 marks an event, #0 a state reply (Encyclopedia functional/who-25-transversal/dry-contact-ir.md). Captured on an H4890 (MyHOME tests/fixtures/traces/issue_466/myhome_trace_H4890_all_2026-09-26T11-51-26.json, MyHOME#466). Replaces the Encyclopedia-derived *25*31#1*15##: on the bus every captured dry contact has a WHERE that starts with 3 (33, 331..339 on MH200N, MH202, F454 and H4890), and the firmware's bt_luci uses the same rule (gdluck's audit, OWNd#77). The judge warns that 339 is outside the documented 1..201; the captures show the bus form", "roundtrip": true, "_file": "who25_cen_plus.yaml" }, diff --git a/tests/golden/frames/who02_automation.yaml b/tests/golden/frames/who02_automation.yaml index 815f38d..ee1e6e8 100644 --- a/tests/golden/frames/who02_automation.yaml +++ b/tests/golden/frames/who02_automation.yaml @@ -59,36 +59,36 @@ args: ["93"] - id: cover.cmd.up.bus.21 - frame: "*2*1*21#4#1##" + frame: "*2*1*21#4#01##" direction: command who: 2 what: 1 where: "21" - interface: "1" - source: legrand-spec + interface: "01" + source: encyclopedia mcp_valid: true - mcp_notes: "Automation: UP at address 21 on private SCS bus 1" + mcp_notes: "Automation: UP at address 21 on local bus interface 01 (Encyclopedia functional/who-2-automation/addressing.md: APL#4#INTERFACE, INTERFACE = [0-1][1-9]). Corrected from *2*1*21#4#1##: the MyHomeServer1 firmware refuses a one-digit interface (*2*1*31#4#1## NACK, *2*1*31#4#01## ACK, au2.own, OWNd#77) and so does the MH200N emulation; every interface capture in this file uses two digits" roundtrip: true builder: class: OWNAutomationCommand method: raise_shutter - args: ["21#4#1"] + args: ["21#4#01"] - id: cover.cmd.stop.bus.21 - frame: "*2*0*21#4#1##" + frame: "*2*0*21#4#01##" direction: command who: 2 what: 0 where: "21" - interface: "1" - source: legrand-spec + interface: "01" + source: encyclopedia mcp_valid: true - mcp_notes: "Automation: STOP at address 21 on private SCS bus 1" + mcp_notes: "Automation: STOP at address 21 on local bus interface 01 (Encyclopedia who-2-automation/addressing.md, INTERFACE = [0-1][1-9]). Corrected from *2*0*21#4#1##, a one-digit interface the firmware refuses (au2.own, OWNd#77)" roundtrip: true builder: class: OWNAutomationCommand method: stop_shutter - args: ["21#4#1"] + args: ["21#4#01"] - id: cover.cmd.up.gen.0 frame: "*2*1*0##" diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index 0082a4d..f39e8e5 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -41,25 +41,25 @@ args: ["1", 20.5, "heat", true] - id: thermo.cmd.mode.antifreeze.zone1 - frame: "*4*110*1##" + frame: "*4*102*#1##" direction: command who: 4 - what: 110 - where: "1" - source: legrand-spec + what: 102 + where: "#1" + source: encyclopedia mcp_valid: true - mcp_notes: "Set Zone 1 to Antifreeze protection mode" + mcp_notes: "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. The judge's description of 102 is not used" roundtrip: true - id: thermo.cmd.mode.protection.zone1 - frame: "*4*111*1##" + frame: "*4*302*#1##" direction: command who: 4 - what: 111 - where: "1" - source: legrand-spec + what: 302 + where: "#1" + source: encyclopedia mcp_valid: true - mcp_notes: "Set Zone 1 to Thermal Protection mode" + mcp_notes: "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). The judge's description of 302 (manual cooling setpoint) is wrong" roundtrip: true - id: thermo.resp.probe.status.neg_temp @@ -138,6 +138,21 @@ args: ["#0", 20.5, "heat"] - id: thermo.cmd.central.status.cu99 + frame: "*#4*#0##" + direction: status_request + who: 4 + what: null + where: "#0" + source: community-plant-capture + mcp_valid: true + mcp_notes: "Request 99-zone Central Unit plain status: an F454 (FW 1.0.34) answered within 0.13 s with *4*202*#0##, *4*21*#0##, *4*22*#0##, *4*24*#0## (MyHOME tests/fixtures/traces/issue_629/myhome_trace_F454_all_2026-10-05T11-14-00.json, MyHOME#629). Mirrors the entry in MyHOME's golden copy" + roundtrip: true + builder: + class: OWNHeatingCommand + method: status + args: ["#0"] + +- id: thermo.cmd.central.status.cu99.dim14_legacy frame: "*#4*#0*14##" direction: dimension_request who: 4 @@ -147,7 +162,7 @@ dimension_values: [] source: legrand-spec mcp_valid: true - mcp_notes: "Request 99-zone Central Unit status" + mcp_notes: "Request 99-zone Central Unit status on dimension 14, what central_status() builds. Gateways refuse it: an F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (gdluck's gateway probe, OWNd#77) answer *#*0##. Kept so builder parity still covers central_status(); poll the central unit with status('#0') instead. Mirrors the entry in MyHOME's golden copy" roundtrip: true builder: class: OWNHeatingCommand diff --git a/tests/golden/frames/who15_cen.yaml b/tests/golden/frames/who15_cen.yaml index 089b3b3..202d59d 100644 --- a/tests/golden/frames/who15_cen.yaml +++ b/tests/golden/frames/who15_cen.yaml @@ -66,5 +66,20 @@ where: "0001" source: openwebnet4j mcp_valid: true - mcp_notes: "CEN: Button 1 extended pressure on module 0001" + mcp_notes: "CEN: Button 1 extended pressure on module 0001. Disputed: the Encyclopedia CEN table allows [00][01-15] (functional/who-15-cen/README.md), but the MyHomeServer1 firmware refuses WHERE 0001 (*15*01*0001## produces no bus frame, au1.tsv, OWNd#77; four digits only when A or PL is 10 or more). No capture either way; see cen.event.extended_press.btn3.0512 for a captured four-digit address" roundtrip: true + +- id: cen.event.extended_press.btn3.0512 + frame: "*15*03#3*0512##" + direction: event + who: 15 + what: "03#3" + where: "0512" + source: community-plant-capture + mcp_valid: true + mcp_notes: "CEN: extended pressure on button 3 of source 0512 (A 5, PL 12: four digits because PL is 10 or more). Captured on an MH200N and a MyHomeServer1 on the same bus, which emit the same WHERE with its leading zero (MyHOME tests/fixtures/traces/issue_624/, MyHOME#624). The full press: *15*03*0512##, *15*03#3*0512## (repeated while held), *15*03#2*0512##" + roundtrip: true + builder: + class: OWNCenCommand + method: start_long_press + args: ["0512", 3] diff --git a/tests/golden/frames/who25_cen_plus.yaml b/tests/golden/frames/who25_cen_plus.yaml index be134d1..a1f0b32 100644 --- a/tests/golden/frames/who25_cen_plus.yaml +++ b/tests/golden/frames/who25_cen_plus.yaml @@ -58,15 +58,15 @@ method: release args: ["21", 1] -- id: dry_contact.event.on.15 - frame: "*25*31#1*15##" +- id: dry_contact.event.on.339 + frame: "*25*31#1*339##" direction: event who: 25 what: "31#1" - where: "15" - source: encyclopedia + where: "339" + source: community-plant-capture mcp_valid: true - mcp_notes: "Dry contact 15 ON / IR detection; #1 marks an event, #0 a state reply (Encyclopedia functional/who-25-transversal/dry-contact-ir.md @c57dcec)" + mcp_notes: "Dry contact ON / IR detection at WHERE 339; #1 marks an event, #0 a state reply (Encyclopedia functional/who-25-transversal/dry-contact-ir.md). Captured on an H4890 (MyHOME tests/fixtures/traces/issue_466/myhome_trace_H4890_all_2026-09-26T11-51-26.json, MyHOME#466). Replaces the Encyclopedia-derived *25*31#1*15##: on the bus every captured dry contact has a WHERE that starts with 3 (33, 331..339 on MH200N, MH202, F454 and H4890), and the firmware's bt_luci uses the same rule (gdluck's audit, OWNd#77). The judge warns that 339 is outside the documented 1..201; the captures show the bus form" roundtrip: true - id: cen_plus.event.start_long_press.btn2.22047 From a8614c24db2fc662c57762a1495f3a5667640717 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:08:31 +0200 Subject: [PATCH 3/6] fix(#77): tie zone protection fixtures to a builder, deprecate central_status, share zone parsing Review of #83: - thermo.cmd.mode.antifreeze.zone1 / .protection.zone1 now have builder blocks: set_central_mode("#1", "antifreeze") and ("#1", "protection") build *4*102*#1## and *4*302*#1##. set_mode only builds 303 and 311, so without these nothing pinned what a caller gets for antifreeze or protection. - central_status() raises a DeprecationWarning pointing to status(where). It still builds *#4*WHERE*14## for existing callers, which an F454 (MyHOME#629) and a real MyHomeServer1 refuse; status("#0") is the poll the F454 answers. thermo.cmd.central.status.cu99.dim14_legacy loses its builder block, so the corpus no longer parity-tests a deprecated method, and its note says roundtrip only means OWNd re-serializes it exactly. - set_mode and set_temperature take the zone from the first WHERE field, through the same _zone_number helper as set_fan_speed: "#23#1" was zone 1 and "23#1" raised ValueError. Co-Authored-By: Claude Opus 5.5 --- OWNd/message/heating.py | 36 ++++++++++++++++-------- tests/golden/corpus.json | 29 ++++++++++++------- tests/golden/frames/who04_thermo.yaml | 18 +++++++----- tests/test_issue_77_firmware_findings.py | 30 ++++++++++++++++++++ 4 files changed, 85 insertions(+), 28 deletions(-) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index d3580e4..3572757 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import warnings from .base import OWNCommand, OWNEvent, register_command_parser, register_event_parser from .lighting import MESSAGE_TYPE_ACTION @@ -98,6 +99,14 @@ def _zone_state_text(values: list[str]) -> str | None: return f"{context} {state}" +def _zone_number(where: str | int) -> int: + """Zone of a WHO 4 address: the first field, so ``#23#1`` and ``23#1`` are zone 23. + + libqtdevices TS10_1_0_23 addresses a probe ``#23#1`` and still writes zone 23 + (``*#4*23*#11*3##``). Callers handle the 4-zone central form ``#0#N`` first. + """ + return int(str(where).lstrip("#").split("#")[0]) + class OWNHeatingEvent(OWNEvent): def __init__(self, data: str) -> None: @@ -607,9 +616,7 @@ def set_mode( zone = str(where) zone_name = f"zone {int(str(where).split('#')[-1])}" else: - zone_number = ( - int(str(where).split("#")[-1]) if str(where).startswith("#") else int(where) - ) + zone_number = _zone_number(where) zone_name = f"zone {zone_number}" if zone_number > 0 else "general" if standalone: @@ -649,9 +656,7 @@ def set_temperature( zone = str(where) zone_name = f"zone {int(str(where).split('#')[-1])}" else: - zone_number = ( - int(str(where).split("#")[-1]) if str(where).startswith("#") else int(where) - ) + zone_number = _zone_number(where) zone_name = f"zone {zone_number}" if zone_number > 0 else "general" if standalone: @@ -690,11 +695,8 @@ def set_fan_speed( raise ValueError( f"Fan speed cannot be set on central unit or general zone: {where}" ) - # The zone is the first field: a probe or actuator address such as - # ``#23#1`` or ``23#1`` still sets the fan coil of zone 23, the plain - # address libqtdevices sends (``*#4*23*#11*3##`` for probe ``#23#1``). try: - zone_number = int(where_str.lstrip("#").split("#")[0]) + zone_number = _zone_number(where_str) except (ValueError, TypeError): raise ValueError(f"Invalid zone address: {where}") if not (1 <= zone_number <= 99): @@ -760,7 +762,19 @@ def set_central_temperature( @classmethod def central_status(cls, where: str = "#0") -> OWNHeatingCommand: - """Query Central Unit status.""" + """Deprecated: builds ``*#4*WHERE*14##``, which gateways refuse. + + An F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (OWNd#77 gateway + probe) answer ``*#*0##``; the F454 answers ``status("#0")`` + (``*#4*#0##``) within 0.13 s. Use ``status(where)`` to poll a central + unit. The frame is kept unchanged for existing callers. + """ + warnings.warn( + "OWNHeatingCommand.central_status() builds *#4*WHERE*14##, which gateways " + "refuse; use OWNHeatingCommand.status(where) instead", + DeprecationWarning, + stacklevel=2, + ) message = cls(f"*#4*{where}*14##") message._human_readable_log = f"Requesting Central Unit {where} status." return message diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index a396e46..ad2cb3e 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1167,8 +1167,16 @@ "where": "#1", "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. The judge's description of 102 is not used", + "mcp_notes": "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. The judge's description of 102 is not used. Built by set_central_mode, whose 'antifreeze' is 102: set_mode only builds 303 and 311", "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "set_central_mode", + "args": [ + "#1", + "antifreeze" + ] + }, "_file": "who04_thermo.yaml" }, { @@ -1180,8 +1188,16 @@ "where": "#1", "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). The judge's description of 302 (manual cooling setpoint) is wrong", + "mcp_notes": "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). The judge's description of 302 (manual cooling setpoint) is wrong. Built by set_central_mode, whose 'protection' is 302", "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "set_central_mode", + "args": [ + "#1", + "protection" + ] + }, "_file": "who04_thermo.yaml" }, { @@ -1323,15 +1339,8 @@ "dimension_values": [], "source": "legrand-spec", "mcp_valid": true, - "mcp_notes": "Request 99-zone Central Unit status on dimension 14, what central_status() builds. Gateways refuse it: an F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (gdluck's gateway probe, OWNd#77) answer *#*0##. Kept so builder parity still covers central_status(); poll the central unit with status('#0') instead. Mirrors the entry in MyHOME's golden copy", + "mcp_notes": "Request 99-zone Central Unit status on dimension 14, what central_status() builds. Gateways refuse it: an F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (gdluck's gateway probe, OWNd#77) answer *#*0##. central_status() is deprecated for that reason (poll with status('#0'), see thermo.cmd.central.status.cu99), so this entry has no builder block. Kept only to record the refused frame; roundtrip means OWNd parses and re-serializes it exactly, not that a gateway accepts it", "roundtrip": true, - "builder": { - "class": "OWNHeatingCommand", - "method": "central_status", - "args": [ - "#0" - ] - }, "_file": "who04_thermo.yaml" }, { diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index f39e8e5..697ff68 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -48,8 +48,12 @@ where: "#1" source: encyclopedia mcp_valid: true - mcp_notes: "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. The judge's description of 102 is not used" + mcp_notes: "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. The judge's description of 102 is not used. Built by set_central_mode, whose 'antifreeze' is 102: set_mode only builds 303 and 311" roundtrip: true + builder: + class: OWNHeatingCommand + method: set_central_mode + args: ["#1", "antifreeze"] - id: thermo.cmd.mode.protection.zone1 frame: "*4*302*#1##" @@ -59,8 +63,12 @@ where: "#1" source: encyclopedia mcp_valid: true - mcp_notes: "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). The judge's description of 302 (manual cooling setpoint) is wrong" + mcp_notes: "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). The judge's description of 302 (manual cooling setpoint) is wrong. Built by set_central_mode, whose 'protection' is 302" roundtrip: true + builder: + class: OWNHeatingCommand + method: set_central_mode + args: ["#1", "protection"] - id: thermo.resp.probe.status.neg_temp frame: "*#4*6*12*1048*3##" @@ -162,12 +170,8 @@ dimension_values: [] source: legrand-spec mcp_valid: true - mcp_notes: "Request 99-zone Central Unit status on dimension 14, what central_status() builds. Gateways refuse it: an F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (gdluck's gateway probe, OWNd#77) answer *#*0##. Kept so builder parity still covers central_status(); poll the central unit with status('#0') instead. Mirrors the entry in MyHOME's golden copy" + mcp_notes: "Request 99-zone Central Unit status on dimension 14, what central_status() builds. Gateways refuse it: an F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (gdluck's gateway probe, OWNd#77) answer *#*0##. central_status() is deprecated for that reason (poll with status('#0'), see thermo.cmd.central.status.cu99), so this entry has no builder block. Kept only to record the refused frame; roundtrip means OWNd parses and re-serializes it exactly, not that a gateway accepts it" roundtrip: true - builder: - class: OWNHeatingCommand - method: central_status - args: ["#0"] - id: thermo.cmd.central.mode.heat.cu4 frame: "*4*1*#0#1##" diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index 6f6a19c..433a80a 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -156,6 +156,36 @@ def test_fix2_fan_speed_takes_the_zone_from_the_first_field(where: str) -> None: assert str(OWNHeatingCommand.set_fan_speed(where, 3)) == "*#4*23*#11*3##" +@pytest.mark.parametrize("where", ["#23#1", "23#1", "#23"]) +def test_zone_builders_take_the_zone_from_the_first_field(where: str) -> None: + """Code: set_mode and set_temperature parse WHERE like set_fan_speed (review of #83). + + They took the last ``#`` field for an address starting with ``#`` (zone 1 for + ``#23#1``) and raised ValueError for ``23#1``. + """ + assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_OFF)) == "*4*303*#23##" + assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_OFF, standalone=True)) == "*4*303*23##" + assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_AUTO, standalone=True)) == "*4*311*#23##" + assert ( + str(OWNHeatingCommand.set_temperature(where, 21.0, CLIMATE_MODE_HEAT)) + == "*#4*#23*#14*0210*1##" + ) + + +def test_central_status_is_deprecated_and_unchanged() -> None: + """Capture + Gateway: gateways refuse ``*#4*#0*14##``; ``*#4*#0##`` is answered. + + An F454 answered ``*#4*#0##`` within 0.13 s and nothing for dimension 14 + (MyHOME#629); a real MyHomeServer1 3.1.8 refuses ``*#4*#0*14##`` (gateway + probe). central_status() keeps its frame for existing callers but warns. + """ + with pytest.warns(DeprecationWarning, match=r"use OWNHeatingCommand\.status"): + command = OWNHeatingCommand.central_status("#0") + + assert str(command) == "*#4*#0*14##" + assert str(OWNHeatingCommand.status("#0")) == "*#4*#0##" + + # ── Fix 3: AUTO on a standalone zone ──────────────────────────────────────── From 83c4756b3cb37f61bf3caa18adb69c5b59c9df58 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:56:50 +0200 Subject: [PATCH 4/6] fix(#77): address audit nits (202 thermal protection fixture, docstring, zone validation) --- OWNd/message/heating.py | 23 ++++++++++++++++++----- tests/golden/README.md | 2 +- tests/golden/corpus.json | 13 +++++++++++++ tests/golden/frames/who04_thermo.yaml | 11 +++++++++++ tests/test_issue_77_firmware_findings.py | 14 ++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 3572757..babea93 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -105,7 +105,19 @@ def _zone_number(where: str | int) -> int: libqtdevices TS10_1_0_23 addresses a probe ``#23#1`` and still writes zone 23 (``*#4*23*#11*3##``). Callers handle the 4-zone central form ``#0#N`` first. """ - return int(str(where).lstrip("#").split("#")[0]) + where_str = str(where) + if where_str.startswith("##"): + raise ValueError(f"Invalid zone address: {where}") + if where_str.startswith("#"): + where_str = where_str[1:] + part = where_str.split("#")[0] + try: + val = int(part) + if val < 0: + raise ValueError(f"Invalid zone address: {where}") + return val + except (ValueError, TypeError): + raise ValueError(f"Invalid zone address: {where}") class OWNHeatingEvent(OWNEvent): @@ -764,10 +776,11 @@ def set_central_temperature( def central_status(cls, where: str = "#0") -> OWNHeatingCommand: """Deprecated: builds ``*#4*WHERE*14##``, which gateways refuse. - An F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (OWNd#77 gateway - probe) answer ``*#*0##``; the F454 answers ``status("#0")`` - (``*#4*#0##``) within 0.13 s. Use ``status(where)`` to poll a central - unit. The frame is kept unchanged for existing callers. + A real MyHomeServer1 3.1.8 (OWNd#77 gateway probe) answers ``*#*0##`` + (NACK); on an F454 (MyHOME#629) dimension 14 silently timed out while + ``status("#0")`` (``*#4*#0##``) answered within 0.13 s. Use + ``status(where)`` to poll a central unit. The frame is kept unchanged + for existing callers. """ warnings.warn( "OWNHeatingCommand.central_status() builds *#4*WHERE*14##, which gateways " diff --git a/tests/golden/README.md b/tests/golden/README.md index edd1bae..11cecf4 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -20,7 +20,7 @@ Every other source (`legrand-spec`, `encyclopedia`, `openwebnet4j`, `public-read - A `builder:` block proves the builder matches the fixture, not that either is right. When both are written in the same change from the same reading, the test only compares the code with itself. Prefer builder parity against a capture or an independent source such as `openwebnet4j`. - When a capture arrives for a frame that so far exists only as a spec-derived fixture, add the capture and keep the spec entry only if it agrees. -## Supported Subsystems Catalog (117 Fixtures) +## Supported Subsystems Catalog (118 Fixtures) - **Signaling (`who00_signaling.yaml`)**: Gateway ACK (`*#*1##`) and NACK (`*#*0##`). - **WHO=0 Scenarios (`who00_scenario.yaml`)**: Basic scenario execution and stop. diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index ad2cb3e..8ef141c 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1200,6 +1200,19 @@ }, "_file": "who04_thermo.yaml" }, + { + "id": "thermo.cmd.mode.thermal_protection.zone1", + "frame": "*4*202*#1##", + "direction": "command", + "who": 4, + "what": 202, + "where": "#1", + "source": "encyclopedia", + "mcp_valid": true, + "mcp_notes": "Set Zone 1 (through the central unit) to Thermal Protection (cooling protection), WHAT 202 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*202*#WHERE##; WHO_4.pdf conditioning: thermal protection). No builder block: set_central_mode does not offer 202 because setting cooling protection on a central unit switches the entire plant to cooling", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, { "id": "thermo.resp.probe.status.neg_temp", "frame": "*#4*6*12*1048*3##", diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index 697ff68..d76ee40 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -70,6 +70,17 @@ method: set_central_mode args: ["#1", "protection"] +- id: thermo.cmd.mode.thermal_protection.zone1 + frame: "*4*202*#1##" + direction: command + who: 4 + what: 202 + where: "#1" + source: encyclopedia + mcp_valid: true + mcp_notes: "Set Zone 1 (through the central unit) to Thermal Protection (cooling protection), WHAT 202 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*202*#WHERE##; WHO_4.pdf conditioning: thermal protection). No builder block: set_central_mode does not offer 202 because setting cooling protection on a central unit switches the entire plant to cooling" + roundtrip: true + - id: thermo.resp.probe.status.neg_temp frame: "*#4*6*12*1048*3##" direction: dimension_response diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index 433a80a..32c845e 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -172,6 +172,20 @@ def test_zone_builders_take_the_zone_from_the_first_field(where: str) -> None: ) +@pytest.mark.parametrize( + "bad_where", + ["##23", "invalid", "-1"], +) +def test_zone_builders_refuse_invalid_addresses(bad_where: str) -> None: + """Code: set_mode, set_temperature and set_fan_speed reject malformed addresses.""" + with pytest.raises(ValueError, match="Invalid zone address"): + OWNHeatingCommand.set_mode(bad_where, CLIMATE_MODE_OFF) + with pytest.raises(ValueError, match="Invalid zone address"): + OWNHeatingCommand.set_temperature(bad_where, 21.0, CLIMATE_MODE_HEAT) + with pytest.raises(ValueError, match="Invalid zone address"): + OWNHeatingCommand.set_fan_speed(bad_where, 1) + + def test_central_status_is_deprecated_and_unchanged() -> None: """Capture + Gateway: gateways refuse ``*#4*#0*14##``; ``*#4*#0##`` is answered. From e80c3accb2050a67a3877563295a8086d2693840 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:56:18 +0200 Subject: [PATCH 5/6] fix(#77): preserve compound probe address in set_mode and set_temperature Review of #83 (fedem95): libqtdevices TS10_1_0_23 test_probe_device.cpp (line 111, ControlledProbeDevice) expects plain zone for fan speed (*#4*23*#11*3##), but retains the full #probe#central address for automatic (*4*311*#23#1##) and setpoint (*#4*#23#1*#14*0250*3##). Use _zone_address_and_name helper to retain compound addresses (#23#1, 23#1, #0#1) for set_mode and set_temperature, while keeping _zone_number extracting the plain zone for set_fan_speed. --- OWNd/message/heating.py | 81 +++++++++++++----------- tests/test_issue_77_firmware_findings.py | 43 ++++++++++--- 2 files changed, 77 insertions(+), 47 deletions(-) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index babea93..ad55c4f 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -102,22 +102,53 @@ def _zone_state_text(values: list[str]) -> str | None: def _zone_number(where: str | int) -> int: """Zone of a WHO 4 address: the first field, so ``#23#1`` and ``23#1`` are zone 23. - libqtdevices TS10_1_0_23 addresses a probe ``#23#1`` and still writes zone 23 - (``*#4*23*#11*3##``). Callers handle the 4-zone central form ``#0#N`` first. + libqtdevices TS10_1_0_23 addresses a probe ``#23#1`` and writes the plain + zone for fan speed (``*#4*23*#11*3##``, test_probe_device.cpp line 139). """ where_str = str(where) if where_str.startswith("##"): raise ValueError(f"Invalid zone address: {where}") - if where_str.startswith("#"): - where_str = where_str[1:] - part = where_str.split("#")[0] - try: - val = int(part) - if val < 0: - raise ValueError(f"Invalid zone address: {where}") - return val - except (ValueError, TypeError): + clean = where_str[1:] if where_str.startswith("#") else where_str + parts = clean.split("#") + if not parts or any(not p.isdigit() for p in parts) or len(parts) > 2: raise ValueError(f"Invalid zone address: {where}") + return int(parts[0]) + + +def _zone_address_and_name( + where: str | int, standalone: bool = False +) -> tuple[str, str]: + """Parse a WHO 4 WHERE address into (zone_str, zone_name). + + Preserves full compound addresses for central-unit controlled devices + (e.g. ``#0#1`` -> ``#0#1``, ``#23#1`` / ``23#1`` -> ``#23#1``) as expected + by central units and libqtdevices TS10_1_0_23 (test_probe_device.cpp line 111, + automatic ``*4*311*#23#1##`` and setpoint ``*#4*#23#1*#14*0250*3##``). + """ + where_str = str(where) + if where_str.startswith("##"): + raise ValueError(f"Invalid zone address: {where}") + clean = where_str[1:] if where_str.startswith("#") else where_str + parts = clean.split("#") + if not parts or any(not p.isdigit() for p in parts) or len(parts) > 2: + raise ValueError(f"Invalid zone address: {where}") + + zone_number = int(parts[0]) + if len(parts) > 1: + # Compound address: 4-zone central (#0#N) or probe under central (#Z#C / Z#C) + zone_str = f"#{clean}" + if zone_number == 0: + zone_name = f"zone {int(parts[1])}" + else: + zone_name = f"zone {zone_number}" + else: + zone_name = f"zone {zone_number}" if zone_number > 0 else "general" + if standalone: + zone_str = f"#{zone_number}" if zone_number == 0 else str(zone_number) + else: + zone_str = f"#{zone_number}" + + return zone_str, zone_name class OWNHeatingEvent(OWNEvent): @@ -622,19 +653,7 @@ def get_probe_temperature(cls, where: str | int) -> OWNHeatingCommand: def set_mode( cls, where: str | int, mode: str, standalone: bool = False ) -> OWNHeatingCommand | None: - central_local = re.compile(r"^#0#\d+$") - zone: str - if central_local.match(str(where)): - zone = str(where) - zone_name = f"zone {int(str(where).split('#')[-1])}" - else: - zone_number = _zone_number(where) - zone_name = f"zone {zone_number}" if zone_number > 0 else "general" - - if standalone: - zone = f"#{zone_number}" if zone_number == 0 else str(zone_number) - else: - zone = f"#{zone_number}" + zone, zone_name = _zone_address_and_name(where, standalone=standalone) mode_name = mode if mode == CLIMATE_MODE_OFF: @@ -662,19 +681,7 @@ def turn_off( def set_temperature( cls, where: str | int, temperature: float, mode: str, standalone: bool = False ) -> OWNHeatingCommand: - central_local = re.compile(r"^#0#\d+$") - zone: str - if central_local.match(str(where)): - zone = str(where) - zone_name = f"zone {int(str(where).split('#')[-1])}" - else: - zone_number = _zone_number(where) - zone_name = f"zone {zone_number}" if zone_number > 0 else "general" - - if standalone: - zone = f"#{zone_number}" if zone_number == 0 else str(zone_number) - else: - zone = f"#{zone_number}" + zone, zone_name = _zone_address_and_name(where, standalone=standalone) temperature = round(temperature * 2) / 2 if temperature < 5.0: diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index 32c845e..703bc20 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -156,25 +156,48 @@ def test_fix2_fan_speed_takes_the_zone_from_the_first_field(where: str) -> None: assert str(OWNHeatingCommand.set_fan_speed(where, 3)) == "*#4*23*#11*3##" -@pytest.mark.parametrize("where", ["#23#1", "23#1", "#23"]) -def test_zone_builders_take_the_zone_from_the_first_field(where: str) -> None: - """Code: set_mode and set_temperature parse WHERE like set_fan_speed (review of #83). - - They took the last ``#`` field for an address starting with ``#`` (zone 1 for - ``#23#1``) and raised ValueError for ``23#1``. +@pytest.mark.parametrize("where", ["#23#1", "23#1"]) +def test_zone_builders_preserve_compound_probe_addresses(where: str) -> None: + """BTicino client: set_mode and set_temperature preserve the full address for compound probes. + + libqtdevices TS10_1_0_23 test_probe_device.cpp (line 111, ControlledProbeDevice("23#1", ...)) + expects: + - fan speed (line 139): *#4*23*#11*3## (plain zone) + - automatic (line 132): *4*311*#23#1## (full address) + - manual setpoint (line 125): *#4*#23#1*#14*0250*3## (full address) """ - assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_OFF)) == "*4*303*#23##" - assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_OFF, standalone=True)) == "*4*303*23##" - assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_AUTO, standalone=True)) == "*4*311*#23##" + assert str(OWNHeatingCommand.set_fan_speed(where, 3)) == "*#4*23*#11*3##" + assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_OFF)) == "*4*303*#23#1##" + assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_OFF, standalone=True)) == "*4*303*#23#1##" + assert str(OWNHeatingCommand.set_mode(where, CLIMATE_MODE_AUTO, standalone=True)) == "*4*311*#23#1##" assert ( str(OWNHeatingCommand.set_temperature(where, 21.0, CLIMATE_MODE_HEAT)) + == "*#4*#23#1*#14*0210*1##" + ) + assert ( + str(OWNHeatingCommand.set_temperature(where, 21.0, CLIMATE_MODE_HEAT, standalone=True)) + == "*#4*#23#1*#14*0210*1##" + ) + + +def test_zone_builders_simple_zone_addresses() -> None: + """Simple zone addresses build standard plain or # frames.""" + assert str(OWNHeatingCommand.set_mode("#23", CLIMATE_MODE_OFF)) == "*4*303*#23##" + assert str(OWNHeatingCommand.set_mode("23", CLIMATE_MODE_OFF, standalone=True)) == "*4*303*23##" + assert str(OWNHeatingCommand.set_mode("23", CLIMATE_MODE_AUTO, standalone=True)) == "*4*311*#23##" + assert ( + str(OWNHeatingCommand.set_temperature("#23", 21.0, CLIMATE_MODE_HEAT)) == "*#4*#23*#14*0210*1##" ) + assert ( + str(OWNHeatingCommand.set_temperature("23", 21.0, CLIMATE_MODE_HEAT, standalone=True)) + == "*#4*23*#14*0210*1##" + ) @pytest.mark.parametrize( "bad_where", - ["##23", "invalid", "-1"], + ["##23", "invalid", "-1", "#23#invalid", "#23#-1", "23#1#2", ""], ) def test_zone_builders_refuse_invalid_addresses(bad_where: str) -> None: """Code: set_mode, set_temperature and set_fan_speed reject malformed addresses.""" From f2df92024ae006d145bb13921e8c532ae3e1740d Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:12:09 +0200 Subject: [PATCH 6/6] feat(heating): achieve full protocol parity with libqtdevices TS10_1_0_23 - Support protection, antifreeze, and thermal_protection in set_mode and set_protection matching ControlledProbeDevice::setProtection. - Add get_fan_speed matching ControlledProbeDevice::requestFancoilStatus. - Support external sensor parameter in get_probe_temperature matching NonControlledProbeDevice::requestStatus. - Add set_central_program, set_central_scenario, set_timed_manual, set_central_weekend, set_central_holiday, and dimensions 30/31/32 end date/time methods matching ThermalDevice. - Support timed manual (112, 212, 312), program (3101..3116), scenario (3201..3216), and holiday (33001..33255) modes and dimensions 30/31/32 in OWNHeatingEvent. - Update who04_thermo.yaml fixtures to use set_mode for protection modes. - 100.0% statement and branch coverage on heating.py. --- OWNd/message/heating.py | 363 ++++++++++++++++++++++- tests/golden/corpus.json | 18 +- tests/golden/frames/who04_thermo.yaml | 14 +- tests/test_issue_77_firmware_findings.py | 162 ++++++++++ 4 files changed, 537 insertions(+), 20 deletions(-) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index ad55c4f..a9fda25 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -183,6 +183,9 @@ def __init__(self, data: str) -> None: self._measured_temperature = None self._secondary_temperature = None self._measured_humidity = None + self._holiday_end_date: tuple[int, int, int] | None = None + self._holiday_end_time: tuple[int, int] | None = None + self._manual_timed_duration: tuple[int, int] | None = None self._is_active = None self._is_heating = None @@ -205,7 +208,7 @@ def __init__(self, data: str) -> None: f"Zone {self._zone}'s mode is set to '{self._mode_name}'" ) elif ( - self._mode in [0, 210, 211, 215] + self._mode in [0, 210, 211, 212, 215] or (self._mode >= 2101 and self._mode <= 2103) or (self._mode >= 2201 and self._mode <= 2216) ): @@ -215,7 +218,7 @@ def __init__(self, data: str) -> None: f"Zone {self._zone}'s mode is set to '{self._mode_name}'" ) elif ( - self._mode in [1, 110, 111, 115] + self._mode in [1, 110, 111, 112, 115] or (self._mode >= 1101 and self._mode <= 1103) or (self._mode >= 1201 and self._mode <= 1216) ): @@ -225,7 +228,10 @@ def __init__(self, data: str) -> None: f"Zone {self._zone}'s mode is set to '{self._mode_name}'" ) elif ( - self._mode in [310, 311, 315] + self._mode in [310, 311, 312, 315] + or (self._mode >= 3101 and self._mode <= 3116) + or (self._mode >= 3201 and self._mode <= 3216) + or (self._mode >= 33001 and self._mode <= 33255) or (self._mode >= 23001 and self._mode <= 23255) or (self._mode >= 13001 and self._mode <= 13255) ): @@ -505,6 +511,49 @@ def __init__(self, data: str) -> None: self._is_active = False self._human_readable_log = f"Zone {self._zone}'s fan is off." + elif ( + self._dimension == 30 + and self._dimension_value + and len(self._dimension_value) >= 3 + and all(v.isdigit() for v in self._dimension_value[:3]) + and self._message_type != "DIMENSION_WRITING" + ): # Holiday / weekend end date (DD*MM*YYYY) + d, m, y = ( + int(self._dimension_value[0]), + int(self._dimension_value[1]), + int(self._dimension_value[2]), + ) + self._holiday_end_date = (d, m, y) + self._human_readable_log = ( + f"Zone {self._zone}'s holiday end date is {d:02d}/{m:02d}/{y:04d}." + ) + + elif ( + self._dimension == 31 + and self._dimension_value + and len(self._dimension_value) >= 2 + and all(v.isdigit() for v in self._dimension_value[:2]) + and self._message_type != "DIMENSION_WRITING" + ): # Holiday / weekend end time (HH*MM) + h, m = int(self._dimension_value[0]), int(self._dimension_value[1]) + self._holiday_end_time = (h, m) + self._human_readable_log = ( + f"Zone {self._zone}'s holiday end time is {h:02d}:{m:02d}." + ) + + elif ( + self._dimension == 32 + and self._dimension_value + and len(self._dimension_value) >= 2 + and all(v.isdigit() for v in self._dimension_value[:2]) + and self._message_type != "DIMENSION_WRITING" + ): # Timed manual duration (HH*MM) + h, m = int(self._dimension_value[0]), int(self._dimension_value[1]) + self._manual_timed_duration = (h, m) + self._human_readable_log = ( + f"Zone {self._zone}'s timed manual duration is {h:02d}h {m:02d}m." + ) + elif self._dimension == 60: # Humidity self._type = MESSAGE_TYPE_MAIN_HUMIDITY self._measured_humidity = float(self._dimension_value[0]) @@ -608,6 +657,21 @@ def cooling_fan_speed(self) -> int | None: def cooling_fan_on(self) -> bool | None: return self._cooling_fan_on + @property + def holiday_end_date(self) -> tuple[int, int, int] | None: + """Holiday / weekend end date (day, month, year), or None.""" + return self._holiday_end_date + + @property + def holiday_end_time(self) -> tuple[int, int] | None: + """Holiday / weekend end time (hour, minute), or None.""" + return self._holiday_end_time + + @property + def manual_timed_duration(self) -> tuple[int, int] | None: + """Timed manual duration (hours, minutes), or None.""" + return self._manual_timed_duration + class OWNHeatingCommand(OWNCommand): @@ -624,6 +688,25 @@ def __init__(self, data: str) -> None: self._human_readable_log = f"Setting zone {self._where} to {text}." elif self._dimension == 5: self._human_readable_log = f"Setting zone {self._where}'s local control (dimension 5) to {self._dimension_value[0]}." # pylint: disable=line-too-long + elif self._dimension == 30 and len(self._dimension_value) >= 3: + day, month, year = ( + self._dimension_value[0], + self._dimension_value[1], + self._dimension_value[2], + ) + self._human_readable_log = ( + f"Setting zone {self._where}'s holiday end date to {day}/{month}/{year}." + ) + elif self._dimension == 31 and len(self._dimension_value) >= 2: + hour, minute = self._dimension_value[0], self._dimension_value[1] + self._human_readable_log = ( + f"Setting zone {self._where}'s holiday end time to {hour}:{minute}." + ) + elif self._dimension == 32 and len(self._dimension_value) >= 2: + hour, minute = self._dimension_value[0], self._dimension_value[1] + self._human_readable_log = ( + f"Setting zone {self._where}'s timed manual duration to {hour}h {minute}m." + ) @classmethod def status(cls, where: str | int) -> OWNHeatingCommand: @@ -644,26 +727,83 @@ def get_temperature(cls, where: str | int) -> OWNHeatingCommand: return message @classmethod - def get_probe_temperature(cls, where: str | int) -> OWNHeatingCommand: - message = cls(f"*#4*{where}*15##") - message._human_readable_log = f"Requesting probe temperature status update for {message._where}{message._interface_log_text}." + def get_probe_temperature( + cls, where: str | int, sensor: int | None = None + ) -> OWNHeatingCommand: + """Request probe temperature status update (*#4*WHERE*15## or *#4*WHERE*15#SENSOR##).""" + if sensor is not None: + try: + sensor_num = int(sensor) + except (ValueError, TypeError): + raise ValueError(f"Invalid sensor number: {sensor}") + if sensor_num < 1: + raise ValueError(f"Invalid sensor number: {sensor}") + message = cls(f"*#4*{where}*15#{sensor_num}##") + message._human_readable_log = ( + f"Requesting probe {sensor_num} temperature status update for " + f"{message._where}{message._interface_log_text}." + ) + else: + message = cls(f"*#4*{where}*15##") + message._human_readable_log = ( + f"Requesting probe temperature status update for " + f"{message._where}{message._interface_log_text}." + ) + return message + + @classmethod + def get_fan_speed(cls, where: str | int) -> OWNHeatingCommand: + """Request fan speed status update for a zone (*#4*ZONE*11##). + + BTicino client (libqtdevices TS10_1_0_23 ControlledProbeDevice::requestFancoilStatus) + sends *#4*ZONE*11## using the plain zone number. + """ + where_str = str(where) + if where_str in ("#0", "0") or where_str.startswith("#0#"): + raise ValueError( + f"Fan speed cannot be requested on central unit or general zone: {where}" + ) + try: + zone_number = _zone_number(where_str) + except (ValueError, TypeError): + raise ValueError(f"Invalid zone address: {where}") + if not (1 <= zone_number <= 99): + raise ValueError(f"Invalid zone number: {zone_number}. Zone must be 1..99") + + message = cls(f"*#4*{zone_number}*11##") + message._human_readable_log = ( + f"Requesting zone {zone_number} fan speed update{message._interface_log_text}." + ) return message @classmethod def set_mode( cls, where: str | int, mode: str, standalone: bool = False ) -> OWNHeatingCommand | None: + """Set zone operation mode. + + Supports standard off/auto as well as protection, antifreeze, and + thermal_protection matching libqtdevices ControlledProbeDevice::setProtection. + """ zone, zone_name = _zone_address_and_name(where, standalone=standalone) mode_name = mode + if mode in (CLIMATE_MODE_AUTO, "protection", "antifreeze", "thermal_protection"): + # The firmware and central unit forward these only as *4*WHAT*#Z## + # (libqtdevices ControlledProbeDevice and OWNd#77 A3), so standalone does not apply. + if not zone.startswith("#"): + zone = f"#{zone}" + if mode == CLIMATE_MODE_OFF: mode_code = 303 elif mode == CLIMATE_MODE_AUTO: - # The firmware forwards 311 only as *4*311*#Z## (OWNd#77, A3), the - # form libqtdevices sends too, so ``standalone`` does not apply. - if not zone.startswith("#"): - zone = f"#{zone}" mode_code = 311 + elif mode == "protection": + mode_code = 302 + elif mode == "antifreeze": + mode_code = 102 + elif mode == "thermal_protection": + mode_code = 202 else: return None @@ -671,6 +811,13 @@ def set_mode( message._human_readable_log = f"Setting {zone_name} mode to '{mode_name}'." return message + @classmethod + def set_protection( + cls, where: str | int, mode: str = "protection", standalone: bool = False + ) -> OWNHeatingCommand | None: + """Set zone protection mode ('protection', 'antifreeze', or 'thermal_protection').""" + return cls.set_mode(where=where, mode=mode, standalone=standalone) + @classmethod def turn_off( cls, where: str | int, standalone: bool = False @@ -779,6 +926,202 @@ def set_central_temperature( ) return message + @classmethod + def set_central_program( + cls, where: str = "#0", program: int = 1 + ) -> OWNHeatingCommand: + """Select weekly program (1..16) on Central Unit (*4*31PP*WHERE##). + + BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setWeekProgram) + sends *4*3100+prog*WHERE##. + """ + try: + prog_num = int(program) + except (ValueError, TypeError): + raise ValueError(f"Invalid program number: {program}") + if not (1 <= prog_num <= 16): + raise ValueError( + f"Invalid program number: {program}. Program must be 1..16" + ) + what = 3100 + prog_num + message = cls(f"*4*{what}*{where}##") + message._human_readable_log = ( + f"Setting Central Unit {where} weekly program to {prog_num} (code {what})." + ) + return message + + @classmethod + def set_central_scenario( + cls, where: str = "#0", scenario: int = 1 + ) -> OWNHeatingCommand: + """Select preset scenario (1..16) on 99-zone Central Unit (*4*32SS*WHERE##). + + BTicino client (libqtdevices TS10_1_0_23 ThermalDevice99Zones::setScenario) + sends *4*3200+scen*WHERE##. + """ + try: + scen_num = int(scenario) + except (ValueError, TypeError): + raise ValueError(f"Invalid scenario number: {scenario}") + if not (1 <= scen_num <= 16): + raise ValueError( + f"Invalid scenario number: {scenario}. Scenario must be 1..16" + ) + what = 3200 + scen_num + message = cls(f"*4*{what}*{where}##") + message._human_readable_log = ( + f"Setting Central Unit {where} scenario to {scen_num} (code {what})." + ) + return message + + @classmethod + def set_timed_manual( + cls, + where: str | int, + temperature: float, + hours: int = 2, + standalone: bool = False, + ) -> OWNHeatingCommand: + """Set timed manual operation temperature (*4*312#TTTT#H*WHERE##). + + BTicino client (libqtdevices TS10_1_0_23 ThermalDevice4Zones::setManualTempTimed) + sends *4*312#TTTT#H*WHERE##. + """ + zone, zone_name = _zone_address_and_name(where, standalone=standalone) + temperature = round(temperature * 2) / 2 + temperature = max(5.0, min(40.0, temperature)) + temp_code = int(temperature * 10) + try: + h = int(hours) + except (ValueError, TypeError): + raise ValueError(f"Invalid duration hours: {hours}") + if not (1 <= h <= 24): + raise ValueError(f"Invalid duration hours: {hours}. Hours must be 1..24") + + message = cls(f"*4*312#{temp_code:04d}#{h}*{zone}##") + message._human_readable_log = ( + f"Setting {zone_name} timed manual to {temperature}°C for {h} hours." + ) + return message + + @classmethod + def set_central_weekend( + cls, where: str = "#0", program: int = 1 + ) -> OWNHeatingCommand: + """Set Central Unit weekend mode to run program (1..16) (*4*315#31PP*WHERE##). + + BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setWeekendDateTime) + sends *4*315#3100+prog*WHERE##. + """ + try: + prog_num = int(program) + except (ValueError, TypeError): + raise ValueError(f"Invalid program number: {program}") + if not (1 <= prog_num <= 16): + raise ValueError( + f"Invalid program number: {program}. Program must be 1..16" + ) + prog_code = 3100 + prog_num + message = cls(f"*4*315#{prog_code}*{where}##") + message._human_readable_log = ( + f"Setting Central Unit {where} weekend mode with program {prog_num}." + ) + return message + + @classmethod + def set_central_holiday( + cls, where: str = "#0", days: int = 1, program: int = 1 + ) -> OWNHeatingCommand: + """Set Central Unit holiday mode for days (1..255) with program (1..16) (*4*33DDD#31PP*WHERE##). + + BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setHolidayDateTime) + sends *4*33000+days#3100+prog*WHERE##. + """ + try: + d = int(days) + except (ValueError, TypeError): + raise ValueError(f"Invalid holiday days: {days}") + if not (1 <= d <= 255): + raise ValueError(f"Invalid holiday days: {days}. Days must be 1..255") + try: + prog_num = int(program) + except (ValueError, TypeError): + raise ValueError(f"Invalid program number: {program}") + if not (1 <= prog_num <= 16): + raise ValueError( + f"Invalid program number: {program}. Program must be 1..16" + ) + holiday_code = 33000 + d + prog_code = 3100 + prog_num + message = cls(f"*4*{holiday_code}#{prog_code}*{where}##") + message._human_readable_log = ( + f"Setting Central Unit {where} holiday mode for {d} days with program {prog_num}." + ) + return message + + @classmethod + def set_holiday_end_date( + cls, where: str = "#0", day: int = 1, month: int = 1, year: int = 2026 + ) -> OWNHeatingCommand: + """Set holiday / weekend end date (*#4*WHERE*#30*DD*MM*YYYY##). + + BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setHolidayEndDate) + sends *#4*WHERE*#30*DD*MM*YYYY##. + """ + try: + d, m, y = int(day), int(month), int(year) + except (ValueError, TypeError): + raise ValueError(f"Invalid date: {day}/{month}/{year}") + if not (1 <= d <= 31 and 1 <= m <= 12 and 2000 <= y <= 2099): + raise ValueError(f"Invalid date values: {d}/{m}/{y}") + message = cls(f"*#4*{where}*#30*{d:02d}*{m:02d}*{y:04d}##") + message._human_readable_log = ( + f"Setting Central Unit {where} holiday end date to {d:02d}/{m:02d}/{y:04d}." + ) + return message + + @classmethod + def set_holiday_end_time( + cls, where: str = "#0", hour: int = 0, minute: int = 0 + ) -> OWNHeatingCommand: + """Set holiday / weekend end time (*#4*WHERE*#31*HH*MM##). + + BTicino client (libqtdevices TS10_1_0_23 ThermalDevice::setHolidayEndTime) + sends *#4*WHERE*#31*HH*MM##. + """ + try: + h, m = int(hour), int(minute) + except (ValueError, TypeError): + raise ValueError(f"Invalid time: {hour}:{minute}") + if not (0 <= h <= 23 and 0 <= m <= 59): + raise ValueError(f"Invalid time values: {h}:{m}") + message = cls(f"*#4*{where}*#31*{h:02d}*{m:02d}##") + message._human_readable_log = ( + f"Setting Central Unit {where} holiday end time to {h:02d}:{m:02d}." + ) + return message + + @classmethod + def set_timed_manual_end_time( + cls, where: str = "#0", hour: int = 0, minute: int = 0 + ) -> OWNHeatingCommand: + """Set timed manual duration/end time (*#4*WHERE*#32*HH*MM##). + + BTicino client (libqtdevices TS10_1_0_23 ThermalDevice4Zones::setEndTime) + sends *#4*WHERE*#32*HH*MM##. + """ + try: + h, m = int(hour), int(minute) + except (ValueError, TypeError): + raise ValueError(f"Invalid time: {hour}:{minute}") + if not (0 <= h <= 24 and 0 <= m <= 59): + raise ValueError(f"Invalid time values: {h}:{m}") + message = cls(f"*#4*{where}*#32*{h:02d}*{m:02d}##") + message._human_readable_log = ( + f"Setting Central Unit {where} timed manual duration to {h:02d}h {m:02d}m." + ) + return message + @classmethod def central_status(cls, where: str = "#0") -> OWNHeatingCommand: """Deprecated: builds ``*#4*WHERE*14##``, which gateways refuse. diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index 8ef141c..03fda2d 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1167,11 +1167,11 @@ "where": "#1", "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. The judge's description of 102 is not used. Built by set_central_mode, whose 'antifreeze' is 102: set_mode only builds 303 and 311", + "mcp_notes": "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##; libqtdevices TS10_1_0_23 PROT_ANTIFREEZE 102). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. Built by set_mode.", "roundtrip": true, "builder": { "class": "OWNHeatingCommand", - "method": "set_central_mode", + "method": "set_mode", "args": [ "#1", "antifreeze" @@ -1188,11 +1188,11 @@ "where": "#1", "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). The judge's description of 302 (manual cooling setpoint) is wrong. Built by set_central_mode, whose 'protection' is 302", + "mcp_notes": "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). Built by set_mode.", "roundtrip": true, "builder": { "class": "OWNHeatingCommand", - "method": "set_central_mode", + "method": "set_mode", "args": [ "#1", "protection" @@ -1209,8 +1209,16 @@ "where": "#1", "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set Zone 1 (through the central unit) to Thermal Protection (cooling protection), WHAT 202 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*202*#WHERE##; WHO_4.pdf conditioning: thermal protection). No builder block: set_central_mode does not offer 202 because setting cooling protection on a central unit switches the entire plant to cooling", + "mcp_notes": "Set Zone 1 (through the central unit) to Thermal Protection (cooling protection), WHAT 202 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*202*#WHERE##; WHO_4.pdf conditioning: thermal protection). Built by set_mode.", "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "set_mode", + "args": [ + "#1", + "thermal_protection" + ] + }, "_file": "who04_thermo.yaml" }, { diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index d76ee40..2c1df40 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -48,11 +48,11 @@ where: "#1" source: encyclopedia mcp_valid: true - mcp_notes: "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. The judge's description of 102 is not used. Built by set_central_mode, whose 'antifreeze' is 102: set_mode only builds 303 and 311" + mcp_notes: "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##; libqtdevices TS10_1_0_23 PROT_ANTIFREEZE 102). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. Built by set_mode." roundtrip: true builder: class: OWNHeatingCommand - method: set_central_mode + method: set_mode args: ["#1", "antifreeze"] - id: thermo.cmd.mode.protection.zone1 @@ -63,11 +63,11 @@ where: "#1" source: encyclopedia mcp_valid: true - mcp_notes: "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). The judge's description of 302 (manual cooling setpoint) is wrong. Built by set_central_mode, whose 'protection' is 302" + mcp_notes: "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). Built by set_mode." roundtrip: true builder: class: OWNHeatingCommand - method: set_central_mode + method: set_mode args: ["#1", "protection"] - id: thermo.cmd.mode.thermal_protection.zone1 @@ -78,8 +78,12 @@ where: "#1" source: encyclopedia mcp_valid: true - mcp_notes: "Set Zone 1 (through the central unit) to Thermal Protection (cooling protection), WHAT 202 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*202*#WHERE##; WHO_4.pdf conditioning: thermal protection). No builder block: set_central_mode does not offer 202 because setting cooling protection on a central unit switches the entire plant to cooling" + mcp_notes: "Set Zone 1 (through the central unit) to Thermal Protection (cooling protection), WHAT 202 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*202*#WHERE##; WHO_4.pdf conditioning: thermal protection). Built by set_mode." roundtrip: true + builder: + class: OWNHeatingCommand + method: set_mode + args: ["#1", "thermal_protection"] - id: thermo.resp.probe.status.neg_temp frame: "*#4*6*12*1048*3##" diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index 703bc20..f799176 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -491,3 +491,165 @@ def test_fix10_matrix_examples_all_parse() -> None: message = OWNMessage.parse(frame) assert message is not None, frame assert message.is_valid, frame + + +# ── Libqtdevices TS10_1_0_23 parity tests ───────────────────────────────────── + + +def test_libqtdevices_probe_device_commands() -> None: + """Test parity with NonControlledProbeDevice and ControlledProbeDevice.""" + # NonControlledProbeDevice: status request for internal probe vs external probe (sensor 1) + assert str(OWNHeatingCommand.get_probe_temperature("11")) == "*#4*11*15##" + assert str(OWNHeatingCommand.get_probe_temperature("11", sensor=1)) == "*#4*11*15#1##" + + with pytest.raises(ValueError, match="Invalid sensor number"): + OWNHeatingCommand.get_probe_temperature("11", sensor="invalid") # type: ignore[arg-type] + with pytest.raises(ValueError, match="Invalid sensor number"): + OWNHeatingCommand.get_probe_temperature("11", sensor=0) + + # ControlledProbeDevice::requestFancoilStatus: *#4*ZONE*11## + assert str(OWNHeatingCommand.get_fan_speed("23")) == "*#4*23*11##" + assert str(OWNHeatingCommand.get_fan_speed(1)) == "*#4*1*11##" + assert str(OWNHeatingCommand.get_fan_speed("#23#1")) == "*#4*23*11##" + + with pytest.raises(ValueError, match="central unit or general zone"): + OWNHeatingCommand.get_fan_speed("#0") + with pytest.raises(ValueError, match="central unit or general zone"): + OWNHeatingCommand.get_fan_speed("0") + with pytest.raises(ValueError, match="central unit or general zone"): + OWNHeatingCommand.get_fan_speed("#0#1") + with pytest.raises(ValueError, match="Invalid zone address"): + OWNHeatingCommand.get_fan_speed("invalid") + with pytest.raises(ValueError, match="Invalid zone number"): + OWNHeatingCommand.get_fan_speed(100) + + # ControlledProbeDevice::setProtection / setOff / setAutomatic + assert str(OWNHeatingCommand.set_mode("1", "protection", standalone=True)) == "*4*302*#1##" + assert str(OWNHeatingCommand.set_mode("#1", "protection")) == "*4*302*#1##" + assert str(OWNHeatingCommand.set_mode("#23#1", "protection")) == "*4*302*#23#1##" + assert str(OWNHeatingCommand.set_protection("1", "protection")) == "*4*302*#1##" + assert str(OWNHeatingCommand.set_mode("1", "antifreeze", standalone=True)) == "*4*102*#1##" + assert str(OWNHeatingCommand.set_mode("1", "thermal_protection", standalone=True)) == "*4*202*#1##" + assert OWNHeatingCommand.set_mode("1", "unknown_mode") is None + + +def test_libqtdevices_thermal_device_commands() -> None: + """Test parity with ThermalDevice (4-zone and 99-zone central units).""" + # Weekly program selection: *4*31PP*#0## + assert str(OWNHeatingCommand.set_central_program("#0", 13)) == "*4*3113*#0##" + with pytest.raises(ValueError, match="Invalid program number"): + OWNHeatingCommand.set_central_program("#0", "invalid") # type: ignore[arg-type] + with pytest.raises(ValueError, match="Program must be 1..16"): + OWNHeatingCommand.set_central_program("#0", 0) + with pytest.raises(ValueError, match="Program must be 1..16"): + OWNHeatingCommand.set_central_program("#0", 17) + + # Preset scenario selection (99-zone): *4*32SS*#0## + assert str(OWNHeatingCommand.set_central_scenario("#0", 12)) == "*4*3212*#0##" + with pytest.raises(ValueError, match="Invalid scenario number"): + OWNHeatingCommand.set_central_scenario("#0", "invalid") # type: ignore[arg-type] + with pytest.raises(ValueError, match="Scenario must be 1..16"): + OWNHeatingCommand.set_central_scenario("#0", 0) + with pytest.raises(ValueError, match="Scenario must be 1..16"): + OWNHeatingCommand.set_central_scenario("#0", 17) + + # Timed manual (4-zone): *4*312#TTTT#H*#0## + assert str(OWNHeatingCommand.set_timed_manual("#0", 20.0, hours=2)) == "*4*312#0200#2*#0##" + assert str(OWNHeatingCommand.set_timed_manual("#23#1", 21.5, hours=3)) == "*4*312#0215#3*#23#1##" + assert str(OWNHeatingCommand.set_timed_manual("1", 2.0, hours=1, standalone=True)) == "*4*312#0050#1*1##" + assert str(OWNHeatingCommand.set_timed_manual("1", 50.0, hours=1)) == "*4*312#0400#1*#1##" + with pytest.raises(ValueError, match="Invalid duration hours"): + OWNHeatingCommand.set_timed_manual("1", 20.0, hours="invalid") # type: ignore[arg-type] + with pytest.raises(ValueError, match="Hours must be 1..24"): + OWNHeatingCommand.set_timed_manual("1", 20.0, hours=0) + with pytest.raises(ValueError, match="Hours must be 1..24"): + OWNHeatingCommand.set_timed_manual("1", 20.0, hours=25) + + # Weekend mode: *4*315#31PP*#0## + assert str(OWNHeatingCommand.set_central_weekend("#0", 12)) == "*4*315#3112*#0##" + with pytest.raises(ValueError, match="Invalid program number"): + OWNHeatingCommand.set_central_weekend("#0", "invalid") # type: ignore[arg-type] + with pytest.raises(ValueError, match="Program must be 1..16"): + OWNHeatingCommand.set_central_weekend("#0", 0) + with pytest.raises(ValueError, match="Program must be 1..16"): + OWNHeatingCommand.set_central_weekend("#0", 17) + + # Holiday mode: *4*33DDD#31PP*#0## + assert str(OWNHeatingCommand.set_central_holiday("#0", days=2, program=15)) == "*4*33002#3115*#0##" + with pytest.raises(ValueError, match="Invalid holiday days"): + OWNHeatingCommand.set_central_holiday("#0", days="invalid", program=1) # type: ignore[arg-type] + with pytest.raises(ValueError, match="Days must be 1..255"): + OWNHeatingCommand.set_central_holiday("#0", days=0, program=1) + with pytest.raises(ValueError, match="Days must be 1..255"): + OWNHeatingCommand.set_central_holiday("#0", days=256, program=1) + with pytest.raises(ValueError, match="Invalid program number"): + OWNHeatingCommand.set_central_holiday("#0", days=2, program="invalid") # type: ignore[arg-type] + with pytest.raises(ValueError, match="Program must be 1..16"): + OWNHeatingCommand.set_central_holiday("#0", days=2, program=0) + with pytest.raises(ValueError, match="Program must be 1..16"): + OWNHeatingCommand.set_central_holiday("#0", days=2, program=17) + + # Dimensions 30, 31, 32 write frames + assert str(OWNHeatingCommand.set_holiday_end_date("#0", 29, 8, 2012)) == "*#4*#0*#30*29*08*2012##" + with pytest.raises(ValueError, match="Invalid date"): + OWNHeatingCommand.set_holiday_end_date("#0", "bad", 1, 2026) # type: ignore[arg-type] + with pytest.raises(ValueError, match="Invalid date values"): + OWNHeatingCommand.set_holiday_end_date("#0", 32, 1, 2026) + + assert str(OWNHeatingCommand.set_holiday_end_time("#0", 23, 8)) == "*#4*#0*#31*23*08##" + with pytest.raises(ValueError, match="Invalid time"): + OWNHeatingCommand.set_holiday_end_time("#0", "bad", 1) # type: ignore[arg-type] + with pytest.raises(ValueError, match="Invalid time values"): + OWNHeatingCommand.set_holiday_end_time("#0", 25, 0) + + assert str(OWNHeatingCommand.set_timed_manual_end_time("#0", 13, 5)) == "*#4*#0*#32*13*05##" + with pytest.raises(ValueError, match="Invalid time"): + OWNHeatingCommand.set_timed_manual_end_time("#0", "bad", 1) # type: ignore[arg-type] + with pytest.raises(ValueError, match="Invalid time values"): + OWNHeatingCommand.set_timed_manual_end_time("#0", 25, 0) + + +def test_libqtdevices_thermal_device_event_and_write_parsing() -> None: + """Test OWNHeatingEvent and OWNHeatingCommand parsing of libqtdevices frames.""" + # Holiday date event: *#4*#0*30*29*08*2012## + evt_date = OWNEvent.parse("*#4*#0*30*29*08*2012##") + assert isinstance(evt_date, OWNHeatingEvent) + assert evt_date.holiday_end_date == (29, 8, 2012) + assert "29/08/2012" in evt_date.human_readable_log + + # Holiday time event: *#4*#0*31*23*08## + evt_time = OWNEvent.parse("*#4*#0*31*23*08##") + assert isinstance(evt_time, OWNHeatingEvent) + assert evt_time.holiday_end_time == (23, 8) + assert "23:08" in evt_time.human_readable_log + + # Timed manual duration event: *#4*#0*32*24*59## + evt_dur = OWNEvent.parse("*#4*#0*32*24*59##") + assert isinstance(evt_dur, OWNHeatingEvent) + assert evt_dur.manual_timed_duration == (24, 59) + assert "24h 59m" in evt_dur.human_readable_log + + # Default event has None for holiday/duration properties + evt_plain = OWNHeatingEvent("*#4*1*0*0215##") + assert evt_plain.holiday_end_date is None + assert evt_plain.holiday_end_time is None + assert evt_plain.manual_timed_duration is None + + # Dimension writing logs in OWNHeatingCommand + cmd_date = OWNHeatingCommand("*#4*#0*#30*29*08*2012##") + assert "holiday end date to 29/08/2012" in cmd_date.human_readable_log + + cmd_time = OWNHeatingCommand("*#4*#0*#31*23*08##") + assert "holiday end time to 23:08" in cmd_time.human_readable_log + + cmd_dur = OWNHeatingCommand("*#4*#0*#32*13*05##") + assert "timed manual duration to 13h 05m" in cmd_dur.human_readable_log + + # Event mode parsing for timed manual, programs, scenarios, holiday + assert OWNHeatingEvent("*4*212*#0##").mode == CLIMATE_MODE_COOL + assert OWNHeatingEvent("*4*112*#0##").mode == CLIMATE_MODE_HEAT + assert OWNHeatingEvent("*4*312*#0##").mode == CLIMATE_MODE_AUTO + assert OWNHeatingEvent("*4*3113*#0##").mode == CLIMATE_MODE_AUTO + assert OWNHeatingEvent("*4*3212*#0##").mode == CLIMATE_MODE_AUTO + assert OWNHeatingEvent("*4*33002*#0##").mode == CLIMATE_MODE_AUTO +