diff --git a/OWNd/message/__init__.py b/OWNd/message/__init__.py index 5d4aa9c..788c982 100644 --- a/OWNd/message/__init__.py +++ b/OWNd/message/__init__.py @@ -33,6 +33,8 @@ OWNCENPlusEvent, OWNCenCommand, OWNCenPlusCommand, + OWNScenarioPlusCommand, + OWNScenarioPlusEvent, OWNDryContactCommand, OWNDryContactEvent, ) @@ -189,6 +191,8 @@ "OWNCenCommand", "OWNDryContactCommand", "OWNCenPlusCommand", + "OWNScenarioPlusEvent", + "OWNScenarioPlusCommand", # Sound & AV (WHO 16 & 22 & 7) "OWNSoundEvent", "OWNAVCommand", diff --git a/OWNd/message/cen.py b/OWNd/message/cen.py index 2dfeb71..21e6afc 100644 --- a/OWNd/message/cen.py +++ b/OWNd/message/cen.py @@ -155,6 +155,78 @@ def human_readable_log(self) -> str: +class OWNScenarioPlusEvent(OWNEvent): + """WHO 25 WHAT 11-15: scenario-plus / dimmer-like commands on a WHERE. + + libqtdevices ScenarioPlusDevice sends 11#0 on, 12 off, 13#0#5 increase, + 14#0#5 decrease, 15 stop (scenario_device.cpp:37-41, 163-186); mhs1 bt_luci + accepts all five and echoes them on the monitor port. + """ + + _NAMES = {11: "on", 12: "off", 13: "increase", 14: "decrease", 15: "stop"} + + def __init__(self, data: str) -> None: + super().__init__(data) + self._action = self._NAMES.get(self._what) if self._what is not None else None + self.object = self._where + _params = "#".join(self._what_param) if self._what_param else "" + self._human_readable_log = ( + f"Scenario plus {self._where}: {self._action}" + + (f" (parameters {_params})" if _params else "") + + "." + ) + + @property + def action(self) -> str | None: + return self._action + + +class OWNScenarioPlusCommand(OWNCommand): + """Builders for the five WHO 25 frames libqtdevices ScenarioPlusDevice sends.""" + + _NAMES = {11: "on", 12: "off", 13: "increase", 14: "decrease", 15: "stop"} + + def __init__(self, data: str) -> None: + super().__init__(data) + self._action = self._NAMES.get(self._what) if self._what is not None else None + self.object = self._where + self._human_readable_log = f"Scenario plus {self._where}: {self._action}." + + @property + def action(self) -> str | None: + return self._action + + @classmethod + def turn_on(cls, where: str | int) -> OWNScenarioPlusCommand: + message = cls(f"*25*11#0*{where}##") + message._human_readable_log = f"Scenario plus {where}: on." + return message + + @classmethod + def turn_off(cls, where: str | int) -> OWNScenarioPlusCommand: + message = cls(f"*25*12*{where}##") + message._human_readable_log = f"Scenario plus {where}: off." + return message + + @classmethod + def increase(cls, where: str | int) -> OWNScenarioPlusCommand: + message = cls(f"*25*13#0#5*{where}##") + message._human_readable_log = f"Scenario plus {where}: increase." + return message + + @classmethod + def decrease(cls, where: str | int) -> OWNScenarioPlusCommand: + message = cls(f"*25*14#0#5*{where}##") + message._human_readable_log = f"Scenario plus {where}: decrease." + return message + + @classmethod + def stop(cls, where: str | int) -> OWNScenarioPlusCommand: + message = cls(f"*25*15*{where}##") + message._human_readable_log = f"Scenario plus {where}: stop." + return message + + class OWNDryContactCommand(OWNCommand): @classmethod def status(cls, where: str | int) -> OWNDryContactCommand: @@ -280,6 +352,8 @@ def _parse_who25_event(data: str) -> OWNEvent: what_code = None if what_code is not None and 21 <= what_code <= 28: return OWNCENPlusEvent(data) + if what_code is not None and 11 <= what_code <= 15: + return OWNScenarioPlusEvent(data) if what_code is not None and what_code not in (31, 32): return OWNEvent(data) return OWNDryContactEvent(data) @@ -296,6 +370,8 @@ def _parse_who25_command(data: str) -> OWNCommand: what_code = None if what_code is not None and 21 <= what_code <= 28: return OWNCenPlusCommand(data) + if what_code is not None and 11 <= what_code <= 15: + return OWNScenarioPlusCommand(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) diff --git a/tests/test_issue_77_firmware_findings.py b/tests/test_issue_77_firmware_findings.py index f799176..cd1b47e 100644 --- a/tests/test_issue_77_firmware_findings.py +++ b/tests/test_issue_77_firmware_findings.py @@ -25,6 +25,8 @@ import pytest from OWNd.message import ( + OWNScenarioPlusCommand, + OWNScenarioPlusEvent, CLIMATE_MODE_AUTO, CLIMATE_MODE_COOL, CLIMATE_MODE_HEAT, @@ -396,15 +398,22 @@ def test_fix8_interface_is_read_for_scenarios_and_who14(frame: str) -> None: # ── Fix 9: other WHO 25 messages ──────────────────────────────────────────── -@pytest.mark.parametrize("frame", ["*25*11#121*10##", "*25*12*131##"]) -def test_fix9_other_who25_messages_are_not_dry_contacts(frame: str) -> None: +@pytest.mark.parametrize( + ("frame", "action"), + [("*25*11#121*10##", "on"), ("*25*12*131##", "off"), ("*25*13#0#5*11##", "increase")], +) +def test_fix9_other_who25_messages_are_not_dry_contacts(frame: str, action: str) -> None: """Emitted: the firmware sends WHAT 11..15 on ``1xx`` addresses. - Encyclopedia: dry contacts are WHAT 31 and 32 only. + Encyclopedia: dry contacts are WHAT 31 and 32 only. libqtdevices + ScenarioPlusDevice sends 11#0 / 12 / 13#0#5 / 14#0#5 / 15 + (scenario_device.cpp:37-41) and mhs1 bt_luci accepts and echoes all five + (oracle3/out/luci.tsv), so they are scenario-plus events. """ event = OWNEvent.parse(frame) - assert type(event) is OWNEvent + assert isinstance(event, OWNScenarioPlusEvent) + assert event.action == action assert not isinstance(event, OWNDryContactEvent) @@ -425,8 +434,8 @@ def test_fix9_captured_who25_messages_keep_their_class(frame: str, cls: type) -> @pytest.mark.parametrize( ("frame", "cls"), [ - ("*25*11#121*10##", OWNCommand), - ("*25*12*131##", OWNCommand), + ("*25*11#121*10##", OWNScenarioPlusCommand), + ("*25*12*131##", OWNScenarioPlusCommand), ("*25*31#1*339##", OWNDryContactCommand), ("*25*32#1*33##", OWNDryContactCommand), ("*#25*331##", OWNDryContactCommand), diff --git a/tests/test_who25_scenario_plus.py b/tests/test_who25_scenario_plus.py new file mode 100644 index 0000000..c20b57a --- /dev/null +++ b/tests/test_who25_scenario_plus.py @@ -0,0 +1,56 @@ +"""WHO 25 WHAT 11-15 are scenario-plus commands (libqtdevices TS10_1_0_23 +scenario_device.cpp:37-41 and :163-186). Firmware replay on bt_luci +(MyHomeServer1, F454, F459): all five ACK with a bus frame (B1 01 93 00, +94 00, 95 40, 96 40, 97 00) and are echoed on the monitor port.""" + +from OWNd.message import OWNCommand, OWNEvent, OWNScenarioPlusCommand, OWNScenarioPlusEvent + + +def test_builders_send_the_ts10_frames(): + assert str(OWNScenarioPlusCommand.turn_on(11)) == "*25*11#0*11##" + assert str(OWNScenarioPlusCommand.turn_off(11)) == "*25*12*11##" + assert str(OWNScenarioPlusCommand.increase(11)) == "*25*13#0#5*11##" + assert str(OWNScenarioPlusCommand.decrease(11)) == "*25*14#0#5*11##" + assert str(OWNScenarioPlusCommand.stop(11)) == "*25*15*11##" + + +def test_commands_and_events_are_routed(): + for frame, action in [ + ("*25*11#0*11##", "on"), + ("*25*12*11##", "off"), + ("*25*13#0#5*11##", "increase"), + ("*25*14#0#5*11##", "decrease"), + ("*25*15*11##", "stop"), + ]: + cmd = OWNCommand.parse(frame) + assert isinstance(cmd, OWNScenarioPlusCommand) + assert cmd.action == action + assert cmd.object == "11" + assert cmd.human_readable_log == f"Scenario plus 11: {action}." + + event = OWNEvent.parse(frame) + assert isinstance(event, OWNScenarioPlusEvent) + assert event.action == action + assert event.object == "11" + + +def test_classes_exported_in_all(): + import OWNd.message as message_mod + + assert "OWNScenarioPlusCommand" in message_mod.__all__ + assert "OWNScenarioPlusEvent" in message_mod.__all__ + + +def test_firmware_emitted_forms_parse(): + # bt_luci prints WHERE 1xx for these events (64 corpus lines for 12 and 15) + event = OWNEvent.parse("*25*12*131##") + assert isinstance(event, OWNScenarioPlusEvent) + assert event.action == "off" + event = OWNEvent.parse("*25*11#121*10##") + assert event.action == "on" + + +def test_other_who25_whats_stay_bare_events_and_commands(): + """WHATs outside 11-15, 21-28 and 31/32 keep the PR 82 rule: a bare event / command.""" + assert type(OWNEvent.parse("*25*16*1##")) is OWNEvent + assert type(OWNCommand.parse("*25*16*1##")) is OWNCommand