pymmcore-plus 0.16.0__py3-none-any.whl → 0.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. pymmcore_plus/_ipy_completion.py +1 -1
  2. pymmcore_plus/_logger.py +2 -2
  3. pymmcore_plus/core/_device.py +37 -6
  4. pymmcore_plus/core/_mmcore_plus.py +4 -15
  5. pymmcore_plus/core/_property.py +1 -1
  6. pymmcore_plus/core/_sequencing.py +2 -0
  7. pymmcore_plus/experimental/simulate/__init__.py +88 -0
  8. pymmcore_plus/experimental/simulate/_objects.py +670 -0
  9. pymmcore_plus/experimental/simulate/_render.py +510 -0
  10. pymmcore_plus/experimental/simulate/_sample.py +156 -0
  11. pymmcore_plus/experimental/unicore/__init__.py +2 -0
  12. pymmcore_plus/experimental/unicore/_device_manager.py +26 -0
  13. pymmcore_plus/experimental/unicore/core/_config.py +706 -0
  14. pymmcore_plus/experimental/unicore/core/_unicore.py +832 -20
  15. pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
  16. pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
  17. pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
  18. pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
  19. pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
  20. pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
  21. pymmcore_plus/metadata/_ome.py +75 -21
  22. pymmcore_plus/metadata/functions.py +2 -1
  23. {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.1.dist-info}/METADATA +5 -3
  24. {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.1.dist-info}/RECORD +27 -21
  25. {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.1.dist-info}/WHEEL +1 -1
  26. {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.1.dist-info}/entry_points.txt +0 -0
  27. {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.1.dist-info}/licenses/LICENSE +0 -0
@@ -6,6 +6,7 @@ from collections.abc import Iterator, MutableMapping, Sequence
6
6
  from contextlib import suppress
7
7
  from datetime import datetime
8
8
  from itertools import count
9
+ from pathlib import Path
9
10
  from time import perf_counter_ns
10
11
  from typing import (
11
12
  TYPE_CHECKING,
@@ -20,39 +21,59 @@ from typing import (
20
21
  import numpy as np
21
22
 
22
23
  import pymmcore_plus._pymmcore as pymmcore
23
- from pymmcore_plus.core import CMMCorePlus, DeviceType, Keyword
24
+ from pymmcore_plus.core import CMMCorePlus, DeviceType, FocusDirection, Keyword
24
25
  from pymmcore_plus.core import Keyword as KW
26
+ from pymmcore_plus.core._config import Configuration
25
27
  from pymmcore_plus.core._constants import PixelType
26
28
  from pymmcore_plus.experimental.unicore._device_manager import PyDeviceManager
27
29
  from pymmcore_plus.experimental.unicore._proxy import create_core_proxy
28
30
  from pymmcore_plus.experimental.unicore.devices._camera import CameraDevice
29
31
  from pymmcore_plus.experimental.unicore.devices._device_base import Device
32
+ from pymmcore_plus.experimental.unicore.devices._hub import HubDevice
30
33
  from pymmcore_plus.experimental.unicore.devices._shutter import ShutterDevice
31
34
  from pymmcore_plus.experimental.unicore.devices._slm import SLMDevice
32
- from pymmcore_plus.experimental.unicore.devices._stage import XYStageDevice, _BaseStage
35
+ from pymmcore_plus.experimental.unicore.devices._stage import (
36
+ StageDevice,
37
+ XYStageDevice,
38
+ _BaseStage,
39
+ )
33
40
  from pymmcore_plus.experimental.unicore.devices._state import StateDevice
34
41
 
42
+ from ._config import load_system_configuration, save_system_configuration
35
43
  from ._sequence_buffer import SequenceBuffer
36
44
 
37
45
  if TYPE_CHECKING:
38
- from collections.abc import Mapping, Sequence
46
+ from collections.abc import Iterator, Mapping, Sequence
39
47
  from typing import Literal, NewType
40
48
 
41
49
  from numpy.typing import DTypeLike
42
50
  from pymmcore import (
43
51
  AdapterName,
44
52
  AffineTuple,
53
+ ConfigPresetName,
45
54
  DeviceLabel,
46
55
  DeviceName,
47
56
  PropertyName,
48
57
  StateLabel,
49
58
  )
59
+ from typing_extensions import TypeAlias
50
60
 
51
61
  from pymmcore_plus.core._constants import DeviceInitializationState, PropertyType
52
62
 
53
63
  PyDeviceLabel = NewType("PyDeviceLabel", DeviceLabel)
54
64
  _T = TypeVar("_T")
55
65
 
66
+ # =============================================================================
67
+ # Config Group Type Aliases
68
+ # =============================================================================
69
+
70
+ DevPropTuple = tuple[str, str]
71
+ ConfigDict: TypeAlias = "MutableMapping[DevPropTuple, Any]"
72
+ ConfigGroup: TypeAlias = "MutableMapping[ConfigPresetName, ConfigDict]"
73
+ # technically the keys are ConfigGroupName (a NewType from pymmcore)
74
+ # but to avoid all the casting, we use str here
75
+ ConfigGroups: TypeAlias = MutableMapping[str, ConfigGroup]
76
+
56
77
 
57
78
  class BufferOverflowStop(Exception):
58
79
  """Exception raised to signal graceful stop on buffer overflow."""
@@ -78,7 +99,7 @@ class _CoreDevice:
78
99
  determine which real device to use.
79
100
  """
80
101
 
81
- def __init__(self, state_cache: PropertyStateCache) -> None:
102
+ def __init__(self, state_cache: ThreadSafeConfig) -> None:
82
103
  self._state_cache = state_cache
83
104
  self._pycurrent: dict[Keyword, PyDeviceLabel | None] = {}
84
105
  self.reset_current()
@@ -102,11 +123,15 @@ class UniMMCore(CMMCorePlus):
102
123
 
103
124
  def __init__(self, *args: Any, **kwargs: Any) -> None:
104
125
  self._pydevices = PyDeviceManager() # manager for python devices
105
- self._state_cache = PropertyStateCache() # threadsafe cache for property states
126
+ self._state_cache = ThreadSafeConfig() # threadsafe cache for property states
106
127
  self._pycore = _CoreDevice(self._state_cache) # virtual core for python
107
128
  self._stop_event: threading.Event = threading.Event()
108
129
  self._acquisition_thread: AcquisitionThread | None = None # TODO: implement
109
130
  self._seq_buffer = SequenceBuffer(size_mb=_DEFAULT_BUFFER_SIZE_MB)
131
+ # Storage for Python device settings in config groups.
132
+ # Groups and presets are ALWAYS also created in C++ (via super() calls).
133
+ # This dict only stores (device, property) -> value for Python devices.
134
+ self._py_config_groups: ConfigGroups = {}
110
135
 
111
136
  super().__init__(*args, **kwargs)
112
137
 
@@ -136,7 +161,78 @@ class UniMMCore(CMMCorePlus):
136
161
  super().waitForDeviceType(DeviceType.AnyType)
137
162
  self.unloadAllDevices()
138
163
  self._pycore.reset_current()
139
- super().reset()
164
+ self._py_config_groups.clear() # Clear Python device config settings
165
+ super().reset() # Clears C++ config groups and channel group
166
+
167
+ def loadSystemConfiguration(
168
+ self, fileName: str | Path = "MMConfig_demo.cfg"
169
+ ) -> None:
170
+ """Load a system config file conforming to the MM `.cfg` format.
171
+
172
+ This is a Python implementation that supports both C++ and Python devices.
173
+ Lines prefixed with `#py ` are processed as Python device commands but
174
+ are ignored by upstream C++/pymmcore implementations.
175
+
176
+ Format example::
177
+
178
+ # C++ devices
179
+ Device, Camera, DemoCamera, DCam
180
+ Property, Core, Initialize, 1
181
+
182
+ # Python devices (hidden from upstream via comment prefix)
183
+ # py pyDevice,PyCamera,mypackage.cameras,MyCameraClass
184
+ # py Property,PyCamera,Exposure,50.0
185
+
186
+ https://micro-manager.org/Micro-Manager_Configuration_Guide#configuration-file-syntax
187
+
188
+ For relative paths, the current working directory is first checked, then
189
+ the device adapter path is checked.
190
+
191
+ Parameters
192
+ ----------
193
+ fileName : str | Path
194
+ Path to the configuration file. Defaults to "MMConfig_demo.cfg".
195
+ """
196
+ fpath = Path(fileName).expanduser()
197
+ if not fpath.exists() and not fpath.is_absolute() and self._mm_path:
198
+ fpath = Path(self._mm_path) / fileName
199
+ if not fpath.exists():
200
+ raise FileNotFoundError(f"Path does not exist: {fpath}")
201
+
202
+ cfg_path = str(fpath.resolve())
203
+ try:
204
+ load_system_configuration(self, cfg_path)
205
+ except Exception:
206
+ # On failure, unload all devices to avoid leaving loaded but
207
+ # uninitialized devices that could cause crashes
208
+ with suppress(Exception):
209
+ self.unloadAllDevices()
210
+ raise
211
+
212
+ self._last_sys_config = cfg_path
213
+ # Emit system configuration loaded event
214
+ self.events.systemConfigurationLoaded.emit()
215
+
216
+ def saveSystemConfiguration(
217
+ self, filename: str | Path, *, prefix_py_devices: bool = True
218
+ ) -> None:
219
+ """Save the current system configuration to a text file.
220
+
221
+ This saves both C++ and Python devices. Python device lines are prefixed
222
+ with `#py ` by default so they are ignored by upstream C++/pymmcore.
223
+
224
+ Parameters
225
+ ----------
226
+ filename : str | Path
227
+ Path to save the configuration file.
228
+ prefix_py_devices : bool, optional
229
+ If True (default), Python device lines are prefixed with `#py ` so
230
+ they are ignored by upstream C++/pymmcore implementations, allowing
231
+ config files to work with regular pymmcore. If False, Python device
232
+ lines are saved without the prefix (config will only be loadable by
233
+ UniMMCore).
234
+ """
235
+ save_system_configuration(self, filename, prefix_py_devices=prefix_py_devices)
140
236
 
141
237
  # ------------------------------------------------------------------------
142
238
  # ----------------- Functionality for All Devices ------------------------
@@ -163,11 +259,11 @@ class UniMMCore(CMMCorePlus):
163
259
  try:
164
260
  CMMCorePlus.loadDevice(self, label, moduleName, deviceName)
165
261
  except RuntimeError as e:
166
- # it was a C++ device, should have worked ... raise the error
167
262
  if moduleName not in super().getDeviceAdapterNames():
168
263
  pydev = self._get_py_device_instance(moduleName, deviceName)
169
264
  self.loadPyDevice(label, pydev)
170
265
  return
266
+ # it was a C++ device, should have worked ... raise the error
171
267
  if exc := self._load_error_with_info(label, moduleName, deviceName, str(e)):
172
268
  raise exc from e
173
269
 
@@ -212,6 +308,10 @@ class UniMMCore(CMMCorePlus):
212
308
 
213
309
  load_py_device = loadPyDevice
214
310
 
311
+ def isPyDevice(self, label: DeviceLabel | str) -> bool:
312
+ """Returns True if the specified device label corresponds to a Python device."""
313
+ return label in self._pydevices
314
+
215
315
  def unloadDevice(self, label: DeviceLabel | str) -> None:
216
316
  if label not in self._pydevices: # pragma: no cover
217
317
  return super().unloadDevice(label)
@@ -240,7 +340,7 @@ class UniMMCore(CMMCorePlus):
240
340
 
241
341
  def getLoadedDevicesOfType(self, devType: int) -> tuple[DeviceLabel, ...]:
242
342
  pydevs = self._pydevices.get_labels_of_type(devType)
243
- return pydevs + super().getLoadedDevicesOfType(devType)
343
+ return pydevs + tuple(super().getLoadedDevicesOfType(devType))
244
344
 
245
345
  def getDeviceType(self, label: str) -> DeviceType:
246
346
  if label not in self._pydevices: # pragma: no cover
@@ -262,6 +362,67 @@ class UniMMCore(CMMCorePlus):
262
362
  return super().getDeviceDescription(label)
263
363
  return self._pydevices[label].description()
264
364
 
365
+ # ---------------------------- Parent/Hub Relationships ---------------------------
366
+
367
+ def getParentLabel(
368
+ self, peripheralLabel: DeviceLabel | str
369
+ ) -> DeviceLabel | Literal[""]:
370
+ if peripheralLabel not in self._pydevices: # pragma: no cover
371
+ return super().getParentLabel(peripheralLabel)
372
+ return self._pydevices[peripheralLabel].get_parent_label() # type: ignore[return-value]
373
+
374
+ def setParentLabel(
375
+ self, deviceLabel: DeviceLabel | str, parentHubLabel: DeviceLabel | str
376
+ ) -> None:
377
+ if deviceLabel == KW.CoreDevice:
378
+ return
379
+
380
+ # Reject cross-language hub/peripheral relationships
381
+ device_is_py = deviceLabel in self._pydevices
382
+ parent_is_py = parentHubLabel in self._pydevices
383
+ if parentHubLabel and device_is_py != parent_is_py:
384
+ raise RuntimeError( # pragma: no cover
385
+ "Cannot set cross-language parent/child relationship between C++ and "
386
+ "Python devices"
387
+ )
388
+
389
+ if device_is_py:
390
+ self._pydevices[deviceLabel].set_parent_label(parentHubLabel)
391
+ else:
392
+ super().setParentLabel(deviceLabel, parentHubLabel)
393
+
394
+ def getInstalledDevices(
395
+ self, hubLabel: DeviceLabel | str
396
+ ) -> tuple[DeviceName, ...]:
397
+ if hubLabel not in self._pydevices: # pragma: no cover
398
+ return tuple(super().getInstalledDevices(hubLabel))
399
+
400
+ with self._pydevices.get_device_of_type(hubLabel, HubDevice) as hub:
401
+ peripherals = hub.get_installed_peripherals()
402
+ return tuple(p[0] for p in peripherals if p[0]) # type: ignore[misc]
403
+
404
+ def getLoadedPeripheralDevices(
405
+ self, hubLabel: DeviceLabel | str
406
+ ) -> tuple[DeviceLabel, ...]:
407
+ cpp_peripherals = super().getLoadedPeripheralDevices(hubLabel)
408
+ py_peripherals = self._pydevices.get_loaded_peripherals(hubLabel)
409
+ return tuple(cpp_peripherals) + py_peripherals
410
+
411
+ def getInstalledDeviceDescription(
412
+ self, hubLabel: DeviceLabel | str, peripheralLabel: DeviceName | str
413
+ ) -> str:
414
+ if hubLabel not in self._pydevices:
415
+ return super().getInstalledDeviceDescription(hubLabel, peripheralLabel)
416
+
417
+ with self._pydevices.get_device_of_type(hubLabel, HubDevice) as hub:
418
+ for p in hub.get_installed_peripherals():
419
+ if p[0] == peripheralLabel:
420
+ return p[1] or "N/A"
421
+ raise RuntimeError( # pragma: no cover
422
+ f"No peripheral with name {peripheralLabel!r} installed in hub "
423
+ f"{hubLabel!r}"
424
+ )
425
+
265
426
  # ---------------------------- Properties ---------------------------
266
427
 
267
428
  def getDevicePropertyNames(
@@ -299,6 +460,12 @@ class UniMMCore(CMMCorePlus):
299
460
  def setProperty(
300
461
  self, label: str, propName: str, propValue: bool | float | int | str
301
462
  ) -> None:
463
+ # FIXME:
464
+ # this single case is probably just the tip of the iceberg when label is "Core"
465
+ if label == KW.CoreDevice and propName == KW.CoreChannelGroup:
466
+ self.setChannelGroup(str(propValue))
467
+ return
468
+
302
469
  if label not in self._pydevices: # pragma: no cover
303
470
  return super().setProperty(label, propName, propValue)
304
471
  with self._pydevices[label] as dev:
@@ -418,7 +585,21 @@ class UniMMCore(CMMCorePlus):
418
585
  return super().waitForDevice(label)
419
586
  self._pydevices.wait_for(label, self.getTimeoutMs())
420
587
 
421
- # def waitForConfig
588
+ def waitForConfig(self, group: str, configName: str) -> None:
589
+ # Get config data (merged from C++ and Python)
590
+ cfg = self.getConfigData(group, configName, native=True)
591
+
592
+ # Wait for each unique device in the config
593
+ devs_to_await: set[str] = set()
594
+ for i in range(cfg.size()):
595
+ devs_to_await.add(cfg.getSetting(i).getDeviceLabel())
596
+
597
+ for device in devs_to_await:
598
+ try:
599
+ self.waitForDevice(device)
600
+ except Exception:
601
+ # Like C++, trap exceptions and keep quiet
602
+ pass
422
603
 
423
604
  # probably only needed because C++ method is not virtual
424
605
  def systemBusy(self) -> bool:
@@ -656,6 +837,192 @@ class UniMMCore(CMMCorePlus):
656
837
  with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
657
838
  dev.stop_sequence()
658
839
 
840
+ # ########################################################################
841
+ # ----------------------------- StageDevice ------------------------------
842
+ # ########################################################################
843
+
844
+ def getFocusDevice(self) -> DeviceLabel | Literal[""]:
845
+ """Return the current Focus Device."""
846
+ return self._pycore.current(KW.CoreFocus) or super().getFocusDevice()
847
+
848
+ def setFocusDevice(self, focusLabel: str) -> None:
849
+ """Set new current Focus Device."""
850
+ try:
851
+ super().setFocusDevice(focusLabel)
852
+ except Exception:
853
+ # python device
854
+ if focusLabel in self._pydevices:
855
+ if self.getDeviceType(focusLabel) == DeviceType.StageDevice:
856
+ # assign focus device
857
+ label = self._set_current_if_pydevice(KW.CoreFocus, focusLabel)
858
+ super().setFocusDevice(label)
859
+ # otherwise do nothing
860
+
861
+ @overload
862
+ def getPosition(self) -> float: ...
863
+ @overload
864
+ def getPosition(self, stageLabel: str) -> float: ...
865
+ def getPosition(self, stageLabel: str | None = None) -> float:
866
+ label = stageLabel or self.getFocusDevice()
867
+ if label not in self._pydevices:
868
+ return super().getPosition(label)
869
+ with self._pydevices.get_device_of_type(label, StageDevice) as device:
870
+ return device.get_position_um()
871
+
872
+ @overload
873
+ def setPosition(self, position: float, /) -> None: ...
874
+ @overload
875
+ def setPosition(
876
+ self, stageLabel: DeviceLabel | str, position: float, /
877
+ ) -> None: ...
878
+ def setPosition(self, *args: Any) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
879
+ label, args = _ensure_label(args, min_args=2, getter=self.getFocusDevice)
880
+ if label not in self._pydevices: # pragma: no cover
881
+ return super().setPosition(label, *args)
882
+ with self._pydevices.get_device_of_type(label, StageDevice) as dev:
883
+ dev.set_position_um(*args)
884
+
885
+ def setFocusDirection(self, stageLabel: DeviceLabel | str, sign: int) -> None:
886
+ if stageLabel not in self._pydevices: # pragma: no cover
887
+ return super().setFocusDirection(stageLabel, sign)
888
+ with self._pydevices.get_device_of_type(stageLabel, StageDevice) as device:
889
+ device.set_focus_direction(sign)
890
+
891
+ def getFocusDirection(self, stageLabel: DeviceLabel | str) -> FocusDirection:
892
+ """Get the current focus direction of the Z stage."""
893
+ if stageLabel not in self._pydevices: # pragma: no cover
894
+ return super().getFocusDirection(stageLabel)
895
+ with self._pydevices.get_device_of_type(stageLabel, StageDevice) as device:
896
+ return device.get_focus_direction()
897
+
898
+ @overload
899
+ def setOrigin(self) -> None: ...
900
+ @overload
901
+ def setOrigin(self, stageLabel: DeviceLabel | str) -> None: ...
902
+ def setOrigin(self, stageLabel: DeviceLabel | str | None = None) -> None:
903
+ """Zero the current focus/Z stage's coordinates at the current position."""
904
+ label = stageLabel or self.getFocusDevice()
905
+ if label not in self._pydevices: # pragma: no cover
906
+ return super().setOrigin(label)
907
+ with self._pydevices.get_device_of_type(label, StageDevice) as device:
908
+ device.set_origin()
909
+
910
+ @overload
911
+ def setRelativePosition(self, d: float, /) -> None: ...
912
+ @overload
913
+ def setRelativePosition(
914
+ self, stageLabel: DeviceLabel | str, d: float, /
915
+ ) -> None: ...
916
+ def setRelativePosition(self, *args: Any) -> None:
917
+ """Sets the relative position of the stage in microns."""
918
+ label, args = _ensure_label(args, min_args=2, getter=self.getFocusDevice)
919
+ if label not in self._pydevices: # pragma: no cover
920
+ return super().setRelativePosition(label, *args)
921
+ with self._pydevices.get_device_of_type(label, StageDevice) as dev:
922
+ dev.set_relative_position_um(*args)
923
+
924
+ @overload
925
+ def setAdapterOrigin(self, newZUm: float, /) -> None: ...
926
+ @overload
927
+ def setAdapterOrigin(
928
+ self, stageLabel: DeviceLabel | str, newZUm: float, /
929
+ ) -> None: ...
930
+ def setAdapterOrigin(self, *args: Any) -> None:
931
+ """Enable software translation of coordinates for the current focus/Z stage.
932
+
933
+ The current position of the stage becomes Z = newZUm. Only some stages
934
+ support this functionality; it is recommended that setOrigin() be used
935
+ instead where available.
936
+ """
937
+ label, args = _ensure_label(args, min_args=2, getter=self.getFocusDevice)
938
+ if label not in self._pydevices: # pragma: no cover
939
+ return super().setAdapterOrigin(label, *args)
940
+ with self._pydevices.get_device_of_type(label, StageDevice) as dev:
941
+ dev.set_adapter_origin_um(*args)
942
+
943
+ def isStageSequenceable(self, stageLabel: DeviceLabel | str) -> bool:
944
+ """Queries stage if it can be used in a sequence."""
945
+ if stageLabel not in self._pydevices: # pragma: no cover
946
+ return super().isStageSequenceable(stageLabel)
947
+ dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
948
+ return dev.is_sequenceable()
949
+
950
+ def isStageLinearSequenceable(self, stageLabel: DeviceLabel | str) -> bool:
951
+ """Queries if the stage can be used in a linear sequence.
952
+
953
+ A linear sequence is defined by a step size and number of slices.
954
+ """
955
+ if stageLabel not in self._pydevices: # pragma: no cover
956
+ return super().isStageLinearSequenceable(stageLabel)
957
+ dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
958
+ return dev.is_linear_sequenceable()
959
+
960
+ def getStageSequenceMaxLength(self, stageLabel: DeviceLabel | str) -> int:
961
+ """Gets the maximum length of a stage's position sequence."""
962
+ if stageLabel not in self._pydevices: # pragma: no cover
963
+ return super().getStageSequenceMaxLength(stageLabel)
964
+ dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
965
+ return dev.get_sequence_max_length()
966
+
967
+ def loadStageSequence(
968
+ self,
969
+ stageLabel: DeviceLabel | str,
970
+ positionSequence: Sequence[float],
971
+ ) -> None:
972
+ """Transfer a sequence of stage positions to the stage.
973
+
974
+ This should only be called for stages that are sequenceable.
975
+ """
976
+ if stageLabel not in self._pydevices: # pragma: no cover
977
+ return super().loadStageSequence(stageLabel, positionSequence)
978
+ dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
979
+ if len(positionSequence) > dev.get_sequence_max_length():
980
+ raise ValueError(
981
+ f"Sequence is too long. Max length is {dev.get_sequence_max_length()}"
982
+ )
983
+ dev.send_sequence(tuple(positionSequence))
984
+
985
+ def startStageSequence(self, stageLabel: DeviceLabel | str) -> None:
986
+ """Starts an ongoing sequence of triggered events in a stage.
987
+
988
+ This should only be called for stages that are sequenceable.
989
+ """
990
+ if stageLabel not in self._pydevices: # pragma: no cover
991
+ return super().startStageSequence(stageLabel)
992
+ with self._pydevices.get_device_of_type(stageLabel, StageDevice) as dev:
993
+ dev.start_sequence()
994
+
995
+ def stopStageSequence(self, stageLabel: DeviceLabel | str) -> None:
996
+ """Stops an ongoing sequence of triggered events in a stage.
997
+
998
+ This should only be called for stages that are sequenceable.
999
+ """
1000
+ if stageLabel not in self._pydevices: # pragma: no cover
1001
+ return super().stopStageSequence(stageLabel)
1002
+ with self._pydevices.get_device_of_type(stageLabel, StageDevice) as dev:
1003
+ dev.stop_sequence()
1004
+
1005
+ def setStageLinearSequence(
1006
+ self, stageLabel: DeviceLabel | str, dZ_um: float, nSlices: int
1007
+ ) -> None:
1008
+ """Loads a linear sequence (defined by step size and nr. of steps)."""
1009
+ if nSlices < 0:
1010
+ raise ValueError("Linear sequence cannot have negative length")
1011
+ if stageLabel not in self._pydevices: # pragma: no cover
1012
+ return super().setStageLinearSequence(stageLabel, dZ_um, nSlices)
1013
+ with self._pydevices.get_device_of_type(stageLabel, StageDevice) as dev:
1014
+ dev.set_linear_sequence(dZ_um, nSlices)
1015
+
1016
+ def isContinuousFocusDrive(self, stageLabel: DeviceLabel | str) -> bool:
1017
+ """Check if a stage has continuous focusing capability.
1018
+
1019
+ Returns True if positions can be set while continuous focus runs.
1020
+ """
1021
+ if stageLabel not in self._pydevices: # pragma: no cover
1022
+ return super().isContinuousFocusDrive(stageLabel)
1023
+ dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
1024
+ return dev.is_continuous_focus_drive()
1025
+
659
1026
  # -----------------------------------------------------------------------
660
1027
  # ---------------------------- Any Stage --------------------------------
661
1028
  # -----------------------------------------------------------------------
@@ -1100,9 +1467,8 @@ class UniMMCore(CMMCorePlus):
1100
1467
  def getNumberOfCameraChannels(self) -> int:
1101
1468
  if self._py_camera() is None: # pragma: no cover
1102
1469
  return super().getNumberOfCameraChannels()
1103
- raise NotImplementedError(
1104
- "getNumberOfCameraChannels is not implemented for Python cameras."
1105
- )
1470
+
1471
+ return 1
1106
1472
 
1107
1473
  def getCameraChannelName(self, channelNr: int) -> str:
1108
1474
  """Get the name of the camera channel."""
@@ -1663,6 +2029,437 @@ class UniMMCore(CMMCorePlus):
1663
2029
  with shutter:
1664
2030
  shutter.set_open(state)
1665
2031
 
2032
+ # ########################################################################
2033
+ # -------------------- Configuration Group Methods -----------------------
2034
+ # ########################################################################
2035
+ #
2036
+ # HYBRID PATTERN: Config groups exist in both C++ and Python.
2037
+ # - Groups and presets are ALWAYS created in C++ (via super())
2038
+ # - _config_groups only stores settings for PYTHON devices
2039
+ # - Methods merge results from both systems where appropriate
2040
+ #
2041
+ # This ensures:
2042
+ # - C++ CoreCallback can find configs and emit proper events
2043
+ # - defineStateLabel in C++ can update configs referencing C++ devices
2044
+ # - loadSystemConfiguration works correctly
2045
+ # - Python device settings are properly tracked and applied
2046
+
2047
+ # -------------------------------------------------------------------------
2048
+ # Group-level operations
2049
+ # -------------------------------------------------------------------------
2050
+
2051
+ def defineConfigGroup(self, groupName: str) -> None:
2052
+ # Create group in C++ (handles validation and events)
2053
+ super().defineConfigGroup(groupName)
2054
+ # Also create empty group in Python for potential Python device settings
2055
+ self._py_config_groups[groupName] = {}
2056
+
2057
+ def deleteConfigGroup(self, groupName: str) -> None:
2058
+ # Delete from C++ (handles validation and events)
2059
+ super().deleteConfigGroup(groupName)
2060
+ self._py_config_groups.pop(groupName, None)
2061
+
2062
+ def renameConfigGroup(self, oldGroupName: str, newGroupName: str) -> None:
2063
+ # Rename in C++ (handles validation)
2064
+ super().renameConfigGroup(oldGroupName, newGroupName)
2065
+ if oldGroupName in self._py_config_groups:
2066
+ self._py_config_groups[newGroupName] = self._py_config_groups.pop(
2067
+ oldGroupName
2068
+ )
2069
+
2070
+ # -------------------------------------------------------------------------
2071
+ # Preset-level operations
2072
+ # -------------------------------------------------------------------------
2073
+
2074
+ @overload
2075
+ def defineConfig(self, groupName: str, configName: str) -> None: ...
2076
+ @overload
2077
+ def defineConfig(
2078
+ self,
2079
+ groupName: str,
2080
+ configName: str,
2081
+ deviceLabel: str,
2082
+ propName: str,
2083
+ value: Any,
2084
+ ) -> None: ...
2085
+ def defineConfig(
2086
+ self,
2087
+ groupName: str,
2088
+ configName: str,
2089
+ deviceLabel: str | None = None,
2090
+ propName: str | None = None,
2091
+ value: Any | None = None,
2092
+ ) -> None:
2093
+ # Route to appropriate storage based on device type
2094
+ if deviceLabel is None or propName is None or value is None:
2095
+ # No device specified: just create empty group/preset in C++
2096
+ super().defineConfig(groupName, configName)
2097
+ # Also ensure Python storage has the structure
2098
+ group = self._py_config_groups.setdefault(groupName, {})
2099
+ group.setdefault(cast("ConfigPresetName", configName), {})
2100
+
2101
+ elif deviceLabel in self._pydevices:
2102
+ # Python device: store in our _config_groups
2103
+ # But first ensure the group/preset exists in C++ too
2104
+ if not super().isGroupDefined(groupName):
2105
+ super().defineConfigGroup(groupName)
2106
+ self._py_config_groups[groupName] = {}
2107
+ if not super().isConfigDefined(groupName, configName):
2108
+ super().defineConfig(groupName, configName)
2109
+
2110
+ # Store Python device setting locally
2111
+ group = self._py_config_groups.setdefault(groupName, {})
2112
+ preset = group.setdefault(cast("ConfigPresetName", configName), {})
2113
+ preset[(deviceLabel, propName)] = value
2114
+ # Emit event (C++ won't emit for Python device settings)
2115
+ self.events.configDefined.emit(
2116
+ groupName, configName, deviceLabel, propName, str(value)
2117
+ )
2118
+ else:
2119
+ # C++ device: let C++ handle it entirely
2120
+ # C++ expects string values, so convert
2121
+ super().defineConfig(
2122
+ groupName, configName, deviceLabel, propName, str(value)
2123
+ )
2124
+ # Ensure our Python storage has the group/preset structure
2125
+ group = self._py_config_groups.setdefault(groupName, {})
2126
+ group.setdefault(cast("ConfigPresetName", configName), {})
2127
+
2128
+ @overload
2129
+ def deleteConfig(self, groupName: str, configName: str) -> None: ...
2130
+ @overload
2131
+ def deleteConfig(
2132
+ self, groupName: str, configName: str, deviceLabel: str, propName: str
2133
+ ) -> None: ...
2134
+ def deleteConfig(
2135
+ self,
2136
+ groupName: str,
2137
+ configName: str,
2138
+ deviceLabel: str | None = None,
2139
+ propName: str | None = None,
2140
+ ) -> None:
2141
+ if deviceLabel is None or propName is None:
2142
+ # Deleting entire preset: delete from both C++ and Python storage
2143
+ py_group = self._py_config_groups.get(groupName, {})
2144
+ py_group.pop(configName, None) # type: ignore[call-overload]
2145
+ super().deleteConfig(groupName, configName)
2146
+
2147
+ # Deleting a specific property from a preset
2148
+ elif deviceLabel in self._pydevices:
2149
+ # Python device: remove from our storage
2150
+ py_group = self._py_config_groups.get(groupName, {})
2151
+ py_preset = py_group.get(configName, {}) # type: ignore[call-overload]
2152
+ key = (deviceLabel, propName)
2153
+ if key in py_preset:
2154
+ del py_preset[key]
2155
+ self.events.configDeleted.emit(groupName, configName)
2156
+ else:
2157
+ raise RuntimeError(
2158
+ f"Property '{propName}' not found in preset '{configName}'"
2159
+ )
2160
+ else:
2161
+ # C++ device: let C++ handle it
2162
+ super().deleteConfig(groupName, configName, deviceLabel, propName)
2163
+
2164
+ def renameConfig(
2165
+ self, groupName: str, oldConfigName: str, newConfigName: str
2166
+ ) -> None:
2167
+ # Rename in C++ (handles validation)
2168
+ super().renameConfig(groupName, oldConfigName, newConfigName)
2169
+ # Also rename in Python storage if present
2170
+ py_group = self._py_config_groups.get(groupName, {})
2171
+ if oldConfigName in py_group:
2172
+ py_group[newConfigName] = py_group.pop(oldConfigName) # type: ignore
2173
+
2174
+ @overload
2175
+ def getConfigData(
2176
+ self, configGroup: str, configName: str, *, native: Literal[True]
2177
+ ) -> pymmcore.Configuration: ...
2178
+ @overload
2179
+ def getConfigData(
2180
+ self, configGroup: str, configName: str, *, native: Literal[False] = False
2181
+ ) -> Configuration: ...
2182
+ def getConfigData(
2183
+ self, configGroup: str, configName: str, *, native: bool = False
2184
+ ) -> Configuration | pymmcore.Configuration:
2185
+ # Get C++ config data (includes all C++ device settings)
2186
+ cpp_cfg: pymmcore.Configuration = super().getConfigData(
2187
+ configGroup, configName, native=True
2188
+ )
2189
+
2190
+ # Add Python device settings from our storage
2191
+ py_group = self._py_config_groups.get(configGroup, {})
2192
+ py_preset = py_group.get(configName, {}) # type: ignore[call-overload]
2193
+ for (dev, prop), value in py_preset.items():
2194
+ cpp_cfg.addSetting(pymmcore.PropertySetting(dev, prop, str(value)))
2195
+
2196
+ if native:
2197
+ return cpp_cfg
2198
+ return Configuration.from_configuration(cpp_cfg)
2199
+
2200
+ # -------------------------------------------------------------------------
2201
+ # Applying configurations
2202
+ # -------------------------------------------------------------------------
2203
+
2204
+ def setConfig(self, groupName: str, configName: str) -> None:
2205
+ # Apply C++ device settings via super() - this handles validation,
2206
+ # error retry logic, and state cache updates for C++ devices
2207
+ super().setConfig(groupName, configName)
2208
+
2209
+ # Now apply Python device settings from our storage
2210
+ py_group = self._py_config_groups.get(groupName, {})
2211
+ py_preset = py_group.get(configName, {}) # type: ignore[call-overload]
2212
+
2213
+ if py_preset:
2214
+ failed: list[tuple[DevPropTuple, Any]] = []
2215
+ for (device, prop), value in py_preset.items():
2216
+ try:
2217
+ self.setProperty(device, prop, value)
2218
+ except Exception:
2219
+ failed.append(((device, prop), value))
2220
+
2221
+ # Retry failed properties (handles dependency chains)
2222
+ if failed:
2223
+ errors: list[str] = []
2224
+ for (device, prop), value in failed:
2225
+ try:
2226
+ self.setProperty(device, prop, value)
2227
+ except Exception as e:
2228
+ errors.append(f"{device}.{prop}={value}: {e}")
2229
+ if errors:
2230
+ raise RuntimeError("Failed to apply: " + "; ".join(errors))
2231
+
2232
+ # -------------------------------------------------------------------------
2233
+ # Current config detection
2234
+ # -------------------------------------------------------------------------
2235
+
2236
+ def getCurrentConfig(self, groupName: str) -> ConfigPresetName | Literal[""]:
2237
+ return self._getCurrentConfig(groupName, from_cache=False)
2238
+
2239
+ def getCurrentConfigFromCache(
2240
+ self, groupName: str
2241
+ ) -> ConfigPresetName | Literal[""]:
2242
+ return self._getCurrentConfig(groupName, from_cache=True)
2243
+
2244
+ def _getCurrentConfig(
2245
+ self, groupName: str, from_cache: bool
2246
+ ) -> ConfigPresetName | Literal[""]:
2247
+ """Find the first preset whose settings all match current device state.
2248
+
2249
+ This checks both C++ device settings (via super()) and Python device settings.
2250
+ """
2251
+ # Get C++ result first
2252
+ if from_cache:
2253
+ cpp_result = super().getCurrentConfigFromCache(groupName)
2254
+ else:
2255
+ cpp_result = super().getCurrentConfig(groupName)
2256
+
2257
+ # If no Python device settings exist for this group, C++ result is sufficient
2258
+ py_group = self._py_config_groups.get(groupName, {})
2259
+ has_py_settings = any(py_group.values())
2260
+ if not has_py_settings:
2261
+ return cpp_result
2262
+
2263
+ # We have Python device settings - need to verify they match too
2264
+ # Get current state of all Python device properties in this group
2265
+ getter = self.getPropertyFromCache if from_cache else self.getProperty
2266
+ current_py_state: ConfigDict = {}
2267
+ seen_keys: set[DevPropTuple] = set()
2268
+ for preset in py_group.values():
2269
+ for key in preset:
2270
+ if key not in seen_keys:
2271
+ seen_keys.add(key)
2272
+ with suppress(Exception):
2273
+ current_py_state[key] = getter(*key)
2274
+
2275
+ # Check each preset to see if Python device settings match
2276
+ for preset_name in self.getAvailableConfigs(groupName):
2277
+ py_preset = py_group.get(preset_name, {})
2278
+ if all(
2279
+ _values_match(current_py_state.get(k), v) for k, v in py_preset.items()
2280
+ ):
2281
+ # Python settings match - but only return if C++ also matches
2282
+ # (or if there are no C++ settings for this preset)
2283
+ cpp_cfg = super().getConfigData(groupName, preset_name, native=True)
2284
+ if cpp_cfg.size() == 0:
2285
+ # No C++ settings, Python match is sufficient
2286
+ return preset_name
2287
+ # Check each C++ setting with numeric-aware comparison
2288
+ # (C++ getCurrentConfig uses strict string comparison which fails
2289
+ # for values like "50.0000" vs "50")
2290
+ all_cpp_match = True
2291
+ for i in range(cpp_cfg.size()):
2292
+ setting = cpp_cfg.getSetting(i)
2293
+ current_val = getter(
2294
+ setting.getDeviceLabel(), setting.getPropertyName()
2295
+ )
2296
+ if not _values_match(current_val, setting.getPropertyValue()):
2297
+ all_cpp_match = False
2298
+ break
2299
+ if all_cpp_match:
2300
+ return preset_name
2301
+
2302
+ return ""
2303
+
2304
+ # -------------------------------------------------------------------------
2305
+ # State queries
2306
+ # -------------------------------------------------------------------------
2307
+
2308
+ def getConfigState(
2309
+ self, group: str, config: str, *, native: bool = False
2310
+ ) -> Configuration | pymmcore.Configuration:
2311
+ # Get C++ config state (current values for C++ device properties)
2312
+ cpp_state: pymmcore.Configuration = super().getConfigState(
2313
+ group, config, native=True
2314
+ )
2315
+
2316
+ # Add current values for Python device properties
2317
+ py_group = self._py_config_groups.get(group, {})
2318
+ py_preset = py_group.get(config, {}) # type: ignore[call-overload]
2319
+ for dev, prop in py_preset:
2320
+ current_value = self.getProperty(dev, prop)
2321
+ cpp_state.addSetting(
2322
+ pymmcore.PropertySetting(dev, prop, str(current_value))
2323
+ )
2324
+
2325
+ if native:
2326
+ return cpp_state
2327
+ return Configuration.from_configuration(cpp_state)
2328
+
2329
+ @overload
2330
+ def getConfigGroupState(
2331
+ self, group: str, *, native: Literal[True]
2332
+ ) -> pymmcore.Configuration: ...
2333
+ @overload
2334
+ def getConfigGroupState(
2335
+ self, group: str, *, native: Literal[False] = False
2336
+ ) -> Configuration: ...
2337
+ def getConfigGroupState(
2338
+ self, group: str, *, native: bool = False
2339
+ ) -> Configuration | pymmcore.Configuration:
2340
+ return self._getConfigGroupState(group, from_cache=False, native=native)
2341
+
2342
+ @overload
2343
+ def getConfigGroupStateFromCache(
2344
+ self, group: str, *, native: Literal[True]
2345
+ ) -> pymmcore.Configuration: ...
2346
+ @overload
2347
+ def getConfigGroupStateFromCache(
2348
+ self, group: str, *, native: Literal[False] = False
2349
+ ) -> Configuration: ...
2350
+ def getConfigGroupStateFromCache(
2351
+ self, group: str, *, native: bool = False
2352
+ ) -> Configuration | pymmcore.Configuration:
2353
+ return self._getConfigGroupState(group, from_cache=True, native=native)
2354
+
2355
+ def _getConfigGroupState(
2356
+ self, group: str, from_cache: bool, native: bool = False
2357
+ ) -> Configuration | pymmcore.Configuration:
2358
+ """Get current values for all properties in a group."""
2359
+ # Get C++ group state
2360
+ if from_cache:
2361
+ cpp_state: pymmcore.Configuration = super().getConfigGroupStateFromCache(
2362
+ group, native=True
2363
+ )
2364
+ else:
2365
+ cpp_state = super().getConfigGroupState(group, native=True)
2366
+
2367
+ # Add Python device property values
2368
+ py_group = self._py_config_groups.get(group, {})
2369
+ getter = self.getPropertyFromCache if from_cache else self.getProperty
2370
+ for preset in py_group.values():
2371
+ for device, prop in preset:
2372
+ value = str(getter(device, prop))
2373
+ cpp_state.addSetting(pymmcore.PropertySetting(device, prop, value))
2374
+
2375
+ if native:
2376
+ return cpp_state
2377
+ return Configuration.from_configuration(cpp_state)
2378
+
2379
+ # ########################################################################
2380
+ # ----------------------- System State Methods ---------------------------
2381
+ # ########################################################################
2382
+
2383
+ # currently we still allow C++ to cache the system state for C++ devices,
2384
+ # but we could choose to just own it all ourselves in the future.
2385
+
2386
+ def getSystemState(
2387
+ self, *, native: bool = False
2388
+ ) -> Configuration | pymmcore.Configuration:
2389
+ """Return the entire system state including Python device properties.
2390
+
2391
+ This method iterates through all devices (C++ and Python) and returns
2392
+ all property values. Following the C++ implementation pattern.
2393
+ """
2394
+ return self._getSystemStateCache(cache=False, native=native)
2395
+
2396
+ @overload
2397
+ def getSystemStateCache(
2398
+ self, *, native: Literal[True]
2399
+ ) -> pymmcore.Configuration: ...
2400
+ @overload
2401
+ def getSystemStateCache(
2402
+ self, *, native: Literal[False] = False
2403
+ ) -> Configuration: ...
2404
+ def getSystemStateCache(
2405
+ self, *, native: bool = False
2406
+ ) -> Configuration | pymmcore.Configuration:
2407
+ return self._getSystemStateCache(cache=True, native=native)
2408
+
2409
+ def _getSystemStateCache(
2410
+ self, cache: bool, native: bool = False
2411
+ ) -> Configuration | pymmcore.Configuration:
2412
+ """Return the entire system state from cache, including Python devices.
2413
+
2414
+ For Python devices, returns cached values from our state cache.
2415
+ Falls back to live values if not in cache.
2416
+ """
2417
+ # Get the C++ system state cache first
2418
+ if cache:
2419
+ cpp_cfg: pymmcore.Configuration = super().getSystemStateCache(native=True)
2420
+ else:
2421
+ cpp_cfg = super().getSystemState(native=True)
2422
+
2423
+ # Add Python device properties from our cache
2424
+ for label in self._pydevices:
2425
+ with suppress(Exception): # Skip devices that can't be accessed
2426
+ with self._pydevices[label] as dev:
2427
+ for prop_name in dev.get_property_names():
2428
+ with suppress(Exception): # Skip properties that fail
2429
+ key = (label, prop_name)
2430
+ if cache and key in self._state_cache:
2431
+ value = self._state_cache[key]
2432
+ else:
2433
+ value = dev.get_property_value(prop_name)
2434
+ cpp_cfg.addSetting(
2435
+ pymmcore.PropertySetting(
2436
+ label,
2437
+ prop_name,
2438
+ str(value),
2439
+ dev.is_property_read_only(prop_name),
2440
+ )
2441
+ )
2442
+
2443
+ return cpp_cfg if native else Configuration.from_configuration(cpp_cfg)
2444
+
2445
+ def updateSystemStateCache(self) -> None:
2446
+ """Update the system state cache for all devices including Python devices.
2447
+
2448
+ This populates our Python-side cache with current values from all
2449
+ Python devices, then calls the C++ updateSystemStateCache.
2450
+ """
2451
+ # Update Python device properties in our cache
2452
+ for label in self._pydevices:
2453
+ with suppress(Exception): # Skip devices that can't be accessed
2454
+ with self._pydevices[label] as dev:
2455
+ for prop_name in dev.get_property_names():
2456
+ with suppress(Exception): # Skip properties that fail
2457
+ value = dev.get_property_value(prop_name)
2458
+ self._state_cache[(label, prop_name)] = value
2459
+
2460
+ # Call C++ updateSystemStateCache
2461
+ super().updateSystemStateCache()
2462
+
1666
2463
 
1667
2464
  # -------------------------------------------------------------------------------
1668
2465
 
@@ -1685,7 +2482,7 @@ def _ensure_label(
1685
2482
  return cast("str", args[0]), args[1:]
1686
2483
 
1687
2484
 
1688
- class PropertyStateCache(MutableMapping[tuple[str, str], Any]):
2485
+ class ThreadSafeConfig(MutableMapping["DevPropTuple", Any]):
1689
2486
  """A thread-safe cache for property states.
1690
2487
 
1691
2488
  Keys are tuples of (device_label, property_name), and values are the last known
@@ -1693,24 +2490,24 @@ class PropertyStateCache(MutableMapping[tuple[str, str], Any]):
1693
2490
  """
1694
2491
 
1695
2492
  def __init__(self) -> None:
1696
- self._store: dict[tuple[str, str], Any] = {}
2493
+ self._store: dict[DevPropTuple, Any] = {}
1697
2494
  self._lock = threading.Lock()
1698
2495
 
1699
- def __getitem__(self, key: tuple[str, str]) -> Any:
2496
+ def __getitem__(self, key: DevPropTuple) -> Any:
1700
2497
  with self._lock:
1701
2498
  try:
1702
2499
  return self._store[key]
1703
2500
  except KeyError: # pragma: no cover
1704
- prop, dev = key
2501
+ dev, prop = key
1705
2502
  raise KeyError(
1706
2503
  f"Property {prop!r} of device {dev!r} not found in cache"
1707
2504
  ) from None
1708
2505
 
1709
- def __setitem__(self, key: tuple[str, str], value: Any) -> None:
2506
+ def __setitem__(self, key: DevPropTuple, value: Any) -> None:
1710
2507
  with self._lock:
1711
2508
  self._store[key] = value
1712
2509
 
1713
- def __delitem__(self, key: tuple[str, str]) -> None:
2510
+ def __delitem__(self, key: DevPropTuple) -> None:
1714
2511
  with self._lock:
1715
2512
  del self._store[key]
1716
2513
 
@@ -1718,7 +2515,7 @@ class PropertyStateCache(MutableMapping[tuple[str, str], Any]):
1718
2515
  with self._lock:
1719
2516
  return key in self._store
1720
2517
 
1721
- def __iter__(self) -> Iterator[tuple[str, str]]:
2518
+ def __iter__(self) -> Iterator[DevPropTuple]:
1722
2519
  with self._lock:
1723
2520
  return iter(self._store.copy()) # Prevent modifications during iteration
1724
2521
 
@@ -1769,4 +2566,19 @@ class AcquisitionThread(threading.Thread):
1769
2566
  ) from e
1770
2567
 
1771
2568
 
1772
- # -------------------------------------------------------------------------------
2569
+ # --------- helpers -------------------------------------------------------
2570
+
2571
+
2572
+ def _values_match(current: Any, expected: Any) -> bool:
2573
+ """Compare property values, handling numeric string comparisons.
2574
+
2575
+ Unlike C++ MMCore which does strict string comparison, this performs
2576
+ numeric-aware comparison to handle cases like "50.0000" == 50.
2577
+ """
2578
+ if current == expected:
2579
+ return True
2580
+ # Try numeric comparison
2581
+ try:
2582
+ return float(current) == float(expected)
2583
+ except (ValueError, TypeError):
2584
+ return str(current) == str(expected)