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()