diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 9ef6b4f..4f7b33c 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,13 +1,78 @@ # indigo-matter — Build Handover -**Last updated:** 2026-06-12 18:52 UTC -**Branch:** `fix/sensor-display-props` (PR pending) -**Version:** `2026.2.21` -**Tests:** 634 passing (`cd indigo-matter && /Library/Frameworks/Python.framework/Versions/Current/bin/python3 -m pytest -q`) +**Last updated:** 2026-06-12 20:38 UTC +**Branch:** `fix/device-mapping-hardening` (PR pending) +**Version:** `2026.2.22` +**Tests:** 743 passing (`cd indigo-matter && /Library/Frameworks/Python.framework/Versions/Current/bin/python3 -m pytest -q`) **Status:** **Wi-Fi AND Thread validated with real hardware.** Tapo P110M (Wi-Fi, energy) + Aqara FP300 (Thread, presence/lux/temp/humidity — tester CliveS). Known-open: issue #56 follow-ups (button + air-quality display states), fan brightness echo (rig flake); issues #43 (bridge-child naming), #46 (fan TurnOn FanMode mapping), #21–#24 (commission hardening). --- +## 2026-06-12 session (late) — endpoint-mapping hardening (issue #58) + +Audit prompted by #56 ("what else is in this bug class?"). Three gaps found and +fixed; a **device-zoo contract harness** (`tests/test_device_zoo.py`) now pins +the whole class: + +1. **Duplicate actuators.** Deferral chain was only OnOff→Level→Color (+ + Fan→Thermostat). A Matter fan (OnOff mandatory alongside FanControl since + 1.2), a thermostat/lock/valve/covering with OnOff, or a colour light + *without* LevelControl (zoo caught that one immediately) all produced a + duplicate relay/dimmer. Fix: `ENDPOINT_OWNER_CLUSTERS` in + `matter_handlers/base.py` (Valve 0x0081, DoorLock 0x0101, WindowCovering + 0x0102, Thermostat 0x0201, FanControl 0x0202); OnOff and LevelControl defer + to any of them, and OnOff also defers directly to ColorControl. +2. **Unknown devices vanished.** An endpoint with only unhandled clusters + produced nothing; a node of such endpoints commissioned to SUCCESS with + `indigoDeviceIds: []` (Domio shows success, Indigo shows nothing). Fix: + `matterUnknown` placeholder (its `supportedClusters` ConfigUI field was + already in Devices.xml, never wired) for device-bearing endpoints ≥1 whose + clusters aren't all in `_NON_DEVICE_CLUSTERS`; INFO log invites a GitHub + device-support report. When a later pass finds the endpoint supported + (firmware update — the Tapo pattern), the real device is created and an + INFO says the placeholder can be deleted (never auto-deleted). +3. **Type-edit guard.** Indigo's Edit Device dialog always offers the full + Type menu (platform behaviour, can't be removed). `createdTypeId` is now + stamped into props at creation and healed onto legacy devices by the + reconcile self-heal; `validateDeviceConfigUi` rejects a type change with an + explanatory alert; `deviceStartComm` warns as backstop for edits made while + the plugin was off. + +The zoo asserts, over every cluster-combo entry: expected device types, at +most one actuator per endpoint, spec types exist in Devices.xml, initial +states declared, sensor specs carry explicit display props (the #56 class). +**Add new wild devices' cluster sets to ZOO** — invariants run automatically. + +**Live-rig verification (same evening, jarvis on the 2026.2.22 branch build):** +deployed via rsync + restart with all 15 virtual devices running. First +reconcile healed every pre-fix device in one pass — 9 value sensors + smoke +got display props, all 20 devices got `createdTypeId`, every replace passed +the persistence verification (zero "did not persist" warnings). Second +restart: "reconciled 16 Matter node(s)" with NO re-asserts and **NO +stale-display warnings** → **real Indigo DOES re-derive `displayStateId` from +`replacePluginPropsOnServer`**. Confirmed on-device: the temp sensor's +`on_state` went `false` → `null` (built-in onOffState removed). So the #56 fix +is fully self-healing — users (incl. CliveS) just upgrade; delete-and-recreate +is never needed and the stale-display warning is pure dead-man insurance. + +**Follow-up experiment (same evening, also live on jarvis): #56 fully closed.** +Patched the jarvis copy to set BOTH Supports* False on the button and +air-quality handlers + temporary displayStateId instrumentation. Result: +button `displayStateId` → **`lastButtonEvent`**, air quality → **`airQuality`**. +The precedence rule for API-created devices: a True Supports* prop wins; with +both explicitly False, Indigo falls back to Devices.xml ``. +Implemented for real: `display_props = {both False}` on GenericSwitchHandler +and AirQualityHandler (the zoo invariant immediately caught that +generic_switch didn't merge display_props into its spec props — fixed); the +stale-display nag now covers any type disclaiming SupportsOnState; zoo +invariant allows (False, False) only when the XML declares a UiDisplayStateId +pointing at a declared custom state. 747 tests. Full display census of the +16-node fleet (from the instrumentation pass): relays/locks/valves onOffState, +dimmers/covering/fan brightnessLevel, thermostat temperatureInputsAll, value +sensors sensorValue, button lastButtonEvent, AQ airQuality — all correct. + +--- + ## 2026-06-12 session — THREAD VALIDATED + sensor display fix (issue #56) **Thread is real-device validated.** Tester CliveS commissioned an **Aqara FP300** diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index 9af25a5..212b614 100644 --- a/indigo-matter.indigoPlugin/Contents/Info.plist +++ b/indigo-matter.indigoPlugin/Contents/Info.plist @@ -20,7 +20,7 @@ IwsApiVersion 1.0.0 PluginVersion - 2026.2.21 + 2026.2.22 ServerApiVersion 3.6 diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml index e510fe1..034b13c 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -436,10 +436,11 @@ + UiDisplayStateId = airQuality IS the live display — it applies to + API-created devices only because the handler sets BOTH + SupportsOnState and SupportsSensorValue False in creation props + (the built-in display wins whenever one of them is True; verified + live, issue #56). --> Matter Air Quality Sensor diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py index 7214a5e..e540837 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py @@ -34,6 +34,7 @@ node_id_to_str, parse_node, ) +from matter_handlers.base import IndigoDeviceSpec from matter_handlers.electrical import CLUSTER_ELECTRICAL_ENERGY, CLUSTER_ELECTRICAL_POWER from matter_handlers.power_source import CLUSTER_POWER_SOURCE from protocol import MatterCommand @@ -43,6 +44,27 @@ # (on_off → matterRelay, level_control → matterDimmer, color_control → matterColorDimmer). _METER_CAPABLE_TYPES = frozenset({"matterRelay", "matterDimmer", "matterColorDimmer"}) +#: Clusters that don't, by themselves, make an endpoint a *device*: utility / +#: metadata clusters any endpoint may carry, plus the merge-only measurement +#: clusters (their handlers augment a sibling device, never create one). An +#: endpoint ≥1 whose unhandled clusters are all in this set gets no +#: matterUnknown placeholder (issue #58). +_NON_DEVICE_CLUSTERS = frozenset({ + 0x0003, # Identify + 0x0004, # Groups + 0x0005, # Scenes (pre-Matter-1.3 id) + 0x001D, # Descriptor + 0x001E, # Binding + 0x0028, # BasicInformation + 0x002F, # PowerSource (node-scoped battery, merged into siblings) + 0x0039, # BridgedDeviceBasicInformation + 0x0040, # FixedLabel + 0x0041, # UserLabel + 0x0062, # ScenesManagement (Matter 1.3 id) + 0x0090, # ElectricalPowerMeasurement (merge-only) + 0x0091, # ElectricalEnergyMeasurement (merge-only) +}) + def _kvlist(states: dict) -> list: return [{"key": key, "value": value} for key, value in states.items()] @@ -245,7 +267,31 @@ def create_devices(self, node: NodeInfo, suggested_room: Optional[str] = None) - endpoint.has(CLUSTER_POWER_SOURCE) for endpoint in node.endpoints ) for endpoint in node.endpoints: - for spec in self.registry.handlers_for_endpoint(node, endpoint): + specs = self.registry.handlers_for_endpoint(node, endpoint) + 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. + 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: spec.props.setdefault("SupportsBatteryLevel", True) plan.append((endpoint, spec)) @@ -304,6 +350,15 @@ def create_devices(self, node: NodeInfo, suggested_room: Optional[str] = None) - if dev_id is None: failed += 1 continue + if type_id == "matterUnknown": + self.logger.info( + "node %s endpoint %s exposes only clusters this plugin does not " + "support yet (%s) — created placeholder device %s; please report " + "the device at github.com/simons-plugins/indigo-matter/issues so " + "support can be added", + node_id_to_str(node.node_id), endpoint.endpoint_id, + spec.props.get("supportedClusters", "?"), dev_id, + ) self._index.setdefault(ep_key, {})[type_id] = dev_id created.append(dev_id) new_ids.append(dev_id) @@ -343,7 +398,38 @@ def create_devices(self, node: NodeInfo, suggested_room: Optional[str] = None) - result["failedEndpoints"] = failed return result + @staticmethod + def _unknown_spec(node: NodeInfo, endpoint: Any) -> Optional[IndigoDeviceSpec]: + """Placeholder spec for a device-bearing endpoint no handler claims. + + Returns None for endpoint 0 (the Matter root — utility clusters only) + and for endpoints whose clusters are all in _NON_DEVICE_CLUSTERS + (nothing device-like to surface). + """ + if int(endpoint.endpoint_id) == 0: + return None + unmapped = sorted(set(endpoint.cluster_ids) - _NON_DEVICE_CLUSTERS) + if not unmapped: + return None + name = node.suggested_name or node.product_name or f"Matter {node.node_id}" + return IndigoDeviceSpec( + device_type_id="matterUnknown", + name=name, + props={ + "nodeId": str(node.node_id), + "endpointId": str(endpoint.endpoint_id), + "vendorName": node.vendor_name, + "productName": node.product_name, + "supportedClusters": ", ".join(f"0x{c:04X}" for c in unmapped), + }, + initial_states={"reachable": True}, + ) + def _create_one(self, spec: Any, name: str, folder_id: int = 0) -> Optional[int]: + # Stamp the type the cluster pipeline chose, so the type-edit guard + # (validateDeviceConfigUi + deviceStartComm) can detect a manual change + # via Indigo's Edit Device Type menu (issue #58). + spec.props.setdefault("createdTypeId", spec.device_type_id) try: dev = indigo.device.create( protocol=indigo.kProtocol.Plugin, @@ -574,6 +660,16 @@ def _reassert_capability_props(self, node: NodeInfo) -> None: # be written explicitly, not skipped as already-falsy. if key not in current_props or bool(current_props.get(key)) != bool(value): missing[key] = value + # Heal the type-edit guard's stamp onto devices created + # before it existed (issue #58). Records the CURRENT type as + # canonical — for a pristine fleet that is the created type. + if type_id and "createdTypeId" not in current_props: + missing["createdTypeId"] = type_id + # Heal the address (UI protocol-id column, issue #18) onto + # devices created before that stamping existed — it is the + # node id a user needs for decommission. + if not current_props.get("address"): + missing["address"] = node_id_to_str(node.node_id) if not missing: # Props are already correct — but Indigo caches the list # display state at creation and may not re-derive it from @@ -623,20 +719,25 @@ def _reassert_capability_props(self, node: NodeInfo) -> None: ) def _warn_stale_display_state(self, dev: Any, type_id: str, display_props: dict) -> None: - """Warn when a value sensor's cached display state is still on/off. + """Warn when a device's cached display state is wrongly stuck on on/off. Indigo derives ``displayStateId`` from the Supports* props at device - creation; replacing props afterwards rebuilds states but the display - may stay cached on ``onOffState`` (issue #56). The fix is user-side - and cheap — deleting the Indigo device and reloading the plugin lets - reconcile recreate it with the correct creation props — so name the - device and say exactly that. Deliberately checked only on passes where - no props needed replacing: right after a replace, Indigo may not have - re-derived yet, and warning there would be noise (a replace that never - converges is surfaced separately by the did-not-persist warning). + creation; replacing props afterwards rebuilds states and (verified + live, 2026-06-12) normally re-derives the display too — this warning + is dead-man insurance for the case where it doesn't. Applies to any + type whose display_props disclaim the on/off display: value sensors + (SupportsSensorValue) and UiDisplayStateId-fallback types like the + button and air-quality sensor (both Supports* False). The remedy is + user-side and cheap — deleting the Indigo device and reloading the + plugin lets reconcile recreate it with the correct creation props — + so name the device and say exactly that. Deliberately checked only on + passes where no props needed replacing: right after a replace, Indigo + may not have re-derived yet, and warning there would be noise (a + replace that never converges is surfaced separately by the + did-not-persist warning). """ - if not display_props.get("SupportsSensorValue"): - return # on/off IS the right display for binary sensors + if display_props.get("SupportsOnState", True): + return # on/off IS the right display (binary sensors, non-sensor types) display_state = getattr(dev, "displayStateId", None) if display_state is None: # Unreadable is "unknown", not "healthy" — keep a trace, but a real diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/air_quality.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/air_quality.py index d7bbd72..f10d7fb 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/air_quality.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/air_quality.py @@ -45,15 +45,20 @@ class AirQualityHandler(_SensorHandler): Attribute 0x0000 is an enum; we expose both the raw integer (sensorValue) and the human-readable string (airQuality) so automations can trigger on - the string and display it directly. Devices.xml declares UiDisplayStateId - = airQuality, but API-created devices ignore it (issue #56) — the list - display is the raw enum via the inherited SupportsSensorValue prop; the - string display is a #56 follow-up pending hardware testing. + the string and display it directly. The list display IS the string: + with both Supports* props False the Devices.xml + applies even to API-created devices (verified live — issue #56). """ cluster_id = 0x005B cluster_name = "AirQuality" device_type_id = "matterAirQualitySensor" + # Override the value-sensor pair: with BOTH Supports* False, Indigo falls + # back to airQuality, so the device + # list shows "good"/"poor" instead of the raw enum int. Verified live on + # jarvis (issue #56 follow-up). sensorValue keeps working — it is an + # XML-declared custom state, not the disabled built-in. + display_props = {"SupportsOnState": False, "SupportsSensorValue": False} measured_attr = ATTR_AIR_QUALITY diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py index ec30ae4..31af6e5 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py @@ -17,11 +17,28 @@ from protocol import MatterCommand, MatterWrite # re-exported for handlers/tests -__all__ = ["IndigoDeviceSpec", "MatterCommand", "MatterWrite", "ClusterHandler", "MatterAction"] +__all__ = [ + "IndigoDeviceSpec", "MatterCommand", "MatterWrite", "ClusterHandler", + "MatterAction", "ENDPOINT_OWNER_CLUSTERS", +] #: A handler action is either a cluster-command invoke or an attribute write. MatterAction = Union[MatterCommand, MatterWrite] +#: Clusters whose handler owns the whole endpoint. When one of these is present, +#: OnOff/LevelControl on the same endpoint are subordinate controls of that +#: device (a fan's power switch, a thermostat's enable) and must NOT produce a +#: standalone relay/dimmer — that would duplicate the device in Indigo. The +#: Matter spec makes such combinations normal: e.g. the Fan device type +#: mandates OnOff alongside FanControl since Matter 1.2 (issue #58). +ENDPOINT_OWNER_CLUSTERS = frozenset({ + 0x0081, # ValveConfigurationAndControl + 0x0101, # DoorLock + 0x0102, # WindowCovering + 0x0201, # Thermostat + 0x0202, # FanControl +}) + @dataclass class IndigoDeviceSpec: diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/generic_switch.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/generic_switch.py index 8882ece..3d2cb49 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/generic_switch.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/generic_switch.py @@ -66,6 +66,11 @@ class GenericSwitchHandler(ClusterHandler): cluster_id = CLUSTER_SWITCH cluster_name = "GenericSwitch" device_type_id = "matterButton" + # With BOTH Supports* False, Indigo falls back to the Devices.xml + # (lastButtonEvent) even for API-created devices — + # the props-driven built-in display only takes precedence when one of + # these is True. Verified live on jarvis (issue #56 follow-up). + display_props = {"SupportsOnState": False, "SupportsSensorValue": False} def create_indigo_devices(self, node: Any, endpoint: Any) -> list[IndigoDeviceSpec]: name = node.suggested_name or node.product_name or f"Matter {node.node_id}" @@ -78,6 +83,7 @@ def create_indigo_devices(self, node: Any, endpoint: Any) -> list[IndigoDeviceSp "endpointId": str(endpoint.endpoint_id), "vendorName": node.vendor_name, "productName": node.product_name, + **self.display_props, }, initial_states={ "lastButtonEvent": "", diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/level_control.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/level_control.py index db0bea5..17a0918 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/level_control.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/level_control.py @@ -11,7 +11,7 @@ from typing import Any, Optional -from .base import ClusterHandler, IndigoDeviceSpec, MatterCommand +from .base import ENDPOINT_OWNER_CLUSTERS, ClusterHandler, IndigoDeviceSpec, MatterCommand from .electrical import CLUSTER_ELECTRICAL_ENERGY, CLUSTER_ELECTRICAL_POWER CLUSTER_ON_OFF = 0x0006 @@ -35,8 +35,13 @@ class LevelControlHandler(ClusterHandler): ATTR_CURRENT_LEVEL = 0x0000 def is_primary_for(self, node: Any, endpoint: Any) -> bool: - # ColorControl present → the color handler owns this endpoint. - return not endpoint.has(CLUSTER_COLOR_CONTROL) + # ColorControl present → the color handler owns this endpoint. A rich + # actuator cluster present → that handler owns it (a covering's lift + # or a fan's speed is driven through its own cluster, not LevelControl) + # and a standalone dimmer would be a duplicate (issue #58). + if endpoint.has(CLUSTER_COLOR_CONTROL): + return False + return not any(endpoint.has(c) for c in ENDPOINT_OWNER_CLUSTERS) def create_indigo_devices(self, node: Any, endpoint: Any) -> list[IndigoDeviceSpec]: if not self.is_primary_for(node, endpoint): diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/on_off.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/on_off.py index 4dcef0b..c41ed95 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/on_off.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/on_off.py @@ -9,10 +9,11 @@ from typing import Any, Optional -from .base import ClusterHandler, IndigoDeviceSpec, MatterCommand +from .base import ENDPOINT_OWNER_CLUSTERS, ClusterHandler, IndigoDeviceSpec, MatterCommand from .electrical import CLUSTER_ELECTRICAL_ENERGY, CLUSTER_ELECTRICAL_POWER CLUSTER_LEVEL_CONTROL = 0x0008 +CLUSTER_COLOR_CONTROL = 0x0300 class OnOffHandler(ClusterHandler): @@ -26,8 +27,14 @@ class OnOffHandler(ClusterHandler): CMD_TOGGLE = "Toggle" def is_primary_for(self, node: Any, endpoint: Any) -> bool: - # LevelControl present → a dimmer handler owns this endpoint. - return not endpoint.has(CLUSTER_LEVEL_CONTROL) + # A richer lighting handler (dimmer OR colour — a colour light is not + # required to carry LevelControl) owns this endpoint; a rich actuator + # cluster (fan/thermostat/covering/lock/valve) present → that handler + # owns it and this OnOff is its subordinate power switch, not a + # standalone relay (issue #58 — duplicate-device class). + if endpoint.has(CLUSTER_LEVEL_CONTROL) or endpoint.has(CLUSTER_COLOR_CONTROL): + return False + return not any(endpoint.has(c) for c in ENDPOINT_OWNER_CLUSTERS) def create_indigo_devices(self, node: Any, endpoint: Any) -> list[IndigoDeviceSpec]: if not self.is_primary_for(node, endpoint): diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py index 1ebd4a3..079ae0f 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py @@ -198,7 +198,45 @@ def closedPrefsConfigUi(self, valuesDict, userCancelled): # noqa: N802 # ------------------------------------------------------------------ # Device lifecycle # ------------------------------------------------------------------ + def validateDeviceConfigUi(self, valuesDict, typeId, devId): # noqa: N802 + """Reject changing a Matter device's Indigo type (issue #58). + + Indigo's Edit Device dialog always offers the plugin's full Type menu + and a plugin cannot remove it — but Matter device types are derived + from the node's clusters at creation. A manual change desyncs the + device: the next reconcile creates a duplicate of the correct type, + and the re-typed device becomes a zombie whose actions target clusters + the node does not implement. ``createdTypeId`` is stamped into props at + creation (and healed onto older devices at reconcile); absence of the + stamp (e.g. a manually created device) allows the save. + """ + created = "" + try: + created = indigo.devices[devId].pluginProps.get("createdTypeId", "") + except KeyError: + pass # brand-new device — nothing to protect yet + if created and typeId != created: + errors = indigo.Dict() + errors["showAlertText"] = ( + "Matter device types are derived from the device itself and " + f"cannot be changed (this device was created as '{created}'). " + "If the device is wrong, delete it and reload the plugin — it " + "will be recreated from the node's clusters." + ) + return (False, valuesDict, errors) + return (True, valuesDict) + def deviceStartComm(self, dev): # noqa: N802 + # Backstop for type edits made while the plugin was not running (the + # validateDeviceConfigUi guard can't fire then) — issue #58. + created = dev.pluginProps.get("createdTypeId", "") + if created and dev.deviceTypeId != created: + self.logger.warning( + "device %s (%s) was created as type %s but is now %s — Matter " + "device types cannot be changed; delete the device and reload " + "this plugin to recreate it correctly", + dev.id, dev.name, created, dev.deviceTypeId, + ) self.device_sync.note_device(dev) self.device_sync.set_active(dev.id, True) diff --git a/tests/test_air_quality.py b/tests/test_air_quality.py index 3c72aa6..b4e581c 100644 --- a/tests/test_air_quality.py +++ b/tests/test_air_quality.py @@ -198,10 +198,18 @@ def test_registry_cluster_id_lookup(): assert reg.handler_for_cluster(0x042E).device_type_id == "matterTVOCSensor" -def test_air_quality_handlers_inherit_value_display_props(): +def test_concentration_handlers_inherit_value_display_props(): # Issue #56: these are value sensors — their list display must be the - # reading, not on/off. (The AirQuality string display, UiDisplayStateId= - # airQuality, is a hardware-test follow-up; raw enum beats "off".) - for handler_cls in (AirQualityHandler, CO2Handler, PM25Handler, TVOCHandler): + # reading, not on/off. + for handler_cls in (CO2Handler, PM25Handler, TVOCHandler): props = handler_cls.display_props assert props == {"SupportsSensorValue": True, "SupportsOnState": False}, handler_cls + + +def test_air_quality_display_is_ui_display_state_fallback(): + # AirQuality disclaims BOTH built-in displays so Indigo falls back to the + # Devices.xml UiDisplayStateId (airQuality string) — precedence verified + # live on jarvis (issue #56 follow-up). + assert AirQualityHandler.display_props == { + "SupportsOnState": False, "SupportsSensorValue": False, + } diff --git a/tests/test_device_sync.py b/tests/test_device_sync.py index 46f796e..0c49c71 100644 --- a/tests/test_device_sync.py +++ b/tests/test_device_sync.py @@ -27,11 +27,17 @@ def __init__(self, dev_id, name, device_type_id, props): self.errorState = "" self.folderId = 0 # Real Indigo derives the list display from Supports* props at CREATION - # and caches it (issue #56) — approximate the sensor-family behaviour - # here (non-sensor types crudely get onOffState, which the warn path - # never reads), and deliberately do NOT re-derive it in - # replacePluginPropsOnServer below. - self.displayStateId = "sensorValue" if props.get("SupportsSensorValue") else "onOffState" + # and caches it (issue #56) — approximate the precedence rule verified + # live on jarvis: a True Supports* wins; with BOTH explicitly False the + # Devices.xml UiDisplayStateId applies ("uiDisplayState" stands in for + # it here). Deliberately do NOT re-derive in replacePluginPropsOnServer + # below (models the pessimistic cached case the warn path exists for). + if props.get("SupportsSensorValue"): + self.displayStateId = "sensorValue" + elif "SupportsOnState" in props and not props.get("SupportsOnState"): + self.displayStateId = "uiDisplayState" + else: + self.displayStateId = "onOffState" def updateStatesOnServer(self, kvlist): for kv in kvlist: @@ -1325,7 +1331,8 @@ def test_reassert_corrects_present_but_wrong_display_props(ds, indigo_env): def test_unknown_device_type_in_index_is_processed_quietly(ds, indigo_env, mock_logger): """A stale index entry whose deviceTypeId no handler owns (e.g. after a type rename) must neither crash the re-assert loop nor log a could-not-re-assert - warning — and must not block healing of other devices.""" + warning — and must not block healing of other devices. The only change it + may receive is the createdTypeId stamp (the #58 type-edit guard heal).""" _indigo, devices = indigo_env ds.create_from_raw(TEMP_SENSOR_NODE, "Landing Sensor") ghost = FakeDev(devices.next_id(), "Ghost", "matterRetiredType", @@ -1337,7 +1344,9 @@ def test_unknown_device_type_in_index_is_processed_quietly(ds, indigo_env, mock_ msgs = [c[0][0] for c in mock_logger.warning.call_args_list] assert not any("could not re-assert" in m for m in msgs), msgs - assert not getattr(ghost, "replaced_props", False) + # The stamp heal is the one legitimate write; no other props may change. + assert ghost.pluginProps.get("createdTypeId") == "matterRetiredType" + assert "SupportsSensorValue" not in ghost.pluginProps def test_props_replace_that_does_not_persist_warns_instead_of_claiming_success(ds, indigo_env, mock_logger): @@ -1362,3 +1371,167 @@ def test_props_replace_that_does_not_persist_warns_instead_of_claiming_success(d assert persist_warnings, "expected a did-not-persist warning" formatted = persist_warnings[0][0] % tuple(persist_warnings[0][1:]) assert "SupportsSensorValue" in formatted and "SupportsOnState" in formatted + + +# =========================================================================== +# fix/#58 — matterUnknown fallback + createdTypeId stamp +# =========================================================================== + +# A node whose only device-bearing endpoint exposes clusters we don't handle +# (RVC RunMode 0x0054 = 84). Must surface as a placeholder, not vanish. +UNKNOWN_NODE = { + "node_id": 0x50, + "available": True, + "attributes": { + "0/40/1": "Roborock", + "0/40/3": "RoboVac", + "1/29/0": [{"0": 116}], + "1/84/0": 0, + }, +} + +# Root endpoint only — nothing device-bearing anywhere. Must create nothing. +ROOT_ONLY_NODE = { + "node_id": 0x51, + "available": True, + "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. +ELECTRICAL_ONLY_NODE = { + "node_id": 0x52, + "available": True, + "attributes": { + "0/40/1": "ACME", + "1/29/0": [{"0": 1296}], + "1/144/8": 1200, + "1/145/1": {"energy": 1}, + }, +} + + +def test_unknown_endpoint_creates_placeholder_not_nothing(ds, indigo_env, mock_logger): + _indigo, devices = indigo_env + result = ds.create_from_raw(UNKNOWN_NODE, "Vacuum") + assert result["indigoDeviceIds"], "unknown device vanished — commission would claim success with no devices" + dev = devices[result["primaryDeviceId"]] + assert dev.deviceTypeId == "matterUnknown" + assert "0x0054" in dev.pluginProps["supportedClusters"] + assert dev.states.get("reachable") is True + assert dev.pluginProps["createdTypeId"] == "matterUnknown" + # The report-invite must be logged. + info_msgs = [c[0][0] % tuple(c[0][1:]) for c in mock_logger.info.call_args_list] + assert any("please report" in m for m in info_msgs) + + +def test_unknown_placeholder_is_idempotent(ds, indigo_env): + _indigo, devices = indigo_env + ds.create_from_raw(UNKNOWN_NODE, "Vacuum") + first = ds.lookup(0x50, 1) + result = ds.create_from_raw(UNKNOWN_NODE, "Vacuum") + assert result["indigoDeviceIds"] == [first] + + +def test_root_only_node_creates_nothing(ds, indigo_env): + _indigo, devices = indigo_env + result = ds.create_from_raw(ROOT_ONLY_NODE, "Mystery") + assert result["indigoDeviceIds"] == [] + assert len(list(devices)) == 0 + + +def test_merge_only_endpoint_gets_no_placeholder(ds, indigo_env): + _indigo, devices = indigo_env + result = ds.create_from_raw(ELECTRICAL_ONLY_NODE, "Meter") + assert result["indigoDeviceIds"] == [] + + +def test_placeholder_obsolete_when_endpoint_becomes_supported(ds, indigo_env, mock_logger): + """Firmware update adds a supported cluster: the real device is created and + the user is told the placeholder can be deleted — never auto-deleted.""" + _indigo, devices = indigo_env + ds.create_from_raw(UNKNOWN_NODE, "Vacuum") + placeholder_id = ds.lookup(0x50, 1, "matterUnknown") + + upgraded = { + "node_id": 0x50, + "available": True, + "attributes": dict(UNKNOWN_NODE["attributes"], **{"1/6/0": False}), + } + mock_logger.info.reset_mock() + ds.reconcile_all([upgraded]) + + assert ds.lookup(0x50, 1, "matterRelay") is not None + assert ds.lookup(0x50, 1, "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) + + +def test_created_devices_carry_created_type_id(ds, indigo_env): + _indigo, devices = indigo_env + ds.create_from_raw(TEMP_SENSOR_NODE, "Landing Sensor") + dev = devices[ds.lookup(0x40, 1)] + assert dev.pluginProps["createdTypeId"] == "matterTemperatureSensor" + + +def test_reassert_stamps_created_type_id_on_legacy_devices(ds, indigo_env): + """Devices created before the #58 guard gain the stamp at reconcile.""" + _indigo, devices = indigo_env + ds.create_from_raw(TEMP_SENSOR_NODE, "Landing Sensor") + dev = devices[ds.lookup(0x40, 1)] + props = dict(dev.pluginProps) + props.pop("createdTypeId", None) + dev.pluginProps = props + + ds.reconcile_all([TEMP_SENSOR_NODE]) + assert dev.pluginProps.get("createdTypeId") == "matterTemperatureSensor" + + +BUTTON_NODE = { + "node_id": 0x43, + "available": True, + "attributes": { + "0/40/1": "Aqara", + "0/40/3": "Mini Switch", + "1/29/0": [{"0": 15}], + "1/59/1": 0, # GenericSwitch CurrentPosition + }, +} + + +def test_stale_display_warning_covers_ui_display_fallback_types(ds, indigo_env, mock_logger): + """A pre-fix button stuck on the on/off display gets the same nag as a + value sensor — any type whose display_props disclaim SupportsOnState.""" + _indigo, devices = indigo_env + ds.create_from_raw(BUTTON_NODE, "Hall Button") + dev = devices[ds.lookup(0x43, 1)] + _strip_display_props(dev) + + ds.reconcile_all([BUTTON_NODE]) # heals props; fake keeps display cached + mock_logger.warning.reset_mock() + ds.reconcile_all([BUTTON_NODE]) + + msgs = [c[0][0] % tuple(c[0][1:]) for c in mock_logger.warning.call_args_list] + assert any("delete the Indigo device" in m and "Hall Button" in m for m in msgs), msgs + + +def test_healthy_button_reconcile_is_quiet(ds, indigo_env, mock_logger): + ds.create_from_raw(BUTTON_NODE, "Hall Button") + mock_logger.warning.reset_mock() + ds.reconcile_all([BUTTON_NODE]) + msgs = [c[0][0] for c in mock_logger.warning.call_args_list] + assert not any("display" in m for m in msgs) + + +def test_reassert_backfills_address_on_legacy_devices(ds, indigo_env): + """Devices created before the issue #18 address stamping gain the node-id + address at reconcile — it's what a user needs for decommission.""" + _indigo, devices = indigo_env + ds.create_from_raw(TEMP_SENSOR_NODE, "Landing Sensor") + dev = devices[ds.lookup(0x40, 1)] + props = dict(dev.pluginProps) + props.pop("address", None) + dev.pluginProps = props + + ds.reconcile_all([TEMP_SENSOR_NODE]) + assert dev.pluginProps.get("address") == "0x40" diff --git a/tests/test_device_zoo.py b/tests/test_device_zoo.py new file mode 100644 index 0000000..5f9f471 --- /dev/null +++ b/tests/test_device_zoo.py @@ -0,0 +1,249 @@ +"""Device-zoo contract harness (issue #58). + +Structural invariants checked over a zoo of cluster combinations, run through +the REAL handler registry — including combinations no validated device has +shipped yet (a Matter fan mandates OnOff alongside FanControl since 1.2, some +thermostats/locks expose OnOff, etc.). The point is the *invariants*, not the +individual expectations: when a strange device shows up in the wild, add its +cluster set to ZOO and every invariant runs over it automatically. + +Invariants: + 1. the endpoint maps to exactly the expected device types; + 2. at most ONE actuator device per endpoint (the duplicate-relay class); + 3. every spec's device_type_id exists in Devices.xml; + 4. every initial state is XML-declared or an Indigo built-in; + 5. every sensor-type spec carries explicit SupportsOnState / + SupportsSensorValue with exactly one True (the issue #56 class). +""" +from __future__ import annotations + +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from matter_handlers.registry import HandlerRegistry +from matter_model import parse_node + +DEVICES_XML = ( + Path(__file__).parent.parent + / "indigo-matter.indigoPlugin" / "Contents" / "Server Plugin" / "Devices.xml" +) +_DEVICE_ELEMENTS = ET.parse(DEVICES_XML).findall("Device") +XML_TYPE_BY_ID = {d.get("id"): d.get("type") for d in _DEVICE_ELEMENTS} +XML_STATES_BY_ID = { + d.get("id"): {s.get("id") for s in d.findall("./States/State")} + for d in _DEVICE_ELEMENTS +} +XML_UI_DISPLAY_BY_ID = { + d.get("id"): (d.findtext("UiDisplayStateId") or "").strip() + for d in _DEVICE_ELEMENTS +} + +# Indigo built-in states implied by the device type / Supports* props +# (SDK: Example Device - Sensor and Example Device - Relay and Dimmer). +BUILTIN_STATES = { + "onOffState", "brightnessLevel", "sensorValue", "batteryLevel", + "curEnergyLevel", "accumEnergyTotal", +} + +# Device types that *control* the physical endpoint. Two of these for one +# endpoint means two Indigo devices fighting over one piece of hardware. +ACTUATOR_TYPES = { + "matterRelay", "matterDimmer", "matterColorDimmer", "matterFan", + "matterThermostat", "matterWindowCovering", "matterLock", "matterValve", +} + + + +def _raw(node_id, eps): + """Build a raw matter-server node dict from {ep: {"cluster/attr": value}}.""" + attributes = {} + for ep, attrs in eps.items(): + for suffix, value in attrs.items(): + attributes[f"{ep}/{suffix}"] = value + return {"node_id": node_id, "available": True, "attributes": attributes} + + +# name → (raw node, {endpoint: sorted expected device_type_ids}) +ZOO = { + "plug": ( + _raw(1, {1: {"29/0": [{"0": 266}], "6/0": False}}), + {1: ["matterRelay"]}, + ), + "energy_plug": ( + _raw(2, {1: {"29/0": [{"0": 266}], "6/0": False, + "144/8": 1200, "145/1": {"energy": 1}}}), + {1: ["matterRelay"]}, + ), + "dimmer": ( + _raw(3, {1: {"29/0": [{"0": 257}], "6/0": False, "8/0": 128}}), + {1: ["matterDimmer"]}, + ), + "color_light": ( + _raw(4, {1: {"29/0": [{"0": 269}], "6/0": False, "8/0": 128, "768/0": 0}}), + {1: ["matterColorDimmer"]}, + ), + # ColorControl without LevelControl — colour handler still owns the endpoint. + "color_no_level": ( + _raw(5, {1: {"29/0": [{"0": 269}], "6/0": False, "768/0": 0}}), + {1: ["matterColorDimmer"]}, + ), + # Matter 1.2+ Fan device type mandates OnOff + FanControl: one fan, NOT + # fan + duplicate relay (issue #58). + "fan_with_onoff": ( + _raw(6, {1: {"29/0": [{"0": 43}], "6/0": True, "514/0": 2}}), + {1: ["matterFan"]}, + ), + "thermostat_with_onoff": ( + _raw(7, {1: {"29/0": [{"0": 769}], "6/0": True, "513/0": 2150}}), + {1: ["matterThermostat"]}, + ), + # FanControl co-located with Thermostat merges into the thermostat device. + "thermostat_with_fan": ( + _raw(8, {1: {"29/0": [{"0": 769}], "513/0": 2150, "514/0": 2}}), + {1: ["matterThermostat"]}, + ), + "covering_with_onoff_and_level": ( + _raw(9, {1: {"29/0": [{"0": 514}], "6/0": True, "8/0": 10, "258/0": 0}}), + {1: ["matterWindowCovering"]}, + ), + "lock_with_onoff": ( + _raw(10, {1: {"29/0": [{"0": 10}], "6/0": False, "257/0": 1}}), + {1: ["matterLock"]}, + ), + "valve_with_onoff": ( + _raw(11, {1: {"29/0": [{"0": 66}], "6/0": False, "129/0": None}}), + {1: ["matterValve"]}, + ), + "climate_sensor": ( + _raw(12, {1: {"29/0": [{"0": 770}], "1026/0": 2185, "1029/0": 4750}}), + {1: ["matterHumiditySensor", "matterTemperatureSensor"]}, + ), + "presence_lux_sensor": ( + _raw(13, {1: {"29/0": [{"0": 263}], "1030/0": 1, "1024/0": 10000}}), + {1: ["matterIlluminanceSensor", "matterMotionSensor"]}, + ), + "contact_sensor": ( + _raw(14, {1: {"29/0": [{"0": 21}], "69/0": True}}), + {1: ["matterContactSensor"]}, + ), + "smoke_alarm": ( + _raw(15, {1: {"29/0": [{"0": 118}], "92/0": 0}}), + {1: ["matterSmokeCOAlarm"]}, + ), + "air_quality_station": ( + _raw(16, {1: {"29/0": [{"0": 44}], "91/0": 1, "1037/0": 400.0, + "1066/0": 5.0, "1070/0": 100.0}}), + {1: ["matterAirQualitySensor", "matterCO2Sensor", + "matterPM25Sensor", "matterTVOCSensor"]}, + ), + "button": ( + _raw(17, {1: {"29/0": [{"0": 15}], "59/1": 0}}), + {1: ["matterButton"]}, + ), + "pressure_flow_sensor": ( + _raw(18, {1: {"29/0": [{"0": 773}], "1027/0": 1013, "1028/0": 25}}), + {1: ["matterFlowSensor", "matterPressureSensor"]}, + ), + # An endpoint made only of clusters we don't handle (RVC RunMode 0x0054): + # the registry yields nothing — device_sync's matterUnknown fallback takes + # over (covered in test_device_sync.py). + "unknown_only": ( + _raw(19, {1: {"29/0": [{"0": 116}], "84/0": 0}}), + {1: []}, + ), +} + + +def _specs_by_endpoint(raw): + node = parse_node(raw) + registry = HandlerRegistry() + return { + ep.endpoint_id: registry.handlers_for_endpoint(node, ep) + for ep in node.endpoints + } + + +@pytest.mark.parametrize("name", ZOO) +def test_zoo_maps_to_expected_device_types(name): + raw, expected = ZOO[name] + specs = _specs_by_endpoint(raw) + actual = {ep: sorted(s.device_type_id for s in eps) for ep, eps in specs.items()} + for ep, types in expected.items(): + assert actual.get(ep, []) == types, f"{name} ep{ep}: {actual.get(ep)}" + + +@pytest.mark.parametrize("name", ZOO) +def test_zoo_at_most_one_actuator_per_endpoint(name): + raw, _ = ZOO[name] + for ep, specs in _specs_by_endpoint(raw).items(): + actuators = [s.device_type_id for s in specs if s.device_type_id in ACTUATOR_TYPES] + assert len(actuators) <= 1, ( + f"{name} ep{ep}: {actuators} would fight over one physical device" + ) + + +@pytest.mark.parametrize("name", ZOO) +def test_zoo_spec_types_exist_in_devices_xml(name): + raw, _ = ZOO[name] + for specs in _specs_by_endpoint(raw).values(): + for spec in specs: + assert spec.device_type_id in XML_TYPE_BY_ID, ( + f"{name}: {spec.device_type_id} not declared in Devices.xml" + ) + + +@pytest.mark.parametrize("name", ZOO) +def test_zoo_initial_states_are_declared_or_builtin(name): + raw, _ = ZOO[name] + for specs in _specs_by_endpoint(raw).values(): + for spec in specs: + allowed = XML_STATES_BY_ID.get(spec.device_type_id, set()) | BUILTIN_STATES + unknown = set(spec.initial_states) - allowed + assert not unknown, ( + f"{name}: {spec.device_type_id} seeds undeclared states {unknown}" + ) + + +@pytest.mark.parametrize("name", ZOO) +def test_zoo_sensor_specs_carry_explicit_display_props(name): + # The issue #56 class: Indigo derives a sensor's list display from these + # creation props. Precedence (verified live on jarvis): a True Supports* + # wins; with BOTH explicitly False, Devices.xml applies + # — so (False, False) is only legal when the XML declares one that points + # at an XML-declared custom state. + raw, _ = ZOO[name] + for specs in _specs_by_endpoint(raw).values(): + for spec in specs: + if XML_TYPE_BY_ID.get(spec.device_type_id) != "sensor": + continue + on_state = spec.props.get("SupportsOnState") + sensor_value = spec.props.get("SupportsSensorValue") + assert (on_state, sensor_value) in { + (True, False), (False, True), (False, False), + }, ( + f"{name}: {spec.device_type_id} display props " + f"({on_state!r}, {sensor_value!r}) — would display 'off' forever" + ) + if (on_state, sensor_value) == (False, False): + ui_display = XML_UI_DISPLAY_BY_ID.get(spec.device_type_id, "") + assert ui_display, ( + f"{name}: {spec.device_type_id} disclaims both built-in " + "displays but Devices.xml has no UiDisplayStateId fallback" + ) + assert ui_display in XML_STATES_BY_ID.get(spec.device_type_id, set()), ( + f"{name}: {spec.device_type_id} UiDisplayStateId " + f"'{ui_display}' is not an XML-declared custom state" + ) + + +def test_every_registered_handler_type_exists_in_devices_xml(): + # Registry-wide, zoo-independent: a handler pointing at a typo'd or + # removed Devices.xml id would create devices that Indigo rejects. + for handler in HandlerRegistry().handlers: + if not handler.device_type_id: + continue # merge-only handlers (PowerSource, Electrical*) + assert handler.device_type_id in XML_TYPE_BY_ID, ( + f"{type(handler).__name__} → {handler.device_type_id} not in Devices.xml" + ) diff --git a/tests/test_generic_switch.py b/tests/test_generic_switch.py index baa27ee..95fe5f4 100644 --- a/tests/test_generic_switch.py +++ b/tests/test_generic_switch.py @@ -615,3 +615,14 @@ def test_base_handler_on_node_event_default_returns_empty(): dev = MagicMock() result = h.on_node_event(dev, 0x01, {"someField": 1}) assert result == {} + + +def test_button_display_is_ui_display_state_fallback(): + # The button disclaims BOTH built-in displays so Indigo falls back to the + # Devices.xml UiDisplayStateId (lastButtonEvent) — precedence verified + # live on jarvis (issue #56 follow-up). Without this the device list + # showed "off" forever. + from matter_handlers.generic_switch import GenericSwitchHandler + assert GenericSwitchHandler.display_props == { + "SupportsOnState": False, "SupportsSensorValue": False, + } diff --git a/tests/test_plugin_module.py b/tests/test_plugin_module.py index 8efa787..7f10af1 100644 --- a/tests/test_plugin_module.py +++ b/tests/test_plugin_module.py @@ -119,3 +119,61 @@ def test_relay_devices_do_not_redefine_native_onoffstate(): and any(s.get("id") == "onOffState" for s in dev.iter("State")) ] assert not offenders, f"relay-type devices must not redefine native onOffState: {offenders}" + + +# --------------------------------------------------------------------------- +# fix/#58 — type-edit guard +# --------------------------------------------------------------------------- + +def _dev(props, type_id="matterTemperatureSensor"): + from types import SimpleNamespace + return SimpleNamespace(id=5, name="Landing Sensor", deviceTypeId=type_id, + pluginProps=props) + + +def test_validate_rejects_type_change(plugin_cls, mock_indigo_base): + mock_indigo_base.devices = {5: _dev({"createdTypeId": "matterTemperatureSensor"})} + ok, _values, errors = plugin_cls.validateDeviceConfigUi(None, {}, "matterRelay", 5) + assert ok is False + assert "cannot be changed" in errors["showAlertText"] + + +def test_validate_allows_unchanged_type(plugin_cls, mock_indigo_base): + mock_indigo_base.devices = {5: _dev({"createdTypeId": "matterTemperatureSensor"})} + result = plugin_cls.validateDeviceConfigUi(None, {}, "matterTemperatureSensor", 5) + assert result[0] is True + + +def test_validate_allows_unstamped_device(plugin_cls, mock_indigo_base): + # Pre-guard or manually created device — no stamp, no opinion. + mock_indigo_base.devices = {5: _dev({})} + result = plugin_cls.validateDeviceConfigUi(None, {}, "matterRelay", 5) + assert result[0] is True + + +def test_validate_allows_unknown_device_id(plugin_cls, mock_indigo_base): + mock_indigo_base.devices = {} + result = plugin_cls.validateDeviceConfigUi(None, {}, "matterRelay", 999) + assert result[0] is True + + +def test_device_start_comm_warns_on_type_mismatch(plugin_cls, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + stub = SimpleNamespace(logger=mock_logger, device_sync=MagicMock()) + dev = _dev({"createdTypeId": "matterTemperatureSensor"}, type_id="matterRelay") + plugin_cls.deviceStartComm(stub, dev) + assert mock_logger.warning.called + fmt, *args = mock_logger.warning.call_args[0] + message = fmt % tuple(args) + assert "cannot be changed" in message and "Landing Sensor" in message + stub.device_sync.note_device.assert_called_once_with(dev) + + +def test_device_start_comm_quiet_when_types_match(plugin_cls, mock_logger): + from types import SimpleNamespace + from unittest.mock import MagicMock + stub = SimpleNamespace(logger=mock_logger, device_sync=MagicMock()) + dev = _dev({"createdTypeId": "matterRelay"}, type_id="matterRelay") + plugin_cls.deviceStartComm(stub, dev) + assert not mock_logger.warning.called