diff --git a/docs/plans/ISSUE-79-split-endpoint-energy.md b/docs/plans/ISSUE-79-split-endpoint-energy.md
new file mode 100644
index 0000000..dcc532e
--- /dev/null
+++ b/docs/plans/ISSUE-79-split-endpoint-energy.md
@@ -0,0 +1,232 @@
+# Plan: Issue #79 — Energy measurement on a separate endpoint (IKEA GRILLPLATS)
+
+**Status:** REVIEWED — adversarially verified against the codebase 2026-07-05
+**Issue:** https://github.com/simons-plugins/indigo-matter/issues/79
+**Author:** Claude (sonnet subagent review 2026-07-05)
+
+## Problem
+
+The IKEA GRILLPLATS Thread plug exposes On/Off on endpoint 1 and its energy
+clusters — Electrical Power Measurement (0x0090), Electrical Energy
+Measurement (0x0091), Power Topology (0x009C) — on a dedicated endpoint 2
+(the Matter 1.3 "Electrical Sensor" device type, 0x0510). The plugin only
+supports Tapo-style co-located energy, so today:
+
+- Endpoint 2 becomes a stateless `matterUnknown` placeholder (0x9C is the
+ only cluster that survives the `_NON_DEVICE_CLUSTERS` filter).
+- The endpoint-1 relay is created without `SupportsPowerMeter` /
+ `SupportsEnergyMeter` props, so no `curEnergyLevel` / `accumEnergyTotal`
+ states exist.
+- Even if states existed, `_on_attribute()` routes endpoint-2 reports to the
+ endpoint-2 device (the placeholder), never the relay.
+
+## Spec grounding (research brief, 2026-07-05)
+
+- Power Topology (0x009C) features: **NodeTopology** (bit 0, "measures the
+ whole node", no endpoint list), TreeTopology (bit 1), **SetTopology**
+ (bit 2, gates `AvailableEndpoints` attr 0x0000), DynamicPowerFlow (bit 3,
+ gates `ActiveEndpoints` attr 0x0001).
+- **GRILLPLATS uses NodeTopology** — there is no endpoint list to read.
+ Correct client behaviour is to attribute readings to the node's primary
+ controllable endpoint by convention.
+- Existing attribute IDs in `electrical.py` (ActivePower 0x0008 mW,
+ CumulativeEnergyImported 0x0001 mWh struct) are spec-correct; only
+ endpoint attribution changes.
+- Electrical Sensor device type 0x0510 requires exactly: Descriptor + 0x90 +
+ 0x91 + 0x9C — the GRILLPLATS endpoint-2 layout.
+- Home Assistant does not yet handle this pattern cleanly (core #149847) —
+ no upstream implementation to port.
+
+## Design: a per-node "meter link" map
+
+One new concept: at device-planning time, resolve each standalone
+energy-measurement endpoint to the endpoint whose power it measures, and
+record the link **in both directions** — forward
+`(node_id, source_ep) → target_ep` for live routing, and reverse
+`(node_id, target_ep) → [source_eps]` for priming and self-heal (both
+`_prime_states` and `_reassert_capability_props` start from the *target*
+device and need to find its sources). All the same-endpoint assumptions
+(creation props, state priming, live routing, reconcile self-heal) consult
+this map. No `node_scoped` fan-out (over-broadcasts on bridges), no new
+persistent storage (map is deterministic from the node model, rebuilt on
+every reconcile).
+
+### Link resolution algorithm (`device_sync.py`, new helper)
+
+A **candidate source endpoint** is a device-bearing endpoint (≥1) that has
+0x90 or 0x91 and would produce no primary handler spec.
+
+For each candidate, resolve the target:
+
+1. **SetTopology** (0x9C FeatureMap 0xFFFC bit 2 set): read
+ `AvailableEndpoints` (attr 0x0000; prefer `ActiveEndpoints` 0x0001 when
+ DynamicPowerFlow is set) from `node.attributes`. Link only when exactly
+ one listed endpoint hosts a `_METER_CAPABLE_TYPES` device; ambiguous
+ multi-match falls back to the standalone device. v1 keeps the link
+ static after reconcile (DYPF changes picked up on next reconcile — note
+ as follow-up).
+2. **NodeTopology, no 0x9C at all, or SetTopology attrs unreadable**: find
+ endpoints that will host a `_METER_CAPABLE_TYPES` device
+ (relay/dimmer/colour). If **exactly one**, link to it. If zero or more
+ than one (bridge, power strip without SET), attribution fails →
+ fallback device (below). Never guess on multi-actuator nodes.
+
+### Changes by file
+
+**`matter_handlers/electrical.py`**
+- Add `CLUSTER_POWER_TOPOLOGY = 0x009C` + FeatureMap/attr constants.
+
+**`device_sync.py`**
+1. Add `0x009C` to `_NON_DEVICE_CLUSTERS` (merge-only, like 0x90/0x91) —
+ this alone stops the placeholder (verified: `_unknown_spec()` returns
+ None once `{29,144,145,156}` minus the set is empty, and the electrical
+ handlers never produce a primary spec — two independent guarantees).
+2. `create_devices()` becomes **two-pass**: first pass calls
+ `handlers_for_endpoint()` for every endpoint and caches the specs (needed
+ to know which endpoints will host `_METER_CAPABLE_TYPES` devices);
+ link resolution runs between the passes; second pass creates devices
+ from the cached specs. Precedent for pre-loop node-wide computation
+ already exists: the `SupportsBatteryLevel` any-endpoint scan at
+ `device_sync.py:298-300` + `setdefault` injection at `:328`.
+3. Creation: inject `SupportsPowerMeter` / `SupportsEnergyMeter` into the
+ target endpoint's cached spec based on the *linked source endpoint's*
+ clusters (central injection in device_sync; the existing same-endpoint
+ checks in on_off/level/color stay for the Tapo case).
+4. `_lookup_for_cluster()`: for clusters 0x90/0x91, rewrite `endpoint_id`
+ via the forward map **at the top of the function** (the electrical
+ handlers have `device_type_id=""`, so execution always falls through to
+ the bare `lookup()` — the rewrite must happen before that). After
+ rewriting, filter the target endpoint's type-map by
+ `_METER_CAPABLE_TYPES` rather than relying on sole/first-device
+ semantics, so a multi-device target endpoint can never mis-route.
+5. `_prime_states()`: the per-device attribute loop currently skips
+ `ep != endpoint_id` (`:630`); extend the condition to also accept
+ `ep in reverse_links[(node_id, endpoint_id)]` for the electrical
+ handlers.
+6. Reconcile self-heal: `_capability_props()` is currently a `@staticmethod`
+ that inspects only the endpoint passed in — it must become an instance
+ method (or take the links map as a parameter) so it can include linked
+ source-endpoint clusters when computing meter props for the target.
+ This heals the existing jarvis relay 1794293937 in place via
+ `replacePluginPropsOnServer` (self-heal pattern live-verified in the
+ #56 work per HANDOVER.md; no delete-and-recreate).
+7. Log fix (issue point 3): `_unknown_spec()` log/props list **all** the
+ endpoint's non-utility clusters, annotating which are merge-only, e.g.
+ `unsupported: 0x009C (endpoint also exposes merge-only 0x0090, 0x0091)`.
+
+**Fallback device (issue point 2)** — when attribution fails:
+- New Devices.xml type `matterEnergyMeter` (custom). **State ids must match
+ what the electrical handlers already write**, because custom types get no
+ auto-derived states from `Supports*Meter` props and the handlers guard on
+ key presence: declare `curEnergyLevel` (W, UiDisplayStateId),
+ `accumEnergyTotal` (kWh), `reachable`. (An earlier draft said
+ `curPowerLevel` — that would silently drop every power update.)
+- Built by device_sync directly (a `_energy_meter_spec()` sibling of
+ `_unknown_spec()`), **not** via a registered `ClusterHandler` —
+ `is_primary_for()` has no access to cross-endpoint link state, and
+ `_unknown_spec()` is the established precedent for device_sync-owned
+ specs. Electrical handlers then write to it via the normal per-endpoint
+ route (no link needed — device lives on the source endpoint).
+
+**Behaviour change to an existing test:** `ELECTRICAL_ONLY_NODE`
+(fixture at `test_device_sync.py:1430`, assertion in
+`test_merge_only_endpoint_gets_no_placeholder` at `:1471-1474`) currently
+asserts "no device at all". Under this plan a node whose *only* device
+endpoint is electrical gets the fallback `matterEnergyMeter` instead —
+update the test; it is the issue's requested behaviour.
+
+### Tests
+
+- **Zoo** (`test_device_zoo.py`): add a multi-endpoint GRILLPLATS entry
+ (ep1: Descriptor/Identify/Groups/OnOff; ep2: Descriptor/0x90/0x91/0x9C —
+ expressed as `_raw()` attribute paths, not bare cluster lists). Scope:
+ the zoo exercises `HandlerRegistry.handlers_for_endpoint()` directly and
+ has no `DeviceSync`, so it can only assert the type mapping — **relay on
+ ep1, nothing on ep2**. Meter-prop and link assertions belong in
+ `test_device_sync.py`.
+- **`test_device_sync.py`** (via the existing `ds`/`indigo_env` fixtures):
+ link resolution (NodeTopology; SetTopology with
+ `AvailableEndpoints=[1]`; no 0x9C + single actuator; ambiguous
+ two-actuator node → fallback; electrical-only node → fallback device);
+ creation-time meter props on the ep1 relay; live routing (ep2
+ ActivePower report updates ep1 relay's `curEnergyLevel`); priming from
+ linked endpoint; reconcile self-heal adds meter props to a pre-existing
+ relay without recreation.
+- **`test_electrical.py`**: parsing untouched; add a cross-endpoint stub
+ case if needed.
+- Full suite green (`python3 -m pytest -q`, 743+ tests).
+
+### Rollout
+
+1. PR with version bump (`PluginVersion` in Info.plist). 2026.4.0 shipped
+ (the repo was already at 2026.3.2 by the time this landed, past the
+ 2026.3.0/2026.2.24 suggestion above — kept here for the historical record
+ of the original recommendation).
+2. Deploy to jarvis (cp + restart), verify:
+ - Reconcile heals relay 1794293937 → props + energy states appear.
+ - Live wattage/energy from GRILLPLATS (it's on the house fabric).
+ - Placeholder 1456954491 triggers the "can be deleted" obsolescence log.
+ - Tapo P110M co-located path unchanged.
+3. Update issue #79 with results; add GRILLPLATS to the zoo note in
+ HANDOVER.md.
+
+### Risks / notes
+
+- **Unverified external assumption:** SetTopology resolution needs the 0x9C
+ FeatureMap (`node.attributes[(2, 0x009C, 0xFFFC)]`). `parse_node()` keeps
+ every attribute path with no allowlist, so the plugin side is fine, but
+ whether matter-server includes global attributes of an *unhandled*
+ cluster in its dump must be checked against the live GRILLPLATS before
+ coding the SetTopology branch (`/diagnostics?nodeId=0x34` or a raw node
+ dump). If absent, the NodeTopology/heuristic path still works — it needs
+ no attribute reads.
+- Primary real-device path is the NodeTopology heuristic ("sole actuator");
+ SetTopology is synthetic-tested only until a power strip shows up.
+- Bridges: link resolution must run per *node*, and bridged nodes with
+ multiple actuators deliberately fall back rather than guess.
+- `attributes_to_subscribe()` is dead code (server streams everything), so
+ no subscription changes needed — routing is purely a device_sync concern.
+- Follow-up (not in scope): DynamicPowerFlow live re-linking; Voltage /
+ ActiveCurrent extra states (0x90 optional attrs — GRILLPLATS has the AC
+ feature, could surface later).
+
+### Post-review addendum (2026-07-05)
+
+Four review agents plus an audit surfaced a consolidated fix batch, applied on
+top of the original #79 implementation in the same PR (#80):
+
+- **A — co-located target crosstalk:** an endpoint with its own co-located
+ 0x0090/0x0091 (Tapo-style) could still be selected as a link TARGET for an
+ unrelated orphaned electrical-only endpoint on the same node, letting the
+ orphan's readings overwrite the co-located device's own. Fixed by excluding
+ any endpoint that already carries 0x0090/0x0091 itself from
+ `meter_capable_eps` in `_resolve_meter_links`.
+- **B — obsolescence log unreachable for meter-linked endpoints:** the
+ "placeholder can be deleted" log lived in an `elif` gated on non-empty
+ specs, so an endpoint reclassified as a meter-linked source (specs stay
+ empty) or an ambiguous-fallback `matterEnergyMeter` never triggered it,
+ orphaning the old placeholder. Restructured `create_devices` to capture
+ `had_placeholder` up front and emit the log whenever the endpoint no longer
+ needs a `matterUnknown` placeholder this pass, regardless of which path
+ produced its final spec list.
+- **C — unguarded `int()` over the SetTopology endpoint list:** a malformed
+ `AvailableEndpoints`/`ActiveEndpoints` element (`None`, a string, a dict)
+ raised inside `_resolve_meter_target`, and since link resolution runs
+ inside `create_devices`' lock-held body this aborted the whole node's
+ device creation. Now wrapped in `try/except (TypeError, ValueError)`,
+ degrading to the sole-actuator heuristic.
+- **D — debug logging on link-resolution failure:** both return-None paths in
+ `_resolve_meter_target` now log at debug (node, source endpoint, candidate
+ set, reason) so "why do I have two devices for my plug" is diagnosable.
+- **Issue #81 (rolled in) — silent partial coverage:** when an endpoint
+ produces real handler spec(s), any remaining cluster not claimed by a
+ registered handler and not in `_NON_DEVICE_CLUSTERS` is now surfaced via an
+ INFO log (no second device) instead of silently dropped.
+- **Issue #82 (rolled in) — bridge battery cross-contamination:** a node with
+ more than one PowerSource-bearing endpoint no longer fans battery updates
+ node-wide across creation props, `_prime_states`, and the live
+ `_on_attribute` fan-out (plus the reconcile self-heal path,
+ `_capability_props`) — each device now only gets the battery capability/
+ update from its OWN endpoint. A node with exactly one PowerSource-bearing
+ endpoint keeps the original node-wide behaviour (the common case, e.g.
+ FP300: battery on ep0, sensor on ep1).
diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist
index d95a704..44b1e42 100644
--- a/indigo-matter.indigoPlugin/Contents/Info.plist
+++ b/indigo-matter.indigoPlugin/Contents/Info.plist
@@ -20,7 +20,7 @@
IwsApiVersion1.0.0PluginVersion
- 2026.3.2
+ 2026.4.0ServerApiVersion3.6
diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml
index 4ade3b1..68a37f9 100644
--- a/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml
+++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml
@@ -751,6 +751,51 @@
lastButtonEvent
+
+
+
+
+
+ Matter Energy Meter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Number
+ Power
+ Power is
+ Power (W)
+
+
+ Number
+ Total Energy
+ Total Energy is
+ Total Energy (kWh)
+
+
+ Boolean
+ Reachable
+ Reachable
+
+
+ curEnergyLevel
+
+
diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py
index 871f248..43c998e 100644
--- a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py
+++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py
@@ -35,7 +35,16 @@
parse_node,
)
from matter_handlers.base import IndigoDeviceSpec
-from matter_handlers.electrical import CLUSTER_ELECTRICAL_ENERGY, CLUSTER_ELECTRICAL_POWER
+from matter_handlers.electrical import (
+ ATTR_ACTIVE_ENDPOINTS,
+ ATTR_AVAILABLE_ENDPOINTS,
+ ATTR_POWER_TOPOLOGY_FEATURE_MAP,
+ CLUSTER_ELECTRICAL_ENERGY,
+ CLUSTER_ELECTRICAL_POWER,
+ CLUSTER_POWER_TOPOLOGY,
+ FEATURE_DYNAMIC_POWER_FLOW,
+ FEATURE_SET_TOPOLOGY,
+)
from matter_handlers.power_source import CLUSTER_POWER_SOURCE
from protocol import MatterCommand
@@ -63,6 +72,15 @@
0x0062, # ScenesManagement (Matter 1.3 id)
0x0090, # ElectricalPowerMeasurement (merge-only)
0x0091, # ElectricalEnergyMeasurement (merge-only)
+ 0x009C, # PowerTopology (merge-only; drives meter-link resolution, issue #79)
+})
+
+#: Electrical clusters annotated in the _unknown_spec log/props when they
+#: co-occur with a genuinely-unsupported cluster on the same endpoint (issue
+#: #79 point 3) — distinct from _NON_DEVICE_CLUSTERS, which also includes
+#: plain utility clusters (Identify, Groups, …) that aren't worth calling out.
+_ELECTRICAL_MERGE_CLUSTERS = frozenset({
+ CLUSTER_ELECTRICAL_POWER, CLUSTER_ELECTRICAL_ENERGY, CLUSTER_POWER_TOPOLOGY,
})
@@ -94,6 +112,7 @@
"matterCO2Sensor": "CO₂",
"matterPM25Sensor": "PM2.5",
"matterTVOCSensor": "TVOC",
+ "matterEnergyMeter": "Energy",
"matterUnknown": "Unsupported",
}
@@ -113,6 +132,32 @@ def __init__(self, registry: Any, logger: Any) -> None:
self._index: dict[tuple[int, int], dict[str, int]] = {}
self._active: set[int] = set()
self._lock = threading.RLock()
+ # Meter-link maps for split-endpoint energy measurement (issue #79 —
+ # e.g. IKEA GRILLPLATS: relay on ep1, ElectricalPower/Energy on ep2).
+ # Deterministic from the node model, rebuilt by _resolve_meter_links on
+ # every create_devices/reconcile pass — never persisted.
+ # Forward: (node_id, source_ep) -> target_ep, for live attribute routing.
+ self._forward_links: dict[tuple[int, int], int] = {}
+ # Reverse: (node_id, target_ep) -> {source_eps}, for state priming and
+ # capability-prop self-heal (both start from the target device).
+ self._reverse_links: dict[tuple[int, int], set[int]] = {}
+ # Every cluster this plugin recognizes at all — either a registered
+ # ClusterHandler (whether or not it's primary for a given endpoint;
+ # e.g. PowerSource/Electrical never produce a spec of their own but
+ # ARE recognized) or a plain utility cluster. Used by the issue #81
+ # "leftover cluster" diagnostic to distinguish a genuinely-unsupported
+ # cluster from one this plugin already accounts for some other way.
+ self._recognized_clusters = frozenset(
+ h.cluster_id for h in registry.handlers
+ ) | _NON_DEVICE_CLUSTERS
+ # PowerSource-bearing endpoints per node (issue #82 — bridge battery
+ # cross-contamination). Populated by create_devices/_resolve_meter_links-
+ # adjacent bookkeeping on every create pass; consulted by creation,
+ # priming, and live fan-out to decide whether PowerSource is node-wide
+ # (the common single-endpoint case, e.g. FP300: battery on ep0, sensor
+ # on ep1) or must stay confined to its own endpoint (a bridge with
+ # more than one battery-bearing child).
+ self._power_source_eps: dict[int, set[int]] = {}
# ------------------------------------------------------------------
# Active-device tracking (deviceStartComm/deviceStopComm)
@@ -176,22 +221,48 @@ def _all_dev_ids_for_endpoint(self, node_id: Any, endpoint_id: Any) -> list[int]
def _lookup_for_cluster(self, node_id: Any, endpoint_id: Any, cluster: int) -> Optional[int]:
"""Resolve the correct Indigo device for a non-node-scoped cluster update.
+ 0. Electrical measurement clusters (0x0090/0x0091) are rewritten to
+ their linked target endpoint FIRST (issue #79 — split-endpoint
+ energy, e.g. IKEA GRILLPLATS): the electrical handlers have no
+ ``device_type_id``, so without this rewrite step 3's bare
+ ``lookup()`` would resolve (or fail to resolve) against the source
+ endpoint itself, which has no device when the link succeeded.
1. Resolve the handler's ``device_type_id`` (if any).
2. If that type is present in the endpoint's type-map → return it.
3. Else fall back to ``lookup(node, ep)`` (single/first device) —
covers merge-into cases: FanControl→thermostat, Electrical→relay.
+
+ Step 0 is numbered to match execution order in the code below, not
+ just to list a concern — it must run before the bare ``lookup()`` in
+ step 3 has a chance to miss the (now-empty) source endpoint.
"""
+ nid, eid = int(node_id), int(endpoint_id)
+ if cluster in (CLUSTER_ELECTRICAL_POWER, CLUSTER_ELECTRICAL_ENERGY):
+ with self._lock:
+ target = self._forward_links.get((nid, eid))
+ if target is not None:
+ eid = target
+ # The linked target endpoint may host more than one device
+ # (e.g. a colour light); prefer the meter-capable one over the
+ # sole/first-device fallback in step 3 so a multi-device
+ # endpoint can never mis-route an energy reading.
+ with self._lock:
+ type_map = self._index.get((nid, eid))
+ if type_map:
+ for cap_type in _METER_CAPABLE_TYPES:
+ if cap_type in type_map:
+ return type_map[cap_type]
handler = self.registry.handler_for_cluster(cluster)
if handler is None:
return None
type_id = getattr(handler, "device_type_id", "") or ""
if type_id:
with self._lock:
- type_map = self._index.get((int(node_id), int(endpoint_id)))
+ type_map = self._index.get((nid, eid))
if type_map and type_id in type_map:
return type_map[type_id]
# Fallback: use the single/first device on the endpoint
- return self.lookup(node_id, endpoint_id)
+ return self.lookup(nid, eid)
def note_device(self, dev: Any) -> None:
"""Index a single device from its pluginProps (deviceStartComm)."""
@@ -291,41 +362,126 @@ def create_devices(self, node: NodeInfo, suggested_room: Optional[str] = None) -
# not by how many happen to be missing on this pass. A plug's root
# endpoint 0 produces no handler, so this is not len(node.endpoints).
plan: list[tuple] = [] # (endpoint, spec)
- # Detect PowerSource anywhere on the node so we can set
- # SupportsBatteryLevel on every device we create. Indigo applies
+ # Detect which endpoint(s) carry PowerSource. Indigo applies
# Supports* via device props at creation, not Devices.xml statics
# (the colour-support lesson — see HANDOVER 2026-06-09 item 4).
- node_has_power_source = any(
- endpoint.has(CLUSTER_POWER_SOURCE) for endpoint in node.endpoints
- )
+ # Single PowerSource-bearing endpoint (the common case — e.g. FP300:
+ # battery on ep0, sensor on ep1) keeps the original node-wide
+ # behaviour: SupportsBatteryLevel on every device regardless of its
+ # own endpoint. More than one PowerSource-bearing endpoint (a
+ # bridge with multiple battery-powered children) must NOT fan out
+ # node-wide — issue #82's cross-contamination bug — so each
+ # device only gets the prop when ITS OWN endpoint bears PowerSource.
+ power_source_eps = {
+ int(endpoint.endpoint_id) for endpoint in node.endpoints
+ if endpoint.has(CLUSTER_POWER_SOURCE)
+ }
+ multi_power_source = len(power_source_eps) > 1
+ with self._lock:
+ if power_source_eps:
+ self._power_source_eps[int(node.node_id)] = power_source_eps
+ else:
+ self._power_source_eps.pop(int(node.node_id), None)
+ # Pass 1: cache every endpoint's handler-produced specs BEFORE any
+ # fallback/placeholder decision. Meter-link resolution (issue #79)
+ # needs to know which endpoints will host a _METER_CAPABLE_TYPES
+ # device regardless of endpoint iteration order — precedent for
+ # pre-loop node-wide computation is the SupportsBatteryLevel scan
+ # just above.
+ specs_by_ep: dict[int, list] = {
+ int(endpoint.endpoint_id): self.registry.handlers_for_endpoint(node, endpoint)
+ for endpoint in node.endpoints
+ }
+ self._resolve_meter_links(node, specs_by_ep)
+
for endpoint in node.endpoints:
- specs = self.registry.handlers_for_endpoint(node, endpoint)
+ eid = int(endpoint.endpoint_id)
+ ep_key = (int(node.node_id), eid)
+ raw_specs = specs_by_ep[eid]
+ specs = list(raw_specs)
+ # Captured up front (issue #80 review point B) so the
+ # obsolescence log below can fire for EVERY way an endpoint
+ # stops needing its matterUnknown placeholder this pass — not
+ # just the "already had real specs" case the old `elif` only
+ # covered. Without this, an endpoint reclassified as a
+ # meter-linked source (specs stay empty) or as an ambiguous
+ # matterEnergyMeter fallback never got the "can be deleted"
+ # log, orphaning the placeholder (or leaving a duplicate
+ # device with no log connecting them).
+ had_placeholder = bool(self._index.get(ep_key, {}).get("matterUnknown"))
if not specs:
- # No handler claimed this endpoint. If it carries clusters
- # that DO make it a device (just ones we don't support yet),
- # surface it as a matterUnknown placeholder instead of
- # silently creating nothing — a commission that "succeeds"
- # with no visible device is a half-happened op (issue #58).
- # Endpoint 0 is the Matter root (utility clusters only) and
- # never gets a placeholder.
- fallback = self._unknown_spec(node, endpoint)
- if fallback is not None:
- specs = [fallback]
- elif self._index.get(
- (int(node.node_id), int(endpoint.endpoint_id)), {}
- ).get("matterUnknown"):
- # The endpoint used to be unsupported (placeholder exists)
- # but now maps to real device(s) — e.g. a firmware update
- # added clusters. Never auto-delete a user's device; tell
- # them the placeholder is obsolete instead.
+ if self._is_meter_source_candidate(endpoint, specs):
+ # Standalone energy-measurement endpoint (e.g. IKEA
+ # GRILLPLATS ep2): if link resolution found the
+ # endpoint it measures, no device is created here at
+ # all — readings route to the linked target via
+ # _lookup_for_cluster. Otherwise fall back to a
+ # standalone matterEnergyMeter device rather than the
+ # matterUnknown placeholder (issue #79).
+ if ep_key not in self._forward_links:
+ specs = [self._energy_meter_spec(node, endpoint)]
+ else:
+ # No handler claimed this endpoint. If it carries clusters
+ # that DO make it a device (just ones we don't support yet),
+ # surface it as a matterUnknown placeholder instead of
+ # silently creating nothing — a commission that "succeeds"
+ # with no visible device is a half-happened op (issue #58).
+ # Endpoint 0 is the Matter root (utility clusters only) and
+ # never gets a placeholder.
+ fallback = self._unknown_spec(node, endpoint)
+ if fallback is not None:
+ specs = [fallback]
+ elif raw_specs:
+ # Endpoint produced real handler spec(s): surface any
+ # cluster this plugin recognizes nothing about at all, so
+ # it isn't silently dropped once a device already exists
+ # for the endpoint (issue #81 — e.g. a pump exposing
+ # OnOff + an unhandled cluster: the relay is created but
+ # the extra cluster vanishes with no diagnostic). No
+ # second device — an INFO log only, same voice as the
+ # matterUnknown placeholder log; may repeat every
+ # reconcile pass (same precedent as the obsolescence log
+ # below).
+ leftover = sorted(set(endpoint.cluster_ids) - self._recognized_clusters)
+ if leftover:
+ self.logger.info(
+ "node %s endpoint %s also exposes clusters this plugin "
+ "does not support yet (%s) — please report the device "
+ "at github.com/simons-plugins/indigo-matter/issues",
+ node_id_to_str(node.node_id), endpoint.endpoint_id,
+ ", ".join(f"0x{c:04X}" for c in leftover),
+ )
+ if had_placeholder and not any(s.device_type_id == "matterUnknown" for s in specs):
+ # The endpoint no longer needs (or has already replaced)
+ # its matterUnknown placeholder this pass. Never
+ # auto-delete a user's device; tell them it's obsolete.
self.logger.info(
"node %s endpoint %s is now supported — the 'Matter Device "
"(unsupported clusters)' placeholder device can be deleted",
node_id_to_str(node.node_id), endpoint.endpoint_id,
)
for spec in specs:
- if node_has_power_source:
+ # Single-PowerSource-endpoint node: fan out to every
+ # device as before. Multi-PowerSource-endpoint node
+ # (issue #82): only the device(s) on the SAME endpoint as
+ # a PowerSource cluster get the prop.
+ if power_source_eps and (not multi_power_source or eid in power_source_eps):
spec.props.setdefault("SupportsBatteryLevel", True)
+ if spec.device_type_id in _METER_CAPABLE_TYPES:
+ # Central injection (issue #79): a linked source
+ # endpoint's electrical clusters unlock the meter
+ # states on the TARGET device. The existing
+ # same-endpoint checks in on_off/level_control/
+ # color_control stay untouched — they cover the
+ # Tapo-style co-located case.
+ for src_eid in self._reverse_links.get(ep_key, ()):
+ src_endpoint = self._endpoint_by_id(node, src_eid)
+ if src_endpoint is None:
+ continue
+ if src_endpoint.has(CLUSTER_ELECTRICAL_POWER):
+ spec.props.setdefault("SupportsPowerMeter", True)
+ if src_endpoint.has(CLUSTER_ELECTRICAL_ENERGY):
+ spec.props.setdefault("SupportsEnergyMeter", True)
plan.append((endpoint, spec))
multi = len(plan) > 1
@@ -467,6 +623,16 @@ def _unknown_spec(node: NodeInfo, endpoint: Any) -> Optional[IndigoDeviceSpec]:
if not unmapped:
return None
name = node.suggested_name or node.product_name or f"Matter {node.node_id}"
+ supported = ", ".join(f"0x{c:04X}" for c in unmapped)
+ # List merge-only electrical clusters too when they co-occur with a
+ # genuinely-unsupported one, so the log/UI doesn't silently drop them
+ # (issue #79 point 3) — they're real clusters on this endpoint, just
+ # not the reason a placeholder was needed.
+ merge_only = sorted(set(endpoint.cluster_ids) & _ELECTRICAL_MERGE_CLUSTERS)
+ if merge_only:
+ supported += " (endpoint also exposes merge-only {})".format(
+ ", ".join(f"0x{c:04X}" for c in merge_only)
+ )
return IndigoDeviceSpec(
device_type_id="matterUnknown",
name=name,
@@ -475,11 +641,177 @@ def _unknown_spec(node: NodeInfo, endpoint: Any) -> Optional[IndigoDeviceSpec]:
"endpointId": str(endpoint.endpoint_id),
"vendorName": node.vendor_name,
"productName": node.product_name,
- "supportedClusters": ", ".join(f"0x{c:04X}" for c in unmapped),
+ "supportedClusters": supported,
},
initial_states={"reachable": True},
)
+ @staticmethod
+ def _energy_meter_spec(node: NodeInfo, endpoint: Any) -> IndigoDeviceSpec:
+ """Fallback spec for a standalone energy-measurement endpoint whose
+ reading can't be attributed to any actuator endpoint (issue #79) —
+ e.g. a bridge, or a power strip with more than one relay and no
+ SetTopology endpoint list. Sibling of ``_unknown_spec`` — built
+ directly by device_sync, not via a registered ``ClusterHandler``
+ (``is_primary_for`` has no access to cross-endpoint link state;
+ ``_unknown_spec`` is the established device_sync-owned-spec
+ precedent). ElectricalPowerHandler/ElectricalEnergyHandler then write
+ to this device via the normal per-endpoint route — no link needed,
+ since the device lives on the source endpoint itself.
+ """
+ name = node.suggested_name or node.product_name or f"Matter {node.node_id}"
+ return IndigoDeviceSpec(
+ device_type_id="matterEnergyMeter",
+ name=name,
+ props={
+ "nodeId": str(node.node_id),
+ "endpointId": str(endpoint.endpoint_id),
+ "vendorName": node.vendor_name,
+ "productName": node.product_name,
+ },
+ initial_states={"curEnergyLevel": 0.0, "accumEnergyTotal": 0.0, "reachable": True},
+ )
+
+ @staticmethod
+ def _endpoint_by_id(node: NodeInfo, endpoint_id: int) -> Optional[Any]:
+ for endpoint in node.endpoints:
+ if int(endpoint.endpoint_id) == endpoint_id:
+ return endpoint
+ return None
+
+ @staticmethod
+ def _is_meter_source_candidate(endpoint: Any, specs: list) -> bool:
+ """A device-bearing endpoint that has 0x0090/0x0091 but produces no
+ primary handler spec of its own — a standalone energy-measurement
+ endpoint whose reading must be attributed to another endpoint, or
+ fall back to a standalone matterEnergyMeter device (issue #79).
+
+ Requires the endpoint's clusters to be otherwise entirely within
+ _NON_DEVICE_CLUSTERS (the same bar _unknown_spec uses): an endpoint
+ that ALSO carries a genuinely-unsupported cluster is not a "pure"
+ energy-measurement endpoint, so it must fall through to the
+ matterUnknown placeholder path instead — otherwise the unsupported
+ cluster is silently dropped with no placeholder and no log line,
+ reintroducing the exact "commission succeeds with nothing visible"
+ failure mode issue #58 fixed.
+ """
+ if int(endpoint.endpoint_id) == 0 or specs:
+ return False
+ if not (endpoint.has(CLUSTER_ELECTRICAL_POWER) or endpoint.has(CLUSTER_ELECTRICAL_ENERGY)):
+ return False
+ return not set(endpoint.cluster_ids) - _NON_DEVICE_CLUSTERS
+
+ def _resolve_meter_target(self, node: NodeInfo, source_endpoint: Any,
+ meter_capable_eps: set) -> Optional[int]:
+ """Resolve the single endpoint a candidate source endpoint's energy
+ readings should be attributed to, or None if attribution is ambiguous
+ (caller falls back to a standalone matterEnergyMeter device).
+
+ 1. SetTopology (0x009C FeatureMap bit 2): read AvailableEndpoints
+ (preferring ActiveEndpoints when DynamicPowerFlow is also set) and
+ link only when exactly one listed endpoint hosts a
+ _METER_CAPABLE_TYPES device. A malformed endpoint-list element
+ (None/str/dict — issue #80 review point C) degrades to the
+ sole-actuator heuristic below rather than raising and aborting the
+ whole node's device creation.
+ 2. NodeTopology, no 0x009C at all, or SetTopology attrs unreadable:
+ the node's SINGLE endpoint hosting a _METER_CAPABLE_TYPES device
+ (the "sole actuator" heuristic) — zero or more than one candidate
+ means attribution is ambiguous, so no link is made. Never guess on
+ a multi-actuator node (bridges, power strips without SetTopology).
+
+ Every path that returns None is logged at debug (issue #80 review
+ point D) so "why do I have two devices for my plug" is diagnosable
+ from the event log without adding print-debugging.
+ """
+ eid = int(source_endpoint.endpoint_id)
+ feature_map = node.attributes.get(
+ (eid, CLUSTER_POWER_TOPOLOGY, ATTR_POWER_TOPOLOGY_FEATURE_MAP)
+ )
+ if source_endpoint.has(CLUSTER_POWER_TOPOLOGY) and isinstance(feature_map, int) \
+ and feature_map & FEATURE_SET_TOPOLOGY:
+ attr_id = ATTR_ACTIVE_ENDPOINTS if feature_map & FEATURE_DYNAMIC_POWER_FLOW \
+ else ATTR_AVAILABLE_ENDPOINTS
+ listed = node.attributes.get((eid, CLUSTER_POWER_TOPOLOGY, attr_id))
+ if isinstance(listed, list):
+ try:
+ listed_eps = {int(e) for e in listed}
+ except (TypeError, ValueError):
+ listed_eps = None
+ if listed_eps is not None:
+ candidates = sorted(listed_eps & meter_capable_eps)
+ if len(candidates) == 1:
+ return candidates[0]
+ self.logger.debug(
+ "meter-link: node %s source endpoint %s SetTopology "
+ "listed=%s meter-capable=%s — ambiguous/empty match, no link",
+ node_id_to_str(node.node_id), eid,
+ sorted(listed_eps), sorted(meter_capable_eps),
+ )
+ return None
+ # malformed endpoint-list element — fall through to the
+ # sole-actuator heuristic below rather than raise.
+ # SetTopology bit set but the endpoint-list attribute is
+ # unreadable — fall through to the sole-actuator heuristic below.
+ candidates = sorted(meter_capable_eps)
+ if len(candidates) == 1:
+ return candidates[0]
+ self.logger.debug(
+ "meter-link: node %s source endpoint %s sole-actuator heuristic "
+ "candidates=%s — expected exactly one meter-capable endpoint, no link",
+ node_id_to_str(node.node_id), eid, candidates,
+ )
+ return None
+
+ def _resolve_meter_links(self, node: NodeInfo, specs_by_ep: dict) -> None:
+ """Rebuild this node's meter-link maps (issue #79 — split-endpoint
+ energy measurement).
+
+ No persistent storage: rebuilt from the node model on every
+ create_devices call (commission, node_added, reconcile), so a stale
+ link from a prior interview state self-heals automatically.
+ """
+ node_id = int(node.node_id)
+ with self._lock:
+ self._forward_links = {
+ k: v for k, v in self._forward_links.items() if k[0] != node_id
+ }
+ self._reverse_links = {
+ k: v for k, v in self._reverse_links.items() if k[0] != node_id
+ }
+ # A target endpoint that already has its OWN co-located 0x0090/0x0091
+ # (Tapo-style) alongside its actuator clusters must never also become
+ # a link TARGET (issue #80 review point A): otherwise an unrelated
+ # orphaned electrical-only endpoint on the same node links onto it and
+ # overwrites its own readings (both at priming and via live routing).
+ # Such a node's orphan endpoint correctly falls back to a standalone
+ # matterEnergyMeter device instead.
+ endpoints_by_id = {int(ep.endpoint_id): ep for ep in node.endpoints}
+ meter_capable_eps = set()
+ for eid, specs in specs_by_ep.items():
+ if not any(spec.device_type_id in _METER_CAPABLE_TYPES for spec in specs):
+ continue
+ ep = endpoints_by_id.get(eid)
+ if ep is not None and (
+ ep.has(CLUSTER_ELECTRICAL_POWER) or ep.has(CLUSTER_ELECTRICAL_ENERGY)
+ ):
+ continue
+ meter_capable_eps.add(eid)
+ forward: dict[tuple[int, int], int] = {}
+ reverse: dict[tuple[int, int], set] = {}
+ for endpoint in node.endpoints:
+ eid = int(endpoint.endpoint_id)
+ if not self._is_meter_source_candidate(endpoint, specs_by_ep.get(eid, [])):
+ continue
+ target_eid = self._resolve_meter_target(node, endpoint, meter_capable_eps)
+ if target_eid is None:
+ continue
+ forward[(node_id, eid)] = target_eid
+ reverse.setdefault((node_id, target_eid), set()).add(eid)
+ with self._lock:
+ self._forward_links.update(forward)
+ self._reverse_links.update(reverse)
+
def _create_one(self, spec: Any, name: str, folder_id: int = 0,
model: str = "") -> Optional[int]:
# Stamp the type the cluster pipeline chose, so the type-edit guard
@@ -595,7 +927,17 @@ def _prime_states(self, node: NodeInfo, dev_id: int, endpoint_id: int,
Two passes: first the device's own endpoint (standard clusters); then any
OTHER endpoints whose cluster handler is node-scoped (e.g. PowerSource on
- endpoint 0 primes battery level into a sensor on endpoint 1).
+ endpoint 0 primes battery level into a sensor on endpoint 1), OR that are
+ a linked meter-source endpoint for this device (issue #79 — split-endpoint
+ energy, e.g. IKEA GRILLPLATS ep2's ActivePower priming the ep1 relay).
+
+ The node-scoped cross-endpoint fan-in is itself confined to the
+ device's own endpoint when the node has MORE THAN ONE PowerSource-
+ bearing endpoint (issue #82 — a bridge with several battery-powered
+ children): otherwise endpoint 2's battery reading would prime
+ endpoint 1's device too. A node with zero or one PowerSource endpoint
+ keeps the original any-endpoint behaviour (the common single-battery
+ case, e.g. FP300).
Within the own endpoint, skip attributes whose cluster's handler targets
a *different* existing device on the same endpoint (fix/#44: prevents the
@@ -620,14 +962,24 @@ def _prime_states(self, node: NodeInfo, dev_id: int, endpoint_id: int,
self._index.get((int(node.node_id), int(endpoint_id)), {}).keys()
)
dev = indigo.devices[dev_id]
+ with self._lock:
+ linked_sources = self._reverse_links.get((int(node.node_id), int(endpoint_id)), set())
+ multi_power_source = len(self._power_source_eps.get(int(node.node_id), ())) > 1
kv: list = []
for (ep, cluster, attribute), value in node.attributes.items():
handler = self.registry.handler_for_cluster(cluster)
if handler is None:
continue
- # Include attributes from: this device's own endpoint, OR any
- # node-scoped cluster living on a different endpoint.
- if ep != endpoint_id and not handler.node_scoped:
+ # Include attributes from: this device's own endpoint, any
+ # node-scoped cluster living on a different endpoint, or a linked
+ # meter-source endpoint (issue #79 — split-endpoint energy).
+ if ep != endpoint_id and not handler.node_scoped and ep not in linked_sources:
+ continue
+ # Issue #82: a node-scoped cluster (PowerSource) on a DIFFERENT
+ # endpoint only fans in when the node has at most one
+ # PowerSource-bearing endpoint — a bridge with several
+ # battery-powered children must not cross-contaminate.
+ if ep != endpoint_id and handler.node_scoped and multi_power_source:
continue
# For this device's own endpoint, skip attributes that belong to a
# sibling device (handler has a non-empty device_type_id that differs
@@ -661,28 +1013,54 @@ def _prime_states(self, node: NodeInfo, dev_id: int, endpoint_id: int,
# Capability-prop helpers (issue #45 — self-heal mid-interview creations)
# ------------------------------------------------------------------
- @staticmethod
- def _capability_props(node: NodeInfo, endpoint: Any) -> dict:
+ def _capability_props(self, node: NodeInfo, endpoint: Any) -> dict:
"""Return capability props implied by the node's CURRENT cluster set.
These props unlock Indigo states that handlers write into:
- - SupportsPowerMeter → curEnergyLevel (cluster 0x0090 on the endpoint)
- - SupportsEnergyMeter → accumEnergyTotal (cluster 0x0091 on the endpoint)
- - SupportsBatteryLevel → batteryLevel (cluster 0x002F anywhere on the node)
+ - SupportsPowerMeter → curEnergyLevel (cluster 0x0090 on the endpoint,
+ or on a LINKED source endpoint)
+ - SupportsEnergyMeter → accumEnergyTotal (cluster 0x0091 on the endpoint,
+ or on a LINKED source endpoint)
+ - SupportsBatteryLevel → batteryLevel (cluster 0x002F anywhere on the node,
+ confined to its own endpoint when
+ the node has more than one
+ PowerSource-bearing endpoint)
The cluster constants are imported from their handler modules — no magic
- numbers here. The battery check fans across ALL node endpoints, mirroring
- the create_devices central setdefault that was the original source of truth.
+ numbers here. The battery check mirrors create_devices' central setdefault
+ (issue #82): a single PowerSource-bearing endpoint still fans out to every
+ device on the node, but more than one (a bridge with several
+ battery-powered children) confines the prop to each device's own endpoint
+ so siblings don't cross-contaminate.
+
+ No longer a ``@staticmethod``: split-endpoint energy (issue #79 — e.g.
+ IKEA GRILLPLATS) needs the instance's meter-link map to fold a linked
+ source endpoint's electrical clusters into the TARGET endpoint's props,
+ so the reconcile self-heal (below) can add meter props to a relay
+ whose energy actually lives on a sibling endpoint.
"""
props: dict = {}
if endpoint.has(CLUSTER_ELECTRICAL_POWER):
props["SupportsPowerMeter"] = True
if endpoint.has(CLUSTER_ELECTRICAL_ENERGY):
props["SupportsEnergyMeter"] = True
- node_has_power_source = any(
- ep.has(CLUSTER_POWER_SOURCE) for ep in node.endpoints
- )
- if node_has_power_source:
+ with self._lock:
+ linked_sources = self._reverse_links.get(
+ (int(node.node_id), int(endpoint.endpoint_id)), set()
+ )
+ for src_eid in linked_sources:
+ src_endpoint = self._endpoint_by_id(node, src_eid)
+ if src_endpoint is None:
+ continue
+ if src_endpoint.has(CLUSTER_ELECTRICAL_POWER):
+ props["SupportsPowerMeter"] = True
+ if src_endpoint.has(CLUSTER_ELECTRICAL_ENERGY):
+ props["SupportsEnergyMeter"] = True
+ with self._lock:
+ power_source_eps = self._power_source_eps.get(int(node.node_id), set())
+ if power_source_eps and (
+ len(power_source_eps) == 1 or int(endpoint.endpoint_id) in power_source_eps
+ ):
props["SupportsBatteryLevel"] = True
return props
@@ -1097,14 +1475,27 @@ def _on_attribute(self, evt: protocol.MatterEvent) -> None:
if handler.node_scoped:
# Node-scoped clusters (e.g. PowerSource) live on a different endpoint
# than the devices they augment. Fan the update out to ALL Indigo devices
- # for this node so every sensor on the node receives the battery update.
+ # for this node so every sensor on the node receives the battery update —
+ # UNLESS the node has more than one PowerSource-bearing endpoint (issue
+ # #82 — a bridge with several battery-powered children), in which case
+ # fanning out node-wide would cross-contaminate siblings; confine the
+ # update to devices on the event's own endpoint instead.
with self._lock:
- dev_ids = [
- dev_id
- for (nid, _eid), type_map in self._index.items()
- if nid == int(evt.node_id)
- for dev_id in type_map.values()
- ]
+ multi_power_source = len(self._power_source_eps.get(int(evt.node_id), ())) > 1
+ if multi_power_source:
+ dev_ids = [
+ dev_id
+ for (nid, eid), type_map in self._index.items()
+ if nid == int(evt.node_id) and eid == int(evt.endpoint)
+ for dev_id in type_map.values()
+ ]
+ else:
+ dev_ids = [
+ dev_id
+ for (nid, _eid), type_map in self._index.items()
+ if nid == int(evt.node_id)
+ for dev_id in type_map.values()
+ ]
for dev_id in dev_ids:
if self._active and dev_id not in self._active:
continue # gate updates to active devices once any are started
diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/electrical.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/electrical.py
index 94ee113..b54c33f 100644
--- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/electrical.py
+++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/electrical.py
@@ -20,6 +20,29 @@
CLUSTER_ELECTRICAL_POWER = 0x0090
CLUSTER_ELECTRICAL_ENERGY = 0x0091
+# Power Topology (0x009C) — Matter 1.3's "Electrical Sensor" device type
+# (0x0510, e.g. IKEA GRILLPLATS) puts the ElectricalPower/Energy clusters on
+# their own endpoint rather than co-locating them with the relay/dimmer. This
+# cluster tells device_sync which OTHER endpoint(s) the measurement applies
+# to (issue #79). It has no ClusterHandler of its own — no live attribute
+# routing is needed, device_sync reads its snapshot values directly out of
+# node.attributes when resolving the meter-link map.
+CLUSTER_POWER_TOPOLOGY = 0x009C
+
+# FeatureMap (global attribute 0xFFFC, present on every cluster).
+ATTR_POWER_TOPOLOGY_FEATURE_MAP = 0xFFFC
+
+# Power Topology FeatureMap bits (connectedhomeip power-topology-cluster.xml /
+# src/app_clusters/PowerTopology.adoc).
+FEATURE_NODE_TOPOLOGY = 1 << 0 # "measures the whole node" — no endpoint list
+FEATURE_TREE_TOPOLOGY = 1 << 1
+FEATURE_SET_TOPOLOGY = 1 << 2 # gates AvailableEndpoints (0x0000)
+FEATURE_DYNAMIC_POWER_FLOW = 1 << 3 # gates ActiveEndpoints (0x0001)
+
+# Power Topology attributes (only meaningful when SetTopology is set).
+ATTR_AVAILABLE_ENDPOINTS = 0x0000
+ATTR_ACTIVE_ENDPOINTS = 0x0001
+
# ---------------------------------------------------------------------------
# ElectricalPowerMeasurement helpers
diff --git a/tests/test_device_sync.py b/tests/test_device_sync.py
index 6b1611a..4290e8a 100644
--- a/tests/test_device_sync.py
+++ b/tests/test_device_sync.py
@@ -15,6 +15,19 @@
from test_handlers import RELAY_NODE
+# Real Indigo auto-derives these built-in states from Supports* props, both at
+# device CREATION and at any later pluginProps replace. FakeDev seeds them in
+# both places so handler update guards (e.g. ElectricalPowerHandler's
+# `"curEnergyLevel" not in indigo_dev.states`) behave like the real server —
+# issue #79's priming/live-routing tests need this true immediately after
+# creation, not only after a reconcile-triggered replacePluginPropsOnServer.
+_SUPPORTS_TO_STATE = {
+ "SupportsPowerMeter": "curEnergyLevel",
+ "SupportsEnergyMeter": "accumEnergyTotal",
+ "SupportsBatteryLevel": "batteryLevel",
+ "SupportsSensorValue": "sensorValue",
+}
+
class FakeDev:
def __init__(self, dev_id, name, device_type_id, props):
@@ -23,6 +36,9 @@ def __init__(self, dev_id, name, device_type_id, props):
self.deviceTypeId = device_type_id
self.pluginProps = props
self.states = {}
+ for prop_key, state_key in _SUPPORTS_TO_STATE.items():
+ if props.get(prop_key):
+ self.states[state_key] = 0 # Indigo-style initial value
self.error = None
self.errorState = ""
self.folderId = 0
@@ -59,13 +75,7 @@ def replacePluginPropsOnServer(self, new_props):
self.pluginProps = dict(new_props)
self.replaced_props = True
# Simulate Indigo auto-creating states for Supports* props.
- _supports_to_states = {
- "SupportsPowerMeter": "curEnergyLevel",
- "SupportsEnergyMeter": "accumEnergyTotal",
- "SupportsBatteryLevel": "batteryLevel",
- "SupportsSensorValue": "sensorValue",
- }
- for prop_key, state_key in _supports_to_states.items():
+ for prop_key, state_key in _SUPPORTS_TO_STATE.items():
if new_props.get(prop_key) and state_key not in self.states:
self.states[state_key] = 0 # Indigo-style initial value
@@ -1425,8 +1435,11 @@ def test_props_replace_that_does_not_persist_warns_instead_of_claiming_success(d
"attributes": {"0/40/1": "ACME", "0/29/0": [{"0": 22}]},
}
-# Endpoint whose clusters are all merge-only/utility (electrical measurement):
-# no placeholder — there is no device-shaped thing to surface.
+# Endpoint whose clusters are all merge-only/utility (electrical measurement)
+# and no actuator exists anywhere on the node: no matterUnknown placeholder
+# (nothing device-shaped for the unmapped-cluster path to surface) — but
+# since issue #79, attribution fails (no actuator to link to) so it gets the
+# fallback matterEnergyMeter device instead of no device at all.
ELECTRICAL_ONLY_NODE = {
"node_id": 0x52,
"available": True,
@@ -1469,9 +1482,15 @@ def test_root_only_node_creates_nothing(ds, indigo_env):
def test_merge_only_endpoint_gets_no_placeholder(ds, indigo_env):
+ """No matterUnknown placeholder for an all-merge-only endpoint — but since
+ issue #79, an electrical-only node (no actuator anywhere to attribute the
+ reading to) gets the matterEnergyMeter fallback device, not nothing."""
_indigo, devices = indigo_env
result = ds.create_from_raw(ELECTRICAL_ONLY_NODE, "Meter")
- assert result["indigoDeviceIds"] == []
+ assert result["indigoDeviceIds"], "electrical-only node should get the matterEnergyMeter fallback"
+ dev = devices[result["primaryDeviceId"]]
+ assert dev.deviceTypeId == "matterEnergyMeter"
+ assert not any(d.deviceTypeId == "matterUnknown" for d in devices)
def test_placeholder_obsolete_when_endpoint_becomes_supported(ds, indigo_env, mock_logger):
@@ -1580,3 +1599,583 @@ def explode(**kwargs):
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
+
+
+# ===========================================================================
+# issue #79 — split-endpoint energy measurement ("meter links")
+#
+# IKEA GRILLPLATS-shaped node: the relay lives on endpoint 1, ElectricalPower
+# (0x0090) / ElectricalEnergy (0x0091) / PowerTopology (0x009C) live on a
+# dedicated endpoint 2 (Matter 1.3 "Electrical Sensor" device type 0x0510).
+# ===========================================================================
+
+# NodeTopology (FeatureMap bit 0 — "measures the whole node", no endpoint
+# list): the primary real-device path, resolved via the sole-actuator
+# heuristic since there is nothing to read an endpoint list from.
+GRILLPLATS_NODE = {
+ "node_id": 0x34,
+ "available": True,
+ "attributes": {
+ "0/40/1": "IKEA",
+ "0/40/3": "GRILLPLATS",
+ "1/29/0": [{"0": 266}], # OnOffPlugInUnit
+ "1/3/0": 0, # Identify
+ "1/4/0": 0, # Groups
+ "1/6/0": False, # OnOff
+ "2/29/0": [{"0": 0x0510}], # Descriptor: Electrical Sensor
+ "2/144/8": 1200, # ElectricalPowerMeasurement.ActivePower = 1200 mW
+ "2/145/1": {"energy": 3_600_000}, # ElectricalEnergyMeasurement.CumulativeEnergyImported
+ "2/156/65532": 1, # PowerTopology.FeatureMap = NodeTopology (bit 0)
+ },
+}
+
+
+def test_grillplats_split_energy_links_to_sole_relay(ds, indigo_env):
+ """NodeTopology heuristic: ep2's readings attribute to ep1's relay — one
+ device created (the relay, with meter props), nothing at all on ep2."""
+ _indigo, devices = indigo_env
+ result = ds.create_from_raw(GRILLPLATS_NODE, "Grill Plug")
+ assert len(result["indigoDeviceIds"]) == 1
+ dev = devices[result["primaryDeviceId"]]
+ assert dev.deviceTypeId == "matterRelay"
+ assert dev.pluginProps.get("SupportsPowerMeter") is True
+ assert dev.pluginProps.get("SupportsEnergyMeter") is True
+ assert ds.lookup(0x34, 2) is None, "ep2 must get no device at all when the link succeeds"
+
+
+def test_grillplats_priming_sets_relay_energy_states_from_linked_endpoint(ds, indigo_env):
+ """Priming: node.attributes' ep2 electrical values reach the ep1 relay's
+ states at creation — mW/mWh converted to W/kWh exactly like the co-located
+ (Tapo) case."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(GRILLPLATS_NODE, "Grill Plug")
+ relay = devices[ds.lookup(0x34, 1)]
+ assert relay.states.get("curEnergyLevel") == 1.2 # 1200 mW → 1.2 W
+ assert relay.states.get("accumEnergyTotal") == 3.6 # 3,600,000 mWh → 3.6 kWh
+
+
+# Separate node/id for the live-routing test so the baseline reading is
+# distinguishable from the value pushed by the live event below.
+GRILLPLATS_LIVE_NODE = {
+ "node_id": 0x3A,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "2/29/0": [{"0": 0x0510}],
+ "2/144/8": 500, # baseline ActivePower = 500 mW
+ "2/145/1": {"energy": 100_000}, # baseline CumulativeEnergyImported
+ "2/156/65532": 1, # NodeTopology
+ },
+}
+
+
+def test_grillplats_live_attribute_event_routes_to_linked_relay(ds, indigo_env):
+ """Live routing: an attribute_updated event for ep2 (0x0090/0x0091) must
+ update the ep1 relay's states via the same handle_event path production
+ uses — not the (nonexistent) ep2 device."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(GRILLPLATS_LIVE_NODE, "Grill Plug")
+ relay_id = ds.lookup(0x3A, 1)
+ assert devices[relay_id].states.get("curEnergyLevel") == 0.5 # primed baseline
+
+ ds.handle_event(MatterEvent(
+ kind=protocol.EVT_ATTRIBUTE_UPDATED,
+ node_id=0x3A, endpoint=2, cluster=0x0090, attribute=0x0008, value=2500,
+ ))
+ assert devices[relay_id].states.get("curEnergyLevel") == 2.5 # 2500 mW → 2.5 W
+
+ ds.handle_event(MatterEvent(
+ kind=protocol.EVT_ATTRIBUTE_UPDATED,
+ node_id=0x3A, endpoint=2, cluster=0x0091, attribute=0x0001,
+ value={"energy": 5_000_000},
+ ))
+ assert devices[relay_id].states.get("accumEnergyTotal") == 5.0 # 5,000,000 mWh → 5.0 kWh
+ # No device was ever created on ep2 — the reading never had anywhere else to go.
+ assert ds.lookup(0x3A, 2) is None
+
+
+# SetTopology (FeatureMap bit 2): AvailableEndpoints lists the endpoint(s) the
+# reading applies to explicitly — v1 keeps the link static after reconcile.
+GRILLPLATS_SET_TOPOLOGY_NODE = {
+ "node_id": 0x35,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "2/29/0": [{"0": 0x0510}],
+ "2/144/8": 1200,
+ "2/145/1": {"energy": 3_600_000},
+ "2/156/65532": 4, # FeatureMap = SetTopology (bit 2)
+ "2/156/0": [1], # AvailableEndpoints = [1]
+ },
+}
+
+
+def test_grillplats_set_topology_links_to_available_endpoint(ds, indigo_env):
+ _indigo, devices = indigo_env
+ result = ds.create_from_raw(GRILLPLATS_SET_TOPOLOGY_NODE, "Grill Plug")
+ dev = devices[result["primaryDeviceId"]]
+ assert dev.deviceTypeId == "matterRelay"
+ assert dev.pluginProps.get("SupportsPowerMeter") is True
+ assert dev.pluginProps.get("SupportsEnergyMeter") is True
+ assert ds.lookup(0x35, 2) is None
+
+
+# No 0x009C at all: the heuristic (sole actuator) still applies — a client
+# doesn't have to expose Power Topology for the common single-actuator case.
+GRILLPLATS_NO_TOPOLOGY_NODE = {
+ "node_id": 0x36,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "2/144/8": 900,
+ "2/145/1": {"energy": 500_000},
+ },
+}
+
+
+def test_grillplats_no_power_topology_cluster_still_links_via_heuristic(ds, indigo_env):
+ _indigo, devices = indigo_env
+ result = ds.create_from_raw(GRILLPLATS_NO_TOPOLOGY_NODE, "Grill Plug")
+ dev = devices[result["primaryDeviceId"]]
+ assert dev.deviceTypeId == "matterRelay"
+ assert dev.pluginProps.get("SupportsPowerMeter") is True
+ assert dev.pluginProps.get("SupportsEnergyMeter") is True
+ assert ds.lookup(0x36, 2) is None
+
+
+# Two actuator endpoints (ep1, ep3) + one electrical endpoint (ep2), no
+# SetTopology list — attribution is ambiguous (a power strip without SET
+# topology): never guess. ep2 falls back to the standalone matterEnergyMeter.
+AMBIGUOUS_MULTI_RELAY_NODE = {
+ "node_id": 0x37,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "2/144/8": 800,
+ "2/145/1": {"energy": 200_000},
+ "3/29/0": [{"0": 266}],
+ "3/6/0": False,
+ },
+}
+
+
+def test_ambiguous_multi_actuator_node_no_link_electrical_gets_fallback(ds, indigo_env):
+ _indigo, devices = indigo_env
+ ds.create_from_raw(AMBIGUOUS_MULTI_RELAY_NODE, "Power Strip")
+ ep1_id = ds.lookup(0x37, 1)
+ ep3_id = ds.lookup(0x37, 3)
+ assert ep1_id is not None and ep3_id is not None
+ assert not devices[ep1_id].pluginProps.get("SupportsPowerMeter")
+ assert not devices[ep3_id].pluginProps.get("SupportsPowerMeter")
+ ep2_id = ds.lookup(0x37, 2)
+ assert ep2_id is not None, "attribution failure must fall back to a device, not vanish"
+ assert devices[ep2_id].deviceTypeId == "matterEnergyMeter"
+
+
+def test_electrical_only_node_fallback_device_states(ds, indigo_env):
+ """Extends test_merge_only_endpoint_gets_no_placeholder: the fallback
+ matterEnergyMeter's states are wired to what the electrical handlers
+ actually write (curEnergyLevel/accumEnergyTotal — not the curPowerLevel
+ typo an earlier draft of the plan flagged as a footgun), and — since the
+ clusters live on the SAME endpoint as the fallback device — priming
+ applies ELECTRICAL_ONLY_NODE's own readings (1200 mW / 1 mWh) at creation,
+ same as any other device."""
+ _indigo, devices = indigo_env
+ result = ds.create_from_raw(ELECTRICAL_ONLY_NODE, "Meter")
+ dev = devices[result["primaryDeviceId"]]
+ assert dev.deviceTypeId == "matterEnergyMeter"
+ assert dev.states.get("curEnergyLevel") == 1.2 # 1200 mW → 1.2 W, primed
+ assert dev.states.get("accumEnergyTotal") == 0.0 # 1 mWh → 0.000001 kWh, rounds to 0.0
+ assert dev.states.get("reachable") is True
+
+
+def test_reconcile_self_heals_meter_props_via_link_without_recreation(ds, indigo_env):
+ """A pre-existing relay (created before the split-endpoint fix landed, or
+ before ep2's interview data arrived — mirrors the live jarvis relay
+ 1794293937 from the issue) gains SupportsPowerMeter/SupportsEnergyMeter
+ through the linked ep2 without being deleted and recreated."""
+ _indigo, devices = indigo_env
+ bare_node = {
+ "node_id": 0x34, "available": True,
+ "attributes": {"1/29/0": [{"0": 266}], "1/6/0": False},
+ }
+ ds.create_from_raw(bare_node, "Grill Plug")
+ dev_id = ds.lookup(0x34, 1)
+ assert dev_id is not None
+ assert not devices[dev_id].pluginProps.get("SupportsPowerMeter")
+ assert not devices[dev_id].pluginProps.get("SupportsEnergyMeter")
+
+ ds.reconcile_all([GRILLPLATS_NODE])
+
+ assert devices[dev_id].pluginProps.get("SupportsPowerMeter") is True
+ assert devices[dev_id].pluginProps.get("SupportsEnergyMeter") is True
+ assert "curEnergyLevel" in devices[dev_id].states
+ assert "accumEnergyTotal" in devices[dev_id].states
+ assert ds.lookup(0x34, 1) == dev_id, "must be healed in place, not recreated"
+ assert ds.lookup(0x34, 2) is None, "ep2 still gets no standalone device"
+
+
+# A genuinely-unsupported cluster (RVC RunMode 0x0054, reused from UNKNOWN_NODE)
+# co-occurring with the merge-only electrical clusters on the SAME endpoint.
+UNSUPPORTED_PLUS_ELECTRICAL_NODE = {
+ "node_id": 0x39,
+ "available": True,
+ "attributes": {
+ "0/40/1": "ACME",
+ "1/29/0": [{"0": 0x0510}],
+ "1/84/0": 0, # RVC RunMode — genuinely unsupported
+ "1/144/8": 500, # ElectricalPowerMeasurement (merge-only)
+ "1/145/1": {"energy": 100}, # ElectricalEnergyMeasurement (merge-only)
+ },
+}
+
+
+def test_unsupported_cluster_with_electrical_gets_placeholder_with_merge_annotation(ds, indigo_env):
+ """An endpoint with a genuinely-unsupported cluster PLUS 0x90/0x91 must
+ still surface the matterUnknown placeholder (issue #58's "no silent
+ success" guarantee) — the electrical clusters are annotated as
+ merge-only in the log/props, not silently absorbed into a meter link or
+ matterEnergyMeter fallback (issue #79 point 3)."""
+ _indigo, devices = indigo_env
+ result = ds.create_from_raw(UNSUPPORTED_PLUS_ELECTRICAL_NODE, "Mystery Device")
+ dev = devices[result["primaryDeviceId"]]
+ assert dev.deviceTypeId == "matterUnknown"
+ supported = dev.pluginProps["supportedClusters"]
+ assert "0x0054" in supported
+ assert "merge-only" in supported
+ assert "0x0090" in supported and "0x0091" in supported
+
+
+def test_tapo_colocated_energy_unchanged_no_meter_links(ds, indigo_env):
+ """Regression: co-located energy (Tapo-style — OnOff + 0x90/0x91 on the
+ SAME endpoint) must be untouched by issue #79 — props come from on_off.py's
+ own same-endpoint check, and the meter-link maps stay empty throughout."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(ENERGY_PLUG_NODE, "Energy Plug")
+ dev = devices[ds.lookup(0x20, 1)]
+ assert dev.pluginProps.get("SupportsPowerMeter") is True
+ assert dev.pluginProps.get("SupportsEnergyMeter") is True
+ assert not ds._forward_links
+ assert not ds._reverse_links
+
+
+# ===========================================================================
+# issue #80 review batch — PR #80 follow-up fixes/hardening on top of #79.
+# ===========================================================================
+
+# ep1: relay WITH its own co-located 0x0090/0x0091 (Tapo-style) — 500 mW /
+# 250,000 mWh. ep2: an UNRELATED orphaned electrical-only endpoint with
+# DIFFERENT readings — 900 mW / 700,000 mWh. ep2 must NOT link onto ep1 (that
+# would overwrite ep1's own readings, both at priming and live routing); it
+# must fall back to its own standalone matterEnergyMeter device instead.
+COLOCATED_PLUS_ORPHAN_NODE = {
+ "node_id": 0x53,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "1/144/8": 500, # ep1's OWN ActivePower
+ "1/145/1": {"energy": 250_000}, # ep1's OWN CumulativeEnergyImported
+ "2/144/8": 900, # ep2 orphan ActivePower (different!)
+ "2/145/1": {"energy": 700_000}, # ep2 orphan CumulativeEnergyImported
+ },
+}
+
+
+def test_colocated_target_excluded_from_meter_link_no_crosstalk(ds, indigo_env):
+ """issue #80 review point A: an endpoint with its OWN co-located 0x90/0x91
+ must never become a link TARGET for an unrelated orphan endpoint on the
+ same node — that would let the orphan's readings silently overwrite the
+ co-located device's own (both at priming and live routing)."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(COLOCATED_PLUS_ORPHAN_NODE, "Mixed Node")
+
+ assert not ds._forward_links, "ep1 must never become a meter-link target here"
+ assert not ds._reverse_links
+
+ relay = devices[ds.lookup(0x53, 1)]
+ assert relay.deviceTypeId == "matterRelay"
+ assert relay.pluginProps.get("SupportsPowerMeter") is True
+ assert relay.pluginProps.get("SupportsEnergyMeter") is True
+ # ep1's OWN readings win, untouched by ep2's orphan values.
+ assert relay.states.get("curEnergyLevel") == 0.5 # 500 mW → 0.5 W
+ assert relay.states.get("accumEnergyTotal") == 0.25 # 250,000 mWh → 0.25 kWh
+
+ ep2_dev_id = ds.lookup(0x53, 2)
+ assert ep2_dev_id is not None, "orphan endpoint must fall back to its own device"
+ ep2_dev = devices[ep2_dev_id]
+ assert ep2_dev.deviceTypeId == "matterEnergyMeter"
+ assert ep2_dev.states.get("curEnergyLevel") == 0.9 # 900 mW → 0.9 W (its own)
+ assert ep2_dev.states.get("accumEnergyTotal") == 0.7 # 700,000 mWh → 0.7 kWh
+
+
+# A node whose ep2 starts out with a genuinely-unsupported cluster (RVC
+# RunMode) and later loses it in favour of split-endpoint electrical clusters
+# — simulates the live jarvis scenario (device 1456954491): an existing
+# matterUnknown placeholder that must become obsolete once ep2 reclassifies
+# as a meter-linked SOURCE (which itself gets no device — specs stay empty).
+GRILLPLATS_PRE_EP2_UNKNOWN_NODE = {
+ "node_id": 0x54,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "2/29/0": [{"0": 0x0510}],
+ "2/84/0": 0, # RVC RunMode — genuinely unsupported, triggers a placeholder
+ },
+}
+
+GRILLPLATS_EP2_HEALED_NODE = {
+ "node_id": 0x54,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "2/29/0": [{"0": 0x0510}],
+ "2/144/8": 1200,
+ "2/145/1": {"energy": 3_600_000},
+ "2/156/65532": 1, # NodeTopology
+ },
+}
+
+
+def test_obsolescence_log_fires_when_endpoint_becomes_meter_linked_source(ds, indigo_env, mock_logger):
+ """issue #80 review point B: an endpoint reclassified as a meter-linked
+ SOURCE (which itself gets no device — specs stay empty) must still
+ surface the obsolescence log for its stale matterUnknown placeholder. The
+ old `elif` only fired when the endpoint kept producing a real spec of its
+ own, silently orphaning the placeholder in this case."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(GRILLPLATS_PRE_EP2_UNKNOWN_NODE, "Grill Plug")
+ placeholder_id = ds.lookup(0x54, 2, "matterUnknown")
+ assert placeholder_id is not None
+ relay_id = ds.lookup(0x54, 1)
+ assert not devices[relay_id].pluginProps.get("SupportsPowerMeter")
+
+ mock_logger.info.reset_mock()
+ ds.reconcile_all([GRILLPLATS_EP2_HEALED_NODE])
+
+ # Relay healed via the existing reconcile self-heal path.
+ assert devices[relay_id].pluginProps.get("SupportsPowerMeter") is True
+ assert devices[relay_id].pluginProps.get("SupportsEnergyMeter") is True
+ # ep2's stale placeholder is untouched (never auto-deleted) and no NEW
+ # device was created on ep2 either (the link succeeded).
+ assert ds.lookup(0x54, 2, "matterUnknown") == placeholder_id
+ assert ds._all_dev_ids_for_endpoint(0x54, 2) == [placeholder_id]
+ info_msgs = [c[0][0] % tuple(c[0][1:]) for c in mock_logger.info.call_args_list]
+ assert any("can be deleted" in m for m in info_msgs)
+
+
+# Same idea, but the node stays ambiguous (two actuators): ep2's electrical
+# clusters can't be attributed, so a NEW matterEnergyMeter is created beside
+# the stale matterUnknown placeholder — the log must connect the two.
+AMBIGUOUS_PRE_EP2_UNKNOWN_NODE = {
+ "node_id": 0x55,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}], "1/6/0": False,
+ "2/29/0": [{"0": 1296}], "2/84/0": 0, # RVC RunMode — placeholder trigger
+ "3/29/0": [{"0": 266}], "3/6/0": False,
+ },
+}
+
+AMBIGUOUS_EP2_BECOMES_ELECTRICAL_NODE = {
+ "node_id": 0x55,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}], "1/6/0": False,
+ "2/144/8": 800, "2/145/1": {"energy": 200_000},
+ "3/29/0": [{"0": 266}], "3/6/0": False,
+ },
+}
+
+
+def test_obsolescence_log_fires_alongside_new_meter_fallback_device(ds, indigo_env, mock_logger):
+ """issue #80 review point B: the ambiguous-attribution case creates a NEW
+ matterEnergyMeter beside a stale matterUnknown placeholder — the
+ obsolescence log must fire so the two are connected, not silently orphaned."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(AMBIGUOUS_PRE_EP2_UNKNOWN_NODE, "Power Strip")
+ placeholder_id = ds.lookup(0x55, 2, "matterUnknown")
+ assert placeholder_id is not None
+
+ mock_logger.info.reset_mock()
+ ds.reconcile_all([AMBIGUOUS_EP2_BECOMES_ELECTRICAL_NODE])
+
+ new_meter_id = ds.lookup(0x55, 2, "matterEnergyMeter")
+ assert new_meter_id is not None
+ assert new_meter_id != placeholder_id
+ assert ds.lookup(0x55, 2, "matterUnknown") == placeholder_id # untouched
+ info_msgs = [c[0][0] % tuple(c[0][1:]) for c in mock_logger.info.call_args_list]
+ assert any("can be deleted" in m for m in info_msgs)
+
+
+# SetTopology bit set but AvailableEndpoints contains malformed elements
+# (None, non-numeric string) — must degrade to the sole-actuator heuristic
+# rather than raise (which would abort the whole node's device creation,
+# since link resolution runs inside create_devices' lock-held body).
+GRILLPLATS_MALFORMED_SET_TOPOLOGY_NODE = {
+ "node_id": 0x38,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "2/29/0": [{"0": 0x0510}],
+ "2/144/8": 1200,
+ "2/145/1": {"energy": 3_600_000},
+ "2/156/65532": 4, # FeatureMap = SetTopology (bit 2)
+ "2/156/0": [None, "x"], # malformed AvailableEndpoints elements
+ },
+}
+
+
+def test_set_topology_malformed_endpoint_list_falls_back_to_heuristic(ds, indigo_env):
+ """issue #80 review point C: a malformed SetTopology endpoint-list
+ element must not raise and abort node creation — it degrades to the
+ sole-actuator heuristic, which still links since there's exactly one
+ meter-capable endpoint."""
+ _indigo, devices = indigo_env
+ result = ds.create_from_raw(GRILLPLATS_MALFORMED_SET_TOPOLOGY_NODE, "Grill Plug")
+ dev = devices[result["primaryDeviceId"]]
+ assert dev.deviceTypeId == "matterRelay"
+ assert dev.pluginProps.get("SupportsPowerMeter") is True
+ assert ds.lookup(0x38, 2) is None
+
+
+# A pump-shaped node: OnOff (recognized, relay created) + an extra cluster
+# (0x0200) this plugin has no handler for at all — must be surfaced as a
+# diagnostic, not silently dropped (issue #81), without creating a second device.
+PUMP_MIXED_CLUSTER_NODE = {
+ "node_id": 0x56,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "1/512/0": 0, # 0x0200 — recognized by no registered handler
+ },
+}
+
+
+def test_unmapped_cluster_alongside_real_device_logs_diagnostic(ds, indigo_env, mock_logger):
+ """issue #81: a cluster this plugin doesn't recognize at all, co-occurring
+ with a real handled cluster (a pump's OnOff + an extra cluster), must not
+ be silently dropped once the endpoint's relay is created — an INFO log
+ must name it, with no second device."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(PUMP_MIXED_CLUSTER_NODE, "Pump")
+ dev = devices[ds.lookup(0x56, 1)]
+ assert dev.deviceTypeId == "matterRelay"
+ assert ds._all_dev_ids_for_endpoint(0x56, 1) == [dev.id], "no second device created"
+ info_msgs = [c[0][0] % tuple(c[0][1:]) for c in mock_logger.info.call_args_list]
+ assert any("0x0200" in m and "does not support yet" in m for m in info_msgs)
+
+
+def test_pure_relay_endpoint_has_no_unmapped_cluster_log(ds, indigo_env, mock_logger):
+ """Regression: a plain relay with nothing left over must NOT get the
+ issue #81 diagnostic log."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(RELAY_NODE, "Office Plug")
+ info_msgs = [c[0][0] for c in mock_logger.info.call_args_list]
+ assert not any("does not support yet" in m for m in info_msgs)
+
+
+# DynamicPowerFlow (bit 3) gates ActiveEndpoints (attr 0x0001) over
+# AvailableEndpoints (attr 0x0000) — proven via a decoy endpoint (99, which
+# doesn't exist on the node) in AvailableEndpoints, while ActiveEndpoints
+# correctly lists the real actuator endpoint 1.
+GRILLPLATS_DYNAMIC_POWER_FLOW_DECOY_NODE = {
+ "node_id": 0x57,
+ "available": True,
+ "attributes": {
+ "1/29/0": [{"0": 266}],
+ "1/6/0": False,
+ "2/29/0": [{"0": 0x0510}],
+ "2/144/8": 1200,
+ "2/145/1": {"energy": 3_600_000},
+ "2/156/65532": 12, # FeatureMap = SetTopology(bit2) | DynamicPowerFlow(bit3)
+ "2/156/0": [99], # AvailableEndpoints — decoy, must be ignored
+ "2/156/1": [1], # ActiveEndpoints — the real list when DYPF is set
+ },
+}
+
+
+def test_dynamic_power_flow_prefers_active_endpoints_over_available_decoy(ds, indigo_env):
+ """issue #80 review point G1: with DynamicPowerFlow set, ActiveEndpoints
+ (not AvailableEndpoints) must be consulted."""
+ _indigo, devices = indigo_env
+ result = ds.create_from_raw(GRILLPLATS_DYNAMIC_POWER_FLOW_DECOY_NODE, "Grill Plug")
+ dev = devices[result["primaryDeviceId"]]
+ assert dev.deviceTypeId == "matterRelay"
+ assert dev.pluginProps.get("SupportsPowerMeter") is True
+ assert ds.lookup(0x57, 2) is None
+
+
+def test_electrical_attribute_event_before_any_create_is_silent_noop(ds, indigo_env):
+ """issue #80 review point G2: an electrical attribute event for a node
+ that has never been created/reconciled must not raise and must create
+ nothing — the reading is silently dropped (there's nowhere for it to go
+ yet)."""
+ _indigo, devices = indigo_env
+ ds.handle_event(MatterEvent(
+ kind=protocol.EVT_ATTRIBUTE_UPDATED,
+ node_id=0x62, endpoint=2, cluster=0x0090, attribute=0x0008, value=1000,
+ )) # must not raise
+ assert len(list(devices)) == 0
+
+
+# ===========================================================================
+# issue #82 — bridge battery cross-contamination
+# ===========================================================================
+
+BRIDGE_MULTI_BATTERY_NODE = {
+ "node_id": 0x61,
+ "available": True,
+ "attributes": {
+ "0/40/1": "ACME",
+ "0/40/3": "Multi Sensor Bridge",
+ # Child 1: temperature sensor + its OWN PowerSource (battery = 80%)
+ "1/29/0": [{"0": 770}],
+ "1/1026/0": 2100,
+ "1/47/12": 160, # BatPercentRemaining = 160/2 = 80%
+ # Child 2: humidity sensor + its OWN PowerSource (battery = 40%, distinct)
+ "2/29/0": [{"0": 775}],
+ "2/1029/0": 5500,
+ "2/47/12": 80, # BatPercentRemaining = 80/2 = 40%
+ # Child 3: mains relay — no PowerSource at all
+ "3/29/0": [{"0": 266}],
+ "3/6/0": False,
+ },
+}
+
+
+def test_bridge_multi_power_source_battery_isolated_per_endpoint(ds, indigo_env):
+ """issue #82: a bridge with MORE THAN ONE PowerSource-bearing endpoint
+ must not fan battery updates node-wide — each child gets ONLY its own
+ battery level, and a mains child (no PowerSource) gets no
+ SupportsBatteryLevel at all."""
+ _indigo, devices = indigo_env
+ ds.create_from_raw(BRIDGE_MULTI_BATTERY_NODE, "Multi Sensor Bridge")
+ temp_id = ds.lookup(0x61, 1, "matterTemperatureSensor")
+ hum_id = ds.lookup(0x61, 2, "matterHumiditySensor")
+ relay_id = ds.lookup(0x61, 3, "matterRelay")
+ assert temp_id and hum_id and relay_id
+
+ assert devices[temp_id].pluginProps.get("SupportsBatteryLevel") is True
+ assert devices[hum_id].pluginProps.get("SupportsBatteryLevel") is True
+ assert not devices[relay_id].pluginProps.get("SupportsBatteryLevel")
+
+ assert devices[temp_id].states.get("batteryLevel") == 80
+ assert devices[hum_id].states.get("batteryLevel") == 40
+ assert "batteryLevel" not in devices[relay_id].states
+
+ # Live routing: an update on ep1's PowerSource must not leak to ep2/ep3.
+ ds.handle_event(MatterEvent(
+ kind=protocol.EVT_ATTRIBUTE_UPDATED,
+ node_id=0x61, endpoint=1, cluster=0x002F, attribute=0x000C, value=200,
+ ))
+ assert devices[temp_id].states.get("batteryLevel") == 100
+ assert devices[hum_id].states.get("batteryLevel") == 40 # untouched, no leak
diff --git a/tests/test_device_zoo.py b/tests/test_device_zoo.py
index 505a6c7..6ef000f 100644
--- a/tests/test_device_zoo.py
+++ b/tests/test_device_zoo.py
@@ -169,6 +169,27 @@ def _raw(node_id, eps):
_raw(19, {1: {"29/0": [{"0": 116}], "84/0": 0}}),
{1: []},
),
+ # IKEA GRILLPLATS-shaped split-endpoint energy (issue #79): the relay
+ # lives on ep1, ElectricalPower/Energy/PowerTopology on a dedicated ep2
+ # (Matter 1.3 "Electrical Sensor" device type 0x0510). The registry has no
+ # DeviceSync, so it only sees per-endpoint cluster→handler mapping — ep2's
+ # electrical clusters are merge-only and produce no primary spec of their
+ # own; the meter-link resolution that attributes them to ep1 is a
+ # DeviceSync concern, asserted in test_device_sync.py instead.
+ "grillplats_split_energy": (
+ _raw(0x34, {1: {"29/0": [{"0": 266}], "3/0": 0, "4/0": 0, "6/0": False},
+ 2: {"29/0": [{"0": 0x0510}], "144/8": 1200,
+ "145/1": {"energy": 1}, "156/65532": 1}}),
+ {1: ["matterRelay"], 2: []},
+ ),
+ # A pump-shaped endpoint (issue #81): OnOff + an extra cluster (0x0200)
+ # this plugin has no handler for at all. The registry still maps OnOff to
+ # a relay — the "leftover cluster" diagnostic that flags 0x0200 is a
+ # DeviceSync concern (asserted in test_device_sync.py), not visible here.
+ "mixed_unmapped": (
+ _raw(0x9B, {1: {"29/0": [{"0": 266}], "6/0": False, "512/0": 0}}),
+ {1: ["matterRelay"]},
+ ),
}