diff --git a/CLAUDE.md b/CLAUDE.md index 3d03c0d..77bb9e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,13 @@ loop→Indigo writes go straight through `device_sync.apply_states` (thread-safe | Module | Role | |---|---| -| `plugin.py` | Lifecycle glue, action bridge, IWS HTTP handlers. Since E6 also the pairing surface: **Pair Matter Bridge…** (§3.8, codes to the event log because Indigo dialogs have no dynamic labels), **Unpair an Ecosystem…** (§3.9, two confirm gates + a dynamic fabric picker), the `pairing` IWS page, and `getPrefsConfigUiValues`, which seeds PRD §5.5's read-only export readout from state the plugin already holds (no I/O on a dialog open) | +| `plugin.py` | Lifecycle glue and the device/action bridge — `startup`/`shutdown`/`runConcurrentThread`, the device-subscription/export-id watchdog, `deviceUpdated`/`deviceDeleted`, `actionControl*`, and `getPrefsConfigUiValues`'s PRD §5.5 export readout. Composes the four mixins below into `Plugin`; Indigo resolves every ``/list-method by name as an attribute on the `Plugin` class, which is why the split (issue #146) is mixins rather than free-standing modules — none of them may define `__init__`/`deviceUpdated`/etc., or the `super()` chain to `indigo.PluginBase` breaks | +| `plugin_constants.py` | Constants (timeouts, menu/option labels) and the two pure prefs helpers, `server_location`/`sanitize_host`, shared across `plugin.py` and the four mixins — split out because several groups are consumed by two different mixins each, and a mixin importing from `plugin.py` would be a back-import (issue #146) | +| `pairing_page.py` | The pairing IWS page's HTML template (PRD §6) — pure rendering, no `indigo` import, no plugin state. Only caller is `HttpApiMixin._pairing_page` | +| `http_api_mixin.py` | The Domio HTTP API (`docs/API.md` v1.1) — `http_status`/`http_commission`/`http_decommission`/`http_diagnostics`/`http_pairing`. The five IWS `` handlers share the `_parse_request`/`_reply` helpers, which is why the pairing page's IWS glue lives here rather than in `PairingMenuMixin` | +| `export_dialog_mixin.py` | The **Manage Matter Exports…** dialog (PRD-indigo-matter-export §5.1 UI-D) — candidate/role pickers, add/update/remove, and the reconcile/save machinery `startup` calls into. `_truthy` is a de facto shared helper used by call sites across three mixins; it lives here only because `Plugin` always composes all four | +| `pairing_menu_mixin.py` | **Pair Matter Bridge…** (§3.8, codes to the event log because Indigo dialogs have no dynamic labels), **Unpair an Ecosystem…** (§3.9, two confirm gates + a dynamic fabric picker), and fabric backup/restore (issues #26, #136) | +| `server_menu_mixin.py` | matter-server install/restart menus, manual commission/decommission-device menus (+ folder and node pickers), export-bridge recovery (rebuild endpoint map, reset pairings), and the bridge node's LaunchAgent start/stop/diagnose seams — originally separate bands kept in one mixin because they share one construction rule (`ServerProcess` only ever from `Plugin._server_prefs()`) and one install thread | | `async_runtime.py` | The event loop + thread + bridge primitives | | `protocol.py` | **Rename firewall** — the only place that knows matter-server wire field names | | `ws_json_client.py` | Shared transport core for both WS clients: run loop, `min(2**attempt, 30)` reconnect backoff, `message_id`→future correlation, disconnect/diagnostic handling. Handshake + frame vocabulary are subclass hooks; unmatched **error** responses are logged (BRIDGE_PROTOCOL §3.4) | diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index 11f3790..f3a9840 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.9.1 + 2026.9.2 ServerApiVersion 3.6 diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py index 5aa9459..699efd0 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py @@ -402,7 +402,7 @@ def list_nodes(self) -> list: Every node matter-server has reported is listed, INCLUDING one that produced no Indigo devices — its entry carries an empty name list, which - ``getMatterNodes`` in ``plugin.py`` renders as "(no Indigo devices)". + ``ServerMenuMixin.getMatterNodes`` renders as "(no Indigo devices)". See ``_known_nodes`` for why ``_index`` alone could not do this. Sorted by node id; device names resolved outside the lock so a slow diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py index 8c239fe..3ca23c6 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py @@ -1,7 +1,7 @@ """The outbound export engine — Indigo device changes ⇄ the bridge node. -``plugin.py`` stays lifecycle glue: it owns the Indigo callbacks and nothing -else. Everything those callbacks *mean* for export lives here — when the +``plugin.py`` and its mixins stay UI/lifecycle glue: they own the Indigo +callbacks and nothing else. Everything those callbacks *mean* for export lives here — when the :class:`bridge_client.BridgeClient` exists at all, what the desired endpoint set is, how a device change becomes a ``set_state``, and how an ecosystem command becomes an ``indigo.*`` call. A bare ``§N`` below is ``docs/BRIDGE_PROTOCOL.md``. diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/export_dialog_mixin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/export_dialog_mixin.py new file mode 100644 index 0000000..a1b89c0 --- /dev/null +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/export_dialog_mixin.py @@ -0,0 +1,641 @@ +"""The Matter export allow-list — the "Manage Matter Exports…" dialog +(PRD-indigo-matter-export §5.1 UI-D; roles per BRIDGE_PROTOCOL §4.2). Owns the +picker/status callbacks and the reconcile/save machinery ``startup`` calls into. +See issue #146. +""" +from __future__ import annotations + +from typing import Any, Optional + +import indigo # provided by the Indigo runtime + +import export_catalog +import export_handlers +from export_store import ExportEntry, OPTION_INVERT +from plugin_constants import ( + EXCLUDED_OPTION_PREFIX, EXPORT_PICKER_LIMIT, LIST_ERROR_OPTION, + MENU_MANAGE_EXPORTS, MENU_UNPAIR_ECOSYSTEM, NO_MATCH_OPTION, + NO_SELECTION_ID, NO_SELECTION_LABEL, ROW_ERROR_LABEL, TRUNCATED_OPTION, +) + + +class ExportDialogMixin: + """The export allow-list dialog: pickers, add/remove, and startup reconcile. + + Composed into ``Plugin`` alongside the other three mixins; never + instantiated on its own and never subclasses ``indigo.PluginBase``. + """ + + # Self-attribute contract: these are created by ``Plugin.__init__``/ + # ``startup`` and stay in plugin.py. Declared here as class-level + # annotations only (no assignment) so mixin methods resolve `self.` + # without shadowing instance state, and so pylint's `no-member` check has + # something to verify against. See issue #146. (``self.pluginId`` is + # supplied by ``indigo.PluginBase`` itself, not by ``Plugin.__init__``, and + # is read via ``getattr`` below — it needs no annotation here.) + exports: Any + export_bridge: Any + logger: Any + + # ------------------------------------------------------------------ + # Matter export allow-list — the "Manage Matter Exports…" dialog + # (PRD-indigo-matter-export §5.1 UI-D; roles per BRIDGE_PROTOCOL §4.2) + # ------------------------------------------------------------------ + def _export_plugin_id(self) -> str: + """This plugin's id, for the loop guard (XNG3/XAC6). + + Read from the running plugin rather than hardcoded, so the guard can + never drift from the bundle it is protecting; the catalog constant is + only the fallback for a plugin object built without one (tests). + """ + return getattr(self, "pluginId", "") or export_catalog.DEFAULT_PLUGIN_ID + + @staticmethod + def _truthy(value) -> bool: + """Indigo checkboxes arrive as bools or as "true"/"false" strings. + + Cross-band helper: also called from ``PairingMenuMixin`` and + ``ServerMenuMixin`` via MRO — its home here is correct only because + ``ExportDialogMixin`` is always composed into ``Plugin`` (issue #146). + """ + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "1") + return bool(value) + + @staticmethod + def _indigo_device(device_id): + """``indigo.devices[device_id]`` or None — a stale id is never fatal.""" + try: + return indigo.devices[int(device_id)] + except Exception: # pylint: disable=broad-except # KeyError/ValueError/Indigo's own + return None + + def _export_selection(self, values_dict) -> tuple[str, int]: + """Decode the picker value into ``(kind, device_id)``. + + ``kind`` is ``"none"`` (nothing chosen, or one of the informational + rows — the "select a device" seed, the truncation tail, the no-match + note), ``"excluded"`` (an ``x-`` row the user may see but not pick), or + ``"device"``. + """ + raw = str((values_dict or {}).get("exportDevice", "") or "") + if not raw or raw == NO_SELECTION_ID or raw in (TRUNCATED_OPTION[0], NO_MATCH_OPTION[0]): + return ("none", 0) + excluded = raw.startswith(EXCLUDED_OPTION_PREFIX) + if excluded: + raw = raw[len(EXCLUDED_OPTION_PREFIX):] + try: + device_id = int(raw) + except (TypeError, ValueError): + return ("none", 0) + return ("excluded" if excluded else "device", device_id) + + def _save_plugin_prefs(self) -> None: + """Flush pluginPrefs to Indigo's database (the store's commit step).""" + indigo.server.savePluginPrefs() + + def _reject_unexportable_entry(self, entry) -> str | None: + """Validator for entries restored from prefs — the loop guard, re-run. + + Load is the one write path the dialog's guards never see: a blob + restored from a backup, or hand-edited in the ``.indiPref``, can name a + device this plugin created. Only the loop guard is enforced here. + Ordinary ineligibility is *reported* by the startup reconcile and left + alone, because a device can be temporarily odd (a plugin still + starting) and silently deleting the user's export would be worse than + an accessory that fails to build. + """ + dev = self._indigo_device(entry.indigo_device_id) + if dev is None: + return None + verdict = export_catalog.classify(dev, self._export_plugin_id()) + if isinstance(verdict, export_catalog.Excluded) \ + and verdict.reason == export_catalog.REASON_LOOP_GUARD: + return export_catalog.REASON_LOOP_GUARD + return None + + def _reconcile_exports(self) -> None: + """Report-only startup sweep of the allow-list (never edits it). + + An export whose device has been deleted, or which no longer classifies + as exportable, is a real problem the user should hear about at startup + rather than discovering as a missing accessory. It is NOT auto-removed: + the allow-list is the user's declaration, and E3 re-classifies at + endpoint-build time anyway. + """ + if self.exports is None: + return + try: + plugin_id = self._export_plugin_id() + for entry in self.exports.all(): + dev = self._indigo_device(entry.indigo_device_id) + if dev is None: + self.logger.warning( + "Matter export allow-list: device %s is exported as %s but no longer " + "exists in Indigo — it will not be bridged. Remove it in " + "'Manage Matter Exports…'.", + entry.indigo_device_id, entry.role) + continue + verdict = export_catalog.classify(dev, plugin_id) + if isinstance(verdict, export_catalog.Excluded): + self.logger.warning( + "Matter export allow-list: %s (id %s) is exported as %s but is no longer " + "exportable: %s. It will not be bridged.", + getattr(dev, "name", ""), entry.indigo_device_id, entry.role, + verdict.reason) + elif entry.role not in verdict.eligible_roles: + self.logger.warning( + "Matter export allow-list: %s (id %s) is exported as %s, which this " + "device no longer offers (%s). Re-pick its role in " + "'Manage Matter Exports…'.", + getattr(dev, "name", ""), entry.indigo_device_id, entry.role, + ", ".join(verdict.eligible_roles)) + except Exception as exc: # pylint: disable=broad-except + # A diagnostic sweep must never be the thing that fails startup. + self.logger.exception(exc) + + def _export_summary(self) -> str: + if self.exports is None: + return "Plugin still starting — reopen this dialog in a moment." + count = len(self.exports) + # A load failure has to lead. Reporting "Nothing is exported yet." over + # a blob we could not read invites the user to rebuild the list from + # scratch, and the rebuild's first save overwrites the rescue copy. + error = self.exports.load_error + if error: + return error if not count else \ + f"{error} {count} device(s) exported.{self._export_bridge_note()}" + if not count: + return "Nothing is exported yet." + summary = f"{count} device(s) exported." + # An export whose role this version cannot bridge is silently absent + # from every ecosystem otherwise — the dialog is the only place the user + # would ever look for the reason. + pending = sum(1 for entry in self.exports.all() + if not export_handlers.is_bridgeable(entry.role)) + if pending: + # E4 completed the v1 role table, so this can now only mean an + # allow-list written by a NEWER plugin than the one running — the + # export blob lives in plugin prefs and survives a downgrade. + summary += (f" {pending} of them use a role this version cannot bridge " + "and will not appear in any ecosystem — they were most likely " + "added by a newer version of this plugin.") + return summary + self._export_bridge_note() + + def _export_bridge_note(self) -> str: + """One sentence when the exports exist but are not actually live. + + "3 device(s) exported." is true and useless while the bridge client is + halted on a version skew: the user is looking at this dialog precisely + because a light is missing from the Home app, and every state below + answers that question. Reported as a suffix so a load error — which is + about rescuing the user's list, and outranks everything — still leads. + """ + bridge = self.export_bridge + if bridge is None or not bridge.active: + # No client is the CORRECT state for an empty allow-list (XG5), and + # the count above already says the list is not empty — so this is a + # plugin still starting, which its own log line covers. + return "" + client = bridge.client + if client.halted: + return (f" Bridge client halted ({client.halted_reason or 'no reason recorded'}) " + "— restart the bridge node.") + if client.recovery: + return (" The bridge node is waiting for an endpoint-map rebuild — exports are not " + "live until it is done. Use 'Rebuild Matter Endpoint Map…' in the plugin " + "menu.") + if not client.attached: + return " Not connected to the bridge node — exports are not live." + return self._export_health_note(client.status) + + @staticmethod + def _export_health_note(status) -> str: + """The §4.3 facts the dialog is the only place a user would look for. + + `drift` and `warnings` were parsed and then read by nobody: an endpoint + number that had moved, or a map the node could not write, showed up in + the log at the moment it happened and nowhere at all afterwards. This + dialog is where somebody goes when an accessory is behaving oddly. + """ + if status is None: + return "" + if status.warnings: + return (f" The bridge node reports {len(status.warnings)} persistence problem(s): " + f"{'; '.join(status.warnings)}") + if status.drift: + return (f" WARNING: {len(status.drift)} exported accessory number(s) have DRIFTED — " + "they may have swapped identities in paired ecosystems. See the log; this is " + "never repaired automatically.") + if not status.drift_checked: + return (" Endpoint numbers have not been checked against a saved map yet — that " + "happens on the first reconcile.") + return "" + + def get_menu_action_config_ui_values(self, menu_id): + """Seed the export and unpair dialogs (menu dialogs never remember values). + + Only those two are seeded — this callback fires for EVERY menu item that + has a ConfigUI, and returning values for another one would overwrite its + defaults. + """ + values = indigo.Dict() + if menu_id == MENU_UNPAIR_ECOSYSTEM: + # The picker leads with a no-selection row; seed the field to match + # it, or Indigo renders the seeded-but-unmatched value as a blank + # first item and the user is one click from unpairing whatever + # happens to be second. + values["fabric"] = NO_SELECTION_ID + return values + if menu_id != MENU_MANAGE_EXPORTS: + return values + values["exportFilter"] = "" + values["exportDevice"] = NO_SELECTION_ID + values["exportRole"] = "" + values["exportName"] = "" + values["exportInvert"] = False + values["exportStatus"] = self._export_summary() + return values + + def _log_row_failure(self, exc, first: bool) -> None: + """Log one unreadable picker row — stack for the first, one line after. + + A database with fifty broken proxies must not write fifty tracebacks + into the event log, but the first one has to carry enough to debug. + """ + if first: + self.logger.exception(exc) + else: + self.logger.error("Matter bridge: another device could not be read — %s", exc) + + @staticmethod + def _candidate_row(dev, name: str, plugin_id: str, exported) -> Optional[tuple[str, str]]: + """One picker row for ``dev``, or None to omit it. May raise — the caller contains it. + + Loop-guard devices (created by this plugin) return None: XAC6 requires + them ABSENT from the picker, not merely unpickable — every one of them + shadows a device the user already sees, so listing them as excluded + would only add noise. Every OTHER exclusion is listed with its reason + (XAC9); hiding those would leave a user hunting for a device that never + appears. + """ + device_id = dev.id + # An excluded device that IS exported keeps its marker: the pair + # "excluded" + "exported" is exactly the state the user has to know + # about, and hiding half of it reads as a picker bug rather than the + # stale export it actually is. + mark = "● " if device_id in exported else "" + verdict = export_catalog.classify(dev, plugin_id) + if isinstance(verdict, export_catalog.Excluded): + if verdict.reason == export_catalog.REASON_LOOP_GUARD: + return None + return (f"{EXCLUDED_OPTION_PREFIX}{device_id}", + f"{mark}{name} — not exportable: {verdict.reason}") + return (str(device_id), f"{mark}{name}") + + def getExportCandidates(self, filter="", valuesDict=None, typeId="", targetId=0): + # pylint: disable=redefined-builtin, unused-argument, too-many-locals + """Picker rows: every Indigo device, exportable or not (XAC9). + + Excluded devices are listed **with the reason in the label** and an + ``x-``-prefixed id so the callbacks can reject the pick cleanly — + hiding them would leave a user hunting for a device that will never + appear. ``filter`` here is the XML's static filter attribute, NOT the + user's text: textfields have no callbacks, so the typed filter arrives + in ``valuesDict`` and the Apply-filter button drives the reload. + + One device that cannot be read costs one row, not the whole list: the + try/except is INSIDE the loop, because the alternative is a dialog that + renders empty the moment any device in the database misbehaves. That + promise holds below :data:`EXPORT_PICKER_LIMIT`; past it, unreadable + devices are counted in the truncation tail like any other row, and the + log still carries every one. + + Ordering: the seeded ``(select a device)`` row is always first, then + every already-exported device (database order), then everything else + (also database order) — never alphabetised. This dialog is the only + place a user can remove an export, so an exported device buried past + :data:`EXPORT_PICKER_LIMIT` would be effectively stuck there; exported + rows are therefore classified and kept unconditionally, and the cap is + applied only to the rest, at the end, once the exported count is known. + """ + try: + text = str((valuesDict or {}).get("exportFilter", "") or "").strip().lower() + exported = self.exports.ids() if self.exports is not None else frozenset() + plugin_id = self._export_plugin_id() + # Always a real row for the seeded value, and always first. + options: list[tuple[str, str]] = [(NO_SELECTION_ID, NO_SELECTION_LABEL)] + # One pass, two row lists: exported rows are never truncated (see + # docstring), so the cap is applied only to `other_rows`, after the + # loop, once `len(exported_rows)` is known. + exported_rows: list[tuple[str, str]] = [] + other_rows: list[tuple[str, str]] = [] + truncated = 0 + failures = 0 + for dev in indigo.devices: + try: + name = str(getattr(dev, "name", "") or "") + if text and text not in name.lower(): + continue + is_exported = dev.id in exported + if not is_exported and len(other_rows) >= EXPORT_PICKER_LIMIT: + # Upper bound on what the cap below could ever keep — + # exported rows only shrink that allowance, never + # raise it — so it's safe to stop building rows here. + # Still counted, so the tail stays honest. + truncated += 1 + continue + row = self._candidate_row(dev, name, plugin_id, exported) + if row is None: # loop guard: absent, not excluded (XAC6) + continue + (exported_rows if is_exported else other_rows).append(row) + except Exception as exc: # pylint: disable=broad-except + self._log_row_failure(exc, first=not failures) + failures += 1 + # `dev.id` may be exactly what failed to read, so an + # unreadable device can't be safely tested for membership + # in `exported` — it always lands with the others. + other_rows.append((f"{EXCLUDED_OPTION_PREFIX}err{failures}", + f"— {ROW_ERROR_LABEL}")) + allowance = max(EXPORT_PICKER_LIMIT - len(exported_rows), 0) + truncated += max(len(other_rows) - allowance, 0) + options.extend(exported_rows) + options.extend(other_rows[:allowance]) + if truncated: + options.append(TRUNCATED_OPTION) + if len(options) == 1: + options.append(NO_MATCH_OPTION) + return options + except Exception as exc: # pylint: disable=broad-except + self.logger.exception(exc) + return [LIST_ERROR_OPTION] + + def getExportRoles(self, filter="", valuesDict=None, typeId="", targetId=0): + # pylint: disable=redefined-builtin, unused-argument + """Roles the picked device may legitimately be exported as (§5.2). + + Empty for no selection or an excluded pick — an empty role menu is the + honest rendering of "there is nothing you may choose here". An outright + failure is NOT empty: it says so, so the user does not read a broken + callback as "this device offers no roles". + """ + try: + kind, device_id = self._export_selection(valuesDict) + if kind != "device": + return [] + dev = self._indigo_device(device_id) + if dev is None: + return [] + verdict = export_catalog.classify(dev, self._export_plugin_id()) + if isinstance(verdict, export_catalog.Excluded): + return [] + options = [] + for role in verdict.eligible_roles: + try: + options.append((role, export_catalog.role_label(role))) + except Exception as exc: # pylint: disable=broad-except + self._log_row_failure(exc, first=not options) + return options + except Exception as exc: # pylint: disable=broad-except + self.logger.exception(exc) + return [LIST_ERROR_OPTION] + + def getCurrentExports(self, filter="", valuesDict=None, typeId="", targetId=0): + # pylint: disable=redefined-builtin, unused-argument + """Read-only summary of the allow-list (one row per export).""" + try: + if self.exports is None: + return [(NO_SELECTION_ID, "(plugin still starting)")] + options = [] + failures = 0 + for entry in self.exports.all(): + try: + dev = self._indigo_device(entry.indigo_device_id) + name = str(getattr(dev, "name", "") or "") if dev is not None else "" + if not name: + name = f"(deleted device {entry.indigo_device_id})" + label = f"{name} → {export_catalog.role_label(entry.role)}" + if entry.name_override: + label += f' · shown as "{entry.name_override}"' + if entry.options.get(OPTION_INVERT): + label += " · inverted" + options.append((str(entry.indigo_device_id), label)) + except Exception as exc: # pylint: disable=broad-except + self._log_row_failure(exc, first=not failures) + failures += 1 + options.append((f"{EXCLUDED_OPTION_PREFIX}err{len(options)}", + f"— {ROW_ERROR_LABEL}")) + return options or [(NO_SELECTION_ID, "(nothing exported yet)")] + except Exception as exc: # pylint: disable=broad-except + self.logger.exception(exc) + return [LIST_ERROR_OPTION] + + def _exported_warning(self, device_id: int) -> str: + """Suffix warning shown when an EXCLUDED device is nonetheless exported. + + This is the incoherent state worth naming out loud: the allow-list says + export it, the catalog says it cannot be. Left alone it becomes an + accessory that never appears, with no visible cause. + """ + if self.exports is not None and device_id in self.exports: + return " — but this device IS currently exported — remove it or it will fail to bridge" + return "" + + def exportReloadPicker(self, valuesDict, typeId="", devId=0): + # pylint: disable=unused-argument + """Apply-filter button: the return trip is what reloads the lists.""" + values = valuesDict + text = str(values.get("exportFilter", "") or "").strip() + values["exportStatus"] = (f'Filtered on "{text}". {self._export_summary()}' if text + else self._export_summary()) + return values + + def exportDeviceChanged(self, valuesDict, typeId="", devId=0): + # pylint: disable=unused-argument + """Picker selection changed: load that device's saved export, or defaults. + + Menu callbacks return a valuesDict, not an error dict (the SDK's menu + contract), so an excluded pick is reported in the read-only status + field here — and refused again by the Add/update button below. Both + paths are covered by tests. + """ + values = valuesDict + kind, device_id = self._export_selection(values) + if kind == "none": + values["exportRole"] = "" + values["exportName"] = "" + values["exportInvert"] = False + values["exportStatus"] = self._export_summary() + return values + dev = self._indigo_device(device_id) + if kind == "excluded" or dev is None: + reason = "that device no longer exists" + if dev is not None: + verdict = export_catalog.classify(dev, self._export_plugin_id()) + reason = verdict.reason if isinstance(verdict, export_catalog.Excluded) \ + else "that device is not exportable" + values["exportRole"] = "" + values["exportName"] = "" + values["exportInvert"] = False + values["exportStatus"] = (f"Not exportable: {reason}" + f"{self._exported_warning(device_id)}") + return values + verdict = export_catalog.classify(dev, self._export_plugin_id()) + if isinstance(verdict, export_catalog.Excluded): + values["exportRole"] = "" + values["exportName"] = "" + values["exportInvert"] = False + values["exportStatus"] = (f"Not exportable: {verdict.reason}" + f"{self._exported_warning(device_id)}") + return values + entry = self.exports.get(device_id) if self.exports is not None else None + if entry is not None: + values["exportRole"] = entry.role + values["exportName"] = entry.name_override or "" + values["exportInvert"] = bool(entry.options.get(OPTION_INVERT, False)) + values["exportStatus"] = f"{dev.name} is exported as {export_catalog.role_label(entry.role)}." + else: + values["exportRole"] = verdict.default_role + values["exportName"] = "" + values["exportInvert"] = False + values["exportStatus"] = f"{dev.name} is not exported yet." + return values + + def exportAddOrUpdate(self, valuesDict, typeId="", devId=0): + # pylint: disable=unused-argument + """Add or update one export. Validates the role against the catalog. + + A role the catalog does not offer for this device is refused here + rather than by the bridge node, which would only reject it with + ``unknown_role``/``role_change`` long after the user could connect the + failure to what they did (BRIDGE_PROTOCOL §1.1). + + Returns **the values dict only**. A ``(valuesDict, errorsDict)`` tuple + is the documented contract for *validation* methods, not for button + ``CallbackMethod``s — the SDK's button reference says a button callback + returns a dictionary of field changes, and the field carrying a button's + outcome is read-only, so it cannot hold an error message anyway. Every + refusal therefore lands in ``exportStatus``, which is what the dialog + actually shows. + """ + values = valuesDict + if self.exports is None: + values["exportStatus"] = "Plugin still starting — try again in a moment." + return values + kind, device_id = self._export_selection(values) + if kind == "none": + values["exportStatus"] = "Select a device to export." + return values + dev = self._indigo_device(device_id) + if dev is None: + values["exportStatus"] = "That device no longer exists — refresh the list." + return values + verdict = export_catalog.classify(dev, self._export_plugin_id()) + if kind == "excluded" or isinstance(verdict, export_catalog.Excluded): + reason = verdict.reason if isinstance(verdict, export_catalog.Excluded) \ + else "not exportable" + values["exportStatus"] = (f"{dev.name} cannot be exported: {reason}" + f"{self._exported_warning(device_id)}") + return values + role = str(values.get("exportRole", "") or "") + if role not in verdict.eligible_roles: + values["exportStatus"] = ("Choose how this device should appear " + f"({', '.join(verdict.eligible_roles)}).") + return values + name_override = str(values.get("exportName", "") or "").strip() or None + options = {} + if role == export_catalog.ROLE_WINDOW_COVERING and self._truthy(values.get("exportInvert")): + options[OPTION_INVERT] = True + previous = self.exports.get(device_id) + existed = previous is not None + role_changed = existed and previous.role != role + try: + self.exports.upsert(ExportEntry( + indigo_device_id=device_id, role=role, + name_override=name_override, options=options, + )) + except Exception as exc: # pylint: disable=broad-except + # The store rolled back, so nothing was saved — say so rather than + # reporting the success the old code reported unconditionally. + self.logger.error("Matter bridge: saving the export list FAILED — %s", exc) + self.logger.exception(exc) + values["exportStatus"] = "FAILED to save the export list — see Event Log" + return values + verb = "Updated" if existed else "Added" + self.logger.info("%s Matter export for %s (id %s) as %s%s", + verb, dev.name, device_id, role, + f' named "{name_override}"' if name_override else "") + self._nudge_export(device_id, role_changed=role_changed) + values["exportStatus"] = f"{verb} {dev.name} as {export_catalog.role_label(role)}. " \ + f"{self._role_change_warning(role_changed)}{self._export_summary()}" + return values + + @staticmethod + def _role_change_warning(role_changed: bool) -> str: + """What a role change actually costs the user, said before they find out. + + BRIDGE_PROTOCOL §4.1 rejects changing an existing endpoint's role, so the + plugin removes and re-adds it. Ecosystems treat that as a brand-new + accessory: the name and room it was given in Apple Home are gone. + """ + if not role_changed: + return "" + return ("Changing the role RE-CREATES the accessory, so it loses the name and room " + "you gave it in Apple Home and any other paired ecosystem. ") + + def _nudge_export(self, device_id: int, *, role_changed: bool = False) -> None: + """Tell the bridge about one changed export, without a full reconnect. + + A role change is the one case that cannot be an ``upsert``: §4.1 refuses + it with ``role_change``, so it becomes remove-then-add. + """ + self._exports_changed() # pylint: disable=no-member # lifecycle, stays in plugin.py + bridge = self.export_bridge + if bridge is None: + return + try: + if role_changed: + bridge.replace(device_id) + else: + bridge.upsert(device_id) + except Exception as exc: # pylint: disable=broad-except + self.logger.exception(exc) + + def exportRemove(self, valuesDict, typeId="", devId=0): + # pylint: disable=unused-argument + """Drop the picked device from the allow-list. Returns values only (see above).""" + values = valuesDict + if self.exports is None: + values["exportStatus"] = "Plugin still starting — try again in a moment." + return values + kind, device_id = self._export_selection(values) + if kind == "none": + values["exportStatus"] = "Select a device to remove from the export list." + return values + try: + removed = self.exports.remove(device_id) + except Exception as exc: # pylint: disable=broad-except + self.logger.error("Matter bridge: saving the export list FAILED — %s", exc) + self.logger.exception(exc) + values["exportStatus"] = "FAILED to save the export list — see Event Log" + return values + if not removed: + values["exportStatus"] = "That device is not exported." + return values + dev = self._indigo_device(device_id) + name = str(getattr(dev, "name", "") or "") if dev is not None else f"device {device_id}" + self.logger.info("Removed Matter export for %s (id %s)", name, device_id) + # XAC7: the accessory has to leave every paired ecosystem, not just the + # allow-list. Order matters — remove the endpoint BEFORE the empty + # allow-list stops the client out from under it. + if self.export_bridge is not None: + try: + self.export_bridge.remove(device_id) + except Exception as exc: # pylint: disable=broad-except + self.logger.exception(exc) + self._exports_changed() # pylint: disable=no-member # lifecycle, stays in plugin.py + values["exportRole"] = "" + values["exportName"] = "" + values["exportInvert"] = False + values["exportStatus"] = f"Removed {name}. {self._export_summary()}" + return values diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/http_api_mixin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/http_api_mixin.py new file mode 100644 index 0000000..f335564 --- /dev/null +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/http_api_mixin.py @@ -0,0 +1,239 @@ +"""The Domio HTTP API (IWS hidden-action handlers — API.md v1.1) and the pairing +page (PRD §6). Both are IWS ```` surfaces reached over +the Reflector, and both share ``_parse_request``/``_reply``, so they live in one +mixin rather than being split further. See issue #146. +""" +from __future__ import annotations + +import json +import time +from concurrent.futures import TimeoutError as FuturesTimeoutError +from typing import Any + +import indigo # provided by the Indigo runtime + +from commission_jobs import node_id_to_str +from http_handlers import MatterUnavailable +from pairing_page import _pairing_html +from plugin_constants import DECOMMISSION_TIMEOUT, PAIRING_READ_TIMEOUT + + +class HttpApiMixin: + """IWS HTTP handlers, their shared request/reply helpers, and the QR pairing page. + + Composed into ``Plugin`` alongside the other three mixins; never + instantiated on its own and never subclasses ``indigo.PluginBase``. + """ + + # Self-attribute contract: these are created by ``Plugin.__init__``/ + # ``startup`` and stay in plugin.py. Declared here as class-level + # annotations only (no assignment) so mixin methods resolve `self.` + # without shadowing instance state, and so pylint's `no-member` check has + # something to verify against. See issue #146. + runtime: Any + matter: Any + http: Any + device_sync: Any + export_bridge: Any + logger: Any + _version: str + _start_ts: float + + # ------------------------------------------------------------------ + # HTTP API (IWS hidden-action handlers — API.md v1.1) + # ------------------------------------------------------------------ + def http_status(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802 + status, body = self.http.status() + return self._reply(status, body) + + def http_commission(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802 + method, path_args, query = self._parse_request(action) + status, body = self.http.commission(method, path_args, query) + return self._reply(status, body) + + def http_decommission(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802 + method, path_args, query = self._parse_request(action) + status, body = self.http.decommission(method, path_args, query) + return self._reply(status, body) + + def http_diagnostics(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802 + _method, path_args, query = self._parse_request(action) + status, body = self.http.diagnostics(path_args, query) + return self._reply(status, body) + + @staticmethod + def _parse_request(action): + props = dict(action.props) + method = props.get("incoming_request_method", "GET") + path_args = list(props.get("file_path", []) or []) + query = dict(props.get("url_query_args", {}) or {}) + body_params = props.get("body_params") + if body_params: + query = {**query, **dict(body_params)} + return method, path_args, query + + @staticmethod + def _reply(status, body): + reply = indigo.Dict() + reply["status"] = status + reply["headers"] = indigo.Dict({"Content-Type": "application/json"}) + reply["content"] = json.dumps(body) + return reply + + # ----- providers used by HttpApi (bridge into the loop where needed) ----- + def _status_body(self) -> dict: + # server_info fields per ws-controller v0.6.2: sdk_version, fabric_id, + # bluetooth_enabled (there is no plain "version" key). + connected = bool(self.matter is not None and self.matter.connected) + info = (self.matter.server_info if self.matter else None) or {} + body = { + "ready": connected, + "controllerVersion": self._version, + "matterServerReachable": connected, + "matterServerVersion": str(info.get("sdk_version", "unknown")), + "fabricId": str(info.get("fabric_id", "")), + "nodeCount": self.device_sync.node_count(), + "bleAvailable": bool(info.get("bluetooth_enabled", False)), + "uptime": int(time.monotonic() - self._start_ts), + } + if not connected: + # API.md §3.1: the 503 body carries the standard error envelope so + # Domio can surface an actionable message, not a generic failure. + uri = getattr(self.matter, "uri", None) if self.matter else None + body["error"] = "matter_server_unreachable" + body["message"] = f"Cannot reach matter-server at {uri}" if uri else "Cannot reach matter-server" + return body + + def _decommission_sync(self, node_id): + # None → genuine unknown node (404). MatterUnavailable → 503. Other → 500. + if self.runtime is None: + raise MatterUnavailable("plugin not ready") + if self.matter is None: + # Without this guard the AttributeError inside _decommission would be + # misread as "device offline" and the Indigo devices deleted anyway. + raise MatterUnavailable("matter-server not connected") + try: + return self.runtime.submit(self._decommission(node_id)).result(timeout=DECOMMISSION_TIMEOUT) + except FuturesTimeoutError as exc: + self.logger.error("decommission %s timed out", node_id) + raise MatterUnavailable("matter-server timed out") from exc + except MatterUnavailable: + raise + except RuntimeError as exc: # asyncio runtime not running + raise MatterUnavailable(str(exc)) from exc + + async def _decommission(self, node_id): + # Captured BEFORE the delete: it is the only way to tell "node we have + # never heard of" (a genuine 404) from "node we know, whose removal + # failed". Conflating them told the user "Unknown node" about a node that + # was still commissioned, and — for a node with no Indigo devices, where + # removed_ids is always empty — dropped it from the picker so the + # decommission could not be retried (issue #111 review). + known = self.device_sync.knows_node(node_id) + fabric_removed = True + try: + await self.matter.remove_node(node_id) + except Exception as exc: # noqa: BLE001 + self.logger.warning("remove_node failed (device may be offline): %s", exc) + fabric_removed = False + # Only forget the node if it actually left the fabric; otherwise it must + # stay listed so the user can retry. + removed_ids = self.device_sync.delete_node(node_id, forget=fabric_removed) + if not removed_ids and not fabric_removed and not known: + return None # genuinely unknown and unreachable → 404 + return { + "nodeId": node_id_to_str(node_id), + "removedIndigoDeviceIds": removed_ids, + "fabricRemoved": fabric_removed, + } + + def _diagnostics_sync(self, node_id): + if self.runtime is None: + raise MatterUnavailable("plugin not ready") + try: + return self.runtime.submit(self._diagnostics(node_id)).result(timeout=DECOMMISSION_TIMEOUT) + except FuturesTimeoutError as exc: + self.logger.error("diagnostics %s timed out", node_id) + raise MatterUnavailable("matter-server timed out") from exc + except MatterUnavailable: + raise + except RuntimeError as exc: + raise MatterUnavailable(str(exc)) from exc + + async def _diagnostics(self, node_id): + from matter_model import parse_node + try: + raw = await self.matter.get_node(node_id) + except ConnectionError as exc: + raise MatterUnavailable(str(exc)) from exc + except Exception as exc: # noqa: BLE001 - protocol/timeout error reading the node + self.logger.warning("diagnostics get_node(%s) failed: %s", node_id, exc) + raise MatterUnavailable(str(exc)) from exc + if not raw: + return None # genuine unknown node → 404 + node = parse_node(raw) + return { + "nodeId": node_id_to_str(node_id), + "reachable": True, + "vendorId": node.vendor_id, + "productId": node.product_id, + "vendorName": node.vendor_name, + "productName": node.product_name, + "softwareVersion": node.sw_version, + "endpoints": [ + { + "endpointId": ep.endpoint_id, + "clusters": sorted(ep.cluster_ids), + "indigoDeviceId": self.device_sync.lookup(node.node_id, ep.endpoint_id), + } + for ep in node.endpoints + ], + } + + # ------------------------------------------------------------------ + # The QR page (IWS hidden action — PRD §6 "display mechanism") + # ------------------------------------------------------------------ + def http_pairing(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802, ARG002 + """Serve the pairing page. GET only; authenticated by IWS before we run. + + This is the *only* handler here that returns HTML rather than JSON, and + it exists because the one thing the event log cannot carry is a QR code. + """ + method, _path_args, _query = self._parse_request(action) + if method.upper() != "GET": + return self._reply(405, {"error": "method_not_allowed"}) + reply = indigo.Dict() + reply["status"] = 200 + reply["headers"] = indigo.Dict({"Content-Type": "text/html; charset=utf-8"}) + reply["content"] = self._pairing_page() + return reply + + def _pairing_page(self) -> str: + """Build the pairing page's HTML from a live ``get_pairing``. + + **No QR is generated here, and that is a deliberate choice.** Rendering + one needs either a Python dependency (Indigo's framework Python has no + image stack and this plugin ships none) or a hand-written JS encoder — + a few hundred lines of Reed-Solomon and bit-masking whose failure mode is + a plausible-looking square that no phone can read. Neither is worth it + for a code that Apple Home, Alexa and Google all accept *typed in*: the + page therefore shows the manual code at a size you can read across a + room, the raw ``MT:`` payload for copying, and a link to the CHIP + project's own QR viewer for anyone who wants to scan. The tradeoff is + recorded in ``docs/HANDOVER.md`` rather than only in this docstring. + """ + client = self.export_bridge.client if self.export_bridge is not None else None + if client is None or not client.connected: + return _pairing_html(None, "The plugin is not connected to the Matter bridge node. " + "Export at least one device, then reload this page.") + try: + pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) + except Exception as exc: # noqa: BLE001 + self.logger.exception(exc) + return _pairing_html(None, f"Could not read the bridge node's pairing state: {exc}") + if not pairing.manual_pairing_code: + return _pairing_html( + pairing, + "No pairing window is open, so there is no code to show. Open one with " + "Plugins ▸ Matter ▸ Pair Matter Bridge… in Indigo.") + return _pairing_html(pairing, "") diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py index 5106e35..f841554 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py @@ -190,8 +190,8 @@ def __init__( # matter-server npm package exposes no bin executable (see server_process). self.node_path = os.path.join(os.path.dirname(self.npx_path), "node") self.project_dir = os.path.join(self.home, DEFAULT_PROJECT_DIRNAME) - # Mirrored from the spec so callers (plugin.py, fabric backup) keep reading it - # off the agent itself. + # Mirrored from the spec so callers (the pairing/backup menus, fabric backup) + # keep reading it off the agent itself. self.storage_path = spec.storage_path # ------------------------------------------------------------------ diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/pairing_menu_mixin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/pairing_menu_mixin.py new file mode 100644 index 0000000..fcde3a3 --- /dev/null +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/pairing_menu_mixin.py @@ -0,0 +1,520 @@ +"""Pairing (PRD §6, BRIDGE_PROTOCOL §3.7-§3.9) and fabric backup/restore — the +"Pair Matter Bridge…"/"Unpair an Ecosystem…" menus, the fabric picker, and the +export-bridge backup/restore menus (issue #26, #136). See issue #146. +""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Optional + +import indigo # provided by the Indigo runtime + +import bridge_agent +import bridge_protocol +import export_bridge # export_bridge.describe_fabric +import fabric_backup +from plugin_constants import ( + LIST_ERROR_OPTION, NO_SELECTION_ID, PAIRING_READ_TIMEOUT, + UNPAIR_TIMEOUT, WINDOW_OPEN_TIMEOUT, +) +from server_process import ServerProcess + + +class PairingMenuMixin: + """Pairing, ecosystem unpair, and fabric backup/restore menus. + + Composed into ``Plugin`` alongside the other three mixins; never + instantiated on its own and never subclasses ``indigo.PluginBase``. + """ + + # Self-attribute contract: these are created by ``Plugin.__init__``/ + # ``startup`` and stay in plugin.py. Declared here as class-level + # annotations only (no assignment) so mixin methods resolve `self.` + # without shadowing instance state, and so pylint's `no-member` check has + # something to verify against. See issue #146. + runtime: Any + export_bridge: Any + exports: Any + bridge_process: Any # written here too — _bridge_restore_control() assigns it + server_process: Any + pluginPrefs: Any + logger: Any + + # ------------------------------------------------------------------ + # Pairing and fabric management (PRD §6, BRIDGE_PROTOCOL §3.7-§3.9) + # ------------------------------------------------------------------ + def _pairing_client(self, errors, field: str): + """The bridge client for a pairing action, or ``None`` with ``errors`` set. + + ``connected`` rather than ``attached``, for the same §1.1 reason the + recovery menus use it: the node answers ``get_pairing`` while refusing to + serve endpoints, and a user whose bridge is in that state still needs to + be able to see and manage their pairings. + + The message names the real precondition, which is not obvious: the client + exists only while something is exported (XG5), so "pair the bridge" is + genuinely unreachable until the user has exported a device. That is XAC2's + ordering, not an accident — a bridge with no accessories is nothing worth + pairing, and Apple Home would show an empty one. + """ + bridge = self.export_bridge + client = bridge.client if bridge is not None else None + if client is None or not client.connected: + exported = len(self.exports) if self.exports is not None else 0 + why = ("Export at least one device in 'Manage Matter Exports…' first — the bridge only " + "runs while something is exported." if not exported else + "The bridge node is not answering; see the log for what its own error log says.") + self.logger.warning("Matter bridge: cannot reach the bridge node for pairing. %s", why) + errors[field] = "Not connected to the bridge node — see the log." + return None + return client + + def menuPairMatterBridge(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """Open a pairing window and put the codes where the user can read them. + + **Why this is "open a window" and not "show the code" (PRD §6).** A Matter + commissioning passcode is not durable: the moment the first ecosystem + commissions, the basic window closes and the original code stops working. + Every ecosystem after that needs an *enhanced* window with a freshly + derived code (§3.8), so there is no such thing as "the" pairing code to + display. + + **Why the event log.** Indigo dialogs have no dynamic labels and no image + fields, so a runtime string cannot be shown in the dialog that produced + it. The log is this plugin's established channel for exactly that, and it + is also the one place the codes survive being scrolled past — a window + lasts up to 15 minutes and users do not type 11 digits first time. + """ + errors = indigo.Dict() + duration = self._window_duration(valuesDict, errors) + if duration is None: + return (False, valuesDict, errors) + client = self._pairing_client(errors, "duration") + if client is None: + return (False, valuesDict, errors) + try: + pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) + except Exception as exc: # noqa: BLE001 + self.logger.error("Matter bridge: could not read the bridge node's pairing state — %s. " + "No pairing window was opened.", exc) + self.logger.exception(exc) + errors["duration"] = "Could not reach the bridge node — see the log." + return (False, valuesDict, errors) + # Two states already have a usable code, and opening a window in either + # would be actively harmful: §3.8's `assertClosed` refuses a second one, + # and on a never-commissioned node the basic window is ALREADY open with + # the persisted originals (§3.7) — deriving a fresh enhanced code there + # would invalidate a code the user may already be typing. + if pairing.window_open and pairing.manual_pairing_code: + self._log_pairing_codes(pairing.manual_pairing_code, pairing.qr_pairing_code, + pairing.window_expires_at, + already_open=not pairing.commissioned) + return (True, valuesDict) + try: + window = self.runtime.submit( + client.open_commissioning_window(duration)).result(timeout=WINDOW_OPEN_TIMEOUT) + except Exception as exc: # noqa: BLE001 + self.logger.error( + "Matter bridge: opening a pairing window FAILED — %s. Nothing was changed and " + "existing pairings are untouched. If the bridge says a window is already open, " + "wait for it to expire (up to 15 minutes) and try again.", exc) + self.logger.exception(exc) + errors["duration"] = "Could not open a pairing window — see the log." + return (False, valuesDict, errors) + if self.export_bridge is not None: + self.export_bridge.note_window_opened(window.window_expires_at) + self._log_pairing_codes(window.manual_pairing_code, window.qr_pairing_code, + window.window_expires_at, already_open=False) + return (True, valuesDict) + + @staticmethod + def _window_duration(values_dict, errors) -> Optional[int]: + """Validate the duration field against §3.8's 180-900s band. + + Rejected in the dialog rather than clamped silently by the node: the + number is how long the user has to walk to another room with a phone, and + being given 900 when they asked for 60 is a difference they should be + told about while the dialog is still open. + """ + raw = str((values_dict or {}).get("duration", "") or "").strip() + if not raw: + return bridge_protocol.DEFAULT_WINDOW_SECONDS + try: + duration = int(raw) + except (TypeError, ValueError): + errors["duration"] = "Enter a whole number of seconds between 180 and 900." + return None + if not 180 <= duration <= 900: + errors["duration"] = "Matter allows 180 to 900 seconds (3 to 15 minutes)." + return None + return duration + + def _log_pairing_codes(self, manual: Optional[str], qr: Optional[str], + expires_at: Optional[str], *, already_open: bool) -> None: + """Write the codes, the expiry and the QR page URL to the event log.""" + when = f" It expires at {expires_at}." if expires_at else "" + opening = ("The bridge has never been paired, so it is ALREADY advertising with its " + "original code — no new window was opened." if already_open else + "A pairing window is now open.") + self.logger.info( + "Matter export — %s%s\n" + " Manual pairing code: %s\n" + " QR payload: %s\n" + " QR code page: %s\n" + "Add the bridge in your ecosystem's app as you would any Matter accessory, and type " + "the manual code if it asks for one. Expect an 'uncertified accessory' warning — that " + "is normal for a bridge like this one; choose Add Anyway.\n" + "SECURITY: while this window is open, anyone who can reach that page (or read this " + "code) can add your exported Indigo devices to THEIR Apple Home, Alexa or Google " + "account. The page is served by the Indigo Web Server, which asks for a password only " + "if you have switched authentication on — turn it on before using this over anything " + "but a network you trust, and do not share the URL.", + opening, when, manual or "(none)", qr or "(none)", self._pairing_page_url()) + + def _pairing_page_url(self) -> str: + """The IWS URL of the QR page (Actions.xml ``pairing``). + + ``getWebServerURL`` picks the reflector, then the Bonjour name, then + localhost — so this is reachable from the phone the user is holding + whenever a reflector or a ``.local`` name exists, which is the case the + page is FOR. A failure falls back to the loopback default rather than + omitting the line: a wrong-host URL a user can edit beats no URL. + """ + base = "http://localhost:8176" + try: + base = str(indigo.server.getWebServerURL() or base) + except Exception as exc: # noqa: BLE001 + self.logger.debug("could not resolve the Indigo web server URL (%s)", exc) + return f"{base}/message/{self._export_plugin_id()}/pairing/" # pylint: disable=no-member # ExportDialogMixin + + def getBridgeFabrics(self, filter="", valuesDict=None, typeId="", targetId=0): # noqa: N802, A002 + # pylint: disable=redefined-builtin, unused-argument + """Picker rows for the unpair menu: one per commissioned ecosystem. + + Built from the fabric set the bridge already reported (attach, then every + §5 ``fabrics_changed``), never from a fresh WS round trip: a dynamic list + callback runs on the Indigo UI's thread while the dialog is opening, and + blocking it on a node that may be down would hang the dialog rather than + render an empty one. That is a deliberate trade and the reason + :meth:`menuUnpairEcosystem` re-reads the set *after* it acts, and the + reason §3.9 now reports whether it removed anything: the list can be + stale, so nothing downstream may assume it is not. + + **The first row is always "(select an ecosystem)".** Indigo pre-selects + row one, so without it the dialog opened with a real ecosystem already + chosen on a menu whose Execute button removes it — every other picker in + this plugin (device, node, backup) leads with a no-selection row for + exactly this reason, and the one destructive picker did not. + """ + try: + bridge = self.export_bridge + fabrics = bridge.fabrics if bridge is not None else None + if not fabrics: + # None and [] are different facts, and both are un-pickable, but + # only one of them should read as "you are not paired". + return [(NO_SELECTION_ID, + "(no paired ecosystems)" if fabrics == [] + else "(not connected to the bridge node)")] + return [(NO_SELECTION_ID, "(select an ecosystem)")] + [ + (str(fabric.fabric_index), export_bridge.describe_fabric(fabric)) + for fabric in fabrics] + except Exception as exc: # pylint: disable=broad-except + self.logger.exception(exc) + return [LIST_ERROR_OPTION] + + def menuUnpairEcosystem(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """§3.9 — remove one ecosystem's fabric from the bridge. + + Two gates, like the reset menu, because the outcome is the same size for + the ecosystem being removed: every accessory Indigo exports disappears + from it, with the names, rooms and automations built on them. + """ + errors = indigo.Dict() + selected = str(valuesDict.get("fabric", "") or "") + if not selected or selected == NO_SELECTION_ID: + errors["fabric"] = "Select an ecosystem to unpair." + return (False, valuesDict, errors) + try: + fabric_index = int(selected) + except (TypeError, ValueError): + errors["fabric"] = "Invalid selection." + return (False, valuesDict, errors) + # pylint: disable=no-member # ExportDialogMixin._truthy via MRO (issue #146) + if not self._truthy(valuesDict.get("confirm")) \ + or not self._truthy(valuesDict.get("confirmAgain")): + field = "confirm" if not self._truthy(valuesDict.get("confirm")) else "confirmAgain" + errors[field] = "Tick BOTH boxes — this removes every exported accessory from that "\ + "ecosystem." + return (False, valuesDict, errors) + # pylint: enable=no-member + client = self._pairing_client(errors, "confirmAgain") + if client is None: + return (False, valuesDict, errors) + cached_last = self._is_last_fabric(fabric_index) + try: + removal = self.runtime.submit( + client.remove_fabric(fabric_index)).result(timeout=UNPAIR_TIMEOUT) + except Exception as exc: # noqa: BLE001 + self.logger.error("Matter bridge: unpairing ecosystem %s FAILED — %s. Pairings are " + "unchanged.", fabric_index, exc) + self.logger.exception(exc) + errors["confirmAgain"] = "Unpair failed — see the log. Nothing was changed." + return (False, valuesDict, errors) + # The picker is built from a CACHED fabric list, so the ecosystem may + # have unpaired itself since — which the node reports as a successful + # no-op. Re-read before saying anything, so the picker cannot keep + # offering a ghost and the sentence below is about the real outcome. + self._refresh_fabric_cache(client) + if not removal.removed: + # ⊗ This used to be indistinguishable from a real removal: the node + # answered `{}` either way and the menu logged "has been unpaired. + # Every accessory has been removed" over a node-side no-op. + self.logger.warning( + "Matter bridge: ecosystem %s was ALREADY gone from the bridge node — nothing was " + "removed by this action, because there was nothing there to remove. It had most " + "likely unpaired itself since this dialog was opened. The ecosystem list has been " + "refreshed%s.", fabric_index, + f"; {removal.remaining} pairing(s) remain" if removal.remaining is not None else "") + return (True, valuesDict) + # `remaining` is the node's own post-removal count and beats the cache; + # the cache is only the fallback for a node that could not read it. + last = cached_last if removal.remaining is None else removal.remaining == 0 + if last: + # §3.9: matter.js factory-resets itself when the fabric set empties, + # and the node clears its commissioning witness to match. Say what + # that actually means rather than reporting a routine removal, because + # the user has just reset the whole bridge without using the reset menu. + self.logger.warning( + "Matter bridge: ecosystem %s was the LAST one paired, so the bridge node has " + "reset itself and is advertising for commissioning again — exactly as 'Reset " + "Matter Bridge Pairings…' would have done. Nothing in Indigo changed. Use " + "'Pair Matter Bridge…' to pair it again.", fabric_index) + else: + self.logger.warning( + "Matter bridge: ecosystem %s has been unpaired. Every accessory Indigo exports " + "has been removed from it; remove any leftover 'Indigo' bridge entry in that " + "ecosystem's app by hand.", fabric_index) + return (True, valuesDict) + + def _refresh_fabric_cache(self, client) -> None: + """Re-read the fabric set from the node after an unpair. Never raises. + + The §5 ``fabrics_changed`` that follows a removal is asynchronous, and + the picker is built from the cache it updates — so without this a user + who unpairs and immediately re-opens the dialog is offered the ecosystem + they just removed. Blocking is fine HERE (a menu Execute already blocked + on the removal itself); it is not fine in the picker callback, which runs + on the UI thread while the dialog opens. + """ + bridge = self.export_bridge + if bridge is None: + return + try: + pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) + bridge.note_fabrics(pairing.fabrics) + except Exception as exc: # noqa: BLE001 + # The removal itself already succeeded or was already true; failing + # to re-read the list afterwards is not worth reporting as a failure. + self.logger.debug("Matter bridge: could not refresh the ecosystem list (%s)", exc) + + def _is_last_fabric(self, fabric_index: int) -> bool: + """Whether removing ``fabric_index`` empties the fabric set. + + Read BEFORE the removal, from the set the bridge last reported: the §5 + ``fabrics_changed`` that follows arrives asynchronously, so asking + afterwards races it. Unknown (nothing reported yet) reads as False — + the message it selects is only the difference between two warnings. + """ + bridge = self.export_bridge + fabrics = bridge.fabrics if bridge is not None else None + if not fabrics: + return False + return [f.fabric_index for f in fabrics] == [fabric_index] + + def _resolve_storage_path(self) -> str: + """Storage dir path in BOTH managed and manual modes. + + In managed mode ``self.server_process`` already knows it. In manual mode + we construct a throwaway ``ServerProcess`` purely to read ``storage_path`` + — its ``__init__`` writes no plist and runs no launchctl, so this is a + side-effect-free path lookup. + """ + if self.server_process is not None: + return self.server_process.storage_path + # See plugin.py's `_server_prefs` docstring: constructing a ServerProcess + # from raw prefs skips the local-mode pinning — always go through it. + # Tests wanting to intercept THIS construction must patch + # pairing_menu_mixin.ServerProcess — patching plugin.ServerProcess misses it. + return ServerProcess(self._server_prefs(), self.logger).storage_path # pylint: disable=no-member + + def _bridge_storage_path(self) -> str: + """The **export** bridge node's storage dir — sibling of the controller's. + + Derived rather than read from a pref, and derived by the module that also + hands it to the agent as ``--storage-path`` (E7), so the directory this + backs up and the directory the node actually writes cannot disagree. The + path is the PRD §4.3 default (``…/com.simons-plugins.indigo-matter/ + bridge-node``), which is also ``bridge-node/src/config.ts``'s + ``DEFAULT_STORAGE_PATH``. + """ + return bridge_agent.bridge_storage_path(self._resolve_storage_path()) + + def _bridge_restore_control(self) -> Optional["bridge_agent.BridgeProcess"]: + """The bridge's ``stop()``/``start()`` seam for :func:`fabric_backup.restore_backup`. + + ``stop()``/``start()``, NOT ``uninstall()``: restore wants the node + back in exactly its prior lifecycle state, plist included, so that a + reboot afterwards behaves exactly as it would have before the + restore. ``uninstall()`` is ``menuStopBridgeNode``'s primitive + (:meth:`_stop_bridge_agent`) and answers "make sure a reboot cannot + bring this back" — the wrong question here, and why THIS path leaves + the XAC1 latch alone (see the call site in ``menuRestoreFabricBackup``). + + Built from CURRENT prefs when no agent object exists yet, exactly as + ``menuStopBridgeNode`` does — this must work in a session that never + exported anything. Construction writes nothing and runs no launchctl. + """ + if self.bridge_process is not None: + return self.bridge_process + try: + self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + except Exception as exc: # pylint: disable=broad-except + self.logger.warning( + "Matter bridge: could not build a control for the bridge node (%s), so any " + "bridge files in this backup will be reported and skipped. The controller " + "fabric restores normally.", exc) + return None + return self.bridge_process + + @staticmethod + def _human_size(num_bytes: int) -> str: + size = float(num_bytes) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1024.0 or unit == "TB": + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TB" + + def menuExportFabricBackup(self): # noqa: N802 + # menuItem has no ConfigUI/valuesDict, so outcome can only surface via the + # log — make both success and failure unmistakable there. create_backup + # already prunes (no duplicate prune here) and validates its own output. + storage_path = None + try: + storage_path = self._resolve_storage_path() + archive = fabric_backup.create_backup( + storage_path, now=datetime.now(timezone.utc), logger=self.logger, + # PRD-indigo-matter-export §4.3: the bridge node's storage is + # backed up alongside the controller's. Losing it costs every + # ecosystem pairing AND every exported accessory's identity. + bridge_storage_path=self._bridge_storage_path(), + ) + size = self._human_size(os.path.getsize(archive)) + self.logger.info( + "Fabric backup complete: %s (%s). This is a best-effort live snapshot — " + "matter-server was NOT stopped. Backups live in %s.", + archive, size, fabric_backup.backups_dir_for(storage_path), + ) + except FileNotFoundError as exc: + # storage dir missing or empty — there is no fabric to back up. + self.logger.error( + "Fabric backup FAILED — no fabric to back up, nothing was written: %s", exc, + ) + except Exception as exc: # noqa: BLE001 + self.logger.error("Fabric backup FAILED — nothing was written: %s", exc) + self.logger.exception(exc) + + def getFabricBackups(self, filter="", valuesDict=None, typeId="", targetId=0): # noqa: N802, A002 + """List-callback populating the restore picker (newest first).""" + try: + storage_path = self._resolve_storage_path() + except Exception as exc: # noqa: BLE001 + self.logger.exception(exc) + return [] + options = [] + for entry in fabric_backup.list_backups(storage_path): + when = datetime.fromtimestamp(entry["mtime"], timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + label = f"{entry['filename']} — {self._human_size(entry['size_bytes'])} — {when}" + options.append((entry["path"], label)) + return options + + def menuRestoreFabricBackup(self, valuesDict, menuId=""): # noqa: N802, ARG002 + errors = indigo.Dict() + # Restore must stop/start matter-server, which the plugin can only do in + # managed mode. Externally-managed (run.sh / manual) servers must be + # stopped by the user by hand. + if self.server_process is None: + msg = ("LaunchAgent management is off — the plugin cannot stop matter-server. " + "Stop matter-server yourself, unzip the chosen backup over the storage dir, " + "then restart it. Refusing to restore automatically.") + self.logger.warning(msg) + errors["backup"] = "Turn on 'Manage LaunchAgent', or restore by hand (see log)." + return (False, valuesDict, errors) + + selected = valuesDict.get("backup", "") + if not selected: + errors["backup"] = "Select a backup to restore." + return (False, valuesDict, errors) + if not valuesDict.get("confirm", False): + errors["confirm"] = "Tick the box to confirm — restore replaces the current fabric." + return (False, valuesDict, errors) + + try: + storage_path = self._resolve_storage_path() + # The bridge control/path are passed in, but the XAC1 latch (which + # session started the bridge agent) is NEVER touched here in either + # direction: alive+latched -> stop/start -> unchanged, correct; + # alive+unlatched -> unchanged, because setting it would arm a + # future bootout of an agent whose lifecycle this session does not + # own; stopped-by-us + restart-failed -> the latch is left EXACTLY + # as it was: if this session had started the agent it stays set + # (so the next empty-export transition still uninstalls the + # RunAtLoad plist); if a prior session's agent, it stays unset — + # no worse than before the restore (XAC1/XG5). + # restore_backup uses stop()/start(), never uninstall(), so the + # plist survives and the latch's claim stays true throughout. + result = fabric_backup.restore_backup( + selected, storage_path, self.server_process, + now=datetime.now(timezone.utc), logger=self.logger, + bridge_storage_path=self._bridge_storage_path(), + bridge_control=self._bridge_restore_control(), + ) + # restore_backup only returns on success: the server was stopped, the + # fabric was swapped, the restored dir is non-empty, and start() + # returned True. Be honest — matter-server is RESTARTING, the node + # count is not yet known; point the user at the real signal instead of + # logging a likely-stale count and pretending it is confirmation. + self.logger.info( + "Fabric restored from %s; previous fabric preserved at %s. matter-server is " + "restarting — watch the log for 'reconciled N node(s)' to confirm the devices " + "came back.", + result["restored_from"], result["moved_aside_to"], + ) + if result["bridge_restored"]: + if result["bridge_started"] is False: + self.logger.error( + "The controller fabric restored, but the Matter bridge node did not " + "come back up. %s", self._bridge_agent_diagnosis() or # pylint: disable=no-member + "Check the bridge node's error log.") + else: + # There may have been no pre-existing bridge dir to preserve — + # say nothing rather than "preserved at None". + preserved = ( + f" (previous copy preserved at {result['bridge_moved_aside_to']})" + if result["bridge_moved_aside_to"] else "") + self.logger.info( + "The Matter bridge node's storage was restored too%s%s. It now holds " + "the accessory identities and endpoint numbers as of that backup — if " + "a paired ecosystem has changed since, the bridge REPORTS endpoint-map " + "drift in the log and renumbers nothing.", preserved, + " and the node has been restarted" if result["bridge_started"] else "") + return (True, valuesDict) + except Exception as exc: # noqa: BLE001 + # restore_backup rolled back and preserved the original fabric (or + # aborted before touching it). Surface the failure in the UI dialog — + # never report success when the underlying op failed. + self.logger.error("Fabric restore FAILED: %s", exc) + self.logger.exception(exc) + errors["backup"] = "Restore failed — see the log. Your existing fabric was preserved." + return (False, valuesDict, errors) diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/pairing_page.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/pairing_page.py new file mode 100644 index 0000000..30bd3e6 --- /dev/null +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/pairing_page.py @@ -0,0 +1,103 @@ +"""The pairing page's HTML (PRD §6). Pure rendering — no I/O, no Indigo import. + +The only caller is :meth:`Plugin._pairing_page`, which builds the live +``pairing`` object and passes it here to render. +""" +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +import export_bridge # for export_bridge.describe_fabric + +#: Where the raw ``MT:`` payload can be rendered as a scannable QR code. The +#: CHIP project's own tool, which is the reference implementation of the payload +#: format — so a code it cannot render is a code no commissioner would accept +#: either. Linked rather than embedded: see :meth:`Plugin._pairing_page` for why +#: no QR is generated locally. +QR_VIEWER_URL = "https://project-chip.github.io/connectedhomeip/qrcode.html" + + +def _escape(text: Any) -> str: + """Minimal HTML escaping for the pairing page. + + Hand-rolled rather than ``html.escape`` only in that it also handles a + ``None`` — every value on that page comes from the bridge node or from an + exception string, and one of them being absent must not render the word + "None" into a field a user is about to type into their phone. + """ + if text is None: + return "" + return (str(text).replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def _pairing_html(pairing, message: str) -> str: + """The pairing page (PRD §6). Self-contained: no scripts, no assets. + + ``pairing`` may be ``None`` when there is nothing to report — the page still + renders, carrying ``message``, because a blank page over a bridge that is + merely not running is indistinguishable from a broken handler. + """ + manual = _escape(getattr(pairing, "manual_pairing_code", None)) + qr_payload = _escape(getattr(pairing, "qr_pairing_code", None)) + expires = _escape(getattr(pairing, "window_expires_at", None)) + fabrics = list(getattr(pairing, "fabrics", ()) or []) + paired = ", ".join(_escape(export_bridge.describe_fabric(f)) for f in fabrics) or "none yet" + banner = f'

{_escape(message)}

' if message else "" + codes = "" + if manual: + # The payload is URL-encoded into the viewer link because an `MT:` string + # is base-38 and can legitimately contain characters that would otherwise + # end the query (`+`, `/`, `%`), producing a link that opens the tool with + # a silently truncated payload — a QR that scans and means the wrong thing. + viewer = f"{QR_VIEWER_URL}?data={quote(str(getattr(pairing, 'qr_pairing_code', '') or ''), safe='')}" + codes = f""" +

This page shows a live commissioning passcode. + Anyone who can reach this URL can add the bridge — and every Indigo device you + export — to their Apple Home, Alexa or Google account, for as long as the + window is open. The Indigo Web Server only asks for a password if you have turned + authentication on, so if you have not, treat this URL as the code itself: do not + put it in a chat or an email, and close the window when you are done (it also + expires on its own).

+

Manual pairing code

+

{manual}

+

QR payload

+

{qr_payload}

+

+ Render this payload as a scannable QR code (opens the Matter project's own + viewer — it needs internet access, and the payload is sent to it).

+ {f'

This code stops working at {expires}.

' if expires else ''} +

What to expect

+

Add the bridge in your ecosystem's app as you would any Matter accessory. Every + ecosystem will warn that it is an uncertified accessory — that is + normal for a bridge like this one, and the same warning Homebridge and Home Assistant + produce. Choose "Add Anyway".

""" + return f""" + + +Indigo Matter bridge — pairing + +

Indigo Matter bridge

+{banner}{codes} +
Paired ecosystems: {paired}.
+This page is served by the Indigo Web Server from the Matter plugin.
+""" diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py index 3805e2b..f892243 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py @@ -1,10 +1,19 @@ """indigo-matter — Matter device support for the Indigo home automation server. -Lifecycle glue only. All I/O lives on the asyncio loop owned by -:class:`AsyncRuntime`; this class wires the async services in ``startup``, runs a -non-I/O watchdog in ``runConcurrentThread``, tears everything down in -``shutdown``, bridges Indigo device actions onto the loop, and exposes the Domio -HTTP API as Indigo Web Server hidden-action handlers. +Lifecycle glue and the device/action bridge. All I/O lives on the asyncio loop +owned by :class:`AsyncRuntime`; this class wires the async services in +``startup``, runs a non-I/O watchdog in ``runConcurrentThread``, tears +everything down in ``shutdown``, and bridges Indigo device actions onto the +loop. Everything Indigo reaches only through XML-named callbacks — the IWS +HTTP handlers, the export dialog, pairing/fabric management, and the +matter-server/bridge-node menus — has moved to mixins (issue #146), except +the Set Sensitivity Level custom action (``actionSetSensitivityLevel`` and +its ``getSensitivityLevels`` picker), which stays with the action bridge: +:class:`HttpApiMixin`, :class:`ExportDialogMixin`, :class:`PairingMenuMixin`, +:class:`ServerMenuMixin`. ``plugin_constants.py`` holds the shared constants +and prefs helpers; ``pairing_page.py`` holds the pairing IWS page template. +``Plugin`` composes all four mixins so every callback still resolves as a +plain attribute on the ``Plugin`` class, which is how Indigo looks them up. See ``docs/PRD-indigo-matter-plugin.md``, ``docs/IMPLEMENTATION.md`` (protocol + scaffold) and ``docs/API.md`` (the Domio contract). matter-server protocol field @@ -12,239 +21,59 @@ """ from __future__ import annotations -import json -import os import threading import time -from concurrent.futures import CancelledError as FuturesCancelledError from concurrent.futures import TimeoutError as FuturesTimeoutError from datetime import datetime, timezone -from typing import Any, Optional -from urllib.parse import quote import indigo # provided by the Indigo runtime -import bridge_agent -import bridge_client -import bridge_protocol -import fabric_backup from async_runtime import AsyncRuntime -from commission_jobs import CommissionJobs, node_id_to_str +from commission_jobs import CommissionJobs from device_sync import DeviceSync import export_bridge from export_bridge import ExportBridge -import export_catalog -import export_handlers -from export_store import ExportEntry, ExportStore, OPTION_INVERT -from http_handlers import HttpApi, MatterUnavailable +import export_dialog_mixin # noqa: F401 (tests patch EXPORT_PICKER_LIMIT) # pylint: disable=unused-import +from export_dialog_mixin import ExportDialogMixin +from export_store import ExportStore +from http_api_mixin import HttpApiMixin +from http_handlers import HttpApi 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 +from pairing_menu_mixin import PairingMenuMixin import protocol from protocol import MatterWrite, Protocol +import server_menu_mixin # noqa: F401 (tests patch ServerProcess) # pylint: disable=unused-import +from server_menu_mixin import ServerMenuMixin from server_process import ServerProcess -PLUGIN_NAME = "indigo-matter" -COMMAND_TIMEOUT = 5.0 -DECOMMISSION_TIMEOUT = 15.0 - -#: Deadlines for the export/pairing menu actions, which block the **Indigo UI -#: thread** on a WS round trip: without one, a bridge node that accepts the -#: socket and then stops answering hangs the dialog — and Indigo's client — with -#: no way out but force-quitting it. Named rather than inline because a `.result()` -#: with no timeout looks like an ordinary call at a glance, so nothing about the -#: absence of one is visible at the call site. -#: -#: PAIRING_READ_TIMEOUT covers a plain read (`get_pairing`). The other two are -#: long because the node does real Matter work behind them: opening an enhanced -#: window derives a fresh passcode and re-advertises, and removing a fabric -#: flushes subscriptions and — on the last one — factory-resets the whole stack. -PAIRING_READ_TIMEOUT = 15.0 -WINDOW_OPEN_TIMEOUT = 45.0 -UNPAIR_TIMEOUT = 45.0 -FACTORY_RESET_TIMEOUT = 45.0 - -#: Watchdog ticks (~15s each) of an active export with no ``deviceUpdated`` at -#: all before ``subscribeToChanges`` is re-issued — see -#: ``Plugin._resubscribe_tick``. ~1 minute, the same shape as every other streak -#: counter here. -RESUBSCRIBE_TICKS = 4 -#: How many times, at most. Bounded because a house where nothing changes looks -#: identical to a subscription that never registered. -MAX_RESUBSCRIBE_ATTEMPTS = 3 - -#: Menu id of the export dialog (MenuItems.xml) — matched in -#: ``get_menu_action_config_ui_values`` so other menus are never seeded. -MENU_MANAGE_EXPORTS = "manageMatterExports" -#: Menu id of the unpair dialog. Seeded for the same reason the export dialog is -#: — Indigo pre-selects the first row of a picker, and this picker's rows are -#: real ecosystems whose Execute button removes them. -MENU_UNPAIR_ECOSYSTEM = "unpairEcosystem" -#: Option-id prefix marking a picker row the user may look at but not choose -#: (PRD §5.2: excluded devices are shown *with a reason*, never hidden — XAC9). -EXCLUDED_OPTION_PREFIX = "x-" -#: The "nothing selected" sentinel. Never "": Indigo rejects an empty list id -#: with "UI dynamic list function returned illegal ID string" and silently -#: drops the option. The picker always emits a REAL row carrying this id -#: (:data:`NO_SELECTION_LABEL`), because the dialog is seeded with it — a -#: seeded value with no matching row renders as a blank first item. -NO_SELECTION_ID = "0" -NO_SELECTION_LABEL = "— select a device —" -#: Informational rows. They get their own ids so :data:`NO_SELECTION_ID` stays -#: unique, and the ``x-`` prefix keeps them unpickable through the same door -#: excluded devices use. -TRUNCATED_OPTION = (f"{EXCLUDED_OPTION_PREFIX}truncated", - "…too many matches — narrow the filter") -NO_MATCH_OPTION = (f"{EXCLUDED_OPTION_PREFIX}nomatch", "(no devices match the filter)") -#: What a list callback returns when it fails outright. An empty list would -#: render as an empty popup the user cannot tell from "nothing to choose". -LIST_ERROR_OPTION = (NO_SELECTION_ID, "(error building list — see Event Log)") -#: One unreadable device inside an otherwise fine list (D3): the row is kept so -#: the count is honest, but it is not selectable. -ROW_ERROR_LABEL = "(error reading device — see Event Log)" -#: Picker cap. Past this the tail row asks the user to narrow the filter — a -#: 2000-device database would otherwise build an unusable popup menu. -EXPORT_PICKER_LIMIT = 300 - - -def server_location(prefs: dict) -> str: - """Resolve the one user-facing choice: is matter-server on this Mac? - - Returns ``"local"`` (the plugin runs and manages matter-server here on - loopback) or ``"remote"`` (connect to a matter-server elsewhere). - - Migrates pre-2026.6 prefs that predate the ``serverLocation`` menu: - * a managed LaunchAgent meant the plugin already ran the server here → local; - * a host pointed at another machine → remote (keep its host/port); - * anything else — a fresh install or a loopback self-run server → local, - the turnkey default. - """ - loc = str(prefs.get("serverLocation") or "").strip().lower() - if loc in ("local", "remote"): - return loc - if prefs.get("manageLaunchAgent", False): - return "local" - host = str(prefs.get("matterServerHost") or "").strip().lower() - if host and host not in ("localhost", "127.0.0.1", "::1"): - return "remote" - return "local" - - -def sanitize_host(raw: str) -> str: - """Reduce a user-entered host to a bare hostname / IP. - - Users paste full URLs into the host field (e.g. ``http://jobs2.local:8176``); - a scheme, an embedded port, and any path all corrupt ``ws://{host}:{port}{path}``. - Strip them so the separate port field stays authoritative. IPv6 literals - (multiple colons) are left untouched. - """ - host = str(raw or "").strip() - if "://" in host: - host = host.split("://", 1)[1] - host = host.split("/", 1)[0] # drop any /path - # strip an embedded :PORT (host:1234) but preserve IPv6 literals (many colons) - if host.count(":") == 1 and host.rsplit(":", 1)[1].isdigit(): - host = host.rsplit(":", 1)[0] - return host - - -#: Where the raw ``MT:`` payload can be rendered as a scannable QR code. The -#: CHIP project's own tool, which is the reference implementation of the payload -#: format — so a code it cannot render is a code no commissioner would accept -#: either. Linked rather than embedded: see :meth:`Plugin._pairing_page` for why -#: no QR is generated locally. -QR_VIEWER_URL = "https://project-chip.github.io/connectedhomeip/qrcode.html" - - -def _escape(text: Any) -> str: - """Minimal HTML escaping for the pairing page. - - Hand-rolled rather than ``html.escape`` only in that it also handles a - ``None`` — every value on that page comes from the bridge node or from an - exception string, and one of them being absent must not render the word - "None" into a field a user is about to type into their phone. - """ - if text is None: - return "" - return (str(text).replace("&", "&").replace("<", "<").replace(">", ">") - .replace('"', """)) - - -def _pairing_html(pairing, message: str) -> str: - """The pairing page (PRD §6). Self-contained: no scripts, no assets. - - ``pairing`` may be ``None`` when there is nothing to report — the page still - renders, carrying ``message``, because a blank page over a bridge that is - merely not running is indistinguishable from a broken handler. - """ - manual = _escape(getattr(pairing, "manual_pairing_code", None)) - qr_payload = _escape(getattr(pairing, "qr_pairing_code", None)) - expires = _escape(getattr(pairing, "window_expires_at", None)) - fabrics = list(getattr(pairing, "fabrics", ()) or []) - paired = ", ".join(_escape(export_bridge.describe_fabric(f)) for f in fabrics) or "none yet" - banner = f'

{_escape(message)}

' if message else "" - codes = "" - if manual: - # The payload is URL-encoded into the viewer link because an `MT:` string - # is base-38 and can legitimately contain characters that would otherwise - # end the query (`+`, `/`, `%`), producing a link that opens the tool with - # a silently truncated payload — a QR that scans and means the wrong thing. - viewer = f"{QR_VIEWER_URL}?data={quote(str(getattr(pairing, 'qr_pairing_code', '') or ''), safe='')}" - codes = f""" -

This page shows a live commissioning passcode. - Anyone who can reach this URL can add the bridge — and every Indigo device you - export — to their Apple Home, Alexa or Google account, for as long as the - window is open. The Indigo Web Server only asks for a password if you have turned - authentication on, so if you have not, treat this URL as the code itself: do not - put it in a chat or an email, and close the window when you are done (it also - expires on its own).

-

Manual pairing code

-

{manual}

-

QR payload

-

{qr_payload}

-

- Render this payload as a scannable QR code (opens the Matter project's own - viewer — it needs internet access, and the payload is sent to it).

- {f'

This code stops working at {expires}.

' if expires else ''} -

What to expect

-

Add the bridge in your ecosystem's app as you would any Matter accessory. Every - ecosystem will warn that it is an uncertified accessory — that is - normal for a bridge like this one, and the same warning Homebridge and Home Assistant - produce. Choose "Add Anyway".

""" - return f""" - - -Indigo Matter bridge — pairing - -

Indigo Matter bridge

-{banner}{codes} -
Paired ecosystems: {paired}.
-This page is served by the Indigo Web Server from the Matter plugin.
-""" - - -class Plugin(indigo.PluginBase): +from plugin_constants import ( + COMMAND_TIMEOUT, MAX_RESUBSCRIBE_ATTEMPTS, PLUGIN_NAME, + RESUBSCRIBE_TICKS, sanitize_host, server_location, +) + +# Re-exported for the test suite, which reaches into this module's namespace +# (tests/test_*.py do `plugin_mod.`) and for backwards compatibility with +# anything importing these from `plugin`. See issue #146. +# pylint: disable=unused-import +import bridge_agent # noqa: F401 (tests patch bridge_agent.BridgeProcess) +import export_catalog # noqa: F401 +import export_handlers # noqa: F401 +from pairing_page import _escape, _pairing_html # noqa: F401 +from plugin_constants import ( # noqa: F401 + DECOMMISSION_TIMEOUT, EXCLUDED_OPTION_PREFIX, EXPORT_PICKER_LIMIT, + FACTORY_RESET_TIMEOUT, LIST_ERROR_OPTION, MENU_MANAGE_EXPORTS, + MENU_UNPAIR_ECOSYSTEM, NO_MATCH_OPTION, NO_SELECTION_ID, NO_SELECTION_LABEL, + PAIRING_READ_TIMEOUT, ROW_ERROR_LABEL, TRUNCATED_OPTION, + UNPAIR_TIMEOUT, WINDOW_OPEN_TIMEOUT, +) + + +class Plugin(HttpApiMixin, ExportDialogMixin, PairingMenuMixin, ServerMenuMixin, indigo.PluginBase): """Matter plugin entry point.""" def __init__(self, plugin_id, plugin_display_name, plugin_version, plugin_prefs, **kwargs): @@ -1168,2003 +997,3 @@ def _on_late_matter_response(self, late) -> None: if self.jobs is not None: self.jobs.note_late_response(late) - # ------------------------------------------------------------------ - # HTTP API (IWS hidden-action handlers — API.md v1.1) - # ------------------------------------------------------------------ - def http_status(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802 - status, body = self.http.status() - return self._reply(status, body) - - def http_commission(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802 - method, path_args, query = self._parse_request(action) - status, body = self.http.commission(method, path_args, query) - return self._reply(status, body) - - def http_decommission(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802 - method, path_args, query = self._parse_request(action) - status, body = self.http.decommission(method, path_args, query) - return self._reply(status, body) - - def http_diagnostics(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802 - _method, path_args, query = self._parse_request(action) - status, body = self.http.diagnostics(path_args, query) - return self._reply(status, body) - - @staticmethod - def _parse_request(action): - props = dict(action.props) - method = props.get("incoming_request_method", "GET") - path_args = list(props.get("file_path", []) or []) - query = dict(props.get("url_query_args", {}) or {}) - body_params = props.get("body_params") - if body_params: - query = {**query, **dict(body_params)} - return method, path_args, query - - @staticmethod - def _reply(status, body): - reply = indigo.Dict() - reply["status"] = status - reply["headers"] = indigo.Dict({"Content-Type": "application/json"}) - reply["content"] = json.dumps(body) - return reply - - # ----- providers used by HttpApi (bridge into the loop where needed) ----- - def _status_body(self) -> dict: - # server_info fields per ws-controller v0.6.2: sdk_version, fabric_id, - # bluetooth_enabled (there is no plain "version" key). - connected = bool(self.matter is not None and self.matter.connected) - info = (self.matter.server_info if self.matter else None) or {} - body = { - "ready": connected, - "controllerVersion": self._version, - "matterServerReachable": connected, - "matterServerVersion": str(info.get("sdk_version", "unknown")), - "fabricId": str(info.get("fabric_id", "")), - "nodeCount": self.device_sync.node_count(), - "bleAvailable": bool(info.get("bluetooth_enabled", False)), - "uptime": int(time.monotonic() - self._start_ts), - } - if not connected: - # API.md §3.1: the 503 body carries the standard error envelope so - # Domio can surface an actionable message, not a generic failure. - uri = getattr(self.matter, "uri", None) if self.matter else None - body["error"] = "matter_server_unreachable" - body["message"] = f"Cannot reach matter-server at {uri}" if uri else "Cannot reach matter-server" - return body - - def _decommission_sync(self, node_id): - # None → genuine unknown node (404). MatterUnavailable → 503. Other → 500. - if self.runtime is None: - raise MatterUnavailable("plugin not ready") - if self.matter is None: - # Without this guard the AttributeError inside _decommission would be - # misread as "device offline" and the Indigo devices deleted anyway. - raise MatterUnavailable("matter-server not connected") - try: - return self.runtime.submit(self._decommission(node_id)).result(timeout=DECOMMISSION_TIMEOUT) - except FuturesTimeoutError as exc: - self.logger.error("decommission %s timed out", node_id) - raise MatterUnavailable("matter-server timed out") from exc - except MatterUnavailable: - raise - except RuntimeError as exc: # asyncio runtime not running - raise MatterUnavailable(str(exc)) from exc - - async def _decommission(self, node_id): - # Captured BEFORE the delete: it is the only way to tell "node we have - # never heard of" (a genuine 404) from "node we know, whose removal - # failed". Conflating them told the user "Unknown node" about a node that - # was still commissioned, and — for a node with no Indigo devices, where - # removed_ids is always empty — dropped it from the picker so the - # decommission could not be retried (issue #111 review). - known = self.device_sync.knows_node(node_id) - fabric_removed = True - try: - await self.matter.remove_node(node_id) - except Exception as exc: # noqa: BLE001 - self.logger.warning("remove_node failed (device may be offline): %s", exc) - fabric_removed = False - # Only forget the node if it actually left the fabric; otherwise it must - # stay listed so the user can retry. - removed_ids = self.device_sync.delete_node(node_id, forget=fabric_removed) - if not removed_ids and not fabric_removed and not known: - return None # genuinely unknown and unreachable → 404 - return { - "nodeId": node_id_to_str(node_id), - "removedIndigoDeviceIds": removed_ids, - "fabricRemoved": fabric_removed, - } - - def _diagnostics_sync(self, node_id): - if self.runtime is None: - raise MatterUnavailable("plugin not ready") - try: - return self.runtime.submit(self._diagnostics(node_id)).result(timeout=DECOMMISSION_TIMEOUT) - except FuturesTimeoutError as exc: - self.logger.error("diagnostics %s timed out", node_id) - raise MatterUnavailable("matter-server timed out") from exc - except MatterUnavailable: - raise - except RuntimeError as exc: - raise MatterUnavailable(str(exc)) from exc - - async def _diagnostics(self, node_id): - from matter_model import parse_node - try: - raw = await self.matter.get_node(node_id) - except ConnectionError as exc: - raise MatterUnavailable(str(exc)) from exc - except Exception as exc: # noqa: BLE001 - protocol/timeout error reading the node - self.logger.warning("diagnostics get_node(%s) failed: %s", node_id, exc) - raise MatterUnavailable(str(exc)) from exc - if not raw: - return None # genuine unknown node → 404 - node = parse_node(raw) - return { - "nodeId": node_id_to_str(node_id), - "reachable": True, - "vendorId": node.vendor_id, - "productId": node.product_id, - "vendorName": node.vendor_name, - "productName": node.product_name, - "softwareVersion": node.sw_version, - "endpoints": [ - { - "endpointId": ep.endpoint_id, - "clusters": sorted(ep.cluster_ids), - "indigoDeviceId": self.device_sync.lookup(node.node_id, ep.endpoint_id), - } - for ep in node.endpoints - ], - } - - # ------------------------------------------------------------------ - # Menu items - # ------------------------------------------------------------------ - def menuInstallMatterServer(self): # noqa: N802 - """Install/update the matter-server npm package, then pin the node used. - - Only meaningful in local (managed) mode — self-managers keep their own - server untouched. Runs off the Indigo main thread so the UI never blocks on - npm; progress and outcome go to the log. - """ - if server_location(self.pluginPrefs) != "local": - self.logger.error( - "Install is only for local mode. Set 'is matter-server on this Mac?' " - "to local (managed) first, or install/manage the server yourself." - ) - return - if self._install_thread is not None and self._install_thread.is_alive(): - self.logger.warning("matter-server install already in progress.") - return - self.logger.info("Starting matter-server install in the background — watch the " - "log for progress; this can take a minute.") - self._install_thread = threading.Thread( - target=self._install_matter_server, name="matter-install", daemon=True) - self._install_thread.start() - - def _install_matter_server(self, clean: bool = False) -> None: - try: - sp = self.server_process or ServerProcess(self._server_prefs(), self.logger) - if clean: - # "Start fresh": delete node_modules and reinstall. Stops/reaps the server - # first and leaves the storage (fabric/pairings) intact. Used to recover a - # matter-server that won't start after an upgrade. - self.logger.info("Removing the installed matter-server for a clean " - "reinstall (your devices/pairings are kept)…") - if not sp.remove_package(): - # remove_package has already said what is still there. Do NOT - # install over it: a clean reinstall that quietly became a - # plain reinstall leaves the wedge the user came here for. - self.logger.error( - "Clean reinstall ABANDONED — the old package could not be removed, so " - "nothing was reinstalled over it. Nothing was changed.") - return - if not sp.install(): - self.logger.error( - "Install/update of the Matter controller (matter-server) did not " - "complete — see the error above. The server was not (re)installed; " - "retry when resolved." - ) - return - if self._stopping: # plugin is tearing down — don't mutate its state - return - # Pin the exact node used so the LaunchAgent runs the same one forever — - # this is what keeps install-node == run-node and avoids ABI crash-loops. - self.pluginPrefs["nodeBinDir"] = sp.resolved_bin_dir - indigo.server.savePluginPrefs() - self.server_process = ServerProcess(self._server_prefs(), self.logger) - self.server_process.ensure_installed() - # Restart matter-server onto the just-installed version — otherwise the - # newly-installed package sits on disk while the OLD process keeps running - # (a running LaunchAgent doesn't pick up new files). This is what makes the - # menu action a one-click, no-CLI update. - self._expect_restart() - if not self.server_process.restart(): - # Don't claim success: the new version may not be running. - self._restart_expected_until = 0.0 # let the crash diagnostic work - self.logger.error( - "matter-server was installed and pinned to node at %s, but the " - "restart onto the new version FAILED — the old version may still be " - "running. Use Plugins ▸ Matter ▸ Restart the Matter controller, or " - "reload the plugin.", sp.resolved_bin_dir, - ) - return - self.logger.info( - "matter-server installed, pinned to node at %s, and restarting onto the " - "new version — it reconnects automatically.", sp.resolved_bin_dir, - ) - except Exception as exc: # noqa: BLE001 - # npm may have succeeded and only the pin/activate step failed — say so, so - # the user doesn't reinstall in circles chasing a downstream problem. - self.logger.exception(exc) - self.logger.error( - "matter-server install did not complete after the npm step — the " - "package may be installed but the node was not pinned and the server " - "was not (re)started. See the trace above, then retry Plugins ▸ Matter " - "▸ Install/update the Matter controller (matter-server)." - ) - - def menuReinstallMatterServerClean(self, valuesDict, menuId=""): # noqa: N802, ARG002 - """Menu callback: delete matter-server and reinstall it fresh (keeps devices). - - The "blow it all away and start over" recovery when matter-server won't start - after an upgrade (e.g. a wedged install or a stray process). Removes - ~/indigo-matter/node_modules and reinstalls; the fabric/storage is left intact so - commissioned devices survive. Runs in the background like the plain install. - """ - errors = indigo.Dict() - if not valuesDict.get("confirm", False): - errors["confirm"] = "Tick the box to confirm the reinstall." - return (False, valuesDict, errors) - if server_location(self.pluginPrefs) != "local": - errors["confirm"] = ("Reinstall is only for local (managed) mode. Set 'is " - "matter-server on this Mac?' to local first.") - return (False, valuesDict, errors) - if self._install_thread is not None and self._install_thread.is_alive(): - errors["confirm"] = "An install is already in progress — wait for it to finish." - return (False, valuesDict, errors) - self.logger.info("Starting a clean matter-server reinstall in the background — " - "watch the log for progress; this can take a minute.") - self._install_thread = threading.Thread( - target=self._install_matter_server, kwargs={"clean": True}, - name="matter-reinstall", daemon=True) - self._install_thread.start() - return (True, valuesDict) - - def menuRestartMatterServer(self): # noqa: N802 - if self.server_process is None: - self.logger.warning("LaunchAgent management is off; start matter-server manually") - return - # Rebuild from CURRENT prefs first. ServerProcess snapshots prefs at construction - # and restart() bootstraps the plist *as it is on disk*, which only - # ensure_installed() regenerates — so without this, a setting changed since - # startup (notably the attestation flag) is silently NOT applied and this menu - # still logs success. - self.server_process = ServerProcess(self._server_prefs(), self.logger) - self._expect_restart() # expected outage, not a crash - try: - # None = preflight failed (plist torn down, nothing to restart); - # True = it already reloaded launchd, so a restart() here would stop and - # start the server a SECOND time for nothing — two outages, every device's - # session dropped twice; False = job left running, so we do the restart. - reloaded = self.server_process.ensure_installed() - restarted = True if reloaded else ( - False if reloaded is None else self.server_process.restart() - ) - except Exception as exc: # noqa: BLE001 - # Unguarded, this would escape with the expected-restart window still armed, - # suppressing the crash diagnostic for 30s while the server is down. - self._restart_expected_until = 0.0 - self.logger.exception(exc) - return - if restarted: - self.logger.info("matter-server restart requested") - elif reloaded is None: - self._restart_expected_until = 0.0 - self.logger.error( - "matter-server cannot be restarted — see the error above. Its LaunchAgent " - "was removed to stop a crash-loop; fix the cause, then reload the plugin." - ) - else: - self._restart_expected_until = 0.0 # restart failed — don't suppress the diagnostic - self.logger.error( - "matter-server restart failed; check ~/Library/Logs/indigo-matter/matter-server.err.log" - ) - - def menuShowMatterServerLogs(self): # noqa: N802 - self.logger.info("matter-server log: ~/Library/Logs/indigo-matter/matter-server.log") - - def menuCommissionDeviceManually(self, valuesDict, menuId=""): # noqa: N802, ARG002 - if self.jobs is None: - # Surface WHY OK did nothing — a bare (False, valuesDict) leaves the - # dialog open with no explanation at all. - self.logger.warning("manual commission requested before the plugin finished starting") - errors = indigo.Dict() - errors["setupCode"] = "Plugin still starting — try again in a moment." - return (False, valuesDict, errors) - status, body = self.jobs.create_job({ - "setupCode": valuesDict.get("setupCode", ""), - "suggestedName": valuesDict.get("suggestedName", "Matter Device"), - # The picker's value is a folder id; map it back to the folder NAME and - # pass it as suggestedRoom, which device_sync resolves to that folder - # (the same path Domio's room uses). "0"/unknown → no folder (root). - "suggestedRoom": self._folder_name_for(valuesDict.get("folder")), - }) - self.logger.info("manual commission → %s %s", status, body) - return (status in (202, 409), valuesDict) - - def getDeviceFolders(self, filter="", valuesDict=None, typeId="", targetId=0): # noqa: N802, A002, ARG002 - """List-callback populating the folder picker on the manual-commission menu. - - Options are (folderId, folderName) with a leading "0" → no folder (the - device-list root). The id MUST be a non-empty string — Indigo rejects an - empty list id with "UI dynamic list function returned illegal ID string", - which silently drops the option — so the no-folder sentinel is "0" (folder - id 0 == no folder), never "". menuCommissionDeviceManually maps the chosen - id back to the folder NAME for suggestedRoom.""" - options = [("0", "(no folder)")] - try: - for folder in indigo.devices.folders: - options.append((str(folder.id), folder.name)) - except Exception as exc: # noqa: BLE001 - never break the dialog; degrade to no-folder only - self.logger.exception(exc) - return options - - def _folder_name_for(self, folder_id): - """Resolve the folder picker's selected id (string) to the folder NAME. - - "0", empty, or an unknown/stale id → None (commission to the device-list - root). Never raises — an unresolvable folder must not fail the commission.""" - if not folder_id or folder_id == "0": - return None - try: - fid = int(folder_id) - for folder in indigo.devices.folders: - if folder.id == fid: - return folder.name - # Parses fine but matches nothing — e.g. folder deleted between the - # picker rendering and submit. Benign (device lands at root), but leave - # a trail rather than silently dropping the selection. - self.logger.debug("folder id %r not found, commissioning at root", folder_id) - except Exception as exc: # noqa: BLE001 - degrade to no folder, never fail the commission - self.logger.warning("folder id %r not resolvable, commissioning without a folder: %s", folder_id, exc) - return None - - def getMatterNodes(self, filter="", valuesDict=None, typeId="", targetId=0): # noqa: N802, A002, ARG002 - """List-callback populating the decommission picker (one entry per node).""" - if self.device_sync is None: - return [] - try: - options = [] - for node_id, names in self.device_sync.list_nodes(): - label = ", ".join(names) if names else "(no Indigo devices)" - options.append((str(node_id), f"{label} — node {node_id_to_str(node_id)}")) - return options - except Exception as exc: # noqa: BLE001 - never break the dialog; degrade to an empty picker - self.logger.exception(exc) - return [] - - def menuDecommissionDevice(self, valuesDict, menuId=""): # noqa: N802, ARG002 - """Menu callback: decommission the selected node. - - Returns ``(False, valuesDict, errors)`` to keep the dialog open with a - field error, ``(True, valuesDict)`` only when the node was fully - removed (fabric AND Indigo devices) — partial outcomes are dialog - errors so they can't masquerade as success. - """ - errors = indigo.Dict() - selected = valuesDict.get("node", "") - if not selected: - errors["node"] = "Select a device to decommission." - return (False, valuesDict, errors) - if not valuesDict.get("confirm", False): - errors["confirm"] = "Tick the box to confirm removal from Indigo." - return (False, valuesDict, errors) - try: - node_id = int(selected) - except (TypeError, ValueError): - errors["node"] = "Invalid selection." - return (False, valuesDict, errors) - try: - result = self._decommission_sync(node_id) - except MatterUnavailable as exc: - self.logger.error("decommission %s failed — matter-server unavailable: %s", - node_id_to_str(node_id), exc) - # A timeout does NOT cancel the in-flight coroutine — the removal may - # still complete in the background, so don't claim nothing happened. - errors["node"] = ("matter-server did not respond — see the log. The removal may " - "still complete in the background; check the device before retrying.") - return (False, valuesDict, errors) - except (Exception, FuturesCancelledError) as exc: # CancelledError is BaseException on 3.10+ - self.logger.error("decommission %s failed: %s", node_id_to_str(node_id), exc) - self.logger.exception(exc) - errors["node"] = "Decommission failed — see the Indigo event log." - return (False, valuesDict, errors) - if result is None: - errors["node"] = "Unknown node — nothing was removed." - return (False, valuesDict, errors) - if result["fabricRemoved"]: - self.logger.info( - "Decommissioned Matter node %s: fabric removed, Indigo device(s) deleted: %s", - result["nodeId"], result["removedIndigoDeviceIds"] or "none", - ) - return (True, valuesDict) - # remove_node failed (usually: device offline) — matter-server most likely - # still has the node (any remove_node failure is treated as not-removed), - # so the next reconcile (plugin restart or matter-server reconnect) will - # recreate the Indigo devices we just deleted. Surface that in the dialog — - # never report success when the underlying op only half-happened. - self.logger.warning( - "Decommission of node %s incomplete: Indigo device(s) %s deleted but the " - "fabric removal failed (device offline?). The node is still commissioned in " - "matter-server and its devices will reappear at the next reconcile — retry " - "once the device is reachable.", - result["nodeId"], result["removedIndigoDeviceIds"] or "none", - ) - errors["node"] = ("Device unreachable — removed from Indigo, but it is still commissioned " - "in matter-server and will reappear at the next reconcile (plugin restart " - "or reconnect). Retry once the device is powered and reachable.") - return (False, valuesDict, errors) - - # ------------------------------------------------------------------ - # Matter export allow-list — the "Manage Matter Exports…" dialog - # (PRD-indigo-matter-export §5.1 UI-D; roles per BRIDGE_PROTOCOL §4.2) - # ------------------------------------------------------------------ - def _export_plugin_id(self) -> str: - """This plugin's id, for the loop guard (XNG3/XAC6). - - Read from the running plugin rather than hardcoded, so the guard can - never drift from the bundle it is protecting; the catalog constant is - only the fallback for a plugin object built without one (tests). - """ - return getattr(self, "pluginId", "") or export_catalog.DEFAULT_PLUGIN_ID - - @staticmethod - def _truthy(value) -> bool: - """Indigo checkboxes arrive as bools or as "true"/"false" strings.""" - if isinstance(value, str): - return value.strip().lower() in ("true", "yes", "1") - return bool(value) - - @staticmethod - def _indigo_device(device_id): - """``indigo.devices[device_id]`` or None — a stale id is never fatal.""" - try: - return indigo.devices[int(device_id)] - except Exception: # pylint: disable=broad-except # KeyError/ValueError/Indigo's own - return None - - def _export_selection(self, values_dict) -> tuple[str, int]: - """Decode the picker value into ``(kind, device_id)``. - - ``kind`` is ``"none"`` (nothing chosen, or one of the informational - rows — the "select a device" seed, the truncation tail, the no-match - note), ``"excluded"`` (an ``x-`` row the user may see but not pick), or - ``"device"``. - """ - raw = str((values_dict or {}).get("exportDevice", "") or "") - if not raw or raw == NO_SELECTION_ID or raw in (TRUNCATED_OPTION[0], NO_MATCH_OPTION[0]): - return ("none", 0) - excluded = raw.startswith(EXCLUDED_OPTION_PREFIX) - if excluded: - raw = raw[len(EXCLUDED_OPTION_PREFIX):] - try: - device_id = int(raw) - except (TypeError, ValueError): - return ("none", 0) - return ("excluded" if excluded else "device", device_id) - - def _save_plugin_prefs(self) -> None: - """Flush pluginPrefs to Indigo's database (the store's commit step).""" - indigo.server.savePluginPrefs() - - def _reject_unexportable_entry(self, entry) -> str | None: - """Validator for entries restored from prefs — the loop guard, re-run. - - Load is the one write path the dialog's guards never see: a blob - restored from a backup, or hand-edited in the ``.indiPref``, can name a - device this plugin created. Only the loop guard is enforced here. - Ordinary ineligibility is *reported* by the startup reconcile and left - alone, because a device can be temporarily odd (a plugin still - starting) and silently deleting the user's export would be worse than - an accessory that fails to build. - """ - dev = self._indigo_device(entry.indigo_device_id) - if dev is None: - return None - verdict = export_catalog.classify(dev, self._export_plugin_id()) - if isinstance(verdict, export_catalog.Excluded) \ - and verdict.reason == export_catalog.REASON_LOOP_GUARD: - return export_catalog.REASON_LOOP_GUARD - return None - - def _reconcile_exports(self) -> None: - """Report-only startup sweep of the allow-list (never edits it). - - An export whose device has been deleted, or which no longer classifies - as exportable, is a real problem the user should hear about at startup - rather than discovering as a missing accessory. It is NOT auto-removed: - the allow-list is the user's declaration, and E3 re-classifies at - endpoint-build time anyway. - """ - if self.exports is None: - return - try: - plugin_id = self._export_plugin_id() - for entry in self.exports.all(): - dev = self._indigo_device(entry.indigo_device_id) - if dev is None: - self.logger.warning( - "Matter export allow-list: device %s is exported as %s but no longer " - "exists in Indigo — it will not be bridged. Remove it in " - "'Manage Matter Exports…'.", - entry.indigo_device_id, entry.role) - continue - verdict = export_catalog.classify(dev, plugin_id) - if isinstance(verdict, export_catalog.Excluded): - self.logger.warning( - "Matter export allow-list: %s (id %s) is exported as %s but is no longer " - "exportable: %s. It will not be bridged.", - getattr(dev, "name", ""), entry.indigo_device_id, entry.role, - verdict.reason) - elif entry.role not in verdict.eligible_roles: - self.logger.warning( - "Matter export allow-list: %s (id %s) is exported as %s, which this " - "device no longer offers (%s). Re-pick its role in " - "'Manage Matter Exports…'.", - getattr(dev, "name", ""), entry.indigo_device_id, entry.role, - ", ".join(verdict.eligible_roles)) - except Exception as exc: # pylint: disable=broad-except - # A diagnostic sweep must never be the thing that fails startup. - self.logger.exception(exc) - - def _export_summary(self) -> str: - if self.exports is None: - return "Plugin still starting — reopen this dialog in a moment." - count = len(self.exports) - # A load failure has to lead. Reporting "Nothing is exported yet." over - # a blob we could not read invites the user to rebuild the list from - # scratch, and the rebuild's first save overwrites the rescue copy. - error = self.exports.load_error - if error: - return error if not count else \ - f"{error} {count} device(s) exported.{self._export_bridge_note()}" - if not count: - return "Nothing is exported yet." - summary = f"{count} device(s) exported." - # An export whose role this version cannot bridge is silently absent - # from every ecosystem otherwise — the dialog is the only place the user - # would ever look for the reason. - pending = sum(1 for entry in self.exports.all() - if not export_handlers.is_bridgeable(entry.role)) - if pending: - # E4 completed the v1 role table, so this can now only mean an - # allow-list written by a NEWER plugin than the one running — the - # export blob lives in plugin prefs and survives a downgrade. - summary += (f" {pending} of them use a role this version cannot bridge " - "and will not appear in any ecosystem — they were most likely " - "added by a newer version of this plugin.") - return summary + self._export_bridge_note() - - def _export_bridge_note(self) -> str: - """One sentence when the exports exist but are not actually live. - - "3 device(s) exported." is true and useless while the bridge client is - halted on a version skew: the user is looking at this dialog precisely - because a light is missing from the Home app, and every state below - answers that question. Reported as a suffix so a load error — which is - about rescuing the user's list, and outranks everything — still leads. - """ - bridge = self.export_bridge - if bridge is None or not bridge.active: - # No client is the CORRECT state for an empty allow-list (XG5), and - # the count above already says the list is not empty — so this is a - # plugin still starting, which its own log line covers. - return "" - client = bridge.client - if client.halted: - return (f" Bridge client halted ({client.halted_reason or 'no reason recorded'}) " - "— restart the bridge node.") - if client.recovery: - return (" The bridge node is waiting for an endpoint-map rebuild — exports are not " - "live until it is done. Use 'Rebuild Matter Endpoint Map…' in the plugin " - "menu.") - if not client.attached: - return " Not connected to the bridge node — exports are not live." - return self._export_health_note(client.status) - - @staticmethod - def _export_health_note(status) -> str: - """The §4.3 facts the dialog is the only place a user would look for. - - `drift` and `warnings` were parsed and then read by nobody: an endpoint - number that had moved, or a map the node could not write, showed up in - the log at the moment it happened and nowhere at all afterwards. This - dialog is where somebody goes when an accessory is behaving oddly. - """ - if status is None: - return "" - if status.warnings: - return (f" The bridge node reports {len(status.warnings)} persistence problem(s): " - f"{'; '.join(status.warnings)}") - if status.drift: - return (f" WARNING: {len(status.drift)} exported accessory number(s) have DRIFTED — " - "they may have swapped identities in paired ecosystems. See the log; this is " - "never repaired automatically.") - if not status.drift_checked: - return (" Endpoint numbers have not been checked against a saved map yet — that " - "happens on the first reconcile.") - return "" - - def get_menu_action_config_ui_values(self, menu_id): - """Seed the export and unpair dialogs (menu dialogs never remember values). - - Only those two are seeded — this callback fires for EVERY menu item that - has a ConfigUI, and returning values for another one would overwrite its - defaults. - """ - values = indigo.Dict() - if menu_id == MENU_UNPAIR_ECOSYSTEM: - # The picker leads with a no-selection row; seed the field to match - # it, or Indigo renders the seeded-but-unmatched value as a blank - # first item and the user is one click from unpairing whatever - # happens to be second. - values["fabric"] = NO_SELECTION_ID - return values - if menu_id != MENU_MANAGE_EXPORTS: - return values - values["exportFilter"] = "" - values["exportDevice"] = NO_SELECTION_ID - values["exportRole"] = "" - values["exportName"] = "" - values["exportInvert"] = False - values["exportStatus"] = self._export_summary() - return values - - def _log_row_failure(self, exc, first: bool) -> None: - """Log one unreadable picker row — stack for the first, one line after. - - A database with fifty broken proxies must not write fifty tracebacks - into the event log, but the first one has to carry enough to debug. - """ - if first: - self.logger.exception(exc) - else: - self.logger.error("Matter bridge: another device could not be read — %s", exc) - - @staticmethod - def _candidate_row(dev, name: str, plugin_id: str, exported) -> Optional[tuple[str, str]]: - """One picker row for ``dev``, or None to omit it. May raise — the caller contains it. - - Loop-guard devices (created by this plugin) return None: XAC6 requires - them ABSENT from the picker, not merely unpickable — every one of them - shadows a device the user already sees, so listing them as excluded - would only add noise. Every OTHER exclusion is listed with its reason - (XAC9); hiding those would leave a user hunting for a device that never - appears. - """ - device_id = dev.id - # An excluded device that IS exported keeps its marker: the pair - # "excluded" + "exported" is exactly the state the user has to know - # about, and hiding half of it reads as a picker bug rather than the - # stale export it actually is. - mark = "● " if device_id in exported else "" - verdict = export_catalog.classify(dev, plugin_id) - if isinstance(verdict, export_catalog.Excluded): - if verdict.reason == export_catalog.REASON_LOOP_GUARD: - return None - return (f"{EXCLUDED_OPTION_PREFIX}{device_id}", - f"{mark}{name} — not exportable: {verdict.reason}") - return (str(device_id), f"{mark}{name}") - - def getExportCandidates(self, filter="", valuesDict=None, typeId="", targetId=0): - # pylint: disable=redefined-builtin, unused-argument, too-many-locals - """Picker rows: every Indigo device, exportable or not (XAC9). - - Excluded devices are listed **with the reason in the label** and an - ``x-``-prefixed id so the callbacks can reject the pick cleanly — - hiding them would leave a user hunting for a device that will never - appear. ``filter`` here is the XML's static filter attribute, NOT the - user's text: textfields have no callbacks, so the typed filter arrives - in ``valuesDict`` and the Apply-filter button drives the reload. - - One device that cannot be read costs one row, not the whole list: the - try/except is INSIDE the loop, because the alternative is a dialog that - renders empty the moment any device in the database misbehaves. That - promise holds below :data:`EXPORT_PICKER_LIMIT`; past it, unreadable - devices are counted in the truncation tail like any other row, and the - log still carries every one. - - Ordering: the seeded ``(select a device)`` row is always first, then - every already-exported device (database order), then everything else - (also database order) — never alphabetised. This dialog is the only - place a user can remove an export, so an exported device buried past - :data:`EXPORT_PICKER_LIMIT` would be effectively stuck there; exported - rows are therefore classified and kept unconditionally, and the cap is - applied only to the rest, at the end, once the exported count is known. - """ - try: - text = str((valuesDict or {}).get("exportFilter", "") or "").strip().lower() - exported = self.exports.ids() if self.exports is not None else frozenset() - plugin_id = self._export_plugin_id() - # Always a real row for the seeded value, and always first. - options: list[tuple[str, str]] = [(NO_SELECTION_ID, NO_SELECTION_LABEL)] - # One pass, two row lists: exported rows are never truncated (see - # docstring), so the cap is applied only to `other_rows`, after the - # loop, once `len(exported_rows)` is known. - exported_rows: list[tuple[str, str]] = [] - other_rows: list[tuple[str, str]] = [] - truncated = 0 - failures = 0 - for dev in indigo.devices: - try: - name = str(getattr(dev, "name", "") or "") - if text and text not in name.lower(): - continue - is_exported = dev.id in exported - if not is_exported and len(other_rows) >= EXPORT_PICKER_LIMIT: - # Upper bound on what the cap below could ever keep — - # exported rows only shrink that allowance, never - # raise it — so it's safe to stop building rows here. - # Still counted, so the tail stays honest. - truncated += 1 - continue - row = self._candidate_row(dev, name, plugin_id, exported) - if row is None: # loop guard: absent, not excluded (XAC6) - continue - (exported_rows if is_exported else other_rows).append(row) - except Exception as exc: # pylint: disable=broad-except - self._log_row_failure(exc, first=not failures) - failures += 1 - # `dev.id` may be exactly what failed to read, so an - # unreadable device can't be safely tested for membership - # in `exported` — it always lands with the others. - other_rows.append((f"{EXCLUDED_OPTION_PREFIX}err{failures}", - f"— {ROW_ERROR_LABEL}")) - allowance = max(EXPORT_PICKER_LIMIT - len(exported_rows), 0) - truncated += max(len(other_rows) - allowance, 0) - options.extend(exported_rows) - options.extend(other_rows[:allowance]) - if truncated: - options.append(TRUNCATED_OPTION) - if len(options) == 1: - options.append(NO_MATCH_OPTION) - return options - except Exception as exc: # pylint: disable=broad-except - self.logger.exception(exc) - return [LIST_ERROR_OPTION] - - def getExportRoles(self, filter="", valuesDict=None, typeId="", targetId=0): - # pylint: disable=redefined-builtin, unused-argument - """Roles the picked device may legitimately be exported as (§5.2). - - Empty for no selection or an excluded pick — an empty role menu is the - honest rendering of "there is nothing you may choose here". An outright - failure is NOT empty: it says so, so the user does not read a broken - callback as "this device offers no roles". - """ - try: - kind, device_id = self._export_selection(valuesDict) - if kind != "device": - return [] - dev = self._indigo_device(device_id) - if dev is None: - return [] - verdict = export_catalog.classify(dev, self._export_plugin_id()) - if isinstance(verdict, export_catalog.Excluded): - return [] - options = [] - for role in verdict.eligible_roles: - try: - options.append((role, export_catalog.role_label(role))) - except Exception as exc: # pylint: disable=broad-except - self._log_row_failure(exc, first=not options) - return options - except Exception as exc: # pylint: disable=broad-except - self.logger.exception(exc) - return [LIST_ERROR_OPTION] - - def getCurrentExports(self, filter="", valuesDict=None, typeId="", targetId=0): - # pylint: disable=redefined-builtin, unused-argument - """Read-only summary of the allow-list (one row per export).""" - try: - if self.exports is None: - return [(NO_SELECTION_ID, "(plugin still starting)")] - options = [] - failures = 0 - for entry in self.exports.all(): - try: - dev = self._indigo_device(entry.indigo_device_id) - name = str(getattr(dev, "name", "") or "") if dev is not None else "" - if not name: - name = f"(deleted device {entry.indigo_device_id})" - label = f"{name} → {export_catalog.role_label(entry.role)}" - if entry.name_override: - label += f' · shown as "{entry.name_override}"' - if entry.options.get(OPTION_INVERT): - label += " · inverted" - options.append((str(entry.indigo_device_id), label)) - except Exception as exc: # pylint: disable=broad-except - self._log_row_failure(exc, first=not failures) - failures += 1 - options.append((f"{EXCLUDED_OPTION_PREFIX}err{len(options)}", - f"— {ROW_ERROR_LABEL}")) - return options or [(NO_SELECTION_ID, "(nothing exported yet)")] - except Exception as exc: # pylint: disable=broad-except - self.logger.exception(exc) - return [LIST_ERROR_OPTION] - - def _exported_warning(self, device_id: int) -> str: - """Suffix warning shown when an EXCLUDED device is nonetheless exported. - - This is the incoherent state worth naming out loud: the allow-list says - export it, the catalog says it cannot be. Left alone it becomes an - accessory that never appears, with no visible cause. - """ - if self.exports is not None and device_id in self.exports: - return " — but this device IS currently exported — remove it or it will fail to bridge" - return "" - - def exportReloadPicker(self, valuesDict, typeId="", devId=0): - # pylint: disable=unused-argument - """Apply-filter button: the return trip is what reloads the lists.""" - values = valuesDict - text = str(values.get("exportFilter", "") or "").strip() - values["exportStatus"] = (f'Filtered on "{text}". {self._export_summary()}' if text - else self._export_summary()) - return values - - def exportDeviceChanged(self, valuesDict, typeId="", devId=0): - # pylint: disable=unused-argument - """Picker selection changed: load that device's saved export, or defaults. - - Menu callbacks return a valuesDict, not an error dict (the SDK's menu - contract), so an excluded pick is reported in the read-only status - field here — and refused again by the Add/update button below. Both - paths are covered by tests. - """ - values = valuesDict - kind, device_id = self._export_selection(values) - if kind == "none": - values["exportRole"] = "" - values["exportName"] = "" - values["exportInvert"] = False - values["exportStatus"] = self._export_summary() - return values - dev = self._indigo_device(device_id) - if kind == "excluded" or dev is None: - reason = "that device no longer exists" - if dev is not None: - verdict = export_catalog.classify(dev, self._export_plugin_id()) - reason = verdict.reason if isinstance(verdict, export_catalog.Excluded) \ - else "that device is not exportable" - values["exportRole"] = "" - values["exportName"] = "" - values["exportInvert"] = False - values["exportStatus"] = (f"Not exportable: {reason}" - f"{self._exported_warning(device_id)}") - return values - verdict = export_catalog.classify(dev, self._export_plugin_id()) - if isinstance(verdict, export_catalog.Excluded): - values["exportRole"] = "" - values["exportName"] = "" - values["exportInvert"] = False - values["exportStatus"] = (f"Not exportable: {verdict.reason}" - f"{self._exported_warning(device_id)}") - return values - entry = self.exports.get(device_id) if self.exports is not None else None - if entry is not None: - values["exportRole"] = entry.role - values["exportName"] = entry.name_override or "" - values["exportInvert"] = bool(entry.options.get(OPTION_INVERT, False)) - values["exportStatus"] = f"{dev.name} is exported as {export_catalog.role_label(entry.role)}." - else: - values["exportRole"] = verdict.default_role - values["exportName"] = "" - values["exportInvert"] = False - values["exportStatus"] = f"{dev.name} is not exported yet." - return values - - def exportAddOrUpdate(self, valuesDict, typeId="", devId=0): - # pylint: disable=unused-argument - """Add or update one export. Validates the role against the catalog. - - A role the catalog does not offer for this device is refused here - rather than by the bridge node, which would only reject it with - ``unknown_role``/``role_change`` long after the user could connect the - failure to what they did (BRIDGE_PROTOCOL §1.1). - - Returns **the values dict only**. A ``(valuesDict, errorsDict)`` tuple - is the documented contract for *validation* methods, not for button - ``CallbackMethod``s — the SDK's button reference says a button callback - returns a dictionary of field changes, and the field carrying a button's - outcome is read-only, so it cannot hold an error message anyway. Every - refusal therefore lands in ``exportStatus``, which is what the dialog - actually shows. - """ - values = valuesDict - if self.exports is None: - values["exportStatus"] = "Plugin still starting — try again in a moment." - return values - kind, device_id = self._export_selection(values) - if kind == "none": - values["exportStatus"] = "Select a device to export." - return values - dev = self._indigo_device(device_id) - if dev is None: - values["exportStatus"] = "That device no longer exists — refresh the list." - return values - verdict = export_catalog.classify(dev, self._export_plugin_id()) - if kind == "excluded" or isinstance(verdict, export_catalog.Excluded): - reason = verdict.reason if isinstance(verdict, export_catalog.Excluded) \ - else "not exportable" - values["exportStatus"] = (f"{dev.name} cannot be exported: {reason}" - f"{self._exported_warning(device_id)}") - return values - role = str(values.get("exportRole", "") or "") - if role not in verdict.eligible_roles: - values["exportStatus"] = ("Choose how this device should appear " - f"({', '.join(verdict.eligible_roles)}).") - return values - name_override = str(values.get("exportName", "") or "").strip() or None - options = {} - if role == export_catalog.ROLE_WINDOW_COVERING and self._truthy(values.get("exportInvert")): - options[OPTION_INVERT] = True - previous = self.exports.get(device_id) - existed = previous is not None - role_changed = existed and previous.role != role - try: - self.exports.upsert(ExportEntry( - indigo_device_id=device_id, role=role, - name_override=name_override, options=options, - )) - except Exception as exc: # pylint: disable=broad-except - # The store rolled back, so nothing was saved — say so rather than - # reporting the success the old code reported unconditionally. - self.logger.error("Matter bridge: saving the export list FAILED — %s", exc) - self.logger.exception(exc) - values["exportStatus"] = "FAILED to save the export list — see Event Log" - return values - verb = "Updated" if existed else "Added" - self.logger.info("%s Matter export for %s (id %s) as %s%s", - verb, dev.name, device_id, role, - f' named "{name_override}"' if name_override else "") - self._nudge_export(device_id, role_changed=role_changed) - values["exportStatus"] = f"{verb} {dev.name} as {export_catalog.role_label(role)}. " \ - f"{self._role_change_warning(role_changed)}{self._export_summary()}" - return values - - @staticmethod - def _role_change_warning(role_changed: bool) -> str: - """What a role change actually costs the user, said before they find out. - - BRIDGE_PROTOCOL §4.1 rejects changing an existing endpoint's role, so the - plugin removes and re-adds it. Ecosystems treat that as a brand-new - accessory: the name and room it was given in Apple Home are gone. - """ - if not role_changed: - return "" - return ("Changing the role RE-CREATES the accessory, so it loses the name and room " - "you gave it in Apple Home and any other paired ecosystem. ") - - def _nudge_export(self, device_id: int, *, role_changed: bool = False) -> None: - """Tell the bridge about one changed export, without a full reconnect. - - A role change is the one case that cannot be an ``upsert``: §4.1 refuses - it with ``role_change``, so it becomes remove-then-add. - """ - self._exports_changed() - bridge = self.export_bridge - if bridge is None: - return - try: - if role_changed: - bridge.replace(device_id) - else: - bridge.upsert(device_id) - except Exception as exc: # pylint: disable=broad-except - self.logger.exception(exc) - - def exportRemove(self, valuesDict, typeId="", devId=0): - # pylint: disable=unused-argument - """Drop the picked device from the allow-list. Returns values only (see above).""" - values = valuesDict - if self.exports is None: - values["exportStatus"] = "Plugin still starting — try again in a moment." - return values - kind, device_id = self._export_selection(values) - if kind == "none": - values["exportStatus"] = "Select a device to remove from the export list." - return values - try: - removed = self.exports.remove(device_id) - except Exception as exc: # pylint: disable=broad-except - self.logger.error("Matter bridge: saving the export list FAILED — %s", exc) - self.logger.exception(exc) - values["exportStatus"] = "FAILED to save the export list — see Event Log" - return values - if not removed: - values["exportStatus"] = "That device is not exported." - return values - dev = self._indigo_device(device_id) - name = str(getattr(dev, "name", "") or "") if dev is not None else f"device {device_id}" - self.logger.info("Removed Matter export for %s (id %s)", name, device_id) - # XAC7: the accessory has to leave every paired ecosystem, not just the - # allow-list. Order matters — remove the endpoint BEFORE the empty - # allow-list stops the client out from under it. - if self.export_bridge is not None: - try: - self.export_bridge.remove(device_id) - except Exception as exc: # pylint: disable=broad-except - self.logger.exception(exc) - self._exports_changed() - values["exportRole"] = "" - values["exportName"] = "" - values["exportInvert"] = False - values["exportStatus"] = f"Removed {name}. {self._export_summary()}" - return values - - # ------------------------------------------------------------------ - # Export-bridge recovery menus (BRIDGE_PROTOCOL §3.10/§3.11) - # ------------------------------------------------------------------ - def _recovery_client(self, errors, field: str): - """The bridge client, or ``None`` with ``errors`` filled in. - - Both recovery commands need a live socket, and the state they exist to - fix is exactly the one where the plugin holds the connection open - UN-attached (§1.1 recovery). So `connected`, not `attached`, is the - right gate — requiring an attach would make the rebuild unreachable in - the only situation that needs it. - """ - bridge = self.export_bridge - client = bridge.client if bridge is not None else None - if client is None or not client.connected: - msg = ("Not connected to the Matter bridge node. Start it (it is launched by " - "hand in this build), export at least one device so the plugin connects, then " - "try again.") - self.logger.warning(msg) - errors[field] = "Not connected to the bridge node — see the log." - return None - return client - - def menuRebuildEndpointMap(self, valuesDict, menuId=""): # noqa: N802, ARG002 - """§3.11 — the way out of the endpoint_map_invalid refuse-to-start state. - - Two outcomes are reported separately, because they can differ and the - expensive one is the first: the rebuild is irreversible (it discards - the persisted baseline and adopts the live numbers) while the re-attach - that follows it is an ordinary connection step that retries on its own. - It renumbers nothing, so it cannot itself duplicate accessories — any - duplication belongs to the storage loss that caused the refusal (#132). - Reporting the pair as one used to tell users their node was "unchanged - and still refusing" over a map that had already been rewritten — and - invite them to do it again. - """ - errors = indigo.Dict() - if not self._truthy(valuesDict.get("confirm")): - errors["confirm"] = ("Tick the box — a rebuild replaces the endpoint-number record " - "and cannot be undone.") - return (False, valuesDict, errors) - client = self._recovery_client(errors, "confirm") - if client is None: - return (False, valuesDict, errors) - if not client.recovery: - # M11: `connected` is the right gate for REACHING the node, but it - # is not a reason to rebuild. Run against a healthy node this - # silently discards the retained endpoint-number allocations of - # every export that is not currently live — §3.3 keeps them exactly - # so that re-adding a device restores its accessory — and there is - # nothing to recover from in the first place. - msg = ("Matter bridge: the bridge node is NOT refusing to serve endpoints, so there " - "is nothing to rebuild. Rebuilding anyway would discard the retained endpoint " - "numbers of every device that is not currently exported, and those are what " - "make re-adding one restore the same accessory.") - self.logger.warning(msg) - errors["confirm"] = ("The bridge node is not refusing to export — there is nothing " - "to rebuild. See the log.") - return (False, valuesDict, errors) - # Derived from the same two deadlines the call itself is built from: a - # flat number here is the one that expires first on a large export list, - # turning a rebuild that worked into a reported failure. - deadline = bridge_client.rebuild_timeout_for( - len(self.exports) if self.exports is not None else 0) - try: - status = self.runtime.submit(client.rebuild_endpoint_map()).result(timeout=deadline) - except Exception as exc: # noqa: BLE001 - # Never report success over a rebuild that did not persist: the node - # answers with an error rather than a StatusReport when the new map - # could not be written, and the refusal is still in force. - self.logger.error("Matter bridge: rebuilding the endpoint map FAILED — %s. The bridge " - "node is unchanged and still refusing to export.", exc) - self.logger.exception(exc) - errors["confirm"] = "Rebuild failed — see the log. Nothing was changed." - return (False, valuesDict, errors) - self.logger.warning( - "Matter bridge: endpoint map REBUILT — the bridge node has stopped refusing and is " - "serving %d endpoint(s). Nothing was renumbered. If only the map file was damaged, " - "no paired ecosystem will see any change; if the bridge's Matter storage was lost, " - "your ecosystems already hold dead accessories under the old numbers — delete those " - "by hand. Do NOT run this again for the same fault.", - status.endpoint_count) - for warning in status.warnings: - self.logger.warning("Matter bridge: the bridge node reports — %s", warning) - if not client.attached: - # The rebuild stands; only the connection step after it did not. The - # client's own triage has already named the reason at error level. - self.logger.warning( - "Matter bridge: the rebuild succeeded but re-attaching to the bridge node did " - "not. The rebuild does NOT need repeating — exports resume when the connection " - "does, and the reason is logged above.") - return (True, valuesDict) - - def menuResetBridgePairings(self, valuesDict, menuId=""): # noqa: N802, ARG002 - """§3.10 — wipe the Matter bridge's commissioning and re-advertise.""" - errors = indigo.Dict() - # Two boxes, deliberately. This is the only plugin action that destroys - # every ecosystem pairing at once, and it is irreversible without - # re-pairing each ecosystem by hand. - if not self._truthy(valuesDict.get("confirm")) \ - or not self._truthy(valuesDict.get("confirmAgain")): - field = "confirm" if not self._truthy(valuesDict.get("confirm")) else "confirmAgain" - errors[field] = "Tick BOTH boxes — this removes every ecosystem pairing." - return (False, valuesDict, errors) - client = self._recovery_client(errors, "confirmAgain") - if client is None: - return (False, valuesDict, errors) - try: - # preserve_endpoint_numbers=True: a user resetting to re-pair the - # same ecosystems should not also lose accessory identity. The - # "the map itself is corrupt" path is the rebuild above. - self.runtime.submit(client.factory_reset(True)).result(timeout=FACTORY_RESET_TIMEOUT) - except Exception as exc: # noqa: BLE001 - self.logger.error("Matter bridge: resetting the bridge pairings FAILED — %s. " - "Pairings are unchanged.", exc) - self.logger.exception(exc) - errors["confirmAgain"] = "Reset failed — see the log. Pairings were not changed." - return (False, valuesDict, errors) - self.logger.warning( - "Matter bridge: the bridge node's pairings have been RESET. It is advertising for " - "commissioning again — pair it from each ecosystem, and remove the now-dead Indigo " - "bridge from any ecosystem that still lists it.") - return (True, valuesDict) - - # ------------------------------------------------------------------ - # The Matter bridge node's LaunchAgent (E7 — PRD §4.2, XG5, XAC1) - # ------------------------------------------------------------------ - def _start_bridge_agent(self) -> None: - """Install (if needed) and start the bridge node's LaunchAgent. - - The ``agent_start`` seam. Called by :class:`export_bridge.ExportBridge` - on the empty→non-empty allow-list transition, on whichever Indigo thread - made that change — never on the loop. Blocking, but only by a couple of - ``launchctl`` calls; ``install`` is deliberately NOT attempted here (npm - takes a minute and this can run from ``deviceDeleted``), so a missing - package surfaces as ``ensure_installed``'s actionable preflight error - naming the install menu. - - Rebuilt from current prefs on every call rather than cached: the ports - and the mDNS interface are prefs, ``ensure_installed`` only reloads - launchd when the resulting plist actually differs, and a stale - ``BridgeProcess`` would write yesterday's ports while reporting success. - """ - self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) - agent = self.bridge_process - if agent.ensure_installed() is None: - # Preflight failed; the plist has been torn down and the reason - # logged. Nothing to start, and starting would only crash-loop. - return - # ``ensure_installed() is not None`` is NOT the process being up. It is - # False for "the current definition was already loaded and healthy" AND - # for "bootout succeeded but neither bootstrap nor load did" AND for "the - # job is loaded with a pid line we could not parse" — and the middle one - # was reported here as "bridge node LaunchAgent is running". Ask launchd. - state = agent.run_state() - if state == agent.RUNNING: - self.logger.info("Matter bridge: bridge node LaunchAgent is running (protocol port %s, " - "Matter port %s)", agent.ws_port, agent.matter_port) - elif state == agent.UNKNOWN: - # A pid line we could not parse. It may well be serving; what we must - # not do is assert either way. - self.logger.info( - "Matter bridge: the bridge node's LaunchAgent is loaded (protocol port %s, Matter " - "port %s); launchd did not report a readable pid, so whether the process is up " - "will show as the plugin connects — or fails to.", agent.ws_port, agent.matter_port) - else: - self.logger.error( - "Matter bridge: the bridge node's LaunchAgent %s. Exported accessories will be " - "unavailable until it does. %s", - "did not start" if state == agent.LOADED_NOT_RUNNING - else "could not be loaded by launchd", - self._bridge_agent_diagnosis()) - - def _stop_bridge_agent(self) -> None: - """Stop the bridge agent and REMOVE its plist. The storage is untouched. - - The ``agent_stop`` seam, called only once there is genuinely nothing to - serve. - - **``uninstall()`` rather than ``stop()``, and that is a correction.** - ``stop()`` boots the job out and keeps the plist, which reads as a - thrifty choice until you notice the plist carries ``RunAtLoad: True``: - at the next login launchd started an *unpaired* bridge node with an - EMPTY allow-list, advertising on the Matter port, that this plugin never - started and — because ``_agent_started`` is false in a session that - never brought it up (XAC1) — would never stop. XG5's guarantee is that a - fresh or emptied install runs no bridge process, and a guarantee that - does not survive a reboot is not one. Re-deriving the plist costs one - ``ensure_installed`` on the next export. - - The storage dir — every ecosystem pairing plus the endpoint-number - witness — is never touched by either (PRD §5.4). - """ - agent = self.bridge_process - if agent is None: - return - was_loaded = agent.is_running() # "is there a job on the books" - agent.uninstall() # bootout + remove the plist - if agent.is_running() or os.path.exists(agent.plist_path): - # ⊗ The silent branch. stop() returning False used to say nothing at - # all, and `_agent_started` had already been cleared, so nothing - # retried: the node kept serving every paired ecosystem with the log - # asserting the opposite by omission. - self.logger.warning( - "Matter bridge: nothing is exported, but the bridge node's LaunchAgent could not " - "be %s (%s). It keeps running and serving every paired ecosystem, and NOTHING " - "retries this on its own — reload the plugin, or run 'launchctl bootout " - "gui/$(id -u)/%s' and delete that file by hand.", - "stopped" if agent.is_running() else "removed", agent.plist_path, - bridge_agent.LABEL) - return - if was_loaded: - self.logger.info( - "Matter bridge: nothing is exported — the bridge node has been stopped and its " - "LaunchAgent removed, so a restart of this Mac cannot bring it back. Its pairings " - "are kept.") - else: - # The two Falses `stop()` conflated: this one is "there was no job", - # which is not a failure and must not be reported as one. - self.logger.debug( - "Matter bridge: nothing is exported and no bridge node LaunchAgent was loaded; " - "any plist has been removed. Pairings are kept.") - - def _bridge_agent_diagnosis(self) -> Optional[str]: - """Why is the bridge node not answering? The ``agent_diagnose`` seam. - - Reads the agent's own error log, which is the only place the real cause - appears: a Matter port already bound by another stack (PRD §7), a package - that was never installed, an ABI mismatch. The socket sees "connection - refused" for all of them. - - **Read-only.** It deliberately does not restart anything. launchd already - owns respawn via ``KeepAlive``, the loaded-but-dead revival lives in - ``ensure_installed``, and a diagnostic that quietly bounced the agent on - every failure streak would turn a crash-loop into a crash-loop nobody can - read the log of. - """ - agent = self.bridge_process - if agent is None: - return ("The bridge node's LaunchAgent has not been started by this plugin session — " - "export at least one device, or reload the plugin.") - # ⊗ Asked FIRST, and it was not asked at all. preflight() holds the - # actual fact — is the node interpreter there, is the package installed — - # while the old code guessed at it from an empty error log and then said - # "checked {project_dir}", which it had not looked at. A missing package - # is also the case where the error log is empty *for the right reason*: - # launchd never got far enough to write one. - problem = agent.preflight() - if problem: - return f"The bridge node cannot start: {problem}" - tail = agent.tail_error_log() - if tail: - # NOT "recent". The file is appended to and never truncated, so the - # last 20 lines can be from a crash-loop days ago that has since been - # fixed — naming the file is what lets the user check the timestamps. - return (f"The last lines of {os.path.join(agent.log_dir, bridge_agent.BRIDGE_ERR_LOG)} " - f"(appended to since the bridge was first started, so these may be old):\n" - f"{tail}") - return (f"The {bridge_agent.BRIDGE_PACKAGE} package is installed and its error log " - f"({os.path.join(agent.log_dir, bridge_agent.BRIDGE_ERR_LOG)}) is empty, so the " - f"node is failing without saying why — check that nothing else on this Mac holds " - f"Matter port {agent.matter_port} or protocol port {agent.ws_port}.") - - def menuInstallBridgeNode(self): # noqa: N802 - """Install/update the ``indigo-matter-bridge`` npm package. - - The export-side twin of ``menuInstallMatterServer``, and a sibling rather - than an extension of it: the two agents are separately versioned, and a - user recovering a wedged bridge must not also be made to reinstall a - controller that is working (or the reverse). They share the install - thread because ``~/indigo-matter`` is one npm root and two concurrent - ``npm install``s into it corrupt each other. - """ - self._run_bridge_install(clean=False) - - def menuReinstallBridgeNodeClean(self, valuesDict, menuId=""): # noqa: N802, ARG002 - """Remove the bridge package and install it fresh (the controller's twin). - - The controller has had this exit since 2026.7; the bridge shipped without - it, so a user whose bridge install was wedged had a menu that reinstalled - *over* the wedge and no way to clear it. ``remove_package`` is per-package - since E7, which is what makes this safe to offer at all — it used to - rmtree the shared ``node_modules`` and take the controller with it. - - Pairings are untouched: they live in the storage dir, which nothing in - the install path goes near. - """ - errors = indigo.Dict() - if not self._truthy(valuesDict.get("confirm")): - errors["confirm"] = "Tick the box to confirm." - return (False, valuesDict, errors) - if not self._run_bridge_install(clean=True): - errors["confirm"] = "An npm install is already running — wait for it to finish." - return (False, valuesDict, errors) - return (True, valuesDict) - - def _run_bridge_install(self, *, clean: bool) -> bool: - """Start the background bridge install. False if one is already running.""" - if self._install_thread is not None and self._install_thread.is_alive(): - self.logger.warning("An npm install is already in progress — wait for it to finish.") - return False - self.logger.info( - "%s the Matter bridge node in the background — watch the log for progress; " - "this can take a minute.", - "Removing and reinstalling" if clean else "Installing") - self._install_thread = threading.Thread( - target=self._install_bridge_node, args=(clean,), - name="matter-bridge-install", daemon=True) - self._install_thread.start() - return True - - def menuStopBridgeNode(self, valuesDict, menuId=""): # noqa: N802, ARG002 - """Stop the bridge node and remove its LaunchAgent, by hand. - - The controller has "Restart the Matter controller"; the bridge had nothing at - all, because it is started and stopped by the allow-list. That leaves one - state with no UI: a user who disables the plugin (or whose plugin dies - mid-session) has a node still running and still serving every paired - ecosystem, and the only lever is ``launchctl``. Exporting nothing is not - that lever — it needs the plugin to be running to notice. - - Exports are NOT changed. The next export starts the node again, which is - exactly XG5 and is why this is safe to hand a user: the worst outcome is - a bridge that comes back. - """ - errors = indigo.Dict() - if not self._truthy(valuesDict.get("confirm")): - errors["confirm"] = "Tick the box to confirm." - return (False, valuesDict, errors) - # Built from CURRENT prefs rather than reused: this must work in a - # session that never started the agent, which is the whole point of it. - self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) - self._stop_bridge_agent() - if self.export_bridge is not None: - # The plugin no longer has an agent it started, so the XAC1 latch - # must not go on claiming it does. - self.export_bridge.note_agent_stopped() - return (True, valuesDict) - - def _install_bridge_node(self, clean: bool = False) -> None: - """npm-install the bridge package, then restart it if anything is exported. - - The restart is conditional on there being something to export, which is - the difference from the controller's install: bringing the agent up - because a package was updated would violate XG5 on an install with an - empty allow-list, and leave a bridge process running for nothing. - - **``ensure_installed()`` before ``restart()``, and its absence was the - first-run dead end.** The bridge's plist is written by exactly one place - — ``_start_bridge_agent`` — and on a machine where the package has never - been installed that place cannot get past its own preflight, so it writes - no plist and (correctly) tears any stale one down. The user's route out - of that is this menu; it then went install() → restart(), restart found - no plist, and printed "nothing to restart. Fix the problem reported - above" (there was no problem above — the install had just SUCCEEDED) - followed by "the restart FAILED — the old version may still be running" - (nothing was running). Two wrong messages, no bridge, and the only real - remedy — write the plist now that the package exists — never attempted. - """ - try: - agent = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) - if clean and not agent.remove_package(): - # remove_package has said what is still there. Installing over a - # wedged install is what the clean variant exists to avoid. - self.logger.error( - "Clean reinstall of the Matter bridge ABANDONED — the old package " - "could not be removed, so nothing was reinstalled over it.") - return - if not agent.install(): - self.logger.error( - "Install/update of the Matter bridge did not complete — see the error " - "above. Nothing was changed; retry when resolved.") - return - if self._stopping: # plugin is tearing down — don't mutate its state - return - self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) - if self.exports is None or not len(self.exports): - self.logger.info( - "Matter bridge installed. It is NOT being started: nothing is exported " - "yet, and the bridge only runs while the export list is non-empty. Add a " - "device in 'Manage Matter Exports…' and it will start itself.") - return - # Write (or refresh) the plist first. On the first-run path there is - # none — this is where it comes from — and `ensure_installed` returning - # True means launchd has already bootstrapped the NEW files, so there - # is nothing left for restart() to do and bouncing again would be a - # second gratuitous outage. - applied = self.bridge_process.ensure_installed() - if applied is None: - self.logger.error( - "The Matter bridge was installed, but its LaunchAgent could not be " - "written — see the reason above. The package is on disk; fix that and reload " - "the plugin.") - return - # A running LaunchAgent does not pick up new files on disk, so a job - # left alone by ensure_installed is still executing the OLD version. - if applied is False and not self.bridge_process.restart(): - self.logger.error( - "The Matter bridge was installed but the restart onto the new version " - "FAILED — the old version may still be running. Check %s.", - os.path.join(self.bridge_process.log_dir, bridge_agent.BRIDGE_ERR_LOG)) - return - if self._stopping: - # Second check, deliberately: ensure_installed()/restart() are - # subprocess work that can outlast shutdown()'s 5s thread join, - # and revive_after_install is the one caller of start() that - # can genuinely race teardown — it would bootstrap launchctl - # and then log a scary "could not schedule bridge client run - # loop" over a plugin that is simply exiting. - return - # #154: a client HALTED on version skew is not the retry_now() case - # below — it declines the poke by design (a halt is fail-closed) and - # nothing revives it on its own, so the reinstall that was SUPPOSED - # to fix it left the user with no route back except a plugin reload. - # Tried first, and only ever replaces a client actually halted for - # that reason — see `revive_after_install`'s own reason gate. - revived = self.export_bridge is not None and self.export_bridge.revive_after_install() - if revived: - self.logger.info("Matter bridge installed and restarted onto the new " - "version — the halted connection has been replaced; " - "reconnecting now.") - else: - # Cuts the reconnect backoff short (issue #135): it grew to its - # 30s ceiling while the package was missing, and without this - # the user watches out the rest of that delay right after a - # success message. `poked` is truthful, not assumed: no bridge, - # no client, or a declining client (halted/closing/#154) all - # mean the poke never reached a run loop, so the log must not - # claim it is reconnecting. - poked = self.export_bridge is not None and self.export_bridge.retry_now() - if poked: - self.logger.info("Matter bridge installed and restarted onto the new " - "version — reconnecting now.") - else: - self.logger.info("Matter bridge installed and restarted onto the new " - "version — reload the plugin to reconnect.") - except Exception as exc: # noqa: BLE001 - self.logger.exception(exc) - self.logger.error( - "Install of the Matter bridge did not complete after the npm step — the " - "package may be installed but the agent was not restarted. See the trace above, " - "then retry Plugins ▸ Matter ▸ Install/update the Matter bridge.") - - # ------------------------------------------------------------------ - # Pairing and fabric management (PRD §6, BRIDGE_PROTOCOL §3.7-§3.9) - # ------------------------------------------------------------------ - def _pairing_client(self, errors, field: str): - """The bridge client for a pairing action, or ``None`` with ``errors`` set. - - ``connected`` rather than ``attached``, for the same §1.1 reason the - recovery menus use it: the node answers ``get_pairing`` while refusing to - serve endpoints, and a user whose bridge is in that state still needs to - be able to see and manage their pairings. - - The message names the real precondition, which is not obvious: the client - exists only while something is exported (XG5), so "pair the bridge" is - genuinely unreachable until the user has exported a device. That is XAC2's - ordering, not an accident — a bridge with no accessories is nothing worth - pairing, and Apple Home would show an empty one. - """ - bridge = self.export_bridge - client = bridge.client if bridge is not None else None - if client is None or not client.connected: - exported = len(self.exports) if self.exports is not None else 0 - why = ("Export at least one device in 'Manage Matter Exports…' first — the bridge only " - "runs while something is exported." if not exported else - "The bridge node is not answering; see the log for what its own error log says.") - self.logger.warning("Matter bridge: cannot reach the bridge node for pairing. %s", why) - errors[field] = "Not connected to the bridge node — see the log." - return None - return client - - def menuPairMatterBridge(self, valuesDict, menuId=""): # noqa: N802, ARG002 - """Open a pairing window and put the codes where the user can read them. - - **Why this is "open a window" and not "show the code" (PRD §6).** A Matter - commissioning passcode is not durable: the moment the first ecosystem - commissions, the basic window closes and the original code stops working. - Every ecosystem after that needs an *enhanced* window with a freshly - derived code (§3.8), so there is no such thing as "the" pairing code to - display. - - **Why the event log.** Indigo dialogs have no dynamic labels and no image - fields, so a runtime string cannot be shown in the dialog that produced - it. The log is this plugin's established channel for exactly that, and it - is also the one place the codes survive being scrolled past — a window - lasts up to 15 minutes and users do not type 11 digits first time. - """ - errors = indigo.Dict() - duration = self._window_duration(valuesDict, errors) - if duration is None: - return (False, valuesDict, errors) - client = self._pairing_client(errors, "duration") - if client is None: - return (False, valuesDict, errors) - try: - pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) - except Exception as exc: # noqa: BLE001 - self.logger.error("Matter bridge: could not read the bridge node's pairing state — %s. " - "No pairing window was opened.", exc) - self.logger.exception(exc) - errors["duration"] = "Could not reach the bridge node — see the log." - return (False, valuesDict, errors) - # Two states already have a usable code, and opening a window in either - # would be actively harmful: §3.8's `assertClosed` refuses a second one, - # and on a never-commissioned node the basic window is ALREADY open with - # the persisted originals (§3.7) — deriving a fresh enhanced code there - # would invalidate a code the user may already be typing. - if pairing.window_open and pairing.manual_pairing_code: - self._log_pairing_codes(pairing.manual_pairing_code, pairing.qr_pairing_code, - pairing.window_expires_at, - already_open=not pairing.commissioned) - return (True, valuesDict) - try: - window = self.runtime.submit( - client.open_commissioning_window(duration)).result(timeout=WINDOW_OPEN_TIMEOUT) - except Exception as exc: # noqa: BLE001 - self.logger.error( - "Matter bridge: opening a pairing window FAILED — %s. Nothing was changed and " - "existing pairings are untouched. If the bridge says a window is already open, " - "wait for it to expire (up to 15 minutes) and try again.", exc) - self.logger.exception(exc) - errors["duration"] = "Could not open a pairing window — see the log." - return (False, valuesDict, errors) - if self.export_bridge is not None: - self.export_bridge.note_window_opened(window.window_expires_at) - self._log_pairing_codes(window.manual_pairing_code, window.qr_pairing_code, - window.window_expires_at, already_open=False) - return (True, valuesDict) - - @staticmethod - def _window_duration(values_dict, errors) -> Optional[int]: - """Validate the duration field against §3.8's 180-900s band. - - Rejected in the dialog rather than clamped silently by the node: the - number is how long the user has to walk to another room with a phone, and - being given 900 when they asked for 60 is a difference they should be - told about while the dialog is still open. - """ - raw = str((values_dict or {}).get("duration", "") or "").strip() - if not raw: - return bridge_protocol.DEFAULT_WINDOW_SECONDS - try: - duration = int(raw) - except (TypeError, ValueError): - errors["duration"] = "Enter a whole number of seconds between 180 and 900." - return None - if not 180 <= duration <= 900: - errors["duration"] = "Matter allows 180 to 900 seconds (3 to 15 minutes)." - return None - return duration - - def _log_pairing_codes(self, manual: Optional[str], qr: Optional[str], - expires_at: Optional[str], *, already_open: bool) -> None: - """Write the codes, the expiry and the QR page URL to the event log.""" - when = f" It expires at {expires_at}." if expires_at else "" - opening = ("The bridge has never been paired, so it is ALREADY advertising with its " - "original code — no new window was opened." if already_open else - "A pairing window is now open.") - self.logger.info( - "Matter export — %s%s\n" - " Manual pairing code: %s\n" - " QR payload: %s\n" - " QR code page: %s\n" - "Add the bridge in your ecosystem's app as you would any Matter accessory, and type " - "the manual code if it asks for one. Expect an 'uncertified accessory' warning — that " - "is normal for a bridge like this one; choose Add Anyway.\n" - "SECURITY: while this window is open, anyone who can reach that page (or read this " - "code) can add your exported Indigo devices to THEIR Apple Home, Alexa or Google " - "account. The page is served by the Indigo Web Server, which asks for a password only " - "if you have switched authentication on — turn it on before using this over anything " - "but a network you trust, and do not share the URL.", - opening, when, manual or "(none)", qr or "(none)", self._pairing_page_url()) - - def _pairing_page_url(self) -> str: - """The IWS URL of the QR page (Actions.xml ``pairing``). - - ``getWebServerURL`` picks the reflector, then the Bonjour name, then - localhost — so this is reachable from the phone the user is holding - whenever a reflector or a ``.local`` name exists, which is the case the - page is FOR. A failure falls back to the loopback default rather than - omitting the line: a wrong-host URL a user can edit beats no URL. - """ - base = "http://localhost:8176" - try: - base = str(indigo.server.getWebServerURL() or base) - except Exception as exc: # noqa: BLE001 - self.logger.debug("could not resolve the Indigo web server URL (%s)", exc) - return f"{base}/message/{self._export_plugin_id()}/pairing/" - - def getBridgeFabrics(self, filter="", valuesDict=None, typeId="", targetId=0): - # pylint: disable=redefined-builtin, unused-argument - """Picker rows for the unpair menu: one per commissioned ecosystem. - - Built from the fabric set the bridge already reported (attach, then every - §5 ``fabrics_changed``), never from a fresh WS round trip: a dynamic list - callback runs on the Indigo UI's thread while the dialog is opening, and - blocking it on a node that may be down would hang the dialog rather than - render an empty one. That is a deliberate trade and the reason - :meth:`menuUnpairEcosystem` re-reads the set *after* it acts, and the - reason §3.9 now reports whether it removed anything: the list can be - stale, so nothing downstream may assume it is not. - - **The first row is always "(select an ecosystem)".** Indigo pre-selects - row one, so without it the dialog opened with a real ecosystem already - chosen on a menu whose Execute button removes it — every other picker in - this plugin (device, node, backup) leads with a no-selection row for - exactly this reason, and the one destructive picker did not. - """ - try: - bridge = self.export_bridge - fabrics = bridge.fabrics if bridge is not None else None - if not fabrics: - # None and [] are different facts, and both are un-pickable, but - # only one of them should read as "you are not paired". - return [(NO_SELECTION_ID, - "(no paired ecosystems)" if fabrics == [] - else "(not connected to the bridge node)")] - return [(NO_SELECTION_ID, "(select an ecosystem)")] + [ - (str(fabric.fabric_index), export_bridge.describe_fabric(fabric)) - for fabric in fabrics] - except Exception as exc: # pylint: disable=broad-except - self.logger.exception(exc) - return [LIST_ERROR_OPTION] - - def menuUnpairEcosystem(self, valuesDict, menuId=""): # noqa: N802, ARG002 - """§3.9 — remove one ecosystem's fabric from the bridge. - - Two gates, like the reset menu, because the outcome is the same size for - the ecosystem being removed: every accessory Indigo exports disappears - from it, with the names, rooms and automations built on them. - """ - errors = indigo.Dict() - selected = str(valuesDict.get("fabric", "") or "") - if not selected or selected == NO_SELECTION_ID: - errors["fabric"] = "Select an ecosystem to unpair." - return (False, valuesDict, errors) - try: - fabric_index = int(selected) - except (TypeError, ValueError): - errors["fabric"] = "Invalid selection." - return (False, valuesDict, errors) - if not self._truthy(valuesDict.get("confirm")) \ - or not self._truthy(valuesDict.get("confirmAgain")): - field = "confirm" if not self._truthy(valuesDict.get("confirm")) else "confirmAgain" - errors[field] = "Tick BOTH boxes — this removes every exported accessory from that "\ - "ecosystem." - return (False, valuesDict, errors) - client = self._pairing_client(errors, "confirmAgain") - if client is None: - return (False, valuesDict, errors) - cached_last = self._is_last_fabric(fabric_index) - try: - removal = self.runtime.submit( - client.remove_fabric(fabric_index)).result(timeout=UNPAIR_TIMEOUT) - except Exception as exc: # noqa: BLE001 - self.logger.error("Matter bridge: unpairing ecosystem %s FAILED — %s. Pairings are " - "unchanged.", fabric_index, exc) - self.logger.exception(exc) - errors["confirmAgain"] = "Unpair failed — see the log. Nothing was changed." - return (False, valuesDict, errors) - # The picker is built from a CACHED fabric list, so the ecosystem may - # have unpaired itself since — which the node reports as a successful - # no-op. Re-read before saying anything, so the picker cannot keep - # offering a ghost and the sentence below is about the real outcome. - self._refresh_fabric_cache(client) - if not removal.removed: - # ⊗ This used to be indistinguishable from a real removal: the node - # answered `{}` either way and the menu logged "has been unpaired. - # Every accessory has been removed" over a node-side no-op. - self.logger.warning( - "Matter bridge: ecosystem %s was ALREADY gone from the bridge node — nothing was " - "removed by this action, because there was nothing there to remove. It had most " - "likely unpaired itself since this dialog was opened. The ecosystem list has been " - "refreshed%s.", fabric_index, - f"; {removal.remaining} pairing(s) remain" if removal.remaining is not None else "") - return (True, valuesDict) - # `remaining` is the node's own post-removal count and beats the cache; - # the cache is only the fallback for a node that could not read it. - last = cached_last if removal.remaining is None else removal.remaining == 0 - if last: - # §3.9: matter.js factory-resets itself when the fabric set empties, - # and the node clears its commissioning witness to match. Say what - # that actually means rather than reporting a routine removal, because - # the user has just reset the whole bridge without using the reset menu. - self.logger.warning( - "Matter bridge: ecosystem %s was the LAST one paired, so the bridge node has " - "reset itself and is advertising for commissioning again — exactly as 'Reset " - "Matter Bridge Pairings…' would have done. Nothing in Indigo changed. Use " - "'Pair Matter Bridge…' to pair it again.", fabric_index) - else: - self.logger.warning( - "Matter bridge: ecosystem %s has been unpaired. Every accessory Indigo exports " - "has been removed from it; remove any leftover 'Indigo' bridge entry in that " - "ecosystem's app by hand.", fabric_index) - return (True, valuesDict) - - def _refresh_fabric_cache(self, client) -> None: - """Re-read the fabric set from the node after an unpair. Never raises. - - The §5 ``fabrics_changed`` that follows a removal is asynchronous, and - the picker is built from the cache it updates — so without this a user - who unpairs and immediately re-opens the dialog is offered the ecosystem - they just removed. Blocking is fine HERE (a menu Execute already blocked - on the removal itself); it is not fine in the picker callback, which runs - on the UI thread while the dialog opens. - """ - bridge = self.export_bridge - if bridge is None: - return - try: - pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) - bridge.note_fabrics(pairing.fabrics) - except Exception as exc: # noqa: BLE001 - # The removal itself already succeeded or was already true; failing - # to re-read the list afterwards is not worth reporting as a failure. - self.logger.debug("Matter bridge: could not refresh the ecosystem list (%s)", exc) - - def _is_last_fabric(self, fabric_index: int) -> bool: - """Whether removing ``fabric_index`` empties the fabric set. - - Read BEFORE the removal, from the set the bridge last reported: the §5 - ``fabrics_changed`` that follows arrives asynchronously, so asking - afterwards races it. Unknown (nothing reported yet) reads as False — - the message it selects is only the difference between two warnings. - """ - bridge = self.export_bridge - fabrics = bridge.fabrics if bridge is not None else None - if not fabrics: - return False - return [f.fabric_index for f in fabrics] == [fabric_index] - - # ------------------------------------------------------------------ - # The QR page (IWS hidden action — PRD §6 "display mechanism") - # ------------------------------------------------------------------ - def http_pairing(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802, ARG002 - """Serve the pairing page. GET only; authenticated by IWS before we run. - - This is the *only* handler here that returns HTML rather than JSON, and - it exists because the one thing the event log cannot carry is a QR code. - """ - method, _path_args, _query = self._parse_request(action) - if method.upper() != "GET": - return self._reply(405, {"error": "method_not_allowed"}) - reply = indigo.Dict() - reply["status"] = 200 - reply["headers"] = indigo.Dict({"Content-Type": "text/html; charset=utf-8"}) - reply["content"] = self._pairing_page() - return reply - - def _pairing_page(self) -> str: - """Build the pairing page's HTML from a live ``get_pairing``. - - **No QR is generated here, and that is a deliberate choice.** Rendering - one needs either a Python dependency (Indigo's framework Python has no - image stack and this plugin ships none) or a hand-written JS encoder — - a few hundred lines of Reed-Solomon and bit-masking whose failure mode is - a plausible-looking square that no phone can read. Neither is worth it - for a code that Apple Home, Alexa and Google all accept *typed in*: the - page therefore shows the manual code at a size you can read across a - room, the raw ``MT:`` payload for copying, and a link to the CHIP - project's own QR viewer for anyone who wants to scan. The tradeoff is - recorded in ``docs/HANDOVER.md`` rather than only in this docstring. - """ - client = self.export_bridge.client if self.export_bridge is not None else None - if client is None or not client.connected: - return _pairing_html(None, "The plugin is not connected to the Matter bridge node. " - "Export at least one device, then reload this page.") - try: - pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) - except Exception as exc: # noqa: BLE001 - self.logger.exception(exc) - return _pairing_html(None, f"Could not read the bridge node's pairing state: {exc}") - if not pairing.manual_pairing_code: - return _pairing_html( - pairing, - "No pairing window is open, so there is no code to show. Open one with " - "Plugins ▸ Matter ▸ Pair Matter Bridge… in Indigo.") - return _pairing_html(pairing, "") - - def _resolve_storage_path(self) -> str: - """Storage dir path in BOTH managed and manual modes. - - In managed mode ``self.server_process`` already knows it. In manual mode - we construct a throwaway ``ServerProcess`` purely to read ``storage_path`` - — its ``__init__`` writes no plist and runs no launchctl, so this is a - side-effect-free path lookup. - """ - if self.server_process is not None: - return self.server_process.storage_path - return ServerProcess(self._server_prefs(), self.logger).storage_path - - def _bridge_storage_path(self) -> str: - """The **export** bridge node's storage dir — sibling of the controller's. - - Derived rather than read from a pref, and derived by the module that also - hands it to the agent as ``--storage-path`` (E7), so the directory this - backs up and the directory the node actually writes cannot disagree. The - path is the PRD §4.3 default (``…/com.simons-plugins.indigo-matter/ - bridge-node``), which is also ``bridge-node/src/config.ts``'s - ``DEFAULT_STORAGE_PATH``. - """ - return bridge_agent.bridge_storage_path(self._resolve_storage_path()) - - def _bridge_restore_control(self) -> Optional["bridge_agent.BridgeProcess"]: - """The bridge's ``stop()``/``start()`` seam for :func:`fabric_backup.restore_backup`. - - ``stop()``/``start()``, NOT ``uninstall()``: restore wants the node - back in exactly its prior lifecycle state, plist included, so that a - reboot afterwards behaves exactly as it would have before the - restore. ``uninstall()`` is ``menuStopBridgeNode``'s primitive - (:meth:`_stop_bridge_agent`) and answers "make sure a reboot cannot - bring this back" — the wrong question here, and why THIS path leaves - the XAC1 latch alone (see the call site in ``menuRestoreFabricBackup``). - - Built from CURRENT prefs when no agent object exists yet, exactly as - ``menuStopBridgeNode`` does — this must work in a session that never - exported anything. Construction writes nothing and runs no launchctl. - """ - if self.bridge_process is not None: - return self.bridge_process - try: - self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) - except Exception as exc: # pylint: disable=broad-except - self.logger.warning( - "Matter bridge: could not build a control for the bridge node (%s), so any " - "bridge files in this backup will be reported and skipped. The controller " - "fabric restores normally.", exc) - return None - return self.bridge_process - - @staticmethod - def _human_size(num_bytes: int) -> str: - size = float(num_bytes) - for unit in ("B", "KB", "MB", "GB", "TB"): - if size < 1024.0 or unit == "TB": - return f"{size:.1f} {unit}" - size /= 1024.0 - return f"{size:.1f} TB" - - def menuExportFabricBackup(self): # noqa: N802 - # menuItem has no ConfigUI/valuesDict, so outcome can only surface via the - # log — make both success and failure unmistakable there. create_backup - # already prunes (no duplicate prune here) and validates its own output. - storage_path = None - try: - storage_path = self._resolve_storage_path() - archive = fabric_backup.create_backup( - storage_path, now=datetime.now(timezone.utc), logger=self.logger, - # PRD-indigo-matter-export §4.3: the bridge node's storage is - # backed up alongside the controller's. Losing it costs every - # ecosystem pairing AND every exported accessory's identity. - bridge_storage_path=self._bridge_storage_path(), - ) - size = self._human_size(os.path.getsize(archive)) - self.logger.info( - "Fabric backup complete: %s (%s). This is a best-effort live snapshot — " - "matter-server was NOT stopped. Backups live in %s.", - archive, size, fabric_backup.backups_dir_for(storage_path), - ) - except FileNotFoundError as exc: - # storage dir missing or empty — there is no fabric to back up. - self.logger.error( - "Fabric backup FAILED — no fabric to back up, nothing was written: %s", exc, - ) - except Exception as exc: # noqa: BLE001 - self.logger.error("Fabric backup FAILED — nothing was written: %s", exc) - self.logger.exception(exc) - - def getFabricBackups(self, filter="", valuesDict=None, typeId="", targetId=0): # noqa: N802, A002 - """List-callback populating the restore picker (newest first).""" - try: - storage_path = self._resolve_storage_path() - except Exception as exc: # noqa: BLE001 - self.logger.exception(exc) - return [] - options = [] - for entry in fabric_backup.list_backups(storage_path): - when = datetime.fromtimestamp(entry["mtime"], timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - label = f"{entry['filename']} — {self._human_size(entry['size_bytes'])} — {when}" - options.append((entry["path"], label)) - return options - - def menuRestoreFabricBackup(self, valuesDict, menuId=""): # noqa: N802, ARG002 - errors = indigo.Dict() - # Restore must stop/start matter-server, which the plugin can only do in - # managed mode. Externally-managed (run.sh / manual) servers must be - # stopped by the user by hand. - if self.server_process is None: - msg = ("LaunchAgent management is off — the plugin cannot stop matter-server. " - "Stop matter-server yourself, unzip the chosen backup over the storage dir, " - "then restart it. Refusing to restore automatically.") - self.logger.warning(msg) - errors["backup"] = "Turn on 'Manage LaunchAgent', or restore by hand (see log)." - return (False, valuesDict, errors) - - selected = valuesDict.get("backup", "") - if not selected: - errors["backup"] = "Select a backup to restore." - return (False, valuesDict, errors) - if not valuesDict.get("confirm", False): - errors["confirm"] = "Tick the box to confirm — restore replaces the current fabric." - return (False, valuesDict, errors) - - try: - storage_path = self._resolve_storage_path() - # The bridge control/path are passed in, but the XAC1 latch (which - # session started the bridge agent) is NEVER touched here in either - # direction: alive+latched -> stop/start -> unchanged, correct; - # alive+unlatched -> unchanged, because setting it would arm a - # future bootout of an agent whose lifecycle this session does not - # own; stopped-by-us + restart-failed -> the latch is left EXACTLY - # as it was: if this session had started the agent it stays set - # (so the next empty-export transition still uninstalls the - # RunAtLoad plist); if a prior session's agent, it stays unset — - # no worse than before the restore (XAC1/XG5). - # restore_backup uses stop()/start(), never uninstall(), so the - # plist survives and the latch's claim stays true throughout. - result = fabric_backup.restore_backup( - selected, storage_path, self.server_process, - now=datetime.now(timezone.utc), logger=self.logger, - bridge_storage_path=self._bridge_storage_path(), - bridge_control=self._bridge_restore_control(), - ) - # restore_backup only returns on success: the server was stopped, the - # fabric was swapped, the restored dir is non-empty, and start() - # returned True. Be honest — matter-server is RESTARTING, the node - # count is not yet known; point the user at the real signal instead of - # logging a likely-stale count and pretending it is confirmation. - self.logger.info( - "Fabric restored from %s; previous fabric preserved at %s. matter-server is " - "restarting — watch the log for 'reconciled N node(s)' to confirm the devices " - "came back.", - result["restored_from"], result["moved_aside_to"], - ) - if result["bridge_restored"]: - if result["bridge_started"] is False: - self.logger.error( - "The controller fabric restored, but the Matter bridge node did not " - "come back up. %s", self._bridge_agent_diagnosis() or - "Check the bridge node's error log.") - else: - # There may have been no pre-existing bridge dir to preserve — - # say nothing rather than "preserved at None". - preserved = ( - f" (previous copy preserved at {result['bridge_moved_aside_to']})" - if result["bridge_moved_aside_to"] else "") - self.logger.info( - "The Matter bridge node's storage was restored too%s%s. It now holds " - "the accessory identities and endpoint numbers as of that backup — if " - "a paired ecosystem has changed since, the bridge REPORTS endpoint-map " - "drift in the log and renumbers nothing.", preserved, - " and the node has been restarted" if result["bridge_started"] else "") - return (True, valuesDict) - except Exception as exc: # noqa: BLE001 - # restore_backup rolled back and preserved the original fabric (or - # aborted before touching it). Surface the failure in the UI dialog — - # never report success when the underlying op failed. - self.logger.error("Fabric restore FAILED: %s", exc) - self.logger.exception(exc) - errors["backup"] = "Restore failed — see the log. Your existing fabric was preserved." - return (False, valuesDict, errors) diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin_constants.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin_constants.py new file mode 100644 index 0000000..f09fbae --- /dev/null +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin_constants.py @@ -0,0 +1,107 @@ +"""Module-level constants and pure prefs helpers shared across ``plugin.py`` and +the four mixin modules it composes. See issue #146. +""" +from __future__ import annotations + +PLUGIN_NAME = "indigo-matter" +COMMAND_TIMEOUT = 5.0 +DECOMMISSION_TIMEOUT = 15.0 + +#: Deadlines for the export/pairing menu actions, which block the **Indigo UI +#: thread** on a WS round trip: without one, a bridge node that accepts the +#: socket and then stops answering hangs the dialog — and Indigo's client — with +#: no way out but force-quitting it. Named rather than inline because a `.result()` +#: with no timeout looks like an ordinary call at a glance, so nothing about the +#: absence of one is visible at the call site. +#: +#: PAIRING_READ_TIMEOUT covers a plain read (`get_pairing`). The other two are +#: long because the node does real Matter work behind them: opening an enhanced +#: window derives a fresh passcode and re-advertises, and removing a fabric +#: flushes subscriptions and — on the last one — factory-resets the whole stack. +PAIRING_READ_TIMEOUT = 15.0 +WINDOW_OPEN_TIMEOUT = 45.0 +UNPAIR_TIMEOUT = 45.0 +FACTORY_RESET_TIMEOUT = 45.0 + +#: Watchdog ticks (~15s each) of an active export with no ``deviceUpdated`` at +#: all before ``subscribeToChanges`` is re-issued — see +#: ``Plugin._resubscribe_tick``. ~1 minute, the same shape as every other streak +#: counter here. +RESUBSCRIBE_TICKS = 4 +#: How many times, at most. Bounded because a house where nothing changes looks +#: identical to a subscription that never registered. +MAX_RESUBSCRIBE_ATTEMPTS = 3 + +#: Menu id of the export dialog (MenuItems.xml) — matched in +#: ``get_menu_action_config_ui_values`` so other menus are never seeded. +MENU_MANAGE_EXPORTS = "manageMatterExports" +#: Menu id of the unpair dialog. Seeded for the same reason the export dialog is +#: — Indigo pre-selects the first row of a picker, and this picker's rows are +#: real ecosystems whose Execute button removes them. +MENU_UNPAIR_ECOSYSTEM = "unpairEcosystem" +#: Option-id prefix marking a picker row the user may look at but not choose +#: (PRD §5.2: excluded devices are shown *with a reason*, never hidden — XAC9). +EXCLUDED_OPTION_PREFIX = "x-" +#: The "nothing selected" sentinel. Never "": Indigo rejects an empty list id +#: with "UI dynamic list function returned illegal ID string" and silently +#: drops the option. The picker always emits a REAL row carrying this id +#: (:data:`NO_SELECTION_LABEL`), because the dialog is seeded with it — a +#: seeded value with no matching row renders as a blank first item. +NO_SELECTION_ID = "0" +NO_SELECTION_LABEL = "— select a device —" +#: Informational rows. They get their own ids so :data:`NO_SELECTION_ID` stays +#: unique, and the ``x-`` prefix keeps them unpickable through the same door +#: excluded devices use. +TRUNCATED_OPTION = (f"{EXCLUDED_OPTION_PREFIX}truncated", + "…too many matches — narrow the filter") +NO_MATCH_OPTION = (f"{EXCLUDED_OPTION_PREFIX}nomatch", "(no devices match the filter)") +#: What a list callback returns when it fails outright. An empty list would +#: render as an empty popup the user cannot tell from "nothing to choose". +LIST_ERROR_OPTION = (NO_SELECTION_ID, "(error building list — see Event Log)") +#: One unreadable device inside an otherwise fine list (D3): the row is kept so +#: the count is honest, but it is not selectable. +ROW_ERROR_LABEL = "(error reading device — see Event Log)" +#: Picker cap. Past this the tail row asks the user to narrow the filter — a +#: 2000-device database would otherwise build an unusable popup menu. +EXPORT_PICKER_LIMIT = 300 + + +def server_location(prefs: dict) -> str: + """Resolve the one user-facing choice: is matter-server on this Mac? + + Returns ``"local"`` (the plugin runs and manages matter-server here on + loopback) or ``"remote"`` (connect to a matter-server elsewhere). + + Migrates pre-2026.6 prefs that predate the ``serverLocation`` menu: + * a managed LaunchAgent meant the plugin already ran the server here → local; + * a host pointed at another machine → remote (keep its host/port); + * anything else — a fresh install or a loopback self-run server → local, + the turnkey default. + """ + loc = str(prefs.get("serverLocation") or "").strip().lower() + if loc in ("local", "remote"): + return loc + if prefs.get("manageLaunchAgent", False): + return "local" + host = str(prefs.get("matterServerHost") or "").strip().lower() + if host and host not in ("localhost", "127.0.0.1", "::1"): + return "remote" + return "local" + + +def sanitize_host(raw: str) -> str: + """Reduce a user-entered host to a bare hostname / IP. + + Users paste full URLs into the host field (e.g. ``http://jobs2.local:8176``); + a scheme, an embedded port, and any path all corrupt ``ws://{host}:{port}{path}``. + Strip them so the separate port field stays authoritative. IPv6 literals + (multiple colons) are left untouched. + """ + host = str(raw or "").strip() + if "://" in host: + host = host.split("://", 1)[1] + host = host.split("/", 1)[0] # drop any /path + # strip an embedded :PORT (host:1234) but preserve IPv6 literals (many colons) + if host.count(":") == 1 and host.rsplit(":", 1)[1].isdigit(): + host = host.rsplit(":", 1)[0] + return host diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/server_menu_mixin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/server_menu_mixin.py new file mode 100644 index 0000000..4860afa --- /dev/null +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/server_menu_mixin.py @@ -0,0 +1,789 @@ +"""matter-server menus (install/restart + manual commission/decommission with +their pickers), export-bridge recovery, and the bridge node's +LaunchAgent (PRD §4.2/§4.4, BRIDGE_PROTOCOL §3.10/§3.11, E7 — PRD-indigo-matter-export +§4.2/XG5/XAC1). Three originally separate sections in one mixin because they share +one construction rule (ServerProcess only ever from ``self._server_prefs()``) and +one install thread. See issue #146. +""" +from __future__ import annotations + +import os +import threading +from concurrent.futures import CancelledError as FuturesCancelledError +from typing import Any, Optional + +import indigo # provided by the Indigo runtime + +import bridge_agent +import bridge_client # bridge_client.rebuild_timeout_for +from commission_jobs import node_id_to_str +from http_handlers import MatterUnavailable +from plugin_constants import FACTORY_RESET_TIMEOUT, server_location +from server_process import ServerProcess + + +class ServerMenuMixin: + """matter-server install/restart, manual commission/decommission-device, + export-bridge recovery, and bridge-node LaunchAgent menus. + + Composed into ``Plugin`` alongside the other three mixins; never + instantiated on its own and never subclasses ``indigo.PluginBase``. + """ + + # Self-attribute contract: these are created by ``Plugin.__init__``/ + # ``startup`` and stay in plugin.py. Declared here as class-level + # annotations only (no assignment) so mixin methods resolve `self.` + # without shadowing instance state, and so pylint's `no-member` check has + # something to verify against. See issue #146. + server_process: Any # read and written here (_install_matter_server, menuRestartMatterServer) + bridge_process: Any # read and written here — also written by PairingMenuMixin._bridge_restore_control + _install_thread: Any # read and written here + _stopping: Any + # ⚠ Written here (menuRestartMatterServer, _install_matter_server) and read + # by plugin.py's `_on_server_unreachable` — the tightest lifecycle coupling + # in the split. Still one plain attribute on one object; nobody should + # "tidy" it into a local (issue #146). + _restart_expected_until: Any + exports: Any + export_bridge: Any + jobs: Any + device_sync: Any + runtime: Any + pluginPrefs: Any + logger: Any + + # ------------------------------------------------------------------ + # Menu items + # ------------------------------------------------------------------ + def menuInstallMatterServer(self): # noqa: N802 + """Install/update the matter-server npm package, then pin the node used. + + Only meaningful in local (managed) mode — self-managers keep their own + server untouched. Runs off the Indigo main thread so the UI never blocks on + npm; progress and outcome go to the log. + """ + if server_location(self.pluginPrefs) != "local": + self.logger.error( + "Install is only for local mode. Set 'is matter-server on this Mac?' " + "to local (managed) first, or install/manage the server yourself." + ) + return + if self._install_thread is not None and self._install_thread.is_alive(): + self.logger.warning("matter-server install already in progress.") + return + self.logger.info("Starting matter-server install in the background — watch the " + "log for progress; this can take a minute.") + self._install_thread = threading.Thread( + target=self._install_matter_server, name="matter-install", daemon=True) + self._install_thread.start() + + def _install_matter_server(self, clean: bool = False) -> None: + try: + # See plugin.py's `_server_prefs` docstring: constructing a ServerProcess + # from raw prefs skips the local-mode pinning — always go through it. + sp = self.server_process or ServerProcess(self._server_prefs(), self.logger) # pylint: disable=no-member + if clean: + # "Start fresh": delete node_modules and reinstall. Stops/reaps the server + # first and leaves the storage (fabric/pairings) intact. Used to recover a + # matter-server that won't start after an upgrade. + self.logger.info("Removing the installed matter-server for a clean " + "reinstall (your devices/pairings are kept)…") + if not sp.remove_package(): + # remove_package has already said what is still there. Do NOT + # install over it: a clean reinstall that quietly became a + # plain reinstall leaves the wedge the user came here for. + self.logger.error( + "Clean reinstall ABANDONED — the old package could not be removed, so " + "nothing was reinstalled over it. Nothing was changed.") + return + if not sp.install(): + self.logger.error( + "Install/update of the Matter controller (matter-server) did not " + "complete — see the error above. The server was not (re)installed; " + "retry when resolved." + ) + return + if self._stopping: # plugin is tearing down — don't mutate its state + return + # Pin the exact node used so the LaunchAgent runs the same one forever — + # this is what keeps install-node == run-node and avoids ABI crash-loops. + self.pluginPrefs["nodeBinDir"] = sp.resolved_bin_dir + indigo.server.savePluginPrefs() + self.server_process = ServerProcess(self._server_prefs(), self.logger) # pylint: disable=no-member + self.server_process.ensure_installed() + # Restart matter-server onto the just-installed version — otherwise the + # newly-installed package sits on disk while the OLD process keeps running + # (a running LaunchAgent doesn't pick up new files). This is what makes the + # menu action a one-click, no-CLI update. + self._expect_restart() # pylint: disable=no-member + if not self.server_process.restart(): + # Don't claim success: the new version may not be running. + self._restart_expected_until = 0.0 # let the crash diagnostic work + self.logger.error( + "matter-server was installed and pinned to node at %s, but the " + "restart onto the new version FAILED — the old version may still be " + "running. Use Plugins ▸ Matter ▸ Restart the Matter controller, or " + "reload the plugin.", sp.resolved_bin_dir, + ) + return + self.logger.info( + "matter-server installed, pinned to node at %s, and restarting onto the " + "new version — it reconnects automatically.", sp.resolved_bin_dir, + ) + except Exception as exc: # noqa: BLE001 + # npm may have succeeded and only the pin/activate step failed — say so, so + # the user doesn't reinstall in circles chasing a downstream problem. + self.logger.exception(exc) + self.logger.error( + "matter-server install did not complete after the npm step — the " + "package may be installed but the node was not pinned and the server " + "was not (re)started. See the trace above, then retry Plugins ▸ Matter " + "▸ Install/update the Matter controller (matter-server)." + ) + + def menuReinstallMatterServerClean(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """Menu callback: delete matter-server and reinstall it fresh (keeps devices). + + The "blow it all away and start over" recovery when matter-server won't start + after an upgrade (e.g. a wedged install or a stray process). Removes + ~/indigo-matter/node_modules and reinstalls; the fabric/storage is left intact so + commissioned devices survive. Runs in the background like the plain install. + """ + errors = indigo.Dict() + if not valuesDict.get("confirm", False): + errors["confirm"] = "Tick the box to confirm the reinstall." + return (False, valuesDict, errors) + if server_location(self.pluginPrefs) != "local": + errors["confirm"] = ("Reinstall is only for local (managed) mode. Set 'is " + "matter-server on this Mac?' to local first.") + return (False, valuesDict, errors) + if self._install_thread is not None and self._install_thread.is_alive(): + errors["confirm"] = "An install is already in progress — wait for it to finish." + return (False, valuesDict, errors) + self.logger.info("Starting a clean matter-server reinstall in the background — " + "watch the log for progress; this can take a minute.") + self._install_thread = threading.Thread( + target=self._install_matter_server, kwargs={"clean": True}, + name="matter-reinstall", daemon=True) + self._install_thread.start() + return (True, valuesDict) + + def menuRestartMatterServer(self): # noqa: N802 + if self.server_process is None: + self.logger.warning("LaunchAgent management is off; start matter-server manually") + return + # Rebuild from CURRENT prefs first. ServerProcess snapshots prefs at construction + # and restart() bootstraps the plist *as it is on disk*, which only + # ensure_installed() regenerates — so without this, a setting changed since + # startup (notably the attestation flag) is silently NOT applied and this menu + # still logs success. + self.server_process = ServerProcess(self._server_prefs(), self.logger) # pylint: disable=no-member + self._expect_restart() # expected outage, not a crash # pylint: disable=no-member + try: + # None = preflight failed (plist torn down, nothing to restart); + # True = it already reloaded launchd, so a restart() here would stop and + # start the server a SECOND time for nothing — two outages, every device's + # session dropped twice; False = job left running, so we do the restart. + reloaded = self.server_process.ensure_installed() + restarted = True if reloaded else ( + False if reloaded is None else self.server_process.restart() + ) + except Exception as exc: # noqa: BLE001 + # Unguarded, this would escape with the expected-restart window still armed, + # suppressing the crash diagnostic for 30s while the server is down. + self._restart_expected_until = 0.0 + self.logger.exception(exc) + return + if restarted: + self.logger.info("matter-server restart requested") + elif reloaded is None: + self._restart_expected_until = 0.0 + self.logger.error( + "matter-server cannot be restarted — see the error above. Its LaunchAgent " + "was removed to stop a crash-loop; fix the cause, then reload the plugin." + ) + else: + self._restart_expected_until = 0.0 # restart failed — don't suppress the diagnostic + self.logger.error( + "matter-server restart failed; check ~/Library/Logs/indigo-matter/matter-server.err.log" + ) + + def menuShowMatterServerLogs(self): # noqa: N802 + self.logger.info("matter-server log: ~/Library/Logs/indigo-matter/matter-server.log") + + def menuCommissionDeviceManually(self, valuesDict, menuId=""): # noqa: N802, ARG002 + if self.jobs is None: + # Surface WHY OK did nothing — a bare (False, valuesDict) leaves the + # dialog open with no explanation at all. + self.logger.warning("manual commission requested before the plugin finished starting") + errors = indigo.Dict() + errors["setupCode"] = "Plugin still starting — try again in a moment." + return (False, valuesDict, errors) + status, body = self.jobs.create_job({ + "setupCode": valuesDict.get("setupCode", ""), + "suggestedName": valuesDict.get("suggestedName", "Matter Device"), + # The picker's value is a folder id; map it back to the folder NAME and + # pass it as suggestedRoom, which device_sync resolves to that folder + # (the same path Domio's room uses). "0"/unknown → no folder (root). + "suggestedRoom": self._folder_name_for(valuesDict.get("folder")), + }) + self.logger.info("manual commission → %s %s", status, body) + return (status in (202, 409), valuesDict) + + def getDeviceFolders(self, filter="", valuesDict=None, typeId="", targetId=0): # noqa: N802, A002, ARG002 + """List-callback populating the folder picker on the manual-commission menu. + + Options are (folderId, folderName) with a leading "0" → no folder (the + device-list root). The id MUST be a non-empty string — Indigo rejects an + empty list id with "UI dynamic list function returned illegal ID string", + which silently drops the option — so the no-folder sentinel is "0" (folder + id 0 == no folder), never "". menuCommissionDeviceManually maps the chosen + id back to the folder NAME for suggestedRoom.""" + options = [("0", "(no folder)")] + try: + for folder in indigo.devices.folders: + options.append((str(folder.id), folder.name)) + except Exception as exc: # noqa: BLE001 - never break the dialog; degrade to no-folder only + self.logger.exception(exc) + return options + + def _folder_name_for(self, folder_id): + """Resolve the folder picker's selected id (string) to the folder NAME. + + "0", empty, or an unknown/stale id → None (commission to the device-list + root). Never raises — an unresolvable folder must not fail the commission.""" + if not folder_id or folder_id == "0": + return None + try: + fid = int(folder_id) + for folder in indigo.devices.folders: + if folder.id == fid: + return folder.name + # Parses fine but matches nothing — e.g. folder deleted between the + # picker rendering and submit. Benign (device lands at root), but leave + # a trail rather than silently dropping the selection. + self.logger.debug("folder id %r not found, commissioning at root", folder_id) + except Exception as exc: # noqa: BLE001 - degrade to no folder, never fail the commission + self.logger.warning("folder id %r not resolvable, commissioning without a folder: %s", folder_id, exc) + return None + + def getMatterNodes(self, filter="", valuesDict=None, typeId="", targetId=0): # noqa: N802, A002, ARG002 + """List-callback populating the decommission picker (one entry per node).""" + if self.device_sync is None: + return [] + try: + options = [] + for node_id, names in self.device_sync.list_nodes(): + label = ", ".join(names) if names else "(no Indigo devices)" + options.append((str(node_id), f"{label} — node {node_id_to_str(node_id)}")) + return options + except Exception as exc: # noqa: BLE001 - never break the dialog; degrade to an empty picker + self.logger.exception(exc) + return [] + + def menuDecommissionDevice(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """Menu callback: decommission the selected node. + + Returns ``(False, valuesDict, errors)`` to keep the dialog open with a + field error, ``(True, valuesDict)`` only when the node was fully + removed (fabric AND Indigo devices) — partial outcomes are dialog + errors so they can't masquerade as success. + """ + errors = indigo.Dict() + selected = valuesDict.get("node", "") + if not selected: + errors["node"] = "Select a device to decommission." + return (False, valuesDict, errors) + if not valuesDict.get("confirm", False): + errors["confirm"] = "Tick the box to confirm removal from Indigo." + return (False, valuesDict, errors) + try: + node_id = int(selected) + except (TypeError, ValueError): + errors["node"] = "Invalid selection." + return (False, valuesDict, errors) + try: + result = self._decommission_sync(node_id) # pylint: disable=no-member # HttpApiMixin + except MatterUnavailable as exc: + self.logger.error("decommission %s failed — matter-server unavailable: %s", + node_id_to_str(node_id), exc) + # A timeout does NOT cancel the in-flight coroutine — the removal may + # still complete in the background, so don't claim nothing happened. + errors["node"] = ("matter-server did not respond — see the log. The removal may " + "still complete in the background; check the device before retrying.") + return (False, valuesDict, errors) + except (Exception, FuturesCancelledError) as exc: # CancelledError is BaseException on 3.10+ + self.logger.error("decommission %s failed: %s", node_id_to_str(node_id), exc) + self.logger.exception(exc) + errors["node"] = "Decommission failed — see the Indigo event log." + return (False, valuesDict, errors) + if result is None: + errors["node"] = "Unknown node — nothing was removed." + return (False, valuesDict, errors) + if result["fabricRemoved"]: + self.logger.info( + "Decommissioned Matter node %s: fabric removed, Indigo device(s) deleted: %s", + result["nodeId"], result["removedIndigoDeviceIds"] or "none", + ) + return (True, valuesDict) + # remove_node failed (usually: device offline) — matter-server most likely + # still has the node (any remove_node failure is treated as not-removed), + # so the next reconcile (plugin restart or matter-server reconnect) will + # recreate the Indigo devices we just deleted. Surface that in the dialog — + # never report success when the underlying op only half-happened. + self.logger.warning( + "Decommission of node %s incomplete: Indigo device(s) %s deleted but the " + "fabric removal failed (device offline?). The node is still commissioned in " + "matter-server and its devices will reappear at the next reconcile — retry " + "once the device is reachable.", + result["nodeId"], result["removedIndigoDeviceIds"] or "none", + ) + errors["node"] = ("Device unreachable — removed from Indigo, but it is still commissioned " + "in matter-server and will reappear at the next reconcile (plugin restart " + "or reconnect). Retry once the device is powered and reachable.") + return (False, valuesDict, errors) + + # ------------------------------------------------------------------ + # Export-bridge recovery menus (BRIDGE_PROTOCOL §3.10/§3.11) + # ------------------------------------------------------------------ + def _recovery_client(self, errors, field: str): + """The bridge client, or ``None`` with ``errors`` filled in. + + Both recovery commands need a live socket, and the state they exist to + fix is exactly the one where the plugin holds the connection open + UN-attached (§1.1 recovery). So `connected`, not `attached`, is the + right gate — requiring an attach would make the rebuild unreachable in + the only situation that needs it. + """ + bridge = self.export_bridge + client = bridge.client if bridge is not None else None + if client is None or not client.connected: + msg = ("Not connected to the Matter bridge node. Start it (it is launched by " + "hand in this build), export at least one device so the plugin connects, then " + "try again.") + self.logger.warning(msg) + errors[field] = "Not connected to the bridge node — see the log." + return None + return client + + def menuRebuildEndpointMap(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """§3.11 — the way out of the endpoint_map_invalid refuse-to-start state. + + Two outcomes are reported separately, because they can differ and the + expensive one is the first: the rebuild is irreversible (it discards + the persisted baseline and adopts the live numbers) while the re-attach + that follows it is an ordinary connection step that retries on its own. + It renumbers nothing, so it cannot itself duplicate accessories — any + duplication belongs to the storage loss that caused the refusal (#132). + Reporting the pair as one used to tell users their node was "unchanged + and still refusing" over a map that had already been rewritten — and + invite them to do it again. + """ + errors = indigo.Dict() + if not self._truthy(valuesDict.get("confirm")): # pylint: disable=no-member # ExportDialogMixin + errors["confirm"] = ("Tick the box — a rebuild replaces the endpoint-number record " + "and cannot be undone.") + return (False, valuesDict, errors) + client = self._recovery_client(errors, "confirm") + if client is None: + return (False, valuesDict, errors) + if not client.recovery: + # M11: `connected` is the right gate for REACHING the node, but it + # is not a reason to rebuild. Run against a healthy node this + # silently discards the retained endpoint-number allocations of + # every export that is not currently live — §3.3 keeps them exactly + # so that re-adding a device restores its accessory — and there is + # nothing to recover from in the first place. + msg = ("Matter bridge: the bridge node is NOT refusing to serve endpoints, so there " + "is nothing to rebuild. Rebuilding anyway would discard the retained endpoint " + "numbers of every device that is not currently exported, and those are what " + "make re-adding one restore the same accessory.") + self.logger.warning(msg) + errors["confirm"] = ("The bridge node is not refusing to export — there is nothing " + "to rebuild. See the log.") + return (False, valuesDict, errors) + # Derived from the same two deadlines the call itself is built from: a + # flat number here is the one that expires first on a large export list, + # turning a rebuild that worked into a reported failure. + deadline = bridge_client.rebuild_timeout_for( + len(self.exports) if self.exports is not None else 0) + try: + status = self.runtime.submit(client.rebuild_endpoint_map()).result(timeout=deadline) + except Exception as exc: # noqa: BLE001 + # Never report success over a rebuild that did not persist: the node + # answers with an error rather than a StatusReport when the new map + # could not be written, and the refusal is still in force. + self.logger.error("Matter bridge: rebuilding the endpoint map FAILED — %s. The bridge " + "node is unchanged and still refusing to export.", exc) + self.logger.exception(exc) + errors["confirm"] = "Rebuild failed — see the log. Nothing was changed." + return (False, valuesDict, errors) + self.logger.warning( + "Matter bridge: endpoint map REBUILT — the bridge node has stopped refusing and is " + "serving %d endpoint(s). Nothing was renumbered. If only the map file was damaged, " + "no paired ecosystem will see any change; if the bridge's Matter storage was lost, " + "your ecosystems already hold dead accessories under the old numbers — delete those " + "by hand. Do NOT run this again for the same fault.", + status.endpoint_count) + for warning in status.warnings: + self.logger.warning("Matter bridge: the bridge node reports — %s", warning) + if not client.attached: + # The rebuild stands; only the connection step after it did not. The + # client's own triage has already named the reason at error level. + self.logger.warning( + "Matter bridge: the rebuild succeeded but re-attaching to the bridge node did " + "not. The rebuild does NOT need repeating — exports resume when the connection " + "does, and the reason is logged above.") + return (True, valuesDict) + + def menuResetBridgePairings(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """§3.10 — wipe the Matter bridge's commissioning and re-advertise.""" + errors = indigo.Dict() + # Two boxes, deliberately. This is the only plugin action that destroys + # every ecosystem pairing at once, and it is irreversible without + # re-pairing each ecosystem by hand. + # pylint: disable=no-member # ExportDialogMixin._truthy via MRO (issue #146) + if not self._truthy(valuesDict.get("confirm")) \ + or not self._truthy(valuesDict.get("confirmAgain")): + field = "confirm" if not self._truthy(valuesDict.get("confirm")) else "confirmAgain" + errors[field] = "Tick BOTH boxes — this removes every ecosystem pairing." + return (False, valuesDict, errors) + # pylint: enable=no-member + client = self._recovery_client(errors, "confirmAgain") + if client is None: + return (False, valuesDict, errors) + try: + # preserve_endpoint_numbers=True: a user resetting to re-pair the + # same ecosystems should not also lose accessory identity. The + # "the map itself is corrupt" path is the rebuild above. + self.runtime.submit(client.factory_reset(True)).result(timeout=FACTORY_RESET_TIMEOUT) + except Exception as exc: # noqa: BLE001 + self.logger.error("Matter bridge: resetting the bridge pairings FAILED — %s. " + "Pairings are unchanged.", exc) + self.logger.exception(exc) + errors["confirmAgain"] = "Reset failed — see the log. Pairings were not changed." + return (False, valuesDict, errors) + self.logger.warning( + "Matter bridge: the bridge node's pairings have been RESET. It is advertising for " + "commissioning again — pair it from each ecosystem, and remove the now-dead Indigo " + "bridge from any ecosystem that still lists it.") + return (True, valuesDict) + + # ------------------------------------------------------------------ + # The Matter bridge node's LaunchAgent (E7 — PRD §4.2, XG5, XAC1) + # ------------------------------------------------------------------ + def _start_bridge_agent(self) -> None: + """Install (if needed) and start the bridge node's LaunchAgent. + + The ``agent_start`` seam. Called by :class:`export_bridge.ExportBridge` + on the empty→non-empty allow-list transition, on whichever Indigo thread + made that change — never on the loop. Blocking, but only by a couple of + ``launchctl`` calls; ``install`` is deliberately NOT attempted here (npm + takes a minute and this can run from ``deviceDeleted``), so a missing + package surfaces as ``ensure_installed``'s actionable preflight error + naming the install menu. + + Rebuilt from current prefs on every call rather than cached: the ports + and the mDNS interface are prefs, ``ensure_installed`` only reloads + launchd when the resulting plist actually differs, and a stale + ``BridgeProcess`` would write yesterday's ports while reporting success. + """ + self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + agent = self.bridge_process + if agent.ensure_installed() is None: + # Preflight failed; the plist has been torn down and the reason + # logged. Nothing to start, and starting would only crash-loop. + return + # ``ensure_installed() is not None`` is NOT the process being up. It is + # False for "the current definition was already loaded and healthy" AND + # for "bootout succeeded but neither bootstrap nor load did" AND for "the + # job is loaded with a pid line we could not parse" — and the middle one + # was reported here as "bridge node LaunchAgent is running". Ask launchd. + state = agent.run_state() + if state == agent.RUNNING: + self.logger.info("Matter bridge: bridge node LaunchAgent is running (protocol port %s, " + "Matter port %s)", agent.ws_port, agent.matter_port) + elif state == agent.UNKNOWN: + # A pid line we could not parse. It may well be serving; what we must + # not do is assert either way. + self.logger.info( + "Matter bridge: the bridge node's LaunchAgent is loaded (protocol port %s, Matter " + "port %s); launchd did not report a readable pid, so whether the process is up " + "will show as the plugin connects — or fails to.", agent.ws_port, agent.matter_port) + else: + self.logger.error( + "Matter bridge: the bridge node's LaunchAgent %s. Exported accessories will be " + "unavailable until it does. %s", + "did not start" if state == agent.LOADED_NOT_RUNNING + else "could not be loaded by launchd", + self._bridge_agent_diagnosis()) + + def _stop_bridge_agent(self) -> None: + """Stop the bridge agent and REMOVE its plist. The storage is untouched. + + The ``agent_stop`` seam, called only once there is genuinely nothing to + serve. + + **``uninstall()`` rather than ``stop()``, and that is a correction.** + ``stop()`` boots the job out and keeps the plist, which reads as a + thrifty choice until you notice the plist carries ``RunAtLoad: True``: + at the next login launchd started an *unpaired* bridge node with an + EMPTY allow-list, advertising on the Matter port, that this plugin never + started and — because ``_agent_started`` is false in a session that + never brought it up (XAC1) — would never stop. XG5's guarantee is that a + fresh or emptied install runs no bridge process, and a guarantee that + does not survive a reboot is not one. Re-deriving the plist costs one + ``ensure_installed`` on the next export. + + The storage dir — every ecosystem pairing plus the endpoint-number + witness — is never touched by either (PRD §5.4). + """ + agent = self.bridge_process + if agent is None: + return + was_loaded = agent.is_running() # "is there a job on the books" + agent.uninstall() # bootout + remove the plist + if agent.is_running() or os.path.exists(agent.plist_path): + # ⊗ The silent branch. stop() returning False used to say nothing at + # all, and `_agent_started` had already been cleared, so nothing + # retried: the node kept serving every paired ecosystem with the log + # asserting the opposite by omission. + self.logger.warning( + "Matter bridge: nothing is exported, but the bridge node's LaunchAgent could not " + "be %s (%s). It keeps running and serving every paired ecosystem, and NOTHING " + "retries this on its own — reload the plugin, or run 'launchctl bootout " + "gui/$(id -u)/%s' and delete that file by hand.", + "stopped" if agent.is_running() else "removed", agent.plist_path, + bridge_agent.LABEL) + return + if was_loaded: + self.logger.info( + "Matter bridge: nothing is exported — the bridge node has been stopped and its " + "LaunchAgent removed, so a restart of this Mac cannot bring it back. Its pairings " + "are kept.") + else: + # The two Falses `stop()` conflated: this one is "there was no job", + # which is not a failure and must not be reported as one. + self.logger.debug( + "Matter bridge: nothing is exported and no bridge node LaunchAgent was loaded; " + "any plist has been removed. Pairings are kept.") + + def _bridge_agent_diagnosis(self) -> Optional[str]: + """Why is the bridge node not answering? The ``agent_diagnose`` seam. + + Reads the agent's own error log, which is the only place the real cause + appears: a Matter port already bound by another stack (PRD §7), a package + that was never installed, an ABI mismatch. The socket sees "connection + refused" for all of them. + + **Read-only.** It deliberately does not restart anything. launchd already + owns respawn via ``KeepAlive``, the loaded-but-dead revival lives in + ``ensure_installed``, and a diagnostic that quietly bounced the agent on + every failure streak would turn a crash-loop into a crash-loop nobody can + read the log of. + """ + agent = self.bridge_process + if agent is None: + return ("The bridge node's LaunchAgent has not been started by this plugin session — " + "export at least one device, or reload the plugin.") + # ⊗ Asked FIRST, and it was not asked at all. preflight() holds the + # actual fact — is the node interpreter there, is the package installed — + # while the old code guessed at it from an empty error log and then said + # "checked {project_dir}", which it had not looked at. A missing package + # is also the case where the error log is empty *for the right reason*: + # launchd never got far enough to write one. + problem = agent.preflight() + if problem: + return f"The bridge node cannot start: {problem}" + tail = agent.tail_error_log() + if tail: + # NOT "recent". The file is appended to and never truncated, so the + # last 20 lines can be from a crash-loop days ago that has since been + # fixed — naming the file is what lets the user check the timestamps. + return (f"The last lines of {os.path.join(agent.log_dir, bridge_agent.BRIDGE_ERR_LOG)} " + f"(appended to since the bridge was first started, so these may be old):\n" + f"{tail}") + return (f"The {bridge_agent.BRIDGE_PACKAGE} package is installed and its error log " + f"({os.path.join(agent.log_dir, bridge_agent.BRIDGE_ERR_LOG)}) is empty, so the " + f"node is failing without saying why — check that nothing else on this Mac holds " + f"Matter port {agent.matter_port} or protocol port {agent.ws_port}.") + + def menuInstallBridgeNode(self): # noqa: N802 + """Install/update the ``indigo-matter-bridge`` npm package. + + The export-side twin of ``menuInstallMatterServer``, and a sibling rather + than an extension of it: the two agents are separately versioned, and a + user recovering a wedged bridge must not also be made to reinstall a + controller that is working (or the reverse). They share the install + thread because ``~/indigo-matter`` is one npm root and two concurrent + ``npm install``s into it corrupt each other. + """ + self._run_bridge_install(clean=False) + + def menuReinstallBridgeNodeClean(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """Remove the bridge package and install it fresh (the controller's twin). + + The controller has had this exit since 2026.7; the bridge shipped without + it, so a user whose bridge install was wedged had a menu that reinstalled + *over* the wedge and no way to clear it. ``remove_package`` is per-package + since E7, which is what makes this safe to offer at all — it used to + rmtree the shared ``node_modules`` and take the controller with it. + + Pairings are untouched: they live in the storage dir, which nothing in + the install path goes near. + """ + errors = indigo.Dict() + if not self._truthy(valuesDict.get("confirm")): # pylint: disable=no-member # ExportDialogMixin + errors["confirm"] = "Tick the box to confirm." + return (False, valuesDict, errors) + if not self._run_bridge_install(clean=True): + errors["confirm"] = "An npm install is already running — wait for it to finish." + return (False, valuesDict, errors) + return (True, valuesDict) + + def _run_bridge_install(self, *, clean: bool) -> bool: + """Start the background bridge install. False if one is already running.""" + if self._install_thread is not None and self._install_thread.is_alive(): + self.logger.warning("An npm install is already in progress — wait for it to finish.") + return False + self.logger.info( + "%s the Matter bridge node in the background — watch the log for progress; " + "this can take a minute.", + "Removing and reinstalling" if clean else "Installing") + self._install_thread = threading.Thread( + target=self._install_bridge_node, args=(clean,), + name="matter-bridge-install", daemon=True) + self._install_thread.start() + return True + + def menuStopBridgeNode(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """Stop the bridge node and remove its LaunchAgent, by hand. + + The controller has "Restart the Matter controller"; the bridge had nothing at + all, because it is started and stopped by the allow-list. That leaves one + state with no UI: a user who disables the plugin (or whose plugin dies + mid-session) has a node still running and still serving every paired + ecosystem, and the only lever is ``launchctl``. Exporting nothing is not + that lever — it needs the plugin to be running to notice. + + Exports are NOT changed. The next export starts the node again, which is + exactly XG5 and is why this is safe to hand a user: the worst outcome is + a bridge that comes back. + """ + errors = indigo.Dict() + if not self._truthy(valuesDict.get("confirm")): # pylint: disable=no-member # ExportDialogMixin + errors["confirm"] = "Tick the box to confirm." + return (False, valuesDict, errors) + # Built from CURRENT prefs rather than reused: this must work in a + # session that never started the agent, which is the whole point of it. + self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + self._stop_bridge_agent() + if self.export_bridge is not None: + # The plugin no longer has an agent it started, so the XAC1 latch + # must not go on claiming it does. + self.export_bridge.note_agent_stopped() + return (True, valuesDict) + + def _install_bridge_node(self, clean: bool = False) -> None: + """npm-install the bridge package, then restart it if anything is exported. + + The restart is conditional on there being something to export, which is + the difference from the controller's install: bringing the agent up + because a package was updated would violate XG5 on an install with an + empty allow-list, and leave a bridge process running for nothing. + + **``ensure_installed()`` before ``restart()``, and its absence was the + first-run dead end.** The bridge's plist is written by exactly one place + — ``_start_bridge_agent`` — and on a machine where the package has never + been installed that place cannot get past its own preflight, so it writes + no plist and (correctly) tears any stale one down. The user's route out + of that is this menu; it then went install() → restart(), restart found + no plist, and printed "nothing to restart. Fix the problem reported + above" (there was no problem above — the install had just SUCCEEDED) + followed by "the restart FAILED — the old version may still be running" + (nothing was running). Two wrong messages, no bridge, and the only real + remedy — write the plist now that the package exists — never attempted. + """ + try: + agent = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + if clean and not agent.remove_package(): + # remove_package has said what is still there. Installing over a + # wedged install is what the clean variant exists to avoid. + self.logger.error( + "Clean reinstall of the Matter bridge ABANDONED — the old package " + "could not be removed, so nothing was reinstalled over it.") + return + if not agent.install(): + self.logger.error( + "Install/update of the Matter bridge did not complete — see the error " + "above. Nothing was changed; retry when resolved.") + return + if self._stopping: # plugin is tearing down — don't mutate its state + return + self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + if self.exports is None or not len(self.exports): + self.logger.info( + "Matter bridge installed. It is NOT being started: nothing is exported " + "yet, and the bridge only runs while the export list is non-empty. Add a " + "device in 'Manage Matter Exports…' and it will start itself.") + return + # Write (or refresh) the plist first. On the first-run path there is + # none — this is where it comes from — and `ensure_installed` returning + # True means launchd has already bootstrapped the NEW files, so there + # is nothing left for restart() to do and bouncing again would be a + # second gratuitous outage. + applied = self.bridge_process.ensure_installed() + if applied is None: + self.logger.error( + "The Matter bridge was installed, but its LaunchAgent could not be " + "written — see the reason above. The package is on disk; fix that and reload " + "the plugin.") + return + # A running LaunchAgent does not pick up new files on disk, so a job + # left alone by ensure_installed is still executing the OLD version. + if applied is False and not self.bridge_process.restart(): + self.logger.error( + "The Matter bridge was installed but the restart onto the new version " + "FAILED — the old version may still be running. Check %s.", + os.path.join(self.bridge_process.log_dir, bridge_agent.BRIDGE_ERR_LOG)) + return + if self._stopping: + # Second check, deliberately: ensure_installed()/restart() are + # subprocess work that can outlast shutdown()'s 5s thread join, + # and revive_after_install is the one caller of start() that + # can genuinely race teardown — it would bootstrap launchctl + # and then log a scary "could not schedule bridge client run + # loop" over a plugin that is simply exiting. + return + # #154: a client HALTED on version skew is not the retry_now() case + # below — it declines the poke by design (a halt is fail-closed) and + # nothing revives it on its own, so the reinstall that was SUPPOSED + # to fix it left the user with no route back except a plugin reload. + # Tried first, and only ever replaces a client actually halted for + # that reason — see `revive_after_install`'s own reason gate. + revived = self.export_bridge is not None and self.export_bridge.revive_after_install() + if revived: + self.logger.info("Matter bridge installed and restarted onto the new " + "version — the halted connection has been replaced; " + "reconnecting now.") + else: + # Cuts the reconnect backoff short (issue #135): it grew to its + # 30s ceiling while the package was missing, and without this + # the user watches out the rest of that delay right after a + # success message. `poked` is truthful, not assumed: no bridge, + # no client, or a declining client (halted/closing/#154) all + # mean the poke never reached a run loop, so the log must not + # claim it is reconnecting. + poked = self.export_bridge is not None and self.export_bridge.retry_now() + if poked: + self.logger.info("Matter bridge installed and restarted onto the new " + "version — reconnecting now.") + else: + self.logger.info("Matter bridge installed and restarted onto the new " + "version — reload the plugin to reconnect.") + except Exception as exc: # noqa: BLE001 + self.logger.exception(exc) + self.logger.error( + "Install of the Matter bridge did not complete after the npm step — the " + "package may be installed but the agent was not restarted. See the trace above, " + "then retry Plugins ▸ Matter ▸ Install/update the Matter bridge.") diff --git a/tests/conftest.py b/tests/conftest.py index ac891c8..0a8fa1b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,6 +75,16 @@ def deviceDeleted(self, dev): # noqa: N802 self._record_base_call("deviceDeleted", dev) +#: Every module `plugin` composes. The mixins bind `indigo` at import time and MUST +#: be evicted alongside `plugin` so `importlib.reload(plugin)` re-imports them against +#: the mock installed for THIS test; `plugin_constants`/`pairing_page` bind no indigo +#: but are listed so the rule stays simply "everything plugin composes" (issue #146). +#: Eviction uses raising=False, so a typo'd entry would no-op silently — +#: test_plugin_module.py pins each entry to a real Server Plugin file. +_PLUGIN_MODULES = ("plugin", "plugin_constants", "pairing_page", "http_api_mixin", + "export_dialog_mixin", "pairing_menu_mixin", "server_menu_mixin") + + @pytest.fixture def mock_indigo_base(monkeypatch): """Install a minimal ``indigo`` module into ``sys.modules``. @@ -86,5 +96,6 @@ def mock_indigo_base(monkeypatch): indigo.PluginBase = _IndigoPluginBaseStub indigo.Dict = dict monkeypatch.setitem(sys.modules, "indigo", indigo) - monkeypatch.delitem(sys.modules, "plugin", raising=False) + for name in _PLUGIN_MODULES: + monkeypatch.delitem(sys.modules, name, raising=False) return indigo diff --git a/tests/test_export_menu.py b/tests/test_export_menu.py index 2f59a7d..dd076a5 100644 --- a/tests/test_export_menu.py +++ b/tests/test_export_menu.py @@ -417,7 +417,7 @@ def test_allowance_never_goes_negative_when_exports_exceed_the_cap( """Exported rows are uncapped by design; without the floor a shrunk limit would slice other_rows with a negative index and keep the wrong rows (a plain device that should have been dropped along with everything else).""" - monkeypatch.setattr(plugin_mod, "EXPORT_PICKER_LIMIT", 3) + monkeypatch.setattr(plugin_mod.export_dialog_mixin, "EXPORT_PICKER_LIMIT", 3) for device_id in range(500, 504): devices.add(RelayDevice(device_id, f"Export {device_id}")) plug.exports.upsert(ExportEntry(device_id, "onOffPlugInUnit")) diff --git a/tests/test_plugin_behaviour.py b/tests/test_plugin_behaviour.py index 1ce330e..c2f17fc 100644 --- a/tests/test_plugin_behaviour.py +++ b/tests/test_plugin_behaviour.py @@ -1197,7 +1197,7 @@ def test_install_handler_pins_node_and_reinstalls(plug, plugin_mod, monkeypatch) plug._stopping = False plug._restart_expected_until = 0.0 reinstalled = SimpleNamespace(ensure_installed=Mock(), restart=Mock()) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: reinstalled) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: reinstalled) plug._install_matter_server() # the node used for install is pinned so the LaunchAgent runs the same one assert plug.pluginPrefs["nodeBinDir"] == "/opt/homebrew/bin" @@ -1214,7 +1214,7 @@ def test_install_handler_aborts_and_logs_when_install_fails(plug, plugin_mod, mo plug.pluginPrefs = {"serverLocation": "local"} plug._stopping = False reinstalled = SimpleNamespace(ensure_installed=Mock()) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: reinstalled) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: reinstalled) plug._install_matter_server() assert "nodeBinDir" not in plug.pluginPrefs # no pin on failure reinstalled.ensure_installed.assert_not_called() # no reinstall on failure @@ -1225,7 +1225,7 @@ def test_install_handler_skips_state_mutation_when_stopping(plug, plugin_mod, mo plug.server_process = SimpleNamespace(install=lambda: True, resolved_bin_dir="/x") plug.pluginPrefs = {"serverLocation": "local"} plug._stopping = True # plugin tearing down - monkeypatch.setattr(plugin_mod, "ServerProcess", + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: SimpleNamespace(ensure_installed=Mock())) plug._install_matter_server() assert "nodeBinDir" not in plug.pluginPrefs # no pin/rewrite against teardown @@ -1274,7 +1274,7 @@ def test_install_handler_clean_removes_package_before_reinstall(plug, plugin_mod plug._stopping = False plug._restart_expected_until = 0.0 reinstalled = SimpleNamespace(ensure_installed=Mock(), restart=Mock(return_value=True)) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: reinstalled) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: reinstalled) plug._install_matter_server(clean=True) assert order == ["remove", "install"] # package deleted BEFORE reinstalling reinstalled.restart.assert_called_once() @@ -1311,7 +1311,7 @@ def test_install_handler_default_does_not_remove_package(plug, plugin_mod, monke plug.pluginPrefs = {"serverLocation": "local"} plug._stopping = False plug._restart_expected_until = 0.0 - monkeypatch.setattr(plugin_mod, "ServerProcess", + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: SimpleNamespace(ensure_installed=Mock(), restart=Mock(return_value=True))) plug._install_matter_server() # clean defaults to False @@ -1398,7 +1398,7 @@ def test_install_handler_logs_when_restart_fails(plug, plugin_mod, monkeypatch): plug._restart_expected_until = 0.0 plug._restart_notice_shown = False reinstalled = SimpleNamespace(ensure_installed=Mock(), restart=Mock(return_value=False)) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: reinstalled) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: reinstalled) plug._install_matter_server() plug.logger.error.assert_called() # restart failure surfaced, not silent success assert plug._restart_expected_until == 0.0 # window cleared so the crash diagnostic works @@ -1410,7 +1410,7 @@ def test_menu_restart_sets_window_on_success(plug, plugin_mod, monkeypatch): # ensure_installed can delete the developer's live LaunchAgent plist). rebuilt = SimpleNamespace(ensure_installed=Mock(return_value=False), restart=Mock(return_value=True)) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: rebuilt) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: rebuilt) plug.server_process = SimpleNamespace(restart=Mock(return_value=True)) plug.pluginPrefs = {"serverLocation": "local"} plug._restart_expected_until = 0.0 @@ -1423,7 +1423,7 @@ def test_menu_restart_sets_window_on_success(plug, plugin_mod, monkeypatch): def test_menu_restart_clears_window_on_failure(plug, plugin_mod, monkeypatch): rebuilt = SimpleNamespace(ensure_installed=Mock(return_value=False), restart=Mock(return_value=False)) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: rebuilt) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: rebuilt) plug.server_process = SimpleNamespace(restart=Mock(return_value=False)) plug.pluginPrefs = {"serverLocation": "local"} plug._restart_expected_until = 0.0 @@ -1451,7 +1451,7 @@ def _factory(prefs, logger): seen.update(prefs) return rebuilt - monkeypatch.setattr(plugin_mod, "ServerProcess", _factory) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", _factory) stale = SimpleNamespace(restart=Mock(return_value=True)) plug.server_process = stale plug.pluginPrefs = {"serverLocation": "local", "enableTestNetDcl": True} @@ -1471,7 +1471,7 @@ def test_menu_restart_does_not_double_restart_when_prefs_changed(plug, plugin_mo parent = Mock() parent.ensure_installed.return_value = True rebuilt = SimpleNamespace(ensure_installed=parent.ensure_installed, restart=parent.restart) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: rebuilt) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: rebuilt) plug.server_process = SimpleNamespace(restart=Mock()) # non-None: management is on plug.pluginPrefs = {"serverLocation": "local"} plug._restart_expected_until = 0.0 @@ -1488,7 +1488,7 @@ def test_menu_restart_stops_when_preflight_tore_the_plist_down(plug, plugin_mod, parent = Mock() parent.ensure_installed.return_value = None rebuilt = SimpleNamespace(ensure_installed=parent.ensure_installed, restart=parent.restart) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: rebuilt) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: rebuilt) plug.server_process = SimpleNamespace(restart=Mock()) # non-None: management is on plug.pluginPrefs = {"serverLocation": "local"} plug._restart_expected_until = 0.0 @@ -1505,7 +1505,7 @@ def test_menu_restart_clears_window_when_ensure_installed_raises(plug, plugin_mo # is suppressed for 30s while the server is down. rebuilt = SimpleNamespace(ensure_installed=Mock(side_effect=OSError("boom")), restart=Mock(return_value=True)) - monkeypatch.setattr(plugin_mod, "ServerProcess", lambda *a, **k: rebuilt) + monkeypatch.setattr(plugin_mod.server_menu_mixin, "ServerProcess", lambda *a, **k: rebuilt) plug.server_process = SimpleNamespace(restart=Mock()) # non-None: management is on plug.pluginPrefs = {"serverLocation": "local"} plug._restart_expected_until = 0.0 diff --git a/tests/test_plugin_module.py b/tests/test_plugin_module.py index 0d683f6..f92c684 100644 --- a/tests/test_plugin_module.py +++ b/tests/test_plugin_module.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import re import xml.etree.ElementTree as ET from pathlib import Path @@ -577,3 +578,32 @@ def test_no_current_doc_names_a_retired_menu_item(doc): text = " ".join(doc.read_text(encoding="utf-8").split()) hits = [name for name in RETIRED_MENU_NAMES if name in text] assert not hits, f"{doc.name} still references retired menu name(s): {hits}" + + +#: Matches `import plugin` / `from plugin import ...` but not `plugin_constants` +#: or any other `plugin_`-prefixed module — the trailing `\b` fails right after +#: "plugin" when the next character is the word-character `_`. +_BACK_IMPORT_RE = re.compile(r"^\s*(import plugin\b|from plugin\b\s+import)") + + +def test_no_mixin_module_imports_plugin(): + """The dependency arrows point away from plugin.py — a back-import would + make the extraction a cycle waiting to happen (issue #146).""" + for path in SERVER_PLUGIN.rglob("*.py"): + if path.name == "plugin.py": + continue + src = path.read_text(encoding="utf-8") + offenders = [line for line in src.splitlines() if _BACK_IMPORT_RE.match(line)] + assert not offenders, f"{path.name} back-imports plugin: {offenders}" + + +def test_plugin_modules_eviction_tuple_matches_real_files(): + """conftest's ``_PLUGIN_MODULES`` eviction uses ``raising=False``, so a + typo'd or renamed entry would silently no-op forever — pin each entry to a + real module file (issue #146).""" + from conftest import _PLUGIN_MODULES + + for name in _PLUGIN_MODULES: + assert (SERVER_PLUGIN / f"{name}.py").is_file(), ( + f"conftest._PLUGIN_MODULES entry {name!r} has no matching Server Plugin file" + )