diff --git a/LICENSE b/LICENSE index f1ccab1..3367ac5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) Copyright (c) 2017 Adam Patt +Copyright (c) 2019-2020 Roy Hooper, Kattni Rembor Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/adafruit_led_animation/__init__.py b/adafruit_led_animation/__init__.py index c508980..fbe8130 100644 --- a/adafruit_led_animation/__init__.py +++ b/adafruit_led_animation/__init__.py @@ -1,5 +1,49 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Roy Hooper +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. """ -Adafruit LED Animation library. +Timing for Adafruit LED Animation library. + +Author(s): Roy Hooper """ -NANOS_PER_SECOND = 1000000000 +try: + from micropython import const +except ImportError: + + def const(value): # pylint: disable=missing-docstring + return value + + +try: + from time import monotonic_ns +except ImportError: + import time + + def monotonic_ns(): + """ + Implementation of monotonic_ns for platforms without time.monotonic_ns + """ + return int(time.time() * NANOS_PER_SECOND) + + +NANOS_PER_SECOND = const(1000000000) +NANOS_PER_MS = const(1000000) diff --git a/adafruit_led_animation/animation.py b/adafruit_led_animation/animation.py index c79827e..51a7e9d 100644 --- a/adafruit_led_animation/animation.py +++ b/adafruit_led_animation/animation.py @@ -1,6 +1,7 @@ # The MIT License (MIT) # -# Copyright (c) 2019 Kattni Rembor for Adafruit Industries +# Copyright (c) 2019-2020 Roy Hooper +# Copyright (c) 2020 Kattni Rembor for Adafruit Industries # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -23,8 +24,7 @@ `adafruit_led_animation.animation` ================================================================================ -CircuitPython helper library for LED animations. - +Animation base class, and basic animations for CircuitPython helper library for LED animations. * Author(s): Roy Hooper, Kattni Rembor @@ -43,45 +43,45 @@ """ -import random from math import ceil - -from . import NANOS_PER_SECOND +from . import NANOS_PER_SECOND, monotonic_ns from .color import BLACK, RAINBOW -try: - from time import monotonic_ns -except ImportError: - import time - - def monotonic_ns(): - """ - Implementation of monotonic_ns for platforms without time.monotonic_ns - """ - return int(time.time() * NANOS_PER_SECOND) - - __version__ = "0.0.0-auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_LED_Animation.git" class Animation: + # pylint: disable=too-many-instance-attributes """ Base class for animations. """ + cycle_complete_supported = False # pylint: disable=too-many-arguments - def __init__(self, pixel_object, speed, color, peers=None, paused=False): + def __init__(self, pixel_object, speed, color, peers=None, paused=False, name=None): self.pixel_object = pixel_object self.pixel_object.auto_write = False self.peers = peers if peers else [] + """A sequence of animations to trigger .draw() on when this animation draws.""" self._speed_ns = 0 self._color = None self._paused = paused self._next_update = monotonic_ns() self._time_left_at_pause = 0 + self._also_notify = [] self.speed = speed # sets _speed_ns self.color = color # Triggers _recompute_color + self.name = name + self.notify_cycles = 1 + """Number of cycles to trigger additional cycle_done notifications after""" + self.draw_count = 0 + """Number of animation frames drawn.""" + self.cycle_count = 0 + """Number of animation cycles completed.""" + + def __str__(self): + return "<%s: %s>" % (self.__class__.__name__, self.name) def animate(self): """ @@ -98,12 +98,12 @@ def animate(self): return False self.draw() + self.draw_count += 1 + + # Draw related animations together if self.peers: for peer in self.peers: peer.draw() - self.show() - for peer in self.peers: - peer.show() self._next_update = now + self._speed_ns return True @@ -111,6 +111,7 @@ def animate(self): def draw(self): """ Animation subclasses must implement draw() to render the animation sequence. + Draw must call show(). """ raise NotImplementedError() @@ -140,7 +141,6 @@ def fill(self, color): Fills the pixel object with a color. """ self.pixel_object.fill(color) - self.show() @property def color(self): @@ -175,26 +175,54 @@ def _recompute_color(self, color): Override as needed. """ + def cycle_complete(self): + """ + Called by some animations when they complete an animation cycle. + Animations that support cycle complete notifications will have X property set to False. + Override as needed. + """ + self.cycle_count += 1 + if self.cycle_count % self.notify_cycles == 0: + for callback in self._also_notify: + callback(self) + + def add_cycle_complete_receiver(self, callback): + """ + Adds an additional callback when the cycle completes. + + :param callback: Additional callback to trigger when a cycle completes. The callback + is passed the animation object instance. + """ + self._also_notify.append(callback) + + def reset(self): + """ + Resets the animation sequence. + """ + class ColorCycle(Animation): """ Animate a sequence of one or more colors, cycling at the specified speed. :param pixel_object: The initialised LED object. - :param int speed: Animation speed in seconds, e.g. ``0.1``. + :param float speed: Animation speed in seconds, e.g. ``0.1``. :param colors: A list of colors to cycle through in ``(r, g, b)`` tuple, or ``0x000000`` hex format. Defaults to a rainbow color cycle. """ - def __init__(self, pixel_object, speed, colors=RAINBOW): + def __init__(self, pixel_object, speed, colors=RAINBOW, name=None): self.colors = colors - super(ColorCycle, self).__init__(pixel_object, speed, colors[0]) + super().__init__(pixel_object, speed, colors[0], name=name) self._generator = self._color_generator() + next(self._generator) + + cycle_complete_supported = True def draw(self): - next(self._generator) self.pixel_object.fill(self.color) self.show() + next(self._generator) def _color_generator(self): index = 0 @@ -202,6 +230,14 @@ def _color_generator(self): self._color = self.colors[index] yield index = (index + 1) % len(self.colors) + if index == 0: + self.cycle_complete() + + def reset(self): + """ + Resets to the first color. + """ + self._generator = self._color_generator() class Blink(ColorCycle): @@ -209,12 +245,12 @@ class Blink(ColorCycle): Blink a color on and off. :param pixel_object: The initialised LED object. - :param int speed: Animation speed in seconds, e.g. ``0.1``. + :param float speed: Animation speed in seconds, e.g. ``0.1``. :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. """ - def __init__(self, pixel_object, speed, color): - super(Blink, self).__init__(pixel_object, speed, [color, BLACK]) + def __init__(self, pixel_object, speed, color, name=None): + super().__init__(pixel_object, speed, [color, BLACK], name=name) def _recompute_color(self, color): self.colors = [color, BLACK] @@ -228,8 +264,8 @@ class Solid(ColorCycle): :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. """ - def __init__(self, pixel_object, color): - super(Solid, self).__init__(pixel_object, speed=1, colors=[color]) + def __init__(self, pixel_object, color, name=None): + super().__init__(pixel_object, speed=1, colors=[color], name=name) def _recompute_color(self, color): self.colors = [color] @@ -240,7 +276,7 @@ class Comet(Animation): A comet animation. :param pixel_object: The initialised LED object. - :param int speed: Animation speed in seconds, e.g. ``0.1``. + :param float speed: Animation speed in seconds, e.g. ``0.1``. :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. :param int tail_length: The length of the comet. Defaults to 10. Cannot exceed the number of pixels present in the pixel object, e.g. if the strip is 30 pixels @@ -251,22 +287,33 @@ class Comet(Animation): # pylint: disable=too-many-arguments def __init__( - self, pixel_object, speed, color, tail_length=10, reverse=False, bounce=False + self, + pixel_object, + speed, + color, + tail_length=10, + reverse=False, + bounce=False, + name=None, ): self._tail_length = tail_length + 1 self._color_step = 0.9 / tail_length self._color_offset = 0.1 self._comet_colors = None self._reverse_comet_colors = None + self._initial_reverse = reverse self.reverse = reverse self.bounce = bounce - # Super is called late because it needs ._color to be initialized. - super(Comet, self).__init__(pixel_object, speed, color) - # _recompute_color needs calling before creating the generator, so setup the generator - # afterwards + self._computed_color = color self._generator = self._comet_generator() + super().__init__(pixel_object, speed, color, name=name) + + cycle_complete_supported = True def _recompute_color(self, color): + pass + + def __recompute_color(self, color): self._comet_colors = [BLACK] + [ [ int(color[rgb] * ((n * self._color_step) + self._color_offset)) @@ -275,6 +322,7 @@ def _recompute_color(self, color): for n in range(self._tail_length - 1) ] self._reverse_comet_colors = list(reversed(self._comet_colors)) + self._computed_color = color def _get_range(self, num_pixels): if self.reverse: @@ -283,7 +331,10 @@ def _get_range(self, num_pixels): def _comet_generator(self): num_pixels = len(self.pixel_object) + cycle_passes = 0 while True: + if self._color != self._computed_color or not self._comet_colors: + self.__recompute_color(self._color) colors = self._reverse_comet_colors if self.reverse else self._comet_colors for start in self._get_range(num_pixels): @@ -300,47 +351,22 @@ def _comet_generator(self): self.pixel_object[start : start + end] = colors[0:end] self.show() yield + cycle_passes += 1 if self.bounce: self.reverse = not self.reverse + if not self.bounce or cycle_passes == 2: + self.cycle_complete() + cycle_passes = 0 def draw(self): next(self._generator) - -class Sparkle(Animation): - """ - Sparkle animation of a single color. - - :param pixel_object: The initialised LED object. - :param int speed: Animation speed in seconds, e.g. ``0.1``. - :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. - """ - - def __init__(self, pixel_object, speed, color): - if len(pixel_object) < 2: - raise ValueError("Sparkle needs at least 2 pixels") - self._half_color = None - self._dim_color = None - super(Sparkle, self).__init__(pixel_object, speed, color) - - def _recompute_color(self, color): - half_color = tuple(color[rgb] // 4 for rgb in range(len(color))) - dim_color = tuple(color[rgb] // 10 for rgb in range(len(color))) - for pixel in range(len(self.pixel_object)): - if self.pixel_object[pixel] == self._half_color: - self.pixel_object[pixel] = half_color - elif self.pixel_object[pixel] == self._dim_color: - self.pixel_object[pixel] = dim_color - self._half_color = half_color - self._dim_color = dim_color - - def draw(self): - pixel = random.randint(0, (len(self.pixel_object) - 2)) - self.pixel_object[pixel] = self._color - self.show() - self.pixel_object[pixel] = self._half_color - self.pixel_object[pixel + 1] = self._dim_color - self.show() + def reset(self): + """ + Resets to the first color. + """ + self._generator = self._comet_generator() + self.reverse = self._initial_reverse class Pulse(Animation): @@ -348,104 +374,37 @@ class Pulse(Animation): Pulse all pixels a single color. :param pixel_object: The initialised LED object. - :param int speed: Animation refresh rate in seconds, e.g. ``0.1``. + :param float speed: Animation refresh rate in seconds, e.g. ``0.1``. :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. :param period: Period to pulse the LEDs over. Default 5. - :param max_intensity: The maximum intensity to pulse, between 0 and 1.0. Default 1. - :param min_intensity: The minimum intensity to pulse, between 0 and 1.0. Default 0. """ # pylint: disable=too-many-arguments - def __init__( - self, pixel_object, speed, color, period=5, max_intensity=1, min_intensity=0 - ): - self.max_intensity = max_intensity - self.min_intensity = min_intensity + def __init__(self, pixel_object, speed, color, period=5, name=None): + super().__init__(pixel_object, speed, color, name=name) self._period = period - self._intensity_delta = max_intensity - min_intensity - self._half_period = period / 2 - self._position_factor = 1 / self._half_period - self._bpp = len(pixel_object[0]) - self._last_update = monotonic_ns() - self._cycle_position = 0 - super(Pulse, self).__init__(pixel_object, speed, color) + self._generator = None + self.reset() + + cycle_complete_supported = True def draw(self): - now = monotonic_ns() - time_since_last_draw = (now - self._last_update) / NANOS_PER_SECOND - self._last_update = now - pos = self._cycle_position = ( - self._cycle_position + time_since_last_draw - ) % self._period - if pos > self._half_period: - pos = self._period - pos - intensity = self.min_intensity + ( - pos * self._intensity_delta * self._position_factor - ) - color = [int(self.color[n] * intensity) for n in range(self._bpp)] + color = next(self._generator) self.fill(color) self.show() - -class SparklePulse(Animation): - """ - Combination of the Spark and Pulse animations. - - :param pixel_object: The initialised LED object. - :param int speed: Animation refresh rate in seconds, e.g. ``0.1``. - :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. - :param period: Period to pulse the LEDs over. Default 5. - :param max_intensity: The maximum intensity to pulse, between 0 and 1.0. Default 1. - :param min_intensity: The minimum intensity to pulse, between 0 and 1.0. Default 0. - """ - - # pylint: disable=too-many-arguments - def __init__( - self, pixel_object, speed, color, period=5, max_intensity=1, min_intensity=0 - ): - if len(pixel_object) < 2: - raise ValueError("Sparkle needs at least 2 pixels") - self.max_intensity = max_intensity - self.min_intensity = min_intensity - self._period = period - self._intensity_delta = max_intensity - min_intensity - self._half_period = period / 2 - self._position_factor = 1 / self._half_period - self._bpp = len(pixel_object[0]) - self._last_update = monotonic_ns() - self._cycle_position = 0 - self._half_color = None - self._dim_color = None - super(SparklePulse, self).__init__(pixel_object, speed, color) - - def _recompute_color(self, color): - half_color = tuple(color[rgb] // 4 for rgb in range(len(color))) - dim_color = tuple(color[rgb] // 10 for rgb in range(len(color))) - for pixel in range(len(self.pixel_object)): - if self.pixel_object[pixel] == self._half_color: - self.pixel_object[pixel] = half_color - elif self.pixel_object[pixel] == self._dim_color: - self.pixel_object[pixel] = dim_color - self._half_color = half_color - self._dim_color = dim_color - - def draw(self): - pixel = random.randint(0, (len(self.pixel_object) - 2)) - - now = monotonic_ns() - time_since_last_draw = (now - self._last_update) / NANOS_PER_SECOND - self._last_update = now - pos = self._cycle_position = ( - self._cycle_position + time_since_last_draw - ) % self._period - if pos > self._half_period: - pos = self._period - pos - intensity = self.min_intensity + ( - pos * self._intensity_delta * self._position_factor + def reset(self): + """ + Resets the animation. + """ + white = len(self.pixel_object[0]) > 3 and isinstance( + self.pixel_object[0][-1], int + ) + from adafruit_led_animation.helper import ( # pylint: disable=import-outside-toplevel + pulse_generator, ) - color = [int(self.color[n] * intensity) for n in range(self._bpp)] - self.pixel_object[pixel] = color - self.show() + + self._generator = pulse_generator(self._period, self, white) class Chase(Animation): @@ -453,7 +412,7 @@ class Chase(Animation): Chase pixels in one direction in a single color, like a theater marquee sign. :param pixel_object: The initialised LED object. - :param int speed: Animation speed rate in seconds, e.g. ``0.1``. + :param float speed: Animation speed rate in seconds, e.g. ``0.1``. :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. :param size: Number of pixels to turn on in a row. :param spacing: Number of pixels to turn off in a row. @@ -461,7 +420,9 @@ class Chase(Animation): """ # pylint: disable=too-many-arguments - def __init__(self, pixel_object, speed, color, size=2, spacing=3, reverse=False): + def __init__( + self, pixel_object, speed, color, size=2, spacing=3, reverse=False, name=None + ): self._size = size self._spacing = spacing self._repeat_width = size + spacing @@ -469,8 +430,18 @@ def __init__(self, pixel_object, speed, color, size=2, spacing=3, reverse=False) self._overflow = len(pixel_object) % self._repeat_width self._direction = 1 if not reverse else -1 self._reverse = reverse - self._n = 0 - super(Chase, self).__init__(pixel_object, speed, color) + self._offset = 0 + + def _resetter(): + self._offset = 0 + self._reverse = reverse + self._direction = 1 if not reverse else -1 + + self._reset = _resetter + + super().__init__(pixel_object, speed, color, name=name) + + cycle_complete_supported = True @property def reverse(self): @@ -485,201 +456,49 @@ def reverse(self, value): self._direction = -1 if self._reverse else 1 def draw(self): - self.pixel_object.fill((0, 0, 0)) - for i in range(self._size): - n = (self._n + i) % self._repeat_width - num = len(self.pixel_object[n :: self._repeat_width]) - self.pixel_object[n :: self._repeat_width] = [ - self.group_color(n) for n in range(num) - ] - self._n = (self._n + self._direction) % self._repeat_width + def bar_colors(): + bar_no = 0 + for i in range(self._offset, 0, -1): + if i > self._spacing: + yield self.bar_color(bar_no, i) + else: + yield self.space_color(bar_no, i) + bar_no = 1 + while True: + for bar_pixel in range(self._size): + yield self.bar_color(bar_no, bar_pixel) + for space_pixel in range(self._spacing): + yield self.space_color(bar_no, space_pixel) + bar_no += 1 + + colorgen = bar_colors() + self.pixel_object[:] = [next(colorgen) for _ in self.pixel_object] self.show() - def group_color(self, n): # pylint: disable=unused-argument + if self.draw_count % len(self.pixel_object) == 0: + self.cycle_complete() + self._offset = (self._offset + self._direction) % self._repeat_width + + def bar_color(self, n, pixel_no=0): # pylint: disable=unused-argument """ - Generate the color for the n'th group + Generate the color for the n'th bar_color in the Chase :param n: The pixel group to get the color for + :param pixel_no: Which pixel in the group to get the color for """ return self.color - -class AnimationSequence: - """ - A sequence of Animations to run in sequence, looping forever. - Advances manually or at the specified interval. - - :param members: The animation objects or groups. - :param int advance_interval: Time in seconds between animations if cycling - automatically. Defaults to ``None``. - - .. code-block:: python - - from adafruit_led_animation.animation import AnimationSequence, Blink, Comet, Sparkle - import adafruit_led_animation.color as color - import board - import neopixel - - strip_pixels = neopixel.NeoPixel(board.A1, 30, brightness=1, auto_write=False) - - blink = Blink(strip_pixels, 0.2, color.RED) - comet = Comet(strip_pixels, 0.1, color.BLUE) - sparkle = Sparkle(strip_pixels, 0.05, color.GREEN) - - animations = AnimationSequence(blink, comet, sparkle, advance_interval=1) - - while True: - animations.animate() - """ - - def __init__(self, *members, advance_interval=None, auto_clear=False): - self._members = members - self._advance_interval = ( - advance_interval * NANOS_PER_SECOND if advance_interval else None - ) - self._last_advance = monotonic_ns() - self._current = 0 - self._auto_clear = auto_clear - self.clear_color = BLACK - self._paused = False - self._paused_at = 0 - - def _auto_advance(self): - if not self._advance_interval: - return - now = monotonic_ns() - if now - self._last_advance > self._advance_interval: - self._last_advance = now - self.next() - - def next(self): - """ - Jump to the next animation. - """ - if self._auto_clear: - self.fill(self.clear_color) - self._current = (self._current + 1) % len(self._members) - - def animate(self): - """ - Call animate() from your code's main loop. It will draw the current animation - or go to the next animation based on the advance_interval if set. - - :return: True if the animation draw cycle was triggered, otherwise False. - """ - if not self._paused: - self._auto_advance() - return self.current_animation.animate() - - @property - def current_animation(self): - """ - Returns the current animation in the sequence. - """ - return self._members[self._current] - - @property - def color(self): - """ - Use this property to change the color of all members of the animation. - """ - return None - - @color.setter - def color(self, color): - for item in self._members: - item.color = color - - def fill(self, color): - """ - Fills the current animation with a color. - """ - self.current_animation.fill(color) - - def freeze(self): - """ - Freeze the current animation in the sequence. - Also stops auto_advance. - """ - if self._paused: - return - self._paused = True - self._paused_at = monotonic_ns() - self.current_animation.freeze() - - def resume(self): - """ - Resume the current animation in the sequence, and resumes auto advance if enabled. - """ - if not self._paused: - return - self._paused = False - now = monotonic_ns() - self._last_advance += now - self._paused_at - self._paused_at = 0 - self.current_animation.resume() - - -class AnimationGroup: - """ - A group of animations that are active together. An example would be grouping a strip of - pixels connected to a board and the onboard LED. - - :param members: The animation objects or groups. - :param bool sync: Synchronises the timing of all members of the group to the settings of the - first member of the group. Defaults to ``False``. - - """ - - def __init__(self, *members, sync=False): - self._members = members - self._sync = sync - if sync: - main = members[0] - main.peers = members[1:] - - def animate(self): + def space_color(self, n, pixel_no=0): # pylint: disable=unused-argument,no-self-use """ - Call animate() from your code's main loop. It will draw all of the animations - in the group. + Generate the spacing color for the n'th bar_color in the Chase - :return: True if any animation draw cycle was triggered, otherwise False. + :param n: The pixel group to get the spacing color for + :param pixel_no: Which pixel in the group to get the spacing color for """ - if self._sync: - return self._members[0].animate() - - return any([item.animate() for item in self._members]) + return 0 - def _for_all(self, method, *args, **kwargs): - for item in self._members: - getattr(item, method)(*args, **kwargs) - - @property - def color(self): - """ - Use this property to change the color of all members of the animation group. - """ - return None - - @color.setter - def color(self, color): - for item in self._members: - item.color = color - - def fill(self, color): - """ - Fills all pixel objects in the group with a color. - """ - self._for_all("fill", color) - - def freeze(self): - """ - Freeze all animations in the group. - """ - self._for_all("freeze") - - def resume(self): + def reset(self): """ - Resume all animations in the group. + Reset the animation. """ - self._for_all("resume") + self._reset() diff --git a/adafruit_led_animation/color.py b/adafruit_led_animation/color.py index b34513c..0cdb0d2 100644 --- a/adafruit_led_animation/color.py +++ b/adafruit_led_animation/color.py @@ -1,4 +1,25 @@ -"""Color variables made available for import. +# The MIT License (MIT) +# +# Copyright (c) 2019 Kattni Rembor for Adafruit Industries +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +"""Color variables made available for import for CircuitPython LED animations library. RAINBOW is a list of colors to use for cycling through. """ @@ -21,3 +42,21 @@ AMBER = (255, 100, 0) RAINBOW = (RED, ORANGE, YELLOW, GREEN, BLUE, PURPLE) + + +try: + from _pixelbuf import colorwheel # pylint: disable=unused-import +except ImportError: + # Ensure we have a wheel if not built in + def colorwheel(pos): + """Input a value 0 to 255 to get a color value. + The colours are a transition r - g - b - back to r.""" + if pos < 0 or pos > 255: + return 0, 0, 0 + if pos < 85: + return int(255 - pos * 3), int(pos * 3), 0 + if pos < 170: + pos -= 85 + return 0, int(255 - pos * 3), int(pos * 3) + pos -= 170 + return int(pos * 3), 0, int(255 - (pos * 3)) diff --git a/adafruit_led_animation/group.py b/adafruit_led_animation/group.py new file mode 100644 index 0000000..90cee46 --- /dev/null +++ b/adafruit_led_animation/group.py @@ -0,0 +1,167 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019-2020 Roy Hooper +# Copyright (c) 2020 Kattni Rembor for Adafruit Industries +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_led_animation.group` +================================================================================ + +Animation group helper for CircuitPython helper library for LED animations.. + + +* Author(s): Roy Hooper, Kattni Rembor + +Implementation Notes +-------------------- + +**Hardware:** + +* `Adafruit NeoPixels `_ +* `Adafruit DotStars `_ + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://circuitpython.org/downloads + +""" + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_LED_Animation.git" + + +class AnimationGroup: + """ + A group of animations that are active together. An example would be grouping a strip of + pixels connected to a board and the onboard LED. + + :param members: The animation objects or groups. + :param bool sync: Synchronises the timing of all members of the group to the settings of the + first member of the group. Defaults to ``False``. + + """ + + def __init__(self, *members, sync=False, name=None): + if not members: + raise ValueError("At least one member required in an AnimationGroup") + self.draw_count = 0 + """Number of animation frames drawn.""" + self.cycle_count = 0 + """Number of animation cycles completed.""" + self.notify_cycles = 1 + """Number of cycles to trigger additional cycle_done notifications after""" + self._members = list(members) + self._sync = sync + self._also_notify = [] + self.cycle_count = 0 + self.name = name + if sync: + main = members[0] + main.peers = members[1:] + + # Catch cycle_complete on the last animation. + self._members[-1].add_cycle_complete_receiver(self._group_done) + self.cycle_complete_supported = self._members[-1].cycle_complete_supported + + def __str__(self): + return "" % (self.__class__.__name__, self.name) + + def _group_done(self, animation): # pylint: disable=unused-argument + self.cycle_complete() + + def cycle_complete(self): + """ + Called by some animations when they complete an animation cycle. + Animations that support cycle complete notifications will have X property set to False. + Override as needed. + """ + self.cycle_count += 1 + if self.cycle_count % self.notify_cycles == 0: + for callback in self._also_notify: + callback(self) + + def add_cycle_complete_receiver(self, callback): + """ + Adds an additional callback when the cycle completes. + + :param callback: Additional callback to trigger when a cycle completes. The callback + is passed the animation object instance. + """ + self._also_notify.append(callback) + + def animate(self): + """ + Call animate() from your code's main loop. It will draw all of the animations + in the group. + + :return: True if any animation draw cycle was triggered, otherwise False. + """ + if self._sync: + return self._members[0].animate() + + return any([item.animate() for item in self._members]) + + @property + def color(self): + """ + Use this property to change the color of all members of the animation group. + """ + return None + + @color.setter + def color(self, color): + for item in self._members: + item.color = color + + def fill(self, color): + """ + Fills all pixel objects in the group with a color. + """ + for item in self._members: + item.fill(color) + + def freeze(self): + """ + Freeze all animations in the group. + """ + for item in self._members: + item.freeze() + + def resume(self): + """ + Resume all animations in the group. + """ + for item in self._members: + item.resume() + + def reset(self): + """ + Resets the animations in the group. + """ + for item in self._members: + item.reset() + + def show(self): + """ + Draws the current animation group members. + """ + for item in self._members: + item.show() diff --git a/adafruit_led_animation/helper.py b/adafruit_led_animation/helper.py new file mode 100644 index 0000000..f38367f --- /dev/null +++ b/adafruit_led_animation/helper.py @@ -0,0 +1,394 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Roy Hooper +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_led_animation.helper` +================================================================================ + +Helper classes for making complex animations using CircuitPython LED animations library. + +* Author(s): Roy Hooper, Kattni Rembor + +Implementation Notes +-------------------- + +**Hardware:** + +* `Adafruit NeoPixels `_ +* `Adafruit DotStars `_ + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://circuitpython.org/downloads + +""" + +import math + +from . import NANOS_PER_SECOND, monotonic_ns + + +class PixelMap: + """ + PixelMap lets you treat ranges of pixels as single pixels for animation purposes. + + :param strip: An object that implements the Neopixel or Dotstar protocol. + :param iterable pixel_ranges: Pixel ranges (or individual pixels). + :param bool individual_pixels: Whether pixel_ranges are individual pixels. + + To use with ranges of pixels: + + .. code-block:: python + + import board + import neopixel + from adafruit_led_animation.helper import PixelMap + pixels = neopixel.NeoPixel(board.D6, 32, auto_write=False) + + pixel_wing_horizontal = PixelMap(pixels, [(0, 8), (8, 16), (16, 24), (24, 32)]) + + pixel_wing_horizontal[0] = (255, 255, 0) + pixel_wing_horizontal.show() + + To use with individual pixels: + + .. code-block:: python + + import board + import neopixel + from adafruit_led_animation.helper import PixelMap + pixels = neopixel.NeoPixel(board.D6, 32, auto_write=False) + + pixel_wing_vertical = PixelMap(pixels, [ + (0, 8, 16, 24), + (1, 9, 17, 25), + (2, 10, 18, 26), + (3, 11, 19, 27), + (4, 12, 20, 28), + (5, 13, 21, 29), + (6, 14, 22, 30), + (7, 15, 23, 31), + ], individual_pixels=True) + + pixel_wing_vertical[0] = (255, 255, 0) + pixel_wing_vertical.show() + + """ + + def __init__(self, strip, pixel_ranges, individual_pixels=False): + self._pixels = strip + self._ranges = pixel_ranges + self.n = len(self._ranges) + self._individual_pixels = individual_pixels + + def __repr__(self): + return "[" + ", ".join([str(x) for x in self]) + "]" + + def _set_pixels(self, index, val): + if self._individual_pixels: + for pixel in self._ranges[index]: + self._pixels[pixel] = val + else: + range_start, range_stop = self._ranges[index] + self._pixels[range_start:range_stop] = [val] * (range_stop - range_start) + + def __setitem__(self, index, val): + if isinstance(index, slice): + start, stop, step = index.indices(len(self._ranges)) + length = stop - start + if step != 0: + length = math.ceil(length / step) + if len(val) != length: + raise ValueError("Slice and input sequence size do not match.") + for val_i, in_i in enumerate(range(start, stop, step)): + self._set_pixels(in_i, val[val_i]) + else: + self._set_pixels(index, val) + + if not self._pixels.auto_write: + self.show() + + def __getitem__(self, index): + if isinstance(index, slice): + out = [] + for in_i in range(*index.indices(len(self._ranges))): + out.append(self._pixels[self._ranges[in_i][0]]) + return out + if index < 0: + index += len(self) + if index >= self.n or index < 0: + raise IndexError + return self._pixels[self._ranges[index][0]] + + def __len__(self): + return len(self._ranges) + + @property + def brightness(self): + """ + brightness from the underlying strip. + """ + return self._pixels.brightness + + @brightness.setter + def brightness(self, brightness): + # pylint: disable=attribute-defined-outside-init + self._pixels.brightness = min(max(brightness, 0.0), 1.0) + + def fill(self, color): + """ + Fill the used pixel ranges with color. + + :param color: Color to fill all pixels referenced by this PixelMap definition with. + """ + if self._individual_pixels: + for pixels in self._ranges: + for pixel in pixels: + self._pixels[pixel] = color + else: + for start, stop in self._ranges: + self._pixels[start:stop] = [color] * (stop - start) + + def show(self): + """ + Shows the pixels on the underlying strip. + """ + self._pixels.show() + + @property + def auto_write(self): + """ + auto_write from the underlying strip. + """ + return self._pixels.auto_write + + @auto_write.setter + def auto_write(self, value): + self._pixels.auto_write = value + + @classmethod + def vertical_lines(cls, pixels, width, height, gridmapper): + """ + Generate a PixelMap of horizontal lines on a strip arranged in a grid. + + :param pixels: pixel object + :param width: width of grid + :param height: height of grid + :param gridmapper: a function to map x and y coordinates to the grid + see vertical_strip_gridmap and horizontal_strip_gridmap + :return: PixelMap + + Example: Vertical lines on a 32x8 grid with the pixel rows oriented vertically, + alternating direction every row. + + .. code-block:: python + + PixelMap.vertical_lines(pixels, 32, 8, vertical_strip_gridmap(8)) + + """ + if len(pixels) < width * height: + raise ValueError("number of pixels is less than width x height") + mapping = [] + for x in range(width): + mapping.append([gridmapper(x, y) for y in range(height)]) + return cls(pixels, mapping, individual_pixels=True) + + @classmethod + def horizontal_lines(cls, pixels, width, height, gridmapper): + """ + Generate a PixelMap of horizontal lines on a strip arranged in a grid. + + :param pixels: pixel object + :param width: width of grid + :param height: height of grid + :param gridmapper: a function to map x and y coordinates to the grid + see vertical_strip_gridmap and horizontal_strip_gridmap + :return: PixelMap + + Example: Horizontal lines on a 16x16 grid with the pixel rows oriented vertically, + alternating direction every row. + + .. code-block:: python + + PixelMap.horizontal_lines(pixels, 16, 16, vertical_strip_gridmap(16)) + """ + if len(pixels) < width * height: + raise ValueError("number of pixels is less than width x height") + mapping = [] + for y in range(height): + mapping.append([gridmapper(x, y) for x in range(width)]) + return cls(pixels, mapping, individual_pixels=True) + + +def vertical_strip_gridmap(height, alternating=True): + """ + Returns a function that determines the pixel number for a grid with strips arranged vertically. + + :param height: strip height in pixels + :param alternating: strips alternate directions in a zigzag + :return: mapper(x, y) + """ + + def mapper(x, y): + if alternating and x % 2: + return x * height + (height - 1 - y) + return x * height + y + + return mapper + + +def horizontal_strip_gridmap(width, alternating=True): + """ + Determines the pixel number for a grid with strips arranged horizontally. + + :param width: strip width in pixels + :param alternating: strips alternate directions in a zigzag + :return: mapper(x, y) + """ + + def mapper(x, y): + if alternating and y % 2: + return y * width + (width - 1 - x) + return y * width + x + + return mapper + + +class PixelSubset: + """ + PixelSubset lets you work with a subset of a pixel object. + + :param strip: An object that implements the Neopixel or Dotstar protocol. + :param int start: Starting pixel number. + :param int end: Ending pixel number. + + .. code-block:: python + + import board + import neopixel + from adafruit_led_animation.helper import PixelSubset + pixels = neopixel.NeoPixel(board.D12, 307, auto_write=False) + + star_start = 260 + star_arm = PixelSubset(pixels, star_start + 7, star_start + 15) + star_arm.fill((255, 0, 255)) + pixels.show() + """ + + def __init__(self, strip, start, end): + self._pixels = strip + self._start = start + self._end = end + self.n = self._end - self._start + + def __repr__(self): + return "[" + ", ".join([str(x) for x in self]) + "]" + + def __setitem__(self, index, val): + if isinstance(index, slice): + start, stop, step = index.indices(self.n) + self._pixels[start + self._start : stop + self._start : step] = val + else: + self._pixels[index + self._start] = val + + if not self._pixels.auto_write: + self.show() + + def __getitem__(self, index): + if isinstance(index, slice): + start, stop, step = index.indices(self.n) + return self._pixels[start + self._start : stop + self._start : step] + if index < 0: + index += len(self) + if index >= self.n or index < 0: + raise IndexError + return self._pixels[index] + + def __len__(self): + return self.n + + @property + def brightness(self): + """ + brightness from the underlying strip. + """ + return self._pixels.brightness + + @brightness.setter + def brightness(self, brightness): + self._pixels.brightness = min(max(brightness, 0.0), 1.0) + + def fill(self, color): + """ + Fill the used pixel ranges with color. + """ + self._pixels[self._start : self._end] = [color] * (self.n) + + def show(self): + """ + Shows the pixels on the underlying strip. + """ + self._pixels.show() + + @property + def auto_write(self): + """ + auto_write from the underlying strip. + """ + return self._pixels.auto_write + + @auto_write.setter + def auto_write(self, value): + self._pixels.auto_write = value + + +def pulse_generator(period: float, animation_object, white=False): + """ + Generates a sequence of colors for a pulse, based on the time period specified. + :param period: Pulse duration in seconds. + :param animation_object: An animation object to interact with. + :param white: Whether the pixel strip has a white pixel. + """ + period = int(period * NANOS_PER_SECOND) + half_period = period // 2 + + last_update = monotonic_ns() + cycle_position = 0 + last_pos = 0 + while True: + fill_color = list(animation_object.color) + now = monotonic_ns() + time_since_last_draw = now - last_update + last_update = now + pos = cycle_position = (cycle_position + time_since_last_draw) % period + if pos < last_pos: + animation_object.cycle_complete() + last_pos = pos + if pos > half_period: + pos = period - pos + intensity = pos / half_period + if white: + fill_color[3] = int(fill_color[3] * intensity) + fill_color[0] = int(fill_color[0] * intensity) + fill_color[1] = int(fill_color[1] * intensity) + fill_color[2] = int(fill_color[2] * intensity) + yield fill_color diff --git a/adafruit_led_animation/rainbow.py b/adafruit_led_animation/rainbow.py new file mode 100644 index 0000000..78500b1 --- /dev/null +++ b/adafruit_led_animation/rainbow.py @@ -0,0 +1,193 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019-2020 Roy Hooper +# Copyright (c) 2020 Kattni Rembor for Adafruit Industries +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_led_animation.rainbow` +================================================================================ + +Rainbow animations for CircuitPython helper library for LED animations. + +* Author(s): Roy Hooper, Kattni Rembor + +Implementation Notes +-------------------- + +**Hardware:** + +* `Adafruit NeoPixels `_ +* `Adafruit DotStars `_ + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://circuitpython.org/downloads + +""" + +from adafruit_led_animation.animation import Animation, Chase, Comet +from adafruit_led_animation.color import BLACK, colorwheel +from . import NANOS_PER_SECOND, monotonic_ns + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_LED_Animation.git" + + +class Rainbow(Animation): + """ + The classic rainbow color wheel. + + :param pixel_object: The initialised LED object. + :param float speed: Animation refresh rate in seconds, e.g. ``0.1``. + :param period: Period to cycle the rainbow over. Default 5. + """ + + # pylint: disable=too-many-arguments + def __init__(self, pixel_object, speed, period=5, name=None): + super().__init__(pixel_object, speed, BLACK, name=name) + self._period = period + self._generator = self._color_wheel_generator() + + cycle_complete_supported = True + + def _color_wheel_generator(self): + period = int(self._period * NANOS_PER_SECOND) + + last_update = monotonic_ns() + cycle_position = 0 + last_pos = 0 + while True: + cycle_completed = False + now = monotonic_ns() + time_since_last_draw = now - last_update + last_update = now + pos = cycle_position = (cycle_position + time_since_last_draw) % period + if pos < last_pos: + cycle_completed = True + last_pos = pos + wheel_index = int((pos / period) * 256) + self.pixel_object[:] = [ + colorwheel((i + wheel_index) % 255) + for i, _ in enumerate(self.pixel_object) + ] + self.show() + if cycle_completed: + self.cycle_complete() + yield + + def draw(self): + next(self._generator) + + def reset(self): + """ + Resets the animation. + """ + self._generator = self._color_wheel_generator() + + +class RainbowChase(Chase): + """ + Chase pixels in one direction, like a theater marquee but with rainbows! + + :param pixel_object: The initialised LED object. + :param float speed: Animation speed rate in seconds, e.g. ``0.1``. + :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. + :param size: Number of pixels to turn on in a row. + :param spacing: Number of pixels to turn off in a row. + :param reverse: Reverse direction of movement. + :param wheel_step: How many colors to skip in `colorwheel` per bar (default 8) + """ + + # pylint: disable=too-many-arguments + def __init__( + self, + pixel_object, + speed, + size=2, + spacing=3, + reverse=False, + name=None, + wheel_step=8, + ): + self._num_colors = 256 // wheel_step + self._colors = [colorwheel(n % 256) for n in range(0, 512, wheel_step)] + self._color_idx = 0 + super().__init__(pixel_object, speed, 0, size, spacing, reverse, name) + + def bar_color(self, n, pixel_no=0): + return self._colors[self._color_idx - n] + + def cycle_complete(self): + self._color_idx = (self._color_idx + self._direction) % len(self._colors) + super().cycle_complete() + + +class RainbowComet(Comet): + """ + A rainbow comet animation. + + :param pixel_object: The initialised LED object. + :param float speed: Animation speed in seconds, e.g. ``0.1``. + :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. + :param int tail_length: The length of the comet. Defaults to 10. Cannot exceed the number of + pixels present in the pixel object, e.g. if the strip is 30 pixels + long, the ``tail_length`` cannot exceed 30 pixels. + :param bool reverse: Animates the comet in the reverse order. Defaults to ``False``. + :param bool bounce: Comet will bounce back and forth. Defaults to ``True``. + :param int colorwheel_offset: Offset from start of colorwheel (0-255). + """ + + # pylint: disable=too-many-arguments + def __init__( + self, + pixel_object, + speed, + tail_length=10, + reverse=False, + bounce=False, + colorwheel_offset=0, + name=None, + ): + self._colorwheel_is_tuple = isinstance(colorwheel(0), tuple) + self._colorwheel_offset = colorwheel_offset + + super().__init__(pixel_object, speed, 0, tail_length, reverse, bounce, name) + + def _calc_brightness(self, n, color): + brightness = (n * self._color_step) + self._color_offset + if not self._colorwheel_is_tuple: + color = (color & 0xFF, ((color & 0xFF00) >> 8), (color >> 16)) + return [int(i * brightness) for i in color] + + def __recompute_color(self, color): + factor = int(256 / self._tail_length) + self._comet_colors = [BLACK] + [ + self._calc_brightness( + n, + colorwheel( + int((n * factor) + self._color_offset + self._colorwheel_offset) + % 256 + ), + ) + for n in range(self._tail_length - 1) + ] + self._reverse_comet_colors = list(reversed(self._comet_colors)) + self._computed_color = color diff --git a/adafruit_led_animation/sequence.py b/adafruit_led_animation/sequence.py new file mode 100644 index 0000000..79e7161 --- /dev/null +++ b/adafruit_led_animation/sequence.py @@ -0,0 +1,272 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019-2020 Roy Hooper +# Copyright (c) 2020 Kattni Rembor for Adafruit Industries +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_led_animation.sequence` +================================================================================ + +Animation sequence helper for CircuitPython helper library for LED animations. + + +* Author(s): Roy Hooper, Kattni Rembor + +Implementation Notes +-------------------- + +**Hardware:** + +* `Adafruit NeoPixels `_ +* `Adafruit DotStars `_ + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://circuitpython.org/downloads + +""" + +import random +from adafruit_led_animation.color import BLACK +from . import NANOS_PER_SECOND, monotonic_ns + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_LED_Animation.git" + + +class AnimationSequence: + """ + A sequence of Animations to run in succession, looping forever. + Advances manually, or at the specified interval. + + :param members: The animation objects or groups. + :param int advance_interval: Time in seconds between animations if cycling + automatically. Defaults to ``None``. + :param bool auto_clear: Clear the pixels between animations. If ``True``, the current animation + will be cleared from the pixels before the next one starts. + Defaults to ``False``. + :param bool random_order: Activate the animations in a random order. Defaults to ``False``. + :param bool auto_reset: Automatically call reset() on animations when changing animations. + :param bool advance_on_cycle_complete: Automatically advance when `cycle_complete` is triggered + on member animations. All Animations must support + cycle_complete to use this. + + .. code-block:: python + + import board + import neopixel + from adafruit_led_animation.animation import AnimationSequence, Blink, Comet, Sparkle + import adafruit_led_animation.color as color + + strip_pixels = neopixel.NeoPixel(board.A1, 30, brightness=1, auto_write=False) + + blink = Blink(strip_pixels, 0.2, color.RED) + comet = Comet(strip_pixels, 0.1, color.BLUE) + sparkle = Sparkle(strip_pixels, 0.05, color.GREEN) + + animations = AnimationSequence(blink, comet, sparkle, advance_interval=1) + + while True: + animations.animate() + """ + + # pylint: disable=too-many-instance-attributes + def __init__( + self, + *members, + advance_interval=None, + auto_clear=False, + random_order=False, + auto_reset=False, + advance_on_cycle_complete=False, + name=None + ): + if advance_interval and advance_on_cycle_complete: + raise ValueError("Cannot use both advance_interval and auto_clear") + self._members = members + self._advance_interval = ( + advance_interval * NANOS_PER_SECOND if advance_interval else None + ) + self._last_advance = monotonic_ns() + self._current = 0 + self.auto_clear = auto_clear + self.auto_reset = auto_reset + self.advance_on_cycle_complete = advance_on_cycle_complete + self.clear_color = BLACK + self._paused = False + self._paused_at = 0 + self._random = random_order + self._also_notify = [] + self.cycle_count = 0 + self.notify_cycles = 1 + self.name = name + if random_order: + self._current = random.randint(0, len(self._members) - 1) + self._color = None + for member in self._members: + member.add_cycle_complete_receiver(self._sequence_complete) + self.cycle_complete_supported = self._members[-1].cycle_complete_supported + + cycle_complete_supported = True + + def __str__(self): + return "<%s: %s>" % (self.__class__.__name__, self.name) + + def cycle_complete(self): + """ + Called by some animations when they complete an animation cycle. + Animations that support cycle complete notifications will have X property set to False. + Override as needed. + """ + self.cycle_count += 1 + if self.cycle_count % self.notify_cycles == 0: + for callback in self._also_notify: + callback(self) + + def _sequence_complete(self, animation): # pylint: disable=unused-argument + self.cycle_complete() + if self.advance_on_cycle_complete: + self._advance() + + def add_cycle_complete_receiver(self, callback): + """ + Adds an additional callback when the cycle completes. + + :param callback: Additional callback to trigger when a cycle completes. The callback + is passed the animation object instance. + """ + self._also_notify.append(callback) + + def _auto_advance(self): + if not self._advance_interval: + return + now = monotonic_ns() + if now - self._last_advance > self._advance_interval: + self._last_advance = now + self._advance() + + def _advance(self): + if self.auto_reset: + self.current_animation.reset() + if self.auto_clear: + self.current_animation.fill(self.clear_color) + self.current_animation.show() + if self._random: + self.random() + else: + self.next() + + def activate(self, index): + """ + Activates a specific animation. + """ + if isinstance(index, str): + self._current = [member.name for member in self._members].index(index) + else: + self._current = index + if self._color: + self.current_animation.color = self._color + + def next(self): + """ + Jump to the next animation. + """ + current = self._current + if current > self._current: + self.cycle_complete() + self.activate((self._current + 1) % len(self._members)) + + def random(self): + """ + Jump to a random animation. + """ + self.activate(random.randint(0, len(self._members) - 1)) + + def animate(self): + """ + Call animate() from your code's main loop. It will draw the current animation + or go to the next animation based on the advance_interval if set. + + :return: True if the animation draw cycle was triggered, otherwise False. + """ + if not self._paused and self._advance_interval: + self._auto_advance() + return self.current_animation.animate() + + @property + def current_animation(self): + """ + Returns the current animation in the sequence. + """ + return self._members[self._current] + + @property + def color(self): + """ + Use this property to change the color of all animations in the sequence. + """ + return self._color + + @color.setter + def color(self, color): + self._color = color + self.current_animation.color = color + + def fill(self, color): + """ + Fills the current animation with a color. + """ + self.current_animation.fill(color) + + def freeze(self): + """ + Freeze the current animation in the sequence. + Also stops auto_advance. + """ + if self._paused: + return + self._paused = True + self._paused_at = monotonic_ns() + self.current_animation.freeze() + + def resume(self): + """ + Resume the current animation in the sequence, and resumes auto advance if enabled. + """ + if not self._paused: + return + self._paused = False + now = monotonic_ns() + self._last_advance += now - self._paused_at + self._paused_at = 0 + self.current_animation.resume() + + def reset(self): + """ + Resets the current animation. + """ + self.current_animation.reset() + + def show(self): + """ + Draws the current animation group members. + """ + self.current_animation.show() diff --git a/adafruit_led_animation/sparkle.py b/adafruit_led_animation/sparkle.py new file mode 100644 index 0000000..8ac5b36 --- /dev/null +++ b/adafruit_led_animation/sparkle.py @@ -0,0 +1,157 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019-2020 Roy Hooper +# Copyright (c) 2020 Kattni Rembor for Adafruit Industries +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_led_animation.sparkle` +================================================================================ + +Sparkle animations for CircuitPython helper library for LED animations. + + +* Author(s): Roy Hooper, Kattni Rembor + +Implementation Notes +-------------------- + +**Hardware:** + +* `Adafruit NeoPixels `_ +* `Adafruit DotStars `_ + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://circuitpython.org/downloads + +""" + +import random +from adafruit_led_animation.animation import Animation +from . import NANOS_PER_SECOND, monotonic_ns + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_LED_Animation.git" + + +class Sparkle(Animation): + """ + Sparkle animation of a single color. + + :param pixel_object: The initialised LED object. + :param float speed: Animation speed in seconds, e.g. ``0.1``. + :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. + :param num_sparkles: Number of sparkles to generate per animation cycle. + """ + + # pylint: disable=too-many-arguments + def __init__(self, pixel_object, speed, color, num_sparkles=1, name=None): + if len(pixel_object) < 2: + raise ValueError("Sparkle needs at least 2 pixels") + self._half_color = None + self._dim_color = None + self._num_sparkles = num_sparkles + super().__init__(pixel_object, speed, color, name=name) + + def _recompute_color(self, color): + half_color = tuple(color[rgb] // 4 for rgb in range(len(color))) + dim_color = tuple(color[rgb] // 10 for rgb in range(len(color))) + for pixel in range(len(self.pixel_object)): + if self.pixel_object[pixel] == self._half_color: + self.pixel_object[pixel] = half_color + elif self.pixel_object[pixel] == self._dim_color: + self.pixel_object[pixel] = dim_color + self._half_color = half_color + self._dim_color = dim_color + + def draw(self): + pixels = [ + random.randint(0, (len(self.pixel_object) - 2)) + for n in range(self._num_sparkles) + ] + for pixel in pixels: + self.pixel_object[pixel] = self._color + self.show() + for pixel in pixels: + self.pixel_object[pixel] = self._half_color + self.pixel_object[pixel + 1] = self._dim_color + self.show() + + +class SparklePulse(Animation): + """ + Combination of the Spark and Pulse animations. + + :param pixel_object: The initialised LED object. + :param int speed: Animation refresh rate in seconds, e.g. ``0.1``. + :param color: Animation color in ``(r, g, b)`` tuple, or ``0x000000`` hex format. + :param period: Period to pulse the LEDs over. Default 5. + :param max_intensity: The maximum intensity to pulse, between 0 and 1.0. Default 1. + :param min_intensity: The minimum intensity to pulse, between 0 and 1.0. Default 0. + """ + + # pylint: disable=too-many-arguments + def __init__( + self, pixel_object, speed, color, period=5, max_intensity=1, min_intensity=0 + ): + if len(pixel_object) < 2: + raise ValueError("Sparkle needs at least 2 pixels") + self.max_intensity = max_intensity + self.min_intensity = min_intensity + self._period = period + self._intensity_delta = max_intensity - min_intensity + self._half_period = period / 2 + self._position_factor = 1 / self._half_period + self._bpp = len(pixel_object[0]) + self._last_update = monotonic_ns() + self._cycle_position = 0 + self._half_color = None + self._dim_color = None + super().__init__(pixel_object, speed, color) + + def _recompute_color(self, color): + half_color = tuple(color[rgb] // 4 for rgb in range(len(color))) + dim_color = tuple(color[rgb] // 10 for rgb in range(len(color))) + for pixel in range(len(self.pixel_object)): + if self.pixel_object[pixel] == self._half_color: + self.pixel_object[pixel] = half_color + elif self.pixel_object[pixel] == self._dim_color: + self.pixel_object[pixel] = dim_color + self._half_color = half_color + self._dim_color = dim_color + + def draw(self): + pixel = random.randint(0, (len(self.pixel_object) - 2)) + + now = monotonic_ns() + time_since_last_draw = (now - self._last_update) / NANOS_PER_SECOND + self._last_update = now + pos = self._cycle_position = ( + self._cycle_position + time_since_last_draw + ) % self._period + if pos > self._half_period: + pos = self._period - pos + intensity = self.min_intensity + ( + pos * self._intensity_delta * self._position_factor + ) + color = [int(self.color[n] * intensity) for n in range(self._bpp)] + self.pixel_object[pixel] = color + self.show() diff --git a/docs/api.rst b/docs/api.rst index 465084a..61f7163 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,10 +1,26 @@ -API Reference -************* .. If you created a package, create one automodule per module in the package. .. If your library file(s) are nested in a directory (e.g. /adafruit_foo/foo.py) .. use this format as the module name: "adafruit_foo.foo" -.. automodule:: led_animation +.. automodule:: adafruit_led_animation.animation + :members: + +.. automodule:: adafruit_led_animation.color + :members: + +.. automodule:: adafruit_led_animation.helper + :members: + +.. automodule:: adafruit_led_animation.group + :members: + +.. automodule:: adafruit_led_animation.sequence + :members: + +.. automodule:: adafruit_led_animation.rainbow + :members: + +.. automodule:: adafruit_led_animation.sparkle :members: diff --git a/docs/index.rst b/docs/index.rst index c7c4b7a..426a2fb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,16 +20,10 @@ Table of Contents api -.. toctree:: - :caption: Tutorials - -.. toctree:: - :caption: Related Products - .. toctree:: :caption: Other Links - Download + Download CircuitPython Reference Documentation CircuitPython Support Forum Discord Chat diff --git a/examples/led_animation_all_animations.py b/examples/led_animation_all_animations.py new file mode 100644 index 0000000..1d9504b --- /dev/null +++ b/examples/led_animation_all_animations.py @@ -0,0 +1,61 @@ +""" +This example repeatedly displays all available animations, at a five second interval. + +For NeoPixel FeatherWing. Update pixel_pin and pixel_num to match your wiring if using +a different form of NeoPixels. + +This example does not work on SAMD21 (M0) boards. +""" +import board +import neopixel + +import adafruit_led_animation.rainbow +import adafruit_led_animation.sparkle +from adafruit_led_animation import animation +from adafruit_led_animation.sequence import AnimationSequence +from adafruit_led_animation.color import PURPLE, WHITE, AMBER, JADE + +# Update to match the pin connected to your NeoPixels +pixel_pin = board.D6 +# Update to match the number of NeoPixels you have connected +pixel_num = 32 + +pixels = neopixel.NeoPixel(pixel_pin, pixel_num, brightness=0.2, auto_write=False) + +blink = animation.Blink(pixels, speed=0.1, color=JADE) +comet = animation.Comet(pixels, speed=0.01, color=PURPLE, tail_length=10, bounce=True) +chase = animation.Chase(pixels, speed=0.1, size=3, spacing=6, color=WHITE) +pulse = animation.Pulse(pixels, speed=0.1, period=3, color=AMBER) +sparkle = adafruit_led_animation.sparkle.Sparkle( + pixels, speed=0.1, color=PURPLE, num_sparkles=10 +) +solid = animation.Solid(pixels, color=JADE) +rainbow = adafruit_led_animation.rainbow.Rainbow(pixels, speed=0.1, period=2) +sparkle_pulse = adafruit_led_animation.sparkle.SparklePulse( + pixels, speed=0.1, period=3, color=JADE +) +rainbow_comet = adafruit_led_animation.rainbow.RainbowComet( + pixels, speed=0.1, tail_length=7, bounce=True +) +rainbow_chase = adafruit_led_animation.rainbow.RainbowChase( + pixels, speed=0.1, size=3, spacing=2, wheel_step=8 +) + + +animations = AnimationSequence( + blink, + comet, + chase, + pulse, + sparkle, + solid, + rainbow, + sparkle_pulse, + rainbow_comet, + rainbow_chase, + advance_interval=5, + auto_clear=True, +) + +while True: + animations.animate() diff --git a/examples/led_animation_gridmap.py b/examples/led_animation_gridmap.py new file mode 100644 index 0000000..2931aab --- /dev/null +++ b/examples/led_animation_gridmap.py @@ -0,0 +1,63 @@ +""" +This example shows usage of the gridmap helper to easily treat a single strip as a horizontal or +vertical grid for animation purposes. + +For NeoPixel FeatherWing. Update pixel_pin and pixel_num to match your wiring if using +a different form of NeoPixels. + +This example does not work on SAMD21 (M0) boards. +""" +import board +import neopixel + +import adafruit_led_animation.rainbow +import adafruit_led_animation.sequence +from adafruit_led_animation import animation +from adafruit_led_animation import helper +from adafruit_led_animation.color import PURPLE, JADE, AMBER + + +pixels = neopixel.NeoPixel(board.D6, 32, brightness=0.2, auto_write=False) + +pixel_wing_vertical = helper.PixelMap.vertical_lines( + pixels, 8, 4, helper.horizontal_strip_gridmap(8, alternating=False) +) +pixel_wing_horizontal = helper.PixelMap.horizontal_lines( + pixels, 8, 4, helper.horizontal_strip_gridmap(8, alternating=False) +) + +comet_h = animation.Comet( + pixel_wing_horizontal, speed=0.1, color=PURPLE, tail_length=3, bounce=True +) +comet_v = animation.Comet( + pixel_wing_vertical, speed=0.1, color=AMBER, tail_length=6, bounce=True +) +chase_h = animation.Chase( + pixel_wing_horizontal, speed=0.1, size=3, spacing=6, color=JADE +) +rainbow_chase_v = adafruit_led_animation.rainbow.RainbowChase( + pixel_wing_vertical, speed=0.1, size=3, spacing=2, wheel_step=8 +) +rainbow_comet_v = adafruit_led_animation.rainbow.RainbowComet( + pixel_wing_vertical, speed=0.1, tail_length=7, bounce=True +) +rainbow_v = adafruit_led_animation.rainbow.Rainbow( + pixel_wing_vertical, speed=0.1, period=2 +) +rainbow_chase_h = adafruit_led_animation.rainbow.RainbowChase( + pixel_wing_horizontal, speed=0.1, size=3, spacing=3 +) + +animations = adafruit_led_animation.sequence.AnimationSequence( + rainbow_v, + comet_h, + rainbow_comet_v, + chase_h, + rainbow_chase_v, + comet_v, + rainbow_chase_h, + advance_interval=5, +) + +while True: + animations.animate() diff --git a/examples/led_animation_simpletest.py b/examples/led_animation_simpletest.py index f8b3b2e..bb53d95 100644 --- a/examples/led_animation_simpletest.py +++ b/examples/led_animation_simpletest.py @@ -1,17 +1,29 @@ -"""Blink LED animation.""" +""" +This simpletest example repeatedly displays two animations, Comet and Chase, at a five second +interval. + +For NeoPixel FeatherWing. Update pixel_pin and pixel_num to match your wiring if using +a different form of NeoPixels. + +This example does not work on SAMD21 (M0) boards. +""" import board import neopixel -from adafruit_led_animation.animation import Blink -import adafruit_led_animation.color as color +from adafruit_led_animation.animation import Comet, Chase +from adafruit_led_animation.sequence import AnimationSequence +from adafruit_led_animation.color import PURPLE, WHITE + +# Update to match the pin connected to your NeoPixels +pixel_pin = board.D6 +# Update to match the number of NeoPixels you have connected +pixel_num = 32 + +pixels = neopixel.NeoPixel(pixel_pin, pixel_num, brightness=0.2, auto_write=False) -# Works on Circuit Playground Express and Bluefruit. -# For other boards, change board.NEOPIXEL to match the pin to which the NeoPixels are attached. -pixel_pin = board.NEOPIXEL -# Change to match the number of pixels you have attached to your board. -num_pixels = 10 +comet = Comet(pixels, speed=0.01, color=PURPLE, tail_length=10, bounce=True) +chase = Chase(pixels, speed=0.1, size=3, spacing=6, color=WHITE) -pixels = neopixel.NeoPixel(pixel_pin, num_pixels) -blink = Blink(pixels, 0.5, color.PURPLE) +animations = AnimationSequence(comet, chase, advance_interval=5) while True: - blink.animate() + animations.animate()