Add LittleConnectionMachine project (Pi + CircuitPython)
This commit is contained in:
parent
94495f5af7
commit
c2f469b3ef
5 changed files with 539 additions and 0 deletions
103
LittleConnectionMachine/CircuitPython/code.py
Executable file
103
LittleConnectionMachine/CircuitPython/code.py
Executable file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
CircuitPython random blinkenlights for Little Connection Machine. For
|
||||||
|
Raspberry Pi Pico RP2040, but could be adapted to other CircuitPython-
|
||||||
|
capable boards with two or more I2C buses. Requires adafruit_bus_device
|
||||||
|
and adafruit_is31fl3731 libraries.
|
||||||
|
|
||||||
|
This code plays dirty pool to get fast matrix updates and is NOT good code
|
||||||
|
to learn from, and might fail to work with future versions of the IS31FL3731
|
||||||
|
library. But doing things The Polite Way wasn't fast enough. Explained as
|
||||||
|
we go...
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=import-error
|
||||||
|
import random
|
||||||
|
import board
|
||||||
|
import busio
|
||||||
|
from adafruit_is31fl3731.matrix import Matrix as Display
|
||||||
|
|
||||||
|
BRIGHTNESS = 40 # CONFIGURABLE: LED brightness, 0 (off) to 255 (max)
|
||||||
|
PERCENT = 33 # CONFIGURABLE: amount of 'on' LEDs, 0 (none) to 100 (all)
|
||||||
|
|
||||||
|
# This code was originally written for the Raspberry Pi Pico, but should be
|
||||||
|
# portable to any CircuitPython-capable board WITH TWO OR MORE I2C BUSES.
|
||||||
|
# IS31FL3731 can have one of four addresses, so to run eight of them we
|
||||||
|
# need *two* I2C buses, and not all boards can provide that. Here's where
|
||||||
|
# you'd define the pin numbers for a board...
|
||||||
|
I2C1_SDA = board.GP4 # First I2C bus
|
||||||
|
I2C1_SCL = board.GP5
|
||||||
|
I2C2_SDA = board.GP26 # Second I2C bus
|
||||||
|
I2C2_SCL = board.GP27
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
class FakePILImage:
|
||||||
|
"""Minimal class meant to simulate a small subset of a Python PIL image,
|
||||||
|
so we can pass it to the IS31FL3731 image() function later. THIS IS THE
|
||||||
|
DIRTY POOL PART OF THE CODE, because CircuitPython doesn't have PIL,
|
||||||
|
it's too much to handle. That image() function is normally meant for
|
||||||
|
robust "desktop" Python, using the Blinka package...but it's still
|
||||||
|
present (but normally goes unused) in CircuitPython. Having worked with
|
||||||
|
that library source, I know exactly what object members its looking for,
|
||||||
|
and can fake a minimal set here...BUT THIS MAY BREAK IF THE LIBRARY OR
|
||||||
|
PIL CHANGES!"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.mode = "L" # Grayscale mode in PIL
|
||||||
|
self.size = (16, 9) # 16x9 pixels
|
||||||
|
self.pixels = bytearray(16 * 9) # Pixel buffer
|
||||||
|
|
||||||
|
def tobytes(self):
|
||||||
|
"""IS31 lib requests image pixels this way, more dirty pool."""
|
||||||
|
return self.pixels
|
||||||
|
|
||||||
|
|
||||||
|
# Okay, back to business...
|
||||||
|
# Instantiate the two I2C buses. 400 KHz bus speed is recommended.
|
||||||
|
# Default 100 KHz is a bit slow, and 1 MHz has occasional glitches.
|
||||||
|
I2C = [
|
||||||
|
busio.I2C(I2C1_SCL, I2C1_SDA, frequency=400000),
|
||||||
|
busio.I2C(I2C2_SCL, I2C2_SDA, frequency=400000),
|
||||||
|
]
|
||||||
|
# Four matrices on each bus, for a total of eight...
|
||||||
|
DISPLAY = [
|
||||||
|
Display(I2C[0], address=0x74, frames=(0, 1)), # Upper row
|
||||||
|
Display(I2C[0], address=0x75, frames=(0, 1)),
|
||||||
|
Display(I2C[0], address=0x76, frames=(0, 1)),
|
||||||
|
Display(I2C[0], address=0x77, frames=(0, 1)),
|
||||||
|
Display(I2C[1], address=0x74, frames=(0, 1)), # Lower row
|
||||||
|
Display(I2C[1], address=0x75, frames=(0, 1)),
|
||||||
|
Display(I2C[1], address=0x76, frames=(0, 1)),
|
||||||
|
Display(I2C[1], address=0x77, frames=(0, 1)),
|
||||||
|
]
|
||||||
|
|
||||||
|
IMAGE = FakePILImage() # Instantiate fake PIL image object
|
||||||
|
FRAME_INDEX = 0 # Double-buffering frame index
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Draw to each display's "back" frame buffer
|
||||||
|
for disp in DISPLAY:
|
||||||
|
for pixel in range(0, 16 * 9): # Randomize each pixel
|
||||||
|
IMAGE.pixels[pixel] = BRIGHTNESS if random.randint(1, 100) <= PERCENT else 0
|
||||||
|
# Here's the function that we're NOT supposed to call in
|
||||||
|
# CircuitPython, but is still present. This writes the pixel
|
||||||
|
# data to the display's back buffer. Pass along our "fake" PIL
|
||||||
|
# image and it accepts it.
|
||||||
|
disp.image(IMAGE, frame=FRAME_INDEX)
|
||||||
|
|
||||||
|
# Then quickly flip all matrix display buffers to FRAME_INDEX
|
||||||
|
for disp in DISPLAY:
|
||||||
|
disp.frame(FRAME_INDEX, show=True)
|
||||||
|
FRAME_INDEX ^= 1 # Swap buffers
|
||||||
|
|
||||||
|
|
||||||
|
# This is actually the LESS annoying way to get fast updates. Other involved
|
||||||
|
# writing IS31 registers directly and accessing intended-as-private methods
|
||||||
|
# in the IS31 lib. That's a really bad look. It's pretty simple here because
|
||||||
|
# this code is just drawing random dots. Producing a spatially-coherent
|
||||||
|
# image would take a lot more work, because matrices are rotated, etc.
|
||||||
|
# The PIL+Blinka code for Raspberry Pi easily handles such things, so
|
||||||
|
# consider working with that if you need anything more sophisticated.
|
||||||
212
LittleConnectionMachine/RaspberryPi/audio.py
Normal file
212
LittleConnectionMachine/RaspberryPi/audio.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
Audio spectrum display for Little Connection Machine. This is designed to be
|
||||||
|
fun to look at, not a Serious Audio Tool(tm). Requires USB microphone & ALSA
|
||||||
|
config. Prerequisite libraries include PyAudio and NumPy:
|
||||||
|
sudo apt-get install libatlas-base-dev libportaudio2
|
||||||
|
pip3 install numpy pyaudio
|
||||||
|
See the following for ALSA config (use Stretch directions):
|
||||||
|
learn.adafruit.com/usb-audio-cards-with-a-raspberry-pi/updating-alsa-config
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
import numpy as np
|
||||||
|
import pyaudio
|
||||||
|
from cm1 import CM1
|
||||||
|
|
||||||
|
# FFT configurables. These numbers are 'hard,' actual figures:
|
||||||
|
RATE = 11025 # For audio vis, don't want or need high sample rate!
|
||||||
|
FFT_SIZE = 128 # Audio samples to read per frame (for FFT input)
|
||||||
|
ROWS = 32 # FFT output filtered down to this many 'buckets'
|
||||||
|
# Then things start getting subjective. For example, the lower and upper
|
||||||
|
# ends of the FFT output don't make a good contribution to the resulting
|
||||||
|
# graph...either too noisy, or out of musical range. Clip a range between
|
||||||
|
# between 0 and FFT_SIZE-1. These aren't hard science, they were determined
|
||||||
|
# by playing various music and seeing what looked good:
|
||||||
|
LEAST = 1 # Lowest bin of FFT output to use
|
||||||
|
MOST = 111 # Highest bin of FFT output to use
|
||||||
|
# And moreso. Normally, FFT results are linearly spaced by frequency,
|
||||||
|
# and with music this results in a crowded low end and sparse high end.
|
||||||
|
# The visualizer reformats this logarithmically so octaves are linearly
|
||||||
|
# spaced...the low end is expanded, upper end compressed. But just picking
|
||||||
|
# individial FFT bins will cause visual dropouts. Instead, a number of
|
||||||
|
# inputs are merged into each output, and because of the logarithmic scale,
|
||||||
|
# that number needs to be focused near the low end and spread out among
|
||||||
|
# many samples toward the top. Again, not scientific, these were derived
|
||||||
|
# empirically by throwing music at it and adjusting:
|
||||||
|
FIRST_WIDTH = 2 # Width of sampling curve at low end
|
||||||
|
LAST_WIDTH = 40 # Width of sampling curve at high end
|
||||||
|
# Except for ROWS above, none of this is involved in the actual rendering
|
||||||
|
# of the graph, just how the data is massaged. If modifying this for your
|
||||||
|
# own FFT-based visualizer, you could keep this around and just change the
|
||||||
|
# drawing parts of the main loop.
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSpectrum(CM1):
|
||||||
|
"""Audio spectrum display for Little Connection Machine."""
|
||||||
|
|
||||||
|
# pylint: disable=too-many-locals
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs) # CM1 base initialization
|
||||||
|
|
||||||
|
# Access USB mic via PyAudio
|
||||||
|
audio = pyaudio.PyAudio()
|
||||||
|
self.stream = audio.open(
|
||||||
|
format=pyaudio.paInt16, # 16-bit int
|
||||||
|
channels=1, # Mono
|
||||||
|
rate=RATE,
|
||||||
|
input=True,
|
||||||
|
output=False,
|
||||||
|
frames_per_buffer=FFT_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Precompute a few items for the math to follow
|
||||||
|
first_center_log = math.log2(LEAST + 0.5)
|
||||||
|
center_log_spread = math.log2(MOST + 0.5) - first_center_log
|
||||||
|
width_low_log = math.log2(FIRST_WIDTH)
|
||||||
|
width_log_spread = math.log2(LAST_WIDTH) - width_low_log
|
||||||
|
|
||||||
|
# As mentioned earlier, each row of the graph is filtered down from
|
||||||
|
# multiple FFT elements. These lists are involved in that filtering,
|
||||||
|
# each has one item per row of output:
|
||||||
|
self.low_bin = [] # First FFT bin that contributes to row
|
||||||
|
self.bin_weight = [] # List of subsequent FFT element weightings
|
||||||
|
self.bin_sum = [] # Precomputed sum of bin_weight for row
|
||||||
|
self.noise = [] # Subtracted from FFT output (see note later)
|
||||||
|
|
||||||
|
for row in range(ROWS): # For each row...
|
||||||
|
# Calc center & spread of cubic curve for bin weighting
|
||||||
|
center_log = first_center_log + center_log_spread * row / (ROWS - 1)
|
||||||
|
center_linear = 2**center_log
|
||||||
|
width_log = width_low_log + width_log_spread * row / (ROWS - 1)
|
||||||
|
width_linear = 2**width_log
|
||||||
|
half_width = width_linear * 0.5
|
||||||
|
lower = center_linear - half_width
|
||||||
|
upper = center_linear + half_width
|
||||||
|
low_bin = int(lower) # First FFT element to use
|
||||||
|
hi_bin = min(FFT_SIZE - 1, int(upper)) # Last "
|
||||||
|
weights = [] # FFT weights for row
|
||||||
|
for bin_num in range(low_bin, hi_bin + 1):
|
||||||
|
bin_center = bin_num + 0.5
|
||||||
|
dist = abs(bin_center - center_linear) / half_width
|
||||||
|
if dist < 1.0: # Filter out a math stragglers at either end
|
||||||
|
# Bin weights have a cubic falloff curve within range:
|
||||||
|
dist = 1.0 - dist # Invert dist so 1.0 is at center
|
||||||
|
weight = ((3.0 - (dist * 2.0)) * dist) * dist
|
||||||
|
weights.append(weight)
|
||||||
|
self.bin_weight.append(weights) # Save list of weights for row
|
||||||
|
self.bin_sum.append(sum(weights)) # And sum of weights
|
||||||
|
self.low_bin.append(low_bin) # And first FFT bin index
|
||||||
|
# FFT output always has a little "sparkle" due to ambient hum.
|
||||||
|
# Subtracting a bit helps. Noise varies per element, more at low
|
||||||
|
# end...this table is just a non-scientific fudge factor...
|
||||||
|
self.noise.append(int(2.4 ** (4 - 4 * row / ROWS)))
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Main loop for audio visualizer."""
|
||||||
|
|
||||||
|
# Some tables associated with each row of the display. These are
|
||||||
|
# visualizer specific, not part of the FFT processing, so they're
|
||||||
|
# here instead of part of the class above.
|
||||||
|
width = [0 for _ in range(ROWS)] # Current row width
|
||||||
|
peak = [0 for _ in range(ROWS)] # Recent row peak
|
||||||
|
dropv = [0.0 for _ in range(ROWS)] # Current peak falling speed
|
||||||
|
autolevel = [32.0 for _ in range(ROWS)] # Per-row auto adjust
|
||||||
|
|
||||||
|
start_time = time.monotonic()
|
||||||
|
frames = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
|
||||||
|
# Read bytes from PyAudio stream, convert to int16, process
|
||||||
|
# via NumPy's FFT function...
|
||||||
|
data_8 = self.stream.read(FFT_SIZE * 2, exception_on_overflow=False)
|
||||||
|
data_16 = np.frombuffer(data_8, np.int16)
|
||||||
|
fft_out = np.fft.fft(data_16, norm="ortho")
|
||||||
|
# fft_out will have FFT_SIZE * 2 elements, mirrored at center
|
||||||
|
|
||||||
|
# Get spectrum of first half. Instead of square root for
|
||||||
|
# magnitude, use something between square and cube root.
|
||||||
|
# No scientific reason, just looked good.
|
||||||
|
spec_y = [
|
||||||
|
(c.real * c.real + c.imag * c.imag) ** 0.4 for c in fft_out[0:FFT_SIZE]
|
||||||
|
]
|
||||||
|
|
||||||
|
self.clear() # Clear canvas before drawing
|
||||||
|
for row in range(ROWS): # Low to high freq...
|
||||||
|
# Weigh & sum up all the FFT outputs affecting this row
|
||||||
|
total = 0
|
||||||
|
for idx, weight in enumerate(self.bin_weight[row]):
|
||||||
|
total += (spec_y[self.low_bin[row] + idx]) * weight
|
||||||
|
total /= self.bin_sum[row]
|
||||||
|
|
||||||
|
# Auto-leveling is intended to make each column 'pop'.
|
||||||
|
# When a particular column isn't getting a lot of input
|
||||||
|
# from the FFT, gradually boost that column's sensitivity.
|
||||||
|
if total > autolevel[row]: # New level is louder
|
||||||
|
# Make autolevel rise quickly if column total exceeds it
|
||||||
|
autolevel[row] = autolevel[row] * 0.25 + total * 0.75
|
||||||
|
else: # New level is softer
|
||||||
|
# And fall slowly otherwise
|
||||||
|
autolevel[row] = autolevel[row] * 0.98 + total * 0.02
|
||||||
|
# Autolevel limit keeps things from getting TOO boosty.
|
||||||
|
# Trial and error, no science to this number.
|
||||||
|
autolevel[row] = max(autolevel[row], 20)
|
||||||
|
|
||||||
|
# Apply autoleveling to weighted input.
|
||||||
|
# This is the prelim. row width before further filtering...
|
||||||
|
total *= 18 / autolevel[row] # 18 is 1/2 display width
|
||||||
|
|
||||||
|
# ...then filter the column width computed above
|
||||||
|
if total > width[row]:
|
||||||
|
# If it's greater than this column's current width,
|
||||||
|
# move column's width quickly in that direction
|
||||||
|
width[row] = width[row] * 0.3 + total * 0.7
|
||||||
|
else:
|
||||||
|
# If less, move slowly down
|
||||||
|
width[row] = width[row] * 0.5 + total * 0.5
|
||||||
|
|
||||||
|
# Compute "peak dots," which sort of show the recent
|
||||||
|
# peak level for each column (mostly just neat to watch).
|
||||||
|
if width[row] > peak[row]:
|
||||||
|
# If column exceeds old peak, move peak immediately,
|
||||||
|
# give it a slight upward boost.
|
||||||
|
dropv[row] = (peak[row] - width[row]) * 0.07
|
||||||
|
peak[row] = min(width[row], 18)
|
||||||
|
else:
|
||||||
|
# Otherwise, peak gradually accelerates down
|
||||||
|
dropv[row] += 0.2
|
||||||
|
peak[row] -= dropv[row]
|
||||||
|
|
||||||
|
# Draw bar for this row. It's done as a gradient,
|
||||||
|
# bright toward center, dim toward edge.
|
||||||
|
iwidth = int(width[row] + 0.5) # Integer width
|
||||||
|
drow = ROWS - 1 - row # Display row, reverse of freq row
|
||||||
|
if iwidth > 0:
|
||||||
|
iwidth = min(iwidth, 18) # Clip to 18 pixels
|
||||||
|
scale = self.brightness * iwidth / 18 # Center brightness
|
||||||
|
for col in range(iwidth):
|
||||||
|
level = int(scale * ((1.0 - col / iwidth) ** 2.6))
|
||||||
|
self.draw.point([17 - col, drow], fill=level)
|
||||||
|
self.draw.point([18 + col, drow], fill=level)
|
||||||
|
|
||||||
|
# Draw peak dot
|
||||||
|
if peak[row] > 0:
|
||||||
|
col = int(peak[row] + 0.5)
|
||||||
|
self.draw.point([17 - col, drow], fill=self.brightness)
|
||||||
|
self.draw.point([18 + col, drow], fill=self.brightness)
|
||||||
|
|
||||||
|
# Update matrices and show est. frames/second
|
||||||
|
self.redraw()
|
||||||
|
frames += 1
|
||||||
|
elapsed = time.monotonic() - start_time
|
||||||
|
print(frames / elapsed)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
MY_APP = AudioSpectrum() # Instantiate class, calls __init__() above
|
||||||
|
MY_APP.process()
|
||||||
70
LittleConnectionMachine/RaspberryPi/chaser.py
Normal file
70
LittleConnectionMachine/RaspberryPi/chaser.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
Chaser lights for Little Connection Machine. Random bit patterns shift
|
||||||
|
left or right in groups of four rows. Nothing functional, just looks cool.
|
||||||
|
Inspired by Jurassic Park's blinky CM-5 prop. As it's a self-contained demo
|
||||||
|
and not connected to any system services or optional installations, this is
|
||||||
|
the simplest of the Little Connection Machine examples, making it a good
|
||||||
|
starting point for your own projects...there's the least to rip out here!
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from cm1 import CM1
|
||||||
|
|
||||||
|
DENSITY = 30 # Percentage of bits to set (0-100)
|
||||||
|
FPS = 6 # Frames/second to update (roughly)
|
||||||
|
|
||||||
|
|
||||||
|
def randbit(bitpos):
|
||||||
|
"""Return a random bit value based on the global DENSITY percentage,
|
||||||
|
shifted into position 'bitpos' (typically 0 or 8 for this code)."""
|
||||||
|
return (random.randint(1, 100) > (100 - DENSITY)) << bitpos
|
||||||
|
|
||||||
|
|
||||||
|
class Chaser(CM1):
|
||||||
|
"""Purely decorative chaser lights for Little Connection Machine."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs) # CM1 base initialization
|
||||||
|
# Initialize all bits to 0. 32 rows, 4 columns of 9-bit patterns.
|
||||||
|
self.bits = [[0 for _ in range(4)] for _ in range(32)]
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Main loop for Little Connection Machine chaser lights."""
|
||||||
|
|
||||||
|
last_redraw_time = time.monotonic()
|
||||||
|
interval = 1 / FPS # Frame-to-frame time
|
||||||
|
|
||||||
|
while True:
|
||||||
|
self.clear() # Clear PIL self.image, part of the CM1 base class
|
||||||
|
for row in range(self.image.height): # For each row...
|
||||||
|
for col in range(4): # For each of 4 columns...
|
||||||
|
# Rows operate in groups of 4. Some shift left, others
|
||||||
|
# shift right. Empty spots are filled w/random bits.
|
||||||
|
if row & 4:
|
||||||
|
self.bits[row][col] = (self.bits[row][col] >> 1) + randbit(8)
|
||||||
|
else:
|
||||||
|
self.bits[row][col] = (self.bits[row][col] << 1) + randbit(0)
|
||||||
|
# Draw new bit pattern into image...
|
||||||
|
xoffset = col * 9
|
||||||
|
for bit in range(9):
|
||||||
|
mask = 0x100 >> bit
|
||||||
|
if self.bits[row][col] & mask:
|
||||||
|
# self.draw is PIL draw object in CM1 base class
|
||||||
|
self.draw.point([xoffset + bit, row], fill=self.brightness)
|
||||||
|
|
||||||
|
# Dillydally to roughly frames/second refresh. Preferable to
|
||||||
|
# time.sleep() because bit-drawing above isn't deterministic.
|
||||||
|
while (time.monotonic() - last_redraw_time) < interval:
|
||||||
|
pass
|
||||||
|
last_redraw_time = time.monotonic()
|
||||||
|
self.redraw()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
MY_APP = Chaser() # Instantiate class, calls __init__() above
|
||||||
|
MY_APP.process()
|
||||||
108
LittleConnectionMachine/RaspberryPi/cm1.py
Normal file
108
LittleConnectionMachine/RaspberryPi/cm1.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
Base class for Little Connection Machine projects. Allows drawing on a
|
||||||
|
single PIL image spanning eight IS31fl3731 Charlieplex matrices and handles
|
||||||
|
double-buffered updates. Matrices are arranged four across, two down, "the
|
||||||
|
tall way" (9 pixels across, 16 down) with I2C pins at the bottom.
|
||||||
|
|
||||||
|
IS31fl3731 can be jumpered for one of four addresses. Because this uses
|
||||||
|
eight, two groups are split across a pair of "soft" I2C buses by adding this
|
||||||
|
to /boot/config.txt:
|
||||||
|
dtoverlay=i2c-gpio,bus=2,i2c_gpio_scl=17,i2c_gpio_sda=27,i2c_gpio_delay_us=1
|
||||||
|
dtoverlay=i2c-gpio,bus=3,i2c_gpio_scl=23,i2c_gpio_sda=24,i2c_gpio_delay_us=1
|
||||||
|
And run:
|
||||||
|
pip3 install adafruit-extended-bus adafruit-circuitpython-is31fl3731
|
||||||
|
The extra buses will be /dev/i2c-2 and /dev/i2c-3. These are not as fast as
|
||||||
|
the "true" I2C bus, but are adequate for this application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from PIL import Image
|
||||||
|
from PIL import ImageDraw
|
||||||
|
from adafruit_extended_bus import ExtendedI2C as I2C
|
||||||
|
from adafruit_is31fl3731.matrix import Matrix as Display
|
||||||
|
|
||||||
|
DEFAULT_BRIGHTNESS = 40
|
||||||
|
|
||||||
|
|
||||||
|
class CM1:
|
||||||
|
"""A base class for Little Connection Machine projects, handling common
|
||||||
|
functionality like LED matrix init, updates and signal handler."""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.brightness = DEFAULT_BRIGHTNESS
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"-b",
|
||||||
|
action="store",
|
||||||
|
help="Brightness, 0-255. Default: %d" % DEFAULT_BRIGHTNESS,
|
||||||
|
default=42,
|
||||||
|
type=int,
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.b:
|
||||||
|
self.brightness = min(max(args.b, 0), 255)
|
||||||
|
|
||||||
|
i2c = [
|
||||||
|
I2C(2), # Extended bus on 17, 27 (clock, data)
|
||||||
|
I2C(3), # Extended bus on 23, 24 (clock, data)
|
||||||
|
]
|
||||||
|
self.display = [
|
||||||
|
Display(i2c[0], address=0x74, frames=(0, 1)), # Upper row
|
||||||
|
Display(i2c[0], address=0x75, frames=(0, 1)),
|
||||||
|
Display(i2c[0], address=0x76, frames=(0, 1)),
|
||||||
|
Display(i2c[0], address=0x77, frames=(0, 1)),
|
||||||
|
Display(i2c[1], address=0x74, frames=(0, 1)), # Lower row
|
||||||
|
Display(i2c[1], address=0x75, frames=(0, 1)),
|
||||||
|
Display(i2c[1], address=0x76, frames=(0, 1)),
|
||||||
|
Display(i2c[1], address=0x77, frames=(0, 1)),
|
||||||
|
]
|
||||||
|
self.image = Image.new("L", (9 * 4, 16 * 2))
|
||||||
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.frame_index = 0 # Front/back buffer index
|
||||||
|
signal.signal(signal.SIGTERM, self.signal_handler) # Kill signal
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Placeholder. Override this in subclass."""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def signal_handler(self, signum, frame):
|
||||||
|
"""Signal handler. Clears all matrices and exits."""
|
||||||
|
self.clear()
|
||||||
|
self.redraw()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clears PIL image. Does not invoke refresh(), just clears."""
|
||||||
|
self.draw.rectangle([0, 0, self.image.size[0], self.image.size[1]], fill=0)
|
||||||
|
|
||||||
|
def redraw(self):
|
||||||
|
"""Update matrices with PIL image contents, swap buffers."""
|
||||||
|
# First pass crops out sections over the overall image, rotates
|
||||||
|
# them to matrix space, and writes this data to each matrix.
|
||||||
|
for num, display in enumerate(self.display):
|
||||||
|
col = (num % 4) * 9
|
||||||
|
row = (num // 4) * 16
|
||||||
|
cropped = self.image.crop((col, row, col + 9, row + 16))
|
||||||
|
cropped = cropped.rotate(angle=-90, expand=1)
|
||||||
|
display.image(cropped, frame=self.frame_index)
|
||||||
|
# Swapping frames is done in a separate pass so they all occur
|
||||||
|
# close together, no conspicuous per-matrix refresh.
|
||||||
|
for display in self.display:
|
||||||
|
display.frame(self.frame_index, show=True) # True = show frame
|
||||||
|
self.frame_index ^= 1 # Swap frame index
|
||||||
|
|
||||||
|
def process(self):
|
||||||
|
"""Call CM1 subclass run() function, with keyboard interrupt trapping
|
||||||
|
centralized here so it doesn't need to be implemented everywhere."""
|
||||||
|
try:
|
||||||
|
self.run() # In subclass
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.signal_handler(0, 0) # clear/redraw/exit
|
||||||
46
LittleConnectionMachine/RaspberryPi/cpuload.py
Normal file
46
LittleConnectionMachine/RaspberryPi/cpuload.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
CPU load blinkenlights for Little Connection Machine. Random bits flicker
|
||||||
|
in response to overall processor load across all cores. This relies on the
|
||||||
|
'psutil' Python module, which should already be present in Raspbian OS,
|
||||||
|
but if not, install with: pip install psutil
|
||||||
|
|
||||||
|
psutil can provide all sorts of system information -- per-core load, network
|
||||||
|
use, temperature and more -- but this example is meant to be minimal,
|
||||||
|
understandable and work across different Pi models (even single-core).
|
||||||
|
Consider it a stepping off point for your own customizations. See psutil
|
||||||
|
documentation for ideas: https://pypi.org/project/psutil/
|
||||||
|
|
||||||
|
Since this program itself comprises part of the overall CPU load, the LEDs
|
||||||
|
some amount of LEDs will always be blinking. This is normal and by design.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import psutil
|
||||||
|
from cm1 import CM1
|
||||||
|
|
||||||
|
|
||||||
|
class CPULoad(CM1):
|
||||||
|
"""Simple CPU load blinkenlights for Little Connection Machine."""
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Main loop for Little Connection Machine CPU load blinkies."""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
self.clear() # Clear PIL self.image, part of the CM1 base class
|
||||||
|
blinkyness = 100 - int(psutil.cpu_percent() + 0.5)
|
||||||
|
for row in range(self.image.height):
|
||||||
|
for col in range(self.image.width):
|
||||||
|
if random.randint(1, 100) > blinkyness:
|
||||||
|
# self.draw is PIL draw object in CM1 base class
|
||||||
|
self.draw.point([col, row], fill=self.brightness)
|
||||||
|
self.redraw()
|
||||||
|
# No delay in this example, just run full-tilt!
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
MY_APP = CPULoad() # Instantiate class, calls __init__() above
|
||||||
|
MY_APP.process()
|
||||||
Loading…
Reference in a new issue