diff --git a/OWNd/message/__init__.py b/OWNd/message/__init__.py index 5d4aa9c..cf7b627 100644 --- a/OWNd/message/__init__.py +++ b/OWNd/message/__init__.py @@ -72,9 +72,12 @@ MESSAGE_TYPE_MAIN_TEMPERATURE, MESSAGE_TYPE_MODE, MESSAGE_TYPE_MODE_TARGET, + MESSAGE_TYPE_SEASON, MESSAGE_TYPE_SECONDARY_TEMPERATURE, MESSAGE_TYPE_TARGET_TEMPERATURE, MESSAGE_TYPE_ZONE_STATE, + SEASON_CONDITIONING, + SEASON_HEATING, ZONE_CONTEXT_AUTOMATIC, ZONE_CONTEXT_COOLING, ZONE_CONTEXT_GENERIC, @@ -145,8 +148,11 @@ "MESSAGE_TYPE_LOCAL_TARGET_TEMPERATURE", "MESSAGE_TYPE_MODE", "MESSAGE_TYPE_MODE_TARGET", + "MESSAGE_TYPE_SEASON", "MESSAGE_TYPE_FAN_SPEED", "MESSAGE_TYPE_ZONE_STATE", + "SEASON_CONDITIONING", + "SEASON_HEATING", "CLIMATE_MODE_OFF", "CLIMATE_MODE_HEAT", "CLIMATE_MODE_COOL", diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index a9fda25..d1e0c74 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -16,6 +16,7 @@ MESSAGE_TYPE_LOCAL_TARGET_TEMPERATURE = "local_target_temperature" MESSAGE_TYPE_MODE = "hvac_mode" MESSAGE_TYPE_MODE_TARGET = "hvac_mode_target" +MESSAGE_TYPE_SEASON = "hvac_season" MESSAGE_TYPE_FAN_SPEED = "fan_speed" MESSAGE_TYPE_ZONE_STATE = "zone_state" @@ -25,6 +26,9 @@ CLIMATE_MODE_COOL = "cool" CLIMATE_MODE_AUTO = "auto" +SEASON_HEATING = "heating" +SEASON_CONDITIONING = "conditioning" + LOCAL_CONTROL_NORMAL = "normal" LOCAL_CONTROL_OFFSET = "offset" LOCAL_CONTROL_OFF = "local_off" @@ -173,6 +177,7 @@ def __init__(self, data: str) -> None: self._mode = None self._mode_name = None + self._season: str | None = None self._zone_context: str | None = None self._zone_state: str | None = None self._set_temperature = None @@ -183,9 +188,12 @@ def __init__(self, data: str) -> None: self._measured_temperature = None self._secondary_temperature = None self._measured_humidity = None + self._program: int | None = None + self._scenario: int | None = 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._holiday_days: int | None = None self._is_active = None self._is_heating = None @@ -201,29 +209,66 @@ def __init__(self, data: str) -> None: if self._what is not None: self._mode = int(self._what) - if self._mode in [103, 203, 303, 102, 202, 302]: + + if self._mode in (0, 1) and not self._what_param: + # Zone operation mode frame: the zone (or the central unit) + # is operating in the heating (1) or conditioning (0) season. + # It is not an operating mode change: Legrand WHO 4 p. 13, 16, + # 19 and 63 list it next to the setpoint frames as "zone + # operation mode acquire frame"; the MyHomeServer1 translator + # emits it with the dimension 12 setpoint report; BTicino's + # client keeps a zone in automatic on it (libqtdevices + # TS10_1_0_23 probe_device.cpp:240-253). Manual / automatic / + # off arrive as 110 / 111 / 103 etc. WHAT 0 / 1 with a + # parameter (0#T, 1#T) is in neither the specification nor + # any capture, so it falls through to the unknown branch. + self._type = MESSAGE_TYPE_SEASON + self._mode_name = None + self._season = ( + SEASON_HEATING if self._mode == 1 else SEASON_CONDITIONING + ) + self._human_readable_log = ( + f"Zone {self._zone}'s season is {self._season}." + ) + elif self._mode in [103, 203, 303, 102, 202, 302]: self._type = MESSAGE_TYPE_MODE self._mode_name = CLIMATE_MODE_OFF + if self._mode in (102, 103): + self._season = SEASON_HEATING + elif self._mode in (202, 203): + self._season = SEASON_CONDITIONING self._human_readable_log = ( f"Zone {self._zone}'s mode is set to '{self._mode_name}'" ) elif ( - self._mode in [0, 210, 211, 212, 215] + self._mode in [210, 211, 212, 215] or (self._mode >= 2101 and self._mode <= 2103) or (self._mode >= 2201 and self._mode <= 2216) + or (self._mode >= 23001 and self._mode <= 23255) ): + # 23xxx: holiday days in conditioning mode (Legrand WHO 4 + # p. 5 / p. 64; libqtdevices thermal_device.cpp:58, 258-262). self._type = MESSAGE_TYPE_MODE self._mode_name = CLIMATE_MODE_COOL + self._season = SEASON_CONDITIONING + if self._mode >= 23001: + self._holiday_days = self._mode % 1000 self._human_readable_log = ( f"Zone {self._zone}'s mode is set to '{self._mode_name}'" ) elif ( - self._mode in [1, 110, 111, 112, 115] + self._mode in [110, 111, 112, 115] or (self._mode >= 1101 and self._mode <= 1103) or (self._mode >= 1201 and self._mode <= 1216) + or (self._mode >= 13001 and self._mode <= 13255) ): + # 13xxx: holiday days in heating mode (Legrand WHO 4 p. 5 / + # p. 64; libqtdevices thermal_device.cpp:68, 303-307). self._type = MESSAGE_TYPE_MODE self._mode_name = CLIMATE_MODE_HEAT + self._season = SEASON_HEATING + if self._mode >= 13001: + self._holiday_days = self._mode % 1000 self._human_readable_log = ( f"Zone {self._zone}'s mode is set to '{self._mode_name}'" ) @@ -232,11 +277,11 @@ def __init__(self, data: str) -> None: 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) ): self._type = MESSAGE_TYPE_MODE self._mode_name = CLIMATE_MODE_AUTO + if self._mode >= 33001: + self._holiday_days = self._mode % 1000 self._human_readable_log = ( f"Zone {self._zone}'s mode is set to '{self._mode_name}'" ) @@ -250,22 +295,77 @@ def __init__(self, data: str) -> None: self._human_readable_log = ( f"Zone {self._zone}'s remote control is enabled" ) + elif self._mode in (22, 23, 24, 30, 31): + # Central unit system status (Legrand WHO 4 p. 5, p. 64): + # 22 at least one probe OFF, 23 at least one probe in + # antifreeze, 24 at least one probe in manual, 30 failure + # discovered, 31 central unit battery KO. + self._mode_name = None + _status_text = { + 22: "at least one probe is OFF", + 23: "at least one probe is in antifreeze", + 24: "at least one probe is in manual mode", + 30: "a failure was discovered", + 31: "the central unit battery is KO", + }[self._mode] + self._human_readable_log = ( + f"Zone {self._zone}'s central unit reports {_status_text}" + ) else: self._mode_name = None self._human_readable_log = f"Zone {self._zone}'s mode is unknown" + # Program / scenario numbers carried in the WHAT (Legrand WHO 4 + # p. 5 and p. 64; libqtdevices thermal_device.cpp:209-211, 246-256, + # 291-301): 11xx/21xx/31xx program, 12xx/22xx/32xx scenario. + if self._type == MESSAGE_TYPE_MODE: + if ( + 1101 <= self._mode <= 1103 + or 2101 <= self._mode <= 2103 + or 3101 <= self._mode <= 3116 + ): + self._program = self._mode % 100 + elif ( + 1201 <= self._mode <= 1216 + or 2201 <= self._mode <= 2216 + or 3201 <= self._mode <= 3216 + ): + self._scenario = self._mode % 100 + if ( + ( + self._mode in (115, 215, 315) + or 13001 <= self._mode <= 13255 + or 23001 <= self._mode <= 23255 + or 33001 <= self._mode <= 33255 + ) + and self._what_param + and self._what_param[0] + ): + # Holiday daily plan (115#P) and holiday days (13DDD#P, 23DDD#P, + # 33DDD#P, as sent by set_central_holiday): the parameter is the + # weekly program the central unit resumes afterwards, 1101-1103 / + # 2101-2103 / 31PP (Legrand WHO 4 p. 56 and p. 64, + # "115#parameterH"); libqtdevices reads it as whatArgN(0) % 100 + # (thermal_device.cpp:241, 286). It is not a temperature. The + # grammar only admits digits in a WHAT parameter (base.py + # _STATUS), so int() cannot fail. + self._program = int(self._what_param[0]) % 100 + self._human_readable_log += f" (program {self._program})." + elif ( self._type == MESSAGE_TYPE_MODE and self._what_param and self._what_param[0] is not None ): + # 110#T / 210#T manual with temperature (Legrand WHO 4 p. 23, + # p. 56), 312#T timed manual (libqtdevices thermal_device.cpp:378). self._type = MESSAGE_TYPE_MODE_TARGET self._set_temperature = who4_temperature(self._what_param[0]) if self._set_temperature is not None: self._human_readable_log += f" at {self._set_temperature}°C." else: self._human_readable_log += "." - else: + elif self._type != MESSAGE_TYPE_SEASON: self._human_readable_log += "." if self._dimension == 0: # Temperature @@ -580,6 +680,17 @@ def zone(self) -> int: def mode(self) -> str | None: return self._mode_name + @property + def season(self) -> str | None: + """SEASON_HEATING or SEASON_CONDITIONING named by the WHAT, else None. + + Set for the bare season frame (WHAT 1 / 0, message_type + MESSAGE_TYPE_SEASON, mode None) and for every season-specific mode + WHAT (1xx, 11xx, 12xx, 13xxx heating; 2xx, 21xx, 22xx, 23xxx + conditioning). Generic WHATs (3xx, 31xx, 32xx, 33xxx) have no season. + """ + return self._season + @property def zone_context(self) -> str | None: """DIMENSION 7 thermal context (ZONE_CONTEXT_*), else None.""" @@ -657,11 +768,26 @@ def cooling_fan_speed(self) -> int | None: def cooling_fan_on(self) -> bool | None: return self._cooling_fan_on + @property + def program(self) -> int | None: + """Weekly program number (1..3 for seasonal, 1..16 for generic) named by the WHAT, else None.""" + return self._program + + @property + def scenario(self) -> int | None: + """Scenario number (1..16) named by the WHAT, else None.""" + return self._scenario + @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_days(self) -> int | None: + """Holiday days (1..255) named by a 13xxx/23xxx/33xxx WHAT, else None.""" + return self._holiday_days + @property def holiday_end_time(self) -> tuple[int, int] | None: """Holiday / weekend end time (hour, minute), or None.""" diff --git a/README.md b/README.md index ed3a1aa..0f47bb3 100755 --- a/README.md +++ b/README.md @@ -235,14 +235,14 @@ frame_cenplus_held = OWNCenPlusCommand.still_held(where="21", button=5) # CEN+ (WHO=25): Button 5 release after a long press -> *25*24#5*21## frame_cenplus_rel = OWNCenPlusCommand.release(where="21", button=5) -# Central Unit (WHO=4): Set 3550 (#0) master mode to Heating at 21.5°C -> *4*1#0215*#0## -frame_heat = OWNHeatingCommand.set_central_mode(where="#0", mode="heating", temperature=21.5) +# Central Unit (WHO=4): Set 3550 (#0) master mode to Heating -> *4*1*#0## +frame_heat = OWNHeatingCommand.set_central_mode(where="#0", mode="heating") -# Central Unit (WHO=4): Set 4695 (#0#1) master mode to Cooling at 24.0°C -> *4*2#0240*#0#1## -frame_cool = OWNHeatingCommand.set_central_mode(where="#0#1", mode="cooling", temperature=24.0) +# Central Unit (WHO=4): Set 4695 (#0#1) master mode to Cooling -> *4*0*#0#1## +frame_cool = OWNHeatingCommand.set_central_mode(where="#0#1", mode="cooling") -# Central Unit (WHO=4): Turn Central Unit OFF -> *4*303#0215*#0## -frame_off = OWNHeatingCommand.set_central_off(where="#0", mode="heating", temperature=21.5) +# Central Unit (WHO=4): Turn Central Unit OFF -> *4*303*#0## +frame_off = OWNHeatingCommand.set_central_mode(where="#0", mode="off") ``` --- diff --git a/scripts/oracle_who4_facts.py b/scripts/oracle_who4_facts.py new file mode 100644 index 0000000..bf1f93d --- /dev/null +++ b/scripts/oracle_who4_facts.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Extract WHO 4 firmware facts from the own-firmware-oracle verdict index. + +The oracle (OpenWebNet-HA/own-firmware-oracle) replays OpenWebNet frames on the +emulated firmware of up to ten BTicino/Legrand gateways and records the serial +lines each one sends to its SCS bus interface. An ACKed WHO 4 command becomes +one line in one of two dialects: + + $06 D1 03 02 (all gateways) + $04 30 (plain-zone frames on some) + +This script keeps, per WHO 4 frame, every telegram that at least two gateways +agree on (the gateway ACKed and sent exactly that one line), and records the +rest as dissent. tests/test_firmware_contract.py checks OWNd's decoding of each +frame against those bytes. + +Usage: + python scripts/oracle_who4_facts.py --ref [--index PATH] + [-o tests/oracle/who4_facts.json] + +Without --index the index of --ref is downloaded from GitHub. Standard library +only; the output is sorted and stable, so a changed fixture means changed +firmware facts. +""" + +from __future__ import annotations + +import argparse +import json +import re +import urllib.request +from collections import defaultdict +from pathlib import Path +from typing import Any + +ORACLE_REPO = "OpenWebNet-HA/own-firmware-oracle" +RAW_INDEX = "https://raw.githubusercontent.com/{repo}/{ref}/results/mcp_index.json" +DEFAULT_OUT = Path(__file__).resolve().parent.parent / "tests" / "oracle" / "who4_facts.json" +REF = re.compile(r"^[0-9a-f]{40}$") +LONG = re.compile(r"^\$06D1([0-9A-F]{2})0302([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})$") +SHORT = re.compile(r"^\$04([0-9A-F]{2})([0-9A-F]{2})30([0-9A-F]{2})$") + + +def load_index(index: str | None, ref: str) -> dict[str, Any]: + if index: + return json.loads(Path(index).read_text(encoding="utf-8")) + url = RAW_INDEX.format(repo=ORACLE_REPO, ref=ref) + with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310 - fixed https host + return json.loads(resp.read().decode("utf-8")) + + +def telegram(bus_frame: str) -> tuple[str, ...] | None: + """The fields of one recorded bus line, or None if it is not a WHO 4 telegram. + + Long: ("06", addr, class, b1, b2, b3) + Short: ("04", class, zone, value) + """ + try: + line = bytes.fromhex(bus_frame.replace(" ", "")).decode("ascii").strip() + except (ValueError, UnicodeDecodeError): + return None + if m := LONG.match(line): + return ("06", *m.groups()) + if m := SHORT.match(line): + return ("04", *m.groups()) + return None + + +def extract(index: dict[str, Any]) -> list[dict[str, Any]]: + facts: list[dict[str, Any]] = [] + for frame, entries in sorted(index["verdicts"].items()): + if not frame.startswith(("*4*", "*#4*")): + continue + seen: dict[tuple[str, ...], set[str]] = defaultdict(set) + for e in entries: + if e.get("direction") != "down" or e.get("reply") != "ack": + continue + frames = e.get("bus_frames", []) + t = telegram(frames[0]) if len(frames) == 1 else None + if t is not None: + seen[t].add(f"{e['product']} {e['version']}") + agreed = sorted(t for t, gws in seen.items() if len(gws) >= 2) + dissent = {" ".join(t): sorted(gws) for t, gws in sorted(seen.items()) if len(gws) < 2} + for t in agreed: + facts.append( + { + "frame": frame, + "telegram": list(t), + "gateways": sorted(seen[t]), + "dissent": dissent, + } + ) + return facts + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--ref", required=True, help="oracle commit (40 hex digits)") + ap.add_argument("--index", help="local mcp_index.json of that commit") + ap.add_argument("-o", "--out", type=Path, default=DEFAULT_OUT) + args = ap.parse_args(argv) + if not REF.match(args.ref): + ap.error("--ref must be a full 40-digit commit") + index = load_index(args.index, args.ref) + doc = { + "source": { + "repo": ORACLE_REPO, + "ref": args.ref, + "verdicts_sha256": index.get("verdicts_sha256"), + "generator": "scripts/oracle_who4_facts.py", + }, + "facts": extract(index), + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(doc, indent=1, sort_keys=True) + "\n", encoding="utf-8") + print(f"{len(doc['facts'])} WHO 4 facts -> {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/oracle/who4_facts.json b/tests/oracle/who4_facts.json new file mode 100644 index 0000000..5ee52bd --- /dev/null +++ b/tests/oracle/who4_facts.json @@ -0,0 +1,1745 @@ +{ + "facts": [ + { + "dissent": {}, + "frame": "*#4*#0#2*#11*1##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "02", + "0B", + "01", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*#0*#11*1##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "00", + "0B", + "01", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*#0*#14*0210*1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "12", + "2A", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*#0*#14*0250*1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "12", + "32", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*#0*#30*07*10*2026##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C3", + "07", + "0A", + "1A" + ] + }, + { + "dissent": {}, + "frame": "*#4*#0*#31*18*30##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C4", + "12", + "1E", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*#0*#32*04*15##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C5", + "04", + "0F", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*#1*#11*1##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "00", + "0B", + "01", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C2 17 12 2B": [ + "H4684 020054" + ] + }, + "frame": "*#4*#23#1*#14*0215*1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C2", + "17", + "12", + "2B" + ] + }, + { + "dissent": { + "06 00 C2 17 22 2E": [ + "H4684 020054" + ] + }, + "frame": "*#4*#23#1*#14*0230*2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024" + ], + "telegram": [ + "06", + "01", + "C2", + "17", + "22", + "2E" + ] + }, + { + "dissent": { + "06 00 C2 17 02 32": [ + "H4684 020054" + ] + }, + "frame": "*#4*#23#1*#14*0250*3##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C2", + "17", + "02", + "32" + ] + }, + { + "dissent": {}, + "frame": "*#4*#99*#11*1##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "00", + "0B", + "01", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*1*#11*0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024" + ], + "telegram": [ + "06", + "01", + "0B", + "00", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*1*#11*1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "0B", + "01", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*1*#11*2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024" + ], + "telegram": [ + "06", + "01", + "0B", + "02", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*1*#11*3##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024" + ], + "telegram": [ + "06", + "01", + "0B", + "03", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*1*#11*4##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "01", + "0B", + "04", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*23*#11*0##", + "gateways": [ + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "17", + "0B", + "00", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*23*#11*1##", + "gateways": [ + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "17", + "0B", + "01", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*23*#11*2##", + "gateways": [ + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "17", + "0B", + "02", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*23*#11*3##", + "gateways": [ + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "17", + "0B", + "03", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*99*#11*0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024" + ], + "telegram": [ + "06", + "63", + "0B", + "00", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*99*#11*1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "63", + "0B", + "01", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*99*#11*2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024" + ], + "telegram": [ + "06", + "63", + "0B", + "02", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*#4*99*#11*3##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024" + ], + "telegram": [ + "06", + "63", + "0B", + "03", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*0*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "F1", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*0*#0#2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "02", + "C1", + "F1", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*0*1##", + "gateways": [ + "F454 020051", + "F459 020105", + "MyHomeServer1 028206" + ], + "telegram": [ + "04", + "93", + "01", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*0*1##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "01", + "C1", + "F1", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*0*23##", + "gateways": [ + "F459 020105", + "MyHomeServer1 028206" + ], + "telegram": [ + "04", + "93", + "17", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*0*23##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "17", + "C1", + "F1", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*1*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "F0", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*1*#0#2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "02", + "C1", + "F0", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*1*1##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "01", + "C1", + "F0", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*1*23##", + "gateways": [ + "F459 020105", + "MyHomeServer1 028206" + ], + "telegram": [ + "04", + "93", + "17", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*1*23##", + "gateways": [ + "F453AV 030014", + "MH200N 010108" + ], + "telegram": [ + "06", + "17", + "C1", + "F0", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "17", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C1 17 00 00": [ + "H4684 020054" + ] + }, + "frame": "*4*102*#0#2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "02", + "C1", + "17", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C2 17 17 00": [ + "H4684 020054" + ] + }, + "frame": "*4*102*#23#1##", + "gateways": [ + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C2", + "17", + "17", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*103*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "16", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*103*#0#2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "02", + "C1", + "16", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*111*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C2", + "00", + "10", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*111*#0#2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "02", + "C2", + "00", + "10", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*112#0210#5*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "19", + "2A", + "05" + ] + }, + { + "dissent": {}, + "frame": "*4*115#1101*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "14", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*115#1102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "14", + "01", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*115#1103*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "14", + "02", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*115*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "14", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*13001#3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "15", + "01", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*13005#1102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "15", + "05", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*13005#3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "15", + "05", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*13255#3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "15", + "FF", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*202*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "27", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C1 27 00 00": [ + "H4684 020054" + ] + }, + "frame": "*4*202*#0#2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "02", + "C1", + "27", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C2 17 27 00": [ + "H4684 020054" + ] + }, + "frame": "*4*202*#23#1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C2", + "17", + "27", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*212#0210#5*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "29", + "2A", + "05" + ] + }, + { + "dissent": {}, + "frame": "*4*215#2101*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "24", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*215#2102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "24", + "01", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*215#2103*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024" + ], + "telegram": [ + "06", + "00", + "C1", + "24", + "02", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*23005#2102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "25", + "05", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*23005#3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "25", + "05", + "01" + ] + }, + { + "dissent": { + "06 00 C2 17 07 00": [ + "H4684 020054" + ] + }, + "frame": "*4*302*#23#1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C2", + "17", + "07", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*303*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "06", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C1 06 00 00": [ + "H4684 020054" + ] + }, + "frame": "*4*303*#0#2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "02", + "C1", + "06", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C2 17 06 00": [ + "H4684 020054" + ] + }, + "frame": "*4*303*#23#1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C2", + "17", + "06", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*3101*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "01", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C1 01 00 00": [ + "H4684 020054" + ] + }, + "frame": "*4*3101*#0#1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C1", + "01", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "01", + "01", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*311*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C2", + "00", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*311*#0#2##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "02", + "C2", + "00", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*311*#1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C2", + "01", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C2 17 00 00": [ + "H4684 020054" + ] + }, + "frame": "*4*311*#23#1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C2", + "17", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*311*#99##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C2", + "63", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*3116*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "01", + "0F", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*312#0210#5*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "09", + "2A", + "05" + ] + }, + { + "dissent": {}, + "frame": "*4*312#0210#5*#1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "09", + "2A", + "05" + ] + }, + { + "dissent": {}, + "frame": "*4*315#3101*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "04", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*315#3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "04", + "01", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*3201*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "03", + "00", + "00" + ] + }, + { + "dissent": { + "06 00 C1 03 00 00": [ + "H4684 020054" + ] + }, + "frame": "*4*3201*#0#1##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "01", + "C1", + "03", + "00", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*3202*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "03", + "01", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*3216*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "03", + "0F", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*33001#3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "05", + "01", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*33005#3101*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "05", + "05", + "00" + ] + }, + { + "dissent": {}, + "frame": "*4*33005#3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "05", + "05", + "01" + ] + }, + { + "dissent": {}, + "frame": "*4*33255#3102*#0##", + "gateways": [ + "F453AV 030014", + "F454 020051", + "F459 020105", + "H4684 020054", + "MH200N 010108", + "MH202 010024", + "MyHomeServer1 028206" + ], + "telegram": [ + "06", + "00", + "C1", + "05", + "FF", + "01" + ] + } + ], + "source": { + "generator": "scripts/oracle_who4_facts.py", + "ref": "f400fc67eb1abd105736a91418cafaf811be0224", + "repo": "OpenWebNet-HA/own-firmware-oracle", + "verdicts_sha256": "b9c6cc5484f31c7bc74339bf7f32d17ebd238b8181d64bb56d3f0dc2049e9231" + } +} diff --git a/tests/test_firmware_contract.py b/tests/test_firmware_contract.py new file mode 100644 index 0000000..6b97729 --- /dev/null +++ b/tests/test_firmware_contract.py @@ -0,0 +1,183 @@ +"""OWNd's WHO 4 decoding checked against what gateway firmware puts on the bus. + +tests/oracle/who4_facts.json holds, per WHO 4 frame, the serial telegram that +at least two emulated BTicino/Legrand gateways sent to their SCS bus interface +after ACKing the frame (OpenWebNet-HA/own-firmware-oracle, extracted by +scripts/oracle_who4_facts.py; the commit is in the file). The telegram bytes +are the firmware's own reading of the frame, so OWNd must read it the same way. + +Telegram grammar (consistent across MH200N, MyHomeServer1, F453AV, F454, F459, +F460, F461, MH202, H4684): + + $06 D1 03 02 C1 central-unit command, addr = zone + $06 D1 03 02 C2 zone (#Z) command + $06 D1 03 02 0B 00 00 fan-coil speed, addr = zone (00 and + no zone when written via the central) + $04 93 30 <00|01|0F> plain zone: heating / cooling / off + + op high nibble: 0 generic, 1 heating, 2 conditioning (F: season frame) + op low nibble: 0 automatic 1 weekly program (a1 = P-1) + 2 setpoint (a1 = T*2) 3 scenario (a1 = S-1) + 4 holiday plan (a1 = return program - 1) + 5 vacation (a1 = days, a2 = return program - 1) + 6 off 7 protection 9 timed manual (a1 = T*2) + +Every telegram class in the fixture is either checked below or listed in +NOT_DECODED with the reason, and every difference OWNd keeps on purpose is in +KNOWN_DIFFERENCES: a new firmware fact therefore fails this test until someone +decides what OWNd does with it. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from OWNd.message import ( + CLIMATE_MODE_OFF, + MESSAGE_TYPE_SEASON, + SEASON_CONDITIONING, + SEASON_HEATING, + OWNEvent, + OWNHeatingEvent, +) + +FACTS = Path(__file__).resolve().parent / "oracle" / "who4_facts.json" +DOC = json.loads(FACTS.read_text(encoding="utf-8")) + +SEASON_NIBBLE = {0x0: None, 0x1: SEASON_HEATING, 0x2: SEASON_CONDITIONING} + +# Telegram classes OWNd does not decode, with the reason. Adding decoding for +# one of them means moving it from here into a rule below. +NOT_DECODED = { + "C3": "central-unit date write (*#4*#0*#30*D*M*Y##): OWNd has no date decoding", + "C4": "central-unit time write (*#4*#0*#31*H*M##): OWNd has no time decoding", + "C5": "central-unit holiday end write (*#4*#0*#32*…##): not decoded by OWNd", +} + +# Frames whose OWNd decoding differs from the bus on purpose: frame -> (the one +# aspect not compared, why). Everything else about the frame is still checked. +KNOWN_DIFFERENCES = { + "*4*115*#0##": ( + "program", + "the frame names no return program; gateways put program 1 (index 0) on " + "the bus, OWNd reports program None because the frame does not state one", + ), + "*#4*1*#11*4##": ( + "fan_speed", + "speed 4 is outside the specification (0 automatic, 1..3, 15 off); " + "gateways pass the byte through, OWNd reports no speed", + ), + "*4*312#0210#5*#1##": ( + "zone", + "timed manual is a central-unit operation: gateways address the central " + "(addr 00) whatever zone the frame names; OWNd reports the zone written", + ), +} + + +def _waived(frame: str, aspect: str) -> bool: + return KNOWN_DIFFERENCES.get(frame, ("", ""))[0] == aspect + + +def _decode(frame: str) -> OWNHeatingEvent: + event = OWNEvent.parse(frame) + assert isinstance(event, OWNHeatingEvent), f"{frame} is not a heating event" + return event + + +def _check_operation(frame: str, event: OWNHeatingEvent, op: int, a1: int, a2: int) -> None: + """The parts of a central/zone command every class shares.""" + what_frame = frame.startswith("*4*") + if op in (0xF0, 0xF1): + assert event.message_type == MESSAGE_TYPE_SEASON + assert event.mode is None + assert event.season == (SEASON_HEATING if op == 0xF0 else SEASON_CONDITIONING) + return + if what_frame: + # The setpoint write's season (*#4*…*#14*T*M##, M) is not reported by OWNd. + assert event.season == SEASON_NIBBLE[op >> 4], "season" + kind = op & 0x0F + if kind == 0x0: + assert event.mode not in (None, CLIMATE_MODE_OFF), "automatic" + elif kind == 0x1: + assert event.program == a1 + 1, "weekly program" + elif kind == 0x2: + assert event.set_temperature == a1 / 2, "setpoint" + elif kind == 0x3: + assert event.scenario == a1 + 1, "scenario" + elif kind == 0x4: + assert event.set_temperature is None, "holiday plan carries no temperature" + if not _waived(frame, "program"): + assert event.program == a1 + 1, "holiday return program" + elif kind == 0x5: + assert event.set_temperature is None, "vacation carries no temperature" + assert event.holiday_days == a1, "vacation days" + assert event.program == a2 + 1, "vacation return program" + elif kind in (0x6, 0x7): + assert event.mode == CLIMATE_MODE_OFF, "off / protection" + elif kind == 0x9: + assert event.set_temperature == a1 / 2, "timed manual setpoint" + else: + pytest.fail(f"{frame}: operation {op:02X} has no contract rule") + + +def check(fact: dict[str, Any]) -> None: + frame, t = fact["frame"], fact["telegram"] + event = _decode(frame) + if t[0] == "04": + _, cls, zone, value = t + assert cls == "93", f"{frame}: short telegram class {cls} has no contract rule" + assert event.zone == int(zone, 16), "zone" + if value == "0F": + assert event.mode == CLIMATE_MODE_OFF + else: + assert event.message_type == MESSAGE_TYPE_SEASON + assert event.season == {"00": SEASON_HEATING, "01": SEASON_CONDITIONING}[value] + return + _, addr, cls, b1, b2, b3 = t + if cls in NOT_DECODED: + return + if cls == "C1": + if not _waived(frame, "zone"): + assert event.zone == int(addr, 16), "zone" + _check_operation(frame, event, int(b1, 16), int(b2, 16), int(b3, 16)) + elif cls == "C2": + # Zone b1; zone N of a 4-zone central (#0#N) goes out as zone 0, addr N. + zone = int(addr, 16) if b1 == "00" and "#0#" in frame else int(b1, 16) + assert event.zone == zone, "zone" + _check_operation(frame, event, int(b2, 16), int(b3, 16), 0) + elif cls == "0B": + # Written through the central (*#4*#Z*#11*S##) the telegram goes to the + # central (addr 00) and carries no zone: #1 and #99 give the same bytes. + if not frame.startswith("*#4*#"): + assert event.zone == int(addr, 16), "zone" + if not _waived(frame, "fan_speed"): + assert event.fan_speed == int(b1, 16), "fan speed" + else: + pytest.fail(f"{frame}: telegram class {cls} has no contract rule") + + +@pytest.mark.parametrize( + "fact", DOC["facts"], ids=[f"{f['frame']} {' '.join(f['telegram'])}" for f in DOC["facts"]] +) +def test_ownd_reads_the_frame_as_the_firmware_does(fact: dict[str, Any]) -> None: + check(fact) + + +def test_the_facts_are_pinned_to_an_oracle_commit() -> None: + source = DOC["source"] + assert source["repo"] == "OpenWebNet-HA/own-firmware-oracle" + assert len(source["ref"]) == 40 + assert DOC["facts"], "no WHO 4 facts extracted" + + +def test_every_exception_is_still_needed() -> None: + """A stale entry in NOT_DECODED or KNOWN_DIFFERENCES is a silent gap.""" + classes = {f["telegram"][2] for f in DOC["facts"] if f["telegram"][0] == "06"} + frames = {f["frame"] for f in DOC["facts"]} + assert set(NOT_DECODED) <= classes + assert set(KNOWN_DIFFERENCES) <= frames diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index f799176..1f74b5a 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -29,6 +29,7 @@ CLIMATE_MODE_COOL, CLIMATE_MODE_HEAT, CLIMATE_MODE_OFF, + MESSAGE_TYPE_SEASON, OWNCenPlusCommand, OWNCENPlusEvent, OWNCommand, @@ -40,6 +41,8 @@ OWNHeatingEvent, OWNLightingCommand, OWNMessage, + SEASON_CONDITIONING, + SEASON_HEATING, ) REPO_ROOT = Path(__file__).resolve().parents[1] @@ -53,15 +56,15 @@ ("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_HEAT, 1, None), + (CLIMATE_MODE_COOL, 0, None), (CLIMATE_MODE_AUTO, 311, CLIMATE_MODE_AUTO), ("antifreeze", 102, CLIMATE_MODE_OFF), ("protection", 302, CLIMATE_MODE_OFF), ], ) def test_fix1_central_mode_uses_the_who4_what_table( - where: str, mode: str, what: int, parsed_mode: str + where: str, mode: str, what: int, parsed_mode: str | None ) -> None: """Central-unit modes use WHAT 303 / 1 / 0 / 311 / 102 / 302. @@ -80,12 +83,18 @@ def test_fix1_central_mode_uses_the_who4_what_table( 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). + (antifreeze and protection are shown as off). WHAT 1 / 0 are the season + frames: they come back as ``season`` heating / conditioning with no + operating mode (Legrand WHO 4 v2.0.0 p. 5, 13, 63). """ command = OWNHeatingCommand.set_central_mode(where, mode) assert str(command) == f"*4*{what}*{where}##" - assert OWNHeatingEvent(str(command)).mode == parsed_mode + event = OWNHeatingEvent(str(command)) + assert event.mode == parsed_mode + if what in (1, 0): + assert event.message_type == MESSAGE_TYPE_SEASON + assert event.season == (SEASON_HEATING if what == 1 else SEASON_CONDITIONING) def test_fix1_captured_central_unit_mode_parses_as_cooling() -> None: @@ -96,7 +105,9 @@ def test_fix1_captured_central_unit_mode_parses_as_cooling() -> None: """ event = OWNHeatingEvent("*4*0*#0##") - assert event.mode == CLIMATE_MODE_COOL + assert event.message_type == MESSAGE_TYPE_SEASON + assert event.season == SEASON_CONDITIONING + assert event.mode is None assert str(OWNHeatingCommand.set_central_mode("#0", CLIMATE_MODE_COOL)) == "*4*0*#0##" diff --git a/tests/test_message_edges.py b/tests/test_message_edges.py index 4d3ac4d..03179d1 100644 --- a/tests/test_message_edges.py +++ b/tests/test_message_edges.py @@ -196,15 +196,32 @@ def test_mode_auto_variant_315(self): assert isinstance(msg, OWNHeatingEvent) assert msg.mode == CLIMATE_MODE_AUTO - def test_mode_auto_weekly_23001(self): + def test_mode_holiday_23001_is_conditioning(self): + # Legrand WHO 4 p. 5 and p. 64: 23xxx = holiday days in conditioning + # mode; libqtdevices thermal_device.cpp:58 (SUM_HOLIDAY = 23000) and + # :258-262 (season SE_SUMMER). msg = OWNEvent.parse("*4*23001*1##") assert isinstance(msg, OWNHeatingEvent) - assert msg.mode == CLIMATE_MODE_AUTO + assert msg.mode == CLIMATE_MODE_COOL + assert msg.holiday_days == 1 - def test_mode_auto_weekly_13001(self): + def test_mode_holiday_13001_is_heating(self): + # Legrand WHO 4 p. 5 and p. 64: 13xxx = holiday days in heating mode; + # libqtdevices thermal_device.cpp:68 (WIN_HOLIDAY = 13000) and + # :303-307 (season SE_WINTER). msg = OWNEvent.parse("*4*13001*1##") assert isinstance(msg, OWNHeatingEvent) + assert msg.mode == CLIMATE_MODE_HEAT + assert msg.holiday_days == 1 + + def test_mode_holiday_33004_is_generic(self): + msg = OWNEvent.parse("*4*33004*#0##") assert msg.mode == CLIMATE_MODE_AUTO + assert msg.holiday_days == 4 + + def test_holiday_days_is_none_for_other_modes(self): + assert OWNEvent.parse("*4*1*1##").holiday_days is None + assert OWNEvent.parse("*4*3101*#0##").holiday_days is None def test_unknown_mode(self): msg = OWNEvent.parse("*4*999*1##") diff --git a/tests/test_message_exhaustive.py b/tests/test_message_exhaustive.py index 44664ba..6ced1d8 100644 --- a/tests/test_message_exhaustive.py +++ b/tests/test_message_exhaustive.py @@ -158,8 +158,9 @@ def test_automation_states_and_commands(self) -> None: assert str(OWNAutomationCommand.set_shutter_level("31", 50)) == "*#2*31*#11#001*50##" def test_heating_dimensions_and_commands(self) -> None: - # Mode Target with temperature string (WHAT=1#0215) - ev_target = OWNHeatingEvent("*4*1#0215*1##") + # Mode Target with temperature string (WHAT=110#0215, manual heating + # with temperature, Legrand WHO 4 p. 23 / p. 56) + ev_target = OWNHeatingEvent("*4*110#0215*1##") assert ev_target.message_type == MESSAGE_TYPE_MODE_TARGET assert ev_target.set_temperature == 21.5 assert "21.5" in ev_target.human_readable_log diff --git a/tests/test_who4_central_status_whats.py b/tests/test_who4_central_status_whats.py new file mode 100644 index 0000000..8dc0890 --- /dev/null +++ b/tests/test_who4_central_status_whats.py @@ -0,0 +1,32 @@ +"""WHO 4 WHAT 22/23/24/30/31 are central-unit status reports (Legrand WHO 4 +v2.0.0 p. 5, p. 24, p. 64; libqtdevices TS10_1_0_23 thermal_device.cpp:47-48 +MALFUNCTIONING_FOUND = 30, BATTERY_KO = 31).""" + +import pytest + +from OWNd.message import OWNEvent, OWNHeatingEvent + + +@pytest.mark.parametrize( + ("what", "text"), + [ + (22, "at least one probe is OFF"), + (23, "at least one probe is in antifreeze"), + (24, "at least one probe is in manual mode"), + (30, "a failure was discovered"), + (31, "the central unit battery is KO"), + ], +) +def test_central_unit_status_whats_are_named(what: int, text: str): + msg = OWNEvent.parse(f"*4*{what}*#0##") + assert isinstance(msg, OWNHeatingEvent) + assert msg.mode is None + assert msg.message_type != "hvac_mode" + assert text in msg.human_readable_log + assert "unknown" not in msg.human_readable_log + + +def test_other_unknown_whats_still_log_unknown(): + msg = OWNEvent.parse("*4*999*1##") + assert msg.mode is None + assert "unknown" in msg.human_readable_log diff --git a/tests/test_who4_f453av_traces.py b/tests/test_who4_f453av_traces.py new file mode 100644 index 0000000..f3a413d --- /dev/null +++ b/tests/test_who4_f453av_traces.py @@ -0,0 +1,89 @@ +"""WHO 4: decoding of real F453AV bus captures (central unit #0, BTicino plant). + +Captures: OpenWebNet-HA/OWNd#97 comment 6110745254 (@nce2704), F453AV, 2026-10-11, taken +with the MyHOME trace export while the central unit was driven from the Freedompro app: + +* ``Holiday ON`` : daily holiday started on the central unit. +* ``Holiday OFF auto``: the holiday expired by itself. +* ``one zone to cool``: one zone switched Heat -> Cool, the whole plant follows. +* ``Antifreeze ON`` / ``Central to OFF``: central unit modes. +* baseline : plant in winter, zones on the weekly program. + +Only the distinct WHO 4 status frames are pinned here, not the raw exports. +""" + +import pytest + +from OWNd.message import ( + CLIMATE_MODE_HEAT, + CLIMATE_MODE_OFF, + MESSAGE_TYPE_MODE, + MESSAGE_TYPE_SEASON, + SEASON_CONDITIONING, + SEASON_HEATING, + OWNEvent, + OWNHeatingEvent, +) + + +def _parse(raw: str) -> OWNHeatingEvent: + msg = OWNEvent.parse(raw) + assert isinstance(msg, OWNHeatingEvent) + return msg + + +def test_holiday_on_report_carries_the_program_not_a_temperature(): + # Daily holiday ON: the central answers 115#1101 (program 1), not -10.1 °C. + msg = _parse("*4*115#1101*#0##") + assert msg.message_type == MESSAGE_TYPE_MODE + assert msg.mode == CLIMATE_MODE_HEAT + assert msg.season == SEASON_HEATING + assert msg.program == 1 + assert msg.set_temperature is None + + +def test_holiday_expiry_restores_the_weekly_program(): + # Holiday OFF automatically: the central broadcasts 1101, then the zone reports 111. + msg = _parse("*4*1101*#0##") + assert msg.message_type == MESSAGE_TYPE_MODE + assert msg.mode == CLIMATE_MODE_HEAT + assert msg.program == 1 + assert msg.set_temperature is None + assert _parse("*4*111*#10##").mode == CLIMATE_MODE_HEAT + + +@pytest.mark.parametrize("raw", ["*4*1*3##", "*4*1*4##", "*4*1*7##"]) +def test_season_frame_next_to_a_programmed_zone_is_not_a_mode(raw): + # Baseline: *4*1*Z## arrives while the zone reports a weekly program (*4*111*#Z##). + msg = _parse(raw) + assert msg.message_type == MESSAGE_TYPE_SEASON + assert msg.season == SEASON_HEATING + assert msg.mode is None + + +def test_central_switching_to_summer_reports_thermal_protection(): + # One zone Heat -> Cool: the plant switches to summer and the central parks in + # thermal protection (*4*202*#0##), which is "off" in the conditioning season. + msg = _parse("*4*202*#0##") + assert msg.mode == CLIMATE_MODE_OFF + assert msg.season == SEASON_CONDITIONING + + +@pytest.mark.parametrize("raw", ["*4*102*#0##", "*4*103*#0##"]) +def test_central_antifreeze_and_off_in_winter(raw): + msg = _parse(raw) + assert msg.mode == CLIMATE_MODE_OFF + assert msg.season == SEASON_HEATING + + +@pytest.mark.parametrize("raw", ["*4*21*#0##", "*4*22*#0##"]) +def test_central_status_frames_do_not_set_a_mode(raw): + msg = _parse(raw) + assert msg.mode is None + assert msg.season is None + + +def test_zone_off_report_does_not_claim_a_season(): + msg = _parse("*4*303*1##") + assert msg.mode == CLIMATE_MODE_OFF + assert msg.season is None diff --git a/tests/test_who4_holiday_plan_program.py b/tests/test_who4_holiday_plan_program.py new file mode 100644 index 0000000..a1f6178 --- /dev/null +++ b/tests/test_who4_holiday_plan_program.py @@ -0,0 +1,193 @@ +"""WHO 4: the parameter of 115#/215#/315# is a weekly program, not a temperature. + +Evidence: Legrand "Open Web Net Language, WHO = 4" v2.0.0 p. 56 and p. 64 +("115#parameterH", parameterH = 1101-1103); libqtdevices TS10_1_0_23 +thermal_device.cpp:241 and :286 (``values_list[DIM_PROGRAM] = msg.whatArgN(0) % 100``); +firmware replay: ``*4*115#1102*#0##`` is ACKed by bt_termo on MyHomeServer1, F454, +F459, F461 and F450 and forwarded as ``D1 00 03 02 C1 14 01 00`` (program byte 01, +no temperature field), see docs in the pull request. +""" + +from OWNd.message import ( + CLIMATE_MODE_AUTO, + CLIMATE_MODE_COOL, + CLIMATE_MODE_HEAT, + SEASON_CONDITIONING, + SEASON_HEATING, + OWNEvent, + OWNHeatingCommand, + OWNHeatingEvent, +) + + +def test_holiday_plan_parameter_is_a_program_not_a_temperature(): + msg = OWNEvent.parse("*4*115#1102*#0##") + assert isinstance(msg, OWNHeatingEvent) + assert msg.mode == CLIMATE_MODE_HEAT + assert msg.program == 2 + assert msg.set_temperature is None + assert msg.message_type == "hvac_mode" + + +def test_cooling_holiday_plan_parameter(): + msg = OWNEvent.parse("*4*215#2102*#0##") + assert msg.mode == CLIMATE_MODE_COOL + assert msg.program == 2 + assert msg.set_temperature is None + + +def test_program_and_scenario_numbers_are_exposed(): + # thermal_device.cpp:209-211: program = what - commandRange(what) + assert OWNEvent.parse("*4*1102*#0##").program == 2 + assert OWNEvent.parse("*4*2102*#0##").program == 2 + assert OWNEvent.parse("*4*3113*#0##").program == 13 + assert OWNEvent.parse("*4*1203*#0##").scenario == 3 + assert OWNEvent.parse("*4*3209*#0##").scenario == 9 + assert OWNEvent.parse("*4*1*23##").program is None + assert OWNEvent.parse("*4*1*23##").scenario is None + + +def test_manual_with_temperature_parameter_still_reads_the_temperature(): + # Legrand WHO 4 p. 23 / p. 56: 110#T manual heating with temperature. + msg = OWNEvent.parse("*4*110#0215*#0##") + assert msg.set_temperature == 21.5 + assert msg.message_type == "hvac_mode_target" + # thermal_device.cpp:378: 312#T#2 timed manual, generic mode. + msg = OWNEvent.parse("*4*312#0220#2*#0##") + assert msg.set_temperature == 22.0 + assert msg.message_type == "hvac_mode_target" + + +def test_holiday_days_parameter_is_a_program_not_a_temperature(): + # 13DDD#P / 23DDD#P / 33DDD#P carry the program the plant resumes after the + # holiday, exactly like 115#P; 13005#1102 used to read as -10.2 degrees. + heat = OWNEvent.parse("*4*13005#1102*#0##") + assert heat.mode == CLIMATE_MODE_HEAT + assert heat.holiday_days == 5 + assert heat.program == 2 + assert heat.set_temperature is None + assert heat.message_type == "hvac_mode" + + cool = OWNEvent.parse("*4*23005#2102*#0##") + assert cool.mode == CLIMATE_MODE_COOL + assert cool.holiday_days == 5 + assert cool.program == 2 + assert cool.set_temperature is None + assert cool.message_type == "hvac_mode" + + auto = OWNEvent.parse("*4*33005#3102*#0##") + assert auto.mode == CLIMATE_MODE_AUTO + assert auto.holiday_days == 5 + assert auto.program == 2 + assert auto.set_temperature is None + assert auto.message_type == "hvac_mode" + assert "program 2" in auto.human_readable_log + + +def test_set_central_holiday_round_trips_without_a_temperature(): + command = OWNHeatingCommand.set_central_holiday(days=5, program=2) + assert str(command) == "*4*33005#3102*#0##" + + event = OWNHeatingEvent(str(command)) + assert event.mode == CLIMATE_MODE_AUTO + assert event.holiday_days == 5 + assert event.program == 2 + assert event.set_temperature is None + assert event.message_type == "hvac_mode" + + +def test_holiday_days_without_a_parameter_keep_no_program(): + msg = OWNEvent.parse("*4*13005*#0##") + assert msg.holiday_days == 5 + assert msg.program is None + assert msg.set_temperature is None + + +def test_multi_day_vacation_uses_the_generic_return_program_in_every_season(): + # Encyclopedia WHO 4 "Vacation, program and scenario forms": the multi-day + # vacation commands use 3101..3103 in all three contexts (13DDD / 23DDD / + # 33DDD), unlike the daily holiday plan (1101..1103 / 2101..2103). + # 3102 used to decode as a -10.2 degree setpoint. + heat = OWNEvent.parse("*4*13005#3102*#0##") + assert (heat.mode, heat.holiday_days, heat.program) == (CLIMATE_MODE_HEAT, 5, 2) + assert heat.set_temperature is None + assert heat.message_type == "hvac_mode" + + cool = OWNEvent.parse("*4*23005#3102*#0##") + assert (cool.mode, cool.holiday_days, cool.program) == (CLIMATE_MODE_COOL, 5, 2) + assert cool.set_temperature is None + assert cool.message_type == "hvac_mode" + + +def test_multi_day_vacation_countdown_and_expiry_cycle(): + """Verify complete multi-day vacation cycle grounded in firmware contracts. + + Grounded in: + 1. Gateway bt_termo scs2open (0x0000EC74 / 0x0000ECF8): broadcasts bare *4*%d*%s## + without #P parameter (13DDD / 23DDD / 33DDD). + 2. 3550 Central Unit firmware (3550_030020.mot 0x0EF881): decrements active vacation + day counter via SUB.B #1, 0x2414 every midnight tick. + 3. Expiration handler (bt_termo 0x0000D2A8): logs ' Invio frame di fine ferie' + and broadcasts *4*%d*#0## restoring the return program. + """ + # 1. Client initiates 5-day vacation command with return program 2 + cmd = OWNEvent.parse("*4*13005#3102*#0##") + assert cmd.mode == CLIMATE_MODE_HEAT + assert cmd.season == SEASON_HEATING + assert cmd.holiday_days == 5 + assert cmd.program == 2 + assert cmd.set_temperature is None + + # 2. Central unit / gateway broadcasts active vacation telemetry without #P + active = OWNEvent.parse("*4*13005*#0##") + assert active.mode == CLIMATE_MODE_HEAT + assert active.season == SEASON_HEATING + assert active.holiday_days == 5 + assert active.program is None + assert active.set_temperature is None + + # 3. Midnight countdown decrements across remaining days (SUB.B #1 in 3550 CU) + for day in (4, 3, 2, 1): + tick = OWNEvent.parse(f"*4*13{day:03d}*#0##") + assert tick.mode == CLIMATE_MODE_HEAT + assert tick.season == SEASON_HEATING + assert tick.holiday_days == day + assert tick.program is None + + # 4. Vacation expires: central unit restores weekly program 2 via *4*1102*#0## + expiry = OWNEvent.parse("*4*1102*#0##") + assert expiry.mode == CLIMATE_MODE_HEAT + assert expiry.season == SEASON_HEATING + assert expiry.program == 2 + assert expiry.holiday_days is None + assert expiry.set_temperature is None + + # 5. Cooling season equivalent cycle (23DDD) + cool_cmd = OWNEvent.parse("*4*23005#3102*#0##") + assert (cool_cmd.mode, cool_cmd.season, cool_cmd.holiday_days, cool_cmd.program) == ( + CLIMATE_MODE_COOL, + SEASON_CONDITIONING, + 5, + 2, + ) + cool_active = OWNEvent.parse("*4*23005*#0##") + assert (cool_active.mode, cool_active.season, cool_active.holiday_days, cool_active.program) == ( + CLIMATE_MODE_COOL, + SEASON_CONDITIONING, + 5, + None, + ) + cool_tick = OWNEvent.parse("*4*23001*#0##") + assert (cool_tick.mode, cool_tick.season, cool_tick.holiday_days) == ( + CLIMATE_MODE_COOL, + SEASON_CONDITIONING, + 1, + ) + cool_expiry = OWNEvent.parse("*4*2102*#0##") + assert (cool_expiry.mode, cool_expiry.season, cool_expiry.program, cool_expiry.holiday_days) == ( + CLIMATE_MODE_COOL, + SEASON_CONDITIONING, + 2, + None, + ) + diff --git a/tests/test_who4_season.py b/tests/test_who4_season.py new file mode 100644 index 0000000..e2cd61b --- /dev/null +++ b/tests/test_who4_season.py @@ -0,0 +1,92 @@ +"""WHAT 1 / 0 on WHO 4 is the season (zone operation mode), not an operating mode. + +Legrand WHO 4 v2.0.0 p. 5 (WHAT table: 0 conditioning mode, 1 heating mode), p. 13, +16, 19 and 63 (zone operation mode acquire frame, listed next to the setpoint frames); +libqtdevices TS10_1_0_23 probe_device.cpp:240-253 keeps a zone in automatic on it; the +MyHomeServer1 translator emits it together with the dimension 12 setpoint report +(512 of 534 dimension-12 reports in the emitted corpus). +""" + +from __future__ import annotations + +import pytest + +from OWNd.message import ( + CLIMATE_MODE_AUTO, + CLIMATE_MODE_COOL, + CLIMATE_MODE_HEAT, + CLIMATE_MODE_OFF, + MESSAGE_TYPE_MODE, + MESSAGE_TYPE_MODE_TARGET, + MESSAGE_TYPE_SEASON, + SEASON_CONDITIONING, + SEASON_HEATING, + OWNEvent, + OWNHeatingEvent, +) + + +@pytest.mark.parametrize( + ("frame", "season", "zone"), + [ + ("*4*1*23##", SEASON_HEATING, 23), + ("*4*0*23##", SEASON_CONDITIONING, 23), + ("*4*1*#0##", SEASON_HEATING, 0), + ("*4*0*#0#1##", SEASON_CONDITIONING, 1), + ], +) +def test_bare_what_0_and_1_are_season_frames(frame: str, season: str, zone: int) -> None: + event = OWNEvent.parse(frame) + assert isinstance(event, OWNHeatingEvent) + assert event.message_type == MESSAGE_TYPE_SEASON + assert event.season == season + assert event.mode is None + assert event.zone == zone + assert event.human_readable_log == f"Zone {zone}'s season is {season}." + + +@pytest.mark.parametrize( + ("frame", "mode", "season", "message_type"), + [ + ("*4*110*#1##", CLIMATE_MODE_HEAT, SEASON_HEATING, MESSAGE_TYPE_MODE), + ("*4*111*#1##", CLIMATE_MODE_HEAT, SEASON_HEATING, MESSAGE_TYPE_MODE), + ("*4*210*#1##", CLIMATE_MODE_COOL, SEASON_CONDITIONING, MESSAGE_TYPE_MODE), + ("*4*110#0215*#0##", CLIMATE_MODE_HEAT, SEASON_HEATING, MESSAGE_TYPE_MODE_TARGET), + ("*4*102*1##", CLIMATE_MODE_OFF, SEASON_HEATING, MESSAGE_TYPE_MODE), + ("*4*202*1##", CLIMATE_MODE_OFF, SEASON_CONDITIONING, MESSAGE_TYPE_MODE), + ("*4*103*1##", CLIMATE_MODE_OFF, SEASON_HEATING, MESSAGE_TYPE_MODE), + ("*4*1102*#0##", CLIMATE_MODE_HEAT, SEASON_HEATING, MESSAGE_TYPE_MODE), + ("*4*2201*#0##", CLIMATE_MODE_COOL, SEASON_CONDITIONING, MESSAGE_TYPE_MODE), + ("*4*311*#1##", CLIMATE_MODE_AUTO, None, MESSAGE_TYPE_MODE), + ("*4*303*1##", CLIMATE_MODE_OFF, None, MESSAGE_TYPE_MODE), + ("*4*3102*#0##", CLIMATE_MODE_AUTO, None, MESSAGE_TYPE_MODE), + ], +) +def test_season_of_mode_whats(frame: str, mode: str, season: str | None, message_type: str) -> None: + event = OWNHeatingEvent(frame) + assert event.mode == mode + assert event.season == season + assert event.message_type == message_type + + +def test_non_mode_frames_have_no_season() -> None: + assert OWNHeatingEvent("*#4*1*0*0215##").season is None + assert OWNHeatingEvent("*4*21*#0##").season is None + + +def test_unrecognised_whats_have_no_season() -> None: + for frame in ( + "*4*150*1##", + "*4*1104*#0##", + "*4*1199*#0##", + "*4*13000*1##", + "*4*13256*1##", + # 0#T / 1#T: no specification form and no capture. + "*4*1#0215*1##", + "*4*0#0215*1##", + ): + event = OWNHeatingEvent(frame) + assert event.season is None + assert event.message_type is None + assert event.mode is None + assert event.set_temperature is None