diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 94f0e60..6e80551 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -35,18 +35,10 @@ jobs:
strategy:
fail-fast: false
matrix:
- # 3.13 ONLY, for now. 3.11 is the declared floor (`pyproject.toml`
- # requires-python, and pylint's own py-version) and SHOULD be here, but
- # four tests in tests/test_bridge_client.py fail on 3.11 today: they
- # assert on `client.status` straight after `wait_connected()` and pass
- # on 3.13 purely because its event loop happens to have run the attach
- # by then. Scheduling order, not logic — and pre-existing, confirmed
- # failing at 913ad44, well before the refactor that added this file.
- #
- # Adding a red leg to a brand-new gate only teaches people to ignore
- # the gate. Tracked separately; restore "3.11" here once those four
- # tests await the condition instead of assuming it.
- python-version: ["3.13"]
+ # 3.11 is the declared floor (`pyproject.toml` requires-python, and
+ # pylint's own py-version); 3.13 is what jarvis actually runs. Both
+ # legs are cheap (~1s each), so both run on every push.
+ python-version: ["3.11", "3.13"]
steps:
- uses: actions/checkout@v4
diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist
index 5ae041b..26abdc9 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.28.7
+ 2026.28.8
ServerApiVersion
3.6
diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py
index 6e5a782..fd14099 100644
--- a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py
+++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py
@@ -230,6 +230,7 @@ def __init__(
#: The last StatusReport the node returned (attach / get_status).
self.status: Optional[StatusReport] = None
self._attached = False
+ self._attached_event = asyncio.Event()
#: True while the node refused the attach with ``endpoint_map_invalid``
#: and we are holding the connection open for the §1.1 recovery trio.
self.recovery = False
@@ -245,8 +246,27 @@ def attached(self) -> bool:
"""
return self._attached and self.connected
+ async def wait_attached(self, timeout: float = 30.0) -> None:
+ """Block until an ``attach`` the node accepted has landed.
+
+ :meth:`wait_connected` only proves the transport is up — the socket
+ exists the moment the handshake starts, but ``self.status`` and
+ ``self._attached`` are not populated until the attach that follows
+ it completes, on the run loop, some time later. A caller that reads
+ ``status`` or relies on ``set_state`` being deliverable right after
+ ``wait_connected`` returns is trusting that the node is already
+ serving its endpoint set — which is exactly the assumption that is
+ NOT guaranteed: the attach might still be in flight, or might have
+ been refused (§1.1's ``endpoint_map_invalid``), leaving a live socket
+ that is connected but not attached. Wait on this instead of
+ ``wait_connected`` whenever what you need is the node actually
+ serving your endpoints, not merely a live socket to it.
+ """
+ await asyncio.wait_for(self._attached_event.wait(), timeout)
+
def _mark_disconnected(self) -> None:
self._attached = False
+ self._attached_event.clear()
self.recovery = False
super()._mark_disconnected()
@@ -293,6 +313,7 @@ async def _handshake(self, first: Any) -> None:
self._handle_attach_refused(exc)
return
self._attached = True
+ self._attached_event.set()
self.recovery = False
self._notify(self._on_attached, status, replace_all)
@@ -437,6 +458,7 @@ async def _retry_with_intent(self) -> bool:
"rather than halting", owed)
status = await self._attach(None, replace_all=True, timeout=None, inline=True)
self._attached = True
+ self._attached_event.set()
self.recovery = False
self._notify(self._on_attached, status, True)
return True
@@ -596,6 +618,7 @@ async def _attach(self, endpoints: Optional[list], *, replace_all: bool,
else:
result = await self._request_frame(frame, timeout)
self._attached = True
+ self._attached_event.set()
self.recovery = False
self.status = bridge_protocol.parse_status(result)
return self.status
diff --git a/tests/indigo_fakes.py b/tests/indigo_fakes.py
new file mode 100644
index 0000000..971a0f9
--- /dev/null
+++ b/tests/indigo_fakes.py
@@ -0,0 +1,322 @@
+"""Stateful fake Indigo devices/device-command-namespace for INBOUND tests.
+
+Used by test_device_sync.py, test_generic_switch.py, test_power_source.py,
+and test_integration.py to stand in for ``indigo.devices``/``indigo.device``
+when exercising device_sync's reconciliation and event-routing paths without
+a live Indigo server.
+
+These fakes are deliberately STATEFUL — a ``states`` dict that mutates on
+``updateStatesOnServer``, ``stateListOrDisplayStateIdChanged`` call counting,
+``replaceOnServer`` failure/rollback semantics, and a real device-group model
+(see ``FakeDeviceFactory``, per ADR-0009) — because the inbound code under
+test reads state back and branches on it (e.g. "was the rebuild hook called
+exactly once?", "did a failed replace roll the name back?").
+
+Do NOT merge these with fakes.py's ``FakeIndigoDevice``/``FakeIndigoDevices``.
+Those are EXPORT-side static attribute holders for a different direction of
+data flow and are deliberately simpler; conflating the two families would
+either weaken this stateful suite or drag unwanted state-machine behaviour
+into the export-side one. Keep them in separate modules.
+"""
+from __future__ import annotations
+
+# Real Indigo auto-derives these built-in states from Supports* props, both at
+# device CREATION and at any later pluginProps replace. FakeDev seeds them in
+# both places so handler update guards (e.g. ElectricalPowerHandler's
+# `"curEnergyLevel" not in indigo_dev.states`) behave like the real server —
+# issue #79's priming/live-routing tests need this true immediately after
+# creation, not only after a reconcile-triggered replacePluginPropsOnServer.
+_SUPPORTS_TO_STATE = {
+ "SupportsPowerMeter": "curEnergyLevel",
+ "SupportsEnergyMeter": "accumEnergyTotal",
+ "SupportsBatteryLevel": "batteryLevel",
+ "SupportsSensorValue": "sensorValue",
+}
+
+# Devices.xml declares these as plain custom states (no Supports* prop gates
+# them) — real Indigo instantiates a device's declared at CREATION
+# regardless of props, unlike the Supports*-driven built-ins above. Only
+# BooleanStateConfigHandler's guard (issue #85 — "sensitivityLevel" not in
+# indigo_dev.states) needs this modelled; matterLock's lockState needs no
+# guard (door_lock.py writes it unconditionally) so it needs no seeding here.
+_STATIC_DEVICE_TYPE_STATES = {
+ # Mirrors the each type declares in Devices.xml — real Indigo
+ # creates a device with every declared state present, and the handlers'
+ # "is this state on this device?" guards depend on that being true.
+ "matterMotionSensor": {"sensitivityLevel", "holdTime"},
+ "matterContactSensor": {"sensitivityLevel"},
+ "matterRelay": {"startUpOnOff"},
+ # issue #204 / ADR-0008 — declared unconditionally in Devices.xml (no
+ # Supports* prop gates any of them), same custom-type discipline as
+ # matterEnergyMeter/matterUnknown's `reachable`. curEnergyLevel/
+ # accumEnergyTotal joined the list once ep-0 energy attribution shipped
+ # (issue #204's final stage) — same ids matterEnergyMeter already
+ # declares, also unconditionally.
+ "matterNode": {"nodeLabel", "softwareVersion", "batteryLevel", "reachable",
+ "curEnergyLevel", "accumEnergyTotal"},
+}
+
+
+class FakeDev:
+ def __init__(self, dev_id, name, device_type_id, props, initial_states=None):
+ self.id = dev_id
+ self.name = name
+ self.deviceTypeId = device_type_id
+ self.pluginProps = props
+ self.states = {}
+ for prop_key, state_key in _SUPPORTS_TO_STATE.items():
+ if props.get(prop_key):
+ self.states[state_key] = 0 # Indigo-style initial value
+ for state_key in _STATIC_DEVICE_TYPE_STATES.get(device_type_id, ()):
+ self.states.setdefault(state_key, 0)
+ # Callers that need states Devices.xml wouldn't auto-derive here (e.g.
+ # matterButton's lastButtonEvent/pressCount) seed them explicitly.
+ self.states.update(dict(initial_states or {}))
+ self.error = None
+ self.errorState = ""
+ self.folderId = 0
+ # See replaceOnServer: fail_replace models real Indigo's discard-on-
+ # failure semantics; _name_on_server is the last name Indigo accepted.
+ self.fail_replace = False
+ self._name_on_server = name
+ # Issue #190: how many times Indigo was asked to rebuild this device's
+ # state list. The question the tests care about is WHEN that is asked
+ # for, not what Indigo does next, so the fake only counts.
+ self.state_list_rebuilds = 0
+ # Real Indigo derives the list display from Supports* props at CREATION
+ # 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:
+ self.states[kv["key"]] = kv["value"]
+ # Real Indigo's states dict answers a "key.ui" lookup with the
+ # display value from a uiValue-bearing write (used by both fix A's
+ # evidence check and fix D's reachable/unreachable column text).
+ if "uiValue" in kv:
+ self.states[f"{kv['key']}.ui"] = kv["uiValue"]
+
+ def stateListOrDisplayStateIdChanged(self):
+ self.state_list_rebuilds += 1
+
+ def setErrorStateOnServer(self, value):
+ self.error = value
+ self.errorState = value
+
+ def replaceOnServer(self):
+ # Real Indigo persists the in-memory edits (name etc.). Production
+ # writes dev.name BEFORE calling this, and real Indigo discards the
+ # in-memory edit on failure — so a failing fake must roll the name
+ # back, or later passes vote on a name Indigo never had (issue #204
+ # verification round). Set fail_replace=True to model failure; do NOT
+ # override this method with a bare raiser.
+ if self.fail_replace:
+ self.name = self._name_on_server
+ raise ValueError("replaceOnServer refused (fail_replace)")
+ self.replaced = True
+ self._name_on_server = self.name
+
+ def replacePluginPropsOnServer(self, new_props):
+ # Real Indigo updates pluginProps and rebuilds device states from Supports*
+ # entries. The fake merges the new props and, for each Supports* key that
+ # transitions to True, seeds the corresponding state so handler guards pass.
+ self.pluginProps = dict(new_props)
+ self.replaced_props = True
+ # Simulate Indigo auto-creating states for Supports* props.
+ for prop_key, state_key in _SUPPORTS_TO_STATE.items():
+ if new_props.get(prop_key) and state_key not in self.states:
+ self.states[state_key] = 0 # Indigo-style initial value
+
+
+class FakeFolder:
+ def __init__(self, folder_id, name):
+ self.id = folder_id
+ self.name = name
+
+
+class FakeFolderFactory:
+ """Stands in for ``indigo.devices.folder`` (the folder command namespace)."""
+
+ def __init__(self, devices):
+ self.devices = devices
+
+ def create(self, name):
+ return self.devices.add_folder(name)
+
+
+class FakeDevices:
+ def __init__(self):
+ self._by_id = {}
+ self._counter = 1000
+ self._folders = {}
+ self._folder_counter = 0
+
+ def next_id(self):
+ self._counter += 1
+ return self._counter
+
+ def add(self, dev):
+ self._by_id[dev.id] = dev
+
+ def add_folder(self, name):
+ self._folder_counter += 1
+ folder = FakeFolder(self._folder_counter, name)
+ self._folders[folder.id] = folder
+ return folder
+
+ @property
+ def folders(self):
+ return list(self._folders.values())
+
+ def __iter__(self):
+ return iter(list(self._by_id.values()))
+
+ def __getitem__(self, dev_id):
+ return self._by_id[dev_id]
+
+ def iter(self, _filter=None):
+ # Real Indigo EXCLUDES unconfigured devices from iter("self") — that
+ # exclusion is the whole mechanism of issue #62, so the fake has to
+ # model it or a test for the stray warning would pass vacuously.
+ # Plain iteration (__iter__) stays unfiltered, like the real
+ # `indigo.devices`, which is the only place a stray is still visible.
+ return [dev for dev in self._by_id.values() if getattr(dev, "configured", True)]
+
+
+class FakeDeviceFactory:
+ """Stands in for the ``indigo.device`` command namespace.
+
+ Since issue #204 stage 2 this carries a REAL device-group model rather than
+ recording calls, and since ADR-0009 that model is the one the CONTROLLED
+ EXPERIMENT on jarvis (2026-08-12) established rather than the one the docs
+ imply:
+
+ * Indigo orders a group's members by device AGE (creation order), and the
+ OLDEST member is the root — ``getGroupList``'s first element, identical
+ whichever member is asked.
+ * ``groupWithDevice(a, b)`` and ``groupWithDevice(b, a)`` produce the
+ SAME group. **The argument order does nothing.** A fake that honoured
+ arg order would let a plugin that (wrongly) depends on it pass.
+ * ``indigo.device.delete`` REFUSES to delete the root of a non-empty
+ group — whichever device that turns out to be, which is what makes
+ ``delete_node``'s dissolve-first shape load-bearing rather than
+ decorative.
+ """
+
+ def __init__(self, devices):
+ self.devices = devices
+ self.created = []
+ #: dev_id → the group's member list, SHARED by every member, ordered
+ #: OLDEST FIRST; [0] is therefore the root. Absent means ungrouped.
+ self.groups = {}
+ #: (dev_1, dev_2) per groupWithDevice call — the idempotence assertion
+ #: is "a second reconcile pass adds none of these". The order inside
+ #: the tuple is what the plugin passed and means nothing to Indigo.
+ self.group_calls = []
+ self.ungroup_calls = []
+
+ @staticmethod
+ def _id_of(dev_or_id):
+ return dev_or_id.id if hasattr(dev_or_id, "id") else int(dev_or_id)
+
+ def _age_of(self, dev_id):
+ """Creation sequence of a device — lower is older.
+
+ ``FakeDevices.next_id`` is a monotonic counter, so an id IS its
+ creation rank for every device these tests make; devices built by hand
+ with an explicit id (the orphan/ghost fixtures) sort by that id, which
+ is all the ordering they need.
+ """
+ return dev_id
+
+ def create(self, protocol=None, deviceTypeId="", name="", props=None, folder=0, **kwargs):
+ dev = FakeDev(self.devices.next_id(), name, deviceTypeId, dict(props or {}))
+ if isinstance(folder, int) and folder:
+ dev.folderId = folder
+ self.devices.add(dev)
+ self.created.append(dev)
+ return dev
+
+ def delete(self, dev):
+ dev_id = self._id_of(dev)
+ members = self.groups.get(dev_id)
+ if members and len(members) > 1 and members[0] == dev_id:
+ raise ValueError(
+ "cannot delete device %s: it is the root of a non-empty device group" % dev_id)
+ self._drop_from_group(dev_id)
+ self.devices._by_id.pop(dev_id, None)
+
+ def moveToFolder(self, dev_or_id, value=None):
+ dev = dev_or_id if hasattr(dev_or_id, "folderId") else self.devices[dev_or_id]
+ dev.folderId = value
+
+ # -- the device-group model -----------------------------------------
+ def getGroupList(self, dev_or_id):
+ dev_id = self._id_of(dev_or_id)
+ if dev_id not in self.devices._by_id:
+ # Real Indigo cannot answer for an id that is not a device, and the
+ # plugin's index CAN carry one: plugin.deviceDeleted only prunes
+ # matterNode ids, so a hand-deleted endpoint device leaves a dead
+ # id behind (issue #204 review, fix D). Without this tooth the
+ # grouping sweep looks harmless against a fake that answers anyway.
+ raise ValueError("device %s does not exist" % dev_id)
+ members = self.groups.get(dev_id)
+ # An ungrouped device answers with just itself — the plugin tolerates an
+ # empty list too (both shapes are undocumented; see _ensure_grouped).
+ return list(members) if members else [dev_id]
+
+ def groupWithDevice(self, dev_1, dev_2):
+ """The experiment's semantics: the two devices' groups are UNIONED and
+ the result is ordered by device age, oldest first.
+
+ ``groupWithDevice(motion, node)`` and ``groupWithDevice(node, motion)``
+ returned byte-identical member lists on the live rig, and adding a
+ third device to an existing pair left the root untouched — so this
+ models a symmetric union with an age sort and no notion of a joiner.
+
+ What is and isn't experiment-backed, honestly: SINGLETON+SINGLETON
+ (both argument orders) and SINGLETON-JOINS-EXISTING-GROUP (the
+ experiment's third line) are what the live rig actually exercised.
+ A general GROUP+GROUP union — two already-multi-member groups
+ merged in one call — is EXTRAPOLATED from those, never run on
+ jarvis. Production's own guard (`_ensure_grouped`'s family check
+ only ever passes a SINGLETON node device as one side) means only
+ the backed directions are exercised today; this fake unions
+ unconditionally because nothing here currently calls it any other
+ way. Anyone relaxing that guard to call this with two genuine
+ multi-member groups must extend the live experiment first, not
+ just trust this model to still be right.
+ """
+ first, second = self._id_of(dev_1), self._id_of(dev_2)
+ self.group_calls.append((first, second))
+ members = set(self.groups.get(first) or [first])
+ members |= set(self.groups.get(second) or [second])
+ ordered = sorted(members, key=self._age_of)
+ for member in ordered:
+ self.groups[member] = ordered
+
+ def ungroupDevice(self, dev_or_id):
+ dev_id = self._id_of(dev_or_id)
+ self.ungroup_calls.append(dev_id)
+ self._drop_from_group(dev_id)
+
+ def _drop_from_group(self, dev_id):
+ members = self.groups.pop(dev_id, None)
+ if not members:
+ return
+ remaining = [member for member in members if member != dev_id]
+ for member in remaining:
+ # A group of one is no group at all.
+ if len(remaining) > 1:
+ self.groups[member] = remaining
+ else:
+ self.groups.pop(member, None)
diff --git a/tests/test_bridge_client.py b/tests/test_bridge_client.py
index 51dde30..c6c5f67 100644
--- a/tests/test_bridge_client.py
+++ b/tests/test_bridge_client.py
@@ -429,7 +429,7 @@ async def scenario():
{bridge_protocol.CMD_SET_STATE: EXCHANGES["set_state_unknown_device"]["response"]}))
client = _client(mock_logger, fake)
task = asyncio.create_task(client.run())
- await client.wait_connected(timeout=2)
+ await client.wait_attached(timeout=2)
await client.set_state(123456791, {"onOff": True})
await settle(lambda: mock_logger.warning.called)
@@ -978,7 +978,7 @@ async def scenario():
fake = DyingSocket(responder=golden_responder(), handshake=HELLO)
client = _client(mock_logger, fake)
task = asyncio.create_task(client.run())
- await client.wait_connected(timeout=2)
+ await client.wait_attached(timeout=2)
mock_logger.debug.reset_mock()
await client.set_state(123456789, {"onOff": True}) # must not raise
@@ -1015,7 +1015,7 @@ async def scenario():
{bridge_protocol.CMD_SET_STATE: EXCHANGES["set_state_unknown_device"]["response"]}))
client = _client(mock_logger, fake)
task = asyncio.create_task(client.run())
- await client.wait_connected(timeout=2)
+ await client.wait_attached(timeout=2)
await client.set_state(123456791, {"onOff": True})
await settle(lambda: mock_logger.warning.called)
@@ -1119,7 +1119,7 @@ def responder(frame):
fake = _fake(responder=responder)
client = _client(mock_logger, fake)
task = asyncio.create_task(client.run())
- await client.wait_connected(timeout=2)
+ await client.wait_attached(timeout=2)
assert client.status.endpoint_count == 0
# The node now serves the two-endpoint set.
diff --git a/tests/test_device_sync.py b/tests/test_device_sync.py
index 2de1fd1..71e253d 100644
--- a/tests/test_device_sync.py
+++ b/tests/test_device_sync.py
@@ -16,304 +16,12 @@
from protocol import MatterEvent
from test_handlers import RELAY_NODE
-
-# Real Indigo auto-derives these built-in states from Supports* props, both at
-# device CREATION and at any later pluginProps replace. FakeDev seeds them in
-# both places so handler update guards (e.g. ElectricalPowerHandler's
-# `"curEnergyLevel" not in indigo_dev.states`) behave like the real server —
-# issue #79's priming/live-routing tests need this true immediately after
-# creation, not only after a reconcile-triggered replacePluginPropsOnServer.
-_SUPPORTS_TO_STATE = {
- "SupportsPowerMeter": "curEnergyLevel",
- "SupportsEnergyMeter": "accumEnergyTotal",
- "SupportsBatteryLevel": "batteryLevel",
- "SupportsSensorValue": "sensorValue",
-}
-
-# Devices.xml declares these as plain custom states (no Supports* prop gates
-# them) — real Indigo instantiates a device's declared at CREATION
-# regardless of props, unlike the Supports*-driven built-ins above. Only
-# BooleanStateConfigHandler's guard (issue #85 — "sensitivityLevel" not in
-# indigo_dev.states) needs this modelled; matterLock's lockState needs no
-# guard (door_lock.py writes it unconditionally) so it needs no seeding here.
-_STATIC_DEVICE_TYPE_STATES = {
- # Mirrors the each type declares in Devices.xml — real Indigo
- # creates a device with every declared state present, and the handlers'
- # "is this state on this device?" guards depend on that being true.
- "matterMotionSensor": {"sensitivityLevel", "holdTime"},
- "matterContactSensor": {"sensitivityLevel"},
- "matterRelay": {"startUpOnOff"},
- # issue #204 / ADR-0008 — declared unconditionally in Devices.xml (no
- # Supports* prop gates any of them), same custom-type discipline as
- # matterEnergyMeter/matterUnknown's `reachable`. curEnergyLevel/
- # accumEnergyTotal joined the list once ep-0 energy attribution shipped
- # (issue #204's final stage) — same ids matterEnergyMeter already
- # declares, also unconditionally.
- "matterNode": {"nodeLabel", "softwareVersion", "batteryLevel", "reachable",
- "curEnergyLevel", "accumEnergyTotal"},
-}
-
-
-class FakeDev:
- def __init__(self, dev_id, name, device_type_id, props):
- self.id = dev_id
- self.name = name
- self.deviceTypeId = device_type_id
- self.pluginProps = props
- self.states = {}
- for prop_key, state_key in _SUPPORTS_TO_STATE.items():
- if props.get(prop_key):
- self.states[state_key] = 0 # Indigo-style initial value
- for state_key in _STATIC_DEVICE_TYPE_STATES.get(device_type_id, ()):
- self.states.setdefault(state_key, 0)
- self.error = None
- self.errorState = ""
- self.folderId = 0
- # See replaceOnServer: fail_replace models real Indigo's discard-on-
- # failure semantics; _name_on_server is the last name Indigo accepted.
- self.fail_replace = False
- self._name_on_server = name
- # Issue #190: how many times Indigo was asked to rebuild this device's
- # state list. The question the tests care about is WHEN that is asked
- # for, not what Indigo does next, so the fake only counts.
- self.state_list_rebuilds = 0
- # Real Indigo derives the list display from Supports* props at CREATION
- # 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:
- self.states[kv["key"]] = kv["value"]
- # Real Indigo's states dict answers a "key.ui" lookup with the
- # display value from a uiValue-bearing write (used by both fix A's
- # evidence check and fix D's reachable/unreachable column text).
- if "uiValue" in kv:
- self.states[f"{kv['key']}.ui"] = kv["uiValue"]
-
- def stateListOrDisplayStateIdChanged(self):
- self.state_list_rebuilds += 1
-
- def setErrorStateOnServer(self, value):
- self.error = value
- self.errorState = value
-
- def replaceOnServer(self):
- # Real Indigo persists the in-memory edits (name etc.). Production
- # writes dev.name BEFORE calling this, and real Indigo discards the
- # in-memory edit on failure — so a failing fake must roll the name
- # back, or later passes vote on a name Indigo never had (issue #204
- # verification round). Set fail_replace=True to model failure; do NOT
- # override this method with a bare raiser.
- if self.fail_replace:
- self.name = self._name_on_server
- raise ValueError("replaceOnServer refused (fail_replace)")
- self.replaced = True
- self._name_on_server = self.name
-
- def replacePluginPropsOnServer(self, new_props):
- # Real Indigo updates pluginProps and rebuilds device states from Supports*
- # entries. The fake merges the new props and, for each Supports* key that
- # transitions to True, seeds the corresponding state so handler guards pass.
- self.pluginProps = dict(new_props)
- self.replaced_props = True
- # Simulate Indigo auto-creating states for Supports* props.
- for prop_key, state_key in _SUPPORTS_TO_STATE.items():
- if new_props.get(prop_key) and state_key not in self.states:
- self.states[state_key] = 0 # Indigo-style initial value
-
-
-class FakeFolder:
- def __init__(self, folder_id, name):
- self.id = folder_id
- self.name = name
-
-
-class FakeFolderFactory:
- """Stands in for ``indigo.devices.folder`` (the folder command namespace)."""
-
- def __init__(self, devices):
- self.devices = devices
-
- def create(self, name):
- return self.devices.add_folder(name)
-
-
-class FakeDevices:
- def __init__(self):
- self._by_id = {}
- self._counter = 1000
- self._folders = {}
- self._folder_counter = 0
-
- def next_id(self):
- self._counter += 1
- return self._counter
-
- def add(self, dev):
- self._by_id[dev.id] = dev
-
- def add_folder(self, name):
- self._folder_counter += 1
- folder = FakeFolder(self._folder_counter, name)
- self._folders[folder.id] = folder
- return folder
-
- @property
- def folders(self):
- return list(self._folders.values())
-
- def __iter__(self):
- return iter(list(self._by_id.values()))
-
- def __getitem__(self, dev_id):
- return self._by_id[dev_id]
-
- def iter(self, _filter=None):
- # Real Indigo EXCLUDES unconfigured devices from iter("self") — that
- # exclusion is the whole mechanism of issue #62, so the fake has to
- # model it or a test for the stray warning would pass vacuously.
- # Plain iteration (__iter__) stays unfiltered, like the real
- # `indigo.devices`, which is the only place a stray is still visible.
- return [dev for dev in self._by_id.values() if getattr(dev, "configured", True)]
-
-
-class FakeDeviceFactory:
- """Stands in for the ``indigo.device`` command namespace.
-
- Since issue #204 stage 2 this carries a REAL device-group model rather than
- recording calls, and since ADR-0009 that model is the one the CONTROLLED
- EXPERIMENT on jarvis (2026-08-12) established rather than the one the docs
- imply:
-
- * Indigo orders a group's members by device AGE (creation order), and the
- OLDEST member is the root — ``getGroupList``'s first element, identical
- whichever member is asked.
- * ``groupWithDevice(a, b)`` and ``groupWithDevice(b, a)`` produce the
- SAME group. **The argument order does nothing.** A fake that honoured
- arg order would let a plugin that (wrongly) depends on it pass.
- * ``indigo.device.delete`` REFUSES to delete the root of a non-empty
- group — whichever device that turns out to be, which is what makes
- ``delete_node``'s dissolve-first shape load-bearing rather than
- decorative.
- """
-
- def __init__(self, devices):
- self.devices = devices
- self.created = []
- #: dev_id → the group's member list, SHARED by every member, ordered
- #: OLDEST FIRST; [0] is therefore the root. Absent means ungrouped.
- self.groups = {}
- #: (dev_1, dev_2) per groupWithDevice call — the idempotence assertion
- #: is "a second reconcile pass adds none of these". The order inside
- #: the tuple is what the plugin passed and means nothing to Indigo.
- self.group_calls = []
- self.ungroup_calls = []
-
- @staticmethod
- def _id_of(dev_or_id):
- return dev_or_id.id if hasattr(dev_or_id, "id") else int(dev_or_id)
-
- def _age_of(self, dev_id):
- """Creation sequence of a device — lower is older.
-
- ``FakeDevices.next_id`` is a monotonic counter, so an id IS its
- creation rank for every device these tests make; devices built by hand
- with an explicit id (the orphan/ghost fixtures) sort by that id, which
- is all the ordering they need.
- """
- return dev_id
-
- def create(self, protocol=None, deviceTypeId="", name="", props=None, folder=0, **kwargs):
- dev = FakeDev(self.devices.next_id(), name, deviceTypeId, dict(props or {}))
- if isinstance(folder, int) and folder:
- dev.folderId = folder
- self.devices.add(dev)
- self.created.append(dev)
- return dev
-
- def delete(self, dev):
- dev_id = self._id_of(dev)
- members = self.groups.get(dev_id)
- if members and len(members) > 1 and members[0] == dev_id:
- raise ValueError(
- "cannot delete device %s: it is the root of a non-empty device group" % dev_id)
- self._drop_from_group(dev_id)
- self.devices._by_id.pop(dev_id, None)
-
- def moveToFolder(self, dev_or_id, value=None):
- dev = dev_or_id if hasattr(dev_or_id, "folderId") else self.devices[dev_or_id]
- dev.folderId = value
-
- # -- the device-group model -----------------------------------------
- def getGroupList(self, dev_or_id):
- dev_id = self._id_of(dev_or_id)
- if dev_id not in self.devices._by_id:
- # Real Indigo cannot answer for an id that is not a device, and the
- # plugin's index CAN carry one: plugin.deviceDeleted only prunes
- # matterNode ids, so a hand-deleted endpoint device leaves a dead
- # id behind (issue #204 review, fix D). Without this tooth the
- # grouping sweep looks harmless against a fake that answers anyway.
- raise ValueError("device %s does not exist" % dev_id)
- members = self.groups.get(dev_id)
- # An ungrouped device answers with just itself — the plugin tolerates an
- # empty list too (both shapes are undocumented; see _ensure_grouped).
- return list(members) if members else [dev_id]
-
- def groupWithDevice(self, dev_1, dev_2):
- """The experiment's semantics: the two devices' groups are UNIONED and
- the result is ordered by device age, oldest first.
-
- ``groupWithDevice(motion, node)`` and ``groupWithDevice(node, motion)``
- returned byte-identical member lists on the live rig, and adding a
- third device to an existing pair left the root untouched — so this
- models a symmetric union with an age sort and no notion of a joiner.
-
- What is and isn't experiment-backed, honestly: SINGLETON+SINGLETON
- (both argument orders) and SINGLETON-JOINS-EXISTING-GROUP (the
- experiment's third line) are what the live rig actually exercised.
- A general GROUP+GROUP union — two already-multi-member groups
- merged in one call — is EXTRAPOLATED from those, never run on
- jarvis. Production's own guard (`_ensure_grouped`'s family check
- only ever passes a SINGLETON node device as one side) means only
- the backed directions are exercised today; this fake unions
- unconditionally because nothing here currently calls it any other
- way. Anyone relaxing that guard to call this with two genuine
- multi-member groups must extend the live experiment first, not
- just trust this model to still be right.
- """
- first, second = self._id_of(dev_1), self._id_of(dev_2)
- self.group_calls.append((first, second))
- members = set(self.groups.get(first) or [first])
- members |= set(self.groups.get(second) or [second])
- ordered = sorted(members, key=self._age_of)
- for member in ordered:
- self.groups[member] = ordered
-
- def ungroupDevice(self, dev_or_id):
- dev_id = self._id_of(dev_or_id)
- self.ungroup_calls.append(dev_id)
- self._drop_from_group(dev_id)
-
- def _drop_from_group(self, dev_id):
- members = self.groups.pop(dev_id, None)
- if not members:
- return
- remaining = [member for member in members if member != dev_id]
- for member in remaining:
- # A group of one is no group at all.
- if len(remaining) > 1:
- self.groups[member] = remaining
- else:
- self.groups.pop(member, None)
+from indigo_fakes import (
+ FakeDev,
+ FakeDeviceFactory,
+ FakeDevices,
+ FakeFolderFactory,
+)
@pytest.fixture
diff --git a/tests/test_generic_switch.py b/tests/test_generic_switch.py
index c6ed5cb..0e7f5d0 100644
--- a/tests/test_generic_switch.py
+++ b/tests/test_generic_switch.py
@@ -39,6 +39,7 @@
switch_features,
)
from matter_handlers.registry import HandlerRegistry
+from indigo_fakes import FakeDev, FakeDeviceFactory, FakeDevices, FakeFolderFactory
# ---------------------------------------------------------------------------
@@ -164,85 +165,6 @@ def test_evt_node_event_constant_value():
assert protocol.EVT_NODE_EVENT == "node_event"
-# ---------------------------------------------------------------------------
-# Helpers shared with DeviceSync tests
-# ---------------------------------------------------------------------------
-
-class FakeDev:
- def __init__(self, dev_id, name, device_type_id, props, initial_states=None):
- self.id = dev_id
- self.name = name
- self.deviceTypeId = device_type_id
- self.pluginProps = props
- self.states = dict(initial_states or {})
- self.error = None
- self.errorState = ""
- self.folderId = 0
-
- def updateStatesOnServer(self, kvlist):
- for kv in kvlist:
- self.states[kv["key"]] = kv["value"]
-
- def setErrorStateOnServer(self, value):
- self.error = value
- self.errorState = value
-
- def replaceOnServer(self):
- self.replaced = True
-
-
-class FakeDevices:
- def __init__(self):
- self._by_id = {}
- self._counter = 2000
- self._folders = {}
-
- def next_id(self):
- self._counter += 1
- return self._counter
-
- def add(self, dev):
- self._by_id[dev.id] = dev
-
- @property
- def folders(self):
- return list(self._folders.values())
-
- def __iter__(self):
- return iter(list(self._by_id.values()))
-
- def __getitem__(self, dev_id):
- return self._by_id[dev_id]
-
- def iter(self, _filter=None):
- return list(self._by_id.values())
-
-
-class FakeDeviceFactory:
- def __init__(self, devices):
- self.devices = devices
- self.created = []
-
- def create(self, protocol=None, deviceTypeId="", name="", props=None, folder=0, **kwargs):
- from test_generic_switch import FakeDev # local import to avoid circular ref
- dev = FakeDev(self.devices.next_id(), name, deviceTypeId, dict(props or {}))
- self.devices.add(dev)
- self.created.append(dev)
- return dev
-
- def moveToFolder(self, dev_or_id, value=None):
- pass
-
-
-class FakeFolderFactory:
- def __init__(self, devices):
- self.devices = devices
-
- def create(self, name):
- from test_generic_switch import FakeDev # noqa
- raise RuntimeError("no folder creation in generic switch tests")
-
-
@pytest.fixture
def indigo_env(mock_indigo_base):
indigo = mock_indigo_base
diff --git a/tests/test_integration.py b/tests/test_integration.py
index d96f61e..25b66b8 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -20,7 +20,7 @@
from fakes import FakeWebSocket, returns, scripted_responder
from test_handlers import RELAY_NODE
-from test_device_sync import FakeDev, FakeDeviceFactory, FakeDevices
+from indigo_fakes import FakeDev, FakeDeviceFactory, FakeDevices
@pytest.fixture
diff --git a/tests/test_power_source.py b/tests/test_power_source.py
index fc99d86..278c49a 100644
--- a/tests/test_power_source.py
+++ b/tests/test_power_source.py
@@ -229,13 +229,11 @@ def test_no_power_sources_covers_nothing():
# ---------------------------------------------------------------------------
# device_sync integration tests
-# (reuse helpers from test_device_sync.py via conftest + local FakeDev copies)
+# (reuse the shared Indigo fakes from indigo_fakes.py)
# ---------------------------------------------------------------------------
-# Import fakes from test_device_sync (they're not importable as a module but we
-# can replicate the minimal subset we need here, or import the symbols directly).
# Since pytest adds tests/ to sys.path via conftest, import directly.
-from test_device_sync import FakeDev, FakeDeviceFactory, FakeDevices, FakeFolderFactory # noqa: E402
+from indigo_fakes import FakeDev, FakeDeviceFactory, FakeDevices, FakeFolderFactory # noqa: E402
# A sensor node with PowerSource on endpoint 0 and a temperature sensor on endpoint 1.