From 8534b03424abd485ab88fc005fea87448c3ff22b Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:02:52 +0200 Subject: [PATCH 1/2] fix(message): treat lighting WHATs outside the WHO 1 table as unknown An MH200 actuator answers the status request *#1*74## with *1*19*74## followed by *#1001*74*11*111110111111111111110111##, a WHO 1001 DIMENSION 11 autodiagnostic mask with two bits cleared. WHAT 19 is not in the published WHO 1 WHAT table (0-18, 20-29, 30/31, 1000) nor in the MyHOME Suite OPEN.db, yet is_on returned True for anything in 1..31, so Home Assistant showed the light permanently on. Any WHAT outside the table (other than motion 34) now leaves the state unknown: is_on is None, the raw value is exposed as unknown_state and the log reads "Light 74 reports fault/unknown state 19." WHATs 32/33 and 39+, which used to come out as "off", are unknown too. 30/31 keep is_on True and now have a log line. Co-Authored-By: Claude Opus 5.5 --- OWNd/message.py | 23 +++++++++++++++++++- tests/test_message_protocol.py | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/OWNd/message.py b/OWNd/message.py index 69a4fe4..f7d1902 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, which an MH200 actuator + # with a WHO 1001 autodiagnostic fault reports): 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 fault/unknown state {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 WHO 1 WHAT table, else None. + + An MH200 actuator answers *1*19*WHERE## together with a WHO 1001 + DIMENSION 11 autodiagnostic mask, so treat it as a fault report. + """ + 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..a13addd 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,44 @@ 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 fault/unknown state 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"fault/unknown state {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)), (34, False)], +) +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 state" not in msg.human_readable_log + + +def test_lighting_step_what_has_a_log() -> None: + assert OWNLightingEvent("*1*30*21##").human_readable_log == "Light 21 is dimmed up one level." + assert OWNLightingEvent("*1*31*21##").human_readable_log == "Light 21 is dimmed down one level." + + 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##" From b931ca280a3b6a395722bb9bfe22e00737d4d044 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:02:26 +0200 Subject: [PATCH 2/2] fix(message): say "unknown lighting WHAT", not "fault", for values outside the table Only 19 has been seen next to a WHO 1001 fault mask; 32, 33, 35 are just not in the table. The docstring no longer presents 19 as a fault report either, since the DIMENSION 11 mask is not interpreted. Tests: motion 34 split out of the "in the table" case (it is an OWNd extension, not WHO_1.pdf), the 1000 wrapper never sets unknown_state, and parameterized 30#x#y / 31#x#y still take the step branch. Co-Authored-By: Claude Opus 5.5 --- OWNd/message.py | 12 +++++----- tests/test_message_protocol.py | 44 ++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/OWNd/message.py b/OWNd/message.py index f7d1902..75ec75e 100755 --- a/OWNd/message.py +++ b/OWNd/message.py @@ -501,12 +501,12 @@ def __init__(self, data: str) -> None: 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, which an MH200 actuator - # with a WHO 1001 autodiagnostic fault reports): the on/off + # 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 fault/unknown state {self._unknown_state}." # pylint: disable=line-too-long + 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 @@ -612,10 +612,10 @@ def is_on(self) -> bool | None: @property def unknown_state(self) -> int | None: - """The raw WHAT when it is not in the WHO 1 WHAT table, else None. + """The raw WHAT when it is not in the published WHO 1 WHAT table, else None. - An MH200 actuator answers *1*19*WHERE## together with a WHO 1001 - DIMENSION 11 autodiagnostic mask, so treat it as a fault report. + 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 diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index a13addd..0916afd 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -221,7 +221,7 @@ def test_lighting_what_19_is_an_unknown_state() -> None: assert isinstance(msg, OWNLightingEvent) assert msg.is_on is None assert msg.unknown_state == 19 - assert msg.human_readable_log == "Light 74 reports fault/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]) @@ -231,24 +231,54 @@ def test_lighting_what_outside_the_table_is_an_unknown_state(what: int) -> None: assert msg.is_on is None assert msg.unknown_state == what assert msg.is_sensor is False - assert f"fault/unknown state {what}" in msg.human_readable_log + 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)), (34, False)], + [(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 state" not in msg.human_readable_log + assert "unknown" not in msg.human_readable_log -def test_lighting_step_what_has_a_log() -> None: - assert OWNLightingEvent("*1*30*21##").human_readable_log == "Light 21 is dimmed up one level." - assert OWNLightingEvent("*1*31*21##").human_readable_log == "Light 21 is dimmed down one level." +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: