diff --git a/docs/API.md b/docs/API.md
index 93e7075..a3590c3 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -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):
@@ -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.
diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist
index 1e20067..d95a704 100644
--- a/indigo-matter.indigoPlugin/Contents/Info.plist
+++ b/indigo-matter.indigoPlugin/Contents/Info.plist
@@ -16,11 +16,11 @@
CFBundleVersion
- 2026.3.1
+ 2026.3.2
IwsApiVersion
1.0.0
PluginVersion
- 2026.3.1
+ 2026.3.2
ServerApiVersion
3.6
diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/commission_jobs.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/commission_jobs.py
index 89913ac..3fd96db 100644
--- a/indigo-matter.indigoPlugin/Contents/Server Plugin/commission_jobs.py
+++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/commission_jobs.py
@@ -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):
@@ -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
+ try:
+ return int(supported) - int(commissioned)
+ except (TypeError, ValueError):
+ return None
+
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
@@ -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 = ""
@@ -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
@@ -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)
@@ -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
# ------------------------------------------------------------------
diff --git a/tests/test_commission_jobs.py b/tests/test_commission_jobs.py
index 1f7eff7..627fecf 100644
--- a/tests/test_commission_jobs.py
+++ b/tests/test_commission_jobs.py
@@ -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)}