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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

WiFi Settings

+
+ +
+

Realtime Trains API Settings

+
+ Visit https://api.rtt.io/ to register + and obtain an API login. +
+ +
+ +
+

Train Options

+
+ Visit wikipedia + to find the 3-letter station code. +
+

Display Options

+
+

Advanced

+
+ +
+
+ + + +

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