Rewrite to reduce memory usage and add more features

In addition to the clock scene, both the animation scene and the weather
scene should now work under MicroPython on devices with 520kBytes of RAM
(e.g. LoPy 1, WiPy 2) after:

- combating heap fragmentation during initialization by temporarily allocating
  a large chunk of RAM in the beginning of main.py and freeing it after all
  modules have been imported and initialized
- stream parsing the JSON response from the weather API
- converting animations to binary and streaming them from the flash file system

(additionally, older ESP8266 modules with 4MB flash have been found working
 under some circumstances with MicroPython 1.9.4 and an 8x8 LED matrix)

- 3D parts: add diffuser grid and frame for square LED matrix displays
- Arduino projects needs to be in a folder with the same name as the .ino file
- config: allow multiple WiFi networks to be configured
- config: add support for debug flags
- config: add intensity configuration
- HAL: unify serial input processing for Arduino and Pycom devices
- HAL: handle UART write failures on Pycom devices
- HAL: drop garbage collection from .update_display() because it takes several
  hundred milliseconds on 4MB devices
- MCU: clear display when enabling/disabling MCU independence from host
- PixelFont: move data to class attributes to reduce memory usage
- PixelFont: add more characters
- PixelFont: move data generation to scripts/generate-pixelfont.py
- LedMatrix: support LED matrixes with strides other than 8 (e.g. as 16x16 matrices)
- LedMatrix: add method to render text
- LedMatrix: let consumers handle brightness themselves
- AnimationScene: MicroPython does not implement bytearray.find
- AnimationScene: ensure minimum on-screen time
- BootScene: wifi connection and RTC sync progress for Pycom devices
- ClockScene: delete unused code, switch to generic text rendering method
- FireScene: classical fire effect
- WeatherScene: bug fixes, switch to generic text rendering method
- WeatherScene: ensure minimum on-screen time
- WeatherScene: use custom JSON parsing to reduce memory usage
This commit is contained in:
Noah
2018-12-26 20:26:05 +00:00
parent 30903a207b
commit 3e4dd4c0bc
32 changed files with 102728 additions and 1274 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
**/*.json
**/*.bin
*~
*.swp
*.pyc

File diff suppressed because it is too large Load Diff

318
3d-parts/lamatrix.scad Normal file
View File

@@ -0,0 +1,318 @@
/**
* Things to contain flexible LED matrix displays
*
* Hit F6 to render the part and then export it to STL.
* Load in your favorite slicer and print it.
*
* -- noah@hack.se, 2018
*/
/**
* Combine with '32x8 LED Matrix grid for diffuser'
* https://www.thingiverse.com/thing:1903744
*
* Additional hardware: 12x 2.6x10mm plastic screws
*/
backsideFrame32x8();
/**
* Parts for 8x8 or 16x16 LED matrices
*
* Comment the above part by prefixing the module call with an asterisk
* and then uncomment one of the parts below. Hit F6 to render and then
* export as STL.
*
*/
*squareDiffuserGrid(16); // 16x16 LED matrix
*squareBacksideFrame(16);
*squareDiffuserGrid(8); // Uncomment for 8x8 LED matrix
*squareBacksideFrame(8); // 16x16 LED matrix
// Config
M2hole=1.9;
M2_3hole=2.2;
M3hole=2.7;
// Helper routine
module polyhole(d, h) {
n = max(round(2 * d),3);
rotate([0,0,180])
cylinder(h = h, r = (d / 2) / cos (180 / n), $fn = n);
}
module squareDiffuserGrid(pixels=16) {
xRows=pixels;
yRows=pixels;
cellSize=10;
thickness=5;
cylSize=6;
gridThickness=0.8;
componentLength=6;
componentHeight=1.25;
difference() {
union() {
// Grid
for(x=[1:xRows-1]) {
tmp = x != xRows/2? gridThickness: gridThickness; //*2
translate([x*cellSize-tmp/2, 0, 0])
cube([tmp, cellSize*yRows, thickness]);
}
for(y=[1:yRows-1]) {
tmp = y != yRows/2? gridThickness: gridThickness; //*2
translate([0, y*cellSize-tmp/2, 0])
cube([cellSize*xRows, tmp, thickness]);
}
// Corners
for(x=[0,1]) {
for(y=[0,1]) {
translate([-cylSize/PI+x*(xRows*cellSize+2*cylSize/PI), -cylSize/PI+y*(yRows*cellSize+2*cylSize/PI), 0])
polyhole(d=cylSize, h=thickness);
}
}
// Outer joining the corners and the grid
for(i=[0,1]) {
// X walls
translate([-cylSize/2, gridThickness/2-thickness+i*(cellSize*yRows+thickness-gridThickness), 0])
cube([cylSize+cellSize*xRows, thickness, thickness]);
// Y walls
translate([gridThickness/2-thickness+i*(cellSize*xRows+thickness-gridThickness), -cylSize/2, 0])
cube([thickness, cylSize+cellSize*yRows, thickness]);
}
}
// Make room for external components
for(y=[1:yRows]) {
for(x=[1:xRows-1]) {
translate([x*cellSize-gridThickness,y*cellSize-cellSize/2-componentLength/2,thickness-componentHeight])
cube([gridThickness*2,componentLength,componentHeight+.5]);
}
}
// Screw holes corners
for(x=[0,1])
for(y=[0,1])
translate([-cylSize/PI+x*(xRows*cellSize+2*cylSize/PI), -cylSize/PI+y*(yRows*cellSize+2*cylSize/PI), -.5])
polyhole(d=M2_3hole, h=thickness+1);
}
}
module squareBacksideFrame(pixels=16) {
xRows=pixels;
yRows=pixels;
cellSize=10;
thickness=5;
cylSize=6;
gridThickness=0.8;
componentLength=6;
componentHeight=1.25;
pcbHoleDistance=36;
usbHoleDistance=9;
expansionBoardHoleDistanceX=45;
expansionBoardHoleDistanceY=55;
height=(cellSize*yRows+thickness-gridThickness);
width=(cellSize*xRows+thickness-gridThickness);
difference() {
union() {
// Corners
for(x=[0,1]) {
for(y=[0,1]) {
translate([-cylSize/PI+x*(xRows*cellSize+2*cylSize/PI), -cylSize/PI+y*(yRows*cellSize+2*cylSize/PI), 0])
polyhole(d=cylSize, h=thickness);
}
}
// Outer joining the corners and the grid
for(i=[0,1]) {
// X walls
translate([-cylSize/2, gridThickness/2-thickness+i*(cellSize*yRows+thickness-gridThickness), 0])
cube([cylSize+cellSize*xRows, thickness, thickness]);
// Y walls
translate([gridThickness/2-thickness+i*(cellSize*xRows+thickness-gridThickness), -cylSize/2, 0])
cube([thickness, cylSize+cellSize*yRows, thickness]);
}
// Stabilizator Pycom expansion board
for(i=[-1,1]) {
translate([0, height/2-thickness/2+i*expansionBoardHoleDistanceY/2,0])
cube([width, thickness, thickness]);
// Screw holes
translate([15, height/2-i*expansionBoardHoleDistanceY/2,0])
cylinder(d=6, h=thickness);
translate([15, height/2-i*expansionBoardHoleDistanceY/2,0])
cylinder(d=6, h=thickness);
translate([15+expansionBoardHoleDistanceX, height/2-i*expansionBoardHoleDistanceY/2,0])
cylinder(d=6, h=thickness);
translate([15+expansionBoardHoleDistanceX, height/2-i*expansionBoardHoleDistanceY/2,0])
cylinder(d=6, h=thickness);
}
// Adafruit Perma-Proto stabilizator and screw holes
translate([0, height/2-thickness/2,0])
cube([15+expansionBoardHoleDistanceX, thickness, thickness]);
translate([15+expansionBoardHoleDistanceX-thickness/2, height/2-expansionBoardHoleDistanceY/2, 0])
cube([thickness, expansionBoardHoleDistanceY, thickness]); // Join with stabilizator for Pycom exp board
translate([15, height/2,0])
cylinder(d=6, h=thickness);
translate([15+pcbHoleDistance, height/2,0])
cylinder(d=6, h=thickness);
}
// Screw holes corners
for(x=[0,1])
for(y=[0,1])
translate([-cylSize/PI+x*(xRows*cellSize+2*cylSize/PI), -cylSize/PI+y*(yRows*cellSize+2*cylSize/PI), -.5])
polyhole(d=M2_3hole, h=thickness+1);
// SS-5GL micro switch screw holes
for(y=[0,1]) {
for(x=[-20,20]) {
translate([x+width/2-9.5, y*height-thickness/2+gridThickness/2, -.5])
polyhole(d=M2hole, h=thickness+1);
translate([x+width/2, y*height-thickness/2+gridThickness/2, -.5])
polyhole(d=M2hole, h=thickness+1);
translate([x+width/2+9.5, y*height-thickness/2+gridThickness/2, -.5])
polyhole(d=M2hole, h=thickness+1);
}
}
for(y=[-20,20]) {
translate([cellSize*xRows+thickness/2-gridThickness/2, y+height/2-9.5, -.5])
polyhole(d=M2hole, h=thickness+1);
translate([cellSize*xRows+thickness/2-gridThickness/2, y+height/2, -.5])
polyhole(d=M2hole, h=thickness+1);
translate([cellSize*xRows+thickness/2-gridThickness/2, y+height/2+9.5, -.5])
polyhole(d=M2hole, h=thickness+1);
}
// Permaproto screw holes
translate([15, height/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
translate([15+pcbHoleDistance, height/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
// Pycom expansion board screw holes
for(i=[-1,1]) {
translate([15, height/2-i*expansionBoardHoleDistanceY/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
translate([15, height/2-i*expansionBoardHoleDistanceY/2, -.5])
cylinder(d=M3hole, h=thickness+2+1);
translate([15+expansionBoardHoleDistanceX, height/2-i*expansionBoardHoleDistanceY/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
translate([15+expansionBoardHoleDistanceX, height/2-i*expansionBoardHoleDistanceY/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
}
}
}
module backsideFrame32x8() {
thickness=5;
cylSize=6.25;
screwXDistance=75;
screwYDistance=86;
// Extra feature: PCB mounting bars
pcbHoleDistance=36;
usbHoleDistance=9;
expansionBoardHoleDistanceX=45;
expansionBoardHoleDistanceY=55;
difference() {
union() {
// 2x3 screw holes
for(x=[0,1,2]) {
translate([x*screwXDistance, 0, 0])
polyhole(d=cylSize, h=thickness);
translate([x*screwXDistance, screwYDistance, 0])
polyhole(d=cylSize, h=thickness);
}
// Stabilizator
translate([2*screwXDistance-8,-cylSize/2,0])
rotate([0,0,45])
cube([14,5,thickness]);
translate([2*screwXDistance+19.5, screwYDistance, 0])
polyhole(d=cylSize, h=thickness);
// X beams to joins screw holes
for(x=[0,1]) {
translate([x*screwXDistance, -cylSize/2, 0])
cube([screwXDistance, thickness, thickness]);
translate([x*screwXDistance, cylSize/2-thickness+screwYDistance, 0])
cube([screwXDistance, thickness, thickness]);
}
// Stabilizator
translate([2*screwXDistance, cylSize/2-thickness+screwYDistance, 0])
cube([19.5+cylSize/2, thickness, thickness]);
// Y beam to join screw holes
translate([-cylSize/2, 0, 0])
cube([thickness, 86, thickness]);
for(x=[1,2]) {
translate([x*screwXDistance-thickness/2, 0, 0])
cube([thickness, screwYDistance, thickness]);
}
// Extra feature: PCB mounting bars
translate([-thickness/2+screwXDistance,0,0]) {
// Stabilizator Adafruit perma-proto board
translate([0, screwYDistance/2-thickness/2,0])
cube([screwXDistance, thickness, thickness]);
translate([15, screwYDistance/2,0])
cylinder(d=6, h=thickness);
translate([15+pcbHoleDistance, screwYDistance/2,0])
cylinder(d=6, h=thickness);
// Stabilizator Pycom expansion board
for(i=[-1,1]) {
translate([0, screwYDistance/2-thickness/2+i*expansionBoardHoleDistanceY/2, 0])
cube([screwXDistance, thickness, thickness]);
// Screw holes
translate([15, screwYDistance/2-i*expansionBoardHoleDistanceY/2, 0])
cylinder(d=6, h=thickness);
translate([15, screwYDistance/2-i*expansionBoardHoleDistanceY/2, 0])
cylinder(d=6, h=thickness);
translate([15+expansionBoardHoleDistanceX, screwYDistance/2-i*expansionBoardHoleDistanceY/2, 0])
cylinder(d=6, h=thickness);
translate([15+expansionBoardHoleDistanceX, screwYDistance/2-i*expansionBoardHoleDistanceY/2, 0])
cylinder(d=6, h=thickness);
}
}
}
// Screw holes
for(x=[0,1,2]) {
translate([x*screwXDistance, 0, -1])
polyhole(d=M2_3hole, h=thickness+2);
translate([x*screwXDistance, screwYDistance, -1])
polyhole(d=M2_3hole, h=thickness+2);
}
// Stabilizator hole
translate([2*screwXDistance+19.5,screwYDistance,-1]) polyhole(d=M2_3hole, h=thickness+2);
// Stabilizator removal bottom side
translate([2*screwXDistance-cylSize/2-0.5,-cylSize/2-0.5,-1]) cube([cylSize+1,cylSize+1, thickness+2]);
// Extra feature: PCB mounting bars
translate([-thickness/2+screwXDistance,0,0]) {
// Adafruit perma-proto screw holes
translate([15, screwYDistance/2, -.5])
polyhole(d=M3hole, h=thickness+1);
translate([15+pcbHoleDistance, screwYDistance/2, -.5])
polyhole(d=M3hole, h=thickness+1);
// Pycom expansion board screw holes
for(i=[-1,1]) {
translate([15, screwYDistance/2-i*expansionBoardHoleDistanceY/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
translate([15, screwYDistance/2-i*expansionBoardHoleDistanceY/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
translate([15+expansionBoardHoleDistanceX, screwYDistance/2-i*expansionBoardHoleDistanceY/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
translate([15+expansionBoardHoleDistanceX, screwYDistance/2-i*expansionBoardHoleDistanceY/2, -.5])
polyhole(d=M3hole, h=thickness+2+1);
}
// SS-5GL micro switch screw holes
for(x=[1,3]) {
translate([x*screwXDistance/4-9.5, cylSize/2-thickness/2+screwYDistance, -.5]) polyhole(d=M2hole, h=thickness+1);
translate([x*screwXDistance/4, cylSize/2-thickness/2+screwYDistance, -.5]) polyhole(d=M2hole, h=thickness+1);
translate([x*screwXDistance/4+9.5, cylSize/2-thickness/2+screwYDistance, -.5]) polyhole(d=M2hole, h=thickness+1);
translate([x*screwXDistance/4-9.5, -cylSize/2+thickness/2, -.5]) polyhole(d=M2hole, h=thickness+1);
translate([x*screwXDistance/4, -cylSize/2+thickness/2, -.5]) polyhole(d=M2hole, h=thickness+1);
translate([x*screwXDistance/4+9.5, -cylSize/2+thickness/2, -.5]) polyhole(d=M2hole, h=thickness+1);
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +0,0 @@
/**
* Combine with '32x8 LED Matrix grid for diffuser'
* https://www.thingiverse.com/thing:1903744
*
* 12x 2.6x10mm plastic screws
*
* -- noah@hack.se, 2018
*
*/
module polyhole(d, h) {
n = max(round(2 * d),3);
rotate([0,0,180])
cylinder(h = h, r = (d / 2) / cos (180 / n), $fn = n);
}
thickness=5;
cylSize=6.25;
screwXDistance=75;
screwYDistance=86;
difference() {
union() {
// 2x3 screw holes
for(x=[0,1,2]) {
translate([x*screwXDistance,0,0]) polyhole(d=cylSize, h=thickness);
translate([x*screwXDistance,screwYDistance,0]) polyhole(d=cylSize, h=thickness);
}
// Stabilizator
translate([2*screwXDistance-8,-cylSize/2,0]) rotate([0,0,45])cube([14,5,thickness]);
translate([2*screwXDistance+19.5,screwYDistance,0]) polyhole(d=cylSize, h=thickness);
// X beams to joins screw holes
for(x=[0,1]) {
translate([x*screwXDistance,-cylSize/2,0]) cube([screwXDistance,thickness, thickness]);
translate([x*screwXDistance,cylSize/2-thickness+screwYDistance,0]) cube([screwXDistance,thickness, thickness]);
}
// Stabilizator
translate([2*screwXDistance,cylSize/2-thickness+screwYDistance,0]) cube([19.5+cylSize/2,thickness, thickness]);
// Y beam to join screw holes
translate([-cylSize/2,0,0]) cube([thickness, 86, thickness]);
for(x=[1,2])
translate([x*screwXDistance-thickness/2, 0, 0]) cube([thickness, screwYDistance, thickness]);
}
// Screw holes
for(x=[0,1,2]) {
translate([x*screwXDistance,0,-1]) polyhole(d=2.2, h=thickness+2);
translate([x*screwXDistance,screwYDistance,-1]) polyhole(d=2.2, h=thickness+2);
}
// Stabilizator hole
translate([2*screwXDistance+19.5,screwYDistance,-1]) polyhole(d=2.2, h=thickness+2);
// Stabilizator removal bottom side
# translate([2*screwXDistance-cylSize/2-0.5,-cylSize/2-0.5,-1]) cube([cylSize+1,cylSize+1, thickness+2]);
}

View File

@@ -13,7 +13,8 @@
#define HOST_SHUTDOWN_PIN 8
#define BUTTON_PIN 12
#define LEFT_BUTTON_PIN 9
#define RIGHT_BUTTON_PIN 10
#define NUM_LEDS 256
#ifdef TEENSYDUINO
@@ -55,8 +56,8 @@ enum {
/* Computed with pixelfont.py */
static int font_width = 4;
static int font_height = 5;
static char font_alphabet[] = " %'-./0123456789:cms";
static unsigned char font_data[] = "\x00\x00\x50\x24\x51\x66\x00\x00\x60\x00\x00\x00\x42\x24\x11\x57\x55\x27\x23\x72\x47\x17\x77\x64\x74\x55\x47\x74\x71\x74\x17\x57\x77\x44\x44\x57\x57\x77\x75\x74\x20\x20\x20\x15\x25\x75\x57\x75\x71\x74";
static char font_alphabet[] = " %'-./0123456789:?acdefgiklmnoprstwxy";
static unsigned char font_data[] = "\x00\x00\x50\x24\x51\x66\x00\x00\x60\x00\x00\x00\x42\x24\x11\x57\x55\x27\x23\x72\x47\x17\x77\x64\x74\x55\x47\x74\x71\x74\x17\x57\x77\x44\x44\x57\x57\x77\x75\x74\x20\x20\x30\x24\x20\x52\x57\x25\x15\x25\x53\x55\x73\x31\x71\x17\x13\x71\x71\x75\x27\x22\x57\x35\x55\x11\x11\x57\x77\x55\x75\x77\x75\x55\x75\x57\x17\x71\x35\x55\x17\x47\x77\x22\x22\x55\x77\x55\x25\x55\x55\x27\x02";
/* Global states */
int state = 0;
@@ -76,8 +77,8 @@ CRGB leds[NUM_LEDS];
static volatile int g_button_state;
static int button_down_t;
static void button_irq(void) {
int state = digitalRead(BUTTON_PIN);
static void button_irq_left(void) {
int state = digitalRead(LEFT_BUTTON_PIN);
if(state == HIGH) {
/* Start counting when the circuit is broken */
@@ -88,7 +89,9 @@ static void button_irq(void) {
return;
int pressed_for_ms = millis() - button_down_t;
if(pressed_for_ms > 500)
if(pressed_for_ms > 1500)
g_button_state = 4;
else if(pressed_for_ms > 500)
g_button_state = 2;
else if(pressed_for_ms > 100)
g_button_state = 1;
@@ -96,6 +99,28 @@ static void button_irq(void) {
button_down_t = 0;
}
static void button_irq_right(void) {
int state = digitalRead(RIGHT_BUTTON_PIN);
if(state == HIGH) {
/* Start counting when the circuit is broken */
button_down_t = millis();
return;
}
if(!button_down_t)
return;
int pressed_for_ms = millis() - button_down_t;
if(pressed_for_ms > 1500)
g_button_state = 64;
else if(pressed_for_ms > 500)
g_button_state = 32;
else if(pressed_for_ms > 100)
g_button_state = 16;
button_down_t = 0;
}
void setup() {
Serial.begin(460800);
@@ -107,9 +132,11 @@ void setup() {
pinMode(HOST_SHUTDOWN_PIN, OUTPUT);
digitalWrite(HOST_SHUTDOWN_PIN, HIGH);
/* Configure pin for button */
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), button_irq, CHANGE);
/* Configure pins for the buttons */
pinMode(LEFT_BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(LEFT_BUTTON_PIN), button_irq_left, CHANGE);
pinMode(RIGHT_BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(RIGHT_BUTTON_PIN), button_irq_right, CHANGE);
#ifdef TEENSYDUINO
/* Initialize time library */
@@ -130,17 +157,25 @@ void loop() {
#ifdef TEENSYDUINO
time_t now = getTeensy3Time();
#else
int now = 42;
int now = 0;
#endif
int button_state = g_button_state;
if(button_state) {
g_button_state = 0;
if(button_state == 1)
Serial.println("BUTTON_SHRT_PRESS");
else
Serial.println("BUTTON_LONG_PRESS");
if(button_state & 1)
Serial.println("LEFT_SHRT_PRESS");
else if(button_state & 2)
Serial.println("LEFT_LONG_PRESS");
else if(button_state & 4)
Serial.println("LEFT_HOLD_PRESS");
if(button_state & 16)
Serial.println("RGHT_SHRT_PRESS");
else if(button_state & 32)
Serial.println("RGHT_LONG_PRESS");
else if(button_state & 64)
Serial.println("RGHT_HOLD_PRESS");
}
if(reboot_at && now >= reboot_at) {
@@ -238,6 +273,12 @@ void loop() {
show_time = !show_time; /* toggle */
else
show_time = val;
/* Clear display */
for(int i = 0; i < NUM_LEDS; i++)
leds[i].setRGB(0,0,0);
FastLED.show();
Serial.printf("Automatic rendering of current time: %d\n", show_time);
state = FUNC_RESET;
break;

551
README.md
View File

@@ -3,85 +3,96 @@
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
- [Animated LED matrix display](#animated-led-matrix-display)
- [Running the Python scripts](#running-the-python-scripts)
- [Building and deploying the MCU](#building-and-deploying-the-mcu)
- [Arduino](#arduino)
- [MicroPython](#micropython)
- [Configuring the Raspberry Pi](#configuring-the-raspberry-pi)
- [Optional steps](#optional-steps)
- [Arduino](#arduino)
- [Hardware](#hardware)
- [LED matrix display](#led-matrix-display)
- [MCUs](#mcus)
- [Teensy 3.1/3.2 pinout](#teensy-3132-pinout)
- [WiPy 3.0 pinout](#wipy-30-pinout)
- [Teensy 3.1/3.2 pinout](#teensy-3132-pinout)
- [Raspberry Pi](#raspberry-pi)
- [On the serial protocol](#on-the-serial-protocol)
- [Hacking](#hacking)
- [Wiring things up](#wiring-things-up)
- [LED matrix](#led-matrix)
- [Button](#button)
- [Connecting second UART on Pycom module to Raspberry Pi](#connecting-second-uart-on-pycom-module-to-raspberry-pi)
- [Remote power management for Raspberry Pi](#remote-power-management-for-raspberry-pi)
- [Hacking](#hacking)
- [On the serial protocol](#on-the-serial-protocol)
- [Running the Python scripts](#running-the-python-scripts)
- [Configuring the Raspberry Pi](#configuring-the-raspberry-pi)
- [Optional steps](#optional-steps)
- [Credits](#credits)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
# Animated LED matrix display
This is a project to drive a 8x32 (or 8x8) LED matrix based on the popular WS2812 RGB LEDs using a microcontroller and optionally control them both using a more powerful host computer, such as a Raspberry Pi Zero W.
This is a project to drive a 32x8 or 16x16 LED matrix based on the popular WS2812 RGB LEDs using a microcontroller running [MicroPython](https://micropython.org). There is experimental support for allowing a more powerful host computer (e.g. a Raspberry Pi Zero W) to remotely control a microcontroller without WiFi (e.g. a Teensy 3.x) and the display connected to it over USB serial. Low FPS video of a standalone Pycom LoPy 1 development board cycling through the scenes:
The microcontroller is used to provide the accurate timing signals needed to control the WS2812 LEDs. The microcontroller exposes a custom API over its USB serial interface to allow a more powerful computer to treat the display as a framebuffer connected over a serial link.
Currently this microcontroller can be either a Teensy 3.1/3.2 or one of [Pycom](https://www.pycom.io)'s development boards running [MicroPython](https://micropython.org) (e.g. WiPy 3.0). For the Teensy, an Arduino [sketch](arduino/ArduinoSer2FastLED.ino) is provided which controls the WS2812 LEDs using the FastLED library and implements the serial protocol expected by the host software. For boards running MicroPython a corresponding Python [implementation](pycomhal.py) is provided.
The host software is written in Python to allow for rapid development.
Features:
- button input(s)
- clock (short-press button to toggle between time and date)
- weather
- random animations
![LED matrix animated](docs/lamatrix.gif)
Static picture with clock scene. For some reason the colors aren't captured as vidvid as they are in real life.
![LED matrix with clock scene](docs/lamatrix.jpg)
Features:
## Running the Python scripts
- clock (time, date and weekday)
- weather
- random animations
- button inputs
- [config](config.json) file in JSON
- multiple WiFi networks can be configured
For Debian/Ubuntu derived Linux systems, try:
```bash
sudo apt install -y python-requests python-serial
```
On macOS, install pyserial (macOS already ships the requests module):
```bash
sudo -H easy_install serial
```
NOTE: There are known issues with hardware flow control and the driver/chip used in the Pycom modules (e.g. WiPy, LoPy). This causes the host side scripts to overwhelm the microcontroller with data. There do not appear to be any such issues with the Teensy on macOS. There are no known issues with either module with recent Linux distributions, including Raspbian.
Connect the LED matrix to the microcontroller and then connect the microcontroller to your computer (via USB). You should now be able to command the microcontroller to drive the display with:
```bash
python main.py
```
NOTES:
- The animation scene expects animated icons from a third-party source. See the [animations/README.md](animations/README.md) for details on how to download them.
- The weather scene expects animated icons from a third-party source. See the [weather/README.md](weather/README.md) for details on how to download them.
Primary development has been made on [Pycom](https://www.pycom.io)'s development boards, including the (obsolete) LoPy 1 and the newer WiPy 3. There is also an Arduino [sketch](ArduinoSer2FastLED/ArduinoSer2FastLED.ino) for Teensy 3.1/3.2 boards that implements a custom serial protocol that is spoken by the host software ([main.py](main.py) and [arduinoserialhal.py](arduinoserialhal.py)) that allows the LED matrix to be remotely controlled.
## Building and deploying the MCU
The host-side Python code expects to be able to talk to the microcontroller over a serial protocol (running on top of USB serial). Software that speaks this special protocol and can talk to the LED matrix needs to be loaded onto the microcontroller.
### MicroPython
[Connect](https://docs.pycom.io/gettingstarted/connection/) your Pycom module to your computer via USB (or 3.3v serial). Open a serial connection to the module and [configure WiFi](https://docs.pycom.io/tutorials/all/wlan.html) in the REPL like this:
from network import WLAN
wlan = WLAN(mode=WLAN.STA)
wlan.connect('yourSSID', auth=(WLAN.WPA2, 'yourPassword'))
Connect to the Pycom module's [native FTP server](https://docs.pycom.io/gettingstarted/programming/ftp.html) and login with `micro` / `python`.
Update [config.json](config.json) with your wireless SSID and password.
Upload the following files to `/flash`:
- [config.json](config.json)
- [main.py](main.py)
Upload the following files to `/flash/lib`:
- [animationscene.py](animationscene.py)
- [bootscene.py](bootscene.py)
- [clockscene.py](clockscene.py)
- [demoscene.py](demoscene.py)
- [firescene.py](firescene.py)
- [weatherscene.py](weatherscene.py)
- [icon.py](icon.py)
- [ledmatrix.py](ledmatrix.py)
- [pycomhal.py](pycomhal.py)
- [renderloop.py](renderloop.py)
- [urequests.py](urequests.py) (needed by `weatherscene.py`)
- [ws2812.py](ws2812.py) (needed by `pycomhal.py`)
Create a new directory under `/flash/icons` and upload any animation icons referenced in [config.json](config.json) (see [icons/README.md](icons/README.md) for details).
Create a new directory under `/flash/weather` and upload animated weather icons (see [weather/README.md](weather/README.md) for details).
Next, you'll want to read [Wiring things up](#wiring-things-up).
### Arduino
The host-side Python code expects to be able to talk to the microcontroller over a serial protocol (running on top of USB serial). Software that speaks this special protocol and can talk to the LED matrix needs to be loaded onto the microcontroller.
Assuming you have an MCU which is supported by Arduino, try:
1. Download the latest version of Arduino
@@ -96,101 +107,6 @@ Assuming you have an MCU which is supported by Arduino, try:
8. Upload the newly built sketch to the MCU via the menu entry _Sketch > Upload_
### MicroPython
[Connect](https://docs.pycom.io/gettingstarted/connection/) your Pycom module to your computer via USB (or 3.3v serial). Open a serial connection to the module and [configure WiFi](https://docs.pycom.io/tutorials/all/wlan.html) in the REPL like this:
from network import WLAN
wlan = WLAN(mode=WLAN.STA)
wlan.connect('yourSSID', auth=(WLAN.WPA2, 'yourPassword'))
Connect to the Pycom module's [native FTP server](https://docs.pycom.io/gettingstarted/programming/ftp.html) and login with `micro` / `python`.
Upload the following files to `/flash`:
- [main.py](main.py)
Upload the following files to `/flash/lib`:
- [clockscene.py](clockscene.py)
- [weatherscene.py](weatherscene.py)
- [ledmatrix.py](ledmatrix.py)
- [pycomhal.py](pycomhal.py)
- [urequests.py](urequests.py) (needed by `weatherscene.py`)
- [ws2812.py](ws2812.py)
Create a new directory under `/flash/animations` and upload any animation icons referenced in [config.json](config.json) (see [animations/README.md](animations/README.md) for details).
Create a new directory under `/flash/weather` and upload animated weather icons (see [weather/README.md](weather/README.md) for details).
If you haven't already, you probably want to read [Wiring things up](#wiring-things-up).
## Configuring the Raspberry Pi
If you want to run the Python scripts, install the necessary Python packages:
```bash
sudo apt install -y python-requests python-serial
# On Raspberry Pi Zero importing the Python `requests` package takes 20+ seconds
# if the python-openssl package is installed (default on Raspbian).
# https://github.com/requests/requests/issues/4278
#
# Uninstall it to speed up loading of `weatherscene.py`
sudo apt purge -y python-openssl
```
Install the support files:
- copy [gpio-shutdown.service](raspberry-pi/gpio-shutdown.service) into `/etc/systemd/system/`
- copy [lamatrix.service](raspberry-pi/lamatrix.service) into `/etc/systemd/system/`
Assuming you've cloned this repo at `/home/pi/lamatrix`, proceed as follows:
```bash
sudo apt install -y python-requests python-serial
cd ~/lamatrix
# Download animated weather icons (refer to weather/README.md)
curl -o weather/weather-cloud-partly.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2286
curl -o weather/weather-cloudy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=12019
curl -o weather/weather-moon-stars.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16310
curl -o weather/weather-rain-snow.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=160
curl -o weather/weather-rain.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=72
curl -o weather/weather-snow-house.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=7075
curl -o weather/weather-snowy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2289
curl -o weather/weather-thunderstorm.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=11428
# Install and start services
chmod +x main.py raspberry-pi/gpio-shutdown.py
sudo cp raspberry-pi/gpio-shutdown.service /etc/systemd/system
sudo cp raspberry-pi/lamatrix.service /etc/systemd/system
sudo systemctl daemon-reload
sudo systemctl enable gpio-shutdown.service lamatrix.service
sudo systemctl start gpio-shutdown.service lamatrix.service
```
NOTE: If you're not running under the `pi` user or have placed the files somewhere else than `/home/pi/lamatrix` you will have to update the `ExecPath=`, `User=` and `Group=` attributes in the `.service` files accordingly.
Your Raspberry Pi will now poweroff when board pin number 5 (a.k.a BCM 3 a.k.a SCL) goes LOW (e.g. is temporarily tied to ground). The shutdown process takes 10-15 seconds. The Pi can be powered up by again temporarily tying the pin to ground again.
To actually make use of the remote shutdown and reboot feature you need to physically wire the microcontroller to the Raspberry Pi. Connect the microcontroller's `GND` (ground) to one of the `GND` pins on the Raspberry Pi. Connect pin 14 (see `HOST_SHUTDOWN_PIN` in [ArduinoSer2FastLED.ino](arduino/ArduinoSer2FastLED.ino)) on the microcontroller to the Raspberry Pi's [BCM 3 a.k.a SCL](https://pinout.xyz/pinout/i2c).
### Optional steps
If you're running a headless Raspberry Pi you can reduce the boot time by a few seconds with:
```bash
sudo apt-get purge -y nfs-common libnfsidmap2 libtirpc1 rpcbind python-openssl
grep -q boot_delay /boot/config.txt || echo boot_delay=0 |sudo tee -a /boot/config.txt
sudo systemctl disable dphys-swapfile exim4 keyboard-setup raspi-config rsyslog
```
(the `python-openssl` package slows down the import of the `python-requests` package: https://github.com/requests/requests/issues/4278)
## Hardware
### LED matrix display
@@ -204,35 +120,28 @@ Price: €35-45
For the 8x32 variant, you can 3D print a frame from these objects:
- [32x8 LED Matrix grid for diffuser](https://www.thingiverse.com/thing:1903744)
- [8x32-ledmatrix-back.scad](8x32-ledmatrix-back.scad) (customize it in [OpenSCAD](https://www.openscad.org/downloads.html), hit Render (F6) and export it as STL)
- [3d-parts/lamatrix.scad](3d-parts/lamatrix.scad) (customize it in [OpenSCAD](https://www.openscad.org/downloads.html), hit Render (F6) and export it as STL)
For diffusing the light emitted by the LEDS a paper works suprisingly well if it's tightly held to the grid.
### MCUs
- [https://www.pjrc.com/teensy/](Teensy) (for Teensy 3.1/3.2, solder a 32.768kHz crystal to allow for [time-keeping via a battery](https://www.pjrc.com/teensy/td_libs_Time.html))
- Teensy 3.2: ARM Cortex-M4 72MHz, 64kBytes SRAM, 256kBytes flash, RTC (requires 32kHz crystal and a 3V battery)
- Price: €20-25
- Specs and pinout: https://www.pjrc.com/teensy/teensyLC.html
- [WiPy 3.0](https://pycom.io/product/wipy-3-0/) and an [Expansion Board 3.0](https://pycom.io/product/expansion-board-3-0/) for easy programming via USB
- ESP-32 platform, 520kBytes SRAM + 4MBytes (external) pSRAM, 8MBytes flash, 802.11b/g/n 16Mbps WiFi
- Price: €20-25
- Docs and pinout: https://docs.pycom.io/datasheets/development/wipy3
- Expansion Board docs: https://docs.pycom.io/datasheets/boards/expansion3
- Expansion Board (€20-25) docs: https://docs.pycom.io/datasheets/boards/expansion3
- [https://www.pjrc.com/teensy/](Teensy) (for Teensy 3.1/3.2, solder a 32.768kHz crystal to allow for [time-keeping via a battery](https://www.pjrc.com/teensy/td_libs_Time.html))
- Teensy 3.2: ARM Cortex-M4 72MHz, 64kBytes SRAM, 256kBytes flash, RTC (requires 32kHz crystal and a 3V battery)
- Price: €20-25
- Specs and pinout: https://www.pjrc.com/teensy/teensyLC.html
Both alternatives support both Arduino and MicroPython.
NOTE: it seems that hardware flow control between pyserial and the Pycom modules (e.g. WiPy, LoPy) doesn't work properly for some reason. This results in the host overwhelming the microcontroller with data, leading to data loss in the serial protocol which in turn messes up what is displayed on the LED matrix. The Teensy 3.x boards work without problems however.
#### Teensy 3.1/3.2 pinout
![Teensy 3.1/3.2 pinout](docs/teensy31_front_pinout.png)
Source: https://www.pjrc.com/teensy/teensyLC.html
#### WiPy 3.0 pinout
![WiPy 3.0 pinout](docs/wipy3-pinout.png)
@@ -240,6 +149,13 @@ Source: https://www.pjrc.com/teensy/teensyLC.html
Source: https://docs.pycom.io/datasheets/development/wipy3.html
#### Teensy 3.1/3.2 pinout
![Teensy 3.1/3.2 pinout](docs/teensy31_front_pinout.png)
Source: https://www.pjrc.com/teensy/teensyLC.html
### Raspberry Pi
Newer Raspberry Pi computers have a non-populated RUN pin (marked with a square) that, if tied to ground, will reset the Pi's CPU. See this answer on [What are the RUN pin holes on Raspberry Pi 2?](https://raspberrypi.stackexchange.com/questions/29339/what-are-the-run-pin-holes-on-raspberry-pi-2/33945#33945).
@@ -270,118 +186,6 @@ call(["/bin/systemctl","poweroff","--force"])
```
## On the serial protocol
Because of the limited amount of memory available on MCU it was decided to use a more powerful computer to render things on the LED matrix display. The most natural way of connecting a MCU and a host computer is to use the serial interface available on many popular MCUs, and thus a serial protocol was born.
To add new functionality to the serial protocol, ensure that you make the necessary updates in:
- the MCU implementation on the Arduino side, in and around `loop()`
- the MCU implementation on the MicroPython side, in `pycomhal.py`
- the host computer implementation, in `arduinoserialhal.py`
## Hacking
In short:
- on the host-side, everything starts in [main.py](main.py)
- somewhat confusingly this file is also the entrypoint for microcontrollers running MicroPython
- `main.py` has a `RenderLoop` which consumes _scenes_ (e.g. a [clock](clockscene.py) scene, a [weather](weatherscene.py) scene, ..)
- a framebuffer wrapper around the LED matrix display is in [ledmatrix.py](ledmatrix.py)
- this is used primarily for the host-side but is also used on microcontrollers running MicroPython
- the framebuffer wrapper does low-level display operations via a HAL (hardware abstraction layer)
- on the host-side this is implemented in [arduinoserialhal.py](arduinoserialhal.py)
- on the host-side this file pretty much opens a serial port and speaks a custom protocol to command the microcontroller to do things
- on the microcontroller-side the custom protocol is implemented in:
- [ArduinoSer2FastLED.ino](arduino/ArduinoSer2FastLED.ino) for devices running Arduino
- [pycomhal.py](pycomhal.py) for Pycom devices running MicroPython
To add a new scene, create a Python module (e.g. `demoscene.py`) like this:
```python
#!/usr/bin/env python
class DemoScene:
"""This module implements an example scene with a traveling pixel"""
def __init__(self, display, config):
"""
Initialize the module.
`display` is saved as an instance variable because it is needed to
update the display via self.display.put_pixel() and .render()
"""
self.display = display
self.x_pos = 0 # ..just an example
print('DemoScene: yay, initialized')
def reset(self):
"""
This method is called before transitioning to this scene.
Use it to (re-)initialize any state necessary for your scene.
"""
self.x_pos = 0
print('DemoScene: here we go')
def input(self, button_id, button_state):
"""
Handle button input
"""
print('DemoScene: button {} pressed: {}'.format(button_id, button_state))
return False # signal that we did not handle the input
def render(self, frame, dropped_frames, fps):
"""
Render the scene.
This method is called by the render loop with the current frame number,
the number of dropped frames since the previous invocation and the
requested frames per second (FPS).
"""
time_in_seconds = frame * fps
if not time_in_seconds.is_integer():
# Only update pixel once every second
return True
y = 3
color = 64
self.display.clear()
self.display.put_pixel(self.x_pos, y, color, color, color >> 1)
self.display.render()
print('DemoScene: rendered a pixel at ({},{})'.format(self.x_pos, y))
self.x_pos += 1
if self.x_pos == self.display.columns:
return False # our work is done!
return True # we want to be called again
if __name__ == '__main__':
display = None
config = None
scene = DemoScene(display, config)
scene.reset()
```
Then open [main.py](main.py) and locate the following line:
```python
r = RenderLoop(display, fps=10)
```
Below it, create an instance of your module and call `RenderLoop.add_scene()` to add it to the list of scenes. If your module is named `demoscene.py` and implements the `DemoScene` class it should look something like this:
```python
from demoscene import DemoScene
scene = DemoScene(display, config['DemoScene'])
r.add_scene(scene)
```
You should also add a `"DemoScene": {},` block to the config file `config.json`. Store any settings your scene needs here.
With these steps completed, the scene's `render()` method should now eventually be called when you run the host-side software (e.g. `python main.py`). The method should return `True` until you're ready to hand over control to the next scene, in which case you signal this by returning `False`.
## Wiring things up
To the extent possible I've attempted to choose the same set of board pins on both MCUs (microcontrollers).
@@ -404,10 +208,12 @@ Connect the display like this:
### Button
Connect the button like this:
Connect the buttons like this:
Button pin 1 --> MCU: digital pin 12 on Teensy; P12 (a.k.a. GPIO21) pin on Pycom module
Button pin 2 --> MCU: GND pin
Left button pin 1 --> MCU: digital pin 9 on Teensy; P9 (a.k.a. GPIO21) pin on Pycom module
Left button pin 2 --> MCU: GND pin
Right button pin 1 --> MCU: digital pin 10 on Teensy; P10 (a.k.a. GPIO13) pin on Pycom module
Right button pin 2 --> MCU: GND pin
### Connecting second UART on Pycom module to Raspberry Pi
@@ -433,6 +239,213 @@ To let the MCU manage the Raspberry Pi's power, connect:
Raspberry Pi: board pin 6 (a.k.a GND) --> MCU: GND pin
## Hacking
In short:
- everything starts in [main.py](main.py)
- after initializing things, `main.py` hands over control to [renderloop.py](renderloop.py) which consumes _scenes_ (e.g. a [clock](clockscene.py) scene, a [weather](weatherscene.py) scene, ..)
- a framebuffer wrapper around the LED matrix display is in [ledmatrix.py](ledmatrix.py)
- the framebuffer wrapper does low-level display operations via a HAL (hardware abstraction layer)
- on the host-side this is implemented in [arduinoserialhal.py](arduinoserialhal.py)
- on the host-side this file pretty much opens a serial port and speaks a custom protocol to command the microcontroller to do things
- on the microcontroller-side the custom protocol is implemented in:
- [ArduinoSer2FastLED.ino](ArduinoSer2FastLED/ArduinoSer2FastLED.ino) for devices running Arduino
- [pycomhal.py](pycomhal.py) for Pycom devices running MicroPython
To add a new scene, create a Python module (e.g. `demoscene.py`) like this:
```python
from pixelfont import PixelFont
class DemoScene:
"""This module implements an example scene with a traveling pixel"""
def __init__(self, display, config):
"""
Initialize the module.
`display` is saved as an instance variable because it is needed to
update the display via self.display.put_pixel() and .render()
"""
self.display = display
self.intensity = 32
self.x_pos = 0
self.text = 'example'
if not config:
return
if 'intensity' in config:
self.intensity = int(round(config['intensity']*255))
def reset(self):
"""
This method is called before transitioning to this scene.
Use it to (re-)initialize any state necessary for your scene.
"""
self.x_pos = 0
print('DemoScene: here we go')
def input(self, button_state):
"""
Handle button input
"""
print('DemoScene: button state: {}'.format(button_state))
return 0 # signal that we did not handle the input
def set_intensity(self, value=None):
if value is not None:
self.intensity -= 1
if not self.intensity:
self.intensity = 16
return self.intensity
def render(self, frame, dropped_frames, fps):
"""
Render the scene.
This method is called by the render loop with the current frame number,
the number of dropped frames since the previous invocation and the
requested frames per second (FPS).
"""
if (frame % fps) == 0:
# Only update pixel once every second
return True
display = self.display
intensity = self.intensity
dot_x, dot_y = self.x_pos, 0
text_x, text_y = 2, 2
color = intensity
display.clear()
display.put_pixel(dot_x, dot_y, color, color, color >> 1)
display.render_text(PixelFont, self.text, text_x, text_y, self.intensity)
display.render()
self.x_pos += 1
if self.x_pos == display.columns:
return False # signal that our work is done
return True # we want to be called again
```
Then open [main.py](main.py) and locate the following line:
```python
r = RenderLoop(display, config)
```
Below it, create an instance of your module and call `RenderLoop.add_scene()` to add it to the list of scenes. If your module is named `demoscene.py` and implements the `DemoScene` class it should look something like this:
```python
if 'Demo' in config:
from demoscene import DemoScene
scene = DemoScene(display, config['Demo'])
r.add_scene(scene)
```
You should also add a `"Demo": {},` block to the config file `config.json`. Store any settings your scene needs here.
With these steps completed, the scene's `render()` method should now eventually be called when you run the host-side software (e.g. `python main.py`). The method should return `True` until you're ready to hand over control to the next scene, in which case you signal this by returning `False`.
### On the serial protocol
Because of the limited amount of memory available on MCU it was initially decided to use a more powerful computer to render things on the LED matrix display. The most natural way of connecting a MCU and a host computer is to use the serial interface available on many popular MCUs, and thus a serial protocol was born.
To add new functionality to the serial protocol, ensure that you make the necessary updates in:
- the MCU implementation on the Arduino side, in and around `loop()`
- the MCU implementation on the MicroPython side, in `pycomhal.py`
- the host computer implementation, in `arduinoserialhal.py`
## Running the Python scripts
For Debian/Ubuntu derived Linux systems, try:
```bash
sudo apt install -y python-requests python-serial
```
On macOS, install pyserial (macOS already ships the requests module):
```bash
sudo -H easy_install serial
```
NOTE: There are known issues with hardware flow control and the driver/chip used in the Pycom modules (e.g. WiPy, LoPy). This causes the host side scripts to overwhelm the microcontroller with data. There do not appear to be any such issues with the Teensy on macOS. There are no known issues with either module with recent Linux distributions, including Raspbian.
Connect the LED matrix to the microcontroller and then connect the microcontroller to your computer (via USB). You should now be able to command the microcontroller to drive the display with:
```bash
python main.py
```
NOTES:
- The animation scene expects animated icons from a third-party source. See the [icons/README.md](icons/README.md) for details on how to download them.
- The weather scene expects animated icons from a third-party source. See the [weather/README.md](weather/README.md) for details on how to download them.
## Configuring the Raspberry Pi
If you want to run the Python scripts on the Raspberry Pi, install the necessary Python packages:
```bash
sudo apt install -y python-requests python-serial
# On Raspberry Pi Zero importing the Python `requests` package takes 20+ seconds
# if the python-openssl package is installed (default on Raspbian).
# https://github.com/requests/requests/issues/4278
#
# Uninstall it to speed up loading of `weatherscene.py`
sudo apt purge -y python-openssl
```
Install the support files:
- copy [gpio-shutdown.service](raspberry-pi/gpio-shutdown.service) into `/etc/systemd/system/`
- copy [lamatrix.service](raspberry-pi/lamatrix.service) into `/etc/systemd/system/`
Assuming you've cloned this repo at `/home/pi/lamatrix`, proceed as follows:
```bash
sudo apt install -y python-requests python-serial
cd ~/lamatrix
mkdir -p icons weather
# Download animated icons per the instructions in icons/README.md
# Download animated weather icons per the instructions in icons/README.md
# Install and start services
chmod +x main.py raspberry-pi/gpio-shutdown.py
sudo cp raspberry-pi/gpio-shutdown.service /etc/systemd/system
sudo cp raspberry-pi/lamatrix.service /etc/systemd/system
sudo systemctl daemon-reload
sudo systemctl enable gpio-shutdown.service lamatrix.service
sudo systemctl start gpio-shutdown.service lamatrix.service
```
NOTE: If you're not running under the `pi` user or have placed the files somewhere else than `/home/pi/lamatrix` you will have to update the `ExecPath=`, `User=` and `Group=` attributes in the `.service` files accordingly.
Your Raspberry Pi will now poweroff when board pin number 5 (a.k.a BCM 3 a.k.a SCL) goes LOW (e.g. is temporarily tied to ground). The shutdown process takes 10-15 seconds. The Pi can be powered up by again temporarily tying the pin to ground again.
To actually make use of the remote shutdown and reboot feature you need to physically wire the microcontroller to the Raspberry Pi. Connect the microcontroller's `GND` (ground) to one of the `GND` pins on the Raspberry Pi. Connect pin 14 (see `HOST_SHUTDOWN_PIN` in [ArduinoSer2FastLED.ino](ArduinoSer2FastLED/ArduinoSer2FastLED.ino)) on the microcontroller to the Raspberry Pi's [BCM 3 a.k.a SCL](https://pinout.xyz/pinout/i2c).
### Optional steps
If you're running a headless Raspberry Pi you can reduce the boot time by a few seconds with:
```bash
sudo apt-get purge -y nfs-common libnfsidmap2 libtirpc1 rpcbind python-openssl
grep -q boot_delay /boot/config.txt || echo boot_delay=0 |sudo tee -a /boot/config.txt
sudo systemctl disable dphys-swapfile exim4 keyboard-setup raspi-config rsyslog
```
(the `python-openssl` package slows down the import of the `python-requests` package: https://github.com/requests/requests/issues/4278)
# Credits
Several animations in the form of `.json` files were backed up from LaMetric's developer API. Credit goes to the original authors of these animations.

View File

@@ -1,16 +0,0 @@
Assuming you're in the directory where you cloned this Git repository (i.e. one level up from here), try:
```bash
curl -o animations/game-brick.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=1524
curl -o animations/game-invaders-1.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=3405
curl -o animations/game-invaders-2.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=3407
curl -o animations/game-nintendo.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=5038
curl -o animations/game-pacman-ghosts.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=20117
curl -o animations/game-pingpong.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=4075
curl -o animations/game-snake.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16036
curl -o animations/matrix.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=653
curl -o animations/newyears.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=9356
curl -o animations/tv-movie.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=7862
```
You might want to update `AnimationScene.filenames` in [config.json](../config.json) to make use of the animations.

View File

@@ -1,140 +1,161 @@
#!/usr/bin/env python
# Render a box with up to three animations
#
import time
import json
from icon import Icon
class AnimationScene:
"""Render animations from https://developer.lametric.com"""
def __init__(self, display, config):
self.name = 'Animation'
self.display = display
self.objs = []
self.obj_i = 0
self.debug = False
self.intensity = 16
self.icons = []
self.icon_id = 0
self.states = []
self.on_screen_objs = []
if config and 'files' in config:
for filename in config['files']:
self.add_obj(filename)
def add_obj(self, filename):
# This method expects an animation as downloaded from LaMetric's developer site
#
# Example:
# curl -sA '' https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=4007 > blah.json
#
# To get an index of available animations, try:
# curl -sA '' 'https://developer.lametric.com/api/v1/dev/preloadicons?page=1&category=popular&search=&count=5000' | tee popular-24k-p1.json
#
with open(filename) as f:
data = f.read()
obj = json.loads(data)
obj = json.loads(obj['body'])
self.objs.append(obj)
self.on_screen_icons = []
if not config:
return
if 'debug' in config:
self.debug = config['debug']
if 'intensity' in config:
self.intensity = int(round(config['intensity']*255))
if 'icons' in config:
for filename in config['icons']:
self.add_icon(filename)
self.set_intensity(self.intensity)
def reset(self):
print('Animation: reset called, loading animation objects')
while self.load_obj():
while self.load_icon():
pass
def input(self, button_id, button_state):
def set_intensity(self, value=None):
if value is not None:
self.intensity -= 1
if not self.intensity:
self.intensity = 16
for i in self.icons:
i.set_intensity(self.intensity)
return self.intensity
def input(self, button_state):
"""
Handle button input
"""
print('Animation: button {} pressed: {}'.format(button_id, button_state))
return False # signal that we did not handle the input
def load_obj(self):
"""
Load object into first available slot
"""
cols = bytearray(' ' * 32)
obj_width = 8
padding = 1
for state in self.on_screen_objs:
obj_x = state['x_pos']
cols[obj_x:obj_x+obj_width] = ('x'*obj_width).encode()
x = cols.find(' ' * (obj_width + padding))
if x < 0:
# no available space
print('Animation: not enough columns to add another object')
return False
if not x:
# center
x += 3
else:
# left-pad next animation
x += padding
obj = self.objs[self.obj_i]
num_frames = len(obj['icons'])
state = {
'i': self.obj_i, # for unloading the object
'x_pos': x,
'frames': obj['icons'],
'frame_delay_ms': obj['delays'],
'num_frames': num_frames, # cached for convenience
'remaining_frames': 2*num_frames, # keep track of the currently rendered frame
'next_frame_at': 0 # for handling delays
}
self.on_screen_objs.append(state)
print('Animation: loaded object {} at column {}'.format(self.obj_i, x))
self.obj_i = (self.obj_i + 1) % len(self.objs)
return True
def unload_obj(self, i):
display = self.display
for state in self.on_screen_objs:
if state['i'] == i:
height = len(state['frames'][0])
width = len(state['frames'][0][0])
x_pos = state['x_pos']
for y in range(height):
for x in range(width):
display.put_pixel(x_pos+x, y, 0, 0, 0)
self.on_screen_objs.remove(state)
print('Animation: unloaded object {} from column {}'.format(i, x_pos))
return
return 0 # signal that we did not handle the input
def render(self, frame, dropped_frames, fps):
t0 = time.time()
display = self.display
intensity = self.intensity
unload_queue = []
for state in self.on_screen_objs:
for state in self.on_screen_icons:
if frame < state['next_frame_at']:
continue
state['remaining_frames'] -= 1
if state['remaining_frames'] == 0:
# Queue object for removal
# Queue icon for removal from screen
unload_queue.append(state['i'])
n = state['num_frames']
index = n - (state['remaining_frames'] % n) - 1
data = state['frames'][index]
x_pos = state['x_pos']
for y in range(len(data)):
row = data[y]
for x in range(len(row)):
r = round(row[x][0] * 255)
g = round(row[x][1] * 255)
b = round(row[x][2] * 255)
display.put_pixel(x_pos+x, y, r, g, b)
# Do not repaint until some spe
state['next_frame_at'] = frame + int(fps * state['frame_delay_ms'][index] / 1000)
print('AnimationScene: obj {}: queueing repaint at frame {}+{}=={}, fps {}, delay {}'.format(state['i'], frame, int(fps * state['frame_delay_ms'][index] / 1000), state['next_frame_at'], fps, state['frame_delay_ms'][index]))
t1 = time.time() - t0
y_pos = state['y_pos']
icon = self.icons[state['i']]
# Do not repaint until some specified time in the future
state['next_frame_at'] = frame + int(fps * icon.frame_length() / 1000)
# Render icon
icon.blit(self.display, x_pos, y_pos)
t2 = time.time()
t1 = t2 - t0
display.render()
t3 = time.time() - t2
print('AnimationScene: Spent {}ms plotting objects, {}ms updating LedMatrix+HAL, {}ms total'.format(round(1000*t1), round(1000*t2), round(1000*(time.time()-t0))))
t3 = time.time()
t2 = t3 - t2
if self.debug:
print('AnimationScene: Spent {}ms plotting icons, {}ms updating LedMatrix+HAL, {}ms total'.format(round(t1*1000.0), round(t2*1000.0), round((t3-t0)*1000.0)))
for i in unload_queue:
self.unload_obj(i)
self.unload_icon(i)
if not self.on_screen_objs:
# Nothing more to display
if not self.on_screen_icons:
return False # Nothing more to display
return True # We still have icons left to render
def add_icon(self, filename):
"""
See animations/README.md for details
"""
icon = Icon(filename)
self.icons.append(icon)
def load_icon(self):
"""
Load icon into first available slot
"""
cols = bytearray(' ' * 32)
icon_width = 8
padding = 1 if self.display.columns == 32 else 0
for state in self.on_screen_icons:
icon_x = state['x_pos'] + (state['y_pos']<<1)
cols[icon_x:icon_x+icon_width] = ('x'*icon_width).encode()
x = 0
space = ord(' ')
need = icon_width+padding
for i in range(32):
if cols[i] != space:
x = i+1
elif i+1 == x+need:
break
if i+1 != x+need:
# no available space
return False
# We still have objects left to render
if not x:
# center for 32x8 displays
x += 3 if self.display.columns == 32 else 0
else:
# left-pad next icon
x += padding
icon = self.icons[self.icon_id]
num_frames = icon.frame_count()
state = {
'i': self.icon_id, # for unloading the icon
'x_pos': x if self.display.columns == 32 else x & 0xf,
'y_pos': 0 if self.display.columns == 32 else (x >> 4) << 3,
'num_frames': num_frames, # cached for convenience
'remaining_frames': num_frames, # keep track of the currently rendered frame
'next_frame_at': 0 # for handling delays
}
# Ensure a minimum display time
t_icon = icon.length_total()
for i in range(1,6):
if t_icon*i >= 4000:
break
state['remaining_frames'] += num_frames
self.on_screen_icons.append(state)
if self.debug:
print('Animation: loaded icon {} at ({}, {})'.format(self.icon_id, state['x_pos'], state['y_pos']))
self.icon_id = (self.icon_id + 1) % len(self.icons)
return True
def unload_icon(self, i):
display = self.display
for state in self.on_screen_icons:
if state['i'] == i:
icon = self.icons[i]
height = icon.rows
width = icon.cols
x_pos = state['x_pos']
y_pos = state['y_pos']
for y in range(height):
for x in range(width):
display.put_pixel(x_pos+x, y_pos+y, 0, 0, 0)
self.on_screen_icons.remove(state)
return

View File

@@ -7,6 +7,7 @@
# or under MicroPython.
#
import serial
import time
class ArduinoSerialHAL:
"""
@@ -17,12 +18,15 @@ class ArduinoSerialHAL:
def __init__(self, config):
self.port = config['port']
self.baudrate = config['baudrate']
self.tz_adjust = config['tzOffsetSeconds']
self.ser = None # initialized in reset()
self.reset()
def readline(self):
def process_input(self):
"""
Read output from MCU over the serial link
Process data coming from the MCU over the serial link, such as any
captured button presses by the firmware or log messages, and return
it as input data to the caller (the main game loop)
"""
if not self.ser.in_waiting:
return None
@@ -39,6 +43,7 @@ class ArduinoSerialHAL:
print('SerialProtocol: opening port {} @ {} baud'.format(self.port, self.baudrate))
self.ser = serial.Serial(self.port, baudrate=self.baudrate, rtscts=True, timeout=0.1, write_timeout=0.5)
self.resynchronize_protocol()
self.set_rtc(int(time.time()) + self.tz_adjust)
def resynchronize_protocol(self):
"""

70
bootscene.py Executable file
View File

@@ -0,0 +1,70 @@
from network import WLAN
from machine import RTC
from pixelfont import PixelFont
class BootScene:
"""
This module implements a boot scene for Pycom modules
"""
def __init__(self, display, config):
"""
Initialize the module.
`display` is saved as an instance variable because it is needed to
update the display via self.display.put_pixel() and .render()
"""
self.display = display
self.debug = False
self.intensity = 16
self.rtc = RTC()
self.wlan = WLAN()
if not config:
return
if 'debug' in config:
self.debug = config['debug']
if 'intensity' in config:
self.intensity = int(round(config['intensity']*255))
def reset(self):
"""
This method is called before transitioning to this scene.
Use it to (re-)initialize any state necessary for your scene.
"""
pass
def set_intensity(self, value=None):
if value is not None:
self.intensity -= 1
if not self.intensity:
self.intensity = 16
return self.intensity
def input(self, button_state):
"""
Handle button input
"""
return 0 # signal that we did not handle the input
def render(self, frame, dropped_frames, fps):
"""
Render the scene.
This method is called by the render loop with the current frame number,
the number of dropped frames since the previous invocation and the
requested frames per second (FPS).
"""
dots = str('.' * ((frame % 3) + 1))
if not self.wlan.isconnected():
if not frame:
dots = '?'
text = 'wifi{}'.format(dots)
elif not self.rtc.synced():
text = 'clock{}'.format(dots)
else:
text = 'loading'
display = self.display
intensity = self.intensity
display.render_text(PixelFont, text, 1, 1, intensity)
display.render()
return True

View File

@@ -1,5 +1,3 @@
#!/usr/bin/env python
#
# This file implements a simple clock scene displaying the current time.
#
# While it is primarily designed to be run on the host computer, an MCU
@@ -10,138 +8,98 @@ import time
if not hasattr(time, 'ticks_ms'):
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time() * 1000)
time.sleep_ms = lambda x: time.sleep(x/1000.0)
# Local imports
from pixelfont import PixelFont
class ClockScene:
def __init__(self, display, config):
self.name = 'Clock'
self.display = display
self.font = PixelFont()
self.button_state = 0
# delete me
self.x_pos = 4
self.y_pos = 0
self.x_vel = 1
self.y_vel = 1
self.step = 0
self.debug = False
self.intensity = 16
self.date_was_shown = False
self.columns = display.columns
if not config:
return
if 'debug' in config:
self.debug = config['debug']
if 'intensity' in config:
self.intensity = int(round(config['intensity']*255))
def reset(self):
"""
Unused in this scene
"""
pass
def input(self, button_id, button_state):
"""
Handle button input
"""
if button_state == 1:
def input(self, button_state):
if button_state & 0x22:
# Handle long-press on either button
self.button_state ^= 1
elif button_state == 2:
return False
return True # signal that we handled the button
self.display.clear()
return button_state & ~0x22
return 0 # signal that we did not handle the button press
def set_intensity(self, value=None):
if value is not None:
self.intensity -= 1
if not self.intensity:
self.intensity = 16
return self.intensity
def render(self, frame, dropped_frames, fps):
"""
Render the current time and day of week
"""
t0 = time.ticks_ms()
# This takes 0ms
print('Rendering frame {} @ {}fps after a delay of {}s, {} dropped frames'.format(frame, fps, 1.*(1+dropped_frames)/fps, dropped_frames))
display = self.display
display.clear()
intensity = self.intensity
x_off = 0
y_off = 0
# Automatically switch to showing the date for a few secs
tmp = fps << 6
tmp = ((fps << 4) + frame) % tmp
y_off = 1
(year, month, day, hour, minute, second, weekday, _) = time.localtime()[:8]
if not self.button_state:
time_str = '{:02d}:{:02d}'.format(hour, minute)
if (int(time.ticks_ms() // 100.0) % 10) < 4:
time_str = time_str.replace(':', ' ')
x_off = 8
if not self.button_state and tmp > (fps<<2):
if self.date_was_shown:
display.clear()
self.date_was_shown = False
if self.columns == 32:
text = ' {:02d}:{:02d} '.format(hour, minute)
if (int(time.ticks_ms() // 100.0) % 10) < 4:
text = text.replace(':', ' ')
display.render_text(PixelFont, text, 2, y_off, intensity)
else:
text = '{:02d}'.format(hour)
display.render_text(PixelFont, text, 4, y_off, intensity)
text = '{:02d}'.format(minute)
display.render_text(PixelFont, text, 4, y_off+8, intensity)
else:
time_str = '{:02d}.{:02d}.{:02d}'.format(day, month, year % 100)
x_off = 2
if self.columns == 32:
text = '{:02d}.{:02d}.{:02d}'.format(day, month, year % 100)
display.render_text(PixelFont, text, 2, y_off, intensity)
else:
text = '{:02d}{:02d}'.format(day, month)
display.render_text(PixelFont, text, 0, y_off, intensity)
display.put_pixel(7, y_off+PixelFont.height, intensity, intensity, intensity)
text = '{:04d}'.format(year)
display.render_text(PixelFont, text, 0, y_off+8, intensity)
self.date_was_shown = True
t2 = time.ticks_ms()
alphabet = self.font.alphabet
font_data = self.font.data
font_height = self.font.height
font_width = self.font.width
for i in range(len(time_str)):
digit = time_str[i]
if digit in ':. ' or time_str[i-1] in ':. ':
# Kludge to compress rendering of colon
x_off -= 1
data_offset = alphabet.find(digit)
if data_offset < 0:
data_offset = 0
tmp = (data_offset * font_height) << 2 # optimization: multiply by font with
font_byte = tmp >> 3 # optimization: divide by number of bits
font_bit = tmp & 7 # optimization: modulo number of bits
for row in range(font_height):
for col in range(font_width):
val = 0
if font_data[font_byte] & (1 << font_bit):
val = 255
font_bit += 1
if font_bit == 8:
font_byte += 1
font_bit = 0
display.put_pixel(x_off+col, y_off+row, val, val, val)
# Per letter offset
x_off += 4
t2 = time.ticks_ms() - t2
if 0:
# Flare effect.. lame
print('Clock: kernel at {},{} to {},{}'.format(self.x_pos, self.y_pos, self.x_pos+1,self.y_pos+1))
for i in range(3):
y = self.y_pos+i
for j in range(6):
x = self.x_pos+j
colors = self.display.get_pixel_front(x, y)
if not sum(colors):
continue
if j in [0,1,4,5]:
c = colors[0]-24
else:
c = colors[0]+24
if c < 0:
c = 0
elif c > 255:
c = 255
self.display.put_pixel(x, y, c, c, 2*c//3)
if 1:
self.x_pos += self.x_vel
if self.x_pos < 1 or self.x_pos > 31-7:
self.x_vel *= -1
if (frame % 3) == 0:
self.y_pos += self.y_vel
if self.y_pos == 0 or self.y_pos >= 5:
self.y_vel *= -1
t3 = time.ticks_ms()
x_off = 2
x_off = 2 if self.columns == 32 else 1
lower_intensity = intensity // 3
for i in range(7):
color = 128 if i == weekday else 48
x = x_off + (i << 2)
display.put_pixel(x+0, 7, color, color, 2*color//5)
display.put_pixel(x+1, 7, color, color, 2*color//5)
display.put_pixel(x+2, 7, color, color, 2*color//5)
t3 = time.ticks_ms() - t3
color = intensity if i == weekday else lower_intensity
b = (color << 1) // 7
display.put_pixel(x_off, 7, color, color, b)
if self.columns == 32:
display.put_pixel(x_off+1, 7, color, color, b)
display.put_pixel(x_off+2, 7, color, color, b)
x_off += 4
else:
x_off += 2
t4 = time.ticks_ms()
display.render()
t4 = time.ticks_ms() - t4
print('ClockScene: Spent {}ms plotting time, {}ms plotting weekdays, {}ms updating LedMatrix+HAL, {}ms total'.format(t2, t3, t4, time.ticks_ms()-t0))
if self.button_state == 2:
self.button_state = 0

View File

@@ -1,22 +1,54 @@
{
"ssid":"yourWiFiSSID",
"password":"yourWiFiPassword",
"networks": [
{
"ssid":"yourWiFiSSID",
"password":"yourWiFiPassword"
},
{
"ssid":"otherWiFiSSID",
"password":"otherWiFiPassword"
}
],
"port": "/dev/ttyACM0",
"baudrate": 921600,
"baudrate": 115200,
"remapConsole": false,
"sceneTimeout": 40,
"tzOffsetSeconds": 3600,
"AnimationScene": {
"files": [
"animations/game-pingpong.json",
"animations/matrix.json",
"animations/newyears.json",
"animations/tv-movie.json"
"LedMatrix": {
"debug": false,
"columns": 32,
"stride": 8,
"fps": 10
},
"Boot": {
"intensity": 0.03
},
"Animation": {
"intensity": 0.05,
"debug": false,
"icons": [
"icons/game-tetris.bin",
"icons/game-pingpong.bin",
"icons/newyears.bin",
"icons/matrix.bin",
"icons/game-invaders-1.bin",
"icons/game-invaders-2.bin",
"icons/tv-movie.bin"
]
},
"ClockScene": {
"Clock": {
"intensity": 0.05,
"debug": true
},
"DemoScene": {
"disabledDemo": {
"intensity": 0.05
},
"WeatherScene": {
"Fire": {
"intensity": 0.05
},
"Weather": {
"intensity": 0.05,
"debug": true,
"lat": 59.3293,
"lon": 18.0686
}

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python
from pixelfont import PixelFont
class DemoScene:
"""This module implements an example scene with a traveling pixel"""
@@ -9,8 +10,13 @@ class DemoScene:
update the display via self.display.put_pixel() and .render()
"""
self.display = display
self.x_pos = 0 # ..just an example
print('DemoScene: yay, initialized')
self.intensity = 32
self.x_pos = 0
self.text = 'example'
if not config:
return
if 'intensity' in config:
self.intensity = int(round(config['intensity']*255))
def reset(self):
"""
@@ -20,12 +26,19 @@ class DemoScene:
self.x_pos = 0
print('DemoScene: here we go')
def input(self, button_id, button_state):
def input(self, button_state):
"""
Handle button input
"""
print('DemoScene: button {} pressed: {}'.format(button_id, button_state))
return False # signal that we did not handle the input
print('DemoScene: button state: {}'.format(button_state))
return 0 # signal that we did not handle the input
def set_intensity(self, value=None):
if value is not None:
self.intensity -= 1
if not self.intensity:
self.intensity = 16
return self.intensity
def render(self, frame, dropped_frames, fps):
"""
@@ -35,26 +48,23 @@ class DemoScene:
requested frames per second (FPS).
"""
time_in_seconds = frame * fps
if not time_in_seconds.is_integer():
if (frame % fps) == 0:
# Only update pixel once every second
return True
y = 3
color = 64
self.display.clear()
self.display.put_pixel(self.x_pos, y, color, color, color >> 1)
self.display.render()
print('DemoScene: rendered a pixel at ({},{})'.format(self.x_pos, y))
display = self.display
intensity = self.intensity
dot_x, dot_y = self.x_pos, 0
text_x, text_y = 2, 2
color = intensity
display.clear()
display.put_pixel(dot_x, dot_y, color, color, color >> 1)
display.render_text(PixelFont, self.text, text_x, text_y, self.intensity)
display.render()
self.x_pos += 1
if self.x_pos == self.display.columns:
return False # our work is done!
if self.x_pos == display.columns:
return False # signal that our work is done
return True # we want to be called again
if __name__ == '__main__':
display = None
config = None
scene = DemoScene(display, config)
scene.reset()

BIN
docs/lamatrix.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

83
firescene.py Executable file
View File

@@ -0,0 +1,83 @@
from uos import urandom
from pixelfont import PixelFont
class FireScene:
"""This module implements an example scene with a traveling pixel"""
def __init__(self, display, config):
"""
Initialize the module.
`display` is saved as an instance variable because it is needed to
update the display via self.display.put_pixel() and .render()
"""
self.display = display
self.intensity = 32
self.remaining_frames = self.display.fps<<2
if not config:
return
if 'intensity' in config:
self.intensity = int(round(config['intensity']*255))
def reset(self):
"""
This method is called before transitioning to this scene.
Use it to (re-)initialize any state necessary for your scene.
"""
self.remaining_frames = self.display.fps<<2
def input(self, button_state):
"""
Handle button input
"""
return 0 # signal that we did not handle the input
def set_intensity(self, value=None):
if value is not None:
self.intensity -= 1
if not self.intensity:
self.intensity = 16
return self.intensity
def render(self, frame, dropped_frames, fps):
"""
Render the scene.
This method is called by the render loop with the current frame number,
the number of dropped frames since the previous invocation and the
requested frames per second (FPS).
"""
display = self.display
get_pixel = display.get_pixel
put_pixel = display.put_pixel
intensity = self.intensity
width = display.columns
max_y = display.stride - 1
# Fire source
b = intensity >> 1
for x in range(display.columns):
put_pixel(x, max_y, intensity, intensity, b)
# Spread fire
for y in range(max_y):
for x in range(width):
# Cool previous pixel
r, g, b = display.get_pixel(x, y)
if r or g or b:
r -= 1
g -= 1
b >>= 1
put_pixel(x, y, max(r, 0), max(g, 0), b)
# Spread heat from below
r, g, b = get_pixel(x, y+1)
spread = (urandom(1)[0]&3) - 1
r -= spread
g -= 1
b >>= 2
put_pixel(x+spread, y, max(r, 0), max(g, 0), b)
display.render()
self.remaining_frames -= 1
if not self.remaining_frames:
return False
return True

67
icon.py Normal file
View File

@@ -0,0 +1,67 @@
try:
from ustruct import unpack_from
except ImportError:
from struct import unpack_from
class Icon:
def __init__(self, filename, intensity_bits=4):
self.f = open(filename, 'rb')
self.intensity_bits = intensity_bits
self.frame = 0
chunk = bytearray(4)
self.f.readinto(chunk)
self.num_frames = chunk[0]
self.rows = chunk[1]
self.cols = chunk[2]
self.buf = bytearray(self.rows*self.cols*3)
self.delays = [0] * self.num_frames
chunk = self.f.read(self.num_frames*2)
for i in range(self.num_frames):
self.delays[i] = unpack_from('!h', chunk, i*2)[0]
self.frame_offset = self.f.tell()
def frame_count(self):
return self.num_frames
def frame_length(self):
return self.delays[self.frame]
def length_total(self):
return sum(self.delays)
def reset(self):
self.frame = 0
self.f.seek(self.frame_offset)
def set_intensity(self, intensity):
if intensity >= 128:
self.intensity_bits = 7
elif intensity >= 64:
self.intensity_bits = 6
elif intensity >= 32:
self.intensity_bits = 5
elif intensity >= 16:
self.intensity_bits = 4
elif intensity >= 8:
self.intensity_bits = 3
elif intensity >= 4:
self.intensity_bits = 2
elif intensity >= 2:
self.intensity_bits = 1
def blit(self, display, x, y):
self.f.readinto(self.buf)
bits_to_drop = 8 - self.intensity_bits
buf = self.buf
for i in range(len(self.buf)):
buf[i] >>= bits_to_drop
display.render_block(self.buf, self.rows, self.cols, x, y)
self.frame += 1
if self.frame == self.num_frames:
self.reset()
def close(self):
"""
MicroPython do not call __del__ so we need a manual destructor
"""
self.f.close()

20
icons/README.md Normal file
View File

@@ -0,0 +1,20 @@
Assuming you're in the directory where you cloned this Git repository (i.e. one level up from here), try:
```bash
curl -o icons/game-brick.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=1524
curl -o icons/game-invaders-1.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=3405
curl -o icons/game-invaders-2.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=3407
curl -o icons/game-tetris.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=4007
curl -o icons/game-nintendo.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=5038
curl -o icons/game-pacman-ghosts.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=20117
curl -o icons/game-pingpong.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=4075
curl -o icons/game-snake.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16036
curl -o icons/matrix.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=653
curl -o icons/newyears.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=9356
curl -o icons/tv-movie.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=7862
# Convert JSON to a less verbose binary representation
scripts/convert-animation.py icons/*.json
rm icons/*.json
```
You might want to update `AnimationScene.filenames` in [config.json](../config.json) to make use of the animations.

View File

@@ -14,30 +14,36 @@ if not hasattr(time, 'ticks_ms'):
time.ticks_ms = lambda: int(time.time()*1000)
class LedMatrix:
rotation = 180
# Reduce brightness by scaling down colors
brightness_scaler = 32
rows = 0
columns = 0
driver = None
fb = []
def __init__(self, driver, columns = 8, rows = 8, rotation = 0):
def __init__(self, driver, config):
self.driver = driver
self.columns = columns
self.rows = rows
self.num_pixels = rows * columns
self.debug = False
self.stride = 8
self.columns = 32
self.rotation = 0
self.fps = 10
self.fix_r = 0xff
self.fix_g = 0xff
self.fix_b = 0xc0
if config:
if 'debug' in config:
self.debug = config['debug']
if 'stride' in config:
self.stride = config['stride']
if 'columns' in config:
self.columns = config['columns']
if 'rotation' in config:
self.rotation = (360 + config['rotation']) % 360
if 'fps' in config:
self.fps = config['fps']
self.num_pixels = self.stride * self.columns
# For avoiding multiplications and divisions
self.num_modified_pixels = self.num_pixels # optimization: avoid rendering too many pixels
assert rows == 8, "Calculations in xy_to_phys expect 8 rows"
self.rotation = (360 + rotation) % 360
# This is laid out in physical order
self.fb.append(bytearray(self.num_pixels*3))
self.fb.append(bytearray(self.num_pixels*3))
self.fb = [
bytearray(self.num_pixels*3),
bytearray(self.num_pixels*3),
]
self.fb_index = 0
# Optimize clear
self.fb.append(bytearray(self.num_pixels*3))
for i in range(len(self.fb[0])):
self.fb[0][i] = 1
# Initialize display
self.driver.init_display(self.num_pixels)
@@ -49,11 +55,11 @@ class LedMatrix:
pass
elif self.rotation < 180:
tmp = x
x = self.rows-1-y
x = self.stride-1-y
y = tmp
elif self.rotation < 270:
x = self.columns-1-x
y = self.rows-1-y
y = self.stride-1-y
else:
tmp = x
x = y
@@ -61,69 +67,41 @@ class LedMatrix:
# The LEDs are laid out in a long string going from north to south,
# one step to the east, and then south to north, before the cycle
# starts over.
#
# Here we calculate the physical offset for the desired rotation, with
# the assumption that the first LED is at (0,0).
# We'll need this adjusting for the north-south-south-north layout
cycle = self.rows << 1 # optimization: twice the number of rows
# First we determine which "block" (of a complete cyle) the pixel is in
nssn_block = x >> 1 # optimization: divide by two
phys_addr = nssn_block << 4 # optimization: Multiply by cycle
# Second we determine if the column has decreasing or increasing addrs
is_decreasing = x & 1
if is_decreasing:
phys_addr += cycle - 1 - y
stride = self.stride
phys_addr = x*stride
if x & 1:
phys_addr += stride - 1 - y
else:
phys_addr += y
return phys_addr
def phys_to_xy(self, phys_addr):
"""
Map physical LED address to x,y after accounting for display rotation
"""
x = phys_addr >> 3 # optimization: divide by number of rows
cycle = self.rows << 1 # optimization: twice the number of rows
y = phys_addr & (cycle-1) # optimization: modulo the cycle
if y >= self.rows:
y = cycle - 1 - y
if self.rotation < 90:
pass
elif self.rotation < 180:
tmp = x
x = self.rows-1-y
y = tmp
elif self.rotation < 270:
x = self.columns-1-x
y = self.rows-1-y
else:
tmp = x
x = y
y = self.columns-1-tmp
return [x, y]
def get_pixel(self, x, y):
"""
Get pixel from the currently displayed frame buffer
"""
pixel = self.xy_to_phys(x, y)
back_index = (self.fb_index+1)%2
fb_id = (self.fb_index+1)%2
offset = pixel*3
return [self.fb[back_index][offset+0], self.fb[back_index][offset+1], self.fb[back_index][offset+2]]
return [self.fb[fb_id][offset+0], self.fb[fb_id][offset+1], self.fb[fb_id][offset+2]]
def get_pixel_front(self, x, y):
"""
Get pixel from the to-be-displayed frame buffer
"""
pixel = self.xy_to_phys(x, y)
back_index = (self.fb_index)%2
fb_id = (self.fb_index)%2
offset = pixel*3
return [self.fb[back_index][offset+0], self.fb[back_index][offset+1], self.fb[back_index][offset+2]]
return [self.fb[fb_id][offset+0], self.fb[fb_id][offset+1], self.fb[fb_id][offset+2]]
def put_pixel(self, x, y, r, g, b):
"""
Set pixel ni the to-be-displayed frame buffer"
"""
if x >= self.columns or y >= self.rows:
if x > self.columns:
# TODO: proper fix for 16x16 displays
x -= self.stride
y += 8
if x >= self.columns or y >= self.stride:
return
pixel = self.xy_to_phys(x, y)
offset = pixel*3
@@ -138,11 +116,67 @@ class LedMatrix:
"""
Clear the frame buffer by setting all pixels to black
"""
self.fb_index ^= 1
self.fb[self.fb_index][:] = self.fb[2][:]
# Optimization: keep track of last updated pixel
buf = self.fb[self.fb_index]
for i in range(self.num_pixels*3):
buf[i] = 0
self.num_modified_pixels = self.num_pixels
def render_block(self, data, rows, cols, x, y):
"""
Put a block of data of rows*cols*3 size at (x,y)
"""
if x+cols > self.columns or y+rows > self.stride:
return
offset = 0
for row in range(rows):
for col in range(cols):
self.put_pixel(x+col, y+row, data[offset], data[offset+1], data[offset+2])
offset += 3
def render_text(self, font, text, x_off, y_off, intensity=32):
"""
Render text with the pixel font
"""
put_pixel_fn = self.put_pixel
w = font.width
h = font.height
alphabet = font.alphabet
font_data = font.data
in_r = self.fix_r * intensity // 255
in_g = self.fix_g * intensity // 255
in_b = self.fix_b * intensity // 255
low_r = in_r >> 1
low_g = in_g >> 1
low_b = in_b >> 1
for i in range(len(text)):
digit = text[i]
if digit in '.:-\' ' or (i and text[i-1] in '.: '):
x_off -= 1
data_offset = alphabet.find(digit)
if data_offset < 0:
data_offset = 0
tmp = data_offset * w * h
font_byte = tmp >> 3
font_bit = tmp & 7
for row in range(h):
for col in range(w):
if font_data[font_byte] & (1<<font_bit):
put_pixel_fn(x_off+col, y_off+row, in_r, in_g, in_b)
else:
put_pixel_fn(x_off+col, y_off+row, 0, 0, 0)
font_bit += 1
if font_bit == 8:
font_byte += 1
font_bit = 0
if digit == 'm':
put_pixel_fn(x_off+1, y_off+1, low_r, low_g, low_b)
elif digit == 'w':
put_pixel_fn(x_off+1, y_off+3, low_r, low_g, low_b)
elif digit == 'n':
put_pixel_fn(x_off, y_off+3, low_r, low_g, low_b)
put_pixel_fn(x_off+2, y_off+1, low_r, low_g, low_b)
x_off += w
def render(self):
"""
Render the to-be-displayed frame buffer by making put_pixel() and
@@ -152,6 +186,7 @@ class LedMatrix:
tX = t0 = time.ticks_ms()
front = self.fb[self.fb_index]
back = self.fb[self.fb_index ^ 1]
put_pixel = self.driver.put_pixel
num_rendered = 0
for pixel in range(self.num_modified_pixels):
# This crap saves about 4ms
@@ -162,38 +197,88 @@ class LedMatrix:
g = front[j]
b = front[k]
if r != back[i] or g != back[j] or b != back[k]:
self.driver.put_pixel(pixel, r // self.brightness_scaler, g // self.brightness_scaler, b // self.brightness_scaler)
num_rendered += 1
t0 = time.ticks_ms() - t0
put_pixel(pixel, r, g, b)
num_rendered += 1
t1 = time.ticks_ms()
t0 = t1 - t0
# This takes 52ms
t1 = time.ticks_ms()
self.driver.update_display(self.num_modified_pixels)
t1 = time.ticks_ms() - t1
#time.sleep(0.00004 * self.columns * self.rows)
#time.sleep_ms(10)
t2 = time.ticks_ms()
t1 = t2 - t1
# This takes 0ms
self.fb_index ^= 1
self.fb[self.fb_index][:] = self.fb[self.fb_index^1]
print('LedMatrix render: {} pixels updated in {}ms, spent {}ms in driver update call, total {}ms'.format(num_rendered, t0, t1, time.ticks_ms() - tX))
# Optimization: keep track of last updated pixel
self.num_modified_pixels = 0
if self.debug:
print('LedMatrix render: {} driver.put_pixel() in {}ms, spent {}ms in driver.update_display(), total {}ms'.format(num_rendered, t0, t1, t2 - tX))
def scrollout(self):
def hscroll(self, distance=4):
"""
Scene transition effect: scroll away pixels
Scroll away pixels, left or right
"""
for i in range(self.rows):
for x in range(self.columns):
self.put_pixel(x, i, 0, 0, 0)
for y in range(self.rows-1):
for x in range(self.columns):
colors = self.get_pixel(x, y)
self.put_pixel(x, y+1, colors[0], colors[1], colors[2])
if distance > 0:
z_start, z_end, delta = 0, self.columns, -1
else:
z_start, z_end, delta = self.columns-1, -1, 1
if self.columns % distance:
distance -= delta
for zero_lane in range(z_start, z_end, distance):
fb_cur = self.fb[self.fb_index^1]
fb_next = self.fb[self.fb_index]
for y in range(self.stride):
for x in range(z_end+delta, zero_lane+distance+delta, delta):
src = self.xy_to_phys(x-distance, y)*3
dst = self.xy_to_phys(x, y)
if dst >= self.num_modified_pixels:
self.num_modified_pixels = dst+1
dst *= 3
fb_next[dst] = fb_cur[src]
fb_next[dst+1] = fb_cur[src+1]
fb_next[dst+2] = fb_cur[src+2]
for y in range(self.stride):
for x in range(zero_lane, zero_lane+distance, -delta):
dst = self.xy_to_phys(x, y)
if dst >= self.num_modified_pixels:
self.num_modified_pixels = dst+1
dst *= 3
fb_next[dst] = fb_next[dst+1] = fb_next[dst+2] = 0
self.render()
def vscroll(self, distance=2):
"""
Scroll away pixels, up or down
"""
if distance > 0:
z_start, z_end, delta = 0, self.stride, -1
else:
z_start, z_end, delta = self.stride-1, -1, 1
if self.stride % distance:
distance -= delta
for zero_lane in range(z_start, z_end, distance):
fb_cur = self.fb[self.fb_index^1]
fb_next = self.fb[self.fb_index]
for y in range(z_end+delta, zero_lane+distance+delta, delta):
for x in range(self.columns):
src = self.xy_to_phys(x, y-distance)*3
dst = self.xy_to_phys(x, y)
if dst >= self.num_modified_pixels:
self.num_modified_pixels = dst+1
dst *= 3
fb_next[dst] = fb_cur[src]
fb_next[dst+1] = fb_cur[src+1]
fb_next[dst+2] = fb_cur[src+2]
for y in range(zero_lane, zero_lane+distance, -delta):
for x in range(self.columns):
dst = self.xy_to_phys(x, y)
if dst >= self.num_modified_pixels:
self.num_modified_pixels = dst+1
dst *= 3
fb_next[dst] = fb_next[dst+1] = fb_next[dst+2] = 0
self.render()
#time.sleep(0.05)
return False
def fade(self):
@@ -220,14 +305,13 @@ class LedMatrix:
Scene transition effect: dissolve active pixels with LFSR
"""
active_pixels = 0
for i in range(self.columns*self.rows):
colors = self.get_pixel(i % self.columns, i // self.columns)
if colors[0] or colors[1] or colors[2]:
active_pixels += 1
for y in range(self.stride):
for x in range(self.columns):
colors = self.get_pixel(x, y)
if colors[0] or colors[1] or colors[2]:
active_pixels += 1
if not active_pixels:
# No more pixels to dissolve
return False
per_pixel_sleep = (0.1-0.00003*self.num_pixels)/active_pixels
pixel = 1
for i in range(256):
@@ -235,14 +319,12 @@ class LedMatrix:
pixel >>= 1
if bit:
pixel ^= 0xb4
if pixel >= self.columns*self.rows:
continue
colors = self.get_pixel(pixel % self.columns, pixel // self.columns)
x, y = pixel % self.columns, pixel // self.columns
colors = self.get_pixel(x, y)
if not colors[0] and not colors[1] and not colors[2]:
continue
self.put_pixel(pixel % self.columns, pixel // self.columns, 0, 0, 0)
self.render()
time.sleep(per_pixel_sleep)
self.put_pixel(x, y, 0, 0, 0)
if i % 4 == 3:
self.render()
# There are still pixels to dissolve
return True

312
main.py
View File

@@ -1,157 +1,52 @@
#!/usr/bin/env python
#
# This is a project to drive a 8x32 (or 8x8) LED matrix based on the
# popular WS2812 RGB LEDs using a microcontroller (e.g. a Teensy 3.x
# or a Pycom module with 4MB RAM) and optionally control them both
# using a more powerful host computer, such as a Raspberry Pi Zero W.
# This is a project to drive a 32x8 or 16x16 LED matrix based on the popular
# WS2812 RGB LEDs using a microcontroller running MicroPython (preferrably
# one with 4MB RAM although modules with 1MB also work).
#
# -- noah@hack.se, 2018
#
import sys
import time
import gc
from math import ceil
from ledmatrix import LedMatrix
# This is to make sure we have a large contiguous block of RAM on devices with
# 520kB RAM after all modules and modules have been compiled and instantiated.
#
# In the weather scene, the ussl module needs a large chunk of around 1850
# bytes, and without the dummy allocation below the heap will be too
# fragmented after all the initial processing to find such a large chunk.
large_temp_chunk = bytearray(3400)
pycom_board = False
esp8266_board = False
if hasattr(sys,'implementation') and sys.implementation.name == 'micropython':
pycom_board = True
import ujson as json
import machine
from network import WLAN
# Local imports
from pycomhal import PycomHAL
from os import uname
tmp = uname()
if tmp.sysname == 'esp8266':
esp8266_board = True
from upyhal import uPyHAL as HAL
else:
pycom_board = True
from pycomhal import PycomHAL as HAL
tmp = None
del uname
else:
pycom_board = False
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time() * 1000)
time.sleep_ms = lambda x: time.sleep(x/1000.0)
import json
import os
import sys
import signal
# Local imports
from arduinoserialhal import ArduinoSerialHAL
from arduinoserialhal import ArduinoSerialHAL as HAL
# Local imports
from ledmatrix import LedMatrix
from clockscene import ClockScene
gc.collect()
from renderloop import RenderLoop
class RenderLoop:
def __init__(self, display = None, fps=10):
self.display = display
self.fps = fps
self.t_next_frame = None
self.prev_frame = 0
self.frame = 1
self.t_init = time.ticks_ms() / 1000.0
self.debug = 1
self.scenes = []
self.scene_index = 0
self.scene_switch_effect = 0
self.reset_scene_switch_counter()
def reset_scene_switch_counter(self):
"""
Reset counter used to automatically switch scenes.
The counter is decreased in .next_frame()
"""
self.scene_switch_countdown = 45 * self.fps
def add_scene(self, scene):
"""
Add new scene to the render loop
"""
self.scenes.append(scene)
def next_scene(self):
"""
Transition to a new scene and re-initialize the scene
"""
print('RenderLoop: next_scene: transitioning scene')
# Fade out current scene
effect = self.scene_switch_effect
self.scene_switch_effect = (effect + 1) % 3
if effect == 0:
self.display.dissolve()
elif effect == 1:
self.display.fade()
else:
self.display.scrollout()
self.scene_index += 1
if self.scene_index == len(self.scenes):
self.scene_index = 0
i = self.scene_index
print('RenderLoop: next_scene: selected {}'.format(self.scenes[i].__class__.__name__))
# (Re-)initialize scene
self.scenes[i].reset()
def next_frame(self, button_pressed=0):
"""
Display next frame, possibly after a delay to ensure we meet the FPS target
"""
scene = self.scenes[self.scene_index]
if button_pressed:
# Let the scene handle input
if scene.input(0, button_pressed):
# The scene handled the input itself so ignore it
button_pressed = 0
t_now = time.ticks_ms() / 1000.0 - self.t_init
if not self.t_next_frame:
self.t_next_frame = t_now
delay = self.t_next_frame - t_now
if delay >= 0:
# Wait until we can display next frame
x = time.ticks_ms() / 1000.0
time.sleep_ms(int(1000 * delay))
x = time.ticks_ms() / 1000.0 - x
if x-delay > 0.01:
print('RenderLoop: WARN: Overslept when sleeping for {}s, slept {}s more'.format(delay, round(x-delay, 6)))
else:
if self.debug:
print('RenderLoop: WARN: FPS {} might be too high, {}s behind and missed {} frames'.format(self.fps, -delay, round(-delay*self.fps, 2)))
# Resynchronize
t_diff = self.fps * (t_now-self.t_next_frame)/self.fps - delay
if self.debug:
print('RenderLoop: Should have rendered frame {} at {} but was {}s late'.format(self.frame, self.t_next_frame, t_diff))
t_diff += 1./self.fps
self.frame += int(round(self.fps * t_diff))
self.t_next_frame += t_diff
if self.debug:
print('RenderLoop: Will instead render frame {} at {}'.format(self.frame, self.t_next_frame))
if self.debug:
print('RenderLoop: Rendering frame {}, next frame at {}'.format(self.frame, round(self.t_next_frame+1./self.fps, 4)))
# Render current scene
t = time.ticks_ms() / 1000.0
loop_again = scene.render(self.frame, self.frame - self.prev_frame - 1, self.fps)
t = time.ticks_ms() / 1000.0 - t
if t > 0.1:
print('RenderLoop: WARN: Spent {}s rendering'.format(t))
self.scene_switch_countdown -= 1
if button_pressed or not loop_again or not self.scene_switch_countdown:
self.reset_scene_switch_counter()
if not loop_again:
print('RenderLoop: scene "{}" signalled completion'.format(self.scenes[self.scene_index].__class__.__name__))
else:
print('RenderLoop: forcefully switching scenes (button: {}, timer: {}'.format(button_pressed, self.scene_switch_countdown))
# Transition to next scene
self.next_scene()
# Account for time wasted above
t_new = time.ticks_ms() / 1000.0 - self.t_init
t_diff = t_new - t_now
frames_wasted = ceil(t_diff * self.fps)
#print('RenderLoop: setup: scene switch took {}s, original t {}s, new t {}s, spent {} frames'.format(t_diff, t_now,t_new, self.fps*t_diff))
self.frame += int(frames_wasted)
self.t_next_frame += frames_wasted / self.fps
self.prev_frame = self.frame
self.frame += 1
self.t_next_frame += 1./self.fps
def sigint_handler(sig, frame):
"""
Clear display when the program is terminated by Ctrl-C or SIGTERM
@@ -166,19 +61,11 @@ if __name__ == '__main__':
f = open('config.json')
config = json.loads(f.read())
f.close()
del json
# Initialize HAL
if pycom_board:
# We're running under MCU here
print('WLAN: Connecting')
wlan = WLAN(mode=WLAN.STA)
wlan.connect(config['ssid'], auth=(WLAN.WPA2, config['password']))
while not wlan.isconnected():
machine.idle() # save power while waiting
time.sleep(1)
print('WLAN: Connected with IP: {}'.format(wlan.ifconfig()[0]))
driver = PycomHAL(config)
else:
driver = HAL(config)
if not esp8266_board and not pycom_board:
# We're running on the host computer here
ports = [
'/dev/tty.usbmodem575711', # Teensy 3.x on macOS
@@ -188,57 +75,122 @@ if __name__ == '__main__':
]
for port in ports:
if os.path.exists(port):
config['port'] = port
break
driver = ArduinoSerialHAL(config)
driver.set_rtc(time.time() + config['tzOffsetSeconds'])
# Disable automatic rendering of time
driver.set_auto_time(False)
# Trap Ctrl-C and service termination
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
# Initialize led matrix framebuffer on top of HAL
num_leds = 256
rows = 8
cols = num_leds // rows
display = LedMatrix(driver, cols, rows, rotation=0)
display = LedMatrix(driver, config['LedMatrix'])
driver.clear_display()
if pycom_board:
# If we're running on the MCU then loop forever
while True:
driver.serial_loop(display)
# We're running under MCU here
from bootscene import BootScene
scene = BootScene(display, config['Boot'])
wlan = WLAN(mode=WLAN.STA)
if not wlan.isconnected():
print('WLAN: Scanning for networks')
scene.render(0,0,0)
default_ssid, default_auth = wlan.ssid(), wlan.auth()
candidates = wlan.scan()
for conf in config['networks']:
nets = [candidate for candidate in candidates if candidate.ssid == conf['ssid']]
if not nets:
continue
print('WLAN: Connecting to known network: {}'.format(nets[0].ssid))
wlan.connect(nets[0].ssid, auth=(nets[0].sec, conf['password']))
for i in range(1,40):
scene.render(i, 0, 0)
time.sleep(0.2)
if wlan.isconnected():
break
if wlan.isconnected():
break
scene.render(0, 0, 0)
if not wlan.isconnected():
# TODO: This will only use SSID/auth settings from NVRAM during cold boots
print('WLAN: No known networks, enabling AP with ssid={}, pwd={}'.format(default_ssid, default_auth[1]))
wlan.init(mode=WLAN.AP, ssid=default_ssid, auth=default_auth, channel=6)
else:
display.clear()
print('WLAN: Connected with IP: {}'.format(wlan.ifconfig()[0]))
# Initialize RTC now that we're connected
driver.set_rtc(scene)
scene.render(0,0,0)
scene = None
del BootScene
elif esp8266_board:
pass
# This is where it all begins
r = RenderLoop(display, fps=10)
r = RenderLoop(display, config)
scene = ClockScene(display, config['ClockScene'])
r.add_scene(scene)
if 'Clock' in config:
from clockscene import ClockScene
scene = ClockScene(display, config['Clock'])
r.add_scene(scene)
gc.collect()
from weatherscene import WeatherScene
scene = WeatherScene(display, config['WeatherScene'])
r.add_scene(scene)
if 'Demo' in config:
from demoscene import DemoScene
scene = DemoScene(display, config['Demo'])
r.add_scene(scene)
gc.collect()
from animationscene import AnimationScene
scene = AnimationScene(display, config['AnimationScene'])
r.add_scene(scene)
if 'Weather' in config:
from weatherscene import WeatherScene
scene = WeatherScene(display, config['Weather'])
r.add_scene(scene)
gc.collect()
if 'Fire' in config:
from firescene import FireScene
scene = FireScene(display, config['Fire'])
r.add_scene(scene)
gc.collect()
if 'Animation' in config:
from animationscene import AnimationScene
scene = AnimationScene(display, config['Animation'])
r.add_scene(scene)
gc.collect()
# Now that we're all setup, release the large chunk
large_temp_chunk = None
# Render scenes forever
while True:
button_pressed = 0
while True:
# Drain output from MCU and detect button presses
line = driver.readline()
if not line:
break
event = line.strip()
if event == 'BUTTON_SHRT_PRESS':
button_pressed = 1
elif event == 'BUTTON_LONG_PRESS':
button_pressed = 2
else:
print('MCU: {}'.format(event))
r.next_frame(button_pressed)
if button_pressed:
button_state = 0
# Process input
button_state = 0
if pycom_board:
# When running under MicroPython on the MCU we need to deal with
# any input coming from the host over the serial link
while True:
button_state = driver.process_input()
if driver.enable_auto_time:
# If the host disconnected we'll hand over control to the
# game loop which will take care of updating the display
break
else:
# When running under regular Python on the host computer we need
# to pick up any button presses sent over the serial link from
# the Arduino firmware
n = 0
while True:
# Drain output from MCU and detect button presses
line = driver.process_input()
if not line:
break
event = line.strip()
if event == 'LEFTB_SHRT_PRESS':
button_state = 1
elif event == 'LEFTB_LONG_PRESS':
button_state = 2
else:
print('MCU: {}'.format(event))
r.next_frame(button_state)

View File

@@ -1,109 +1,8 @@
#!/usr/bin/env python
#
# This file provides a small 4x5 (width and height) font primarily designed to
# present the current date and time.
#
# The .data property provides the bits for each character available in .alphabet.
# For each character, the consumer must extract the bits (4x5 == 20 bits) from
# the offset given by the the character's position in .alphabet * 20.
#
# Example:
#
# font = PixelFont()
# digit = '2' # Extract and plot the digit '2'
#
# start_bit = font.alphabet.find(digit) * font.width * font.height
# font_byte = start_bit // 8
# font_bit = start_bit % 8
# for y in range(font.height):
# for x in range(font.width):
# is_lit = font.data[font_byte] & (1<<font_bit)
# put_pixel(x, y, is_lit)
# font_bit +=1
# if font_bit == 8:
# font_byte += 1
# font_bit = 0
#
# To add new symbols to the font, edit the `font` variable in to_bytearray(),
# add a new 4x5 block representing the symbol, update the .alphabet property
# and then run this file to generate updated data for Python and C:
#
# $ ./pixelfont.py
#
# Update the variables in the constructor with the new output.
# 4x5 pixel font
# See scripts/generate-pixelfont.py for details
#
class PixelFont:
def __init__(self):
self.width = 4
self.height = 5
self.alphabet = ' %\'-./0123456789:cms'
self.data = bytearray("\x00\x00\x50\x24\x51\x66\x00\x00\x60\x00\x00\x00\x42\x24\x11\x57\x55\x27\x23\x72\x47\x17\x77\x64\x74\x55\x47\x74\x71\x74\x17\x57\x77\x44\x44\x57\x57\x77\x75\x74\x20\x20\x20\x15\x25\x75\x57\x75\x71\x74")
def to_bytearray(self):
###|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|
font = """
# # ## # ### # ### ### # # ### ### ### ### ### # # # ###
# ## # # # ## # # # # # # # # # # # # # # ### #
# ## # # # # ### ## ### ### ### # ### ### # ### ###
# # # # # # # # # # # # # # # # # # # # #
# # # # ### ### ### ### # ### ### # ### ### # # # ###
""".strip('\n').replace('\n', '')
###|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|
data = bytearray()
byte = bits = 0
num_digits = len(self.alphabet)
for i in range(num_digits):
pixels = bytearray()
for row in range(self.height):
for col in range(self.width):
pos = row * num_digits * self.width
pos += i * self.width + col
is_lit = int(font[pos] != ' ')
byte |= is_lit << bits
bits += 1
if bits == 8:
data.append(byte)
bits = byte = 0
if bits:
data.append(byte)
return data
if __name__ == '__main__':
import sys
f = PixelFont()
data = f.to_bytearray()
print('')
print('# Computed with pixelfont.py')
print(' self.width = {}'.format(f.width))
print(' self.height = {}'.format(f.height))
print(' self.alphabet = "{}"'.format(f.alphabet))
print(' self.data = bytearray("{}")'.format("".join("\\x{:02x}".format(x) for x in data)))
print('')
print('/* Computed with pixelfont.py */')
print('static int font_width = {};'.format(f.width))
print('static int font_height = {};'.format(f.height))
print('static char font_alphabet[] = "{}";'.format(f.alphabet))
print('static unsigned char font_data[] = "{}";'.format("".join("\\x{:02x}".format(x) for x in data)))
debugstr = '12:30 1.8\'c'
for j in range(len(debugstr)):
digit = debugstr[j]
i = f.alphabet.find(digit)
if i < 0:
print('WARN: digit {} not found in alphabet'.format(digit))
font_byte = (i * f.height * f.width) // 8
font_bit = (i * f.height * f.width) % 8
for row in range(f.height):
for col in range(f.width):
val = 0
if data[font_byte] & (1 << font_bit):
val = 255
sys.stdout.write('#' if val else ' ')
font_bit += 1
if font_bit == 8:
font_byte += 1
font_bit = 0
sys.stdout.write('\n')
width = 4
height = 5
alphabet = " %'-./0123456789:?acdefgiklmnoprstwxy"
data = bytearray("\x00\x00\x50\x24\x51\x66\x00\x00\x60\x00\x00\x00\x42\x24\x11\x57\x55\x27\x23\x72\x47\x17\x77\x64\x74\x55\x47\x74\x71\x74\x17\x57\x77\x44\x44\x57\x57\x77\x75\x74\x20\x20\x30\x24\x20\x52\x57\x25\x15\x25\x53\x55\x73\x31\x71\x17\x13\x71\x71\x75\x27\x22\x57\x35\x55\x11\x11\x57\x77\x55\x75\x77\x75\x55\x75\x57\x17\x71\x35\x55\x17\x47\x77\x22\x22\x55\x77\x55\x25\x55\x55\x27\x02")

View File

@@ -11,53 +11,73 @@
# From https://raw.githubusercontent.com/Gadgetoid/wipy-WS2812/master/ws2812alt.py
# ..via: https://forum.pycom.io/topic/2214/driving-ws2812-neopixel-led-strip/3
from ws2812 import WS2812
#from rmt import WS2812
from machine import Pin, RTC, UART, idle
from machine import Pin, RTC, UART
import utime
import os
import sys
import pycom
import gc
# Local imports
from clockscene import ClockScene
from weatherscene import WeatherScene
class PycomHAL:
def __init__(self, config):
self.chain = None # will be initialized in reset()
self.num_pixels = 256
self.pixels = []
self.reset()
self.enable_auto_time = True
self.frame = 0
# TODO: Fix these
self.scene = 0
self.clock = None
self.weather = None
self.config = config
# A Raspberry Pi will reboot/wake up if this pin is set low
# https://docs.pycom.io/firmwareapi/pycom/machine/pin.html#pinholdhold
self.suspend_host_pin = Pin('P8', Pin.OUT, Pin.PULL_UP)
self.suspend_host_pin.hold(True)
# Handle button input
self.button_pin = Pin('P12', Pin.IN, Pin.PULL_UP)
self.button_pin.callback(Pin.IRQ_FALLING|Pin.IRQ_RISING, handler=lambda arg: self.button_irq(arg))
self.left_button = Pin('P9', Pin.IN, Pin.PULL_UP)
self.left_button.callback(Pin.IRQ_FALLING|Pin.IRQ_RISING, handler=lambda arg: self.button_irq(arg))
self.right_button = Pin('P10', Pin.IN, Pin.PULL_UP)
self.right_button.callback(Pin.IRQ_FALLING|Pin.IRQ_RISING, handler=lambda arg: self.button_irq(arg))
print('PycomHAL: left button {}, right button {}'.format(self.left_button.value(), self.right_button.value()))
self.button_state = 0
self.button_down_t = 0
# Setup RTC
self.rtc = None
self.set_rtc(0)
utime.timezone(config['tzOffsetSeconds'])
pycom.heartbeat(False)
gc.collect()
# Free resources
if self.left_button.value() and self.right_button.value():
self.disable_stuff()
# For the serial bridge implementation
self.uart = None # will be initialized in serial_loop()
self.uart = None
self.console = None
gc.collect()
self.rxbuf = bytearray(256)
self.reconfigure_uarts(config)
# Needed for maintaining the serial protocol state
self.reboot_at = 0
self.state = 0
self.acc = 0
self.color = 0
gc.collect()
def disable_stuff(self):
from network import Bluetooth, Server
bluetooth = Bluetooth()
bluetooth.deinit()
# Disable FTP server unless button is pressed during startup
server = Server()
server.deinit()
print('PycomHAL: FTP server disabled (hold any button during startup to enable)')
def reconfigure_uarts(self, config):
"""
Reconfigure UARTs to make
- UART 0 become the one we can be controlled by via USB serial
- UART 1 the console (print output and REPL)
"""
self.uart = UART(0, config['baudrate'], pins=('P1', 'P0', 'P20', 'P19')) # TX/RX/RTS/CTS on ExpBoard2
self.console = UART(1, 115200)
if not config or not 'remapConsole' in config or config['remapConsole']:
print('HAL: Disabling REPL on UART0 and switching to serial protocol')
os.dupterm(self.console)
print('HAL: Enabled REPL on UART1')
def button_irq(self, pin):
"""
@@ -69,16 +89,27 @@ class PycomHAL:
return
if not self.button_down_t:
return
t = utime.ticks_ms() - self.button_down_t
if t > 500:
self.button_state = 2
shift = 0 if pin == self.left_button else 4
if t > 1500:
self.button_state |= 1<<(shift+2)
elif t > 500:
self.button_state |= 1<<(shift+1)
elif t > 80:
self.button_state = 1
self.button_state |= 1<<(shift+0)
self.button_down_t = 0
# Implement the serial protocol understood by ArduinoSerialHAL
# This function should be similar to the Arduino project's loop()
def serial_loop(self, display):
def process_input(self):
"""
Process control messages coming from the host as well as any
button presses captured. Return button presses as input to
the caller (the main game loop).
Also takes care of waking up the host computer if the timer expired.
"""
# Wake up the host computer if necessary
if self.reboot_at:
if utime.time() > self.reboot_at:
self.reboot_at = 0
@@ -89,56 +120,46 @@ class PycomHAL:
self.suspend_host_pin(1)
self.suspend_host_pin.hold(True)
if not self.uart:
print('HAL: Disabling REPL on UART0 and switching to serial protocol')
idle()
os.dupterm(None)
self.uart = UART(0, 115200*8, pins=('P1', 'P0', 'P20', 'P19')) # TX/RX/RTS/CTS on ExpBoard2
self.console = UART(1, 115200)
os.dupterm(self.console)
idle()
print('HAL: Enabled REPL on UART1')
# Process button input
button_state = self.button_state
if button_state:
if button_state == 1:
print('BUTTON_SHRT_PRESS')
elif button_state == 2:
print('BUTTON_LONG_PRESS')
try:
if button_state & 1:
# Notify the host about the button press in a similar manner
# to what ArduinoSer2FastLED does
self.uart.write(bytearray('LEFTB_SHRT_PRESS\n'))
elif button_state & 2:
self.uart.write(bytearray('LEFTB_LONG_PRESS\n'))
elif button_state & 4:
self.uart.write(bytearray('LEFTB_HOLD_PRESS\n'))
elif button_state & 16:
self.uart.write(bytearray('RGHTB_SHRT_PRESS\n'))
elif button_state & 32:
self.uart.write(bytearray('RGHTB_LONG_PRESS\n'))
elif button_state & 64:
self.uart.write(bytearray('RGHTB_HOLD_PRESS\n'))
except OSError as e:
print('HAL: UART write failed: {}'.format(e.args[0]))
self.button_state = 0
if self.enable_auto_time:
# TODO: Unify with main.py::RenderLoop
self.frame += 1
if not self.clock:
print('HAL: Initiating clock scene')
self.clock = ClockScene(display, self.config['ClockScene'])
if not self.weather:
self.weather = WeatherScene(display, self.config['WeatherScene'])
self.weather.reset()
if button_state == 1:
self.clock.input(0, button_state)
elif button_state == 2:
self.scene ^= 1
self.clear_display()
if self.scene == 0:
self.clock.render(self.frame, 0, 5)
else:
self.weather.render(self.frame, 0, 5)
avail = self.uart.any()
if not avail:
return
# No incoming data from the host, return the button state to the
# caller (game loop) so it can process it if self.enable_auto_time
# is True
return button_state
if avail > 256:
# Currently shipping releases have a 512 byte buffer
print('HAL: More than 256 bytes available: {}'.format(avail))
data = self.uart.readall()
for val in data:
self.uart.readinto(self.rxbuf)
for val in self.rxbuf:
if self.state == 0:
# reset
if not val:
# Host is trying to resynchronize
self.uart.write(bytearray('RESET\n'))
print('HAL: Reset sequence from host detected or out-of-sync')
self.state = val
elif self.state >= ord('i') and self.state <= ord('i')+1:
# init display
@@ -192,6 +213,7 @@ class PycomHAL:
self.set_auto_time(not self.enable_auto_time)
else:
self.set_auto_time(bool(val))
self.clear_display()
print('HAL: Automatic rendering of time is now: {}'.format(self.enable_auto_time))
self.state = 0 # reset state
elif self.state >= ord('@') and self.state <= ord('@')+3:
@@ -211,44 +233,48 @@ class PycomHAL:
else:
print('HAL: Unhandled state: {}'.format(self.state))
self.state = 0 # reset state
def readline(self):
"""
No-op in this implementation
"""
return None
return button_state
def reset(self):
print('HAL: Reset called')
self.chain = WS2812(ledNumber=self.num_pixels, intensity=0.5)
self.chain = WS2812(ledNumber=self.num_pixels)
gc.collect()
def init_display(self, num_pixels=256):
print('HAL: Initializing display with {} pixels'.format(num_pixels))
self.num_pixels = num_pixels
self.pixels = [(0,0,0) for _ in range(self.num_pixels)]
self.clear_display()
self.chain.clear()
self.chain.send_buf()
def clear_display(self):
for i in range(self.num_pixels):
self.pixels[i] = (0,0,0)
"""
Turn off all pixels
"""
self.chain.clear()
self.update_display(self.num_pixels)
def update_display(self, num_modified_pixels):
if not num_modified_pixels:
return
self.chain.show(self.pixels[:num_modified_pixels])
gc.collect()
self.chain.send_buf()
def put_pixel(self, addr, r, g, b):
self.pixels[addr % self.num_pixels] = (r,g,b)
"""
Update pixel in buffer
"""
self.chain.put_pixel(addr % self.num_pixels, r, g, b)
def set_rtc(self, t):
def set_rtc(self, scene):
# Resynchronize RTC
self.rtc = RTC()
self.rtc.ntp_sync('ntps1-1.eecsit.tu-berlin.de')
print('HAL: Waiting for NTP sync')
while not self.rtc.synced():
idle()
if type(scene) != int:
# Kludge: render RTC sync progress
frame = 0
while not self.rtc.synced():
scene.render(frame, 0, 0)
frame += 1
print('HAL: RTC synched')
def set_auto_time(self, enable=True):
@@ -256,6 +282,7 @@ class PycomHAL:
Enable rendering of current time without involvment from host computer
"""
self.enable_auto_time = enable
gc.collect()
def suspend_host(self, restart_timeout_seconds):
"""
@@ -269,17 +296,3 @@ class PycomHAL:
self.suspend_host_pin(0)
self.suspend_host_pin(1)
self.suspend_host_pin.hold(True)
pass
if __name__ == '__main__':
import os
import time
p = PycomHAL()
p.init_display(256)
p.clear_display()
p.put_pixel(0, 8, 0, 0)
p.put_pixel(8, 0, 8, 0)
p.put_pixel(16, 0, 0, 8)
p.update_display(p.num_pixels)
time.sleep(1)
p.clear_display()

173
renderloop.py Executable file
View File

@@ -0,0 +1,173 @@
# The game looop
import time
import gc
from math import ceil
if not hasattr(time, 'ticks_ms'):
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time() * 1000)
time.sleep_ms = lambda x: time.sleep(x/1000.0)
class RenderLoop:
def __init__(self, display, config=None):
self.display = display
self.debug = False
self.fps = display.fps
self.t_next_frame = None
self.prev_frame = 0
self.frame = 1
self.t_init = time.ticks_ms()
self.scenes = []
self.scene_index = 0
self.scene_switch_effect = 0
self.scene_switch_countdown = self.fps * 40
self.display.clear()
if not config:
return
if 'debug' in config:
self.debug = config['debug']
if 'sceneTimeout' in config:
self.scene_switch_countdown = self.fps * config['sceneTimeout']
def add_scene(self, scene):
"""
Add new scene to the render loop.
Called by main.py.
"""
self.scenes.append(scene)
def next_frame(self, button_state=0):
"""
Display next frame, possibly after a delay to ensure we meet the FPS target
Called by main.py.
"""
scene = self.scenes[self.scene_index]
# Process input
if button_state:
# Let the scene handle input
handled_bit = scene.input(button_state)
button_state &= ~handled_bit
# Use long-pressed buttons to handle intensity changes
if button_state & 0x22:
clear = 0
for s in self.scenes:
if hasattr(s, 'set_intensity'):
i = s.set_intensity()
if button_state & 0x02:
i -= 2
clear = 0x02
elif button_state & 0x20:
i += 2
clear = 0x20
i = (i + 32) % 32
s.set_intensity(i)
button_state &= ~clear
if self.debug:
print('RenderLoop: updated intensity to {} on scenes, remaining state: {}'.format(i, button_state))
# Calculate how much we need to wait before rendering the next frame
t_now = time.ticks_ms() - self.t_init
if not self.t_next_frame:
self.t_next_frame = t_now
delay = self.t_next_frame - t_now
if delay >= 0:
# Wait until we can display next frame
time.sleep_ms(delay)
else:
# Resynchronize
num_dropped_frames = ceil(-delay*self.fps/1000)
if self.debug:
print('RenderLoop: FPS {} too high, should\'ve rendered frame {} at {}ms but was {}ms late and dropped {} frames'.format(self.fps, self.frame, self.t_next_frame, -delay, num_dropped_frames))
self.frame += num_dropped_frames
self.t_next_frame += ceil(1000*num_dropped_frames/self.fps)
if self.debug:
print('RenderLoop: Updated frame counters to frame {} with current next at {}'.format(self.frame, self.t_next_frame))
# Let the scene render its frame
t = time.ticks_ms()
loop_again = scene.render(self.frame, self.frame - self.prev_frame - 1, self.fps)
t = time.ticks_ms() - t
if t > 1000/self.fps and self.debug:
print('RenderLoop: WARN: Spent {}ms rendering'.format(t))
# Consider switching scenes and update frame counters
self.scene_switch_countdown -= 1
scene_increment = 1
if not loop_again:
self.scene_switch_countdown = 0
elif button_state:
self.scene_switch_countdown = 0
if button_state & 0x1:
scene_increment = -1
if not self.scene_switch_countdown:
self.reset_scene_switch_counter()
# Transition to next scene
self.next_scene(scene_increment, button_state)
# Account for time wasted above
t_new = time.ticks_ms() - self.t_init
t_diff = t_new - t_now
frames_wasted = ceil(t_diff*self.fps/1000.0)
if self.debug:
print('RenderLoop: setup: scene switch took {}ms, original t {}ms, new t {}ms, spent {} frames'.format(t_diff, t_now,t_new, self.fps*t_diff/1000.0))
self.frame += int(frames_wasted)
self.t_next_frame += int(1000.0 * frames_wasted / self.fps)
self.prev_frame = self.frame
self.frame += 1
self.t_next_frame += int(1000/self.fps)
def reset_scene_switch_counter(self):
"""
Reset counter used to automatically switch scenes.
The counter is decreased in .next_frame()
"""
self.scene_switch_countdown = 45 * self.fps
def next_scene(self, increment=1, button_state=0):
"""
Transition to a new scene and re-initialize the scene
"""
if len(self.scenes) < 2:
return
print('RenderLoop: next_scene: transitioning scene')
# Fade out current scene
t0 = time.ticks_ms()
if button_state & 0x01:
self.display.hscroll(-4)
button_state &= ~0x01
elif button_state & 0x10:
self.display.hscroll(4)
button_state &= ~0x10
else:
effect = self.scene_switch_effect
self.scene_switch_effect = (effect + 1) % 4
if effect == 0:
self.display.vscroll()
elif effect == 1:
self.display.hscroll()
elif effect == 2:
self.display.fade()
else:
self.display.dissolve()
t2 = time.ticks_ms()
t1 = t2 - t0
gc.collect()
t3 = time.ticks_ms()
t2 = t3 - t1
num_scenes = len(self.scenes)
i = self.scene_index = (num_scenes + self.scene_index + increment) % num_scenes
# (Re-)initialize scene
self.scenes[i].reset()
t4 = time.ticks_ms()
t3 = t4 - t3
if self.debug:
print('RenderLoop: next_scene: selected {}, effect {}ms, gc {}ms, scene reset {}ms, total {}ms'.format(self.scenes[i].__class__.__name__, t1, t2, t3, t4-t0))
return button_state

48
scripts/convert-animation.py Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python
import sys
import json
import struct
if len(sys.argv) < 2:
print('Usage: {} <input.json>'.format(sys.argv[0]))
sys.exit(0)
for filename in sys.argv[1:]:
f = open(filename)
obj = json.loads(f.read())
f.close()
obj = json.loads(obj['body'])
out_filename = '.'.join(filename.split('.')[:-1]) + '.bin'
f = open(out_filename, 'wb')
delays = obj['delays']
icons = obj['icons']
num_frames = len(icons)
rows = len(icons[0])
cols = len(icons[0][0])
colors = min(len(icons[0][0][0]), 3)
assert colors == 3, "Number of colors must be 3"
header = bytearray(4)
header[0] = num_frames
header[1] = rows
header[2] = cols
header[3] = 3
f.write(header)
chunk = bytearray(num_frames * 2)
for i in range(num_frames):
struct.pack_into('!h', chunk, i*2, delays[i])
f.write(chunk)
for i in range(len(icons)):
icon = icons[i]
for y in range(len(icon)):
row = icon[y]
for x in range(len(row)):
column = row[x]
for color in range(min(len(column), 3)):
f.write(struct.pack('B', 255*column[color]))
f.close()
print('Created {} from {}'.format(out_filename, filename))

105
scripts/generate-pixelfont.py Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python
#
# This file provides a small 4x5 (width and height) font primarily designed to
# present the current date and time.
#
# The .data property provides the bits for each character available in .alphabet.
# For each character, the consumer must extract the bits (4x5 == 20 bits) from
# the offset given by the the character's position in .alphabet * 20.
#
# Example:
#
# font = PixelFont
# digit = '2' # Extract and plot the digit '2'
#
# start_bit = font.alphabet.find(digit) * font.width * font.height
# font_byte = start_bit // 8
# font_bit = start_bit % 8
# for y in range(font.height):
# for x in range(font.width):
# is_lit = font.data[font_byte] & (1<<font_bit)
# put_pixel(x, y, is_lit)
# font_bit +=1
# if font_bit == 8:
# font_byte += 1
# font_bit = 0
#
# To add new symbols to the font, edit the `font` variable in to_bytearray(),
# add a new 4x5 block representing the symbol, update the .alphabet property
# and then run this file to generate updated data for Python and C:
#
# $ ./generate-pixelfont.py
#
# Finally update the instance variables in pixelfont.py with the new data.
#
import sys
font_width = 4
font_height = 5
font_alphabet = ' %\'-./0123456789:?acdefgiklmnoprstwxy'
def font_to_bytearray(width, height, alphabet):
###|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|
font = """
# # ## # ### # ### ### # # ### ### ### ### ### ## # # ## ### ### ### ### # # # # # # # ### ### ### ### ### # # # # # #
# ## # # # ## # # # # # # # # # # # # # # # # # # # # # # # # # # ### ### # # # # # # # # # # # # # #
# ## # # # # ### ## ### ### ### # ### ### # ### # # # ## ## ### # ## # ### ### # # ### ## ### # ### # ###
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # ### # # # # # # # ### # # #
# # # # ### ### ### ### # ### ### # ### ### # # # # ## ### # ### ### # # ### # # # # ### # # # ### # # # # # #
""".strip('\n').replace('\n', '')
###|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|123|
data = bytearray()
byte = bits = 0
num_digits = len(alphabet)
for i in range(num_digits):
pixels = bytearray()
for row in range(height):
for col in range(width):
pos = row * num_digits * width
pos += i * width + col
is_lit = int(font[pos] != ' ')
byte |= is_lit << bits
bits += 1
if bits == 8:
data.append(byte)
bits = byte = 0
if bits:
data.append(byte)
return data
if __name__ == '__main__':
data = font_to_bytearray(font_width, font_height, font_alphabet)
debugstr = '12:30 1.8\'c'
print('Rendering string: {}'.format(debugstr))
for j in range(len(debugstr)):
digit = debugstr[j]
i = font_alphabet.find(digit)
if i < 0:
print('WARN: digit {} not found in alphabet'.format(digit))
font_byte = (i * font_height * font_width) // 8
font_bit = (i * font_height * font_width) % 8
for row in range(font_height):
for col in range(font_width):
val = 0
if data[font_byte] & (1 << font_bit):
val = 255
sys.stdout.write('#' if val else ' ')
font_bit += 1
if font_bit == 8:
font_byte += 1
font_bit = 0
sys.stdout.write('\n')
print('')
print('# Computed with pixelfont.py')
print('width = {}'.format(font_width))
print('height = {}'.format(font_height))
print('alphabet = "{}"'.format(font_alphabet))
print('data = bytearray("{}")'.format("".join("\\x{:02x}".format(x) for x in data)))
print('')
print('/* Computed with pixelfont.py */')
print('static int font_width = {};'.format(font_width))
print('static int font_height = {};'.format(font_height))
print('static char font_alphabet[] = "{}";'.format(font_alphabet))
print('static unsigned char font_data[] = "{}";'.format("".join("\\x{:02x}".format(x) for x in data)))

35
upyhal.py Normal file
View File

@@ -0,0 +1,35 @@
# HAL for mainline MicroPython running on ESP8266
from neopixel import NeoPixel
from machine import Pin
from ntptime import settime
class uPyHAL:
def __init__(self, config):
self.num_pixels = 64
self.np = NeoPixel(Pin(13), self.num_pixels)
self.enable_auto_time = False
# https://github.com/micropython/micropython/issues/2130
#utime.timezone(config['tzOffsetSeconds'])
def init_display(self, num_pixels=64):
self.clear_display()
def clear_display(self):
for i in range(self.num_pixels):
self.np[i] = (0,0,0)
self.np.write()
def update_display(self, num_modified_pixels):
if not num_modified_pixels:
return
self.np.write()
def put_pixel(self, addr, r, g, b):
self.np[addr % self.num_pixels] = (r,g,b)
def reset(self):
self.clear_display()
def process_input(self):
#TODO: implement
return 0
def set_rtc(self, t):
settime()
def set_auto_time(self, enable=True):
self.enable_auto_time = enable
def suspend_host(self, restart_timeout_seconds):
if restart_timeout_seconds < 15:
return

View File

@@ -1,12 +1,18 @@
Assuming you're in the directory where you cloned this Git repository (i.e. one level up from here), try:
```bash
curl -o weather/weather-cloud-partly.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2286
curl -o weather/weather-cloudy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=12019
curl -o weather/weather-moon-stars.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16310
curl -o weather/weather-rain-snow.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=160
curl -o weather/weather-rain.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=72
curl -o weather/weather-snow-house.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=7075
curl -o weather/weather-snowy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2289
curl -o weather/weather-thunderstorm.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=11428
curl -o weather/sunny.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=1338
curl -o weather/sunny-with-clouds.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=8756
curl -o weather/cloud-partly.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2286
curl -o weather/cloudy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=12019
curl -o weather/fog.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=17056
curl -o weather/moon-stars.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16310
curl -o weather/rain-snow.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=160
curl -o weather/rain.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=72
curl -o weather/snow-house.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=7075
curl -o weather/snowy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2289
curl -o weather/thunderstorm.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=11428
# Convert JSON to a less verbose binary representation
scripts/convert-animation.py weather/*.json
rm weather/*.json
```

View File

@@ -1,18 +1,15 @@
#!/usr/bin/env python
#
# Render the current weather forecast from SMHI.se
#
import os
import time
import gc
try:
import ujson as json
import urequests as requests
except ImportError:
import json
import requests
# Local imports
from pixelfont import PixelFont
from icon import Icon
# Based on demoscene.py
class WeatherScene:
@@ -20,6 +17,8 @@ class WeatherScene:
This module displays a weather forecast from SMHI (Sweden)
"""
dir_prefix = 'weather/'
def __init__(self, display, config):
"""
Initialize the module.
@@ -27,117 +26,137 @@ class WeatherScene:
update the display via self.display.put_pixel() and .render()
"""
self.display = display
self.font = PixelFont()
self.last_refreshed_at = 0
self.api_url = 'https://opendata-download-metfcst.smhi.se'
self.icon = None
self.debug = False
self.intensity = 16
self.lat = 59.3293
self.lon = 18.0686
if config:
if 'lat' in config:
self.lat = config['lat']
if 'lon' in config:
self.lon = config['lon']
self.api_url = 'https://opendata-download-metfcst.smhi.se'
self.headers = {
'User-Agent':'weatherscene.py/1.0 (+https://github.com/noahwilliamsson/lamatrix)',
'Accept-Encoding': 'identity',
}
self.temperature = 0
self.wind_speed = 0
self.last_refreshed_at = 0
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
self.symbol = None
self.symbol_version = 1
self.symbol_to_animation = [
self.symbol_to_icon = [
None,
'weather/weather-moon-stars.json', # clear sky
'weather/weather-moon-stars.json', # nearly clear sky
'weather/weather-cloud-partly.json', # variable cloudiness
'weather/weather-cloud-partly.json', # halfclear sky
'weather/weather-cloudy.json', # cloudy sky
'weather/weather-cloudy.json', # overcast
'weather/weather-cloudy.json', # fog
'weather/weather-rain.json', # rain showers
'weather/weather-rain.json', # thunderstorm
'weather/weather-rain-snowy.json', # light sleet
'weather/weather-rain-snowy.json', # snow showers
'weather/weather-rain.json', # rain
'weather/weather-thunderstorm.json', # thunder
'weather/weather-rain-snowy.json', # sleet
'weather/weather-snow-house.json', # snowfall
['moon-stars.bin', 'sunny.bin'], # clear sky
['moon-stars.bin', 'sunny-with-clouds.bin'], # nearly clear sky
'cloud-partly.bin', # variable cloudiness
'sunny-with-clouds.bin', # halfclear sky
'cloudy.bin', # cloudy sky
'cloudy.bin', # overcast
'fog.bin', # fog
'rain.bin', # light rain showers
'rain.bin', # medium rain showers
'rain.bin', # heavy rain showers
'rain.bin', # thunderstorm
'rain-snow.bin', # light sleet showers
'rain-snow.bin', # medium sleet showers
'rain-snow.bin', # heavy sleet showers
'rain-snow.bin', # light snow showers
'rain-snow.bin', # medium snow showers
'rain-snow.bin', # heavy snow showers
'rain.bin', # light rain
'rain.bin', # medium rain
'rain.bin', # heavy rain
'thunderstorm.bin', # thunder
'rain-snowy.bin', # light sleet
'rain-snowy.bin', # medium sleet
'rain-snowy.bin', # heavy sleet
'snow-house.bin', # light snowfall
'snow-house.bin', # medium snowfall
'snow-house.bin', # heavy snowfall
]
self.frames = [[[]]]
self.delays = [0]
self.num_frames = 1
self.remaining_frames = 1
self.next_frame_at = 0
self.loops = 3
if not config:
return
if 'debug' in config:
self.debug = config['debug']
if 'intensity' in config:
self.intensity = int(round(config['intensity']*255))
if 'lat' in config:
self.lat = config['lat']
if 'lon' in config:
self.lon = config['lon']
def reset(self):
"""
This method is called before transitioning to this scene.
Use it to (re-)initialize any state necessary for your scene.
"""
self.remaining_frames = len(self.frames)*self.loops
self.next_frame_at = 0
self.reset_icon()
t = time.time()
if t < self.last_refreshed_at + 1800:
return
# fetch a new forecast from SMHI
print('WeatherScene: reset called, requesting weather forecast')
url = '{}/api/category/pmp2g/version/2/geotype/point/lon/{}/lat/{}/data.json'.format(self.api_url, self.lon, self.lat)
r = requests.get(url, headers=self.headers)
url = '{}/api/category/pmp3g/version/2/geotype/point/lon/{}/lat/{}/data.json'.format(self.api_url, self.lon, self.lat)
print('WeatherScene: reset called, requesting weather forecast from: {}'.format(url))
r = requests.get(url, headers=self.headers, stream=True)
if r.status_code != 200:
print('WeatherScene: failed to request {}: status {}'.format(url, r.status_code))
return
print('WeatherScene: parsing weather forecast')
next_hour = int(time.time())
next_hour = next_hour - next_hour%3600 + 3600
expected_timestamp = '{:04d}-{:02d}-{:02d}T{:02d}'.format(*time.gmtime(next_hour))
temp, ws, symb = self.get_forecast(r.raw, expected_timestamp)
# Close socket and free up RAM
r.close()
r = None
gc.collect()
forecast = None
expected_timestamp = '{:04d}-{:02d}-{:02d}T{:02d}'.format(*time.gmtime())
data = json.loads(r.text)
for ts in data['timeSeries']:
if ts['validTime'].startswith(expected_timestamp):
forecast = ts
break
if not forecast:
print('WeatherScene: failed to find forecast for UNIX timestamp {}'.format(this_hour))
if temp == None:
print('WeatherScene: failed to find forecast for timestamp prefix: {}'.format(expected_timestamp))
return
self.temperature = float(temp.decode())
self.wind_speed = float(ws.decode())
self.symbol = int(symb.decode())
self.last_refreshed_at = t
n = 0
for obj in forecast['parameters']:
if obj['name'] == 't':
self.temperature = obj['values'][0]
elif obj['name'] == 'ws':
self.wind_speed = obj['values'][0]
elif obj['name'] == 'Wsymb':
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
self.symbol = obj['values'][0]
self.symbol_version = 1
elif obj['name'] == 'Wsymb2':
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
self.symbol = obj['values'][0]
self.symbol_version = 2
else:
continue
n += 1
print('WeatherScene: updated {} parameters from forecast for {}'.format(n, forecast['validTime']))
filename = self.symbol_to_animation[self.symbol]
filename = self.symbol_to_icon[self.symbol]
if not filename:
return
f = open(filename)
obj = json.loads(f.read())
f.close()
obj = json.loads(obj['body'])
self.delays = obj['delays']
self.frames = obj['icons']
self.num_frames = len(self.frames)
self.remaining_frames = self.num_frames*4
def input(self, button_id, button_state):
print('WeatherScene: button {} pressed: {}'.format(button_id, button_state))
return False # signal that we did not handle the input
if type(filename) == list:
lt = time.localtime(next_hour)
if lt[3] < 7 or lt[3] > 21:
# Assume night icon
filename = filename[0]
else:
filename = filename[1]
if self.icon:
# MicroPython does not support destructors so we need to manually
# close the file we have opened
self.icon.close() # Close icon file
self.icon = Icon(self.dir_prefix + filename)
self.icon.set_intensity(self.intensity)
self.reset_icon()
def input(self, button_state):
"""
Handle button inputs
"""
return 0 # signal that we did not handle the input
def set_intensity(self, value=None):
if value is not None:
self.intensity -= 1
if not self.intensity:
self.intensity = 16
if self.icon:
self.icon.set_intensity(self.intensity)
return self.intensity
def render(self, frame, dropped_frames, fps):
"""
@@ -153,65 +172,98 @@ class WeatherScene:
self.remaining_frames -= 1
n = self.num_frames
index = n - (self.remaining_frames % n) - 1
# Calculate next frame
self.next_frame_at = frame + int(fps * self.delays[index]/1000)
# Calculate next frame number
self.next_frame_at = frame + int(fps * self.icon.frame_length()/1000)
# Render frame
display = self.display
data = self.frames[index]
for y in range(len(data)):
row = data[y]
for x in range(len(data)):
r = round(row[x][0] * 255)
g = round(row[x][0] * 255)
b = round(row[x][0] * 255)
display.put_pixel(x, y, r, g, b)
intensity = self.intensity
self.icon.blit(display, 0 if display.columns == 32 else 4, 0)
# Render text
if self.remaining_frames >= n:
text = '{:.2g}\'c'.format(self.temperature)
else:
text = '{:.2g}m/s'.format(self.wind_speed)
self.render_text(text)
if display.columns <= 16:
text = '{:.1g}m/s'.format(self.wind_speed)
display.render_text(PixelFont, text, 9 if display.columns == 32 else 0, 1 if display.columns == 32 else 10, intensity)
display.render()
if self.remaining_frames == 0:
return False
return True
def render_text(self, text, x_off = 8+1, y_off = 1):
"""
Render text with the pixel font
"""
display = self.display
f = self.font
w = f.width
h = f.height
alphabet = f.alphabet
font = f.data
for i in range(len(text)):
digit = text[i]
if digit in '.-\'' or text[i-1] in '.':
x_off -= 1
data_offset = alphabet.find(digit)
if data_offset < 0:
data_offset = 0
tmp = data_offset * w * h
font_byte = tmp >> 3
font_bit = tmp & 7
for row in range(h):
for col in range(w):
c = 0
if font[font_byte] & (1<<font_bit):
c = 255
font_bit += 1
if font_bit == 8:
font_byte += 1
font_bit = 0
display.put_pixel(x_off+col, y_off+row, c, c, c)
x_off += w
def reset_icon(self):
if not self.icon:
return
self.icon.reset()
self.num_frames = self.icon.num_frames
self.remaining_frames = self.num_frames*2
t_icon = self.icon.length_total()
# Ensure a minimum display time
for i in range(1,6):
if t_icon*i >= 4000:
break
self.remaining_frames += self.num_frames
def get_forecast(self, f, validTime):
"""
Extract temperature, windspeed, weather symbol from JSON response
"""
timeStr = None
tempStr = None
wsStr = None
symbStr = None
while True:
v = f.read(1)
if v != b'"':
continue
s = self.next_string(f)
if not timeStr:
if not s.startswith(b'validTime'):
continue
timeStr = self.next_string(f, 2)
if not timeStr.startswith(validTime):
timeStr = None
continue
elif not tempStr and s == b't':
tempStr = self.next_array_entry(f)
elif not wsStr and s == b'ws':
wsStr = self.next_array_entry(f)
elif not symbStr and s == b'Wsymb2':
symbStr = self.next_array_entry(f)
elif timeStr and tempStr and wsStr and symbStr:
break
return (tempStr, wsStr, symbStr)
if __name__ == '__main__':
# Debug API
scene = WeatherScene(None, None)
scene.reset()
def next_string(self, f, start_at = 0):
"""
Extract string value from JSON
"""
if start_at:
f.read(start_at)
stash = bytearray()
while True:
c = f.read(1)
if c == b'"':
break
stash.append(c[0])
return bytes(stash)
def next_array_entry(self, f):
"""
Extract first array entry from JSON
"""
in_array = False
stash = bytearray()
while True:
c = f.read(1)
if not in_array:
if c != b'[':
continue
in_array = True
continue
if c == b']' or c == b' ' or c == b',':
break
stash.append(c[0])
return bytes(stash)

View File

@@ -53,15 +53,6 @@ class WS2812:
self.spi = SPI(spi_bus, SPI.MASTER, baudrate=3200000, polarity=0, phase=1)
# turn LEDs off
self.show([])
def show(self, data):
"""
Show RGB data on LEDs. Expected data = [(R, G, B), ...] where R, G and B
are intensities of colors in range from 0 to 255. One RGB tuple for each
LED. Count of tuples may be less than count of connected LEDs.
"""
self.fill_buf(data)
self.send_buf()
def send_buf(self):
@@ -71,60 +62,33 @@ class WS2812:
disable_irq()
self.spi.write(self.buf)
enable_irq()
#gc.collect()
def update_buf(self, data, start=0):
"""
Fill a part of the buffer with RGB data.
Order of colors in buffer is changed from RGB to GRB because WS2812 LED
has GRB order of colors. Each color is represented by 4 bytes in buffer
(1 byte for each 2 bits).
Returns the index of the first unfilled LED
Note: If you find this function ugly, it's because speed optimisations
beated purity of code.
"""
buf = self.buf
buf_bytes = self.buf_bytes
intensity = self.intensity
mask = 0x03
index = start * 12
for red, green, blue in data:
# This saves 10ms for 256 leds
#red = int(red * intensity)
#green = int(green * intensity)
#blue = int(blue * intensity)
buf[index] = buf_bytes[green >> 6 & mask]
buf[index+1] = buf_bytes[green >> 4 & mask]
buf[index+2] = buf_bytes[green >> 2 & mask]
buf[index+3] = buf_bytes[green & mask]
buf[index+4] = buf_bytes[red >> 6 & mask]
buf[index+5] = buf_bytes[red >> 4 & mask]
buf[index+6] = buf_bytes[red >> 2 & mask]
buf[index+7] = buf_bytes[red & mask]
buf[index+8] = buf_bytes[blue >> 6 & mask]
buf[index+9] = buf_bytes[blue >> 4 & mask]
buf[index+10] = buf_bytes[blue >> 2 & mask]
buf[index+11] = buf_bytes[blue & mask]
index += 12
return index // 12
def fill_buf(self, data):
"""
Fill buffer with RGB data.
All LEDs after the data are turned off.
"""
end = self.update_buf(data)
# NOTE: show(), update_buf() and fill_buf() were replaced
# with these to reduce memory usage in pycomhal.py
def clear(self):
# turn off the rest of the LEDs
buf = self.buf
off = self.buf_bytes[0]
for index in range(end * 12, self.buf_length):
for index in range(self.buf_length):
buf[index] = off
index += 1
def put_pixel(self, addr, red, green, blue):
buf = self.buf
buf_bytes = self.buf_bytes
mask = 0x03
index = addr * 12
buf[index] = buf_bytes[green >> 6 & mask]
buf[index+1] = buf_bytes[green >> 4 & mask]
buf[index+2] = buf_bytes[green >> 2 & mask]
buf[index+3] = buf_bytes[green & mask]
buf[index+4] = buf_bytes[red >> 6 & mask]
buf[index+5] = buf_bytes[red >> 4 & mask]
buf[index+6] = buf_bytes[red >> 2 & mask]
buf[index+7] = buf_bytes[red & mask]
buf[index+8] = buf_bytes[blue >> 6 & mask]
buf[index+9] = buf_bytes[blue >> 4 & mask]
buf[index+10] = buf_bytes[blue >> 2 & mask]
buf[index+11] = buf_bytes[blue & mask]