From 05b73e23e1f0346f3b705e262ee53b0eec39f534 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 1/2] fix(scenario): decode WHO 0 WHAT 40-46 as programming and lock states OWNd reported *0*40#1*W## as "Scenario 40 from control panel W has been launched". WHAT 40-46 are scenario-module states: libqtdevices TS10_1_0_23 defines START_PROG = 40, STOP_PROG = 41, DELETE = 42, LOCK = 43, UNLOCK = 44 (scenario_device.cpp:27-34) and parses the #N parameter as the scenario being programmed (:93-156). The gateway firmware (bt_luci on MyHomeServer1, F454, F459) accepts *0*40#N*W##, 41#N, 42, 42#N, 43 and 44 with distinct bus frames (1C 41 / 61 / 00 / 21 / FF / E0) and refuses bare 40 and 41; it emits *0*42*W##, *0*43*W##, *0*45*W## and *0*46#N*W## for bus frames. OWNScenarioEvent gains event ("launched", "programming started", "programming stopped", "deleted", "locked", "unlocked", "status 45/46") and programming_scenario; scenario is None for these WHATs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018oHp1P4vxxoFnaLUUZpbpZ --- OWNd/message/scenario.py | 42 +++++++++++++++++++++- tests/test_who0_scenario_module_states.py | 44 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/test_who0_scenario_module_states.py diff --git a/OWNd/message/scenario.py b/OWNd/message/scenario.py index 4c1f0aa..5dd6811 100644 --- a/OWNd/message/scenario.py +++ b/OWNd/message/scenario.py @@ -13,7 +13,37 @@ def __init__(self, data: str) -> None: self._scenario = self._what self._control_panel = self._where - self._human_readable_log = f"Scenario {self._scenario} from control panel {self._control_panel} has been launched." # pylint: disable=line-too-long + self._programming_scenario: int | None = None + self._event: str | None = None + # WHAT 40-46 are scenario-module programming states, not scenarios + # (libqtdevices scenario_device.cpp:27-35, 93-156; mhs1 bt_luci + # accepts *0*40#N*W##, 41#N, 42, 42#N, 43, 44 and emits 40#N..46#N). + _programming = { + 40: "programming started", + 41: "programming stopped", + 42: "deleted", + 43: "locked", + 44: "unlocked", + 45: "status 45", + 46: "status 46", + } + if self._what in _programming: + self._event = _programming[self._what] + self._scenario = None + # The grammar only admits digits in a WHAT parameter, so int() + # cannot fail here. + self._programming_scenario = ( + int(self._what_param[0]) if self._what_param else None + ) + _target = ( + f"scenario {self._programming_scenario}" + if self._programming_scenario is not None + else "all scenarios" + ) + self._human_readable_log = f"Scenario module {self._control_panel}: {_target} {self._event}." # pylint: disable=line-too-long + else: + self._event = "launched" + self._human_readable_log = f"Scenario {self._scenario} from control panel {self._control_panel} has been launched." # pylint: disable=line-too-long @property def scenario(self) -> int | None: @@ -23,6 +53,16 @@ def scenario(self) -> int | None: def control_panel(self) -> str | None: return self._control_panel + @property + def event(self) -> str | None: + """'launched' for WHAT 1-31, else the programming/lock state (WHAT 40-46).""" + return self._event + + @property + def programming_scenario(self) -> int | None: + """Scenario named by a WHAT 40/41/42 parameter, None when it targets all.""" + return self._programming_scenario + class OWNAuxEvent(OWNEvent): diff --git a/tests/test_who0_scenario_module_states.py b/tests/test_who0_scenario_module_states.py new file mode 100644 index 0000000..1ee0a10 --- /dev/null +++ b/tests/test_who0_scenario_module_states.py @@ -0,0 +1,44 @@ +"""WHO 0 WHAT 40-46 are scenario-module programming / lock states, not scenarios. + +Evidence: libqtdevices TS10_1_0_23 scenario_device.cpp:27-34 (START_PROG = 40, +STOP_PROG = 41, DELETE = 42, LOCK = 43, UNLOCK = 44) and :93-156 (parseFrame); +firmware replay on bt_luci (MyHomeServer1, F454, F459): *0*40#1*W## ACK bus +11 00 1C 41, *0*41#1*W## 1C 61, *0*42*W## 1C 00, *0*42#1*W## 1C 21, *0*43*W## +1C FF, *0*44*W## 1C E0; emitted by the firmware for bus frames: *0*42*31##, +*0*43*31##, *0*45*99##, *0*46#9*99##. +""" + +import pytest + +from OWNd.message import OWNEvent, OWNScenarioEvent + + +@pytest.mark.parametrize( + ("frame", "event", "target"), + [ + ("*0*40#1*11##", "programming started", 1), + ("*0*41#1*11##", "programming stopped", 1), + ("*0*42*31##", "deleted", None), + ("*0*42#1*11##", "deleted", 1), + ("*0*43*31##", "locked", None), + ("*0*44*11##", "unlocked", None), + ("*0*45*99##", "status 45", None), + ("*0*46#9*99##", "status 46", 9), + ], +) +def test_programming_and_lock_states(frame: str, event: str, target: int | None): + msg = OWNEvent.parse(frame) + assert isinstance(msg, OWNScenarioEvent) + assert msg.event == event + assert msg.programming_scenario == target + assert msg.scenario is None + assert "launched" not in msg.human_readable_log + + +def test_plain_scenario_is_still_launched(): + msg = OWNEvent.parse("*0*3*11##") + assert isinstance(msg, OWNScenarioEvent) + assert msg.scenario == 3 + assert msg.event == "launched" + assert msg.programming_scenario is None + assert msg.human_readable_log == "Scenario 3 from control panel 11 has been launched." From 48210ab672018036eb3ed46ff0c378148d571128 Mon Sep 17 00:00:00 2001 From: gdluck <9041273+gdluck@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:34:05 +0300 Subject: [PATCH 2/2] fix(scenario): name WHAT 45/46 states and ignore status requests Review follow-ups on PR 90: WHAT 45 is 'unavailable' and WHAT 46 is 'memory full' (scenario central unit state indications) instead of the raw 'status 45' / 'status 46' strings, and a status request such as *#0*11## no longer produces a phantom 'Scenario None ... launched' event (scenario and event are None, as OWNAuxEvent does for *#9##). Co-Authored-By: Claude Fable 5.1 --- OWNd/message/scenario.py | 12 ++++++++---- tests/test_who0_scenario_module_states.py | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/OWNd/message/scenario.py b/OWNd/message/scenario.py index 5dd6811..4f8242e 100644 --- a/OWNd/message/scenario.py +++ b/OWNd/message/scenario.py @@ -24,10 +24,14 @@ def __init__(self, data: str) -> None: 42: "deleted", 43: "locked", 44: "unlocked", - 45: "status 45", - 46: "status 46", + 45: "unavailable", + 46: "memory full", } - if self._what in _programming: + if self._what is None: + # Status request (*#0*W##, *#0##): no scenario and no event, as + # OWNAuxEvent does for a bare *#9##. + self._scenario = None + elif self._what in _programming: self._event = _programming[self._what] self._scenario = None # The grammar only admits digits in a WHAT parameter, so int() @@ -55,7 +59,7 @@ def control_panel(self) -> str | None: @property def event(self) -> str | None: - """'launched' for WHAT 1-31, else the programming/lock state (WHAT 40-46).""" + """'launched' for WHAT 1-31, the programming/lock state for WHAT 40-46, None for a status request.""" return self._event @property diff --git a/tests/test_who0_scenario_module_states.py b/tests/test_who0_scenario_module_states.py index 1ee0a10..80e3099 100644 --- a/tests/test_who0_scenario_module_states.py +++ b/tests/test_who0_scenario_module_states.py @@ -22,8 +22,8 @@ ("*0*42#1*11##", "deleted", 1), ("*0*43*31##", "locked", None), ("*0*44*11##", "unlocked", None), - ("*0*45*99##", "status 45", None), - ("*0*46#9*99##", "status 46", 9), + ("*0*45*99##", "unavailable", None), + ("*0*46#9*99##", "memory full", 9), ], ) def test_programming_and_lock_states(frame: str, event: str, target: int | None): @@ -42,3 +42,13 @@ def test_plain_scenario_is_still_launched(): assert msg.event == "launched" assert msg.programming_scenario is None assert msg.human_readable_log == "Scenario 3 from control panel 11 has been launched." + + +@pytest.mark.parametrize("frame", ["*#0*11##", "*#0##"]) +def test_status_request_is_not_a_launch(frame: str): + msg = OWNEvent.parse(frame) + assert isinstance(msg, OWNScenarioEvent) + assert msg.scenario is None + assert msg.event is None + assert msg.programming_scenario is None + assert "launched" not in msg.human_readable_log