diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index 1f13eeb..e9f0504 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.25 + 2026.2.26 ServerApiVersion 3.6 diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py index c366741..871f248 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py @@ -66,6 +66,38 @@ }) +#: Friendly role suffix per Indigo device type, used to name the individual +#: devices of a multi-function node. A HomePod exposing temperature + humidity +#: becomes " - Temperature" / " - Humidity" rather than the opaque +#: " (endpoint 1)". Endpoint-number naming is kept only as the fallback +#: for a type absent from this map, and to disambiguate genuinely identical +#: siblings (e.g. four outlets on one strip — see create_devices). +_ROLE_LABELS = { + "matterRelay": "Switch", + "matterDimmer": "Dimmer", + "matterColorDimmer": "Light", + "matterTemperatureSensor": "Temperature", + "matterHumiditySensor": "Humidity", + "matterMotionSensor": "Motion", + "matterContactSensor": "Contact", + "matterIlluminanceSensor": "Illuminance", + "matterPressureSensor": "Pressure", + "matterFlowSensor": "Flow", + "matterThermostat": "Thermostat", + "matterFan": "Fan", + "matterWindowCovering": "Window Covering", + "matterLock": "Lock", + "matterValve": "Valve", + "matterButton": "Button", + "matterSmokeCOAlarm": "Smoke/CO", + "matterAirQualitySensor": "Air Quality", + "matterCO2Sensor": "CO₂", + "matterPM25Sensor": "PM2.5", + "matterTVOCSensor": "TVOC", + "matterUnknown": "Unsupported", +} + + def _kvlist(states: dict) -> list: return [{"key": key, "value": value} for key, value in states.items()] @@ -297,6 +329,19 @@ def create_devices(self, node: NodeInfo, suggested_room: Optional[str] = None) - plan.append((endpoint, spec)) multi = len(plan) > 1 + # Count role occurrences across the plan so genuinely identical + # siblings (e.g. four outlets on one strip, all "Switch") fall back + # to an endpoint-numbered suffix, while a node's distinct functions + # (temperature vs humidity) read as " - ". + role_counts: dict[str, int] = {} + for _ep, _spec in plan: + role = _ROLE_LABELS.get(_spec.device_type_id, "") + if role: + role_counts[role] = role_counts.get(role, 0) + 1 + # Stamp the hardware product as each device's Model so a node's + # sibling devices share one value in the Indigo device list's Model + # column (the grouping Indigo offers short of a Device Factory). + model = node.product_name or node.vendor_name or "" folder_id = self._resolve_folder_id(suggested_room) if authoritative else 0 # Pre-compute the FULL set of planned device_type_ids per endpoint so # _prime_states can identify merge-into handlers correctly regardless @@ -325,7 +370,17 @@ def create_devices(self, node: NodeInfo, suggested_room: Optional[str] = None) - # tracks the fix (prefix or bridge-only authoritative stamp). name = spec.name if authoritative else bridge_label elif multi: - name = f"{spec.name} (endpoint {endpoint.endpoint_id})" + # Name by the device's function ("- Temperature"), not its + # Matter endpoint number, which is meaningless to the user. + # Fall back to the endpoint suffix only for identical + # siblings or an unmapped type. + role = _ROLE_LABELS.get(spec.device_type_id, "") + if role and role_counts.get(role, 0) > 1: + name = f"{spec.name} - {role} {endpoint.endpoint_id}" + elif role: + name = f"{spec.name} - {role}" + else: + name = f"{spec.name} (endpoint {endpoint.endpoint_id})" else: name = spec.name ep_key = (int(node.node_id), int(endpoint.endpoint_id)) @@ -346,7 +401,7 @@ def create_devices(self, node: NodeInfo, suggested_room: Optional[str] = None) - # protocol-identifier column) so the nodeId is recoverable for # decommission without spelunking pluginProps (issue #18). spec.props.setdefault("address", node_id_to_str(node.node_id)) - dev_id = self._create_one(spec, name, folder_id) + dev_id = self._create_one(spec, name, folder_id, model) if dev_id is None: failed += 1 continue @@ -425,7 +480,8 @@ def _unknown_spec(node: NodeInfo, endpoint: Any) -> Optional[IndigoDeviceSpec]: initial_states={"reachable": True}, ) - def _create_one(self, spec: Any, name: str, folder_id: int = 0) -> Optional[int]: + 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 # (validateDeviceConfigUi + deviceStartComm) can detect a manual change # via Indigo's Edit Device Type menu (issue #58). @@ -459,6 +515,14 @@ def _create_one(self, spec: Any, name: str, folder_id: int = 0) -> Optional[int] except Exception as exc: # noqa: BLE001 - surface but don't abort the batch self.logger.exception(exc) return None + if model: + # Cosmetic only (the Model column); a failure here must never sink + # an otherwise-created device. + try: + dev.model = model + dev.replaceOnServer() + except Exception as exc: # noqa: BLE001 + self.logger.debug("could not set model on device %s: %s", dev.id, exc) if spec.initial_states: try: dev.updateStatesOnServer(_kvlist(spec.initial_states)) @@ -556,7 +620,7 @@ 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] - states: dict = {} + kv: list = [] for (ep, cluster, attribute), value in node.attributes.items(): handler = self.registry.handler_for_cluster(cluster) if handler is None: @@ -585,11 +649,13 @@ def _prime_states(self, node: NodeInfo, dev_id: int, endpoint_id: int, if handler_type_id in ep_sibling_types: continue try: - states.update(handler.on_attribute_update(dev, attribute, value)) + update = handler.on_attribute_update(dev, attribute, value) + if update: + kv.extend(handler.format_kv(update)) except Exception as exc: # noqa: BLE001 - one bad attr must not abort priming self.logger.warning("prime %s attr %s/%s failed: %s", dev_id, cluster, attribute, exc) - if states: - self.apply_states(dev_id, _kvlist(states)) + if kv: + self.apply_states(dev_id, kv) # ------------------------------------------------------------------ # Capability-prop helpers (issue #45 — self-heal mid-interview creations) @@ -995,7 +1061,7 @@ def _on_node_event(self, evt: protocol.MatterEvent) -> None: ) return if states: - self.apply_states(dev_id, _kvlist(states)) + self.apply_states(dev_id, handler.format_kv(states)) def _on_attribute(self, evt: protocol.MatterEvent) -> None: if evt.node_id is None or evt.endpoint is None or evt.cluster is None: @@ -1046,7 +1112,7 @@ def _on_attribute(self, evt: protocol.MatterEvent) -> None: dev = indigo.devices[dev_id] states = handler.on_attribute_update(dev, evt.attribute, evt.value) if states: - self.apply_states(dev_id, _kvlist(states)) + self.apply_states(dev_id, handler.format_kv(states)) except Exception as exc: # noqa: BLE001 - one bad value must not silently freeze the device self.logger.warning( "bad update for device %s (ep%s cl%s attr%s value=%r): %s", @@ -1071,7 +1137,7 @@ def _on_attribute(self, evt: protocol.MatterEvent) -> None: ) return if states: - self.apply_states(dev_id, _kvlist(states)) + self.apply_states(dev_id, handler.format_kv(states)) def apply_states(self, dev_id: int, kvlist: list) -> None: """The single asyncio→Indigo write seam (see module docstring).""" 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 f10d7fb..e904af2 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 @@ -89,6 +89,8 @@ class CO2Handler(_SensorHandler): cluster_id = 0x040D cluster_name = "CarbonDioxideConcentrationMeasurement" device_type_id = "matterCO2Sensor" + decimal_places = 1 + unit = " ppm" def transform(self, value: Any) -> float: return round(float(value), 1) # ppm, MeasuredValue is a float @@ -100,6 +102,8 @@ class PM25Handler(_SensorHandler): cluster_id = 0x042A cluster_name = "PM25ConcentrationMeasurement" device_type_id = "matterPM25Sensor" + decimal_places = 1 + unit = " µg/m³" def transform(self, value: Any) -> float: return round(float(value), 1) # µg/m³, MeasuredValue is a float @@ -117,6 +121,7 @@ class TVOCHandler(_SensorHandler): cluster_id = 0x042E cluster_name = "TotalVolatileOrganicCompoundsConcentrationMeasurement" device_type_id = "matterTVOCSensor" + decimal_places = 1 # unit device-dependent (ppb or µg/m³) — no suffix until 0x0008 is read def transform(self, value: Any) -> float: return round(float(value), 1) # units device-dependent (ppb or µg/m³) 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 c68cd4e..f745d7e 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/base.py @@ -92,6 +92,21 @@ def attributes_to_subscribe(self) -> list[int]: def on_attribute_update(self, indigo_dev: Any, attribute_id: int, value: Any) -> dict: """Translate a Matter attribute change to Indigo state updates.""" + def format_kv(self, states: dict) -> list: + """Turn a ``{state_key: value}`` dict into an Indigo ``updateStatesOnServer`` + kvlist. + + The default is a plain ``[{"key": k, "value": v}, …]`` — identical to + the bare mapping callers used before this hook existed. Override to + attach ``uiValue`` / ``decimalPlaces`` so a numeric state renders at a + sensible precision in the Indigo UI instead of the raw float + (a ``round(x, 2)`` still displays as ``21.340000000001`` without it — + the 6-dp problem). ``device_sync`` calls this for every state write a + handler produces, so formatting lives next to the cluster that owns the + value rather than in the generic write seam. + """ + return [{"key": key, "value": value} for key, value in states.items()] + def on_node_event(self, indigo_dev: Any, event_id: int, data: Any) -> dict: """Translate a Matter cluster event to Indigo state updates. diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/sensors.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/sensors.py index e107abe..b84f4ff 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/sensors.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/matter_handlers/sensors.py @@ -33,6 +33,24 @@ class _SensorHandler(ClusterHandler): # (SupportsOnState=True) makes value sensors display "off" forever # (issue #56). Binary subclasses override with the on/off pair. display_props = {"SupportsSensorValue": True, "SupportsOnState": False} + #: UI display precision + unit suffix for the numeric reading. Without a + #: uiValue/decimalPlaces Indigo renders the raw float — a rounded 21.34 can + #: still show as 21.340000000001 (the 6-dp problem). ``decimal_places=None`` + #: (the boolean sensors below) means "don't format" — the value passes + #: through unchanged so the on/off display is untouched. + decimal_places: Optional[int] = None + unit: str = "" + + def format_kv(self, states: dict) -> list: + out: list = [] + for key, value in states.items(): + entry = {"key": key, "value": value} + if (key == self.state_key and self.decimal_places is not None + and isinstance(value, (int, float)) and not isinstance(value, bool)): + entry["decimalPlaces"] = self.decimal_places + entry["uiValue"] = f"{value:.{self.decimal_places}f}{self.unit}" + out.append(entry) + return out 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}" @@ -70,6 +88,8 @@ class TemperatureHandler(_SensorHandler): cluster_id = 0x0402 cluster_name = "TemperatureMeasurement" device_type_id = "matterTemperatureSensor" + decimal_places = 1 + unit = " °C" def transform(self, value: Any) -> float: return round(int(value) / 100.0, 2) # 0.01 °C → °C @@ -79,6 +99,8 @@ class HumidityHandler(_SensorHandler): cluster_id = 0x0405 cluster_name = "RelativeHumidityMeasurement" device_type_id = "matterHumiditySensor" + decimal_places = 1 + unit = "%" def transform(self, value: Any) -> float: return round(int(value) / 100.0, 1) # 0.01 %RH → %RH @@ -110,6 +132,8 @@ class IlluminanceHandler(_SensorHandler): cluster_id = 0x0400 cluster_name = "IlluminanceMeasurement" device_type_id = "matterIlluminanceSensor" + decimal_places = 0 + unit = " lux" def transform(self, value: Any) -> float: raw = int(value) @@ -122,6 +146,8 @@ class PressureHandler(_SensorHandler): cluster_id = 0x0403 cluster_name = "PressureMeasurement" device_type_id = "matterPressureSensor" + decimal_places = 0 + unit = " hPa" def transform(self, value: Any) -> float: # MeasuredValue is int16 in units of 0.1 kPa. @@ -135,6 +161,8 @@ class FlowHandler(_SensorHandler): cluster_id = 0x0404 cluster_name = "FlowMeasurement" device_type_id = "matterFlowSensor" + decimal_places = 1 + unit = " m³/h" def transform(self, value: Any) -> float: # MeasuredValue is uint16 in units of 0.1 m³/h. diff --git a/tests/test_bridges.py b/tests/test_bridges.py index 8c98278..b26c0ba 100644 --- a/tests/test_bridges.py +++ b/tests/test_bridges.py @@ -280,8 +280,9 @@ def test_bridged_endpoint_name_skips_endpoint_suffix(bds, bridge_indigo_env): assert "(endpoint" not in devices[dev_id2].name -def test_non_bridged_multi_endpoint_keeps_suffix(bds, bridge_indigo_env): - """Non-bridged multi-endpoint nodes still get '(endpoint N)' suffix.""" +def test_non_bridged_multi_endpoint_identical_siblings_role_numbered(bds, bridge_indigo_env): + """Non-bridged multi-endpoint nodes name by role; identical siblings + (two switches) get an endpoint-numbered role suffix.""" _indigo, devices = bridge_indigo_env multi_node = { "node_id": 99, @@ -293,8 +294,8 @@ def test_non_bridged_multi_endpoint_keeps_suffix(bds, bridge_indigo_env): bds.create_from_raw(multi_node, "Patio") id1 = bds.lookup(99, 1) id2 = bds.lookup(99, 2) - assert devices[id1].name == "Patio (endpoint 1)" - assert devices[id2].name == "Patio (endpoint 2)" + assert devices[id1].name == "Patio - Switch 1" + assert devices[id2].name == "Patio - Switch 2" def test_authoritative_rename_wins_over_bridge_label(bds, bridge_indigo_env): diff --git a/tests/test_device_sync.py b/tests/test_device_sync.py index 8d5176b..6b1611a 100644 --- a/tests/test_device_sync.py +++ b/tests/test_device_sync.py @@ -474,16 +474,44 @@ def test_multi_endpoint_authoritative_rename_after_race(ds, indigo_env): )) id1, id2 = ds.lookup(92, 1), ds.lookup(92, 2) assert id1 and id2 and id1 != id2 - assert "(endpoint 1)" in devices[id1].name and "Patio" not in devices[id1].name + # Two identical switches → role-numbered suffix, not the bare product name. + assert "- Switch 1" in devices[id1].name and "Patio" not in devices[id1].name result = ds.create_from_raw(MULTI_NODE, "Patio", "Outside") assert sorted(result["indigoDeviceIds"]) == sorted([id1, id2]) # no dupes - assert devices[id1].name == "Patio (endpoint 1)" - assert devices[id2].name == "Patio (endpoint 2)" + assert devices[id1].name == "Patio - Switch 1" + assert devices[id2].name == "Patio - Switch 2" outside = next(f.id for f in _indigo.devices.folders if f.name == "Outside") assert devices[id1].folderId == outside and devices[id2].folderId == outside +MULTI_FUNCTION_NODE = { + "node_id": 77, + "attributes": { + "0/40/3": "HomePod mini", # BasicInformation ProductName + "1/1026/0": 2150, # TemperatureMeasurement MeasuredValue = 21.5 °C + "1/1029/0": 4750, # RelativeHumidity MeasuredValue = 47.5 % + }, +} + + +def test_multi_function_node_names_by_role_not_endpoint(ds, indigo_env): + # A HomePod exposing temperature + humidity must read "HomePod - Temperature" + # / "HomePod - Humidity", never "HomePod (endpoint 1)". + _indigo, devices = indigo_env + ds.create_from_raw(MULTI_FUNCTION_NODE, "HomePod") + temp_id = ds.lookup(77, 1, "matterTemperatureSensor") + hum_id = ds.lookup(77, 1, "matterHumiditySensor") + assert temp_id and hum_id and temp_id != hum_id + assert devices[temp_id].name == "HomePod - Temperature" + assert devices[hum_id].name == "HomePod - Humidity" + # The reading is primed in (formatted) and the product stamped as the Model + # so the two devices share a Model column entry. + assert devices[temp_id].states["sensorValue"] == 21.5 + assert devices[temp_id].model == "HomePod mini" + assert devices[hum_id].model == "HomePod mini" + + def test_folder_resolution_failure_falls_back_to_folder_0(ds, indigo_env): # a folder API failure must not sink device creation _indigo, devices = indigo_env diff --git a/tests/test_sensors.py b/tests/test_sensors.py index e29bb7b..35c2905 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -138,3 +138,37 @@ def test_binary_sensor_handlers_carry_on_state_display_props(): for handler_cls in (OccupancyHandler, ContactHandler): props = handler_cls.display_props assert props == {"SupportsOnState": True, "SupportsSensorValue": False}, handler_cls + + +# --------------------------------------------------------------------------- +# Display formatting (uiValue + decimalPlaces) — without these Indigo renders +# a rounded float at full precision (the "6 decimal places" report). +# --------------------------------------------------------------------------- + +def test_numeric_sensor_format_kv_adds_uivalue_and_decimal_places(): + assert TemperatureHandler().format_kv({"sensorValue": 21.5}) == [ + {"key": "sensorValue", "value": 21.5, "decimalPlaces": 1, "uiValue": "21.5 °C"} + ] + assert HumidityHandler().format_kv({"sensorValue": 47.5}) == [ + {"key": "sensorValue", "value": 47.5, "decimalPlaces": 1, "uiValue": "47.5%"} + ] + assert IlluminanceHandler().format_kv({"sensorValue": 123.4}) == [ + {"key": "sensorValue", "value": 123.4, "decimalPlaces": 0, "uiValue": "123 lux"} + ] + assert PressureHandler().format_kv({"sensorValue": 1013.0}) == [ + {"key": "sensorValue", "value": 1013.0, "decimalPlaces": 0, "uiValue": "1013 hPa"} + ] + assert FlowHandler().format_kv({"sensorValue": 2.5}) == [ + {"key": "sensorValue", "value": 2.5, "decimalPlaces": 1, "uiValue": "2.5 m³/h"} + ] + + +def test_binary_sensor_format_kv_passes_through_unformatted(): + # Occupancy/contact display on/off — a numeric uiValue/decimalPlaces would + # be meaningless and must NOT be attached. + assert OccupancyHandler().format_kv({"onOffState": True}) == [ + {"key": "onOffState", "value": True} + ] + assert ContactHandler().format_kv({"onOffState": False}) == [ + {"key": "onOffState", "value": False} + ]