diff --git a/docs/api.rst b/docs/api.rst index 214a6dc..7a461b9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -56,6 +56,13 @@ Speaker :inherited-members: :members: +Servo +----- +.. autoclass:: Servo + :show-inheritance: + :inherited-members: + :members: + Motor ----- diff --git a/docs/examples/servo_move.py b/docs/examples/servo_move.py new file mode 100644 index 0000000..9e1c4e5 --- /dev/null +++ b/docs/examples/servo_move.py @@ -0,0 +1,15 @@ +from picozero import Servo +from time import sleep + +servo = Servo(1) + +servo.min() +sleep(1) + +servo.mid() +sleep(1) + +servo.max() +sleep(1) + +servo.off() \ No newline at end of file diff --git a/docs/examples/servo_pulse.py b/docs/examples/servo_pulse.py new file mode 100644 index 0000000..40ffa56 --- /dev/null +++ b/docs/examples/servo_pulse.py @@ -0,0 +1,5 @@ +from picozero import Servo + +servo = Servo(1) + +servo.pulse() \ No newline at end of file diff --git a/docs/examples/servo_sweep.py b/docs/examples/servo_sweep.py new file mode 100644 index 0000000..36640db --- /dev/null +++ b/docs/examples/servo_sweep.py @@ -0,0 +1,10 @@ +from picozero import Servo +from time import sleep + +servo = Servo(1) + +for i in range(0, 100): + servo.value = i / 100 + sleep(0.1) + +servo.off() diff --git a/docs/images/servo.svg b/docs/images/servo.svg new file mode 100644 index 0000000..42dbf43 --- /dev/null +++ b/docs/images/servo.svg @@ -0,0 +1,648 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + D + + + E + + + B + + + U + + + G + + + B + + + O + + + O + + + T + + + S + + + E + + + L + + + L + + + E + + + D + + + 1 + + + 2 + + + 3 + + + 9 + + + U + + + S + + + B + + + + R + + + a + + + s + + + p + + + b + + + e + + + r + + + r + + + y + + + + + + P + + + i + + + + + + P + + + i + + + c + + + o + + + + + + © + + + 2 + + + 0 + + + 2 + + + 0 + + + R + + + P + + + 2 + + + - + + + 8 + + + 0 + + + + + + + + + + + + + + + 2 + + + 0 + + + / + + + 2 + + + 1 + + + P + + + 6 + + + 4 + + + M + + + 1 + + + 5 + + + . + + + 0 + + + 0 + + + + + + T + + + T + + + T + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/recipes.rst b/docs/recipes.rst index bd6c2a3..87438d1 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -182,6 +182,26 @@ Play individual notes and control the timing or perform another action: .. literalinclude:: examples/speaker_notes.py +Servo +----- + +A servo motor connected to a single pin, 3.3v and ground. + +.. image:: images/servo.svg + :alt: A diagram of the Raspberry Pi Pico connected to a servo motor + +Move the servo to its minimum, mid and maximum positions. + +.. literalinclude:: examples/servo_move.py + +Pulse the servo between its minumum and maximum position. + +.. literalinclude:: examples/servo_pulse.py + +Move the servo gradually from its minimum to maximum position in 100 increments. + +.. literalinclude:: examples/servo_sweep.py + Motor ----- diff --git a/docs/sketches/servo.fzz b/docs/sketches/servo.fzz new file mode 100644 index 0000000..e053fba Binary files /dev/null and b/docs/sketches/servo.fzz differ diff --git a/picozero/__init__.py b/picozero/__init__.py index 97630ee..6c6b963 100644 --- a/picozero/__init__.py +++ b/picozero/__init__.py @@ -21,6 +21,7 @@ RGBLED, Motor, Robot, + Servo, DigitalInputDevice, Switch, diff --git a/picozero/picozero.py b/picozero/picozero.py index a94c05e..03370cf 100644 --- a/picozero/picozero.py +++ b/picozero/picozero.py @@ -16,6 +16,8 @@ class EventFailedScheduleQueueFull(Exception): # SUPPORTING CLASSES ############################################################################### +def clamp(n, low, high): return max(low, min(n, high)) + class PinMixin: """ Mixin used by devices that have a single pin number. @@ -358,8 +360,8 @@ class PWMOutputDevice(OutputDevice, PinMixin): LOW (the :meth:`off` method always does the opposite). :param bool initial_value: - If :data:`False` (the default), the LED will be off initially. If - :data:`True`, the LED will be switched on initially. + If :data:`0` (the default), the device will be off initially. If + :data:`1`, the device will be switched on initially. """ PIN_TO_PWM_CHANNEL = ["0A","0B","1A","1B","2A","2B","3A","3B","4A","4B","5A","5B","6A","6B","7A","7B","0A","0B","1A","1B","2A","2B","3A","3B","4A","4B","5A","5B","6A","6B"] @@ -1393,6 +1395,74 @@ def close(self): Rover = Robot +class Servo(PWMOutputDevice): + """ + Represents a PWM-controlled servo motor. + + Setting the `value` to 0 will move the servo to its minimum position, + 1 will move the servo to its maximum position. Setting the `value` to + :data:`None` will turn the servo "off" (i.e. no signal is sent). + + :type pin: int + :param pin: + The pin the servo motor is connected to. + + :param bool initial_value: + If :data:`0`, the servo will be set to its minimum position. If + :data:`1`, the servo will set to its maximum position. If :data:`None` + (the default), the position of the servo will not change. + + :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. + + :param int duty_factor: + The duty factor of the PWM signal. This is a value between 0 and 65535. + Defaults to 65535. + """ + def __init__(self, pin, initial_value=None, min_pulse_width=1/1000, max_pulse_width=2/1000, frame_width=20/1000, duty_factor=65535): + self._min_duty = int((min_pulse_width / frame_width) * duty_factor) + self._max_duty = int((max_pulse_width / frame_width) * duty_factor) + + super().__init__(pin, freq=int(1 / frame_width), duty_factor=duty_factor, initial_value=initial_value) + + def _state_to_value(self, state): + return None if state == 0 else clamp((state - self._min_duty) / (self._max_duty - self._min_duty), 0, 1) + + def _value_to_state(self, value): + return 0 if value is None else int(self._min_duty + ((self._max_duty - self._min_duty) * value)) + + def min(self): + """ + Set the servo to its minimum position. + """ + self.value = 0 + + def mid(self): + """ + Set the servo to its mid-point position. + """ + self.value = 0.5 + + def max(self): + """ + Set the servo to its maximum position. + """ + self.value = 1 + + def off(self): + """ + Turn the servo "off" by setting the value to `None`. + """ + self.value = None ############################################################################### # INPUT DEVICES diff --git a/tests/test_picozero.py b/tests/test_picozero.py index 9b216f1..bbd4afa 100644 --- a/tests/test_picozero.py +++ b/tests/test_picozero.py @@ -62,7 +62,12 @@ def reset(self): self._is_set = False class Testpicozero(unittest.TestCase): - + + def assertInRange(self, value, lower, upper): + msg = "Expected %r to be in range {} to {}".format(lower, upper) + self.assertTrue(value <= upper, msg) + self.assertTrue(value >= lower, msg) + ########################################################################### # OUTPUT DEVICES ########################################################################### @@ -317,10 +322,6 @@ def test_pico_led(self): pico_led.off() self.assertEqual(pico_led.value, 0) - ########################################################################### - # INPUT DEVICES - ########################################################################### - def test_rgb_led_default_values(self): d = RGBLED(1,2,3) @@ -366,6 +367,57 @@ def test_rgb_led_alt_values(self): self.assertEqual(d.value, (0,1,1)) d.close() + + def test_servo_default_value(self): + d = Servo(1) + + self.assertEqual(d.value, None) + + d.value = 0 + self.assertAlmostEqual(d.value, 0, 2) + self.assertInRange(d._pwm.duty_u16(), int((0.001 / 0.02) * 65535) - 1, int((0.001 / 0.02) * 65535) + 1) + + d.value = 1 + self.assertAlmostEqual(d.value, 1, 2) + self.assertInRange(d._pwm.duty_u16(), int((0.002 / 0.02) * 65535) - 1, int((0.002 / 0.02) * 65535) + 1) + + d.value = None + self.assertEqual(d.value, None) + self.assertEqual(d._pwm.duty_u16(), 0) + + d.min() + self.assertAlmostEqual(d.value, 0, 2) + + d.mid() + self.assertAlmostEqual(d.value, 0.5, 2) + + d.max() + self.assertAlmostEqual(d.value, 1, 2) + + d.off() + self.assertEqual(d._pwm.duty_u16(), 0) + + d.close() + + def test_servo_alt_values(self): + d = Servo(1, initial_value=1, min_pulse_width=0.9/1000, max_pulse_width=2.1/1000, frame_width=19/1000) + + self.assertAlmostEqual(d.value, 1, 2) + + d.value = 0 + self.assertInRange(d._pwm.duty_u16(), int((0.0009 / 0.019) * 65535) - 1, int((0.0009 / 0.019) * 65535) + 1) + + d.value = 1 + self.assertInRange(d._pwm.duty_u16(), int((0.0021 / 0.019) * 65535) - 1, int((0.0021 / 0.019) * 65535) + 1) + + d.value = None + self.assertEqual(d._pwm.duty_u16(), 0) + + d.close() + + ########################################################################### + # INPUT DEVICES + ########################################################################### def test_digital_input_device_default_values(self): d = DigitalInputDevice(1)