If your CLUE board isn’t already running CircuitPython…
- Head to the CircuitPython download page for CLUE nRF52840 Express and fetch the latest build.
- Then go to the CircuitPython libraries download page and fetch the corresponding “bundle” for that version.
- If using Windows 7, you’ll also need a USB driver. This and other setup basics are covered in the Welcome to CircuitPython and Introducing Adafruit CLUE guides…if things behave oddly, start there.
- Connect a USB cable from CLUE to your computer and double-tap CLUE’s reset button on the back. When the CLUEBOOT drive appears, drag-and-drop the CircuitPython .UF2 file to that drive. Any program currently on the board (Arduino, earlier CircuitPython or anything else) will be overwritten.
Next steps after CircuitPython is installed…
Below in the code box, click the "Download Project Bundle" button which will download all the files needed for this project including the libraries.
- Open the bundle ZIP file and copy two folders — adafruit_display_shapes and adafruit_display_text — to the CIRCUITPY drive lib folder.
- Copy the folder bmps-72px to the CIRCUITPY drive.
- Get the code and images for this project from the ZIP file (don't put it on CIRCUITPY yet!). Read below!
# SPDX-FileCopyrightText: 2020 Phillip Burgess for Adafruit Industries # # SPDX-License-Identifier: MIT """ Light painting project for Adafruit CLUE using DotStar LED strip. Images should be in 24-bit BMP format, with width matching the length of the LED strip. Uses ulab module to assist with interpolation and dithering, displayio for a minimal user interface. TO RUN, boot.py MUST CONFIGURE FILESYSTEM FOR READ-WRITE MODE. TO EDIT CODE, FILESYSTEM MUST BE IN READ-ONLY MODE. boot.py sets up latter condition using a jumper from pin 0 to GND. """ # pylint: disable=import-error import gc from time import monotonic, sleep import board import busio import displayio from digitalio import DigitalInOut, Direction from bmp2led import BMP2LED, BMPError from neopixel_write import neopixel_write from richbutton import RichButton from adafruit_display_text import label from adafruit_display_shapes.rect import Rect from terminalio import FONT # terminalio font is crude but fast to display FONT_WIDTH, FONT_HEIGHT = FONT.get_bounding_box() # These are permanent global settings, can only change by editing the code: NUM_PIXELS = 72 # LED strip length PIXEL_PINS = board.SDA, board.SCL # Data, clock pins for DotStars PIXEL_ORDER = 'bgr' # Pixel color order PATH = '/bmps-72px' # Folder with BMP images (or '' for root path) TEMPFILE = '/led.dat' # Working file for LED data (will be clobbered!) FLIP_SCREEN = False # If True, turn CLUE screen & buttons upside-down GAMMA = 2.4 # Correction for perceptually linear brightness BRIGHTNESS_RANGE = 0.15, 0.75 # Min, max brightness (0.0-1.0) TIMES = ['1/8', '1/4', '1/3', '1/2', '2/3', '1', '1.5', '2', '3', '4'] TIMES.sort(key=eval) # Ensure times are shortest-to-longest def centered_label(text, y_pos, scale): """ Create a displayio label that's horizontally centered on screen. Arguments: text (string) : Label string. y_pos (int) : Vertical position on screen. scale (int) : Text scale. Returns: displayio group object. """ group = displayio.Group(scale=scale, x=board.DISPLAY.width // 2) x_pos = len(text) * FONT_WIDTH // -2 group.append(label.Label(FONT, text=text, x=x_pos, y=y_pos)) return group # pylint: disable=too-many-instance-attributes class ClueLightPainter: """ CLUE Light Painter is wrapped in this class to avoid a bunch more globals. """ # pylint: disable=too-many-arguments def __init__(self, flip, path, tempfile, num_pixels, pixel_order, pixel_pins, gamma, brightness): """ App constructor. Follow up with a call to ClueLightPainter.run(). Arguments: flip (boolean) : If True, CLUE display and buttons are flipped 180 degrees from normal (makes wiring easier in some situations). path (string) : Directory containing BMP images. tempfile (string) : Full path/filename of temporary working file for LED data (will be clobbered). num_pixels (int) : LED strip length. pixel_order (string) : LED data order, e.g. 'grb'. pixel_pins (tuple) : Board pin for LED data output (SPI data and clock pins respectively). gamma (float) : Correction for perceptual linearity. brightness (2 floats) : Minimum and maximum LED brightness settings, each 0.0 (off) to 1.0 (full brightness). Too-low brightness levels just don't photograph well. Too-high levels may draw more current than the battery can provide, board may lock up and may even need CircuitPython re-flash. """ self.bmp2led = BMP2LED(num_pixels, pixel_order, gamma) self.path = path self.tempfile = tempfile self.brightness_range = brightness # The SPI peripheral is locked and config'd once here and never # relinquished, to save some time on every row (need them issued # as fast as possible). self.spi = busio.SPI(pixel_pins[1], MOSI=pixel_pins[0]) self.spi.try_lock() self.spi.configure(baudrate=8000000) # Determine filesystem-to-LEDs throughput (also clears LED strip) self.rows_per_second, self.row_size = self.benchmark() # Configure hardware initial state self.button_left = RichButton(board.BUTTON_A) self.button_right = RichButton(board.BUTTON_B) if flip: board.DISPLAY.rotation = 180 self.button_left, self.button_right = (self.button_right, self.button_left) else: board.DISPLAY.rotation = 0 # Turn off onboard NeoPixel onboard_pixel_pin = DigitalInOut(board.NEOPIXEL) onboard_pixel_pin.direction = Direction.OUTPUT neopixel_write(onboard_pixel_pin, bytearray(3)) # Get list of compatible BMP images in path self.images = self.bmp2led.scandir(path) if not self.images: group = displayio.Group() group.append(centered_label('NO IMAGES', 40, 3)) board.DISPLAY.root_group = group while True: pass self.image_num = 0 # Current selected image index in self.path self.num_rows = 0 # Nothing loaded yet self.loop = False # Repeat image playback self.brightness = 1.0 # LED brightness, 0.0 (off) to 1.0 (bright) self.config_mode = 0 # Current setting being changed self.rect = None # Multipurpose progress/setting rect self.time = (len(TIMES) + 1) // 2 # Paint time index from TIMES[] def benchmark(self): """ Estimate filesystem-to-LED-strip throughput. Returns: rows-per-second throughput (int), LED row size in bytes (including DotStar header and footer) (int). """ # Generate a small temporary file equal to one full LED row, # all set 'off'. row_data = bytearray([0] * 4 + [255, 0, 0, 0] * self.bmp2led.num_pixels + [255] * ((self.bmp2led.num_pixels + 15) // 16)) row_size = len(row_data) with open(self.tempfile, 'wb') as file: file.write(row_data) # For a period of 1 second, repeatedly seek to start of file, # read row of data and write to LED strip as fast as possible. # Not super precise, but good-enough guess of light painting speed. # (Bonus, this will turn off LED strip on startup). rows = 0 with open(self.tempfile, 'rb') as file: start_time = monotonic() while monotonic() - start_time < 1.0: file.seek(0) file.readinto(row_data) self.spi.write(row_data) sleep(0.001) # See notes in paint() rows += 1 return rows, row_size def clear_strip(self): """ Turn off all LEDs of the DotStar strip. """ self.spi.write(bytearray([0] * 4 + [255, 0, 0, 0] * self.bmp2led.num_pixels + [255] * ((self.bmp2led.num_pixels + 15) // 16))) def load_progress(self, amount): """ Callback function for image loading, moves progress bar on display. Arguments: amount (float) : Current 'amount loaded' coefficient; 0.0 to 1.0 """ #self.rect.x = int(board.DISPLAY.width * (amount - 1.0)) num_on = int(amount * self.bmp2led.num_pixels + 0.5) num_off = self.bmp2led.num_pixels - num_on on_pixel = [255, 0, 0, 0] on_pixel[1 + self.bmp2led.green_index] = 10 self.spi.write(bytearray([0] * 4 + on_pixel * num_on + [255, 0, 0, 0] * num_off + [255] * ((self.bmp2led.num_pixels + 15) // 16))) def load_image(self): """ Load BMP from image list, determined by variable self.image_num (not a passed argument). Data is converted and placed in self.tempfile. """ # Minimal progress display while image is loaded. group = displayio.Group() group.append(centered_label('LOADING...', 40, 3)) #self.rect = Rect(-board.DISPLAY.width, 120, # board.DISPLAY.width, 40, fill=0x00B000) #group.append(self.rect) board.DISPLAY.root_group = group # pylint: disable=eval-used # (It's cool, is a 'trusted string' in the code) duration = eval(TIMES[self.time]) # Playback time in seconds # The 0.9 here is an empirical guesstimate; playback is ever-so- # slightly slower than benchmark speed due to button testing. rows = int(duration * self.rows_per_second * 0.9 + 0.5) # Remap brightness from 0.0-1.0 to brightness_range. brightness = (self.brightness_range[0] + self.brightness * (self.brightness_range[1] - self.brightness_range[0])) try: self.num_rows = self.bmp2led.process(self.path + '/' + self.images[self.image_num], self.tempfile, rows, brightness, self.loop, self.load_progress) except (MemoryError, BMPError): group = displayio.Group() group.append(centered_label('TOO BIG', 40, 3)) board.DISPLAY.root_group = group sleep(4) board.DISPLAY.root_group = displayio.Group() # Clear display self.clear_strip() # LEDs off def paint(self): """ Paint mode. Watch for button taps to start/stop image playback, or button hold to switch to config mode. """ board.DISPLAY.brightness = 0 # Screen backlight OFF painting = False row = 0 action_list = [None, None] with open(self.tempfile, 'rb') as file: led_buffer = bytearray(self.row_size) # During painting, automatic garbage collection is disabled # so there are no pauses in the LED output (which would wreck # the photo). This requires that the loop below is written in # such a way to avoid ANY allocations within that scope! gc.collect() gc.disable() while True: # This peculiar assignment (rather than just declaring this # as a new list or set) is to avoid temporary memory allocs, # since the garbage collector is disabled. action_list[0] = self.button_left.action() action_list[1] = self.button_right.action() if RichButton.TAP in action_list: if painting: # If currently painting self.clear_strip() # Turn LEDs OFF else: row = 0 # Start at beginning of file painting = not painting # Toggle paint mode on/off elif RichButton.HOLD in action_list: break # End paint loop if painting: file.seek(row * self.row_size) # using readinto() instead of read() is another # avoid-automatic-garbage-collection strategy. file.readinto(led_buffer) self.spi.write(led_buffer) # Strip updates are more than fast enough... # it's the file conversion that takes forever. # This small delay (also present in the benchmark() # function) reduces the output resolution slightly, # in turn reducing the preprocessing requirements. sleep(0.001) row += 1 if row >= self.num_rows: if self.loop: row = 0 else: painting = False # Re-enable automatic garbage collection before # exiting paint mode and returning to config mode. gc.enable() # Each config screen is broken out into its own function... # Generates its UI, handles button interactions, clears screen. # It was a toss-up between this and one big multimodal config # function. This way definitely generates less pylint gas pains. # Also, creating and destroying elements (rather than creating # them all up-front and showing or hiding elements as needed) # tends to use less RAM. def make_ui_group(self, main_config, config_label, rect_val=None): """ Generates and displays a displayio group containing several elements that all config screens have in common (or nearly in common). Arguments: main_config (boolean) : If true, function generates the main config screen elements, else makes elements for other config screens. config_label (string) : Text to appear at center(ish) of screen. rect_val (float) : If specified, a Rect object is created whose width represents the value. 0.0 = min, 1.0 = full display width. Returns: displayio group """ group = displayio.Group() group.append(centered_label('TAP L/R to', 3, 2)) group.append(centered_label('select item' if main_config else 'select image' if self.config_mode is 0 else 'change', 16, 2)) group.append(centered_label('HOLD L: item config' if main_config else 'HOLD L: back', 100, 2)) group.append(centered_label('HOLD R: paint', 113, 2)) if rect_val: self.rect = Rect(int(board.DISPLAY.width * (rect_val - 1.0)), 120, board.DISPLAY.width, 40, fill=0x00B000) group.append(self.rect) # Config label always appears as last item in group # so calling func can pop() and replace it if need be. group.append(centered_label(config_label, 30 if rect_val else 40, 3)) board.DISPLAY.root_group = group return group def config_select(self, first_run=False): """ Initial configuration screen, in which the user selects which setting will be changed. Tap L/R to select which setting, hold L to change that setting, or hold R to resume painting. """ self.clear_strip() strings = ['IMAGE', 'TIME', 'LOOP', 'BRIGHTNESS'] funcs = [self.config_image, self.config_time, self.config_loop, self.config_brightness] group = self.make_ui_group(True, strings[self.config_mode]) board.DISPLAY.brightness = 1 # Screen on prev_mode = self.config_mode reload_image = first_run while True: action_left, action_right = (self.button_left.action(), self.button_right.action()) if action_left is RichButton.HOLD: # Call one of the configuration sub-menu functions. # These all return two booleans. One indicates whether # the setting change requires reloading the image, # other indicates if it was a R button hold, in which # case this should return to paint mode. reload, paint = funcs[self.config_mode]() # Image reload is not immediate, it can wait until # returning to paint. reload_image |= reload if paint: break # Exit loop, resume paint else: board.DISPLAY.root_group = group # Put config UI back up elif action_right is RichButton.HOLD: break elif action_left is RichButton.TAP: self.config_mode = (self.config_mode - 1) % len(strings) elif action_right is RichButton.TAP: self.config_mode = (self.config_mode + 1) % len(strings) if self.config_mode is not prev_mode: # Create/destroy mode descriptions as needed group.pop() group.append(centered_label(strings[self.config_mode], 40, 3)) prev_mode = self.config_mode # Before exiting to paint mode, check if new image needs loaded if reload_image: self.load_image() def config_image(self): """ Image select screen. Tap L/R to cycle among image filenames, hold L to go back to main config menu, hold R to paint. Returns: two booleans, first indicates whether image needs to be reloaded, second indicates if returning to paint mode vs more config. """ group = self.make_ui_group(False, self.images[self.image_num].split('.')[0]) orig_image, prev_image = self.image_num, self.image_num while True: action_left, action_right = (self.button_left.action(), self.button_right.action()) if action_left is RichButton.HOLD: return self.image_num is not orig_image, False # Resume config if action_right is RichButton.HOLD: return self.image_num is not orig_image, True # Resume paint if action_left is RichButton.TAP: self.image_num = (self.image_num - 1) % len(self.images) elif action_right is RichButton.TAP: self.image_num = (self.image_num + 1) % len(self.images) if self.image_num is not prev_image: group.pop() group.append(centered_label( self.images[self.image_num].split('.')[0], 40, 3)) prev_image = self.image_num def config_time(self): """ Time (paint duration) select screen. Tap L/R to decrease/increase paint time, hold L to go back to main config menu, hold R to paint. Returns: two booleans, first is always False, second indicates if returning to paint mode vs more config. """ group = self.make_ui_group(False, 'Time:', self.time / (len(TIMES) - 1)) group.append(centered_label(TIMES[self.time] + ' Sec', 70, 2)) orig_time, prev_time = self.time, self.time while True: action_left, action_right = (self.button_left.action(), self.button_right.action()) if action_left is RichButton.HOLD: return self.time is not orig_time, False # Resume config if action_right is RichButton.HOLD: return self.time is not orig_time, True # Resume paint if action_left is RichButton.TAP: self.time = max(0, self.time - 1) elif action_right is RichButton.TAP: self.time = min(len(TIMES) - 1, self.time + 1) if self.time is not prev_time: self.rect.x = int(board.DISPLAY.width * (self.time / (len(TIMES) - 1) - 1.0)) prev_time = self.time group.pop() group.append(centered_label(TIMES[self.time] + ' Sec', 70, 2)) def config_loop(self): """ Loop select screen. Tap L/R to toggle looping on/off, hold L to go back to main config menu, hold R to paint. Returns: two booleans, first is always False, second indicates if returning to paint mode vs more config. """ loop_label = ['Loop OFF', 'Loop ON'] group = self.make_ui_group(False, loop_label[self.loop]) orig_loop = self.loop while True: action_left, action_right = (self.button_left.action(), self.button_right.action()) if action_left is RichButton.HOLD: return self.loop is not orig_loop, False # Resume config if action_right is RichButton.HOLD: return self.loop is not orig_loop, True # Resume paint if RichButton.TAP in {action_left, action_right}: self.loop = not self.loop group.pop() group.append(centered_label(loop_label[self.loop], 40, 3)) def config_brightness(self): """ Brightness select screen. Tap L/R to decrease/increase brightness, hold L to go back to main config menu, hold R to paint. Returns: two booleans, first is always False, second indicates if returning to paint mode vs more config. """ orig_brightness, prev_brightness = self.brightness, self.brightness self.make_ui_group(False, 'Brightness:', self.brightness) while True: action_left, action_right = (self.button_left.action(), self.button_right.action()) if action_left is RichButton.HOLD: return self.brightness is not orig_brightness, False # Config if action_right is RichButton.HOLD: return self.brightness is not orig_brightness, True # Paint if action_left is RichButton.TAP: self.brightness = max(0.0, self.brightness - 0.1) elif action_right is RichButton.TAP: self.brightness = min(1.0, self.brightness + 0.1) if self.brightness is not prev_brightness: self.rect.x = int(board.DISPLAY.width * (self.brightness - 1.0)) prev_brightness = self.brightness def run(self): """ Post-init application loop. After a one-time visit to image select (and possibly other config), just consists of alternating paint and config modes. Each function has its own condition for return (switching to the opposite mode). Repeat forever. """ _, paint = self.config_image() if paint: self.load_image() else: self.config_select(True) while True: self.paint() self.config_select() ClueLightPainter(FLIP_SCREEN, PATH, TEMPFILE, NUM_PIXELS, PIXEL_ORDER, PIXEL_PINS, GAMMA, BRIGHTNESS_RANGE).run()
- Do not copy any of the Python files to the drive until the following is understood:
When boot.py runs at startup, it lets the code write to CLUE’s flash filesystem, but CODE AND FILES ON THE BOARD CAN’T BE EDITED OR DELETED OVER USB. boot.py checks for a connection between PIN 0 and GND. If present at boot or reset, code and files CAN be changed, but the light painter won’t run.
To edit or remove files, make this connection between PIN 0 and GND and boot or reset the board. Now you can edit, but the light painter code won’t run.
Remove the connection and boot/reset and the light painter code runs, but you can’t edit. This is normal, it’s just part of how CircuitPython interacts with the flash filesystem and a USB-connected computer.
If you misplace the alligator cable, it is possible to get back into the board, but this involves accessing the CircuitPython REPL (e.g. from a terminal program) and entering some commands to remove or rename the boot.py
file, as documented in this guide.
Copy the four .py files to the CIRCUITPY drive:
- bmp2led.py — this processes BMP image files into a format that our light-painting code can read quickly. It’s in a separate file so we have the option to use it in other projects later.
- boot.py — this runs when the board initially boots or is reset, and sets the flash filesystem to write-enabled mode if the alligator jumper is not present.
- code.py — the main light-painting application, using the CLUE’s display and buttons to provide a barebones user interface.
- richbutton.py — a CircuitPython library that processes button inputs to distinguish taps, double-taps and long holds.
Once these files are installed, you may want to edit the code.py file (using your text editor of preference) to configure some things for your particular setup. Remember, the PIN 0 to GND jumper must be in place when you boot or reset the board to edit files. Then remove the jumper and reset to test the changes. You might be going back and forth a few times like this.
Near the top of code.py, look for this section:
NUM_PIXELS = 72 # LED strip length PIXEL_PINS = board.SDA, board.SCL # Data, clock pins for DotStars PIXEL_ORDER = 'bgr' # Pixel color order PATH = '/bmps-72px' # Folder with BMP images (or '' for root path) TEMPFILE = '/led.dat' # Working file for LED data (will be clobbered!) FLIP_SCREEN = False # If True, turn CLUE screen & buttons upside-down GAMMA = 2.4 # Correction for perceptually linear brightness BRIGHTNESS_RANGE = 0.15, 0.75 # Min, max brightness (0.0-1.0)
NUM_PIXELS
is the length of the DotStar strip — 72
or 60
pixels are both well suited to the performance of this board, but you’re welcome to try building something a different size.
PIXEL_PINS
are the DotStar data and clock pins, respectively. Comma-separated. By default these are the board.SDA
and board.SCL
pins of the STEMMA QT socket (blue and yellow wires, respectively). If you’ve gone with a more temporary build, using ’gator clips to the board edge connector, you can use board.P1
and board.P2
here (avoid P0
, already in use in boot.py
).
PIXEL_ORDER
is the sequence in which DotStar LEDs process the red, green and blue values of each pixel. Currently-shipping DotStar strips use 'bgr'
order (blue, green, red)…but older DotStars, and compatible APA-102 LEDs from other sources, may use a different sequence ('gbr'
was previously common).
PATH
is the folder where the code looks for BMP images. See the “Pixel Art” page for requirements.
FLIP_SCREEN
can be set True
or False
. If True
, the display and buttons are flipped 180°…sometimes the physical assembly of the light stick just works out easier with the board turned around, and this compensates for it.
BRIGHTNESS_RANGE
, two comma-separated values from 0.0
(off) to 1.0
(brightest), are the minimum and maximum LED brightness to use when light painting (a setting in the UI). 0.0
isn’t very useful, since that’s just off, so the default minimum is 0.15
. The maximum is set below 1.0
(0.85
by default) because the LEDs coming on full brightness can cause a voltage sag…the CLUE board may lock up and, in severe situations, may even require reinstalling CircuitPython. If you see this happen, dial back the maximum value until the DotStars, battery and CLUE all play nice.
TEMPFILE
and GAMMA
can be ignored, probably won’t need to change these.
If you make changes but can’t save the file: the alligator jumper wasn’t in place when booting. Connect P0 and GND, reset before editing files. Remove the jumper and reset to run the code.
Page last edited February 24, 2025
Text editor powered by tinymce.