Skip to content

feat(export): E3b — device sync, export handlers, bridge client wiring - #124

Merged
simons-plugins merged 2 commits into
mainfrom
feat/e3b-plugin-export-wiring
Aug 5, 2026
Merged

feat(export): E3b — device sync, export handlers, bridge client wiring#124
simons-plugins merged 2 commits into
mainfrom
feat/e3b-plugin-export-wiring

Conversation

@simons-plugins

@simons-plugins simons-plugins commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

Second half of milestone E3 — the full outbound pipeline: Indigo device changes → bridge node → ecosystems, and ecosystem commands → indigo.device.*.

  • export_handlers.py — per-role table (states_for / diff / dispatch) for the five E3 roles; real Indigo API only (indigo.dimmer.setColorLevels with Kelvin whiteTemperature riding the current white level — there is no setColorTemp); hue ±1° tolerance in diff
  • export_bridge.py — owns the BridgeClient; endpoint provider re-classifies every store entry at build (the store is not the guard); E4-roled entries skip-with-warning; fire-and-forget pushes; on_command dispatch; attach-refused/halt surfacing; watchdog branch
  • plugin.pysubscribeToChanges only on first export (it's server-wide and a one-way door — no unsubscribe exists); deviceUpdated fast path = one attribute load + one frozenset hash for non-exported devices (asserted by a test that makes classify/handler_for raise if touched); rename→label, disable→reachable, delete→store+remove; empty↔non-empty client lifecycle from the dialog
  • Attach timeout scalingmax(8.0, 2.0 + 0.15 × endpoints): the zero-protocol-change answer to E3a's pacing×count interaction

Tests

+151 (suite 1784 green). pylint 9.33 (baseline 9.30). XAC10 green. PluginVersion 2026.7.28.

Live E2E

The report's jarvis runbook follows after review: fresh bundle install (jarvis is generations behind), hand-started bridge node, pick a plug in Manage Matter Exports, pair Apple Home, verify XAC4/XAC7/XAC8 + reconnect-without-duplicates.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S

Summary by CodeRabbit

  • New Features

    • Added outbound Matter export support for Indigo devices, including live syncing of device changes and removals.
    • Expanded support for multiple light types, including on/off, dimmable, and color-capable devices.
    • Improved device status reporting with clearer connection and compatibility information.
  • Bug Fixes

    • Improved export reliability during startup, attachment, and recovery.
    • Reduced color drift and improved handling of low-saturation color updates.
    • Better handling of failed updates so device state stays in sync.

Wires the whole outbound pipeline: Indigo device changes → the bridge node,
and ecosystem commands → Indigo devices (PRD-indigo-matter-export §5.4).

export_handlers.py — the per-ROLE outbound handler table, the mirror of
matter_handlers/ (inbound is keyed by cluster; outbound there is no cluster,
only a user-declared role). Three methods per role: states_for, diff and
dispatch. E3 roles only — handler_for answers None for the E4 roles the §5.1
dialog can already write into the allow-list, because an unknown role fails
the WHOLE attach on the node. Hue diffs carry a ±1° tolerance (Matter's 0-254
hue round-trips ±1°); saturation deliberately has none.

export_bridge.py — the engine. Owns the BridgeClient and starts one only
while the allow-list is non-empty (XG5), stopping it via the deliberate §3.1
replace_all attach when it empties. The endpoint provider re-runs
export_catalog.classify on EVERY attach — the store is a past user
declaration, not a guard — and skips-with-warning anything deleted, excluded,
re-typed or E4-roled. State pushes are fire-and-forget; on_command dispatches
indigo.* on the loop thread, the discipline device_sync.apply_states already
uses.

plugin.py — deviceUpdated/deviceDeleted, the _exports_changed seam, the
watchdog branch and shutdown ordering. subscribeToChanges is conditional
(it is a server-wide firehose and the default posture is inert) and one-way
(no unsubscribe exists in the canonical reference); deviceUpdated's guard is
a plain frozenset attribute, so a non-exported device costs one hash lookup.

bridge_client.py — attach_timeout_for(n) = max(8.0, 2.0 + 0.15n). The node
answers an attach only after its ~100ms-paced removals, so ~80 endpoints
spend 8s in pacing alone and the flat deadline timed out on exactly the
databases that need export most. Zero protocol change.

A role change in the dialog is now remove+re-add (§4.1 refuses it in place)
and exportStatus warns that the accessory loses its Home-app name and room.

bridgeWsPort stays out of PluginConfig.xml: PRD §5.5's Export section is a
whole panel and belongs with E6/E7.

Tests: 1784 (was 1633). pylint 9.33 (was 9.30).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds outbound E3 export handlers and an ExportBridge, updates attach timeout handling, and wires persisted exports into Indigo device callbacks, bridge lifecycle operations, status reporting, and health checks. It also adds comprehensive tests, documentation, fixtures, and a plugin version update.

Changes

Role-based export handlers

Layer / File(s) Summary
Handler contracts and role implementations
indigo-matter.indigoPlugin/Contents/Server Plugin/export_handlers.py
Adds E3 handlers for on/off, dimming, color temperature, and extended color. Handlers provide state diffs, command dispatch, validation, unit conversion, and bridgeability checks.
Handler validation and conversion tests
tests/test_export_handlers.py
Tests role registration, state extraction, dispatch, bounds checking, color-mode exclusivity, hue tolerances, and RGB conversions.

Bridge lifecycle and plugin integration

Layer / File(s) Summary
Attach deadlines and endpoint synchronization
indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py, indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py, tests/fakes.py, tests/test_export_bridge.py
Adds endpoint-count-based attach deadlines, endpoint reclassification, lifecycle management, asynchronous updates, CRUD operations, command dispatch, diagnostics, watchdog handling, and failure recovery.
Plugin callbacks and lifecycle wiring
indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py, tests/conftest.py, tests/test_export_wiring.py, tests/test_export_menu.py, tests/test_plugin_behaviour.py
Connects startup, allow-list changes, device updates and deletions, role replacement, status reporting, shutdown, and health checks to ExportBridge.
Documentation and supporting updates
CLAUDE.md, docs/HANDOVER.md, indigo-matter.indigoPlugin/Contents/Info.plist, pyproject.toml, tests/test_ws_json_client.py
Documents the export architecture and operational behavior, updates the plugin version, adjusts the attribute limit, and updates attach-timeout test setup.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Indigo
  participant Plugin
  participant ExportBridge
  participant BridgeClient
  participant BridgeNode
  Plugin->>ExportBridge: synchronize persisted exports
  ExportBridge->>Indigo: fetch and classify devices
  ExportBridge->>BridgeClient: attach endpoint specifications
  BridgeClient->>BridgeNode: attach endpoints
  Indigo->>Plugin: report device update
  Plugin->>ExportBridge: forward device update
  ExportBridge->>BridgeClient: push state diff
  BridgeNode->>ExportBridge: send device command
  ExportBridge->>Indigo: dispatch command
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: E3b device synchronization, export handlers, and bridge client integration.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/e3b-plugin-export-wiring

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The attach deadline, the colour handlers, and every path that could stop
exporting without saying so.

X1 the un-export deadline is sized by the REMOVALS. attach latency is
dominated by the node's ~100ms-paced removals (§3.3), not by what we send
— and _replace_all_then_stop sends nothing while removing everything, so
it was taking the 8s floor. Over ~60 exports that times out mid-reconcile,
warns that accessories "may linger", then close() yanks the socket out
from under a node that was working. exports_changed captures the size
before it empties the store; attach_timeout_for documents the asymmetry.

X2 _set_color_temp clamps mireds to [153, 500] before converting, and
rounds rather than truncates. The node clamps too, but that is the other
process: unclamped, 1 mired writes 1,000,000 K.
X3 hue is omitted below saturation 20 (SATURATION_HUE_FLOOR). Indigo
recovers hue from three integer 0-100 channels, and the round-trip error
is 30° at sat 1 and 180° at sat 0 — far past the ±1° tolerance, so every
pastel change pushed a spurious set_state and the Home wheel jumped. The
measured error table is in the constant's docstring.
X4 colour-mode exclusivity: _set_color zeroes whiteLevel, _set_color_temp
zeroes RGB, each only where the device has that channel; a device with no
white channel skips setColorTemp entirely rather than inventing 100.
Matter colorMode is one-of and the node already picks hue/sat over CT.
*** RGBW driver behaviour needs the live E2E — see docs/HANDOVER.md. ***
X5 endpoint_specs (N blocking indigo.devices[id] IPC copies) moves to an
executor; the loop is shared with the inbound matter-server client.
X6 on_command's thread-safety claim is now "unverified from docs; single
seam so it can move to an executor" — device_sync's precedent covers state
writes on our own devices, not device commands.

Silent failures: not-attached drops name the device at debug and warn once
per streak with the real reason when halted/in recovery (F1); run-loop and
un-export scheduling failures warn and name what was lost (F2); a failed
dispatch fires a corrective set_state so the ecosystem snaps back to truth
(F5); the per-event catches in plugin.deviceUpdated and
export_bridge.device_updated name the device and latch per streak (F6/F7);
non-terminal attach refusals report once per streak (F8c); the skip dedupe
key no longer contains str(exc) (F8b); the watchdog's halted/recovery
branches latch (F9); deviceDeleted refreshes the id cache in a finally
(F9a); the dialog says when the bridge is halted, rebuilding, or not
connected, with load_error still leading (F10); _un_export closes the
socket in a finally so CancelledError cannot leak it (F4).

Tests: startup with a non-empty allow-list (T1, verified by deleting the
startup _exports_changed() call); the fast-path guard now fails on any
store ATTRIBUTE access, not just a call (T2); MIREDS_MAX clamp, the n=41
timeout crossover, setLevel 0, re-enable -> set_reachable(True), E4-role
silent skip, attach-failure still closes (T3). 1826 pass, pylint 9.35.
Also fixes the pre-existing E0602 (Optional undefined in plugin.py).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (5)
tests/test_export_handlers.py (2)

128-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add negative-path coverage for onOff arguments.

TestOnOff covers True, False, and an unknown command. It does not cover a missing or non-boolean value. That is the path where _on_off currently turns the device off instead of refusing the frame (see the comment on export_handlers.py lines 222-227).

Add the case with the handler fix so the behaviour is pinned:

💚 Proposed test
     def test_an_unknown_command_is_refused_not_guessed(self, handlers, mock_indigo_base):
         dev = RelayDevice(1, "Plug")
         assert handlers.handler_for("onOffPlugInUnit").dispatch("setLevel", {"level": 5}, dev) \
             is False
         mock_indigo_base.device.turnOn.assert_not_called()
+
+    `@pytest.mark.parametrize`("args", [{}, {"value": None}, {"value": "on"}])
+    def test_a_malformed_on_off_is_refused_not_read_as_off(self, handlers, mock_indigo_base,
+                                                           args):
+        """A frame we cannot read must not be the thing that switches the load off."""
+        with pytest.raises(ValueError):
+            handlers.handler_for("onOffPlugInUnit").dispatch("onOff", args, RelayDevice(1, "P"))
+        mock_indigo_base.device.turnOff.assert_not_called()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_export_handlers.py` around lines 128 - 144, Add negative-path
tests in TestOnOff for onOff requests with a missing value and with a
non-boolean value, asserting dispatch returns False and neither turnOn nor
turnOff is called. Update the _on_off handler in export_handlers.py to reject
these invalid arguments instead of treating them as a request to turn the device
off, while preserving the existing True and False behavior.

443-448: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Compare hue on the circle, not on the line.

abs(back - hue) is not wrap-safe. At hue 359 a one-degree drift to 0 reports an error of 359°, which hides the real cause. The test passes today because the conversion is exact at that boundary.

♻️ Proposed change
     red, green, blue = handlers.hue_saturation_to_rgb(hue, 100)
     back, _saturation = handlers.rgb_to_hue_saturation(red, green, blue)
-    assert abs(back - hue) <= handlers.HUE_TOLERANCE_DEGREES
+    drift = min((back - hue) % 360, (hue - back) % 360)
+    assert drift <= handlers.HUE_TOLERANCE_DEGREES
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_export_handlers.py` around lines 443 - 448, Update
test_hue_survives_a_round_trip_through_rgb to compare hue values using the
shortest circular distance, normalizing the difference across the 0/360 boundary
before applying handlers.HUE_TOLERANCE_DEGREES. Preserve the existing
parameterized cases and tolerance assertion.
indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py (1)

436-445: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

_recreate performs Indigo IPC on the loop thread.

_spec_for calls self._device_getter, which is a blocking indigo.devices[id] copy. Inside _recreate that call runs on the asyncio loop, and this loop is shared with the inbound matter-server client. The module docstring on lines 38-43 sets the rule and names on_command as "the single remaining seam"; replace adds a second one that the docstring does not cover.

upsert already shows the correct shape: it resolves the spec on the calling thread and schedules only the coroutine. The spec does not depend on the removal, so hoist it.

♻️ Proposed change
         client = self._live_client("the role change", device_id)
         if client is None:
             return
+        entry = self._store.get(device_id)
+        spec = self._spec_for(entry) if entry is not None else None
 
         async def _recreate() -> None:
             await client.remove_endpoint(device_id)
-            entry = self._store.get(device_id)
-            if entry is None:
-                return
-            spec = self._spec_for(entry)
             if spec is not None:
                 await client.upsert_endpoint(spec)

This also removes the re-read of the store from the loop thread, which the store's RLock currently has to serialise against the Indigo thread.

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

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py around
lines 436 - 445, Update the replace/recreation flow around _recreate so
_spec_for and the associated store lookup execute before scheduling the
coroutine, on the calling thread. Pass the resolved spec into _recreate, leaving
the coroutine responsible only for client.remove_endpoint and conditional
client.upsert_endpoint; preserve the existing behavior when no entry or spec
exists.
indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py (1)

363-379: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider bounding the endpoint-provider call.

_gather_endpoints awaits the executor with no deadline. The provider does one synchronous indigo.devices[id] IPC per exported device. If IndigoServer stops answering, the handshake blocks at Line 219 forever: _mark_connected is never reached, no attach is sent, and the run loop cannot fall through to its reconnect backoff. The client then reports "not connected" without a bounded failure.

An asyncio.wait_for around the executor call converts that hang into a normal reconnect. Note that the worker thread itself stays blocked, so pick a deadline that is generous relative to the export count.

♻️ Proposed bound on the provider read
         loop = asyncio.get_running_loop()
-        return await loop.run_in_executor(None, self._endpoint_provider)
+        return await asyncio.wait_for(
+            loop.run_in_executor(None, self._endpoint_provider),
+            timeout=ENDPOINT_READ_TIMEOUT,
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/bridge_client.py around
lines 363 - 379, Update _gather_endpoints to wrap the executor-backed
self._endpoint_provider call in asyncio.wait_for with a generous timeout
appropriate for the number of exported devices. Allow the timeout to propagate
as a normal endpoint-gathering failure so the reconnect loop can apply its
existing backoff, while preserving the executor offloading and synchronous
provider interface.
tests/test_export_bridge.py (1)

125-125: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Ruff flags × (MULTIPLICATION SIGN) in two new prose lines. Both lines were added in this PR and use × where Ruff's ambiguous-character rules expect x. If RUF002 and RUF003 are enforced in CI, both fail.

  • tests/test_export_bridge.py#L125-L125: replace pacing×count with pacing x count in the class docstring (RUF002).
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py#L59-L59: replace 0.1s × removals with 0.1s x removals in the comment (RUF003).

If these rules are intentionally suppressed for this repository, no change is needed.

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

In `@tests/test_export_bridge.py` at line 125, Replace the ambiguous
multiplication sign in both prose sites so Ruff’s ambiguous-character checks
pass: update the class docstring in tests/test_export_bridge.py at lines 125-125
from pacing×count to pacing x count, and update the comment in
indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py at lines
59-59 from 0.1s × removals to 0.1s x removals; keep the surrounding wording
unchanged.

Source: Linters/SAST tools

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

Inline comments:
In `@CLAUDE.md`:
- Line 51: Update the CLAUDE.md entry for export_bridge.py to remove the
assertion that on_command dispatches indigo.* from the loop thread as
established discipline. Describe this behavior as an unverified assumption or
explicitly defer to the qualification in export_bridge.py and HANDOVER.md, while
preserving the surrounding lifecycle and attach-provider documentation.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py:
- Around line 156-164: Update the stop method to store client.close() in a
coroutine variable, then separately call _runtime.submit and result so only
submit failures close the coroutine with coro.close(). Preserve the existing
shutdown logging and idempotent behavior, and do not close a coroutine after it
has been successfully submitted.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_handlers.py:
- Around line 222-227: Update _on_off to validate args["value"] as a boolean
before acting, raising ValueError for missing or malformed values so
export_bridge.on_command handles the correction path; replace the bare "value"
literal with the module’s established constant for the §4.2 argument name, or
define and reuse one if none exists.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/plugin.py:
- Around line 1397-1412: Update _export_bridge_note to capture bridge.client
once before checking bridge.active, then return the existing empty note when the
captured client is unavailable. Use that captured client for the halted,
recovery, and attached checks so a client unset between property reads cannot
cause an AttributeError.

In `@tests/test_export_wiring.py`:
- Line 496: Replace the ineffective uninstall assertion in the shutdown test
with assertions for the actual contract: verify shutdown calls stop and does not
invoke any bridge teardown method. Configure the bridge mock with a spec based
on the real ExportBridge so unexpected uninstall calls fail immediately.

---

Nitpick comments:
In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/bridge_client.py:
- Around line 363-379: Update _gather_endpoints to wrap the executor-backed
self._endpoint_provider call in asyncio.wait_for with a generous timeout
appropriate for the number of exported devices. Allow the timeout to propagate
as a normal endpoint-gathering failure so the reconnect loop can apply its
existing backoff, while preserving the executor offloading and synchronous
provider interface.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py:
- Around line 436-445: Update the replace/recreation flow around _recreate so
_spec_for and the associated store lookup execute before scheduling the
coroutine, on the calling thread. Pass the resolved spec into _recreate, leaving
the coroutine responsible only for client.remove_endpoint and conditional
client.upsert_endpoint; preserve the existing behavior when no entry or spec
exists.

In `@tests/test_export_bridge.py`:
- Line 125: Replace the ambiguous multiplication sign in both prose sites so
Ruff’s ambiguous-character checks pass: update the class docstring in
tests/test_export_bridge.py at lines 125-125 from pacing×count to pacing x
count, and update the comment in indigo-matter.indigoPlugin/Contents/Server
Plugin/bridge_client.py at lines 59-59 from 0.1s × removals to 0.1s x removals;
keep the surrounding wording unchanged.

In `@tests/test_export_handlers.py`:
- Around line 128-144: Add negative-path tests in TestOnOff for onOff requests
with a missing value and with a non-boolean value, asserting dispatch returns
False and neither turnOn nor turnOff is called. Update the _on_off handler in
export_handlers.py to reject these invalid arguments instead of treating them as
a request to turn the device off, while preserving the existing True and False
behavior.
- Around line 443-448: Update test_hue_survives_a_round_trip_through_rgb to
compare hue values using the shortest circular distance, normalizing the
difference across the 0/360 boundary before applying
handlers.HUE_TOLERANCE_DEGREES. Preserve the existing parameterized cases and
tolerance assertion.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 14c90224-6e86-495a-bf23-cdc9ee67679c

📥 Commits

Reviewing files that changed from the base of the PR and between c9a2a66 and 139a7fa.

📒 Files selected for processing (16)
  • CLAUDE.md
  • docs/HANDOVER.md
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/export_handlers.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py
  • pyproject.toml
  • tests/conftest.py
  • tests/fakes.py
  • tests/test_export_bridge.py
  • tests/test_export_handlers.py
  • tests/test_export_menu.py
  • tests/test_export_wiring.py
  • tests/test_plugin_behaviour.py
  • tests/test_ws_json_client.py

Comment thread CLAUDE.md
| `export_store.py` | The export allow-list (PRD-indigo-matter-export §5.1): `ExportEntry` (device id + role + name override + options) and an `RLock`'d store persisted as ONE JSON string in `pluginPrefs["matterExports"]`, schema-versioned. A blob it cannot parse is moved aside to `matterExports.corrupt` and the store starts empty — user config is never silently discarded |
| `export_catalog.py` | Indigo device → eligible Matter roles, or an `Excluded(reason)` shown in the picker (PRD §5.2, XAC9). The loop guard (XNG3/XAC6) is `pluginId` and nothing else, checked before any type reasoning. Type dispatch walks the IOM **class-name chain**, not `isinstance` — the indigo module is a MagicMock under test |
| `export_handlers.py` | The **outbound** handler table, keyed by §4.2 **role** (the inbound registry is keyed by cluster; outbound there is no cluster, only a user-declared role) — `states_for` / `diff` / `dispatch` per role. E3 roles only: plug, on/off light, dimmable, colour-temp, extended colour. `handler_for` returns `None` for the E4 roles the §5.1 dialog can already put in the allow-list. Hue diffs carry a ±1° tolerance (Matter's 0–254 hue round-trips ±1°); saturation deliberately has none |
| `export_bridge.py` | The outbound engine: owns the `BridgeClient` and everything the Indigo callbacks *mean* for it. The client exists **only** while the allow-list is non-empty (XG5) and starts/stops on the dialog's empty↔non-empty transitions. The attach endpoint provider **re-runs `export_catalog.classify` on every attach** — the store is a past user declaration, not a guard — and skips-with-warning anything deleted/excluded/re-typed or carrying an E4 role (an unknown role fails the *whole* attach). State pushes are fire-and-forget onto the loop; `on_command` dispatches `indigo.*` from the loop thread, the same discipline `device_sync.apply_states` already uses |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Line 51 contradicts the code and HANDOVER.md on the loop-thread claim.

The entry ends with "on_command dispatches indigo.* from the loop thread, the same discipline device_sync.apply_states already uses". The two other places that describe this say the opposite:

  • export_bridge.py lines 453-459: issuing indigo.* device commands from a non-Indigo thread is "unverified from the docs", and the apply_states precedent "covers state writes on our own devices, which is not the same claim".
  • HANDOVER.md line 508: "Not 'existing house discipline'".

CLAUDE.md is the architecture map a contributor reads first. Presenting an acknowledged unverified assumption as settled discipline is how the hedge gets dropped in the next module.

📝 Proposed wording
-State pushes are fire-and-forget onto the loop; `on_command` dispatches `indigo.*` from the loop thread, the same discipline `device_sync.apply_states` already uses |
+State pushes are fire-and-forget onto the loop; `on_command` dispatches `indigo.*` from the loop thread, which is **unverified from the docs** (`device_sync.apply_states` only sets a precedent for state *writes* on our own devices). It is kept as a single seam so it can move to `run_in_executor` if the loop is seen stalling |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| `export_bridge.py` | The outbound engine: owns the `BridgeClient` and everything the Indigo callbacks *mean* for it. The client exists **only** while the allow-list is non-empty (XG5) and starts/stops on the dialog's empty↔non-empty transitions. The attach endpoint provider **re-runs `export_catalog.classify` on every attach** — the store is a past user declaration, not a guard — and skips-with-warning anything deleted/excluded/re-typed or carrying an E4 role (an unknown role fails the *whole* attach). State pushes are fire-and-forget onto the loop; `on_command` dispatches `indigo.*` from the loop thread, the same discipline `device_sync.apply_states` already uses |
| `export_bridge.py` | The outbound engine: owns the `BridgeClient` and everything the Indigo callbacks *mean* for it. The client exists **only** while the allow-list is non-empty (XG5) and starts/stops on the dialog's empty↔non-empty transitions. The attach endpoint provider **re-runs `export_catalog.classify` on every attach** — the store is a past user declaration, not a guard — and skips-with-warning anything deleted/excluded/re-typed or carrying an E4 role (an unknown role fails the *whole* attach). State pushes are fire-and-forget onto the loop; `on_command` dispatches `indigo.*` from the loop thread, which is **unverified from the docs** (`device_sync.apply_states` only sets a precedent for state *writes* on our own devices). It is kept as a single seam so it can move to `run_in_executor` if the loop is seen stalling |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` at line 51, Update the CLAUDE.md entry for export_bridge.py to
remove the assertion that on_command dispatches indigo.* from the loop thread as
established discipline. Describe this behavior as an unverified assumption or
explicitly defer to the qualification in export_bridge.py and HANDOVER.md, while
preserving the surrounding lifecycle and attach-provider documentation.

Comment on lines +156 to +164
def stop(self, timeout: float = 4.0) -> None:
"""Close the client. Idempotent; never raises at shutdown."""
client, self.client = self.client, None
if client is None:
return
try:
self._runtime.submit(client.close()).result(timeout=timeout)
except Exception as exc: # pylint: disable=broad-except
self._logger.debug("bridge client close error: %s", exc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Close the coroutine when submit fails in stop.

client.close() builds a coroutine object before self._runtime.submit receives it. If submit raises — the likely case at shutdown, when the runtime is already stopping — the coroutine is never awaited. Python then emits a RuntimeWarning: coroutine 'close' was never awaited, and the socket is left to the garbage collector.

_fire already handles this exact case with coro.close() on line 641. Apply the same treatment here.

🐛 Proposed fix
     def stop(self, timeout: float = 4.0) -> None:
         """Close the client. Idempotent; never raises at shutdown."""
         client, self.client = self.client, None
         if client is None:
             return
+        coro = client.close()
         try:
-            self._runtime.submit(client.close()).result(timeout=timeout)
+            self._runtime.submit(coro).result(timeout=timeout)
         except Exception as exc:  # pylint: disable=broad-except
+            coro.close()
             self._logger.debug("bridge client close error: %s", exc)

Note that coro.close() on an already-scheduled coroutine is unsafe, so this fix is only correct while the sole failure mode is submit itself raising. If result(timeout=...) can also raise here, split the two calls so only the submit failure closes the coroutine.

🧰 Tools
🪛 Ruff (0.16.1)

[warning] 163-163: Do not catch blind exception: Exception

(BLE001)

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

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py around
lines 156 - 164, Update the stop method to store client.close() in a coroutine
variable, then separately call _runtime.submit and result so only submit
failures close the coroutine with coro.close(). Preserve the existing shutdown
logging and idempotent behavior, and do not close a coroutine after it has been
successfully submitted.

Comment on lines +222 to +227
@staticmethod
def _on_off(args: dict, dev: Any) -> None:
if bool(args.get("value")):
indigo.device.turnOn(dev)
else:
indigo.device.turnOff(dev)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

_on_off turns the device off when value is missing or malformed.

bool(args.get("value")) maps an absent key, None, and a non-boolean to "off". Every other command in this module rejects a bad argument: _set_level, _set_color_temp and _set_color all raise ValueError, which export_bridge.on_command catches, logs against the device, and answers with a corrective set_state. A malformed onOff frame instead switches the load off silently.

Also, "value" is the only §4.2 argument name in this module spelled as a bare literal. _set_level uses STATE_LEVEL and _set_color uses STATE_HUE/STATE_SATURATION, so those are pinned by the zoo test. A typo in "value" would be an every-command-turns-off bug that no test catches.

🐛 Proposed fix: validate the argument and pin its name
 COMMAND_ON_OFF = "onOff"
+#: The §4.2 argument name carried by ``onOff``.
+ARG_VALUE = "value"
     `@staticmethod`
     def _on_off(args: dict, dev: Any) -> None:
-        if bool(args.get("value")):
+        value = args.get(ARG_VALUE)
+        if not isinstance(value, bool):
+            raise ValueError(f"onOff without a boolean value: {args!r}")
+        if value:
             indigo.device.turnOn(dev)
         else:
             indigo.device.turnOff(dev)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_handlers.py around
lines 222 - 227, Update _on_off to validate args["value"] as a boolean before
acting, raising ValueError for missing or malformed values so
export_bridge.on_command handles the correction path; replace the bare "value"
literal with the module’s established constant for the §4.2 argument name, or
define and reuse one if none exists.

Comment on lines +1397 to +1412
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.")
if not client.attached:
return " Not connected to the bridge node — exports are not live."
return ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Locate every write to ExportBridge.client and every caller of stop()/exports_changed().
set -euo pipefail

fd -e py . | xargs rg -n -C 6 'self\.client\s*=|def stop\(|def exports_changed\(|\.exports_changed\(\)|\.stop\('

Repository: simons-plugins/indigo-matter

Length of output: 16797


🏁 Script executed:

#!/bin/bash
set -euo pipefail

pwd
git ls-files | fd -e py . | sed -n '1,80p'
printf '\n--- plugin files with client references ---\n'
rg -n -C 7 'self\.client\s*=|def stop\(|def exports_changed\(|\.exports_changed\(\)|\.stop\(|_export_summary|get_menu_action_config_ui_values|export_bridge' --glob '*.py' .

Repository: simons-plugins/indigo-matter

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- export_bridge.py relevant methods ---'
sed -n '120,230p' 'indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py'| cat -n

printf '%s\n' '--- plugin.py _export_summary and dialog config ---'
sed -n '1360,1430p' 'indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py'| cat -n

printf '%s\n' '--- exports changed callers ---'
rg -n -C 4 'exports_changed\(' --glob '*.py'

Repository: simons-plugins/indigo-matter

Length of output: 10743


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n 'exports_changed|\.stop\(' --glob '*.py' .
printf '%s\n' '--- plugin methods around export dialogs ---'
rg -n -C 7 'MENU_MANAGE_EXPORTS|get_menu|exportStatus|manageMatterExports|dialog' 'indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py'

Repository: simons-plugins/indigo-matter

Length of output: 29918


Capture bridge.client before checking active.

active and client both read self.client. If an un-export unsets the client after active returns true, _export_bridge_note() reads None and raises AttributeError. Capture client = bridge.client once before the active check and handle None.

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

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/plugin.py around lines
1397 - 1412, Update _export_bridge_note to capture bridge.client once before
checking bridge.active, then return the existing empty note when the captured
client is unavailable. Use that captured client for the halted, recovery, and
attached checks so a client unset between property reads cannot cause an
AttributeError.

plug._stopping = False
plug.shutdown()
bridge.stop.assert_called_once()
assert not hasattr(bridge, "uninstall") or not bridge.uninstall.called

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This assertion can never fail.

bridge is a Mock, so hasattr(bridge, "uninstall") is always True and bridge.uninstall auto-creates a child mock whose .called is always False. Both operands of the or are therefore satisfied on every run. The test states it protects PM-B — "a plugin reload must not un-pair anyone's ecosystems" — but it asserts nothing about that.

Assert on the real contract instead: shutdown must call stop and must not call any teardown method on the bridge. Mock(spec=...) against the actual ExportBridge would also make an accidental uninstall call raise.

💚 Proposed fix
         plug.shutdown()
         bridge.stop.assert_called_once()
-        assert not hasattr(bridge, "uninstall") or not bridge.uninstall.called
+        # `stop` is the ONLY thing shutdown may do to the bridge — anything that
+        # tears the agent down would un-pair every ecosystem (PM-B).
+        assert [name for name, *_ in bridge.method_calls] == ["stop"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert not hasattr(bridge, "uninstall") or not bridge.uninstall.called
plug.shutdown()
bridge.stop.assert_called_once()
# `stop` is the ONLY thing shutdown may do to the bridge — anything that
# tears the agent down would un-pair every ecosystem (PM-B).
assert [name for name, *_ in bridge.method_calls] == ["stop"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_export_wiring.py` at line 496, Replace the ineffective uninstall
assertion in the shutdown test with assertions for the actual contract: verify
shutdown calls stop and does not invoke any bridge teardown method. Configure
the bridge mock with a spec based on the real ExportBridge so unexpected
uninstall calls fail immediately.

@simons-plugins
simons-plugins merged commit df5053c into main Aug 5, 2026
3 checks passed
@simons-plugins
simons-plugins deleted the feat/e3b-plugin-export-wiring branch August 5, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant