cm1.py
  1  # SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """
  6  Base class for Little Connection Machine projects. Allows drawing on a
  7  single PIL image spanning eight IS31fl3731 Charlieplex matrices and handles
  8  double-buffered updates. Matrices are arranged four across, two down, "the
  9  tall way" (9 pixels across, 16 down) with I2C pins at the bottom.
 10  
 11  IS31fl3731 can be jumpered for one of four addresses. Because this uses
 12  eight, two groups are split across a pair of "soft" I2C buses by adding this
 13  to /boot/config.txt:
 14  dtoverlay=i2c-gpio,bus=2,i2c_gpio_scl=17,i2c_gpio_sda=27,i2c_gpio_delay_us=1
 15  dtoverlay=i2c-gpio,bus=3,i2c_gpio_scl=23,i2c_gpio_sda=24,i2c_gpio_delay_us=1
 16  And run:
 17  pip3 install adafruit-extended-bus adafruit-circuitpython-is31fl3731
 18  The extra buses will be /dev/i2c-2 and /dev/i2c-3. These are not as fast as
 19  the "true" I2C bus, but are adequate for this application.
 20  """
 21  
 22  import argparse
 23  import signal
 24  import sys
 25  from PIL import Image
 26  from PIL import ImageDraw
 27  from adafruit_extended_bus import ExtendedI2C as I2C
 28  from adafruit_is31fl3731.matrix import Matrix as Display
 29  
 30  DEFAULT_BRIGHTNESS = 40
 31  
 32  
 33  class CM1:
 34      """A base class for Little Connection Machine projects, handling common
 35      functionality like LED matrix init, updates and signal handler."""
 36  
 37      # pylint: disable=unused-argument
 38      def __init__(self, *args, **kwargs):
 39          self.brightness = DEFAULT_BRIGHTNESS
 40  
 41          parser = argparse.ArgumentParser()
 42          parser.add_argument(
 43              "-b",
 44              action="store",
 45              help="Brightness, 0-255. Default: %d" % DEFAULT_BRIGHTNESS,
 46              default=42,
 47              type=int,
 48          )
 49          args = parser.parse_args()
 50          if args.b:
 51              self.brightness = min(max(args.b, 0), 255)
 52  
 53          i2c = [
 54              I2C(2),  # Extended bus on 17, 27 (clock, data)
 55              I2C(3),  # Extended bus on 23, 24 (clock, data)
 56          ]
 57          self.display = [
 58              Display(i2c[0], address=0x74, frames=(0, 1)),  # Upper row
 59              Display(i2c[0], address=0x75, frames=(0, 1)),
 60              Display(i2c[0], address=0x76, frames=(0, 1)),
 61              Display(i2c[0], address=0x77, frames=(0, 1)),
 62              Display(i2c[1], address=0x74, frames=(0, 1)),  # Lower row
 63              Display(i2c[1], address=0x75, frames=(0, 1)),
 64              Display(i2c[1], address=0x76, frames=(0, 1)),
 65              Display(i2c[1], address=0x77, frames=(0, 1)),
 66          ]
 67          self.image = Image.new("L", (9 * 4, 16 * 2))
 68          self.draw = ImageDraw.Draw(self.image)
 69          self.frame_index = 0  # Front/back buffer index
 70          signal.signal(signal.SIGTERM, self.signal_handler)  # Kill signal
 71  
 72      def run(self):
 73          """Placeholder. Override this in subclass."""
 74  
 75      # pylint: disable=unused-argument
 76      def signal_handler(self, signum, frame):
 77          """Signal handler. Clears all matrices and exits."""
 78          self.clear()
 79          self.redraw()
 80          sys.exit(0)
 81  
 82      def clear(self):
 83          """Clears PIL image. Does not invoke refresh(), just clears."""
 84          self.draw.rectangle([0, 0, self.image.size[0], self.image.size[1]], fill=0)
 85  
 86      def redraw(self):
 87          """Update matrices with PIL image contents, swap buffers."""
 88          # First pass crops out sections over the overall image, rotates
 89          # them to matrix space, and writes this data to each matrix.
 90          for num, display in enumerate(self.display):
 91              col = (num % 4) * 9
 92              row = (num // 4) * 16
 93              cropped = self.image.crop((col, row, col + 9, row + 16))
 94              cropped = cropped.rotate(angle=-90, expand=1)
 95              display.image(cropped, frame=self.frame_index)
 96          # Swapping frames is done in a separate pass so they all occur
 97          # close together, no conspicuous per-matrix refresh.
 98          for display in self.display:
 99              display.frame(self.frame_index, show=True)  # True = show frame
100          self.frame_index ^= 1  # Swap frame index
101  
102      def process(self):
103          """Call CM1 subclass run() function, with keyboard interrupt trapping
104          centralized here so it doesn't need to be implemented everywhere."""
105          try:
106              self.run()  # In subclass
107          except KeyboardInterrupt:
108              self.signal_handler(0, 0)  # clear/redraw/exit