helper.py
  1  # SPDX-FileCopyrightText: 2019 Kattni Rembor for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """
  6  `adafruit_led_animation.helper`
  7  ================================================================================
  8  
  9  Helper classes for making complex animations using CircuitPython LED animations library.
 10  
 11  * Author(s): Kattni Rembor
 12  
 13  Implementation Notes
 14  --------------------
 15  
 16  **Hardware:**
 17  
 18  * `Adafruit NeoPixels <https://www.adafruit.com/category/168>`_
 19  * `Adafruit DotStars <https://www.adafruit.com/category/885>`_
 20  
 21  **Software and Dependencies:**
 22  
 23  * Adafruit CircuitPython firmware for the supported boards:
 24    https://circuitpython.org/downloads
 25  
 26  """
 27  
 28  import math
 29  
 30  from . import MS_PER_SECOND, monotonic_ms
 31  from .color import calculate_intensity
 32  
 33  
 34  class PixelMap:
 35      """
 36      PixelMap lets you treat ranges of pixels as single pixels for animation purposes.
 37  
 38      :param strip: An object that implements the Neopixel or Dotstar protocol.
 39      :param iterable pixel_ranges: Pixel ranges (or individual pixels).
 40      :param bool individual_pixels: Whether pixel_ranges are individual pixels.
 41  
 42      To use with ranges of pixels:
 43  
 44      .. code-block:: python
 45  
 46          import board
 47          import neopixel
 48          from adafruit_led_animation.helper import PixelMap
 49          pixels = neopixel.NeoPixel(board.D6, 32, auto_write=False)
 50  
 51          pixel_wing_horizontal = PixelMap(pixels, [(0, 8), (8, 16), (16, 24), (24, 32)])
 52  
 53          pixel_wing_horizontal[0] = (255, 255, 0)
 54          pixel_wing_horizontal.show()
 55  
 56      To use with groups of individual pixels:
 57  
 58      .. code-block:: python
 59  
 60          import board
 61          import neopixel
 62          from adafruit_led_animation.helper import PixelMap
 63          pixels = neopixel.NeoPixel(board.D6, 32, auto_write=False)
 64  
 65          pixel_wing_vertical = PixelMap(pixels, [
 66              (0, 8, 16, 24),
 67              (1, 9, 17, 25),
 68              (2, 10, 18, 26),
 69              (3, 11, 19, 27),
 70              (4, 12, 20, 28),
 71              (5, 13, 21, 29),
 72              (6, 14, 22, 30),
 73              (7, 15, 23, 31),
 74          ], individual_pixels=True)
 75  
 76          pixel_wing_vertical[0] = (255, 255, 0)
 77          pixel_wing_vertical.show()
 78  
 79      To use with individual pixels:
 80  
 81      .. code-block:: python
 82  
 83          import board
 84          import neopixel
 85          import time
 86          from adafruit_led_animation.helper import PixelMap
 87  
 88          pixels = neopixel.NeoPixel(board.D6, 8, auto_write=False)
 89  
 90          pixel_map = PixelMap(pixels, [
 91              0, 7, 1, 6, 2, 5, 3, 4
 92          ], individual_pixels=True)
 93  
 94          n = 0
 95          while True:
 96              pixel_map[n] = AMBER
 97              pixel_map.show()
 98              n = n + 1
 99              if n > 7:
100                  n = 0
101                  pixel_map.fill(0)
102              time.sleep(0.25)
103  
104  
105      """
106  
107      def __init__(self, strip, pixel_ranges, individual_pixels=False):
108          self._pixels = strip
109          self._ranges = pixel_ranges
110  
111          self.n = len(self._ranges)
112          if self.n == 0:
113              raise ValueError("A PixelMap must have at least one pixel defined")
114          self._individual_pixels = individual_pixels
115          self._expand_ranges()
116  
117      def _expand_ranges(self):
118          if not self._individual_pixels:
119              self._ranges = [list(range(start, end)) for start, end in self._ranges]
120              return
121          if isinstance(self._ranges[0], int):
122              self._ranges = [[n] for n in self._ranges]
123  
124      def __repr__(self):
125          return "[" + ", ".join([str(self[x]) for x in range(self.n)]) + "]"
126  
127      def _set_pixels(self, index, val):
128          for pixel in self._ranges[index]:
129              self._pixels[pixel] = val
130  
131      def __setitem__(self, index, val):
132          if isinstance(index, slice):
133              start, stop, step = index.indices(len(self._ranges))
134              length = stop - start
135              if step != 0:
136                  length = math.ceil(length / step)
137              if len(val) != length:
138                  raise ValueError("Slice and input sequence size do not match.")
139              for val_i, in_i in enumerate(range(start, stop, step)):
140                  self._set_pixels(in_i, val[val_i])
141          else:
142              self._set_pixels(index, val)
143  
144          if self._pixels.auto_write:
145              self.show()
146  
147      def __getitem__(self, index):
148          if isinstance(index, slice):
149              out = []
150              for in_i in range(*index.indices(len(self._ranges))):
151                  out.append(self._pixels[self._ranges[in_i][0]])
152              return out
153          if index < 0:
154              index += len(self)
155          if index >= self.n or index < 0:
156              raise IndexError
157          return self._pixels[self._ranges[index][0]]
158  
159      def __len__(self):
160          return len(self._ranges)
161  
162      @property
163      def brightness(self):
164          """
165          brightness from the underlying strip.
166          """
167          return self._pixels.brightness
168  
169      @brightness.setter
170      def brightness(self, brightness):
171          # pylint: disable=attribute-defined-outside-init
172          self._pixels.brightness = min(max(brightness, 0.0), 1.0)
173  
174      def fill(self, color):
175          """
176          Fill the used pixel ranges with color.
177  
178          :param color: Color to fill all pixels referenced by this PixelMap definition with.
179          """
180          for pixels in self._ranges:
181              for pixel in pixels:
182                  self._pixels[pixel] = color
183  
184      def show(self):
185          """
186          Shows the pixels on the underlying strip.
187          """
188          self._pixels.show()
189  
190      @property
191      def auto_write(self):
192          """
193          auto_write from the underlying strip.
194          """
195          return self._pixels.auto_write
196  
197      @auto_write.setter
198      def auto_write(self, value):
199          self._pixels.auto_write = value
200  
201      @classmethod
202      def vertical_lines(cls, pixel_object, width, height, gridmap):
203          """
204          Generate a PixelMap of horizontal lines on a strip arranged in a grid.
205  
206          :param pixel_object: pixel object
207          :param width: width of grid
208          :param height: height of grid
209          :param gridmap: a function to map x and y coordinates to the grid
210                             see vertical_strip_gridmap and horizontal_strip_gridmap
211          :return: PixelMap
212  
213          Example: Vertical lines on a 32x8 grid with the pixel rows oriented vertically,
214                   alternating direction every row.
215  
216          .. code-block:: python
217  
218              PixelMap.vertical_lines(pixels, 32, 8, vertical_strip_gridmap(8))
219  
220          """
221          if len(pixel_object) < width * height:
222              raise ValueError("number of pixels is less than width x height")
223          mapping = []
224          for x in range(width):
225              mapping.append([gridmap(x, y) for y in range(height)])
226          return cls(pixel_object, mapping, individual_pixels=True)
227  
228      @classmethod
229      def horizontal_lines(cls, pixel_object, width, height, gridmap):
230          """
231          Generate a PixelMap of horizontal lines on a strip arranged in a grid.
232  
233          :param pixel_object: pixel object
234          :param width: width of grid
235          :param height: height of grid
236          :param gridmap: a function to map x and y coordinates to the grid
237                             see vertical_strip_gridmap and horizontal_strip_gridmap
238          :return: PixelMap
239  
240          Example: Horizontal lines on a 16x16 grid with the pixel rows oriented vertically,
241                   alternating direction every row.
242  
243          .. code-block:: python
244  
245              PixelMap.horizontal_lines(pixels, 16, 16, vertical_strip_gridmap(16))
246          """
247          if len(pixel_object) < width * height:
248              raise ValueError("number of pixels is less than width x height")
249          mapping = []
250          for y in range(height):
251              mapping.append([gridmap(x, y) for x in range(width)])
252          return cls(pixel_object, mapping, individual_pixels=True)
253  
254  
255  def vertical_strip_gridmap(height, alternating=True):
256      """
257      Returns a function that determines the pixel number for a grid with strips arranged vertically.
258  
259      :param height: grid height in pixels
260      :param alternating: Whether or not the lines in the grid run alternate directions in a zigzag
261      :return: mapper(x, y)
262      """
263  
264      def mapper(x, y):
265          if alternating and x % 2:
266              return x * height + (height - 1 - y)
267          return x * height + y
268  
269      return mapper
270  
271  
272  def horizontal_strip_gridmap(width, alternating=True):
273      """
274      Determines the pixel number for a grid with strips arranged horizontally.
275  
276      :param width: grid width in pixels
277      :param alternating: Whether or not the lines in the grid run alternate directions in a zigzag
278      :return: mapper(x, y)
279      """
280  
281      def mapper(x, y):
282          if alternating and y % 2:
283              return y * width + (width - 1 - x)
284          return y * width + x
285  
286      return mapper
287  
288  
289  class PixelSubset(PixelMap):
290      """
291      PixelSubset lets you work with a subset of a pixel object.
292  
293      :param pixel_object: An object that implements the Neopixel or Dotstar protocol.
294      :param int start: Starting pixel number.
295      :param int end: Ending pixel number.
296  
297      .. code-block:: python
298  
299          import board
300          import neopixel
301          from adafruit_led_animation.helper import PixelSubset
302          pixels = neopixel.NeoPixel(board.D12, 307, auto_write=False)
303  
304          star_start = 260
305          star_arm = PixelSubset(pixels, star_start + 7, star_start + 15)
306          star_arm.fill((255, 0, 255))
307          pixels.show()
308      """
309  
310      def __init__(self, pixel_object, start, end):
311          super().__init__(
312              pixel_object,
313              pixel_ranges=[[n] for n in range(start, end)],
314              individual_pixels=True,
315          )
316  
317  
318  def pulse_generator(period: float, animation_object, dotstar_pwm=False):
319      """
320      Generates a sequence of colors for a pulse, based on the time period specified.
321      :param period: Pulse duration in seconds.
322      :param animation_object: An animation object to interact with.
323      :param dotstar_pwm: Whether to use the dostar per pixel PWM value for brightness control.
324      """
325      period = int(period * MS_PER_SECOND)
326      half_period = period // 2
327  
328      last_update = monotonic_ms()
329      cycle_position = 0
330      last_pos = 0
331      while True:
332          now = monotonic_ms()
333          time_since_last_draw = now - last_update
334          last_update = now
335          pos = cycle_position = (cycle_position + time_since_last_draw) % period
336          if pos < last_pos:
337              animation_object.cycle_complete = True
338          last_pos = pos
339          if pos > half_period:
340              pos = period - pos
341          intensity = pos / half_period
342          if dotstar_pwm:
343              fill_color = (
344                  animation_object.color[0],
345                  animation_object.color[1],
346                  animation_object.color[2],
347                  intensity,
348              )
349              yield fill_color
350              continue
351          yield calculate_intensity(animation_object.color, intensity)