From 356fe72672e989cbe4681d856a99e1715849188b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:36:27 +0000 Subject: [PATCH] 5.17: a Home Security "all clear" clears only what has ended - An idle notification (event 0x00) names the event that ended in its first event parameter. It used to clear motion AND tamper whatever it named, so motion ending hid a tamper still active. Now a named idle clears only that event (motion family -> motion; tamper -> tamper); an idle naming nothing clears both, as before. Same rule as zwave-js 15.31.2 (lib/node/CCHandlers/NotificationCC.js), checked in the published package. - Home Security events drive onOffState only on Motion and Generic devices. A contact on a node that also reports tamper or motion had its primary state set by those events, so a tamper clearing showed an open door as closed. The motion and tamper states still update on every device that has them. - displayStatus after an idle shows what is still active. - Tests: the two idle xfails now pass (markers removed); new tests for named and unnamed idles, each motion-family event, unrelated events, the properties-byte flags, contacts and generic devices. 281 passed. - Version 5.17: Info.plist, plugin.py header, README, docs/changelog.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Z2FM7PBbhgq9QjrM5W2HY --- README.md | 4 +- .../Contents/Info.plist | 2 +- .../Contents/Server Plugin/plugin.py | 86 +++++++++++++-- docs/changelog.md | 5 + tests/test_zwave_code_tables.py | 101 ++++++++++++++++-- 5 files changed, 179 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index aed13a4..c67b5c3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Show the readings Indigo leaves out when it does not fully know a Z-Wave sensor, as Indigo devices of their own.** -**Version:** 5.16 | **Author:** CliveS & Claude | **Needs:** Indigo 2022.1 or later, with a Z-Wave interface +**Version:** 5.17 | **Author:** CliveS & Claude | **Needs:** Indigo 2022.1 or later, with a Z-Wave interface **[Read the full guide](https://highsteads.github.io/UniversalZWaveSensor/)** — setting up, what everything means, and what to do when something goes wrong. @@ -53,6 +53,8 @@ The [full guide](https://highsteads.github.io/UniversalZWaveSensor/) goes throug ## What's new +**v5.17** — When a sensor reports that one thing has ended, only that thing is cleared. Before, motion ending also cleared a **Tamper** that was still active, and a tamper clearing could make a door sensor on the same device look closed while the door was open. Motion and tamper reports now only change the main on/off state of a **Motion** or **Generic** device. + **v5.16** — Some sensor readings were filed under the wrong name. A water sensor using the older binary report showed as **Tamper** rather than **Water Leak**, a tamper showed as **Motion**, and readings such as current, voltage and CO2 landed in the wrong state, so a plug's current in amps appeared as its power in watts. The plugin's lookup tables now follow the Z-Wave standard. A reading the plugin has no state for is now reported as unknown rather than stored somewhere wrong. **v5.15** — A sensor's **Online** state now says online from the moment the plugin starts it, and whenever it reports, so **Show Status** no longer calls a working sensor offline. A lock's **Latch Closed** state no longer shows the opposite of the truth. One of the sample messages in **Simulate Z-Wave Report** was labelled the wrong way round, and the old manual inside the plugin has gone, as the guide replaces it. diff --git a/UniversalZWaveSensor.indigoPlugin/Contents/Info.plist b/UniversalZWaveSensor.indigoPlugin/Contents/Info.plist index dd34b99..f11aee9 100644 --- a/UniversalZWaveSensor.indigoPlugin/Contents/Info.plist +++ b/UniversalZWaveSensor.indigoPlugin/Contents/Info.plist @@ -27,7 +27,7 @@ IwsApiVersion 1.0.0 PluginVersion - 5.16 + 5.17 ServerApiVersion 3.0 diff --git a/UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py b/UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py index 9d31651..f549378 100644 --- a/UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py @@ -7,7 +7,20 @@ # natively. Uses subscribeToIncoming() to receive ALL Z-Wave bytes. # Author: CliveS & Claude Opus 4.8, Claude Opus 5.5 # Date: 01-11-2026 -# Version: 5.16 +# Version: 5.17 +# +# v5.17 (01-11-2026): a Home Security "all clear" clears only what has ended. +# * An idle notification (event 0x00) names the event that ended in its first +# event parameter. It used to clear motion AND tamper whatever it named, so +# motion ending hid a tamper that was still active. Now a named idle clears +# only that event (motion/intrusion/glass break -> motion, tamper -> tamper); +# an idle naming nothing clears both, as before. Matches zwave-js 15.31.2 +# (CCHandlers/NotificationCC.js: "The target value is the first byte of the +# event parameters", else "Reset all values to idle"). +# * Home Security events drive the primary on/off state only on Motion and +# Generic devices. A door contact on a node that also reports tamper or +# motion used to have its primary state set by those events, so a tamper +# clearing showed an open door as closed. # # v5.16 (01-11-2026): the two Z-Wave code tables now follow the Z-Wave standard # (checked against the zwave-js project's definitions). @@ -296,6 +309,9 @@ HS_MOTION_DETECTED = 0x07 # Motion Detection, location provided HS_MOTION_DETECTED_NL = 0x08 # Motion Detection, unknown location HS_TAMPER_ALT = 0x09 # Tampering — product moved / alternate code +# Events this plugin records in the "motion" state, so an idle naming any of +# them clears motion (5.17). +HS_MOTION_FAMILY = (0x01, 0x02, 0x05, 0x06, 0x07, 0x08) # ACCESS_CONTROL events — door/window open/close AC_DOOR_OPEN = 0x16 @@ -1114,11 +1130,15 @@ def _handle_notification(self, device, raw) -> bool: _dt = self._sensor_type(device) _motion_disp = _dt in ("motion", "generic") _log = self.logger.info if _motion_disp else self.logger.debug + # The primary on/off state follows Home Security only where motion + # IS the device's job (5.17). On a contact or lock device it means + # something else, and a tamper or motion report from the same node + # must not change it. if notif_event in (HS_MOTION_DETECTED, HS_MOTION_DETECTED_NL): _log(f"{device.name}: Motion DETECTED") self._safe_update(device, "motion", value=True, uiValue="detected") - device.updateStateOnServer("onOffState", value=True) if _motion_disp: + device.updateStateOnServer("onOffState", value=True) self._safe_update(device, "displayStatus", value="detected") elif notif_event in (HS_TAMPER, HS_TAMPER_ALT): _log(f"{device.name}: Tamper DETECTED") @@ -1128,22 +1148,17 @@ def _handle_notification(self, device, raw) -> bool: elif notif_event in (HS_INTRUSION, HS_INTRUSION_NL): _log(f"{device.name}: Intrusion DETECTED") self._safe_update(device, "motion", value=True, uiValue="intrusion") - device.updateStateOnServer("onOffState", value=True) if _motion_disp: + device.updateStateOnServer("onOffState", value=True) self._safe_update(device, "displayStatus", value="intrusion") elif notif_event in (HS_GLASS_BREAK, HS_GLASS_BREAK_NL): _log(f"{device.name}: Glass break DETECTED") self._safe_update(device, "motion", value=True, uiValue="glass break") - device.updateStateOnServer("onOffState", value=True) if _motion_disp: + device.updateStateOnServer("onOffState", value=True) self._safe_update(device, "displayStatus", value="glass break") elif notif_event == HS_IDLE: - self.logger.debug(f"{device.name}: Home security idle (all clear)") - self._safe_update(device, "motion", value=False, uiValue="clear") - self._safe_update(device, "tamper", value=False, uiValue="clear") - device.updateStateOnServer("onOffState", value=False) - if _motion_disp: - self._safe_update(device, "displayStatus", value="clear") + self._handle_home_security_idle(device, raw, _motion_disp) else: self.logger.info( f"{device.name}: HOME_SECURITY event=0x{notif_event:02X} " @@ -1401,6 +1416,57 @@ def _handle_notification(self, device, raw) -> bool: self._touch(device) return True + def _handle_home_security_idle(self, device, raw, motion_is_primary): + """A Home Security "idle" (event 0x00): something has ended. + + The first event parameter names WHICH event ended, so only that one is + cleared: motion, intrusion or glass break clear "motion", a tamper + clears "tamper". An idle with no parameter (older devices) clears both. + This is the same rule zwave-js uses. Before 5.17 every idle cleared + both, so motion ending hid a tamper that was still active. + + Only cleared events are written. The local device object's states are + not refreshed by updateStateOnServer, so what is still active is read + from the states as they were before this report, minus what it cleared. + """ + named = self._extract_idle_event(raw) + if named is None: + clear_motion, clear_tamper = True, True + else: + clear_motion = named in HS_MOTION_FAMILY + clear_tamper = named in (HS_TAMPER, HS_TAMPER_ALT) + if not (clear_motion or clear_tamper): + self.logger.debug( + f"{device.name}: Home security idle for event 0x{named:02X} " + f"(no state of its own here)") + return + + what = ("all clear" if named is None + else f"event 0x{named:02X} ended") + self.logger.debug(f"{device.name}: Home security idle ({what})") + + if clear_motion: + self._safe_update(device, "motion", value=False, uiValue="clear") + if motion_is_primary: + device.updateStateOnServer("onOffState", value=False) + if clear_tamper: + self._safe_update(device, "tamper", value=False, uiValue="clear") + + if motion_is_primary: + motion_left = not clear_motion and bool(device.states.get("motion")) + tamper_left = not clear_tamper and bool(device.states.get("tamper")) + status = "detected" if motion_left else "tamper" if tamper_left else "clear" + self._safe_update(device, "displayStatus", value=status) + + @staticmethod + def _extract_idle_event(raw) -> int | None: + """The event an idle notification says has ended, or None if it names + none. Same layout as _extract_notif_user: raw[8] holds the event + parameter length in its low 5 bits, raw[9] is the first parameter.""" + if len(raw) >= 10 and (raw[8] & 0x1F) >= 1: + return raw[9] + return None + def _handle_battery(self, device, raw) -> bool: """ BATTERY_REPORT (CC=0x80, cmd=0x03) diff --git a/docs/changelog.md b/docs/changelog.md index a44198f..f855690 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,11 @@ nav_order: 10 The newest version is at the top. +## 5.17 — 1 November 2026 + +- **"All clear" clears only what has ended.** When a motion sensor's motion stops, or its cover is put back, it sends an "all clear" that says which of the two has ended. The plugin ignored that and cleared both, so motion ending also cleared a **Tamper** that was still active. Now only the event named is cleared. An "all clear" that names nothing, which some older sensors send, still clears both. +- **A door sensor is not opened or closed by motion or tamper.** Some devices report a door contact as well as motion or tamper. Those motion and tamper reports used to switch the main on/off state of every device on the same sensor, so a tamper clearing could show an open door as closed. Motion and tamper reports now change the main on/off state only on a **Motion** or **Generic** device. The **Motion** and **Tamper** states themselves still update on every device that has them. + ## 5.16 — 1 November 2026 - **Sensor readings are filed under the right name.** A sensor says what kind of reading it is sending with a code number, and two of the plugin's lookup tables had several codes wrong: diff --git a/tests/test_zwave_code_tables.py b/tests/test_zwave_code_tables.py index 6e63e10..ff42577 100644 --- a/tests/test_zwave_code_tables.py +++ b/tests/test_zwave_code_tables.py @@ -9,12 +9,13 @@ # packages/cc/src/lib/_Types.ts (enum BinarySensorType) # packages/core/src/registries/Sensors.ts (multilevel sensor types) # packages/core/src/registries/Notifications.ts -# The two code tables were corrected in v5.16; BINARY_BUG and -# MULTILEVEL_BUG now record what the tests below guard against. -# The idle-event fault is still open and stays xfail(strict=True). +# The two code tables were corrected in v5.16 and the idle handling +# in v5.17; BINARY_BUG, MULTILEVEL_BUG and IDLE_BUG now record what +# the tests below guard against. The idle rule follows zwave-js +# 15.31.2, lib/node/CCHandlers/NotificationCC.js. # Author: CliveS & Claude Opus 5.5 # Date: 01-11-2026 -# Version: 1.1 +# Version: 1.2 import pytest @@ -121,13 +122,12 @@ def test_a_current_report_does_not_land_in_watts(): # ------------------------------------------------------------ idle events -IDLE_BUG = ("BUG: a Home Security 'idle' notification (event 0x00) names the event " +IDLE_BUG = ("Fixed in v5.17. A Home Security 'idle' notification (event 0x00) names the event " "that ended in its first event parameter, but _handle_notification clears " "motion AND tamper on every idle, and writes onOffState=False whatever the " "device type. Motion ending clears a tamper that is still active.") -@pytest.mark.xfail(strict=True, reason=IDLE_BUG) def test_motion_ending_does_not_clear_an_active_tamper(): p = make_plugin() dev = MockDevice(1, "Hall PIR", plugin_props={"sensorType": "motion"}) @@ -139,7 +139,6 @@ def test_motion_ending_does_not_clear_an_active_tamper(): assert dev.states.get("tamper") is True, "the cover is still off" -@pytest.mark.xfail(strict=True, reason=IDLE_BUG) def test_a_tamper_clearing_does_not_close_a_door_contact(): p = make_plugin() dev = MockDevice(1, "Back Door", plugin_props={"sensorType": "contact"}) @@ -236,3 +235,91 @@ def test_a_binary_type_with_no_state_of_its_own_only_sets_the_primary_state(code assert dev.state_writes["onOffState"]["value"] is True for key in ("motion", "tamper", "waterLeak", "contact"): assert key not in dev.state_writes, f"0x{code:02X} wrote {key}" + + +# ------------------------------------------------------------ v5.17 idle rule + +def _hs(p, dev, event, *params): + """Send a Home Security notification, with event parameters if given.""" + frame = [0x71, 0x05, 0, 0, 0, 0xFF, 0x07, event, len(params)] + list(params) + p._handle_notification(dev, frame) + + +def test_tamper_ending_leaves_active_motion_alone(): + p = make_plugin() + dev = MockDevice(1, "Hall PIR", plugin_props={"sensorType": "motion"}) + _hs(p, dev, 0x08) # motion + _hs(p, dev, 0x03) # cover off + _hs(p, dev, 0x00, 0x03) # idle: the cover went back on + assert dev.states.get("tamper") is False + assert dev.states.get("motion") is True + assert dev.states.get("onOffState") is True + assert dev.states.get("displayStatus") == "detected" + + +def test_motion_ending_with_tamper_still_active_shows_tamper(): + p = make_plugin() + dev = MockDevice(1, "Hall PIR", plugin_props={"sensorType": "motion"}) + _hs(p, dev, 0x03) + _hs(p, dev, 0x08) + _hs(p, dev, 0x00, 0x08) + assert dev.states.get("onOffState") is False + assert dev.states.get("displayStatus") == "tamper" + + +@pytest.mark.parametrize("named", [0x01, 0x02, 0x05, 0x06, 0x07, 0x08]) +def test_each_event_recorded_as_motion_clears_motion_when_it_ends(named): + p = make_plugin() + dev = MockDevice(1, "Hall PIR", plugin_props={"sensorType": "motion"}) + _hs(p, dev, named) + _hs(p, dev, 0x00, named) + assert dev.states.get("motion") is False + + +def test_an_idle_naming_nothing_clears_everything(): + p = make_plugin() + dev = MockDevice(1, "Hall PIR", plugin_props={"sensorType": "motion"}) + _hs(p, dev, 0x08) + _hs(p, dev, 0x03) + _hs(p, dev, 0x00) # no event parameter + assert dev.states.get("motion") is False + assert dev.states.get("tamper") is False + assert dev.states.get("displayStatus") == "clear" + + +def test_an_idle_for_an_event_with_no_state_here_changes_nothing(): + p = make_plugin() + dev = MockDevice(1, "Hall PIR", plugin_props={"sensorType": "motion"}) + _hs(p, dev, 0x08) + dev.state_writes.clear() + _hs(p, dev, 0x00, 0x0B) # e.g. an event this plugin does not record + for key in ("motion", "tamper", "onOffState", "displayStatus"): + assert key not in dev.state_writes, f"wrote {key}" + + +def test_the_event_parameter_length_ignores_the_sequence_bits(): + """Bits 5-7 of the properties byte are flags, not part of the length.""" + p = make_plugin() + dev = MockDevice(1, "Hall PIR", plugin_props={"sensorType": "motion"}) + _hs(p, dev, 0x08) + _hs(p, dev, 0x03) + p._handle_notification(dev, [0x71, 0x05, 0, 0, 0, 0xFF, 0x07, 0x00, 0x81, 0x03]) + assert dev.states.get("motion") is True and dev.states.get("tamper") is False + + +@pytest.mark.parametrize("event", [0x08, 0x02, 0x06]) +def test_motion_on_a_door_contact_does_not_open_the_door(event): + p = make_plugin() + dev = MockDevice(1, "Back Door", plugin_props={"sensorType": "contact"}) + p._handle_notification(dev, [0x71, 0x05, 0, 0, 0, 0xFF, 0x06, 0x17, 0x00]) # closed + _hs(p, dev, event) + assert dev.states.get("onOffState") is False + + +def test_a_generic_device_still_follows_motion(): + p = make_plugin() + dev = MockDevice(1, "Multi", plugin_props={"sensorType": "generic"}) + _hs(p, dev, 0x08) + assert dev.states.get("onOffState") is True + _hs(p, dev, 0x00, 0x08) + assert dev.states.get("onOffState") is False