Add SPI output support for APA102 LEDs
The APA102 is an RGB LED similar to the neopixel. They're easy to control with SPI, they're cheap, and they're getting more common.
They're used in Pimoroni's Blinkt, and Alex Eames's Inspiring.
Pimoroni's library uses RPi.GPIO to bit-bang SPI: https://github.com/pimoroni/blinkt/blob/master/library/blinkt.py
Our SPI only currently supports analogue input devices like ADCs, so the SPI base class would need some work, but would support both hardware-based (via spidev or pigpio) SPI, and software-based bit-banging.
Coincidentally http://abyz.me.uk/rpi/pigpio/examples.html#Python_test-APA102_py
Great - thanks
I'd love support for Blinkt via pigpio SPI so that it'd be easy to synchronize patterns across many Blinkt boards over the network.
I found this pigpio APA102 example: http://abyz.me.uk/rpi/pigpio/examples.html http://abyz.me.uk/rpi/pigpio/code/test-APA102_py.zip
It might be of interest.
One tricky part will have to decide between bit bang or native SPI depending on the accessory you want to work with.
Blinkt! is not on the right GPIO for native SPI, so bit-bang is the only option without hardware modification. Rainbow HAT (with 7 APA102) is on SPI and can be controlled that way (however Pimoroni library does not use spidev library, so maybe bit bang again? Inspire APA102 are using SPI and the (not released) library rely on spidev.
If someone work on gpiozero implementation of APA102 support, I can help with testing and doing demo. I have many APA102 accessories for the Pi.
2017-05-01 22:08 GMT+02:00 rgm [email protected]:
I'd love support for Blinkt via pigpio SPI so that it'd be easy to synchronize patterns across many Blinkt boards over the network.
— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/RPi-Distro/python-gpiozero/issues/551#issuecomment-298419163, or mute the thread https://github.com/notifications/unsubscribe-auth/ASiRnNIqESAz1SuElXRuSAHQu3rHcrnkks5r1jvIgaJpZM4MrirI .
It would be good to include support for WS2812B / neopixels, too. Currently, I use the rpi_ws281x library, but integrating this into gpiozero would be fantastic.
Completely different kettle of fish, I'm afraid. Neopixels require very precise timing, and the ws281x lib requires root - but we think we can get there eventually, it's on the list. Just APA102 pixels are much easier.
#583
I was prying into the underlying SPI classes this morning - oh what a rabbit hole that is for an initiated developer - trying to figure out how I might implement an APA102 driver in GPIO Zero. I was left a little flummoxed.
It looks like transfer for an SPI device expects a MISO pin, but this can be specified as None so that transfer returns a List of zeros the same length of the transmitted list?
So I try;
import gpiozero
apa102 = gpiozero.SPIDevice(mosi_pin=23, clock_pin=24)
print(dir(apa102))
apa102._spi.transfer([
0b00000000, # SOF
0b00000000,
0b00000000,
0b00000000, # SOF
0b11111111, # SOP
0b11111111, # B
0b00000000, # G
0b00000000, # R
0b11111111, # SOP
0b00000000, # B
0b11111111, # G
0b00000000, # R
0b00000000, # EOF
0b00000000,
0b00000000,
0b00000000, # EOF
0b00000000, # Moar bits
])
And it works! Which means presumably I can just derive an Apa102 class from SPIDevice, include a led_count, and standard set_pixel and get_pixel methods?
Awesome! The API would be a bit different to the usual get/set methods but if you were to implement something based on that, we could firm up the API details later.
I have some notes somewhere on what an API for a board like Blinkt would be, but start with something RGBLED for the individual LED, and LEDBoard for the strip.
Edit: Just realised that RGBLED already exists
Interesting to hear your take on it- as I was piecing together a quick and dirty class I released that it might be useful to have a representation of an individual LED to prevent nasty loose ends such as:
self._led_values = [[0, 0, 0, 0] for _ in range(self._led_count)]
And, additionally, because you might want to feed a stream of values into- for example- the hue of a specific LED, or for dozens of other reasons that relate to a specific single LED rather than a chain.
RGBLED makes sense- although there are both WWW and RGBW LEDs throwing a spanner into that nomenclature. Additionally the APA102s have a "Global Brightness" per LED that I've typically found to be a non-feature, but I think people would expect a generic class to implement it.
I don't know if we should use some cleverness with every pixel having a "Brightness" value and the APA102s using that to determine "Global Brightness" and individual brightness for a wider gamut (at least I hypothesise that's possible), or just scale only the RGB values and let the user choose to fiddle the "Global Brightness" if they wish. Or both!?
Right now my POC class looks like the following, which is utterly tangential to how everything else in GPIOZero works, from what I understand:
import gpiozero
class Apa102(gpiozero.SPIDevice):
def __init__(self, *args, **kwargs):
gpiozero.SPIDevice.__init__(self, *args, **kwargs)
self._led_count = kwargs.get('led_count', 8)
self._led_values = [[0, 0, 0, 0] for _ in range(self._led_count)]
def set_pixel(self, x, r, g, b, brightness=1.0):
self._led_values[x] = [r, g, b, brightness]
def set_all(self, r, g, b, brightness=1.0):
for x in range(self._led_count):
self.set_pixel(x, r, g, b, brightness)
def show(self):
data = []
# Start Of Frame
for _ in range(4):
data.append(0)
for led in self._led_values:
r, g, b, brightness = led
brightness = max(0, min(0b11111, int(brightness * 0b11111)))
data.append(0b11100000 | brightness)
data.append(b & 0xff)
data.append(g & 0xff)
data.append(r & 0xff)
# End Of Frame
for _ in range(5):
data.append(0)
self._spi.transfer(data)
blinkt = Apa102(mosi_pin=23, clock_pin=24)
blinkt.set_pixel(0, 0, 0, 255)
blinkt.set_pixel(1, 0, 255, 0)
blinkt.set_pixel(2, 255, 0, 0)
blinkt.show()
I guess we need to reach something like:
blinkt = LEDBoard(led_count=8, type='apa102', data_pin=23, clock_pin=24)
button = Button(2)
blinkt.leds[0].hue = button.values
With the LEDBoard class initialising each RGBLED with a handler that triggers an update of the whole strand of LEDs within the LEDBoard class itself upon any change of R, G or B values.
Start with what .value looks like.
For a single pixel it would be a 3-tuple representing RGB like RGBLED:
pixel.value = (1, 1, 1)
For a strip like Blinkt, an n-tuple of 3-tuples:
blinkt.value = ((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1))
For a unicorn hat -like grid, an 8-tuple of 8-tuples of 3-tuples:
unicorn.value = (
((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1)),
((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1)),
((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1)),
((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1)),
((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1)),
((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1)),
((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1)),
((1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1), (1, 1, 1))
)
then you could do things like access individual strips/pixels with unicorn[0] or blinkt[0], or slices like blinkt[:4].
The grid example may even be a nested strip (see how LEDBoards can be nested)
I appear to have been lost down a rabbit hole of complexity trying to understand where LED drivers, in general, might fit into the bigger picture.
Perhaps an LED driver board could be a special variant of LEDCollection?
Yeah it becomes more complex. It's easy to do LEDBoards and things with regular LEDs and accessing/controlling one within a set is the same as one on its own, but as part of a driver board set that's no longer the case. I think we have to implement the driver logic at the individual LED level and override it in the LEDCollection equivalent.
Just having a play with this. Got a few questions...
Why do you max brightness at 225 not 255? Or is it 31? Would it be a huge loss to not provide the brightness setting, and just let the user specify RGB values?
Why do you do all this?
brightness = max(0, min(0b11111, int(brightness * 0b11111)))
data.append(0b11100000 | brightness)
data.append(b & 0xff)
data.append(g & 0xff)
data.append(r & 0xff)
Basic implementation of a single pixel:
class DotstarPixel(SPIDevice):
def __init__(self, *args, **kwargs):
super(DotstarPixel, self).__init__(*args, **kwargs)
self.value = (0, 0, 0)
@property
def value(self):
return self._value
@value.setter
def value(self, value):
brightness = 255
r, g, b = [int(255*v) for v in value]
data = [0]*4 + [brightness, b, g, r] + [0]*5
self._spi.transfer(data)
self._value = value
def on(self):
self.value = (1, 1, 1)
def off(self):
self.value = (0, 0, 0)
def close(self):
self.off()
super(DotstarPixel, self).close()
I'm not sure it's worth keeping single pixel separate from a strip...
And a basic implementation of a strip:
class DotstarPixelStrip(SPIDevice):
def __init__(self, pixels, *args, **kwargs):
super(DotstarPixelStrip, self).__init__(*args, **kwargs)
self._pixels = pixels
self.off()
@property
def value(self):
return self._value
@value.setter
def value(self, value):
brightness = 255
start = [0]*4
end = [0]*5
pixels = [[int(255*v) for v in p] for p in value]
pixels = [[brightness, b, g, r] for r, g, b in pixels]
pixels = [i for p in pixels for i in p]
data = start + pixels + end
self._spi.transfer(data)
self._value = value
def on(self):
self.value = ((1, 1, 1),) * self._pixels
def off(self):
self.value = ((0, 0, 0),) * self._pixels
def close(self):
self.off()
super(DotstarPixelStrip, self).close()
which works for a single pixel too, but you'd have to set .value to ((r, g, b),) not (r, g, b).
So I think the strip could extend the pixel class, and just override where necessary.
I think for the API it would help if we implemented RGBLEDBoard... (I'm not suggesting RGBLEDBarGraph!)
The 0b11100000 part - IE the first three most significant bits of the first byte for each LED - is the "start of frame" for that LED.
The 0b00011111 - the five least significant bits - are the global brightness value. This is actually a separate PWM frequency that is superimposed over the brightness of each individual LED element. I'm not convinced it's particularly useful. I guess it's down to the creative direction and goals of GPIO Zero as to whether or not it should be exposed to the user or used at all.
The more traditional implementation of brightness on these kinds of LEDs (as with WS2812) is to simply scale the individual RGB element values according to some brightness value.
Your single pixel implementation could be:
class DotstarPixel(SPIDevice):
def __init__(self, *args, **kwargs):
super(DotstarPixel, self).__init__(*args, **kwargs)
self.value = (0, 0, 0)
@property
def value(self):
return self._value
@value.setter
def value(self, value):
brightness = 31
r, g, b = [int(255*v) for v in value]
data = [0]*4 + [0b11100000 | brightness, b, g, r] + [0]*5
self._spi.transfer(data)
self._value = value
def on(self):
self.value = (1, 1, 1)
def off(self):
self.value = (0, 0, 0)
def close(self):
self.off()
super(DotstarPixel, self).close()
I know binary notation 0b11100000 is a little long-winded vs 0xe0 or just 224, but it's much more immediately clear that "the bits matter" if they're laid bare.
More frustratingly, not all APA102s are created equal. We have observed three different types, which have different nuances and are not all mutually compatible with the same implementation of the protocol. I need to do some more testing on this front, though. Some need 4 bytes of zeros clocked out at the end - a literal interpretation of the datasheet - and some simply wont work with this approach.
Recommended reading: https://cpldcpu.wordpress.com/2014/11/30/understanding-the-apa102-superled/
Also I'd discourage the use of Dotstar since that's Adafruit's own (trademarked?) re-branding of the APA102.
Also I'd discourage the use of Dotstar since that's Adafruit's own (trademarked?) re-branding of the APA102.
Oh, that's annoying. I was hoping it was just a friendly name for them. Is there anything else that will work? Otherwise will have to be APA102Pixel or something.
Honestly, I don't think Adafruit would mind you using the Dotstar branding but since nobody else can sell them as Dotstar it has the potential for confusion.
If the class were a little more generic I guess it would be SPILED and SPILEDStrip or something (ooh, the acronyms, my eyes!). The APA102 are far from the only SPI-compatible "smart" LED driver with this sort of protocol. As a quick (but poor, oh my the datasheet is bad) example: https://cdn-shop.adafruit.com/datasheets/LPD6803.pdf
It's not identical but it's close enough that it raises the question- should they be handled by the same class? (or a class factory)
Borrowing and tweaking your code, I get the following which works with Blinkt!:
import time
from gpiozero import SPIDevice
class Apa102Pixel(SPIDevice):
def __init__(self, *args, **kwargs):
super(Apa102Pixel, self).__init__(*args, **kwargs)
self._brightness = 255.0
self._value = (0, 0, 0)
@property
def value(self):
return self._value
@value.setter
def value(self, value):
r, g, b = [int(self._brightness * v) for v in value]
data = [0]*4 + [0b11100000 | 31, b, g, r] + [0]*5
self._spi.transfer(data)
self._value = value
def on(self):
self.value = (1, 1, 1)
def off(self):
self.value = (0, 0, 0)
def close(self):
self.off()
super(Apa102Pixel, self).close()
class Apa102PixelStrip(SPIDevice):
def __init__(self, pixels, *args, **kwargs):
super(Apa102PixelStrip, self).__init__(*args, **kwargs)
self._brightness = 255.0
self._pixels = pixels
self.off()
@property
def value(self):
return self._value
@value.setter
def value(self, value):
start_of_frame = [0]*4
end_of_frame = [0]*5
start_of_pixel = 0b11100000 | 31 # Start bits and 5-bit brightness (0-31)
pixels = [[int(self._brightness*v) for v in p] for p in value]
pixels = [[start_of_pixel, b, g, r] for r, g, b in pixels]
pixels = [i for p in pixels for i in p]
data = start_of_frame + pixels + end_of_frame
self._spi.transfer(data)
self._value = value
def on(self):
self.value = ((1, 1, 1),) * self._pixels
def off(self):
self.value = ((0, 0, 0),) * self._pixels
def close(self):
self.off()
super(Apa102PixelStrip, self).close()
As a strip:
blinkt = Apa102PixelStrip(8, mosi_pin=23, clock_pin=24)
for colour in [(255, 0, 0), (0, 255, 0), (0, 0, 255)]:
blinkt.value = [colour] * 8
time.sleep(1)
As a single LED:
blinkt = Apa102Pixel(mosi_pin=23, clock_pin=24)
for colour in [(255, 0, 0), (0, 255, 0), (0, 0, 255)]:
blinkt.value = colour
time.sleep(1)
Apparently "DotStar" is not an Adafruit name/trademark in the same way that NeoPixel is, but Adafruit. by widely referring to them as DotStar. have basically appropriated the term. So if we're making a class that's APA102-compatible only DotStar is a perfectly cromulent and much prettier name than "APA102Pixel" or "SPILEDPixel"
Ok cool. I think the use of the name Dotstar embiggens their presence anyway.
My implementation above was working fine with blinkt too (either single pixel or the strip of 8).
We would use 0-1 for RGB rather than 0-255 (as mine did).
Oh, wait, what! I totally forgot to use 0-1 and the values are being scaled up to 0-255 by the _brightness value anyway. I'm guessing the SPI bitwidth is implicitly translating my silly values (0-65025) into 8bits and that would explain why Blinkt! was so dim when I was testing it.
🤦♂️
Edit: your implementation would fail for any value of brightness that doesn't have the 3 most-significant bits set. My only change of note was splitting that out and making it clear that global brightness is a 5-bit value and the 3MSBs are part of the protocol.
Hehe.
Ok I think a compromise on universal brightness is to have it as a settable property (0-1) for the user, and use it the way you've used self._brightness. It doesn't need to be per-pixel, and it doesn't need to be passed in every time they set a pixel (like you do with blinkt.set_pixel(br, r, g, b)). I'd be happy with that.
It would be cool to provide a brightness_source property as well as a source for setting value.
There are a few places around the library that would be handy. You can do it with things like LEDBoard or TrafficLights like lights.red.source = whatever, but not with RGBLED as it doesn't have a red.source because there red is just a float, not a LED object, but that would be handy.
@bennuttall recently pointed to some other example code here
I found that the APA102 datasheet wasn't detailed enough with respect to how the 'end frame' should be created. I found the following excellent information at https://cpldcpu.wordpress.com/2014/11/30/understanding-the-apa102-superled/ which states "An end frame consisting of at least (n/2) bits of 1, where n is the number of LEDs in the string".
They state "It should not matter, whether the end frame consists of ones or zeroes. Just don’t mix them.", I found it necessary to use zeroes as otherwise I didn't seem to be able to correctly clear the LED strip though.
So I've adapted the previous examples to take this into account. I'm using a 144 LED strip, so it was necessary to use >4 bytes of 0s for end termination, otherwise the last LED didn't seem to display the correct colour.
I've used values 0 - 1, for the r,g,b values and then multiply by 255, for the actual output.
I removed the close function, as the call to self.off() within it failed, with the following exception now:
Exception ignored in: <function GPIOBase.__del__ at 0xb645f858>
Traceback (most recent call last):
File "/home/pi/Repositories/gpiozero/gpiozero/devices.py", line 158, in __del__
File "./mytest2.py", line 37, in close
File "./mytest2.py", line 33, in off
File "/home/pi/Repositories/gpiozero/gpiozero/devices.py", line 155, in __setattr__
File "./mytest2.py", line 26, in value
File "/home/pi/Repositories/gpiozero/gpiozero/pins/local.py", line 191, in transfer
AttributeError: 'NoneType' object has no attribute 'xfer2'
#!/usr/bin/python3
import time
from gpiozero import SPIDevice
class Apa102PixelStrip(SPIDevice):
def __init__(self, pixels, *args, **kwargs):
super(Apa102PixelStrip, self).__init__(*args, **kwargs)
self._brightness = 1
self._pixels = pixels
self.off()
@property
def value(self):
return self._value
@value.setter
def value(self, value):
start_of_frame = [0]*4
end_of_frame = [0] * int((len(value)/2/8)+1)
bright = int(self._brightness * 31)
pixels = [[ 0b1110000 | bright, b*255, g*255, r*255 ] for r, g, b in value ]
pixels = [y for x in pixels for y in x]
data = start_of_frame + pixels + end_of_frame
self._spi.transfer(data)
self._value = value
def on(self):
self.value = ((1, 1, 1),) * self._pixels
def off(self):
self.value = ((0, 0, 0),) * self._pixels
strip = Apa102PixelStrip(8, clock_pin=11, mosi_pin=10)
for colour in [(1, 0, 0), (0, 1, 0), (0, 0, 1), (0, 0, 0)]:
strip.value = [colour] * 144
time.sleep(1)
Similarly I created a class for a board with these pixels using the above code as a starter, which is mostly gpiozero-like but not perfectly: https://github.com/ThePiHut/rgbxmastree
Thanks, I don't think I'd seen that repo before, will have a look :)