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}
+ ]