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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion UniversalZWaveSensor.indigoPlugin/Contents/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<key>IwsApiVersion</key>
<string>1.0.0</string>
<key>PluginVersion</key>
<string>5.16</string>
<string>5.17</string>
<key>ServerApiVersion</key>
<string>3.0</string>
</dict>
Expand Down
86 changes: 76 additions & 10 deletions UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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} "
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 94 additions & 7 deletions tests/test_zwave_code_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"})
Expand All @@ -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"})
Expand Down Expand Up @@ -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
Loading