diff --git a/docs/api.rst b/docs/api.rst index 782d90c..2067d3e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -63,6 +63,14 @@ Servo :inherited-members: :members: +Stepper +------- + +.. autoclass:: Stepper + :show-inheritance: + :inherited-members: + :members: + Motor ----- diff --git a/docs/changelog.rst b/docs/changelog.rst index 8c397cc..1aae270 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,8 @@ Change log ----------- + Introduced ``MotionSensor`` class for PIR sensors ++ Introduced ``Stepper`` class for stepper motor control ++ Added comprehensive stepper motor API with multiple control methods 0.4.2 - 2023-05-12 ------------------ diff --git a/docs/examples/stepper.py b/docs/examples/stepper.py new file mode 100644 index 0000000..91b45bd --- /dev/null +++ b/docs/examples/stepper.py @@ -0,0 +1,96 @@ +from picozero import Stepper +from time import sleep + +stepper = Stepper((1, 2, 3, 4)) + +print(f"Starting position: {stepper.step_count} steps, {stepper.angle:.1f} degrees") +sleep(0.5) +print() + +print("Using step() with default clockwise direction:") +stepper.step(10) +print(f"Position after step(10): {stepper.step_count} steps") +sleep(0.5) +print() + +print("step_clockwise(20):") +stepper.step_clockwise(20) +print(f"Position: {stepper.step_count} steps") +sleep(0.5) +print() + +print("step_counterclockwise(15):") +stepper.step_counterclockwise(15) +print(f"Position: {stepper.step_count} steps") +sleep(0.5) +print() + +print("step_ccw(5) - short alias:") +stepper.step_ccw(5) +print(f"Position: {stepper.step_count} steps") +sleep(0.5) +print() + +print("step(10, direction=1) - numeric:") +stepper.step(10, direction=1) +print(f"Position: {stepper.step_count} steps") +print() + +print("step(10, direction='cw') - string abbreviation:") +stepper.step(10, direction='cw') +print(f"Position: {stepper.step_count} steps") +sleep(0.5) +print() + +print("step(10, direction='clockwise') - full string:") +stepper.step(10, direction='clockwise') +print(f"Position: {stepper.step_count} steps") +sleep(0.5) +print() + +print("rotate_clockwise(90) - 90 degrees CW:") +stepper.rotate_clockwise(90) +print(f"Position: {stepper.step_count} steps, {stepper.angle:.1f} degrees") +sleep(0.5) +print() + +print("rotate(45, direction='ccw') - 45 degrees CCW:") +stepper.rotate(45, direction='ccw') +print(f"Position: {stepper.step_count} steps, {stepper.angle:.1f} degrees") +sleep(0.5) +print() + +print("revolve_clockwise() - 1 full revolution CW:") +stepper.revolve_clockwise() # Default is 1 revolution +print(f"Position: {stepper.step_count} steps, {stepper.angle:.1f} degrees") +sleep(0.5) +print() + +print("revolve(0.5, direction=-1) - half revolution CCW:") +stepper.revolve(0.5, direction=-1) +print(f"Position: {stepper.step_count} steps, {stepper.angle:.1f} degrees") +sleep(0.5) +print() + +print("Resetting position to home (0 steps, 0°)...") +stepper.reset_position() +print(f"After reset: {stepper.step_count} steps, {stepper.angle:.1f} degrees") +sleep(0.5) +print() + +print() +print("Final demonstration - all methods achieve the same result:") +for i, (method_name, method_call) in enumerate([ + ("Default direction", lambda: stepper.step(5)), + ("Numeric direction", lambda: stepper.step(5, direction=1)), + ("String direction", lambda: stepper.step(5, direction='cw')), + ("Convenience method", lambda: stepper.step_clockwise(5)) +], 1): + print(f"{i}. {method_name}: 5 steps clockwise") + method_call() + +print(f"All methods reached: {stepper.step_count} steps total") + +# Turn off motor when done +stepper.off() +stepper.close() diff --git a/docs/examples/stepper_positioning.py b/docs/examples/stepper_positioning.py new file mode 100644 index 0000000..6807407 --- /dev/null +++ b/docs/examples/stepper_positioning.py @@ -0,0 +1,110 @@ +from picozero import Stepper, pico_led +from time import sleep + +# Create devices +stepper = Stepper((1, 2, 3, 4), step_sequence="half", step_delay=0.003) + +print("Stepper Motor Positioning Demo") +print("Press Ctrl+C to exit") +print() + +# Define preset positions in degrees +positions = [0, 90, 180, 270, 360, 270, 180, 90] # Forward and return cycle +position_names = ["Home", "90° CW", "180° CW", "270° CW", "Full rotation", "270° CCW", "180° CCW", "90° CCW"] + +def move_to_position(target_angle, description=""): + """Move stepper to target angle from current position.""" + current_angle = stepper.angle + angle_diff = target_angle - current_angle + + print(f"Target: {description} ({target_angle}°)") + print(f" Moving from {current_angle:.1f}° to {target_angle}°") + pico_led.on() # Indicate movement + + if angle_diff > 0: + print(f" → Rotating {angle_diff}° clockwise...") + stepper.rotate_clockwise(angle_diff) + elif angle_diff < 0: + print(f" → Rotating {-angle_diff}° counter-clockwise...") + stepper.rotate_counterclockwise(-angle_diff) + else: + print(" → Already at target position") + + pico_led.off() # Movement complete + print(f" ✓ Position: {stepper.angle:.1f}° ({stepper.step_count} steps)") + print() + +def demonstrate_positioning_methods(): + """Demonstrate different positioning approaches.""" + print("=== POSITIONING METHOD COMPARISON ===") + print() + + # Method 1: Using convenience methods (current approach) + print("Method 1: Convenience methods (rotate_clockwise/rotate_counterclockwise)") + stepper.reset_position() + move_to_position(90, "90° using rotate_clockwise") + move_to_position(45, "45° using rotate_counterclockwise") + + sleep(1) + + # Method 2: Using parameterized methods + print("Method 2: Parameterized methods (rotate with direction parameter)") + stepper.reset_position() + + print("Target: 120° using rotate(120, direction='cw')") + stepper.rotate(120, direction='cw') + print(f" ✓ Position: {stepper.angle:.1f}°") + + print("Target: 60° using rotate(60, direction='ccw')") + stepper.rotate(60, direction='ccw') + print(f" ✓ Position: {stepper.angle:.1f}°") + print() + + sleep(1) + +# Start demonstration +stepper.reset_position() +print(f"Starting position: {stepper.angle}° ({stepper.step_count} steps)") +print() + +try: + # Demonstrate positioning methods + demonstrate_positioning_methods() + + # Main positioning sequence + print("=== AUTOMATIC POSITIONING SEQUENCE ===") + print("Moving through preset positions...") + print() + + for i, (target_pos, name) in enumerate(zip(positions, position_names)): + print(f"Step {i+1}/8:") + move_to_position(target_pos, name) + sleep(1.5) + + print("=== ADVANCED POSITIONING DEMO ===") + print("Demonstrating precise positioning capabilities...") + print() + + # Reset and demonstrate fractional positioning + stepper.reset_position() + + # Small precise movements + precise_positions = [22.5, 67.5, 112.5, 157.5, 202.5] + for pos in precise_positions: + move_to_position(pos, f"Precise positioning to {pos}°") + sleep(1) + + # Return home + move_to_position(0, "Return to home") + + print("Positioning demonstration complete!") + +except KeyboardInterrupt: + print("\nInterrupted by user") + +finally: + # Clean shutdown + stepper.off() + pico_led.off() + stepper.close() + pico_led.close() diff --git a/docs/recipes.rst b/docs/recipes.rst index f0e29bf..c1a9778 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -276,6 +276,17 @@ Move the rover (roughly) in a square: .. literalinclude:: examples/robot_rover_square.py +Stepper motor +------------- + +Control a stepper motor connected via a driver board (e.g. ULN2003): + +.. literalinclude:: examples/stepper.py + +Advanced positioning with precise angle control: + +.. literalinclude:: examples/stepper_positioning.py + Internal temperature sensor --------------------------- @@ -283,6 +294,17 @@ Check the internal temperature of the Raspberry Pi Pico in degrees Celcius: .. literalinclude:: examples/pico_temperature.py +Motion sensor +------------- + +Detect motion using a PIR (Passive Infrared) sensor: + +.. literalinclude:: examples/motion_sensor.py + +Use callbacks to respond to motion events: + +.. literalinclude:: examples/motion_sensor_callbacks.py + Ultrasonic distance sensor -------------------------- diff --git a/picozero/__init__.py b/picozero/__init__.py index 83cb63c..fda5d26 100644 --- a/picozero/__init__.py +++ b/picozero/__init__.py @@ -23,6 +23,7 @@ RGBLED, Motor, Robot, + Stepper, Servo, DigitalInputDevice, diff --git a/picozero/picozero.py b/picozero/picozero.py index 2836e0b..f6285e3 100644 --- a/picozero/picozero.py +++ b/picozero/picozero.py @@ -1664,6 +1664,277 @@ def close(self): Rover = Robot +class Stepper(PinsMixin): + """ + Represents a stepper motor connected via a driver board (e.g. ULN2003). + + Supports both 4-pin unipolar stepper motors and bipolar steppers via driver boards. + + :param tuple pins: + A tuple of 4 pins connected to the stepper motor driver. + For ULN2003: (IN1, IN2, IN3, IN4) + + :param str step_sequence: + The stepping sequence to use. Options are: + - 'wave' - Wave drive (1 coil energized, lower torque, lower power) + - 'full' - Full step (2 coils energized, higher torque) + - 'half' - Half step (alternates between wave and full, smoother) + Defaults to 'full'. + + :param float step_delay: + Delay in seconds between steps. Smaller values = faster rotation. + Defaults to 0.002 (2ms) which gives ~500 steps/second. + + :param int steps_per_revolution: + Number of steps for a complete 360-degree rotation. + Common values: 2048 (28BYJ-48 with ULN2003), 200 (NEMA steppers). + Defaults to 2048. + """ + + # Step sequences for different drive modes + STEP_SEQUENCES = { + "wave": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], + "full": [[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1], [1, 0, 0, 1]], + "half": [ + [1, 0, 0, 0], + [1, 1, 0, 0], + [0, 1, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 1], + [1, 0, 0, 1], + ], + } + + def __init__( + self, pins, step_sequence="full", step_delay=0.002, steps_per_revolution=2048 + ): + if len(pins) != 4: + raise ValueError("Stepper requires exactly 4 pins") + + self._pin_nums = tuple(pins) + self._pins = tuple(DigitalOutputDevice(pin) for pin in pins) + self._step_delay = step_delay + self._steps_per_revolution = steps_per_revolution + self._current_step = 0 + self._step_count = 0 + + if step_sequence not in self.STEP_SEQUENCES: + raise ValueError( + f"Invalid step_sequence. Must be one of: {list(self.STEP_SEQUENCES.keys())}" + ) + self._step_sequence = step_sequence + self._sequence = self.STEP_SEQUENCES[step_sequence] + + # Turn off all coils initially + self._set_step([0, 0, 0, 0]) + + def _normalize_direction(self, direction): + """ + Normalize direction parameter to internal numeric representation. + + Accepts: + - Numeric: 1 or positive numbers for clockwise, -1 or negative for counter-clockwise + - String: 'cw' or 'clockwise' for clockwise, 'ccw' or 'counter-clockwise' for counter-clockwise + + Returns: 1 for clockwise, -1 for counter-clockwise + """ + if isinstance(direction, str): + direction_lower = direction.lower().strip() + if direction_lower in ("cw", "clockwise"): + return 1 + elif direction_lower in ("ccw", "counter-clockwise", "counterclockwise"): + return -1 + else: + raise ValueError( + f"Invalid direction string: '{direction}'. Use 'cw', 'ccw', 'clockwise', or 'counter-clockwise'" + ) + else: + # Numeric direction: positive = clockwise, negative = counter-clockwise + return 1 if direction >= 0 else -1 + + def _set_step(self, pattern): + """Set the pin states for a step pattern.""" + for pin, state in zip(self._pins, pattern): + pin.value = state + + def _single_step(self, direction=1): + """Execute a single step in the given direction.""" + normalized_direction = self._normalize_direction(direction) + + if normalized_direction > 0: # Clockwise + self._current_step = (self._current_step + 1) % len(self._sequence) + self._step_count += 1 + else: # Counter-clockwise + self._current_step = (self._current_step - 1) % len(self._sequence) + self._step_count -= 1 + + self._set_step(self._sequence[self._current_step]) + sleep(self._step_delay) + + def step(self, steps, direction=1): + """ + Move the stepper motor by a number of steps. + + :param int steps: + Number of steps to move. Must be positive. + + :param direction: + Direction to move. Accepts: + - Numeric: 1 or positive for clockwise, -1 or negative for counter-clockwise + - String: 'cw'/'clockwise' for clockwise, 'ccw'/'counter-clockwise' for counter-clockwise + Defaults to 1 (clockwise). + :type direction: int or str + """ + steps = abs(int(steps)) + + for _ in range(steps): + self._single_step(direction) + + def rotate(self, angle, direction=1): + """ + Rotate the stepper motor by a specific angle. + + :param float angle: + Angle to rotate in degrees. Must be positive. + + :param direction: + Direction to rotate. Accepts: + - Numeric: 1 or positive for clockwise, -1 or negative for counter-clockwise + - String: 'cw'/'clockwise' for clockwise, 'ccw'/'counter-clockwise' for counter-clockwise + Defaults to 1 (clockwise). + :type direction: int or str + """ + angle = abs(float(angle)) + steps = int((angle / 360.0) * self._steps_per_revolution) + self.step(steps, direction) + + def revolution(self, revolutions=1, direction=1): + """ + Rotate the stepper motor by full revolutions. + + :param float revolutions: + Number of full revolutions. Must be positive. + + :param direction: + Direction to rotate. Accepts: + - Numeric: 1 or positive for clockwise, -1 or negative for counter-clockwise + - String: 'cw'/'clockwise' for clockwise, 'ccw'/'counter-clockwise' for counter-clockwise + Defaults to 1 (clockwise). + :type direction: int or str + """ + revolutions = abs(float(revolutions)) + steps = int(revolutions * self._steps_per_revolution) + self.step(steps, direction) + + def reset_position(self): + """Reset the step counter to zero (home position).""" + self._step_count = 0 + + def step_clockwise(self, steps): + """ + Move the stepper motor clockwise by a number of steps. + + :param int steps: + Number of steps to move clockwise. Must be positive. + """ + self.step(steps, direction=1) + + def step_counterclockwise(self, steps): + """ + Move the stepper motor counter-clockwise by a number of steps. + + :param int steps: + Number of steps to move counter-clockwise. Must be positive. + """ + self.step(steps, direction=-1) + + def rotate_clockwise(self, angle): + """ + Rotate the stepper motor clockwise by a specific angle. + + :param float angle: + Angle to rotate clockwise in degrees. Must be positive. + """ + self.rotate(angle, direction=1) + + def rotate_counterclockwise(self, angle): + """ + Rotate the stepper motor counter-clockwise by a specific angle. + + :param float angle: + Angle to rotate counter-clockwise in degrees. Must be positive. + """ + self.rotate(angle, direction=-1) + + def revolve_clockwise(self, revolutions=1): + """ + Revolve the stepper motor clockwise by full revolutions. + + :param float revolutions: + Number of full clockwise revolutions. Must be positive. Defaults to 1. + """ + self.revolution(revolutions, direction=1) + + def revolve_counterclockwise(self, revolutions=1): + """ + Revolve the stepper motor counter-clockwise by full revolutions. + + :param float revolutions: + Number of full counter-clockwise revolutions. Must be positive. Defaults to 1. + """ + self.revolution(revolutions, direction=-1) + + def off(self): + """Turn off all coils to reduce power consumption.""" + self._set_step([0, 0, 0, 0]) + + @property + def step_delay(self): + """Get or set the delay between steps in seconds.""" + return self._step_delay + + @step_delay.setter + def step_delay(self, value): + self._step_delay = float(value) + + @property + def step_count(self): + """Get the current step count (can be negative).""" + return self._step_count + + @property + def angle(self): + """Get the current angle in degrees from the reset position.""" + return (self._step_count / self._steps_per_revolution) * 360.0 + + @property + def steps_per_revolution(self): + """Get the configured steps per full revolution.""" + return self._steps_per_revolution + + def close(self): + """Turn off the motor and close all pin resources.""" + self.off() + for pin in self._pins: + pin.close() + self._pins = None + + +# Convenience method aliases for Stepper +Stepper.step_cw = Stepper.step_clockwise +Stepper.step_ccw = Stepper.step_counterclockwise +Stepper.rotate_cw = Stepper.rotate_clockwise +Stepper.rotate_ccw = Stepper.rotate_counterclockwise +Stepper.revolve_cw = Stepper.revolve_clockwise +Stepper.revolve_ccw = Stepper.revolve_counterclockwise +Stepper.revolve = Stepper.revolution + +# Alias for backward compatibility +StepperMotor = Stepper + + class Servo(PWMOutputDevice): """ Represents a PWM-controlled servo motor. diff --git a/tests/test_picozero.py b/tests/test_picozero.py index e994cd2..c1ba02d 100644 --- a/tests/test_picozero.py +++ b/tests/test_picozero.py @@ -675,5 +675,152 @@ def test_pico_temp_sensor(self): self.assertEqual(pico_temp_sensor.pin, 4) self.assertIsNotNone(pico_temp_sensor.temp) + def test_stepper_basic_configuration(self): + """ + Test 1: Basic stepper motor initialization and configuration. + """ + stepper = Stepper((1, 2, 3, 4)) + + # Test initial configuration + self.assertEqual(stepper.pins, (1, 2, 3, 4)) + self.assertEqual(stepper.step_count, 0) + self.assertEqual(stepper.angle, 0.0) + self.assertEqual(stepper.steps_per_revolution, 2048) + self.assertEqual(stepper.step_delay, 0.002) + + # Test invalid pin count + with self.assertRaises(ValueError): + Stepper((1, 2, 3)) # Too few pins + + # Test invalid step sequence + with self.assertRaises(ValueError): + Stepper((1, 2, 3, 4), step_sequence="invalid") + + stepper.close() + + def test_stepper_simple_methods(self): + """ + Test 2: Simple single-parameter methods (convenience methods). + """ + stepper = Stepper((1, 2, 3, 4)) + + # Test default direction (should be clockwise) + initial_count = stepper.step_count + stepper.step(10) # Default direction + self.assertEqual(stepper.step_count, initial_count + 10) + + # Test convenience methods - clockwise + stepper.step_clockwise(5) + self.assertEqual(stepper.step_count, initial_count + 15) + + # Test convenience methods - counter-clockwise + stepper.step_counterclockwise(3) + self.assertEqual(stepper.step_count, initial_count + 12) + + # Test short aliases + stepper.step_cw(2) + self.assertEqual(stepper.step_count, initial_count + 14) + + stepper.step_ccw(4) + self.assertEqual(stepper.step_count, initial_count + 10) + + stepper.close() + + def test_stepper_parameterized_methods(self): + """ + Test 3: Parameterized methods with flexible direction handling. + """ + stepper = Stepper((1, 2, 3, 4)) + initial_count = stepper.step_count + + # Test numeric directions + stepper.step(10, direction=1) # Clockwise + self.assertEqual(stepper.step_count, initial_count + 10) + + stepper.step(5, direction=-1) # Counter-clockwise + self.assertEqual(stepper.step_count, initial_count + 5) + + # Test string directions + stepper.step(8, direction="cw") + self.assertEqual(stepper.step_count, initial_count + 13) + + stepper.step(3, direction="ccw") + self.assertEqual(stepper.step_count, initial_count + 10) + + # Test full string directions + stepper.step(5, direction="clockwise") + self.assertEqual(stepper.step_count, initial_count + 15) + + stepper.step(7, direction="counter-clockwise") + self.assertEqual(stepper.step_count, initial_count + 8) + + # Test invalid direction + with self.assertRaises(ValueError): + stepper.step(5, direction="invalid") + + stepper.close() + + def test_stepper_angle_and_revolution_methods(self): + """ + Test 4: Higher-level methods (angle-based and revolution-based). + """ + stepper = Stepper( + (1, 2, 3, 4), steps_per_revolution=200 + ) # Use smaller value for testing + + # Test angle calculations + initial_angle = stepper.angle + stepper.rotate(90, direction=1) + expected_steps = int((90 / 360.0) * 200) + self.assertEqual(stepper.step_count, expected_steps) + self.assertAlmostEqual(stepper.angle, 90.0, places=1) + + # Test revolution methods + stepper.reset_position() + stepper.revolution(0.5, direction=1) # Half revolution + self.assertEqual(stepper.step_count, 100) + self.assertAlmostEqual(stepper.angle, 180.0, places=1) + + # Test revolve alias + stepper.reset_position() + stepper.revolve(1, direction=1) # Full revolution + self.assertEqual(stepper.step_count, 200) + self.assertAlmostEqual(stepper.angle, 360.0, places=1) + + stepper.close() + + def test_stepper_advanced_features(self): + """ + Test 5: Advanced features (position tracking, sequences, etc.). + """ + stepper = Stepper((1, 2, 3, 4), step_sequence="half", steps_per_revolution=100) + + # Test different step sequences + self.assertEqual(len(stepper.STEP_SEQUENCES["half"]), 8) + self.assertEqual(len(stepper.STEP_SEQUENCES["full"]), 4) + self.assertEqual(len(stepper.STEP_SEQUENCES["wave"]), 4) + + # Test position tracking through multiple movements + stepper.step_clockwise(25) + stepper.step_counterclockwise(10) + stepper.rotate_clockwise(90) + + expected_angle_steps = int((90 / 360.0) * 100) + expected_total = 25 - 10 + expected_angle_steps + self.assertEqual(stepper.step_count, expected_total) + + # Test position reset + stepper.reset_position() + self.assertEqual(stepper.step_count, 0) + self.assertEqual(stepper.angle, 0.0) + + # Test speed control + original_delay = stepper.step_delay + stepper.step_delay = 0.001 + self.assertEqual(stepper.step_delay, 0.001) + stepper.step_delay = original_delay + + stepper.close() + unittest.main()