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 **/*.json
**/*.bin
*~ *~
*.swp *.swp
*.pyc *.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 HOST_SHUTDOWN_PIN 8
#define BUTTON_PIN 12 #define LEFT_BUTTON_PIN 9
#define RIGHT_BUTTON_PIN 10
#define NUM_LEDS 256 #define NUM_LEDS 256
#ifdef TEENSYDUINO #ifdef TEENSYDUINO
@@ -55,8 +56,8 @@ enum {
/* Computed with pixelfont.py */ /* Computed with pixelfont.py */
static int font_width = 4; static int font_width = 4;
static int font_height = 5; static int font_height = 5;
static char font_alphabet[] = " %'-./0123456789:cms"; 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\x20\x15\x25\x75\x57\x75\x71\x74"; 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 */ /* Global states */
int state = 0; int state = 0;
@@ -76,8 +77,8 @@ CRGB leds[NUM_LEDS];
static volatile int g_button_state; static volatile int g_button_state;
static int button_down_t; static int button_down_t;
static void button_irq(void) { static void button_irq_left(void) {
int state = digitalRead(BUTTON_PIN); int state = digitalRead(LEFT_BUTTON_PIN);
if(state == HIGH) { if(state == HIGH) {
/* Start counting when the circuit is broken */ /* Start counting when the circuit is broken */
@@ -88,7 +89,9 @@ static void button_irq(void) {
return; return;
int pressed_for_ms = millis() - button_down_t; 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; g_button_state = 2;
else if(pressed_for_ms > 100) else if(pressed_for_ms > 100)
g_button_state = 1; g_button_state = 1;
@@ -96,6 +99,28 @@ static void button_irq(void) {
button_down_t = 0; 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() { void setup() {
Serial.begin(460800); Serial.begin(460800);
@@ -107,9 +132,11 @@ void setup() {
pinMode(HOST_SHUTDOWN_PIN, OUTPUT); pinMode(HOST_SHUTDOWN_PIN, OUTPUT);
digitalWrite(HOST_SHUTDOWN_PIN, HIGH); digitalWrite(HOST_SHUTDOWN_PIN, HIGH);
/* Configure pin for button */ /* Configure pins for the buttons */
pinMode(BUTTON_PIN, INPUT_PULLUP); pinMode(LEFT_BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), button_irq, CHANGE); 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 #ifdef TEENSYDUINO
/* Initialize time library */ /* Initialize time library */
@@ -130,17 +157,25 @@ void loop() {
#ifdef TEENSYDUINO #ifdef TEENSYDUINO
time_t now = getTeensy3Time(); time_t now = getTeensy3Time();
#else #else
int now = 42; int now = 0;
#endif #endif
int button_state = g_button_state; int button_state = g_button_state;
if(button_state) { if(button_state) {
g_button_state = 0; g_button_state = 0;
if(button_state == 1) if(button_state & 1)
Serial.println("BUTTON_SHRT_PRESS"); Serial.println("LEFT_SHRT_PRESS");
else else if(button_state & 2)
Serial.println("BUTTON_LONG_PRESS"); 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) { if(reboot_at && now >= reboot_at) {
@@ -238,6 +273,12 @@ void loop() {
show_time = !show_time; /* toggle */ show_time = !show_time; /* toggle */
else else
show_time = val; 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); Serial.printf("Automatic rendering of current time: %d\n", show_time);
state = FUNC_RESET; state = FUNC_RESET;
break; break;

551
README.md
View File

@@ -3,85 +3,96 @@
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
- [Animated LED matrix display](#animated-led-matrix-display) - [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) - [Building and deploying the MCU](#building-and-deploying-the-mcu)
- [Arduino](#arduino)
- [MicroPython](#micropython) - [MicroPython](#micropython)
- [Configuring the Raspberry Pi](#configuring-the-raspberry-pi) - [Arduino](#arduino)
- [Optional steps](#optional-steps)
- [Hardware](#hardware) - [Hardware](#hardware)
- [LED matrix display](#led-matrix-display) - [LED matrix display](#led-matrix-display)
- [MCUs](#mcus) - [MCUs](#mcus)
- [Teensy 3.1/3.2 pinout](#teensy-3132-pinout)
- [WiPy 3.0 pinout](#wipy-30-pinout) - [WiPy 3.0 pinout](#wipy-30-pinout)
- [Teensy 3.1/3.2 pinout](#teensy-3132-pinout)
- [Raspberry Pi](#raspberry-pi) - [Raspberry Pi](#raspberry-pi)
- [On the serial protocol](#on-the-serial-protocol)
- [Hacking](#hacking)
- [Wiring things up](#wiring-things-up) - [Wiring things up](#wiring-things-up)
- [LED matrix](#led-matrix) - [LED matrix](#led-matrix)
- [Button](#button) - [Button](#button)
- [Connecting second UART on Pycom module to Raspberry Pi](#connecting-second-uart-on-pycom-module-to-raspberry-pi) - [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) - [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) - [Credits](#credits)
<!-- END doctoc generated TOC please keep comment here to allow auto update --> <!-- END doctoc generated TOC please keep comment here to allow auto update -->
# Animated LED matrix display # 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. ![LED matrix animated](docs/lamatrix.gif)
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
Static picture with clock scene. For some reason the colors aren't captured as vidvid as they are in real life. 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) ![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: 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.
```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.
## Building and deploying the MCU ## 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 ### 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: Assuming you have an MCU which is supported by Arduino, try:
1. Download the latest version of Arduino 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_ 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 ## Hardware
### LED matrix display ### LED matrix display
@@ -204,35 +120,28 @@ Price: €35-45
For the 8x32 variant, you can 3D print a frame from these objects: 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) - [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. For diffusing the light emitted by the LEDS a paper works suprisingly well if it's tightly held to the grid.
### MCUs ### 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 - [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 - ESP-32 platform, 520kBytes SRAM + 4MBytes (external) pSRAM, 8MBytes flash, 802.11b/g/n 16Mbps WiFi
- Price: €20-25 - Price: €20-25
- Docs and pinout: https://docs.pycom.io/datasheets/development/wipy3 - 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. 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. 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
![WiPy 3.0 pinout](docs/wipy3-pinout.png) ![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 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 ### 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). 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 ## Wiring things up
To the extent possible I've attempted to choose the same set of board pins on both MCUs (microcontrollers). 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 ### 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 Left button pin 1 --> MCU: digital pin 9 on Teensy; P9 (a.k.a. GPIO21) pin on Pycom module
Button pin 2 --> MCU: GND pin 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 ### 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 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 # 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. 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 time
import json from icon import Icon
class AnimationScene: class AnimationScene:
"""Render animations from https://developer.lametric.com""" """Render animations from https://developer.lametric.com"""
def __init__(self, display, config): def __init__(self, display, config):
self.name = 'Animation'
self.display = display self.display = display
self.objs = [] self.debug = False
self.obj_i = 0 self.intensity = 16
self.icons = []
self.icon_id = 0
self.states = [] self.states = []
self.on_screen_objs = [] self.on_screen_icons = []
if config and 'files' in config: if not config:
for filename in config['files']: return
self.add_obj(filename) if 'debug' in config:
self.debug = config['debug']
def add_obj(self, filename): if 'intensity' in config:
# This method expects an animation as downloaded from LaMetric's developer site self.intensity = int(round(config['intensity']*255))
# if 'icons' in config:
# Example: for filename in config['icons']:
# curl -sA '' https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=4007 > blah.json self.add_icon(filename)
# self.set_intensity(self.intensity)
# 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)
def reset(self): def reset(self):
print('Animation: reset called, loading animation objects') while self.load_icon():
while self.load_obj():
pass 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 Handle button input
""" """
print('Animation: button {} pressed: {}'.format(button_id, button_state)) return 0 # signal that we did not handle the input
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
def render(self, frame, dropped_frames, fps): def render(self, frame, dropped_frames, fps):
t0 = time.time() t0 = time.time()
display = self.display display = self.display
intensity = self.intensity
unload_queue = [] unload_queue = []
for state in self.on_screen_objs: for state in self.on_screen_icons:
if frame < state['next_frame_at']: if frame < state['next_frame_at']:
continue continue
state['remaining_frames'] -= 1 state['remaining_frames'] -= 1
if state['remaining_frames'] == 0: if state['remaining_frames'] == 0:
# Queue object for removal # Queue icon for removal from screen
unload_queue.append(state['i']) unload_queue.append(state['i'])
n = state['num_frames'] n = state['num_frames']
index = n - (state['remaining_frames'] % n) - 1 index = n - (state['remaining_frames'] % n) - 1
data = state['frames'][index]
x_pos = state['x_pos'] x_pos = state['x_pos']
for y in range(len(data)): y_pos = state['y_pos']
row = data[y] icon = self.icons[state['i']]
for x in range(len(row)): # Do not repaint until some specified time in the future
r = round(row[x][0] * 255) state['next_frame_at'] = frame + int(fps * icon.frame_length() / 1000)
g = round(row[x][1] * 255) # Render icon
b = round(row[x][2] * 255) icon.blit(self.display, x_pos, y_pos)
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
t2 = time.time() t2 = time.time()
t1 = t2 - t0
display.render() display.render()
t3 = time.time() - t2 t3 = time.time()
print('AnimationScene: Spent {}ms plotting objects, {}ms updating LedMatrix+HAL, {}ms total'.format(round(1000*t1), round(1000*t2), round(1000*(time.time()-t0)))) 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: for i in unload_queue:
self.unload_obj(i) self.unload_icon(i)
if not self.on_screen_objs: if not self.on_screen_icons:
# Nothing more to display 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 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 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. # or under MicroPython.
# #
import serial import serial
import time
class ArduinoSerialHAL: class ArduinoSerialHAL:
""" """
@@ -17,12 +18,15 @@ class ArduinoSerialHAL:
def __init__(self, config): def __init__(self, config):
self.port = config['port'] self.port = config['port']
self.baudrate = config['baudrate'] self.baudrate = config['baudrate']
self.tz_adjust = config['tzOffsetSeconds']
self.ser = None # initialized in reset() self.ser = None # initialized in reset()
self.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: if not self.ser.in_waiting:
return None return None
@@ -39,6 +43,7 @@ class ArduinoSerialHAL:
print('SerialProtocol: opening port {} @ {} baud'.format(self.port, self.baudrate)) 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.ser = serial.Serial(self.port, baudrate=self.baudrate, rtscts=True, timeout=0.1, write_timeout=0.5)
self.resynchronize_protocol() self.resynchronize_protocol()
self.set_rtc(int(time.time()) + self.tz_adjust)
def resynchronize_protocol(self): 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. # 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 # 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'): if not hasattr(time, 'ticks_ms'):
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html # Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time() * 1000) time.ticks_ms = lambda: int(time.time() * 1000)
time.sleep_ms = lambda x: time.sleep(x/1000.0)
# Local imports # Local imports
from pixelfont import PixelFont from pixelfont import PixelFont
class ClockScene: class ClockScene:
def __init__(self, display, config): def __init__(self, display, config):
self.name = 'Clock'
self.display = display self.display = display
self.font = PixelFont()
self.button_state = 0 self.button_state = 0
# delete me self.debug = False
self.x_pos = 4 self.intensity = 16
self.y_pos = 0 self.date_was_shown = False
self.x_vel = 1 self.columns = display.columns
self.y_vel = 1 if not config:
self.step = 0 return
if 'debug' in config:
self.debug = config['debug']
if 'intensity' in config:
self.intensity = int(round(config['intensity']*255))
def reset(self): def reset(self):
"""
Unused in this scene
"""
pass pass
def input(self, button_id, button_state): def input(self, button_state):
""" if button_state & 0x22:
Handle button input # Handle long-press on either button
"""
if button_state == 1:
self.button_state ^= 1 self.button_state ^= 1
elif button_state == 2: self.display.clear()
return False return button_state & ~0x22
return True # signal that we handled the button
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): def render(self, frame, dropped_frames, fps):
""" """
Render the current time and day of week 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 = self.display
display.clear() intensity = self.intensity
x_off = 0 # Automatically switch to showing the date for a few secs
y_off = 0 tmp = fps << 6
tmp = ((fps << 4) + frame) % tmp
y_off = 1
(year, month, day, hour, minute, second, weekday, _) = time.localtime()[:8] (year, month, day, hour, minute, second, weekday, _) = time.localtime()[:8]
if not self.button_state: if not self.button_state and tmp > (fps<<2):
time_str = '{:02d}:{:02d}'.format(hour, minute) 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: if (int(time.ticks_ms() // 100.0) % 10) < 4:
time_str = time_str.replace(':', ' ') text = text.replace(':', ' ')
x_off = 8 display.render_text(PixelFont, text, 2, y_off, intensity)
else: else:
time_str = '{:02d}.{:02d}.{:02d}'.format(day, month, year % 100) text = '{:02d}'.format(hour)
x_off = 2 display.render_text(PixelFont, text, 4, y_off, intensity)
text = '{:02d}'.format(minute)
t2 = time.ticks_ms() display.render_text(PixelFont, text, 4, y_off+8, intensity)
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: else:
c = colors[0]+24 if self.columns == 32:
if c < 0: text = '{:02d}.{:02d}.{:02d}'.format(day, month, year % 100)
c = 0 display.render_text(PixelFont, text, 2, y_off, intensity)
elif c > 255: else:
c = 255 text = '{:02d}{:02d}'.format(day, month)
self.display.put_pixel(x, y, c, c, 2*c//3) display.render_text(PixelFont, text, 0, y_off, intensity)
if 1: display.put_pixel(7, y_off+PixelFont.height, intensity, intensity, intensity)
self.x_pos += self.x_vel text = '{:04d}'.format(year)
if self.x_pos < 1 or self.x_pos > 31-7: display.render_text(PixelFont, text, 0, y_off+8, intensity)
self.x_vel *= -1 self.date_was_shown = True
if (frame % 3) == 0:
self.y_pos += self.y_vel
if self.y_pos == 0 or self.y_pos >= 5:
self.y_vel *= -1
x_off = 2 if self.columns == 32 else 1
t3 = time.ticks_ms() lower_intensity = intensity // 3
x_off = 2
for i in range(7): for i in range(7):
color = 128 if i == weekday else 48 color = intensity if i == weekday else lower_intensity
x = x_off + (i << 2) b = (color << 1) // 7
display.put_pixel(x+0, 7, color, color, 2*color//5) display.put_pixel(x_off, 7, color, color, b)
display.put_pixel(x+1, 7, color, color, 2*color//5) if self.columns == 32:
display.put_pixel(x+2, 7, color, color, 2*color//5) display.put_pixel(x_off+1, 7, color, color, b)
t3 = time.ticks_ms() - t3 display.put_pixel(x_off+2, 7, color, color, b)
x_off += 4
else:
x_off += 2
t4 = time.ticks_ms()
display.render() 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: if self.button_state == 2:
self.button_state = 0 self.button_state = 0

View File

@@ -1,22 +1,54 @@
{
"networks": [
{ {
"ssid":"yourWiFiSSID", "ssid":"yourWiFiSSID",
"password":"yourWiFiPassword", "password":"yourWiFiPassword"
},
{
"ssid":"otherWiFiSSID",
"password":"otherWiFiPassword"
}
],
"port": "/dev/ttyACM0", "port": "/dev/ttyACM0",
"baudrate": 921600, "baudrate": 115200,
"remapConsole": false,
"sceneTimeout": 40,
"tzOffsetSeconds": 3600, "tzOffsetSeconds": 3600,
"AnimationScene": { "LedMatrix": {
"files": [ "debug": false,
"animations/game-pingpong.json", "columns": 32,
"animations/matrix.json", "stride": 8,
"animations/newyears.json", "fps": 10
"animations/tv-movie.json" },
"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, "lat": 59.3293,
"lon": 18.0686 "lon": 18.0686
} }

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python from pixelfont import PixelFont
class DemoScene: class DemoScene:
"""This module implements an example scene with a traveling pixel""" """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() update the display via self.display.put_pixel() and .render()
""" """
self.display = display self.display = display
self.x_pos = 0 # ..just an example self.intensity = 32
print('DemoScene: yay, initialized') 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): def reset(self):
""" """
@@ -20,12 +26,19 @@ class DemoScene:
self.x_pos = 0 self.x_pos = 0
print('DemoScene: here we go') print('DemoScene: here we go')
def input(self, button_id, button_state): def input(self, button_state):
""" """
Handle button input Handle button input
""" """
print('DemoScene: button {} pressed: {}'.format(button_id, button_state)) print('DemoScene: button state: {}'.format(button_state))
return False # signal that we did not handle the 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): def render(self, frame, dropped_frames, fps):
""" """
@@ -35,26 +48,23 @@ class DemoScene:
requested frames per second (FPS). requested frames per second (FPS).
""" """
time_in_seconds = frame * fps if (frame % fps) == 0:
if not time_in_seconds.is_integer():
# Only update pixel once every second # Only update pixel once every second
return True return True
y = 3 display = self.display
color = 64 intensity = self.intensity
self.display.clear()
self.display.put_pixel(self.x_pos, y, color, color, color >> 1) dot_x, dot_y = self.x_pos, 0
self.display.render() text_x, text_y = 2, 2
print('DemoScene: rendered a pixel at ({},{})'.format(self.x_pos, y)) 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 self.x_pos += 1
if self.x_pos == self.display.columns: if self.x_pos == display.columns:
return False # our work is done! return False # signal that our work is done
return True # we want to be called again 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) time.ticks_ms = lambda: int(time.time()*1000)
class LedMatrix: class LedMatrix:
rotation = 180 def __init__(self, driver, config):
# 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):
self.driver = driver self.driver = driver
self.columns = columns self.debug = False
self.rows = rows self.stride = 8
self.num_pixels = rows * columns 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 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 # This is laid out in physical order
self.fb.append(bytearray(self.num_pixels*3)) self.fb = [
self.fb.append(bytearray(self.num_pixels*3)) bytearray(self.num_pixels*3),
bytearray(self.num_pixels*3),
]
self.fb_index = 0 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 # Initialize display
self.driver.init_display(self.num_pixels) self.driver.init_display(self.num_pixels)
@@ -49,11 +55,11 @@ class LedMatrix:
pass pass
elif self.rotation < 180: elif self.rotation < 180:
tmp = x tmp = x
x = self.rows-1-y x = self.stride-1-y
y = tmp y = tmp
elif self.rotation < 270: elif self.rotation < 270:
x = self.columns-1-x x = self.columns-1-x
y = self.rows-1-y y = self.stride-1-y
else: else:
tmp = x tmp = x
x = y x = y
@@ -61,69 +67,41 @@ class LedMatrix:
# The LEDs are laid out in a long string going from north to south, # 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 # one step to the east, and then south to north, before the cycle
# starts over. # starts over.
# stride = self.stride
# Here we calculate the physical offset for the desired rotation, with phys_addr = x*stride
# the assumption that the first LED is at (0,0). if x & 1:
# We'll need this adjusting for the north-south-south-north layout phys_addr += stride - 1 - y
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
else: else:
phys_addr += y phys_addr += y
return phys_addr 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): def get_pixel(self, x, y):
""" """
Get pixel from the currently displayed frame buffer Get pixel from the currently displayed frame buffer
""" """
pixel = self.xy_to_phys(x, y) pixel = self.xy_to_phys(x, y)
back_index = (self.fb_index+1)%2 fb_id = (self.fb_index+1)%2
offset = pixel*3 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): def get_pixel_front(self, x, y):
""" """
Get pixel from the to-be-displayed frame buffer Get pixel from the to-be-displayed frame buffer
""" """
pixel = self.xy_to_phys(x, y) pixel = self.xy_to_phys(x, y)
back_index = (self.fb_index)%2 fb_id = (self.fb_index)%2
offset = pixel*3 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): def put_pixel(self, x, y, r, g, b):
""" """
Set pixel ni the to-be-displayed frame buffer" 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 return
pixel = self.xy_to_phys(x, y) pixel = self.xy_to_phys(x, y)
offset = pixel*3 offset = pixel*3
@@ -138,11 +116,67 @@ class LedMatrix:
""" """
Clear the frame buffer by setting all pixels to black Clear the frame buffer by setting all pixels to black
""" """
self.fb_index ^= 1 buf = self.fb[self.fb_index]
self.fb[self.fb_index][:] = self.fb[2][:] for i in range(self.num_pixels*3):
# Optimization: keep track of last updated pixel buf[i] = 0
self.num_modified_pixels = self.num_pixels 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): def render(self):
""" """
Render the to-be-displayed frame buffer by making put_pixel() and Render the to-be-displayed frame buffer by making put_pixel() and
@@ -152,6 +186,7 @@ class LedMatrix:
tX = t0 = time.ticks_ms() tX = t0 = time.ticks_ms()
front = self.fb[self.fb_index] front = self.fb[self.fb_index]
back = self.fb[self.fb_index ^ 1] back = self.fb[self.fb_index ^ 1]
put_pixel = self.driver.put_pixel
num_rendered = 0 num_rendered = 0
for pixel in range(self.num_modified_pixels): for pixel in range(self.num_modified_pixels):
# This crap saves about 4ms # This crap saves about 4ms
@@ -162,38 +197,88 @@ class LedMatrix:
g = front[j] g = front[j]
b = front[k] b = front[k]
if r != back[i] or g != back[j] or b != back[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) put_pixel(pixel, r, g, b)
num_rendered += 1 num_rendered += 1
t0 = time.ticks_ms() - t0
t1 = time.ticks_ms()
t0 = t1 - t0
# This takes 52ms # This takes 52ms
t1 = time.ticks_ms()
self.driver.update_display(self.num_modified_pixels) self.driver.update_display(self.num_modified_pixels)
t1 = time.ticks_ms() - t1 t2 = time.ticks_ms()
#time.sleep(0.00004 * self.columns * self.rows) t1 = t2 - t1
#time.sleep_ms(10)
# This takes 0ms # This takes 0ms
self.fb_index ^= 1 self.fb_index ^= 1
self.fb[self.fb_index][:] = self.fb[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 # Optimization: keep track of last updated pixel
self.num_modified_pixels = 0 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): if distance > 0:
for x in range(self.columns): z_start, z_end, delta = 0, self.columns, -1
self.put_pixel(x, i, 0, 0, 0) else:
for y in range(self.rows-1): z_start, z_end, delta = self.columns-1, -1, 1
for x in range(self.columns): if self.columns % distance:
colors = self.get_pixel(x, y) distance -= delta
self.put_pixel(x, y+1, colors[0], colors[1], colors[2]) 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() self.render()
#time.sleep(0.05)
return False return False
def fade(self): def fade(self):
@@ -220,14 +305,13 @@ class LedMatrix:
Scene transition effect: dissolve active pixels with LFSR Scene transition effect: dissolve active pixels with LFSR
""" """
active_pixels = 0 active_pixels = 0
for i in range(self.columns*self.rows): for y in range(self.stride):
colors = self.get_pixel(i % self.columns, i // self.columns) for x in range(self.columns):
colors = self.get_pixel(x, y)
if colors[0] or colors[1] or colors[2]: if colors[0] or colors[1] or colors[2]:
active_pixels += 1 active_pixels += 1
if not active_pixels: if not active_pixels:
# No more pixels to dissolve
return False return False
per_pixel_sleep = (0.1-0.00003*self.num_pixels)/active_pixels
pixel = 1 pixel = 1
for i in range(256): for i in range(256):
@@ -235,14 +319,12 @@ class LedMatrix:
pixel >>= 1 pixel >>= 1
if bit: if bit:
pixel ^= 0xb4 pixel ^= 0xb4
x, y = pixel % self.columns, pixel // self.columns
if pixel >= self.columns*self.rows: colors = self.get_pixel(x, y)
continue
colors = self.get_pixel(pixel % self.columns, pixel // self.columns)
if not colors[0] and not colors[1] and not colors[2]: if not colors[0] and not colors[1] and not colors[2]:
continue continue
self.put_pixel(pixel % self.columns, pixel // self.columns, 0, 0, 0) self.put_pixel(x, y, 0, 0, 0)
if i % 4 == 3:
self.render() self.render()
time.sleep(per_pixel_sleep)
# There are still pixels to dissolve # There are still pixels to dissolve
return True return True

290
main.py
View File

@@ -1,157 +1,52 @@
#!/usr/bin/env python #!/usr/bin/env python
# #
# This is a project to drive a 8x32 (or 8x8) LED matrix based on the # This is a project to drive a 32x8 or 16x16 LED matrix based on the popular
# popular WS2812 RGB LEDs using a microcontroller (e.g. a Teensy 3.x # WS2812 RGB LEDs using a microcontroller running MicroPython (preferrably
# or a Pycom module with 4MB RAM) and optionally control them both # one with 4MB RAM although modules with 1MB also work).
# using a more powerful host computer, such as a Raspberry Pi Zero W.
# #
# -- noah@hack.se, 2018 # -- noah@hack.se, 2018
# #
import sys import sys
import time import time
import gc
from math import ceil 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': if hasattr(sys,'implementation') and sys.implementation.name == 'micropython':
pycom_board = True
import ujson as json import ujson as json
import machine import machine
from network import WLAN from network import WLAN
# Local imports from os import uname
from pycomhal import PycomHAL 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: else:
pycom_board = False
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html # Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time() * 1000) time.ticks_ms = lambda: int(time.time() * 1000)
time.sleep_ms = lambda x: time.sleep(x/1000.0)
import json import json
import os import os
import sys
import signal import signal
# Local imports from arduinoserialhal import ArduinoSerialHAL as HAL
from arduinoserialhal import ArduinoSerialHAL
# Local imports gc.collect()
from ledmatrix import LedMatrix from renderloop import RenderLoop
from clockscene import ClockScene
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): def sigint_handler(sig, frame):
""" """
Clear display when the program is terminated by Ctrl-C or SIGTERM Clear display when the program is terminated by Ctrl-C or SIGTERM
@@ -166,19 +61,11 @@ if __name__ == '__main__':
f = open('config.json') f = open('config.json')
config = json.loads(f.read()) config = json.loads(f.read())
f.close() f.close()
del json
# Initialize HAL # Initialize HAL
if pycom_board: driver = HAL(config)
# We're running under MCU here if not esp8266_board and not pycom_board:
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:
# We're running on the host computer here # We're running on the host computer here
ports = [ ports = [
'/dev/tty.usbmodem575711', # Teensy 3.x on macOS '/dev/tty.usbmodem575711', # Teensy 3.x on macOS
@@ -188,57 +75,122 @@ if __name__ == '__main__':
] ]
for port in ports: for port in ports:
if os.path.exists(port): if os.path.exists(port):
config['port'] = port
break break
driver = ArduinoSerialHAL(config) # Disable automatic rendering of time
driver.set_rtc(time.time() + config['tzOffsetSeconds'])
driver.set_auto_time(False) driver.set_auto_time(False)
# Trap Ctrl-C and service termination # Trap Ctrl-C and service termination
signal.signal(signal.SIGINT, sigint_handler) signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler) signal.signal(signal.SIGTERM, sigint_handler)
# Initialize led matrix framebuffer on top of HAL # Initialize led matrix framebuffer on top of HAL
num_leds = 256 display = LedMatrix(driver, config['LedMatrix'])
rows = 8
cols = num_leds // rows
display = LedMatrix(driver, cols, rows, rotation=0)
driver.clear_display() driver.clear_display()
if pycom_board: if pycom_board:
# If we're running on the MCU then loop forever # We're running under MCU here
while True: from bootscene import BootScene
driver.serial_loop(display) 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 # This is where it all begins
r = RenderLoop(display, fps=10) r = RenderLoop(display, config)
scene = ClockScene(display, config['ClockScene']) if 'Clock' in config:
from clockscene import ClockScene
scene = ClockScene(display, config['Clock'])
r.add_scene(scene) r.add_scene(scene)
gc.collect()
if 'Demo' in config:
from demoscene import DemoScene
scene = DemoScene(display, config['Demo'])
r.add_scene(scene)
gc.collect()
if 'Weather' in config:
from weatherscene import WeatherScene from weatherscene import WeatherScene
scene = WeatherScene(display, config['WeatherScene']) scene = WeatherScene(display, config['Weather'])
r.add_scene(scene) r.add_scene(scene)
gc.collect()
from animationscene import AnimationScene if 'Fire' in config:
scene = AnimationScene(display, config['AnimationScene']) from firescene import FireScene
scene = FireScene(display, config['Fire'])
r.add_scene(scene) 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 # Render scenes forever
while True: while True:
button_pressed = 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: while True:
# Drain output from MCU and detect button presses # Drain output from MCU and detect button presses
line = driver.readline() line = driver.process_input()
if not line: if not line:
break break
event = line.strip() event = line.strip()
if event == 'BUTTON_SHRT_PRESS': if event == 'LEFTB_SHRT_PRESS':
button_pressed = 1 button_state = 1
elif event == 'BUTTON_LONG_PRESS': elif event == 'LEFTB_LONG_PRESS':
button_pressed = 2 button_state = 2
else: else:
print('MCU: {}'.format(event)) print('MCU: {}'.format(event))
r.next_frame(button_state)
r.next_frame(button_pressed)
if button_pressed:
button_state = 0

View File

@@ -1,109 +1,8 @@
#!/usr/bin/env python # 4x5 pixel font
# # See scripts/generate-pixelfont.py for details
# 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.
# #
class PixelFont: class PixelFont:
def __init__(self): width = 4
self.width = 4 height = 5
self.height = 5 alphabet = " %'-./0123456789:?acdefgiklmnoprstwxy"
self.alphabet = ' %\'-./0123456789:cms' 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")
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')

View File

@@ -11,53 +11,73 @@
# From https://raw.githubusercontent.com/Gadgetoid/wipy-WS2812/master/ws2812alt.py # From https://raw.githubusercontent.com/Gadgetoid/wipy-WS2812/master/ws2812alt.py
# ..via: https://forum.pycom.io/topic/2214/driving-ws2812-neopixel-led-strip/3 # ..via: https://forum.pycom.io/topic/2214/driving-ws2812-neopixel-led-strip/3
from ws2812 import WS2812 from ws2812 import WS2812
#from rmt import WS2812 from machine import Pin, RTC, UART
from machine import Pin, RTC, UART, idle
import utime import utime
import os import os
import sys import sys
import pycom import pycom
import gc import gc
# Local imports
from clockscene import ClockScene
from weatherscene import WeatherScene
class PycomHAL: class PycomHAL:
def __init__(self, config): def __init__(self, config):
self.chain = None # will be initialized in reset() self.chain = None # will be initialized in reset()
self.num_pixels = 256 self.num_pixels = 256
self.pixels = []
self.reset() self.reset()
self.enable_auto_time = True 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 # A Raspberry Pi will reboot/wake up if this pin is set low
# https://docs.pycom.io/firmwareapi/pycom/machine/pin.html#pinholdhold # 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 = Pin('P8', Pin.OUT, Pin.PULL_UP)
self.suspend_host_pin.hold(True) self.suspend_host_pin.hold(True)
# Handle button input # Handle button input
self.button_pin = Pin('P12', Pin.IN, Pin.PULL_UP) self.left_button = Pin('P9', 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.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_state = 0
self.button_down_t = 0 self.button_down_t = 0
# Setup RTC # Setup RTC
self.rtc = None self.rtc = None
self.set_rtc(0)
utime.timezone(config['tzOffsetSeconds']) utime.timezone(config['tzOffsetSeconds'])
pycom.heartbeat(False) 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 # 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.reboot_at = 0
self.state = 0 self.state = 0
self.acc = 0 self.acc = 0
self.color = 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): def button_irq(self, pin):
""" """
@@ -69,16 +89,27 @@ class PycomHAL:
return return
if not self.button_down_t: if not self.button_down_t:
return return
t = utime.ticks_ms() - self.button_down_t t = utime.ticks_ms() - self.button_down_t
if t > 500: shift = 0 if pin == self.left_button else 4
self.button_state = 2 if t > 1500:
self.button_state |= 1<<(shift+2)
elif t > 500:
self.button_state |= 1<<(shift+1)
elif t > 80: elif t > 80:
self.button_state = 1 self.button_state |= 1<<(shift+0)
self.button_down_t = 0 self.button_down_t = 0
# Implement the serial protocol understood by ArduinoSerialHAL # Implement the serial protocol understood by ArduinoSerialHAL
# This function should be similar to the Arduino project's loop() # 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 self.reboot_at:
if utime.time() > self.reboot_at: if utime.time() > self.reboot_at:
self.reboot_at = 0 self.reboot_at = 0
@@ -89,56 +120,46 @@ class PycomHAL:
self.suspend_host_pin(1) self.suspend_host_pin(1)
self.suspend_host_pin.hold(True) self.suspend_host_pin.hold(True)
if not self.uart: # Process button input
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')
button_state = self.button_state button_state = self.button_state
if button_state: if button_state:
if button_state == 1: try:
print('BUTTON_SHRT_PRESS') if button_state & 1:
elif button_state == 2: # Notify the host about the button press in a similar manner
print('BUTTON_LONG_PRESS') # 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 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() avail = self.uart.any()
if not avail: 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: if avail > 256:
# Currently shipping releases have a 512 byte buffer # Currently shipping releases have a 512 byte buffer
print('HAL: More than 256 bytes available: {}'.format(avail)) print('HAL: More than 256 bytes available: {}'.format(avail))
data = self.uart.readall() self.uart.readinto(self.rxbuf)
for val in data: for val in self.rxbuf:
if self.state == 0: 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 self.state = val
elif self.state >= ord('i') and self.state <= ord('i')+1: elif self.state >= ord('i') and self.state <= ord('i')+1:
# init display # init display
@@ -192,6 +213,7 @@ class PycomHAL:
self.set_auto_time(not self.enable_auto_time) self.set_auto_time(not self.enable_auto_time)
else: else:
self.set_auto_time(bool(val)) self.set_auto_time(bool(val))
self.clear_display()
print('HAL: Automatic rendering of time is now: {}'.format(self.enable_auto_time)) print('HAL: Automatic rendering of time is now: {}'.format(self.enable_auto_time))
self.state = 0 # reset state self.state = 0 # reset state
elif self.state >= ord('@') and self.state <= ord('@')+3: elif self.state >= ord('@') and self.state <= ord('@')+3:
@@ -211,44 +233,48 @@ class PycomHAL:
else: else:
print('HAL: Unhandled state: {}'.format(self.state)) print('HAL: Unhandled state: {}'.format(self.state))
self.state = 0 # reset state self.state = 0 # reset state
return button_state
def readline(self):
"""
No-op in this implementation
"""
return None
def reset(self): def reset(self):
print('HAL: Reset called') 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): def init_display(self, num_pixels=256):
print('HAL: Initializing display with {} pixels'.format(num_pixels)) print('HAL: Initializing display with {} pixels'.format(num_pixels))
self.num_pixels = num_pixels self.num_pixels = num_pixels
self.pixels = [(0,0,0) for _ in range(self.num_pixels)] self.chain.clear()
self.clear_display() self.chain.send_buf()
def clear_display(self): 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) self.update_display(self.num_pixels)
def update_display(self, num_modified_pixels): def update_display(self, num_modified_pixels):
if not num_modified_pixels: if not num_modified_pixels:
return return
self.chain.show(self.pixels[:num_modified_pixels]) self.chain.send_buf()
gc.collect()
def put_pixel(self, addr, r, g, b): 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 # Resynchronize RTC
self.rtc = RTC() self.rtc = RTC()
self.rtc.ntp_sync('ntps1-1.eecsit.tu-berlin.de') self.rtc.ntp_sync('ntps1-1.eecsit.tu-berlin.de')
print('HAL: Waiting for NTP sync') print('HAL: Waiting for NTP sync')
if type(scene) != int:
# Kludge: render RTC sync progress
frame = 0
while not self.rtc.synced(): while not self.rtc.synced():
idle() scene.render(frame, 0, 0)
frame += 1
print('HAL: RTC synched') print('HAL: RTC synched')
def set_auto_time(self, enable=True): def set_auto_time(self, enable=True):
@@ -256,6 +282,7 @@ class PycomHAL:
Enable rendering of current time without involvment from host computer Enable rendering of current time without involvment from host computer
""" """
self.enable_auto_time = enable self.enable_auto_time = enable
gc.collect()
def suspend_host(self, restart_timeout_seconds): def suspend_host(self, restart_timeout_seconds):
""" """
@@ -269,17 +296,3 @@ class PycomHAL:
self.suspend_host_pin(0) self.suspend_host_pin(0)
self.suspend_host_pin(1) self.suspend_host_pin(1)
self.suspend_host_pin.hold(True) 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: Assuming you're in the directory where you cloned this Git repository (i.e. one level up from here), try:
```bash ```bash
curl -o weather/weather-cloud-partly.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2286 curl -o weather/sunny.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=1338
curl -o weather/weather-cloudy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=12019 curl -o weather/sunny-with-clouds.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=8756
curl -o weather/weather-moon-stars.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16310 curl -o weather/cloud-partly.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2286
curl -o weather/weather-rain-snow.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=160 curl -o weather/cloudy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=12019
curl -o weather/weather-rain.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=72 curl -o weather/fog.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=17056
curl -o weather/weather-snow-house.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=7075 curl -o weather/moon-stars.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16310
curl -o weather/weather-snowy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2289 curl -o weather/rain-snow.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=160
curl -o weather/weather-thunderstorm.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=11428 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 # Render the current weather forecast from SMHI.se
# #
import os
import time import time
import gc
try: try:
import ujson as json
import urequests as requests import urequests as requests
except ImportError: except ImportError:
import json
import requests import requests
# Local imports # Local imports
from pixelfont import PixelFont from pixelfont import PixelFont
from icon import Icon
# Based on demoscene.py # Based on demoscene.py
class WeatherScene: class WeatherScene:
@@ -20,6 +17,8 @@ class WeatherScene:
This module displays a weather forecast from SMHI (Sweden) This module displays a weather forecast from SMHI (Sweden)
""" """
dir_prefix = 'weather/'
def __init__(self, display, config): def __init__(self, display, config):
""" """
Initialize the module. Initialize the module.
@@ -27,117 +26,137 @@ class WeatherScene:
update the display via self.display.put_pixel() and .render() update the display via self.display.put_pixel() and .render()
""" """
self.display = display self.display = display
self.font = PixelFont() self.icon = None
self.last_refreshed_at = 0 self.debug = False
self.api_url = 'https://opendata-download-metfcst.smhi.se' self.intensity = 16
self.lat = 59.3293 self.lat = 59.3293
self.lon = 18.0686 self.lon = 18.0686
if config: 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_to_icon = [
None,
['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.num_frames = 1
self.remaining_frames = 1
self.next_frame_at = 0
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: if 'lat' in config:
self.lat = config['lat'] self.lat = config['lat']
if 'lon' in config: if 'lon' in config:
self.lon = config['lon'] self.lon = config['lon']
self.headers = {
'User-Agent':'weatherscene.py/1.0 (+https://github.com/noahwilliamsson/lamatrix)',
}
self.temperature = 0
self.wind_speed = 0
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
self.symbol = None
self.symbol_version = 1
self.symbol_to_animation = [
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
]
self.frames = [[[]]]
self.delays = [0]
self.remaining_frames = 1
self.next_frame_at = 0
self.loops = 3
def reset(self): def reset(self):
""" """
This method is called before transitioning to this scene. This method is called before transitioning to this scene.
Use it to (re-)initialize any state necessary for your 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.next_frame_at = 0
self.reset_icon()
t = time.time() t = time.time()
if t < self.last_refreshed_at + 1800: if t < self.last_refreshed_at + 1800:
return return
# fetch a new forecast from SMHI # fetch a new forecast from SMHI
print('WeatherScene: reset called, requesting weather forecast') url = '{}/api/category/pmp3g/version/2/geotype/point/lon/{}/lat/{}/data.json'.format(self.api_url, self.lon, self.lat)
url = '{}/api/category/pmp2g/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)
r = requests.get(url, headers=self.headers, stream=True)
if r.status_code != 200: if r.status_code != 200:
print('WeatherScene: failed to request {}: status {}'.format(url, r.status_code)) print('WeatherScene: failed to request {}: status {}'.format(url, r.status_code))
return return
print('WeatherScene: parsing weather forecast') 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 if temp == None:
expected_timestamp = '{:04d}-{:02d}-{:02d}T{:02d}'.format(*time.gmtime()) print('WeatherScene: failed to find forecast for timestamp prefix: {}'.format(expected_timestamp))
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))
return return
self.temperature = float(temp.decode())
self.wind_speed = float(ws.decode())
self.symbol = int(symb.decode())
self.last_refreshed_at = t self.last_refreshed_at = t
n = 0 filename = self.symbol_to_icon[self.symbol]
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]
if not filename: if not filename:
return 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): if type(filename) == list:
print('WeatherScene: button {} pressed: {}'.format(button_id, button_state)) lt = time.localtime(next_hour)
return False # signal that we did not handle the input 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): def render(self, frame, dropped_frames, fps):
""" """
@@ -153,65 +172,98 @@ class WeatherScene:
self.remaining_frames -= 1 self.remaining_frames -= 1
n = self.num_frames n = self.num_frames
index = n - (self.remaining_frames % n) - 1 index = n - (self.remaining_frames % n) - 1
# Calculate next frame # Calculate next frame number
self.next_frame_at = frame + int(fps * self.delays[index]/1000) self.next_frame_at = frame + int(fps * self.icon.frame_length()/1000)
# Render frame # Render frame
display = self.display display = self.display
data = self.frames[index] intensity = self.intensity
for y in range(len(data)): self.icon.blit(display, 0 if display.columns == 32 else 4, 0)
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)
# Render text # Render text
if self.remaining_frames >= n: if self.remaining_frames >= n:
text = '{:.2g}\'c'.format(self.temperature) text = '{:.2g}\'c'.format(self.temperature)
else: else:
text = '{:.2g}m/s'.format(self.wind_speed) 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() display.render()
if self.remaining_frames == 0: if self.remaining_frames == 0:
return False return False
return True return True
def render_text(self, text, x_off = 8+1, y_off = 1): def reset_icon(self):
""" if not self.icon:
Render text with the pixel font return
""" self.icon.reset()
display = self.display self.num_frames = self.icon.num_frames
f = self.font self.remaining_frames = self.num_frames*2
w = f.width t_icon = self.icon.length_total()
h = f.height # Ensure a minimum display time
alphabet = f.alphabet for i in range(1,6):
font = f.data if t_icon*i >= 4000:
for i in range(len(text)): break
digit = text[i] self.remaining_frames += self.num_frames
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 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__': def next_string(self, f, start_at = 0):
# Debug API """
scene = WeatherScene(None, None) Extract string value from JSON
scene.reset() """
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) self.spi = SPI(spi_bus, SPI.MASTER, baudrate=3200000, polarity=0, phase=1)
# turn LEDs off # 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() self.send_buf()
def send_buf(self): def send_buf(self):
@@ -71,31 +62,22 @@ class WS2812:
disable_irq() disable_irq()
self.spi.write(self.buf) self.spi.write(self.buf)
enable_irq() enable_irq()
#gc.collect()
def update_buf(self, data, start=0): # NOTE: show(), update_buf() and fill_buf() were replaced
""" # with these to reduce memory usage in pycomhal.py
Fill a part of the buffer with RGB data. def clear(self):
Order of colors in buffer is changed from RGB to GRB because WS2812 LED # turn off the rest of the LEDs
has GRB order of colors. Each color is represented by 4 bytes in buffer buf = self.buf
(1 byte for each 2 bits). off = self.buf_bytes[0]
Returns the index of the first unfilled LED for index in range(self.buf_length):
Note: If you find this function ugly, it's because speed optimisations buf[index] = off
beated purity of code. index += 1
"""
def put_pixel(self, addr, red, green, blue):
buf = self.buf buf = self.buf
buf_bytes = self.buf_bytes buf_bytes = self.buf_bytes
intensity = self.intensity
mask = 0x03 mask = 0x03
index = start * 12 index = addr * 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] = buf_bytes[green >> 6 & mask]
buf[index+1] = buf_bytes[green >> 4 & mask] buf[index+1] = buf_bytes[green >> 4 & mask]
buf[index+2] = buf_bytes[green >> 2 & mask] buf[index+2] = buf_bytes[green >> 2 & mask]
@@ -110,21 +92,3 @@ class WS2812:
buf[index+9] = buf_bytes[blue >> 4 & mask] buf[index+9] = buf_bytes[blue >> 4 & mask]
buf[index+10] = buf_bytes[blue >> 2 & mask] buf[index+10] = buf_bytes[blue >> 2 & mask]
buf[index+11] = buf_bytes[blue & 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)
# turn off the rest of the LEDs
buf = self.buf
off = self.buf_bytes[0]
for index in range(end * 12, self.buf_length):
buf[index] = off
index += 1