diff --git a/OWNd/message/base.py b/OWNd/message/base.py index 62925b5..dbea1c0 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -73,7 +73,7 @@ class OWNMessage: r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*#(?P\d+)(?P(?:#\d+)*)?(?P(?:\*\d*)+)##$" # pylint: disable=line-too-long ) # *#WHO*WHERE*#DIMENSION*VAL1*VALn## _DIMENSION_REQUEST = re.compile( - r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*(?P\d+)##$" + r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*(?P\d+)(?P(?:#\d+)*)?##$" ) # *#WHO*WHERE*DIMENSION## _DIMENSION_REQUEST_REPLY = re.compile( r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*(?P\d+)(?P(?:#\d+)*)?(?P(?:\*\d*)+)##$" # pylint: disable=line-too-long @@ -146,6 +146,7 @@ def __init__(self, data: str) -> None: self._where = match.group("where") or "" self._where_param = (match.group("where_param") or "").split("#")[1:] self._dimension = int(match.group("dimension")) + self._dimension_param = (match.group("dimension_param") or "").split("#")[1:] elif match := self._DIMENSION_REQUEST_REPLY.match(self._raw): self._is_valid_message = True @@ -231,7 +232,7 @@ def interface(self) -> str | None: """The 'where' parameter corresponding to the bus interface of the subject of this message""" return ( self._where_param[1] - if self._who in [1, 2, 15] + if self._who in [0, 1, 2, 14, 15] and len(self._where_param) > 1 and self._where_param[0] == "4" else None diff --git a/OWNd/message/cen.py b/OWNd/message/cen.py index eac19cc..70f01c3 100644 --- a/OWNd/message/cen.py +++ b/OWNd/message/cen.py @@ -280,6 +280,8 @@ def _parse_who25_event(data: str) -> OWNEvent: what_code = None if what_code is not None and 21 <= what_code <= 28: return OWNCENPlusEvent(data) + if what_code is not None and what_code not in (31, 32): + return OWNEvent(data) return OWNDryContactEvent(data) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 0aaa959..b5338a3 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -339,7 +339,11 @@ def __init__(self, data: str) -> None: else: self._human_readable_log = f"Zone {self._zone}'s temperature probe is reporting a temperature of {self._secondary_temperature}°C." - elif self._dimension == 19: # Valves status + elif ( + self._dimension == 19 + and len(self._dimension_value) >= 2 + and all(self._dimension_value[:2]) + ): # Valves status self._type = MESSAGE_TYPE_ACTION self._is_cooling = self._dimension_value[0] in _valve_active_states self._is_heating = self._dimension_value[1] in _valve_active_states @@ -399,11 +403,15 @@ def __init__(self, data: str) -> None: self._is_active = False self._human_readable_log += "; heating fan is off." - elif self._dimension == 20: # Actuator status + elif ( + self._dimension == 20 + and self._dimension_value + and self._dimension_value[0] + ): # Actuator status self._type = MESSAGE_TYPE_ACTION self._is_active = self._dimension_value[0] in _actuator_active_states self._actuator = ( - self._where_param[0] if self._where_param[0] is not None else 1 + self._where_param[0] if self._where_param else "1" ) _value = int(self._dimension_value[0]) if _value == 0: @@ -613,6 +621,10 @@ def set_mode( 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 else: return None @@ -672,24 +684,32 @@ def set_temperature( def set_fan_speed( cls, where: str | int, speed: int, standalone: bool = False ) -> OWNHeatingCommand: - central_local = re.compile(r"^#0#\d+$") - if central_local.match(str(where)): - zone = str(where) - zone_name = f"zone {int(str(where).split('#')[-1])}" - else: + """Build a fan speed command; ``standalone`` is ignored (kept for compatibility).""" + where_str = str(where) + if where_str in ("#0", "0") or where_str.startswith("#0#"): + raise ValueError( + f"Fan speed cannot be set on central unit or general zone: {where}" + ) + try: zone_number = ( - int(str(where).split("#")[-1]) if str(where).startswith("#") else int(where) + int(where_str.split("#")[-1]) + if where_str.startswith("#") + else int(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}" - - speed_code = int(speed) - message = cls(f"*#4*{zone}*#11*{speed_code}##") + 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") + try: + speed_code = int(speed) + except (ValueError, TypeError): + raise ValueError(f"Invalid fan speed: {speed}") + if not (0 <= speed_code <= 3): + raise ValueError(f"Invalid fan speed: {speed_code}. Speed must be 0..3") + + message = cls(f"*#4*{zone_number}*#11*{speed_code}##") message._human_readable_log = ( - f"Setting {zone_name} fan speed to {speed_code}." + f"Setting zone {zone_number} fan speed to {speed_code}." ) return message @@ -697,14 +717,23 @@ def set_fan_speed( def set_central_mode( cls, where: str = "#0", mode: str = CLIMATE_MODE_HEAT ) -> OWNHeatingCommand: - """Set operation mode for Central Unit (3550 99-zone or 4695 4-zone).""" + """Set operation mode for Central Unit (3550 99-zone or 4695 4-zone). + + The codes are the ones BTicino's touch-screen client sends to a central + unit (libqtdevices ``thermal_device.cpp``): 303 generic off, 1 winter, + 0 summer, 302 generic protection. 102 is the winter (antifreeze) + protection; 202, the summer protection, is not offered because it + 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. + """ mode_map = { - CLIMATE_MODE_OFF: 100, - CLIMATE_MODE_HEAT: 101, - CLIMATE_MODE_COOL: 102, - CLIMATE_MODE_AUTO: 103, - "antifreeze": 110, - "protection": 111, + CLIMATE_MODE_OFF: 303, + CLIMATE_MODE_HEAT: 1, + CLIMATE_MODE_COOL: 0, + CLIMATE_MODE_AUTO: 311, + "antifreeze": 102, + "protection": 302, } mode_code = mode_map.get(mode) if mode_code is None: diff --git a/OWNd/message/lighting.py b/OWNd/message/lighting.py index f93a470..409ab08 100644 --- a/OWNd/message/lighting.py +++ b/OWNd/message/lighting.py @@ -412,8 +412,12 @@ def switch_off(cls, where: str | int, _transition: int | None = None) -> OWNLigh def set_brightness( cls, where: str | int, _level: int = 30, _transition: int = 0 ) -> OWNLightingCommand: - command_level = int(_level) + 100 - transition_speed = _transition if _transition >= 0 and _transition <= 255 else 0 + transition_speed = _transition if 0 <= _transition <= 255 else 0 + if int(_level) <= 0: + return cls.switch_off( + where, transition_speed if transition_speed > 0 else None + ) + command_level = min(int(_level), 100) + 100 message = cls(f"*#1*{where}*#1*{command_level}*{transition_speed}##") 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 diff --git a/OWNd/message/sound.py b/OWNd/message/sound.py index 78dd0e9..198c37d 100644 --- a/OWNd/message/sound.py +++ b/OWNd/message/sound.py @@ -1,4 +1,4 @@ -"""WHO 16 & 22: Sound diffusion and audio/video door entry events and commands.""" +"""WHO 16: Sound diffusion and audio/video door entry events and commands.""" from __future__ import annotations diff --git a/README.md b/README.md index 4f8f9f6..ed3a1aa 100755 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ OWNd maintains an automated test suite with strict **100.0% line coverage** (3,0 | [`OWNd/message/heating.py`](OWNd/message/heating.py) | **100%** | WHO 4: Thermoregulation, heating/cooling zone control, and temperature decoders | | [`OWNd/message/lighting.py`](OWNd/message/lighting.py) | **100%** | WHO 1: Lighting controls, dimming, RGB/HSV color, and motion/PIR sensors | | [`OWNd/message/scenario.py`](OWNd/message/scenario.py) | **100%** | WHO 0, WHO 9, WHO 17: Scenario activation, auxiliary commands, and MH200/MH202 scenes | -| [`OWNd/message/sound.py`](OWNd/message/sound.py) | **100%** | WHO 16, WHO 22: Sound system and audio/video matrix routing | +| [`OWNd/message/sound.py`](OWNd/message/sound.py) | **100%** | WHO 16: Sound system and audio/video matrix routing (WHO 22 is not implemented) | | [`OWNd/profiles.py`](OWNd/profiles.py) | **100%** | Declarative hardware gateway models (F454, MH200, MH200N, MH201, MH202, MyHomeServer1) | | [`OWNd/transport/__init__.py`](OWNd/transport/__init__.py) | **100%** | Transport subpackage exports | | [`OWNd/transport/base.py`](OWNd/transport/base.py) | **100%** | Abstract transport layer and event listener notification contracts | diff --git a/docs/protocol_conformance_matrix.md b/docs/protocol_conformance_matrix.md index a931f51..46088ca 100644 --- a/docs/protocol_conformance_matrix.md +++ b/docs/protocol_conformance_matrix.md @@ -26,22 +26,24 @@ To prevent divergence and eliminate guesswork, every protocol frame and capabili |:---:|---|---|---|:---:|:---:|:---:| | **-** | **Signaling** | Gateway ACK / NACK | `*#*1##`, `*#*0##` | Valid | Matched | **VERIFIED (100%)** | | **0** | **Scenarios** | Execute, Stop, Start | `*0*1*1##`, `*0*2*1##` | Valid | Matched | **VERIFIED (100%)** | -| **1** | **Lighting** | ON/OFF, Status, Speed Dimming, Private Bus (`#4#01`), Dimension writes | `*1*1*0311#4#01##`, `*1*1#5*12##`, `*#1*1*#1*20##` | Valid | Matched | **VERIFIED (100%)** | -| **2** | **Automation** | Shutter UP/DOWN/STOP, Bus Routing (`#4#1`), Slat Tilt, Position % | `*2*1*21#4#1##`, `*#2*1*#1*50##`, `*2*2*12##` | Valid | Matched | **VERIFIED (100%)** | +| **1** | **Lighting** | ON/OFF, Status, Speed Dimming, Private Bus (`#4#01`), Dimension writes | `*1*1*0311#4#01##`, `*1*1#5*12##`, `*#1*31*#1*150*5##` | Valid | Matched | **VERIFIED (100%)** | +| **2** | **Automation** | Shutter UP/DOWN/STOP, Bus Routing (`#4#01`), Slat Tilt, Position % | `*2*1*31#4#01##`, `*#2*31*#11#001*100##`, `*2*2*12##` | Valid | Matched | **VERIFIED (100%)** | | **4** | **Thermoregulation** | Probe queries (`21.5°C`), Setpoints, Antifreeze, Negative Temp (`-4.8°C`) | `*#4*1*0*0215##`, `*#4*6*12*1048*3##`, `*#4*1*#14*0200*1##` | Valid | Matched | **VERIFIED (100%)** | | **5** | **Burglar Alarm** | Zone status, CU status, Silent alarms, System arm/disarm | `*5*1*1##`, `*#5*1##`, `*5*2*2##` | Valid | Matched | **VERIFIED (100%)** | | **9** | **Auxiliary** | Relay ON/OFF commands across auxiliary channels | `*9*1*1##`, `*9*0*1##` | Valid | Matched | **VERIFIED (100%)** | | **13** | **Gateway Mgmt** | Firmware version, Gateway internal datetime requests | `*#13*0*0##`, `*#13*0*22##` | Valid | Matched | **VERIFIED (100%)** | -| **15** | **CEN Scenarios** | Press, short release, long release, extended hold | `*15*01*0001##`, `*15*01#1*0001##`, `*15*01#2*0001##`, `*15*01#3*0001##` | Valid | Matched | **VERIFIED (100%)** | -| **18** | **Energy Mgmt** | Instantaneous active power (W), Cumulative energy (kWh) | `*#18*51*113##`, `*#18*51*51##`, `*#18*51*52##` | Valid | Matched | **VERIFIED (100%)** | -| **25** | **CEN+ / Dry Contacts**| Short/Long press, release, 5-digit module addresses | `*25*21*0001##`, `*25*23*0001##`, `*25*22*0001##` | Valid | Matched | **VERIFIED (100%)** | +| **15** | **CEN Scenarios** | Press, short release, long release, extended hold | `*15*01*73##`, `*15*01#1*73##`, `*15*01#2*0110##`, `*15*01#3*0110##` | Valid | Matched | **VERIFIED (100%)** | +| **18** | **Energy Mgmt** | Instantaneous active power (W), Cumulative energy (kWh) | `*#18*51*113##`, `*#18*51*51##`, `*#18*51*52#26#9##` | Valid | Matched | **VERIFIED (100%)** | +| **25** | **CEN+ / Dry Contacts**| Short press; start, hold and release of an extended press; WHERE `2` + object 0–2047 | `*25*21#1*21##`, `*25*22#1*21##`, `*25*23#1*21##`, `*25*24#1*21##` | Valid | Matched | **VERIFIED (100%)** | + +Sources for the examples changed in [OWNd#77](https://github.com/OpenWebNet-HA/OWNd/issues/77). The MyHomeServer1 firmware, run on an emulated bus, produces a bus frame for each of them and refuses the examples they replace. The WHO 15 press and short release (`*15*01*73##`, `*15*01#1*73##`) were captured on an MH200N + MyHomeServer1 bus in [MyHOME#541](https://github.com/OpenWebNet-HA/MyHOME/issues/541). Long release and extended hold (`#2`, `#3`) rest on the firmware alone. The WHO 25 examples are an MH201 capture from [MyHOME#418](https://github.com/OpenWebNet-HA/MyHOME/issues/418). The energy request `*#18*51*52#26#9##` was accepted by a real MyHomeServer1. --- ## 3. Key Architectural Truths & Design Decisions ### 3.1 String Preservation for Hardware Addresses -- **Observation**: Addresses such as `0311` (Lighting PTP) and `0001` (CEN+ module) begin with leading zeros. +- **Observation**: Addresses such as `0311` (Lighting PTP) and `0110` (CEN) begin with leading zeros. - **Verifiable Truth**: Addresses **must never be converted to integers** (`311` or `1`). In OpenWebNet, leading zeros denote specific address spaces and module wiring. OWNd strictly enforces string types for `where` across all subsystems. ### 3.2 Private Bus Routing (`#4#bus`) diff --git a/scripts/update_readme_coverage.py b/scripts/update_readme_coverage.py index 0a0a0de..a7a4eea 100644 --- a/scripts/update_readme_coverage.py +++ b/scripts/update_readme_coverage.py @@ -38,7 +38,7 @@ "OWNd/message/heating.py": "WHO 4: Thermoregulation, heating/cooling zone control, and temperature decoders", "OWNd/message/lighting.py": "WHO 1: Lighting controls, dimming, RGB/HSV color, and motion/PIR sensors", "OWNd/message/scenario.py": "WHO 0, WHO 9, WHO 17: Scenario activation, auxiliary commands, and MH200/MH202 scenes", - "OWNd/message/sound.py": "WHO 16, WHO 22: Sound system and audio/video matrix routing", + "OWNd/message/sound.py": "WHO 16: Sound system and audio/video matrix routing (WHO 22 is not implemented)", "OWNd/profiles.py": "Declarative hardware gateway models (F454, MH200, MH200N, MH201, MH202, MyHomeServer1)", "OWNd/transport/base.py": "Abstract transport layer and event listener notification contracts", "OWNd/transport/serial.py": "Async Serial/USB transport for Legrand 3578 interface with in-band demux", diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index 303d252..d142942 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1204,14 +1204,14 @@ }, { "id": "thermo.cmd.central.mode.heat.cu99", - "frame": "*4*101*#0##", + "frame": "*4*1*#0##", "direction": "command", "who": 4, - "what": 101, + "what": 1, "where": "#0", - "source": "legrand-spec", + "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set 99-zone Central Unit to Heating mode", + "mcp_notes": "Set 99-zone Central Unit to Heating mode, WHAT 1 = heating (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)", "roundtrip": true, "builder": { "class": "OWNHeatingCommand", @@ -1225,14 +1225,14 @@ }, { "id": "thermo.cmd.central.mode.cool.cu99", - "frame": "*4*102*#0##", + "frame": "*4*0*#0##", "direction": "command", "who": 4, - "what": 102, + "what": 0, "where": "#0", - "source": "legrand-spec", + "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set 99-zone Central Unit to Cooling mode", + "mcp_notes": "Set 99-zone Central Unit to Cooling mode, WHAT 0 = conditioning (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)", "roundtrip": true, "builder": { "class": "OWNHeatingCommand", @@ -1246,14 +1246,14 @@ }, { "id": "thermo.cmd.central.mode.off.cu99", - "frame": "*4*100*#0##", + "frame": "*4*303*#0##", "direction": "command", "who": 4, - "what": 100, + "what": 303, "where": "#0", - "source": "legrand-spec", + "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Turn conditioning OFF on 99-zone Central Unit", + "mcp_notes": "Turn 99-zone Central Unit OFF, WHAT 303 = OFF generic (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)", "roundtrip": true, "builder": { "class": "OWNHeatingCommand", @@ -1316,14 +1316,14 @@ }, { "id": "thermo.cmd.central.mode.heat.cu4", - "frame": "*4*101*#0#1##", + "frame": "*4*1*#0#1##", "direction": "command", "who": 4, - "what": 101, + "what": 1, "where": "#0", - "source": "legrand-spec", + "source": "encyclopedia", "mcp_valid": true, - "mcp_notes": "Set 4-zone Central Unit to Heating mode", + "mcp_notes": "Set 4-zone Central Unit to Heating mode, WHAT 1 = heating (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); libqtdevices TS10_1_0_23 sends WHAT 1 to a 4-zone central too, but its test addresses that central as #1#2, not #0#1. The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)", "roundtrip": true, "builder": { "class": "OWNHeatingCommand", diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index 02bfa0a..0082a4d 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -76,14 +76,14 @@ roundtrip: true - id: thermo.cmd.central.mode.heat.cu99 - frame: "*4*101*#0##" + frame: "*4*1*#0##" direction: command who: 4 - what: 101 + what: 1 where: "#0" - source: legrand-spec + source: encyclopedia mcp_valid: true - mcp_notes: "Set 99-zone Central Unit to Heating mode" + mcp_notes: "Set 99-zone Central Unit to Heating mode, WHAT 1 = heating (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)" roundtrip: true builder: class: OWNHeatingCommand @@ -91,14 +91,14 @@ args: ["#0", "heat"] - id: thermo.cmd.central.mode.cool.cu99 - frame: "*4*102*#0##" + frame: "*4*0*#0##" direction: command who: 4 - what: 102 + what: 0 where: "#0" - source: legrand-spec + source: encyclopedia mcp_valid: true - mcp_notes: "Set 99-zone Central Unit to Cooling mode" + mcp_notes: "Set 99-zone Central Unit to Cooling mode, WHAT 0 = conditioning (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)" roundtrip: true builder: class: OWNHeatingCommand @@ -106,14 +106,14 @@ args: ["#0", "cool"] - id: thermo.cmd.central.mode.off.cu99 - frame: "*4*100*#0##" + frame: "*4*303*#0##" direction: command who: 4 - what: 100 + what: 303 where: "#0" - source: legrand-spec + source: encyclopedia mcp_valid: true - mcp_notes: "Turn conditioning OFF on 99-zone Central Unit" + mcp_notes: "Turn 99-zone Central Unit OFF, WHAT 303 = OFF generic (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)" roundtrip: true builder: class: OWNHeatingCommand @@ -155,14 +155,14 @@ args: ["#0"] - id: thermo.cmd.central.mode.heat.cu4 - frame: "*4*101*#0#1##" + frame: "*4*1*#0#1##" direction: command who: 4 - what: 101 + what: 1 where: "#0" - source: legrand-spec + source: encyclopedia mcp_valid: true - mcp_notes: "Set 4-zone Central Unit to Heating mode" + mcp_notes: "Set 4-zone Central Unit to Heating mode, WHAT 1 = heating (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); libqtdevices TS10_1_0_23 sends WHAT 1 to a 4-zone central too, but its test addresses that central as #1#2, not #0#1. The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)" roundtrip: true builder: class: OWNHeatingCommand diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py new file mode 100644 index 0000000..6b98e75 --- /dev/null +++ b/tests/test_issue_77_firmware_findings.py @@ -0,0 +1,373 @@ +"""Regression tests for the MyHomeServer1 firmware findings in OWNd#77. + +One test per finding. Each docstring names the source of the frames it uses: + +* **Firmware**: gdluck ran the MyHomeServer1 translator programs on an emulated SCS + bus and recorded whether a message produced a bus frame (OWNd#77, README and + ``audit_myhome_ownd-20261002T083846-5835.zip``: ``au1.tsv`` .. ``au4.tsv``). +* **Emitted**: messages that firmware produces for a bus frame (OWNd#77 README). +* **Gateway**: read-only requests answered by a real MyHomeServer1, fw 3.1.8 + (``gateway_probe.txt`` in the same zip). +* **Capture**: frames recorded on a real plant, kept in the MyHOME repository under + ``tests/fixtures/traces``. +* **Encyclopedia**: the official specification as recorded in the OpenWebNet + Encyclopedia. + +The frames are copied verbatim from those sources. Do not edit them. +""" + +from __future__ import annotations + +import datetime +import re +from pathlib import Path + +import pytest + +from OWNd.message import ( + CLIMATE_MODE_AUTO, + CLIMATE_MODE_COOL, + CLIMATE_MODE_HEAT, + CLIMATE_MODE_OFF, + OWNCENPlusEvent, + OWNCommand, + OWNDryContactEvent, + OWNEnergyCommand, + OWNEvent, + OWNHeatingCommand, + OWNHeatingEvent, + OWNLightingCommand, + OWNMessage, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +# ── Fix 1: central unit mode ──────────────────────────────────────────────── + + +@pytest.mark.parametrize("where", ["#0", "#0#2"]) +@pytest.mark.parametrize( + ("mode", "what", "parsed_mode"), + [ + (CLIMATE_MODE_OFF, 303, CLIMATE_MODE_OFF), + (CLIMATE_MODE_HEAT, 1, CLIMATE_MODE_HEAT), + (CLIMATE_MODE_COOL, 0, CLIMATE_MODE_COOL), + (CLIMATE_MODE_AUTO, 311, CLIMATE_MODE_AUTO), + ("antifreeze", 102, CLIMATE_MODE_OFF), + ("protection", 302, CLIMATE_MODE_OFF), + ], +) +def test_fix1_central_mode_uses_the_who4_what_table( + where: str, mode: str, what: int, parsed_mode: str +) -> None: + """Central-unit modes use WHAT 303 / 1 / 0 / 311 / 102 / 302. + + Firmware: 303, 1, 0, 311 and 102 produce a bus frame for ``#0`` and ``#0#2``; + the old 100 / 101 / 110 produce none, and the old 102 (cool), 103 and 111 + are forwarded but mean something else. + BTicino client (libqtdevices TS10_1_0_23 ``thermal_device.cpp`` and + ``test/test_thermal_device.cpp``): the central unit is sent ``*4*303*#0##`` + (off), ``*4*1*#0##`` (winter), ``*4*0*#0##`` (summer) and ``*4*302*#0##`` + (protection). Its enum names 102 winter protection, 202 summer protection + and 302 generic protection, so "protection" is 302: 202 would also switch + the plant to summer. + Encyclopedia: ``functional/who-4-temperature-control/what.md`` lists 0 cooling, + 1 heating, 102 antifreeze, 202 thermal protection, 303 OFF generic and + 311 automatic generic. + Not verified: how a physical central unit reacts to them. + + OWNd's own parser must read each frame back as the mode that was asked for + (antifreeze and protection are shown as off). + """ + command = OWNHeatingCommand.set_central_mode(where, mode) + + assert str(command) == f"*4*{what}*{where}##" + assert OWNHeatingEvent(str(command)).mode == parsed_mode + + +def test_fix1_captured_central_unit_mode_parses_as_cooling() -> None: + """Capture: a MyHomeServer1 plant switched to summer mode reported ``*4*0*#0##``. + + MyHOME ``tests/fixtures/traces/issue_453/myhome_trace_MyHomeServer1_all_2026-09-25T19-40-15.json`` + (MyHOME#453, @nicolacavallo84): WHAT 0 is the mode the builder now sends for "cool". + """ + event = OWNHeatingEvent("*4*0*#0##") + + assert event.mode == CLIMATE_MODE_COOL + assert str(OWNHeatingCommand.set_central_mode("#0", CLIMATE_MODE_COOL)) == "*4*0*#0##" + + +# ── Fix 2: fan speed ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("speed", [0, 1, 2, 3]) +@pytest.mark.parametrize("zone", [1, 99]) +def test_fix2_fan_speed_uses_the_plain_zone(zone: int, speed: int) -> None: + """Firmware: ``*#4*1*#11*0##`` .. ``*#4*1*#11*3##`` and zone 99 produce a bus frame. + + The ``#Z`` form (``*#4*#1*#11*1##``) produces none, so a zone managed by a + central unit must still be written as the plain zone. + Encyclopedia: speed is 0 automatic or 1..3. + """ + expected = f"*#4*{zone}*#11*{speed}##" + + assert str(OWNHeatingCommand.set_fan_speed(zone, speed)) == expected + assert str(OWNHeatingCommand.set_fan_speed(str(zone), speed)) == expected + assert str(OWNHeatingCommand.set_fan_speed(f"#{zone}", speed)) == expected + assert str(OWNHeatingCommand.set_fan_speed(f"#{zone:02d}", speed)) == expected + assert str(OWNHeatingCommand.set_fan_speed(zone, speed, standalone=True)) == expected + + +@pytest.mark.parametrize("where", ["#0", "#0#2", "0", 0]) +def test_fix2_fan_speed_refuses_central_unit_addresses(where: str | int) -> None: + """Firmware: ``*#4*#0*#11*1##`` and ``*#4*#0#2*#11*1##`` produce no bus frame.""" + with pytest.raises(ValueError, match="central unit or general zone"): + OWNHeatingCommand.set_fan_speed(where, 1) + + +@pytest.mark.parametrize("speed", [4, 15, -1]) +def test_fix2_fan_speed_refuses_speeds_outside_0_to_3(speed: int) -> None: + """Firmware: ``*#4*1*#11*4##`` produces no bus frame.""" + with pytest.raises(ValueError, match="Invalid fan speed"): + OWNHeatingCommand.set_fan_speed(1, speed) + + +def test_fix2_fan_speed_refuses_input_that_is_not_a_zone_or_speed() -> None: + """Code: the builder raises ValueError instead of building a frame from bad input.""" + with pytest.raises(ValueError, match="Invalid zone number"): + OWNHeatingCommand.set_fan_speed(100, 1) + with pytest.raises(ValueError, match="Invalid zone address"): + OWNHeatingCommand.set_fan_speed("invalid", 1) + with pytest.raises(ValueError, match="Invalid fan speed"): + OWNHeatingCommand.set_fan_speed(1, "invalid") # type: ignore[arg-type] + + +# ── Fix 3: AUTO on a standalone zone ──────────────────────────────────────── + + +def test_fix3_auto_is_always_built_for_the_hash_zone() -> None: + """Firmware: ``*4*311*1##`` produces no bus frame; ``*4*311*#1##`` does. + + So AUTO is built as ``#Z`` even for a zone flagged standalone. MyHOME flags + every zone that is not a central unit standalone by default, so returning + None here would drop AUTO for zones that do run under a central unit. + BTicino client (libqtdevices TS10_1_0_23 ``ControlledProbeDevice::setAutomatic``, + test ``sendSetAutomatic``): ``*4*311*#23#1##``, the ``#`` form. + ``*4*303*1##`` (off) works in the plain form, so off still follows ``standalone``. + Encyclopedia: automatic mode is commanded through the central unit, + ``*4*311*#WHERE##``. + """ + assert str(OWNHeatingCommand.set_mode("1", CLIMATE_MODE_AUTO, standalone=True)) == "*4*311*#1##" + assert str(OWNHeatingCommand.set_mode("1", CLIMATE_MODE_AUTO)) == "*4*311*#1##" + assert str(OWNHeatingCommand.set_mode("#0", CLIMATE_MODE_AUTO, standalone=True)) == "*4*311*#0##" + assert str(OWNHeatingCommand.set_mode("1", CLIMATE_MODE_OFF, standalone=True)) == "*4*303*1##" + + +# ── Fix 4: actuator status without an actuator number ────────────────────── + + +@pytest.mark.parametrize("frame", ["*#4*#0*20*0##", "*#4*100*20*0##"]) +def test_fix4_actuator_status_without_actuator_falls_back_to_actuator_1( + frame: str, +) -> None: + """Emitted: the firmware sends dimension 20 without ``#actuator`` (141 messages). + + Before the fix these raised IndexError and the event session dropped them. + """ + event = OWNEvent.parse(frame) + + assert isinstance(event, OWNHeatingEvent) + assert event._actuator == "1" + assert event.is_active() is False + + +def test_fix4_captured_actuator_status_keeps_its_actuator() -> None: + """Capture: ``*#4*1#1*20*1##`` (MyHOME#466 MH200N sweep and others) names actuator 1.""" + event = OWNHeatingEvent("*#4*1#1*20*1##") + + assert event._actuator == "1" + assert event.is_active() is True + + +# ── Fix 5: valve status with an empty field ──────────────────────────────── + + +@pytest.mark.parametrize("frame", ["*#4*40*19**0##", "*#4*40*19*1*##"]) +def test_fix5_valve_status_with_an_empty_field_is_not_decoded(frame: str) -> None: + """Emitted: the firmware prints an empty field for a status byte outside its table. + + Before the fix ``int("")`` raised ValueError and the frame was dropped. + """ + event = OWNEvent.parse(frame) + + assert isinstance(event, OWNHeatingEvent) + assert event.is_active() is None + + +def test_fix5_gateway_valve_status_is_still_decoded() -> None: + """Gateway: ``*#4*41*19##`` was answered with ``*#4*41*19*0*0##``.""" + event = OWNHeatingEvent("*#4*41*19*0*0##") + + assert event.is_active() is False + assert event._is_cooling is False + assert event._is_heating is False + + +# ── Fix 6: dimension requests with parameters ────────────────────────────── + + +@pytest.mark.parametrize( + ("frame", "dimension", "params"), + [ + ("*#18*51*511#10#1##", 511, ["10", "1"]), + ("*#18*51*511#10#2##", 511, ["10", "2"]), + ("*#18*51*52#26#9##", 52, ["26", "9"]), + ], +) +def test_fix6_dimension_request_with_parameters_parses( + frame: str, dimension: int, params: list[str] +) -> None: + """Gateway: the real gateway accepted ``*#18*51*511#10#1##`` and ``*#18*51*52#26#9##``. + + Encyclopedia: requests are ``*#18*WHERE*511#M#D##`` and ``*#18*WHERE*52#Y#M##``. + """ + message = OWNMessage.parse(frame) + + assert isinstance(message, OWNEnergyCommand) + assert message.is_valid + assert message.who == 18 + assert message.dimension == dimension + assert message._dimension_param == params + assert isinstance(OWNCommand.parse(frame), OWNEnergyCommand) + + +def test_fix6_energy_history_builders_build_valid_requests() -> None: + """Code: OWNd could not parse the two requests its own builders produce.""" + hourly = OWNEnergyCommand.get_hourly_consumption("51", datetime.date.today()) + monthly = OWNEnergyCommand.get_monthly_consumption("51", 2026, 9) + + assert hourly is not None + assert hourly.is_valid and hourly.who == 18 and hourly.dimension == 511 + assert str(monthly) == "*#18*51*52#26#9##" + assert monthly.is_valid and monthly.who == 18 and monthly.dimension == 52 + + +# ── Fix 7: brightness 0 ───────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("transition", "frame"), + [(0, "*1*0*31##"), (1, "*1*0#1*31##"), (5, "*1*0#5*31##"), (255, "*1*0#255*31##")], +) +def test_fix7_brightness_0_switches_off(transition: int, frame: str) -> None: + """Firmware: ``*#1*31*#1*100*0##`` / ``*100*5##`` produce no bus frame. + + ``*1*0*31##``, ``*1*0#1*31##``, ``*1*0#5*31##`` and ``*1*0#255*31##`` do + (``au1.tsv``, ``au4.tsv`` and the OWNd#77 README). + """ + assert str(OWNLightingCommand.set_brightness("31", 0, transition)) == frame + + +@pytest.mark.parametrize("level", range(-5, 121)) +def test_fix7_brightness_never_writes_level_100(level: int) -> None: + """Firmware: level 100 is refused; ``*#1*31*#1*101*0##`` and ``*#1*31*#1*150*5##`` work.""" + frame = str(OWNLightingCommand.set_brightness("31", level, 5)) + + assert "*#1*31*#1*100*" not in frame + if level > 0: + assert frame == f"*#1*31*#1*{min(level, 100) + 100}*5##" + + +# ── Fix 8: interface for WHO 0 and WHO 14 ─────────────────────────────────── + + +@pytest.mark.parametrize("frame", ["*0*1*31#4#01##", "*14*0*31#4#01##"]) +def test_fix8_interface_is_read_for_scenarios_and_who14(frame: str) -> None: + """Firmware: both route through interface 01 like a light does. + + Encyclopedia (WHO 0): ``01..99#4#I`` is a scenario module on a local bus, and + the interface is part of the address. + """ + on_01 = OWNEvent.parse(frame) + on_02 = OWNEvent.parse(frame.replace("#4#01", "#4#02")) + + assert on_01 is not None and on_02 is not None + assert on_01.interface == "01" + assert on_01.unique_id != on_02.unique_id + + +# ── Fix 9: other WHO 25 messages ──────────────────────────────────────────── + + +@pytest.mark.parametrize("frame", ["*25*11#121*10##", "*25*12*131##"]) +def test_fix9_other_who25_messages_are_not_dry_contacts(frame: str) -> None: + """Emitted: the firmware sends WHAT 11..15 on ``1xx`` addresses. + + Encyclopedia: dry contacts are WHAT 31 and 32 only. + """ + event = OWNEvent.parse(frame) + + assert type(event) is OWNEvent + assert not isinstance(event, OWNDryContactEvent) + + +@pytest.mark.parametrize( + ("frame", "cls"), + [ + ("*25*31#1*339##", OWNDryContactEvent), + ("*25*32#1*33##", OWNDryContactEvent), + ("*25*21#1*21##", OWNCENPlusEvent), + ("*25*23#8*233##", OWNCENPlusEvent), + ], +) +def test_fix9_captured_who25_messages_keep_their_class(frame: str, cls: type) -> None: + """Capture: WHO 25 frames from the MyHOME trace fixtures (MyHOME#453, #466).""" + assert isinstance(OWNEvent.parse(frame), cls) + + +# ── Fix 10: conformance matrix ────────────────────────────────────────────── + +_MATRIX = REPO_ROOT / "docs" / "protocol_conformance_matrix.md" + +# Firmware (au1.tsv, au2.own) and Gateway: each of these is refused. +_REFUSED_EXAMPLES = [ + "*2*1*21#4#1##", + "*#1*1*#1*20##", + "*#2*1*#1*50##", + "*#18*51*52##", + "*25*21*0001##", + "*25*22*0001##", + "*25*23*0001##", + "*15*01*0001##", + "*15*01#1*0001##", + "*15*01#2*0001##", + "*15*01#3*0001##", +] + + +def _matrix_frames() -> list[str]: + rows = [ + line + for line in _MATRIX.read_text(encoding="utf-8").splitlines() + if line.startswith("| **") + ] + return [frame for row in rows for frame in re.findall(r"`(\*[^`]*##)`", row)] + + +def test_fix10_matrix_lists_no_frame_the_firmware_refuses() -> None: + """Firmware / Gateway: the six examples flagged in OWNd#77 are refused.""" + frames = _matrix_frames() + + assert frames + assert not set(frames) & set(_REFUSED_EXAMPLES) + + +def test_fix10_matrix_examples_all_parse() -> None: + """Code: every example in the matrix is a frame OWNd can parse.""" + for frame in _matrix_frames(): + if frame in ("*#*1##", "*#*0##"): + continue + message = OWNMessage.parse(frame) + assert message is not None, frame + assert message.is_valid, frame diff --git a/tests/test_message_exhaustive.py b/tests/test_message_exhaustive.py index 2adeed1..44664ba 100644 --- a/tests/test_message_exhaustive.py +++ b/tests/test_message_exhaustive.py @@ -483,7 +483,8 @@ def test_message_remaining_branches(self) -> None: assert OWNHeatingCommand.get_temperature("1") is not None assert OWNHeatingCommand.set_mode("1", "unknown") is None assert OWNHeatingCommand.turn_off("1") is not None - assert OWNHeatingCommand.set_fan_speed("#0#1", 1) is not None + with pytest.raises(ValueError): + OWNHeatingCommand.set_fan_speed("#0#1", 1) assert OWNHeatingCommand.set_fan_speed("1", 1, standalone=True) is not None # Gateway command dimension 22 diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index 7607b08..c66eea2 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -278,7 +278,7 @@ def test_heating_fan_speed_dimension_has_message_type() -> None: assert event.message_type == MESSAGE_TYPE_FAN_SPEED assert event.fan_speed == 2 assert event.fan_on is True - assert str(OWNHeatingCommand.set_fan_speed("1", 2)) == "*#4*#1*#11*2##" + assert str(OWNHeatingCommand.set_fan_speed("1", 2)) == "*#4*1*#11*2##" automatic = OWNHeatingEvent("*#4*1*11*0##") assert automatic.fan_speed == 0