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
23 changes: 22 additions & 1 deletion OWNd/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
69 changes: 69 additions & 0 deletions tests/test_message_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
OWNHeatingCommand,
OWNHeatingEvent,
OWNLightingCommand,
OWNLightingEvent,
MESSAGE_TYPE_FAN_SPEED,
OWNMessage,
OWNSoundCommand,
Expand Down Expand Up @@ -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##"
Expand Down
Loading