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 @@ IwsApiVersion 1.0.0 PluginVersion - 2026.3.2 + 2026.4.0 ServerApiVersion 3.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"]}, + ), }