The code for the robot is divided into two parts. The first part is the main code that is automatically run with CircuitPython. The second part is a custom library for the robot that allows neatly wrapping everything up in a package.
Code and Libraries
After you've finished setting up your CLUE with CircuitPython, you can download all of the code and necessary libraries in the Project Bundle.
To do this, click on the Download Project Bundle button in the window below. It will download as a zipped folder. Inside the folder there should be different versions which correspond to the version of CircuitPython you are running.
# SPDX-FileCopyrightText: 2022 Melissa LeBlanc-Williams for Adafruit Industries # # SPDX-License-Identifier: MIT import board import neopixel from robot import Robot # Ring:bit Pins UNDERLIGHT_PIXELS = board.D0 LEFT_MOTOR = board.D1 RIGHT_MOTOR = board.D2 # Set up the hardware underlight_neopixels = neopixel.NeoPixel(UNDERLIGHT_PIXELS, 2) robot = Robot(LEFT_MOTOR, RIGHT_MOTOR, underlight_neopixels) while True: robot.wait_for_connection() while robot.is_connected(): robot.check_for_packets()
# SPDX-FileCopyrightText: 2022 Melissa LeBlanc-Williams for Adafruit Industries # # SPDX-License-Identifier: MIT import time import pwmio import board import digitalio import displayio import vectorio import terminalio import neopixel import adafruit_motor.servo from adafruit_ble import BLERadio from adafruit_ble.advertising.standard import ProvideServicesAdvertisement from adafruit_ble.services.nordic import UARTService from adafruit_bluefruit_connect.packet import Packet from adafruit_bluefruit_connect.color_packet import ColorPacket from adafruit_bluefruit_connect.button_packet import ButtonPacket from adafruit_display_text.label import Label # Throttle Directions and Speeds FWD = 1.0 REV = -1.0 STOP = 0 # Custom Colors RED = (200, 0, 0) GREEN = (0, 200, 0) BLUE = (0, 0, 200) PURPLE = (120, 0, 160) YELLOW = (100, 100, 0) AQUA = (0, 100, 100) class Robot: def __init__(self, left_pin, right_pin, underlight_neopixel): self.left_motor = self._init_motor(left_pin) self.right_motor = self._init_motor(right_pin) self._init_display() self._init_ble() self.under_pixels = underlight_neopixel self.neopixel = neopixel.NeoPixel(board.NEOPIXEL, 1) self.direction = STOP self.release_color = None self.headlights = digitalio.DigitalInOut(board.WHITE_LEDS) self.headlights.switch_to_output() self.set_underglow(PURPLE) self.set_speed(STOP) @classmethod def _make_palette(cls, color): palette = displayio.Palette(1) palette[0] = color return palette @classmethod def _init_motor(cls, pin): pwm = pwmio.PWMOut(pin, frequency=50) return adafruit_motor.servo.ContinuousServo(pwm, min_pulse=600, max_pulse=2400) def _init_ble(self): self.ble = BLERadio() self.uart_service = UARTService() self.advertisement = ProvideServicesAdvertisement(self.uart_service) def _init_display(self): self.display = board.DISPLAY self.display_group = displayio.Group() self.display.root_group = self.display_group self.shape_color = 0 self.bg_color = 0xFFFF00 rect = vectorio.Rectangle( pixel_shader=self._make_palette(0xFFFF00), x=0, y=0, width=self.display.width, height=self.display.height) self.display_group.append(rect) def wait_for_connection(self): self.set_status_led(BLUE) self.ble.start_advertising(self.advertisement) self._set_status_waiting() while not self.ble.connected: # Wait for a connection. pass self.ble.stop_advertising() self.set_status_led(GREEN) self.set_throttle(STOP) def is_connected(self): return self.ble.connected def check_for_packets(self): if self.uart_service.in_waiting: self._process_packet(Packet.from_stream(self.uart_service)) def set_underglow(self, color, save_release_color = False): if save_release_color: self.release_color = self.get_underglow() for index, _ in enumerate(self.under_pixels): self.under_pixels[index] = color def get_underglow(self): # Set the 2 Neopixels on the underside fo the robot return self.under_pixels[0] def set_status_led(self, color): # Set the status NeoPixel on the CLUE self.neopixel[0] = color def toggle_headlights(self): self.headlights.value = not self.headlights.value def _set_left_throttle(self, speed): self.left_motor.throttle = speed def _set_right_throttle(self, speed): # Motor is rotated 180 degrees of the left, so we invert the throttle self.right_motor.throttle = -1 * speed def rotate_right(self): self.release_color = self.get_underglow() self.set_underglow(YELLOW, True) if self.direction == STOP: self._set_status_rotate_cw() else: self._set_status_right() speed = FWD if self.direction == STOP else self.direction self._set_left_throttle(speed) self._set_right_throttle(STOP if self.direction != STOP else -1 * speed) def rotate_left(self): self.release_color = self.get_underglow() self.set_underglow(YELLOW, True) if self.direction == STOP: self._set_status_rotate_ccw() else: self._set_status_left() speed = FWD if self.direction == STOP else self.direction self._set_left_throttle(STOP if self.direction != STOP else -1 * speed) self._set_right_throttle(speed) def set_throttle(self, speed): if speed == STOP: self._set_status_stop() elif speed > STOP: self._set_status_forward() elif speed < STOP: self._set_status_reverse() self.set_speed(speed) def set_speed(self, speed): self._set_left_throttle(speed) self._set_right_throttle(speed) self.direction = speed def stop(self): # Temporarily grab the current color color = self.get_underglow() self.set_underglow(RED) self.set_throttle(STOP) time.sleep(0.5) self.set_underglow(color) def _remove_shapes(self): while len(self.display_group) > 1: self.display_group.pop() def _add_centered_rect(self, width, height, x_offset=0, y_offset=0, color=None): if color is None: color = self.shape_color rectangle = vectorio.Rectangle( pixel_shader=self._make_palette(color), width=width, height=height, x=(self.display.width//2 - width//2) + x_offset - 1, y=(self.display.height//2 - height//2) + y_offset - 1 ) self.display_group.append(rectangle) def _add_centered_polygon(self, points, x_offset=0, y_offset=0, color=None): if color is None: color = self.shape_color # Figure out the shape dimensions by using min and max width = max(points, key=lambda item:item[0])[0] - min(points, key=lambda item:item[0])[0] height = max(points, key=lambda item:item[1])[1] - min(points, key=lambda item:item[1])[1] polygon = vectorio.Polygon( pixel_shader=self._make_palette(color), points=points, x=(self.display.width // 2 - width // 2) + x_offset - 1, y=(self.display.height // 2 - height // 2) + y_offset - 1 ) self.display_group.append(polygon) def _add_centered_circle(self, radius, x_offset=0, y_offset=0, color=None): if color is None: color = self.shape_color circle = vectorio.Circle( pixel_shader=self._make_palette(color), radius=radius, x=(self.display.width // 2) + x_offset - 1, y=(self.display.height // 2) + y_offset - 1 ) self.display_group.append(circle) def _set_status_waiting(self): self._remove_shapes() text_area = Label( terminalio.FONT, text="Waiting for\nconnection", color=self.shape_color, scale=3, anchor_point=(0.5, 0.5), anchored_position=(self.display.width // 2, self.display.height // 2) ) self.display_group.append(text_area) def _set_status_reverse(self): self._remove_shapes() self._add_centered_polygon([(40, 0), (60, 0), (100, 100), (0, 100)], 0, 0) self._add_centered_polygon([(0, 40), (100, 40), (50, 0)], 0, -40) def _set_status_forward(self): self._remove_shapes() self._add_centered_polygon([(20, 0), (60, 0), (80, 100), (0, 100)]) self._add_centered_polygon([(0, 0), (150, 0), (75, 50)], 0, 50) def _set_status_right(self): self._remove_shapes() self._add_centered_rect(100, 40) self._add_centered_polygon([(50, 0), (50, 100), (0, 50)], -50, 0) def _set_status_rotate_ccw(self): self._remove_shapes() self._add_centered_circle(80) self._add_centered_circle(50, 0, 0, self.bg_color) self._add_centered_rect(160, 60, 0, 0, self.bg_color) self._add_centered_polygon([(40, 0), (80, 40), (0, 40)], 60, 10) self._add_centered_polygon([(40, 40), (80, 0), (0, 0)], -60, -10) def _set_status_left(self): self._remove_shapes() self._add_centered_rect(100, 40) self._add_centered_polygon([(0, 0), (0, 100), (50, 50)], 50) def _set_status_rotate_cw(self): self._remove_shapes() self._add_centered_circle(80) self._add_centered_circle(50, 0, 0, self.bg_color) self._add_centered_rect(160, 60, 0, 0, self.bg_color) self._add_centered_polygon([(40, 0), (80, 40), (0, 40)], -60, 10) self._add_centered_polygon([(40, 40), (80, 0), (0, 0)], 60, -10) def _set_status_stop(self): self._remove_shapes() self._add_centered_rect(100, 100) def _process_packet(self, packet): if isinstance(packet, ColorPacket): self._handle_color_packet(packet) elif isinstance(packet, ButtonPacket) and packet.pressed: # do this when buttons are pressed self._handle_button_press_packet(packet) elif isinstance(packet, ButtonPacket) and not packet.pressed: # do this when some buttons are released self._handle_button_release_packet(packet) def _handle_color_packet(self, packet): # Change the color self.set_underglow(packet.color) def _handle_button_press_packet(self, packet): if packet.button == ButtonPacket.UP: # UP button pressed self.set_throttle(FWD) elif packet.button == ButtonPacket.DOWN: # DOWN button self.set_throttle(REV) elif packet.button == ButtonPacket.RIGHT: self.rotate_right() elif packet.button == ButtonPacket.LEFT: self.rotate_left() elif packet.button == ButtonPacket.BUTTON_1: self.stop() elif packet.button == ButtonPacket.BUTTON_2: self.set_underglow(GREEN) elif packet.button == ButtonPacket.BUTTON_3: self.set_underglow(BLUE) elif packet.button == ButtonPacket.BUTTON_4: self.toggle_headlights() def _handle_button_release_packet(self, packet): if self.release_color is not None: self.set_underglow(self.release_color) self.release_color = None if packet.button == ButtonPacket.RIGHT: self.set_throttle(self.direction) if packet.button == ButtonPacket.LEFT: self.set_throttle(self.direction)
Upload the Code and Libraries to the CLUE
After downloading the Project Bundle, plug your CLUE into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the CLUE's CIRCUITPY drive.
- lib folder
- code.py
- robot.py
Your CLUE CIRCUITPY drive should look like this after copying the lib folder and the code.py file.
Code Walkthrough
It starts off by assigning the expected outputs to the board GPIO numbers. For instance, with the UNDERLIGHT_PIXELS
, it is assigned to D0, which is the same as GPIO 0. Then the libraries are initialized. First the NeoPixel library, the the custom robot library which will be covered below.
# Ring:bit Pins UNDERLIGHT_PIXELS = board.D0 LEFT_MOTOR = board.D1 RIGHT_MOTOR = board.D2 # Set up the hardware underlight_neopixels = neopixel.NeoPixel(UNDERLIGHT_PIXELS, 2) robot = Robot(LEFT_MOTOR, RIGHT_MOTOR, underlight_neopixels)
The main loop here waits for a connection, then continues to check packets while it is connected. If there is a disconnect, it goes back to waiting for a connection.
while True: robot.wait_for_connection() while robot.is_connected(): robot.check_for_packets()
Robot Custom Library
The robot library is organized so similar functions are grouped together. Near the top, after the imports are done, are a few constants. These are just used to define the directions and speeds. There are also some custom colors set up a tuples arranged in red, green, and blue with values between 0-255.
# Throttle Directions and Speeds FWD = 1.0 REV = -1.0 STOP = 0 # Custom Colors RED = (200, 0, 0) GREEN = (0, 200, 0) BLUE = (0, 0, 200) PURPLE = (120, 0, 160) YELLOW = (100, 100, 0) AQUA = (0, 100, 100)
The rest of the code is inside of the Robot
class itself. The first part initializes the robot hardware by calling several initialization functions that will be covered below. Initial variable values are set, and the car is put into a STOP
state with the underglow set to the value of PURPLE
.
def __init__(self, left_pin, right_pin, underlight_neopixel): self.left_motor = self._init_motor(left_pin) self.right_motor = self._init_motor(right_pin) self._init_display() self._init_ble() self.under_pixels = underlight_neopixel self.neopixel = neopixel.NeoPixel(board.NEOPIXEL, 1) self.direction = STOP self.release_color = None self.headlights = digitalio.DigitalInOut(board.WHITE_LEDS) self.headlights.switch_to_output() self.set_underglow(PURPLE) self.set_speed(STOP)
In the next section, there are a couple of functions with the @classmethod
decorator. This allows the functions to be part of the class without actually making use of the self
variable.
The first function just wraps takes the given color and assigns it to a displayio Palette
object and returns that object.
The other three functions just initialize the given motor, BLE (or Bluetooth Low Energy), and the Display.
Note that any functions that start with the underscore character "_
" are considered private functions by Python and not intended to be called from outside of the class.
@classmethod def _make_palette(cls, color): palette = displayio.Palette(1) palette[0] = color return palette @classmethod def _init_motor(cls, pin): pwm = pwmio.PWMOut(pin, frequency=50) return adafruit_motor.servo.ContinuousServo(pwm, min_pulse=600, max_pulse=2400) def _init_ble(self): self.ble = BLERadio() self.uart_service = UARTService() self.advertisement = ProvideServicesAdvertisement(self.uart_service) def _init_display(self): self.display = board.DISPLAY self.display_group = displayio.Group() self.display.show(self.display_group) self.shape_color = 0 self.bg_color = 0xFFFF00 rect = vectorio.Rectangle( pixel_shader=self._make_palette(0xFFFF00), x=0, y=0, width=self.display.width, height=self.display.height) self.display_group.append(rect)
Next are a few functions that are used in the main program loop to manage the BLE connection properly as well as check if any new packets have been sent from the Bluefruit Connect app and send all of those to the _process_packet()
function.
def wait_for_connection(self): self.set_status_led(BLUE) self.ble.start_advertising(self.advertisement) self._set_status_waiting() while not self.ble.connected: # Wait for a connection. pass self.ble.stop_advertising() self.set_status_led(GREEN) self.set_throttle(STOP) def is_connected(self): return self.ble.connected def check_for_packets(self): if self.uart_service.in_waiting: self._process_packet(Packet.from_stream(self.uart_service))
These function are for controlling the NeoPixels and the LED on the CLUE. The get_underglow()
function saves a little memory by making use of the values that are stored in the NeoPixel library to get the current color.
def set_underglow(self, color, save_release_color = False): if save_release_color: self.release_color = self.get_underglow() for index, _ in enumerate(self.under_pixels): self.under_pixels[index] = color def get_underglow(self): # Set the 2 Neopixels on the underside fo the robot return self.under_pixels[0] def set_status_led(self, color): # Set the status NeoPixel on the CLUE self.neopixel[0] = color def toggle_headlights(self): self.headlights.value = not self.headlights.value
The next set of functions are for controlling the movement of the robot. Servos spin in the same direction, but because they are mounted at 180 degrees to each other, one wheel wants to go forward and the other backwards. It starts out with the left and right throttle functions which solves this.
The rotate functions will behave a little different depending on whether the robot is currently stopped or currently moving in a direction. If it is stopped, the robot will rotate in place, whereas either the left or right wheel will act as a pivot if the robot is moving forward or backward.
The set_throttle()
and set_speed()
functions are similar except that the set_throttle
will handle updating the display and underglow in addition to setting the speed.
The stop()
function will also set the underglow to the value of RED
for half a second after stopping to simulate brake lights before returning to the previously set color.
def _set_left_throttle(self, speed): self.left_motor.throttle = speed def _set_right_throttle(self, speed): # Motor is rotated 180 degrees of the left, so we invert the throttle self.right_motor.throttle = -1 * speed def rotate_right(self): self.release_color = self.get_underglow() self.set_underglow(YELLOW, True) if self.direction == STOP: self._set_status_rotate_cw() else: self._set_status_right() speed = FWD if self.direction == STOP else self.direction self._set_left_throttle(speed) self._set_right_throttle(STOP if self.direction != STOP else -1 * speed) def rotate_left(self): self.release_color = self.get_underglow() self.set_underglow(YELLOW, True) if self.direction == STOP: self._set_status_rotate_ccw() else: self._set_status_left() speed = FWD if self.direction == STOP else self.direction self._set_left_throttle(STOP if self.direction != STOP else -1 * speed) self._set_right_throttle(speed) def set_throttle(self, speed): if speed == STOP: self._set_status_stop() elif speed > STOP: self._set_status_forward() elif speed < STOP: self._set_status_reverse() self.set_speed(speed) def set_speed(self, speed): self._set_left_throttle(speed) self._set_right_throttle(speed) self.direction = speed def stop(self): # Temporarily grab the current color color = self.get_underglow() self.set_underglow(RED) self.set_throttle(STOP) time.sleep(0.5) self.set_underglow(color)
Next are the drawing helpers. The first one removes everything from the main displayio
group and acts much like clear()
function.
The remain three function will draw at the center of the display and offset from there if offsets are provided. The _add_centered_polygon()
function makes use of a lambda to return only the x and y values, so it can figure out the largest and smallest points on the respective axis to get the overall geometry of the shape for easy centering.
Once the absolute coordinates are determined, everything is passed to the vectorio shape drawing functions.
def _remove_shapes(self): while len(self.display_group) > 1: self.display_group.pop() def _add_centered_rect(self, width, height, x_offset=0, y_offset=0, color=None): if color is None: color = self.shape_color rectangle = vectorio.Rectangle( pixel_shader=self._make_palette(color), width=width, height=height, x=(self.display.width//2 - width//2) + x_offset - 1, y=(self.display.height//2 - height//2) + y_offset - 1 ) self.display_group.append(rectangle) def _add_centered_polygon(self, points, x_offset=0, y_offset=0, color=None): if color is None: color = self.shape_color # Figure out the shape dimensions by using min and max width = max(points, key=lambda item:item[0])[0] - min(points, key=lambda item:item[0])[0] height = max(points, key=lambda item:item[1])[1] - min(points, key=lambda item:item[1])[1] polygon = vectorio.Polygon( pixel_shader=self._make_palette(color), points=points, x=(self.display.width // 2 - width // 2) + x_offset - 1, y=(self.display.height // 2 - height // 2) + y_offset - 1 ) self.display_group.append(polygon) def _add_centered_circle(self, radius, x_offset=0, y_offset=0, color=None): if color is None: color = self.shape_color circle = vectorio.Circle( pixel_shader=self._make_palette(color), radius=radius, x=(self.display.width // 2) + x_offset - 1, y=(self.display.height // 2) + y_offset - 1 ) self.display_group.append(circle)
The next group of functions make use of the shape drawing functions to draw specific graphics. The only exception to this is the _set_status_waiting()
function, which simply creates a label.
def _set_status_waiting(self): self._remove_shapes() text_area = Label( terminalio.FONT, text="Waiting for\nconnection", color=self.shape_color, scale=3, anchor_point=(0.5, 0.5), anchored_position=(self.display.width // 2, self.display.height // 2) ) self.display_group.append(text_area) def _set_status_reverse(self): self._remove_shapes() self._add_centered_polygon([(40, 0), (60, 0), (100, 100), (0, 100)], 0, 0) self._add_centered_polygon([(0, 40), (100, 40), (50, 0)], 0, -40) def _set_status_forward(self): self._remove_shapes() self._add_centered_polygon([(20, 0), (60, 0), (80, 100), (0, 100)]) self._add_centered_polygon([(0, 0), (150, 0), (75, 50)], 0, 50) def _set_status_right(self): self._remove_shapes() self._add_centered_rect(100, 40) self._add_centered_polygon([(50, 0), (50, 100), (0, 50)], -50, 0) def _set_status_rotate_ccw(self): self._remove_shapes() self._add_centered_circle(80) self._add_centered_circle(50, 0, 0, self.bg_color) self._add_centered_rect(160, 60, 0, 0, self.bg_color) self._add_centered_polygon([(40, 0), (80, 40), (0, 40)], 60, 10) self._add_centered_polygon([(40, 40), (80, 0), (0, 0)], -60, -10) def _set_status_left(self): self._remove_shapes() self._add_centered_rect(100, 40) self._add_centered_polygon([(0, 0), (0, 100), (50, 50)], 50) def _set_status_rotate_cw(self): self._remove_shapes() self._add_centered_circle(80) self._add_centered_circle(50, 0, 0, self.bg_color) self._add_centered_rect(160, 60, 0, 0, self.bg_color) self._add_centered_polygon([(40, 0), (80, 40), (0, 40)], -60, 10) self._add_centered_polygon([(40, 40), (80, 0), (0, 0)], 60, -10) def _set_status_stop(self): self._remove_shapes() self._add_centered_rect(100, 100)
Finally, there are the functions that actually handle the packets. If you would like to modify what the buttons in the Bluefruit app do, this is where you would change that.
The first function will take the given packet and if it is one of the expected packet types, it will route it to the appropriate handler function, otherwise it will be ignored.
The handler functions then look at more specific details in the packet and call the function for whatever action should be performed.
def _process_packet(self, packet): if isinstance(packet, ColorPacket): self._handle_color_packet(packet) elif isinstance(packet, ButtonPacket) and packet.pressed: # do this when buttons are pressed self._handle_button_press_packet(packet) elif isinstance(packet, ButtonPacket) and not packet.pressed: # do this when some buttons are released self._handle_button_release_packet(packet) def _handle_color_packet(self, packet): # Change the color self.set_underglow(packet.color) def _handle_button_press_packet(self, packet): if packet.button == ButtonPacket.UP: # UP button pressed self.set_throttle(FWD) elif packet.button == ButtonPacket.DOWN: # DOWN button self.set_throttle(REV) elif packet.button == ButtonPacket.RIGHT: self.rotate_right() elif packet.button == ButtonPacket.LEFT: self.rotate_left() elif packet.button == ButtonPacket.BUTTON_1: self.stop() elif packet.button == ButtonPacket.BUTTON_2: self.set_underglow(GREEN) elif packet.button == ButtonPacket.BUTTON_3: self.set_underglow(BLUE) elif packet.button == ButtonPacket.BUTTON_4: self.toggle_headlights() def _handle_button_release_packet(self, packet): if self.release_color is not None: self.set_underglow(self.release_color) self.release_color = None if packet.button == ButtonPacket.RIGHT: self.set_throttle(self.direction) if packet.button == ButtonPacket.LEFT: self.set_throttle(self.direction)
Text editor powered by tinymce.