commit 30903a207b2f89c8244848eaaf326ed976b78c73 Author: Noah Date: Fri Dec 21 08:12:54 2018 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a29a804 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +**/*.json +*~ +*.swp +*.pyc +old diff --git a/8x32-ledmatrix-back.scad b/8x32-ledmatrix-back.scad new file mode 100644 index 0000000..652c38c --- /dev/null +++ b/8x32-ledmatrix-back.scad @@ -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]); +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d46d39c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..913c702 --- /dev/null +++ b/README.md @@ -0,0 +1,442 @@ + + +**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) + + + +# 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). diff --git a/animations/README.md b/animations/README.md new file mode 100644 index 0000000..2bfcbd8 --- /dev/null +++ b/animations/README.md @@ -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. diff --git a/animationscene.py b/animationscene.py new file mode 100755 index 0000000..0fcdba2 --- /dev/null +++ b/animationscene.py @@ -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 diff --git a/arduino/ArduinoSer2FastLED.ino b/arduino/ArduinoSer2FastLED.ino new file mode 100644 index 0000000..9ab3448 --- /dev/null +++ b/arduino/ArduinoSer2FastLED.ino @@ -0,0 +1,396 @@ +/** + * Firmware to control a LED matrix display + * https://github.com/noahwilliamsson/lamatrix + * + * -- noah@hack.se, 2018 + * + */ + +#ifdef TEENSYDUINO +#include +#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(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(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<> 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() diff --git a/clockscene.py b/clockscene.py new file mode 100755 index 0000000..619c8b9 --- /dev/null +++ b/clockscene.py @@ -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 diff --git a/config.json b/config.json new file mode 100644 index 0000000..2d8cadb --- /dev/null +++ b/config.json @@ -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 + } +} diff --git a/demoscene.py b/demoscene.py new file mode 100755 index 0000000..7a7582f --- /dev/null +++ b/demoscene.py @@ -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() diff --git a/docs/chinese-8x32-ledmatrix.jpg b/docs/chinese-8x32-ledmatrix.jpg new file mode 100644 index 0000000..3382abf Binary files /dev/null and b/docs/chinese-8x32-ledmatrix.jpg differ diff --git a/docs/lamatrix.jpg b/docs/lamatrix.jpg new file mode 100644 index 0000000..4a3299e Binary files /dev/null and b/docs/lamatrix.jpg differ diff --git a/docs/teensy31_front_pinout.png b/docs/teensy31_front_pinout.png new file mode 100644 index 0000000..240f960 Binary files /dev/null and b/docs/teensy31_front_pinout.png differ diff --git a/docs/wipy3-pinout.png b/docs/wipy3-pinout.png new file mode 100644 index 0000000..a3048ae Binary files /dev/null and b/docs/wipy3-pinout.png differ diff --git a/ledmatrix.py b/ledmatrix.py new file mode 100755 index 0000000..a78dc82 --- /dev/null +++ b/ledmatrix.py @@ -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 diff --git a/main.py b/main.py new file mode 100755 index 0000000..d3d89fa --- /dev/null +++ b/main.py @@ -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 diff --git a/pixelfont.py b/pixelfont.py new file mode 100755 index 0000000..d703b99 --- /dev/null +++ b/pixelfont.py @@ -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< 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() diff --git a/raspberry-pi/gpio-shutdown.py b/raspberry-pi/gpio-shutdown.py new file mode 100755 index 0000000..eefdb51 --- /dev/null +++ b/raspberry-pi/gpio-shutdown.py @@ -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"]) diff --git a/raspberry-pi/gpio-shutdown.service b/raspberry-pi/gpio-shutdown.service new file mode 100644 index 0000000..e85c271 --- /dev/null +++ b/raspberry-pi/gpio-shutdown.service @@ -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 diff --git a/raspberry-pi/lamatrix.service b/raspberry-pi/lamatrix.service new file mode 100644 index 0000000..b2de977 --- /dev/null +++ b/raspberry-pi/lamatrix.service @@ -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 diff --git a/urequests.py b/urequests.py new file mode 100644 index 0000000..c9d277a --- /dev/null +++ b/urequests.py @@ -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) diff --git a/weather/README.md b/weather/README.md new file mode 100644 index 0000000..dd933d9 --- /dev/null +++ b/weather/README.md @@ -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 +``` diff --git a/weatherscene.py b/weatherscene.py new file mode 100755 index 0000000..9416279 --- /dev/null +++ b/weatherscene.py @@ -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<> 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