pymodaq_plugins_utils 5.0.3__tar.gz → 5.0.5__tar.gz

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 (31) hide show
  1. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/Testbase.yml +2 -2
  2. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/compatibility.yml +3 -3
  3. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/python-publish.yml +2 -2
  4. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/updater.yml +1 -1
  5. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/PKG-INFO +2 -1
  6. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/pyproject.toml +1 -0
  7. pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/__init__.py +33 -0
  8. pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/base.py +59 -0
  9. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/hardware/camera_base_pylablib.py +145 -69
  10. pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/serial_ports.py +63 -0
  11. pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/visa.py +84 -0
  12. pymodaq_plugins_utils-5.0.5/tests/hardware_test.py +249 -0
  13. pymodaq_plugins_utils-5.0.3/src/pymodaq_plugins_utils/resources/__init__.py +0 -0
  14. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.gitattributes +0 -0
  15. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/Test.yml +0 -0
  16. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.gitignore +0 -0
  17. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/LICENSE +0 -0
  18. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/README.rst +0 -0
  19. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/hatch_build.py +0 -0
  20. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/icon.ico +0 -0
  21. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/__init__.py +0 -0
  22. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/app/__init__.py +0 -0
  23. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/exporters/__init__.py +0 -0
  24. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/extensions/__init__.py +0 -0
  25. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/extensions/custom_extension_template.py +0 -0
  26. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/models/__init__.py +0 -0
  27. {pymodaq_plugins_utils-5.0.3/src/pymodaq_plugins_utils/hardware → pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/resources}/__init__.py +0 -0
  28. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/resources/config_template.toml +0 -0
  29. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/scanners/__init__.py +0 -0
  30. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/utils.py +0 -0
  31. {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/tests/test_plugin_package_structure.py +0 -0
@@ -18,9 +18,9 @@ jobs:
18
18
  QT_DEBUG_PLUGINS: 1
19
19
  steps:
20
20
  - name: Set up Python ${{ inputs.python }}
21
- uses: actions/checkout@v5.0.0
21
+ uses: actions/checkout@v6.0.3
22
22
  - name: Install dependencies
23
- uses: actions/setup-python@v6.0.0
23
+ uses: actions/setup-python@v6.2.0
24
24
  with:
25
25
  python-version: ${{ inputs.python }}
26
26
  - name: Install package
@@ -36,10 +36,10 @@ jobs:
36
36
  echo "plugin_name=$(echo '${{ github.repository }}' | cut -d'/' -f2)" >> $GITHUB_ENV
37
37
 
38
38
  - name: Checkout the repo
39
- uses: actions/checkout@v5.0.0
39
+ uses: actions/checkout@v6.0.3
40
40
 
41
41
  - name: Set up Python ${{ matrix.python-version }}
42
- uses: actions/setup-python@v6.0.0
42
+ uses: actions/setup-python@v6.2.0
43
43
  with:
44
44
  python-version: ${{ matrix.python-version }}
45
45
 
@@ -69,7 +69,7 @@ jobs:
69
69
 
70
70
  - name: Upload compatibility report
71
71
  if: failure()
72
- uses: actions/upload-artifact@v4.6.2
72
+ uses: actions/upload-artifact@v7.0.1
73
73
  with:
74
74
  name:
75
75
  path: 'import_report_tests_${{ env.plugin_name }}_None.txt'
@@ -13,9 +13,9 @@ jobs:
13
13
  runs-on: ubuntu-latest
14
14
 
15
15
  steps:
16
- - uses: actions/checkout@v5.0.0
16
+ - uses: actions/checkout@v6.0.3
17
17
  - name: Set up Python
18
- uses: actions/setup-python@v6.0.0
18
+ uses: actions/setup-python@v6.2.0
19
19
  with:
20
20
  python-version: '3.13'
21
21
  - name: Install dependencies
@@ -12,7 +12,7 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@v5.0.0
15
+ - uses: actions/checkout@v6.0.3
16
16
  with:
17
17
  # [Required] Access token with `workflow` scope.
18
18
  token: ${{ secrets.WORKFLOW_SECRET }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymodaq_plugins_utils
3
- Version: 5.0.3
3
+ Version: 5.0.5
4
4
  Summary: Set of utility methods and classes to interact with instruments
5
5
  Project-URL: Homepage, https://pymodaq.cnrs.fr
6
6
  Project-URL: Documentation , https://pymodaq.cnrs.fr
@@ -45,6 +45,7 @@ Classifier: Topic :: Software Development :: User Interfaces
45
45
  Requires-Python: >=3.8
46
46
  Requires-Dist: pymodaq>=5.0.0
47
47
  Provides-Extra: serial
48
+ Requires-Dist: pyserial; extra == 'serial'
48
49
  Requires-Dist: pyvisa; extra == 'serial'
49
50
  Description-Content-Type: text/x-rst
50
51
 
@@ -11,6 +11,7 @@ package-url = 'https://github.com/PyMoDAQ/pymodaq_plugins_utils'
11
11
  [project.optional-dependencies]
12
12
  serial = [
13
13
  "pyvisa",
14
+ "pyserial",
14
15
  ]
15
16
 
16
17
  [project]
@@ -0,0 +1,33 @@
1
+ """Hardware discovery caches for PyMoDAQ plugins.
2
+
3
+ Each backend (:mod:`~pymodaq_utils.hardware.visa`,
4
+ :mod:`~pymodaq_utils.hardware.serial_ports`) queries the OS exactly once per
5
+ process. Subsequent calls reuse the cached result, so plugin startup cost is
6
+ paid at most once regardless of how many plugins share the same backend.
7
+
8
+ Quick reference::
9
+
10
+ # VISA-based plugin (Newport, Thorlabs, PI, ...)
11
+ from pymodaq_utils.hardware.visa import list_serial_resources
12
+ ports = list_serial_resources()
13
+
14
+ # pyserial-based plugin (Arduino, Ocean Optics, ...)
15
+ from pymodaq_utils.hardware.serial_ports import list_resources
16
+ ports = list_resources()
17
+
18
+ # After hot-plugging a device
19
+ from pymodaq_utils.hardware import invalidate_all_caches
20
+ invalidate_all_caches()
21
+ """
22
+ from .visa import invalidate_cache as _invalidate_visa
23
+ from .serial_ports import invalidate_cache as _invalidate_serial
24
+
25
+
26
+ def invalidate_all_caches() -> None:
27
+ """Clear both the VISA and serial discovery caches.
28
+
29
+ Call this after hot-plugging a device so the next call to any
30
+ ``list_*`` function re-discovers the current set of instruments.
31
+ """
32
+ _invalidate_visa()
33
+ _invalidate_serial()
@@ -0,0 +1,59 @@
1
+
2
+ class HardwareCache:
3
+ """Base class for process-lifetime hardware discovery caches.
4
+
5
+ Each subclass calls its backend (pyvisa, pyserial, …) exactly once per
6
+ process. The result is stored as a class variable and reused by every
7
+ caller, regardless of which plugin package triggered the first call.
8
+
9
+ Subclasses must override :meth:`_fetch` and :meth:`list_resources`.
10
+ Call :meth:`invalidate_cache` to force re-discovery, for example after
11
+ hot-plugging a device.
12
+
13
+ Example — defining a new backend::
14
+
15
+ class MyCache(HardwareCache):
16
+ _cache = None
17
+
18
+ @classmethod
19
+ def _fetch(cls):
20
+ return some_expensive_os_call()
21
+
22
+ @classmethod
23
+ def list_resources(cls) -> list[str]:
24
+ return [item.id for item in cls._get_cache()]
25
+ """
26
+
27
+ _cache = None
28
+
29
+ @classmethod
30
+ def _fetch(cls):
31
+ """Perform the actual hardware discovery.
32
+
33
+ Called at most once per process. Must return a value that can be
34
+ stored and reused (list, dict, …). Should catch all exceptions and
35
+ return an empty container so that callers never need to guard against
36
+ missing backends.
37
+ """
38
+ raise NotImplementedError
39
+
40
+ @classmethod
41
+ def _get_cache(cls):
42
+ """Return the cached discovery result, populating it on first call."""
43
+ if cls._cache is None:
44
+ cls._cache = cls._fetch()
45
+ return cls._cache
46
+
47
+ @classmethod
48
+ def invalidate_cache(cls) -> None:
49
+ """Clear the cache so the next call to any list_* method re-discovers.
50
+
51
+ Use this after hot-plugging a device or when the set of available
52
+ instruments may have changed since process startup.
53
+ """
54
+ cls._cache = None
55
+
56
+ @classmethod
57
+ def list_resources(cls) -> list[str]:
58
+ """Return a list of connectable resource strings for this backend."""
59
+ raise NotImplementedError
@@ -1,4 +1,9 @@
1
+ import dataclasses
2
+ from typing import Type
3
+
1
4
  import cv2
5
+
6
+ from pymodaq_data import DataToExport
2
7
  from pymodaq_utils.logger import set_logger, get_module_name
3
8
  from pymodaq_utils.utils import ThreadCommand
4
9
  from pymodaq_gui.parameter import Parameter
@@ -10,15 +15,20 @@ except ImportError:
10
15
  from pymodaq.utils.data import DataFromPlugins, Axis
11
16
  from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main
12
17
 
18
+ from pylablib.devices.interface.camera import trim_frames
19
+
13
20
  from qtpy import QtWidgets, QtCore
14
21
  import numpy as np
15
22
  from time import perf_counter
16
23
 
17
24
 
25
+ logger = set_logger(get_module_name(__file__))
26
+
27
+
18
28
  cam_params = [
19
29
  {'title': 'Camera name:', 'name': 'camera_name', 'type': 'str', 'value': '', 'readonly': True},
20
- {'title': 'Sensor type:', 'name': 'sensor', 'type': 'list', 'limits': ['Monochrome', 'Bayer']},
21
- {'title': 'Ouput Color:', 'name': 'output_color', 'type': 'list', 'limits': ['RGB', 'MonoChrome']},
30
+ {'title': 'Color Conversion:', 'name': 'color_conversion', 'type': 'list',
31
+ 'limits': ['None', 'RGB2GRAY', 'BAYER_BG2RGB', 'BAYER_BG2GRAY']},
22
32
  {'title': 'ROI', 'name': 'roi', 'type': 'group', 'children': [
23
33
  {'title': 'Update ROI from Viewer', 'name': 'update_roi', 'type': 'led', 'value': False},
24
34
  {'title': 'Apply ROI', 'name': 'apply_roi', 'type': 'led', 'value': False},
@@ -42,6 +52,60 @@ cam_params = [
42
52
  ]
43
53
 
44
54
 
55
+ @dataclasses.dataclass
56
+ class Grab:
57
+ do_acquisition: bool = True
58
+ snap: bool = False
59
+ since: str = 'now'
60
+ nframes: int = 1
61
+ n_average: int = 1
62
+
63
+
64
+ class CameraCallback(QtCore.QObject):
65
+ """Callback object """
66
+ data_sig = QtCore.Signal(np.ndarray)
67
+ error = QtCore.Signal()
68
+
69
+ def __init__(self, controller):
70
+ super().__init__()
71
+ # Set the wait function
72
+ self.controller = controller
73
+ self.do_acquisition = True
74
+
75
+ def set_do_grab(self, mode: Grab):
76
+ self.do_acquisition = mode.do_acquisition
77
+ if mode.do_acquisition:
78
+ self.wait_for_acquisition(mode)
79
+
80
+ def wait_for_acquisition(self, mode: Grab):
81
+ while self.do_acquisition:
82
+ try:
83
+ ind_average = 0
84
+ while ind_average < mode.n_average:
85
+ ind_frames = 0
86
+ while ind_frames < mode.nframes:
87
+ self.controller.wait_for_frame(since='now')
88
+ new_frames, rng = self.controller.read_multiple_images(missing_frame='skip', return_rng=True)
89
+ if ind_average == 0 and ind_frames == 0:
90
+ shape = list(new_frames.shape[1:])
91
+ shape = [mode.n_average, mode.nframes] + shape
92
+ frames = np.zeros(shape, dtype=new_frames.dtype)
93
+ nacq = rng[1] - rng[0]
94
+ frames[ind_average, ind_frames:nacq, ...] = new_frames
95
+ ind_frames += nacq
96
+ ind_average += 1
97
+ self.data_sig.emit(frames)
98
+ QtCore.QThread.msleep(10)
99
+ except Exception as e:
100
+ logger.exception(str(e))
101
+ self.error.emit()
102
+ break
103
+ QtWidgets.QApplication.processEvents()
104
+ if not self.do_acquisition or mode.snap:
105
+ break
106
+
107
+
108
+
45
109
  class CameraBasePyLabLib(DAQ_Viewer_base):
46
110
  """
47
111
  Base implementation for Camera using pylablib framework. Works for TSI and uc480 thorlabs camera and rpobaly others
@@ -52,13 +116,15 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
52
116
 
53
117
  params = comon_parameters + serial_params + cam_params
54
118
 
55
- callback_signal = QtCore.Signal(bool)
119
+ callback_signal = QtCore.Signal(Grab)
56
120
  live_mode_available = True
57
-
121
+ hardware_averaging = True
58
122
 
59
123
  def ini_attributes(self):
60
124
  self.controller = None
61
125
  self.callback_thread: QtCore.QThread = None
126
+ self.is_live: bool = False
127
+ self.Naverage: int = 1
62
128
 
63
129
  self.x_axis: Axis = None
64
130
  self.y_axis: Axis = None
@@ -135,16 +201,16 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
135
201
  if param.name() == "exposure_time":
136
202
  self.controller.set_exposure(param.value()/1000)
137
203
 
138
- if param.name() == "fps_on":
204
+ elif param.name() == "fps_on":
139
205
  self.settings.child('timing_opts', 'fps').setOpts(visible=param.value())
140
206
 
141
- if param.name() == "apply_roi":
207
+ elif param.name() == "apply_roi":
142
208
  if param.value(): # Switching on ROI
143
209
  self.apply_roi()
144
210
  else:
145
211
  self.clear_roi()
146
212
 
147
- if param.name() in ['x_binning', 'y_binning']:
213
+ elif param.name() in ['x_binning', 'y_binning']:
148
214
  # We handle ROI and binning separately for clarity
149
215
  (x0, w, y0, h, *_) = self.controller.get_roi() # Get current ROI
150
216
  xbin = self.settings['roi', 'x_binning']
@@ -152,14 +218,11 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
152
218
  new_roi = (x0, w, xbin, y0, h, ybin)
153
219
  self.update_rois(new_roi)
154
220
 
155
- if param.name() == "clear_roi":
221
+ elif param.name() == "clear_roi":
156
222
  if param.value(): # Switching on ROI
157
223
  self.clear_roi()
158
224
  param.setValue(False)
159
225
 
160
- if param.name() == 'sensor':
161
- self.get_set_color()
162
-
163
226
  def ini_detector_custom(self, controller=None):
164
227
  raise NotImplementedError
165
228
 
@@ -178,12 +241,13 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
178
241
  initialized: bool
179
242
  False if initialization failed otherwise True
180
243
  """
244
+
181
245
  self.ini_detector_custom(controller)
182
246
 
183
247
  self.get_device_info()
184
- self.get_set_color()
185
248
  self.get_set_main_parameters()
186
249
  self.setup_callback_thread()
250
+ self.controller.set_frame_format("array")
187
251
 
188
252
  info = "Initialized camera"
189
253
  initialized = True
@@ -199,13 +263,6 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
199
263
  elif hasattr(device_info, 'model'):
200
264
  self.settings.child('camera_name').setValue(device_info.model)
201
265
 
202
- def get_set_color(self):
203
- if 'monochrome' in self.settings['sensor'].lower():
204
- self.settings.child('output_color').setValue('MonoChrome')
205
- self.settings.child('output_color').setOpts(visible=False)
206
- else:
207
- self.settings.child('output_color').setOpts(visible=True)
208
-
209
266
  def get_set_main_parameters(self):
210
267
  # Set exposure time
211
268
  self.controller.set_exposure(self.settings['timing_opts', 'exposure_time']/1000)
@@ -227,18 +284,28 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
227
284
  self.settings.child('roi', 'roi_slices').setValue(str(slices))
228
285
  self.compute_axes()
229
286
 
287
+ @property
288
+ def callback(self) -> Type[CameraCallback]:
289
+ """ Return the class handling the wait for acquisition and signal emission
290
+
291
+ Should be reimplement as well as CameraCallback if needed
292
+ """
293
+ return CameraCallback
294
+
230
295
  def setup_callback_thread(self):
231
296
  # Way to define a wait function with arguments
232
297
  wait_func = lambda: self.controller.wait_for_frame(since=self.settings['buffer', 'mode'],
233
298
  nframes=1, timeout=20.0)
234
- callback = CameraCallback(wait_func)
299
+ callback = CameraCallback(self.controller)
235
300
  self.settings.child('buffer', 'mode').setReadonly(True)
236
301
 
237
302
 
238
303
  self.callback_thread = QtCore.QThread() # creation of a Qt5 thread
239
304
  callback.moveToThread(self.callback_thread) # callback object will live within this thread
305
+
240
306
  callback.data_sig.connect(
241
307
  self.emit_data) # when the wait for acquisition returns (with data taken), emit_data will be fired
308
+ callback.error.connect(self.handle_error)
242
309
 
243
310
  self.callback_signal.connect(callback.set_do_grab)
244
311
  self.callback_thread.callback = callback
@@ -246,6 +313,8 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
246
313
 
247
314
  self._prepare_view()
248
315
 
316
+ def handle_error(self):
317
+ self.stop()
249
318
 
250
319
  def _prepare_view(self):
251
320
  """Preparing a data viewer by emitting temporary data. Typically, needs to be called whenever the
@@ -280,14 +349,20 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
280
349
  """
281
350
  try:
282
351
  # Warning, acquisition_in_progress returns 1,0 and not a real bool
283
- if not kwargs.get('live', False):
284
- self.emit_data(self.controller.snap())
285
- else:
286
- if not self.controller.acquisition_in_progress():
287
- self.controller.clear_acquisition()
288
- self.controller.start_acquisition(nframes=self.settings['buffer', 'size'])
289
- #Then start the acquisition
290
- self.callback_signal.emit(True) # will trigger the wait for acquisition
352
+ self.is_live = kwargs.get('live', False)
353
+ self.Naverage = Naverage
354
+
355
+ self.n_frames = 1
356
+
357
+ if not self.controller.acquisition_in_progress():
358
+ self.controller.clear_acquisition()
359
+ self.controller.start_acquisition(nframes=self.n_frames)
360
+ #Then start the acquisition
361
+ self.callback_signal.emit(Grab(do_acquisition=True,
362
+ snap=not self.is_live,
363
+ n_average=Naverage,
364
+ nframes=self.n_frames,
365
+ since=self.settings['buffer', 'mode']))
291
366
 
292
367
  except Exception as e:
293
368
  self.emit_status(ThreadCommand('Update_Status', [str(e), "log"]))
@@ -309,21 +384,48 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
309
384
  if frame is None:
310
385
  frame = self.controller.read_newest_image()
311
386
  # Emit the frame.
312
- if frame is not None: # happens for last frame when stopping camera
313
- if self.settings['output_color'] == 'RGB':
314
- rgb_image = cv2.cvtColor(frame, cv2.COLOR_BAYER_BG2RGB)
315
- data_arrays = [np.atleast_1d(rgb_image[..., ind]) for ind in range(3)]
387
+ if frame is not None:
388
+ conversion_str = self.settings['color_conversion']
389
+ if conversion_str != "None":
390
+ for ind_average in range(frame.shape[0]):
391
+ for ind_frame in range(frame.shape[1]):
392
+ if ind_frame == 0 and ind_average == 0:
393
+ new_frame = cv2.cvtColor(frame[ind_average, ind_frame, ...],
394
+ getattr(cv2, f'COLOR_{conversion_str}'))
395
+ shape = [frame.shape[0], frame.shape[1]] + list(new_frame.shape)
396
+ out_frames = np.zeros(shape, dtype=new_frame.dtype)
397
+ out_frames[ind_average, ind_frame, ...] = new_frame
398
+ else:
399
+ cv2.cvtColor(frame[ind_average, ind_frame, ...],
400
+ getattr(cv2, f'COLOR_{conversion_str}'),
401
+ out_frames[ind_average, ind_frame, ...])
402
+ else:
403
+ out_frames = frame
404
+ if self.Naverage > 1:
405
+ out_frames = np.sum(out_frames, axis=0) / self.Naverage
406
+ else:
407
+ out_frames = out_frames[0, ...]
408
+
409
+ if self.n_frames > 1:
410
+ pass
411
+ #todo handle chunks of frames in ND data
412
+ else:
413
+ out_frames = out_frames[0, ...]
414
+
415
+ if out_frames.shape[-1] == 3:
416
+ data_arrays = [np.atleast_1d(out_frames[..., ind]) for ind in range(3)]
417
+ labels = ['Red', 'Green', 'Blue']
316
418
  else:
317
- if 'monochrome' in self.settings['sensor'].lower():
318
- data_arrays = [np.atleast_1d(frame)]
319
- else:
320
- data_arrays = [np.atleast_1d(cv2.cvtColor(frame, cv2.COLOR_BAYER_BG2GRAY))]
321
-
322
- self.data_grabed_signal.emit([DataFromPlugins(name='Thorlabs Camera',
323
- data=data_arrays,
324
- dim=self.data_shape,
325
- labels=[f'ThorCam_{self.data_shape}'],
326
- axes=[self.x_axis, self.y_axis])])
419
+ labels = ['Intensity']
420
+ data_arrays = [out_frames]
421
+
422
+ self.dte_signal.emit(
423
+ DataToExport('Camera',
424
+ data=[DataFromPlugins(name='Camera',
425
+ data=data_arrays,
426
+ dim=self.data_shape,
427
+ labels=labels,
428
+ axes=[self.y_axis, self.x_axis])]))
327
429
  if self.settings.child('timing_opts', 'fps_on').value():
328
430
  self.update_fps()
329
431
 
@@ -367,37 +469,11 @@ class CameraBasePyLabLib(DAQ_Viewer_base):
367
469
 
368
470
  def stop(self):
369
471
  """Stop the acquisition."""
370
- self.callback_signal.emit(False)
472
+ self.callback_signal.emit(Grab(do_acquisition=False))
371
473
  QtWidgets.QApplication.processEvents()
372
-
373
474
  self.controller.clear_acquisition()
374
475
  return ''
375
476
 
376
477
 
377
- class CameraCallback(QtCore.QObject):
378
- """Callback object """
379
- data_sig = QtCore.Signal()
380
-
381
- def __init__(self, wait_fn):
382
- super().__init__()
383
- # Set the wait function
384
- self.wait_fn = wait_fn
385
- self.do_grab = True
386
-
387
- def set_do_grab(self, do_grab=True):
388
- self.do_grab = do_grab
389
- if do_grab:
390
- self.wait_for_acquisition()
391
-
392
- def wait_for_acquisition(self):
393
- while self.do_grab:
394
- try:
395
- new_data = self.wait_fn()
396
- if new_data is not False: # will be returned if the main thread called CancelWait
397
- self.data_sig.emit()
398
- except Exception as e:
399
- pass
400
- QtWidgets.QApplication.processEvents()
401
-
402
478
 
403
479
 
@@ -0,0 +1,63 @@
1
+ """pyserial hardware discovery cache.
2
+
3
+ Wraps :mod:`serial.tools.list_ports` to enumerate available serial ports
4
+ exactly once per process. ``pyserial`` is an optional dependency: if it is
5
+ not installed, all functions return empty lists and a warning is logged.
6
+
7
+ Typical usage in a plugin::
8
+
9
+ from pymodaq_utils.hardware.serial_ports import list_resources
10
+
11
+ ports = list_resources() # e.g. ['/dev/ttyUSB0', 'COM3']
12
+
13
+ After hot-plugging a device, refresh the cache with::
14
+
15
+ from pymodaq_utils.hardware.serial_ports import invalidate_cache
16
+ invalidate_cache()
17
+ """
18
+ import pymodaq_utils.logger as logger_module
19
+ from .base import HardwareCache
20
+
21
+ logger = logger_module.set_logger(logger_module.get_module_name(__file__))
22
+
23
+
24
+ class SerialPortsCache(HardwareCache):
25
+ _cache = None
26
+
27
+ @classmethod
28
+ def _fetch(cls) -> list:
29
+ try:
30
+ from serial.tools.list_ports import comports
31
+ return list(comports())
32
+ except ImportError:
33
+ logger.warning('pyserial is not installed — serial port discovery unavailable. '
34
+ 'Install it with: pip install pyserial')
35
+ return []
36
+ except Exception as e:
37
+ logger.warning(f'Serial port discovery failed: {e}')
38
+ return []
39
+
40
+ @classmethod
41
+ def list_resources(cls) -> list[str]:
42
+ """Serial port device strings (e.g. '/dev/ttyUSB0', 'COM3')."""
43
+ return [p.device for p in cls._get_cache()]
44
+
45
+ @classmethod
46
+ def list_port_descriptions(cls) -> list[str]:
47
+ """Human-readable descriptions for each serial port."""
48
+ return [p.description for p in cls._get_cache()]
49
+
50
+
51
+ def list_resources() -> list[str]:
52
+ """Serial port device strings (e.g. '/dev/ttyUSB0', 'COM3')."""
53
+ return SerialPortsCache.list_resources()
54
+
55
+
56
+ def list_port_descriptions() -> list[str]:
57
+ """Human-readable descriptions for each serial port."""
58
+ return SerialPortsCache.list_port_descriptions()
59
+
60
+
61
+ def invalidate_cache() -> None:
62
+ """Clear the serial port cache so the next call re-discovers."""
63
+ SerialPortsCache.invalidate_cache()
@@ -0,0 +1,84 @@
1
+ """VISA hardware discovery cache.
2
+
3
+ Wraps :mod:`pyvisa` to enumerate available VISA resources exactly once per
4
+ process. ``pyvisa`` is an optional dependency: if it is not installed, or no
5
+ VISA backend is found, all functions return empty lists and a warning is logged.
6
+
7
+ Typical usage in a plugin::
8
+
9
+ from pymodaq_utils.hardware.visa import list_serial_resources
10
+
11
+ ports = list_serial_resources() # e.g. ['ASRL/dev/ttyUSB0::INSTR']
12
+
13
+ After hot-plugging a device, refresh the cache with::
14
+
15
+ from pymodaq_utils.hardware.visa import invalidate_cache
16
+ invalidate_cache()
17
+ """
18
+ import pymodaq_utils.logger as logger_module
19
+ from .base import HardwareCache
20
+
21
+ logger = logger_module.set_logger(logger_module.get_module_name(__file__))
22
+
23
+
24
+ class VisaCache(HardwareCache):
25
+ _cache = None
26
+
27
+ @classmethod
28
+ def _fetch(cls) -> dict:
29
+ try:
30
+ import pyvisa
31
+ rm = pyvisa.ResourceManager()
32
+ info = dict(rm.list_resources_info())
33
+ rm.close()
34
+ return info
35
+ except ImportError:
36
+ logger.warning('pyvisa is not installed — VISA resource discovery unavailable. '
37
+ 'Install it with: pip install pyvisa pyvisa-py')
38
+ return {}
39
+ except Exception as e:
40
+ logger.warning(f'VISA resource discovery failed: {e}')
41
+ return {}
42
+
43
+ @classmethod
44
+ def list_resources(cls) -> list[str]:
45
+ """All available VISA resource strings (e.g. 'GPIB0::5::INSTR')."""
46
+ return list(cls._get_cache().keys())
47
+
48
+ @classmethod
49
+ def list_serial_resources(cls) -> list[str]:
50
+ """ASRL (serial-over-VISA) resource strings only.
51
+
52
+ Linux: 'ASRL/dev/ttyUSB0::INSTR'
53
+ Windows: 'ASRL3::INSTR'
54
+ """
55
+ return [r for r in cls._get_cache() if r.startswith('ASRL')]
56
+
57
+ @classmethod
58
+ def list_resource_aliases(cls) -> list[str]:
59
+ """Human-readable aliases where available (e.g. 'COM3' on Windows)."""
60
+ return [i.alias for i in cls._get_cache().values() if i.alias]
61
+
62
+
63
+ def list_resources() -> list[str]:
64
+ """All available VISA resource strings (e.g. 'GPIB0::5::INSTR', 'TCPIP0::...')."""
65
+ return VisaCache.list_resources()
66
+
67
+
68
+ def list_serial_resources() -> list[str]:
69
+ """ASRL (serial-over-VISA) resource strings only.
70
+
71
+ Linux: ``'ASRL/dev/ttyUSB0::INSTR'``
72
+ Windows: ``'ASRL3::INSTR'``
73
+ """
74
+ return VisaCache.list_serial_resources()
75
+
76
+
77
+ def list_resource_aliases() -> list[str]:
78
+ """Human-readable aliases where available (e.g. ``'COM3'`` on Windows)."""
79
+ return VisaCache.list_resource_aliases()
80
+
81
+
82
+ def invalidate_cache() -> None:
83
+ """Clear the VISA resource cache so the next call re-discovers."""
84
+ VisaCache.invalidate_cache()
@@ -0,0 +1,249 @@
1
+ import pytest
2
+
3
+ from pymodaq_plugins_utils.hardware.base import HardwareCache
4
+ from pymodaq_plugins_utils.hardware import visa, serial_ports, invalidate_all_caches
5
+
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Helpers
9
+ # ---------------------------------------------------------------------------
10
+
11
+ class _CountingCache(HardwareCache):
12
+ """Minimal concrete subclass used to test base-class caching logic."""
13
+ _cache = None
14
+ fetch_count = 0
15
+
16
+ @classmethod
17
+ def _fetch(cls):
18
+ cls.fetch_count += 1
19
+ return ['resource_a', 'resource_b']
20
+
21
+ @classmethod
22
+ def list_resources(cls):
23
+ return list(cls._get_cache())
24
+
25
+ @classmethod
26
+ def reset(cls):
27
+ cls._cache = None
28
+ cls.fetch_count = 0
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # HardwareCache base behaviour
33
+ # ---------------------------------------------------------------------------
34
+
35
+ class TestHardwareCacheBase:
36
+
37
+ def setup_method(self):
38
+ _CountingCache.reset()
39
+
40
+ def test_fetch_called_once(self):
41
+ # Core guarantee: no matter how many times list_resources() is called,
42
+ # the expensive OS-level _fetch() runs exactly once per process lifetime.
43
+ _CountingCache.list_resources()
44
+ _CountingCache.list_resources()
45
+ assert _CountingCache.fetch_count == 1
46
+
47
+ def test_returns_correct_data(self):
48
+ assert _CountingCache.list_resources() == ['resource_a', 'resource_b']
49
+
50
+ def test_invalidate_triggers_refetch(self):
51
+ # After invalidation the cache is empty, so the next call must
52
+ # re-run _fetch() — this is the hot-plug refresh path.
53
+ _CountingCache.list_resources()
54
+ _CountingCache.invalidate_cache()
55
+ _CountingCache.list_resources()
56
+ assert _CountingCache.fetch_count == 2
57
+
58
+ def test_cache_is_none_after_invalidate(self):
59
+ # Validates the internal reset so _get_cache() knows to call _fetch()
60
+ # again on the next access (tested separately in test_invalidate_triggers_refetch).
61
+ _CountingCache.list_resources()
62
+ _CountingCache.invalidate_cache()
63
+ assert _CountingCache._cache is None
64
+
65
+ def test_subclasses_have_independent_caches(self):
66
+ # Each subclass stores its result in its own _cache class variable.
67
+ # Invalidating one must not affect the other — otherwise a Newport
68
+ # plugin resetting its cache would silently clear an Arduino plugin's cache.
69
+ class CacheA(HardwareCache):
70
+ _cache = None
71
+
72
+ @classmethod
73
+ def _fetch(cls):
74
+ return ['a']
75
+
76
+ @classmethod
77
+ def list_resources(cls):
78
+ return list(cls._get_cache())
79
+
80
+ class CacheB(HardwareCache):
81
+ _cache = None
82
+
83
+ @classmethod
84
+ def _fetch(cls):
85
+ return ['b']
86
+
87
+ @classmethod
88
+ def list_resources(cls):
89
+ return list(cls._get_cache())
90
+
91
+ assert CacheA.list_resources() == ['a']
92
+ assert CacheB.list_resources() == ['b']
93
+ CacheA.invalidate_cache()
94
+ assert CacheA._cache is None
95
+ assert CacheB._cache is not None # CacheB untouched
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # visa module
100
+ # ---------------------------------------------------------------------------
101
+
102
+ class TestVisaModule:
103
+
104
+ def setup_method(self):
105
+ # Start each test with a clean cache so tests are isolated.
106
+ visa.VisaCache.invalidate_cache()
107
+
108
+ def test_list_resources_returns_list(self):
109
+ # The module must never raise, even when pyvisa is not installed.
110
+ assert isinstance(visa.list_resources(), list)
111
+
112
+ def test_warns_when_pyvisa_absent(self, monkeypatch):
113
+ # When pyvisa is missing the user should get an explicit warning,
114
+ # not a silent empty list that looks like "no devices connected".
115
+ import builtins
116
+ real_import = builtins.__import__
117
+
118
+ def block_pyvisa(name, *args, **kwargs):
119
+ if name == 'pyvisa':
120
+ raise ImportError('pyvisa blocked for test')
121
+ return real_import(name, *args, **kwargs)
122
+
123
+ warned = []
124
+ monkeypatch.setattr(builtins, '__import__', block_pyvisa)
125
+ monkeypatch.setattr(visa.logger, 'warning', lambda msg: warned.append(msg))
126
+ visa.VisaCache.invalidate_cache()
127
+
128
+ visa.list_resources()
129
+ assert any('pyvisa' in msg for msg in warned)
130
+
131
+ def test_list_serial_resources_returns_list(self):
132
+ assert isinstance(visa.list_serial_resources(), list)
133
+
134
+ def test_list_resource_aliases_returns_list(self):
135
+ assert isinstance(visa.list_resource_aliases(), list)
136
+
137
+ def test_serial_resources_are_subset_of_all(self):
138
+ # list_serial_resources() is a filtered view of list_resources();
139
+ # every ASRL entry must also appear in the full resource list.
140
+ # Skipped when pyvisa is absent: an empty list would make this pass
141
+ # vacuously without testing anything.
142
+ pytest.importorskip('pyvisa')
143
+ all_r = visa.list_resources()
144
+ serial_r = visa.list_serial_resources()
145
+ assert all(r in all_r for r in serial_r)
146
+
147
+ def test_serial_resources_start_with_asrl(self):
148
+ # VISA serial resources always begin with 'ASRL' by the VISA standard.
149
+ # Skipped when pyvisa is absent for the same reason as above.
150
+ pytest.importorskip('pyvisa')
151
+ for r in visa.list_serial_resources():
152
+ assert r.startswith('ASRL')
153
+
154
+ def test_fetch_called_once_across_multiple_functions(self, monkeypatch):
155
+ # Calling list_resources(), list_serial_resources(), and
156
+ # list_resource_aliases() in sequence must trigger only one backend
157
+ # query — the whole point of this module.
158
+ call_count = []
159
+ original_fetch = visa.VisaCache._fetch.__func__
160
+
161
+ @classmethod
162
+ def counting_fetch(cls):
163
+ call_count.append(1)
164
+ return original_fetch(cls)
165
+
166
+ monkeypatch.setattr(visa.VisaCache, '_fetch', counting_fetch)
167
+ visa.VisaCache.invalidate_cache()
168
+
169
+ visa.list_resources()
170
+ visa.list_serial_resources()
171
+ visa.list_resource_aliases()
172
+
173
+ assert len(call_count) == 1
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # serial_ports module
178
+ # ---------------------------------------------------------------------------
179
+
180
+ class TestSerialPortsModule:
181
+
182
+ def setup_method(self):
183
+ serial_ports.SerialPortsCache.invalidate_cache()
184
+
185
+ def test_list_resources_returns_list(self):
186
+ # The module must never raise, even when pyserial is not installed.
187
+ assert isinstance(serial_ports.list_resources(), list)
188
+
189
+ def test_warns_when_pyserial_absent(self, monkeypatch):
190
+ # Same contract as the visa equivalent: missing backend → warning, not silent empty list.
191
+ import builtins
192
+ real_import = builtins.__import__
193
+
194
+ def block_serial(name, *args, **kwargs):
195
+ if name == 'serial.tools.list_ports':
196
+ raise ImportError('pyserial blocked for test')
197
+ return real_import(name, *args, **kwargs)
198
+
199
+ warned = []
200
+ monkeypatch.setattr(builtins, '__import__', block_serial)
201
+ monkeypatch.setattr(serial_ports.logger, 'warning', lambda msg: warned.append(msg))
202
+ serial_ports.SerialPortsCache.invalidate_cache()
203
+
204
+ serial_ports.list_resources()
205
+ assert any('pyserial' in msg for msg in warned)
206
+
207
+ def test_list_port_descriptions_returns_list(self):
208
+ assert isinstance(serial_ports.list_port_descriptions(), list)
209
+
210
+ def test_resources_and_descriptions_same_length(self):
211
+ # Both lists are derived from the same cached port objects, so they
212
+ # must always be parallel (index N in one matches index N in the other).
213
+ # Skipped when pyserial is absent: both would be empty and the assertion
214
+ # passes vacuously without testing anything.
215
+ pytest.importorskip('serial')
216
+ assert len(serial_ports.list_resources()) == len(serial_ports.list_port_descriptions())
217
+
218
+ def test_fetch_called_once_across_multiple_functions(self, monkeypatch):
219
+ # Same single-fetch guarantee as the visa module.
220
+ call_count = []
221
+ original_fetch = serial_ports.SerialPortsCache._fetch.__func__
222
+
223
+ @classmethod
224
+ def counting_fetch(cls):
225
+ call_count.append(1)
226
+ return original_fetch(cls)
227
+
228
+ monkeypatch.setattr(serial_ports.SerialPortsCache, '_fetch', counting_fetch)
229
+ serial_ports.SerialPortsCache.invalidate_cache()
230
+
231
+ serial_ports.list_resources()
232
+ serial_ports.list_port_descriptions()
233
+
234
+ assert len(call_count) == 1
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Package-level helper
239
+ # ---------------------------------------------------------------------------
240
+
241
+ def test_invalidate_all_caches_clears_both():
242
+ # Populate both caches first so the assertion is meaningful.
243
+ visa.list_resources()
244
+ serial_ports.list_resources()
245
+
246
+ invalidate_all_caches()
247
+
248
+ assert visa.VisaCache._cache is None
249
+ assert serial_ports.SerialPortsCache._cache is None