From fa3eeadbc1c189f42dbed1c0365f310a1ba1e95f Mon Sep 17 00:00:00 2001 From: Jeremie Margueritat Date: Mon, 12 Oct 2026 21:16:51 +0200 Subject: [PATCH 1/4] Add stepper motor methods and configuration --- .../daq_move_plugins/daq_move_StepperMotor.py | 151 ++++++++++++++++++ .../hardware/arduino_telemetrix.py | 46 +++++- .../resources/config_template.toml | 9 +- 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py diff --git a/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py new file mode 100644 index 0000000..43b8b67 --- /dev/null +++ b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py @@ -0,0 +1,151 @@ +from typing import Union, List, Dict, Optional +from pymodaq.control_modules.move_utility_classes import ( + DAQ_Move_base, comon_parameters_fun, main, DataActuatorType, DataActuator +) +from pymodaq_utils.utils import ThreadCommand # Object used to send info back to the main thread +from pymodaq_gui.parameter import Parameter +from pymodaq_plugins_arduino.hardware.arduino_telemetrix import Arduino +from pymodaq_plugins_arduino.utils import Config + +config = Config() + + +class DAQ_Move_StepperMotor(DAQ_Move_base): + """Plugin to control stepper motor using Arduino controller and PyMoDAQ. + + This object inherits all functionalities to communicate with PyMoDAQ’s DAQ_Move module through inheritance via + DAQ_Move_base. It makes a bridge between the DAQ_Move module and the Python wrapper of a particular instrument. + + Use the arduino_telemetrix wrapper to communicate with the Arduino Board. + It may work with up to 4 axes depending on the configuration. + It does not consider the daisy chain option: only one controller. + Tested with Arduino Uno and one motor NEMA17 (1 axis). + PyMoDAQ version during the test was PyMoDAQ==5.0.5. + The operating system used was Windows 11. + Telemetrix4arduino has to be uploaded on the Arduino board. + + Attributes + ---------- + controller: object + The particular object that allows communication with the hardware, in general, a Python wrapper around the + hardware library. + """ + is_multiaxes = True + _axis_names: Union[List[str], Dict[str, int]] = {'Stepper': config('stepper')} + _controller_units: Union[str, List[str]] = '' # steps + _epsilon: Union[float, List[float]] = 1 + data_actuator_type = DataActuatorType.float + + params = [ + { + 'title': 'Ports:', + 'name': 'com_port', + 'type': 'list', + 'value': config('com_port'), + 'limits': Arduino.COM_PORTS, + } + ] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + + def ini_attributes(self): + self.controller: Optional[Arduino] = None + + def get_actuator_value(self) -> float: + """Get the current value from the hardware with scaling conversion. + + Returns + ------- + float + The position obtained after scaling conversion. + """ + pos = self.controller.get_stepper_position() + pos = self.get_position_with_scaling(pos) + return pos + + def user_condition_to_reach_target(self) -> bool: + """Will be triggered for each end of move; abs, rel, or homing.""" + return self._move_done + + def close(self): + """Terminate the communication protocol.""" + self.controller.shutdown() + + def commit_settings(self, param: Parameter): + """Apply the consequences of a change of value in the detector settings. + + Parameters + ---------- + param: Parameter + A given parameter (within detector_settings) whose value has been changed by the user. + """ + pass + + def ini_stage(self, controller=None): + """Actuator communication initialization. + + Parameters + ---------- + controller: object + Custom object of a PyMoDAQ plugin (Slave case). None if only one actuator by controller (Master case). + + Returns + ------- + info: str + initialized: bool + False if initialization failed otherwise True. + """ + self.ini_stage_init(slave_controller=controller) # Useful when controller is slave + + if self.is_master: # Needed when controller is master + self.controller = Arduino( + com_port=self.settings['com_port'] + ) + self.controller.initialize_stepper_motor( + config('stepper', 'pins', 'pul_pin'), + config('stepper', 'pins', 'dir_pin'), + config('stepper', 'pins', 'ena_pin'), + ) # Pulse, direction, enable pins + + info = "Stepper motor connected with config file" + initialized = True # TODO: Replace with actual initialization check + return info, initialized + + def move_abs(self, value: DataActuator): + """Move the actuator to the absolute target defined by value. + + Parameters + ---------- + value: float + Value of the absolute target positioning. + """ + value = self.check_bound(value) # Apply bounds if user checked them + self.target_value = value + value = self.set_position_with_scaling(value) # Apply scaling if specified + self._move_done = self.controller.move_stepper_to_position(value) + self.emit_status(ThreadCommand('Update_Status', ['absolute move done'])) + + def move_rel(self, value: DataActuator): + """Move the actuator to the relative target actuator value defined by value. + + Parameters + ---------- + value: float + Value of the relative target positioning. + """ + value = self.check_bound(self.current_position + value) - self.current_position + self.target_value = value + self.current_position + value = self.set_position_relative_with_scaling(value) + self._move_done = self.controller.move_stepper_to_position(self.target_value) + self.emit_status(ThreadCommand('Update_Status', ['relative move done'])) + + def move_home(self): + """Call the reference method of the controller.""" + self.controller.move_stepper_to_position(0) # Move to home position (0) + self.emit_status(ThreadCommand('Update_Status', ['homing'])) + + def stop_motion(self): + """Stop the actuator and emit move_done signal.""" + pass + + +if __name__ == '__main__': + main(__file__) \ No newline at end of file diff --git a/src/pymodaq_plugins_arduino/hardware/arduino_telemetrix.py b/src/pymodaq_plugins_arduino/hardware/arduino_telemetrix.py index 24fa194..f6010c6 100644 --- a/src/pymodaq_plugins_arduino/hardware/arduino_telemetrix.py +++ b/src/pymodaq_plugins_arduino/hardware/arduino_telemetrix.py @@ -1,5 +1,5 @@ import numbers -from threading import Lock +from threading import Lock, Event from pyvisa import ResourceManager from telemetrix import telemetrix @@ -27,6 +27,8 @@ def __init__(self, *args, **kwargs): 3: 0, 4: 0, 5: 0} # Initialized dictionary for 6 analog channels + self.stepper_motor = None + self.position = 0 @staticmethod def round_value(value): @@ -96,6 +98,48 @@ def servo_move_degree(self, pin: int, value: float): self.pin_values_output[pin] = value lock.release() + # Stepper motor methods + def initialize_stepper_motor(self, pulse_pin, direction_pin, enable_pin=7): + """ Initialize the stepper motor with the given pins """ + self.stepper_motor = self.set_pin_mode_stepper(interface=1, pin1=pulse_pin, pin2=direction_pin) + self.enable = enable_pin + self.set_pin_mode_digital_output(self.enable) # Set the enable pin as digital output + self.digital_write(self.enable, 1) # Disable the motor driver to avoid electrical consumption + self.stepper_set_current_position(self.stepper_motor, 0) # Set the current position to 0 + + def move_stepper_to_position(self, position: float, max_speed=200, acceleration=400): + """ Move the stepper motor to the specified position """ + if self.stepper_motor is None: + raise ValueError("Stepper motor not initialized. Call initialize_stepper_motor first.") + self.stepper_set_max_speed(self.stepper_motor, max_speed) + self.stepper_set_acceleration(self.stepper_motor, acceleration) + self.stepper_move_to(self.stepper_motor, int(position)) + completion_event = Event() + + def completion_callback(data): + """ Signal that the stepper motor has completed its movement """ + completion_event.set() + + self.digital_write(self.enable, 0) + self.stepper_run(self.stepper_motor, completion_callback=completion_callback) + completion_event.wait() + self.digital_write(self.enable, 1) + return True + + def get_stepper_position(self): + """ Retrieve the current position of the stepper motor """ + position_event = Event() + + def position_callback(data): + """ Store the current position of the stepper motor """ + self.position = data[2] + position_event.set() + + self.stepper_get_current_position(self.stepper_motor, + current_position_callback=position_callback) + position_event.wait() + return self.position + if __name__ == '__main__': import time diff --git a/src/pymodaq_plugins_arduino/resources/config_template.toml b/src/pymodaq_plugins_arduino/resources/config_template.toml index 50c49c8..a4fc22f 100644 --- a/src/pymodaq_plugins_arduino/resources/config_template.toml +++ b/src/pymodaq_plugins_arduino/resources/config_template.toml @@ -19,4 +19,11 @@ rows = 2 [servo] pin = 2 pos_1 = 55 -pos_2 = 80 \ No newline at end of file +pos_2 = 80 + +[stepper] +name = 0 +[stepper.pins] +ena_pin = 7 +pul_pin = 8 +dir_pin = 9 \ No newline at end of file From a42b4f8edd3c822146439851b8605f5c25c5eb6f Mon Sep 17 00:00:00 2001 From: Jeremie Margueritat Date: Mon, 12 Oct 2026 21:41:49 +0200 Subject: [PATCH 2/4] Add the StepperMotor actuator, updated to the PyMoDAQ 5 DataActuator interface --- .../daq_move_plugins/daq_move_StepperMotor.py | 74 ++++++------------- 1 file changed, 24 insertions(+), 50 deletions(-) diff --git a/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py index 43b8b67..0248059 100644 --- a/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py +++ b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py @@ -2,7 +2,6 @@ from pymodaq.control_modules.move_utility_classes import ( DAQ_Move_base, comon_parameters_fun, main, DataActuatorType, DataActuator ) -from pymodaq_utils.utils import ThreadCommand # Object used to send info back to the main thread from pymodaq_gui.parameter import Parameter from pymodaq_plugins_arduino.hardware.arduino_telemetrix import Arduino from pymodaq_plugins_arduino.utils import Config @@ -30,11 +29,10 @@ class DAQ_Move_StepperMotor(DAQ_Move_base): The particular object that allows communication with the hardware, in general, a Python wrapper around the hardware library. """ - is_multiaxes = True - _axis_names: Union[List[str], Dict[str, int]] = {'Stepper': config('stepper')} - _controller_units: Union[str, List[str]] = '' # steps - _epsilon: Union[float, List[float]] = 1 - data_actuator_type = DataActuatorType.float + _axis_names: Union[List[str], Dict[str, int]] = {'Stepper': config('stepper', 'name')} + _controller_units: Union[str, Dict[str, str]] = {'Stepper': 'count'} + _epsilons: Union[float, Dict[str, float]] = {'Stepper': 1} + data_actuator_type = DataActuatorType.DataActuator params = [ { @@ -44,30 +42,22 @@ class DAQ_Move_StepperMotor(DAQ_Move_base): 'value': config('com_port'), 'limits': Arduino.COM_PORTS, } - ] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + ] + comon_parameters_fun(axis_names=_axis_names) def ini_attributes(self): self.controller: Optional[Arduino] = None - def get_actuator_value(self) -> float: - """Get the current value from the hardware with scaling conversion. - - Returns - ------- - float - The position obtained after scaling conversion. - """ - pos = self.controller.get_stepper_position() + def get_actuator_value(self) -> DataActuator: + """Get the current position of the motor, in steps, with scaling conversion.""" + pos = DataActuator(data=self.controller.get_stepper_position(), + units=self.axis_unit) pos = self.get_position_with_scaling(pos) return pos - def user_condition_to_reach_target(self) -> bool: - """Will be triggered for each end of move; abs, rel, or homing.""" - return self._move_done - def close(self): - """Terminate the communication protocol.""" - self.controller.shutdown() + """Terminate the communication with the Arduino.""" + if self.controller is not None: + self.controller.shutdown() def commit_settings(self, param: Parameter): """Apply the consequences of a change of value in the detector settings. @@ -106,44 +96,28 @@ def ini_stage(self, controller=None): ) # Pulse, direction, enable pins info = "Stepper motor connected with config file" - initialized = True # TODO: Replace with actual initialization check + initialized = self.controller.stepper_motor is not None return info, initialized def move_abs(self, value: DataActuator): - """Move the actuator to the absolute target defined by value. - - Parameters - ---------- - value: float - Value of the absolute target positioning. - """ - value = self.check_bound(value) # Apply bounds if user checked them + """Move the motor to the absolute target defined by value.""" + value = self.check_bound(value) self.target_value = value - value = self.set_position_with_scaling(value) # Apply scaling if specified - self._move_done = self.controller.move_stepper_to_position(value) - self.emit_status(ThreadCommand('Update_Status', ['absolute move done'])) + value = self.set_position_with_scaling(value) + self.controller.move_stepper_to_position(value.units_as(self.axis_unit).value()) def move_rel(self, value: DataActuator): - """Move the actuator to the relative target actuator value defined by value. - - Parameters - ---------- - value: float - Value of the relative target positioning. - """ - value = self.check_bound(self.current_position + value) - self.current_position - self.target_value = value + self.current_position - value = self.set_position_relative_with_scaling(value) - self._move_done = self.controller.move_stepper_to_position(self.target_value) - self.emit_status(ThreadCommand('Update_Status', ['relative move done'])) + """Move the motor by the relative amount defined by value.""" + self.move_abs(self.current_value + value) def move_home(self): - """Call the reference method of the controller.""" - self.controller.move_stepper_to_position(0) # Move to home position (0) - self.emit_status(ThreadCommand('Update_Status', ['homing'])) + """Move the motor back to its reference position (step 0).""" + home = DataActuator(data=0, units=self.axis_unit) + self.target_value = self.get_position_with_scaling(home) + self.controller.move_stepper_to_position(0) def stop_motion(self): - """Stop the actuator and emit move_done signal.""" + """Stopping is not supported: moves are blocking until completion.""" pass From 9a43c894b7622f1901c8ea0782ad589dbfa988b8 Mon Sep 17 00:00:00 2001 From: Jeremie Margueritat Date: Tue, 13 Oct 2026 11:33:50 +0200 Subject: [PATCH 3/4] Address review: explicit master/slave init and simpler unit conversion --- .../daq_move_plugins/daq_move_StepperMotor.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py index 0248059..6bbfcaf 100644 --- a/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py +++ b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py @@ -19,7 +19,7 @@ class DAQ_Move_StepperMotor(DAQ_Move_base): It may work with up to 4 axes depending on the configuration. It does not consider the daisy chain option: only one controller. Tested with Arduino Uno and one motor NEMA17 (1 axis). - PyMoDAQ version during the test was PyMoDAQ==5.0.5. + PyMoDAQ version during the test was PyMoDAQ==5.3.2. The operating system used was Windows 11. Telemetrix4arduino has to be uploaded on the Arduino board. @@ -83,17 +83,15 @@ def ini_stage(self, controller=None): initialized: bool False if initialization failed otherwise True. """ - self.ini_stage_init(slave_controller=controller) # Useful when controller is slave - - if self.is_master: # Needed when controller is master - self.controller = Arduino( - com_port=self.settings['com_port'] - ) + if self.is_master: + self.controller = Arduino(com_port=self.settings['com_port']) self.controller.initialize_stepper_motor( config('stepper', 'pins', 'pul_pin'), config('stepper', 'pins', 'dir_pin'), config('stepper', 'pins', 'ena_pin'), ) # Pulse, direction, enable pins + else: + self.controller = controller info = "Stepper motor connected with config file" initialized = self.controller.stepper_motor is not None @@ -104,7 +102,7 @@ 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_stepper_to_position(value.units_as(self.axis_unit).value()) + self.controller.move_stepper_to_position(value.value(self.axis_unit)) def move_rel(self, value: DataActuator): """Move the motor by the relative amount defined by value.""" From 7092308a30ef874ffa2a256ad370f3320c139cdd Mon Sep 17 00:00:00 2001 From: Jeremie Margueritat Date: Tue, 13 Oct 2026 12:06:50 +0200 Subject: [PATCH 4/4] Address review: non-blocking moves and working Stop --- .../daq_move_plugins/daq_move_StepperMotor.py | 11 ++++++---- .../hardware/arduino_telemetrix.py | 21 ++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py index 6bbfcaf..7279670 100644 --- a/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py +++ b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_StepperMotor.py @@ -16,12 +16,14 @@ class DAQ_Move_StepperMotor(DAQ_Move_base): DAQ_Move_base. It makes a bridge between the DAQ_Move module and the Python wrapper of a particular instrument. Use the arduino_telemetrix wrapper to communicate with the Arduino Board. - It may work with up to 4 axes depending on the configuration. + Telemetrix supports up to 4 stepper motors, but this plugin currently drives a single axis. It does not consider the daisy chain option: only one controller. + Moves are non-blocking and can be stopped with the Stop button. + There is no encoder: the position is the step counter of the Arduino, reset to 0 at initialization. Tested with Arduino Uno and one motor NEMA17 (1 axis). PyMoDAQ version during the test was PyMoDAQ==5.3.2. The operating system used was Windows 11. - Telemetrix4arduino has to be uploaded on the Arduino board. + Telemetrix4Arduino has to be uploaded on the Arduino board. Attributes ---------- @@ -115,8 +117,9 @@ def move_home(self): self.controller.move_stepper_to_position(0) def stop_motion(self): - """Stopping is not supported: moves are blocking until completion.""" - pass + """Stop the motor immediately, then emit the move_done signal""" + self.controller.stop_stepper() + self.move_done() if __name__ == '__main__': diff --git a/src/pymodaq_plugins_arduino/hardware/arduino_telemetrix.py b/src/pymodaq_plugins_arduino/hardware/arduino_telemetrix.py index f6010c6..be42623 100644 --- a/src/pymodaq_plugins_arduino/hardware/arduino_telemetrix.py +++ b/src/pymodaq_plugins_arduino/hardware/arduino_telemetrix.py @@ -108,23 +108,24 @@ def initialize_stepper_motor(self, pulse_pin, direction_pin, enable_pin=7): self.stepper_set_current_position(self.stepper_motor, 0) # Set the current position to 0 def move_stepper_to_position(self, position: float, max_speed=200, acceleration=400): - """ Move the stepper motor to the specified position """ + """ Start moving the stepper motor to the specified position, without waiting for the end """ if self.stepper_motor is None: raise ValueError("Stepper motor not initialized. Call initialize_stepper_motor first.") self.stepper_set_max_speed(self.stepper_motor, max_speed) self.stepper_set_acceleration(self.stepper_motor, acceleration) self.stepper_move_to(self.stepper_motor, int(position)) - completion_event = Event() + self.digital_write(self.enable, 0) # Enable the motor driver + self.stepper_run(self.stepper_motor, completion_callback=self._stepper_move_completed) - def completion_callback(data): - """ Signal that the stepper motor has completed its movement """ - completion_event.set() + def _stepper_move_completed(self, data): + """ Called by Telemetrix when the motor has reached its target """ + self.digital_write(self.enable, 1) # Disable the motor driver to avoid electrical consumption - self.digital_write(self.enable, 0) - self.stepper_run(self.stepper_motor, completion_callback=completion_callback) - completion_event.wait() - self.digital_write(self.enable, 1) - return True + def stop_stepper(self): + """ Stop the stepper motor immediately and disable its driver """ + if self.stepper_motor is not None: + self.stepper_stop(self.stepper_motor) + self.digital_write(self.enable, 1) # Disable the motor driver to avoid electrical consumption def get_stepper_position(self): """ Retrieve the current position of the stepper motor """