diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..898fdd7
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,52 @@
+name: Build
+on: [push]
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ path: main
+ - name: Checkout MicroPython
+ uses: actions/checkout@v4
+ with:
+ repository: micropython/micropython
+ ref: refs/tags/v1.21.0
+ path: micropython
+ - name: Install build tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y cmake gcc-arm-none-eabi \
+ libnewlib-arm-none-eabi build-essential
+ - name: Build Cross-compiler
+ run: |
+ pushd micropython
+ make -C mpy-cross
+ popd
+ - name: Get MicroPython submodules
+ run: |
+ pushd micropython
+ make -C ports/rp2 BOARD=RPI_PICO_W submodules
+ popd
+ - name: Build MicroPython with manifest.
+ run: |
+ pushd micropython
+ cd ports/rp2
+ make -j 4 BOARD=RPI_PICO_W FROZEN_MANIFEST=../../../../main/manifest.py
+ popd
+ - name: Rename UF2
+ run: |
+ pushd micropython
+ mv ports/rp2/build-RPI_PICO_W/firmware.uf2 \
+ ports/rp2/build-RPI_PICO_W/pico_train_display.uf2
+ popd
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v3
+ with:
+ name: pico_train_display
+ path: micropython/ports/rp2/build-RPI_PICO_W/pico_train_display.uf2
+ - name: Release
+ uses: softprops/action-gh-release@v1
+ if: startsWith(github.ref, 'refs/tags/')
+ with:
+ files: micropython/ports/rp2/build-RPI_PICO_W/pico_train_display.uf2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..85206d2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+**/.DS_Store
+.vscode/
+.venv/
+.micropico
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..33fa064
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Tom Ward
+
+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..b8c4225
--- /dev/null
+++ b/README.md
@@ -0,0 +1,84 @@
+# 🚂 Pico train departure display 🚂
+
+A MicroPython-based application for displaying near-realtime UK railway
+departure times. It is designed to run on a
+[Raspberry Pi Pico W](https://www.raspberrypi.com/products/raspberry-pi-pico/)
+microcontroller, with an SSD1322-based 256x64 SPI OLED display.
+
+This project uses [Realtime Trains API](https://api.rtt.io/) as its data source,
+and is heavily inspired by [several other projects](#credits).
+
+![completed display](docs/images/completed.png)
+
+## Introduction
+
+The goal of this project is to display a live departure board for a station,
+showing trains departing for a specific destination. It's written entirely in
+Python and should be able to run on any microcontroller that is capable of
+running MicroPython.
+
+It's been extensively tested on a
+[Raspberry Pi Pico W](https://www.raspberrypi.com/products/raspberry-pi-pico/),
+which was challenging due to its limited RAM, and with an SSD1322-based display.
+
+## Building your own display
+
+> TODO: Add steps on how to build the display from scratch!
+
+## Installation
+
+The easiest way is to install the Pico Train Dispaly software is to download the
+pre-built image from the
+[latest release](http://github.com/tomwardio/pico_train_display/releases/latest).
+To install on a Raspberry Pi Pico:
+
+1. Press and hold down the BOOTSEL button while you connect the other end of the
+ micro-USB cable to your computer. This will put the Raspberry Pi Pico into
+ USB mass storage device mode.
+1. Copy the downloaded
+ [`pico_train_display.uf2`](https://github.com/tomwardio/pico_train_display/releases/latest/download/pico_train_display.uf2)
+ file to the mounted device. Once complete, the device should automatically
+ disconnect.
+1. Connect the Raspberry Pi Pico to a power supply. The display should now show
+ a welcome message with details on how to connect to the setup website.
+1. Follow the on-screen instructions. Once the settings are saved, the device
+ should automatically restart.
+
+You should now have a fully configured Pico-powered train display!
+
+### Reset settings
+
+Settings are stored in flash memory as a JSON file called `config.json`. To
+reset all settings, simply delete this file. One easy way to do this is to reset
+the entire flash memory, which can be done by following the official
+[resetting flash memory](https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html#resetting-flash-memory)
+instructions. Once flashed, you'll need to re-install the software again.
+
+## 🚧 Experimental Displays 🚧
+
+Along with the SSD1322-based displays, there's also experimental support for
+[2.9" e-Paper display](https://www.waveshare.com/wiki/Pico-ePaper-2.9-B). It's
+doesn't look as authentic, but is also super simple to setup!
+
+![ePaper display](docs/images/epaper_display.png)
+
+## Credits
+
+Firstly, a massive thank you to [Dave Ingram](https://github.com/dingram) for
+inspiring me to work on this project in the first place, and helping me with the
+hardware and low-level driver software!
+
+Thanks also goes to various other incantations of this project, namely
+[Chris Crocker-White](https://github.com/chrisys/train-departure-display),
+[Chris Hutchinson](https://github.com/chrishutchinson/train-departure-screen),
+and of course [Dave](https://github.com/dingram/uk-train-display).
+
+Also a big thank you to the wonderful folk at
+[Realtime Trains](https://www.realtimetrains.co.uk/) for providing a brilliant
+API for train departures.
+
+Finally thank you to Daniel Hart who created the wonderful
+[Dot Matrix](https://github.com/DanielHartUK/Dot-Matrix-Typeface) type face, and
+Peter Hinch for his
+[font-to-python](https://github.com/peterhinch/micropython-font-to-py) tool,
+which saved my sanity.
diff --git a/assets/setup.html b/assets/setup.html
new file mode 100644
index 0000000..c70d491
--- /dev/null
+++ b/assets/setup.html
@@ -0,0 +1,146 @@
+
+
+
+
+
+ Pico Train Display Setup
+
+
+Pico Train Display Setup
+
+
+
\ No newline at end of file
diff --git a/config.json b/config.json
new file mode 100644
index 0000000..39a06fb
--- /dev/null
+++ b/config.json
@@ -0,0 +1,20 @@
+{
+ "destination": "",
+ "station": "",
+ "wifi": {
+ "ssid": "",
+ "password": ""
+ },
+ "rtt": {
+ "username": "",
+ "password": "",
+ "update_interval": 20
+ },
+ "display": {
+ "refresh": 30,
+ "type": "ssd1322",
+ "flip": false,
+ "active_time": ""
+ },
+ "debug_log": false
+}
\ No newline at end of file
diff --git a/docs/images/completed.png b/docs/images/completed.png
new file mode 100644
index 0000000..8a49b02
Binary files /dev/null and b/docs/images/completed.png differ
diff --git a/docs/images/epaper_display.png b/docs/images/epaper_display.png
new file mode 100644
index 0000000..870c31e
Binary files /dev/null and b/docs/images/epaper_display.png differ
diff --git a/manifest.py b/manifest.py
new file mode 100644
index 0000000..4c32647
--- /dev/null
+++ b/manifest.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Manifest to freeze packages into firmware."""
+
+# Include the board's default manifest.
+include('$(BOARD_DIR)/manifest.py')
+
+package('assets', base_path='src')
+package('setup', base_path='src')
+
+module('config.py', base_path='src')
+module('display.py', base_path='src')
+module('epd29b.py', base_path='src')
+module('fonts.py', base_path='src')
+module('logging.py', base_path='src')
+module('main.py', base_path='src')
+module('ssd1322.py', base_path='src')
+module('time_range.py', base_path='src')
+module('trains.py', base_path='src')
+module('utils.py', base_path='src')
+module('widgets.py', base_path='src')
diff --git a/src/assets/__init__.py b/src/assets/__init__.py
new file mode 100644
index 0000000..5985316
--- /dev/null
+++ b/src/assets/__init__.py
@@ -0,0 +1,26 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Collection of assets baked as Python modules.
+
+These can be optionally added as frozen modules to the Pico firmware, reducing
+RAM consumption."""
+
+from . import dot_matrix_regular
+from . import dot_matrix_bold
+from . import dot_matrix_bold_tall
\ No newline at end of file
diff --git a/src/assets/dot_matrix_bold.py b/src/assets/dot_matrix_bold.py
new file mode 100644
index 0000000..6c5638d
--- /dev/null
+++ b/src/assets/dot_matrix_bold.py
@@ -0,0 +1,128 @@
+# Code generated by font_to_py.py.
+# Font: Dot Matrix Bold.ttf
+# Cmd: ./third_party/font_to_py/font_to_py.py ./third_party/fonts/Dot Matrix Bold.ttf 10 assets/dot_matrix_bold.py -x
+version = '0.33'
+
+def height():
+ return 9
+
+def baseline():
+ return 7
+
+def max_width():
+ return 9
+
+def hmap():
+ return True
+
+def reverse():
+ return False
+
+def monospaced():
+ return False
+
+def min_ch():
+ return 32
+
+def max_ch():
+ return 126
+
+_font =\
+b'\x06\x00\x70\xd8\x18\x30\x60\x00\x60\x00\x00\x02\x00\x00\x00\x00'\
+b'\x00\x00\x00\x00\x00\x00\x03\x00\xc0\xc0\xc0\xc0\xc0\x00\xc0\x00'\
+b'\x00\x06\x00\xd8\xd8\xd8\x00\x00\x00\x00\x00\x00\x08\x00\x6c\x6c'\
+b'\xfe\x6c\xfe\x6c\x6c\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8'\
+b'\x00\x00\x07\x00\xcc\xcc\x18\x30\x60\xcc\xcc\x00\x00\x08\x00\x70'\
+b'\xd8\xd8\x70\xde\xd8\x76\x00\x00\x03\x00\xc0\xc0\xc0\x00\x00\x00'\
+b'\x00\x00\x00\x05\x00\x30\x60\xc0\xc0\xc0\x60\x30\x00\x00\x05\x00'\
+b'\xc0\x60\x30\x30\x30\x60\xc0\x00\x00\x07\x00\x00\x00\x30\x30\xfc'\
+b'\x78\xcc\x00\x00\x07\x00\x00\x00\x30\x30\xfc\x30\x30\x00\x00\x04'\
+b'\x00\x00\x00\x00\x00\x00\x60\x60\xc0\x00\x04\x00\x00\x00\x00\x00'\
+b'\xe0\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\xc0\xc0\x00\x00'\
+b'\x07\x00\x0c\x0c\x18\x30\x60\xc0\xc0\x00\x00\x07\x00\x78\xcc\xcc'\
+b'\xcc\xcc\xcc\x78\x00\x00\x05\x00\x60\xe0\x60\x60\x60\x60\xf0\x00'\
+b'\x00\x07\x00\x78\xcc\x0c\x78\xc0\xc0\xfc\x00\x00\x07\x00\x78\xcc'\
+b'\x0c\x38\x0c\xcc\x78\x00\x00\x07\x00\x1c\x3c\x6c\xcc\xfc\x0c\x0c'\
+b'\x00\x00\x07\x00\xfc\xc0\xf8\x0c\x0c\xcc\x78\x00\x00\x07\x00\x7c'\
+b'\xc0\xf8\xcc\xcc\xcc\x78\x00\x00\x07\x00\xfc\x0c\x18\x30\x60\x60'\
+b'\x60\x00\x00\x07\x00\x78\xcc\xcc\x78\xcc\xcc\x78\x00\x00\x07\x00'\
+b'\x78\xcc\xcc\x7c\x0c\xcc\x78\x00\x00\x03\x00\x00\x00\xc0\xc0\x00'\
+b'\xc0\xc0\x00\x00\x04\x00\x00\x00\x00\x60\x00\x60\x60\xc0\x00\x05'\
+b'\x00\x00\x00\x30\x60\xc0\x60\x30\x00\x00\x05\x00\x00\x00\x00\xf0'\
+b'\x00\xf0\x00\x00\x00\x05\x00\x00\x00\xc0\x60\x30\x60\xc0\x00\x00'\
+b'\x06\x00\x70\xd8\x18\x30\x60\x00\x60\x00\x00\x05\x00\x00\x00\xf8'\
+b'\x88\x88\x88\xf8\x00\x00\x08\x00\x7c\xc6\xc6\xfe\xc6\xc6\xc6\x00'\
+b'\x00\x08\x00\xfc\xc6\xc6\xfc\xc6\xc6\xfc\x00\x00\x08\x00\x7c\xc6'\
+b'\xc0\xc0\xc0\xc6\x7c\x00\x00\x08\x00\xfc\xc6\xc6\xc6\xc6\xc6\xfc'\
+b'\x00\x00\x07\x00\xfc\xc0\xc0\xf8\xc0\xc0\xfc\x00\x00\x07\x00\xfc'\
+b'\xc0\xc0\xfc\xc0\xc0\xc0\x00\x00\x08\x00\x7c\xc6\xc0\xde\xc6\xc6'\
+b'\x7c\x00\x00\x08\x00\xc6\xc6\xc6\xfe\xc6\xc6\xc6\x00\x00\x07\x00'\
+b'\xfc\x30\x30\x30\x30\x30\xfc\x00\x00\x07\x00\x7c\x0c\x0c\x0c\x0c'\
+b'\xcc\x78\x00\x00\x08\x00\xc6\xcc\xd8\xf0\xd8\xcc\xc6\x00\x00\x07'\
+b'\x00\xc0\xc0\xc0\xc0\xc0\xc0\xfc\x00\x00\x09\x00\xe7\x00\xff\x00'\
+b'\xdb\x00\xdb\x00\xc3\x00\xc3\x00\xc3\x00\x00\x00\x00\x00\x08\x00'\
+b'\xe6\xf6\xde\xce\xc6\xc6\xc6\x00\x00\x07\x00\x78\xcc\xcc\xcc\xcc'\
+b'\xcc\x78\x00\x00\x08\x00\xfc\xc6\xc6\xfc\xc0\xc0\xc0\x00\x00\x09'\
+b'\x00\x7e\x00\xc3\x00\xc3\x00\xc3\x00\xdb\x00\xcf\x00\x7e\x00\x03'\
+b'\x00\x00\x00\x08\x00\xfc\xc6\xc6\xfc\xcc\xc6\xc6\x00\x00\x08\x00'\
+b'\x7c\xc6\xc0\x7c\x06\xc6\x7c\x00\x00\x07\x00\xfc\x30\x30\x30\x30'\
+b'\x30\x30\x00\x00\x07\x00\xcc\xcc\xcc\xcc\xcc\xcc\x78\x00\x00\x08'\
+b'\x00\xc6\xc6\xc6\xc6\xc6\x6c\x38\x00\x00\x08\x00\xc6\xc6\xc6\xd6'\
+b'\xfe\xee\xc6\x00\x00\x08\x00\xc6\xee\x7c\x38\x7c\xee\xc6\x00\x00'\
+b'\x09\x00\xc3\x00\xc3\x00\x66\x00\x3c\x00\x18\x00\x18\x00\x18\x00'\
+b'\x00\x00\x00\x00\x07\x00\xfc\x0c\x18\x30\x60\xc0\xfc\x00\x00\x05'\
+b'\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00\x06\x00\x80\x80\x40\x20'\
+b'\x10\x08\x08\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00'\
+b'\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00\x05\x00\x00\x00\x00'\
+b'\x00\x00\x00\xf0\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00'\
+b'\x00\x08\x00\x00\x00\x7c\x06\x7e\xc6\x7e\x00\x00\x08\x00\xc0\xc0'\
+b'\xfc\xc6\xc6\xc6\xfc\x00\x00\x08\x00\x00\x00\x7e\xc0\xc0\xc0\x7e'\
+b'\x00\x00\x08\x00\x06\x06\x7e\xc6\xc6\xc6\x7e\x00\x00\x08\x00\x00'\
+b'\x00\x7c\xc6\xfe\xc0\x7e\x00\x00\x06\x00\x30\x68\x60\xf0\x60\x60'\
+b'\x60\x00\x00\x08\x00\x00\x00\x7c\xc6\xc6\xc6\x7e\x06\xfc\x08\x00'\
+b'\xc0\xc0\xfc\xc6\xc6\xc6\xc6\x00\x00\x03\x00\xc0\x00\xc0\xc0\xc0'\
+b'\xc0\xc0\x00\x00\x06\x00\x18\x00\x18\x18\x18\x18\x18\xd8\x70\x07'\
+b'\x00\xc0\xc0\xcc\xd8\xf0\xd8\xcc\x00\x00\x03\x00\xc0\xc0\xc0\xc0'\
+b'\xc0\xc0\xc0\x00\x00\x09\x00\x00\x00\x00\x00\xe6\x00\xdb\x00\xdb'\
+b'\x00\xc3\x00\xc3\x00\x00\x00\x00\x00\x08\x00\x00\x00\xfc\xc6\xc6'\
+b'\xc6\xc6\x00\x00\x08\x00\x00\x00\x7c\xc6\xc6\xc6\x7c\x00\x00\x08'\
+b'\x00\x00\x00\xfc\xc6\xc6\xc6\xfc\xc0\xc0\x08\x00\x00\x00\x7e\xc6'\
+b'\xc6\xc6\x7e\x06\x06\x08\x00\x00\x00\xdc\xf6\xc0\xc0\xc0\x00\x00'\
+b'\x07\x00\x00\x00\x7c\xc0\x78\x0c\xf8\x00\x00\x06\x00\x60\x60\xf0'\
+b'\x60\x60\x60\x38\x00\x00\x08\x00\x00\x00\xc6\xc6\xc6\xc6\x7c\x00'\
+b'\x00\x08\x00\x00\x00\xc6\xc6\xc6\x6c\x38\x00\x00\x09\x00\x00\x00'\
+b'\x00\x00\xc3\x00\xc3\x00\xdb\x00\xe7\x00\x66\x00\x00\x00\x00\x00'\
+b'\x08\x00\x00\x00\xc6\x6c\x38\x6c\xc6\x00\x00\x08\x00\x00\x00\xc6'\
+b'\xc6\xc6\xc6\x7e\x06\xfc\x06\x00\x00\x00\xf8\x18\x70\xc0\xf8\x00'\
+b'\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00\x05\x00\x00\x00'\
+b'\xf8\x88\x88\x88\xf8\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8'\
+b'\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00'
+
+_index =\
+b'\x00\x00\x0b\x00\x16\x00\x21\x00\x2c\x00\x37\x00\x42\x00\x4d\x00'\
+b'\x58\x00\x63\x00\x6e\x00\x79\x00\x84\x00\x8f\x00\x9a\x00\xa5\x00'\
+b'\xb0\x00\xbb\x00\xc6\x00\xd1\x00\xdc\x00\xe7\x00\xf2\x00\xfd\x00'\
+b'\x08\x01\x13\x01\x1e\x01\x29\x01\x34\x01\x3f\x01\x4a\x01\x55\x01'\
+b'\x60\x01\x6b\x01\x76\x01\x81\x01\x8c\x01\x97\x01\xa2\x01\xad\x01'\
+b'\xb8\x01\xc3\x01\xce\x01\xd9\x01\xe4\x01\xef\x01\xfa\x01\x0e\x02'\
+b'\x19\x02\x24\x02\x2f\x02\x43\x02\x4e\x02\x59\x02\x64\x02\x6f\x02'\
+b'\x7a\x02\x85\x02\x90\x02\xa4\x02\xaf\x02\xba\x02\xc5\x02\xd0\x02'\
+b'\xdb\x02\xe6\x02\xf1\x02\xfc\x02\x07\x03\x12\x03\x1d\x03\x28\x03'\
+b'\x33\x03\x3e\x03\x49\x03\x54\x03\x5f\x03\x6a\x03\x75\x03\x89\x03'\
+b'\x94\x03\x9f\x03\xaa\x03\xb5\x03\xc0\x03\xcb\x03\xd6\x03\xe1\x03'\
+b'\xec\x03\x00\x04\x0b\x04\x16\x04\x21\x04\x2c\x04\x37\x04\x42\x04'\
+b'\x4d\x04'
+
+_mvfont = memoryview(_font)
+_mvi = memoryview(_index)
+ifb = lambda l : l[0] | (l[1] << 8)
+
+def get_ch(ch):
+ oc = ord(ch)
+ ioff = 2 * (oc - 32 + 1) if oc >= 32 and oc <= 126 else 0
+ doff = ifb(_mvi[ioff : ])
+ width = ifb(_mvfont[doff : ])
+
+ next_offs = doff + 2 + ((width - 1)//8 + 1) * 9
+ return _mvfont[doff + 2:next_offs], 9, width
+
diff --git a/src/assets/dot_matrix_bold_tall.py b/src/assets/dot_matrix_bold_tall.py
new file mode 100644
index 0000000..b83830a
--- /dev/null
+++ b/src/assets/dot_matrix_bold_tall.py
@@ -0,0 +1,85 @@
+# Code generated by font_to_py.py.
+# Font: Dot Matrix Bold Tall.ttf Char set: 0123456789:
+# Cmd: ./third_party/font_to_py/font_to_py.py ./third_party/fonts/Dot Matrix Bold Tall.ttf 20 assets/dot_matrix_bold_tall.py -x -c 1234567890:
+version = '0.33'
+
+def height():
+ return 18
+
+def baseline():
+ return 14
+
+def max_width():
+ return 18
+
+def hmap():
+ return True
+
+def reverse():
+ return False
+
+def monospaced():
+ return False
+
+def min_ch():
+ return 48
+
+def max_ch():
+ return 58
+
+_font =\
+b'\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00\x81\x00'\
+b'\x81\x00\x81\x00\x81\x00\x81\x00\x81\x00\xff\x00\x00\x00\x00\x00'\
+b'\x00\x00\x00\x00\x00\x00\x12\x00\x3f\xfc\x00\x3f\xfc\x00\xf0\x0f'\
+b'\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00'\
+b'\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0'\
+b'\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\x3f\xfc\x00\x3f\xfc\x00\x0a\x00'\
+b'\x3c\x00\x3c\x00\xfc\x00\xfc\x00\x3c\x00\x3c\x00\x3c\x00\x3c\x00'\
+b'\x3c\x00\x3c\x00\x3c\x00\x3c\x00\x3c\x00\x3c\x00\x3c\x00\x3c\x00'\
+b'\xff\x00\xff\x00\x12\x00\x3f\xfc\x00\x3f\xfc\x00\xf0\x0f\x00\xf0'\
+b'\x0f\x00\x00\x0f\x00\x00\x0f\x00\x00\x0f\x00\x00\x0f\x00\x3f\xfc'\
+b'\x00\x3f\xfc\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00'\
+b'\xf0\x00\x00\xf0\x00\x00\xff\xff\x00\xff\xff\x00\x12\x00\x3f\xfc'\
+b'\x00\x3f\xfc\x00\xf0\x0f\x00\xf0\x0f\x00\x00\x0f\x00\x00\x0f\x00'\
+b'\x00\x0f\x00\x00\x0f\x00\x0f\xfc\x00\x0f\xfc\x00\x00\x0f\x00\x00'\
+b'\x0f\x00\x00\x0f\x00\x00\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\x3f\xfc'\
+b'\x00\x3f\xfc\x00\x0e\x00\x03\xf0\x03\xf0\x0f\xf0\x0f\xf0\x3c\xf0'\
+b'\x3c\xf0\xf0\xf0\xf0\xf0\xf0\xf0\xf0\xf0\xff\xf0\xff\xf0\x00\xf0'\
+b'\x00\xf0\x00\xf0\x00\xf0\x00\xf0\x00\xf0\x12\x00\xff\xff\x00\xff'\
+b'\xff\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00'\
+b'\x00\xf0\x00\x00\xff\xfc\x00\xff\xfc\x00\x00\x0f\x00\x00\x0f\x00'\
+b'\x00\x0f\x00\x00\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\x3f\xfc\x00\x3f'\
+b'\xfc\x00\x12\x00\x3f\xfc\x00\x3f\xfc\x00\xf0\x0f\x00\xf0\x0f\x00'\
+b'\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xff\xfc\x00\xff'\
+b'\xfc\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f'\
+b'\x00\xf0\x0f\x00\x3f\xfc\x00\x3f\xfc\x00\x12\x00\xff\xff\x00\xff'\
+b'\xff\x00\x00\x0f\x00\x00\x0f\x00\x00\x3c\x00\x00\x3c\x00\x00\xf0'\
+b'\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x03\xc0\x00\x03\xc0\x00'\
+b'\x03\xc0\x00\x03\xc0\x00\x0f\x00\x00\x0f\x00\x00\x0f\x00\x00\x0f'\
+b'\x00\x00\x12\x00\x3f\xfc\x00\x3f\xfc\x00\xf0\x0f\x00\xf0\x0f\x00'\
+b'\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\x3f\xfc\x00\x3f'\
+b'\xfc\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f'\
+b'\x00\xf0\x0f\x00\x3f\xfc\x00\x3f\xfc\x00\x12\x00\x3f\xfc\x00\x3f'\
+b'\xfc\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\xf0\x0f'\
+b'\x00\xf0\x0f\x00\x3f\xff\x00\x3f\xff\x00\x00\x0f\x00\x00\x0f\x00'\
+b'\x00\x0f\x00\x00\x0f\x00\xf0\x0f\x00\xf0\x0f\x00\x3f\xfc\x00\x3f'\
+b'\xfc\x00\x06\x00\x00\x00\x00\x00\xf0\xf0\xf0\xf0\x00\x00\xf0\xf0'\
+b'\xf0\xf0\x00\x00\x00\x00'
+
+_index =\
+b'\x00\x00\x26\x00\x5e\x00\x84\x00\xbc\x00\xf4\x00\x1a\x01\x52\x01'\
+b'\x8a\x01\xc2\x01\xfa\x01\x32\x02\x46\x02'
+
+_mvfont = memoryview(_font)
+_mvi = memoryview(_index)
+ifb = lambda l : l[0] | (l[1] << 8)
+
+def get_ch(ch):
+ oc = ord(ch)
+ ioff = 2 * (oc - 48 + 1) if oc >= 48 and oc <= 58 else 0
+ doff = ifb(_mvi[ioff : ])
+ width = ifb(_mvfont[doff : ])
+
+ next_offs = doff + 2 + ((width - 1)//8 + 1) * 18
+ return _mvfont[doff + 2:next_offs], 18, width
+
diff --git a/src/assets/dot_matrix_regular.py b/src/assets/dot_matrix_regular.py
new file mode 100644
index 0000000..5697bba
--- /dev/null
+++ b/src/assets/dot_matrix_regular.py
@@ -0,0 +1,125 @@
+# Code generated by font_to_py.py.
+# Font: Dot Matrix Regular.ttf
+# Cmd: ./third_party/font_to_py/font_to_py.py ./third_party/fonts/Dot Matrix Regular.ttf 10 assets/dot_matrix_regular.py -x
+version = '0.33'
+
+def height():
+ return 9
+
+def baseline():
+ return 7
+
+def max_width():
+ return 6
+
+def hmap():
+ return True
+
+def reverse():
+ return False
+
+def monospaced():
+ return False
+
+def min_ch():
+ return 32
+
+def max_ch():
+ return 126
+
+_font =\
+b'\x05\x00\x60\x90\x10\x20\x40\x00\x40\x00\x00\x02\x00\x00\x00\x00'\
+b'\x00\x00\x00\x00\x00\x00\x02\x00\x80\x80\x80\x80\x80\x00\x80\x00'\
+b'\x00\x04\x00\xa0\xa0\x00\x00\x00\x00\x00\x00\x00\x06\x00\x50\x50'\
+b'\xf8\x50\xf8\x50\x50\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8'\
+b'\x00\x00\x06\x00\xc8\xc8\x10\x20\x40\x98\x98\x00\x00\x06\x00\x40'\
+b'\xa0\xa0\x40\xa8\x90\x68\x00\x00\x02\x00\x80\x80\x00\x00\x00\x00'\
+b'\x00\x00\x00\x04\x00\x20\x40\x80\x80\x80\x40\x20\x00\x00\x04\x00'\
+b'\x80\x40\x20\x20\x20\x40\x80\x00\x00\x06\x00\x00\x00\x20\x20\xf8'\
+b'\x50\x88\x00\x00\x06\x00\x00\x00\x20\x20\xf8\x20\x20\x00\x00\x03'\
+b'\x00\x00\x00\x00\x00\x00\x40\x40\x80\x00\x04\x00\x00\x00\x00\x00'\
+b'\xe0\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x80\x00\x00'\
+b'\x06\x00\x08\x08\x10\x20\x40\x80\x80\x00\x00\x05\x00\x60\x90\x90'\
+b'\x90\x90\x90\x60\x00\x00\x04\x00\x40\xc0\x40\x40\x40\x40\xe0\x00'\
+b'\x00\x05\x00\x60\x90\x10\x20\x40\x80\xf0\x00\x00\x05\x00\x60\x90'\
+b'\x10\x20\x10\x90\x60\x00\x00\x05\x00\x10\x30\x50\x90\xf0\x10\x10'\
+b'\x00\x00\x05\x00\xf0\x80\xe0\x10\x10\x90\x60\x00\x00\x05\x00\x70'\
+b'\x80\xe0\x90\x90\x90\x60\x00\x00\x05\x00\xf0\x10\x20\x40\x40\x40'\
+b'\x40\x00\x00\x05\x00\x60\x90\x90\x60\x90\x90\x60\x00\x00\x05\x00'\
+b'\x60\x90\x90\x70\x10\x90\x60\x00\x00\x02\x00\x00\x00\x00\x80\x00'\
+b'\x80\x00\x00\x00\x03\x00\x00\x00\x00\x40\x00\x40\x40\x80\x00\x04'\
+b'\x00\x00\x00\x20\x40\x80\x40\x20\x00\x00\x05\x00\x00\x00\x00\xf0'\
+b'\x00\xf0\x00\x00\x00\x04\x00\x00\x00\x80\x40\x20\x40\x80\x00\x00'\
+b'\x05\x00\x60\x90\x10\x20\x40\x00\x40\x00\x00\x05\x00\x00\x00\xf8'\
+b'\x88\x88\x88\xf8\x00\x00\x06\x00\x70\x88\x88\xf8\x88\x88\x88\x00'\
+b'\x00\x05\x00\xe0\x90\x90\xe0\x90\x90\xe0\x00\x00\x05\x00\x60\x90'\
+b'\x80\x80\x80\x90\x60\x00\x00\x05\x00\xe0\x90\x90\x90\x90\x90\xe0'\
+b'\x00\x00\x05\x00\xf0\x80\x80\xe0\x80\x80\xf0\x00\x00\x05\x00\xf0'\
+b'\x80\x80\xf0\x80\x80\x80\x00\x00\x05\x00\x60\x90\x80\xb0\x90\x90'\
+b'\x60\x00\x00\x05\x00\x90\x90\x90\xf0\x90\x90\x90\x00\x00\x04\x00'\
+b'\xe0\x40\x40\x40\x40\x40\xe0\x00\x00\x05\x00\x70\x10\x10\x10\x10'\
+b'\x90\x60\x00\x00\x06\x00\x88\x90\xa0\xc0\xa0\x90\x88\x00\x00\x05'\
+b'\x00\x80\x80\x80\x80\x80\x80\xf0\x00\x00\x06\x00\x88\xd8\xa8\x88'\
+b'\x88\x88\x88\x00\x00\x05\x00\x90\xd0\xb0\x90\x90\x90\x90\x00\x00'\
+b'\x05\x00\x60\x90\x90\x90\x90\x90\x60\x00\x00\x05\x00\xe0\x90\x90'\
+b'\xe0\x80\x80\x80\x00\x00\x05\x00\x60\x90\x90\x90\x90\x90\x60\x10'\
+b'\x00\x05\x00\xe0\x90\x90\xe0\xa0\x90\x90\x00\x00\x05\x00\x60\x90'\
+b'\x80\x60\x10\x90\x60\x00\x00\x06\x00\xf8\x20\x20\x20\x20\x20\x20'\
+b'\x00\x00\x05\x00\x90\x90\x90\x90\x90\x90\x60\x00\x00\x06\x00\x88'\
+b'\x88\x88\x88\x88\x50\x20\x00\x00\x06\x00\x88\x88\x88\xa8\xa8\xd8'\
+b'\x88\x00\x00\x06\x00\x88\x88\x50\x20\x50\x88\x88\x00\x00\x06\x00'\
+b'\x88\x88\x50\x20\x20\x20\x20\x00\x00\x06\x00\xf8\x08\x10\x20\x40'\
+b'\x80\xf8\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00\x06'\
+b'\x00\x80\x80\x40\x20\x10\x08\x08\x00\x00\x05\x00\x00\x00\xf8\x88'\
+b'\x88\x88\xf8\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00'\
+b'\x05\x00\x00\x00\x00\x00\x00\x00\xf0\x00\x00\x05\x00\x00\x00\xf8'\
+b'\x88\x88\x88\xf8\x00\x00\x05\x00\x00\x00\x60\x10\x70\x90\x70\x00'\
+b'\x00\x05\x00\x80\x80\xe0\x90\x90\x90\xe0\x00\x00\x05\x00\x00\x00'\
+b'\x70\x80\x80\x80\x70\x00\x00\x05\x00\x10\x10\x70\x90\x90\x90\x70'\
+b'\x00\x00\x05\x00\x00\x00\x60\x90\xf0\x80\x70\x00\x00\x05\x00\x20'\
+b'\x50\x40\xe0\x40\x40\x40\x00\x00\x05\x00\x00\x00\x70\x90\x90\x90'\
+b'\x70\x10\xe0\x05\x00\x80\x80\xe0\x90\x90\x90\x90\x00\x00\x02\x00'\
+b'\x80\x00\x80\x80\x80\x80\x80\x00\x00\x04\x00\x20\x00\x20\x20\x20'\
+b'\xa0\x40\x00\x00\x05\x00\x80\x80\x90\xa0\xc0\xa0\x90\x00\x00\x02'\
+b'\x00\x80\x80\x80\x80\x80\x80\x80\x00\x00\x06\x00\x00\x00\xd0\xa8'\
+b'\xa8\x88\x88\x00\x00\x05\x00\x00\x00\xe0\x90\x90\x90\x90\x00\x00'\
+b'\x05\x00\x00\x00\x60\x90\x90\x90\x60\x00\x00\x05\x00\x00\x00\xe0'\
+b'\x90\x90\x90\xe0\x80\x80\x05\x00\x00\x00\x70\x90\x90\x90\x70\x10'\
+b'\x10\x05\x00\x00\x00\xa0\xd0\x80\x80\x80\x00\x00\x05\x00\x00\x00'\
+b'\x70\x80\x60\x10\xe0\x00\x00\x05\x00\x40\x40\xe0\x40\x40\x40\x30'\
+b'\x00\x00\x05\x00\x00\x00\x90\x90\x90\x90\x60\x00\x00\x06\x00\x00'\
+b'\x00\x88\x88\x88\x50\x20\x00\x00\x06\x00\x00\x00\x88\x88\xa8\xd8'\
+b'\x88\x00\x00\x06\x00\x00\x00\x88\x50\x20\x50\x88\x00\x00\x05\x00'\
+b'\x00\x00\x90\x90\x90\x90\x70\x10\xe0\x04\x00\x00\x00\xe0\x20\x40'\
+b'\x80\xe0\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00\x01'\
+b'\x00\x80\x80\x80\x80\x80\x80\x80\x80\x80\x05\x00\x00\x00\xf8\x88'\
+b'\x88\x88\xf8\x00\x00\x05\x00\x00\x00\xf8\x88\x88\x88\xf8\x00\x00'\
+
+_index =\
+b'\x00\x00\x0b\x00\x16\x00\x21\x00\x2c\x00\x37\x00\x42\x00\x4d\x00'\
+b'\x58\x00\x63\x00\x6e\x00\x79\x00\x84\x00\x8f\x00\x9a\x00\xa5\x00'\
+b'\xb0\x00\xbb\x00\xc6\x00\xd1\x00\xdc\x00\xe7\x00\xf2\x00\xfd\x00'\
+b'\x08\x01\x13\x01\x1e\x01\x29\x01\x34\x01\x3f\x01\x4a\x01\x55\x01'\
+b'\x60\x01\x6b\x01\x76\x01\x81\x01\x8c\x01\x97\x01\xa2\x01\xad\x01'\
+b'\xb8\x01\xc3\x01\xce\x01\xd9\x01\xe4\x01\xef\x01\xfa\x01\x05\x02'\
+b'\x10\x02\x1b\x02\x26\x02\x31\x02\x3c\x02\x47\x02\x52\x02\x5d\x02'\
+b'\x68\x02\x73\x02\x7e\x02\x89\x02\x94\x02\x9f\x02\xaa\x02\xb5\x02'\
+b'\xc0\x02\xcb\x02\xd6\x02\xe1\x02\xec\x02\xf7\x02\x02\x03\x0d\x03'\
+b'\x18\x03\x23\x03\x2e\x03\x39\x03\x44\x03\x4f\x03\x5a\x03\x65\x03'\
+b'\x70\x03\x7b\x03\x86\x03\x91\x03\x9c\x03\xa7\x03\xb2\x03\xbd\x03'\
+b'\xc8\x03\xd3\x03\xde\x03\xe9\x03\xf4\x03\xff\x03\x0a\x04\x15\x04'\
+b'\x20\x04'
+
+_mvfont = memoryview(_font)
+_mvi = memoryview(_index)
+ifb = lambda l : l[0] | (l[1] << 8)
+
+def get_ch(ch):
+ oc = ord(ch)
+ ioff = 2 * (oc - 32 + 1) if oc >= 32 and oc <= 126 else 0
+ doff = ifb(_mvi[ioff : ])
+ width = ifb(_mvfont[doff : ])
+
+ next_offs = doff + 2 + ((width - 1)//8 + 1) * 9
+ return _mvfont[doff + 2:next_offs], 9, width
+
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..959bca2
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,138 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Configuration class for storing config options."""
+
+import display
+import time_range
+
+
+class RttConfig:
+ """Real-time trains configuration."""
+
+ def __init__(self, username: str, password: str, update_interval: int):
+ self.username = username
+ self.password = password
+ self.update_interval = update_interval
+
+ def validate(self):
+ if self.update_interval <= 0:
+ raise ValueError(
+ f'RTT update interval must be > 0! {self.update_interval=}'
+ )
+
+
+class WifiConfig:
+ """WiFi configuration."""
+
+ def __init__(self, ssid: str, password: str):
+ self.ssid = ssid
+ self.password = password
+
+ def validate(self):
+ pass
+
+
+class DisplayConfig:
+ """Display configuration."""
+
+ def __init__(
+ self,
+ refresh: int,
+ type: str,
+ flip: bool = False,
+ active_time: str | None = None,
+ ):
+ self.refresh = refresh
+ self.type = type
+ self.flip = flip
+ self.active_time = time_range.parse(active_time) if active_time else None
+
+ def validate(self):
+ if self.refresh <= 0:
+ raise ValueError(f'Display refresh must be > 0! refresh={self.refresh}')
+ if self.type not in display.displays():
+ raise ValueError(f'Unrecognized display name! type={self.type}')
+ if not isinstance(self.flip, bool):
+ raise ValueError(f'Display flip must be a boolean! flip={self.flip}')
+
+
+class DebugConfig:
+ """Debug configuration."""
+
+ def __init__(self, log: bool = False):
+ self.log = log
+
+ def validate(self):
+ if not isinstance(self.log, bool):
+ raise ValueError(f'Debug log must be a boolean! log={self.log}')
+
+
+class Config:
+ """Main configuration class."""
+
+ def __init__(
+ self,
+ *,
+ destination: str,
+ station: str,
+ wifi: WifiConfig,
+ rtt: RttConfig,
+ display: DisplayConfig,
+ min_departure_time: int = 0,
+ debug: DebugConfig = DebugConfig(),
+ ):
+ self.destination = destination
+ self.station = station
+ self.wifi = wifi
+ self.rtt = rtt
+ self.display = display
+ self.min_departure_time = min_departure_time
+ self.debug = debug
+ self.validate()
+
+ def validate(self):
+ if len(self.destination) != 3:
+ raise ValueError(f'Invalid destination! destination={self.destination}')
+ if len(self.station) != 3:
+ raise ValueError(f'Invalid station! station={self.station}')
+ self.wifi.validate()
+ self.rtt.validate()
+ self.display.validate()
+ if self.min_departure_time < 0:
+ raise ValueError(
+ 'Minimum departure time must be >= 0! '
+ f'min_departure_time={self.min_departure_time}'
+ )
+ self.debug.validate()
+
+
+def load(config_json) -> Config:
+ kwargs = {}
+ for k, v in config_json.items():
+ if k == 'wifi':
+ kwargs[k] = WifiConfig(**v)
+ elif k == 'display':
+ kwargs[k] = DisplayConfig(**v)
+ elif k == 'rtt':
+ kwargs[k] = RttConfig(**v)
+ elif k == 'debug':
+ kwargs[k] = DebugConfig(**v)
+ else:
+ kwargs[k] = v
+ return Config(**kwargs)
diff --git a/src/display.py b/src/display.py
new file mode 100644
index 0000000..31f5e04
--- /dev/null
+++ b/src/display.py
@@ -0,0 +1,92 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Base class and factory for creating displays instances."""
+
+import framebuf
+import machine
+
+
+_DEFAULT_DISPLAY = 'ssd1322'
+
+
+# TODO: Make this a proper ABC when Micropython supports abc module.
+class Display(framebuf.FrameBuffer):
+ """Base class for displays."""
+
+ @property
+ def width(self) -> int:
+ """Width in pixels of the display."""
+ ...
+
+ @property
+ def height(self) -> int:
+ """Height in pixels of the display."""
+ ...
+
+ def flush(self) -> None:
+ """Flushes frame buffer to the display."""
+ ...
+
+ def close(self) -> None:
+ """Clears and closes the display."""
+ ...
+
+ def sleep(self) -> None:
+ """Puts display to sleep."""
+ ...
+
+ def awake(self) -> None:
+ """Wakes up a display."""
+ ...
+
+
+def displays():
+ return {'epd29b', _DEFAULT_DISPLAY}
+
+
+def create(name: str = _DEFAULT_DISPLAY, flip_display: bool = False):
+ """Factory function to create display."""
+ name = name.lower()
+ if name == _DEFAULT_DISPLAY:
+ import ssd1322
+
+ spi = machine.SPI(
+ 0, baudrate=8_000_000, sck=machine.Pin(18), mosi=machine.Pin(19)
+ )
+ return ssd1322.SSD1322(
+ spi,
+ dc=machine.Pin(20),
+ cs=machine.Pin(17),
+ rst=machine.Pin(21),
+ flip_display=flip_display,
+ )
+ elif name == 'epd29b':
+ import epd29b
+
+ spi = machine.SPI(1, baudrate=4_000_000)
+
+ return epd29b.EPD29B(
+ spi,
+ dc=machine.Pin(8),
+ cs=machine.Pin(9),
+ rst=machine.Pin(12),
+ busy=machine.Pin(13),
+ )
+ else:
+ raise ValueError('Unrecognized display "{}"!'.format(name))
diff --git a/src/epd29b.py b/src/epd29b.py
new file mode 100644
index 0000000..956b82f
--- /dev/null
+++ b/src/epd29b.py
@@ -0,0 +1,219 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Implementation of e-Paper 2.9" display driver.
+
+Datasheet: https://files.waveshare.com/upload/a/af/2.9inch-e-paper-b-v3-specification.pdf
+"""
+
+import math
+import time
+
+import framebuf
+import machine
+import micropython
+
+import display
+
+
+def _set_array(buffer: memoryview, value: int):
+ """Helper to set all bytes in an array to a certain value."""
+
+ @micropython.viper
+ def _set_array_impl(x: ptr8, length: int, v: int): # type: ignore
+ for i in range(length):
+ x[i] = v
+
+ _set_array_impl(buffer, len(buffer), value)
+
+
+def _invert_array(buffer: memoryview):
+ """Helper to invert all bits in a byte array."""
+
+ @micropython.viper
+ def _invert_array_impl(x: ptr8, length: int): # type: ignore
+ for i in range(length):
+ x[i] ^= 0xFF
+
+ _invert_array_impl(buffer, len(buffer))
+
+
+def _set_pixel(buf: memoryview, x: int, y: int, bytes_per_row: int):
+ """Helper to set bit for a pixel and position (x, y)."""
+ buf[y * bytes_per_row + (x // 8)] |= 1 << 7 - (x % 8)
+
+
+def _is_pixel_set(buf: memoryview, x: int, y: int, bytes_per_row: int) -> bool:
+ """Helper to determine whether a pixel bit is set or not."""
+ return buf[y * bytes_per_row + (x // 8)] & (1 << 7 - (x % 8)) != 0
+
+
+class EPD29B(display.Display):
+ """E-paper display 2.9inch model B."""
+
+ def __init__(
+ self,
+ spi: machine.SPI,
+ cs: machine.Pin,
+ dc: machine.Pin,
+ rst: machine.Pin,
+ busy: machine.Pin,
+ width: int = 296,
+ height: int = 128,
+ rotate: bool = True,
+ ):
+ self.spi = spi
+ self.cs = cs
+ self.dc = dc
+ self.rst = rst
+ self.busy = busy
+
+ self.cs.init(self.cs.OUT, value=1)
+ self.dc.init(self.dc.OUT, value=0)
+ self.rst.init(self.rst.OUT, value=1)
+ self.busy.init(self.busy.IN, value=1)
+
+ self._width = width
+ self._height = height
+
+ self._black_buffer = bytearray(self._width * self._height // 8)
+ self._black_memoryview = memoryview(self._black_buffer)
+ super().__init__(self._black_buffer, width, height, framebuf.MONO_HLSB)
+
+ self._red_buffer = bytearray(self._width * self._height // 8)
+ self._red_memoryview = memoryview(self._red_buffer)
+ self._red = framebuf.FrameBuffer(
+ self._red_memoryview, width, height, framebuf.MONO_HLSB
+ )
+
+ self._rotate = rotate
+ self._buffer = bytearray(self._width * self._height // 8)
+ self._memory_view = memoryview(self._buffer)
+
+ self.clear()
+ self._init_display()
+
+ def _init_display(self):
+ self._reset()
+
+ self.write_cmd(0x04)
+ self._wait_busy()
+
+ self.write_cmd(0x00, 0x0F, 0x89) # Panel configuration
+ self.write_cmd(0x50, 0x77) # Set VCOM and data interval.
+ self.write_cmd(0x61, 0x80, 0x01, 0x28) # Display resolution start and end.
+
+ def clear(self):
+ self.fill(0)
+ self.red.fill(0)
+
+ def _reset(self):
+ self.rst(1)
+ time.sleep_ms(50)
+ self.rst(0)
+ time.sleep_ms(2)
+ self.rst(1)
+ time.sleep_ms(50)
+
+ @micropython.native
+ def _wait_busy(self):
+ cmd = bytearray([0x71])
+ self.write_cmd(cmd)
+ while self.busy.value() == 0:
+ self.write_cmd(cmd)
+ time.sleep_ms(10)
+
+ @property
+ def width(self) -> int:
+ return self._width
+
+ @property
+ def height(self) -> int:
+ return self._height
+
+ @property
+ def red(self) -> framebuf.FrameBuffer:
+ return self._red
+
+ def close(self):
+ self.clear()
+ self.flush()
+ self.sleep()
+
+ def sleep(self):
+ self.write_cmd(0x02)
+ self._wait_busy()
+ self.write_cmd(0x07, 0xA5)
+
+ def awake(self):
+ self._init_display()
+
+ def write_cmd(self, cmd: int | bytearray | memoryview, *args):
+ self.dc(0)
+ self.cs(0)
+ cmd = bytearray([cmd]) if isinstance(cmd, int) else cmd
+ self.spi.write(cmd)
+ self.cs(1)
+
+ if len(args) > 0:
+ self.write_data(bytearray(args))
+
+ def write_data(self, data):
+ self.dc(1)
+ self.cs(0)
+ self.spi.write(data)
+ self.cs(1)
+
+ def flush(self):
+ self.write_cmd(0x10)
+ self.write_data(self._convert(self._black_memoryview))
+
+ self.write_cmd(0x13)
+ self.write_data(self._convert(self._red_memoryview))
+ self._refresh()
+
+ def _refresh(self):
+ self.write_cmd(0x12)
+ self._wait_busy()
+
+ @micropython.native
+ def _convert(self, src: memoryview) -> memoryview:
+ """Converts an internal frame buffer to ePaper format.
+
+ This typically means rotating if we're rendering in landscape, and inverting
+ colors so that 0 == black, 255 = white/red.
+ """
+ if self._rotate:
+ dst = self._memory_view
+ _set_array(dst, 0)
+
+ src_bytes_per_row = int(math.ceil(self._width / 8))
+ dst_bytes_per_row = int(math.ceil(self._height / 8))
+
+ for x in range(self._width):
+ for y in range(self._height):
+ if _is_pixel_set(src, x, y, src_bytes_per_row):
+ _set_pixel(dst, self._height - y - 1, x, dst_bytes_per_row)
+ _invert_array(dst)
+ return memoryview(self._buffer)
+ else:
+ dst = self._memory_view
+ for i in range(len(src)):
+ dst[i] = src[i]
+ _invert_array(dst)
+ return dst
diff --git a/src/fonts.py b/src/fonts.py
new file mode 100644
index 0000000..3f222d8
--- /dev/null
+++ b/src/fonts.py
@@ -0,0 +1,84 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Font class that wraps up a font_to_python module."""
+
+import framebuf
+import gc
+import uctypes
+
+import assets
+
+
+class Font:
+ """Helper class that wraps font_to_python module.
+
+ Provides helper functions for rendering text into a given display."""
+
+ def __init__(self, font, palette: framebuf.FrameBuffer):
+ self._palette = palette
+
+ # Cache per-character frame buffer to prevent re-allocs when rendering.
+ self._chars_framebuf = []
+ self._char_width = bytearray(font.max_ch() - font.min_ch() + 1)
+ self._font = font
+
+ for i in range(font.min_ch(), font.max_ch() + 1):
+ buffer, height, width = font.get_ch(chr(i))
+ self._chars_framebuf.append(
+ framebuf.FrameBuffer(
+ uctypes.bytearray_at(uctypes.addressof(buffer), len(buffer)),
+ width,
+ height,
+ framebuf.MONO_HLSB,
+ (width + 7) & -8, # Round up to next multiple of 8.
+ ),
+ )
+ self._char_width[i - font.min_ch()] = width
+
+ # Need to call gc.collect() each loop to mitigate fragmentation.
+ gc.collect()
+
+ def render_text(
+ self, text: str, framebuffer: framebuf.FrameBuffer, x: int, y: int
+ ) -> None:
+ """Renders text into the provided display at position [x, y]."""
+ idx = 0
+ for char in text:
+ idx = ord(char) - self._font.min_ch()
+ framebuffer.blit(self._chars_framebuf[idx], x, y, -1, self._palette)
+ x += self._char_width[idx]
+
+ def calculate_bounds(self, text: str) -> tuple[int, int]:
+ """Calculates the bounds for a piece of text."""
+ width, height = 0, 0
+ for char in text:
+ width += self._char_width[ord(char) - self._font.min_ch()]
+ height = max(height, self._font.height())
+ return width, height
+
+ def max_bounds(self) -> tuple[int, int]:
+ """Returns the max bounds for any given character."""
+ return self._font.max_width(), self._font.height()
+
+
+_PALETTE = framebuf.FrameBuffer(bytearray([0, 255]), 2, 1, framebuf.GS8)
+
+DEFAULT_FONT = Font(assets.dot_matrix_regular, _PALETTE)
+BOLD_FONT = Font(assets.dot_matrix_bold, _PALETTE)
+TALL_FONT = Font(assets.dot_matrix_bold_tall, _PALETTE)
diff --git a/src/logging.py b/src/logging.py
new file mode 100644
index 0000000..9537625
--- /dev/null
+++ b/src/logging.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Simple logging library that both logs to screen and file."""
+
+import os
+import time
+
+_logging_file = None
+
+
+def set_logging_file(path: str):
+ # Open file in append mode so that we accumulate logs.
+ global _logging_file
+ _logging_file = open(path, 'a')
+ os.dupterm(_logging_file)
+
+
+def _log_message(prefix: str, msg, *args, **kwargs):
+ args = args or []
+ kwargs = kwargs or {}
+ msg = '{} {}'.format(prefix, str(msg).format(*args, **kwargs))
+ print(msg)
+
+
+def log(msg, *args, **kwargs):
+ now = time.localtime()
+ prefix = '[{:0>2}:{:0>2}:{:0>2}]'.format(now[3], now[4], now[5])
+ _log_message(prefix, msg, *args, **kwargs)
+
+
+def on_exit():
+ if _logging_file is not None:
+ os.dupterm(None)
+ _logging_file.flush()
+ _logging_file.close()
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..cf931ab
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,339 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Main entrypoint for Pico train display."""
+
+import asyncio
+import errno
+import gc
+import json
+import sys
+import time
+import _thread
+
+import machine
+import micropython
+import network
+import ntptime
+
+import config as config_module
+import display
+import fonts
+import logging
+from setup import server
+import time_range
+import trains
+import utils
+import widgets
+
+
+_WIFI_CONNECT = 'Connecting'
+_LOADING_DEPARTURES = 'Loading train departures...'
+_DISPLAY_NOT_ACTIVE = 'Outside active hours, going to sleep...'
+
+_SETUP_WIFI_SSID = 'Pico Train Display'
+_SETUP_WIFI_PASSWORD = '12345678'
+_SETUP_MESSAGE = (
+ 'Welcome! To setup the display, join\n'
+ 'Wifi: {}\nPassword: {}\nThen visit http://{}'
+)
+
+_MAX_ATTEMPTS = 3
+_CONNECT_TIMEOUT = 15
+
+gc.collect()
+
+
+def _connect(ssid: str, password: str, screen: display.Display) -> network.WLAN:
+ widget = widgets.MessageWidget(screen, _WIFI_CONNECT, fonts.DEFAULT_FONT)
+ logging.log('Connecting to SSID: {} PASSWORD: {}', ssid, '*' * len(password))
+
+ wlan = network.WLAN(network.STA_IF)
+ wlan.active(True)
+ wlan.connect(ssid, password if password else None)
+
+ for i in range(_CONNECT_TIMEOUT):
+ if wlan.isconnected():
+ logging.log('Connected!')
+ logging.log(wlan.ifconfig())
+ return wlan
+
+ widget.render('{}{}'.format(_WIFI_CONNECT, '.' * (i % 4)))
+ screen.flush()
+ time.sleep(1)
+
+ raise OSError(
+ errno.ETIMEDOUT,
+ 'Failed to connect to wifi in {} secs'.format(_CONNECT_TIMEOUT),
+ )
+
+
+def _reconnect(wlan: network.WLAN, ssid: str, password: str):
+ wlan.active(True)
+ wlan.connect(ssid, password if password else None)
+ for _ in range(_CONNECT_TIMEOUT):
+ if wlan.isconnected():
+ logging.log('Reconnected to wifi!')
+ return
+
+ time.sleep(1)
+ raise TimeoutError(
+ 'Failed to reconnect to wifi in {} secs'.format(_CONNECT_TIMEOUT)
+ )
+
+
+def _configure_time():
+ logging.log('Configure datetime.')
+ while True:
+ try:
+ ntptime.settime()
+ break
+ except OSError:
+ pass
+ t = time.localtime()
+ logging.log('Time set to UTC {}/{}/{} {}:{}', *t[:5])
+
+
+# TODO: Make this an enum when micropython supports such a thing
+class _ScreenState:
+ ENTER_NON_ACTIVE = micropython.const(0)
+ NON_ACTIVE = micropython.const(1)
+ ACTIVE = micropython.const(2)
+
+
+def _render_thread(
+ screen: display.Display,
+ departure_updater: trains.DepartureUpdater,
+ config: config_module.Config,
+ main_running: _thread.LockType,
+ thread_running: _thread.LockType,
+):
+ with thread_running:
+ main_display = widgets.MainWidget(
+ screen,
+ departure_updater,
+ fonts.BOLD_FONT,
+ fonts.TALL_FONT,
+ fonts.DEFAULT_FONT,
+ # Don't render seconds on e-paper displays.
+ render_seconds=(config.display.type != 'epd29b'),
+ )
+ non_active = widgets.MessageWidget(
+ screen, _DISPLAY_NOT_ACTIVE, fonts.DEFAULT_FONT
+ )
+
+ active_time = config.display.active_time
+ refresh_rate_us = int((1 / config.display.refresh) / 1e-6)
+ screen.fill(0)
+ state = _ScreenState.ACTIVE
+
+ while main_running.locked():
+ now = utils.get_uk_time()
+ start = time.ticks_us()
+
+ sleep_time_us = refresh_rate_us
+ if state == _ScreenState.ACTIVE:
+ if active_time is not None and not active_time.in_range(now):
+ state = _ScreenState.ENTER_NON_ACTIVE
+ elif main_display.render(now):
+ gc.collect()
+ screen.flush()
+ elif state == _ScreenState.ENTER_NON_ACTIVE:
+ logging.log(
+ 'Detected non-active time {} sleeping...', utils.get_uk_time()
+ )
+ non_active.render()
+ screen.flush()
+ time.sleep(3)
+ screen.fill(0)
+ screen.flush()
+ screen.sleep()
+ state = _ScreenState.NON_ACTIVE
+ elif state == _ScreenState.NON_ACTIVE:
+ assert active_time is not None
+ if active_time.in_range(now):
+ logging.log('Awake from non-active time {}', utils.get_uk_time())
+ screen.awake()
+ state = _ScreenState.ACTIVE
+ else:
+ # Check again in 10s
+ sleep_time_us = int(10 * 1e6)
+ else:
+ raise ValueError('Unrecognized screen state: {}'.format(state))
+
+ gc.collect()
+ elapsed = time.ticks_diff(time.ticks_us(), start)
+ sleep_for = sleep_time_us - elapsed
+ if sleep_for > 0:
+ time.sleep_us(sleep_for)
+ logging.log('Render thread closing...')
+
+
+def run(config: config_module.Config):
+ logging.log('Starting...')
+
+ screen = display.create(config.display.type, config.display.flip)
+ main_running = _thread.allocate_lock()
+ thread_running = _thread.allocate_lock()
+ try:
+ main_running.acquire()
+ departure_updater = trains.DepartureUpdater(
+ config.station,
+ config.destination,
+ trains.make_basic_auth(
+ username=config.rtt.username,
+ password=config.rtt.password,
+ ),
+ min_departure_time=config.min_departure_time,
+ )
+ gc.collect()
+ micropython.mem_info()
+ gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
+
+ wlan = _connect(config.wifi.ssid, config.wifi.password, screen=screen)
+ _configure_time()
+
+ logging.log('Get initial train departures')
+ # Don't show loading departures for e-Paper displays.
+ if config.display.type != 'epd29b':
+ widget = widgets.MessageWidget(
+ screen, _LOADING_DEPARTURES, fonts.DEFAULT_FONT
+ )
+ widget.render()
+ screen.flush()
+
+ # Get first set of departures synchonously.
+ departure_updater.update()
+ gc.collect()
+
+ logging.log('Start render loop')
+ _ = _thread.start_new_thread(
+ _render_thread,
+ (screen, departure_updater, config, main_running, thread_running),
+ )
+
+ update_interval = config.rtt.update_interval
+ logging.log('Start updating departures every {} seconds', update_interval)
+ while True:
+ for attempt in range(1, _MAX_ATTEMPTS + 1):
+ try:
+ departure_updater.update()
+ gc.collect()
+ break
+ except (OSError, ValueError) as e:
+ # Catch transient network or HTTP issues and retry
+ if isinstance(e, OSError) and e.errno == errno.ECONNABORTED:
+ logging.log('Received ECONNABORTED error, try reconnecting...')
+ _reconnect(wlan, config.wifi.ssid, config.wifi.password)
+ logging.log(
+ 'Train update attempt {}/{} failed!', attempt, _MAX_ATTEMPTS
+ )
+ if attempt < _MAX_ATTEMPTS:
+ sys.print_exception(e)
+ else:
+ raise e
+
+ for _ in range(update_interval):
+ time.sleep(1)
+ finally:
+ logging.log('Main thread closing...')
+ main_running.release()
+
+ # Wait for thread lock to be released, which indicates the thread has
+ # finished running (or was never started).
+ with thread_running:
+ screen.close()
+
+
+async def _setup_access_point():
+ ap = network.WLAN(network.AP_IF)
+ ap.config(ssid=_SETUP_WIFI_SSID, password=_SETUP_WIFI_PASSWORD)
+ ap.active(True)
+ logging.log('Creating AP wifi with SSID: {}', _SETUP_WIFI_SSID)
+
+ for _ in range(_CONNECT_TIMEOUT):
+ if ap.isconnected():
+ return ap
+ await asyncio.sleep(1)
+
+ raise OSError(
+ 'Failed to setup wifi access point in {} secs'.format(_CONNECT_TIMEOUT)
+ )
+
+
+async def setup(screen: display.Display):
+ event = asyncio.Event()
+ ap = await _setup_access_point()
+ ip_address = ap.ifconfig()[0]
+
+ setup_message = _SETUP_MESSAGE.format(
+ _SETUP_WIFI_SSID, _SETUP_WIFI_PASSWORD, ip_address
+ )
+ logging.log(setup_message)
+
+ widget = widgets.MessageWidget(screen, setup_message, fonts.DEFAULT_FONT)
+ widget.render()
+ screen.flush()
+
+ def _write_config(cfg):
+ _ = config_module.load(cfg)
+ with open('config.json', 'w') as f:
+ json.dump(cfg, f)
+
+ web_server = await server.start(_write_config, event)
+ await event.wait()
+ web_server.close()
+ screen.fill(0)
+ screen.flush()
+ await web_server.wait_closed()
+
+
+def main():
+ try:
+ with open('config.json', 'r') as f:
+ config = config_module.load(json.load(f))
+ except OSError:
+ screen = display.create()
+ try:
+ asyncio.run(setup(screen))
+ machine.reset()
+ finally:
+ screen.close()
+
+ if config.debug.log:
+ logging.set_logging_file('debug.txt')
+ run(config)
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except KeyboardInterrupt:
+ logging.log('Keyboard interrupt!')
+ except Exception as e:
+ logging.log('Unhandled exception!')
+ sys.print_exception(e)
+ micropython.mem_info()
+ raise e
+ finally:
+ logging.log('Shutdown')
+ logging.on_exit()
+
+ # Hard reset device to reset RAM. Although this should be unnecessary,
+ # residual, fragmented memory seems to still exist.
+ machine.reset()
diff --git a/src/setup/__init__.py b/src/setup/__init__.py
new file mode 100644
index 0000000..6a58bb3
--- /dev/null
+++ b/src/setup/__init__.py
@@ -0,0 +1,33 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+
+# Copyright 2023 Tom Ward
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
diff --git a/src/setup/content.py b/src/setup/content.py
new file mode 100644
index 0000000..b4aaa36
--- /dev/null
+++ b/src/setup/content.py
@@ -0,0 +1,305 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+
+# Code generated by data_to_py.py.
+version = '0.1'
+
+_data =\
+b'\x3c\x21\x44\x4f\x43\x54\x59\x50\x45\x20\x68\x74\x6d\x6c\x3e\x0a'\
+b'\x3c\x68\x74\x6d\x6c\x3e\x0a\x0a\x3c\x68\x65\x61\x64\x3e\x0a\x20'\
+b'\x20\x3c\x6d\x65\x74\x61\x20\x6e\x61\x6d\x65\x3d\x22\x76\x69\x65'\
+b'\x77\x70\x6f\x72\x74\x22\x20\x63\x6f\x6e\x74\x65\x6e\x74\x3d\x22'\
+b'\x77\x69\x64\x74\x68\x3d\x64\x65\x76\x69\x63\x65\x2d\x77\x69\x64'\
+b'\x74\x68\x2c\x20\x69\x6e\x69\x74\x69\x61\x6c\x2d\x73\x63\x61\x6c'\
+b'\x65\x3d\x31\x2e\x30\x22\x20\x2f\x3e\x0a\x20\x20\x3c\x74\x69\x74'\
+b'\x6c\x65\x3e\x50\x69\x63\x6f\x20\x54\x72\x61\x69\x6e\x20\x44\x69'\
+b'\x73\x70\x6c\x61\x79\x20\x53\x65\x74\x75\x70\x3c\x2f\x74\x69\x74'\
+b'\x6c\x65\x3e\x0a\x3c\x2f\x68\x65\x61\x64\x3e\x0a\x3c\x73\x63\x72'\
+b'\x69\x70\x74\x3e\x0a\x20\x20\x61\x73\x79\x6e\x63\x20\x66\x75\x6e'\
+b'\x63\x74\x69\x6f\x6e\x20\x6f\x6e\x53\x75\x62\x6d\x69\x74\x28\x65'\
+b'\x76\x65\x6e\x74\x29\x20\x7b\x0a\x20\x20\x20\x20\x65\x76\x65\x6e'\
+b'\x74\x2e\x70\x72\x65\x76\x65\x6e\x74\x44\x65\x66\x61\x75\x6c\x74'\
+b'\x28\x29\x3b\x0a\x20\x20\x20\x20\x63\x6f\x6e\x73\x74\x20\x66\x6f'\
+b'\x72\x6d\x20\x3d\x20\x65\x76\x65\x6e\x74\x2e\x63\x75\x72\x72\x65'\
+b'\x6e\x74\x54\x61\x72\x67\x65\x74\x3b\x0a\x20\x20\x20\x20\x63\x6f'\
+b'\x6e\x73\x74\x20\x64\x61\x74\x61\x20\x3d\x20\x6e\x65\x77\x20\x46'\
+b'\x6f\x72\x6d\x44\x61\x74\x61\x28\x66\x6f\x72\x6d\x29\x3b\x0a\x0a'\
+b'\x20\x20\x20\x20\x63\x6f\x6e\x73\x74\x20\x72\x65\x71\x75\x65\x73'\
+b'\x74\x20\x3d\x20\x7b\x0a\x20\x20\x20\x20\x20\x20\x6d\x65\x74\x68'\
+b'\x6f\x64\x3a\x20\x22\x50\x4f\x53\x54\x22\x2c\x0a\x20\x20\x20\x20'\
+b'\x20\x20\x68\x65\x61\x64\x65\x72\x73\x3a\x20\x7b\x20\x22\x43\x6f'\
+b'\x6e\x74\x65\x6e\x74\x2d\x54\x79\x70\x65\x22\x3a\x20\x22\x61\x70'\
+b'\x70\x6c\x69\x63\x61\x74\x69\x6f\x6e\x2f\x6a\x73\x6f\x6e\x22\x20'\
+b'\x7d\x2c\x0a\x20\x20\x20\x20\x20\x20\x62\x6f\x64\x79\x3a\x20\x4a'\
+b'\x53\x4f\x4e\x2e\x73\x74\x72\x69\x6e\x67\x69\x66\x79\x28\x4f\x62'\
+b'\x6a\x65\x63\x74\x2e\x66\x72\x6f\x6d\x45\x6e\x74\x72\x69\x65\x73'\
+b'\x28\x64\x61\x74\x61\x29\x29\x2c\x0a\x20\x20\x20\x20\x20\x20\x72'\
+b'\x65\x64\x69\x72\x65\x63\x74\x3a\x20\x22\x66\x6f\x6c\x6c\x6f\x77'\
+b'\x22\x2c\x0a\x20\x20\x20\x20\x7d\x0a\x20\x20\x20\x20\x63\x6f\x6e'\
+b'\x73\x74\x20\x72\x65\x73\x70\x6f\x6e\x73\x65\x20\x3d\x20\x61\x77'\
+b'\x61\x69\x74\x20\x66\x65\x74\x63\x68\x28\x66\x6f\x72\x6d\x2e\x61'\
+b'\x63\x74\x69\x6f\x6e\x2c\x20\x72\x65\x71\x75\x65\x73\x74\x29\x3b'\
+b'\x0a\x20\x20\x20\x20\x63\x6f\x6e\x73\x6f\x6c\x65\x2e\x6c\x6f\x67'\
+b'\x28\x72\x65\x73\x70\x6f\x6e\x73\x65\x29\x0a\x20\x20\x20\x20\x69'\
+b'\x66\x20\x28\x72\x65\x73\x70\x6f\x6e\x73\x65\x2e\x6f\x6b\x29\x20'\
+b'\x7b\x0a\x20\x20\x20\x20\x20\x20\x64\x6f\x63\x75\x6d\x65\x6e\x74'\
+b'\x2e\x67\x65\x74\x45\x6c\x65\x6d\x65\x6e\x74\x42\x79\x49\x64\x28'\
+b'\x22\x72\x65\x73\x75\x6c\x74\x22\x29\x2e\x69\x6e\x6e\x65\x72\x48'\
+b'\x54\x4d\x4c\x20\x3d\x20\x22\x53\x61\x76\x65\x64\x20\x63\x6f\x6e'\
+b'\x66\x69\x67\x75\x72\x61\x74\x69\x6f\x6e\x21\x22\x3b\x0a\x20\x20'\
+b'\x20\x20\x7d\x20\x65\x6c\x73\x65\x20\x7b\x0a\x20\x20\x20\x20\x20'\
+b'\x20\x64\x6f\x63\x75\x6d\x65\x6e\x74\x2e\x67\x65\x74\x45\x6c\x65'\
+b'\x6d\x65\x6e\x74\x42\x79\x49\x64\x28\x22\x72\x65\x73\x75\x6c\x74'\
+b'\x22\x29\x2e\x69\x6e\x6e\x65\x72\x48\x54\x4d\x4c\x20\x3d\x20\x61'\
+b'\x77\x61\x69\x74\x20\x72\x65\x73\x70\x6f\x6e\x73\x65\x2e\x74\x65'\
+b'\x78\x74\x28\x29\x3b\x0a\x20\x20\x20\x20\x7d\x0a\x20\x20\x20\x20'\
+b'\x72\x65\x74\x75\x72\x6e\x20\x72\x65\x73\x70\x6f\x6e\x73\x65\x3b'\
+b'\x0a\x20\x20\x7d\x0a\x3c\x2f\x73\x63\x72\x69\x70\x74\x3e\x0a\x3c'\
+b'\x68\x31\x3e\x50\x69\x63\x6f\x20\x54\x72\x61\x69\x6e\x20\x44\x69'\
+b'\x73\x70\x6c\x61\x79\x20\x53\x65\x74\x75\x70\x3c\x2f\x68\x31\x3e'\
+b'\x0a\x3c\x66\x6f\x72\x6d\x20\x69\x64\x3d\x22\x66\x6f\x72\x6d\x22'\
+b'\x20\x61\x63\x74\x69\x6f\x6e\x3d\x22\x2f\x73\x75\x62\x6d\x69\x74'\
+b'\x22\x20\x6f\x6e\x73\x75\x62\x6d\x69\x74\x3d\x22\x6f\x6e\x53\x75'\
+b'\x62\x6d\x69\x74\x28\x65\x76\x65\x6e\x74\x29\x22\x20\x6d\x65\x74'\
+b'\x68\x6f\x64\x3d\x22\x70\x6f\x73\x74\x22\x3e\x0a\x20\x20\x3c\x74'\
+b'\x61\x62\x6c\x65\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x3c\x74\x68\x20\x63\x6f\x6c\x73\x70\x61\x6e'\
+b'\x3d\x32\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x68\x32\x3e'\
+b'\x57\x69\x46\x69\x20\x53\x65\x74\x74\x69\x6e\x67\x73\x3c\x2f\x68'\
+b'\x32\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x74\x68\x3e\x0a\x20'\
+b'\x20\x20\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72'\
+b'\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62'\
+b'\x65\x6c\x20\x66\x6f\x72\x3d\x22\x77\x69\x66\x69\x5b\x73\x73\x69'\
+b'\x64\x5d\x22\x3e\x4e\x65\x74\x77\x6f\x72\x6b\x20\x6e\x61\x6d\x65'\
+b'\x3a\x3c\x2f\x6c\x61\x62\x65\x6c\x3e\x3c\x2f\x74\x64\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x69\x6e\x70\x75\x74\x20'\
+b'\x74\x79\x70\x65\x3d\x22\x74\x65\x78\x74\x22\x20\x69\x64\x3d\x22'\
+b'\x77\x69\x66\x69\x5b\x73\x73\x69\x64\x5d\x22\x20\x6e\x61\x6d\x65'\
+b'\x3d\x22\x77\x69\x66\x69\x5b\x73\x73\x69\x64\x5d\x22\x20\x72\x65'\
+b'\x71\x75\x69\x72\x65\x64\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20'\
+b'\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a'\
+b'\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62\x65\x6c'\
+b'\x20\x66\x6f\x72\x3d\x22\x77\x69\x66\x69\x5b\x70\x61\x73\x73\x77'\
+b'\x6f\x72\x64\x5d\x22\x3e\x50\x61\x73\x73\x77\x6f\x72\x64\x3a\x3c'\
+b'\x2f\x6c\x61\x62\x65\x6c\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20'\
+b'\x20\x20\x20\x3c\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20'\
+b'\x3c\x69\x6e\x70\x75\x74\x20\x74\x79\x70\x65\x3d\x22\x70\x61\x73'\
+b'\x73\x77\x6f\x72\x64\x22\x20\x69\x64\x3d\x22\x77\x69\x66\x69\x5b'\
+b'\x70\x61\x73\x73\x77\x6f\x72\x64\x5d\x22\x20\x6e\x61\x6d\x65\x3d'\
+b'\x22\x77\x69\x66\x69\x5b\x70\x61\x73\x73\x77\x6f\x72\x64\x5d\x22'\
+b'\x20\x70\x6c\x61\x63\x65\x68\x6f\x6c\x64\x65\x72\x3d\x22\x45\x6e'\
+b'\x74\x65\x72\x20\x70\x61\x73\x73\x77\x6f\x72\x64\x22\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x3c'\
+b'\x2f\x74\x72\x3e\x0a\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x3c\x74\x68\x20\x63\x6f\x6c\x73\x70\x61\x6e'\
+b'\x3d\x32\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x68\x32\x3e'\
+b'\x52\x65\x61\x6c\x74\x69\x6d\x65\x20\x54\x72\x61\x69\x6e\x73\x20'\
+b'\x41\x50\x49\x20\x53\x65\x74\x74\x69\x6e\x67\x73\x3c\x2f\x68\x32'\
+b'\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x74\x68\x3e\x0a\x20\x20'\
+b'\x20\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e'\
+b'\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x20\x63\x6f\x6c\x73\x70'\
+b'\x61\x6e\x3d\x32\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x56\x69'\
+b'\x73\x69\x74\x20\x3c\x61\x20\x68\x72\x65\x66\x3d\x22\x68\x74\x74'\
+b'\x70\x73\x3a\x2f\x2f\x61\x70\x69\x2e\x72\x74\x74\x2e\x69\x6f\x2f'\
+b'\x22\x3e\x68\x74\x74\x70\x73\x3a\x2f\x2f\x61\x70\x69\x2e\x72\x74'\
+b'\x74\x2e\x69\x6f\x2f\x3c\x2f\x61\x3e\x20\x74\x6f\x20\x72\x65\x67'\
+b'\x69\x73\x74\x65\x72\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x61\x6e'\
+b'\x64\x20\x6f\x62\x74\x61\x69\x6e\x20\x61\x6e\x20\x41\x50\x49\x20'\
+b'\x6c\x6f\x67\x69\x6e\x2e\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x74'\
+b'\x64\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20'\
+b'\x20\x3c\x74\x72\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e'\
+b'\x3c\x6c\x61\x62\x65\x6c\x20\x66\x6f\x72\x3d\x22\x72\x74\x74\x5b'\
+b'\x75\x73\x65\x72\x6e\x61\x6d\x65\x5d\x22\x3e\x55\x73\x65\x72\x6e'\
+b'\x61\x6d\x65\x3a\x3c\x2f\x6c\x61\x62\x65\x6c\x3e\x3c\x2f\x74\x64'\
+b'\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x69\x6e\x70'\
+b'\x75\x74\x20\x74\x79\x70\x65\x3d\x22\x74\x65\x78\x74\x22\x20\x69'\
+b'\x64\x3d\x22\x72\x74\x74\x5b\x75\x73\x65\x72\x6e\x61\x6d\x65\x5d'\
+b'\x22\x20\x6e\x61\x6d\x65\x3d\x22\x72\x74\x74\x5b\x75\x73\x65\x72'\
+b'\x6e\x61\x6d\x65\x5d\x22\x20\x72\x65\x71\x75\x69\x72\x65\x64\x3e'\
+b'\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x74\x72\x3e\x0a'\
+b'\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c'\
+b'\x74\x64\x3e\x3c\x6c\x61\x62\x65\x6c\x20\x66\x6f\x72\x3d\x22\x72'\
+b'\x74\x74\x5b\x70\x61\x73\x73\x77\x6f\x72\x64\x5d\x22\x3e\x50\x61'\
+b'\x73\x73\x77\x6f\x72\x64\x3a\x3c\x2f\x6c\x61\x62\x65\x6c\x3e\x3c'\
+b'\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x0a'\
+b'\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x69\x6e\x70\x75\x74\x20\x74'\
+b'\x79\x70\x65\x3d\x22\x70\x61\x73\x73\x77\x6f\x72\x64\x22\x20\x69'\
+b'\x64\x3d\x22\x72\x74\x74\x5b\x70\x61\x73\x73\x77\x6f\x72\x64\x5d'\
+b'\x22\x20\x6e\x61\x6d\x65\x3d\x22\x72\x74\x74\x5b\x70\x61\x73\x73'\
+b'\x77\x6f\x72\x64\x5d\x22\x20\x70\x6c\x61\x63\x65\x68\x6f\x6c\x64'\
+b'\x65\x72\x3d\x22\x45\x6e\x74\x65\x72\x20\x70\x61\x73\x73\x77\x6f'\
+b'\x72\x64\x22\x20\x72\x65\x71\x75\x69\x72\x65\x64\x3e\x0a\x20\x20'\
+b'\x20\x20\x20\x20\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x3c\x2f'\
+b'\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20\x20\x20'\
+b'\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62\x65\x6c\x20\x66\x6f'\
+b'\x72\x3d\x22\x72\x74\x74\x5b\x75\x70\x64\x61\x74\x65\x5f\x69\x6e'\
+b'\x74\x65\x72\x76\x61\x6c\x5d\x3a\x69\x6e\x74\x22\x3e\x55\x70\x64'\
+b'\x61\x74\x65\x20\x69\x6e\x74\x65\x72\x76\x61\x6c\x20\x28\x69\x6e'\
+b'\x20\x73\x65\x63\x6f\x6e\x64\x73\x29\x3a\x3c\x2f\x6c\x61\x62\x65'\
+b'\x6c\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74'\
+b'\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x69\x6e\x70\x75'\
+b'\x74\x20\x74\x79\x70\x65\x3d\x22\x6e\x75\x6d\x62\x65\x72\x22\x20'\
+b'\x69\x64\x3d\x22\x72\x74\x74\x5b\x75\x70\x64\x61\x74\x65\x5f\x69'\
+b'\x6e\x74\x65\x72\x76\x61\x6c\x5d\x3a\x69\x6e\x74\x22\x20\x6e\x61'\
+b'\x6d\x65\x3d\x22\x72\x74\x74\x5b\x75\x70\x64\x61\x74\x65\x5f\x69'\
+b'\x6e\x74\x65\x72\x76\x61\x6c\x5d\x3a\x69\x6e\x74\x22\x20\x76\x61'\
+b'\x6c\x75\x65\x3d\x32\x30\x20\x72\x65\x71\x75\x69\x72\x65\x64\x3e'\
+b'\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20'\
+b'\x20\x3c\x2f\x74\x72\x3e\x0a\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e'\
+b'\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x68\x20\x63\x6f\x6c\x73\x70'\
+b'\x61\x6e\x3d\x32\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x68'\
+b'\x32\x3e\x54\x72\x61\x69\x6e\x20\x4f\x70\x74\x69\x6f\x6e\x73\x3c'\
+b'\x2f\x68\x32\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x74\x68\x3e'\
+b'\x0a\x20\x20\x20\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c'\
+b'\x74\x72\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x20\x63\x6f'\
+b'\x6c\x73\x70\x61\x6e\x3d\x32\x3e\x0a\x20\x20\x20\x20\x20\x20\x20'\
+b'\x20\x56\x69\x73\x69\x74\x20\x3c\x61\x20\x68\x72\x65\x66\x3d\x68'\
+b'\x74\x74\x70\x73\x3a\x2f\x2f\x65\x6e\x2e\x77\x69\x6b\x69\x70\x65'\
+b'\x64\x69\x61\x2e\x6f\x72\x67\x2f\x77\x69\x6b\x69\x2f\x55\x4b\x5f'\
+b'\x72\x61\x69\x6c\x77\x61\x79\x5f\x73\x74\x61\x74\x69\x6f\x6e\x73'\
+b'\x3e\x77\x69\x6b\x69\x70\x65\x64\x69\x61\x3c\x2f\x61\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x20\x20\x74\x6f\x20\x66\x69\x6e\x64\x20\x74'\
+b'\x68\x65\x20\x33\x2d\x6c\x65\x74\x74\x65\x72\x20\x73\x74\x61\x74'\
+b'\x69\x6f\x6e\x20\x63\x6f\x64\x65\x2e\x0a\x20\x20\x20\x20\x20\x20'\
+b'\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62\x65\x6c\x20'\
+b'\x66\x6f\x72\x3d\x22\x73\x74\x61\x74\x69\x6f\x6e\x22\x3e\x53\x74'\
+b'\x61\x74\x69\x6f\x6e\x20\x28\x33\x2d\x6c\x65\x74\x74\x65\x72\x20'\
+b'\x63\x6f\x64\x65\x29\x3a\x3c\x2f\x6c\x61\x62\x65\x6c\x3e\x3c\x2f'\
+b'\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x69'\
+b'\x6e\x70\x75\x74\x20\x74\x79\x70\x65\x3d\x22\x74\x65\x78\x74\x22'\
+b'\x20\x69\x64\x3d\x22\x73\x74\x61\x74\x69\x6f\x6e\x22\x20\x6e\x61'\
+b'\x6d\x65\x3d\x22\x73\x74\x61\x74\x69\x6f\x6e\x22\x20\x72\x65\x71'\
+b'\x75\x69\x72\x65\x64\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20'\
+b'\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62\x65\x6c\x20'\
+b'\x66\x6f\x72\x3d\x22\x64\x65\x73\x74\x69\x6e\x61\x74\x69\x6f\x6e'\
+b'\x22\x3e\x44\x65\x73\x74\x69\x6e\x61\x74\x69\x6f\x6e\x20\x28\x33'\
+b'\x2d\x6c\x65\x74\x74\x65\x72\x20\x63\x6f\x64\x65\x29\x3a\x3c\x2f'\
+b'\x6c\x61\x62\x65\x6c\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20'\
+b'\x20\x20\x3c\x74\x64\x3e\x3c\x69\x6e\x70\x75\x74\x20\x74\x79\x70'\
+b'\x65\x3d\x22\x74\x65\x78\x74\x22\x20\x69\x64\x3d\x22\x64\x65\x73'\
+b'\x74\x69\x6e\x61\x74\x69\x6f\x6e\x22\x20\x6e\x61\x6d\x65\x3d\x22'\
+b'\x64\x65\x73\x74\x69\x6e\x61\x74\x69\x6f\x6e\x22\x20\x72\x65\x71'\
+b'\x75\x69\x72\x65\x64\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20'\
+b'\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62\x65\x6c\x20'\
+b'\x66\x6f\x72\x3d\x22\x6d\x69\x6e\x5f\x64\x65\x70\x61\x72\x74\x75'\
+b'\x72\x65\x5f\x74\x69\x6d\x65\x3a\x69\x6e\x74\x22\x3e\x4d\x69\x6e'\
+b'\x69\x6d\x75\x6d\x20\x64\x65\x70\x61\x72\x74\x75\x72\x65\x20\x74'\
+b'\x69\x6d\x65\x20\x28\x6d\x69\x6e\x73\x29\x3a\x3c\x2f\x6c\x61\x62'\
+b'\x65\x6c\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c'\
+b'\x74\x64\x3e\x3c\x69\x6e\x70\x75\x74\x20\x74\x79\x70\x65\x3d\x22'\
+b'\x74\x65\x78\x74\x22\x20\x69\x64\x3d\x22\x6d\x69\x6e\x5f\x64\x65'\
+b'\x70\x61\x72\x74\x75\x72\x65\x5f\x74\x69\x6d\x65\x3a\x69\x6e\x74'\
+b'\x22\x20\x6e\x61\x6d\x65\x3d\x22\x6d\x69\x6e\x5f\x64\x65\x70\x61'\
+b'\x72\x74\x75\x72\x65\x5f\x74\x69\x6d\x65\x3a\x69\x6e\x74\x22\x20'\
+b'\x76\x61\x6c\x75\x65\x3d\x30\x20\x72\x65\x71\x75\x69\x72\x65\x64'\
+b'\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x74\x72\x3e'\
+b'\x0a\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20\x20\x20\x20\x20'\
+b'\x20\x3c\x74\x68\x20\x63\x6f\x6c\x73\x70\x61\x6e\x3d\x32\x3e\x0a'\
+b'\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x68\x32\x3e\x44\x69\x73\x70'\
+b'\x6c\x61\x79\x20\x4f\x70\x74\x69\x6f\x6e\x73\x3c\x2f\x68\x32\x3e'\
+b'\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x74\x68\x3e\x0a\x20\x20\x20'\
+b'\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a'\
+b'\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62\x65\x6c'\
+b'\x20\x66\x6f\x72\x3d\x22\x64\x69\x73\x70\x6c\x61\x79\x5b\x66\x6c'\
+b'\x69\x70\x5d\x3a\x62\x6f\x6f\x6c\x22\x3e\x46\x6c\x69\x70\x20\x64'\
+b'\x69\x73\x70\x6c\x61\x79\x3a\x3c\x2f\x6c\x61\x62\x65\x6c\x3e\x3c'\
+b'\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c'\
+b'\x69\x6e\x70\x75\x74\x20\x74\x79\x70\x65\x3d\x22\x63\x68\x65\x63'\
+b'\x6b\x62\x6f\x78\x22\x20\x69\x64\x3d\x22\x64\x69\x73\x70\x6c\x61'\
+b'\x79\x5b\x66\x6c\x69\x70\x5d\x3a\x62\x6f\x6f\x6c\x22\x20\x6e\x61'\
+b'\x6d\x65\x3d\x22\x64\x69\x73\x70\x6c\x61\x79\x5b\x66\x6c\x69\x70'\
+b'\x5d\x3a\x62\x6f\x6f\x6c\x22\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20'\
+b'\x20\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e'\
+b'\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62\x65'\
+b'\x6c\x20\x66\x6f\x72\x3d\x22\x64\x69\x73\x70\x6c\x61\x79\x5b\x61'\
+b'\x63\x74\x69\x76\x65\x5f\x74\x69\x6d\x65\x5d\x22\x3e\x41\x63\x74'\
+b'\x69\x76\x65\x20\x74\x69\x6d\x65\x20\x28\x6f\x70\x74\x69\x6f\x6e'\
+b'\x61\x6c\x29\x3a\x3c\x2f\x6c\x61\x62\x65\x6c\x3e\x3c\x2f\x74\x64'\
+b'\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x69\x6e\x70'\
+b'\x75\x74\x20\x74\x79\x70\x65\x3d\x22\x74\x65\x78\x74\x22\x20\x69'\
+b'\x64\x3d\x22\x64\x69\x73\x70\x6c\x61\x79\x5b\x61\x63\x74\x69\x76'\
+b'\x65\x5f\x74\x69\x6d\x65\x5d\x22\x20\x6e\x61\x6d\x65\x3d\x22\x64'\
+b'\x69\x73\x70\x6c\x61\x79\x5b\x61\x63\x74\x69\x76\x65\x5f\x74\x69'\
+b'\x6d\x65\x5d\x22\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x3c'\
+b'\x2f\x74\x72\x3e\x0a\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20'\
+b'\x20\x20\x20\x20\x20\x3c\x74\x68\x20\x63\x6f\x6c\x73\x70\x61\x6e'\
+b'\x3d\x32\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x68\x32\x3e'\
+b'\x41\x64\x76\x61\x6e\x63\x65\x64\x3c\x2f\x68\x32\x3e\x0a\x20\x20'\
+b'\x20\x20\x20\x20\x3c\x2f\x74\x68\x3e\x0a\x20\x20\x20\x20\x3c\x2f'\
+b'\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74\x72\x3e\x0a\x20\x20\x20'\
+b'\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61\x62\x65\x6c\x20\x66\x6f'\
+b'\x72\x3d\x22\x64\x69\x73\x70\x6c\x61\x79\x5b\x74\x79\x70\x65\x5d'\
+b'\x22\x3e\x44\x69\x73\x70\x6c\x61\x79\x20\x74\x79\x70\x65\x3a\x3c'\
+b'\x2f\x6c\x61\x62\x65\x6c\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20'\
+b'\x20\x20\x20\x3c\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20'\
+b'\x3c\x73\x65\x6c\x65\x63\x74\x20\x69\x64\x3d\x22\x64\x69\x73\x70'\
+b'\x6c\x61\x79\x5b\x74\x79\x70\x65\x5d\x22\x20\x6e\x61\x6d\x65\x3d'\
+b'\x22\x64\x69\x73\x70\x6c\x61\x79\x5b\x74\x79\x70\x65\x5d\x22\x3e'\
+b'\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x6f\x70\x74\x69'\
+b'\x6f\x6e\x20\x76\x61\x6c\x75\x65\x3d\x22\x73\x73\x64\x31\x33\x32'\
+b'\x32\x22\x3e\x53\x53\x44\x20\x31\x33\x32\x32\x3c\x2f\x6f\x70\x74'\
+b'\x69\x6f\x6e\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x3c'\
+b'\x6f\x70\x74\x69\x6f\x6e\x20\x76\x61\x6c\x75\x65\x3d\x22\x65\x70'\
+b'\x64\x32\x39\x62\x22\x3e\x65\x50\x61\x70\x65\x72\x20\x32\x2e\x39'\
+b'\x20\x4d\x6f\x64\x65\x6c\x20\x42\x3c\x2f\x6f\x70\x74\x69\x6f\x6e'\
+b'\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x2f\x73\x65\x6c\x65'\
+b'\x63\x74\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x74\x64\x3e\x0a'\
+b'\x20\x20\x20\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x20\x20\x3c\x74'\
+b'\x72\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x6c\x61'\
+b'\x62\x65\x6c\x20\x66\x6f\x72\x3d\x22\x64\x69\x73\x70\x6c\x61\x79'\
+b'\x5b\x72\x65\x66\x72\x65\x73\x68\x5d\x3a\x69\x6e\x74\x22\x3e\x52'\
+b'\x65\x66\x72\x65\x73\x68\x20\x72\x61\x74\x65\x20\x28\x70\x65\x72'\
+b'\x20\x73\x65\x63\x6f\x6e\x64\x29\x3a\x3c\x2f\x6c\x61\x62\x65\x6c'\
+b'\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64'\
+b'\x3e\x3c\x69\x6e\x70\x75\x74\x20\x74\x79\x70\x65\x3d\x22\x6e\x75'\
+b'\x6d\x62\x65\x72\x22\x20\x69\x64\x3d\x22\x64\x69\x73\x70\x6c\x61'\
+b'\x79\x5b\x72\x65\x66\x72\x65\x73\x68\x5d\x3a\x69\x6e\x74\x22\x20'\
+b'\x6e\x61\x6d\x65\x3d\x22\x64\x69\x73\x70\x6c\x61\x79\x5b\x72\x65'\
+b'\x66\x72\x65\x73\x68\x5d\x3a\x69\x6e\x74\x22\x20\x76\x61\x6c\x75'\
+b'\x65\x3d\x33\x30\x20\x72\x65\x71\x75\x69\x72\x65\x64\x3e\x3c\x2f'\
+b'\x74\x64\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20'\
+b'\x20\x20\x3c\x74\x72\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x74\x64'\
+b'\x3e\x3c\x6c\x61\x62\x65\x6c\x20\x66\x6f\x72\x3d\x22\x64\x65\x62'\
+b'\x75\x67\x5b\x6c\x6f\x67\x5d\x3a\x62\x6f\x6f\x6c\x22\x3e\x53\x61'\
+b'\x76\x65\x20\x64\x65\x62\x75\x67\x20\x6c\x6f\x67\x73\x3a\x3c\x2f'\
+b'\x6c\x61\x62\x65\x6c\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20'\
+b'\x20\x20\x3c\x74\x64\x3e\x3c\x69\x6e\x70\x75\x74\x20\x74\x79\x70'\
+b'\x65\x3d\x22\x63\x68\x65\x63\x6b\x62\x6f\x78\x22\x20\x69\x64\x3d'\
+b'\x22\x64\x65\x62\x75\x67\x5b\x6c\x6f\x67\x5d\x3a\x62\x6f\x6f\x6c'\
+b'\x22\x20\x6e\x61\x6d\x65\x3d\x22\x64\x65\x62\x75\x67\x5b\x6c\x6f'\
+b'\x67\x5d\x3a\x62\x6f\x6f\x6c\x22\x3e\x3c\x2f\x74\x64\x3e\x0a\x20'\
+b'\x20\x20\x20\x3c\x2f\x74\x72\x3e\x0a\x20\x20\x3c\x2f\x74\x61\x62'\
+b'\x6c\x65\x3e\x0a\x20\x20\x3c\x62\x72\x3e\x0a\x20\x20\x3c\x74\x72'\
+b'\x3e\x0a\x20\x20\x20\x20\x3c\x74\x64\x3e\x3c\x69\x6e\x70\x75\x74'\
+b'\x20\x74\x79\x70\x65\x3d\x22\x73\x75\x62\x6d\x69\x74\x22\x20\x76'\
+b'\x61\x6c\x75\x65\x3d\x22\x53\x61\x76\x65\x20\x53\x65\x74\x74\x69'\
+b'\x6e\x67\x73\x22\x3e\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x20\x20\x3c'\
+b'\x74\x64\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x70\x20\x69\x64\x3d'\
+b'\x22\x72\x65\x73\x75\x6c\x74\x22\x3e\x3c\x2f\x70\x3e\x0a\x20\x20'\
+b'\x20\x20\x3c\x2f\x74\x64\x3e\x0a\x20\x20\x3c\x2f\x74\x72\x3e\x0a'\
+b'\x3c\x2f\x66\x6f\x72\x6d\x3e\x0a\x0a\x3c\x2f\x68\x74\x6d\x6c\x3e'\
+
+_mvdata = memoryview(_data)
+
+def data():
+ return _mvdata
+
diff --git a/src/setup/server.py b/src/setup/server.py
new file mode 100644
index 0000000..0ea7bd1
--- /dev/null
+++ b/src/setup/server.py
@@ -0,0 +1,207 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+
+# Copyright 2023 Tom Ward
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Basic HTTP server for setting up train display."""
+
+import asyncio
+import json
+import re
+
+from setup import content
+
+
+# Basic regex to extract key, an optional sub-key, and optional type. Examples:
+# "foo" => key=foo, sub-key=None, type=None
+# "foo[bar] => key=foo, sub-key=bar, type=None"
+# "foo[bar]:int => key=foo, sub-key=bar, type=int"
+_JSON_KEY_REGEX = re.compile(r'(\w+)\[?(\w*)\]?:?(\w*)')
+
+
+def _parse_json_request(data: dict[str, str]):
+ """Parse dictionary keys to create sub-keys and value types."""
+ result = {}
+ for k, v in data.items():
+ match = _JSON_KEY_REGEX.match(k)
+ if match is None:
+ raise ValueError(f'Failed to parse key! key={k}')
+ key, sub_key, value_type = match.group(1), match.group(2), match.group(3)
+ if value_type:
+ if value_type == 'int':
+ v = int(v)
+ elif value_type == 'bool':
+ if v.lower() in {'on', 'true'}:
+ v = True
+ elif v.lower() in {'off', 'false'}:
+ v = False
+ else:
+ ValueError(f'Unrecognized boolean value for key {k}, {v=}')
+ else:
+ raise ValueError(f'Unrecognized value type for key "{k}"')
+
+ if sub_key:
+ result.setdefault(key, {})[sub_key] = v
+ else:
+ result[key] = v
+ return result
+
+
+async def _parse_headers(reader: asyncio.StreamReader):
+ """Helper to parse HTML headers."""
+ headers = {}
+ while True:
+ header = await reader.readline()
+ if header == b'\r\n':
+ break
+ name, value = header.decode().strip().split(': ', 1)
+ headers[name.lower()] = value
+ return headers
+
+
+async def _read_request(reader: asyncio.StreamReader):
+ """Helper to request HTML request."""
+ headers = await _parse_headers(reader)
+
+ content_length = int(headers.get('content-length', 0))
+ content_type = headers.get('content-type')
+
+ content = None
+ if content_length > 0:
+ content = await reader.readexactly(content_length)
+ if content_type == 'application/json':
+ content = _parse_json_request(json.loads(content.decode()))
+ else:
+ raise ValueError(f'Unrecognized request content! {content_type=}')
+
+ return content
+
+
+_STATUS_TO_MESSAGE = {
+ 200: 'OK',
+ 201: 'Created',
+ 202: 'Accepted',
+ 203: 'Non-Authoritative Information',
+ 204: 'No Content',
+ 205: 'Reset Content',
+ 206: 'Partial Content',
+ 300: 'Multiple Choices',
+ 301: 'Moved Permanently',
+ 302: 'Found',
+ 303: 'See Other',
+ 304: 'Not Modified',
+ 305: 'Use Proxy',
+ 306: 'Switch Proxy',
+ 307: 'Temporary Redirect',
+ 308: 'Permanent Redirect',
+ 400: 'Bad Request',
+ 401: 'Unauthorized',
+ 403: 'Forbidden',
+ 404: 'Not Found',
+ 405: 'Method Not Allowed',
+ 406: 'Not Acceptable',
+ 408: 'Request Timeout',
+ 409: 'Conflict',
+ 410: 'Gone',
+ 414: 'URI Too Long',
+ 415: 'Unsupported Media Type',
+ 416: 'Range Not Satisfiable',
+ 500: 'Internal Server Error',
+ 501: 'Not Implemented',
+}
+
+
+async def _write_response(
+ writer: asyncio.StreamWriter,
+ status: int,
+ *,
+ headers={},
+ content: bytes | None = None,
+ content_type: str | None = None,
+):
+ status_message = _STATUS_TO_MESSAGE[status]
+ writer.write(f'HTTP/1.1 {status} {status_message}\r\n'.encode('utf8'))
+ for k, v in headers.items():
+ writer.write(f'{k}: {v}\r\n'.encode('utf8'))
+
+ if content is not None:
+ if content_type is None:
+ raise ValueError('Must provide content_type if the response has content')
+
+ writer.write(f'Content-Type: {type}\r\n'.encode('utf8'))
+ writer.write(f'Content-Length: {len(content)}\r\n'.encode('utf8'))
+ writer.write('\r\n'.encode('utf8'))
+ writer.write(content)
+ else:
+ writer.write('\r\n'.encode('utf8'))
+ await writer.drain()
+
+ writer.close()
+ await writer.wait_closed()
+
+
+async def _server_request(
+ close_event: asyncio.Event,
+ callback,
+ reader: asyncio.StreamReader,
+ writer: asyncio.StreamWriter,
+):
+ request = await reader.readline()
+ try:
+ method, uri, _ = request.decode().split()
+ except:
+ await _write_response(
+ writer, 500, content='Error parsing request!'.encode('utf8')
+ )
+ raise
+
+ request_content = await _read_request(reader)
+ if uri == '/':
+ await _write_response(
+ writer, 200, content=content.data(), content_type='text/html'
+ )
+ elif uri == '/submit' and method == 'POST':
+ try:
+ callback(request_content)
+ await _write_response(writer, 200)
+ close_event.set()
+ except ValueError as e:
+ await _write_response(
+ writer, 404, content=str(e).encode('utf8'), content_type='text/plain'
+ )
+ except:
+ await _write_response(writer, 500)
+ raise
+
+
+async def start(callback, event: asyncio.Event) -> asyncio.Server:
+ # TODO: Use functools.partial when supported in MicroPython.
+ func = lambda reader, writer: _server_request(event, callback, reader, writer)
+ return await asyncio.start_server(func, '0.0.0.0', 80)
diff --git a/src/ssd1322.py b/src/ssd1322.py
new file mode 100644
index 0000000..016603c
--- /dev/null
+++ b/src/ssd1322.py
@@ -0,0 +1,143 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Implementation of SSD 1322 display driver.
+
+Datasheet: https://www.hpinfotech.ro/SSD1322.pdf
+"""
+
+import time
+
+import framebuf
+import machine
+
+import display
+
+
+class SSD1322(display.Display):
+ """SSD1322 SPI-4 display driver."""
+
+ def __init__(
+ self,
+ spi: machine.SPI,
+ cs: machine.Pin,
+ dc: machine.Pin,
+ rst: machine.Pin,
+ width: int = 256,
+ height: int = 64,
+ flip_display: bool = False,
+ ):
+ self.spi = spi
+ self.cs = cs
+ self.dc = dc
+ self.rst = rst
+
+ self.cs.init(self.cs.OUT, value=1)
+ self.dc.init(self.dc.OUT, value=0)
+ self.rst.init(self.rst.OUT, value=1)
+
+ self._width = width
+ self._height = height
+ self._buffer = bytearray(self._width // 2 * self._height)
+
+ super().__init__(self._buffer, width, height, framebuf.GS4_HMSB)
+ self.fill(0)
+
+ self._init_display(flip_display)
+
+ def _init_display(self, flip_display: bool):
+ self._reset()
+
+ # fmt: off
+ self.write_cmd(0xFD, 0x12) # Unlock IC
+ self.write_cmd(0xA4) # Display off (all pixels off)
+ self.write_cmd(0xB3, 0x91) # Display divide clockratio/freq
+ self.write_cmd(0xCA, 0x3F) # Set MUX ratio
+ self.write_cmd(0xA2, 0x00) # Display offset
+ self.write_cmd(0xA1, 0x00) # Display start Line
+ arg = 0x06 if flip_display else 0x14
+ self.write_cmd(0xA0, arg, 0x11) # Set remap & dual COM Line
+ self.write_cmd(0xB5, 0x00) # Set GPIO (disabled)
+ self.write_cmd(0xAB, 0x01) # Function select (internal Vdd)
+ self.write_cmd(0xB4, 0xA0, 0xFD) # Display enhancement A (External VSL)
+ self.write_cmd(0xC1, 0x7F) # Set contrast current (default)
+ self.write_cmd(0xC7, 0x0F) # Master contrast (reset)
+ self.write_cmd(0xB9) # Set default greyscale table
+ self.write_cmd(0xB1, 0xF0) # Phase length
+ self.write_cmd(0xD1, 0x82, 0x20) # Display enhancement B (reset)
+ self.write_cmd(0xBB, 0x0D) # Pre-charge voltage
+ self.write_cmd(0xB6, 0x08) # 2nd precharge period
+ self.write_cmd(0xBE, 0x00) # Set VcomH
+ self.write_cmd(0xA6) # Normal display (reset)
+ self.write_cmd(0xA9) # Exit partial display
+ self.write_cmd(0xAF) # Display on
+ # fmt: on
+
+ self.fill(0)
+ self.flush()
+
+ def _reset(self):
+ self.rst(0)
+ time.sleep_ms(50)
+ self.rst(1)
+ time.sleep_ms(100)
+
+ @property
+ def width(self) -> int:
+ return self._width
+
+ @property
+ def height(self) -> int:
+ return self._height
+
+ def close(self):
+ self.fill(0)
+ self.sleep()
+ self.write_cmd(0xA4) # Display off
+
+ def sleep(self):
+ self.write_cmd(0xAE)
+ self.write_cmd(0xAB, 0x00)
+
+ def awake(self):
+ self.write_cmd(0xAB, 0x01)
+ self.write_cmd(0xAF)
+
+ def write_cmd(self, cmd, *args):
+ self.dc(0)
+ self.cs(0)
+ self.spi.write(bytearray([cmd]))
+ self.cs(1)
+
+ if len(args) > 0:
+ self.write_data(bytearray(args))
+
+ def write_data(self, data):
+ self.dc(1)
+ self.cs(0)
+ self.spi.write(data)
+ self.cs(1)
+
+ def flush(self):
+ offset = (480 - self._width) // 2
+ col_start = offset // 4
+ col_end = col_start + self.width // 4 - 1
+ self.write_cmd(0x15, col_start, col_end)
+ self.write_cmd(0x75, 0, self._height - 1)
+ self.write_cmd(0x5C)
+ self.write_data(self._buffer)
diff --git a/src/time_range.py b/src/time_range.py
new file mode 100644
index 0000000..5c89178
--- /dev/null
+++ b/src/time_range.py
@@ -0,0 +1,77 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Module for parsing active times."""
+
+import re
+import time
+
+import micropython
+
+
+_ACTIVE_TIMES_REGEX = re.compile(r'(daily|weekdays|weekend):(\d+)-(\d+)$')
+
+
+# TODO: Make this an enum when micropython supports such a thing
+class Dates:
+ DAILY = micropython.const(0)
+ WEEKDAYS = micropython.const(1)
+ WEEKEND = micropython.const(2)
+
+
+class TimeRange:
+
+ def __init__(self, dates: Dates, start_hhmm: int, end_hhmm: int):
+ self._dates = dates
+ self._start_hh, self._start_mm = divmod(start_hhmm, 100)
+ self._end_hh, self._end_mm = divmod(end_hhmm, 100)
+
+ def in_range(self, t: tuple[int, ...]) -> bool:
+ day = t[6]
+ if self._dates == Dates.WEEKDAYS and day > 5:
+ return False
+ elif self._dates == Dates.WEEKEND and day <= 5:
+ return False
+
+ start_time = time.mktime(
+ (t[0], t[1], t[2], self._start_hh, self._start_mm, 0, 0, 0)
+ )
+ end_time = time.mktime(
+ (t[0], t[1], t[2], self._end_hh, self._end_mm, 0, 0, 0)
+ )
+
+ now_time = time.mktime(t)
+ return now_time >= start_time and now_time <= end_time
+
+
+def parse(config: str) -> TimeRange:
+ m = _ACTIVE_TIMES_REGEX.match(config)
+ if m is None:
+ raise ValueError('Failed to parse active time! value={}'.format(config))
+
+ date_cfg = m.group(1).lower()
+ if date_cfg == 'daily':
+ dates = Dates.DAILY
+ elif date_cfg == 'weekdays':
+ dates = Dates.WEEKDAYS
+ elif date_cfg == 'weekend':
+ dates = Dates.WEEKEND
+ else:
+ raise ValueError('Unrecognized dates configuration! "{}"'.format(date_cfg))
+
+ return TimeRange(dates, int(m.group(2)), int(m.group(3)))
diff --git a/src/trains.py b/src/trains.py
new file mode 100644
index 0000000..648b390
--- /dev/null
+++ b/src/trains.py
@@ -0,0 +1,371 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Module for communicating with RTT API."""
+
+import binascii
+import collections
+import errno
+import json
+import gc
+import select
+import socket
+import ssl
+import time
+import _thread
+
+import utils
+
+
+_RTT_ENDPOINT = 'https://api.rtt.io/api/v1/json'
+_REQUEST_TIMEOUT = 10
+_MAXRESPONSE_SIZE = 40 * 1024
+
+
+def _calculate_departure_datetime(service) -> int:
+ """Utility to calculate the full datetime in seconds.
+
+ Because RTT only provides the service's origin date and not the date at the
+ requested station, we have to calculate the date based on this origin date.
+
+ We assume that services do not run for > 24hrs.
+ """
+ yyyy, month, dd = map(int, service['runDate'].split('-'))
+
+ location = service['locationDetail']
+ departure_time = int(location['gbttBookedDeparture'])
+ if location.get('cancelReasonCode') is not None:
+ departure_time = int(location.get('realtimeDeparture', departure_time))
+
+ hh, mm = divmod(departure_time, 100)
+
+ origin_hh, origin_mm = divmod(int(location['origin'][0]['publicTime']), 100)
+ full_origin_departure_datetime = time.mktime(
+ (yyyy, month, dd, origin_hh, origin_mm, 0, 0, 0)
+ )
+
+ full_departure_datetime = time.mktime((yyyy, month, dd, hh, mm, 0, 0, 0))
+ if full_departure_datetime < full_origin_departure_datetime:
+ # Iff we've wrapped around into the next day, add 24hrs to the departure
+ # datetime.
+ full_departure_datetime += 24 * 60 * 60 # 24hrs
+
+ return full_departure_datetime
+
+
+# TODO: Make this a dataclass when MicroPython supports it.
+class Response:
+
+ def __init__(self, status_code: int, headers: dict[str, str], content):
+ self._status_code = status_code
+ self._headers = headers
+ self._content = content
+
+ @property
+ def status_code(self):
+ return self._status_code
+
+ @property
+ def content(self):
+ return self._content
+
+ @property
+ def headers(self):
+ return self._headers
+
+ def __repr__(self) -> str:
+ return 'Response(status_code={}, headers={}, content={}'.format(
+ self.status_code, self.headers, self.content
+ )
+
+
+def make_basic_auth(username: str, password: str):
+ auth = '{}:{}'.format(username, password)
+ auth = str(binascii.b2a_base64(auth)[:-1], 'ascii')
+ return auth
+
+
+def _http_request(
+ url: str,
+ *,
+ basic_auth: str | None = None,
+ timeout: int | None = None,
+ buffer: memoryview | None = None,
+ ssl_context: ssl.SSLContext | None = None,
+) -> Response:
+ """Send HTTP GET request and return Response.
+
+ This is heavily influenced by urequests.get(), with a couple of modifications:
+ - Simplify code by not supporting sending params with GET
+ - Support passing a pre-allocated buffer for response body, to help
+ alleviate memory fragmentation.
+ - Fix for transient EINPROGRESS error thrown from connect when using
+ timeouts.
+ """
+ proto, _, host, path = url.split('/', 3)
+ redirect = None
+
+ if proto == 'http:':
+ port = 80
+ elif proto == 'https:':
+ port = 443
+ else:
+ raise ValueError('Unsupported protocol: ' + proto)
+
+ if ':' in host:
+ host, port = host.split(':', 1)
+ port = int(port)
+
+ addr = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0]
+
+ s = socket.socket(addr[0], socket.SOCK_STREAM, addr[2])
+
+ try:
+ s.connect(addr[-1])
+
+ p = select.poll()
+ p.register(s, select.POLLOUT)
+ result = p.poll(timeout if timeout is not None else -1)
+ if not result:
+ raise OSError(errno.ETIMEDOUT, 'Timed out connecting to socket.')
+
+ if timeout is not None:
+ s.settimeout(timeout)
+
+ if proto == 'https:':
+ if ssl_context is not None:
+ s = ssl_context.wrap_socket(s, server_hostname=host)
+ else:
+ s = ssl.wrap_socket(s, server_hostname=host)
+
+ s.write('GET /{} HTTP/1.0\r\n'.format(path))
+ s.write('Host: {}\r\n'.format(host))
+ if basic_auth is not None:
+ s.write('Authorization: Basic {}\r\n'.format(basic_auth))
+ s.write('Connection: close\r\n\r\n')
+
+ http_status = s.readline().split(None, 2)
+ if len(http_status) < 2:
+ raise ValueError('HTTP error: bad status "{}"'.format(http_status))
+
+ status = int(http_status[1])
+
+ # Parse response headers.
+ headers = {}
+ while True:
+ header = s.readline()
+ if not header or header == b'\r\n':
+ break
+ if header.startswith(b'Location:') and not 200 <= status <= 299:
+ if status in [301, 302, 303, 307, 308]:
+ redirect = str(header[10:-2], 'utf-8')
+ else:
+ raise NotImplementedError('Redirect %d not yet supported!' % status)
+ else:
+ header = str(header, 'utf-8')
+ k, v = header.split(':', 1)
+ headers[k] = v.strip()
+
+ except Exception:
+ # Always close socket on any exception
+ s.close()
+ raise
+
+ if redirect is not None:
+ s.close()
+ _http_request(
+ redirect,
+ basic_auth=basic_auth,
+ timeout=timeout,
+ buffer=buffer,
+ ssl_context=ssl_context,
+ )
+
+ try:
+ if buffer is not None:
+ content_length = int(headers.get('Content-Length', -1))
+ if content_length > -1 and len(buffer) < content_length:
+ raise ValueError(
+ 'Content length > buffer! Content-length: {} Buffer {}'.format(
+ content_length, len(buffer)
+ )
+ )
+ else:
+ length = s.readinto(buffer)
+ content = buffer[:length]
+ else:
+ content = s.read()
+ finally:
+ s.close()
+
+ return Response(status, headers, content)
+
+
+# TODO: Make this a dataclass when MicroPython supports dataclasses
+class Departure:
+ """Class that encapsulates a train departure's data to be displayed."""
+
+ def __init__(
+ self,
+ destination: str,
+ departure_time: int,
+ actual_departure_time: int,
+ cancelled: bool,
+ ):
+ self._destination = destination
+ self._departure_time = departure_time
+ self._actual_departure_time = actual_departure_time
+ self._cancelled = cancelled
+
+ @property
+ def destination(self) -> str:
+ return self._destination
+
+ @property
+ def departure_time(self) -> int:
+ return self._departure_time
+
+ @property
+ def actual_departure_time(self) -> int:
+ return self._actual_departure_time
+
+ @property
+ def cancelled(self) -> bool:
+ return self._cancelled
+
+ def __repr__(self) -> str:
+ return (
+ 'Departure(destination="{}", departure_time={},'
+ 'actual_departure_tume={}, cancelled={})'
+ ).format(
+ self.destination,
+ self.departure_time,
+ self.actual_departure_time,
+ self.cancelled,
+ )
+
+ def __eq__(self, other: object) -> bool:
+ return (
+ isinstance(other, Departure)
+ and self.departure_time == other.departure_time
+ and self.actual_departure_time == other.actual_departure_time
+ and self.cancelled == other.cancelled
+ and self.destination == other.destination
+ )
+
+
+Station = collections.namedtuple('Station', ('name', 'departures'))
+
+
+def get_departures(
+ station: str,
+ destination: str,
+ basic_auth: str,
+ min_departure_time: int = 0,
+ buffer: memoryview | None = None,
+ ssl_context: ssl.SSLContext | None = None,
+) -> Station:
+ """Requests set of departures from->to provided stations."""
+ url = _RTT_ENDPOINT + '/search/{station}/to/{destination}'.format(
+ station=station,
+ destination=destination,
+ )
+ response = _http_request(
+ url,
+ basic_auth=basic_auth,
+ timeout=_REQUEST_TIMEOUT,
+ buffer=buffer,
+ ssl_context=ssl_context,
+ )
+ if response.status_code != 200:
+ raise ValueError('Error getting departure! {}'.format(response.status_code))
+
+ # TODO: JSON decoding allocates a lot of small objects, which can put pressure
+ # on memory fragmentation. Might be worth writing custom parsing of content.
+ response_json = json.loads(response.content)
+ services = response_json['services']
+ services = [] if services is None else services
+
+ departures = []
+ for service in services:
+ location = service['locationDetail']
+
+ # We could have multiple destinations, so concatentate them together.
+ destination = ','.join([d['description'] for d in location['destination']])
+ departure_time = int(location['gbttBookedDeparture'])
+ realtime_departure = int(location.get('realtimeDeparture', departure_time))
+ cancelled = location.get('cancelReasonCode') is not None
+
+ if min_departure_time > 0:
+ full_departure_datetime = _calculate_departure_datetime(service)
+ now = time.mktime(utils.get_uk_time())
+ if now + (min_departure_time * 60) > full_departure_datetime:
+ continue
+
+ departures.append(
+ Departure(destination, departure_time, realtime_departure, cancelled)
+ )
+
+ results = Station(response_json['location']['name'], departures)
+ del response_json
+ gc.collect() # Explicitly delete and GC JSON objects.
+ return results
+
+
+class DepartureUpdater:
+ """Class that updates departures for a given station periodically."""
+
+ def __init__(
+ self,
+ station: str,
+ destination: str,
+ auth: str,
+ min_departure_time: int,
+ ):
+ self._station = station
+ self._destination = destination
+ self._auth = auth
+ self._min_departure_time = min_departure_time
+
+ self._lock = _thread.allocate_lock()
+ self._departures = Station(station, tuple())
+ self._buffer = bytearray(_MAXRESPONSE_SIZE)
+ self._memoryview = memoryview(self._buffer)
+ self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+
+ def update(self):
+ """Updates the set of departures for a given station."""
+ departures = get_departures(
+ self._station,
+ self._destination,
+ self._auth,
+ self._min_departure_time,
+ self._memoryview,
+ self._ssl_context,
+ )
+ with self._lock:
+ self._departures = departures
+
+ def departures(self) -> tuple[Departure, ...]:
+ """Returns tuple of departures."""
+ with self._lock:
+ return self._departures.departures
+
+ def station(self) -> str:
+ with self._lock:
+ return self._departures.name
diff --git a/src/utils.py b/src/utils.py
new file mode 100644
index 0000000..e59dffa
--- /dev/null
+++ b/src/utils.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Collection of utility functions used by multiple modules."""
+
+import time
+
+
+def get_uk_time() -> tuple[int, ...]:
+ """Calculate UK time, taking into account daylight savings."""
+ year = time.localtime()[0]
+ bst_start = time.mktime(
+ (year, 3, 31 - ((5 * year // 4 + 4) % 7), 1, 0, 0, 0, 0, 0)
+ )
+ bst_end = time.mktime(
+ (year, 10, 31 - ((5 * year // 4 + 1) % 7), 1, 0, 0, 0, 0, 0)
+ )
+ now = time.time()
+ if now >= bst_start and now < bst_end:
+ return time.localtime(now + 3600)
+ else:
+ return time.localtime(now)
diff --git a/src/widgets.py b/src/widgets.py
new file mode 100644
index 0000000..636da5e
--- /dev/null
+++ b/src/widgets.py
@@ -0,0 +1,273 @@
+# Copyright (c) 2023 Tom Ward
+#
+# 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.
+"""Collection of UI widgets for rendering to a display."""
+
+import display
+import fonts
+import trains
+
+
+_WELCOME_TO = 'Welcome to'
+
+
+def _time_to_str(hh_mm: int) -> str:
+ """Helper to convert integer time [h]h[m]m to HH:mm string."""
+ hh, mm = divmod(hh_mm, 100)
+ return '{:0>2}:{:0>2}'.format(hh, mm)
+
+
+class Widget:
+ """Base class for all Widgets"""
+
+ def __init__(self, screen: display.Display):
+ self._screen = screen
+
+ def render(self, x: int, y: int, w: int, h: int) -> bool:
+ """Renders the widget to the display.
+
+ Returns whether the display needs to flush the back buffer to the display.
+ """
+ ...
+
+
+class ClockWidget(Widget):
+ """Class that renders clock to a display."""
+
+ def __init__(
+ self,
+ screen: display.Display,
+ large_font: fonts.Font,
+ small_font: fonts.Font,
+ render_seconds: bool = True,
+ ):
+ super().__init__(screen)
+ self._large_font = large_font
+ self._small_font = small_font
+
+ self._hh_mm_bounds = large_font.calculate_bounds('00:00')
+ self._ss_bounds = small_font.calculate_bounds(':00')
+
+ self._last_update = None
+ self._render_seconds = render_seconds
+
+ def bounds(self):
+ if self._render_seconds:
+ width = self._hh_mm_bounds[0] + self._ss_bounds[0]
+ height = max(self._hh_mm_bounds[1], self._ss_bounds[1])
+ else:
+ width, height = self._hh_mm_bounds
+ return width, height
+
+ def render(self, now: tuple[int, ...], x: int, y: int, w: int, h: int):
+ current_update = now[3:6] if self._render_seconds else now[3:5]
+ if self._last_update is not None and self._last_update == current_update:
+ return False
+
+ self._screen.fill_rect(x, y, w, h, 0)
+ hh_mm = '{:02d}:{:02d}'.format(now[3], now[4])
+
+ w, h = self._large_font.calculate_bounds(hh_mm)
+ x_offset = self._hh_mm_bounds[0] - w
+
+ self._large_font.render_text(hh_mm, self._screen, x + x_offset, y)
+ if self._render_seconds:
+ ss = ':{:02d}'.format(now[5])
+ self._small_font.render_text(
+ ss,
+ self._screen,
+ x + self._hh_mm_bounds[0],
+ y + self._ss_bounds[1] + 2,
+ ) # TODO: Remove +2 bump to fix vertical alignment.
+
+ self._last_update = current_update
+ return True
+
+
+class OutOfHoursWidget(Widget):
+
+ def __init__(self, screen: display.Display, font: fonts.Font, station: str):
+ super().__init__(screen)
+ self._font = font
+ self._station = station
+ self._welcome_to_bounds = font.calculate_bounds(_WELCOME_TO)
+ self._station_bounds = font.calculate_bounds(station)
+
+ def bounds(self):
+ width = max(self._welcome_to_bounds[0], self._station_bounds[0])
+ height = self._welcome_to_bounds[1] + self._station_bounds[1]
+ return width, height
+
+ def render(self, x: int, y: int, w: int, h: int):
+ x_offset = (w - self._welcome_to_bounds[0]) // 2
+ self._font.render_text(_WELCOME_TO, self._screen, x + x_offset, y)
+ y += self._welcome_to_bounds[1]
+
+ x_offset = (w - self._station_bounds[0]) // 2
+ self._font.render_text(self._station, self._screen, x + x_offset, y)
+ return True
+
+
+class MessageWidget(Widget):
+ """Renders a message in the middle of screen."""
+
+ def __init__(self, screen: display.Display, message: str, font: fonts.Font):
+ super().__init__(screen)
+ self._default_message = message
+ self._font = font
+ w, h = 0, 0
+ for m in message.split('\n'):
+ bounds = font.calculate_bounds(m)
+ w = max(w, bounds[0])
+ h += bounds[1]
+
+ self._x = (screen.width - w) // 2
+ self._y = (screen.height - h) // 2
+
+ def render(self, message: str | None = None) -> bool:
+ self._screen.fill(0)
+ messages = (self._default_message if message is None else message).split(
+ '\n'
+ )
+ for i, message in enumerate(messages):
+ self._font.render_text(
+ message,
+ self._screen,
+ self._x,
+ self._y + (i * self._font.max_bounds()[1]),
+ )
+ return True
+
+
+class DepartureWidget(Widget):
+ """Class that renders a departure to provided display."""
+
+ def __init__(
+ self,
+ screen: display.Display,
+ font: fonts.Font,
+ width: int,
+ status_font: fonts.Font | None = None,
+ ):
+ super().__init__(screen)
+ self._font = font
+ self._width = width
+ self._status_font = status_font if status_font else font
+ self._max_clock_width = self._font.calculate_bounds('00:00')[0]
+
+ self._last_departure = None
+
+ def bounds(self) -> tuple[int, int]:
+ return self._width, max(
+ self._font.max_bounds()[1], self._status_font.max_bounds()[1]
+ )
+
+ def render(
+ self, departure: trains.Departure | None, x: int, y: int, w: int, h: int
+ ) -> bool:
+ if self._last_departure == departure:
+ return False
+
+ self._last_departure = departure
+ self._screen.fill_rect(x, y, w, self._font.max_bounds()[1], 0)
+
+ if departure is None:
+ return True
+
+ departure_time = _time_to_str(departure.departure_time)
+ self._font.render_text(departure_time, self._screen, x, y)
+
+ x += self._max_clock_width + 4
+ self._font.render_text(departure.destination, self._screen, x, y)
+
+ if departure.cancelled:
+ status = 'Cancelled'
+ status_w, _ = self._status_font.calculate_bounds(status)
+ elif departure.departure_time != departure.actual_departure_time:
+ status = 'Exp {}'.format(_time_to_str(departure.actual_departure_time))
+ status_w, _ = self._status_font.calculate_bounds(status)
+ else:
+ status = 'On time'
+ status_w, _ = self._status_font.calculate_bounds(status)
+
+ self._status_font.render_text(status, self._screen, w - status_w, y)
+ return True
+
+
+class MainWidget(Widget):
+ """Class for the main display rendering."""
+
+ def __init__(
+ self,
+ screen: display.Display,
+ departure_updater: trains.DepartureUpdater,
+ bold_font: fonts.Font,
+ tall_font: fonts.Font,
+ default_font: fonts.Font,
+ render_seconds: bool = True,
+ ):
+ super().__init__(screen)
+ self._departure_updater = departure_updater
+ self._departure_widgets = []
+
+ self._clock_widget = ClockWidget(
+ screen, tall_font, bold_font, render_seconds
+ )
+ self._out_of_hours_widget = OutOfHoursWidget(
+ screen, bold_font, departure_updater.station()
+ )
+ self._departures_spacer = default_font.max_bounds()[1] + 2
+ self._num_departures = -1
+
+ num_departures = (
+ screen.height - self._clock_widget.bounds()[1]
+ ) // self._departures_spacer
+ for i in range(num_departures):
+ self._departure_widgets.append(
+ DepartureWidget(
+ screen,
+ bold_font if i == 0 else default_font,
+ screen.width,
+ default_font,
+ )
+ )
+
+ def render(self, now: tuple[int, ...]):
+ """Render display. Currently assumes we're rendering entire display."""
+ need_refresh = False
+ departures = self._departure_updater.departures()
+ if departures:
+ y = 0
+ for i, widget in enumerate(self._departure_widgets):
+ departure = departures[i] if i < len(departures) else None
+ need_refresh |= widget.render(departure, 0, y, *widget.bounds())
+ y += self._departures_spacer
+ else:
+ out_of_hours_bounds = self._out_of_hours_widget.bounds()
+ x = (self._screen.width - out_of_hours_bounds[0]) // 2
+ self._out_of_hours_widget.render(x, 0, *out_of_hours_bounds)
+
+ need_refresh |= self._num_departures != len(departures)
+ self._num_departures = len(departures)
+
+ clock_bounds = self._clock_widget.bounds()
+ x = (self._screen.width - clock_bounds[0]) // 2
+ y = self._screen.height - clock_bounds[1]
+
+ need_refresh |= self._clock_widget.render(now, x, y, *clock_bounds)
+ return need_refresh
diff --git a/third_party/data_to_py/LICENSE b/third_party/data_to_py/LICENSE
new file mode 100644
index 0000000..bbd269b
--- /dev/null
+++ b/third_party/data_to_py/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Peter Hinch
+
+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/third_party/data_to_py/README.md b/third_party/data_to_py/README.md
new file mode 100644
index 0000000..9e404a6
--- /dev/null
+++ b/third_party/data_to_py/README.md
@@ -0,0 +1,3 @@
+# MicroPython data handling
+
+`data_to_py` cloned from https://github.com/peterhinch/micropython-data-to-py
\ No newline at end of file
diff --git a/third_party/data_to_py/data_to_py.py b/third_party/data_to_py/data_to_py.py
new file mode 100755
index 0000000..da60490
--- /dev/null
+++ b/third_party/data_to_py/data_to_py.py
@@ -0,0 +1,153 @@
+#! /usr/bin/python3
+# -*- coding: utf-8 -*-
+
+# The MIT License (MIT)
+#
+# Copyright (c) 2016 Peter Hinch
+#
+# 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.
+
+import argparse
+import sys
+import os
+
+# UTILITIES FOR WRITING PYTHON SOURCECODE TO A FILE
+
+# ByteWriter takes as input a variable name and data values and writes
+# Python source to an output stream of the form
+# my_variable = b'\x01\x02\x03\x04\x05\x06\x07\x08'\
+
+# Lines are broken with \ for readability.
+
+
+class ByteWriter(object):
+ bytes_per_line = 16
+
+ def __init__(self, stream, varname):
+ self.stream = stream
+ self.stream.write('{} =\\\n'.format(varname))
+ self.bytecount = 0 # For line breaks
+
+ def _eol(self):
+ self.stream.write("'\\\n")
+
+ def _eot(self):
+ self.stream.write("'\n")
+
+ def _bol(self):
+ self.stream.write("b'")
+
+ # Output a single byte
+ def obyte(self, data):
+ if not self.bytecount:
+ self._bol()
+ self.stream.write('\\x{:02x}'.format(data))
+ self.bytecount += 1
+ self.bytecount %= self.bytes_per_line
+ if not self.bytecount:
+ self._eol()
+
+ # Output from a sequence
+ def odata(self, bytelist):
+ for byt in bytelist:
+ self.obyte(byt)
+
+ # ensure a correct final line
+ def eot(self): # User force EOL if one hasn't occurred
+ if self.bytecount:
+ self._eot()
+ self.stream.write('\n')
+
+
+# PYTHON FILE WRITING
+
+STR01 = """# Code generated by data_to_py.py.
+version = '0.1'
+"""
+
+STR02 = """_mvdata = memoryview(_data)
+
+def data():
+ return _mvdata
+
+"""
+
+def write_func(stream, name, arg):
+ stream.write('def {}():\n return {}\n\n'.format(name, arg))
+
+
+def write_data(op_path, ip_path):
+ try:
+ with open(ip_path, 'rb') as ip_stream:
+ try:
+ with open(op_path, 'w') as op_stream:
+ write_stream(ip_stream, op_stream)
+ except OSError:
+ print("Can't open", op_path, 'for writing')
+ return False
+ except OSError:
+ print("Can't open", ip_path)
+ return False
+ return True
+
+
+def write_stream(ip_stream, op_stream):
+ op_stream.write(STR01)
+ op_stream.write('\n')
+ data = ip_stream.read()
+ bw_data = ByteWriter(op_stream, '_data')
+ bw_data.odata(data)
+ bw_data.eot()
+ op_stream.write(STR02)
+
+
+# PARSE COMMAND LINE ARGUMENTS
+
+def quit(msg):
+ print(msg)
+ sys.exit(1)
+
+DESC = """data_to_py.py
+Utility to convert an arbitrary binary file to Python source.
+Sample usage:
+data_to_py.py image.jpg image.py
+
+"""
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(__file__, description=DESC,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('infile', type=str, help='Input file path')
+ parser.add_argument('outfile', type=str,
+ help='Path and name of output file. Must have .py extension.')
+
+
+ args = parser.parse_args()
+
+ if not os.path.isfile(args.infile):
+ quit("Data filename does not exist")
+
+ if not os.path.splitext(args.outfile)[1].upper() == '.PY':
+ quit('Output filename must have a .py extension.')
+
+ print('Writing Python file.')
+ if not write_data(args.outfile, args.infile):
+ sys.exit(1)
+
+ print(args.outfile, 'written successfully.')
diff --git a/third_party/font_to_py/LICENSE b/third_party/font_to_py/LICENSE
new file mode 100644
index 0000000..acd380e
--- /dev/null
+++ b/third_party/font_to_py/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016 Peter Hinch
+
+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.
\ No newline at end of file
diff --git a/third_party/font_to_py/README.md b/third_party/font_to_py/README.md
new file mode 100644
index 0000000..c07ee2d
--- /dev/null
+++ b/third_party/font_to_py/README.md
@@ -0,0 +1,3 @@
+# MicroPython font handling
+
+`font_to_py` cloned from https://github.com/peterhinch/micropython-font-to-py
\ No newline at end of file
diff --git a/third_party/font_to_py/font_to_py.py b/third_party/font_to_py/font_to_py.py
new file mode 100755
index 0000000..efd8b31
--- /dev/null
+++ b/third_party/font_to_py/font_to_py.py
@@ -0,0 +1,722 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Needs freetype-py>=1.0
+
+# Implements multi-pass solution to setting an exact font height
+
+# Some code adapted from Daniel Bader's work at the following URL
+# https://dbader.org/blog/monochrome-font-rendering-with-freetype-and-python
+# With thanks to Stephen Irons @ironss for various improvements, also to
+# @enigmaniac for ideas around handling `bdf` and `pcf` files.
+
+# The MIT License (MIT)
+#
+# Copyright (c) 2016-2023 Peter Hinch
+#
+# 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.
+
+import argparse
+import sys
+import os
+try:
+ import freetype
+except ModuleNotFoundError:
+ print('font_to_py requires the freetype library. Please see FONT_TO_PY.md.')
+ sys.exit(1)
+if freetype.version()[0] < 1:
+ print('freetype version should be >= 1. Please see FONT_TO_PY.md')
+
+MINCHAR = 32 # Ordinal values of default printable ASCII set
+MAXCHAR = 126 # 94 chars
+
+# UTILITIES FOR WRITING PYTHON SOURCECODE TO A FILE
+
+# ByteWriter takes as input a variable name and data values and writes
+# Python source to an output stream of the form
+# my_variable = b'\x01\x02\x03\x04\x05\x06\x07\x08'\
+
+# Lines are broken with \ for readability.
+
+class ByteWriter:
+ bytes_per_line = 16
+
+ def __init__(self, stream, varname):
+ self.stream = stream
+ self.stream.write('{} =\\\n'.format(varname))
+ self.bytecount = 0 # For line breaks
+
+ def _eol(self):
+ self.stream.write("'\\\n")
+
+ def _eot(self):
+ self.stream.write("'\n")
+
+ def _bol(self):
+ self.stream.write("b'")
+
+ # Output a single byte
+ def obyte(self, data):
+ if not self.bytecount:
+ self._bol()
+ self.stream.write('\\x{:02x}'.format(data))
+ self.bytecount += 1
+ self.bytecount %= self.bytes_per_line
+ if not self.bytecount:
+ self._eol()
+
+ # Output from a sequence
+ def odata(self, bytelist):
+ for byt in bytelist:
+ self.obyte(byt)
+
+ # ensure a correct final line
+ def eot(self): # User force EOL if one hasn't occurred
+ if self.bytecount:
+ self._eot()
+ self.stream.write('\n')
+
+
+# Define a global
+def var_write(stream, name, value):
+ stream.write('{} = {}\n'.format(name, value))
+
+# FONT HANDLING
+
+
+class Bitmap:
+ """
+ A 2D bitmap image represented as a list of byte values. Each byte indicates
+ the state of a single pixel in the bitmap. A value of 0 indicates that the
+ pixel is `off` and any other value indicates that it is `on`.
+ """
+ def __init__(self, width, height, pixels=None):
+ self.width = width
+ self.height = height
+ self.pixels = pixels or bytearray(width * height)
+
+ def display(self):
+ """Print the bitmap's pixels."""
+ for row in range(self.height):
+ for col in range(self.width):
+ char = '#' if self.pixels[row * self.width + col] else '.'
+ print(char, end='')
+ print()
+ print()
+
+ def bitblt(self, src, top, left):
+ """Copy all pixels from `src` into this bitmap"""
+ srcpixel = 0
+ dstpixel = top * self.width + left
+ row_offset = self.width - src.width
+
+ for _ in range(src.height):
+ for _ in range(src.width):
+ self.pixels[dstpixel] = src.pixels[srcpixel]
+ srcpixel += 1
+ dstpixel += 1
+ dstpixel += row_offset
+
+ # Horizontal mapping generator function
+ def get_hbyte(self, reverse):
+ for row in range(self.height):
+ col = 0
+ while True:
+ bit = col % 8
+ if bit == 0:
+ if col >= self.width:
+ break
+ byte = 0
+ if col < self.width:
+ if reverse:
+ byte |= self.pixels[row * self.width + col] << bit
+ else:
+ # Normal map MSB of byte 0 is (0, 0)
+ byte |= self.pixels[row * self.width + col] << (7 - bit)
+ if bit == 7:
+ yield byte
+ col += 1
+
+ # Vertical mapping
+ def get_vbyte(self, reverse):
+ for col in range(self.width):
+ row = 0
+ while True:
+ bit = row % 8
+ if bit == 0:
+ if row >= self.height:
+ break
+ byte = 0
+ if row < self.height:
+ if reverse:
+ byte |= self.pixels[row * self.width + col] << (7 - bit)
+ else:
+ # Normal map MSB of byte 0 is (0, 7)
+ byte |= self.pixels[row * self.width + col] << bit
+ if bit == 7:
+ yield byte
+ row += 1
+
+
+class Glyph:
+ def __init__(self, pixels, width, height, top, left, advance_width):
+ self.bitmap = Bitmap(width, height, pixels)
+
+ # The glyph bitmap's top-side bearing, i.e. the vertical distance from
+ # the baseline to the bitmap's top-most scanline.
+ self.top = top
+ self.left = left
+
+ # Ascent and descent determine how many pixels the glyph extends
+ # above or below the baseline.
+ self.descent = max(0, self.height - self.top)
+ self.ascent = max(0, max(self.top, self.height) - self.descent)
+
+ # The advance width determines where to place the next character
+ # horizontally, that is, how many pixels we move to the right to
+ # draw the next glyph.
+ self.advance_width = advance_width
+
+ @property
+ def width(self):
+ return self.bitmap.width
+
+ @property
+ def height(self):
+ return self.bitmap.height
+
+ @staticmethod
+ def from_glyphslot(slot):
+ """Construct and return a Glyph object from a FreeType GlyphSlot."""
+ pixels = Glyph.unpack_mono_bitmap(slot.bitmap)
+ width, height = slot.bitmap.width, slot.bitmap.rows
+ top = slot.bitmap_top
+ left = slot.bitmap_left
+
+ # The advance width is given in FreeType's 26.6 fixed point format,
+ # which means that the pixel values are multiples of 64.
+ advance_width = slot.advance.x / 64
+
+ return Glyph(pixels, width, height, top, left, advance_width)
+
+ @staticmethod
+ def unpack_mono_bitmap(bitmap):
+ """
+ Unpack a freetype FT_LOAD_TARGET_MONO glyph bitmap into a bytearray
+ where each pixel is represented by a single byte.
+ """
+ # Allocate a bytearray of sufficient size to hold the glyph bitmap.
+ data = bytearray(bitmap.rows * bitmap.width)
+
+ # Iterate over every byte in the glyph bitmap. Note that we're not
+ # iterating over every pixel in the resulting unpacked bitmap --
+ # we're iterating over the packed bytes in the input bitmap.
+ for row in range(bitmap.rows):
+ for byte_index in range(bitmap.pitch):
+
+ # Read the byte that contains the packed pixel data.
+ byte_value = bitmap.buffer[row * bitmap.pitch + byte_index]
+
+ # We've processed this many bits (=pixels) so far. This
+ # determines where we'll read the next batch of pixels from.
+ num_bits_done = byte_index * 8
+
+ # Pre-compute where to write the pixels that we're going
+ # to unpack from the current byte in the glyph bitmap.
+ rowstart = row * bitmap.width + byte_index * 8
+
+ # Iterate over every bit (=pixel) that's still a part of the
+ # output bitmap. Sometimes we're only unpacking a fraction of
+ # a byte because glyphs may not always fit on a byte boundary.
+ # So we make sure to stop if we unpack past the current row
+ # of pixels.
+ for bit_index in range(min(8, bitmap.width - num_bits_done)):
+
+ # Unpack the next pixel from the current glyph byte.
+ bit = byte_value & (1 << (7 - bit_index))
+
+ # Write the pixel to the output bytearray. We ensure that
+ # `off` pixels have a value of 0 and `on` pixels have a
+ # value of 1.
+ data[rowstart + bit_index] = 1 if bit else 0
+
+ return data
+
+
+# A Font object is a dictionary of ASCII chars indexed by a character e.g.
+# myfont['a']
+# Each entry comprises a list
+# [0] A Bitmap instance containing the character
+# [1] The width of the character data including advance (actual data stored)
+# Public attributes:
+# height (in pixels) of all characters
+# width (in pixels) for monospaced output (advance width of widest char)
+class Font(dict):
+ def __init__(self, filename, size, minchar, maxchar, monospaced, defchar, charset, bitmapped):
+ super().__init__()
+ self._face = freetype.Face(filename)
+ # .crange is the inclusive range of ordinal values spanning the character set.
+ self.crange = range(minchar, maxchar + 1)
+ self.monospaced = monospaced
+ self.defchar = defchar
+ # .charset has all defined characters with '' for those in range but undefined.
+ # Sort order is increasing ordinal value of the character whether defined or not,
+ # except that item 0 is the default char.
+ if defchar is None: # Binary font
+ self.charset = [chr(ordv) for ordv in self.crange]
+ elif charset == '':
+ self.charset = [chr(defchar)] + [chr(ordv) for ordv in self.crange]
+ else:
+ cl = [ord(x) for x in chr(defchar) + charset if self._face.get_char_index(x) != 0 ]
+ self.crange = range(min(cl), max(cl) + 1) # Inclusive ordinal value range
+ cs = [chr(ordv) if chr(ordv) in charset and self._face.get_char_index(chr(ordv)) != 0 else '' for ordv in self.crange]
+ # .charset has an item for all chars in range. '' if unsupported.
+ # item 0 is the default char. Subsequent chars are in increasing ordinal value.
+ self.charset = [chr(defchar)] + cs
+ # Populate self with defined chars only
+ self.update(dict.fromkeys([c for c in self.charset if c]))
+ self.max_width = self.bmp_dimensions(size) if bitmapped else self.get_dimensions(size)
+ self.width = self.max_width if monospaced else 0
+ self._assign_values() # Assign values to existing keys
+
+ def bmp_dimensions(self, height):
+ max_descent = 0
+ # For each character in the charset string we get the glyph
+ # and update the overall dimensions of the resulting bitmap.
+ max_width = 0
+ max_ascent = 0
+ for char in self.keys():
+ glyph = self._glyph_for_character(char)
+ max_ascent = max(max_ascent, glyph.ascent)
+ max_descent = max(max_descent, glyph.descent)
+ # for a few chars e.g. _ glyph.width > glyph.advance_width
+ max_width = int(max(max_width, glyph.advance_width,
+ glyph.width))
+
+ self.height = int(max_ascent + max_descent)
+ self._max_ascent = int(max_ascent)
+ self._max_descent = int(max_descent)
+ print('Requested height', height)
+ print('Actual height', self.height)
+ print('Max width', max_width)
+ print('Max descent', self._max_descent)
+ print('Max ascent', self._max_ascent)
+ return max_width
+
+ # n-pass solution to setting a precise height.
+ def get_dimensions(self, required_height):
+ error = 0
+ height = required_height
+ for npass in range(1):
+ height += error
+ self._face.set_pixel_sizes(0, height)
+ max_descent = 0
+
+ # For each character in the charset string we get the glyph
+ # and update the overall dimensions of the resulting bitmap.
+ max_width = 0
+ max_ascent = 0
+ for char in self.keys():
+ glyph = self._glyph_for_character(char)
+ max_ascent = max(max_ascent, glyph.ascent)
+ max_descent = max(max_descent, glyph.descent)
+ # for a few chars e.g. _ glyph.width > glyph.advance_width
+ max_width = int(max(max_width, glyph.advance_width,
+ glyph.width))
+
+ new_error = required_height - (max_ascent + max_descent)
+ if (new_error == 0) or (abs(new_error) - abs(error) == 0):
+ break
+ error = new_error
+ self.height = int(max_ascent + max_descent)
+ st = 'Height set in {} passes. Actual height {} pixels.\nMax character width {} pixels.'
+ print(st.format(npass + 1, self.height, max_width))
+ self._max_ascent = int(max_ascent)
+ self._max_descent = int(max_descent)
+ return max_width
+
+
+ def _glyph_for_character(self, char):
+ # Let FreeType load the glyph for the given character and tell it to
+ # render a monochromatic bitmap representation.
+ assert char != ''
+ self._face.load_char(char, freetype.FT_LOAD_RENDER |
+ freetype.FT_LOAD_TARGET_MONO)
+ return Glyph.from_glyphslot(self._face.glyph)
+
+ def _assign_values(self):
+ for char in self.keys():
+ glyph = self._glyph_for_character(char)
+ # https://github.com/peterhinch/micropython-font-to-py/issues/21
+ # Handle negative glyph.left correctly (capital J),
+ # also glyph.width > advance (capital K and R).
+ if glyph.left >= 0:
+ char_width = int(max(glyph.advance_width, glyph.width + glyph.left))
+ left = glyph.left
+ else:
+ char_width = int(max(glyph.advance_width - glyph.left, glyph.width))
+ left = 0
+
+ width = self.width if self.width else char_width # Space required if monospaced
+ outbuffer = Bitmap(width, self.height)
+
+ # The vertical drawing position should place the glyph
+ # on the baseline as intended.
+ row = self.height - int(glyph.ascent) - self._max_descent
+ outbuffer.bitblt(glyph.bitmap, row, left)
+ self[char] = [outbuffer, width, char_width]
+
+ def stream_char(self, char, hmap, reverse):
+ outbuffer, _, _ = self[char]
+ if hmap:
+ gen = outbuffer.get_hbyte(reverse)
+ else:
+ gen = outbuffer.get_vbyte(reverse)
+ yield from gen
+
+ def build_arrays(self, hmap, reverse):
+ data = bytearray()
+ index = bytearray()
+ sparse = bytearray()
+ def append_data(data, char):
+ width = self[char][1]
+ data += (width).to_bytes(2, byteorder='little')
+ data += bytearray(self.stream_char(char, hmap, reverse))
+
+ # self.charset is contiguous with chars having ordinal values in the
+ # inclusive range specified. Where the specified character set has gaps
+ # missing characters are empty strings.
+ # Charset includes default char and both max and min chars, hence +2.
+ if len(self.charset) <= MAXCHAR - MINCHAR + 2:
+ # Build normal index. Efficient for ASCII set and smaller as
+ # entries are 2 bytes (-> data[0] for absent glyph)
+ for char in self.charset:
+ if char == '':
+ index += bytearray((0, 0))
+ else:
+ index += (len(data)).to_bytes(2, byteorder='little') # Start
+ append_data(data, char)
+ index += (len(data)).to_bytes(2, byteorder='little') # End
+ else:
+ # Sparse index. Entries are 4 bytes but only populated if the char
+ # has a defined glyph.
+ append_data(data, self.charset[0]) # data[0] is the default char
+ for char in sorted(self.keys()):
+ sparse += ord(char).to_bytes(2, byteorder='little')
+ pad = len(data) % 8
+ if pad: # Ensure len(data) % 8 == 0
+ data += bytearray(8 - pad)
+ try:
+ sparse += (len(data) >> 3).to_bytes(2, byteorder='little') # Start
+ except OverflowError:
+ raise ValueError("Total size of font bitmap exceeds 524287 bytes.")
+ append_data(data, char)
+ return data, index, sparse
+
+ def build_binary_array(self, hmap, reverse, sig):
+ data = bytearray((0x3f + sig, 0xe7, self.max_width, self.height))
+ for char in self.charset:
+ width = self[char][2]
+ data += bytes((width,))
+ data += bytearray(self.stream_char(char, hmap, reverse))
+ return data
+
+# PYTHON FILE WRITING
+# The index only holds the start of data so can't read next_offset but must
+# calculate it.
+
+STR01 = """# Code generated by font_to_py.py.
+# Font: {}{}
+# Cmd: {}
+version = '0.33'
+
+"""
+
+# Code emitted for charsets spanning a small range of ordinal values
+STR02 = """_mvfont = memoryview(_font)
+_mvi = memoryview(_index)
+ifb = lambda l : l[0] | (l[1] << 8)
+
+def get_ch(ch):
+ oc = ord(ch)
+ ioff = 2 * (oc - {0} + 1) if oc >= {0} and oc <= {1} else 0
+ doff = ifb(_mvi[ioff : ])
+ width = ifb(_mvfont[doff : ])
+"""
+
+# Code emiited for large charsets, assumed by build_arrays() to be sparse.
+# Binary search of sorted sparse index.
+# Offset into data array is saved after dividing by 8
+STRSP = """_mvfont = memoryview(_font)
+_mvsp = memoryview(_sparse)
+ifb = lambda l : l[0] | (l[1] << 8)
+
+def bs(lst, val):
+ while True:
+ m = (len(lst) & ~ 7) >> 1
+ v = ifb(lst[m:])
+ if v == val:
+ return ifb(lst[m + 2:])
+ if not m:
+ return 0
+ lst = lst[m:] if v < val else lst[:m]
+
+def get_ch(ch):
+ doff = bs(_mvsp, ord(ch)) << 3
+ width = ifb(_mvfont[doff : ])
+"""
+
+# Code emitted for horizontally mapped fonts.
+STR02H ="""
+ next_offs = doff + 2 + ((width - 1)//8 + 1) * {0}
+ return _mvfont[doff + 2:next_offs], {0}, width
+
+"""
+
+# Code emitted for vertically mapped fonts.
+STR02V ="""
+ next_offs = doff + 2 + (({0} - 1)//8 + 1) * width
+ return _mvfont[doff + 2:next_offs], {0}, width
+
+"""
+
+# Extra code emitted where -i is specified.
+STR03 = '''
+def glyphs():
+ for c in """{}""":
+ yield c, get_ch(c)
+
+'''
+
+def write_func(stream, name, arg):
+ stream.write('def {}():\n return {}\n\n'.format(name, arg))
+
+def write_font(op_path, font_path, height, monospaced, hmap, reverse, minchar,
+ maxchar, defchar, charset, iterate, bitmapped):
+ try:
+ fnt = Font(font_path, height, minchar, maxchar, monospaced, defchar, charset, bitmapped)
+ except freetype.ft_errors.FT_Exception:
+ print("Can't open", font_path)
+ return False
+ try:
+ with open(op_path, 'w', encoding='utf-8') as stream:
+ write_data(stream, fnt, font_path, hmap, reverse, iterate, charset)
+ except OSError:
+ print("Can't open", op_path, 'for writing')
+ return False
+ return True
+
+def write_data(stream, fnt, font_path, hmap, reverse, iterate, charset):
+ height = fnt.height # Actual height, not target height
+ minchar = min(fnt.crange)
+ maxchar = max(fnt.crange)
+ defchar = fnt.defchar
+ st = '' if charset == '' else ' Char set: {}'.format(charset)
+ cl = ' '.join(sys.argv)
+ stream.write(STR01.format(os.path.split(font_path)[1], st, cl))
+ write_func(stream, 'height', height)
+ write_func(stream, 'baseline', fnt._max_ascent)
+ write_func(stream, 'max_width', fnt.max_width)
+ write_func(stream, 'hmap', hmap)
+ write_func(stream, 'reverse', reverse)
+ write_func(stream, 'monospaced', fnt.monospaced)
+ write_func(stream, 'min_ch', minchar)
+ write_func(stream, 'max_ch', maxchar)
+ if iterate:
+ stream.write(STR03.format(''.join(sorted(fnt.keys()))))
+ data, index, sparse = fnt.build_arrays(hmap, reverse)
+ bw_font = ByteWriter(stream, '_font')
+ bw_font.odata(data)
+ bw_font.eot()
+ if sparse: # build_arrays() has returned a sparse index
+ bw_sparse = ByteWriter(stream, '_sparse')
+ bw_sparse.odata(sparse)
+ bw_sparse.eot()
+ stream.write(STRSP)
+ print("Sparse")
+ else:
+ bw_index = ByteWriter(stream, '_index')
+ bw_index.odata(index)
+ bw_index.eot()
+ stream.write(STR02.format(minchar, maxchar))
+ print("Normal")
+ if hmap:
+ stream.write(STR02H.format(height))
+ else:
+ stream.write(STR02V.format(height))
+
+# BINARY OUTPUT
+# hmap reverse magic bytes
+# 0 0 0x3f 0xe7
+# 1 0 0x40 0xe7
+# 0 1 0x41 0xe7
+# 1 1 0x42 0xe7
+def write_binary_font(op_path, font_path, height, hmap, reverse):
+ try:
+ fnt = Font(font_path, height, 32, 126, True, None, '') # All chars have same width
+ except freetype.ft_errors.FT_Exception:
+ print("Can't open", font_path)
+ return False
+ sig = 1 if hmap else 0
+ if reverse:
+ sig += 2
+ try:
+ with open(op_path, 'wb') as stream:
+ data = fnt.build_binary_array(hmap, reverse, sig)
+ stream.write(data)
+ except OSError:
+ print("Can't open", op_path, 'for writing')
+ return False
+ return True
+
+# PARSE COMMAND LINE ARGUMENTS
+
+def quit(msg):
+ print(msg)
+ sys.exit(1)
+
+DESC = """font_to_py.py V0.4.0
+Utility to convert ttf, otf, bdf and pcf font files to Python source.
+Sample usage:
+font_to_py.py FreeSans.ttf 23 freesans.py
+
+This creates a font with nominal height 23 pixels with these defaults:
+Mapping is vertical, pitch variable, character set 32-126 inclusive.
+Illegal characters will be rendered as "?".
+
+To specify monospaced rendering issue:
+font_to_py.py FreeSans.ttf 23 --fixed freesans.py
+"""
+
+BINARY = """Invalid arguments. Binary (random access) font files support the standard ASCII
+character set (from 32 to 126 inclusive). This range cannot be overridden.
+Random access font files don't support an error character.
+"""
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(__file__, description=DESC,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('infile', type=str, help='Input file path')
+ parser.add_argument('height', type=int, help='Font height in pixels')
+ parser.add_argument('outfile', type=str,
+ help='Path and name of output file')
+
+ parser.add_argument('-x', '--xmap', action='store_true',
+ help='Horizontal (x) mapping')
+ parser.add_argument('-r', '--reverse', action='store_true',
+ help='Bit reversal')
+ parser.add_argument('-f', '--fixed', action='store_true',
+ help='Fixed width (monospaced) font')
+ parser.add_argument('-b', '--binary', action='store_true',
+ help='Produce binary (random access) font file.')
+ parser.add_argument('-i', '--iterate', action='store_true',
+ help='Include generator function to iterate over character set.')
+
+ parser.add_argument('-s', '--smallest',
+ type = int,
+ default = MINCHAR,
+ help = 'Ordinal value of smallest character default %(default)i')
+
+ parser.add_argument('-l', '--largest',
+ type = int,
+ help = 'Ordinal value of largest character default %(default)i',
+ default = MAXCHAR)
+
+ parser.add_argument('-e', '--errchar',
+ type = int,
+ help = 'Ordinal value of error character default %(default)i ("?")',
+ default = 63)
+
+ parser.add_argument('-c', '--charset',
+ type = str,
+ help = 'Character set. e.g. 1234567890: to restrict for a clock display.',
+ default = '')
+
+ parser.add_argument('-k', '--charset_file',
+ type = str,
+ help = 'File containing charset e.g. cyrillic_subset.',
+ default = '')
+
+ args = parser.parse_args()
+ if not args.outfile[0].isalpha():
+ quit('Font filenames must be valid Python variable names.')
+
+ if not os.path.isfile(args.infile):
+ quit("Font filename does not exist")
+
+ if not os.path.splitext(args.infile)[1].upper() in ('.TTF', '.OTF', '.BDF', '.PCF'):
+ quit("Font file should be a ttf or otf file.")
+
+ if args.binary:
+ if os.path.splitext(args.outfile)[1].upper() == '.PY':
+ quit('Binary file must not have a .py extension.')
+
+ if args.smallest != 32 or args.largest != 126 or args.errchar != ord('?') or args.charset:
+ quit(BINARY)
+
+ print('Writing binary font file.')
+ if not write_binary_font(args.outfile, args.infile, args.height,
+ args.xmap, args.reverse):
+ sys.exit(1)
+ else:
+ if not os.path.splitext(args.outfile)[1].upper() == '.PY':
+ quit('Output filename must have a .py extension.')
+
+ if args.smallest < 0:
+ quit('--smallest must be >= 0')
+
+ if args.largest > 255:
+ quit('--largest must be < 256')
+ elif args.largest > 127 and os.path.splitext(args.infile)[1].upper() == '.TTF':
+ print('WARNING: extended ASCII characters may not be correctly converted. See docs.')
+
+ if args.errchar < 0 or args.errchar > 255:
+ quit('--errchar must be between 0 and 255')
+ if args.charset and (args.smallest != 32 or args.largest != 126):
+ print('WARNING: specified smallest and largest values ignored.')
+
+ if args.charset_file:
+ try:
+ with open(args.charset_file, 'r', encoding='utf-8') as f:
+ cset = f.read()
+ except OSError:
+ print("Can't open", args.charset_file, 'for reading.')
+ sys.exit(1)
+ else:
+ cset = args.charset
+ # dedupe and remove default char. Allow chars in private use area.
+ # https://github.com/peterhinch/micropython-font-to-py/issues/22
+ cs = {c for c in cset if c.isprintable() or (0xE000 <= ord(c) <= 0xF8FF) } - {args.errchar}
+ cs = sorted(list(cs))
+ cset = ''.join(cs) # Back to string
+ bitmapped = os.path.splitext(args.infile)[1].upper() in ('.BDF', '.PCF')
+ if bitmapped:
+ if args.height != 0:
+ print('Warning: height arg ignored for bitmapped fonts.')
+ chkface = freetype.Face(args.infile)
+ args.height = chkface._get_available_sizes()[0].height
+ print("Found font with size " + str(args.height))
+
+ print('Writing Python font file.')
+ if not write_font(args.outfile, args.infile, args.height, args.fixed,
+ args.xmap, args.reverse, args.smallest, args.largest,
+ args.errchar, cset, args.iterate, bitmapped):
+ sys.exit(1)
+
+ print(args.outfile, 'written successfully.')
+
diff --git a/third_party/fonts/Dot Matrix Bold Tall.ttf b/third_party/fonts/Dot Matrix Bold Tall.ttf
new file mode 100644
index 0000000..b4fe737
Binary files /dev/null and b/third_party/fonts/Dot Matrix Bold Tall.ttf differ
diff --git a/third_party/fonts/Dot Matrix Bold.ttf b/third_party/fonts/Dot Matrix Bold.ttf
new file mode 100644
index 0000000..72b30e1
Binary files /dev/null and b/third_party/fonts/Dot Matrix Bold.ttf differ
diff --git a/third_party/fonts/Dot Matrix Regular.ttf b/third_party/fonts/Dot Matrix Regular.ttf
new file mode 100644
index 0000000..2799866
Binary files /dev/null and b/third_party/fonts/Dot Matrix Regular.ttf differ
diff --git a/third_party/fonts/LICENSE b/third_party/fonts/LICENSE
new file mode 100644
index 0000000..2f23061
--- /dev/null
+++ b/third_party/fonts/LICENSE
@@ -0,0 +1,3 @@
+SIL Open Font License - http://scripts.sil.org/OFL
+
+See https://github.com/DanielHartUK/Dot-Matrix-Typeface for more details.
\ No newline at end of file
diff --git a/third_party/fonts/README.md b/third_party/fonts/README.md
new file mode 100644
index 0000000..f224480
--- /dev/null
+++ b/third_party/fonts/README.md
@@ -0,0 +1,6 @@
+# Dot Matrix Typeface
+
+Fonts cloned from https://github.com/DanielHartUK/Dot-Matrix-Typeface
+
+A Dot Matrix style typeface, in regular and bold weights with bold tall and bold
+semi tall numbers.