Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion indigo-matter.indigoPlugin/Contents/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<key>IwsApiVersion</key>
<string>1.0.0</string>
<key>PluginVersion</key>
<string>2026.4.2</string>
<string>2026.5.0</string>
<key>ServerApiVersion</key>
<string>3.6</string>
</dict>
Expand Down
32 changes: 30 additions & 2 deletions indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
<!-- Device control (on/off, brightness, color, thermostat setpoints/modes) is
provided by Indigo for the built-in device types and routed via
actionControlDevice / actionControlSensor / actionControlThermostat, then
dispatched through the cluster mapping layer — so no custom control actions
are needed here.
dispatched through the cluster mapping layer. BooleanStateConfiguration's
sensitivity level (issue #85) has no Indigo built-in action shape (an
arbitrary numeric picker), so it is the plugin's first custom device
action below.

The uiPath="hidden" actions below are the Domio HTTP API (API.md v1.1),
served by the Indigo Web Server (IWS) at
Expand All @@ -12,6 +14,32 @@
Bearer API key Domio already uses. Auth is enforced by Indigo before the
handler runs. -->
<Actions>
<!-- Indigo's deviceFilter takes exactly ONE filter (comma lists match
nothing — live-verified 2026-07-06), so the sensitivity action is two
type-scoped entries sharing one callback: motion (the Aqara FP300
pairing) and contact (the Matter spec's BooleanState pairing). -->
<Action id="setSensitivityLevel"
deviceFilter="self.matterMotionSensor" uiPath="DeviceActions">
<Name>Set Sensitivity Level</Name>
<CallbackMethod>actionSetSensitivityLevel</CallbackMethod>
<ConfigUI>
<Field id="level" type="menu">
<Label>Sensitivity Level:</Label>
<List class="self" method="getSensitivityLevels" dynamicReload="true"/>
</Field>
</ConfigUI>
</Action>
<Action id="setSensitivityLevelContact"
deviceFilter="self.matterContactSensor" uiPath="DeviceActions">
<Name>Set Sensitivity Level (Contact Sensor)</Name>
<CallbackMethod>actionSetSensitivityLevel</CallbackMethod>
<ConfigUI>
<Field id="level" type="menu">
<Label>Sensitivity Level:</Label>
<List class="self" method="getSensitivityLevels" dynamicReload="true"/>
</Field>
</ConfigUI>
</Action>
<Action id="status" uiPath="hidden">
<Name>matter status</Name>
<CallbackMethod>http_status</CallbackMethod>
Expand Down
14 changes: 14 additions & 0 deletions indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@
<TriggerLabel>Battery Level</TriggerLabel>
<ControlPageLabel>Battery Level</ControlPageLabel>
</State>
<!-- BooleanStateConfiguration (0x0080) — issue #85. Co-locates with
OccupancySensing on e.g. the Aqara FP300. -->
<State id="sensitivityLevel">
<ValueType>Integer</ValueType>
<TriggerLabel>Sensitivity Level</TriggerLabel>
<ControlPageLabel>Sensitivity Level</ControlPageLabel>
</State>
</States>
<UiDisplayStateId>onOffState</UiDisplayStateId>
</Device>
Expand All @@ -206,6 +213,13 @@
<TriggerLabel>Battery Level</TriggerLabel>
<ControlPageLabel>Battery Level</ControlPageLabel>
</State>
<!-- BooleanStateConfiguration (0x0080) — issue #85. This is the
cluster's spec-native pairing (BooleanState, 0x0045). -->
<State id="sensitivityLevel">
<ValueType>Integer</ValueType>
<TriggerLabel>Sensitivity Level</TriggerLabel>
<ControlPageLabel>Sensitivity Level</ControlPageLabel>
</State>
</States>
<UiDisplayStateId>onOffState</UiDisplayStateId>
</Device>
Expand Down
39 changes: 39 additions & 0 deletions indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
# ------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -70,6 +72,7 @@ def default_handlers() -> list[ClusterHandler]:
TVOCHandler(),
ElectricalPowerHandler(),
ElectricalEnergyHandler(),
BooleanStateConfigHandler(),
PowerSourceHandler(),
]

Expand Down
78 changes: 76 additions & 2 deletions indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading