From 04ac92c08c8a3d35cfe0b662f33fcaf86c8b71e0 Mon Sep 17 00:00:00 2001 From: gdluck <9041273+gdluck@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:55:08 +0000 Subject: [PATCH] fix(heating): decode WHAT 1 / 0 as the season, not an operating mode *4*1*Z## and *4*0*Z## are the zone operation mode frame (Legrand WHO 4 v2.0.0 p. 5, 13, 16, 19, 63): the zone, or the central unit, operates in the heating (1) or conditioning (0) season. OWNHeatingEvent read them as mode heat / cool, which made a zone running the central unit's program look like a manual heat / cool zone. They now carry message_type MESSAGE_TYPE_SEASON, season SEASON_HEATING / SEASON_CONDITIONING and mode None; every season-specific mode WHAT (1xx, 11xx, 12xx, 13xxx / 2xx, 21xx, 22xx, 23xxx) also exposes its season. Evidence: libqtdevices TS10_1_0_23 probe_device.cpp:240-253 keeps a zone in automatic on the frame; the MyHomeServer1 translator emits it with the dimension 12 setpoint report (512 of 534 reports in the emitted corpus). Co-Authored-By: Claude Opus 5.5 --- OWNd/message/__init__.py | 3 + OWNd/message/heating.py | 49 ++++++++++++++-- tests/test_issue_77_firmware_findings.py | 21 +++++-- tests/test_message_exhaustive.py | 5 +- tests/test_who4_season.py | 74 ++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 tests/test_who4_season.py diff --git a/OWNd/message/__init__.py b/OWNd/message/__init__.py index 5d4aa9c..1a6d745 100644 --- a/OWNd/message/__init__.py +++ b/OWNd/message/__init__.py @@ -72,6 +72,9 @@ MESSAGE_TYPE_MAIN_TEMPERATURE, MESSAGE_TYPE_MODE, MESSAGE_TYPE_MODE_TARGET, + MESSAGE_TYPE_SEASON, + SEASON_CONDITIONING, + SEASON_HEATING, MESSAGE_TYPE_SECONDARY_TEMPERATURE, MESSAGE_TYPE_TARGET_TEMPERATURE, MESSAGE_TYPE_ZONE_STATE, diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index a9fda25..8d5cc27 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 @@ -201,14 +206,39 @@ 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]: + # Season of every mode WHAT (Legrand WHO 4 v2.0.0 p. 5: 1xx / 11xx / + # 12xx / 13xxx heating, 2xx / 21xx / 22xx / 23xxx conditioning, + # 3xx / 31xx / 32xx / 33xxx generic): the thousands or hundreds + # digit names the season, independently of the operating mode. + _season_digit = str(self._mode)[0] if self._mode >= 100 else None + if self._mode == 1 or _season_digit == "1": + self._season = SEASON_HEATING + elif self._mode == 0 or _season_digit == "2": + self._season = SEASON_CONDITIONING + + if self._mode in (0, 1): + # 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. + self._type = MESSAGE_TYPE_SEASON + self._mode_name = None + 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 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) ): @@ -218,7 +248,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, 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) ): @@ -265,7 +295,7 @@ def __init__(self, data: str) -> 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 +610,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.""" diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index f799176..e5bc356 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -41,6 +41,7 @@ OWNLightingCommand, OWNMessage, ) +from OWNd.message import MESSAGE_TYPE_SEASON, SEASON_CONDITIONING, SEASON_HEATING # OWNd#94 REPO_ROOT = Path(__file__).resolve().parents[1] @@ -53,15 +54,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 +81,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 +103,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_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_season.py b/tests/test_who4_season.py new file mode 100644 index 0000000..49ed02c --- /dev/null +++ b/tests/test_who4_season.py @@ -0,0 +1,74 @@ +"""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