# The MIT License (MIT)
#
# Copyright (c) 2019 Brendan Doherty
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
StepperMotor
=============
a unipolar stepper motor driver class
"""
import time
from math import floor, ceil
import digitalio
IS_THREADED = True
try:
from threading import Thread
except ImportError:
IS_THREADED = False
# pylint: disable=too-many-branches,too-many-instance-attributes
[docs]class StepperMotor():
"""A class designed to control unipolar or bipolar stepper motors. It is still a work in
progress as there is no smoothing algorithm nor limited maximum speed applied to the motor's
input.
:param list,tuple pins: A `list` or `tuple` of (`board` module) pins that are used to drive the
stepper motor. The length of this `list` or `tuple` must be divisible by 2, otherwise a
`ValueError` exception is thrown.
:param int steps_per_rev: An `int` that represents how many steps it takes to complete a whole
revolution. Defaults to 4096. This should correlate with information found in your
motor's datasheet.
:param int,float degree_per_step: The value that represents how many degrees the motor moves
per single step. Defaults to 45/512 or 5.625°/64. This should correlate with information
found in your motor's datasheet.
:param string step_type: This parameter is used upon instantiation to specify what kind of
stepping pattern the motor uses. Valid values are limited to:
* ``half`` (default value)
* ``full``
* ``wave``
This should correlate with information found in your motor's datasheet.
:param int,float rpm: The maximum amount of rotations per minute. This should correlate with
information found in your motor's datasheet.
"""
def __init__(self, pins, steps_per_rev=4096, degree_per_step=45/512, step_type='half', rpm=60):
if len(pins) % 2 or not pins:
raise ValueError('The number of pins should be divisible by 2 and greater than 0.')
if step_type not in ('half', 'full', 'wave'):
raise ValueError("Unrecognized step type {}. Expected 'half', 'full', or 'wave'".format(step_type))
self._spr = steps_per_rev # max # of steps in 1 revolution
self._dps = degree_per_step # used to calculate angle
self._step_type = step_type # can be 'wave', 'full', or 'half' for stepping pattern
self._rpm = rpm # max speed in rotations per minute
self._pins = [] # list of pins including getter & setters
for i, pin in enumerate(pins):
# configure each pin for digital output
self._pins.append(digitalio.DigitalInOut(pin))
self._pins[i].switch_to_output()
self.reset0angle() # init self._steps = 0
# self._steps = steps (specific to motor) for position tracking
self._target_pos = 0
self._it = 0 # iterator for rotating stepper
self._end_delay_time = 0
self._brake = not IS_THREADED
self._move_thread = None
@property
def is_cellerating(self):
"""This attribute contains a `bool` indicating if the motor is in the midst of moving.
(read-only)
"""
return self._target_pos != self._steps if self._target_pos is not None and self._move_thread is not None else False
[docs] def stop(self):
"""Use this function when you want to abort any motion from the motor."""
if IS_THREADED:
self._stop_thread()
self._target_pos = self._steps
def _stop_thread(self):
self._brake = True
if self._move_thread is not None:
self._move_thread.join()
self._move_thread = None
self._brake = False
def _start_thread(self):
self._move_thread = Thread(target=self._move_steps)
self._move_thread.start()
def _move_steps(self):
do_while = True
while do_while:
self.sync()
do_while = not self._brake
[docs] def sync(self):
"""This function should be used only once per main loop iteration. It will trigger stepping operations on the motor if needed."""
if self._steps != self._target_pos and time.monotonic() < self._end_delay_time:
# print(self._steps, '!=', self._target_pos)
# iterate self._steps
self._step()
# write to pins
self._write()
# wait a certain amount of time based on motor speed
self._end_delay_time = time.monotonic() + self._delay
else:
self._brake = True
[docs] def reset0angle(self):
"""A calibrating function that will reset the motor's zero angle to its current position.
This function is also called when the motor's `value`, `steps`, or `angle`
attributes are set to `None`. Additionally, this function will stop all movement in the
motor.
"""
self.stop()
self._steps, self._target_pos = (0, 0)
def _wrap_it(self, maximum, minimum=0, theta=None):
"""
Ensure that argument 'theta' is kept accordingly within range [minimum,maximum]
"""
if theta is None:
theta = self._it
while theta >= maximum:
theta -= maximum
while theta < minimum:
theta += maximum
return theta
@property
def _is_cw(self):
"""determine the shortest route toward target position"""
curr_pos = self._wrap_it(self._spr, 0, self._steps % self._spr)
if curr_pos > self._target_pos:
return curr_pos - self._target_pos < self._spr / 2
return self._target_pos - curr_pos > self._spr / 2
def _step(self):
""" increment or decrement _steps """
if self._is_cw: # going CW
self._steps -= 1
self._it -= 1
else: # going CCW
self._steps += 1
self._it += 1
def _write(self):
"""change pin states based on _steps iterator"""
if self._step_type == "half":
max_steps = 8
self._it = self._wrap_it(max_steps)
base = floor(self._it / 2)
plus1 = base + 1 if base + 1 < len(self._pins) else 0
for i, pin in enumerate(self._pins):
if i == base or (i == plus1 and self._it % 2 == 1):
pin.value = True
else:
pin.value = False
elif self._step_type == "full":
max_steps = 4
self._it = self._wrap_it(max_steps)
plus1 = self._it + 1 if self._it + 1 < len(self._pins) else 0
for i, pin in enumerate(self._pins):
if i in (self._it, plus1):
pin.value = True
else:
pin.value = False
elif self._step_type == "wave":
max_steps = 4
self._it = self._wrap_it(max_steps)
for i, pin in enumerate(self._pins):
if i == self._it:
pin.value = True
else:
pin.value = False
@property
def _delay(self):
"""time (in seconds) to delay between iterations based on motor's rpm attribute.
(read-only)
"""
return self._rpm / 60000.0
@property
def rpm(self):
"""This `int` attribute contains the maximum Rotations Per Minute and can be changed at any
time.
"""
return self._rpm
@rpm.setter
def rpm(self, val):
self._rpm = int(val)
@property
def _pin_bin(self):
"""get all stepper's pin's boolean values as binary number where each bit represents a
pin
"""
pin_bin = 0b0
for i in range(len(self._pins)):
pin_bin = (pin_bin << 1) | int(self._pins[i].value)
return pin_bin
def __repr__(self):
return 'pins: {} Angle: {} Steps: {} Value: {}'.format(
bin(self._pin_bin), self.angle, self.steps, self.value)
@property
def angle(self):
"""Represents the number of the motor's angle from its zero angle position with respect to
the ``steps_per_rev`` parameter passed to the constructor. This value will
be in range [-180, 180]. Input values can be any `int` or `float` as any overflow
outside the range [0, 360] is handled accordingly.
"""
return self._wrap_it(360, 0, (self._steps % self._spr) * self._dps)
@angle.setter
def angle(self, val):
# _wrap_it angle to constraints of [0,360] degrees
if val is None:
self.reset0angle()
else:
self.stop()
self._target_pos = ceil(self._wrap_it(360, 0, val) / self._dps)
# print('targetPos =', self._target_pos, 'curr_pos =', self.steps)
if IS_THREADED:
self._start_thread()
else:
self.sync()
@property
def steps(self):
"""Represents the number of the motor's steps from its zero angle position with respect to
the ``steps_per_rev`` parameter passed to the constructor. This value will
be in range [``steps_per_rev / -2``, ``steps_per_rev / 2``]. Input values
can be any `int` as any overflow outside the range [``0``, ``steps_per_rev``] is
handled accordingly.
"""
return self._wrap_it(self._spr, 0, self._steps)
@steps.setter
def steps(self, val):
if val is None:
self.reset0angle()
else:
self.stop()
self._target_pos = self._wrap_it(self._spr, 0, val)
# print('targetPos =', self._target_pos, 'curr_pos =', self.steps)
if IS_THREADED:
self._start_thread()
else:
self.sync()
@property
def value(self):
"""Represents the percentual value of the motor's angle in range [-100, 100] with respect to
the ``steps_per_rev`` parameter passed to the constructor. Invalid input values
will be constrained to an `int` in the range [-100, 100].
"""
return round(self._wrap_it(self._spr / 2, self._spr / -2, self._steps) / (self._spr / 2) * 100, 1)
@value.setter
def value(self, val):
if val is None:
self.reset0angle()
else:
self.stop()
self._target_pos = self._wrap_it(self._spr, 0, round(self._spr * max(-100.0, min(100.0, val)) / 200.0))
# print('targetPos =', self._target_pos, 'curr_pos =', self.steps)
if IS_THREADED:
self._start_thread()
else:
self.sync()
def __del__(self):
for pin in self._pins:
pin.deinit()