diff --git a/Custom_LED_Animations/grid_both/code.py b/Custom_LED_Animations/grid_both/code.py new file mode 100644 index 000000000..0b6cf6b62 --- /dev/null +++ b/Custom_LED_Animations/grid_both/code.py @@ -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() diff --git a/Custom_LED_Animations/grid_both/conways.py b/Custom_LED_Animations/grid_both/conways.py new file mode 100644 index 000000000..d9984f01a --- /dev/null +++ b/Custom_LED_Animations/grid_both/conways.py @@ -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 diff --git a/Custom_LED_Animations/grid_both/snake.py b/Custom_LED_Animations/grid_both/snake.py new file mode 100644 index 000000000..b1cf6c157 --- /dev/null +++ b/Custom_LED_Animations/grid_both/snake.py @@ -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") diff --git a/Custom_LED_Animations/strand_sequence/code.py b/Custom_LED_Animations/strand_sequence/code.py new file mode 100644 index 000000000..ca9896da0 --- /dev/null +++ b/Custom_LED_Animations/strand_sequence/code.py @@ -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() diff --git a/Custom_LED_Animations/strand_sequence/rainbowsweep.py b/Custom_LED_Animations/strand_sequence/rainbowsweep.py new file mode 100644 index 000000000..ff73929cc --- /dev/null +++ b/Custom_LED_Animations/strand_sequence/rainbowsweep.py @@ -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() diff --git a/Custom_LED_Animations/strand_sequence/sweep.py b/Custom_LED_Animations/strand_sequence/sweep.py new file mode 100644 index 000000000..d56a03b3d --- /dev/null +++ b/Custom_LED_Animations/strand_sequence/sweep.py @@ -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 diff --git a/Custom_LED_Animations/strand_sequence/zipper.py b/Custom_LED_Animations/strand_sequence/zipper.py new file mode 100644 index 000000000..52859ca1c --- /dev/null +++ b/Custom_LED_Animations/strand_sequence/zipper.py @@ -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