diff --git a/README.md b/README.md index c67b5c3..665caa5 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.17 | **Author:** CliveS & Claude | **Needs:** Indigo 2022.1 or later, with a Z-Wave interface +**Version:** 5.18 | **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.18** — A device's main on/off state now follows only the reports that are that device's job. A **Contact** follows door and garage-door reports, a **Lock** follows lock reports, a **Plug** or **Energy Monitor** follows switch reports, a **Scene** device follows button presses, and alarms such as water, smoke and sirens belong to the **Generic Sensor**. Before, a door contact sharing a sensor with a siren, a lock or a mains monitor could be switched by those. + **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. diff --git a/UniversalZWaveSensor.indigoPlugin/Contents/Info.plist b/UniversalZWaveSensor.indigoPlugin/Contents/Info.plist index f11aee9..9f177b8 100644 --- a/UniversalZWaveSensor.indigoPlugin/Contents/Info.plist +++ b/UniversalZWaveSensor.indigoPlugin/Contents/Info.plist @@ -27,7 +27,7 @@ IwsApiVersion 1.0.0 PluginVersion - 5.17 + 5.18 ServerApiVersion 3.0 diff --git a/UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py b/UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py index f549378..f1ddfd0 100644 --- a/UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/UniversalZWaveSensor.indigoPlugin/Contents/Server Plugin/plugin.py @@ -7,7 +7,16 @@ # 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.17 +# Version: 5.18 +# +# v5.18 (01-11-2026): every report now sets a device's primary on/off state +# only when that kind of report is the device's job (PRIMARY_FAMILIES): +# door/barrier -> Contact, lock -> Lock, switch/dimmer -> Plug and Energy, +# scene -> Scene, alarms and mains power -> Generic only, tamper -> Generic +# only. Generic devices take everything, as before. Basic reports and +# general-purpose / untyped Sensor Binary reports still set every device's +# primary state: they carry no type and older sensors rely on them. 5.17 did +# this for Home Security events; this does the rest. # # 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 @@ -416,6 +425,33 @@ # New typed device types (v5.1+) use deviceTypeId; legacy zwaveSensor type # reads from pluginProps["sensorType"] via _sensor_type() fallback. # ============================================================================== +# Which reports may set a device's primary on/off state (onOffState), by the +# device's sensor type (5.18). A report belongs to a family; the primary state +# changes only when the device's type lists that family, or the device is +# Generic. Before 5.18 every report set every device's primary state, so a +# door contact sharing a node with a motion sensor, a siren or a mains-power +# monitor was switched by those. Basic reports and general-purpose / untyped +# Sensor Binary reports carry no type, mean "the sensor's main thing", and +# still set every device's primary state (they bypass this table). +# Alarms (water, smoke, CO, gas, siren) and mains power belong to Generic, +# which is where docs/devices.md puts them. +PRIMARY_FAMILIES = { + "motion": ("motion",), + "contact": ("contact",), + "lock": ("lock",), + "plug": ("switch",), + "energy": ("switch",), + "scene": ("scene",), +} +BINARY_PRIMARY_FAMILY = { + "motion": "motion", + "contact": "contact", + "smoke": "alarm", + "coAlarm": "alarm", + "waterLeak": "alarm", + "tamper": "tamper", +} + DEVICE_TYPE_SENSOR_TYPE = { "zwaveSensorMotion": "motion", "zwaveSensorContact": "contact", @@ -536,6 +572,13 @@ def _sensor_type(self, device) -> str: return DEVICE_TYPE_SENSOR_TYPE[device.deviceTypeId] return device.pluginProps.get("sensorType", "generic") + def _set_primary(self, device, family, value): + """Write onOffState only if a report of this family is this device's job. + See PRIMARY_FAMILIES. Generic devices take every family.""" + dev_type = self._sensor_type(device) + if dev_type == "generic" or family in PRIMARY_FAMILIES.get(dev_type, ()): + device.updateStateOnServer("onOffState", value=value) + def _safe_update(self, device, state_id, **kwargs): """Update a device state only if that state exists on the device type. Silently skips states not in the device's state list — allows handlers to @@ -1098,7 +1141,14 @@ def _handle_binary_sensor(self, device, raw) -> bool: or (state_key == "contact" and dev_type == "contact")) self.logger.debug(f"{device.name}: {state_key} = {label}") self._safe_update(device, state_key, value=is_active, uiValue=label) - device.updateStateOnServer("onOffState", value=is_active) + # A general-purpose or untyped report means "this sensor's main thing", + # so it sets every device's primary state, as before. A typed one sets + # it only where that type is the device's job (5.18). + family = BINARY_PRIMARY_FAMILY.get(state_key) + if family is None: + device.updateStateOnServer("onOffState", value=is_active) + else: + self._set_primary(device, family, is_active) if is_primary: self._safe_update(device, "displayStatus", value=label) self._touch(device) @@ -1176,13 +1226,13 @@ def _handle_notification(self, device, raw) -> bool: if notif_event == AC_DOOR_OPEN: _log_c(f"{device.name}: Door/Window OPEN") self._safe_update(device, "contact", value=True, uiValue="open") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "contact", True) if _contact_disp: self._safe_update(device, "displayStatus", value="open") elif notif_event == AC_DOOR_CLOSED: _log_c(f"{device.name}: Door/Window CLOSED") self._safe_update(device, "contact", value=False, uiValue="closed") - device.updateStateOnServer("onOffState", value=False) + self._set_primary(device, "contact", False) if _contact_disp: self._safe_update(device, "displayStatus", value="closed") elif notif_event in (AC_MANUAL_LOCK, AC_RF_LOCK, AC_KEYPAD_LOCK, AC_AUTO_LOCK): @@ -1190,7 +1240,7 @@ def _handle_notification(self, device, raw) -> bool: user_str = f" user={user_id}" if user_id else "" _log_l(f"{device.name}: Lock LOCKED (event=0x{notif_event:02X}){user_str}") self._safe_update(device, "lockState", value=True, uiValue="locked") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "lock", True) if user_id is not None: self._safe_update(device, "lastUser", value=user_id) if _lock_disp: @@ -1200,7 +1250,7 @@ def _handle_notification(self, device, raw) -> bool: user_str = f" user={user_id}" if user_id else "" _log_l(f"{device.name}: Lock UNLOCKED (event=0x{notif_event:02X}){user_str}") self._safe_update(device, "lockState", value=False, uiValue="unlocked") - device.updateStateOnServer("onOffState", value=False) + self._set_primary(device, "lock", False) if user_id is not None: self._safe_update(device, "lastUser", value=user_id) if _lock_disp: @@ -1220,12 +1270,12 @@ def _handle_notification(self, device, raw) -> bool: if notif_event in (0x01, 0x02): self.logger.info(f"{device.name}: Water LEAK detected") self._safe_update(device, "waterLeak", value=True, uiValue="leak") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "alarm", True) self._safe_update(device, "displayStatus", value="leak") elif notif_event == 0x00 or notif_status == 0x00: self.logger.info(f"{device.name}: Water leak CLEARED") self._safe_update(device, "waterLeak", value=False, uiValue="clear") - device.updateStateOnServer("onOffState", value=False) + self._set_primary(device, "alarm", False) self._safe_update(device, "displayStatus", value="clear") else: self.logger.info(f"{device.name}: WATER event=0x{notif_event:02X} (no state mapping)") @@ -1236,12 +1286,12 @@ def _handle_notification(self, device, raw) -> bool: if notif_event in (0x01, 0x02): self.logger.info(f"{device.name}: Smoke DETECTED") self._safe_update(device, "smoke", value=True, uiValue="smoke") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "alarm", True) self._safe_update(device, "displayStatus", value="smoke") elif notif_event == 0x00: self.logger.info(f"{device.name}: Smoke CLEARED") self._safe_update(device, "smoke", value=False, uiValue="clear") - device.updateStateOnServer("onOffState", value=False) + self._set_primary(device, "alarm", False) self._safe_update(device, "displayStatus", value="clear") else: self.logger.info(f"{device.name}: SMOKE event=0x{notif_event:02X} (no state mapping)") @@ -1252,12 +1302,12 @@ def _handle_notification(self, device, raw) -> bool: if notif_event in (0x01, 0x02): self.logger.info(f"{device.name}: CO ALARM") self._safe_update(device, "coAlarm", value=True, uiValue="alarm") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "alarm", True) self._safe_update(device, "displayStatus", value="alarm") elif notif_event == 0x00: self.logger.info(f"{device.name}: CO alarm CLEARED") self._safe_update(device, "coAlarm", value=False, uiValue="clear") - device.updateStateOnServer("onOffState", value=False) + self._set_primary(device, "alarm", False) self._safe_update(device, "displayStatus", value="clear") else: self.logger.info(f"{device.name}: CO event=0x{notif_event:02X} (no state mapping)") @@ -1271,11 +1321,11 @@ def _handle_notification(self, device, raw) -> bool: self._safe_update(device, "batteryLow", value=True) elif notif_event == PM_AC_DISCONNECTED: self.logger.warning(f"{device.name}: AC mains DISCONNECTED") - device.updateStateOnServer("onOffState", value=False) + self._set_primary(device, "power", False) self._safe_update(device, "displayStatus", value="AC off") elif notif_event == PM_AC_RECONNECTED: self.logger.info(f"{device.name}: AC mains RECONNECTED") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "power", True) self._safe_update(device, "displayStatus", value="AC on") elif notif_event == PM_OVER_CURRENT: self.logger.warning(f"{device.name}: Over-current DETECTED") @@ -1337,12 +1387,12 @@ def _handle_notification(self, device, raw) -> bool: if notif_event == 0x01: self.logger.warning(f"{device.name}: Siren ACTIVE") self._safe_update(device, "sirenActive", value=True, uiValue="active") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "alarm", True) self._safe_update(device, "displayStatus", value="active") elif notif_event == 0x00: self.logger.info(f"{device.name}: Siren silenced") self._safe_update(device, "sirenActive", value=False, uiValue="silent") - device.updateStateOnServer("onOffState", value=False) + self._set_primary(device, "alarm", False) self._safe_update(device, "displayStatus", value="silent") else: self.logger.info(f"{device.name}: SIREN event=0x{notif_event:02X} (no state mapping)") @@ -1372,12 +1422,12 @@ def _handle_notification(self, device, raw) -> bool: if notif_event in (0x01, 0x02, 0x03, 0x04): self.logger.warning(f"{device.name}: GAS leak detected") self._safe_update(device, "gasLeak", value=True, uiValue="leak") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "alarm", True) self._safe_update(device, "displayStatus", value="gas leak") elif notif_event == 0x00: self.logger.info(f"{device.name}: Gas alarm CLEARED") self._safe_update(device, "gasLeak", value=False, uiValue="clear") - device.updateStateOnServer("onOffState", value=False) + self._set_primary(device, "alarm", False) self._safe_update(device, "displayStatus", value="clear") else: self.logger.info(f"{device.name}: GAS event=0x{notif_event:02X} (no state mapping)") @@ -1603,7 +1653,7 @@ def _handle_switch_binary(self, device, raw) -> bool: label = "on" if is_on else "off" self.logger.info(f"{device.name}: Switch = {label}") self._safe_update(device, "switchState", value=is_on, uiValue=label) - device.updateStateOnServer("onOffState", value=is_on) + self._set_primary(device, "switch", is_on) self._safe_update(device, "displayStatus", value=label) self._touch(device) return True @@ -1624,7 +1674,7 @@ def _handle_switch_multilevel(self, device, raw) -> bool: self.logger.info(f"{device.name}: Dim level = {level}%") self._safe_update(device, "dimLevel", value=level, uiValue=f"{level}%") self._safe_update(device, "switchState", value=is_on) - device.updateStateOnServer("onOffState", value=is_on) + self._set_primary(device, "switch", is_on) self._safe_update(device, "displayStatus", value=display_str) self._touch(device) return True @@ -1684,7 +1734,7 @@ def _handle_barrier(self, device, raw) -> bool: self.logger.info(f"{device.name}: Barrier = {label}") self._safe_update(device, "contact", value=is_open, uiValue="open" if is_open else "closed") - device.updateStateOnServer("onOffState", value=is_open) + self._set_primary(device, "contact", is_open) self._safe_update(device, "displayStatus", value=label) self._touch(device) return True @@ -1745,7 +1795,7 @@ def _handle_central_scene(self, device, raw) -> bool: self._safe_update(device, "sceneTimestamp", value=ts) self._safe_update(device, "displayStatus", value=disp_str) # onOffState: True on press/held/double, False on release - device.updateStateOnServer("onOffState", value=(action_code != CS_KEY_RELEASED)) + self._set_primary(device, "scene", (action_code != CS_KEY_RELEASED)) self._touch(device) return True @@ -1783,7 +1833,7 @@ def _handle_door_lock(self, device, raw) -> bool: self.logger.info(f"{device.name}: Lock {label} (mode=0x{mode:02X})") self._safe_update(device, "lockState", value=is_locked, uiValue=label) self._safe_update(device, "lockMode", value=mode) - device.updateStateOnServer("onOffState", value=is_locked) + self._set_primary(device, "lock", is_locked) self._safe_update(device, "displayStatus", value=label) device.updateStateImageOnServer( indigo.kStateImageSel.SensorTripped if is_locked @@ -1829,7 +1879,7 @@ def _handle_scene_activation(self, device, raw) -> bool: self._safe_update(device, "lastSceneAction", value="activated") self._safe_update(device, "sceneTimestamp", value=ts) self._safe_update(device, "displayStatus", value=f"S{scene_id} activated") - device.updateStateOnServer("onOffState", value=True) + self._set_primary(device, "scene", True) self._touch(device) return True diff --git a/docs/changelog.md b/docs/changelog.md index f855690..dab55ae 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,23 @@ nav_order: 10 The newest version is at the top. +## 5.18 — 1 November 2026 + +- **A device's main on/off state follows only its own kind of report.** Every one of the plugin's devices has a main on/off state, which is what a trigger on "Primary state changes" watches. Every report the plugin decoded used to set it on every device made for that sensor. So a **Contact** device sharing a sensor with a siren showed the door shut when the siren went quiet, and a **Lock** device could be switched by a door contact. Now each report sets it only where it is that device's job: + + | Report | Sets the main state of | + |---|---| + | Motion, intrusion, glass break | Motion | + | Door or window, garage door | Contact | + | Lock or unlock | Lock | + | Switch on or off, dimmer level | Plug / Relay, Energy Monitor | + | Scene buttons | Scene | + | Water, smoke, CO, gas, siren, mains power, tamper | Generic only | + + A **Generic Sensor** still follows every report. Each report's own state, such as **Contact** or **Siren Active**, still updates on every device that has it. +- **Older sensors are unaffected.** A "Basic" report, or an on/off report that does not say what kind it is, still sets every device's main state. Older motion and door sensors use these as their only report. +- If you have a trigger on the main on/off state of a device that relied on a report of another kind, point it at that report's own state instead, for example **Siren Active** or **Contact**. + ## 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. diff --git a/tests/test_primary_state.py b/tests/test_primary_state.py new file mode 100644 index 0000000..30f42d6 --- /dev/null +++ b/tests/test_primary_state.py @@ -0,0 +1,99 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# Filename: test_primary_state.py +# Description: v5.18 - which reports may set a device's primary on/off state +# (onOffState). Every kind of report the plugin decodes is sent, +# through the real _route_zwave_report, to every device type, and +# the test checks the primary state changed exactly where that +# report is the device's job: +# motion -> Motion contact/barrier -> Contact +# lock -> Lock switch/dimmer -> Plug, Energy +# scene -> Scene alarms, mains power, tamper -> none +# plus Generic for everything. Basic and untyped binary reports +# set every device, because older sensors rely on them. +# Before 5.18 every report set every device's primary state, so a +# door contact sharing a node with a siren, a lock, a dimmer or a +# mains monitor was switched by those reports. +# Author: CliveS & Claude Opus 5.5 +# Date: 01-11-2026 +# Version: 1.0 + +import pytest + +from test_plugin import MockDevice, make_plugin + +ALL_TYPES = ("motion", "contact", "temperature", "humidity", "luminance", "energy", + "plug", "battery", "lock", "scene", "generic") + + +def _notif(ntype, event): + return [0x71, 0x05, 0, 0, 0, 0xFF, ntype, event, 0x00] + + +# (description, frame, device types whose primary state it may set) +REPORTS = [ + ("HS motion", _notif(0x07, 0x08), {"motion", "generic"}), + ("door open", _notif(0x06, 0x16), {"contact", "generic"}), + ("lock (notification)", _notif(0x06, 0x01), {"lock", "generic"}), + ("water leak", _notif(0x05, 0x02), {"generic"}), + ("smoke", _notif(0x01, 0x02), {"generic"}), + ("CO", _notif(0x02, 0x01), {"generic"}), + ("gas", _notif(0x12, 0x01), {"generic"}), + ("siren", _notif(0x0E, 0x01), {"generic"}), + ("mains restored", _notif(0x08, 0x03), {"generic"}), + ("binary motion", [0x30, 0x03, 0xFF, 0x0C], {"motion", "generic"}), + ("binary door", [0x30, 0x03, 0xFF, 0x0A], {"contact", "generic"}), + ("binary smoke", [0x30, 0x03, 0xFF, 0x02], {"generic"}), + ("binary water", [0x30, 0x03, 0xFF, 0x06], {"generic"}), + ("binary tamper", [0x30, 0x03, 0xFF, 0x08], {"generic"}), + ("switch on", [0x25, 0x03, 0xFF], {"plug", "energy", "generic"}), + ("dimmer 50%", [0x26, 0x03, 0x32], {"plug", "energy", "generic"}), + ("barrier open", [0x66, 0x03, 0xFF], {"contact", "generic"}), + ("central scene", [0x5B, 0x03, 0x01, 0x00, 0x01], {"scene", "generic"}), + ("scene activation", [0x2B, 0x01, 0x01], {"scene", "generic"}), + ("door lock", [0x62, 0x03, 0xFF], {"lock", "generic"}), + # Untyped: "the sensor's main thing" - every device, as before. + ("basic set", [0x20, 0x01, 0xFF], set(ALL_TYPES)), + ("binary untyped (v1)", [0x30, 0x03, 0xFF], set(ALL_TYPES)), + ("binary general", [0x30, 0x03, 0xFF, 0x01], set(ALL_TYPES)), +] + + +@pytest.mark.parametrize("desc, frame, allowed", REPORTS, ids=[r[0] for r in REPORTS]) +@pytest.mark.parametrize("dev_type", ALL_TYPES) +def test_primary_state_changes_only_where_the_report_is_the_devices_job( + desc, frame, allowed, dev_type): + p = make_plugin() + dev = MockDevice(1, "Dev", address="12", + plugin_props={"sensorType": dev_type, "endpointId": ""}) + p._route_zwave_report(dev, 12, frame[0], frame[1], frame, "") + wrote = "onOffState" in dev.state_writes + if dev_type in allowed: + assert wrote, f"{desc} should set a {dev_type} device's primary state" + assert dev.state_writes["onOffState"]["value"] is True + else: + assert not wrote, f"{desc} changed a {dev_type} device's primary state" + + +def test_a_door_contact_on_a_node_with_a_siren_stays_open(): + """The kind of mix that went wrong: one node, a door contact device, and a + siren silencing on the same node.""" + p = make_plugin() + door = MockDevice(1, "Back Door", address="12", + plugin_props={"sensorType": "contact", "endpointId": ""}) + for frame in (_notif(0x06, 0x16), _notif(0x0E, 0x01), _notif(0x0E, 0x00)): + p._route_zwave_report(door, 12, frame[0], frame[1], frame, "") + assert door.states.get("onOffState") is True + assert door.states.get("contact") is True + + +def test_the_report_own_state_still_updates_on_every_device_that_has_it(): + """Only the PRIMARY state is restricted: a lock device still records a + door contact on its node in its own contact state, if it has one.""" + p = make_plugin() + lock = MockDevice(1, "Front Lock", address="12", + plugin_props={"sensorType": "lock", "endpointId": ""}) + frame = _notif(0x06, 0x16) + p._route_zwave_report(lock, 12, frame[0], frame[1], frame, "") + assert lock.states.get("contact") is True + assert "onOffState" not in lock.state_writes