Merge pull request #2922 from FoamyGuy/custom_animations

Custom LED Animations Updates
This commit is contained in:
foamyguy 2024-11-05 07:58:10 -06:00 committed by GitHub
commit 81ba1ce023
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 736 additions and 0 deletions

View file

@ -0,0 +1,43 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Uses NeoPixel Featherwing connected to D10 and
Dotstar Featherwing connected to D13, and D11.
Update pins as needed for your connections.
"""
import board
import neopixel
import adafruit_dotstar as dotstar
from conways import ConwaysLifeAnimation
from snake import SnakeAnimation
# Update to match the pin connected to your NeoPixels
pixel_pin = board.D10
# Update to match the number of NeoPixels you have connected
pixel_num = 32
# initialize the neopixels featherwing
pixels = neopixel.NeoPixel(pixel_pin, pixel_num, brightness=0.02, auto_write=False)
# initialize the dotstar featherwing
dots = dotstar.DotStar(board.D13, board.D11, 72, brightness=0.02)
# initial live cells for conways
initial_cells = [
(2, 1),
(3, 1),
(4, 1),
(5, 1),
(6, 1),
]
# initialize the animations
conways = ConwaysLifeAnimation(dots, 0.1, 0xff00ff, 12, 6, initial_cells)
snake = SnakeAnimation(pixels, speed=0.1, color=0xff00ff, width=8, height=4)
while True:
# call animate to show the next animation frames
conways.animate()
snake.animate()

View file

@ -0,0 +1,192 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks
#
# SPDX-License-Identifier: MIT
"""
ConwaysLifeAnimation helper class
"""
from micropython import const
from adafruit_led_animation.animation import Animation
from adafruit_led_animation.grid import PixelGrid, HORIZONTAL
def _is_pixel_off(pixel):
return pixel[0] == 0 and pixel[1] == 0 and pixel[2] == 0
class ConwaysLifeAnimation(Animation):
# Constants
DIRECTION_OFFSETS = [
(0, 1),
(0, -1),
(1, 0),
(-1, 0),
(1, 1),
(-1, 1),
(1, -1),
(-1, -1),
]
LIVE = const(0x01)
DEAD = const(0x00)
def __init__(
self,
pixel_object,
speed,
color,
width,
height,
initial_cells,
equilibrium_restart=True,
):
"""
Conway's Game of Life implementation. Watch the cells
live and die based on the classic rules.
:param pixel_object: The initialised LED object.
:param float speed: Animation refresh rate in seconds, e.g. ``0.1``.
:param color: the color to use for live cells
:param width: the width of the grid
:param height: the height of the grid
:param initial_cells: list of initial cells to be live
:param equilibrium_restart: whether to restart when the simulation gets stuck unchanging
"""
super().__init__(pixel_object, speed, color)
# list to hold which cells are live
self.drawn_pixels = []
# store the initial cells
self.initial_cells = initial_cells
# PixelGrid helper to access the strand as a 2D grid
self.pixel_grid = PixelGrid(
pixel_object, width, height, orientation=HORIZONTAL, alternating=False
)
# size of the grid
self.width = width
self.height = height
# equilibrium restart boolean
self.equilibrium_restart = equilibrium_restart
# counter to store how many turns since the last change
self.equilibrium_turns = 0
# self._init_cells()
def _is_grid_empty(self):
"""
Checks if the grid is empty.
:return: True if there are no live cells, False otherwise
"""
for y in range(self.height):
for x in range(self.width):
if not _is_pixel_off(self.pixel_grid[x, y]):
return False
return True
def _init_cells(self):
"""
Turn off all LEDs then turn on ones cooresponding to the initial_cells
:return: None
"""
self.pixel_grid.fill(0x000000)
for cell in self.initial_cells:
self.pixel_grid[cell] = self.color
def _count_neighbors(self, cell):
"""
Check how many live cell neighbors are found at the given location
:param cell: the location to check
:return: the number of live cell neighbors
"""
neighbors = 0
for direction in ConwaysLifeAnimation.DIRECTION_OFFSETS:
try:
if not _is_pixel_off(
self.pixel_grid[cell[0] + direction[0], cell[1] + direction[1]]
):
neighbors += 1
except IndexError:
pass
return neighbors
def draw(self):
# pylint: disable=too-many-branches
"""
draw the current frame of the animation
:return: None
"""
# if there are no live cells
if self._is_grid_empty():
# spawn the inital_cells and return
self._init_cells()
return
# list to hold locations to despawn live cells
despawning_cells = []
# list to hold locations spawn new live cells
spawning_cells = []
# loop over the grid
for y in range(self.height):
for x in range(self.width):
# check and set the current cell type, live or dead
if _is_pixel_off(self.pixel_grid[x, y]):
cur_cell_type = ConwaysLifeAnimation.DEAD
else:
cur_cell_type = ConwaysLifeAnimation.LIVE
# get a count of the neigbors
neighbors = self._count_neighbors((x, y))
# if the current cell is alive
if cur_cell_type == ConwaysLifeAnimation.LIVE:
# if it has fewer than 2 neighbors
if neighbors < 2:
# add its location to the despawn list
despawning_cells.append((x, y))
# if it has more than 3 neighbors
if neighbors > 3:
# add its location to the despawn list
despawning_cells.append((x, y))
# if the current location is not a living cell
elif cur_cell_type == ConwaysLifeAnimation.DEAD:
# if it has exactly 3 neighbors
if neighbors == 3:
# add the current location to the spawn list
spawning_cells.append((x, y))
# loop over the despawn locations
for cell in despawning_cells:
# turn off LEDs at each location
self.pixel_grid[cell] = 0x000000
# loop over the spawn list
for cell in spawning_cells:
# turn on LEDs at each location
self.pixel_grid[cell] = self.color
# if equilibrium restart mode is enabled
if self.equilibrium_restart:
# if there were no cells spawned or despaned this round
if len(despawning_cells) == 0 and len(spawning_cells) == 0:
# increment equilibrium turns counter
self.equilibrium_turns += 1
# if the counter is 3 or higher
if self.equilibrium_turns >= 3:
# go back to the initial_cells
self._init_cells()
# reset the turns counter to zero
self.equilibrium_turns = 0

View file

@ -0,0 +1,171 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks
#
# SPDX-License-Identifier: MIT
"""
SnakeAnimation helper class
"""
import random
from micropython import const
from adafruit_led_animation.animation import Animation
from adafruit_led_animation.grid import PixelGrid, HORIZONTAL
class SnakeAnimation(Animation):
UP = const(0x00)
DOWN = const(0x01)
LEFT = const(0x02)
RIGHT = const(0x03)
ALL_DIRECTIONS = [UP, DOWN, LEFT, RIGHT]
DIRECTION_OFFSETS = {
DOWN: (0, 1),
UP: (0, -1),
RIGHT: (1, 0),
LEFT: (-1, 0)
}
def __init__(self, pixel_object, speed, color, width, height, snake_length=3):
"""
Renders a snake that slithers around the 2D grid of pixels
"""
super().__init__(pixel_object, speed, color)
# how many segments the snake will have
self.snake_length = snake_length
# create a PixelGrid helper to access our strand as a 2D grid
self.pixel_grid = PixelGrid(pixel_object, width, height,
orientation=HORIZONTAL, alternating=False)
# size variables
self.width = width
self.height = height
# list that will hold locations of snake segments
self.snake_pixels = []
self.direction = None
# initialize the snake
self._new_snake()
def _clear_snake(self):
"""
Clear the snake segments and turn off all pixels
"""
while len(self.snake_pixels) > 0:
self.pixel_grid[self.snake_pixels.pop()] = 0x000000
def _new_snake(self):
"""
Create a new single segment snake. The snake has a random
direction and location. Turn on the pixel representing the snake.
"""
# choose a random direction and store it
self.direction = random.choice(SnakeAnimation.ALL_DIRECTIONS)
# choose a random starting tile
starting_tile = (random.randint(0, self.width - 1), random.randint(0, self.height - 1))
# add the starting tile to the list of segments
self.snake_pixels.append(starting_tile)
# turn on the pixel at the chosen location
self.pixel_grid[self.snake_pixels[0]] = self.color
def _can_move(self, direction):
"""
returns true if the snake can move in the given direction
"""
# location of the next tile if we would move that direction
next_tile = tuple(map(sum, zip(
SnakeAnimation.DIRECTION_OFFSETS[direction], self.snake_pixels[0])))
# if the tile is one of the snake segments
if next_tile in self.snake_pixels:
# can't move there
return False
# if the tile is within the bounds of the grid
if 0 <= next_tile[0] < self.width and 0 <= next_tile[1] < self.height:
# can move there
return True
# return false if any other conditions not met
return False
def _choose_direction(self):
"""
Choose a direction to go in. Could continue in same direction
as it's already going, or decide to turn to a dirction that
will allow movement.
"""
# copy of all directions in a list
directions_to_check = list(SnakeAnimation.ALL_DIRECTIONS)
# if we can move the direction we're currently going
if self._can_move(self.direction):
# "flip a coin"
if random.random() < 0.5:
# on "heads" we stay going the same direction
return self.direction
# loop over the copied list of directions to check
while len(directions_to_check) > 0:
# choose a random one from the list and pop it out of the list
possible_direction = directions_to_check.pop(
random.randint(0, len(directions_to_check)-1))
# if we can move the chosen direction
if self._can_move(possible_direction):
# return the chosen direction
return possible_direction
# if we made it through all directions and couldn't move in any of them
# then raise the SnakeStuckException
raise SnakeAnimation.SnakeStuckException
def draw(self):
"""
Draw the current frame of the animation
"""
# if the snake is currently the desired length
if len(self.snake_pixels) == self.snake_length:
# remove the last segment from the list and turn it's LED off
self.pixel_grid[self.snake_pixels.pop()] = 0x000000
# if the snake is less than the desired length
# e.g. because we removed one in the previous step
if len(self.snake_pixels) < self.snake_length:
# wrap with try to catch the SnakeStuckException
try:
# update the direction, could continue straight, or could change
self.direction = self._choose_direction()
# the location of the next tile where the head of the snake will move to
next_tile = tuple(map(sum, zip(
SnakeAnimation.DIRECTION_OFFSETS[self.direction], self.snake_pixels[0])))
# insert the next tile at list index 0
self.snake_pixels.insert(0, next_tile)
# turn on the LED for the tile
self.pixel_grid[next_tile] = self.color
# if the snake exception is caught
except SnakeAnimation.SnakeStuckException:
# clear the snake to get rid of the old one
self._clear_snake()
# make a new snake
self._new_snake()
class SnakeStuckException(RuntimeError):
"""
Exception indicating the snake is stuck and can't move in any direction
"""
def __init__(self):
super().__init__("SnakeStuckException")

View file

@ -0,0 +1,36 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import board
import neopixel
from adafruit_led_animation.color import PINK, JADE
from adafruit_led_animation.sequence import AnimationSequence
from rainbowsweep import RainbowSweepAnimation
from sweep import SweepAnimation
from zipper import ZipperAnimation
# Update to match the pin connected to your NeoPixels
pixel_pin = board.A1
# Update to match the number of NeoPixels you have connected
pixel_num = 30
# initialize the neopixels. Change out for dotstars if needed.
pixels = neopixel.NeoPixel(pixel_pin, pixel_num, brightness=0.02, auto_write=False)
# initialize the animations
sweep = SweepAnimation(pixels, speed=0.05, color=PINK)
zipper = ZipperAnimation(pixels, speed=0.1, color=PINK, alternate_color=JADE)
rainbowsweep = RainbowSweepAnimation(pixels, speed=0.05, color=0x000000, sweep_speed=0.1,
sweep_direction=RainbowSweepAnimation.DIRECTION_END_TO_START)
# sequence to play them all one after another
animations = AnimationSequence(
sweep, zipper, rainbowsweep, advance_interval=6, auto_clear=True
)
while True:
animations.animate()

View file

@ -0,0 +1,145 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Adapted From `adafruit_led_animation.animation.rainbow`
"""
from adafruit_led_animation.animation import Animation
from adafruit_led_animation.color import colorwheel
from adafruit_led_animation import MS_PER_SECOND, monotonic_ms
class RainbowSweepAnimation(Animation):
"""
The classic rainbow color wheel that gets swept across by another specified color.
:param pixel_object: The initialised LED object.
:param float speed: Animation refresh rate in seconds, e.g. ``0.1``.
:param float sweep_speed: How long in seconds to wait between sweep steps
:param float period: Period to cycle the rainbow over in seconds. Default 1.
:param sweep_direction: which way to sweep across the rainbow. Must be one of
DIRECTION_START_TO_END or DIRECTION_END_TO_START
:param str name: Name of animation (optional, useful for sequences and debugging).
"""
# constants to represent the different directions
DIRECTION_START_TO_END = 0
DIRECTION_END_TO_START = 1
# pylint: disable=too-many-arguments
def __init__(
self, pixel_object, speed, color, sweep_speed=0.3, period=1,
name=None, sweep_direction=DIRECTION_START_TO_END
):
super().__init__(pixel_object, speed, color, name=name)
self._period = period
# internal var step used inside of color generator
self._step = 256 // len(pixel_object)
# internal var wheel_index used inside of color generator
self._wheel_index = 0
# instance of the generator
self._generator = self._color_wheel_generator()
# convert swap speed from seconds to ms and store it
self._sweep_speed = sweep_speed * 1000
# set the initial sweep index
self.sweep_index = len(pixel_object)
# internal variable to store the timestamp of when a sweep step occurs
self._last_sweep_time = 0
# store the direction argument
self.direction = sweep_direction
# this animation supports on cycle complete callbacks
on_cycle_complete_supported = True
def _color_wheel_generator(self):
# convert period to ms
period = int(self._period * MS_PER_SECOND)
# how many pixels in the strand
num_pixels = len(self.pixel_object)
# current timestamp
last_update = monotonic_ms()
cycle_position = 0
last_pos = 0
while True:
cycle_completed = False
# time vars
now = monotonic_ms()
time_since_last_draw = now - last_update
last_update = now
# cycle position vars
pos = cycle_position = (cycle_position + time_since_last_draw) % period
# if it's time to signal cycle complete
if pos < last_pos:
cycle_completed = True
# update position var for next iteration
last_pos = pos
# calculate wheel_index
wheel_index = int((pos / period) * 256)
# set all pixels to their color based on the wheel color and step
self.pixel_object[:] = [
colorwheel(((i * self._step) + wheel_index) % 255) for i in range(num_pixels)
]
# if it's time for a sweep step
if self._last_sweep_time + self._sweep_speed <= now:
# udpate sweep timestamp
self._last_sweep_time = now
# decrement the sweep index
self.sweep_index -= 1
# if it's finished the last step
if self.sweep_index == -1:
# reset it to the number of pixels in the strand
self.sweep_index = len(self.pixel_object)
# if end to start direction
if self.direction == self.DIRECTION_END_TO_START:
# set the current pixels at the end of the strand to the specified color
self.pixel_object[self.sweep_index:] = (
[self.color] * (len(self.pixel_object) - self.sweep_index))
# if start to end direction
elif self.direction == self.DIRECTION_START_TO_END:
# set the pixels at the begining of the strand to the specified color
inverse_index = len(self.pixel_object) - self.sweep_index
self.pixel_object[:inverse_index] = [self.color] * (inverse_index)
# update the wheel index
self._wheel_index = wheel_index
# signal cycle complete if it's time
if cycle_completed:
self.cycle_complete = True
yield
def draw(self):
"""
draw the current frame of the animation
:return:
"""
next(self._generator)
def reset(self):
"""
Resets the animation.
"""
self._generator = self._color_wheel_generator()

View file

@ -0,0 +1,68 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks
#
# SPDX-License-Identifier: MIT
"""
SweepAnimation helper class
"""
from adafruit_led_animation.animation import Animation
class SweepAnimation(Animation):
def __init__(self, pixel_object, speed, color):
"""
Sweeps across the strand lighting up one pixel at a time.
Once the full strand is lit, sweeps across again turning off
each pixel one at a time.
:param pixel_object: The initialized pixel object
:param speed: The speed to run the animation
:param color: The color the pixels will be lit up.
"""
# Call super class initialization
super().__init__(pixel_object, speed, color)
# custom variable to store the current step of the animation
self.current_step = 0
# one step per pixel
self.last_step = len(pixel_object)
# boolean indicating whether we're currently sweeping LEDs on or off
self.sweeping_on = True
self.cycle_complete = False
# This animation supports the cycle complete callback
on_cycle_complete_supported = True
def draw(self):
"""
Display the current frame of the animation
:return: None
"""
if self.sweeping_on:
# Turn on the next LED
self.pixel_object[self.current_step] = self.color
else: # sweeping off
# Turn off the next LED
self.pixel_object[self.current_step] = 0x000000
# increment the current step variable
self.current_step += 1
# if we've reached the last step
if self.current_step >= self.last_step:
# if we are currently sweeping off
if not self.sweeping_on:
# signal that the cycle is complete
self.cycle_complete = True
# reset the step variable to 0
self.current_step = 0
# flop sweeping on/off indicator variable
self.sweeping_on = not self.sweeping_on

View file

@ -0,0 +1,81 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks
#
# SPDX-License-Identifier: MIT
"""
ZipperAnimation helper class
"""
from adafruit_led_animation.animation import Animation
class ZipperAnimation(Animation):
def __init__(self, pixel_object, speed, color, alternate_color=None):
"""
Lights up every other LED from each ends of the strand, passing each
other in the middle and resulting in the full strand being lit at the
end of the cycle.
:param pixel_object: The initialized pixel object
:param speed: The speed to run the animation
:param color: The color the pixels will be lit up.
"""
# Call super class initialization
super().__init__(pixel_object, speed, color)
# if alternate color is None then use single color
if alternate_color is None:
self.alternate_color = color
else:
self.alternate_color = alternate_color
# custom variable to store the current step of the animation
self.current_step = 0
# We're lighting up every other LED, so we have half the strand
# length in steps.
self.last_step = len(pixel_object) // 2
self.cycle_complete = False
# This animation supports the cycle complete callback
on_cycle_complete_supported = True
def draw(self):
"""
Display the current frame of the animation
:return: None
"""
# Use try/except to ignore indexes outside the strand
try:
# Turn on 1 even indexed pixel starting from the start of the strand
self.pixel_object[self.current_step * 2] = self.color
# Turn on 1 odd indexed pixel starting from the end of the strand
self.pixel_object[-(self.current_step * 2) - 1] = self.alternate_color
except IndexError:
pass
# increment the current step variable
self.current_step += 1
# if we've reached the last step
if self.current_step > self.last_step:
# signal that the cycle is complete
self.cycle_complete = True
# call internal reset() function
self.reset()
def reset(self):
"""
Turns all the LEDs off and resets the current step variable to 0
:return: None
"""
# turn LEDs off
self.pixel_object.fill(0x000000)
# reset current step variable
self.current_step = 0