Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion indigo-matter.indigoPlugin/Contents/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<key>IwsApiVersion</key>
<string>1.0.0</string>
<key>PluginVersion</key>
<string>2026.2.25</string>
<string>2026.2.26</string>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Version 2026.2.26 already exists as a git tag; the CI version-check workflow will fail.

The version-check pipeline confirms that v2026.2.26 is already tagged in the repository. Reusing a version number violates release hygiene and breaks the CI gate. Increment PluginVersion to an unused version (e.g., 2026.2.27).

🔖 Proposed fix to bump to the next available version
-	<string>2026.2.26</string>
+	<string>2026.2.27</string>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<string>2026.2.26</string>
<string>2026.2.27</string>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Info.plist` at line 23, Replace the
existing PluginVersion string "2026.2.26" with a new, unused version (for
example "2026.2.27") so the CI version-check won't fail; locate the "2026.2.26"
entry in Info.plist and update that string to the bumped version.

Source: Pipeline failures

<key>ServerApiVersion</key>
<string>3.6</string>
</dict>
Expand Down
86 changes: 76 additions & 10 deletions indigo-matter.indigoPlugin/Contents/Server Plugin/device_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name> - Temperature" / "<name> - Humidity" rather than the opaque
#: "<name> (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()]

Expand Down Expand Up @@ -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 "<name> - <role>".
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
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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)."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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³)
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions tests/test_bridges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down
34 changes: 31 additions & 3 deletions tests/test_device_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading