diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index 4f41ec8..7b166f0 100644 --- a/indigo-matter.indigoPlugin/Contents/Info.plist +++ b/indigo-matter.indigoPlugin/Contents/Info.plist @@ -20,7 +20,7 @@ IwsApiVersion 1.0.0 PluginVersion - 2026.4.2 + 2026.5.0 ServerApiVersion 3.6 diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml index fd44883..b50882a 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml @@ -2,8 +2,10 @@ + + + Set Sensitivity Level + actionSetSensitivityLevel + + + + + + + + + Set Sensitivity Level (Contact Sensor) + actionSetSensitivityLevel + + + + + + + matter status http_status diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml index 15e34cd..6e89ccc 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -180,6 +180,13 @@ Battery Level Battery Level + + + Integer + Sensitivity Level + Sensitivity Level + onOffState @@ -206,6 +213,13 @@ Battery Level Battery Level + + + Integer + Sensitivity Level + Sensitivity Level + onOffState diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py index 43c998e..f9ddfe9 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py @@ -35,6 +35,10 @@ parse_node, ) from matter_handlers.base import IndigoDeviceSpec +from matter_handlers.boolean_state_config import ( + ATTR_SUPPORTED_SENSITIVITY_LEVELS, + CLUSTER_BOOLEAN_STATE_CONFIG, +) from matter_handlers.electrical import ( ATTR_ACTIVE_ENDPOINTS, ATTR_AVAILABLE_ENDPOINTS, @@ -158,6 +162,12 @@ def __init__(self, registry: Any, logger: Any) -> None: # on ep1) or must stay confined to its own endpoint (a bridge with # more than one battery-bearing child). self._power_source_eps: dict[int, set[int]] = {} + # BooleanStateConfiguration's SupportedSensitivityLevels (0x0080/0x0001) + # per (node_id, endpoint_id) — issue #85. NodeInfo snapshots are + # transient (this class holds no other node-attribute cache), so the + # Set Sensitivity Level menu callback (plugin.py) needs this value + # captured somewhere durable; refreshed on every create/reconcile pass. + self._sensitivity_supported: dict[tuple[int, int], int] = {} # ------------------------------------------------------------------ # Active-device tracking (deviceStartComm/deviceStopComm) @@ -382,6 +392,7 @@ def create_devices(self, node: NodeInfo, suggested_room: Optional[str] = None) - self._power_source_eps[int(node.node_id)] = power_source_eps else: self._power_source_eps.pop(int(node.node_id), None) + self._cache_sensitivity_levels(node) # Pass 1: cache every endpoint's handler-produced specs BEFORE any # fallback/placeholder decision. Meter-link resolution (issue #79) # needs to know which endpoints will host a _METER_CAPABLE_TYPES @@ -1009,6 +1020,34 @@ def _prime_states(self, node: NodeInfo, dev_id: int, endpoint_id: int, if kv: self.apply_states(dev_id, kv) + # ------------------------------------------------------------------ + # BooleanStateConfiguration sensitivity-level cache (issue #85) + # ------------------------------------------------------------------ + + def _cache_sensitivity_levels(self, node: NodeInfo) -> None: + """Cache SupportedSensitivityLevels (0x0080/0x0001) per endpoint. + + Read by :meth:`sensitivity_levels_supported`, which the Set + Sensitivity Level menu callback (plugin.py) uses to label the picker. + Scans the full node snapshot rather than going through a handler, + since the value is deliberately NOT an Indigo state (it's a count, not + a reading) — there is nothing else to dispatch it to. + """ + with self._lock: + for (ep, cluster, attribute), value in node.attributes.items(): + if cluster != CLUSTER_BOOLEAN_STATE_CONFIG or attribute != ATTR_SUPPORTED_SENSITIVITY_LEVELS: + continue + try: + self._sensitivity_supported[(int(node.node_id), int(ep))] = int(value) + except (TypeError, ValueError): + continue + + def sensitivity_levels_supported(self, node_id: Any, endpoint_id: Any) -> Optional[int]: + """SupportedSensitivityLevels for (node, endpoint), or None if unknown + (not yet reconciled, or the node doesn't expose the cluster).""" + with self._lock: + return self._sensitivity_supported.get((int(node_id), int(endpoint_id))) + # ------------------------------------------------------------------ # Capability-prop helpers (issue #45 — self-heal mid-interview creations) # ------------------------------------------------------------------ diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/boolean_state_config.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/boolean_state_config.py new file mode 100644 index 0000000..b1ee2dc --- /dev/null +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/boolean_state_config.py @@ -0,0 +1,81 @@ +"""BooleanStateConfiguration (0x0080) — a sensor's sensitivity level. + +Non-primary: the cluster attaches a `sensitivityLevel` state onto an existing +occupancy/contact device (same endpoint), the same merge-into-sibling pattern +used by the electrical handlers (electrical.py). The Matter spec pairs 0x0080 +with BooleanState (0x0045, matterContactSensor), but the Aqara FP300 co-locates +it with OccupancySensing (0x0406, matterMotionSensor) instead (issue #85) — this +handler supports both by not caring which primary handler owns the endpoint. + +Writing the level is a plain attribute write (no Matter command), sent directly +by the plugin's "Set Sensitivity Level" custom device action (plugin.py) rather +than through ``handle_indigo_action`` — there is no Indigo built-in action shape +for an arbitrary numeric picker, so this is the plugin's first custom device +action (Actions.xml). +""" +from __future__ import annotations + +from typing import Any, Optional + +from .base import ClusterHandler, IndigoDeviceSpec, MatterAction + +CLUSTER_BOOLEAN_STATE_CONFIG = 0x0080 + +# CurrentSensitivityLevel: int8u, writable — the sensor's active sensitivity index. +ATTR_CURRENT_SENSITIVITY = 0x0000 +# SupportedSensitivityLevels: int8u, read-only — count of valid indices (0..count-1). +ATTR_SUPPORTED_SENSITIVITY_LEVELS = 0x0001 + + +def _parse_sensitivity(value: Any) -> Optional[int]: + """Coerce a CurrentSensitivityLevel attribute value to an int, or None. + + Defensive the same way the electrical handlers guard ActivePower/energy + values — matter-server may report a null/unparseable value on a slow or + incomplete read. + """ + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +class BooleanStateConfigHandler(ClusterHandler): + """BooleanStateConfiguration (0x0080) → ``sensitivityLevel``. + + Non-primary: no Indigo device created. ``sensitivityLevel`` is a plain + custom state (no Supports* prop gates it — see Devices.xml), declared only + on matterMotionSensor/matterContactSensor, so the guard below protects + against writing it onto some OTHER device type this cluster might one day + be found co-located with. + """ + + cluster_id = CLUSTER_BOOLEAN_STATE_CONFIG + cluster_name = "BooleanStateConfiguration" + + def is_primary_for(self, node: Any, endpoint: Any) -> bool: + return False + + def create_indigo_devices(self, node: Any, endpoint: Any) -> list[IndigoDeviceSpec]: + return [] + + def attributes_to_subscribe(self) -> list[int]: + return [ATTR_CURRENT_SENSITIVITY] + + def on_attribute_update(self, indigo_dev: Any, attribute_id: int, value: Any) -> dict: + if attribute_id != ATTR_CURRENT_SENSITIVITY: + return {} + level = _parse_sensitivity(value) + if level is None: + return {} + # Guard: state only exists on device types that declare it in Devices.xml + # (matterMotionSensor / matterContactSensor) — mirrors the electrical + # handlers' curEnergyLevel/accumEnergyTotal guard. + if "sensitivityLevel" not in getattr(indigo_dev, "states", {}): + return {} + return {"sensitivityLevel": level} + + def handle_indigo_action(self, indigo_dev: Any, action: Any) -> Optional[MatterAction]: + return None # Set Sensitivity Level writes the attribute directly (plugin.py) diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/registry.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/registry.py index 1eb9dfd..281ae25 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/registry.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/registry.py @@ -13,6 +13,7 @@ from typing import Any, Optional from .base import ClusterHandler, IndigoDeviceSpec +from .boolean_state_config import BooleanStateConfigHandler from .color_control import ColorControlHandler from .door_lock import DoorLockHandler from .electrical import ElectricalEnergyHandler, ElectricalPowerHandler @@ -44,8 +45,9 @@ def default_handlers() -> list[ClusterHandler]: do not overlap any lighting cluster so their order relative to them is independent. Air quality handlers are additive sensors. ElectricalPower/Energy are non-primary and merge into existing relay/dimmer - devices. PowerSource is non-primary and node-scoped (order-independent; - placed last).""" + devices. BooleanStateConfiguration (0x0080) is non-primary and merges into + existing occupancy/contact sensor devices (issue #85). PowerSource is + non-primary and node-scoped (order-independent; placed last).""" return [ ColorControlHandler(), LevelControlHandler(), @@ -70,6 +72,7 @@ def default_handlers() -> list[ClusterHandler]: TVOCHandler(), ElectricalPowerHandler(), ElectricalEnergyHandler(), + BooleanStateConfigHandler(), PowerSourceHandler(), ] diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py index f74670a..3be3d57 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py @@ -27,6 +27,10 @@ from device_sync import DeviceSync from http_handlers import HttpApi, MatterUnavailable from matter_client import MatterClient +from matter_handlers.boolean_state_config import ( + ATTR_CURRENT_SENSITIVITY, + CLUSTER_BOOLEAN_STATE_CONFIG, +) from matter_handlers.registry import HandlerRegistry import protocol from protocol import MatterWrite, Protocol @@ -297,6 +301,73 @@ def actionControlUniversal(self, action, dev): # noqa: N802 else: self.logger.debug('ignored universal action %r for "%s"', cmd, dev.name) + def getSensitivityLevels(self, filter="", valuesDict=None, typeId="", targetId=0): # noqa: N802, A002, ARG002 + """List-callback populating the Set Sensitivity Level action's picker. + + Reads SupportedSensitivityLevels (0x0080/0x0001), cached by device_sync + at the device's last create/reconcile pass — get_node's snapshot isn't + otherwise retained (issue #85). A CONFIRMED count of 3 gets the Aqara + FP300's real labels (Low/Standard/High); any other confirmed count gets + generic "Level N" options. Unknown (device not yet reconciled, or + offline) never presumes the FP300 labelling — it degrades to a generic + 0..2 scale so the dialog is never empty but also never lies about what + the device actually supports. + """ + known = None + try: + dev = indigo.devices[targetId] + node_id = dev.pluginProps.get("nodeId") + endpoint_id = dev.pluginProps.get("endpointId") + if node_id and endpoint_id not in (None, ""): + known = self.device_sync.sensitivity_levels_supported(int(node_id), int(endpoint_id)) + except Exception as exc: # noqa: BLE001 - never break the dialog; degrade to the generic fallback + self.logger.debug("getSensitivityLevels: could not resolve device %r: %s", targetId, exc) + if known and known > 0: + labels = ["Low (0)", "Standard (1)", "High (2)"] if known == 3 else [f"Level {i}" for i in range(known)] + else: + labels = [f"Level {i}" for i in range(3)] # unconfirmed — generic 3-level fallback + return [(str(i), label) for i, label in enumerate(labels)] + + def actionSetSensitivityLevel(self, action, dev): # noqa: N802 + """Custom device action (issue #85): write BooleanStateConfiguration's + writable CurrentSensitivityLevel (0x0080/0x0000) — the Aqara FP300's + motion sensitivity (co-located with OccupancySensing), or a contact + sensor's equivalent per the Matter spec's own BooleanState pairing.""" + try: + level = int(action.props.get("level", "")) + except (TypeError, ValueError): + self.logger.error('"%s": invalid sensitivity level %r', dev.name, action.props.get("level")) + return + if "sensitivityLevel" not in dev.states: + # Defense-in-depth: the two Actions.xml entries are already scoped + # to motion/contact types, but a stale saved action (or a future + # filter change) could still hand us a device without the state. + self.logger.error('"%s": device does not support sensitivity (no Boolean State ' + 'Configuration cluster)', dev.name) + return + node_id = dev.pluginProps.get("nodeId") + endpoint_id = dev.pluginProps.get("endpointId") + if not node_id or endpoint_id in (None, ""): + self.logger.error('"%s": cannot set sensitivity — device has no Matter node/endpoint yet', dev.name) + return + supported = self.device_sync.sensitivity_levels_supported(int(node_id), int(endpoint_id)) + if supported and not 0 <= level < supported: + self.logger.error( + '"%s": sensitivity level %d out of range (device supports 0-%d)', + dev.name, level, supported - 1, + ) + return + write = MatterWrite(int(node_id), int(endpoint_id), CLUSTER_BOOLEAN_STATE_CONFIG, + ATTR_CURRENT_SENSITIVITY, level) + if self._send_matter_command(write, dev): + # Optimistic echo (precedent: color_control's whiteLevel echo) — the + # firehose attribute_updated report will confirm/correct this once + # matter-server processes the write. + try: + dev.updateStateOnServer("sensitivityLevel", level) + except Exception as exc: # noqa: BLE001 - cosmetic echo only, must not fail the action + self.logger.debug('optimistic sensitivityLevel echo failed for "%s": %s', dev.name, exc) + def _refresh_node(self, dev) -> None: """Re-interview the device's Matter node so matter-server re-reads its attributes; matter-server then emits a node_updated event which @@ -327,21 +398,24 @@ def _refresh_node(self, dev) -> None: self.logger.error('refresh of "%s" failed: %s', dev.name, exc) dev.setErrorStateOnServer("cmd failed") - def _send_matter_command(self, action, dev) -> None: + def _send_matter_command(self, action, dev) -> bool: if self.runtime is None or self.matter is None: - return + return False # An action is either a cluster-command invoke or an attribute write. coro = self.matter.write(action) if isinstance(action, MatterWrite) else self.matter.send_command(action) try: self.runtime.submit(coro).result(timeout=COMMAND_TIMEOUT) if getattr(dev, "errorState", ""): dev.setErrorStateOnServer("") # command succeeded — clear a stale error + return True except FuturesTimeoutError: self.logger.error("Matter command to %s timed out", dev.name) dev.setErrorStateOnServer("timeout") + return False except Exception as exc: # noqa: BLE001 self.logger.error("Matter command to %s failed: %s", dev.name, exc) dev.setErrorStateOnServer("cmd failed") + return False # ------------------------------------------------------------------ # asyncio → Indigo event bridge (called on the loop thread) diff --git a/tests/test_boolean_state_config.py b/tests/test_boolean_state_config.py new file mode 100644 index 0000000..a3b557b --- /dev/null +++ b/tests/test_boolean_state_config.py @@ -0,0 +1,159 @@ +"""Tests for BooleanStateConfiguration (0x0080) — issue #85. + +Covers: +- CurrentSensitivityLevel parsing: nominal, zero, None, garbage +- Handler contract: non-primary, no devices created, no Matter action +- on_attribute_update: sensitivityLevel state, missing-state guard, wrong attribute +- Registry registration + non-primary behaviour alongside a real primary handler + (occupancy) — mirrors test_electrical.py's TestOnOffEnergyProps shape. +""" +from __future__ import annotations + +from matter_handlers.boolean_state_config import ( + ATTR_CURRENT_SENSITIVITY, + ATTR_SUPPORTED_SENSITIVITY_LEVELS, + CLUSTER_BOOLEAN_STATE_CONFIG, + BooleanStateConfigHandler, + _parse_sensitivity, +) +from matter_handlers.registry import HandlerRegistry +from matter_handlers.sensors import OccupancyHandler + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _Dev: + """Minimal Indigo device stub.""" + def __init__(self, states=None, props=None): + self.states = states or {} + self.pluginProps = props or {} + + +class _Endpoint: + """Minimal endpoint stub that advertises a set of cluster ids.""" + def __init__(self, endpoint_id: int, clusters: set): + self.endpoint_id = endpoint_id + self._clusters = clusters + + def has(self, cluster_id: int) -> bool: + return cluster_id in self._clusters + + +class _Node: + """Minimal node stub.""" + def __init__(self, node_id: int = 0x2D): + self.node_id = node_id + self.suggested_name = "Test Sensor" + self.vendor_name = "Aqara" + self.product_name = "FP300" + + +# --------------------------------------------------------------------------- +# _parse_sensitivity tests +# --------------------------------------------------------------------------- + +def test_parse_sensitivity_nominal(): + assert _parse_sensitivity(1) == 1 + + +def test_parse_sensitivity_zero(): + assert _parse_sensitivity(0) == 0 + + +def test_parse_sensitivity_none_returns_none(): + assert _parse_sensitivity(None) is None + + +def test_parse_sensitivity_bad_type_returns_none(): + assert _parse_sensitivity("not a number") is None + assert _parse_sensitivity([]) is None + + +# --------------------------------------------------------------------------- +# BooleanStateConfigHandler contract +# --------------------------------------------------------------------------- + +class TestBooleanStateConfigHandler: + def setup_method(self): + self.h = BooleanStateConfigHandler() + + def test_cluster_constants(self): + assert self.h.cluster_id == CLUSTER_BOOLEAN_STATE_CONFIG == 0x0080 + assert self.h.cluster_name == "BooleanStateConfiguration" + + def test_is_not_primary(self): + assert self.h.is_primary_for(None, None) is False + + def test_create_indigo_devices_returns_empty(self): + assert self.h.create_indigo_devices(None, None) == [] + + def test_attributes_to_subscribe(self): + assert self.h.attributes_to_subscribe() == [ATTR_CURRENT_SENSITIVITY] + + def test_handle_indigo_action_returns_none(self): + assert self.h.handle_indigo_action(None, None) is None + + def test_on_attribute_update_sets_sensitivity_level(self): + dev = _Dev(states={"sensitivityLevel": 0}) + result = self.h.on_attribute_update(dev, ATTR_CURRENT_SENSITIVITY, 2) + assert result == {"sensitivityLevel": 2} + + def test_on_attribute_update_zero_level(self): + dev = _Dev(states={"sensitivityLevel": 1}) + result = self.h.on_attribute_update(dev, ATTR_CURRENT_SENSITIVITY, 0) + assert result == {"sensitivityLevel": 0} + + def test_on_attribute_update_null_value_returns_empty(self): + dev = _Dev(states={"sensitivityLevel": 0}) + result = self.h.on_attribute_update(dev, ATTR_CURRENT_SENSITIVITY, None) + assert result == {} + + def test_on_attribute_update_missing_state_guard(self): + """If the device type doesn't declare sensitivityLevel, drop the update.""" + dev = _Dev(states={}) # sensitivityLevel not present + result = self.h.on_attribute_update(dev, ATTR_CURRENT_SENSITIVITY, 2) + assert result == {} + + def test_on_attribute_update_ignores_supported_levels_attribute(self): + """SupportedSensitivityLevels (0x0001) is not a state — never written.""" + dev = _Dev(states={"sensitivityLevel": 0}) + result = self.h.on_attribute_update(dev, ATTR_SUPPORTED_SENSITIVITY_LEVELS, 3) + assert result == {} + + def test_on_attribute_update_wrong_attribute_id(self): + dev = _Dev(states={"sensitivityLevel": 0}) + result = self.h.on_attribute_update(dev, 0x0099, 2) + assert result == {} + + +# --------------------------------------------------------------------------- +# Registry registration + non-primary behaviour +# --------------------------------------------------------------------------- + +def test_registry_includes_boolean_state_config_handler(): + reg = HandlerRegistry() + handler = reg.handler_for_cluster(CLUSTER_BOOLEAN_STATE_CONFIG) + assert isinstance(handler, BooleanStateConfigHandler) + + +def test_registry_boolean_state_config_is_non_primary(): + """Non-primary handlers produce no specs when no primary handler is present. + + (a) BooleanStateConfig-only endpoint -> empty spec list. + (b) OccupancySensing + BooleanStateConfig endpoint -> exactly one spec + (matterMotionSensor), proving BooleanStateConfig contributes nothing + of its own while a real primary (occupancy) exists — the FP300 shape + from issue #85. + """ + reg = HandlerRegistry() + + ep_config_only = _Endpoint(1, {CLUSTER_BOOLEAN_STATE_CONFIG}) + specs = reg.handlers_for_endpoint(_Node(), ep_config_only) + assert specs == [] + + ep_occupancy_with_config = _Endpoint(1, {OccupancyHandler().cluster_id, CLUSTER_BOOLEAN_STATE_CONFIG}) + specs = reg.handlers_for_endpoint(_Node(), ep_occupancy_with_config) + assert len(specs) == 1 + assert specs[0].device_type_id == "matterMotionSensor" diff --git a/tests/test_device_sync.py b/tests/test_device_sync.py index 4290e8a..d829c2b 100644 --- a/tests/test_device_sync.py +++ b/tests/test_device_sync.py @@ -28,6 +28,17 @@ "SupportsSensorValue": "sensorValue", } +# Devices.xml declares these as plain custom states (no Supports* prop gates +# them) — real Indigo instantiates a device's declared at CREATION +# regardless of props, unlike the Supports*-driven built-ins above. Only +# BooleanStateConfigHandler's guard (issue #85 — "sensitivityLevel" not in +# indigo_dev.states) needs this modelled; matterLock's lockState needs no +# guard (door_lock.py writes it unconditionally) so it needs no seeding here. +_STATIC_DEVICE_TYPE_STATES = { + "matterMotionSensor": {"sensitivityLevel"}, + "matterContactSensor": {"sensitivityLevel"}, +} + class FakeDev: def __init__(self, dev_id, name, device_type_id, props): @@ -39,6 +50,8 @@ def __init__(self, dev_id, name, device_type_id, props): for prop_key, state_key in _SUPPORTS_TO_STATE.items(): if props.get(prop_key): self.states[state_key] = 0 # Indigo-style initial value + for state_key in _STATIC_DEVICE_TYPE_STATES.get(device_type_id, ()): + self.states.setdefault(state_key, 0) self.error = None self.errorState = "" self.folderId = 0 @@ -2179,3 +2192,95 @@ def test_bridge_multi_power_source_battery_isolated_per_endpoint(ds, indigo_env) )) assert devices[temp_id].states.get("batteryLevel") == 100 assert devices[hum_id].states.get("batteryLevel") == 40 # untouched, no leak + + +# =========================================================================== +# issue #85 — BooleanStateConfiguration (0x0080) sensitivity level +# =========================================================================== + +# Aqara FP300-shaped node: OccupancySensing (0x0406) co-located with +# BooleanStateConfiguration (0x0080, decimal 128) on endpoint 1 — the live +# hardware wrinkle behind issue #85 (the spec pairs 0x0080 with BooleanState/ +# 0x0045 instead, exercised separately below via CONTACT_SENSITIVITY_NODE). +OCCUPANCY_SENSITIVITY_NODE = { + "node_id": 0x2D, + "available": True, + "attributes": { + "0/40/1": "Aqara", + "0/40/3": "FP300", + "1/29/0": [{"0": 263}], # OccupancySensor + "1/1030/0": 1, # occupied + "1/128/0": 1, # CurrentSensitivityLevel = Standard + "1/128/1": 3, # SupportedSensitivityLevels + }, +} + +# BooleanState-paired shape (the spec's own pairing) — a contact sensor +# exposing 0x0080 alongside BooleanState (0x0045, decimal 69). +CONTACT_SENSITIVITY_NODE = { + "node_id": 0x2E, + "available": True, + "attributes": { + "1/29/0": [{"0": 21}], # ContactSensor + "1/69/0": True, + "1/128/0": 2, # CurrentSensitivityLevel = High + "1/128/1": 3, # SupportedSensitivityLevels + }, +} + + +def test_sensitivity_level_primed_on_motion_sensor_creation(ds, indigo_env): + _indigo, devices = indigo_env + ds.create_from_raw(OCCUPANCY_SENSITIVITY_NODE, "Landing Presence") + dev = devices[ds.lookup(0x2D, 1)] + assert dev.deviceTypeId == "matterMotionSensor" + assert dev.states.get("sensitivityLevel") == 1 + + +def test_sensitivity_level_primed_on_contact_sensor_creation(ds, indigo_env): + _indigo, devices = indigo_env + ds.create_from_raw(CONTACT_SENSITIVITY_NODE, "Back Door") + dev = devices[ds.lookup(0x2E, 1)] + assert dev.deviceTypeId == "matterContactSensor" + assert dev.states.get("sensitivityLevel") == 2 + + +def test_sensitivity_level_live_attribute_event_updates_state(ds, indigo_env): + _indigo, devices = indigo_env + ds.create_from_raw(OCCUPANCY_SENSITIVITY_NODE, "Landing Presence") + dev = devices[ds.lookup(0x2D, 1)] + ds.handle_event(MatterEvent( + kind=protocol.EVT_ATTRIBUTE_UPDATED, + node_id=0x2D, endpoint=1, cluster=0x0080, attribute=0x0000, value=0, + )) + assert dev.states.get("sensitivityLevel") == 0 + + +def test_no_leftover_cluster_log_for_boolean_state_config(ds, indigo_env, mock_logger): + """Regression: before issue #85 registered a handler for 0x0080, this + node's motion sensor would have logged the issue #81 'does not support + yet' diagnostic for it — now it must be quiet.""" + ds.create_from_raw(OCCUPANCY_SENSITIVITY_NODE, "Landing Presence") + info_msgs = [c[0][0] % tuple(c[0][1:]) for c in mock_logger.info.call_args_list] + assert not any("0x0080" in m and "does not support yet" in m for m in info_msgs) + + +def test_sensitivity_levels_supported_returns_cached_count(ds, indigo_env): + ds.create_from_raw(OCCUPANCY_SENSITIVITY_NODE, "Landing Presence") + assert ds.sensitivity_levels_supported(0x2D, 1) == 3 + + +def test_sensitivity_levels_supported_none_before_reconcile(ds, indigo_env): + assert ds.sensitivity_levels_supported(0x2D, 1) is None + + +def test_sensitivity_levels_supported_refreshed_on_reconcile(ds, indigo_env): + """The cache must not go stale across a reconnect — reconcile_all re-primes + it from the fresh snapshot, same as every other capability cache here.""" + ds.create_from_raw(OCCUPANCY_SENSITIVITY_NODE, "Landing Presence") + assert ds.sensitivity_levels_supported(0x2D, 1) == 3 + import copy + changed = copy.deepcopy(OCCUPANCY_SENSITIVITY_NODE) + changed["attributes"]["1/128/1"] = 5 + ds.reconcile_all([changed]) + assert ds.sensitivity_levels_supported(0x2D, 1) == 5 diff --git a/tests/test_device_zoo.py b/tests/test_device_zoo.py index 1dedceb..a2e6460 100644 --- a/tests/test_device_zoo.py +++ b/tests/test_device_zoo.py @@ -140,6 +140,14 @@ def _raw(node_id, eps): _raw(14, {1: {"29/0": [{"0": 21}], "69/0": True}}), {1: ["matterContactSensor"]}, ), + # Aqara FP300-shaped: OccupancySensing + BooleanStateConfiguration (0x0080, + # decimal 128) co-located on ep1 (issue #85). BooleanStateConfig is + # merge-only — it contributes no extra spec of its own, same invariant as + # the electrical clusters above. + "occupancy_with_sensitivity": ( + _raw(0x2D, {1: {"29/0": [{"0": 263}], "1030/0": 1, "128/0": 1, "128/1": 3}}), + {1: ["matterMotionSensor"]}, + ), "smoke_alarm": ( _raw(15, {1: {"29/0": [{"0": 118}], "92/0": 0}}), {1: ["matterSmokeCOAlarm"]}, @@ -306,7 +314,7 @@ def test_every_registered_handler_type_exists_in_devices_xml(): # removed Devices.xml id would create devices that Indigo rejects. for handler in HandlerRegistry().handlers: if not handler.device_type_id: - continue # merge-only handlers (PowerSource, Electrical*) + continue # merge-only handlers (PowerSource, Electrical*, BooleanStateConfig) assert handler.device_type_id in XML_TYPE_BY_ID, ( f"{type(handler).__name__} → {handler.device_type_id} not in Devices.xml" ) diff --git a/tests/test_plugin_module.py b/tests/test_plugin_module.py index 8b307e3..35c2d3f 100644 --- a/tests/test_plugin_module.py +++ b/tests/test_plugin_module.py @@ -201,3 +201,172 @@ def test_action_control_device_sends_each_command_of_a_list(plugin_cls): plugin_cls.actionControlDevice(stub, action=SimpleNamespace(), dev="dev") sent = [c[0][0] for c in stub._send_matter_command.call_args_list] assert sent == pair + + +# --------------------------------------------------------------------------- +# issue #85 — Set Sensitivity Level (the plugin's first custom device action) +# --------------------------------------------------------------------------- + +def _sensitivity_dev(node_id="45", endpoint_id="1", states=None): + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = SimpleNamespace( + id=5, name="Landing Presence", + pluginProps={"nodeId": node_id, "endpointId": endpoint_id}, + # Real 0x0080-bearing devices carry the XML-declared state; the action + # callback rejects devices without it (deviceFilter="self" fallback). + states={"sensitivityLevel": 1} if states is None else states, + updateStateOnServer=MagicMock(), + ) + return dev + + +def test_action_set_sensitivity_level_rejects_device_without_state(plugin_cls, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev(states={}) # e.g. a relay picked via deviceFilter="self" + stub = SimpleNamespace( + device_sync=MagicMock(), logger=mock_logger, + _send_matter_command=MagicMock(return_value=True), + ) + plugin_cls.actionSetSensitivityLevel(stub, SimpleNamespace(props={"level": "1"}), dev) + stub._send_matter_command.assert_not_called() + dev.updateStateOnServer.assert_not_called() + assert any("does not support sensitivity" in str(c) for c in mock_logger.error.call_args_list) + + +def test_get_sensitivity_levels_confirmed_three_gets_friendly_labels(plugin_cls, mock_indigo_base, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev() + mock_indigo_base.devices = {5: dev} + stub = SimpleNamespace(device_sync=MagicMock(), logger=mock_logger) + stub.device_sync.sensitivity_levels_supported.return_value = 3 + options = plugin_cls.getSensitivityLevels(stub, targetId=5) + assert options == [("0", "Low (0)"), ("1", "Standard (1)"), ("2", "High (2)")] + stub.device_sync.sensitivity_levels_supported.assert_called_once_with(45, 1) + + +def test_get_sensitivity_levels_confirmed_other_count_gets_generic_labels(plugin_cls, mock_indigo_base, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev() + mock_indigo_base.devices = {5: dev} + stub = SimpleNamespace(device_sync=MagicMock(), logger=mock_logger) + stub.device_sync.sensitivity_levels_supported.return_value = 5 + options = plugin_cls.getSensitivityLevels(stub, targetId=5) + assert options == [("0", "Level 0"), ("1", "Level 1"), ("2", "Level 2"), + ("3", "Level 3"), ("4", "Level 4")] + + +def test_get_sensitivity_levels_unknown_count_falls_back_generic(plugin_cls, mock_indigo_base, mock_logger): + """Unknown support (device not yet reconciled) must NOT presume the FP300's + Low/Standard/High semantics even though the fallback also has 3 entries.""" + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev() + mock_indigo_base.devices = {5: dev} + stub = SimpleNamespace(device_sync=MagicMock(), logger=mock_logger) + stub.device_sync.sensitivity_levels_supported.return_value = None + options = plugin_cls.getSensitivityLevels(stub, targetId=5) + assert options == [("0", "Level 0"), ("1", "Level 1"), ("2", "Level 2")] + + +def test_get_sensitivity_levels_degrades_when_device_lookup_fails(plugin_cls, mock_indigo_base, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + mock_indigo_base.devices = {} # targetId not found + stub = SimpleNamespace(device_sync=MagicMock(), logger=mock_logger) + options = plugin_cls.getSensitivityLevels(stub, targetId=999) + assert options == [("0", "Level 0"), ("1", "Level 1"), ("2", "Level 2")] + + +def test_action_set_sensitivity_level_sends_matter_write_and_echoes_state(plugin_cls, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + from protocol import MatterWrite + dev = _sensitivity_dev() + stub = SimpleNamespace( + device_sync=MagicMock(), logger=mock_logger, + _send_matter_command=MagicMock(return_value=True), + ) + stub.device_sync.sensitivity_levels_supported.return_value = 3 + action = SimpleNamespace(props={"level": "2"}) + plugin_cls.actionSetSensitivityLevel(stub, action, dev) + + assert stub._send_matter_command.call_count == 1 + write, sent_dev = stub._send_matter_command.call_args[0] + assert isinstance(write, MatterWrite) + assert (write.node_id, write.endpoint, write.cluster, write.attribute, write.value) == (45, 1, 0x0080, 0x0000, 2) + assert sent_dev is dev + dev.updateStateOnServer.assert_called_once_with("sensitivityLevel", 2) + + +def test_action_set_sensitivity_level_no_echo_when_send_fails(plugin_cls, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev() + stub = SimpleNamespace( + device_sync=MagicMock(), logger=mock_logger, + _send_matter_command=MagicMock(return_value=False), + ) + stub.device_sync.sensitivity_levels_supported.return_value = 3 + action = SimpleNamespace(props={"level": "1"}) + plugin_cls.actionSetSensitivityLevel(stub, action, dev) + dev.updateStateOnServer.assert_not_called() + + +def test_action_set_sensitivity_level_rejects_out_of_range(plugin_cls, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev() + stub = SimpleNamespace( + device_sync=MagicMock(), logger=mock_logger, + _send_matter_command=MagicMock(return_value=True), + ) + stub.device_sync.sensitivity_levels_supported.return_value = 3 + action = SimpleNamespace(props={"level": "5"}) + plugin_cls.actionSetSensitivityLevel(stub, action, dev) + stub._send_matter_command.assert_not_called() + dev.updateStateOnServer.assert_not_called() + assert mock_logger.error.called + + +def test_action_set_sensitivity_level_rejects_invalid_level(plugin_cls, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev() + stub = SimpleNamespace(device_sync=MagicMock(), logger=mock_logger, + _send_matter_command=MagicMock()) + action = SimpleNamespace(props={"level": "not-a-number"}) + plugin_cls.actionSetSensitivityLevel(stub, action, dev) + stub._send_matter_command.assert_not_called() + assert mock_logger.error.called + + +def test_action_set_sensitivity_level_rejects_device_with_no_node(plugin_cls, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev(node_id="", endpoint_id="") + stub = SimpleNamespace(device_sync=MagicMock(), logger=mock_logger, + _send_matter_command=MagicMock()) + action = SimpleNamespace(props={"level": "1"}) + plugin_cls.actionSetSensitivityLevel(stub, action, dev) + stub._send_matter_command.assert_not_called() + assert mock_logger.error.called + + +def test_action_set_sensitivity_level_allows_any_level_when_support_unknown(plugin_cls, mock_logger): + """No confirmed SupportedSensitivityLevels yet — don't block the write; + matter-server/the device is the final arbiter.""" + from types import SimpleNamespace + from unittest.mock import MagicMock + dev = _sensitivity_dev() + stub = SimpleNamespace( + device_sync=MagicMock(), logger=mock_logger, + _send_matter_command=MagicMock(return_value=True), + ) + stub.device_sync.sensitivity_levels_supported.return_value = None + action = SimpleNamespace(props={"level": "9"}) + plugin_cls.actionSetSensitivityLevel(stub, action, dev) + stub._send_matter_command.assert_called_once()