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
9 changes: 7 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ Health check. Domio calls this at the start of every commissioning flow as a pre

### 3.2 `POST …/message/com.simons-plugins.indigo-matter/commission`

Submit a setup code for commissioning into the Indigo fabric. Called by Domio immediately after it has opened a commissioning window on the device from its own fabric (multi-admin handoff, see ADR §C3).
Submit a setup code for commissioning into the Indigo fabric as a second admin (multi-admin handoff; workspace ADR-0004). The setup code Domio forwards here can come from either supported flow:

- **C4 — Apple Home / Alexa share (default).** The device is commissioned as admin 1 by the user's existing ecosystem (Apple Home, Alexa, …), which opens a pairing/commissioning window from **its own** fabric. Domio captures the resulting setup code (manual entry or scan) and forwards it here; `domioNodeId` and `expectedFabricSlots` are absent.
- **C3 — Domio direct commissioning.** Domio commissions the factory-fresh device onto **its own** transient fabric via `MatterSupport`/`MatterExtension`, then opens a commissioning window itself and auto-forwards the fresh setup code — no third-party ecosystem involved, and the device never joins Apple Home. Domio additionally sends `domioNodeId` (its own fabric's node id, for log correlation) and may send `expectedFabricSlots` (a hint that the device should still have at least N fabric slots free after Indigo joins).

Either way the plugin's join is identical: a setup code is a setup code, indistinguishable by source, and joins over IP as the Indigo fabric's second (or third, under C3) admin.

**Request parameters** (URL query items):

Expand All @@ -113,7 +118,7 @@ Submit a setup code for commissioning into the Indigo fabric. Called by Domio im
| `suggestedName` | string | yes | User-chosen friendly name. Applied to all created Indigo devices (with " (endpoint N)" suffix for multi-endpoint nodes). |
| `suggestedRoom` | string | no | Indigo room name. Devices are placed here on creation. If the room doesn't exist, the plugin creates it. |
| `domioNodeId` | string | no | Domio's nodeId for this device on its own fabric. Logged for correlation only. |
| `expectedFabricSlots` | int | no | Hint from Domio that this device should have at least N fabric slots available. Plugin logs a warning if matter-server reports fewer. |
| `expectedFabricSlots` | int | no | Hint from Domio (C3 only) that the device should still have at least N fabric slots free after this join. The plugin computes available slots as the interviewed node's Operational Credentials `SupportedFabrics − CommissionedFabrics` and logs a warning (job still succeeds) if that's fewer than N, or stays silent if the node's interview snapshot didn't expose those attributes. |

**Response 202:** commissioning started.

Expand Down
4 changes: 2 additions & 2 deletions indigo-matter.indigoPlugin/Contents/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>2026.3.1</string>
<string>2026.3.2</string>
<key>IwsApiVersion</key>
<string>1.0.0</string>
<key>PluginVersion</key>
<string>2026.3.1</string>
<string>2026.3.2</string>
<key>ServerApiVersion</key>
<string>3.6</string>
</dict>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from matter_client import COMMISSION_TIMEOUT
from matter_model import node_id_to_str # noqa: F401 - canonical home; re-exported for callers
from protocol import ProtocolError
from protocol import Protocol, ProtocolError


class JobStatus(str, Enum):
Expand Down Expand Up @@ -67,6 +67,39 @@ class JobStatus(str, Enum):
"the device may still join — check Indigo before retrying"
)

# Operational Credentials cluster (0x003E), endpoint 0 — SupportedFabrics
# (0x0002) minus CommissionedFabrics (0x0003) is the remaining fabric capacity
# after this join, used for the API.md §3.2 `expectedFabricSlots` warning.
_OP_CREDS_CLUSTER = 0x003E
_ATTR_SUPPORTED_FABRICS = 0x0002
_ATTR_COMMISSIONED_FABRICS = 0x0003


def _fabric_slots_available(node: dict) -> Optional[int]:
"""Best-effort read of remaining fabric capacity from the interview payload.

Returns None (unknown, never treated as zero) if the node's attribute
snapshot doesn't include both Operational Credentials attributes.
"""
supported = commissioned = None
for key, value in (node.get("attributes") or {}).items():
try:
endpoint, cluster, attribute = Protocol.parse_attr_key(str(key))
except (ValueError, AttributeError):
continue
if endpoint != 0 or cluster != _OP_CREDS_CLUSTER:
continue
if attribute == _ATTR_SUPPORTED_FABRICS:
supported = value
elif attribute == _ATTR_COMMISSIONED_FABRICS:
commissioned = value
if supported is None or commissioned is None:
return None
Comment on lines +84 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='indigo-matter.indigoPlugin/Contents/Server Plugin/commission_jobs.py'

wc -l "$FILE"
ast-grep outline "$FILE" --view expanded

# Read the relevant function(s) with line numbers
sed -n '1,220p' "$FILE" | cat -n

# Find the caller / exception handling around _warn_if_fabric_slots_short and _run_job
rg -n "def _warn_if_fabric_slots_short|def _run_job|internal_error|except Exception|attributes" "$FILE"

Repository: simons-plugins/indigo-matter

Length of output: 13813


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='indigo-matter.indigoPlugin/Contents/Server Plugin/commission_jobs.py'

sed -n '340,470p' "$FILE" | cat -n

Repository: simons-plugins/indigo-matter

Length of output: 8666


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "reconcile_node_added\(" -S .

Repository: simons-plugins/indigo-matter

Length of output: 1560


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='indigo-matter.indigoPlugin/Contents/Server Plugin/app.py'
sed -n '320,380p' "$FILE" | cat -n

Repository: simons-plugins/indigo-matter

Length of output: 266


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py'
sed -n '340,380p' "$FILE" | cat -n

Repository: simons-plugins/indigo-matter

Length of output: 2804


Guard attributes before iterating
_warn_if_fabric_slots_short() is also reached from _run_job(), so a truthy non-dict attributes value will raise on .items() and turn the commission into internal_error instead of staying best-effort. Add an isinstance(attributes, dict) guard before iterating.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/commission_jobs.py around
lines 84 - 97, The fabric-slot warning helper currently assumes
node.get("attributes") is a dict, so a truthy non-dict value can make
`_warn_if_fabric_slots_short()` fail inside `_run_job()` and escalate
commissioning to internal_error. Update `_warn_if_fabric_slots_short()` to guard
the `attributes` value before calling `.items()`, only iterating when it is an
actual dict, and otherwise fall back to returning None without raising.

try:
return int(supported) - int(commissioned)
except (TypeError, ValueError):
return None

Comment on lines +70 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Wire-format constants belong in protocol.py, not commission_jobs.py.

_OP_CREDS_CLUSTER, _ATTR_SUPPORTED_FABRICS, _ATTR_COMMISSIONED_FABRICS and the attribute-key interpretation logic in _fabric_slots_available encode matter-server wire semantics (cluster/attribute IDs). Per the repo's own firewall convention, this knowledge should live behind protocol.py, exposed as a helper (e.g. Protocol.fabric_slots_available(node)), rather than duplicated here.

As per coding guidelines, "Use protocol.py as the rename firewall — the only place that knows matter-server wire field names."

♻️ Suggested direction
-_OP_CREDS_CLUSTER = 0x003E
-_ATTR_SUPPORTED_FABRICS = 0x0002
-_ATTR_COMMISSIONED_FABRICS = 0x0003
-
-
-def _fabric_slots_available(node: dict) -> Optional[int]:
-    ...
+# moved to protocol.py:
+# def fabric_slots_available(node: dict) -> Optional[int]: ...

Then in commission_jobs.py: available = Protocol.fabric_slots_available(node).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/commission_jobs.py around
lines 70 - 102, The wire-format Operational Credentials constants and attribute
parsing logic currently live in `_fabric_slots_available` inside
`commission_jobs.py`, but this protocol knowledge should be centralized in
`protocol.py`. Move the cluster/attribute ID handling and the slot গণনা into a
`Protocol` helper such as `Protocol.fabric_slots_available(node)`, keeping
`commission_jobs.py` limited to calling that helper. Update any callers to use
the new `Protocol` method and remove the duplicated wire-semantic constants from
`commission_jobs.py`.

Source: Coding guidelines


def _now_utc() -> datetime:
return datetime.now(timezone.utc)
Expand Down Expand Up @@ -105,6 +138,7 @@ class Job:
suggested_room: Optional[str]
discriminator: Optional[int] = None
domio_node_id: Optional[str] = None
expected_fabric_slots: Optional[int] = None
status: JobStatus = PENDING
progress: float = 0.0
message: str = ""
Expand Down Expand Up @@ -196,6 +230,7 @@ def create_job(self, params: dict) -> tuple[int, dict]:
suggested_room=(params.get("suggestedRoom") or None),
discriminator=_opt_int(params.get("discriminator")),
domio_node_id=(params.get("domioNodeId") or None),
expected_fabric_slots=_opt_int(params.get("expectedFabricSlots")),
started_at=self._clock(),
)
self._jobs[job.job_id] = job
Expand Down Expand Up @@ -347,6 +382,7 @@ async def _run_job(self, job: Job) -> None:

self._advance(job, READING_DESCRIPTORS, 0.6, "Discovering device capabilities…")
node = await self.matter.get_node(node_id)
self._warn_if_fabric_slots_short(job, node, node_id)

self._advance(job, CREATING_DEVICES, 0.85, "Creating Indigo devices…")
created = self._create_devices(node, job.suggested_name, job.suggested_room)
Expand Down Expand Up @@ -419,6 +455,22 @@ async def _fail(self, job: Job, code: str, message: str,
job.error = error
self._advance(job, FAILED, job.progress, "")

def _warn_if_fabric_slots_short(self, job: Job, node: dict, node_id: Any) -> None:
"""API.md §3.2 `expectedFabricSlots`: log a warning (never blocking) if
the device's post-join fabric capacity is under what Domio expects.
Silent when the hint is absent or the interview snapshot didn't include
the Operational Credentials attributes (older firmware, partial read)."""
if job.expected_fabric_slots is None:
return
available = _fabric_slots_available(node)
if available is None or available >= job.expected_fabric_slots:
return
self.logger.warning(
"commission job %s: node %s reports only %d fabric slot(s) available "
"after joining, but Domio expected at least %d (expectedFabricSlots)",
job.job_id, node_id_to_str(node_id), available, job.expected_fabric_slots,
)

# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
Expand Down
66 changes: 66 additions & 0 deletions tests/test_commission_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,72 @@ def test_unknown_job_is_404(mock_logger):
assert code == 404 and body["error"] == "job_not_found"


# ----------------------------------------------------------------------
# expectedFabricSlots (API.md §3.2): warn on low post-join fabric capacity
# ----------------------------------------------------------------------
def _node_with_fabrics(supported, commissioned, node_id=0x1):
return {
"node_id": node_id,
"attributes": {
"0/62/2": supported, # SupportedFabrics
"0/62/3": commissioned, # CommissionedFabrics
},
}


def test_expected_fabric_slots_warns_when_fewer_available(mock_logger):
async def scenario():
matter = FakeMatter(node_id=0x1, node=_node_with_fabrics(5, 4))
jobs = _jobs(matter, mock_logger, schedule=asyncio.ensure_future)
_, body = jobs.create_job({
"setupCode": "12345678901", "suggestedName": "X", "expectedFabricSlots": "3",
})
final = await _await_terminal(jobs, body["jobId"])()
assert final["status"] == "success" # diagnostic only, never blocking
mock_logger.warning.assert_called_once()
logged = str(mock_logger.warning.call_args)
assert body["jobId"] in logged and "fabric slot" in logged
assert ", 1, 3)" in logged # available=1, expected=3
asyncio.run(scenario())


def test_expected_fabric_slots_silent_when_sufficient(mock_logger):
async def scenario():
matter = FakeMatter(node_id=0x1, node=_node_with_fabrics(5, 2))
jobs = _jobs(matter, mock_logger, schedule=asyncio.ensure_future)
_, body = jobs.create_job({
"setupCode": "12345678901", "suggestedName": "X", "expectedFabricSlots": "3",
})
await _await_terminal(jobs, body["jobId"])()
mock_logger.warning.assert_not_called()
asyncio.run(scenario())


def test_expected_fabric_slots_silent_when_absent_from_node(mock_logger):
async def scenario():
# Interview snapshot has no Operational Credentials attributes at all —
# unknown capacity must never be treated as zero / trigger a warning.
matter = FakeMatter(node_id=0x1, node={"node_id": 0x1, "attributes": {}})
jobs = _jobs(matter, mock_logger, schedule=asyncio.ensure_future)
_, body = jobs.create_job({
"setupCode": "12345678901", "suggestedName": "X", "expectedFabricSlots": "3",
})
await _await_terminal(jobs, body["jobId"])()
mock_logger.warning.assert_not_called()
asyncio.run(scenario())


def test_expected_fabric_slots_silent_when_not_requested(mock_logger):
async def scenario():
# Low capacity, but Domio didn't send the hint — no warning.
matter = FakeMatter(node_id=0x1, node=_node_with_fabrics(5, 5))
jobs = _jobs(matter, mock_logger, schedule=asyncio.ensure_future)
_, body = jobs.create_job({"setupCode": "12345678901", "suggestedName": "X"})
await _await_terminal(jobs, body["jobId"])()
mock_logger.warning.assert_not_called()
asyncio.run(scenario())


def test_terminal_jobs_are_reaped_after_retention(mock_logger):
async def scenario():
clock = {"t": datetime(2026, 6, 8, 12, 0, tzinfo=timezone.utc)}
Expand Down
Loading