Skip to content

fix(mapping): endpoint hardening — actuator dupes, unknown-device fallback, type-edit guard (#58)#59

Merged
simons-plugins merged 4 commits into
mainfrom
fix/device-mapping-hardening
Jun 12, 2026
Merged

fix(mapping): endpoint hardening — actuator dupes, unknown-device fallback, type-edit guard (#58)#59
simons-plugins merged 4 commits into
mainfrom
fix/device-mapping-hardening

Conversation

@simons-plugins

@simons-plugins simons-plugins commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Closes #58. Closes #56 (the display follow-ups are resolved here — see the issue comments for the live verification). Audit of the #56 bug class ("Indigo permits things the cluster→device pipeline never assumed").

1. Duplicate actuators for combined-cluster endpoints

The deferral chain was only OnOff→LevelControl→ColorControl (+ FanControl→Thermostat). Nothing deferred to Thermostat, FanControl, WindowCovering, Valve, or DoorLock — but the Matter Fan device type mandates OnOff alongside FanControl (1.2+), and thermostats/locks in the wild expose OnOff too. Each would have produced its proper device plus a duplicate matterRelay fighting over the same hardware.

Fix: ENDPOINT_OWNER_CLUSTERS (Valve, DoorLock, WindowCovering, Thermostat, FanControl) in matter_handlers/base.py; OnOff and LevelControl defer to any of them. The new zoo harness then immediately caught a fourth case on its first run: a colour light without LevelControl duplicated as colour + relay — OnOff now also defers directly to ColorControl.

2. Unknown cluster combinations no longer vanish

An endpoint with only unhandled clusters created nothing, and a node of such endpoints commissioned to SUCCESS with indigoDeviceIds: [] — Domio showed success, Indigo showed nothing. The matterUnknown placeholder (declared in Devices.xml with a supportedClusters field since day one, never wired) is now created for device-bearing endpoints ≥1, with the unhandled cluster ids populated and an INFO log inviting a GitHub device-support report. Endpoint 0 and utility/merge-only-cluster endpoints are excluded via _NON_DEVICE_CLUSTERS. When a later pass finds the endpoint supported (the Tapo firmware-update pattern), the real device is created and an INFO says the placeholder can be deleted — never auto-deleted.

3. Type-edit guard

Indigo's Edit Device dialog always offers the plugin's full Type menu (platform behaviour; can't be removed). A user flipping a sensor to matterRelay desyncs the index → duplicate device next reconcile + a zombie whose actions target clusters the node doesn't implement. Now: createdTypeId stamped into props at creation and healed onto legacy devices by the reconcile self-heal (one-time props replace per existing device on first reconcile after upgrade); validateDeviceConfigUi rejects type changes with an explanatory alert; deviceStartComm warns as backstop for edits made while the plugin was off.

The zoo (tests/test_device_zoo.py)

A contract harness over cluster combinations run through the real registry: expected device types per endpoint, at most one actuator per endpoint, spec types exist in Devices.xml, initial states are XML-declared or built-in, and sensor specs carry explicit display props (the #56 invariant — that check alone would have caught CliveS's bug before he did). Adding a wild device's cluster set to ZOO runs every invariant over it automatically.

Testing

743 tests pass (+109: 96 zoo, 7 fallback/stamp, 6 guard). Not coverable without hardware (noted in HANDOVER): whether replacePluginPropsOnServer re-derives displayStateId in real Indigo, and the #56 button/air-quality display follow-ups.

Version: 2026.2.21 → 2026.2.22 (patch).

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Unknown Matter endpoints now create placeholder devices to preserve cluster information.
  • Bug Fixes

    • Fixed duplicate actuator generation during endpoint discovery.
    • Prevented unintended device type changes through added validation.
  • Tests

    • Added device contract validation test suite for type consistency and state declarations.

…type guard (#58)

Audit of the issue #56 bug class found three gaps between what Indigo
permits and what the cluster→device pipeline assumed:

- Duplicate actuators: OnOff/LevelControl only deferred within the
  lighting chain, so a Matter fan (OnOff is mandatory alongside
  FanControl since 1.2), a thermostat/lock/valve/covering exposing
  OnOff, or a colour light without LevelControl produced a duplicate
  relay/dimmer fighting over the same hardware. New
  ENDPOINT_OWNER_CLUSTERS rule: OnOff and LevelControl defer to any
  rich endpoint owner; OnOff also defers directly to ColorControl
  (found by the zoo harness on its first run).

- Unknown devices vanished: an endpoint with only unhandled clusters
  created nothing, and the commission job reported SUCCESS with
  indigoDeviceIds: [] — Domio showed success, Indigo showed nothing.
  The matterUnknown placeholder (declared in Devices.xml since day
  one, never wired) is now created for device-bearing endpoints with
  supportedClusters populated and an INFO inviting a device-support
  report. When a later pass finds the endpoint supported (the Tapo
  firmware-update pattern) the real device is created and the user is
  told the placeholder can be deleted — never auto-deleted.

- Type-edit guard: Indigo's Edit Device dialog always offers the
  plugin's full Type menu. createdTypeId is stamped at creation (and
  healed onto legacy devices at reconcile); validateDeviceConfigUi
  rejects type changes with an explanatory alert; deviceStartComm
  warns as backstop for edits made while the plugin was off.

tests/test_device_zoo.py is a contract harness over cluster
combinations: expected types, at most one actuator per endpoint, spec
types exist in Devices.xml, initial states declared, sensor specs
carry explicit display props (the #56 class). Add wild devices'
cluster sets to ZOO and every invariant runs over them. 743 tests.

Closes #58

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@simons-plugins, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 23 minutes and 17 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5f9b21d1-1558-413e-914c-ec4da1ecb1c1

📥 Commits

Reviewing files that changed from the base of the PR and between 21f5b6c and 846a23b.

📒 Files selected for processing (9)
  • docs/HANDOVER.md
  • indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml
  • indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/air_quality.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/generic_switch.py
  • tests/test_air_quality.py
  • tests/test_device_sync.py
  • tests/test_device_zoo.py
  • tests/test_generic_switch.py
📝 Walkthrough

Walkthrough

This PR addresses three critical gaps in Matter endpoint mapping: prevents duplicate actuators by deferring lighter handlers when richer ones are present, creates matterUnknown placeholders for unknown endpoints, and guards device type changes via createdTypeId stamping and validation. A device-zoo contract harness enforces structural invariants.

Changes

Endpoint-mapping hardening (issue #58)

Layer / File(s) Summary
Endpoint ownership policy: ENDPOINT_OWNER_CLUSTERS
indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py, matter_handlers/on_off.py, matter_handlers/level_control.py
Exports new ENDPOINT_OWNER_CLUSTERS frozenset (Thermostat 0x0201, FanControl 0x0202, WindowCovering 0x0102, DoorLock 0x0101, Valve 0x0081). OnOffHandler and LevelControlHandler now defer ownership when any of these clusters are present, preventing duplicate relay/dimmer devices on complex endpoints.
Unknown endpoint placeholder creation and healing
indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py
Defines _NON_DEVICE_CLUSTERS to exclude utility-only endpoints from placeholder creation. Adds _unknown_spec() helper to build matterUnknown specs with supportedClusters and reachable=True. Updates create_devices to emit matterUnknown placeholders for device-bearing endpoints with only unhandled clusters, logs cluster inventory, and marks obsolete placeholders during reconcile. Stamps createdTypeId on creation and heals it on old devices.
createdTypeId guard: validation and startup warnings
indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py
Adds validateDeviceConfigUi to reject type changes once createdTypeId is stamped, preventing users from flipping device types in Indigo's UI. Extends deviceStartComm to warn when createdTypeId differs from current deviceTypeId, catching offline edits made while plugin was not running.
Device zoo contract harness
tests/test_device_zoo.py
New pytest module with synthetic cluster/attribute combinations (ZOO fixture). Validates six structural invariants across all endpoints: exactly one actuator per endpoint (preventing duplicates), all device type IDs exist in Devices.xml, initial states are declared or built-in, sensor specs have exclusive SupportsOnState/SupportsSensorValue, and registered handlers exist in Devices.xml.
Integration test coverage
tests/test_device_sync.py, tests/test_plugin_module.py
Added fixture nodes (unknown-only, root-only, electrical-only endpoints) and tests verifying placeholder idempotency, placeholder obsolescence logging, createdTypeId stamping on new and legacy devices, and UI/startup validation of type edits.
Version and documentation
indigo-matter.indigoPlugin/Contents/Info.plist, docs/HANDOVER.md
Bumped PluginVersion to 2026.2.22. Documented #58 fixes, device-zoo harness, and untested hardware caveats in handover notes.

Sequence Diagram

sequenceDiagram
  participant User
  participant EditUI as Indigo Edit Device UI
  participant ValidateUI as validateDeviceConfigUi()
  participant DeviceStartComm as deviceStartComm()
  participant DeviceSync as device_sync
  participant Logger as Logger
  
  User->>EditUI: Try to change device typeId
  EditUI->>ValidateUI: Validate typeId change
  ValidateUI->>ValidateUI: Check if createdTypeId stamped
  ValidateUI-->>EditUI: ❌ Reject with error message
  EditUI-->>User: "Type cannot be changed, delete/reload plugin"
  
  Note over User,Logger: Offline edit scenario (plugin not running)
  User->>EditUI: Manually edit device typeId in config
  
  Note over User,Logger: Plugin restart
  DeviceStartComm->>DeviceStartComm: Load device, compare createdTypeId vs deviceTypeId
  DeviceStartComm->>Logger: ⚠️ Log mismatch warning
  DeviceStartComm->>DeviceSync: note_device() to flag for reconcile
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

This PR introduces a coordinated fix across handler ownership deferral, device creation/healing logic, UI validation, and comprehensive test coverage. The changes span multiple interdependent modules and require understanding the Matter endpoint-to-device mapping pipeline, the deferral chain, placeholder semantics, and the role of createdTypeId as a guard. Medium-density logic edits with heterogeneous file patterns and dense test assertions increase review scope.

Poem

🐰 A rabbit hops through clusters bright,
Defers the light, crowns owners right,
Unknown zones now glow with care,
Types locked safe, no sneaky dare,
Zoo harness guards the way.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly summarizes the three main changes: preventing duplicate actuators, creating unknown-device fallbacks, and adding a type-edit guard to prevent user-induced type mismatches.
Linked Issues check ✅ Passed All primary coding objectives from issue #58 are implemented: duplicate actuator prevention via ENDPOINT_OWNER_CLUSTERS, matterUnknown fallback for unknown endpoints, createdTypeId stamping with validation guards, and device-zoo contract tests for structural invariants.
Out of Scope Changes check ✅ Passed All code changes are directly aligned with the three fixes and testing requirements specified in issue #58; no unrelated changes or scope creep detected.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/device-mapping-hardening

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 and usage tips.

@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: 2

🤖 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 `@indigo-matter.indigoPlugin/Contents/Server` Plugin/device_sync.py:
- Around line 663-667: The persistence check currently compares
bool(applied.get(key)) != bool(value), which treats any non-empty string as
equal and can miss wrong non-empty createdTypeId; update the verification to use
value equality for string fields (e.g., compare applied.get(key) == value) when
key == "createdTypeId" (and similarly for other string properties), and handle
missing vs None explicitly (check key in applied or applied.get(key) is None) so
that wrong non-empty persisted values are detected; modify the logic around
variables key, applied, value (and the construction of missing/createdTypeId
where type_id and current_props are used) to use direct equality instead of bool
coercion.

In `@tests/test_device_sync.py`:
- Line 1423: Tests currently unpack indigo_env into _indigo, devices but never
use devices (RUF059); update the unpacking in the test functions to drop the
unused variable by either assigning the second element to _ (e.g., "_indigo, _ =
indigo_env") or by only extracting the first element (e.g., "_indigo =
indigo_env[0]") wherever you see "_indigo, devices = indigo_env" at the reported
locations so that "devices" is no longer created but test behavior remains
unchanged.
🪄 Autofix (Beta)

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

Run ID: eb9c66df-6267-47bd-9455-3ee73c25564e

📥 Commits

Reviewing files that changed from the base of the PR and between 6487c5b and 21f5b6c.

📒 Files selected for processing (10)
  • docs/HANDOVER.md
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/level_control.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/on_off.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py
  • tests/test_device_sync.py
  • tests/test_device_zoo.py
  • tests/test_plugin_module.py

Comment on lines +663 to +667
# Heal the type-edit guard's stamp onto devices created
# before it existed (issue #58). Records the CURRENT type as
# canonical — for a pristine fleet that is the created type.
if type_id and "createdTypeId" not in current_props:
missing["createdTypeId"] = type_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use value equality when verifying persisted createdTypeId.

At Line 666, createdTypeId (a string) is added to missing, but Lines 695-697 validate persistence with bool(applied.get(key)) != bool(value).
That treats any non-empty string as equivalent, so a wrong non-empty createdTypeId can be falsely reported as “stuck = []”.

Suggested fix
-                    stuck = [
-                        key for key, value in missing.items()
-                        if key not in applied or bool(applied.get(key)) != bool(value)
-                    ]
+                    def _persisted(key: str, expected: Any) -> bool:
+                        actual = applied.get(key)
+                        if key == "createdTypeId":
+                            return actual == expected
+                        return bool(actual) == bool(expected)
+
+                    stuck = [
+                        key for key, value in missing.items()
+                        if key not in applied or not _persisted(key, value)
+                    ]

Also applies to: 694-697

🤖 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/device_sync.py around
lines 663 - 667, The persistence check currently compares bool(applied.get(key))
!= bool(value), which treats any non-empty string as equal and can miss wrong
non-empty createdTypeId; update the verification to use value equality for
string fields (e.g., compare applied.get(key) == value) when key ==
"createdTypeId" (and similarly for other string properties), and handle missing
vs None explicitly (check key in applied or applied.get(key) is None) so that
wrong non-empty persisted values are detected; modify the logic around variables
key, applied, value (and the construction of missing/createdTypeId where type_id
and current_props are used) to use direct equality instead of bool coercion.

Comment thread tests/test_device_sync.py


def test_unknown_placeholder_is_idempotent(ds, indigo_env):
_indigo, devices = indigo_env

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove unused unpacked devices variables in these tests.

Line 1423, Line 1438, and Line 1446 unpack devices but never use it; Ruff flags this as RUF059.

Suggested diff
 def test_unknown_placeholder_is_idempotent(ds, indigo_env):
-    _indigo, devices = indigo_env
+    _indigo, _devices = indigo_env
     ds.create_from_raw(UNKNOWN_NODE, "Vacuum")
     first = ds.lookup(0x50, 1)
     result = ds.create_from_raw(UNKNOWN_NODE, "Vacuum")
     assert result["indigoDeviceIds"] == [first]

 def test_merge_only_endpoint_gets_no_placeholder(ds, indigo_env):
-    _indigo, devices = indigo_env
+    _indigo, _devices = indigo_env
     result = ds.create_from_raw(ELECTRICAL_ONLY_NODE, "Meter")
     assert result["indigoDeviceIds"] == []

 def test_placeholder_obsolete_when_endpoint_becomes_supported(ds, indigo_env, mock_logger):
@@
-    _indigo, devices = indigo_env
+    _indigo, _devices = indigo_env

Also applies to: 1438-1438, 1446-1446

🧰 Tools
🪛 Ruff (0.15.15)

[warning] 1423-1423: Unpacked variable devices is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 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_device_sync.py` at line 1423, Tests currently unpack indigo_env
into _indigo, devices but never use devices (RUF059); update the unpacking in
the test functions to drop the unused variable by either assigning the second
element to _ (e.g., "_indigo, _ = indigo_env") or by only extracting the first
element (e.g., "_indigo = indigo_env[0]") wherever you see "_indigo, devices =
indigo_env" at the reported locations so that "devices" is no longer created but
test behavior remains unchanged.

Source: Linters/SAST tools

… on props replace

Deployed the branch build to jarvis with the 15-device virtual fleet
running: one reconcile healed all pre-fix sensors (verified persistence,
no warnings); a second restart showed zero re-asserts and zero
stale-display warnings, and the temp sensor's on_state went false→null.
The #56 fix is fully self-healing — delete-and-recreate is never needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@simons-plugins

Copy link
Copy Markdown
Owner Author

Live-rig verified on jarvis (see HANDOVER + issue #56 comment): one-pass heal of all 20 pre-fix devices with verified persistence, second reconcile clean — and the open hardware question is answered: Indigo re-derives displayStateId from replacePluginPropsOnServer, so the #56 fix is fully self-healing. jarvis is now running this branch build (2026.2.22).

#56)

Live experiment on jarvis settled the open display question: for
API-created devices a True Supports* prop wins, but with BOTH
SupportsOnState and SupportsSensorValue explicitly False, Indigo falls
back to the Devices.xml <UiDisplayStateId>. Verified by instrumented
reconciles: button displayStateId became lastButtonEvent and air
quality became airQuality (the human-readable string) instead of the
on/off display they were stuck on.

- GenericSwitchHandler and AirQualityHandler set display_props to the
  both-False pair (the existing reconcile self-heal upgrades fielded
  devices automatically — no recreation needed, as verified live).
- The zoo invariant now allows (False, False) only when the XML
  declares a UiDisplayStateId pointing at a declared custom state —
  and it immediately caught that generic_switch never merged
  display_props into its spec props.
- The stale-display nag covers any type that disclaims SupportsOnState
  (value sensors and UiDisplayStateId-fallback types alike).
- FakeDev models the verified precedence rule.

747 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Devices created before the issue #18 address stamping show an empty
Address column (spotted by Simon on the original five virtual devices,
e.g. 'node-matter Thermostat') — and that address is the node id a
user needs for decommission. The reconcile self-heal now writes it
when absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@simons-plugins simons-plugins merged commit e9c0272 into main Jun 12, 2026
3 checks passed
@simons-plugins simons-plugins deleted the fix/device-mapping-hardening branch June 12, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant