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