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.
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/Testbase.yml +2 -2
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/compatibility.yml +3 -3
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/python-publish.yml +2 -2
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/updater.yml +1 -1
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/PKG-INFO +2 -1
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/pyproject.toml +1 -0
- pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/__init__.py +33 -0
- pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/base.py +59 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/hardware/camera_base_pylablib.py +145 -69
- pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/serial_ports.py +63 -0
- pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/visa.py +84 -0
- pymodaq_plugins_utils-5.0.5/tests/hardware_test.py +249 -0
- pymodaq_plugins_utils-5.0.3/src/pymodaq_plugins_utils/resources/__init__.py +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.gitattributes +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/Test.yml +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.gitignore +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/LICENSE +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/README.rst +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/hatch_build.py +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/icon.ico +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/__init__.py +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/app/__init__.py +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/exporters/__init__.py +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/extensions/__init__.py +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/extensions/custom_extension_template.py +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/models/__init__.py +0 -0
- {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
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/resources/config_template.toml +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/scanners/__init__.py +0 -0
- {pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/utils.py +0 -0
- {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@
|
|
21
|
+
uses: actions/checkout@v6.0.3
|
|
22
22
|
- name: Install dependencies
|
|
23
|
-
uses: actions/setup-python@v6.
|
|
23
|
+
uses: actions/setup-python@v6.2.0
|
|
24
24
|
with:
|
|
25
25
|
python-version: ${{ inputs.python }}
|
|
26
26
|
- name: Install package
|
{pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/compatibility.yml
RENAMED
|
@@ -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@
|
|
39
|
+
uses: actions/checkout@v6.0.3
|
|
40
40
|
|
|
41
41
|
- name: Set up Python ${{ matrix.python-version }}
|
|
42
|
-
uses: actions/setup-python@v6.
|
|
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@
|
|
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'
|
{pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/.github/workflows/python-publish.yml
RENAMED
|
@@ -13,9 +13,9 @@ jobs:
|
|
|
13
13
|
runs-on: ubuntu-latest
|
|
14
14
|
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
16
|
+
- uses: actions/checkout@v6.0.3
|
|
17
17
|
- name: Set up Python
|
|
18
|
-
uses: actions/setup-python@v6.
|
|
18
|
+
uses: actions/setup-python@v6.2.0
|
|
19
19
|
with:
|
|
20
20
|
python-version: '3.13'
|
|
21
21
|
- name: Install dependencies
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymodaq_plugins_utils
|
|
3
|
-
Version: 5.0.
|
|
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
|
|
|
@@ -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': '
|
|
21
|
-
|
|
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(
|
|
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
|
-
|
|
204
|
+
elif param.name() == "fps_on":
|
|
139
205
|
self.settings.child('timing_opts', 'fps').setOpts(visible=param.value())
|
|
140
206
|
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
self.
|
|
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:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/utils.py
RENAMED
|
File without changes
|
{pymodaq_plugins_utils-5.0.3 → pymodaq_plugins_utils-5.0.5}/tests/test_plugin_package_structure.py
RENAMED
|
File without changes
|