Add Servo and AngularServo implementation along with docs and tests.
This is a deliberately minimal implementation designed to be added to as
we agree on new extensions (better than making an all-singing,
all-dancing version in which I get things wrong and then wind up making
backward incompatible changes to get it right :)
This commit is contained in:
Dave Jones
2016-06-15 23:34:50 +01:00
parent 20df5e4249
commit 02f7d20bc3
8 changed files with 489 additions and 29 deletions

View File

@@ -43,6 +43,20 @@ Motor
.. autoclass:: Motor(forward, backward, pwm=True)
:members: forward, backward, stop
Servo
=====
.. autoclass:: Servo(pin, initial_value=0, min_pulse_width=1/1000, max_pulse_width=2/1000, frame_width=20/1000)
:inherited-members:
:members:
AngularServo
============
.. autoclass:: AngularServo(pin, initial_angle=0, min_angle=-90, max_angle=90, min_pulse_width=1/1000, max_pulse_width=2/1000, frame_width=20/1000)
:inherited-members:
:members:
Base Classes
============

View File

@@ -32,5 +32,7 @@ digraph classes {
RyanteckRobot->Robot;
CamJamKitRobot->Robot;
Motor->CompositeDevice;
Servo->CompositeDevice;
AngularServo->Servo;
Energenie->Device;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -4,25 +4,25 @@
<!-- Generated by graphviz version 2.38.0 (20140413.2041)
-->
<!-- Title: classes Pages: 1 -->
<svg width="587pt" height="476pt"
viewBox="0.00 0.00 587.00 476.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<svg width="687pt" height="476pt"
viewBox="0.00 0.00 686.50 476.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 472)">
<title>classes</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-472 583,-472 583,4 -4,4"/>
<polygon fill="white" stroke="none" points="-4,4 -4,-472 682.5,-472 682.5,4 -4,4"/>
<!-- Device -->
<g id="node1" class="node"><title>Device</title>
<polygon fill="#9ec6e0" stroke="#9ec6e0" points="499,-468 445,-468 445,-432 499,-432 499,-468"/>
<text text-anchor="middle" x="472" y="-447.5" font-family="Sans" font-size="10.00" fill="#000000">Device</text>
<polygon fill="#9ec6e0" stroke="#9ec6e0" points="553,-468 499,-468 499,-432 553,-432 553,-468"/>
<text text-anchor="middle" x="526" y="-447.5" font-family="Sans" font-size="10.00" fill="#000000">Device</text>
</g>
<!-- CompositeDevice -->
<g id="node2" class="node"><title>CompositeDevice</title>
<polygon fill="#9ec6e0" stroke="#9ec6e0" points="472.5,-396 371.5,-396 371.5,-360 472.5,-360 472.5,-396"/>
<text text-anchor="middle" x="422" y="-375.5" font-family="Sans" font-size="10.00" fill="#000000">CompositeDevice</text>
<polygon fill="#9ec6e0" stroke="#9ec6e0" points="526.5,-396 425.5,-396 425.5,-360 526.5,-360 526.5,-396"/>
<text text-anchor="middle" x="476" y="-375.5" font-family="Sans" font-size="10.00" fill="#000000">CompositeDevice</text>
</g>
<!-- CompositeDevice&#45;&gt;Device -->
<g id="edge1" class="edge"><title>CompositeDevice&#45;&gt;Device</title>
<path fill="none" stroke="black" d="M434.36,-396.303C440.233,-404.526 447.369,-414.517 453.842,-423.579"/>
<polygon fill="black" stroke="black" points="451.122,-425.793 459.783,-431.896 456.818,-421.724 451.122,-425.793"/>
<path fill="none" stroke="black" d="M488.36,-396.303C494.233,-404.526 501.369,-414.517 507.842,-423.579"/>
<polygon fill="black" stroke="black" points="505.122,-425.793 513.783,-431.896 510.818,-421.724 505.122,-425.793"/>
</g>
<!-- CompositeOutputDevice -->
<g id="node3" class="node"><title>CompositeOutputDevice</title>
@@ -31,8 +31,8 @@
</g>
<!-- CompositeOutputDevice&#45;&gt;CompositeDevice -->
<g id="edge2" class="edge"><title>CompositeOutputDevice&#45;&gt;CompositeDevice</title>
<path fill="none" stroke="black" d="M335.888,-324.124C350.793,-333.276 369.329,-344.658 385.382,-354.515"/>
<polygon fill="black" stroke="black" points="383.652,-357.56 394.005,-359.81 387.315,-351.595 383.652,-357.56"/>
<path fill="none" stroke="black" d="M349.097,-324.124C372.166,-333.736 401.136,-345.807 425.57,-355.987"/>
<polygon fill="black" stroke="black" points="424.459,-359.316 435.036,-359.932 427.151,-352.855 424.459,-359.316"/>
</g>
<!-- LEDCollection -->
<g id="node4" class="node"><title>LEDCollection</title>
@@ -136,13 +136,13 @@
</g>
<!-- Robot -->
<g id="node14" class="node"><title>Robot</title>
<polygon fill="#2980b9" stroke="#2980b9" points="449,-324 395,-324 395,-288 449,-288 449,-324"/>
<text text-anchor="middle" x="422" y="-303.5" font-family="Sans" font-size="10.00" fill="#ffffff">Robot</text>
<polygon fill="#2980b9" stroke="#2980b9" points="467,-324 413,-324 413,-288 467,-288 467,-324"/>
<text text-anchor="middle" x="440" y="-303.5" font-family="Sans" font-size="10.00" fill="#ffffff">Robot</text>
</g>
<!-- Robot&#45;&gt;CompositeDevice -->
<g id="edge13" class="edge"><title>Robot&#45;&gt;CompositeDevice</title>
<path fill="none" stroke="black" d="M422,-324.303C422,-332.017 422,-341.288 422,-349.888"/>
<polygon fill="black" stroke="black" points="418.5,-349.896 422,-359.896 425.5,-349.896 418.5,-349.896"/>
<path fill="none" stroke="black" d="M448.899,-324.303C452.997,-332.272 457.949,-341.9 462.493,-350.736"/>
<polygon fill="black" stroke="black" points="459.517,-352.604 467.203,-359.896 465.742,-349.402 459.517,-352.604"/>
</g>
<!-- RyanteckRobot -->
<g id="node15" class="node"><title>RyanteckRobot</title>
@@ -151,8 +151,8 @@
</g>
<!-- RyanteckRobot&#45;&gt;Robot -->
<g id="edge14" class="edge"><title>RyanteckRobot&#45;&gt;Robot</title>
<path fill="none" stroke="black" d="M422,-252.303C422,-260.017 422,-269.288 422,-277.888"/>
<polygon fill="black" stroke="black" points="418.5,-277.896 422,-287.896 425.5,-277.896 418.5,-277.896"/>
<path fill="none" stroke="black" d="M426.449,-252.303C428.455,-260.102 430.869,-269.491 433.101,-278.171"/>
<polygon fill="black" stroke="black" points="429.722,-279.082 435.602,-287.896 436.501,-277.339 429.722,-279.082"/>
</g>
<!-- CamJamKitRobot -->
<g id="node16" class="node"><title>CamJamKitRobot</title>
@@ -161,28 +161,48 @@
</g>
<!-- CamJamKitRobot&#45;&gt;Robot -->
<g id="edge15" class="edge"><title>CamJamKitRobot&#45;&gt;Robot</title>
<path fill="none" stroke="black" d="M504.336,-252.124C490.216,-261.192 472.689,-272.448 457.439,-282.241"/>
<polygon fill="black" stroke="black" points="455.29,-279.462 448.767,-287.81 459.073,-285.352 455.29,-279.462"/>
<path fill="none" stroke="black" d="M508.506,-252.303C497.045,-261.119 482.942,-271.968 470.516,-281.526"/>
<polygon fill="black" stroke="black" points="468.028,-279.024 462.236,-287.896 472.296,-284.573 468.028,-279.024"/>
</g>
<!-- Motor -->
<g id="node17" class="node"><title>Motor</title>
<polygon fill="#2980b9" stroke="#2980b9" points="521,-324 467,-324 467,-288 521,-288 521,-324"/>
<text text-anchor="middle" x="494" y="-303.5" font-family="Sans" font-size="10.00" fill="#ffffff">Motor</text>
<polygon fill="#2980b9" stroke="#2980b9" points="539,-324 485,-324 485,-288 539,-288 539,-324"/>
<text text-anchor="middle" x="512" y="-303.5" font-family="Sans" font-size="10.00" fill="#ffffff">Motor</text>
</g>
<!-- Motor&#45;&gt;CompositeDevice -->
<g id="edge16" class="edge"><title>Motor&#45;&gt;CompositeDevice</title>
<path fill="none" stroke="black" d="M476.202,-324.303C467.396,-332.865 456.618,-343.344 446.999,-352.696"/>
<polygon fill="black" stroke="black" points="444.323,-350.415 439.593,-359.896 449.203,-355.434 444.323,-350.415"/>
<path fill="none" stroke="black" d="M503.101,-324.303C499.003,-332.272 494.051,-341.9 489.507,-350.736"/>
<polygon fill="black" stroke="black" points="486.258,-349.402 484.797,-359.896 492.483,-352.604 486.258,-349.402"/>
</g>
<!-- Servo -->
<g id="node18" class="node"><title>Servo</title>
<polygon fill="#2980b9" stroke="#2980b9" points="647,-324 593,-324 593,-288 647,-288 647,-324"/>
<text text-anchor="middle" x="620" y="-303.5" font-family="Sans" font-size="10.00" fill="#ffffff">Servo</text>
</g>
<!-- Servo&#45;&gt;CompositeDevice -->
<g id="edge17" class="edge"><title>Servo&#45;&gt;CompositeDevice</title>
<path fill="none" stroke="black" d="M592.915,-320.166C572.488,-330.096 544.032,-343.929 520.308,-355.462"/>
<polygon fill="black" stroke="black" points="518.604,-352.398 511.14,-359.918 521.664,-358.694 518.604,-352.398"/>
</g>
<!-- AngularServo -->
<g id="node19" class="node"><title>AngularServo</title>
<polygon fill="#2980b9" stroke="#2980b9" points="678.5,-252 597.5,-252 597.5,-216 678.5,-216 678.5,-252"/>
<text text-anchor="middle" x="638" y="-231.5" font-family="Sans" font-size="10.00" fill="#ffffff">AngularServo</text>
</g>
<!-- AngularServo&#45;&gt;Servo -->
<g id="edge18" class="edge"><title>AngularServo&#45;&gt;Servo</title>
<path fill="none" stroke="black" d="M633.551,-252.303C631.545,-260.102 629.131,-269.491 626.899,-278.171"/>
<polygon fill="black" stroke="black" points="623.499,-277.339 624.398,-287.896 630.278,-279.082 623.499,-277.339"/>
</g>
<!-- Energenie -->
<g id="node18" class="node"><title>Energenie</title>
<polygon fill="#2980b9" stroke="#2980b9" points="555.5,-396 490.5,-396 490.5,-360 555.5,-360 555.5,-396"/>
<text text-anchor="middle" x="523" y="-375.5" font-family="Sans" font-size="10.00" fill="#ffffff">Energenie</text>
<g id="node20" class="node"><title>Energenie</title>
<polygon fill="#2980b9" stroke="#2980b9" points="609.5,-396 544.5,-396 544.5,-360 609.5,-360 609.5,-396"/>
<text text-anchor="middle" x="577" y="-375.5" font-family="Sans" font-size="10.00" fill="#ffffff">Energenie</text>
</g>
<!-- Energenie&#45;&gt;Device -->
<g id="edge17" class="edge"><title>Energenie&#45;&gt;Device</title>
<path fill="none" stroke="black" d="M510.393,-396.303C504.403,-404.526 497.124,-414.517 490.521,-423.579"/>
<polygon fill="black" stroke="black" points="487.522,-421.752 484.462,-431.896 493.179,-425.874 487.522,-421.752"/>
<g id="edge19" class="edge"><title>Energenie&#45;&gt;Device</title>
<path fill="none" stroke="black" d="M564.393,-396.303C558.403,-404.526 551.124,-414.517 544.521,-423.579"/>
<polygon fill="black" stroke="black" points="541.522,-421.752 538.462,-431.896 547.179,-425.874 541.522,-421.752"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -100,6 +100,8 @@ from .output_devices import (
LED,
Buzzer,
Motor,
Servo,
AngularServo,
RGBLED,
)
from .boards import (

View File

@@ -898,3 +898,313 @@ class Motor(SourceMixin, CompositeDevice):
self.forward_device.off()
self.backward_device.off()
class Servo(SourceMixin, CompositeDevice):
"""
Extends :class:`CompositeDevice` and represents a PWM-controlled servo
motor connected to a GPIO pin.
Connect a power source (e.g. a battery pack or the 5V pin) to the power
cable of the servo (this is typically colored red); connect the ground
cable of the servo (typically colored black or brown) to the negative of
your battery pack, or a GND pin; connect the final cable (typically colored
white or orange) to the GPIO pin you wish to use for controlling the servo.
The following code will make the servo move between its minimum, maximum,
and mid-point positions with a pause between each::
from gpiozero import Servo
from time import sleep
servo = Servo(17)
while True:
servo.min()
sleep(1)
servo.mid()
sleep(1)
servo.max()
sleep(1)
:param int pin:
The GPIO pin which the device is attached to. See :ref:`pin_numbering`
for valid pin numbers.
:param float initial_value:
If ``0`` (the default), the device's mid-point will be set
initially. Other values between -1 and +1 can be specified as an
initial position. ``None`` means to start the servo un-controlled (see
:attr:`value`).
:param float min_pulse_width:
The pulse width corresponding to the servo's minimum position. This
defaults to 1ms.
:param float max_pulse_width:
The pulse width corresponding to the servo's maximum position. This
defaults to 2ms.
:param float frame_width:
The length of time between servo control pulses measured in seconds.
This defaults to 20ms which is a common value for servos.
"""
def __init__(
self, pin=None, initial_value=0.0,
min_pulse_width=1/1000, max_pulse_width=2/1000,
frame_width=20/1000):
if min_pulse_width >= max_pulse_width:
raise ValueError('min_pulse_width must be less than max_pulse_width')
if max_pulse_width >= frame_width:
raise ValueError('max_pulse_width must be less than frame_width')
self._frame_width = frame_width
self._min_dc = min_pulse_width / frame_width
self._dc_range = (max_pulse_width - min_pulse_width) / frame_width
self._min_value = -1
self._value_range = 2
super(Servo, self).__init__(
pwm_device=PWMOutputDevice(pin, frequency=int(1 / frame_width)))
try:
self.value = initial_value
except:
self.close()
raise
@property
def frame_width(self):
"""
The time between control pulses, measured in seconds.
"""
return self._frame_width
@property
def min_pulse_width(self):
"""
The control pulse width corresponding to the servo's minimum position,
measured in seconds.
"""
return self._min_dc * self.frame_width
@property
def max_pulse_width(self):
"""
The control pulse width corresponding to the servo's maximum position,
measured in seconds.
"""
return (self._dc_range * self.frame_width) + self.min_pulse_width
@property
def pulse_width(self):
"""
Returns the current pulse width controlling the servo.
"""
if self.pwm_device.pin.frequency is None:
return None
else:
return self.pwm_device.pin.state * self.frame_width
def min(self):
"""
Set the servo to its minimum position.
"""
self.value = -1
def mid(self):
"""
Set the servo to its mid-point position.
"""
self.value = 0
def max(self):
"""
Set the servo to its maximum position.
"""
self.value = 1
def detach(self):
"""
Temporarily disable control of the servo. This is equivalent to
setting :attr:`value` to ``None``.
"""
self.value = None
def _get_value(self):
if self.pwm_device.pin.frequency is None:
return None
else:
return (
((self.pwm_device.pin.state - self._min_dc) / self._dc_range) *
self._value_range + self._min_value)
@property
def value(self):
"""
Represents the position of the servo as a value between -1 (the minimum
position) and +1 (the maximum position). This can also be the special
value ``None`` indicating that the servo is currently "uncontrolled",
i.e. that no control signal is being sent. Typically this means the
servo's position remains unchanged, but that it can be moved by hand.
"""
result = self._get_value()
if result is None:
return result
else:
# NOTE: This round() only exists to ensure we don't confuse people
# by returning 2.220446049250313e-16 as the default initial value
# instead of 0. The reason _get_value and _set_value are split
# out is for descendents that require the un-rounded values for
# accuracy
return round(result, 14)
@value.setter
def value(self, value):
if value is None:
self.pwm_device.pin.frequency = None
elif -1 <= value <= 1:
self.pwm_device.pin.frequency = int(1 / self.frame_width)
self.pwm_device.pin.state = (
self._min_dc + self._dc_range *
((value - self._min_value) / self._value_range)
)
else:
raise OutputDeviceBadValue(
"Servo value must be between -1 and 1, or None")
@property
def is_active(self):
return self.value is not None
class AngularServo(Servo):
"""
Extends :class:`Servo` and represents a rotational PWM-controlled servo
motor which can be set to particular angles (assuming valid minimum and
maximum angles are provided to the constructor).
Connect a power source (e.g. a battery pack or the 5V pin) to the power
cable of the servo (this is typically colored red); connect the ground
cable of the servo (typically colored black or brown) to the negative of
your battery pack, or a GND pin; connect the final cable (typically colored
white or orange) to the GPIO pin you wish to use for controlling the servo.
Next, calibrate the angles that the servo can rotate to. In an interactive
Python session, construct a :class:`Servo` instance. The servo should move
to its mid-point by default. Set the servo to its minimum value, and
measure the angle from the mid-point. Set the servo to its maximum value,
and again measure the angle::
>>> from gpiozero import Servo
>>> s = Servo(17)
>>> s.min() # measure the angle
>>> s.max() # measure the angle
You should now be able to construct an :class:`AngularServo` instance
with the correct bounds::
>>> from gpiozero import AngularServo
>>> s = AngularServo(17, min_angle=-42, max_angle=44)
>>> s.angle = 0.0
>>> s.angle
0.0
>>> s.angle = 15
>>> s.angle
15.0
.. note::
You can set *min_angle* greater than *max_angle* if you wish to reverse
the sense of the angles (e.g. ``min_angle=45, max_angle=-45``). This
can be useful with servos that rotate in the opposite direction to your
expectations of minimum and maximum.
:param int pin:
The GPIO pin which the device is attached to. See :ref:`pin_numbering`
for valid pin numbers.
:param float initial_angle:
Sets the servo's initial angle to the specified value. The default is
0. The value specified must be between *min_angle* and *max_angle*
inclusive. ``None`` means to start the servo un-controlled (see
:attr:`value`).
:param float min_angle:
Sets the minimum angle that the servo can rotate to. This defaults to
-90, but should be set to whatever you measure from your servo during
calibration.
:param float max_angle:
Sets the maximum angle that the servo can rotate to. This defaults to
90, but should be set to whatever you measure from your servo during
calibration.
:param float min_pulse_width:
The pulse width corresponding to the servo's minimum position. This
defaults to 1ms.
:param float max_pulse_width:
The pulse width corresponding to the servo's maximum position. This
defaults to 2ms.
:param float frame_width:
The length of time between servo control pulses measured in seconds.
This defaults to 20ms which is a common value for servos.
"""
def __init__(
self, pin=None, initial_angle=0.0,
min_angle=-90, max_angle=90,
min_pulse_width=1/1000, max_pulse_width=2/1000,
frame_width=20/1000):
self._min_angle = min_angle
self._angular_range = max_angle - min_angle
initial_value = 2 * ((initial_angle - min_angle) / self._angular_range) - 1
super(AngularServo, self).__init__(
pin, initial_value, min_pulse_width, max_pulse_width, frame_width)
@property
def min_angle(self):
"""
The minimum angle that the servo will rotate to when :meth:`min` is
called.
"""
return self._min_angle
@property
def max_angle(self):
"""
The maximum angle that the servo will rotate to when :meth:`max` is
called.
"""
return self._min_angle + self._angular_range
@property
def angle(self):
"""
The position of the servo as an angle measured in degrees. This will
only be accurate if *min_angle* and *max_angle* have been set
appropriately in the constructor.
This can also be the special value ``None`` indicating that the servo
is currently "uncontrolled", i.e. that no control signal is being sent.
Typically this means the servo's position remains unchanged, but that
it can be moved by hand.
"""
result = self._get_value()
if result is None:
return None
else:
# NOTE: Why round(n, 12) here instead of 14? Angle ranges can be
# much larger than -1..1 so we need a little more rounding to
# smooth off the rough corners!
return round(
self._angular_range *
((result - self._min_value) / self._value_range) +
self._min_angle, 12)
@angle.setter
def angle(self, value):
if value is None:
self.value = None
else:
self.value = (
self._value_range *
((value - self._min_angle) / self._angular_range) +
self._min_value)

View File

@@ -920,3 +920,115 @@ def test_motor_reverse_nonpwm():
device.reverse()
assert device.value == -1
assert b.state == 1 and f.state == 0
def test_servo_pins():
p = MockPWMPin(1)
with Servo(p) as device:
assert device.pwm_device.pin is p
assert isinstance(device.pwm_device, PWMOutputDevice)
def test_servo_bad_value():
p = MockPWMPin(1)
with pytest.raises(ValueError):
Servo(p, initial_value=2)
with pytest.raises(ValueError):
Servo(p, min_pulse_width=30/1000)
with pytest.raises(ValueError):
Servo(p, max_pulse_width=30/1000)
def test_servo_pins_nonpwm():
p = MockPin(2)
with pytest.raises(PinPWMUnsupported):
Servo(p)
def test_servo_close():
p = MockPWMPin(2)
with Servo(p) as device:
device.close()
assert device.closed
assert device.pwm_device.pin is None
device.close()
assert device.closed
def test_servo_pulse_width():
p = MockPWMPin(2)
with Servo(p, min_pulse_width=5/10000, max_pulse_width=25/10000) as device:
assert isclose(device.min_pulse_width, 5/10000)
assert isclose(device.max_pulse_width, 25/10000)
assert isclose(device.frame_width, 20/1000)
assert isclose(device.pulse_width, 15/10000)
device.value = -1
assert isclose(device.pulse_width, 5/10000)
device.value = 1
assert isclose(device.pulse_width, 25/10000)
device.value = None
assert device.pulse_width is None
def test_servo_values():
p = MockPWMPin(1)
with Servo(p) as device:
device.min()
assert device.is_active
assert device.value == -1
assert isclose(p.state, 0.05)
device.max()
assert device.is_active
assert device.value == 1
assert isclose(p.state, 0.1)
device.mid()
assert device.is_active
assert device.value == 0.0
assert isclose(p.state, 0.075)
device.value = 0.5
assert device.is_active
assert device.value == 0.5
assert isclose(p.state, 0.0875)
device.detach()
assert not device.is_active
assert device.value is None
device.value = 0
assert device.value == 0
device.value = None
assert device.value is None
def test_angular_servo_range():
p = MockPWMPin(1)
with AngularServo(p, initial_angle=15, min_angle=0, max_angle=90) as device:
assert device.min_angle == 0
assert device.max_angle == 90
def test_angular_servo_angles():
p = MockPWMPin(1)
with AngularServo(p) as device:
device.angle = 0
assert device.angle == 0
assert isclose(device.value, 0)
device.max()
assert device.angle == 90
assert isclose(device.value, 1)
device.min()
assert device.angle == -90
assert isclose(device.value, -1)
device.detach()
assert device.angle is None
with AngularServo(p, initial_angle=15, min_angle=0, max_angle=90) as device:
assert device.angle == 15
assert isclose(device.value, -2/3)
device.angle = 0
assert device.angle == 0
assert isclose(device.value, -1)
device.angle = 90
assert device.angle == 90
assert isclose(device.value, 1)
device.angle = None
assert device.angle is None
with AngularServo(p, min_angle=45, max_angle=-45) as device:
assert device.angle == 0
assert isclose(device.value, 0)
device.angle = -45
assert device.angle == -45
assert isclose(device.value, 1)
device.angle = -15
assert device.angle == -15
assert isclose(device.value, 1/3)