Four layers, stacked like strata
- 1The unit suites — 2,417 pytest tests for the plugin, 405 for the bridge node.
Catches logic, protocol parsing, reconcile and state machinery — on both sides of the loopback socket.
- 2The device zoo — contract invariants over cluster combinations.
Catches whole classes of device-mapping bugs at once.
- 3The virtual fleet — 15+ genuine Matter devices, built from matter.js.
Catches real commissioning, subscriptions and command round-trips.
- 4Real hardware, live server — shipping devices on the production path.
Catches everything else — including what Indigo itself actually does.
The plugin has a second half — the Node process that publishes Indigo devices back out as a Matter bridge (№ 3) — and it is tested on the same principle, in its own language, against a real matter.js server. That’s § 04.
The unit suite
cd indigo-matter && python3 -m pytest -q # 2,417 tests
No Indigo server and no matter-server needed: the indigo
module is mocked (tests/conftest.py) and matter-server is faked at the
WebSocket frame layer (tests/fakes.py), so the real client, reconcile and
dispatch code paths run against recorded protocol shapes.
tests/test_golden_real.py pins parsing against frames captured from a live
matter-server — a wire-format drift fails loudly.
The device zoo
Matter devices arrive with cluster combinations nobody predicted — the spec
requires some surprises. A Matter 1.2+ fan must expose OnOff alongside FanControl.
An Extended Color Light need only implement XY and colour temperature — HueSaturation is
optional. The zoo (tests/test_device_zoo.py) is a contract harness for
exactly this: a table of synthetic nodes run through the real handler
registry, with five invariants asserted over every entry:
- each endpoint maps to exactly the expected Indigo device types;
- at most one actuator device per endpoint — two would fight over one physical device;
- every spec’s device type exists in
Devices.xml; - every seeded initial state is declared or built-in;
- every sensor carries explicit display props — Indigo derives the device-list display from creation props, never from XML statics (issue #56).
Track record
Caught — first run Caught — same week
On its very first run the zoo found a colour light without LevelControl producing a duplicate relay — a case nobody had predicted. Days later it caught the button handler declaring display props without merging them into its creation spec. Invariants find the bugs you didn’t think to write a test for.
ZOO entry (cluster ids in decimal inside the
"ep/cluster/attr" keys) with the device types you expect — every invariant
then runs over it automatically. This is the intended first response to any “weird
device” bug report.
The virtual fleet
Real Matter devices, minus the shopping. The matter.js examples plus small custom scripts compose genuine commissionable Matter nodes on the LAN — the plugin and matter-server cannot tell them from shipping hardware. The development fleet runs fifteen-plus devices:
- relay
- dimmer
- extended colour light
- temp / humidity
- thermostat
- fan
- window covering
- door lock
- valve
- button
- smoke / CO
- air quality ×4
- pressure / flow
- energy plug
- battery sensor
Scripts live in /tmp/matter-test/*.mjs; commissioned identities persist in
~/.matter/<id>, so a relaunch keeps the node:
pgrep -fl "node .*\.mjs" # what's running
cd /tmp/matter-test && node fan.mjs >> fan.log 2>&1 &
Hard-won rig rules
Each of these cost a debugging session:
- One UDP port per device — two devices on one port send PASE to the wrong socket.
- Distinct discriminators with different top nibbles — the 11-digit manual code only carries the 4-bit short discriminator.
- Commission within ~15 minutes of first launch, then the device stops advertising.
- After cycling many device instances, restart matter-server to clear its mDNS cache.
- Custom devices must enable their cluster features explicitly, and must not set server-managed attributes.
That colour light is an ExtendedColorLightDevice with default features —
XY, no HueSaturation. The plugin sent RGB as MoveToHueAndSaturation; the
device rejected every colour command with UNSUPPORTED_COMMAND. A maximal test
device would have hidden that for years.
The other half — the bridge node
The export side (№ 3) is a separate TypeScript process, and it has a suite of its own — 405 cases, run the same way any other Node project would be:
cd bridge-node && npm test
Two things make it more than a mock farm.
It builds real Matter servers. Endpoint CRUD, persistence and the
restart paths run against a genuine, un-commissioned matter.js ServerNode on a
temporary storage path — so “does the aggregator actually come back with its accessories
attached?” is answered by matter.js itself, not by a stub that agrees with us. It is also
why the suite takes a couple of minutes rather than a couple of seconds.
The two halves share golden protocol frames. The loopback contract
between the Python plugin and the Node bridge is pinned by a single JSON fixture: the Python
suite reads it directly, and npm test copies it into the TypeScript build.
Neither side can quietly redefine a message and stay green — the only way to change the wire
format is to change it in both places, deliberately.
Live validation on a production server
Some truths only real Indigo knows — several of its API behaviours were established
empirically here: that Supports* creation props rule the device-list display
and XML statics never apply to API-created devices; that with both display props
explicitly false, the XML UiDisplayStateId applies after all; that
replacePluginPropsOnServer does re-derive the display — proven by
deploying a fix over a fleet of pre-fix devices and watching two reconciles.
The method, reproducible by any plugin developer with a test server:
- Deploy. rsync the build into the live plugin bundle (updates only — first installs must be double-click installed), restart the plugin.
- Read the event log. The plugin is written so the log is the evidence: self-heals log what they changed and verify persistence by reading props back. An unverified write logs as a warning — never as success.
- Restart again. The steady state must be quiet. Anything still healing or warning on pass two is a finding.
- Instrument if needed. For display/UI questions, a temporary INFO line on the deployed copy answers in one restart — and never gets committed.
Real hardware, production path
| Device | Transport | Date | Result |
|---|---|---|---|
| TP-Link Tapo P110M | Wi-Fi | 2026-06-10 | Full Domio share-model flow; on/off + live energy after Tapo’s Matter 1.3 firmware update. validated |
| Aqara FP300 | Thread · HomePod TBR | 2026-06-12 | Share model first try — ~10 s join, four endpoints, battery fan-out, unprompted live reports. Third-party tester. validated |
| Export bridge → Apple Home | Bridge · LAN | 2026-08-06 | Paired from the plugin’s own menu, code read out of the Event Log, uncertified prompt accepted, exported devices controllable. validated |
| Export bridge → Alexa, alongside Apple Home | Bridge · LAN | 2026-08-06 | Second commissioner on the live bridge — the multi-admin bar for the export half, met. Pairing and control both work; newly added exports can read stale in Alexa for some minutes first (issue #143). validated, with a caveat |
| Managed bridge install, unattended | launchd | 2026-08-06 | npm install → LaunchAgent plist → launchd → node online → attach → reconcile, with no hand-holding. validated |
| Accessory identity across a reboot | Bridge | — | Not yet exercised across a full reboot of the Indigo Mac. Outstanding. |
| Export bridge → Google Home | Bridge | — | No Google hardware here. Untested, and therefore unclaimed — see the bridge’s validation position. |
The FP300 test also delivered the bug report that became issue #56 — external testers running real devices are part of the methodology, not an afterthought.