From 82d43186e4b1d5d243540b2386da3eef5b5d0ff0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:36:45 +0000 Subject: [PATCH 1/5] Cookbook lot 2: vendor DLL (ctypes) and multi-axis controller A simulated vendor library that behaves like a ctypes.CDLL, the ctypes driver on it, a multi-axis actuator and a 0D detector sharing one handle, an end of move reported by a callback from another thread, load_library and real calls to the C library of the system. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- README.rst | 20 +- .../daq_move_plugins/daq_move_CookbookDll.py | 116 ++++++++ .../plugins_0D/daq_0Dviewer_CookbookDll.py | 57 ++++ .../hardware/dll_controller.py | 130 +++++++++ .../hardware/mock_dll_controller.py | 253 ++++++++++++++++++ .../hardware/native_library.py | 84 ++++++ .../hardware/system_library_demo.py | 50 ++++ tests/test_cookbook_dll.py | 226 ++++++++++++++++ 8 files changed, 932 insertions(+), 4 deletions(-) create mode 100644 src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py create mode 100644 src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py create mode 100644 src/pymodaq_plugins_mockexamples/hardware/dll_controller.py create mode 100644 src/pymodaq_plugins_mockexamples/hardware/mock_dll_controller.py create mode 100644 src/pymodaq_plugins_mockexamples/hardware/native_library.py create mode 100644 src/pymodaq_plugins_mockexamples/hardware/system_library_demo.py create mode 100644 tests/test_cookbook_dll.py diff --git a/README.rst b/README.rst index fba2dd2..51691e3 100644 --- a/README.rst +++ b/README.rst @@ -178,9 +178,10 @@ Cookbook of device patterns Short, runnable and tested plugins to copy from when writing a plugin for a real instrument. Each recipe is a pair (actuator and 0D detector) that runs without hardware: leave the port or the address to ``MOCK`` and the plugin talks -to a simulated stage with a voltmeter (``hardware/mock_serial_visa_instrument.py``). Each file starts with a header telling what to -copy, what to adapt and what to keep. The tests (``tests/test_cookbook_drivers.py``, ``tests/test_cookbook_plugins.py``) -show how to test a plugin against a simulated device with the harness of ``pymodaq.utils.plugin_testing``. +to a simulated device: a stage with a voltmeter (``hardware/mock_serial_visa_instrument.py``) or a 3-axis controller +behind a library (``hardware/mock_dll_controller.py``). Each file starts with a header telling what to +copy, what to adapt and what to keep. The tests (``tests/test_cookbook_drivers.py``, ``tests/test_cookbook_plugins.py``, +``tests/test_cookbook_dll.py``) show how to test a plugin against a simulated device with the harness of ``pymodaq.utils.plugin_testing``. Giving the same *controller ID* to an actuator and a detector of a recipe makes them share one driver: the first initialized is the master and creates it, the other is the slave and receives it. @@ -198,6 +199,16 @@ driver written directly ``daq_0Dviewer_CookbookSerial``, VISA / SCPI, driver written directly ``daq_move_CookbookVisa``, The same on a ``pyvisa`` resource: explicit terminations and ``daq_0Dviewer_CookbookVisa``, timeout, ``query`` versus ``write`` ``hardware/visa_meter.py`` +Vendor DLL, multi-axis controller, ``daq_move_CookbookDll`` (3 axes), A ``ctypes`` driver: ``argtypes``, ``restype``, opaque handle, +``ctypes`` ``daq_0Dviewer_CookbookDll``, ``byref``, a structure, error codes, a ``CFUNCTYPE`` callback + ``hardware/dll_controller.py`` kept alive. The library is simulated by an object that behaves + like a ``CDLL``. The end of a move is reported by a callback + from a thread of the library, turned into a Qt signal. Axes + share one handle (master and slaves) +Loading a native library ``hardware/native_library.py``, ``load_library``: names per platform, path from a setting or an + ``hardware/system_library_demo.py`` environment variable, an error that lists what was tried. Real + calls to the C library of the system (``strlen``, ``frexp``, + ``qsort`` with a callback) ====================================== ============================================== ================================================================ Patterns already shown by the mock instruments of this package @@ -219,7 +230,8 @@ Simulated device, no hardware every plugin of this package ``hardware/``: the plugins run on it, no hardware needed ====================================== ============================================== ================================================================ -Patterns not yet covered: an instrument driven by a vendor DLL or SDK (``ctypes`` or a Python binding). +Patterns not yet covered: an instrument driven by a Python binding of the manufacturer (it is packaged like the +drivers of ``hardware/``: a class that wraps the binding, created by ``ini_stage``). PID Models diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py new file mode 100644 index 0000000..3d89291 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py @@ -0,0 +1,116 @@ +"""Cookbook recipe 2a: multi-axis actuator on a vendor DLL (``ctypes``), with a move that ends by a callback. + +COPY this file for an actuator whose manufacturer supplies a C library (DLL, shared object) for a controller with +several axes. +ADAPT: ``params`` (what the library needs to connect), ``_axis_names`` (axis name -> the number the library uses), +``_controller_units``, the calls of the driver in ``move_abs``, ``move_rel``, ``get_actuator_value``, ``move_home`` and +``stop_motion``. +KEEP: +- the master/slave handling in ``ini_stage`` and ``close``: the first axis (master) opens the controller, the others + (slaves, same controller ID in the Dashboard) receive it; +- the callback handling. The library calls back from one of its own threads: ``_forward_event`` only emits a Qt signal, + which is delivered to ``_on_motion_event`` in the thread of the plugin. Never call ``move_done`` or touch a widget + from the library thread; +- ``user_condition_to_reach_target``: PyMoDAQ polls the position until it is within epsilon of the target, and also + waits for the end-of-move event. The homing is not polled, so ``move_done`` is called when its event arrives. + +Try it without the DLL: leave the port to ``MOCK``, the library is then the simulated one of +``hardware/mock_dll_controller.py``. See ``hardware/dll_controller.py`` for the ``ctypes`` driver and +``hardware/native_library.py`` for the loading of the library. +""" +from qtpy.QtCore import Signal, Slot + +from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, DataActuatorType, + DataActuator, main) + +from pymodaq_plugins_mockexamples.hardware.dll_controller import MotionController, open_controller +from pymodaq_plugins_mockexamples.hardware.mock_dll_controller import (EVENT_MOVE_DONE, EVENT_STOPPED, + EVENT_HOME_DONE) + + +class DAQ_Move_CookbookDll(DAQ_Move_base): + """One axis of the simulated 3-axis controller, driven through ``MotionController`` on a DLL""" + _controller_units = 'mm' + is_multiaxes = True + _axis_names = {'X': 0, 'Y': 1, 'Z': 2} # the library numbers its axes + _epsilons = 0.01 + data_actuator_type = DataActuatorType.DataActuator + params = comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilons) + [ + {'title': 'Port:', 'name': 'port', 'type': 'str', 'value': 'MOCK', + 'tip': 'Port of the controller, MOCK for the simulated library'}, + {'title': 'Library path:', 'name': 'library_path', 'type': 'browsepath', 'value': '', 'filetype': True, + 'tip': 'Full path to the DLL, empty to search it (environment variable MM_LIBRARY, system folders)'}, + ] + + motion_event = Signal(int, int) # axis, event: emitted from the thread of the library, received in this thread + + def ini_attributes(self): + self.controller: MotionController = None + self._move_finished = True + self._homing = False + self.motion_event.connect(self._on_motion_event) + + def ini_stage(self, controller=None): + if self.is_master: + self.controller = open_controller(self.settings['port'], self.settings['library_path']) + else: # a master axis shares its controller + self.controller = controller + self.controller.subscribe(self._forward_event) + info = self.controller.info() + return f'{info.name}, firmware {info.firmware[0]}.{info.firmware[1]}', True + + def close(self): + if self.controller is not None: + self.controller.unsubscribe(self._forward_event) + if self.is_master: + self.controller.close() + + def _forward_event(self, axis: int, event: int): + """Called from the thread of the library: only emit the signal""" + self.motion_event.emit(axis, event) + + @Slot(int, int) + def _on_motion_event(self, axis: int, event: int): + """Runs in the thread of the plugin""" + if axis != self.axis_value: # the controller is shared: events of the other axes come too + return + if event == EVENT_HOME_DONE and self._homing: + self._homing = False + self.move_done() + elif event in (EVENT_MOVE_DONE, EVENT_STOPPED): + self._move_finished = True + + def user_condition_to_reach_target(self) -> bool: + return self._move_finished + + def get_actuator_value(self) -> DataActuator: + position = DataActuator(data=self.controller.position(self.axis_value), units=self.axis_unit) + return self.get_position_with_scaling(position) + + def move_abs(self, value: DataActuator): + value = self.check_bound(value) + self.target_value = value + value = self.set_position_with_scaling(value) + self._move_finished = False + self.controller.move_abs(self.axis_value, value.value(self.axis_unit)) + + def move_rel(self, value: DataActuator): + value = self.check_bound(self.current_value + value) - self.current_value + self.target_value = value + self.current_value + value = self.set_position_with_scaling(self.target_value) + self._move_finished = False + self.controller.move_abs(self.axis_value, value.value(self.axis_unit)) + + def move_home(self): + self._homing = True + self.controller.home(self.axis_value) + + def stop_motion(self): + self.controller.stop(self.axis_value) + + def commit_settings(self, param): + pass + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py new file mode 100644 index 0000000..0709630 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py @@ -0,0 +1,57 @@ +"""Cookbook recipe 2b: 0D detector reading the positions of the axes of a controller driven by a vendor DLL. + +COPY this file for a detector of a controller that has several functions (here the encoders of three axes) behind one +library handle. Alone it opens its own controller (master); with the same controller ID as the actuators +``CookbookDll`` it is a slave and shares their handle: a library handle must be opened once, not once per plugin. +ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). +KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. +""" +import numpy as np + +from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main +from pymodaq.utils.data import DataFromPlugins +from pymodaq_data import DataToExport + +from pymodaq_plugins_mockexamples.hardware.dll_controller import MotionController, open_controller + + +class DAQ_0DViewer_CookbookDll(DAQ_Viewer_base): + """Positions of the three axes of the simulated controller, read through ``MotionController``""" + params = comon_parameters + [ + {'title': 'Port:', 'name': 'port', 'type': 'str', 'value': 'MOCK', + 'tip': 'Port of the controller, MOCK for the simulated library'}, + {'title': 'Library path:', 'name': 'library_path', 'type': 'browsepath', 'value': '', 'filetype': True, + 'tip': 'Full path to the DLL, empty to search it (environment variable MM_LIBRARY, system folders)'}, + ] + + def ini_attributes(self): + self.controller: MotionController = None + + def ini_detector(self, controller=None): + if self.is_master: + self.controller = open_controller(self.settings['port'], self.settings['library_path']) + else: # a master actuator or detector shares its controller + self.controller = controller + info = self.controller.info() + return f'{info.name}, firmware {info.firmware[0]}.{info.firmware[1]}', True + + def close(self): + if self.is_master and self.controller is not None: + self.controller.close() + + def grab_data(self, Naverage=1, **kwargs): + positions = [np.array([self.controller.position(axis)]) for axis in range(self.controller.n_axes)] + self.dte_signal.emit(DataToExport( + name='CookbookDll', + data=[DataFromPlugins(name='Positions', data=positions, dim='Data0D', labels=['X', 'Y', 'Z'], + units='mm')])) + + def stop(self): + pass + + def commit_settings(self, param): + pass + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_mockexamples/hardware/dll_controller.py b/src/pymodaq_plugins_mockexamples/hardware/dll_controller.py new file mode 100644 index 0000000..d04c8e1 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/hardware/dll_controller.py @@ -0,0 +1,130 @@ +"""Driver of the 3-axis motion controller of ``mock_dll_controller.py``, written like a driver for a vendor DLL. + +This is the ``ctypes`` layer that a plugin author writes (or finds written) on a manufacturer library: + +- ``_declare`` gives every function its ``argtypes`` and ``restype``: without them ``ctypes`` guesses (C ``int`` + arguments and result) and a 64 bits pointer or a ``double`` is silently corrupted; +- the library hands out an opaque *handle* (``void *``) received through ``byref`` and given back to every call; +- every function returns an integer error code, turned into an exception by ``_check`` with the message of the library; +- the library fills a structure (``MM_Info``); +- a callback made with ``CFUNCTYPE`` is registered once and kept in an attribute: if Python collects it while the + library still holds the pointer, the next call crashes the interpreter. It is called from a thread of the library, + so it only forwards the event to the listeners, which must not touch Qt widgets. + +Load the real library with ``MotionController.from_library(load_library('mm.dll'))`` (see ``native_library.py``) or +the simulated one with ``MotionController.mock()``. +""" +import ctypes +from dataclasses import dataclass +from typing import Callable + +from pymodaq_plugins_mockexamples.hardware.mock_dll_controller import (MM_CALLBACK, MM_Info, MockMotionLibrary, MM_OK, + PORT, N_AXES) + + +class MotionControllerError(RuntimeError): + """An error code returned by the library, with the message of the library""" + + +@dataclass +class ControllerInfo: + name: str + n_axes: int + firmware: tuple[int, int] + + +class MotionController: + """Handle of an opened controller and its axes (numbered from 0)""" + n_axes = N_AXES + + def __init__(self, library, port: str = PORT): + self._lib = library + self._declare() + self._handle = ctypes.c_void_p() + self._check(self._lib.mm_open(port.encode(), ctypes.byref(self._handle))) + self._listeners: list[Callable[[int, int], None]] = [] + self._callback = MM_CALLBACK(self._on_event) # kept alive: the library keeps the pointer + self._check(self._lib.mm_set_callback(self._handle, self._callback)) + + @classmethod + def mock(cls, speed: float = 25.) -> 'MotionController': + """The controller on the simulated library (no DLL needed)""" + return cls(MockMotionLibrary(speed)) + + def _declare(self): + lib = self._lib + lib.mm_open.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_void_p)] + lib.mm_close.argtypes = [ctypes.c_void_p] + lib.mm_get_info.argtypes = [ctypes.c_void_p, ctypes.POINTER(MM_Info)] + lib.mm_move_abs.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_double] + lib.mm_get_position.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_double)] + lib.mm_is_moving.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)] + lib.mm_stop.argtypes = [ctypes.c_void_p, ctypes.c_int] + lib.mm_home.argtypes = [ctypes.c_void_p, ctypes.c_int] + lib.mm_set_callback.argtypes = [ctypes.c_void_p, MM_CALLBACK] + lib.mm_error_message.argtypes = [ctypes.c_int] + lib.mm_error_message.restype = ctypes.c_char_p + for name in ('mm_open', 'mm_close', 'mm_get_info', 'mm_move_abs', 'mm_get_position', 'mm_is_moving', 'mm_stop', + 'mm_home', 'mm_set_callback'): + getattr(lib, name).restype = ctypes.c_int + + def _check(self, code: int): + if code != MM_OK: + raise MotionControllerError(f'{self._lib.mm_error_message(code).decode()} (code {code})') + + def _on_event(self, axis: int, event: int): + """Called by the library, from one of its threads""" + for listener in list(self._listeners): + listener(axis, event) + + def subscribe(self, listener: Callable[[int, int], None]): + """``listener(axis, event)`` is called, from a thread of the library, when a move ends (see the EVENT_*)""" + self._listeners.append(listener) + + def unsubscribe(self, listener: Callable[[int, int], None]): + if listener in self._listeners: + self._listeners.remove(listener) + + def info(self) -> ControllerInfo: + info = MM_Info() + self._check(self._lib.mm_get_info(self._handle, ctypes.byref(info))) + return ControllerInfo(info.name.decode(), info.n_axes, (info.firmware[0], info.firmware[1])) + + def position(self, axis: int) -> float: + position = ctypes.c_double() + self._check(self._lib.mm_get_position(self._handle, axis, ctypes.byref(position))) + return position.value + + def is_moving(self, axis: int) -> bool: + moving = ctypes.c_int() + self._check(self._lib.mm_is_moving(self._handle, axis, ctypes.byref(moving))) + return bool(moving.value) + + def move_abs(self, axis: int, position: float): + """Start the move; the library calls back when it is over""" + self._check(self._lib.mm_move_abs(self._handle, axis, position)) + + def stop(self, axis: int): + self._check(self._lib.mm_stop(self._handle, axis)) + + def home(self, axis: int): + """Start the homing; the library calls back when it is over""" + self._check(self._lib.mm_home(self._handle, axis)) + + def close(self): + if self._handle.value is not None: + self._lib.mm_close(self._handle) + self._handle = ctypes.c_void_p() + self._listeners.clear() + + +LIBRARY_NAMES = {'Windows': ['mm.dll'], 'Linux': ['libmm.so'], 'Darwin': ['libmm.dylib']} # names of the (imaginary) DLL + + +def open_controller(port: str, library_path: str = '') -> MotionController: + """The controller of the simulated library for the port ``MOCK``, else of the vendor library found by + ``load_library`` (setting of the plugin, environment variable ``MM_LIBRARY``, system folders)""" + if port == PORT: + return MotionController.mock() + from pymodaq_plugins_mockexamples.hardware.native_library import load_library + return MotionController(load_library(LIBRARY_NAMES, path=library_path or None, env_var='MM_LIBRARY'), port) diff --git a/src/pymodaq_plugins_mockexamples/hardware/mock_dll_controller.py b/src/pymodaq_plugins_mockexamples/hardware/mock_dll_controller.py new file mode 100644 index 0000000..782ad4a --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/hardware/mock_dll_controller.py @@ -0,0 +1,253 @@ +"""A simulated vendor library for a 3-axis motion controller: ``MockMotionLibrary`` behaves like a ``ctypes.CDLL``. + +Most vendor drivers are written with ``ctypes`` on a DLL (or shared object) the manufacturer supplies. Without the +hardware, and so without the DLL, this module gives the driver (``dll_controller.py``) an object that is used exactly +like the loaded library: functions are looked up by name, the driver sets their ``argtypes`` and ``restype``, passes +handles, ``byref`` arguments, a structure and a callback created with ``CFUNCTYPE``, and gets integer error codes. So +the real ``ctypes`` code of the driver runs, and the tests cover it. + +The C API that is simulated:: + + int mm_open(const char *port, void **handle); + int mm_close(void *handle); + int mm_get_info(void *handle, MM_Info *info); + int mm_move_abs(void *handle, int axis, double position); + int mm_get_position(void *handle, int axis, double *position); + int mm_is_moving(void *handle, int axis, int *moving); + int mm_stop(void *handle, int axis); + int mm_home(void *handle, int axis); + int mm_set_callback(void *handle, void (*callback)(int axis, int event)); + const char *mm_error_message(int code); + +``MM_OK`` (0) is returned on success. The callback is called from a thread of the library when a move or a homing +ends or is stopped. +""" +import ctypes +import itertools +import threading +from time import monotonic +from typing import Callable, Optional + +MM_OK = 0 +MM_ERR_PORT = 1 +MM_ERR_HANDLE = 2 +MM_ERR_AXIS = 3 +MM_ERR_RANGE = 4 +ERROR_MESSAGES = {MM_OK: 'ok', MM_ERR_PORT: 'no controller on this port', MM_ERR_HANDLE: 'invalid handle', + MM_ERR_AXIS: 'invalid axis', MM_ERR_RANGE: 'position out of range'} + +EVENT_MOVE_DONE = 1 +EVENT_STOPPED = 2 +EVENT_HOME_DONE = 3 + +N_AXES = 3 +RANGE = (-50., 50.) # mm +PORT = 'MOCK' + +MM_CALLBACK = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) + + +class MM_Info(ctypes.Structure): + """``typedef struct { char name[32]; int n_axes; int firmware[2]; } MM_Info;``""" + _fields_ = [('name', ctypes.c_char * 32), ('n_axes', ctypes.c_int), ('firmware', ctypes.c_int * 2)] + + +class MockMotionController: + """The simulated controller: three axes moving at constant speed, with a thread that reports the end of a move""" + + def __init__(self, speed: float = 25.): + self.speed = speed # mm/s + self.callback: Optional[Callable[[int, int], None]] = None + self._lock = threading.Lock() + self._start = [0.] * N_AXES + self._target = [0.] * N_AXES + self._t0 = [0.] * N_AXES + self._duration = [0.] * N_AXES + self._timers: list[Optional[threading.Timer]] = [None] * N_AXES + self._event = [EVENT_MOVE_DONE] * N_AXES + + def position(self, axis: int) -> float: + with self._lock: + return self._position(axis) + + def _position(self, axis: int) -> float: + if self._duration[axis] == 0.: + return self._target[axis] + fraction = min(1., (monotonic() - self._t0[axis]) / self._duration[axis]) + return self._start[axis] + (self._target[axis] - self._start[axis]) * fraction + + def is_moving(self, axis: int) -> bool: + with self._lock: + return self._timers[axis] is not None + + def move_to(self, axis: int, target: float, end_event: int = EVENT_MOVE_DONE): + with self._lock: + self._cancel(axis) + start = self._position(axis) + self._start[axis], self._target[axis] = start, target + self._t0[axis] = monotonic() + self._duration[axis] = abs(target - start) / self.speed + self._event[axis] = end_event + timer = threading.Timer(self._duration[axis], self._arrived, args=(axis,)) + timer.daemon = True + self._timers[axis] = timer + timer.start() + + def stop(self, axis: int): + with self._lock: + moving = self._timers[axis] is not None + self._cancel(axis) + self._start[axis] = self._target[axis] = self._position(axis) + self._duration[axis] = 0. + if moving: + self._notify(axis, EVENT_STOPPED) + + def _cancel(self, axis: int): + if self._timers[axis] is not None: + self._timers[axis].cancel() + self._timers[axis] = None + + def _arrived(self, axis: int): + """Runs in the thread of the timer, like the thread of a vendor library""" + with self._lock: + if self._timers[axis] is None: # stopped or restarted in the meantime + return + self._timers[axis] = None + self._duration[axis] = 0. + event = self._event[axis] + self._notify(axis, event) + + def _notify(self, axis: int, event: int): + if self.callback is not None: + self.callback(axis, event) + + def close(self): + for axis in range(N_AXES): + with self._lock: + self._cancel(axis) + self.callback = None + + +class _MockFunction: + """A function of the library: carries the ``argtypes`` and ``restype`` the driver sets, as a ``ctypes`` one does + + Arguments are converted as ``ctypes`` would do: Python numbers and bytes are accepted for the simple types, and + ``byref(x)`` gives access to the ``x`` object the function writes into. + """ + + def __init__(self, name: str, implementation: Callable): + self.__name__ = name + self._implementation = implementation + self.argtypes: Optional[list] = None + self.restype = ctypes.c_int + + def __call__(self, *args): + if self.argtypes is not None: + if len(args) != len(self.argtypes): + raise TypeError(f'{self.__name__} takes {len(self.argtypes)} arguments ({len(args)} given)') + args = tuple(self._convert(arg, argtype) for arg, argtype in zip(args, self.argtypes)) + result = self._implementation(*args) + if self.restype is None: + return None + if self.restype is ctypes.c_char_p: + return result + return self.restype(result).value if isinstance(result, int) else result + + @staticmethod + def _convert(arg, argtype): + if hasattr(arg, '_obj'): # a byref(...) object: the function writes in the wrapped ctypes object + return arg._obj + if isinstance(arg, ctypes._SimpleCData): + return arg.value + if isinstance(arg, argtype): # a structure, a callback of the declared type, ... + return arg + converted = argtype.from_param(arg) # what ctypes does: a TypeError for an argument of the wrong type + return getattr(converted, 'value', arg) + + +class MockMotionLibrary: + """Used instead of the loaded library: ``lib = MockMotionLibrary()``, then ``lib.mm_open(...)`` + + Unknown names raise ``AttributeError`` like a library does when a symbol is missing. + """ + + def __init__(self, speed: float = 25.): + self.speed = speed + self._controllers: dict[int, MockMotionController] = {} + self._handles = itertools.count(0x1000) + self._functions = {name: _MockFunction(name, getattr(self, f'_{name}')) for name in ( + 'mm_open', 'mm_close', 'mm_get_info', 'mm_move_abs', 'mm_get_position', 'mm_is_moving', 'mm_stop', + 'mm_home', 'mm_set_callback', 'mm_error_message')} + self._functions['mm_error_message'].restype = ctypes.c_char_p + + def __getattr__(self, name: str) -> _MockFunction: + if name.startswith('mm_') and name in self.__dict__.get('_functions', {}): + return self._functions[name] + raise AttributeError(f'undefined symbol: {name}') + + def _get(self, handle: int) -> Optional[MockMotionController]: + return self._controllers.get(handle) + + def _mm_open(self, port: bytes, handle) -> int: + if port != PORT.encode(): + return MM_ERR_PORT + number = next(self._handles) + self._controllers[number] = MockMotionController(self.speed) + handle.value = number + return MM_OK + + def _mm_close(self, handle: int) -> int: + controller = self._controllers.pop(handle, None) + if controller is None: + return MM_ERR_HANDLE + controller.close() + return MM_OK + + def _mm_get_info(self, handle: int, info: MM_Info) -> int: + if self._get(handle) is None: + return MM_ERR_HANDLE + info.name = b'MockMotion 3000' + info.n_axes = N_AXES + info.firmware[0], info.firmware[1] = 2, 7 + return MM_OK + + def _axis_call(self, handle: int, axis: int, call: Callable[[MockMotionController], None]) -> int: + controller = self._get(handle) + if controller is None: + return MM_ERR_HANDLE + if not 0 <= axis < N_AXES: + return MM_ERR_AXIS + call(controller) + return MM_OK + + def _mm_move_abs(self, handle: int, axis: int, position: float) -> int: + code = self._axis_call(handle, axis, lambda controller: None) # validate the handle and the axis + if code != MM_OK: + return code + if not RANGE[0] <= position <= RANGE[1]: + return MM_ERR_RANGE + return self._axis_call(handle, axis, lambda controller: controller.move_to(axis, position)) + + def _mm_get_position(self, handle: int, axis: int, position) -> int: + return self._axis_call(handle, axis, lambda controller: setattr(position, 'value', controller.position(axis))) + + def _mm_is_moving(self, handle: int, axis: int, moving) -> int: + return self._axis_call(handle, axis, + lambda controller: setattr(moving, 'value', int(controller.is_moving(axis)))) + + def _mm_stop(self, handle: int, axis: int) -> int: + return self._axis_call(handle, axis, lambda controller: controller.stop(axis)) + + def _mm_home(self, handle: int, axis: int) -> int: + return self._axis_call(handle, axis, lambda controller: controller.move_to(axis, 0., EVENT_HOME_DONE)) + + def _mm_set_callback(self, handle: int, callback) -> int: + controller = self._get(handle) + if controller is None: + return MM_ERR_HANDLE + controller.callback = callback # a ctypes function pointer: calling it enters Python from this thread + return MM_OK + + @staticmethod + def _mm_error_message(code: int) -> bytes: + return ERROR_MESSAGES.get(code, 'unknown error').encode() diff --git a/src/pymodaq_plugins_mockexamples/hardware/native_library.py b/src/pymodaq_plugins_mockexamples/hardware/native_library.py new file mode 100644 index 0000000..36e4728 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/hardware/native_library.py @@ -0,0 +1,84 @@ +"""Load a native (C) library with ``ctypes``, the way a plugin for a vendor DLL or shared object should. + +``load_library`` is meant to be copied in a plugin package. It takes a list of names per platform, an optional path +(from a setting of the plugin) and an optional environment variable, and tells what was tried when nothing is found, +instead of the bare ``OSError`` of ``ctypes``. +""" +import ctypes +import ctypes.util +import os +import platform +from pathlib import Path +from typing import Mapping, Optional, Sequence, Union + +Names = Union[str, Sequence[str], Mapping[str, Sequence[str]]] + + +class LibraryNotFoundError(OSError): + """No candidate could be loaded; the message lists every candidate and why it failed""" + + +def candidate_names(names: Names, system: Optional[str] = None) -> list[str]: + """The names to try on this platform + + ``names`` is a name, a list of names, or a dict from ``platform.system()`` ('Windows', 'Linux', 'Darwin') to the + names to try on that platform. + """ + if isinstance(names, str): + return [names] + if isinstance(names, Mapping): + return list(names.get(system or platform.system(), [])) + return list(names) + + +def load_library(names: Names, path: Optional[Union[str, Path]] = None, env_var: Optional[str] = None, + convention: str = 'cdecl') -> ctypes.CDLL: + """Load a native library + + Parameters + ---------- + names: str, list of str or dict + The file name (``avaspec.dll``, ``libusb-1.0.so``) or the name given to ``ctypes.util.find_library`` + (``c``, ``usb-1.0``), see ``candidate_names``. + path: str or Path, optional + Full path to the library or to the folder that holds it (typically a setting of the plugin); tried first. + env_var: str, optional + Name of an environment variable holding such a path; tried second. + convention: 'cdecl' or 'stdcall' + The calling convention of the library. ``stdcall`` is a Windows convention (many vendor DLLs use it, see the + documentation of the SDK): ``ctypes.WinDLL`` is used there, and ``ctypes.CDLL`` on other systems. + + Raises + ------ + LibraryNotFoundError + Listing everything that was tried. + """ + if convention not in ('cdecl', 'stdcall'): + raise ValueError(f"convention should be 'cdecl' or 'stdcall', not {convention!r}") + loader = ctypes.WinDLL if convention == 'stdcall' and hasattr(ctypes, 'WinDLL') else ctypes.CDLL + wanted = candidate_names(names) + + candidates: list[str] = [] + for folder_or_file in (path, os.environ.get(env_var) if env_var else None): + if folder_or_file: + base = Path(folder_or_file) + if base.is_dir(): + candidates.extend(str(base / name) for name in wanted) + else: + candidates.append(str(base)) + for name in wanted: + candidates.append(name) + found = ctypes.util.find_library(name) + if found and found not in candidates: + candidates.append(found) + + errors = [] + for candidate in candidates: + try: + return loader(candidate) + except OSError as e: + errors.append(f'{candidate}: {e}') + raise LibraryNotFoundError( + f'Could not load the library {wanted} on {platform.system()}. Tried:\n ' + '\n '.join(errors or ['(nothing)']) + + '\nGive the full path in the settings of the plugin' + + (f' or in the environment variable {env_var}.' if env_var else '.')) diff --git a/src/pymodaq_plugins_mockexamples/hardware/system_library_demo.py b/src/pymodaq_plugins_mockexamples/hardware/system_library_demo.py new file mode 100644 index 0000000..a81b0bc --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/hardware/system_library_demo.py @@ -0,0 +1,50 @@ +"""Calls to the real C library of the system with ``ctypes``: no vendor library needed to see the techniques. + +Each function shows one thing a driver for a vendor DLL does: declare ``argtypes`` and ``restype``, receive a value +through a pointer (``byref``), give the library a Python function as a callback (``CFUNCTYPE``). They run on Linux, +macOS and Windows (``libc``, ``libm`` or ``ucrtbase``). See ``native_library.py`` for the loading and +``mock_dll_controller.py`` for the same techniques on a simulated vendor library. +""" +import ctypes +from typing import Callable, Sequence + +from pymodaq_plugins_mockexamples.hardware.native_library import load_library + +C_LIBRARY = {'Linux': ['libc.so.6', 'c'], 'Darwin': ['libc.dylib', 'c'], 'Windows': ['ucrtbase.dll']} +MATH_LIBRARY = {'Linux': ['libm.so.6', 'm'], 'Darwin': ['libm.dylib', 'm'], 'Windows': ['ucrtbase.dll']} + + +def string_length(text: str) -> int: + """``size_t strlen(const char *s)``: arguments and return type declared, a Python str sent as bytes""" + libc = load_library(C_LIBRARY) + libc.strlen.argtypes = [ctypes.c_char_p] + libc.strlen.restype = ctypes.c_size_t # the default restype is a C int: wrong for a size_t on 64 bits + return libc.strlen(text.encode('ascii')) + + +def split_mantissa_exponent(value: float) -> tuple[float, int]: + """``double frexp(double x, int *exp)``: an output parameter, received through ``byref``""" + libm = load_library(MATH_LIBRARY) + libm.frexp.argtypes = [ctypes.c_double, ctypes.POINTER(ctypes.c_int)] + libm.frexp.restype = ctypes.c_double + exponent = ctypes.c_int() + mantissa = libm.frexp(value, ctypes.byref(exponent)) + return mantissa, exponent.value + + +def sort_with_callback(values: Sequence[float], compare: Callable[[float, float], int]) -> list[float]: + """``void qsort(void *base, size_t n, size_t size, int (*cmp)(const void *, const void *))`` + + The library calls ``compare`` (a Python function) many times. The ``CFUNCTYPE`` object must stay alive while the + library can call it: here it is a local variable that lives until ``qsort`` returns; a driver that gives a callback + to a library that keeps it (a ``set_callback`` function) must store it in an attribute. + """ + libc = load_library(C_LIBRARY) + compare_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)) + libc.qsort.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_size_t, compare_type] + libc.qsort.restype = None + + array = (ctypes.c_double * len(values))(*values) + libc.qsort(array, len(array), ctypes.sizeof(ctypes.c_double), + compare_type(lambda a, b: compare(a[0], b[0]))) + return list(array) diff --git a/tests/test_cookbook_dll.py b/tests/test_cookbook_dll.py new file mode 100644 index 0000000..dfe8d1c --- /dev/null +++ b/tests/test_cookbook_dll.py @@ -0,0 +1,226 @@ +"""The DLL / SDK recipe: loading a native library, the ctypes driver on the simulated library, the plugins on it + +The first tests call the real C library of the system (``libc``, ``libm``, ``ucrtbase``). To test your own driver copy +the last part and replace the plugin classes. +""" +import ctypes +import threading +import time + +import pytest + +from pymodaq.utils.data import DataActuator +from pymodaq.utils.plugin_testing import (make_actuator, make_detector, move_abs_and_wait, move_rel_and_wait, + move_home_and_wait, grab_and_wait) + +from pymodaq_plugins_mockexamples.daq_move_plugins.daq_move_CookbookDll import DAQ_Move_CookbookDll +from pymodaq_plugins_mockexamples.daq_viewer_plugins.plugins_0D.daq_0Dviewer_CookbookDll import \ + DAQ_0DViewer_CookbookDll +from pymodaq_plugins_mockexamples.hardware import mock_dll_controller as mock +from pymodaq_plugins_mockexamples.hardware import system_library_demo +from pymodaq_plugins_mockexamples.hardware.dll_controller import (MotionController, MotionControllerError, + open_controller) +from pymodaq_plugins_mockexamples.hardware.native_library import (load_library, LibraryNotFoundError, + candidate_names) + + +class TestLoadLibrary: + def test_candidate_names(self): + assert candidate_names('a') == ['a'] + assert candidate_names(['a', 'b']) == ['a', 'b'] + assert candidate_names({'Linux': ['x'], 'Windows': ['y']}, 'Windows') == ['y'] + assert candidate_names({'Linux': ['x']}, 'Darwin') == [] + + def test_loads_the_system_library(self): + assert load_library(system_library_demo.C_LIBRARY) is not None + + def test_a_wrong_path_falls_back_to_the_names(self, tmp_path): + assert load_library(system_library_demo.C_LIBRARY, path=tmp_path / 'nothing.dll') is not None + + def test_environment_variable(self, monkeypatch): + monkeypatch.setenv('MY_LIBRARY', '/does/not/exist') + with pytest.raises(LibraryNotFoundError) as e: + load_library('no_such_library_for_the_tests', env_var='MY_LIBRARY') + assert '/does/not/exist' in str(e.value) and 'MY_LIBRARY' in str(e.value) + + def test_missing_library_says_what_was_tried(self): + with pytest.raises(LibraryNotFoundError) as e: + load_library({'Linux': ['libmissing.so'], 'Darwin': ['libmissing.dylib'], 'Windows': ['missing.dll']}, + path='/somewhere') + message = str(e.value) + assert 'Tried' in message and 'missing' in message and '/somewhere' in message + assert isinstance(e.value, OSError) + + def test_unknown_convention(self): + with pytest.raises(ValueError): + load_library('c', convention='pascal') + + +class TestSystemLibrary: + """Real calls into native code: the techniques of any ctypes driver""" + + def test_argtypes_and_restype(self): + assert system_library_demo.string_length('PyMoDAQ') == 7 + + def test_output_parameter(self): + mantissa, exponent = system_library_demo.split_mantissa_exponent(48.) + assert (mantissa, exponent) == (0.75, 6) + + def test_callback_called_by_the_library(self): + calls = [] + + def compare(a, b): + calls.append((a, b)) + return (a > b) - (a < b) + + assert system_library_demo.sort_with_callback([3., -1., 2., 0.], compare) == [-1., 0., 2., 3.] + assert calls + + +class TestMockLibrary: + @pytest.fixture + def library(self): + return mock.MockMotionLibrary(speed=1000.) + + def test_behaves_like_a_cdll(self, library): + handle = ctypes.c_void_p() + library.mm_open.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_void_p)] + assert library.mm_open(b'MOCK', ctypes.byref(handle)) == mock.MM_OK + assert handle.value + with pytest.raises(AttributeError): + library.not_a_function + + def test_wrong_number_of_arguments(self, library): + library.mm_close.argtypes = [ctypes.c_void_p] + with pytest.raises(TypeError): + library.mm_close() + + def test_unknown_port(self): + with pytest.raises(MotionControllerError, match='no controller on this port'): + MotionController(mock.MockMotionLibrary(), 'COM99') + + +@pytest.fixture +def controller(): + controller = MotionController.mock(speed=1000.) + yield controller + controller.close() + + +class TestDriver: + def test_info_structure(self, controller): + info = controller.info() + assert (info.name, info.n_axes, info.firmware) == ('MockMotion 3000', 3, (2, 7)) + + def test_move_and_callback_from_another_thread(self, controller): + events = [] + controller.subscribe(lambda axis, event: events.append((axis, event, threading.get_ident()))) + controller.move_abs(2, 10.) + deadline = time.monotonic() + 2 + while not events and time.monotonic() < deadline: + time.sleep(0.005) + assert events[0][:2] == (2, mock.EVENT_MOVE_DONE) + assert events[0][2] != threading.get_ident() # the callback comes from a thread of the library + assert controller.position(2) == pytest.approx(10.) + assert not controller.is_moving(2) + + def test_error_codes_become_exceptions(self, controller): + with pytest.raises(MotionControllerError, match='position out of range'): + controller.move_abs(0, 500.) + with pytest.raises(MotionControllerError, match='invalid axis'): + controller.move_abs(5, 1.) + + def test_stop_reports_a_stopped_event(self): + controller = MotionController.mock(speed=1.) + events = [] + controller.subscribe(lambda axis, event: events.append(event)) + try: + controller.move_abs(0, 40.) + time.sleep(0.05) + controller.stop(0) + assert events == [mock.EVENT_STOPPED] + assert 0. < controller.position(0) < 40. + finally: + controller.close() + + def test_closed_handle_is_refused(self): + controller = MotionController.mock() + library, handle = controller._lib, controller._handle + controller.close() + library.mm_get_position.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_double)] + assert library.mm_get_position(handle, 0, ctypes.byref(ctypes.c_double())) == mock.MM_ERR_HANDLE + + def test_open_controller_without_the_library(self): + with pytest.raises(LibraryNotFoundError): + open_controller('COM3', library_path='') + + +@pytest.fixture +def actuator(qapp): + plugin = make_actuator(DAQ_Move_CookbookDll) + assert plugin.initialized + yield plugin + plugin.close() + + +class TestPlugins: + def test_ini_returns_the_identity(self, actuator): + assert 'MockMotion 3000' in actuator.init_info + + def test_move_abs_waits_for_the_callback(self, actuator): + position = move_abs_and_wait(actuator, 5.) + assert position.value('mm') == pytest.approx(5., abs=0.01) + assert actuator._move_finished + + def test_move_rel(self, actuator): + move_abs_and_wait(actuator, 1.) + assert move_rel_and_wait(actuator, 0.5).value('mm') == pytest.approx(1.5, abs=0.01) + + def test_move_home_ends_with_the_callback(self, actuator): + move_abs_and_wait(actuator, 3.) + assert move_home_and_wait(actuator).value('mm') == pytest.approx(0., abs=0.01) + + def test_out_of_range_is_an_error_of_the_driver(self, actuator): + with pytest.raises(MotionControllerError): + actuator.move_abs(DataActuator(data=500., units='mm')) + + def test_axes_share_the_controller(self, actuator): + slave = make_actuator(DAQ_Move_CookbookDll, controller=actuator.controller) + try: + slave.settings.child('controller', 'axis').setValue(1) # the axis Y, number 1 for the library + assert slave.controller is actuator.controller + move_abs_and_wait(actuator, 4.) + move_abs_and_wait(slave, -2.) + assert actuator.get_actuator_value().value('mm') == pytest.approx(4., abs=0.01) + assert slave.get_actuator_value().value('mm') == pytest.approx(-2., abs=0.01) + finally: + slave.close() # a slave never closes the shared controller + assert actuator.get_actuator_value().value('mm') == pytest.approx(4., abs=0.01) + + def test_events_of_the_other_axes_are_ignored(self, actuator): + slave = make_actuator(DAQ_Move_CookbookDll, controller=actuator.controller) + try: + slave.settings.child('controller', 'axis').setValue(1) + actuator._move_finished = False # waiting for its own end of move + move_abs_and_wait(slave, 1.) + assert not actuator._move_finished + finally: + slave.close() + + def test_detector_shares_the_controller(self, actuator): + detector = make_detector(DAQ_0DViewer_CookbookDll, controller=actuator.controller) + try: + move_abs_and_wait(actuator, 2.) + dte = grab_and_wait(detector) + assert [float(array[0]) for array in dte[0].data] == pytest.approx([2., 0., 0.], abs=0.01) + assert dte[0].labels == ['X', 'Y', 'Z'] + finally: + detector.close() + assert actuator.get_actuator_value().value('mm') == pytest.approx(2., abs=0.01) + + def test_detector_alone_is_a_master(self, qapp): + detector = make_detector(DAQ_0DViewer_CookbookDll) + try: + assert grab_and_wait(detector)[0].shape == (1,) + finally: + detector.close() From 6210dbb3402521e6494ef1bbe9003e65ed131fe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:39:37 +0000 Subject: [PATCH 2/5] Tests of load_library: compare paths in the native form of the system Windows writes /somewhere as \somewhere in the message. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- tests/test_cookbook_dll.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_cookbook_dll.py b/tests/test_cookbook_dll.py index dfe8d1c..225dac9 100644 --- a/tests/test_cookbook_dll.py +++ b/tests/test_cookbook_dll.py @@ -37,18 +37,20 @@ def test_loads_the_system_library(self): def test_a_wrong_path_falls_back_to_the_names(self, tmp_path): assert load_library(system_library_demo.C_LIBRARY, path=tmp_path / 'nothing.dll') is not None - def test_environment_variable(self, monkeypatch): - monkeypatch.setenv('MY_LIBRARY', '/does/not/exist') + def test_environment_variable(self, monkeypatch, tmp_path): + missing = str(tmp_path / 'does_not_exist.dll') # paths are written in the native form of the system + monkeypatch.setenv('MY_LIBRARY', missing) with pytest.raises(LibraryNotFoundError) as e: load_library('no_such_library_for_the_tests', env_var='MY_LIBRARY') - assert '/does/not/exist' in str(e.value) and 'MY_LIBRARY' in str(e.value) + assert missing in str(e.value) and 'MY_LIBRARY' in str(e.value) - def test_missing_library_says_what_was_tried(self): + def test_missing_library_says_what_was_tried(self, tmp_path): + folder = str(tmp_path / 'somewhere') with pytest.raises(LibraryNotFoundError) as e: load_library({'Linux': ['libmissing.so'], 'Darwin': ['libmissing.dylib'], 'Windows': ['missing.dll']}, - path='/somewhere') + path=folder) message = str(e.value) - assert 'Tried' in message and 'missing' in message and '/somewhere' in message + assert 'Tried' in message and 'missing' in message and folder in message assert isinstance(e.value, OSError) def test_unknown_convention(self): From bf0fb8ffa123473ecb37b2aefa4ef874df2be04a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 12:57:07 +0000 Subject: [PATCH 3/5] Show the error of the driver in the status bar of the module PyMoDAQ only logs an exception raised by a plugin method. The recipes now emit a status (report_errors) before raising it again. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .../daq_move_plugins/daq_move_CookbookDll.py | 7 ++++++ .../daq_move_CookbookPymeasure.py | 7 ++++++ .../daq_move_CookbookSerial.py | 7 ++++++ .../daq_move_plugins/daq_move_CookbookVisa.py | 7 ++++++ .../plugins_0D/daq_0Dviewer_CookbookDll.py | 4 +++ .../daq_0Dviewer_CookbookPymeasure.py | 4 +++ .../plugins_0D/daq_0Dviewer_CookbookSerial.py | 4 +++ .../plugins_0D/daq_0Dviewer_CookbookVisa.py | 4 +++ src/pymodaq_plugins_mockexamples/utils.py | 20 +++++++++++++++ tests/test_cookbook_dll.py | 25 +++++++++++++++++++ 10 files changed, 89 insertions(+) diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py index 3d89291..fb74f1f 100644 --- a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py @@ -13,6 +13,8 @@ from the library thread; - ``user_condition_to_reach_target``: PyMoDAQ polls the position until it is within epsilon of the target, and also waits for the end-of-move event. The homing is not polled, so ``move_done`` is called when its event arrives. +ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an +exception raised by a plugin method; keep it on the methods that call the driver. Try it without the DLL: leave the port to ``MOCK``, the library is then the simulated one of ``hardware/mock_dll_controller.py``. See ``hardware/dll_controller.py`` for the ``ctypes`` driver and @@ -26,6 +28,7 @@ from pymodaq_plugins_mockexamples.hardware.dll_controller import MotionController, open_controller from pymodaq_plugins_mockexamples.hardware.mock_dll_controller import (EVENT_MOVE_DONE, EVENT_STOPPED, EVENT_HOME_DONE) +from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_Move_CookbookDll(DAQ_Move_base): @@ -87,6 +90,7 @@ def get_actuator_value(self) -> DataActuator: position = DataActuator(data=self.controller.position(self.axis_value), units=self.axis_unit) return self.get_position_with_scaling(position) + @report_errors def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value @@ -94,6 +98,7 @@ def move_abs(self, value: DataActuator): self._move_finished = False self.controller.move_abs(self.axis_value, value.value(self.axis_unit)) + @report_errors def move_rel(self, value: DataActuator): value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value @@ -101,10 +106,12 @@ def move_rel(self, value: DataActuator): self._move_finished = False self.controller.move_abs(self.axis_value, value.value(self.axis_unit)) + @report_errors def move_home(self): self._homing = True self.controller.home(self.axis_value) + @report_errors def stop_motion(self): self.controller.stop(self.axis_value) diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookPymeasure.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookPymeasure.py index 4d61b72..c3ef873 100644 --- a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookPymeasure.py +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookPymeasure.py @@ -7,6 +7,8 @@ the names of the driver properties used in the methods. KEEP: the structure of the methods, the master/slave handling in ``ini_stage`` and ``close``, and the call of ``move_done`` at the end of ``move_home``. +ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an +exception raised by a plugin method; keep it on the methods that call the driver. Try it without hardware: leave the address to ``MOCK``. Give the same controller ID to this actuator and to the detector ``CookbookPymeasure`` to make them share the driver. See ``tests/test_cookbook_drivers.py`` and ``tests/test_cookbook_plugins.py`` for the tests. @@ -19,6 +21,7 @@ DataActuator, main) from pymodaq_plugins_mockexamples.hardware.pymeasure_source import CookbookSource, MockAdapter +from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_Move_CookbookPymeasure(DAQ_Move_base): @@ -52,18 +55,21 @@ def get_actuator_value(self) -> DataActuator: position = DataActuator(data=self.controller.position, units=self.axis_unit) return self.get_position_with_scaling(position) + @report_errors def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value value = self.set_position_with_scaling(value) self.controller.position = value.value(self.axis_unit) # validated by the driver, then sent + @report_errors def move_rel(self, value: DataActuator): value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value value = self.set_position_with_scaling(self.target_value) self.controller.position = value.value(self.axis_unit) + @report_errors def move_home(self): self.controller.home() t_end = time.monotonic() + 10. @@ -71,6 +77,7 @@ def move_home(self): time.sleep(0.01) self.move_done() # PyMoDAQ does not poll the position after a home + @report_errors def stop_motion(self): self.controller.stop() diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSerial.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSerial.py index 25019d5..ae9aded 100644 --- a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSerial.py +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSerial.py @@ -6,6 +6,8 @@ ``stop_motion``. KEEP: the structure of the methods, the master/slave handling in ``ini_stage`` and ``close``, and the call of ``move_done`` at the end of ``move_home``. +ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an +exception raised by a plugin method; keep it on the methods that call the driver. Try it without hardware: leave the port to ``MOCK``, the stage is then the simulated device of ``hardware/mock_serial_visa_instrument.py``. Give the same controller ID in the Dashboard to this actuator and to the detector @@ -17,6 +19,7 @@ from pymodaq_plugins_mockexamples.hardware.mock_serial_visa_instrument import MockSerialPort from pymodaq_plugins_mockexamples.hardware.serial_stage import SerialStage +from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_Move_CookbookSerial(DAQ_Move_base): @@ -51,22 +54,26 @@ def get_actuator_value(self) -> DataActuator: position = DataActuator(data=self.controller.position, units=self.axis_unit) return self.get_position_with_scaling(position) + @report_errors def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value value = self.set_position_with_scaling(value) self.controller.move_to(value.value(self.axis_unit)) + @report_errors def move_rel(self, value: DataActuator): value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value value = self.set_position_with_scaling(self.target_value) self.controller.move_to(value.value(self.axis_unit)) + @report_errors def move_home(self): self.controller.home() self.move_done() # the homing is blocking here, and PyMoDAQ does not poll the position after a home + @report_errors def stop_motion(self): self.controller.stop() diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookVisa.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookVisa.py index a6b66bc..722d838 100644 --- a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookVisa.py +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookVisa.py @@ -6,6 +6,8 @@ ``stop_motion``. KEEP: the structure of the methods, the master/slave handling in ``ini_stage`` and ``close``, and the call of ``move_done`` at the end of ``move_home``. +ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an +exception raised by a plugin method; keep it on the methods that call the driver. Try it without hardware: leave the address to ``MOCK``, the stage is then the simulated device of ``hardware/mock_serial_visa_instrument.py``. Give the same controller ID in the Dashboard to this actuator and to the detector @@ -17,6 +19,7 @@ from pymodaq_plugins_mockexamples.hardware.mock_serial_visa_instrument import MockVisaResource from pymodaq_plugins_mockexamples.hardware.visa_meter import VisaMeter +from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_Move_CookbookVisa(DAQ_Move_base): @@ -52,22 +55,26 @@ def get_actuator_value(self) -> DataActuator: position = DataActuator(data=self.controller.position, units=self.axis_unit) return self.get_position_with_scaling(position) + @report_errors def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value value = self.set_position_with_scaling(value) self.controller.move_to(value.value(self.axis_unit)) + @report_errors def move_rel(self, value: DataActuator): value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value value = self.set_position_with_scaling(self.target_value) self.controller.move_to(value.value(self.axis_unit)) + @report_errors def move_home(self): self.controller.home() self.move_done() # the homing is blocking here, and PyMoDAQ does not poll the position after a home + @report_errors def stop_motion(self): self.controller.stop() diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py index 0709630..d6d83bd 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py @@ -5,6 +5,8 @@ ``CookbookDll`` it is a slave and shares their handle: a library handle must be opened once, not once per plugin. ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. +ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an +exception raised by a plugin method; keep it on the methods that call the driver. """ import numpy as np @@ -13,6 +15,7 @@ from pymodaq_data import DataToExport from pymodaq_plugins_mockexamples.hardware.dll_controller import MotionController, open_controller +from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_0DViewer_CookbookDll(DAQ_Viewer_base): @@ -39,6 +42,7 @@ def close(self): if self.is_master and self.controller is not None: self.controller.close() + @report_errors def grab_data(self, Naverage=1, **kwargs): positions = [np.array([self.controller.position(axis)]) for axis in range(self.controller.n_axes)] self.dte_signal.emit(DataToExport( diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookPymeasure.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookPymeasure.py index 176c090..287e30d 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookPymeasure.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookPymeasure.py @@ -5,6 +5,8 @@ device with several functions (here a stage and a voltmeter) is split in several plugins. ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. +ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an +exception raised by a plugin method; keep it on the methods that call the driver. """ import numpy as np @@ -15,6 +17,7 @@ from pymeasure.adapters import VISAAdapter from pymodaq_plugins_mockexamples.hardware.pymeasure_source import CookbookSource, MockAdapter +from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_0DViewer_CookbookPymeasure(DAQ_Viewer_base): @@ -39,6 +42,7 @@ def close(self): if self.is_master and self.controller is not None: self.controller.adapter.close() + @report_errors def grab_data(self, Naverage=1, **kwargs): voltage = np.mean([self.controller.voltage for _ in range(Naverage)]) self.dte_signal.emit(DataToExport( diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookSerial.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookSerial.py index 3e61477..2ca25df 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookSerial.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookSerial.py @@ -5,6 +5,8 @@ device with several functions (here a stage and a voltmeter) is split in several plugins. ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. +ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an +exception raised by a plugin method; keep it on the methods that call the driver. """ import numpy as np @@ -14,6 +16,7 @@ from pymodaq_plugins_mockexamples.hardware.mock_serial_visa_instrument import MockSerialPort from pymodaq_plugins_mockexamples.hardware.serial_stage import SerialStage +from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_0DViewer_CookbookSerial(DAQ_Viewer_base): @@ -39,6 +42,7 @@ def close(self): if self.is_master and self.controller is not None: self.controller.close() + @report_errors def grab_data(self, Naverage=1, **kwargs): voltage = np.mean([self.controller.read_voltage() for _ in range(Naverage)]) self.dte_signal.emit(DataToExport( diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookVisa.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookVisa.py index 957fb85..c2a0905 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookVisa.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookVisa.py @@ -5,6 +5,8 @@ device with several functions (here a stage and a voltmeter) is split in several plugins. ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. +ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an +exception raised by a plugin method; keep it on the methods that call the driver. """ import numpy as np @@ -14,6 +16,7 @@ from pymodaq_plugins_mockexamples.hardware.mock_serial_visa_instrument import MockVisaResource from pymodaq_plugins_mockexamples.hardware.visa_meter import VisaMeter +from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_0DViewer_CookbookVisa(DAQ_Viewer_base): @@ -40,6 +43,7 @@ def close(self): if self.is_master and self.controller is not None: self.controller.close() + @report_errors def grab_data(self, Naverage=1, **kwargs): voltage = np.mean([self.controller.read_voltage() for _ in range(Naverage)]) self.dte_signal.emit(DataToExport( diff --git a/src/pymodaq_plugins_mockexamples/utils.py b/src/pymodaq_plugins_mockexamples/utils.py index 39550a8..ecc4c4b 100644 --- a/src/pymodaq_plugins_mockexamples/utils.py +++ b/src/pymodaq_plugins_mockexamples/utils.py @@ -4,6 +4,7 @@ @author: Sebastien Weber """ +import functools import shutil from pathlib import Path @@ -32,3 +33,22 @@ def copy_experiments(config: Config) -> list[Path]: shutil.copyfile(source, target) copied.append(target) return copied + + +def report_errors(method): + """Decorator for the methods of a plugin that call a driver: show the error in the status bar of the module + + PyMoDAQ logs the exception raised by a plugin method but shows nothing in the user interface, so without this the + user sees no sign that a move was refused or a reading failed. The status is emitted (it also reaches the log), + then the exception is raised again so PyMoDAQ and the tests still see it. + """ + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + try: + return method(self, *args, **kwargs) + except Exception as e: + from pymodaq.control_modules.thread_commands import ThreadStatus + from pymodaq_utils.utils import ThreadCommand + self.emit_status(ThreadCommand(ThreadStatus.UPDATE_STATUS, f'{method.__name__}: {e}')) + raise + return wrapper diff --git a/tests/test_cookbook_dll.py b/tests/test_cookbook_dll.py index 225dac9..cb39ca0 100644 --- a/tests/test_cookbook_dll.py +++ b/tests/test_cookbook_dll.py @@ -226,3 +226,28 @@ def test_detector_alone_is_a_master(self, qapp): assert grab_and_wait(detector)[0].shape == (1,) finally: detector.close() + + +class TestErrorsReachTheUser: + """PyMoDAQ only logs an exception raised by a plugin method: the recipes also emit a status for the status bar""" + + def test_refused_move_emits_a_status(self, actuator, monkeypatch): + statuses = [] + monkeypatch.setattr(actuator, 'emit_status', statuses.append) + with pytest.raises(MotionControllerError): + actuator.move_abs(DataActuator(data=500., units='mm')) + assert len(statuses) == 1 + assert statuses[0].command == 'update_status' + assert 'position out of range' in statuses[0].attribute and 'move_abs' in statuses[0].attribute + + def test_failed_grab_emits_a_status(self, qapp, monkeypatch): + detector = make_detector(DAQ_0DViewer_CookbookDll) + statuses = [] + monkeypatch.setattr(detector, 'emit_status', statuses.append) + detector.controller.close() # the handle is no longer valid + try: + with pytest.raises(MotionControllerError): + detector.grab_data() + assert 'invalid handle' in statuses[0].attribute + finally: + detector.close() From c91d9473483312bd0472c5a7fbd542c7bf2f5a89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:59:02 +0000 Subject: [PATCH 4/5] Remove @report_errors: PyMoDAQ now shows plugin exceptions itself Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .../daq_move_plugins/daq_move_CookbookDll.py | 7 ------ .../daq_move_CookbookPymeasure.py | 7 ------ .../daq_move_CookbookSerial.py | 7 ------ .../daq_move_plugins/daq_move_CookbookVisa.py | 7 ------ .../plugins_0D/daq_0Dviewer_CookbookDll.py | 4 ---- .../daq_0Dviewer_CookbookPymeasure.py | 4 ---- .../plugins_0D/daq_0Dviewer_CookbookSerial.py | 4 ---- .../plugins_0D/daq_0Dviewer_CookbookVisa.py | 4 ---- src/pymodaq_plugins_mockexamples/utils.py | 19 --------------- tests/test_cookbook_dll.py | 24 ------------------- 10 files changed, 87 deletions(-) diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py index fb74f1f..3d89291 100644 --- a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookDll.py @@ -13,8 +13,6 @@ from the library thread; - ``user_condition_to_reach_target``: PyMoDAQ polls the position until it is within epsilon of the target, and also waits for the end-of-move event. The homing is not polled, so ``move_done`` is called when its event arrives. -ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an -exception raised by a plugin method; keep it on the methods that call the driver. Try it without the DLL: leave the port to ``MOCK``, the library is then the simulated one of ``hardware/mock_dll_controller.py``. See ``hardware/dll_controller.py`` for the ``ctypes`` driver and @@ -28,7 +26,6 @@ from pymodaq_plugins_mockexamples.hardware.dll_controller import MotionController, open_controller from pymodaq_plugins_mockexamples.hardware.mock_dll_controller import (EVENT_MOVE_DONE, EVENT_STOPPED, EVENT_HOME_DONE) -from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_Move_CookbookDll(DAQ_Move_base): @@ -90,7 +87,6 @@ def get_actuator_value(self) -> DataActuator: position = DataActuator(data=self.controller.position(self.axis_value), units=self.axis_unit) return self.get_position_with_scaling(position) - @report_errors def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value @@ -98,7 +94,6 @@ def move_abs(self, value: DataActuator): self._move_finished = False self.controller.move_abs(self.axis_value, value.value(self.axis_unit)) - @report_errors def move_rel(self, value: DataActuator): value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value @@ -106,12 +101,10 @@ def move_rel(self, value: DataActuator): self._move_finished = False self.controller.move_abs(self.axis_value, value.value(self.axis_unit)) - @report_errors def move_home(self): self._homing = True self.controller.home(self.axis_value) - @report_errors def stop_motion(self): self.controller.stop(self.axis_value) diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookPymeasure.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookPymeasure.py index c3ef873..4d61b72 100644 --- a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookPymeasure.py +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookPymeasure.py @@ -7,8 +7,6 @@ the names of the driver properties used in the methods. KEEP: the structure of the methods, the master/slave handling in ``ini_stage`` and ``close``, and the call of ``move_done`` at the end of ``move_home``. -ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an -exception raised by a plugin method; keep it on the methods that call the driver. Try it without hardware: leave the address to ``MOCK``. Give the same controller ID to this actuator and to the detector ``CookbookPymeasure`` to make them share the driver. See ``tests/test_cookbook_drivers.py`` and ``tests/test_cookbook_plugins.py`` for the tests. @@ -21,7 +19,6 @@ DataActuator, main) from pymodaq_plugins_mockexamples.hardware.pymeasure_source import CookbookSource, MockAdapter -from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_Move_CookbookPymeasure(DAQ_Move_base): @@ -55,21 +52,18 @@ def get_actuator_value(self) -> DataActuator: position = DataActuator(data=self.controller.position, units=self.axis_unit) return self.get_position_with_scaling(position) - @report_errors def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value value = self.set_position_with_scaling(value) self.controller.position = value.value(self.axis_unit) # validated by the driver, then sent - @report_errors def move_rel(self, value: DataActuator): value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value value = self.set_position_with_scaling(self.target_value) self.controller.position = value.value(self.axis_unit) - @report_errors def move_home(self): self.controller.home() t_end = time.monotonic() + 10. @@ -77,7 +71,6 @@ def move_home(self): time.sleep(0.01) self.move_done() # PyMoDAQ does not poll the position after a home - @report_errors def stop_motion(self): self.controller.stop() diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSerial.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSerial.py index ae9aded..25019d5 100644 --- a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSerial.py +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSerial.py @@ -6,8 +6,6 @@ ``stop_motion``. KEEP: the structure of the methods, the master/slave handling in ``ini_stage`` and ``close``, and the call of ``move_done`` at the end of ``move_home``. -ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an -exception raised by a plugin method; keep it on the methods that call the driver. Try it without hardware: leave the port to ``MOCK``, the stage is then the simulated device of ``hardware/mock_serial_visa_instrument.py``. Give the same controller ID in the Dashboard to this actuator and to the detector @@ -19,7 +17,6 @@ from pymodaq_plugins_mockexamples.hardware.mock_serial_visa_instrument import MockSerialPort from pymodaq_plugins_mockexamples.hardware.serial_stage import SerialStage -from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_Move_CookbookSerial(DAQ_Move_base): @@ -54,26 +51,22 @@ def get_actuator_value(self) -> DataActuator: position = DataActuator(data=self.controller.position, units=self.axis_unit) return self.get_position_with_scaling(position) - @report_errors def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value value = self.set_position_with_scaling(value) self.controller.move_to(value.value(self.axis_unit)) - @report_errors def move_rel(self, value: DataActuator): value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value value = self.set_position_with_scaling(self.target_value) self.controller.move_to(value.value(self.axis_unit)) - @report_errors def move_home(self): self.controller.home() self.move_done() # the homing is blocking here, and PyMoDAQ does not poll the position after a home - @report_errors def stop_motion(self): self.controller.stop() diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookVisa.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookVisa.py index 722d838..a6b66bc 100644 --- a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookVisa.py +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookVisa.py @@ -6,8 +6,6 @@ ``stop_motion``. KEEP: the structure of the methods, the master/slave handling in ``ini_stage`` and ``close``, and the call of ``move_done`` at the end of ``move_home``. -ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an -exception raised by a plugin method; keep it on the methods that call the driver. Try it without hardware: leave the address to ``MOCK``, the stage is then the simulated device of ``hardware/mock_serial_visa_instrument.py``. Give the same controller ID in the Dashboard to this actuator and to the detector @@ -19,7 +17,6 @@ from pymodaq_plugins_mockexamples.hardware.mock_serial_visa_instrument import MockVisaResource from pymodaq_plugins_mockexamples.hardware.visa_meter import VisaMeter -from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_Move_CookbookVisa(DAQ_Move_base): @@ -55,26 +52,22 @@ def get_actuator_value(self) -> DataActuator: position = DataActuator(data=self.controller.position, units=self.axis_unit) return self.get_position_with_scaling(position) - @report_errors def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value value = self.set_position_with_scaling(value) self.controller.move_to(value.value(self.axis_unit)) - @report_errors def move_rel(self, value: DataActuator): value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value value = self.set_position_with_scaling(self.target_value) self.controller.move_to(value.value(self.axis_unit)) - @report_errors def move_home(self): self.controller.home() self.move_done() # the homing is blocking here, and PyMoDAQ does not poll the position after a home - @report_errors def stop_motion(self): self.controller.stop() diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py index d6d83bd..0709630 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookDll.py @@ -5,8 +5,6 @@ ``CookbookDll`` it is a slave and shares their handle: a library handle must be opened once, not once per plugin. ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. -ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an -exception raised by a plugin method; keep it on the methods that call the driver. """ import numpy as np @@ -15,7 +13,6 @@ from pymodaq_data import DataToExport from pymodaq_plugins_mockexamples.hardware.dll_controller import MotionController, open_controller -from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_0DViewer_CookbookDll(DAQ_Viewer_base): @@ -42,7 +39,6 @@ def close(self): if self.is_master and self.controller is not None: self.controller.close() - @report_errors def grab_data(self, Naverage=1, **kwargs): positions = [np.array([self.controller.position(axis)]) for axis in range(self.controller.n_axes)] self.dte_signal.emit(DataToExport( diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookPymeasure.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookPymeasure.py index 287e30d..176c090 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookPymeasure.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookPymeasure.py @@ -5,8 +5,6 @@ device with several functions (here a stage and a voltmeter) is split in several plugins. ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. -ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an -exception raised by a plugin method; keep it on the methods that call the driver. """ import numpy as np @@ -17,7 +15,6 @@ from pymeasure.adapters import VISAAdapter from pymodaq_plugins_mockexamples.hardware.pymeasure_source import CookbookSource, MockAdapter -from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_0DViewer_CookbookPymeasure(DAQ_Viewer_base): @@ -42,7 +39,6 @@ def close(self): if self.is_master and self.controller is not None: self.controller.adapter.close() - @report_errors def grab_data(self, Naverage=1, **kwargs): voltage = np.mean([self.controller.voltage for _ in range(Naverage)]) self.dte_signal.emit(DataToExport( diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookSerial.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookSerial.py index 2ca25df..3e61477 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookSerial.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookSerial.py @@ -5,8 +5,6 @@ device with several functions (here a stage and a voltmeter) is split in several plugins. ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. -ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an -exception raised by a plugin method; keep it on the methods that call the driver. """ import numpy as np @@ -16,7 +14,6 @@ from pymodaq_plugins_mockexamples.hardware.mock_serial_visa_instrument import MockSerialPort from pymodaq_plugins_mockexamples.hardware.serial_stage import SerialStage -from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_0DViewer_CookbookSerial(DAQ_Viewer_base): @@ -42,7 +39,6 @@ def close(self): if self.is_master and self.controller is not None: self.controller.close() - @report_errors def grab_data(self, Naverage=1, **kwargs): voltage = np.mean([self.controller.read_voltage() for _ in range(Naverage)]) self.dte_signal.emit(DataToExport( diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookVisa.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookVisa.py index c2a0905..957fb85 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookVisa.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_0D/daq_0Dviewer_CookbookVisa.py @@ -5,8 +5,6 @@ device with several functions (here a stage and a voltmeter) is split in several plugins. ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data). KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data. -ERRORS: ``@report_errors`` shows the error of the driver in the status bar of the module, as PyMoDAQ only logs an -exception raised by a plugin method; keep it on the methods that call the driver. """ import numpy as np @@ -16,7 +14,6 @@ from pymodaq_plugins_mockexamples.hardware.mock_serial_visa_instrument import MockVisaResource from pymodaq_plugins_mockexamples.hardware.visa_meter import VisaMeter -from pymodaq_plugins_mockexamples.utils import report_errors class DAQ_0DViewer_CookbookVisa(DAQ_Viewer_base): @@ -43,7 +40,6 @@ def close(self): if self.is_master and self.controller is not None: self.controller.close() - @report_errors def grab_data(self, Naverage=1, **kwargs): voltage = np.mean([self.controller.read_voltage() for _ in range(Naverage)]) self.dte_signal.emit(DataToExport( diff --git a/src/pymodaq_plugins_mockexamples/utils.py b/src/pymodaq_plugins_mockexamples/utils.py index ecc4c4b..5591d3d 100644 --- a/src/pymodaq_plugins_mockexamples/utils.py +++ b/src/pymodaq_plugins_mockexamples/utils.py @@ -4,7 +4,6 @@ @author: Sebastien Weber """ -import functools import shutil from pathlib import Path @@ -34,21 +33,3 @@ def copy_experiments(config: Config) -> list[Path]: copied.append(target) return copied - -def report_errors(method): - """Decorator for the methods of a plugin that call a driver: show the error in the status bar of the module - - PyMoDAQ logs the exception raised by a plugin method but shows nothing in the user interface, so without this the - user sees no sign that a move was refused or a reading failed. The status is emitted (it also reaches the log), - then the exception is raised again so PyMoDAQ and the tests still see it. - """ - @functools.wraps(method) - def wrapper(self, *args, **kwargs): - try: - return method(self, *args, **kwargs) - except Exception as e: - from pymodaq.control_modules.thread_commands import ThreadStatus - from pymodaq_utils.utils import ThreadCommand - self.emit_status(ThreadCommand(ThreadStatus.UPDATE_STATUS, f'{method.__name__}: {e}')) - raise - return wrapper diff --git a/tests/test_cookbook_dll.py b/tests/test_cookbook_dll.py index cb39ca0..dcad085 100644 --- a/tests/test_cookbook_dll.py +++ b/tests/test_cookbook_dll.py @@ -227,27 +227,3 @@ def test_detector_alone_is_a_master(self, qapp): finally: detector.close() - -class TestErrorsReachTheUser: - """PyMoDAQ only logs an exception raised by a plugin method: the recipes also emit a status for the status bar""" - - def test_refused_move_emits_a_status(self, actuator, monkeypatch): - statuses = [] - monkeypatch.setattr(actuator, 'emit_status', statuses.append) - with pytest.raises(MotionControllerError): - actuator.move_abs(DataActuator(data=500., units='mm')) - assert len(statuses) == 1 - assert statuses[0].command == 'update_status' - assert 'position out of range' in statuses[0].attribute and 'move_abs' in statuses[0].attribute - - def test_failed_grab_emits_a_status(self, qapp, monkeypatch): - detector = make_detector(DAQ_0DViewer_CookbookDll) - statuses = [] - monkeypatch.setattr(detector, 'emit_status', statuses.append) - detector.controller.close() # the handle is no longer valid - try: - with pytest.raises(MotionControllerError): - detector.grab_data() - assert 'invalid handle' in statuses[0].attribute - finally: - detector.close() From 96137d03294b17934d9bfe044fcaf3b2a7725eeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:59:12 +0000 Subject: [PATCH 5/5] utils.py: no trailing blank line Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- src/pymodaq_plugins_mockexamples/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pymodaq_plugins_mockexamples/utils.py b/src/pymodaq_plugins_mockexamples/utils.py index 5591d3d..39550a8 100644 --- a/src/pymodaq_plugins_mockexamples/utils.py +++ b/src/pymodaq_plugins_mockexamples/utils.py @@ -32,4 +32,3 @@ def copy_experiments(config: Config) -> list[Path]: shutil.copyfile(source, target) copied.append(target) return copied -