From c726c3bc7c9dfb0a8375fd42f82229f7c2e41e56 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:22:34 +0200 Subject: [PATCH 1/5] fix(protocol): resolve protocol and parser defects from MHS1 firmware analysis (#77) - Fix 1: Align central mode WHAT codes to canonical 303 (off), 1 (heat), 0 (cool), 311 (auto), 102 (antifreeze), 202 (protection) - Fix 2: Strictly require plain zone (1..99) and speed (0..3) in set_fan_speed, rejecting '#' central addresses - Fix 3: Return None for CLIMATE_MODE_AUTO on standalone zones (dropped by gateway) - Fix 4: Guard dimension 20 actuator status against empty where_param IndexError - Fix 5: Guard dimension 19 valve status against empty field ValueError - Fix 6: Support dimension parameters in _DIMENSION_REQUEST regex and extract _dimension_param - Fix 7: Route brightness <= 0 through switch_off and clamp command level to 101..200 - Fix 8: Recognize private bus routing (#4#NN) in interface property for WHO 0 and WHO 14 - Fix 9: Fall back to generic OWNEvent for non-31/32 WHO 25 frames rather than misclassifying as contact OFF - Fix 10: Correct canonical frame examples in protocol conformance matrix - Fix B1: Set supports_audio=False and omit WHO 16 from supported_who in MyHomeServer1Profile (MHS1 exclusively uses WHO 22) - Synchronize golden thermo fixtures, regenerate corpus.json, and maintain 100.0% coverage across all 24 modules --- OWNd/message/base.py | 5 +- OWNd/message/cen.py | 2 + OWNd/message/heating.py | 65 +++++++++++++-------- OWNd/message/lighting.py | 8 ++- OWNd/profiles.py | 4 ++ README.md | 2 +- docs/protocol_conformance_matrix.md | 12 ++-- tests/golden/corpus.json | 16 ++--- tests/golden/frames/who04_thermo.yaml | 16 ++--- tests/test_message_edges.py | 84 +++++++++++++++++++++++++++ tests/test_message_exhaustive.py | 4 +- tests/test_message_protocol.py | 2 +- tests/test_profiles.py | 5 +- 13 files changed, 170 insertions(+), 55 deletions(-) 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..3e61598 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,8 @@ def set_mode( if mode == CLIMATE_MODE_OFF: mode_code = 303 elif mode == CLIMATE_MODE_AUTO: + if not zone.startswith("#"): + return None mode_code = 311 else: return None @@ -672,24 +682,31 @@ 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: + 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 @@ -699,12 +716,12 @@ def set_central_mode( ) -> OWNHeatingCommand: """Set operation mode for Central Unit (3550 99-zone or 4695 4-zone).""" 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": 202, } 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/profiles.py b/OWNd/profiles.py index 5dc17a1..4f393d1 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -322,6 +322,10 @@ def __init__(self) -> None: supports_hmac=True, supports_native_transitions=True, supports_extended_frames=True, + supports_audio=False, + supported_who=tuple( + who for who in DEFAULT_SUPPORTED_WHO if who != WHO_SOUND + ), ) diff --git a/README.md b/README.md index 1f56eff..f128284 100755 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Gateways have varying processing limitations, socket budgets, and pacing require | Gateway Model | Concurrency | Queue Delay | Event keepalive | Features | |:---|:---:|:---:|:---:|:---| -| **MyHomeServer1** | 4 sessions (2 default) | 20 ms | OS TCP only | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | +| **MyHomeServer1** | 4 sessions (2 default) | 20 ms | OS TCP only | HMAC-SHA2, Native transitions, Extended frames | | **F454** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | | **F455** | 4 sessions (2 default) | 50 ms | OS TCP only | HMAC-SHA2, Native transitions, Extended frames | | **F461** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | diff --git a/docs/protocol_conformance_matrix.md b/docs/protocol_conformance_matrix.md index a931f51..66906f2 100644 --- a/docs/protocol_conformance_matrix.md +++ b/docs/protocol_conformance_matrix.md @@ -26,15 +26,17 @@ 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*02*11##`, `*15*02#1*11##`, `*15*02#3*11##`, `*15*02#2*11##` | Valid | Matched | **DOCUMENTED (no bus capture)** | +| **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%)** | + +**DOCUMENTED** means the frames follow the [OpenWebNet Encyclopedia](https://github.com/OpenWebNet-HA/OpenWebNet-Encyclopedia) but no bus capture backs them yet. The WHO 15 row waits for the traces requested in [#434](https://github.com/OpenWebNet-HA/MyHOME/issues/434). The WHO 25 examples are an MH201 capture from [#418](https://github.com/OpenWebNet-HA/MyHOME/issues/418). --- diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index 303d252..3d025fc 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1204,10 +1204,10 @@ }, { "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", "mcp_valid": true, @@ -1225,10 +1225,10 @@ }, { "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", "mcp_valid": true, @@ -1246,10 +1246,10 @@ }, { "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", "mcp_valid": true, @@ -1316,10 +1316,10 @@ }, { "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", "mcp_valid": true, diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index 02bfa0a..7d577c3 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -76,10 +76,10 @@ 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 mcp_valid: true @@ -91,10 +91,10 @@ 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 mcp_valid: true @@ -106,10 +106,10 @@ 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 mcp_valid: true @@ -155,10 +155,10 @@ 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 mcp_valid: true diff --git a/tests/test_message_edges.py b/tests/test_message_edges.py index 4d3ac4d..1d67a4f 100644 --- a/tests/test_message_edges.py +++ b/tests/test_message_edges.py @@ -1224,6 +1224,90 @@ def test_gateway_profile_constants(self): assert WHO_LOAD_CONTROL in DEFAULT_SUPPORTED_WHO assert WHO_SOUND_DIFFUSION in DEFAULT_SUPPORTED_WHO + def test_issue_77_protocol_fixes(self): + from OWNd.profiles import get_gateway_profile, WHO_SOUND + + # Fix 1: Central mode WHAT mapping + assert str(OWNHeatingCommand.set_central_mode("#0", "off")) == "*4*303*#0##" + assert str(OWNHeatingCommand.set_central_mode("#0", "heat")) == "*4*1*#0##" + assert str(OWNHeatingCommand.set_central_mode("#0", "cool")) == "*4*0*#0##" + assert str(OWNHeatingCommand.set_central_mode("#0", "auto")) == "*4*311*#0##" + assert str(OWNHeatingCommand.set_central_mode("#0", "antifreeze")) == "*4*102*#0##" + assert str(OWNHeatingCommand.set_central_mode("#0", "protection")) == "*4*202*#0##" + assert str(OWNHeatingCommand.set_central_mode("#0#1", "heat")) == "*4*1*#0#1##" + + # Fix 2: Fan speed plain zone and validation + cmd_fan = OWNHeatingCommand.set_fan_speed(5, 1) + assert str(cmd_fan) == "*#4*5*#11*1##" + assert str(OWNHeatingCommand.set_fan_speed("5", 0)) == "*#4*5*#11*0##" + assert str(OWNHeatingCommand.set_fan_speed("#5", 3)) == "*#4*5*#11*3##" + assert str(OWNHeatingCommand.set_fan_speed("#01", 1)) == "*#4*1*#11*1##" + with pytest.raises(ValueError, match="central unit or general zone"): + OWNHeatingCommand.set_fan_speed("#0", 1) + with pytest.raises(ValueError, match="central unit or general zone"): + OWNHeatingCommand.set_fan_speed("#0#1", 1) + with pytest.raises(ValueError, match="central unit or general zone"): + OWNHeatingCommand.set_fan_speed("0", 1) + with pytest.raises(ValueError, match="central unit or general zone"): + OWNHeatingCommand.set_fan_speed(0, 1) + with pytest.raises(ValueError, match="Invalid zone number"): + OWNHeatingCommand.set_fan_speed(100, 1) + with pytest.raises(ValueError, match="Invalid fan speed"): + OWNHeatingCommand.set_fan_speed(5, 4) + with pytest.raises(ValueError, match="Invalid fan speed"): + OWNHeatingCommand.set_fan_speed(5, -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(5, "invalid") + + # Fix 3: Standalone AUTO mode returns None + assert OWNHeatingCommand.set_mode("1", CLIMATE_MODE_AUTO, standalone=True) is None + assert OWNHeatingCommand.set_mode("#1", CLIMATE_MODE_AUTO, standalone=False) is not None + + # Fix 4: Guard dimension 20 against empty _where_param + ev_act = OWNHeatingEvent("*#4*1*20*1##") + assert ev_act._actuator == 1 + assert ev_act.is_active() is True + + # Fix 5: Guard dimension 19 against empty fields + ev_valves_empty = OWNHeatingEvent("*#4*40*19**0##") + assert ev_valves_empty.is_active() is None + + # Fix 6: Dimension request with params + cmd_dim_req = OWNCommand.parse("*#18*51*511#10#2##") + assert cmd_dim_req is not None + assert cmd_dim_req.dimension == 511 + assert cmd_dim_req._dimension_param == ["10", "2"] + + # Fix 7: Lighting set_brightness level <= 0 and level > 100 clamping + cmd_off_0 = OWNLightingCommand.set_brightness("21", _level=0) + assert str(cmd_off_0) == "*1*0*21##" + cmd_off_neg = OWNLightingCommand.set_brightness("21", _level=-10, _transition=5) + assert str(cmd_off_neg) == "*1*0#5*21##" + cmd_clamp = OWNLightingCommand.set_brightness("21", _level=150) + assert str(cmd_clamp) == "*#1*21*#1*200*0##" + + # Fix 8: Interface property for WHO 0 and WHO 14 + msg_who0 = OWNMessage("*0*1*1#4#01##") + assert msg_who0.interface == "01" + msg_who14 = OWNMessage("*14*1*1#4#02##") + assert msg_who14.interface == "02" + + # Fix 9: WHO 25 non-31/32 fallback to OWNEvent + evt_other = OWNEvent.parse("*25*10*21##") + assert type(evt_other) is OWNEvent + assert not isinstance(evt_other, OWNDryContactEvent) + evt_on = OWNEvent.parse("*25*31#1*21##") + assert isinstance(evt_on, OWNDryContactEvent) + evt_off = OWNEvent.parse("*25*32#1*21##") + assert isinstance(evt_off, OWNDryContactEvent) + + # Fix B1: MyHomeServer1Profile + mhs1 = get_gateway_profile("MyHomeServer1") + assert mhs1.supports_audio is False + assert WHO_SOUND not in mhs1.supported_who + class TestMessageAuditExhaustiveCoverage: """Exhaustive unit tests targeting all remaining edge-case branches in ownd/message.py.""" diff --git a/tests/test_message_exhaustive.py b/tests/test_message_exhaustive.py index 2adeed1..71303ed 100644 --- a/tests/test_message_exhaustive.py +++ b/tests/test_message_exhaustive.py @@ -482,8 +482,8 @@ def test_message_remaining_branches(self) -> None: # Heating command getters and mode fallbacks 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 diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 910c252..0c6b482 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -69,7 +69,8 @@ def test_profile_lookup_accepts_common_name_variants() -> None: assert profile.model_name == "MyHomeServer1" assert profile.supports_session_count(4) - assert profile.supports_who(WHO_SOUND) + assert not profile.supports_who(WHO_SOUND) + assert profile.supports_audio is False def test_f461_profile_lookup() -> None: @@ -210,7 +211,7 @@ def test_gateway_profile_summary_properties() -> None: assert mhs1.keepalive_summary == "OS TCP only" assert ( mhs1.features_summary - == "HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16)" + == "HMAC-SHA2, Native transitions, Extended frames" ) f454 = get_gateway_profile("F454") From da25d861bfc20a6b18a568c2a55d851bc8e9208b Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:26:09 +0200 Subject: [PATCH 2/5] test(#77): one sourced regression test per firmware finding Replace the single combined test with tests/test_issue_77_firmware_findings.py. Each test uses the frames from the source it names, verbatim: gdluck's MyHomeServer1 firmware run (au1-au4.tsv, OWNd#77 README), the read-only gateway probe, the OpenWebNet Encyclopedia, or MyHOME plant captures (#453, #466, #541). 69 of the new cases fail on a6eda4d, covering every item (fixes 1-10 and B1); the rest pin captured frames that must keep decoding. - Golden: the four central-mode fixtures changed in the previous commit still claimed source legrand-spec. They now cite the Encyclopedia WHO 4 table (what.md: 0, 1, 102, 202, 303, 311) and the firmware run, as the golden README requires for a corrected fixture. corpus.json regenerated. - Matrix: WHO 15 has captures (MyHOME#541 press and short release), so the row is VERIFIED with those frames plus the firmware-checked #2/#3 forms; the footnote names the source of every changed example. - README/sound.py: sound.py implements WHO 16 only (gdluck's README note). - Restore the turn_off assertion dropped from test_message_exhaustive.py. Co-Authored-By: Claude Opus 5.5 --- OWNd/message/sound.py | 2 +- README.md | 2 +- docs/protocol_conformance_matrix.md | 6 +- scripts/update_readme_coverage.py | 2 +- tests/golden/corpus.json | 16 +- tests/golden/frames/who04_thermo.yaml | 16 +- tests/test_issue_77_firmware_findings.py | 378 +++++++++++++++++++++++ tests/test_message_edges.py | 84 ----- tests/test_message_exhaustive.py | 1 + 9 files changed, 401 insertions(+), 106 deletions(-) create mode 100644 tests/test_issue_77_firmware_findings.py 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 f128284..1c95ca3 100755 --- a/README.md +++ b/README.md @@ -332,7 +332,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 66906f2..46088ca 100644 --- a/docs/protocol_conformance_matrix.md +++ b/docs/protocol_conformance_matrix.md @@ -32,18 +32,18 @@ To prevent divergence and eliminate guesswork, every protocol frame and capabili | **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*02*11##`, `*15*02#1*11##`, `*15*02#3*11##`, `*15*02#2*11##` | Valid | Matched | **DOCUMENTED (no bus capture)** | +| **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%)** | -**DOCUMENTED** means the frames follow the [OpenWebNet Encyclopedia](https://github.com/OpenWebNet-HA/OpenWebNet-Encyclopedia) but no bus capture backs them yet. The WHO 15 row waits for the traces requested in [#434](https://github.com/OpenWebNet-HA/MyHOME/issues/434). The WHO 25 examples are an MH201 capture from [#418](https://github.com/OpenWebNet-HA/MyHOME/issues/418). +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 3d025fc..2982bd8 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1209,9 +1209,9 @@ "who": 4, "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). 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", @@ -1230,9 +1230,9 @@ "who": 4, "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). 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", @@ -1251,9 +1251,9 @@ "who": 4, "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). 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", @@ -1321,9 +1321,9 @@ "who": 4, "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). 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 7d577c3..7bff689 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -81,9 +81,9 @@ who: 4 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). 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 @@ -96,9 +96,9 @@ who: 4 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). 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 @@ -111,9 +111,9 @@ who: 4 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). 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 @@ -160,9 +160,9 @@ who: 4 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). 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..8b1d03d --- /dev/null +++ b/tests/test_issue_77_firmware_findings.py @@ -0,0 +1,378 @@ +"""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, +) +from OWNd.profiles import WHO_SOUND, get_gateway_profile + +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", 202, 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 / 202. + + Firmware: these six produce a bus frame for ``#0`` and ``#0#2``; the old + 100 / 101 / 110 produce none, and 102 / 103 / 111 mean something else. + 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_not_built_for_a_plain_zone() -> None: + """Firmware: ``*4*311*1##`` produces no bus frame; ``*4*311*#1##`` does. + + ``*4*303*1##`` (off) works in the plain form, so off is still built. + Encyclopedia: automatic mode is commanded through the central unit, + ``*4*311*#WHERE##``. + """ + assert OWNHeatingCommand.set_mode("1", CLIMATE_MODE_AUTO, standalone=True) is None + assert str(OWNHeatingCommand.set_mode("1", CLIMATE_MODE_AUTO)) == "*4*311*#1##" + 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 + + +# ── B1: MyHomeServer1 and WHO 16 ──────────────────────────────────────────── + + +def test_b1_myhomeserver1_does_not_advertise_who16() -> None: + """Firmware: the sound program (``bt_multi``) translates WHO 22 only. + + 472 WHO 16 commands produced no bus frame, and 3,888 bus frames decode to + ``*22*``. The gateway router (``openserver``) was not emulated, and the real + gateway's refusal of ``*#16*0##`` may only mean that no sound system is + installed, so this rests on the emulation alone. + """ + profile = get_gateway_profile("MyHomeServer1") + + assert profile.supports_audio is False + assert not profile.supports_who(WHO_SOUND) diff --git a/tests/test_message_edges.py b/tests/test_message_edges.py index 1d67a4f..4d3ac4d 100644 --- a/tests/test_message_edges.py +++ b/tests/test_message_edges.py @@ -1224,90 +1224,6 @@ def test_gateway_profile_constants(self): assert WHO_LOAD_CONTROL in DEFAULT_SUPPORTED_WHO assert WHO_SOUND_DIFFUSION in DEFAULT_SUPPORTED_WHO - def test_issue_77_protocol_fixes(self): - from OWNd.profiles import get_gateway_profile, WHO_SOUND - - # Fix 1: Central mode WHAT mapping - assert str(OWNHeatingCommand.set_central_mode("#0", "off")) == "*4*303*#0##" - assert str(OWNHeatingCommand.set_central_mode("#0", "heat")) == "*4*1*#0##" - assert str(OWNHeatingCommand.set_central_mode("#0", "cool")) == "*4*0*#0##" - assert str(OWNHeatingCommand.set_central_mode("#0", "auto")) == "*4*311*#0##" - assert str(OWNHeatingCommand.set_central_mode("#0", "antifreeze")) == "*4*102*#0##" - assert str(OWNHeatingCommand.set_central_mode("#0", "protection")) == "*4*202*#0##" - assert str(OWNHeatingCommand.set_central_mode("#0#1", "heat")) == "*4*1*#0#1##" - - # Fix 2: Fan speed plain zone and validation - cmd_fan = OWNHeatingCommand.set_fan_speed(5, 1) - assert str(cmd_fan) == "*#4*5*#11*1##" - assert str(OWNHeatingCommand.set_fan_speed("5", 0)) == "*#4*5*#11*0##" - assert str(OWNHeatingCommand.set_fan_speed("#5", 3)) == "*#4*5*#11*3##" - assert str(OWNHeatingCommand.set_fan_speed("#01", 1)) == "*#4*1*#11*1##" - with pytest.raises(ValueError, match="central unit or general zone"): - OWNHeatingCommand.set_fan_speed("#0", 1) - with pytest.raises(ValueError, match="central unit or general zone"): - OWNHeatingCommand.set_fan_speed("#0#1", 1) - with pytest.raises(ValueError, match="central unit or general zone"): - OWNHeatingCommand.set_fan_speed("0", 1) - with pytest.raises(ValueError, match="central unit or general zone"): - OWNHeatingCommand.set_fan_speed(0, 1) - with pytest.raises(ValueError, match="Invalid zone number"): - OWNHeatingCommand.set_fan_speed(100, 1) - with pytest.raises(ValueError, match="Invalid fan speed"): - OWNHeatingCommand.set_fan_speed(5, 4) - with pytest.raises(ValueError, match="Invalid fan speed"): - OWNHeatingCommand.set_fan_speed(5, -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(5, "invalid") - - # Fix 3: Standalone AUTO mode returns None - assert OWNHeatingCommand.set_mode("1", CLIMATE_MODE_AUTO, standalone=True) is None - assert OWNHeatingCommand.set_mode("#1", CLIMATE_MODE_AUTO, standalone=False) is not None - - # Fix 4: Guard dimension 20 against empty _where_param - ev_act = OWNHeatingEvent("*#4*1*20*1##") - assert ev_act._actuator == 1 - assert ev_act.is_active() is True - - # Fix 5: Guard dimension 19 against empty fields - ev_valves_empty = OWNHeatingEvent("*#4*40*19**0##") - assert ev_valves_empty.is_active() is None - - # Fix 6: Dimension request with params - cmd_dim_req = OWNCommand.parse("*#18*51*511#10#2##") - assert cmd_dim_req is not None - assert cmd_dim_req.dimension == 511 - assert cmd_dim_req._dimension_param == ["10", "2"] - - # Fix 7: Lighting set_brightness level <= 0 and level > 100 clamping - cmd_off_0 = OWNLightingCommand.set_brightness("21", _level=0) - assert str(cmd_off_0) == "*1*0*21##" - cmd_off_neg = OWNLightingCommand.set_brightness("21", _level=-10, _transition=5) - assert str(cmd_off_neg) == "*1*0#5*21##" - cmd_clamp = OWNLightingCommand.set_brightness("21", _level=150) - assert str(cmd_clamp) == "*#1*21*#1*200*0##" - - # Fix 8: Interface property for WHO 0 and WHO 14 - msg_who0 = OWNMessage("*0*1*1#4#01##") - assert msg_who0.interface == "01" - msg_who14 = OWNMessage("*14*1*1#4#02##") - assert msg_who14.interface == "02" - - # Fix 9: WHO 25 non-31/32 fallback to OWNEvent - evt_other = OWNEvent.parse("*25*10*21##") - assert type(evt_other) is OWNEvent - assert not isinstance(evt_other, OWNDryContactEvent) - evt_on = OWNEvent.parse("*25*31#1*21##") - assert isinstance(evt_on, OWNDryContactEvent) - evt_off = OWNEvent.parse("*25*32#1*21##") - assert isinstance(evt_off, OWNDryContactEvent) - - # Fix B1: MyHomeServer1Profile - mhs1 = get_gateway_profile("MyHomeServer1") - assert mhs1.supports_audio is False - assert WHO_SOUND not in mhs1.supported_who - class TestMessageAuditExhaustiveCoverage: """Exhaustive unit tests targeting all remaining edge-case branches in ownd/message.py.""" diff --git a/tests/test_message_exhaustive.py b/tests/test_message_exhaustive.py index 71303ed..44664ba 100644 --- a/tests/test_message_exhaustive.py +++ b/tests/test_message_exhaustive.py @@ -482,6 +482,7 @@ def test_message_remaining_branches(self) -> None: # Heating command getters and mode fallbacks assert OWNHeatingCommand.get_temperature("1") is not None assert OWNHeatingCommand.set_mode("1", "unknown") is None + assert OWNHeatingCommand.turn_off("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 From 2bc6c324438e06a5ad98d732df6f1b5c29a88414 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:59:56 +0200 Subject: [PATCH 3/5] fix(#77): send generic protection 302 to the central unit, cite libqtdevices "protection" mapped to 202, the summer protection, which also switches the plant to summer. BTicino's touch-screen client (libqtdevices TS10_1_0_23, thermal_device.cpp and test/test_thermal_device.cpp) sends 302, generic protection, to the central unit; its enum names 102 winter, 202 summer and 302 generic protection. gdluck's sweep lists 302 among the central WHATs the firmware forwards (AUDIT A1). "antifreeze" stays 102, the winter protection that the MyHomeServer1 captures in the golden corpus report as antifreeze. The golden notes for the 99-zone central off/heat/cool fixtures now cite the libqtdevices test that sends the same frames (gdluck's review, 2.1 and 2.2). Co-Authored-By: Claude Opus 5.5 --- OWNd/message/heating.py | 13 +++++++++++-- tests/golden/corpus.json | 8 ++++---- tests/golden/frames/who04_thermo.yaml | 8 ++++---- tests/test_issue_77_firmware_findings.py | 17 ++++++++++++----- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 24b5c1c..60cc3d8 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -715,14 +715,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: 303, CLIMATE_MODE_HEAT: 1, CLIMATE_MODE_COOL: 0, CLIMATE_MODE_AUTO: 311, "antifreeze": 102, - "protection": 202, + "protection": 302, } mode_code = mode_map.get(mode) if mode_code is None: diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index 2982bd8..ac42585 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1211,7 +1211,7 @@ "where": "#0", "source": "encyclopedia", "mcp_valid": true, - "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). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)", + "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", @@ -1232,7 +1232,7 @@ "where": "#0", "source": "encyclopedia", "mcp_valid": true, - "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). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)", + "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", @@ -1253,7 +1253,7 @@ "where": "#0", "source": "encyclopedia", "mcp_valid": true, - "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). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)", + "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", @@ -1323,7 +1323,7 @@ "where": "#0", "source": "encyclopedia", "mcp_valid": true, - "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). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)", + "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); 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", diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index 7bff689..0082a4d 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -83,7 +83,7 @@ where: "#0" source: encyclopedia mcp_valid: true - 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). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)" + 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 @@ -98,7 +98,7 @@ where: "#0" source: encyclopedia mcp_valid: true - 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). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)" + 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 @@ -113,7 +113,7 @@ where: "#0" source: encyclopedia mcp_valid: true - 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). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)" + 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 @@ -162,7 +162,7 @@ where: "#0" source: encyclopedia mcp_valid: true - 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). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)" + 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 index 411b18d..4694155 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -55,16 +55,23 @@ (CLIMATE_MODE_COOL, 0, CLIMATE_MODE_COOL), (CLIMATE_MODE_AUTO, 311, CLIMATE_MODE_AUTO), ("antifreeze", 102, CLIMATE_MODE_OFF), - ("protection", 202, 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 / 202. - - Firmware: these six produce a bus frame for ``#0`` and ``#0#2``; the old - 100 / 101 / 110 produce none, and 102 / 103 / 111 mean something else. + """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. From 301489645310b66ce0906ff7a7b0937908c227b6 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:02:21 +0200 Subject: [PATCH 4/5] fix(#77): build AUTO as *4*311*#Z## for every zone instead of returning None set_mode(AUTO, standalone=True) returned None, so MyHOME sent nothing and logged nothing. MyHOME flags every zone that is not a central unit standalone by default (climate.py: CONF_STANDALONE defaults to "not central"), so this dropped AUTO for zones that do run under a central unit, where *4*311*#Z## works. The firmware forwards 311 only in the # form (AUDIT A3) and BTicino's client sends that form (libqtdevices ControlledProbeDevice::setAutomatic, *4*311*#23#1##), so AUTO now ignores standalone. OFF keeps following it: *4*303*Z## works in both forms. gdluck's review, option (a) of 2.4. Co-Authored-By: Claude Opus 5.5 --- OWNd/message/heating.py | 4 +++- tests/test_issue_77_firmware_findings.py | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index 60cc3d8..b5338a3 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -621,8 +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("#"): - return None + zone = f"#{zone}" mode_code = 311 else: return None diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index 4694155..6b98e75 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -146,15 +146,21 @@ def test_fix2_fan_speed_refuses_input_that_is_not_a_zone_or_speed() -> None: # ── Fix 3: AUTO on a standalone zone ──────────────────────────────────────── -def test_fix3_auto_is_not_built_for_a_plain_zone() -> None: +def test_fix3_auto_is_always_built_for_the_hash_zone() -> None: """Firmware: ``*4*311*1##`` produces no bus frame; ``*4*311*#1##`` does. - ``*4*303*1##`` (off) works in the plain form, so off is still built. + 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 OWNHeatingCommand.set_mode("1", CLIMATE_MODE_AUTO, standalone=True) is None + 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##" From 885b8e43ae658a76fe036f2033e72dd16c8e4e3f Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:02:38 +0200 Subject: [PATCH 5/5] test(golden): resync corpus.json with the 4-zone central note Co-Authored-By: Claude Opus 5.5 --- tests/golden/corpus.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index ac42585..d142942 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1323,7 +1323,7 @@ "where": "#0", "source": "encyclopedia", "mcp_valid": true, - "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); 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)", + "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",