diff --git a/OWNd/message.py b/OWNd/message.py index 69a4fe4..75ec75e 100755 --- a/OWNd/message.py +++ b/OWNd/message.py @@ -430,6 +430,7 @@ def __init__(self, data: str) -> None: self._type: str | None = None self._state: int | None = None + self._unknown_state: int | None = None self._brightness: int | None = None self._brightness_preset: int | None = None self._transition: int | None = None @@ -492,10 +493,20 @@ def __init__(self, data: str) -> None: elif self._state >= 20 and self._state <= 29: # Light blinking self._blinker = 0.5 * (self._state - 19) self._human_readable_log = f"Light {self._where}{self._interface_log_text} is blinking every {self._blinker}s." + elif self._state == 30 or self._state == 31: # One level up/down + direction = "up" if self._state == 30 else "down" + self._human_readable_log = f"Light {self._where}{self._interface_log_text} is dimmed {direction} one level." # pylint: disable=line-too-long elif self._state == 34: # Motion detected self._type = MESSAGE_TYPE_MOTION self._motion = True self._human_readable_log = f"Light/motion sensor {self._where}{self._interface_log_text} detected motion" + else: + # Not in the WHO 1 WHAT table (e.g. 19, seen from an MH200 + # actuator next to a WHO 1001 autodiagnostic mask): the on/off + # state is unknown, not "on". + self._unknown_state = self._state + self._state = None + self._human_readable_log = f"Light {self._where}{self._interface_log_text} reports unknown lighting WHAT {self._unknown_state}." # pylint: disable=line-too-long if self._dimension is not None and self._dimension_value: if self._dimension == 1 or self._dimension == 4: # Brightness value @@ -593,11 +604,21 @@ def transition(self) -> int | None: @property def is_on(self) -> bool | None: """True/False when the on/off state is known, None otherwise (e.g. a - reply carrying only a dimension such as illuminance or a timer).""" + reply carrying only a dimension such as illuminance or a timer, or a + WHAT outside the published table; see unknown_state).""" if self._state is None: return None return 0 < self._state < 32 + @property + def unknown_state(self) -> int | None: + """The raw WHAT when it is not in the published WHO 1 WHAT table, else None. + + 19 has been seen from an MH200 actuator next to a WHO 1001 DIMENSION 11 + autodiagnostic mask; the mask itself is not interpreted here. + """ + return self._unknown_state + @property def is_sensor(self) -> bool: return ( diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index 68545e4..0916afd 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -23,6 +23,7 @@ OWNHeatingCommand, OWNHeatingEvent, OWNLightingCommand, + OWNLightingEvent, MESSAGE_TYPE_FAN_SPEED, OWNMessage, OWNSoundCommand, @@ -212,6 +213,74 @@ def test_shutter_level_outside_0_100_is_an_unknown_position() -> None: assert closed.is_position_unknown is False +def test_lighting_what_19_is_an_unknown_state() -> None: + # MH200, 2026-09-24: *#1*74## answers *1*19*74## plus a WHO 1001 + # DIMENSION 11 fault mask. 19 is not in the WHO 1 WHAT table. + msg = OWNEvent.parse("*1*19*74##") + + assert isinstance(msg, OWNLightingEvent) + assert msg.is_on is None + assert msg.unknown_state == 19 + assert msg.human_readable_log == "Light 74 reports unknown lighting WHAT 19." + + +@pytest.mark.parametrize("what", [19, 32, 33, 35, 39, 99]) +def test_lighting_what_outside_the_table_is_an_unknown_state(what: int) -> None: + msg = OWNLightingEvent(f"*1*{what}*21##") + + assert msg.is_on is None + assert msg.unknown_state == what + assert msg.is_sensor is False + assert f"unknown lighting WHAT {what}" in msg.human_readable_log + + +@pytest.mark.parametrize( + ("what", "is_on"), + [(0, False), *((w, True) for w in range(1, 19)), *((w, True) for w in range(20, 32))], +) +def test_lighting_what_in_the_table_keeps_its_state(what: int, is_on: bool) -> None: + msg = OWNLightingEvent(f"*1*{what}*21##") + + assert msg.is_on is is_on + assert msg.unknown_state is None + assert "unknown" not in msg.human_readable_log + + +def test_lighting_motion_what_34_is_not_an_unknown_state() -> None: + # 34 is not in WHO_1.pdf; OWNd decodes it as motion (ZigBee variant). + msg = OWNLightingEvent("*1*34*21##") + + assert msg.motion is True + assert msg.is_on is False + assert msg.unknown_state is None + + +@pytest.mark.parametrize("frame", ["*1*1000#1*74##", "*1*1000#19*74##"]) +def test_lighting_translation_wrapper_is_not_an_unknown_state(frame: str) -> None: + msg = OWNLightingEvent(frame) + + assert msg.is_translation is True + assert msg.is_on is None + assert msg.unknown_state is None + + +@pytest.mark.parametrize( + ("frame", "log"), + [ + ("*1*30*21##", "Light 21 is dimmed up one level."), + ("*1*31*21##", "Light 21 is dimmed down one level."), + ("*1*30#2#3*21##", "Light 21 is dimmed up one level."), + ("*1*31#2#3*21##", "Light 21 is dimmed down one level."), + ], +) +def test_lighting_step_what_has_a_log(frame: str, log: str) -> None: + msg = OWNLightingEvent(frame) + + assert msg.is_on is True + assert msg.unknown_state is None + assert msg.human_readable_log == log + + def test_sound_volume_down_uses_documented_step_what() -> None: # Encyclopedia who-16-sound-system: up = 1001..1015, down = 1101..1115. assert str(OWNSoundCommand.volume_up("21")) == "*16*1001*21##"