Initial commit

This commit is contained in:
Noah
2018-12-21 08:12:54 +00:00
commit 30903a207b
26 changed files with 2851 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
**/*.json
*~
*.swp
*.pyc
old

56
8x32-ledmatrix-back.scad Normal file
View File

@@ -0,0 +1,56 @@
/**
* 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]);
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2018
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

442
README.md Normal file
View File

@@ -0,0 +1,442 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
- [Animated LED matrix display](#animated-led-matrix-display)
- [Running the Python scripts](#running-the-python-scripts)
- [Building and deploying the MCU](#building-and-deploying-the-mcu)
- [Arduino](#arduino)
- [MicroPython](#micropython)
- [Configuring the Raspberry Pi](#configuring-the-raspberry-pi)
- [Optional steps](#optional-steps)
- [Hardware](#hardware)
- [LED matrix display](#led-matrix-display)
- [MCUs](#mcus)
- [Teensy 3.1/3.2 pinout](#teensy-3132-pinout)
- [WiPy 3.0 pinout](#wipy-30-pinout)
- [Raspberry Pi](#raspberry-pi)
- [On the serial protocol](#on-the-serial-protocol)
- [Hacking](#hacking)
- [Wiring things up](#wiring-things-up)
- [LED matrix](#led-matrix)
- [Button](#button)
- [Connecting second UART on Pycom module to Raspberry Pi](#connecting-second-uart-on-pycom-module-to-raspberry-pi)
- [Remote power management for Raspberry Pi](#remote-power-management-for-raspberry-pi)
- [Credits](#credits)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
# Animated LED matrix display
This is a project to drive a 8x32 (or 8x8) LED matrix based on the popular WS2812 RGB LEDs using a microcontroller and optionally control them both using a more powerful host computer, such as a Raspberry Pi Zero W.
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.
![LED matrix with clock scene](docs/lamatrix.jpg)
## 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 [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
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
Assuming you have an MCU which is supported by Arduino, try:
1. Download the latest version of Arduino
2. In the _Library manager_, found via the menu entry _Tools > Manage Libraries..._, search for `fastled` and install the package
3. If you have a Teensy 3.x MCU, install the Arduino software add-on Teensyduino from https://www.pjrc.com/teensy/td_download.html
4. Connect the MCU to your computer using an USB cable
5. Open the Arduino sketch (project)
6. Setup the board under the _Tools_ menu, e.g. for a Teensy board:
- Board: `Teensy 3.2 / 3.1`
- Port: `/dev/tty.usbmodem575711` (exact path might depend on the specific board and OS)
7. Build (compile) the sketch via the menu entry _Sketch > Verify/Compile_
8. Upload the newly built sketch to the MCU via the menu entry _Sketch > Upload_
### MicroPython
[Connect](https://docs.pycom.io/gettingstarted/connection/) your Pycom module to your computer via USB (or 3.3v serial). Open a serial connection to the module and [configure WiFi](https://docs.pycom.io/tutorials/all/wlan.html) in the REPL like this:
from network import WLAN
wlan = WLAN(mode=WLAN.STA)
wlan.connect('yourSSID', auth=(WLAN.WPA2, 'yourPassword'))
Connect to the Pycom module's [native FTP server](https://docs.pycom.io/gettingstarted/programming/ftp.html) and login with `micro` / `python`.
Upload the following files to `/flash`:
- [main.py](main.py)
Upload the following files to `/flash/lib`:
- [clockscene.py](clockscene.py)
- [weatherscene.py](weatherscene.py)
- [ledmatrix.py](ledmatrix.py)
- [pycomhal.py](pycomhal.py)
- [urequests.py](urequests.py) (needed by `weatherscene.py`)
- [ws2812.py](ws2812.py)
Create a new directory under `/flash/animations` and upload any animation icons referenced in [config.json](config.json) (see [animations/README.md](animations/README.md) for details).
Create a new directory under `/flash/weather` and upload animated weather icons (see [weather/README.md](weather/README.md) for details).
If you haven't already, you probably want to read [Wiring things up](#wiring-things-up).
## Configuring the Raspberry Pi
If you want to run the Python scripts, install the necessary Python packages:
```bash
sudo apt install -y python-requests python-serial
# On Raspberry Pi Zero importing the Python `requests` package takes 20+ seconds
# if the python-openssl package is installed (default on Raspbian).
# https://github.com/requests/requests/issues/4278
#
# Uninstall it to speed up loading of `weatherscene.py`
sudo apt purge -y python-openssl
```
Install the support files:
- copy [gpio-shutdown.service](raspberry-pi/gpio-shutdown.service) into `/etc/systemd/system/`
- copy [lamatrix.service](raspberry-pi/lamatrix.service) into `/etc/systemd/system/`
Assuming you've cloned this repo at `/home/pi/lamatrix`, proceed as follows:
```bash
sudo apt install -y python-requests python-serial
cd ~/lamatrix
# Download animated weather icons (refer to weather/README.md)
curl -o weather/weather-cloud-partly.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2286
curl -o weather/weather-cloudy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=12019
curl -o weather/weather-moon-stars.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16310
curl -o weather/weather-rain-snow.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=160
curl -o weather/weather-rain.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=72
curl -o weather/weather-snow-house.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=7075
curl -o weather/weather-snowy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2289
curl -o weather/weather-thunderstorm.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=11428
# Install and start services
chmod +x main.py raspberry-pi/gpio-shutdown.py
sudo cp raspberry-pi/gpio-shutdown.service /etc/systemd/system
sudo cp raspberry-pi/lamatrix.service /etc/systemd/system
sudo systemctl daemon-reload
sudo systemctl enable gpio-shutdown.service lamatrix.service
sudo systemctl start gpio-shutdown.service lamatrix.service
```
NOTE: If you're not running under the `pi` user or have placed the files somewhere else than `/home/pi/lamatrix` you will have to update the `ExecPath=`, `User=` and `Group=` attributes in the `.service` files accordingly.
Your Raspberry Pi will now poweroff when board pin number 5 (a.k.a BCM 3 a.k.a SCL) goes LOW (e.g. is temporarily tied to ground). The shutdown process takes 10-15 seconds. The Pi can be powered up by again temporarily tying the pin to ground again.
To actually make use of the remote shutdown and reboot feature you need to physically wire the microcontroller to the Raspberry Pi. Connect the microcontroller's `GND` (ground) to one of the `GND` pins on the Raspberry Pi. Connect pin 14 (see `HOST_SHUTDOWN_PIN` in [ArduinoSer2FastLED.ino](arduino/ArduinoSer2FastLED.ino)) on the microcontroller to the Raspberry Pi's [BCM 3 a.k.a SCL](https://pinout.xyz/pinout/i2c).
### Optional steps
If you're running a headless Raspberry Pi you can reduce the boot time by a few seconds with:
```bash
sudo apt-get purge -y nfs-common libnfsidmap2 libtirpc1 rpcbind python-openssl
grep -q boot_delay /boot/config.txt || echo boot_delay=0 |sudo tee -a /boot/config.txt
sudo systemctl disable dphys-swapfile exim4 keyboard-setup raspi-config rsyslog
```
(the `python-openssl` package slows down the import of the `python-requests` package: https://github.com/requests/requests/issues/4278)
## Hardware
### LED matrix display
On popular auction sites there are 8x8, 8x32 and 16x16 flexible LED matrix displays with WS2812 LEDs if you search for e.g. `LED Matrix WS2812 5050 flexible`:
![chinese-8x32-ledmatrix.jpg](docs/chinese-8x32-ledmatrix.jpg).
Price: €35-45
For the 8x32 variant, you can 3D print a frame from these objects:
- [32x8 LED Matrix grid for diffuser](https://www.thingiverse.com/thing:1903744)
- [8x32-ledmatrix-back.scad](8x32-ledmatrix-back.scad) (customize it in [OpenSCAD](https://www.openscad.org/downloads.html), hit Render (F6) and export it as STL)
For diffusing the light emitted by the LEDS a paper works suprisingly well if it's tightly held to the grid.
### MCUs
- [https://www.pjrc.com/teensy/](Teensy) (for Teensy 3.1/3.2, solder a 32.768kHz crystal to allow for [time-keeping via a battery](https://www.pjrc.com/teensy/td_libs_Time.html))
- Teensy 3.2: ARM Cortex-M4 72MHz, 64kBytes SRAM, 256kBytes flash, RTC (requires 32kHz crystal and a 3V battery)
- Price: €20-25
- Specs and pinout: https://www.pjrc.com/teensy/teensyLC.html
- [WiPy 3.0](https://pycom.io/product/wipy-3-0/) and an [Expansion Board 3.0](https://pycom.io/product/expansion-board-3-0/) for easy programming via USB
- ESP-32 platform, 520kBytes SRAM + 4MBytes (external) pSRAM, 8MBytes flash, 802.11b/g/n 16Mbps WiFi
- Price: €20-25
- Docs and pinout: https://docs.pycom.io/datasheets/development/wipy3
- Expansion Board docs: https://docs.pycom.io/datasheets/boards/expansion3
Both alternatives support both Arduino and MicroPython.
NOTE: it seems that hardware flow control between pyserial and the Pycom modules (e.g. WiPy, LoPy) doesn't work properly for some reason. This results in the host overwhelming the microcontroller with data, leading to data loss in the serial protocol which in turn messes up what is displayed on the LED matrix. The Teensy 3.x boards work without problems however.
#### Teensy 3.1/3.2 pinout
![Teensy 3.1/3.2 pinout](docs/teensy31_front_pinout.png)
Source: https://www.pjrc.com/teensy/teensyLC.html
#### WiPy 3.0 pinout
![WiPy 3.0 pinout](docs/wipy3-pinout.png)
Source: https://docs.pycom.io/datasheets/development/wipy3.html
### Raspberry Pi
Newer Raspberry Pi computers have a non-populated RUN pin (marked with a square) that, if tied to ground, will reset the Pi's CPU. See this answer on [What are the RUN pin holes on Raspberry Pi 2?](https://raspberrypi.stackexchange.com/questions/29339/what-are-the-run-pin-holes-on-raspberry-pi-2/33945#33945).
Since there is a 10k pull-up resistor connected to this pin, the Pi will turn on again when this pin is no longer tied to ground. The drawback is of course that resetting the CPU leads to an unclean shutdown of the Pi, which in turn might lead to SD card corruption.
A Raspberry Pi which has previously been shutdown using e.g. `sudo poweroff` can be brought back to life by temporarily grounding GPIO 5 (a.k.a BCM 3 a.k.a SCL). With the help of a small Python script running in the background we can make GPIO 5 an input pin and watch for level changes. If this pin becomes LOW (i.e. tied to ground) we can initiate a clean shutdown with `sudo systemctl poweroff --force`.
Example from [raspberry-pi/gpio-shutdown.py](raspberry-pi/gpio-shutdown.py):
```python
#!/usr/bin/python
#
# Watch the board pin number 5 for level changes and initiate a power-off
# when this pin goes low.
#
from RPi import GPIO
from subprocess import call
# https://pinout.xyz/pinout/i2c
pin = 5 # a.k.a BCM 3 a.k.a SCL
GPIO.setmode(GPIO.BOARD)
GPIO.setup(pin, GPIO.IN)
GPIO.wait_for_edge(pin, GPIO.FALLING)
print('GPIO 5 dropped to low, initiating poweroff')
call(["/bin/systemctl","poweroff","--force"])
```
## On the serial protocol
Because of the limited amount of memory available on MCU it was decided to use a more powerful computer to render things on the LED matrix display. The most natural way of connecting a MCU and a host computer is to use the serial interface available on many popular MCUs, and thus a serial protocol was born.
To add new functionality to the serial protocol, ensure that you make the necessary updates in:
- the MCU implementation on the Arduino side, in and around `loop()`
- the MCU implementation on the MicroPython side, in `pycomhal.py`
- the host computer implementation, in `arduinoserialhal.py`
## Hacking
In short:
- on the host-side, everything starts in [main.py](main.py)
- somewhat confusingly this file is also the entrypoint for microcontrollers running MicroPython
- `main.py` has a `RenderLoop` which consumes _scenes_ (e.g. a [clock](clockscene.py) scene, a [weather](weatherscene.py) scene, ..)
- a framebuffer wrapper around the LED matrix display is in [ledmatrix.py](ledmatrix.py)
- this is used primarily for the host-side but is also used on microcontrollers running MicroPython
- the framebuffer wrapper does low-level display operations via a HAL (hardware abstraction layer)
- on the host-side this is implemented in [arduinoserialhal.py](arduinoserialhal.py)
- on the host-side this file pretty much opens a serial port and speaks a custom protocol to command the microcontroller to do things
- on the microcontroller-side the custom protocol is implemented in:
- [ArduinoSer2FastLED.ino](arduino/ArduinoSer2FastLED.ino) for devices running Arduino
- [pycomhal.py](pycomhal.py) for Pycom devices running MicroPython
To add a new scene, create a Python module (e.g. `demoscene.py`) like this:
```python
#!/usr/bin/env python
class DemoScene:
"""This module implements an example scene with a traveling pixel"""
def __init__(self, display, config):
"""
Initialize the module.
`display` is saved as an instance variable because it is needed to
update the display via self.display.put_pixel() and .render()
"""
self.display = display
self.x_pos = 0 # ..just an example
print('DemoScene: yay, initialized')
def reset(self):
"""
This method is called before transitioning to this scene.
Use it to (re-)initialize any state necessary for your scene.
"""
self.x_pos = 0
print('DemoScene: here we go')
def input(self, button_id, button_state):
"""
Handle button input
"""
print('DemoScene: button {} pressed: {}'.format(button_id, button_state))
return False # signal that we did not handle the input
def render(self, frame, dropped_frames, fps):
"""
Render the scene.
This method is called by the render loop with the current frame number,
the number of dropped frames since the previous invocation and the
requested frames per second (FPS).
"""
time_in_seconds = frame * fps
if not time_in_seconds.is_integer():
# Only update pixel once every second
return True
y = 3
color = 64
self.display.clear()
self.display.put_pixel(self.x_pos, y, color, color, color >> 1)
self.display.render()
print('DemoScene: rendered a pixel at ({},{})'.format(self.x_pos, y))
self.x_pos += 1
if self.x_pos == self.display.columns:
return False # our work is done!
return True # we want to be called again
if __name__ == '__main__':
display = None
config = None
scene = DemoScene(display, config)
scene.reset()
```
Then open [main.py](main.py) and locate the following line:
```python
r = RenderLoop(display, fps=10)
```
Below it, create an instance of your module and call `RenderLoop.add_scene()` to add it to the list of scenes. If your module is named `demoscene.py` and implements the `DemoScene` class it should look something like this:
```python
from demoscene import DemoScene
scene = DemoScene(display, config['DemoScene'])
r.add_scene(scene)
```
You should also add a `"DemoScene": {},` block to the config file `config.json`. Store any settings your scene needs here.
With these steps completed, the scene's `render()` method should now eventually be called when you run the host-side software (e.g. `python main.py`). The method should return `True` until you're ready to hand over control to the next scene, in which case you signal this by returning `False`.
## Wiring things up
To the extent possible I've attempted to choose the same set of board pins on both MCUs (microcontrollers).
A short note on pin mappings:
- physical pin numbering refer to the chip's physical pins
- GPIO pin number refer to the chip's internal GPIO pin numbering (e.g. `GPIO22` as shown in pinout mappings)
- board pin numbering refer to the pins as made available on the PCB, with pin 0 or 1 often being in the top left corner after excluding any power/ground/reset pins
- pin ID as mapped in firmware, e.g. _digital pin_ number 10 or _P10_ (as shown in pinout mappings)
### LED matrix
Connect the display like this:
LED matrix: 5V --> MCU: Vin pin (voltage in)
LED matrix: GND --> MCU: GND pin
LED matrix: DIN --> MCU: digital pin 6 on Teensy; P11 (a.k.a GPIO22) on Pycom module
### Button
Connect the button like this:
Button pin 1 --> MCU: digital pin 12 on Teensy; P12 (a.k.a. GPIO21) pin on Pycom module
Button pin 2 --> MCU: GND pin
### Connecting second UART on Pycom module to Raspberry Pi
To connect the Pycom module's (e.g. WiPy) second UART (3.3V serial port) to the Raspberry Pi's UART, connect:
Raspberry Pi: board pin 8 (TXD) --> MCU: P4 (a.k.a RX1 a.k.a GPIO15) on Pycom module
Raspberry Pi: board pin 10 (RXD) --> MCU: P3 (a.k.a TX1 a.k.a GPIO4) on Pycom module
Raspberry Pi: board pin 6 (GND) --> MCU: GND pin
NOTE: Raspberry Pi modules with built-in WiFi/Bluetooth needs the following line in `/boot/config.txt` to [free up the TXD/RXD pins](https://www.raspberrypi.org/documentation/configuration/uart.md):
dtoverlay=pi3-disable-bt
Running `screen /dev/serial0 115200` on the Raspberry Pi should now allow you to see data sent from the MCU's second UART.
### Remote power management for Raspberry Pi
To let the MCU manage the Raspberry Pi's power, connect:
Raspberry Pi: board pin 5 (a.k.a SCL) --> MCU: digital pin 8 on Teensy; P8 (a.k.a GPIO2) on Pycom module
Raspberry Pi: board pin 6 (a.k.a GND) --> MCU: GND pin
# 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.
The [urequests.py](urequests.py) file is a slightly modified copy from [micropython/micropython-lib](https://github.com/micropython/micropython-lib).
The [ws2812.py](ws2812.py) file, a MicroPython implementation for controlling WS2812 LEDs, is based on work published on [JanBednarik/micropython-ws2812](https://github.com/JanBednarik/micropython-ws2812).

16
animations/README.md Normal file
View File

@@ -0,0 +1,16 @@
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.

140
animationscene.py Executable file
View File

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

View File

@@ -0,0 +1,396 @@
/**
* Firmware to control a LED matrix display
* https://github.com/noahwilliamsson/lamatrix
*
* -- noah@hack.se, 2018
*
*/
#ifdef TEENSYDUINO
#include <TimeLib.h>
#endif
#include "FastLED.h"
#define HOST_SHUTDOWN_PIN 8
#define BUTTON_PIN 12
#define NUM_LEDS 256
#ifdef TEENSYDUINO
#define FastLED_Pin 6
#else
#define FastLED_Pin 22
#endif
static void put_pixel(int, int, int);
static void render_clock(int);
#ifdef TEENSYDUINO
static time_t getTeensy3Time();
#endif
/**
* Serial protocol
*/
enum {
FUNC_RESET = 0,
/* Initialize display with [pixels & 0xff, (pixels>>8) & 0xff] LEDs */
FUNC_INIT_DISPLAY = 'i',
/* Clear display: [dummy byte] */
FUNC_CLEAR_DISPLAY = 'c',
/* Update display: [dummy byte] */
FUNC_SHOW_DISPLAY = 's',
/* Put pixel at [pixel&0ff, (pixel >> 8) &0xff, R, G, B] */
FUNC_PUT_PIXEL = 'l',
/* Set time [t&0xff, (t >> 8) & 0xff, (t >> 16) & 0xff, (t >> 24) & 0xff] */
FUNC_SET_RTC = '@',
/* Automatically render time [enable/toggle byte] */
FUNC_AUTO_TIME = 't',
/* Suspend host for [seconds & 0xff, (seconds >> 8) & 0xff] */
FUNC_SUSPEND_HOST = 'S',
};
/* Computed with pixelfont.py */
static int font_width = 4;
static int font_height = 5;
static char font_alphabet[] = " %'-./0123456789:cms";
static unsigned char font_data[] = "\x00\x00\x50\x24\x51\x66\x00\x00\x60\x00\x00\x00\x42\x24\x11\x57\x55\x27\x23\x72\x47\x17\x77\x64\x74\x55\x47\x74\x71\x74\x17\x57\x77\x44\x44\x57\x57\x77\x75\x74\x20\x20\x20\x15\x25\x75\x57\x75\x71\x74";
/* Global states */
int state = 0;
int debug_serial = 0;
/* Debug state issues */
int last_states[8];
unsigned int last_state_counter = 0;
/* Non-zero when automatically rendering the current time */
int show_time = 1;
/* Non-zero while the host computer is turned off */
time_t reboot_at = 0;
/* Accumulator register for use between loop() calls */
unsigned int acc;
unsigned int color;
CRGB leds[NUM_LEDS];
static volatile int g_button_state;
static int button_down_t;
static void button_irq(void) {
int state = digitalRead(BUTTON_PIN);
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 > 500)
g_button_state = 2;
else if(pressed_for_ms > 100)
g_button_state = 1;
button_down_t = 0;
}
void setup() {
Serial.begin(460800);
/* Initialize FastLED library */
FastLED.addLeds<NEOPIXEL, FastLED_Pin>(leds, NUM_LEDS);
/* Configure pin used to shutdown Raspberry Pi (connected to GPIO5 on the Pi) */
pinMode(HOST_SHUTDOWN_PIN, OUTPUT);
digitalWrite(HOST_SHUTDOWN_PIN, HIGH);
/* Configure pin for button */
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), button_irq, CHANGE);
#ifdef TEENSYDUINO
/* Initialize time library */
setSyncProvider(getTeensy3Time);
if (timeStatus() != timeSet) {
Serial.println("Unable to sync with the RTC");
}
else {
Serial.println("RTC has set the system time");
show_time = 1;
}
Serial.printf("%04d-%02d-%02dT%02d:%02d:%02dZ\n", year(), month(), day(), hour(), minute(), second());
#endif
}
void loop() {
#ifdef TEENSYDUINO
time_t now = getTeensy3Time();
#else
int now = 42;
#endif
int button_state = g_button_state;
if(button_state) {
g_button_state = 0;
if(button_state == 1)
Serial.println("BUTTON_SHRT_PRESS");
else
Serial.println("BUTTON_LONG_PRESS");
}
if(reboot_at && now >= reboot_at) {
/* Restart host computer */
digitalWrite(HOST_SHUTDOWN_PIN, LOW);
delay(1);
digitalWrite(HOST_SHUTDOWN_PIN, HIGH);
reboot_at = 0;
}
if(show_time) {
/* Automatically render time */
if(show_time != now || button_state) {
render_clock(button_state);
show_time = now;
}
}
if (Serial.available() <= 0) return;
int val = Serial.read();
last_states[last_state_counter++ % (sizeof(last_states)/sizeof(last_states[0]))] = val;
switch(state) {
case FUNC_RESET:
/**
* Pyserial sometimes experience write timeouts so we
* use a string of zeroes to resynchronize the state.
*/
state = val;
break;
case FUNC_INIT_DISPLAY:
acc = val;
state++;
break;
case FUNC_INIT_DISPLAY+1:
acc |= val << 8;
FastLED.addLeds<NEOPIXEL, FastLED_Pin>(leds, acc);
/* fall through */
case FUNC_SET_RTC:
acc = val;
state++;
break;
case FUNC_SET_RTC+1:
acc |= val << 8;
state++;
break;
case FUNC_SET_RTC+2:
acc |= val << 16;
state++;
break;
case FUNC_SET_RTC+3:
acc |= val << 24;
#ifdef TEENSYDUINO
Teensy3Clock.set(acc); // set the RTC
setTime(acc);
Serial.printf("RTC synchronized: %04d-%02d-%02dT%02d:%02d:%02dZ\n", year(), month(), day(), hour(), minute(), second());
#endif
state = FUNC_RESET;
break;
case FUNC_CLEAR_DISPLAY:
for(int i = 0; i < NUM_LEDS; i++)
leds[i].setRGB(0,0,0);
/* fall through */
case FUNC_SHOW_DISPLAY:
FastLED.show();
state = FUNC_RESET;
break;
case FUNC_SUSPEND_HOST:
acc = val;
state++;
break;
case FUNC_SUSPEND_HOST+1:
acc |= val << 8;
/* TODO: Suspend host computer */
reboot_at = now + acc;
if(reboot_at >= 10) {
/* Automatically render time while host computer is offline */
show_time = 1;
Serial.printf("Shutting down host computer, reboot scheduled in %ds\n", reboot_at);
/* Initiate poweroff on Raspberry Pi */
digitalWrite(HOST_SHUTDOWN_PIN, LOW);
delay(1);
digitalWrite(HOST_SHUTDOWN_PIN, HIGH);
}
state = FUNC_RESET;
break;
case FUNC_AUTO_TIME:
if(val == '\r' || val == '\n')
show_time = !show_time; /* toggle */
else
show_time = val;
Serial.printf("Automatic rendering of current time: %d\n", show_time);
state = FUNC_RESET;
break;
case FUNC_PUT_PIXEL:
acc = val;
state++;
break;
case FUNC_PUT_PIXEL+1:
acc |= val << 8;
state++;
break;
case FUNC_PUT_PIXEL+2:
color = val;
state++;
break;
case FUNC_PUT_PIXEL+3:
color |= val << 8;
state++;
break;
case FUNC_PUT_PIXEL+4:
color |= val << 16;
leds[(acc % NUM_LEDS)].setRGB(color & 0xff, (color >> 8) & 0xff, (color >> 16) & 0xff);
state = FUNC_RESET;
break;
default:
Serial.printf("Unknown func %d with val %d, resetting\n", state, val);
for(unsigned int i = 0; i < sizeof(last_states)/sizeof(last_states[0]) && last_state_counter - i > 0; i++)
Serial.printf("Previous state %d: %d\n", i, last_states[(last_state_counter-i) % (sizeof(last_states)/sizeof(last_states[0]))]);
state = FUNC_RESET;
break;
}
}
/* Pretty much a port of LedMatrix.xy_to_phys() */
static void put_pixel(int x, int y, int lit) {
/**
* The LEDs are laid out in a long string going from north to south,
* one step to the east, and then south to north, before the cycle
* starts over
*/
int cycle = 16;
int nssn_block = x / 2;
int phys_addr = nssn_block * 16;
int brightness_scaler = 48; /* use less power */
if(x % 2)
phys_addr += cycle - 1 - y;
else
phys_addr += y;
lit &= 0xff;
lit /= brightness_scaler;
leds[phys_addr % NUM_LEDS].setRGB(lit, lit, lit);
}
#ifdef TEENSYDUINO
/* Wrapper function for Timelib's sync provider */
static time_t getTeensy3Time(void)
{
return Teensy3Clock.get();
}
#endif
/* Render time as reported by the RTC */
static int clock_state = 0x2;
static void render_clock(int button_state) {
char buf[10];
int x_off;
size_t len;
if(button_state) {
clock_state ^= 1 << (button_state-1);
for(int i = 0; i < NUM_LEDS; i++)
leds[i].setRGB(0,0,0);
}
#ifdef TEENSYDUINO
if((clock_state & 1) == 0) {
sprintf(buf, "%02d:%02d", hour(), minute());
if((clock_state & 2) && second() % 2)
buf[2] = ' ';
}
else {
sprintf(buf, "%02d.%02d.%02d", day(), month(), year() % 100);
}
#else
sprintf(buf, "00:00");
#endif
if((clock_state & 1) == 0)
x_off = 8 - clock_state;
else
x_off = 2;
len = strlen(buf);
for(size_t i = 0; i < len; i++) {
unsigned char digit = buf[i];
size_t offset;
/* Kludge to compress colons and dots to two columns */
if(digit == ':' || digit == '.' || digit == ' ' || (i && (buf[i-1] == ':' || buf[i-1] == '.' || buf[i-1] == ' ')))
x_off--;
for(offset = 0; offset < strlen(font_alphabet); offset++) {
if(font_alphabet[offset] == digit) break;
}
int font_byte = (offset * font_width * font_height) / 8;
int font_bit = (offset * font_width * font_height) % 8;
for(int y = 0; y < font_height; y++) {
for(int x = 0; x < font_width; x++) {
if(font_data[font_byte] & (1<<font_bit))
put_pixel(x_off+x, y, 255);
else
put_pixel(x_off+x, y, 0);
if(++font_bit == 8) {
font_byte++;
font_bit = 0;
}
}
}
x_off += font_width;
}
#ifdef TEENSYDUINO
/* Display seconds bar */
if(clock_state == 2) {
int height = 1 + second() / 12;
for(int y = 0; y < 5; y++) {
int color = 0;
if(y < height) color = 128;
if(y == height-1 && second() % 2) color = 0;
put_pixel(x_off+1, 4-y, color);
}
}
/* Display weekdays */
x_off = 2;
int today_to_i = (weekday() + 5) % 7;
for(int i = 0; i < 7; i++) {
int color = i == today_to_i? 255: 64;
put_pixel(x_off+4*i+0, 7, color);
put_pixel(x_off+4*i+1, 7, color);
put_pixel(x_off+4*i+2, 7, color);
}
#endif
FastLED.show();
}

132
arduinoserialhal.py Executable file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python
#
# This code is running on the host and implements the serial protocol
# used to control the display connected to the MCU.
#
# On the MCU side, the serial protocol is either implemented under Arduino
# or under MicroPython.
#
import serial
class ArduinoSerialHAL:
"""
ArduinoSerialHAL is handles the serial protocol (API) used to control
the display connected to the MCU.
"""
def __init__(self, config):
self.port = config['port']
self.baudrate = config['baudrate']
self.ser = None # initialized in reset()
self.reset()
def readline(self):
"""
Read output from MCU over the serial link
"""
if not self.ser.in_waiting:
return None
line = self.ser.readline()
return line
def reset(self):
"""
(Re-)open serial ports and resynchronize the protocol
"""
if self.ser:
print('SerialProtocol: closing serial link')
self.ser.close()
print('SerialProtocol: opening port {} @ {} baud'.format(self.port, self.baudrate))
self.ser = serial.Serial(self.port, baudrate=self.baudrate, rtscts=True, timeout=0.1, write_timeout=0.5)
self.resynchronize_protocol()
def resynchronize_protocol(self):
"""
Resynchronize the protocol by writing a string of zeroes.
"""
data = bytearray(10)
self.ser.write(data)
def safe_write(self, data):
"""
Write data to the serial link and attempt to handle write timeouts
"""
try:
self.ser.write(data)
return
except serial.SerialTimeoutException:
print('SerialProtocol: write timeout, attempting reset..')
print('WARN: Serial write timed out, attempting reset')
self.reset()
print('SerialProtocol: retrying send of {} bytes'.format(len(data)))
self.ser.write(data)
def init_display(self, num_pixels=256):
# Setup FastLED library
data = bytearray(3)
data[0] = ord('i')
data[1] = num_pixels & 0xff
data[2] = (num_pixels >> 8) & 0xff
self.safe_write(data)
def clear_display(self):
data = bytearray(2)
data[0] = ord('c')
self.safe_write(data)
def update_display(self, num_modified_pixels=None):
data = bytearray(2)
data[0] = ord('s')
self.safe_write(data)
def put_pixel(self, addr, r, g, b):
data = bytearray(6)
data[0] = ord('l')
data[1] = (addr >> 0) & 0xff
data[2] = (addr >> 8) & 0xff
data[3] = r
data[4] = g
data[5] = b
self.safe_write(data)
def set_rtc(self, t):
# Resynchronize RTC
data = bytearray(5)
data[0] = ord('@')
t = int(t)
data[1] = (t >> 0) & 0xff
data[2] = (t >> 8) & 0xff
data[3] = (t >> 16) & 0xff
data[4] = (t >> 24) & 0xff
self.safe_write(data)
def set_auto_time(self, enable=True):
# Enable or disable automatic rendering of current time
data = bytearray(2)
data[0] = ord('t')
data[1] = int(enable)
self.safe_write(data)
def suspend_host(self, restart_timeout_seconds):
data = bytearray(3)
data[0] = ord('S')
data[1] = (restart_timeout_seconds >> 0) & 0xff
data[2] = (restart_timeout_seconds >> 8) & 0xff
self.safe_write(data)
if __name__ == '__main__':
import os
import time
port = '/dev/tty.usbmodem575711'
if not os.path.exists(port):
port = '/dev/ttyACM0'
p = SerialProtocol(port, 115200)
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()
time.sleep(1)
p.clear_display()

149
clockscene.py Executable file
View File

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

23
config.json Normal file
View File

@@ -0,0 +1,23 @@
{
"ssid":"yourWiFiSSID",
"password":"yourWiFiPassword",
"port": "/dev/ttyACM0",
"baudrate": 921600,
"tzOffsetSeconds": 3600,
"AnimationScene": {
"files": [
"animations/game-pingpong.json",
"animations/matrix.json",
"animations/newyears.json",
"animations/tv-movie.json"
]
},
"ClockScene": {
},
"DemoScene": {
},
"WeatherScene": {
"lat": 59.3293,
"lon": 18.0686
}
}

60
demoscene.py Executable file
View File

@@ -0,0 +1,60 @@
#!/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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/lamatrix.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
docs/wipy3-pinout.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

248
ledmatrix.py Executable file
View File

@@ -0,0 +1,248 @@
# This file implements high-level routines for using a LED matrix as a display.
#
# While this code is primarily designed to be running on the host computer,
# MCUs running MicroPython will run this code as well to provide automatic
# rendering of the current time while the host computer is offline.
#
# The constructor needs to be called with a HAL (Hardware Abstraction Layer)
# driver which provides low-level access to the display. The HAL can be
# e.g. a driver that implements a serial protocol running on an MCU.
#
import time
if not hasattr(time, 'ticks_ms'):
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time()*1000)
class LedMatrix:
rotation = 180
# Reduce brightness by scaling down colors
brightness_scaler = 32
rows = 0
columns = 0
driver = None
fb = []
def __init__(self, driver, columns = 8, rows = 8, rotation = 0):
self.driver = driver
self.columns = columns
self.rows = rows
self.num_pixels = rows * columns
self.num_modified_pixels = self.num_pixels # optimization: avoid rendering too many pixels
assert rows == 8, "Calculations in xy_to_phys expect 8 rows"
self.rotation = (360 + rotation) % 360
# This is laid out in physical order
self.fb.append(bytearray(self.num_pixels*3))
self.fb.append(bytearray(self.num_pixels*3))
self.fb_index = 0
# Optimize clear
self.fb.append(bytearray(self.num_pixels*3))
for i in range(len(self.fb[0])):
self.fb[0][i] = 1
# Initialize display
self.driver.init_display(self.num_pixels)
def xy_to_phys(self, x, y):
"""
Map x,y to physical LED address after accounting for display rotation
"""
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
# The LEDs are laid out in a long string going from north to south,
# one step to the east, and then south to north, before the cycle
# starts over.
#
# Here we calculate the physical offset for the desired rotation, with
# the assumption that the first LED is at (0,0).
# We'll need this adjusting for the north-south-south-north layout
cycle = self.rows << 1 # optimization: twice the number of rows
# First we determine which "block" (of a complete cyle) the pixel is in
nssn_block = x >> 1 # optimization: divide by two
phys_addr = nssn_block << 4 # optimization: Multiply by cycle
# Second we determine if the column has decreasing or increasing addrs
is_decreasing = x & 1
if is_decreasing:
phys_addr += cycle - 1 - y
else:
phys_addr += y
return phys_addr
def phys_to_xy(self, phys_addr):
"""
Map physical LED address to x,y after accounting for display rotation
"""
x = phys_addr >> 3 # optimization: divide by number of rows
cycle = self.rows << 1 # optimization: twice the number of rows
y = phys_addr & (cycle-1) # optimization: modulo the cycle
if y >= self.rows:
y = cycle - 1 - y
if self.rotation < 90:
pass
elif self.rotation < 180:
tmp = x
x = self.rows-1-y
y = tmp
elif self.rotation < 270:
x = self.columns-1-x
y = self.rows-1-y
else:
tmp = x
x = y
y = self.columns-1-tmp
return [x, y]
def get_pixel(self, x, y):
"""
Get pixel from the currently displayed frame buffer
"""
pixel = self.xy_to_phys(x, y)
back_index = (self.fb_index+1)%2
offset = pixel*3
return [self.fb[back_index][offset+0], self.fb[back_index][offset+1], self.fb[back_index][offset+2]]
def get_pixel_front(self, x, y):
"""
Get pixel from the to-be-displayed frame buffer
"""
pixel = self.xy_to_phys(x, y)
back_index = (self.fb_index)%2
offset = pixel*3
return [self.fb[back_index][offset+0], self.fb[back_index][offset+1], self.fb[back_index][offset+2]]
def put_pixel(self, x, y, r, g, b):
"""
Set pixel ni the to-be-displayed frame buffer"
"""
if x >= self.columns or y >= self.rows:
return
pixel = self.xy_to_phys(x, y)
offset = pixel*3
self.fb[self.fb_index][offset+0] = int(r)
self.fb[self.fb_index][offset+1] = int(g)
self.fb[self.fb_index][offset+2] = int(b)
# Optimization: keep track of last updated pixel
if pixel >= self.num_modified_pixels:
self.num_modified_pixels = pixel+1
def clear(self):
"""
Clear the frame buffer by setting all pixels to black
"""
self.fb_index ^= 1
self.fb[self.fb_index][:] = self.fb[2][:]
# Optimization: keep track of last updated pixel
self.num_modified_pixels = self.num_pixels
def render(self):
"""
Render the to-be-displayed frame buffer by making put_pixel() and
render() calls down to the HAL driver.
"""
# This takes 11ms
tX = t0 = time.ticks_ms()
front = self.fb[self.fb_index]
back = self.fb[self.fb_index ^ 1]
num_rendered = 0
for pixel in range(self.num_modified_pixels):
# This crap saves about 4ms
i = pixel*3
j = i+1
k = j+1
r = front[i]
g = front[j]
b = front[k]
if r != back[i] or g != back[j] or b != back[k]:
self.driver.put_pixel(pixel, r // self.brightness_scaler, g // self.brightness_scaler, b // self.brightness_scaler)
num_rendered += 1
t0 = time.ticks_ms() - t0
# This takes 52ms
t1 = time.ticks_ms()
self.driver.update_display(self.num_modified_pixels)
t1 = time.ticks_ms() - t1
#time.sleep(0.00004 * self.columns * self.rows)
#time.sleep_ms(10)
# This takes 0ms
self.fb_index ^= 1
self.fb[self.fb_index][:] = self.fb[self.fb_index^1]
print('LedMatrix render: {} pixels updated in {}ms, spent {}ms in driver update call, total {}ms'.format(num_rendered, t0, t1, time.ticks_ms() - tX))
# Optimization: keep track of last updated pixel
self.num_modified_pixels = 0
def scrollout(self):
"""
Scene transition effect: scroll away pixels
"""
for i in range(self.rows):
for x in range(self.columns):
self.put_pixel(x, i, 0, 0, 0)
for y in range(self.rows-1):
for x in range(self.columns):
colors = self.get_pixel(x, y)
self.put_pixel(x, y+1, colors[0], colors[1], colors[2])
self.render()
#time.sleep(0.05)
return False
def fade(self):
"""
Scene transition effect: fade out active pixels
"""
while True:
light = 0
for i in range(self.num_pixels):
colors = self.get_pixel(i % self.columns, i // self.columns)
colors[0] = colors[0] >> 2
colors[1] = colors[1] >> 2
colors[2] = colors[2] >> 2
light |= colors[0]+colors[1]+colors[2]
self.put_pixel(i % self.columns, i // self.columns, colors[0], colors[1], colors[2])
self.render()
time.sleep(0.1)
if not light:
# Everything has faded out
return False
def dissolve(self):
"""
Scene transition effect: dissolve active pixels with LFSR
"""
active_pixels = 0
for i in range(self.columns*self.rows):
colors = self.get_pixel(i % self.columns, i // self.columns)
if colors[0] or colors[1] or colors[2]:
active_pixels += 1
if not active_pixels:
# No more pixels to dissolve
return False
per_pixel_sleep = (0.1-0.00003*self.num_pixels)/active_pixels
pixel = 1
for i in range(256):
bit = pixel & 1
pixel >>= 1
if bit:
pixel ^= 0xb4
if pixel >= self.columns*self.rows:
continue
colors = self.get_pixel(pixel % self.columns, pixel // self.columns)
if not colors[0] and not colors[1] and not colors[2]:
continue
self.put_pixel(pixel % self.columns, pixel // self.columns, 0, 0, 0)
self.render()
time.sleep(per_pixel_sleep)
# There are still pixels to dissolve
return True

244
main.py Executable file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python
#
# This is a project to drive a 8x32 (or 8x8) LED matrix based on the
# popular WS2812 RGB LEDs using a microcontroller (e.g. a Teensy 3.x
# or a Pycom module with 4MB RAM) and optionally control them both
# using a more powerful host computer, such as a Raspberry Pi Zero W.
#
# -- noah@hack.se, 2018
#
import sys
import time
from math import ceil
if hasattr(sys,'implementation') and sys.implementation.name == 'micropython':
pycom_board = True
import ujson as json
import machine
from network import WLAN
# Local imports
from pycomhal import PycomHAL
else:
pycom_board = False
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time() * 1000)
time.sleep_ms = lambda x: time.sleep(x/1000.0)
import json
import os
import sys
import signal
# Local imports
from arduinoserialhal import ArduinoSerialHAL
# Local imports
from ledmatrix import LedMatrix
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):
"""
Clear display when the program is terminated by Ctrl-C or SIGTERM
"""
global driver
driver.clear_display()
driver.set_auto_time(True)
sys.exit(0)
if __name__ == '__main__':
f = open('config.json')
config = json.loads(f.read())
f.close()
# Initialize HAL
if pycom_board:
# We're running under MCU here
print('WLAN: Connecting')
wlan = WLAN(mode=WLAN.STA)
wlan.connect(config['ssid'], auth=(WLAN.WPA2, config['password']))
while not wlan.isconnected():
machine.idle() # save power while waiting
time.sleep(1)
print('WLAN: Connected with IP: {}'.format(wlan.ifconfig()[0]))
driver = PycomHAL(config)
else:
# We're running on the host computer here
ports = [
'/dev/tty.usbmodem575711', # Teensy 3.x on macOS
'/dev/tty.usbserial-DQ008J7R', # Pycom device on macOS
'/dev/ttyUSB0', # Linux
'/dev/ttyACM0', # Linux
]
for port in ports:
if os.path.exists(port):
break
driver = ArduinoSerialHAL(config)
driver.set_rtc(time.time() + config['tzOffsetSeconds'])
driver.set_auto_time(False)
# Trap Ctrl-C and service termination
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
# Initialize led matrix framebuffer on top of HAL
num_leds = 256
rows = 8
cols = num_leds // rows
display = LedMatrix(driver, cols, rows, rotation=0)
driver.clear_display()
if pycom_board:
# If we're running on the MCU then loop forever
while True:
driver.serial_loop(display)
# This is where it all begins
r = RenderLoop(display, fps=10)
scene = ClockScene(display, config['ClockScene'])
r.add_scene(scene)
from weatherscene import WeatherScene
scene = WeatherScene(display, config['WeatherScene'])
r.add_scene(scene)
from animationscene import AnimationScene
scene = AnimationScene(display, config['AnimationScene'])
r.add_scene(scene)
# Render scenes forever
while True:
button_pressed = 0
while True:
# Drain output from MCU and detect button presses
line = driver.readline()
if not line:
break
event = line.strip()
if event == 'BUTTON_SHRT_PRESS':
button_pressed = 1
elif event == 'BUTTON_LONG_PRESS':
button_pressed = 2
else:
print('MCU: {}'.format(event))
r.next_frame(button_pressed)
if button_pressed:
button_state = 0

109
pixelfont.py Executable file
View File

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

285
pycomhal.py Executable file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python
#
# This file is running on the MCU and implements the following features:
# - the serial protocol used to control the MCU from a host computer
# - low-level LED matrix routines (initialization, put pixel, ..)
# - configuration of the real-time clock
# - shutdown/power-up of the host computer (via GPIO)
#
# This file is similar to the Arduino C version running on Teensy.
#
# From https://raw.githubusercontent.com/Gadgetoid/wipy-WS2812/master/ws2812alt.py
# ..via: https://forum.pycom.io/topic/2214/driving-ws2812-neopixel-led-strip/3
from ws2812 import WS2812
#from rmt import WS2812
from machine import Pin, RTC, UART, idle
import utime
import os
import sys
import pycom
import gc
# Local imports
from clockscene import ClockScene
from weatherscene import WeatherScene
class PycomHAL:
def __init__(self, config):
self.chain = None # will be initialized in reset()
self.num_pixels = 256
self.pixels = []
self.reset()
self.enable_auto_time = True
self.frame = 0
# TODO: Fix these
self.scene = 0
self.clock = None
self.weather = None
self.config = config
# A Raspberry Pi will reboot/wake up if this pin is set low
# https://docs.pycom.io/firmwareapi/pycom/machine/pin.html#pinholdhold
self.suspend_host_pin = Pin('P8', Pin.OUT, Pin.PULL_UP)
self.suspend_host_pin.hold(True)
# Handle button input
self.button_pin = Pin('P12', Pin.IN, Pin.PULL_UP)
self.button_pin.callback(Pin.IRQ_FALLING|Pin.IRQ_RISING, handler=lambda arg: self.button_irq(arg))
self.button_state = 0
self.button_down_t = 0
# Setup RTC
self.rtc = None
self.set_rtc(0)
utime.timezone(config['tzOffsetSeconds'])
pycom.heartbeat(False)
gc.collect()
# For the serial bridge implementation
self.uart = None # will be initialized in serial_loop()
self.reboot_at = 0
self.state = 0
self.acc = 0
self.color = 0
def button_irq(self, pin):
"""
Interrrupt handler for button input pin
"""
level = pin.value()
if not level:
self.button_down_t = utime.ticks_ms()
return
if not self.button_down_t:
return
t = utime.ticks_ms() - self.button_down_t
if t > 500:
self.button_state = 2
elif t > 80:
self.button_state = 1
self.button_down_t = 0
# Implement the serial protocol understood by ArduinoSerialHAL
# This function should be similar to the Arduino project's loop()
def serial_loop(self, display):
if self.reboot_at:
if utime.time() > self.reboot_at:
self.reboot_at = 0
# Trigger wakeup
print('HAL: Waking up host computer')
self.suspend_host_pin.hold(False)
self.suspend_host_pin(0)
self.suspend_host_pin(1)
self.suspend_host_pin.hold(True)
if not self.uart:
print('HAL: Disabling REPL on UART0 and switching to serial protocol')
idle()
os.dupterm(None)
self.uart = UART(0, 115200*8, pins=('P1', 'P0', 'P20', 'P19')) # TX/RX/RTS/CTS on ExpBoard2
self.console = UART(1, 115200)
os.dupterm(self.console)
idle()
print('HAL: Enabled REPL on UART1')
button_state = self.button_state
if button_state:
if button_state == 1:
print('BUTTON_SHRT_PRESS')
elif button_state == 2:
print('BUTTON_LONG_PRESS')
self.button_state = 0
if self.enable_auto_time:
# TODO: Unify with main.py::RenderLoop
self.frame += 1
if not self.clock:
print('HAL: Initiating clock scene')
self.clock = ClockScene(display, self.config['ClockScene'])
if not self.weather:
self.weather = WeatherScene(display, self.config['WeatherScene'])
self.weather.reset()
if button_state == 1:
self.clock.input(0, button_state)
elif button_state == 2:
self.scene ^= 1
self.clear_display()
if self.scene == 0:
self.clock.render(self.frame, 0, 5)
else:
self.weather.render(self.frame, 0, 5)
avail = self.uart.any()
if not avail:
return
if avail > 256:
# Currently shipping releases have a 512 byte buffer
print('HAL: More than 256 bytes available: {}'.format(avail))
data = self.uart.readall()
for val in data:
if self.state == 0:
# reset
self.state = val
elif self.state >= ord('i') and self.state <= ord('i')+1:
# init display
tmp = self.state - ord('i')
self.state += 1 # next state
if tmp == 0:
self.acc = val
elif tmp == 1:
self.acc += val << 8
self.init_display(self.acc)
self.state = 0 # reset state
elif self.state == ord('c'):
# clear display
self.clear_display()
self.state = 0 # reset state
elif self.state == ord('s'):
# show display
self.update_display(self.num_pixels)
self.state = 0 # reset state
elif self.state >= ord('l') and self.state <= ord('l')+5:
# put pixel
tmp = self.state - ord('l')
self.state += 1 # next state
if tmp == 0:
self.acc = val
elif tmp == 1:
self.acc += val << 8
elif tmp == 2:
self.color = val
elif tmp == 3:
self.color += val << 8
elif tmp == 4:
self.color += val << 16
c = self.color
self.put_pixel(self.acc, (c >> 0) & 0xff, (c >> 8) & 0xff, (c >> 16) & 0xff)
self.state = 0 # reset state
elif self.state >= ord('S') and self.state <= ord('S')+1:
# suspend host
tmp = self.state - ord('S')
self.state += 1 # next state
if tmp == 0:
self.acc = val
else:
self.acc += val << 8
self.reboot_at = int(utime.time()) + self.acc
# TODO: flip pin to reboot host
self.state = 0 # reset state
elif self.state == ord('t'):
# automatic rendering of current time
if val == 10 or val == 13:
self.set_auto_time(not self.enable_auto_time)
else:
self.set_auto_time(bool(val))
print('HAL: Automatic rendering of time is now: {}'.format(self.enable_auto_time))
self.state = 0 # reset state
elif self.state >= ord('@') and self.state <= ord('@')+3:
# update RTC
tmp = self.state - ord('@')
self.state += 1 # next state
if tmp == 0:
self.acc += val
elif tmp == 1:
self.acc += val << 8
elif tmp == 2:
self.acc += val << 16
if tmp == 3:
self.acc += val << 24
self.set_rtc(self.acc)
self.state = 0 # reset state
else:
print('HAL: Unhandled state: {}'.format(self.state))
self.state = 0 # reset state
def readline(self):
"""
No-op in this implementation
"""
return None
def reset(self):
print('HAL: Reset called')
self.chain = WS2812(ledNumber=self.num_pixels, intensity=0.5)
def init_display(self, num_pixels=256):
print('HAL: Initializing display with {} pixels'.format(num_pixels))
self.num_pixels = num_pixels
self.pixels = [(0,0,0) for _ in range(self.num_pixels)]
self.clear_display()
def clear_display(self):
for i in range(self.num_pixels):
self.pixels[i] = (0,0,0)
self.update_display(self.num_pixels)
def update_display(self, num_modified_pixels):
if not num_modified_pixels:
return
self.chain.show(self.pixels[:num_modified_pixels])
gc.collect()
def put_pixel(self, addr, r, g, b):
self.pixels[addr % self.num_pixels] = (r,g,b)
def set_rtc(self, t):
# Resynchronize RTC
self.rtc = RTC()
self.rtc.ntp_sync('ntps1-1.eecsit.tu-berlin.de')
print('HAL: Waiting for NTP sync')
while not self.rtc.synced():
idle()
print('HAL: RTC synched')
def set_auto_time(self, enable=True):
"""
Enable rendering of current time without involvment from host computer
"""
self.enable_auto_time = enable
def suspend_host(self, restart_timeout_seconds):
"""
Suspend host computer and configure a future wakeup time
"""
if restart_timeout_seconds < 15:
return
self.reboot_at = utime.time() + restart_timeout_seconds
# Trigger shutdown
self.suspend_host_pin.hold(False)
self.suspend_host_pin(0)
self.suspend_host_pin(1)
self.suspend_host_pin.hold(True)
pass
if __name__ == '__main__':
import os
import time
p = PycomHAL()
p.init_display(256)
p.clear_display()
p.put_pixel(0, 8, 0, 0)
p.put_pixel(8, 0, 8, 0)
p.put_pixel(16, 0, 0, 8)
p.update_display(p.num_pixels)
time.sleep(1)
p.clear_display()

16
raspberry-pi/gpio-shutdown.py Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/python
#
# Watch board pin number 5 for level changes and initiate a power-off
# when this pin goes low.
#
from RPi import GPIO
from subprocess import call
# https://pinout.xyz/pinout/i2c
pin = 5 # a.k.a BCM 3 a.k.a SCL
GPIO.setmode(GPIO.BOARD)
GPIO.setup(pin, GPIO.IN)
GPIO.wait_for_edge(pin, GPIO.FALLING)
print('Board pin number 5 dropped to low, initiating poweroff')
call(["/bin/systemctl","poweroff","--force"])

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Shutdown Pi when GPIO 5 is tied to ground
After=network.target
[Service]
ExecStart=/home/pi/lamatrix/raspberry-pi/gpio-shutdown.py
User=pi
Group=pi
[Install]
WantedBy=basic.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Run LaMatrix main loop
After=network.target
[Service]
ExecStart=/usr/bin/env python /home/pi/lamatrix/main.py
WorkingDirectory=/home/pi/lamatrix
StandardOutput=null
Restart=on-failure
RestartSec=60s
User=pi
Group=pi
[Install]
WantedBy=basic.target

124
urequests.py Normal file
View File

@@ -0,0 +1,124 @@
import usocket
class Response:
def __init__(self, f):
self.raw = f
self.encoding = "utf-8"
self._cached = None
def close(self):
if self.raw:
self.raw.close()
self.raw = None
self._cached = None
@property
def content(self):
if self._cached is None:
try:
self._cached = self.raw.read()
finally:
self.raw.close()
self.raw = None
return self._cached
@property
def text(self):
return str(self.content, self.encoding)
def json(self):
import ujson
return ujson.loads(self.content)
def request(method, url, data=None, json=None, headers={}, stream=None):
try:
proto, dummy, host, path = url.split("/", 3)
except ValueError:
proto, dummy, host = url.split("/", 2)
path = ""
if proto == "http:":
port = 80
elif proto == "https:":
import ussl
port = 443
else:
raise ValueError("Unsupported protocol: " + proto)
if ":" in host:
host, port = host.split(":", 1)
port = int(port)
ai = usocket.getaddrinfo(host, port)
ai = ai[0]
s = usocket.socket(ai[0], ai[1], ai[2])
try:
s.connect(ai[-1])
if proto == "https:":
s = ussl.wrap_socket(s, server_hostname=host)
s.write(b"%s /%s HTTP/1.0\r\n" % (method, path))
if not "Host" in headers:
s.write(b"Host: %s\r\n" % host)
# Iterate over keys to avoid tuple alloc
for k in headers:
s.write(k)
s.write(b": ")
s.write(headers[k])
s.write(b"\r\n")
if json is not None:
assert data is None
import ujson
data = ujson.dumps(json)
s.write(b"Content-Type: application/json\r\n")
if data:
s.write(b"Content-Length: %d\r\n" % len(data))
s.write(b"\r\n")
if data:
s.write(data)
l = s.readline()
#print(l)
l = l.split(None, 2)
status = int(l[1])
reason = ""
if len(l) > 2:
reason = l[2].rstrip()
while True:
l = s.readline()
if not l or l == b"\r\n":
break
if l.startswith(b"Transfer-Encoding:"):
if b"chunked" in l:
raise ValueError("Unsupported " + l)
elif l.startswith(b"Location:") and not 200 <= status <= 299:
raise NotImplementedError("Redirects not yet supported")
except OSError:
s.close()
raise
resp = Response(s)
resp.status_code = status
resp.reason = reason
return resp
def head(url, **kw):
return request("HEAD", url, **kw)
def get(url, **kw):
return request("GET", url, **kw)
def post(url, **kw):
return request("POST", url, **kw)
def put(url, **kw):
return request("PUT", url, **kw)
def patch(url, **kw):
return request("PATCH", url, **kw)
def delete(url, **kw):
return request("DELETE", url, **kw)

12
weather/README.md Normal file
View File

@@ -0,0 +1,12 @@
Assuming you're in the directory where you cloned this Git repository (i.e. one level up from here), try:
```bash
curl -o weather/weather-cloud-partly.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2286
curl -o weather/weather-cloudy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=12019
curl -o weather/weather-moon-stars.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=16310
curl -o weather/weather-rain-snow.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=160
curl -o weather/weather-rain.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=72
curl -o weather/weather-snow-house.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=7075
curl -o weather/weather-snowy.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=2289
curl -o weather/weather-thunderstorm.json https://developer.lametric.com/api/v1/dev/preloadicons?icon_id=11428
```

217
weatherscene.py Executable file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python
#
# Render the current weather forecast from SMHI.se
#
import os
import time
try:
import ujson as json
import urequests as requests
except ImportError:
import json
import requests
# Local imports
from pixelfont import PixelFont
# Based on demoscene.py
class WeatherScene:
"""
This module displays a weather forecast from SMHI (Sweden)
"""
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.font = PixelFont()
self.last_refreshed_at = 0
self.api_url = 'https://opendata-download-metfcst.smhi.se'
self.lat = 59.3293
self.lon = 18.0686
if config:
if 'lat' in config:
self.lat = config['lat']
if 'lon' in config:
self.lon = config['lon']
self.headers = {
'User-Agent':'weatherscene.py/1.0 (+https://github.com/noahwilliamsson/lamatrix)',
}
self.temperature = 0
self.wind_speed = 0
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
self.symbol = None
self.symbol_version = 1
self.symbol_to_animation = [
None,
'weather/weather-moon-stars.json', # clear sky
'weather/weather-moon-stars.json', # nearly clear sky
'weather/weather-cloud-partly.json', # variable cloudiness
'weather/weather-cloud-partly.json', # halfclear sky
'weather/weather-cloudy.json', # cloudy sky
'weather/weather-cloudy.json', # overcast
'weather/weather-cloudy.json', # fog
'weather/weather-rain.json', # rain showers
'weather/weather-rain.json', # thunderstorm
'weather/weather-rain-snowy.json', # light sleet
'weather/weather-rain-snowy.json', # snow showers
'weather/weather-rain.json', # rain
'weather/weather-thunderstorm.json', # thunder
'weather/weather-rain-snowy.json', # sleet
'weather/weather-snow-house.json', # snowfall
]
self.frames = [[[]]]
self.delays = [0]
self.remaining_frames = 1
self.next_frame_at = 0
self.loops = 3
def reset(self):
"""
This method is called before transitioning to this scene.
Use it to (re-)initialize any state necessary for your scene.
"""
self.remaining_frames = len(self.frames)*self.loops
self.next_frame_at = 0
t = time.time()
if t < self.last_refreshed_at + 1800:
return
# fetch a new forecast from SMHI
print('WeatherScene: reset called, requesting weather forecast')
url = '{}/api/category/pmp2g/version/2/geotype/point/lon/{}/lat/{}/data.json'.format(self.api_url, self.lon, self.lat)
r = requests.get(url, headers=self.headers)
if r.status_code != 200:
print('WeatherScene: failed to request {}: status {}'.format(url, r.status_code))
return
print('WeatherScene: parsing weather forecast')
forecast = None
expected_timestamp = '{:04d}-{:02d}-{:02d}T{:02d}'.format(*time.gmtime())
data = json.loads(r.text)
for ts in data['timeSeries']:
if ts['validTime'].startswith(expected_timestamp):
forecast = ts
break
if not forecast:
print('WeatherScene: failed to find forecast for UNIX timestamp {}'.format(this_hour))
return
self.last_refreshed_at = t
n = 0
for obj in forecast['parameters']:
if obj['name'] == 't':
self.temperature = obj['values'][0]
elif obj['name'] == 'ws':
self.wind_speed = obj['values'][0]
elif obj['name'] == 'Wsymb':
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
self.symbol = obj['values'][0]
self.symbol_version = 1
elif obj['name'] == 'Wsymb2':
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
self.symbol = obj['values'][0]
self.symbol_version = 2
else:
continue
n += 1
print('WeatherScene: updated {} parameters from forecast for {}'.format(n, forecast['validTime']))
filename = self.symbol_to_animation[self.symbol]
if not filename:
return
f = open(filename)
obj = json.loads(f.read())
f.close()
obj = json.loads(obj['body'])
self.delays = obj['delays']
self.frames = obj['icons']
self.num_frames = len(self.frames)
self.remaining_frames = self.num_frames*4
def input(self, button_id, button_state):
print('WeatherScene: button {} pressed: {}'.format(button_id, button_state))
return False # signal that we did not handle the input
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 < self.next_frame_at:
return True
self.remaining_frames -= 1
n = self.num_frames
index = n - (self.remaining_frames % n) - 1
# Calculate next frame
self.next_frame_at = frame + int(fps * self.delays[index]/1000)
# Render frame
display = self.display
data = self.frames[index]
for y in range(len(data)):
row = data[y]
for x in range(len(data)):
r = round(row[x][0] * 255)
g = round(row[x][0] * 255)
b = round(row[x][0] * 255)
display.put_pixel(x, y, r, g, b)
# Render text
if self.remaining_frames >= n:
text = '{:.2g}\'c'.format(self.temperature)
else:
text = '{:.2g}m/s'.format(self.wind_speed)
self.render_text(text)
display.render()
if self.remaining_frames == 0:
return False
return True
def render_text(self, text, x_off = 8+1, y_off = 1):
"""
Render text with the pixel font
"""
display = self.display
f = self.font
w = f.width
h = f.height
alphabet = f.alphabet
font = f.data
for i in range(len(text)):
digit = text[i]
if digit in '.-\'' or text[i-1] in '.':
x_off -= 1
data_offset = alphabet.find(digit)
if data_offset < 0:
data_offset = 0
tmp = data_offset * w * h
font_byte = tmp >> 3
font_bit = tmp & 7
for row in range(h):
for col in range(w):
c = 0
if font[font_byte] & (1<<font_bit):
c = 255
font_bit += 1
if font_bit == 8:
font_byte += 1
font_bit = 0
display.put_pixel(x_off+col, y_off+row, c, c, c)
x_off += w
if __name__ == '__main__':
# Debug API
scene = WeatherScene(None, None)
scene.reset()

130
ws2812.py Normal file
View File

@@ -0,0 +1,130 @@
# -*- coding: utf-8 -*-
"""This code is borrowed from https://github.com/JanBednarik/micropython-ws2812
It has been modified to work on, and has been tested upon, the Pycom WiPy 2.0 board.
It uses SPI MOSI (Master-out Slave-in) which is P11 or (on the pymakr board) G22.
See: https://docs.pycom.io/chapter/datasheets/downloads/wipy2-pinout.pdf
Modifications include:
* Changing buf_bytes to 0b representation for clearer view of what's happening internally
* Switch to machine.SPI instead of pyb.SPIfor Pycom WiPy 2.0 board
* Adding disable_irq and enable_irq to prevent interrupts firing mid-transaction and causing a premature reset
"""
import gc
from machine import SPI
from machine import disable_irq
from machine import enable_irq
class WS2812:
"""
Driver for WS2812 RGB LEDs. May be used for controlling single LED or chain
of LEDs.
Example of use:
chain = WS2812(spi_bus=1, led_count=4)
data = [
(255, 0, 0), # red
(0, 255, 0), # green
(0, 0, 255), # blue
(85, 85, 85), # white
]
chain.show(data)
Version: 1.0
"""
buf_bytes = (0b000010001, 0b00010011, 0b00110001, 0b00110011)
def __init__(self, spi_bus=0, ledNumber=1, intensity=1):
"""
Params:
* spi_bus = SPI bus ID (1 or 2)
* led_count = count of LEDs
* intensity = light intensity (float up to 1)
"""
self.led_count = ledNumber
self.intensity = intensity
# prepare SPI data buffer (4 bytes for each color)
self.buf_length = self.led_count * 3 * 4
self.buf = bytearray(self.buf_length)
# SPI init
self.spi = SPI(spi_bus, SPI.MASTER, baudrate=3200000, polarity=0, phase=1)
# turn LEDs off
self.show([])
def show(self, data):
"""
Show RGB data on LEDs. Expected data = [(R, G, B), ...] where R, G and B
are intensities of colors in range from 0 to 255. One RGB tuple for each
LED. Count of tuples may be less than count of connected LEDs.
"""
self.fill_buf(data)
self.send_buf()
def send_buf(self):
"""
Send buffer over SPI.
"""
disable_irq()
self.spi.write(self.buf)
enable_irq()
#gc.collect()
def update_buf(self, data, start=0):
"""
Fill a part of the buffer with RGB data.
Order of colors in buffer is changed from RGB to GRB because WS2812 LED
has GRB order of colors. Each color is represented by 4 bytes in buffer
(1 byte for each 2 bits).
Returns the index of the first unfilled LED
Note: If you find this function ugly, it's because speed optimisations
beated purity of code.
"""
buf = self.buf
buf_bytes = self.buf_bytes
intensity = self.intensity
mask = 0x03
index = start * 12
for red, green, blue in data:
# This saves 10ms for 256 leds
#red = int(red * intensity)
#green = int(green * intensity)
#blue = int(blue * intensity)
buf[index] = buf_bytes[green >> 6 & mask]
buf[index+1] = buf_bytes[green >> 4 & mask]
buf[index+2] = buf_bytes[green >> 2 & mask]
buf[index+3] = buf_bytes[green & mask]
buf[index+4] = buf_bytes[red >> 6 & mask]
buf[index+5] = buf_bytes[red >> 4 & mask]
buf[index+6] = buf_bytes[red >> 2 & mask]
buf[index+7] = buf_bytes[red & mask]
buf[index+8] = buf_bytes[blue >> 6 & mask]
buf[index+9] = buf_bytes[blue >> 4 & mask]
buf[index+10] = buf_bytes[blue >> 2 & mask]
buf[index+11] = buf_bytes[blue & mask]
index += 12
return index // 12
def fill_buf(self, data):
"""
Fill buffer with RGB data.
All LEDs after the data are turned off.
"""
end = self.update_buf(data)
# turn off the rest of the LEDs
buf = self.buf
off = self.buf_bytes[0]
for index in range(end * 12, self.buf_length):
buf[index] = off
index += 1