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