Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions OWNd/message/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
OWNCENPlusEvent,
OWNCenCommand,
OWNCenPlusCommand,
OWNScenarioPlusCommand,
OWNScenarioPlusEvent,
OWNDryContactCommand,
OWNDryContactEvent,
)
Expand Down Expand Up @@ -189,6 +191,8 @@
"OWNCenCommand",
"OWNDryContactCommand",
"OWNCenPlusCommand",
"OWNScenarioPlusEvent",
"OWNScenarioPlusCommand",
# Sound & AV (WHO 16 & 22 & 7)
"OWNSoundEvent",
"OWNAVCommand",
Expand Down
76 changes: 76 additions & 0 deletions OWNd/message/cen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
21 changes: 15 additions & 6 deletions tests/test_issue_77_firmware_findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import pytest

from OWNd.message import (
OWNScenarioPlusCommand,
OWNScenarioPlusEvent,
CLIMATE_MODE_AUTO,
CLIMATE_MODE_COOL,
CLIMATE_MODE_HEAT,
Expand Down Expand Up @@ -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)


Expand All @@ -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),
Expand Down
56 changes: 56 additions & 0 deletions tests/test_who25_scenario_plus.py
Original file line number Diff line number Diff line change
@@ -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
Loading