From 7ba059c8c596857a7b1ac73d680af3793de6a718 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 12 Jun 2026 22:34:55 +0100 Subject: [PATCH 1/5] fix(color): pick MoveToColor for XY-only lights via ColorCapabilities (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HueSaturation is OPTIONAL per the Matter spec — an Extended Color Light need only implement XY + ColorTemperature — but the RGB path sent MoveToHueAndSaturation unconditionally, so XY-only lights (including matter.js's ExtendedColorLightDevice on the rig) rejected every colour command with UNSUPPORTED_COMMAND (live repro 2026-06-12). - Subscribe + cache ColorCapabilities (0x400A) in a new colorCapabilities device state; RGB set-colour picks MoveToHueAndSaturation when HS is supported, MoveToColor (sRGB→CIE xy) when the device is XY-only, and keeps historical HS behaviour while capabilities are unknown. - Receive side: CurrentX/CurrentY (0x0003/0x0004) updates recompute the Indigo RGB states via xy→sRGB, with raw values kept in new colorX/colorY states so one-axis reports stay coherent. Pre-fix devices without the new states degrade quietly. - docs/TESTING.md: how the plugin is tested — unit suite, device zoo, the matter.js virtual fleet (incl. the rig rules), and the live validation method, with the real-hardware validation table. 756 tests. Closes #60. Co-Authored-By: Claude Fable 5 --- README.md | 1 + docs/TESTING.md | 148 ++++++++++++++++++ .../Contents/Info.plist | 2 +- .../Contents/Server Plugin/Devices.xml | 25 +++ .../matter_handlers/color_control.py | 120 +++++++++++++- tests/test_dimmer_color.py | 87 ++++++++++ 6 files changed, 376 insertions(+), 7 deletions(-) create mode 100644 docs/TESTING.md diff --git a/README.md b/README.md index 448c07c..8a90a53 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ matter-server is a separate Node.js runtime that you install once. See - **[docs/MATTER.md](./docs/MATTER.md)** — Matter & Thread explained: how Indigo, Domio, matter-server and Apple Home fit together. **Start here.** - **[docs/INSTALL.md](./docs/INSTALL.md)** — matter-server install & plugin setup. +- **[docs/TESTING.md](./docs/TESTING.md)** — how the plugin is tested: unit suite, the device zoo, the virtual matter.js fleet, and live validation. - `docs/PRD-indigo-matter-plugin.md` — product requirements and milestones. - `docs/IMPLEMENTATION.md` — protocols, scaffold, setup, cluster handlers. - `docs/API.md` — the Domio ↔ plugin HTTP contract (v1.3, served over the Indigo Web Server). diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..2f3ac03 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,148 @@ +# How indigo-matter is tested + +This plugin sits between two systems that are both awkward to test — Indigo's +plugin host and a live Matter fabric — so it is tested in four layers, each +catching what the previous one can't. This page explains the layers, what each +has actually caught, and how to reproduce or extend them. + +| Layer | What it is | What it catches | +|---|---|---| +| 1. Unit suite | ~750 pytest tests, everything mocked | Logic, protocol parsing, reconcile/state machinery | +| 2. Device zoo | Contract invariants over cluster combinations | Whole *classes* of device-mapping bugs | +| 3. Virtual fleet | 15+ real Matter devices (matter.js) on the LAN | Real commissioning, subscriptions, command round-trips | +| 4. Real hardware | Shipping devices on the production path | Everything else — including what Indigo itself really does | + +--- + +## 1. Unit suite + +```bash +cd indigo-matter && python3 -m pytest -q +``` + +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/dispatch code paths run +against recorded protocol shapes. `tests/test_golden_real.py` pins parsing +against captured frames from a live matter-server, so a wire-format drift +fails loudly. + +A deliberate piece of test design worth knowing: the fake Indigo device models +the **pessimistic** behaviours we have observed or must guard against — e.g. +it derives the list-display state from `Supports*` props at creation and does +*not* re-derive it on a props replace — so the self-heal and warning paths are +exercised under worst-case Indigo behaviour even though real Indigo turned out +to be kinder (see layer 4). + +## 2. The device zoo (`tests/test_device_zoo.py`) + +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 + ColorTemperature, +HueSaturation is optional). The zoo is a contract harness for exactly this: +a table of synthetic nodes (`ZOO`) run through the **real** handler registry, +with structural invariants asserted over every entry: + +1. each endpoint maps to exactly the expected Indigo device types; +2. **at most one actuator device per endpoint** — two would fight over one + physical device; +3. every spec's `device_type_id` exists in `Devices.xml`; +4. every seeded initial state is XML-declared or an Indigo built-in; +5. every sensor-type spec carries explicit display props + (`SupportsOnState`/`SupportsSensorValue`), because Indigo derives the + device-list display from creation props, never from Devices.xml statics + (issue #56) — with `(False, False)` allowed only when the XML declares a + `UiDisplayStateId` fallback pointing at a declared custom state. + +**Track record:** on its very first run the zoo caught a colour light without +LevelControl producing a duplicate relay (nobody had predicted that case), and +during the issue #56 follow-up it caught the button handler declaring display +props without merging them into its creation spec. + +**When a strange device shows up in the wild:** add its cluster set as one +`ZOO` entry (cluster ids in decimal inside the `"ep/cluster/attr"` keys) with +the device types you expect, and every invariant runs over it automatically. +That is the intended first response to any "weird device" bug report. + +## 3. The virtual device fleet (matter.js) + +Real Matter devices, minus the shopping: the +[`@matter/examples`](https://github.com/project-chip/matter.js) package 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 15+ devices covering relay, dimmer, extended colour +light, temperature/humidity, thermostat, fan, window covering, door lock, +valve, generic switch (button), smoke/CO, air quality (AQ+CO₂+PM2.5+TVOC), +pressure/flow, energy plug, and battery sensor. + +Scripts live in `/tmp/matter-test/*.mjs` on the dev machine (commissioned +identities persist in `~/.matter/`, so relaunching keeps the node). +Check and relaunch with: + +```bash +pgrep -fl "node .*\.mjs" # what's running +cd /tmp/matter-test && node fan.mjs >> fan.log 2>&1 & +``` + +Hard-won rig rules (each cost us a debugging session — full war stories in +[HANDOVER.md](./HANDOVER.md)): + +- **One UDP port per device** (`network.port`); two devices on one port sends + 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 + (e.g. `ThermostatServer.with("Heating", "Cooling")`) and must not set + server-managed attributes. + +The fleet is also deliberately imperfect in useful ways: the colour light is +an `ExtendedColorLightDevice` with default features — XY only, no +HueSaturation — which is exactly how it exposed issue #60 (RGB commands were +sent as `MoveToHueAndSaturation` and rejected with `UNSUPPORTED_COMMAND`). +Keep test devices spec-minimal rather than maximal: minimal devices find more +bugs. + +## 4. Live validation on a production Indigo server + +Some truths only real Indigo knows — its API behaviours are not all +documented, and several were established empirically here: + +- `Supports*` capabilities and `UiDisplayStateId` semantics for API-created + devices: creation props rule; a True `Supports*` wins; with both explicitly + False, the XML `UiDisplayStateId` applies after all (issues #56/#58/#59). +- `replacePluginPropsOnServer` **does** re-derive the display state — proven + by deploying a fix over a fleet of pre-fix devices and watching two + reconciles (heal pass, then a clean pass with zero stale-display warnings). + +The method, reproducible by any plugin dev with a test server: + +1. rsync the build into the live plugin bundle (updates only — first installs + must be double-click installed), then restart the plugin; +2. 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 is logged as a warning, never as success); +3. restart again — the steady state must be quiet; anything still healing or + warning on pass two is a finding; +4. for display/UI questions, temporary instrumentation (an INFO line dumping + `displayStateId` per device) on the deployed copy answers in one restart — + remove it before committing anything. + +**Real hardware validations on the 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 | +| Aqara FP300 | Thread (HomePod TBR) | 2026-06-12 | Share model first try, ~10 s join, 4 endpoints, battery fan-out, unprompted live reports (third-party tester, matter-server 0.6.8) | + +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. +If you test a new device, file what you find (good or bad) at +[github.com/simons-plugins/indigo-matter/issues](https://github.com/simons-plugins/indigo-matter/issues), +ideally with the endpoint's cluster list from +`GET …/message/com.simon.indigo-matter/diagnostics?nodeId=0x…` — unknown +devices also appear in Indigo as a *"Matter Device (unsupported clusters)"* +placeholder whose settings list exactly the cluster ids to report. diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index 212b614..c53ce8d 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.2.22 + 2026.2.23 ServerApiVersion 3.6 diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml index 034b13c..4ade3b1 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -75,6 +75,31 @@ + + + + Integer + Color Capabilities + Color Capabilities + + + + Integer + Color X + Color X + + + Integer + Color Y + Color Y + + diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py index ad4397b..3e0769c 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py @@ -4,12 +4,17 @@ brightness from :class:`LevelControlHandler` and adds colour. Indigo expresses colour as redLevel/greenLevel/blueLevel (0–100) plus -whiteTemperature (Kelvin); Matter uses Hue/Saturation (0–254 each) or -ColorTemperatureMireds. ``colorsys`` bridges RGB ↔ HSV. +whiteTemperature (Kelvin); Matter uses Hue/Saturation (0–254 each), CIE xy +(0–65535 each), or ColorTemperatureMireds. ``colorsys`` bridges RGB ↔ HSV; +the sRGB↔xy helpers below bridge RGB ↔ CIE xy. -NOTE: colour mapping is implemented to spec but, unlike OnOff/brightness, is not -yet validated against a real device (the matter.js examples ship no colour -light). The conversions are unit-tested. +Which colour command a device accepts is governed by its ColorCapabilities +bitmap (attr 0x400A): HueSaturation is OPTIONAL per the Matter spec — even an +Extended Color Light is only required to implement XY + ColorTemperature +(issue #60, found live: matter.js's ExtendedColorLightDevice rejects +MoveToHueAndSaturation with UNSUPPORTED_COMMAND). The capabilities are cached +in the ``colorCapabilities`` device state and the RGB path picks +MoveToHueAndSaturation or MoveToColor accordingly. """ from __future__ import annotations @@ -22,6 +27,14 @@ CLUSTER_COLOR_CONTROL = 0x0300 +# ColorCapabilities (0x400A) bits — Matter spec. +CAP_HUE_SATURATION = 0x01 +CAP_XY = 0x08 + +# Matter encodes CIE x/y as value*65536, capped at 0xFEFF per spec. +_XY_SCALE = 65536 +_XY_MAX = 0xFEFF + def rgb_to_matter_hs(red: float, green: float, blue: float) -> tuple[int, int]: """Indigo RGB levels (0–100) → Matter (hue, saturation) each 0–254.""" @@ -35,6 +48,60 @@ def matter_hs_to_rgb(hue: int, sat: int, value: float = 1.0) -> tuple[int, int, return round(red * 100), round(green * 100), round(blue * 100) +def rgb_to_matter_xy(red: float, green: float, blue: float) -> tuple[int, int]: + """Indigo RGB levels (0–100) → Matter CIE (x, y) ints (0–0xFEFF). + + Standard sRGB → linear → XYZ (D65) → chromaticity. Black has no + chromaticity; return D65 white (0.3127, 0.3290) for it. + """ + def linearise(channel: float) -> float: + c = max(0.0, min(1.0, channel / 100.0)) + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = linearise(red), linearise(green), linearise(blue) + big_x = 0.4124 * r + 0.3576 * g + 0.1805 * b + big_y = 0.2126 * r + 0.7152 * g + 0.0722 * b + big_z = 0.0193 * r + 0.1192 * g + 0.9505 * b + total = big_x + big_y + big_z + if total <= 0.0: + x, y = 0.3127, 0.3290 # D65 white point + else: + x, y = big_x / total, big_y / total + return (min(_XY_MAX, round(x * _XY_SCALE)), min(_XY_MAX, round(y * _XY_SCALE))) + + +def matter_xy_to_rgb(raw_x: int, raw_y: int, brightness: float = 1.0) -> tuple[int, int, int]: + """Matter CIE (x, y) ints → Indigo RGB levels (0–100). + + xyY → XYZ → linear sRGB → gamma, normalised so the brightest channel is + full scale (chromaticity carries no luminance; Indigo brightness lives in + brightnessLevel, so RGB here encodes the hue at full vibrance scaled by + ``brightness``). + """ + x = max(1e-6, raw_x / _XY_SCALE) + y = max(1e-6, raw_y / _XY_SCALE) + big_y = 1.0 + big_x = (x / y) * big_y + big_z = ((1.0 - x - y) / y) * big_y + r = 3.2406 * big_x - 1.5372 * big_y - 0.4986 * big_z + g = -0.9689 * big_x + 1.8758 * big_y + 0.0415 * big_z + b = 0.0557 * big_x - 0.2040 * big_y + 1.0570 * big_z + peak = max(r, g, b) + if peak > 0: + r, g, b = r / peak, g / peak, b / peak + + def delinearise(c: float) -> float: + c = max(0.0, min(1.0, c)) + return c * 12.92 if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055 + + brightness = max(0.0, min(1.0, brightness)) + return ( + round(delinearise(r) * 100 * brightness), + round(delinearise(g) * 100 * brightness), + round(delinearise(b) * 100 * brightness), + ) + + def mireds_to_kelvin(mireds: int) -> int: return round(1_000_000 / mireds) if mireds else 0 @@ -50,8 +117,11 @@ class ColorControlHandler(LevelControlHandler): ATTR_CURRENT_HUE = 0x0000 ATTR_CURRENT_SATURATION = 0x0001 + ATTR_CURRENT_X = 0x0003 + ATTR_CURRENT_Y = 0x0004 ATTR_COLOR_TEMP_MIREDS = 0x0007 ATTR_COLOR_MODE = 0x0008 + ATTR_COLOR_CAPABILITIES = 0x400A def is_primary_for(self, node: Any, endpoint: Any) -> bool: return True # ColorControl is the richest handler for the endpoint @@ -96,7 +166,11 @@ def create_indigo_devices(self, node: Any, endpoint: Any) -> list[IndigoDeviceSp ] def attributes_to_subscribe(self) -> list[int]: - return [self.ATTR_CURRENT_HUE, self.ATTR_CURRENT_SATURATION, self.ATTR_COLOR_TEMP_MIREDS] + return [ + self.ATTR_CURRENT_HUE, self.ATTR_CURRENT_SATURATION, + self.ATTR_CURRENT_X, self.ATTR_CURRENT_Y, + self.ATTR_COLOR_TEMP_MIREDS, self.ATTR_COLOR_CAPABILITIES, + ] def on_attribute_update(self, indigo_dev: Any, attribute_id: int, value: Any) -> dict: if value is None: @@ -109,6 +183,27 @@ def on_attribute_update(self, indigo_dev: Any, attribute_id: int, value: Any) -> if "whiteTemperature" not in getattr(indigo_dev, "states", {}): return {} return {"whiteTemperature": mireds_to_kelvin(int(value))} + if attribute_id == self.ATTR_COLOR_CAPABILITIES: + # Cached for handle_indigo_action's HS-vs-XY command choice; primed + # from the node snapshot at reconcile, refreshed on report. + return {"colorCapabilities": int(value)} + if attribute_id in (self.ATTR_CURRENT_X, self.ATTR_CURRENT_Y): + # XY-mode devices report CIE x/y instead of hue/sat. One axis + # arrives at a time; combine with the stored other axis. Skip when + # the colorX/colorY states don't exist (device created before the + # issue #60 fix added them — degrade quietly, same SDK pattern as + # whiteTemperature above). + states = getattr(indigo_dev, "states", {}) or {} + if "colorX" not in states or "colorY" not in states: + return {} + raw_x = int(value) if attribute_id == self.ATTR_CURRENT_X else int(states.get("colorX", 0) or 0) + raw_y = int(value) if attribute_id == self.ATTR_CURRENT_Y else int(states.get("colorY", 0) or 0) + updates: dict = {"colorX": raw_x, "colorY": raw_y} + if raw_x and raw_y: + brightness = float(states.get("brightnessLevel", 100) or 100) / 100.0 + red, green, blue = matter_xy_to_rgb(raw_x, raw_y, brightness) + updates.update({"redLevel": red, "greenLevel": green, "blueLevel": blue}) + return updates if attribute_id not in (self.ATTR_CURRENT_HUE, self.ATTR_CURRENT_SATURATION): return {} # Hue/sat arrive one at a time; combine with the device's current RGB to @@ -150,6 +245,19 @@ def _set_color(self, indigo_dev: Any, values: dict) -> MatterCommand: red = float(values.get("redLevel", states.get("redLevel", 0)) or 0) green = float(values.get("greenLevel", states.get("greenLevel", 0)) or 0) blue = float(values.get("blueLevel", states.get("blueLevel", 0)) or 0) + # HueSaturation is OPTIONAL per spec (issue #60): pick the command from + # the cached ColorCapabilities bitmap. Unknown capabilities (state + # absent/0 — pre-fix device or snapshot not yet primed) keep the + # historical HS behaviour rather than guessing. + capabilities = int(states.get("colorCapabilities", 0) or 0) + if capabilities and not capabilities & CAP_HUE_SATURATION and capabilities & CAP_XY: + x, y = rgb_to_matter_xy(red, green, blue) + return MatterCommand( + node_id=node_id, endpoint=endpoint_id, cluster=self.cluster_id, + command="MoveToColor", + args={"colorX": x, "colorY": y, + "transitionTime": 0, "optionsMask": 0, "optionsOverride": 0}, + ) hue, sat = rgb_to_matter_hs(red, green, blue) return MatterCommand( node_id=node_id, endpoint=endpoint_id, cluster=self.cluster_id, diff --git a/tests/test_dimmer_color.py b/tests/test_dimmer_color.py index 7bb588d..2ef74cc 100644 --- a/tests/test_dimmer_color.py +++ b/tests/test_dimmer_color.py @@ -202,3 +202,90 @@ def test_brighten_and_dim_clamp_to_range(mock_indigo_base): SimpleNamespace(pluginProps={"nodeId": "8", "endpointId": "1"}, brightness=20), SimpleNamespace(deviceAction=indigo.kDeviceAction.DimBy, actionValue=50)) assert down.args["level"] == pct_to_level(0) # clamped at 0 + + +# --------------------------------------------------------------------------- +# fix/#60 — XY fallback when the device lacks the optional HueSaturation feature +# --------------------------------------------------------------------------- + +def test_xy_only_device_gets_move_to_color(mock_indigo_base): + """An XY+CT light (caps 0x18 — the spec-minimum Extended Color Light, + e.g. matter.js's ExtendedColorLightDevice) must get MoveToColor, never + MoveToHueAndSaturation (which it rejects with UNSUPPORTED_COMMAND).""" + import indigo + h = ColorControlHandler() + dev = SimpleNamespace(pluginProps={"nodeId": "9", "endpointId": "1"}, + states={"colorCapabilities": 0x18}) + action = SimpleNamespace(deviceAction=indigo.kDeviceAction.SetColorLevels, + actionValue={"redLevel": 100, "greenLevel": 0, "blueLevel": 0}) + cmd = h.handle_indigo_action(dev, action) + assert cmd.command == "MoveToColor" + # sRGB red sits around CIE (0.64, 0.33) + assert abs(cmd.args["colorX"] / 65536 - 0.64) < 0.01 + assert abs(cmd.args["colorY"] / 65536 - 0.33) < 0.01 + assert 0 <= cmd.args["colorX"] <= 0xFEFF and 0 <= cmd.args["colorY"] <= 0xFEFF + + +def test_hs_capable_device_keeps_hue_sat(mock_indigo_base): + import indigo + h = ColorControlHandler() + dev = SimpleNamespace(pluginProps={"nodeId": "9", "endpointId": "1"}, + states={"colorCapabilities": 0x19}) # HS + XY + CT + action = SimpleNamespace(deviceAction=indigo.kDeviceAction.SetColorLevels, + actionValue={"redLevel": 0, "greenLevel": 0, "blueLevel": 100}) + cmd = h.handle_indigo_action(dev, action) + assert cmd.command == "MoveToHueAndSaturation" + + +def test_unknown_capabilities_keeps_historical_hs(mock_indigo_base): + """Capabilities not yet primed (state absent/0) → historical behaviour.""" + import indigo + h = ColorControlHandler() + dev = SimpleNamespace(pluginProps={"nodeId": "9", "endpointId": "1"}, states={}) + action = SimpleNamespace(deviceAction=indigo.kDeviceAction.SetColorLevels, + actionValue={"redLevel": 0, "greenLevel": 100, "blueLevel": 0}) + cmd = h.handle_indigo_action(dev, action) + assert cmd.command == "MoveToHueAndSaturation" + + +def test_color_capabilities_attribute_is_cached_as_state(): + h = ColorControlHandler() + assert h.on_attribute_update(SimpleNamespace(states={}), 0x400A, 0x18) == { + "colorCapabilities": 0x18, + } + + +def test_xy_attribute_updates_recompute_rgb(): + h = ColorControlHandler() + dev = SimpleNamespace(states={"colorX": 0, "colorY": 0, "brightnessLevel": 100, + "redLevel": 0, "greenLevel": 0, "blueLevel": 0}) + # X arrives first — stored, no RGB yet (no Y) + out = h.on_attribute_update(dev, 0x0003, 41942) # ~0.64 + assert out["colorX"] == 41942 and "redLevel" not in out + dev.states.update(out) + # Y arrives — both known, RGB recomputed: should be strongly red + out = h.on_attribute_update(dev, 0x0004, 21626) # ~0.33 + assert out["colorY"] == 21626 + assert out["redLevel"] > 90 and out["blueLevel"] < 20 + + +def test_xy_update_skipped_when_states_missing(): + """Pre-#60 device without colorX/colorY states degrades quietly.""" + h = ColorControlHandler() + out = h.on_attribute_update(SimpleNamespace(states={"redLevel": 0}), 0x0003, 41942) + assert out == {} + + +def test_rgb_xy_round_trip_primaries(): + from matter_handlers.color_control import matter_xy_to_rgb, rgb_to_matter_xy + for rgb in ((100, 0, 0), (0, 100, 0), (0, 0, 100), (100, 100, 100)): + x, y = rgb_to_matter_xy(*rgb) + back = matter_xy_to_rgb(x, y) + # Chromaticity round-trip: dominant channel survives + assert max(range(3), key=lambda i: back[i]) == max(range(3), key=lambda i: rgb[i]) or rgb == (100, 100, 100) + + +def test_black_rgb_maps_to_white_point(): + from matter_handlers.color_control import rgb_to_matter_xy + x, y = rgb_to_matter_xy(0, 0, 0) + assert abs(x / 65536 - 0.3127) < 0.01 and abs(y / 65536 - 0.3290) < 0.01 From 9a763dbf3ffb5602a339ca6a5e6549b9cec8947b Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 12 Jun 2026 22:37:47 +0100 Subject: [PATCH 2/5] fix(color): refresh state lists on startComm; guard capabilities write Live deploy of the XY fix surfaced it: Indigo builds a device's state list at creation and never re-reads Devices.xml, so fielded colour devices lacked the new colorCapabilities state and Indigo logged "state key colorCapabilities not defined" on every report. deviceStartComm now calls stateListOrDisplayStateIdChanged() (the documented refresh), and the capabilities write degrades quietly when the state is still missing, same as the other guarded states. Co-Authored-By: Claude Fable 5 --- .../Server Plugin/matter_handlers/color_control.py | 7 ++++++- .../Contents/Server Plugin/plugin.py | 8 ++++++++ tests/test_dimmer_color.py | 12 +++++++++--- tests/test_plugin_module.py | 12 ++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py index 3e0769c..44f220c 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py @@ -185,7 +185,12 @@ def on_attribute_update(self, indigo_dev: Any, attribute_id: int, value: Any) -> return {"whiteTemperature": mireds_to_kelvin(int(value))} if attribute_id == self.ATTR_COLOR_CAPABILITIES: # Cached for handle_indigo_action's HS-vs-XY command choice; primed - # from the node snapshot at reconcile, refreshed on report. + # from the node snapshot at reconcile, refreshed on report. Guarded + # like the other states: a device whose state list hasn't been + # refreshed yet (deviceStartComm does that on upgrade) must degrade + # quietly, not log an Indigo error per report. + if "colorCapabilities" not in (getattr(indigo_dev, "states", {}) or {}): + return {} return {"colorCapabilities": int(value)} if attribute_id in (self.ATTR_CURRENT_X, self.ATTR_CURRENT_Y): # XY-mode devices report CIE x/y instead of hue/sat. One axis diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py index 079ae0f..320c7c6 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py @@ -227,6 +227,14 @@ def validateDeviceConfigUi(self, valuesDict, typeId, devId): # noqa: N802 return (True, valuesDict) def deviceStartComm(self, dev): # noqa: N802 + # Indigo builds a device's state list at creation and does NOT re-read + # Devices.xml on plugin upgrade — without this refresh, states added in + # a new release (e.g. colorCapabilities, issue #60) never exist on + # fielded devices and every update logs an Indigo error. + try: + dev.stateListOrDisplayStateIdChanged() + except Exception as exc: # noqa: BLE001 - refresh failure must not block startComm + self.logger.debug("state list refresh failed for %s: %s", dev.id, exc) # Backstop for type edits made while the plugin was not running (the # validateDeviceConfigUi guard can't fire then) — issue #58. created = dev.pluginProps.get("createdTypeId", "") diff --git a/tests/test_dimmer_color.py b/tests/test_dimmer_color.py index 2ef74cc..a940737 100644 --- a/tests/test_dimmer_color.py +++ b/tests/test_dimmer_color.py @@ -250,9 +250,15 @@ def test_unknown_capabilities_keeps_historical_hs(mock_indigo_base): def test_color_capabilities_attribute_is_cached_as_state(): h = ColorControlHandler() - assert h.on_attribute_update(SimpleNamespace(states={}), 0x400A, 0x18) == { - "colorCapabilities": 0x18, - } + dev = SimpleNamespace(states={"colorCapabilities": 0}) + assert h.on_attribute_update(dev, 0x400A, 0x18) == {"colorCapabilities": 0x18} + + +def test_color_capabilities_skipped_when_state_missing(): + """A device whose state list hasn't been refreshed yet (pre-#60 creation) + must degrade quietly instead of logging an Indigo error per report.""" + h = ColorControlHandler() + assert h.on_attribute_update(SimpleNamespace(states={}), 0x400A, 0x18) == {} def test_xy_attribute_updates_recompute_rgb(): diff --git a/tests/test_plugin_module.py b/tests/test_plugin_module.py index 7f10af1..652f2af 100644 --- a/tests/test_plugin_module.py +++ b/tests/test_plugin_module.py @@ -177,3 +177,15 @@ def test_device_start_comm_quiet_when_types_match(plugin_cls, mock_logger): dev = _dev({"createdTypeId": "matterRelay"}, type_id="matterRelay") plugin_cls.deviceStartComm(stub, dev) assert not mock_logger.warning.called + + +def test_device_start_comm_refreshes_state_list(plugin_cls, mock_logger): + # Indigo never re-reads Devices.xml for existing devices — deviceStartComm + # must request the refresh so states added by upgrades exist (issue #60). + from types import SimpleNamespace + from unittest.mock import MagicMock + stub = SimpleNamespace(logger=mock_logger, device_sync=MagicMock()) + dev = _dev({"createdTypeId": "matterRelay"}, type_id="matterRelay") + dev.stateListOrDisplayStateIdChanged = MagicMock() + plugin_cls.deviceStartComm(stub, dev) + dev.stateListOrDisplayStateIdChanged.assert_called_once_with() From 7c8922a3bf809a357f6801747cdaf37cc4343985 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 12 Jun 2026 22:42:25 +0100 Subject: [PATCH 3/5] fix(reconcile): actionable warning when a name-colliding device blocks creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live evidence (issue #62): a device whose type was changed via Indigo's Type menu is left configured=False, drops out of iter("self"), and never receives deviceStartComm — so the index loses it, _unique_name can't see it, and reconcile's recreate attempt fails NameNotUniqueError every pass as a raw traceback. Both #58 guards are structurally blind to this state. The collision now logs the remedy (delete the device, reload) instead of a traceback. Co-Authored-By: Claude Fable 5 --- .../Contents/Server Plugin/device_sync.py | 18 ++++++++++++++++++ tests/test_device_sync.py | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py index e540837..c366741 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py @@ -438,6 +438,24 @@ def _create_one(self, spec: Any, name: str, folder_id: int = 0) -> Optional[int] props=spec.props, folder=folder_id, ) + except ValueError as exc: # noqa: BLE001 - surface but don't abort the batch + if "NameNotUnique" in str(exc): + # A device with this name exists but was invisible to both the + # index and _unique_name — live evidence (issue #62): a device + # whose type was changed via Indigo's Type menu is left + # configured=False, drops out of iter("self"), and never gets + # deviceStartComm, so neither #58 guard can see it. Replace the + # raw traceback with the remedy. + self.logger.warning( + "a device named \"%s\" already exists but is not usable for this " + "node (typically: its type was changed via Indigo's Type menu, " + "leaving it unconfigured) — delete that device and reload this " + "plugin; it will be recreated correctly", + name, + ) + else: + self.logger.exception(exc) + return None except Exception as exc: # noqa: BLE001 - surface but don't abort the batch self.logger.exception(exc) return None diff --git a/tests/test_device_sync.py b/tests/test_device_sync.py index 0c49c71..8d5176b 100644 --- a/tests/test_device_sync.py +++ b/tests/test_device_sync.py @@ -1535,3 +1535,20 @@ def test_reassert_backfills_address_on_legacy_devices(ds, indigo_env): ds.reconcile_all([TEMP_SENSOR_NODE]) assert dev.pluginProps.get("address") == "0x40" + + +def test_name_collision_logs_remedy_not_traceback(ds, indigo_env, mock_logger): + """A NameNotUniqueError on create (issue #62: type-edited device invisible + to iter('self') still holds the name server-side) must log the actionable + remedy, not a raw traceback, and must not abort the batch.""" + indigo_mock, devices = indigo_env + + def explode(**kwargs): + raise ValueError("NameNotUniqueError") + indigo_mock.device.create = explode + + result = ds.create_from_raw(TEMP_SENSOR_NODE, "Landing Sensor") + assert result.get("partial") is True + msgs = [c[0][0] % tuple(c[0][1:]) for c in mock_logger.warning.call_args_list] + assert any("delete that device and reload" in m for m in msgs), msgs + assert not mock_logger.exception.called From 8b629fb27c5ff99399e8a865075dcec00a1c7d08 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 12 Jun 2026 23:14:58 +0100 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20Field=20Notes=20=E2=80=94=20matter.?= =?UTF-8?q?html=20+=20testing.html=20for=20GitHub=20Pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two long-read pages in the Domio site's Aizome direction (indigo dye + warm paper, Fraunces/General Sans, letterpress detailing) but as their own engineering gazette: numbered sections, drop caps, rubber-stamp validation marks, the share-model pipeline as a real diagram, and the four test layers drawn as strata. Leaf accent for the landscape page, ochre for the proving ground; plus a small landing index and a shared stylesheet. Scroll-reveal is progressive-enhancement only (no-JS, print and screenshot tools always get visible content). Serve from GitHub Pages → main /docs. Co-Authored-By: Claude Fable 5 --- docs/aizome.css | 467 ++++++++++++++++++++++++++++++++++++++++++++++ docs/index.html | 74 ++++++++ docs/matter.html | 383 +++++++++++++++++++++++++++++++++++++ docs/testing.html | 231 +++++++++++++++++++++++ 4 files changed, 1155 insertions(+) create mode 100644 docs/aizome.css create mode 100644 docs/index.html create mode 100644 docs/matter.html create mode 100644 docs/testing.html diff --git a/docs/aizome.css b/docs/aizome.css new file mode 100644 index 0000000..758810b --- /dev/null +++ b/docs/aizome.css @@ -0,0 +1,467 @@ +/* ============================================================ + indigo-matter Field Notes — Aizome direction + Kindred to domio-smart-home.app: indigo dye + warm paper, + editorial serif display, letterpress detailing. These pages + are the engineering gazette of that same world. + ============================================================ */ + +:root { + color-scheme: light; + + --paper: #F2EDE2; + --paper-deep: #EAE3D2; + --ink: #15184A; + --ink-soft: #3D406B; + --ink-faint: #5A5C88; + --rule: rgba(21, 24, 74, 0.13); + --rule-strong: rgba(21, 24, 74, 0.22); + + --leaf: #3B5A3E; + --ochre: #B07A2B; + --purple: #9932CC; + + --accent: var(--leaf); /* page-level override */ + --accent-soft: rgba(59, 90, 62, 0.10); + + --code-bg: rgba(21, 24, 74, 0.06); + + --r-sm: 8px; + --r-md: 14px; + --r-lg: 22px; + --r-pill: 999px; + + --display: "Fraunces", ui-serif, Georgia, "Times New Roman", serif; + --body: "General Sans", ui-sans-serif, system-ui, -apple-system, sans-serif; + --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; + + --ease-out: cubic-bezier(.2, .8, .2, 1); + --ease-out-soft: cubic-bezier(.25, 1, .5, 1); + + --measure: 68ch; +} + +*, *::before, *::after { box-sizing: border-box; } + +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--body); + font-size: 1.0625rem; + line-height: 1.7; + font-weight: 400; + /* faint indigo-dye wash pooling at the page edges */ + background-image: + radial-gradient(1100px 500px at 85% -8%, rgba(21, 24, 74, 0.05), transparent 65%), + radial-gradient(900px 600px at -10% 110%, rgba(21, 24, 74, 0.045), transparent 60%); + background-attachment: fixed; +} + +/* paper grain */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 2; + opacity: .35; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.05'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +::selection { background: var(--ink); color: var(--paper); } + +a { color: var(--ink); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; transition: color .18s var(--ease-out); } +a:hover { color: var(--accent); } + +code, pre { + font-family: var(--mono); + font-size: .86em; +} +code { background: var(--code-bg); padding: .12em .38em; border-radius: 5px; } +pre { + background: var(--code-bg); + border: 1px solid var(--rule); + border-radius: var(--r-md); + padding: 1.1rem 1.3rem; + overflow-x: auto; + line-height: 1.55; +} +pre code { background: none; padding: 0; } + +/* ---------- Masthead ---------- */ + +.mast { + max-width: 1060px; + margin: 0 auto; + padding: clamp(2.2rem, 6vw, 4.5rem) clamp(1.2rem, 4vw, 2.5rem) 0; +} + +.mast-rule { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + border-top: 3px double var(--rule-strong); + border-bottom: 1px solid var(--rule); + padding: .55rem .1rem .6rem; + font-size: .78rem; + font-weight: 500; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--ink-faint); +} +.mast-rule a { text-decoration: none; color: var(--ink-faint); } +.mast-rule a:hover { color: var(--accent); } +.mast-rule .no { color: var(--accent); font-weight: 600; } + +h1 { + font-family: var(--display); + font-optical-sizing: auto; + font-size: clamp(2.6rem, 7vw, 4.6rem); + font-weight: 560; + line-height: 1.04; + letter-spacing: -0.015em; + margin: 2.4rem 0 0; + max-width: 18ch; + text-wrap: balance; +} +h1 em { font-style: italic; font-weight: 480; color: var(--accent); } + +.standfirst { + font-family: var(--display); + font-size: clamp(1.12rem, 2.3vw, 1.38rem); + font-weight: 430; + font-style: italic; + line-height: 1.5; + color: var(--ink-soft); + max-width: 52ch; + margin: 1.4rem 0 0; +} + +.mast-foot { + display: flex; + flex-wrap: wrap; + gap: .5rem 1.6rem; + align-items: center; + margin: 2.2rem 0 0; + padding-bottom: 1.4rem; + border-bottom: 1px solid var(--rule); + font-size: .82rem; + letter-spacing: .05em; + color: var(--ink-faint); +} + +/* ---------- Article scaffold ---------- */ + +main { + max-width: 1060px; + margin: 0 auto; + padding: 0 clamp(1.2rem, 4vw, 2.5rem) 4rem; +} + +section.note { + display: grid; + grid-template-columns: minmax(0, 110px) minmax(0, 1fr); + gap: 0 clamp(1.2rem, 3.5vw, 3rem); + padding: clamp(2rem, 5vw, 3.2rem) 0 0; + border-bottom: 1px solid var(--rule); + margin-bottom: clamp(1rem, 3vw, 1.8rem); +} +section.note:last-of-type { border-bottom: none; } + +.sec-no { + font-family: var(--display); + font-weight: 600; + font-size: 1rem; + color: var(--accent); + letter-spacing: .08em; + padding-top: .55rem; +} +.sec-no::after { + content: ""; + display: block; + width: 2.2rem; + height: 2px; + margin-top: .55rem; + background: var(--accent); +} + +.sec-body { max-width: var(--measure); padding-bottom: 1.6rem; } + +h2 { + font-family: var(--display); + font-size: clamp(1.7rem, 3.6vw, 2.3rem); + font-weight: 560; + line-height: 1.12; + letter-spacing: -0.01em; + margin: 0 0 1.1rem; + text-wrap: balance; +} + +h3 { + font-family: var(--display); + font-size: 1.22rem; + font-weight: 600; + margin: 2rem 0 .6rem; +} + +p { margin: 0 0 1.1rem; } +ul, ol { margin: 0 0 1.2rem; padding-left: 1.3rem; } +li { margin-bottom: .45rem; } +li::marker { color: var(--accent); font-weight: 600; } +strong { font-weight: 600; } + +/* opening drop cap */ +.dropcap::first-letter { + font-family: var(--display); + font-weight: 600; + font-size: 3.4em; + float: left; + line-height: .82; + padding: .06em .12em 0 0; + color: var(--accent); +} + +/* ---------- Editorial furniture ---------- */ + +.pull { + font-family: var(--display); + font-style: italic; + font-weight: 460; + font-size: clamp(1.25rem, 2.6vw, 1.55rem); + line-height: 1.42; + color: var(--ink); + border-left: 3px solid var(--accent); + padding: .35rem 0 .35rem 1.3rem; + margin: 1.8rem 0; + max-width: 46ch; + text-wrap: balance; +} +.pull small { + display: block; + font-family: var(--body); + font-style: normal; + font-size: .76rem; + font-weight: 500; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--ink-faint); + margin-top: .6rem; +} + +.aside { + background: var(--paper-deep); + border: 1px solid var(--rule); + border-radius: var(--r-md); + padding: 1.1rem 1.3rem; + margin: 1.5rem 0; + font-size: .97rem; +} +.aside .tag { + display: inline-block; + font-size: .72rem; + font-weight: 600; + letter-spacing: .13em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: .4rem; +} +.aside.caution { border-color: rgba(176, 122, 43, .5); } +.aside.caution .tag { color: var(--ochre); } + +/* rubber stamp */ +.stamp { + display: inline-block; + font-weight: 600; + font-size: .78rem; + letter-spacing: .16em; + text-transform: uppercase; + color: var(--accent); + border: 2px solid var(--accent); + border-radius: var(--r-sm); + padding: .3rem .7rem; + transform: rotate(-2deg); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='40'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.6' numOctaves='2'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='0 .4 .7 1 1 1 1 1 1 1'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='40' filter='url(%23r)'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='40'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.6' numOctaves='2'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='0 .4 .7 1 1 1 1 1 1 1'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='40' filter='url(%23r)'/%3E%3C/svg%3E"); +} +.stamp.ochre { color: var(--ochre); border-color: var(--ochre); transform: rotate(1.5deg); } + +/* chips */ +.chips { display: flex; flex-wrap: wrap; gap: .55rem; margin: 1rem 0 1.4rem; padding: 0; list-style: none; } +.chips li { + margin: 0; + border: 1px solid var(--rule-strong); + border-radius: var(--r-pill); + padding: .32rem .85rem; + font-size: .85rem; + font-weight: 500; + background: rgba(255,255,255,.35); +} +.chips li b { color: var(--accent); font-weight: 600; } + +/* specimen tables */ +.specimen { width: 100%; border-collapse: collapse; margin: 1.4rem 0; font-size: .95rem; } +.specimen caption { + caption-side: top; + text-align: left; + font-size: .74rem; + font-weight: 600; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--ink-faint); + padding-bottom: .55rem; +} +.specimen th, .specimen td { + text-align: left; + vertical-align: top; + padding: .6rem .8rem .6rem 0; + border-bottom: 1px solid var(--rule); +} +.specimen thead th { + font-size: .76rem; + font-weight: 600; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--ink-faint); + border-bottom: 2px solid var(--rule-strong); +} +.specimen td:first-child, .specimen th:first-child { padding-left: 0; } +.specimen tbody th { font-weight: 600; } + +/* numbered letterpress steps */ +.steps { counter-reset: step; list-style: none; padding: 0; margin: 1.4rem 0; } +.steps > li { + counter-increment: step; + position: relative; + padding: 0 0 1.3rem 3.4rem; + margin: 0; +} +.steps > li::before { + content: counter(step, decimal-leading-zero); + position: absolute; + left: 0; + top: .05em; + font-family: var(--display); + font-weight: 600; + font-size: 1.5rem; + color: var(--accent); +} +.steps > li::after { + content: ""; + position: absolute; + left: .55rem; + top: 2.3rem; + bottom: .4rem; + width: 1px; + background: var(--rule); +} +.steps > li:last-child::after { display: none; } +.steps b:first-child { font-weight: 600; } + +/* ---------- Flow diagram ---------- */ + +.flow { + display: flex; + flex-direction: column; + gap: 0; + margin: 1.8rem 0; + max-width: 34rem; +} +.flow-node { + border: 1.5px solid var(--rule-strong); + border-radius: var(--r-md); + background: rgba(255,255,255,.45); + padding: .85rem 1.1rem; + box-shadow: 0 1px 0 rgba(21,24,74,.06); +} +.flow-node b { font-weight: 600; display: block; } +.flow-node span { font-size: .86rem; color: var(--ink-soft); } +.flow-node.hero { border-color: var(--accent); background: var(--accent-soft); } +.flow-link { + display: flex; + align-items: center; + gap: .7rem; + padding: .45rem 0 .45rem 1.4rem; + color: var(--ink-faint); + font-size: .8rem; + letter-spacing: .06em; +} +.flow-link::before { + content: ""; + width: 1px; + align-self: stretch; + background: var(--rule-strong); + margin-right: .2rem; +} +.flow-link i { font-style: normal; font-family: var(--mono); font-size: .78rem; color: var(--accent); } + +/* ---------- Strata (testing layers) ---------- */ + +.strata { list-style: none; margin: 2rem 0; padding: 0; } +.strata li { + display: grid; + grid-template-columns: 3.2rem 1fr; + gap: 1.1rem; + align-items: start; + border: 1.5px solid var(--rule-strong); + border-radius: var(--r-md); + padding: 1rem 1.2rem; + margin: 0 0 .6rem; + background: rgba(255,255,255,.4); +} +.strata li:nth-child(1) { margin-right: 18%; } +.strata li:nth-child(2) { margin-right: 12%; margin-left: 2%; } +.strata li:nth-child(3) { margin-right: 6%; margin-left: 4%; } +.strata li:nth-child(4) { margin-left: 6%; border-color: var(--accent); background: var(--accent-soft); } +.strata .layer-no { + font-family: var(--display); + font-weight: 600; + font-size: 1.7rem; + color: var(--accent); + line-height: 1; +} +.strata b { font-weight: 600; } +.strata p { margin: .15rem 0 0; font-size: .92rem; color: var(--ink-soft); } + +/* ---------- Reveal motion ---------- + Progressive enhancement: hidden state applies only once the inline + head script has stamped html.js — no-JS readers, print, and full-page + screenshot tools always get visible content. */ + +html.js .reveal { opacity: 0; transform: translateY(14px); transition: opacity .7s var(--ease-out-soft), transform .7s var(--ease-out-soft); } +html.js .reveal.in { opacity: 1; transform: none; } +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + html.js .reveal { opacity: 1; transform: none; transition: none; } +} +@media print { + html.js .reveal { opacity: 1; transform: none; } + body::before { display: none; } +} + +/* ---------- Footer ---------- */ + +footer.colophon { + max-width: 1060px; + margin: 0 auto; + padding: 2rem clamp(1.2rem, 4vw, 2.5rem) 3.5rem; + border-top: 3px double var(--rule-strong); + font-size: .9rem; + color: var(--ink-soft); + display: flex; + flex-wrap: wrap; + gap: 1rem 2.4rem; + justify-content: space-between; + align-items: baseline; +} +footer.colophon .dot { color: var(--purple); } + +/* ---------- Small screens ---------- */ + +@media (max-width: 700px) { + section.note { grid-template-columns: 1fr; } + .sec-no { padding-top: 0; } + .strata li, .strata li:nth-child(n) { margin-left: 0; margin-right: 0; } +} diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..8b0d944 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,74 @@ + + + + + +indigo-matter — Field Notes + + + + + + + + + + +
+
+ indigo-matter · Field Notes + The index + github / simons-plugins / indigo-matter +
+

Matter support for Indigo

+

Commission with the ecosystem you already own; control everything from + Indigo. Two long reads explain the whole thing — the landscape, and how it’s proven.

+
+ +
+ +
+ +
+ indigo-matter Field Notes · set in Fraunces & General Sans on warm paper. + + Install · + API · + Domio + +
+ + + diff --git a/docs/matter.html b/docs/matter.html new file mode 100644 index 0000000..6afeafd --- /dev/null +++ b/docs/matter.html @@ -0,0 +1,383 @@ + + + + + +Matter, Thread & where Indigo fits — indigo-matter Field Notes № 1 + + + + + + + + + + + +
+
+ indigo-matter · Field Notes + № 1 — The Landscape + github / simons-plugins / indigo-matter +
+

Matter, Thread & where Indigo fits

+

A plain-English guide to the Matter landscape — and how Indigo, the + indigo-matter plugin, matter-server, Domio and Apple Home divide the work between them. + This is the “why does it work this way” page.

+
+ Wi-Fi · validated + Thread · validated + Companion read: № 2 — The Proving Ground +
+
+ +
+ +
+
§ 01
+
+

What Matter is

+

Matter is a smart-home standard backed by Apple, Google, Amazon, Samsung + and hundreds of device makers under the Connectivity Standards Alliance. Its promise: buy a + device with the Matter logo and it works with every major ecosystem, locally, without a + vendor cloud.

+
Matter is not a radio. It is a control protocol that runs on top of + ordinary IP networking.The single most useful fact on this page
+

A Matter device talks over one of:

+
    +
  • Wi-Fi — joins your normal wireless network
  • +
  • Ethernet — same, wired
  • +
  • Thread — a low-power mesh radio (§ 02)
  • +
+

Either way, control traffic is local IP — UDP over link-local IPv6, discovered via + mDNS/Bonjour, the same multicast machinery as AirPlay and HomeKit. No cloud round-trips, + no vendor app needed for control.

+
+
+ +
+
§ 02
+
+

Thread, and what it has to do with anything

+

Thread is one of the transports Matter can run over: a low-power, self-healing + mesh radio (802.15.4 — the same silicon family as Zigbee) built for battery devices that + can’t afford Wi-Fi’s power budget — sensors, buttons, locks.

+

Thread devices can’t talk IP to your LAN directly; they need a Thread Border + Router to bridge the mesh onto your network. You probably already own one — by + 2026 almost every ecosystem hub is a TBR:

+
    +
  • Apple — HomePod mini, HomePod 2, recent Apple TV 4K
  • +
  • Amazon — Echo 4, Echo Hub, Echo Studio, recent Echo Shows, eero
  • +
  • Google — Nest Hub 2, Nest Hub Max, Nest Wifi Pro
  • +
+

Two distinctions that save a lot of confusion:

+
    +
  • Thread ≠ Matter. Thread is plumbing; Matter is the language. Some + Thread devices speak HomeKit-over-Thread or proprietary protocols instead.
  • +
  • A Matter Wi-Fi device needs no border router at all. It’s just + another client on your LAN.
  • +
+ +

Thread status in this plugin

+

Works — real hardware

+

Thread devices ride the normal share-model flow — the same Apple Home → pairing + code path as Wi-Fi. Validated on both transports: a Tapo P110M (Wi-Fi, + with live energy metering) and an Aqara FP300 presence multi-sensor + (Thread, via a HomePod border router — a ten-second join, all four sensors, unprompted + live reports).

+

Here’s why it works. The one Thread operation the plugin’s controller stack + (matter-server, matter.js) + can’t do is first-admin commissioning — handing a factory-fresh device the Thread + network credentials over Bluetooth. In the share model that step is always the first + ecosystem’s job: Apple Home (or Alexa, or Google Home — § 07) provisions the device onto + its own mesh with its own radios. From then on the device is just an IP endpoint — + the ecosystem’s border router routes IPv6 between mesh and LAN, so when the plugin joins + as a second admin over IP it neither knows nor cares that the last hop is Thread.

+

Practical caveats: matter-server is Alpha, and battery Thread devices are “sleepy” + (they wake on long intervals) — which is where an alpha controller is most likely to be + flaky. That said, the first Thread device validated here was exactly such a sleepy battery + sensor, and it behaved. And should a true gap ever surface, the WebSocket protocol the + plugin speaks is deliberately compatible with Home Assistant’s python-matter-server — a + different backend is a swap, not a rewrite.

+
+
+ +
+
§ 03
+
+

Bluetooth, commissioning, and why this plugin needs neither

+

When you pair a brand-new Matter device (“commissioning”), the very first step usually + happens over Bluetooth LE: the commissioner sends the device your Wi-Fi + credentials (or Thread network key) over BLE, the device joins the network — and BLE is + never used again.

+

That’s why phones and ecosystem hubs make good first commissioners: they have + BLE radios and, for Thread, the network credentials. A headless Mac running Indigo is a + poor first commissioner — matter-server runs without BLE on macOS, and macOS has no Thread + credential store.

+

This plugin sidesteps the problem entirely with the share model (the + architecture decision recorded as ADR-0006):

+
An ecosystem you already own commissions the device first — it owns the + Bluetooth step. Then the device is shared to Indigo over plain IP. Indigo never + needs BLE, Thread credentials, or proximity to the device.ADR-0006 · the share + model
+

That’s only possible because of Matter’s best feature: multi-admin.

+
+
+ +
+
§ 04
+
+

Fabrics & multi-admin: one device, many controllers

+

A Matter fabric is a controller’s trust domain — a set of cryptographic + credentials a controller installs on a device. The crucial design choice in Matter is that + a device can belong to several fabrics at once (typically five or more). + Each controller talks to the device directly and locally; none of them knows or cares + about the others.

+

A single plug in this house happily serves four admins simultaneously:

+
    +
  • Apple Home
  • +
  • Indigo — this plugin’s fabric
  • +
  • The vendor’s app
  • +
  • Home Assistant
  • +
+

— with no cross-interference. Removing one fabric (“Remove from Indigo”) leaves the + others untouched; only a factory reset wipes them all.

+

The plugin’s fabric lives inside matter-server’s storage directory. It is the single + point of total loss — destroyed, every device must be re-commissioned — which is why the + plugin ships fabric backup and restore (menu items: Export fabric + backup… / Restore fabric backup…).

+
+ Expected behaviour + Why Apple Home calls our fabric “Matter Test”. When the plugin joins a + device, Apple Home’s “joined a new network” notification lists the Indigo fabric as + “Matter Test”. Nothing is wrong: the fabric label is correctly set to “Indigo”, + but that notification shows the fabric’s Vendor ID, and matter-server + commissions under the Matter test vendor ID 0xFFF1, registered as “Matter + Test”. A custom name there needs a paid, certified CSA Vendor ID — the same limitation + every non-certified controller has (Home Assistant shows up the same way). Purely + cosmetic; control and security are unaffected. +
+
+
+ +
+
§ 05
+
+

The pieces, end to end

+ + + + + + + + + + + +
Cast of characters
PieceRole
Apple HomeFirst commissioner. BLE onboarding and, for Thread, the border router. Alexa or Google Home can play this role instead (§ 07).
DomioThe add device UX. Relays the pairing code to the plugin. Not in the control path afterwards.
indigo-matterHolds the Indigo fabric, translates Matter clusters ↔ Indigo device types, serves the Domio API, supervises matter-server.
matter-serverThe Matter controller stack (matter.js). The plugin drives it over a local WebSocket.
IndigoWhere devices live. A Matter plug is a relay, a Matter bulb is a dimmer — first-class citizens in every Indigo feature.
+
+
+ +
+
§ 06
+
+

Adding a device

+
    +
  1. Commission in Apple Home first. Scan the device’s QR code in the Home app. If + your main Wi-Fi runs “advanced” features (WPA3-only, 802.11r, band steering), a basic + 2.4 GHz IoT SSID is far more reliable — but it must be the same subnet + as the Indigo Mac (§ 11).
  2. +
  3. Open pairing mode. Home app → device settings → Turn On Pairing Mode. + A fresh one-time setup code appears — the printed QR code will not work for this.
  4. +
  5. Add in Domio. Add Matter device → enter the code, a name and a room. Domio + posts it to the plugin; the plugin joins as a second admin over IP and creates the + Indigo device(s). Discovery can take a couple of minutes on a busy LAN — the plugin + waits up to five.
  6. +
  7. Done. The device appears in Indigo (the room becomes a device folder), + controllable from everything Indigo offers, plus Domio.
  8. +
+
Once the pairing-mode code exists, the join is pure IP — no Bluetooth, no + phone, no proximity. Everything downstream of the code is ecosystem-free.True for + Thread exactly as for Wi-Fi
+

Without Domio (advanced): because of the above, the plugin menu’s + Commission device by setup code… is fully equivalent — paste the pairing-mode code + by hand, Wi-Fi or Thread alike. Domio’s value is workflow: you’re standing at the new + device with the Home app open when the code appears, and Domio is the next tap on the same + phone. (And if a device is already on your network but was never commissioned by anyone, + its printed code works directly in either entry point.)

+

Removing: plugin menu → Decommission Matter device… — removes + only the Indigo fabric; the device stays in Apple Home and everywhere else. Deleting the + Indigo device alone does not work — by design the plugin recreates it at the next + reconcile. Only a factory reset on the device itself removes everything.

+
+
+ +
+
§ 07
+
+

Don’t have Apple Home? Alexa or Google work too

+

The plugin and Domio never care which ecosystem is admin 1 — they just consume + a pairing code. Apple Home is the smoothest path for Domio users (you’re on an iPhone + already), but any Matter ecosystem can play the role:

+
    +
  • Alexa: commission in the Alexa app, then device settings → + Other assistants and apps → it generates a pairing code. For Thread, a + TBR-capable Echo plays the HomePod’s part.
  • +
  • Google Home: commission in the Google Home app, then device + settings → Linked Matter apps & services → share. Nest Hub / Nest Wifi are + the TBRs.
  • +
+
+ One rule for Thread + A Thread device joins the mesh of whichever ecosystem commissions it, so that + ecosystem’s border router must stay online for the device to be reachable — an + Apple-commissioned device rides the Apple TBR, an Alexa-commissioned one rides the Echo. + For Wi-Fi devices none of this applies. +
+
+
+ +
+
§ 08
+
+

Sharing with other platforms — and vendor apps

+

Multi-admin works in every direction: the same pairing-mode trick adds the device to + Google Home, Alexa or Home Assistant alongside Indigo.

+

Adding a device to its vendor app is often worth doing once, because + firmware updates usually ship through it (§ 10).

+
+ Caution — reset buttons + On TP-Link Tapo plugs, a ~5 s hold is a Wi-Fi-only reset (Matter fabrics survive); + a ~10 s hold is a factory reset that wipes every fabric and undoes + all your commissioning. Vendors differ — check before holding. +
+

Bridges

+

A Matter bridge exposes non-Matter devices (Zigbee, proprietary RF) as + Matter endpoints — Aqara, SwitchBot and Hue hubs all do this. The plugin supports bridges: + each bridged child appears as its own Indigo device. It’s also a practical route into + Indigo for the Zigbee sensors you already own behind such a hub.

+
+
+ +
+
§ 09
+
+

What’s supported

+ + + + + + + + + + + + + + + + + + + + + +
Matter capability → Indigo device
Matter capabilityIndigo device
On/Off — plugs, switches, lightsRelay
Dimming (Level Control)Dimmer
Colour & colour temperatureColour dimmer (RGB + white-temp UI)
Temperature · humidity · occupancy · contact · illuminance · pressure · flowSensor — one device per measurement
Thermostats (incl. attached fan)Thermostat
Standalone fansSpeed-controlled dimmer
Window coveringsDimmer (100% = open)
Door locksRelay with lock UI
Valves (water / irrigation)Relay with flood-safe toggle behaviour
Buttons / scene switchesButton device firing Indigo trigger events
Smoke / CO alarmsSensor (alarm latch)
Air quality — AQI · CO₂ · PM2.5 · TVOCSensors, one per metric
Power & energy meteringcurEnergyLevel (W) / accumEnergyTotal (kWh) on the primary device
Battery levelbatteryLevel on every device of the node
BridgesOne Indigo device per bridged child
Anything elseA visible placeholder listing the unsupported clusters — so you can report it, not lose it
+

A node exposing several capabilities gets several Indigo devices (a multi-sensor becomes + one device per measurement); secondary capabilities like energy and battery merge into the + primary device’s states.

+
+
+ +
+
§ 10
+
+

Firmware updates — they matter more than usual

+

Matter devices typically receive firmware through their vendor’s app, + and vendors are still actively adding Matter features via firmware.

+
A Tapo P110M shipped exposing only on/off over Matter. The energy + clusters appeared after a firmware update — and the existing Indigo device gained its + energy states automatically. No restart, no re-pairing.Field note, this house, + June 2026
+

Rule of thumb: if a device seems to be missing a capability you know it has, check + firmware first. The plugin’s diagnostics endpoint (API.md §3.5) lists exactly which + clusters a node advertises — settling the “device or plugin?” question in one request.

+
+
+ +
+
§ 11
+
+

Troubleshooting

+

Network first. Matter assumes a flat residential network. The plugin, + the device and matter-server must share one subnet/VLAN: discovery is + mDNS multicast, transport is link-local IPv6, and neither crosses routers. You do + not need “IPv6 from your ISP” — link-local addresses are self-assigned and always + present.

+

Common failure modes, in rough order of likelihood:

+
    +
  • Commissioning fails in Apple Home on the main SSID. Advanced Wi-Fi + features break many 2.4 GHz-only IoT radios during onboarding. Use a + backward-compatible IoT SSID — on the same subnet.
  • +
  • Apple Home controls it, Indigo can’t reach it. The IoT SSID is on a + separate VLAN, or AP client isolation is blocking station-to-LAN traffic. (Apple Home can + ride BLE and hubs; the plugin is pure IP, so it notices first.)
  • +
  • “1 discovered, attempt failed” / discovery timeouts. Pairing-mode + windows are short (~15 min) — regenerate the code and commission promptly. mDNS + snooping on managed switches also causes this.
  • +
  • Commissioning times out, then the device appears anyway. LAN + discovery can exceed 60 s; the plugin waits 300 s and reconciles late joins. + Give it the full window.
  • +
  • Unreachable after a vendor-app setup or firmware update. Normal for + a minute or two while it reboots; the plugin marks it unreachable and self-clears. + Persistently dead usually means a factory reset wiped the fabric — re-commission.
  • +
  • Capability missing. Firmware first (§ 10), then diagnostics.
  • +
+

Backups. Export a fabric backup after commissioning anything you’d hate + to re-pair. The plugin keeps rotating zips; restore is menu-driven and reversible.

+
+
+ +
+ + + + + + + diff --git a/docs/testing.html b/docs/testing.html new file mode 100644 index 0000000..774f465 --- /dev/null +++ b/docs/testing.html @@ -0,0 +1,231 @@ + + + + + +The Proving Ground — indigo-matter Field Notes № 2 + + + + + + + + + + + +
+
+ indigo-matter · Field Notes + № 2 — The Proving Ground + github / simons-plugins / indigo-matter +
+

The proving ground

+

This plugin sits between two systems that are both awkward to test — + Indigo’s plugin host and a live Matter fabric. So it is tested in four layers, each catching + what the one before it can’t. This is how, and what each layer has actually caught.

+
+ ~750 tests · green + 2 transports · real hardware + Companion read: № 1 — The Landscape +
+
+ +
+ +
+
§ 00
+
+

Four layers, stacked like strata

+
    +
  1. 1
    The unit suite — ~750 pytest tests, + everything mocked.

    Catches logic, protocol parsing, reconcile and state machinery.

  2. +
  3. 2
    The device zoo — contract invariants over + cluster combinations.

    Catches whole classes of device-mapping bugs at once.

  4. +
  5. 3
    The virtual fleet — 15+ genuine Matter + devices, built from matter.js.

    Catches real commissioning, subscriptions and command + round-trips.

  6. +
  7. 4
    Real hardware, live server — shipping + devices on the production path.

    Catches everything else — including what Indigo itself + actually does.

  8. +
+
+
+ +
+
§ 01
+
+

The unit suite

+
cd indigo-matter && python3 -m pytest -q
+

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 fake Indigo models the pessimistic case — it caches the display + state at creation and refuses to re-derive it — so the self-heal paths are exercised under + worst-case behaviour even though real Indigo turned out to be kinder.Test-design + note
+
+
+ +
+
§ 02
+
+

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:

+
    +
  1. each endpoint maps to exactly the expected Indigo device types;
  2. +
  3. at most one actuator device per endpoint — two would fight over one + physical device;
  4. +
  5. every spec’s device type exists in Devices.xml;
  6. +
  7. every seeded initial state is declared or built-in;
  8. +
  9. every sensor carries explicit display props — Indigo derives the device-list display + from creation props, never from XML statics (issue #56).
  10. +
+

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.

+
+ When a strange device shows up + Add its cluster set as one 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. +
+
+
+ +
+
§ 03
+
+

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 (war stories in + HANDOVER.md):

+
    +
  • 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.
  • +
+
Keep test devices spec-minimal rather than maximal. Minimal devices find + more bugs.The fleet’s colour light is XY-only — exactly how it exposed issue + #60
+

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.

+
+
+ +
+
§ 04
+
+

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:

+
    +
  1. Deploy. rsync the build into the live plugin bundle (updates only — first + installs must be double-click installed), restart the plugin.
  2. +
  3. 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.
  4. +
  5. Restart again. The steady state must be quiet. Anything still healing or + warning on pass two is a finding.
  6. +
  7. Instrument if needed. For display/UI questions, a temporary INFO line on the + deployed copy answers in one restart — and never gets committed.
  8. +
+

Real hardware, production path

+ + + + + + + + + + + + + + + +
Validation record
DeviceTransportDateResult
TP-Link Tapo P110MWi-Fi2026-06-10Full Domio share-model flow; on/off + live energy after Tapo’s Matter 1.3 + firmware update. validated
Aqara FP300Thread · HomePod TBR2026-06-12Share model first try — ~10 s join, four endpoints, battery fan-out, unprompted + live reports. Third-party tester. validated
+

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.

+
+ Test a device, file what you find + Good or bad, report it at + github.com/simons-plugins/indigo-matter/issues — + ideally with the endpoint’s cluster list from the diagnostics endpoint. Unknown devices + also appear in Indigo as a “Matter Device (unsupported clusters)” placeholder + whose settings list exactly the cluster ids to report. +
+
+
+ +
+ + + + + + + From cba8099a3839139b30c2ce24b2b4d8cdae7b0de5 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 12 Jun 2026 23:31:20 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(color):=20map=20the=20W=20slider=20?= =?UTF-8?q?=E2=80=94=20Matter=20white=20mode=20is=20CT=20mode=20+=20level?= =?UTF-8?q?=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing found the W slider (whiteLevel) dead: Matter colour lights have no separate white channel, so the handler ignored the channel entirely and, with no Matter attribute to echo it, the Indigo slider snapped back even when RGB worked. - whiteLevel now maps to its Matter meaning: MoveToColorTemperature at the requested/stored white temperature plus MoveToLevelWithOnOff at the slider's intensity (W=0 → off). Handlers may now return a LIST of actions; the plugin sends each sequentially, individually acked. - whiteLevel (and the RGB-side W zeroing) is echoed optimistically via updateStatesOnServer — cosmetic-only, guarded, since no device report will ever carry it. 765 tests. Co-Authored-By: Claude Fable 5 --- .../Server Plugin/matter_handlers/base.py | 6 +- .../matter_handlers/color_control.py | 49 +++++++++++- .../Contents/Server Plugin/plugin.py | 20 +++-- tests/test_dimmer_color.py | 78 +++++++++++++++++++ tests/test_plugin_module.py | 12 +++ 5 files changed, 152 insertions(+), 13 deletions(-) diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py index 31af6e5..c68cd4e 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py @@ -114,6 +114,8 @@ def on_node_event(self, indigo_dev: Any, event_id: int, data: Any) -> dict: return {} # noqa: D401 @abstractmethod - def handle_indigo_action(self, indigo_dev: Any, action: Any) -> Optional[MatterAction]: + def handle_indigo_action(self, indigo_dev: Any, action: Any) -> Optional[Union[MatterAction, list]]: """Translate an Indigo device action to a Matter command or attribute - write (or None).""" + write (or None). May return a LIST of actions for composite operations + (e.g. the colour W slider = colour-temperature mode + level); the + plugin sends them sequentially, each individually acked.""" diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py index 44f220c..9aec919 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/color_control.py @@ -235,18 +235,58 @@ def handle_indigo_action(self, indigo_dev: Any, action: Any) -> Optional[MatterC return self._set_color(indigo_dev, action.actionValue) return super().handle_indigo_action(indigo_dev, action) - def _set_color(self, indigo_dev: Any, values: dict) -> MatterCommand: + @staticmethod + def _apply_optimistic(indigo_dev: Any, states: dict) -> None: + """Echo channels Matter can't report back (whiteLevel has no Matter + attribute — white mode IS colour-temperature mode), so the Indigo UI + slider doesn't snap back. Cosmetic only: a failure here must never + block the command (the real state echo arrives from the device for + everything that has a Matter attribute).""" + updater = getattr(indigo_dev, "updateStatesOnServer", None) + if updater is None: + return + try: + updater([{"key": key, "value": value} for key, value in states.items()]) + except Exception: # noqa: BLE001 - cosmetic echo only + pass + + def _set_color(self, indigo_dev: Any, values: dict): node_id = int(indigo_dev.pluginProps["nodeId"]) endpoint_id = int(indigo_dev.pluginProps["endpointId"]) + states = getattr(indigo_dev, "states", {}) or {} white_temp = values.get("whiteTemperature") - if white_temp: + white_level = values.get("whiteLevel") + rgb_requested = any( + float(values.get(key, 0) or 0) > 0 + for key in ("redLevel", "greenLevel", "blueLevel") + ) + + def ct_command(kelvin: float) -> MatterCommand: return MatterCommand( node_id=node_id, endpoint=endpoint_id, cluster=self.cluster_id, command="MoveToColorTemperature", - args={"colorTemperatureMireds": kelvin_to_mireds(float(white_temp)), + args={"colorTemperatureMireds": kelvin_to_mireds(kelvin), "transitionTime": 0, "optionsMask": 0, "optionsOverride": 0}, ) - states = getattr(indigo_dev, "states", {}) or {} + + # The white side of Indigo's colour picker: whiteTemperature is the + # temperature control, whiteLevel is the W slider (white intensity). + # Matter colour lights have no separate white channel — white mode IS + # colour-temperature mode, and intensity is LevelControl — so the W + # slider becomes CT(current/requested temperature) + MoveToLevelWithOnOff. + if (white_temp or white_level is not None) and not rgb_requested: + kelvin = float(white_temp) if white_temp else float( + states.get("whiteTemperature", 0) or 2700 + ) + commands: list = [ct_command(kelvin)] + optimistic: dict = {"redLevel": 0, "greenLevel": 0, "blueLevel": 0} + if white_level is not None: + commands.append(self._set_level(node_id, endpoint_id, float(white_level))) + optimistic["whiteLevel"] = round(float(white_level)) + self._apply_optimistic(indigo_dev, optimistic) + return commands if len(commands) > 1 else commands[0] + if white_temp: + return ct_command(float(white_temp)) red = float(values.get("redLevel", states.get("redLevel", 0)) or 0) green = float(values.get("greenLevel", states.get("greenLevel", 0)) or 0) blue = float(values.get("blueLevel", states.get("blueLevel", 0)) or 0) @@ -254,6 +294,7 @@ def _set_color(self, indigo_dev: Any, values: dict) -> MatterCommand: # the cached ColorCapabilities bitmap. Unknown capabilities (state # absent/0 — pre-fix device or snapshot not yet primed) keep the # historical HS behaviour rather than guessing. + self._apply_optimistic(indigo_dev, {"whiteLevel": 0}) capabilities = int(states.get("colorCapabilities", 0) or 0) if capabilities and not capabilities & CAP_HUE_SATURATION and capabilities & CAP_XY: x, y = rgb_to_matter_xy(red, green, blue) diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py index 320c7c6..f74670a 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py @@ -255,16 +255,22 @@ def deviceStopComm(self, dev): # noqa: N802 # Device actions → Matter commands (bridged onto the loop, 5s ack) # ------------------------------------------------------------------ def actionControlDevice(self, action, dev): # noqa: N802 - command = self.device_sync.build_command(dev, action) - if command is None: - return - self._send_matter_command(command, dev) + self._send_built_commands(self.device_sync.build_command(dev, action), dev) def actionControlThermostat(self, action, dev): # noqa: N802 - command = self.device_sync.build_command(dev, action) - if command is None: + self._send_built_commands(self.device_sync.build_command(dev, action), dev) + + def _send_built_commands(self, commands, dev) -> None: + """Send a handler's command(s). Handlers may return one MatterAction or + a list for composite operations (the colour W slider is CT mode + level + — two Matter commands); each is sent and acked individually so a + failure surfaces on the device exactly as a single command's would.""" + if commands is None: return - self._send_matter_command(command, dev) + if not isinstance(commands, list): + commands = [commands] + for command in commands: + self._send_matter_command(command, dev) def actionControlSensor(self, action, dev): # noqa: N802 self.logger.info('ignored "%s" — Matter sensor is read-only', dev.name) diff --git a/tests/test_dimmer_color.py b/tests/test_dimmer_color.py index a940737..7a45c54 100644 --- a/tests/test_dimmer_color.py +++ b/tests/test_dimmer_color.py @@ -295,3 +295,81 @@ def test_black_rgb_maps_to_white_point(): from matter_handlers.color_control import rgb_to_matter_xy x, y = rgb_to_matter_xy(0, 0, 0) assert abs(x / 65536 - 0.3127) < 0.01 and abs(y / 65536 - 0.3290) < 0.01 + + +# --------------------------------------------------------------------------- +# W slider (whiteLevel) — Matter has no white channel; white mode IS CT mode +# --------------------------------------------------------------------------- + +def _color_dev(states=None): + from unittest.mock import MagicMock + dev = SimpleNamespace(pluginProps={"nodeId": "9", "endpointId": "1"}, + states=states or {}) + dev.updateStatesOnServer = MagicMock() + return dev + + +def test_white_level_slider_becomes_ct_plus_level(mock_indigo_base): + import indigo + h = ColorControlHandler() + dev = _color_dev({"whiteTemperature": 4000}) + action = SimpleNamespace(deviceAction=indigo.kDeviceAction.SetColorLevels, + actionValue={"whiteLevel": 30, "redLevel": 0, + "greenLevel": 0, "blueLevel": 0}) + commands = h.handle_indigo_action(dev, action) + assert isinstance(commands, list) and len(commands) == 2 + ct, level = commands + assert ct.command == "MoveToColorTemperature" + assert ct.args["colorTemperatureMireds"] == round(1_000_000 / 4000) # stored temp + assert level.command == "MoveToLevelWithOnOff" and level.cluster == 0x0008 + assert level.args["level"] == round(30 * 254 / 100) + # Optimistic echo: W slider has no Matter attribute to report back. + kv = {item["key"]: item["value"] for item in dev.updateStatesOnServer.call_args[0][0]} + assert kv["whiteLevel"] == 30 and kv["redLevel"] == 0 + + +def test_white_level_zero_turns_white_off(mock_indigo_base): + import indigo + h = ColorControlHandler() + dev = _color_dev() + action = SimpleNamespace(deviceAction=indigo.kDeviceAction.SetColorLevels, + actionValue={"whiteLevel": 0}) + commands = h.handle_indigo_action(dev, action) + assert commands[-1].command == "MoveToLevelWithOnOff" + assert commands[-1].args["level"] == 0 + + +def test_white_temp_without_level_stays_single_command(mock_indigo_base): + import indigo + h = ColorControlHandler() + dev = _color_dev() + action = SimpleNamespace(deviceAction=indigo.kDeviceAction.SetColorLevels, + actionValue={"whiteTemperature": 2700}) + cmd = h.handle_indigo_action(dev, action) + assert not isinstance(cmd, list) + assert cmd.command == "MoveToColorTemperature" + + +def test_rgb_request_zeroes_white_level_optimistically(mock_indigo_base): + import indigo + h = ColorControlHandler() + dev = _color_dev({"colorCapabilities": 0x18}) + action = SimpleNamespace(deviceAction=indigo.kDeviceAction.SetColorLevels, + actionValue={"redLevel": 100, "greenLevel": 0, + "blueLevel": 0, "whiteLevel": 0}) + cmd = h.handle_indigo_action(dev, action) + assert cmd.command == "MoveToColor" + kv = {item["key"]: item["value"] for item in dev.updateStatesOnServer.call_args[0][0]} + assert kv == {"whiteLevel": 0} + + +def test_optimistic_echo_skipped_on_devices_without_updater(mock_indigo_base): + """Test doubles / odd device objects without updateStatesOnServer must not + break the command path.""" + import indigo + h = ColorControlHandler() + dev = SimpleNamespace(pluginProps={"nodeId": "9", "endpointId": "1"}, states={}) + action = SimpleNamespace(deviceAction=indigo.kDeviceAction.SetColorLevels, + actionValue={"whiteLevel": 50}) + commands = h.handle_indigo_action(dev, action) + assert commands[-1].args["level"] == round(50 * 254 / 100) diff --git a/tests/test_plugin_module.py b/tests/test_plugin_module.py index 652f2af..7f919dd 100644 --- a/tests/test_plugin_module.py +++ b/tests/test_plugin_module.py @@ -189,3 +189,15 @@ def test_device_start_comm_refreshes_state_list(plugin_cls, mock_logger): dev.stateListOrDisplayStateIdChanged = MagicMock() plugin_cls.deviceStartComm(stub, dev) dev.stateListOrDisplayStateIdChanged.assert_called_once_with() + + +def test_action_control_device_sends_each_command_of_a_list(plugin_cls): + from types import SimpleNamespace + from unittest.mock import MagicMock + stub = SimpleNamespace(device_sync=MagicMock(), _send_matter_command=MagicMock()) + stub._send_built_commands = lambda commands, dev: plugin_cls._send_built_commands(stub, commands, dev) + pair = ["cmd-a", "cmd-b"] + stub.device_sync.build_command.return_value = pair + plugin_cls.actionControlDevice(stub, action=SimpleNamespace(), dev="dev") + sent = [c[0][0] for c in stub._send_matter_command.call_args_list] + assert sent == pair