From 3cd18115f9a797818c90934a30227c20b26911e0 Mon Sep 17 00:00:00 2001 From: gdluck <9041273+gdluck@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:55:07 +0000 Subject: [PATCH] fix(heating): read the 115#/215#/315# parameter as a program, not a temperature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OWNd parsed *4*115#1102*#0## as "heat at -10.2 °C". The parameter of the holiday-plan WHATs 115/215/315 is the weekly program the central unit resumes afterwards (Legrand WHO 4 v2.0.0 p. 56 and p. 64: "115#parameterH", parameterH = 1101-1103). libqtdevices TS10_1_0_23 reads it as whatArgN(0) % 100 (thermal_device.cpp:241, 286). The gateway firmware (bt_termo on MyHomeServer1, F454, F459, F461, F450) accepts the frame and forwards it as D1 00 03 02 C1 14 01 00: byte 7 is the program number, there is no temperature field. The event now exposes program (also for 11xx/21xx/31xx WHATs) and scenario (12xx/22xx/32xx), and keeps hvac_mode as its type. 110#T, 210#T and 312#T#2 still read the temperature. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018oHp1P4vxxoFnaLUUZpbpZ --- OWNd/message/heating.py | 36 +++++++++++++++++ tests/test_who4_holiday_plan_program.py | 54 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 tests/test_who4_holiday_plan_program.py diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index a9fda25..7fd84b2 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -183,6 +183,8 @@ 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 @@ -254,11 +256,35 @@ def __init__(self, data: str) -> None: 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 <= 1199 or 2101 <= self._mode <= 2199 or 3101 <= self._mode <= 3199: + self._program = self._mode % 100 + elif 1201 <= self._mode <= 1299 or 2201 <= self._mode <= 2299 or 3201 <= self._mode <= 3299: + self._scenario = self._mode % 100 + if ( + self._mode in (115, 215, 315) + and self._what_param + and self._what_param[0] + ): + # Holiday daily plan: the parameter is the weekly program the + # central unit resumes afterwards, 1101-1103 / 2101-2103 + # (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: @@ -657,6 +683,16 @@ 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..16) 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.""" diff --git a/tests/test_who4_holiday_plan_program.py b/tests/test_who4_holiday_plan_program.py new file mode 100644 index 0000000..d1161f1 --- /dev/null +++ b/tests/test_who4_holiday_plan_program.py @@ -0,0 +1,54 @@ +"""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_COOL, + CLIMATE_MODE_HEAT, + OWNEvent, + 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"