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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
**/*.json
|
**/*.json
|
||||||
|
**/*.bin
|
||||||
*~
|
*~
|
||||||
*.swp
|
*.swp
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
6862
3d-parts/backsideFrame32x8.stl
Normal file
6862
3d-parts/backsideFrame32x8.stl
Normal file
File diff suppressed because it is too large
Load Diff
318
3d-parts/lamatrix.scad
Normal file
318
3d-parts/lamatrix.scad
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6666
3d-parts/squareBacksideFrame16x16.stl
Normal file
6666
3d-parts/squareBacksideFrame16x16.stl
Normal file
File diff suppressed because it is too large
Load Diff
6666
3d-parts/squareBacksideFrame8x8.stl
Normal file
6666
3d-parts/squareBacksideFrame8x8.stl
Normal file
File diff suppressed because it is too large
Load Diff
63926
3d-parts/squareDiffuserGrid16x16.stl
Normal file
63926
3d-parts/squareDiffuserGrid16x16.stl
Normal file
File diff suppressed because it is too large
Load Diff
16438
3d-parts/squareDiffuserGrid8x8.stl
Normal file
16438
3d-parts/squareDiffuserGrid8x8.stl
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
@@ -158,7 +193,7 @@ void loop() {
|
|||||||
show_time = now;
|
show_time = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Serial.available() <= 0) return;
|
if (Serial.available() <= 0) return;
|
||||||
int val = Serial.read();
|
int val = Serial.read();
|
||||||
last_states[last_state_counter++ % (sizeof(last_states)/sizeof(last_states[0]))] = val;
|
last_states[last_state_counter++ % (sizeof(last_states)/sizeof(last_states[0]))] = val;
|
||||||
@@ -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;
|
||||||
@@ -358,7 +399,7 @@ static void render_clock(int button_state) {
|
|||||||
put_pixel(x_off+x, y, 255);
|
put_pixel(x_off+x, y, 255);
|
||||||
else
|
else
|
||||||
put_pixel(x_off+x, y, 0);
|
put_pixel(x_off+x, y, 0);
|
||||||
|
|
||||||
if(++font_bit == 8) {
|
if(++font_bit == 8) {
|
||||||
font_byte++;
|
font_byte++;
|
||||||
font_bit = 0;
|
font_bit = 0;
|
||||||
553
README.md
553
README.md
@@ -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.
|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Source: https://www.pjrc.com/teensy/teensyLC.html
|
|
||||||
|
|
||||||
|
|
||||||
#### WiPy 3.0 pinout
|
#### WiPy 3.0 pinout
|
||||||
|
|
||||||

|

|
||||||
@@ -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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
70
bootscene.py
Executable 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
|
||||||
174
clockscene.py
174
clockscene.py
@@ -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:
|
||||||
if (int(time.ticks_ms() // 100.0) % 10) < 4:
|
display.clear()
|
||||||
time_str = time_str.replace(':', ' ')
|
self.date_was_shown = False
|
||||||
x_off = 8
|
if self.columns == 32:
|
||||||
|
text = ' {:02d}:{:02d} '.format(hour, minute)
|
||||||
|
if (int(time.ticks_ms() // 100.0) % 10) < 4:
|
||||||
|
text = text.replace(':', ' ')
|
||||||
|
display.render_text(PixelFont, text, 2, y_off, intensity)
|
||||||
|
else:
|
||||||
|
text = '{:02d}'.format(hour)
|
||||||
|
display.render_text(PixelFont, text, 4, y_off, intensity)
|
||||||
|
text = '{:02d}'.format(minute)
|
||||||
|
display.render_text(PixelFont, text, 4, y_off+8, intensity)
|
||||||
else:
|
else:
|
||||||
time_str = '{:02d}.{:02d}.{:02d}'.format(day, month, year % 100)
|
if self.columns == 32:
|
||||||
x_off = 2
|
text = '{:02d}.{:02d}.{:02d}'.format(day, month, year % 100)
|
||||||
|
display.render_text(PixelFont, text, 2, y_off, intensity)
|
||||||
|
else:
|
||||||
|
text = '{:02d}{:02d}'.format(day, month)
|
||||||
|
display.render_text(PixelFont, text, 0, y_off, intensity)
|
||||||
|
display.put_pixel(7, y_off+PixelFont.height, intensity, intensity, intensity)
|
||||||
|
text = '{:04d}'.format(year)
|
||||||
|
display.render_text(PixelFont, text, 0, y_off+8, intensity)
|
||||||
|
self.date_was_shown = True
|
||||||
|
|
||||||
t2 = time.ticks_ms()
|
x_off = 2 if self.columns == 32 else 1
|
||||||
|
lower_intensity = intensity // 3
|
||||||
alphabet = self.font.alphabet
|
|
||||||
font_data = self.font.data
|
|
||||||
font_height = self.font.height
|
|
||||||
font_width = self.font.width
|
|
||||||
for i in range(len(time_str)):
|
|
||||||
digit = time_str[i]
|
|
||||||
if digit in ':. ' or time_str[i-1] in ':. ':
|
|
||||||
# Kludge to compress rendering of colon
|
|
||||||
x_off -= 1
|
|
||||||
|
|
||||||
data_offset = alphabet.find(digit)
|
|
||||||
if data_offset < 0:
|
|
||||||
data_offset = 0
|
|
||||||
tmp = (data_offset * font_height) << 2 # optimization: multiply by font with
|
|
||||||
font_byte = tmp >> 3 # optimization: divide by number of bits
|
|
||||||
font_bit = tmp & 7 # optimization: modulo number of bits
|
|
||||||
for row in range(font_height):
|
|
||||||
for col in range(font_width):
|
|
||||||
val = 0
|
|
||||||
if font_data[font_byte] & (1 << font_bit):
|
|
||||||
val = 255
|
|
||||||
font_bit += 1
|
|
||||||
if font_bit == 8:
|
|
||||||
font_byte += 1
|
|
||||||
font_bit = 0
|
|
||||||
display.put_pixel(x_off+col, y_off+row, val, val, val)
|
|
||||||
# Per letter offset
|
|
||||||
x_off += 4
|
|
||||||
t2 = time.ticks_ms() - t2
|
|
||||||
|
|
||||||
if 0:
|
|
||||||
# Flare effect.. lame
|
|
||||||
print('Clock: kernel at {},{} to {},{}'.format(self.x_pos, self.y_pos, self.x_pos+1,self.y_pos+1))
|
|
||||||
for i in range(3):
|
|
||||||
y = self.y_pos+i
|
|
||||||
for j in range(6):
|
|
||||||
x = self.x_pos+j
|
|
||||||
colors = self.display.get_pixel_front(x, y)
|
|
||||||
if not sum(colors):
|
|
||||||
continue
|
|
||||||
if j in [0,1,4,5]:
|
|
||||||
c = colors[0]-24
|
|
||||||
else:
|
|
||||||
c = colors[0]+24
|
|
||||||
if c < 0:
|
|
||||||
c = 0
|
|
||||||
elif c > 255:
|
|
||||||
c = 255
|
|
||||||
self.display.put_pixel(x, y, c, c, 2*c//3)
|
|
||||||
if 1:
|
|
||||||
self.x_pos += self.x_vel
|
|
||||||
if self.x_pos < 1 or self.x_pos > 31-7:
|
|
||||||
self.x_vel *= -1
|
|
||||||
if (frame % 3) == 0:
|
|
||||||
self.y_pos += self.y_vel
|
|
||||||
if self.y_pos == 0 or self.y_pos >= 5:
|
|
||||||
self.y_vel *= -1
|
|
||||||
|
|
||||||
|
|
||||||
t3 = time.ticks_ms()
|
|
||||||
x_off = 2
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
56
config.json
56
config.json
@@ -1,22 +1,54 @@
|
|||||||
{
|
{
|
||||||
"ssid":"yourWiFiSSID",
|
"networks": [
|
||||||
"password":"yourWiFiPassword",
|
{
|
||||||
|
"ssid":"yourWiFiSSID",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
54
demoscene.py
54
demoscene.py
@@ -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
BIN
docs/lamatrix.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
83
firescene.py
Executable file
83
firescene.py
Executable 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
67
icon.py
Normal 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
20
icons/README.md
Normal 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.
|
||||||
278
ledmatrix.py
278
ledmatrix.py
@@ -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):
|
||||||
if colors[0] or colors[1] or colors[2]:
|
colors = self.get_pixel(x, y)
|
||||||
active_pixels += 1
|
if colors[0] or colors[1] or colors[2]:
|
||||||
|
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)
|
||||||
self.render()
|
if i % 4 == 3:
|
||||||
time.sleep(per_pixel_sleep)
|
self.render()
|
||||||
# There are still pixels to dissolve
|
# There are still pixels to dissolve
|
||||||
return True
|
return True
|
||||||
|
|||||||
312
main.py
312
main.py
@@ -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:
|
||||||
r.add_scene(scene)
|
from clockscene import ClockScene
|
||||||
|
scene = ClockScene(display, config['Clock'])
|
||||||
|
r.add_scene(scene)
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
from weatherscene import WeatherScene
|
if 'Demo' in config:
|
||||||
scene = WeatherScene(display, config['WeatherScene'])
|
from demoscene import DemoScene
|
||||||
r.add_scene(scene)
|
scene = DemoScene(display, config['Demo'])
|
||||||
|
r.add_scene(scene)
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
from animationscene import AnimationScene
|
if 'Weather' in config:
|
||||||
scene = AnimationScene(display, config['AnimationScene'])
|
from weatherscene import WeatherScene
|
||||||
r.add_scene(scene)
|
scene = WeatherScene(display, config['Weather'])
|
||||||
|
r.add_scene(scene)
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
if 'Fire' in config:
|
||||||
|
from firescene import FireScene
|
||||||
|
scene = FireScene(display, config['Fire'])
|
||||||
|
r.add_scene(scene)
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
if 'Animation' in config:
|
||||||
|
from animationscene import AnimationScene
|
||||||
|
scene = AnimationScene(display, config['Animation'])
|
||||||
|
r.add_scene(scene)
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# Now that we're all setup, release the large chunk
|
||||||
|
large_temp_chunk = None
|
||||||
|
|
||||||
# Render scenes forever
|
# Render scenes forever
|
||||||
while True:
|
while True:
|
||||||
button_pressed = 0
|
# Process input
|
||||||
while True:
|
button_state = 0
|
||||||
# Drain output from MCU and detect button presses
|
if pycom_board:
|
||||||
line = driver.readline()
|
# When running under MicroPython on the MCU we need to deal with
|
||||||
if not line:
|
# any input coming from the host over the serial link
|
||||||
break
|
while True:
|
||||||
event = line.strip()
|
button_state = driver.process_input()
|
||||||
if event == 'BUTTON_SHRT_PRESS':
|
if driver.enable_auto_time:
|
||||||
button_pressed = 1
|
# If the host disconnected we'll hand over control to the
|
||||||
elif event == 'BUTTON_LONG_PRESS':
|
# game loop which will take care of updating the display
|
||||||
button_pressed = 2
|
break
|
||||||
else:
|
else:
|
||||||
print('MCU: {}'.format(event))
|
# When running under regular Python on the host computer we need
|
||||||
|
# to pick up any button presses sent over the serial link from
|
||||||
r.next_frame(button_pressed)
|
# the Arduino firmware
|
||||||
if button_pressed:
|
n = 0
|
||||||
button_state = 0
|
while True:
|
||||||
|
# Drain output from MCU and detect button presses
|
||||||
|
line = driver.process_input()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
event = line.strip()
|
||||||
|
if event == 'LEFTB_SHRT_PRESS':
|
||||||
|
button_state = 1
|
||||||
|
elif event == 'LEFTB_LONG_PRESS':
|
||||||
|
button_state = 2
|
||||||
|
else:
|
||||||
|
print('MCU: {}'.format(event))
|
||||||
|
r.next_frame(button_state)
|
||||||
|
|||||||
113
pixelfont.py
113
pixelfont.py
@@ -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')
|
|
||||||
|
|||||||
197
pycomhal.py
197
pycomhal.py
@@ -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')
|
||||||
while not self.rtc.synced():
|
if type(scene) != int:
|
||||||
idle()
|
# Kludge: render RTC sync progress
|
||||||
|
frame = 0
|
||||||
|
while not self.rtc.synced():
|
||||||
|
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
173
renderloop.py
Executable 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
48
scripts/convert-animation.py
Executable 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
105
scripts/generate-pixelfont.py
Executable 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
35
upyhal.py
Normal 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
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
304
weatherscene.py
304
weatherscene.py
@@ -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'
|
||||||
if 'lat' in config:
|
|
||||||
self.lat = config['lat']
|
|
||||||
if 'lon' in config:
|
|
||||||
self.lon = config['lon']
|
|
||||||
|
|
||||||
self.headers = {
|
self.headers = {
|
||||||
'User-Agent':'weatherscene.py/1.0 (+https://github.com/noahwilliamsson/lamatrix)',
|
'User-Agent':'weatherscene.py/1.0 (+https://github.com/noahwilliamsson/lamatrix)',
|
||||||
|
'Accept-Encoding': 'identity',
|
||||||
}
|
}
|
||||||
self.temperature = 0
|
self.temperature = 0
|
||||||
self.wind_speed = 0
|
self.wind_speed = 0
|
||||||
|
self.last_refreshed_at = 0
|
||||||
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
|
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
|
||||||
self.symbol = None
|
self.symbol = None
|
||||||
self.symbol_version = 1
|
self.symbol_to_icon = [
|
||||||
self.symbol_to_animation = [
|
|
||||||
None,
|
None,
|
||||||
'weather/weather-moon-stars.json', # clear sky
|
['moon-stars.bin', 'sunny.bin'], # clear sky
|
||||||
'weather/weather-moon-stars.json', # nearly clear sky
|
['moon-stars.bin', 'sunny-with-clouds.bin'], # nearly clear sky
|
||||||
'weather/weather-cloud-partly.json', # variable cloudiness
|
'cloud-partly.bin', # variable cloudiness
|
||||||
'weather/weather-cloud-partly.json', # halfclear sky
|
'sunny-with-clouds.bin', # halfclear sky
|
||||||
'weather/weather-cloudy.json', # cloudy sky
|
'cloudy.bin', # cloudy sky
|
||||||
'weather/weather-cloudy.json', # overcast
|
'cloudy.bin', # overcast
|
||||||
'weather/weather-cloudy.json', # fog
|
'fog.bin', # fog
|
||||||
'weather/weather-rain.json', # rain showers
|
'rain.bin', # light rain showers
|
||||||
'weather/weather-rain.json', # thunderstorm
|
'rain.bin', # medium rain showers
|
||||||
'weather/weather-rain-snowy.json', # light sleet
|
'rain.bin', # heavy rain showers
|
||||||
'weather/weather-rain-snowy.json', # snow showers
|
'rain.bin', # thunderstorm
|
||||||
'weather/weather-rain.json', # rain
|
'rain-snow.bin', # light sleet showers
|
||||||
'weather/weather-thunderstorm.json', # thunder
|
'rain-snow.bin', # medium sleet showers
|
||||||
'weather/weather-rain-snowy.json', # sleet
|
'rain-snow.bin', # heavy sleet showers
|
||||||
'weather/weather-snow-house.json', # snowfall
|
'rain-snow.bin', # light snow showers
|
||||||
|
'rain-snow.bin', # medium snow showers
|
||||||
|
'rain-snow.bin', # heavy snow showers
|
||||||
|
'rain.bin', # light rain
|
||||||
|
'rain.bin', # medium rain
|
||||||
|
'rain.bin', # heavy rain
|
||||||
|
'thunderstorm.bin', # thunder
|
||||||
|
'rain-snowy.bin', # light sleet
|
||||||
|
'rain-snowy.bin', # medium sleet
|
||||||
|
'rain-snowy.bin', # heavy sleet
|
||||||
|
'snow-house.bin', # light snowfall
|
||||||
|
'snow-house.bin', # medium snowfall
|
||||||
|
'snow-house.bin', # heavy snowfall
|
||||||
]
|
]
|
||||||
self.frames = [[[]]]
|
self.num_frames = 1
|
||||||
self.delays = [0]
|
|
||||||
self.remaining_frames = 1
|
self.remaining_frames = 1
|
||||||
self.next_frame_at = 0
|
self.next_frame_at = 0
|
||||||
self.loops = 3
|
if not config:
|
||||||
|
return
|
||||||
|
if 'debug' in config:
|
||||||
|
self.debug = config['debug']
|
||||||
|
if 'intensity' in config:
|
||||||
|
self.intensity = int(round(config['intensity']*255))
|
||||||
|
if 'lat' in config:
|
||||||
|
self.lat = config['lat']
|
||||||
|
if 'lon' in config:
|
||||||
|
self.lon = config['lon']
|
||||||
|
|
||||||
|
|
||||||
def reset(self):
|
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)
|
||||||
|
|||||||
84
ws2812.py
84
ws2812.py
@@ -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,60 +62,33 @@ 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):
|
|
||||||
"""
|
|
||||||
Fill a part of the buffer with RGB data.
|
|
||||||
Order of colors in buffer is changed from RGB to GRB because WS2812 LED
|
|
||||||
has GRB order of colors. Each color is represented by 4 bytes in buffer
|
|
||||||
(1 byte for each 2 bits).
|
|
||||||
Returns the index of the first unfilled LED
|
|
||||||
Note: If you find this function ugly, it's because speed optimisations
|
|
||||||
beated purity of code.
|
|
||||||
"""
|
|
||||||
|
|
||||||
buf = self.buf
|
|
||||||
buf_bytes = self.buf_bytes
|
|
||||||
intensity = self.intensity
|
|
||||||
|
|
||||||
mask = 0x03
|
|
||||||
index = start * 12
|
|
||||||
for red, green, blue in data:
|
|
||||||
# This saves 10ms for 256 leds
|
|
||||||
#red = int(red * intensity)
|
|
||||||
#green = int(green * intensity)
|
|
||||||
#blue = int(blue * intensity)
|
|
||||||
|
|
||||||
buf[index] = buf_bytes[green >> 6 & mask]
|
|
||||||
buf[index+1] = buf_bytes[green >> 4 & mask]
|
|
||||||
buf[index+2] = buf_bytes[green >> 2 & mask]
|
|
||||||
buf[index+3] = buf_bytes[green & mask]
|
|
||||||
|
|
||||||
buf[index+4] = buf_bytes[red >> 6 & mask]
|
|
||||||
buf[index+5] = buf_bytes[red >> 4 & mask]
|
|
||||||
buf[index+6] = buf_bytes[red >> 2 & mask]
|
|
||||||
buf[index+7] = buf_bytes[red & mask]
|
|
||||||
|
|
||||||
buf[index+8] = buf_bytes[blue >> 6 & mask]
|
|
||||||
buf[index+9] = buf_bytes[blue >> 4 & mask]
|
|
||||||
buf[index+10] = buf_bytes[blue >> 2 & mask]
|
|
||||||
buf[index+11] = buf_bytes[blue & mask]
|
|
||||||
|
|
||||||
index += 12
|
|
||||||
|
|
||||||
return index // 12
|
|
||||||
|
|
||||||
def fill_buf(self, data):
|
|
||||||
"""
|
|
||||||
Fill buffer with RGB data.
|
|
||||||
All LEDs after the data are turned off.
|
|
||||||
"""
|
|
||||||
end = self.update_buf(data)
|
|
||||||
|
|
||||||
|
# NOTE: show(), update_buf() and fill_buf() were replaced
|
||||||
|
# with these to reduce memory usage in pycomhal.py
|
||||||
|
def clear(self):
|
||||||
# turn off the rest of the LEDs
|
# turn off the rest of the LEDs
|
||||||
buf = self.buf
|
buf = self.buf
|
||||||
off = self.buf_bytes[0]
|
off = self.buf_bytes[0]
|
||||||
for index in range(end * 12, self.buf_length):
|
for index in range(self.buf_length):
|
||||||
buf[index] = off
|
buf[index] = off
|
||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
|
def put_pixel(self, addr, red, green, blue):
|
||||||
|
buf = self.buf
|
||||||
|
buf_bytes = self.buf_bytes
|
||||||
|
mask = 0x03
|
||||||
|
index = addr * 12
|
||||||
|
buf[index] = buf_bytes[green >> 6 & mask]
|
||||||
|
buf[index+1] = buf_bytes[green >> 4 & mask]
|
||||||
|
buf[index+2] = buf_bytes[green >> 2 & mask]
|
||||||
|
buf[index+3] = buf_bytes[green & mask]
|
||||||
|
|
||||||
|
buf[index+4] = buf_bytes[red >> 6 & mask]
|
||||||
|
buf[index+5] = buf_bytes[red >> 4 & mask]
|
||||||
|
buf[index+6] = buf_bytes[red >> 2 & mask]
|
||||||
|
buf[index+7] = buf_bytes[red & mask]
|
||||||
|
|
||||||
|
buf[index+8] = buf_bytes[blue >> 6 & mask]
|
||||||
|
buf[index+9] = buf_bytes[blue >> 4 & mask]
|
||||||
|
buf[index+10] = buf_bytes[blue >> 2 & mask]
|
||||||
|
buf[index+11] = buf_bytes[blue & mask]
|
||||||
|
|||||||
Reference in New Issue
Block a user