From 70149239b3a105b0a683d6a964e634df817cc0d8 Mon Sep 17 00:00:00 2001 From: mrsqr Date: Sun, 9 Aug 2026 22:21:43 +0100 Subject: [PATCH] Add smooth native-resolution typography --- FONT_LICENSE.txt | 93 ++++++++++++++++++++++++++ README.md | 124 +++++++++++++++++++++++++---------- User Guide.md | 2 +- configuration.py | 14 ++-- font_data.bin | Bin 0 -> 30336 bytes font_data.py | 14 ++++ font_data_12.bin | Bin 0 -> 1332 bytes font_data_20.bin | Bin 0 -> 3400 bytes font_data_30.bin | Bin 0 -> 7560 bytes font_data_44.bin | Bin 0 -> 15004 bytes font_data_54.bin | Bin 0 -> 21816 bytes font_data_74.bin | Bin 0 -> 40256 bytes font_data_84.bin | Bin 0 -> 50484 bytes font_renderer.py | 126 ++++++++++++++++++++++++++++++++++++ lcd_1inch28.py | 49 ++++++-------- main.py | 12 ++-- params.json | 2 +- settings.py | 2 +- tests/test_font_renderer.py | 119 ++++++++++++++++++++++++++++++++++ tools/generate_font.py | 125 +++++++++++++++++++++++++++++++++++ touch_drive.py | 63 +++++++++--------- 21 files changed, 637 insertions(+), 108 deletions(-) create mode 100644 FONT_LICENSE.txt create mode 100644 font_data.bin create mode 100644 font_data.py create mode 100644 font_data_12.bin create mode 100644 font_data_20.bin create mode 100644 font_data_30.bin create mode 100644 font_data_44.bin create mode 100644 font_data_54.bin create mode 100644 font_data_74.bin create mode 100644 font_data_84.bin create mode 100644 font_renderer.py create mode 100644 tests/test_font_renderer.py create mode 100644 tools/generate_font.py diff --git a/FONT_LICENSE.txt b/FONT_LICENSE.txt new file mode 100644 index 0000000..3344bfd --- /dev/null +++ b/FONT_LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/README.md b/README.md index b12f03b..e414b10 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ -# Track Session Timer - v3.2 +# Track Session Timer - v3.3 Trackday or race session timer. # Change log ## Version 3.0 -### v3.2 [current] +### v3.3 [current] +* Smooth proportional font rendering at native display resolution. +* Centered typography and improved layout across timer, configuration, and diagnostic screens. +* Hardware-verified framebuffer rendering, touchscreen startup, and QMI8658 initialization on the supported Waveshare board. +### v3.2 * User settings stored between reboots, [track session length, rest session length, launch mode] * Introduction of system params and user params files. ### v3.1 @@ -31,35 +35,90 @@ The timer is designed to support common session lengths, making it quick and eas The timer utilizes the Waveshare 1.28-inch round touch display, allowing for configuration and operation through intuitive swipe gestures. -## Hardware Requirements -* Raspberry Pico -* Waveshare 1.28-inch round touch display (https://www.waveshare.com/1.28inch-touch-lcd.htm) - -Following setup instructions for the display, connecting it to the required pins on the pico, these should be as follows although may vary depending on implementation. - -### Hardware Connections -* VCC -> 3.3V -* GND -> GND -* MISO -> 12 -* MOSI -> 11 -* SCLK -> 10 -* LCD_CS -> 9 -* LCD_DC -> 14 -* LCD_RST -> 8 -* LCD_BL -> 15 -* TP_SDA -> 6 -* TP_SCL -> 7 -* TP_INT -> 16 -* TP_RST -> 17 - -## Setup -The instructions enable the software on the required hardware. These instructions do not cover the physical installation or support for the hardware. -### Pico -* Ensure you rcomputer has the Python (.py) files and also the latest uf2 file (soure from Raspberry foundation) -* Press and hold the BOOTSEL button -* Connect the Pico to your computer via the appropriate USB cable, after connecting release the BOOTSEL button, the PICO should be mounted to your filesystem. -* Copy the .py files -* Copy the uf2 file, the Pico should restart, and the timer automatically starts. +## Display font + +The firmware includes a compact proportional bitmap font rendered directly at the display's native resolution. It replaces enlargement of MicroPython's 8x8 framebuffer font, so large countdown digits and labels retain smooth shapes instead of scaling into square pixels. + +`font_data.py` and its flash-backed `font_data*.bin` glyph assets are generated from Montserrat SemiBold. The assets contain pre-rasterized native UI sizes, allowing the Pico to use its fast framebuffer blitter without holding the complete font in RAM. To regenerate them, install Pillow and run: + +```sh +python tools/generate_font.py /path/to/Montserrat-SemiBold.otf font_data.py +``` + +The generated font data is distributed under the SIL Open Font License 1.1 in `FONT_LICENSE.txt`. + +## Supported hardware + +Version 3.3 supports the integrated [Waveshare RP2040-Touch-LCD-1.28](https://www.waveshare.com/product/rp2040-touch-lcd-1.28.htm). This board combines the RP2040, GC9A01A 240x240 LCD, CST816S touchscreen, and QMI8658 IMU used by the firmware. The standalone 1.28-inch Touch LCD connected to a separate Raspberry Pi Pico uses a different pin map and is not currently supported. + +### Onboard pin map + +These are fixed internal board connections; no external display wiring is required. + +| Function | RP2040 pin | +| --- | ---: | +| LCD SPI SCLK | GP10 | +| LCD SPI MOSI | GP11 | +| LCD CS | GP9 | +| LCD DC | GP8 | +| LCD reset | GP13 | +| LCD backlight | GP25 | +| Touch/IMU I2C SDA | GP6 | +| Touch/IMU I2C SCL | GP7 | +| Touch interrupt | GP21 | +| Touch reset | GP22 | +| Battery ADC | GP29 | + +## Installation + +The application runs on MicroPython. BOOT mode is used only to flash the MicroPython UF2; application files are transferred afterward through the MicroPython serial connection. + +### 1. Flash MicroPython + +1. Download a stable RP2040 UF2 from the [MicroPython Raspberry Pi Pico download page](https://micropython.org/download/RPI_PICO/) or the [Waveshare board wiki](https://www.waveshare.com/wiki/RP2040-Touch-LCD-1.28). +2. Connect the board with a USB data cable. +3. Hold **BOOT**, press and release **RESET**, then release **BOOT**. The `RPI-RP2` storage volume should appear. +4. Copy only the MicroPython `.uf2` file to `RPI-RP2`. The board restarts and exposes a serial device such as `/dev/ttyACM0` or a COM port. + +### 2. Install the transfer tool + +From a terminal on the computer: + +```sh +python -m pip install mpremote +mpremote connect auto exec "import os; print(os.uname())" +``` + +The second command should identify an RP2040 MicroPython board. + +### 3. Upload the application + +Run these commands from the repository root. Supporting files and font assets are copied first; `main.py` is installed last as the automatic entry point. + +```sh +mpremote connect auto fs cp configuration.py font_data.py font_renderer.py launch.py lcd_1inch28.py params.json qmi8658.py settings.py timing.py touch_drive.py font_data*.bin : +mpremote connect auto fs cp main.py : +mpremote connect auto reset +``` + +On a fresh installation, the firmware creates `user.json` with safe defaults. To start with the example preferences in this repository, copy it before `main.py`: + +```sh +mpremote connect auto fs cp user.json : +``` + +When upgrading an existing device, omit that command so its saved track duration, rest duration, and launch sensitivity are preserved. + +### 4. Verify first boot + +The display should show the v3.3 splash and then the green **Ready** screen. The serial console should report the loaded user parameters, `Success:Detected CST816T.`, and the touchscreen revision without a traceback. + +If first boot fails: + +* `OSError: Font bitmap is missing or truncated` means one or more `font_data*.bin` files were not copied. +* An import error generally means a `.py` support module was omitted; repeat the upload command and keep `main.py` last. +* No serial device after flashing usually indicates a charge-only USB cable, an incorrect UF2, or a board still in BOOT mode. +* A missing touchscreen or IMU error indicates unsupported hardware or a board-level connection problem. ## Configuration files @@ -78,5 +137,4 @@ Run the hardware-independent regression suite with: python -m unittest discover -s tests -v ``` -The suite uses fakes for time, touch gestures, display calls, filesystem operations, and accelerometer samples. It does not validate physical SPI/I2C wiring, LCD rendering, touchscreen recognition, QMI8658 readings, or real-world launch thresholds; those behaviors still require the selected target hardware. - +The suite uses fakes for time, touch gestures, display calls, filesystem operations, and accelerometer samples. Version 3.3 was additionally validated on the supported Waveshare board for boot, LCD/font rendering, CST816S touchscreen detection, QMI8658 initialization, saved settings, and launch behavior. diff --git a/User Guide.md b/User Guide.md index ac59f7e..6fa3100 100644 --- a/User Guide.md +++ b/User Guide.md @@ -1,4 +1,4 @@ -# User Guide - v3.2 +# User Guide - v3.3 ## General / Sessions Use The following describes general operation of both the ``Track Session`` and ``Rest in Pits Session`` timer. diff --git a/configuration.py b/configuration.py index f3582a3..d31fc9b 100644 --- a/configuration.py +++ b/configuration.py @@ -18,10 +18,10 @@ def set_sensitivity(LCD=None, Touch=None, sensitivity_values=None, sensitivity=0 def draw(): text_array = [ - [str(values[index]), 60, 80, 5, "white"], - ["Launch", 75, 150, 2, "black"], - ["Sensitivity", 20, 180, 2, "black"], - [operation, 75, 35, 2, "black"], + [str(values[index]), None, 80, 5, "white"], + ["Launch", None, 145, 2, "black"], + ["Sensitivity", None, 175, 2, "black"], + [operation, None, 35, 2, "black"], ] Touch.ControlScreen(LCD, text_array=text_array, back_colour=back_colour) @@ -47,9 +47,9 @@ def set_session(LCD=None, Touch=None, session=None, session_values=None, def draw(): text_array = [ - [str(values[index]), 70, 96, 5, "white"], - [session_name, 75, 195, 2, "black"], - [operation, 75, 35, 2, "black"], + [str(values[index]), None, 90, 5, "white"], + [session_name, None, 180, 2, "black"], + [operation, None, 35, 2, "black"], ] Touch.ControlScreen(LCD, text_array=text_array, back_colour=back_colour) diff --git a/font_data.bin b/font_data.bin new file mode 100644 index 0000000000000000000000000000000000000000..36cc8982f6a1a2b5062d604cfd883279347aafbd GIT binary patch literal 30336 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 literal 0 HcmV?d00001 diff --git a/font_data.py b/font_data.py new file mode 100644 index 0000000..86d1aca --- /dev/null +++ b/font_data.py @@ -0,0 +1,14 @@ +"""Generated TrackTimer Sans metadata. + +Derived from Montserrat SemiBold, Copyright 2024 The Montserrat.Git Project +Authors. Licensed under the SIL Open Font License 1.1; see FONT_LICENSE.txt. +Regenerate with ``tools/generate_font.py`` rather than editing this file. +""" + +FIRST_CODE_POINT = 32 +LAST_CODE_POINT = 126 +PIXEL_HEIGHTS = (12, 20, 30, 44, 54, 64, 74, 84) +WIDTHS = (b'\x03\x03\x05\x08\x07\n\x08\x03\x04\x04\x05\x07\x03\x04\x03\x05\x08\x04\x07\x07\x08\x07\x07\x07\x07\x07\x03\x03\x07\x07\x07\x07\x0c\t\t\x08\n\x08\x07\t\t\x04\x06\x08\x07\x0b\t\n\x08\n\x08\x07\x07\t\t\r\x08\x08\x08\x04\x05\x04\x07\x06\x07\x07\x08\x07\x08\x07\x05\x08\x08\x03\x05\x07\x03\x0c\x08\x07\x08\x08\x05\x06\x05\x08\x07\x0b\x07\x07\x06\x04\x04\x04\x07', b'\x05\x05\x08\x0e\x0c\x10\r\x04\x07\x07\x08\x0b\x05\x07\x05\t\r\x07\x0b\x0b\r\x0b\x0c\x0c\x0c\x0c\x05\x05\x0b\x0b\x0b\x0b\x14\x0f\x0f\x0e\x10\r\x0c\x0f\x0f\x06\n\x0e\x0b\x12\x0f\x10\x0e\x10\x0e\x0c\x0b\x0f\x0e\x16\r\r\r\x07\t\x07\x0b\n\x0b\x0c\r\x0b\r\x0c\x08\r\r\x06\x08\x0c\x06\x14\r\x0c\r\r\x08\n\x08\r\x0b\x12\x0b\x0b\n\x07\x06\x07\x0b', b'\x08\x08\x0c\x14\x12\x19\x14\x07\n\n\x0c\x11\x07\x0b\x07\r\x13\x0b\x11\x11\x14\x11\x12\x12\x13\x12\x07\x07\x11\x11\x11\x11\x1e\x16\x16\x14\x18\x13\x12\x16\x17\t\x10\x15\x11\x1b\x17\x18\x15\x18\x15\x12\x11\x17\x15!\x14\x14\x13\n\r\n\x11\x0e\x11\x12\x14\x11\x14\x12\x0c\x14\x14\x08\x0b\x12\x08\x1e\x14\x12\x14\x14\x0c\x0f\x0c\x14\x11\x1a\x11\x11\x0f\x0b\t\x0b\x11', b'\x0c\x0c\x11\x1e\x1b$\x1e\n\x0f\x0f\x11\x19\n\x10\n\x13\x1c\x10\x18\x18\x1d\x19\x1b\x1a\x1b\x1b\n\n\x19\x19\x19\x18, \x1e#\x1c\x1b!"\x0e\x17\x1f\x19("#\x1e#\x1f\x1b\x19!\x1f0\x1d\x1d\x1c\x0f\x13\x0f\x19\x15\x19\x1a\x1d\x18\x1d\x1a\x11\x1d\x1d\x0c\x11\x1b\x0c,\x1d\x1b\x1d\x1d\x12\x16\x12\x1d\x19\'\x18\x19\x17\x10\r\x10\x19', b'\x0e\x0e\x15%!,$\x0c\x12\x12\x15\x1e\r\x14\r\x17#\x14\x1e\x1e#\x1e! "!\r\r\x1e\x1e\x1e\x1e5((%+#!(*\x11\x1c&\x1f1*+%+&!\x1f)&;$#"\x12\x17\x12\x1e\x1a\x1f #\x1e# \x15$#\x0f\x14!\x0f7#!##\x16\x1b\x16#\x1e0\x1e\x1f\x1c\x13\x10\x13\x1e', b'\x11\x11\x19,\'5+\x0e\x16\x16\x19$\x0f\x17\x0f\x1b)\x17##*$\'%(\'\x0f\x0f$$$#?//,3)\'01\x14"-%:13,3-\'%0.F**)\x16\x1b\x16$\x1e%%*#*&\x19**\x12\x18\'\x12A*\'**\x1a \x1a*$8#%!\x17\x13\x17$', b"\x14\x14\x1d3-=2\x10\x19\x19\x1d*\x12\x1b\x12 0\x1b))0*-+.-\x12\x12***)I663;0-79\x17'4*C9;3;4-*85Q10/\x19 \x19*#*+0)0,\x1d10\x15\x1c-\x15K0-00\x1e%\x1e0*A)*&\x1a\x16\x1a*", b'\x16\x16!93E9\x12\x1c\x1c!/\x14\x1f\x14$6\x1f//7/3143\x14\x14////S>>9C64>A\x1a,;0MAC:C;30?<[875\x1c$\x1c/(017/72 87\x17 4\x17U7477"*"7/J/0+\x1e\x19\x1e/') +OFFSETS = (b'\x00\x00\x00\x0c\x00\x18\x00$\x000\x00<\x00T\x00`\x00l\x00x\x00\x84\x00\x90\x00\x9c\x00\xa8\x00\xb4\x00\xc0\x00\xcc\x00\xd8\x00\xe4\x00\xf0\x00\xfc\x01\x08\x01\x14\x01 \x01,\x018\x01D\x01P\x01\\\x01h\x01t\x01\x80\x01\x8c\x01\xa4\x01\xbc\x01\xd4\x01\xe0\x01\xf8\x02\x04\x02\x10\x02(\x02@\x02L\x02X\x02d\x02p\x02\x88\x02\xa0\x02\xb8\x02\xc4\x02\xdc\x02\xe8\x02\xf4\x03\x00\x03\x18\x030\x03H\x03T\x03`\x03l\x03x\x03\x84\x03\x90\x03\x9c\x03\xa8\x03\xb4\x03\xc0\x03\xcc\x03\xd8\x03\xe4\x03\xf0\x03\xfc\x04\x08\x04\x14\x04 \x04,\x048\x04D\x04\\\x04h\x04t\x04\x80\x04\x8c\x04\x98\x04\xa4\x04\xb0\x04\xbc\x04\xc8\x04\xe0\x04\xec\x04\xf8\x05\x04\x05\x10\x05\x1c\x05(', b'\x00\x00\x00\x14\x00(\x00<\x00d\x00\x8c\x00\xb4\x00\xdc\x00\xf0\x01\x04\x01\x18\x01,\x01T\x01h\x01|\x01\x90\x01\xb8\x01\xe0\x01\xf4\x02\x1c\x02D\x02l\x02\x94\x02\xbc\x02\xe4\x03\x0c\x034\x03H\x03\\\x03\x84\x03\xac\x03\xd4\x03\xfc\x048\x04`\x04\x88\x04\xb0\x04\xd8\x05\x00\x05(\x05P\x05x\x05\x8c\x05\xb4\x05\xdc\x06\x04\x06@\x06h\x06\x90\x06\xb8\x06\xe0\x07\x08\x070\x07X\x07\x80\x07\xa8\x07\xe4\x08\x0c\x084\x08\\\x08p\x08\x98\x08\xac\x08\xd4\x08\xfc\t$\tL\tt\t\x9c\t\xc4\t\xec\n\x00\n(\nP\nd\nx\n\xa0\n\xb4\n\xf0\x0b\x18\x0b@\x0bh\x0b\x90\x0b\xa4\x0b\xcc\x0b\xe0\x0c\x08\x0c0\x0cl\x0c\x94\x0c\xbc\x0c\xe4\x0c\xf8\r\x0c\r ', b'\x00\x00\x00\x1e\x00<\x00x\x00\xd2\x01,\x01\xa4\x01\xfe\x02\x1c\x02X\x02\x94\x02\xd0\x03*\x03H\x03\x84\x03\xa2\x03\xde\x048\x04t\x04\xce\x05(\x05\x82\x05\xdc\x066\x06\x90\x06\xea\x07D\x07b\x07\x80\x07\xda\x084\x08\x8e\x08\xe8\t`\t\xba\n\x14\nn\n\xc8\x0b"\x0b|\x0b\xd6\x0c0\x0cl\x0c\xa8\r\x02\r\\\r\xd4\x0e.\x0e\x88\x0e\xe2\x0f<\x0f\x96\x0f\xf0\x10J\x10\xa4\x10\xfe\x11\x94\x11\xee\x12H\x12\xa2\x12\xde\x13\x1a\x13V\x13\xb0\x13\xec\x14F\x14\xa0\x14\xfa\x15T\x15\xae\x16\x08\x16D\x16\x9e\x16\xf8\x17\x16\x17R\x17\xac\x17\xca\x18B\x18\x9c\x18\xf6\x19P\x19\xaa\x19\xe6\x1a"\x1a^\x1a\xb8\x1b\x12\x1b\x8a\x1b\xe4\x1c>\x1cz\x1c\xb6\x1c\xf2\x1d.', b'\x00\x00\x00X\x00\xb0\x014\x01\xe4\x02\x94\x03p\x04 \x04x\x04\xd0\x05(\x05\xac\x06\\\x06\xb4\x07\x0c\x07d\x07\xe8\x08\x98\x08\xf0\tt\t\xf8\n\xa8\x0bX\x0c\x08\x0c\xb8\rh\x0e\x18\x0ep\x0e\xc8\x0fx\x10(\x10\xd8\x11\\\x12d\x13\x14\x13\xc4\x14t\x15P\x16\x00\x16\xb0\x17\x8c\x18h\x18\xc0\x19D\x19\xf4\x1a\xa4\x1b\x80\x1c\\\x1d8\x1d\xe8\x1e\xc4\x1ft $ \xd4!\xb0"`#h$\x18$\xc8%x%\xd0&T&\xac\'\\\'\xe0(\x90)@)\xf0*t+$+\xd4,X-\x08-\xb8.\x10.\x94/D/\x9c0\xa41T2\x042\xb43d3\xe84l4\xf05\xa06P7,7\xb08`8\xe49<9\x949\xec', b'\x00\x00\x00l\x00\xd8\x01z\x02\x88\x03\x96\x04\xda\x05\xe8\x06T\x06\xf6\x07\x98\x08:\t\x12\t~\n \n\x8c\x0b.\x0c<\x0c\xde\r\xb6\x0e\x8e\x0f\x9c\x10t\x11\x82\x12Z\x13h\x14v\x14\xe2\x15N\x16&\x16\xfe\x17\xd6\x18\xae\x1a(\x1b6\x1cD\x1dR\x1e\x96\x1f\xa4 \xb2!\xc0#\x04#\xa6$~%\x8c&d\'\xde)"*f+t,\xb8-\xc6.\xd4/\xac0\xf01\xfe3\xae4\xbc5\xca6\xd87z8\x1c8\xbe9\x96:n;F<\x1e=,>\x04?\x12?\xea@\x8cA\x9aB\xa8C\x14C\xb6D\xc4E0F\xaaG\xb8H\xc6I\xd4J\xe2K\x84L\\L\xfeN\x0cN\xe4P(Q\x00Q\xd8R\xb0SRS\xbeT`', b'\x00\x00\x00\xc0\x01\x80\x02\x80\x04\x00\x05@\x07\x00\x08\x80\t\x00\t\xc0\n\x80\x0b\x80\x0c\xc0\r@\x0e\x00\x0e\x80\x0f\x80\x11\x00\x11\xc0\x13\x00\x14@\x15\xc0\x17\x00\x18@\x19\x80\x1a\xc0\x1c\x00\x1c\x80\x1d\x00\x1e@\x1f\x80 \xc0"\x00$\x00%\x80\'\x00(\x80*@+\xc0-\x00.\x800@1\x002@3\xc05\x007\x008\xc0:\x80<\x00=\xc0?@@\x80A\xc0C@D\xc0G\x00H\x80J\x00K\x80L@M@N\x00O@P@Q\x80R\xc0T@U\x80W\x00X@Y@Z\xc0\\@]\x00]\xc0_\x00_\xc0b\x00c\x80d\xc0f@g\xc0h\xc0i\xc0j\xc0l@m\x80o@p\x80q\xc0s\x00s\xc0t\x80u@', b'\x00\x00\x00\xde\x01\xbc\x02\xe4\x04\xea\x06\xa6\x08\xf6\n\xfc\x0b\x90\x0c\xb8\r\xe0\x0f\x08\x10\xc4\x11\xa2\x12\xca\x13\xa8\x14\xd0\x16\x8c\x17\xb4\x19p\x1b,\x1c\xe8\x1e\xa4 `"\x1c#\xd8%\x94&r\'P)\x0c*\xc8,\x84.@1$3*50769\x86;B<\xfe?\x04ATB2C\xa4E\xaaGfJ\x00LPN\xa0P\xa6R\xf6T\xfcV\xb8XtZz\\\x80_\xaea\xb4cpe,fTg|h\xa4j`k\xd2m\x8eoJq\x06r\xc2t~v:wbyh{$|\x02}*~\xe6\x7f\xc4\x82\xa8\x84d\x86 \x87\xdc\x89\x98\x8a\xc0\x8c2\x8dZ\x8f\x16\x90\xd2\x93l\x95(\x96\xe4\x98V\x99~\x9a\\\x9b\x84', b'\x00\x00\x00\xfc\x01\xf8\x03\x9c\x06<\x08\x88\x0b|\x0e\x1c\x0f\x18\x10h\x11\xb8\x13\\\x15T\x16P\x17\xa0\x18\x9c\x1a@\x1c\x8c\x1d\xdc\x1f\xd4!\xcc$\x18&\x10(\\*\xa8,\xf4/@0<18305(7 9\x18<\xb4?TA\xf4D\x94G\x88I\xd4L N\xc0Q\xb4S\x04T\xfcW\x9cY\x94\\\xdc_\xd0b\xc4edhXj\xf8mDo5Lp6un9rMS=tu5@e_my67^BlpwM5Y=>DD+hqm8H=UA##5&?8(C3>V{sMnN ze?ou4n@)+FA{E@Y%gXgEXAx#R_a&Lub2BgZo}72@`y8U15DdZtTu1tPv`fvZmXaUt(uSijf5Cc(I@BjYW11jq zhgO-i;7WtK;(%V&tgL}hrXFKv*CbB%QJ^z)%%{z-3r{7>t5R? z*15qrW1d%?SO0E9(+}^+JqkXYy*v$zu<%UvAwG<}Xf2*_G>O+DUJ1DAqJ>i?7bEyX z*hg!qutn4)Z)_Wp|Iqkn7D>3w`!d9-FYe!_=fsbx-AV3VO)% zBHD_3y||v&*ZdHFHOr;>=Kqwh#d~v-J%w2{Q5T9ZxvGt8H)CDxt2Ho=VkK)m2S&luSoY>8UXt;!5ZcJt>5% zhpPUxNI2o9ZfY45?y)B%oO9)_FRtmD?kMH4J8>t)m8)ECoI;z?YTYYbDP?fR&?{@>&>Mzn`)E)IKUM`gE_>>EYVtd(po38 z2~lB`h(Z{ZG598V*<^PwlknqiHYMSTEB4Zpqp0>F9H{~mZf}o*fs^Nv1=G0o*pwzT z11F?OfejVpz&(8eKjuz5AFIL-M!Vd_w#Q(s>vm!O)<2(6WgC0RBVilaG1ik&Nfjc} zWBk})mHvH9v5lC(_uSYjgppV-T3|72HeA(JEm&|sIp%4%FlPw@V%D{Dk-Nik2vNPXdQ|UfO4EZlAMuc%s!_o!qTuD2YEp zeVD0;^q8_(u2o83p3<}Dv|Q5iJuTjo`jynTf;l9W=p;*4NuE4QmOm{1R5iGSEy)nu z`7bk5;o?N@-AC$kqrc&@ttYVBeSJ<9;;o@R-=oZh#Mk#I z?ERuZ6$ontk<=Vt`S8UDj*=~#jk ztW?vULtKSki^}LCMipr5t;TL`tNs?aS_Mh2S1Ttjg?}X%H9DL0H>pZ;1{f3a-uAZF z*f&F}>#bUPf-k0Y3SQU+y4dc`$4Ct~+b=Z!{y*LOK%I1lFVM4c)MFnYa18u48-?d|>Fh$KW8?-<$2(#1#xNg$wLvNEl z4vl;I(p;;@$KI>^-={|YA%DO>I-7#EA#;Yb)ul?)EkcB!IR`KD8fWY0_LS0guN}j6=wJBq-r{=u-Y(zL65VJ;^SNA8 zg#81w<~6i?-6=h{Yrf}WP;biuww$)M#H}@7(*uQNtAbzT)GX&dIW@|Br*A&@Ighd_ il+AHI3G>S5mc!XZXqL5u*|?n!FJ;*AZ|we4qW=KzSh4;9 literal 0 HcmV?d00001 diff --git a/font_data_30.bin b/font_data_30.bin new file mode 100644 index 0000000000000000000000000000000000000000..2a65fee13618a82f181a70b812422c1b1871f2f7 GIT binary patch literal 7560 zcmcIpO^)2S75+2|%uP`AvKNZjN9bi4kfq!pHwe6%ETh3}G)SdICqQ}^Um$QU5(FF= zhKwfv}yl}gD zxA**8euFhA+FOrTw+^qqvuHm7?R((G2cY>& z2VD@`i+=)N-lX!~4YGG!w3OHXfXY^wfny$!@&GQ1-FG{qo1$S@m@e#1|_ROGZ_w$aJ*9~1{N`QyYeCBe=mb+qQUlIpp2P$%UGc-(bt)FuWJk?iaP0fM$t)iA9a85|oBD)U-6ffGmLf zr5v<{0oY6pO>iI1)ZVXTsE93JB40&Z#Ha;+Y0%XeR~X5nW8B;M7KxnT%!trM z3g19$lp2*Lg}xs{1fM>!u6GdazsT-HAo?jZkxR`k&6j#jGr4z`W||nqeHvl@AUsUS z$(66jd@^dpHEo3wNPLeKr5W7tXlq z=ZYDjw{#cC$AP$6jNc`ogLwHRG6G|d56C|ve*<-62|9NKLuVbK8AF9YgH8yducs1a z-i$K0%zc^p5`lG*5fvM4L>LM-3UskIn32E%_7q-7fRv9;bL2a3;K+WG!gcHc8wn+x z`J?AxH9Co`P9`I&P4axHNYzk^E>;n0h6EA#kXqO^lzAeUQ%>R~+oKAz=B5-Ah+JZ? z*w|@1pCgy-S#BiE&1e%Jxa2J~G0v2Fb=SD<kpYSj4_)JLK3G`*5aq()%c&fIU!h1b+cV z6=8(H!r+Rzfw3&EkL4Hs-zfYi6#ruR-TPFU8x%&RGUM;hS%^oaW)|j|gd>WEdwJ-`MC8QMlIA3AVt(&H zO=zaXCb6Udi&*;8G%LXz&#*IbeaZ0BKG4rn)Jc(8T4QTT6R{jKCV zJXTB1NMzVnfhU-3TWvpf_JPz?xvP~exjG)O-iiS7{F0>*XozQ-CDdE!zmg=<#{k}u za!x#rJdllMEzg^?xCsN#gq&|tdA6)`DvoR<#F%G2cMuh1IkPdV)xRxOC zZpuBFjbpZQ&V5KOX-{Q5*GMxacM$0oLhd2tjw0Ps$UTLerZY#HB&WgMl!O)ISGk>e zST5t}>nVbrXR26=QI&_HxU`tf1qv>zz`VqqQT_iljY|f(`-yC)wcw$Z^!(I`Hmc## zX@!eqtjYXwZP4VvC1)OHR*+^=8JIU#^$SY2tbpyX)mE z7t1aV;4op7vKKtUGbEyf?A|+_+;% z{rOkC2V04U-lRjCd8<|#iH8^`RRdn~XwR!i)&pv`FjS(e;edGEu|c!*yGLz$-5GK6 z<>j)m%}O?8^o_ptwffpu%7%xOlZcOZPBipsdvqKvTes3)8!Tu;cndTEssP(5Lez*e z8hY@B3C(A$N_Fg@cYyD%VPE&B>Tsz0yLJP)y+gEldg&X<^q-WRgS;7a{MYaV)(_qD zjuPAZW+101fmc<(><0?v(Ud$duSuQceLqZ}>$O+@=+wi;`&3ohX&IC=PqlSj97W`ZFgJAdX8P5JrEP~>@%1@q4|Ip OE3`{o&C}P;*Z%_|xI-lX literal 0 HcmV?d00001 diff --git a/font_data_44.bin b/font_data_44.bin new file mode 100644 index 0000000000000000000000000000000000000000..b4bcbd340c3369d3a5f60f80cfb4caf08b5d3148 GIT binary patch literal 15004 zcmd6u&5qo-5yvSgh%O7u$2}NL=M9pZ4-LtWl1JF%1n9Iln8Vz59>H%C(=iYq{Rm6L z2OljX2g^urE5AP}X?l8kJpD1_gkn|egDe({#i}BuHsBIRHRW(5PD-QFb*%Hp zJ(#O1vz_Or+Q`qI?Lp;1iky|k+yKk<%$Rm&>b~zbLH82OO%`rK)AwN$@~|+uKidJ_ z{%k*?=qA9b={uA6Yg6>SDFNFrjs5kBsVR2@el^Cff!` zYx6b_c_>2R3t#GR<8f^}#yk8{LM1t|VbF(_jR!z}i-!b!bAIjdT$e~Gf=&)EYMKJ2HdKbqGR>X^#k$h6I3cCk{q!rd- zbpv;UZbm0Ap2Sar zr||fK`^|+cx}c*^Ep7Q!oRWtp2LrT8Oq2mJ_FBzMiDX6*i(iR30twZ;)2-J3W)ZQ$1d9_T2-{Y>+|(FNsmKvvg01AbiEj@hF9+R^;J<#tqkkXt`P zWd=m~F+F5=bIDJ0#g6jhca%SJm3$><$=e2i`l9ntY4Gy_8{XjOFS`YPUiJz$z>|l^ z!iT}b*U>m(pB5WTyofjH5IH?Mk;@nq(RKRH{wLLHVY_RV5z7 z_3y8Y&HK++W>)n5rJI%i0avAOF5Mh6b>ZjM*bQ99^V@hV<0@*2jwpy`Z*0V6-haYQ zy8f+Jq%(tkg*`?TuF??l)4Lg1j!L}{R=`JUg76)w2t$kkDLKJ7_+6ZjEY%UPBwR5@ zK*w-&7I9dYwWZ73(&vFyVBqK#?L^S}wndDwBsEq7sEE=MhPXUFr8pznT_4-?HJlWQ zo51?q?DAJ43LiA>iF022#Z#2c*nS^IbJ z7EEAnzKk}`C80<#BOvYRe7sjvZnTN2U$WvZ)@3J}&exBo42)Z#GLh)=wj!R|%$b*;%aU}*?7qKDv zF`9&fz;r=@PM;3qFD6+F4f9b*GGJJ4qZ5dZj2-+N_t*R}c%PS_CA&yylN zEy9Z;EUjlgHH6z}Gke9+-R;A`R0cc0!b^1+F1%G4Sf$bH+F@x->1BHuk2mPiTP)nW zV9NL2l&=7|E~C|xK?1%}j_qpg%f$LlMozRuQ?%6vK1IrLLb@@how+&3AL80WbIT5% z!0u2LuH|+aAo?Gj8xkRyZ<+3;kZswU1u>lm#In9E|6zV_%h%>xTfRC6U7ddcy#-yq z{Q&y(rY$de(0PA8AlGL!v=&+l&4peFB@TwO)4McLfHFfL=X}svM6J#`p_R~jLcPvj zJJ2f+x(Y%q=u+sS1D&rxZY|VIsMeVkf+9i4gya>`VUQ@h8-eh|<54Fssio*-wADd| zQ@fC;J0S|K1zbE;RSkuecP;4@B zpvdJxY;~Hq^4(aCr0X8xh8PdVxKOd-E+{BNx#YuLsxLZdu~R4S23S8Oy`=JwAB*Ci zv5<0@CUoXSt* zp|JpbJ%DWRcoZSkJ2Ci0`ER_BZKeIZkEN?Oz#$JUmynTs1@H5iK3w3bl6V@G7=ZV& zoIeBDyTY0E6k}=;3l!)UTT15G#2V3U154C#3-xh+k7ahM$4u%pogNFJNNC82ia_8{ zw5;Xxl?}2x(bRdC%u?oBYRJ9!1!&}?qGU=vjbZ~_=xF? zU1{c43-t>eicl?umz8?Bs0wEBq89F~W+s?kcJ|6;^7jcF1pvP>5J4M%V8)_J*z2u8 zh55HUXl0r*btN zlH#$)Lq#04x{GLkfGCY3e4`Q^nd z3Akn(uzshwD8+fjdKqWJpz{dKDdb>c+1Vq2cIIaOA4bd|Fd(R|)oYZ4m z5Crl-H+cse7$dhtR(daLBj7VweJ0R30_AA}>wc!rBCa_K>gyeKdVh8xyZY3?F++;p z6dxtr$p@h6Qy%W~^pJcYEB6-PHO7qFIELf+E;q({d=|$SozQwsI+Dis%ArpK#Fdm| z-$Qc_SjTahzDeC;?%p{JRU7BA-medHdBt1~(nt#^@;VN-SozLn`c|6OoIdAGNmu(G zW1U^U#FGSjV^&nKnYet2j2j|1aexxmI=;t#F@w$WoF z9pf|t*L^VdE6+bt?hT+iPIDCfaJ3sG=(kw;?xm&X@yB;bKfpt4TT6fI4r#Ocmrd$> zY3{fF8}ose&h%UsHTB{)KA~@;WFDX!dy@Fy&&@p<9g7j(>RTe&`uN?_7e>bOl?}S6 z5yAFF!1kfx>0I~pER7hLZ=F>4+Ft~7kQ*L(+2dF7#$&`Ve2zemVEYcZwsU(hxmjeF zZ+VdT0goxKc_$QZla243vpa#6!brue4Hp~G0f}PI{2k(ZKH@VScAN>MYnHUagM$4WTso@U)Vr!XJYghqAUwxigi;jNeh?>yzLM|WNZqxeRjR62c8 zH{YYx{fppT-Eok-L40S|Dg;YFY~qML-DAa`dRMbx;^PU4DO1F$^$tNgpr^#7o)<0n zz2XBS`4RYcF!|Sz*2cKuxK>7zaeuf$R)QaqIu>j51!%G0jm=^S;+yP`QrVh(8VXA&qTebL*8eE!0U+k!wi(xS{FuyZa+fe!`Rxa zEW4E#ZTU77pF;lGN5w>{apgqgQ&`TD=3U?oFokPv!m|JR&}^}aa7h}_nxWNzvG6iZMY)H zo?5JqljhL?fhccBc)}jd^l;;|`F$Fy%sN}=I^w^LZvO*_JyMUq^>xr*)%Npv-Ku`n zqUoP&NAKFXoi(SPEa-aU8sB*K>vtb*vC27$75wffq4!hNcMaqJ%S88-lOoJ``8+dr ZHOC#SOodyRmmU9*W6+I1h4ihu{{`8xilqPm literal 0 HcmV?d00001 diff --git a/font_data_54.bin b/font_data_54.bin new file mode 100644 index 0000000000000000000000000000000000000000..fb66eea1ab2926ae60cedd1e0958b118bcd82017 GIT binary patch literal 21816 zcmd^{yN(<=vW8iki^DYzkTTa8i2Dd@jxlKNqwFILaGWuON)BYKxsRYnSq@~3IgcTyU=h({XsI8y7&RwHCp6<;Y#EM(2JIyxK!o zIK2)71oAO(lh1AstC7=7kT~tr<7BadDJQ0mLfCL)sMK5QL_s^XYoB#mfnMP2Og**2c-8p;749I;&hC8F?f2B#y%UrL{-mSt1fhF3Q{{)* zMX_NJ#0y**laA^jceF7bHM&&>9u-y-Vt0Nc?5k=DHF=PMk==#Qbjc504#E;pgRWc{ zqEj3TP)6RV_)2T;_mrI0aA)aSwq9>qDDu(FfbS=*Er;kcto~Dsj9^-Cs1C< zZYe`w#P1!Kmco^h+BJ4*U{cau2T;vuH zyqNyBJb(T{bcEYmFCkvZ_qK%gm#gWv=XdvRt@tT#_Vj7UvFJ}n*np%zjkA~hog-jW zuEcZH@+~wajkuRay2)o_ATm}h?nc*c?D_40Y~xd7)uM*3MM}4L7ma0r+n~Fh#y2$9 zCa+j2DOZIVqR78s;G4fPbK6fHGu?>2#{l3)7-R-MW?%tK!618J5EDmNV9+)0?n>;- z8a<NPe}+(FNq{D-D($ zP8r0f$=^DFK|r8M)+WvPi#m%_SOdhnwQebpIHi4*!nUkt&F=y~2}YnVKoB5Eljd<~ zqA+>f0jlk!0k1G0OP6#uBY>-k4%He*y%XpuQdXjyN|TgCM$50HKQFx;mtGzy8?F3? zQFQuV?!=NU#-bKu166ESeRS8x{n!=>*1rv4zjd;g!0G17Zey0xOTS@d8HP6KtA0f31U1~{IP^!0?r9#B})IM6es zqSPdywnwD|cS^45`!en|M3iK&Wca-j4Urg)L}_HUBo4_y>69U8+z!ajR^UjM0cb1w zIZW`QX|lj-$ObqDG>x9psok(g{DfJhm`$-oq_U>E9jTsT1(mU~G)ikNO`V5OB`_W- zXhRsypSGz1wKK}bpK}W+gU?pvbdnrG10Wy0C1InA}pO|Em61_I4>$WiK zXaOEUt22$p@ucNwkEq~v2)g>e(666>ARRD8Xo_n-fONr>!L$K+IIs1rv0)5BkD^e5 z9_(s@J9+qn!%tqk#hCG(Z>ZzGUgd2JKBTTKPv`LGwe#gkk%YiKNjAm0jJb%zpgu$7 zsIl!TY3%f9N2h)mI0<$_`GFKjw#7vL{6YGI=9H`HHqNd-tMl8Qf69B`QCUb$%GNLR zkGf3##WbVAkr~Qdh$I=+L54{KDXXtm*cBZH4W^026Tvwrx@QmQOY?gx^_$O|2Kt8; z#p3sh8}YnNA;TRhCfbo;9@RqMG=F%Q-_9FyHT2lA;i_v!rZkaQgY_)wrp2H;Nnm3{ zbOy6lm~qWGzRHTZurV(q7V9Nirdd!8!`Gl~OgCl|*V?wpY*hNv&+EW`W~^jAQrl=r z4^8ZqNb^=1DJHZPuua?524Zd_K8;uMGX!`^O`sfUCB(TPH((kss6`Oia^o1- z-DoAOtUE%=sA$n*E0<1dm}AfuH@X`~abQq(42iN-{(M2YWN%!8#$J?kV56XGbwo9y zJT%@}Fy;~-)@tW}II|lEKsQqUM#$WirCFeGBsJOyz3R9PhDj*i>0m=#RgM`J!fL4XfX21LlsYoZ{3lJZpEXZ?vB}mzHZ_rGuF+3Sjn*;Gr zsLf_c1LMGWF|NXnNs634AO@;`rbIU*qD$%YEj*yerw&?k`O>o1Y_;CHWS3blIc_^R+XieWqp)^hFbQqbYD69Q2b|QBhmTyQ~$nYEJ^&kSt}D?=J;CKCU(k( zp=E2!qFy0*HriS?S=P4i?hI0{Y z>0Ra7Dpz~Y6asT3axEI{n1N-GX&)L+1AB$CK0li2SRheAmSh19g`kCscCKk4hfrZt zZLuBPw->gy3GR3S#eLO@T_YUAcRCH)l0=~FbAk)fH1tjS zcb4~!qI(e5s4Q>R8}ObzA;p+z2~65Tzb`P|7x?Ghq=0sY1TdUYWI0C5`&cxbmpuX^ zt=Mqvs$k$DmQX$l+j2hI zDRQ}$ZxH-y?k#tGYrXabLGwn0%G@Tje1J#2Yf}D3IlL;5|8n6?YI#+TQ=}YY$=BJ} zbF7-Z4!N4s@8~j}wB#HwXLQbXg)6i}R+ib**79t;T~oQ;H2L#Ba(_^$-0K6E(_YJ*MJTRo;-vbfee2FMK|BtR`Ut8-%kd-hm?y&g$Gtp-&i zuF^5!-U$N6^HO92Do-0PGeDD&1aWN<4UHBG_kz!`eB1Y|wn5AuZ)ris+%zD@bqCgiJ^)zHn9QluEFq zpH?aHXGeGd5c|&y4BUK}9x4G@N_j+>V$2H(?Un93U)+C0=xAltMudD0`5!frXTInx z-V{?akdme&QmlAO_5y#@+KuP=(m^kz4pWb}8pbfT`+CmTFO>U0`4dQ0>=;iEfESc_ zUz6)U)}ofOM9{(y&=-oh8T~9LVPbk=UNC2o-^dka?E8R~u(C6o^o?GlY~)qjZ>sk# z9GK+=pEP7N8xa8I^(#$IwixIjbHKVo4l;eve5IUkKx~!j) z{i2b%;uJxH(njVzVWsEd1k{bbb3>_N&2~oA!AJmpwb6;|Kzr?5-1~!-Xk|sk=+XLx zyj4WKYjFTPhdoa8GRADEYI}mC41cAc=;fltV>gAg;(H_|H%Bz|T7rZ$z@3Yj4>1-<8n-w#u+8=xVR_h!^%E~duwDyMBX1S)g(vj} zzRD+{hr0pv2YJmtX;H+$S6RI_ZHBOjzigUgR$j%9htt|#8DQ};!&R>(BDNPO=Yr#H zy7yH)uA+I(##7!G({{|m|Mul?Ji4GuP@-+*m@8KT7taw^%2=1AM^1^&cky)dlQG*D%Bj%z9W` zXYxZJ5X+XHQMNj)HUq0%GJb4$xCY!@;<3Cuy8H`=XDE*;(itCdvdvvb4&uhu$9-0HB} z%xP`^hXXg`xx>x6p#k^=>l9lb*-A?0+5m-&tI5BNi9M#R_Xyd~kd)3lA#r zUp&nWOs8l*kUL4D2ly>dOniL*8@Ic6I}KNPi{;N<=1? zf%}8#G5dH;_k%l8xW1sk<{ZZB58QEO`imAaS|Qn|LIb@;d!90|ZH2C!28u{EF#kxP zU#9X+BA}J@evmLO85&w~Z%4{gwt8C^wa=`zSah+vWm}jxp(DGZ+MOy&_*z-X&g_of zmD-b(z`D$9C_rVyT!g-9eQGI^M|3YJm6qK9pFmF(w_dBag#2i05YABy3wedfh%$^b z$2s!yk~ftN`I4|N>$NYw%nf-}>Bkx5?PvU_ke8IJuj7KhCp28I;A&}zLU1e`azkcY z25>{$Tzzh74d{V3^7!p6dLaK)`;{!pCw5BcbNk(MRe#gR>g!lqKP%TY=>j^j;`hrP z&)e_c#{r5l1?aKc$&r2u?|Ja)0_ISQef_wMq`p!`#PU2RXKm7Yd7 z+(v25N{=+qgHb(43N*tKZAY0b`9iLgxtHDdR61a~hl*&_`6^1WQ|x=m_FqlZ;$XJD z`;%@-)W#b;!4H!TOeA{@Yem1ZK_z~c#{!Q*Gha>a5dl$9>yTZ|D@BB=dUx;0ZaIK8 zIAe$^7DMJyi?@{8-=Y@jt*TaphmbF|gkC@E-=)fQvNrL<$WRm+Ms3F2y*j+(fT6`` zlr{-DmNd4n9d9{kwV(+M)K;~4xW?W|F8m#xnpYUR6~?b4y)6Nq3clu-T}>}hftPL5 L?IhLRMCAVfq0rmX literal 0 HcmV?d00001 diff --git a/font_data_74.bin b/font_data_74.bin new file mode 100644 index 0000000000000000000000000000000000000000..234b6b6df4fe4bf5811686430be8e6a537175bd0 GIT binary patch literal 40256 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 G+W!Nv4PTc4 literal 0 HcmV?d00001 diff --git a/font_data_84.bin b/font_data_84.bin new file mode 100644 index 0000000000000000000000000000000000000000..48cc2068f581e4ea641c4f853379ea4b30c25142 GIT binary patch literal 50484 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 PNz7pKekta8YLNdwmdX#c literal 0 HcmV?d00001 diff --git a/font_renderer.py b/font_renderer.py new file mode 100644 index 0000000..1c283c7 --- /dev/null +++ b/font_renderer.py @@ -0,0 +1,126 @@ +"""Memory-efficient proportional font rendering for MicroPython framebuffers.""" + +from font_data import ( + BITMAP_FILES, + FIRST_CODE_POINT, + LAST_CODE_POINT, + OFFSETS, + PIXEL_HEIGHTS, + WIDTHS, +) + +try: + import framebuf +except ImportError: # Host-side tests use a small scanline surface. + framebuf = None + + +FALLBACK_INDEX = ord("?") - FIRST_CODE_POINT + + +def _size_index(size): + if ( + not isinstance(size, int) + or isinstance(size, bool) + or size < 1 + or size > len(PIXEL_HEIGHTS) + ): + raise ValueError("Font size must be between 1 and {}".format(len(PIXEL_HEIGHTS))) + return size - 1 + + +def pixel_height(size): + """Return the native pixel height represented by a UI font size.""" + return PIXEL_HEIGHTS[_size_index(size)] + + +def _glyph_index(character): + code_point = ord(character) + if code_point < FIRST_CODE_POINT or code_point > LAST_CODE_POINT: + return FALLBACK_INDEX + return code_point - FIRST_CODE_POINT + + +def measure_text(text, size): + """Measure proportional text width without loading glyph pixels.""" + widths = WIDTHS[_size_index(size)] + return sum(widths[_glyph_index(character)] for character in str(text)) + + +def _glyph_offset(offsets, index): + packed_index = index * 2 + return (offsets[packed_index] << 8) | offsets[packed_index + 1] + + +def _draw_scanlines(surface, bitmap, width, height, x, y, color): + """Portable renderer used by host tests and non-framebuffer surfaces.""" + stride = (width + 7) // 8 + for row in range(height): + row_offset = row * stride + run_start = -1 + for column in range(width): + source_byte = bitmap[row_offset + (column >> 3)] + painted = source_byte & (0x80 >> (column & 7)) + if painted and run_start < 0: + run_start = column + elif not painted and run_start >= 0: + surface.hline(x + run_start, y + row, column - run_start, color) + run_start = -1 + if run_start >= 0: + surface.hline(x + run_start, y + row, width - run_start, color) + + +def _blit_glyph(surface, bitmap, width, height, x, y, palette): + glyph_buffer = bytearray(bitmap) + # Generated rows are padded to whole bytes and store the left-most pixel in + # bit 7. MicroPython needs an explicit padded stride and MONO_HLSB for that + # memory layout (the format naming is counter-intuitive in modframebuf.c). + stride = ((width + 7) // 8) * 8 + glyph = framebuf.FrameBuffer( + glyph_buffer, width, height, framebuf.MONO_HLSB, stride, + ) + surface.blit(glyph, x, y, 0, palette) + + +def draw_text(surface, text, x, y, size, color): + """Draw a pre-rasterized native-resolution font and return its width.""" + size_index = _size_index(size) + height = PIXEL_HEIGHTS[size_index] + widths = WIDTHS[size_index] + offsets = OFFSETS[size_index] + cursor = int(x) + use_blitter = framebuf is not None and hasattr(surface, "blit") + + palette = None + palette_buffer = None + if use_blitter: + palette_buffer = bytearray(4) + palette = framebuf.FrameBuffer(palette_buffer, 2, 1, framebuf.RGB565) + palette.pixel(0, 0, 0) + palette.pixel(1, 0, color) + + with open(BITMAP_FILES[size_index], "rb") as bitmap_file: + for character in str(text): + index = _glyph_index(character) + width = widths[index] + glyph_size = ((width + 7) // 8) * height + bitmap_file.seek(_glyph_offset(offsets, index)) + bitmap = bitmap_file.read(glyph_size) + if len(bitmap) != glyph_size: + raise OSError("Font bitmap is missing or truncated") + + if use_blitter: + _blit_glyph(surface, bitmap, width, height, cursor, int(y), palette) + else: + _draw_scanlines(surface, bitmap, width, height, cursor, int(y), color) + cursor += width + + return cursor - int(x) + + +def draw_centered(surface, text, y, size, color): + """Draw text horizontally centered within the framebuffer.""" + width = measure_text(text, size) + x = (surface.width - width) // 2 + draw_text(surface, text, x, y, size, color) + return x, width diff --git a/lcd_1inch28.py b/lcd_1inch28.py index 7da211b..c42235a 100644 --- a/lcd_1inch28.py +++ b/lcd_1inch28.py @@ -1,5 +1,5 @@ #LCD_1inch28 -#Version 3.1 +#Version 3.3 from machine import Pin,I2C,SPI,PWM,Timer,ADC import framebuf import time @@ -35,6 +35,15 @@ def __init__(self): #SPI initialization SPI self.dc(1) self.buffer = bytearray(self.height * self.width * 2) super().__init__(self.buffer, self.width, self.height, framebuf.RGB565) + + # Allocate the display's single largest contiguous block before loading + # font data. Importing generated glyphs first fragments the RP2040 heap + # enough to make the 115,200-byte framebuffer allocation unreliable. + from font_renderer import draw_centered, draw_text, measure_text + self._draw_centered = draw_centered + self._draw_text = draw_text + self._measure_text = measure_text + self.init_display() #Define color, Micropython fixed to BRG format @@ -378,30 +387,14 @@ def Windows_show(self,Xstart,Ystart,Xend,Yend): self.spi.write(self.buffer[Addr : Addr+((Xend-Xstart)*2)]) self.cs(1) - #Write characters, size is the font size, the minimum is 1 - def write_text(self,text,x,y,size,color): - ''' Method to write Text on OLED/LCD Displays - with a variable font size - - Args: - text: the string of chars to be displayed - x: x co-ordinate of starting position - y: y co-ordinate of starting position - size: font size of text - color: color of text to be displayed - ''' - background = self.pixel(x,y) - info = [] - # Creating reference charaters to read their values - self.text(text,x,y,color) - for i in range(x,x+(8*len(text))): - for j in range(y,y+8): - # Fetching amd saving details of pixels, such as - # x co-ordinate, y co-ordinate, and color of the pixel - px_color = self.pixel(i,j) - info.append((i,j,px_color)) if px_color == color else None - # Clearing the reference characters from the screen - self.text(text,x,y,background) - # Writing the custom-sized font characters on screen - for px_info in info: - self.fill_rect(size*px_info[0] - (size-1)*x , size*px_info[1] - (size-1)*y, size, size, px_info[2]) + def text_width(self, text, size): + """Return the proportional width of text at a UI font size.""" + return self._measure_text(text, size) + + def write_text(self, text, x, y, size, color): + """Draw smooth proportional text and return its rendered width.""" + return self._draw_text(self, text, x, y, size, color) + + def write_centered(self, text, y, size, color): + """Draw smooth proportional text centered on the display.""" + return self._draw_centered(self, text, y, size, color) diff --git a/main.py b/main.py index e56d011..b018484 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ # Mark Rodman # Session Timer for track days and racing. -# V3.2 +# V3.3 import time @@ -18,11 +18,11 @@ PIT_SESSION_MSG = ["Cool down!", "Rest in pits"] TRACK_SESSION_MSG = ["Ready", "Swipe DOWN to start"] -CLINE1 = [TRACK_SESSION_MSG[0], 20, 96, 5, "white"] -CLINE2 = [TRACK_SESSION_MSG[1], 44, 195, 1, "black"] -CLINE3 = ["message", 50, 35, 3, "black"] -PLINE1 = [PIT_SESSION_MSG[0], 4, 96, 3, "white"] -PLINE2 = [PIT_SESSION_MSG[1], 23, 150, 2, "red"] +CLINE1 = [TRACK_SESSION_MSG[0], None, 90, 5, "white"] +CLINE2 = [TRACK_SESSION_MSG[1], None, 185, 1, "black"] +CLINE3 = ["message", None, 35, 3, "black"] +PLINE1 = [PIT_SESSION_MSG[0], None, 88, 3, "white"] +PLINE2 = [PIT_SESSION_MSG[1], None, 145, 2, "red"] def main(): diff --git a/params.json b/params.json index bc2096c..947e406 100644 --- a/params.json +++ b/params.json @@ -2,7 +2,7 @@ "DURATION_VALUES": [1, 5, 10, 15, 20, 25, 30, 40, 50, 60], "DISPLAY_DELAY_REST": 5, "LAUNCH_SENSE_VALUES": [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4], - "VERSION": "3.2", + "VERSION": "3.3", "DISPLAY_DELAY_REST_COLOUR": "blue", "BOOT_DELAY_SEC": 2 } diff --git a/settings.py b/settings.py index 54f489d..c9d6de3 100644 --- a/settings.py +++ b/settings.py @@ -12,7 +12,7 @@ "DURATION_VALUES": [1, 5, 10, 15, 20, 25, 30, 40, 50, 60], "DISPLAY_DELAY_REST": 5, "LAUNCH_SENSE_VALUES": [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4], - "VERSION": "3.2", + "VERSION": "3.3", "DISPLAY_DELAY_REST_COLOUR": "blue", "BOOT_DELAY_SEC": 2, } diff --git a/tests/test_font_renderer.py b/tests/test_font_renderer.py new file mode 100644 index 0000000..41803d6 --- /dev/null +++ b/tests/test_font_renderer.py @@ -0,0 +1,119 @@ +import unittest +from pathlib import Path + +import font_data +import font_renderer +from font_renderer import draw_centered, draw_text, measure_text, pixel_height + + +class FakeSurface: + width = 240 + + def __init__(self): + self.lines = [] + + def hline(self, x, y, width, color): + self.lines.append((x, y, width, color)) + + +class FakeFrameBufferModule: + MONO_HLSB = 3 + RGB565 = 1 + + def __init__(self): + self.calls = [] + module = self + + class FrameBuffer: + def __init__(self, *args): + module.calls.append(args) + + def pixel(self, *args): + pass + + self.FrameBuffer = FrameBuffer + + +class FakeBlitSurface(FakeSurface): + def __init__(self): + super().__init__() + self.blits = [] + + def blit(self, *args): + self.blits.append(args) + + +class FontRendererTests(unittest.TestCase): + def test_font_metadata_covers_printable_ascii(self): + glyph_count = font_data.LAST_CODE_POINT - font_data.FIRST_CODE_POINT + 1 + self.assertEqual(95, glyph_count) + self.assertEqual(len(font_data.PIXEL_HEIGHTS), len(font_data.WIDTHS)) + self.assertEqual(len(font_data.PIXEL_HEIGHTS), len(font_data.OFFSETS)) + + for height, widths, offsets, bitmap_file, bitmap_size in zip( + font_data.PIXEL_HEIGHTS, + font_data.WIDTHS, + font_data.OFFSETS, + font_data.BITMAP_FILES, + font_data.BITMAP_SIZES, + ): + with self.subTest(height=height): + self.assertEqual(glyph_count, len(widths)) + self.assertEqual(glyph_count * 2, len(offsets)) + last_offset = (offsets[-2] << 8) | offsets[-1] + last_stride = (widths[-1] + 7) // 8 + self.assertEqual(bitmap_size, last_offset + (last_stride * height)) + self.assertEqual(bitmap_size, Path(bitmap_file).stat().st_size) + + def test_sizes_are_native_pixel_heights(self): + self.assertEqual(12, pixel_height(1)) + self.assertEqual(64, pixel_height(6)) + self.assertEqual(84, pixel_height(8)) + for invalid in (0, -1, 1.5, True, 9): + with self.subTest(invalid=invalid): + with self.assertRaises(ValueError): + pixel_height(invalid) + + def test_proportional_measurement_and_scaling(self): + self.assertLess(measure_text("III", 3), measure_text("WWW", 3)) + self.assertLess(measure_text("Timer", 2), measure_text("Timer", 4)) + self.assertGreater(measure_text(" ", 2), 0) + + def test_draw_uses_single_pixel_scanlines(self): + surface = FakeSurface() + width = draw_text(surface, "8:15", 4, 7, 5, 0xFFFF) + + self.assertEqual(measure_text("8:15", 5), width) + self.assertTrue(surface.lines) + self.assertTrue(all(line[2] > 0 for line in surface.lines)) + self.assertGreaterEqual(min(line[1] for line in surface.lines), 7) + self.assertLess(max(line[1] for line in surface.lines), 7 + pixel_height(5)) + + def test_hardware_blit_uses_msb_first_bits_and_padded_stride(self): + fake_framebuf = FakeFrameBufferModule() + surface = FakeBlitSurface() + original_framebuf = font_renderer.framebuf + font_renderer.framebuf = fake_framebuf + try: + draw_text(surface, "A", 3, 4, 1, 0xFFFF) + finally: + font_renderer.framebuf = original_framebuf + + glyph_call = fake_framebuf.calls[-1] + glyph_width = font_data.WIDTHS[0][ord("A") - font_data.FIRST_CODE_POINT] + self.assertEqual(fake_framebuf.MONO_HLSB, glyph_call[3]) + self.assertEqual(((glyph_width + 7) // 8) * 8, glyph_call[4]) + self.assertEqual(1, len(surface.blits)) + + def test_centering_uses_measured_width(self): + surface = FakeSurface() + x, width = draw_centered(surface, "Ready", 20, 5, 1) + self.assertEqual((surface.width - width) // 2, x) + self.assertEqual(measure_text("Ready", 5), width) + + def test_unknown_characters_use_fallback_glyph(self): + self.assertEqual(measure_text("?", 2), measure_text("\u2603", 2)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/generate_font.py b/tools/generate_font.py new file mode 100644 index 0000000..639e42b --- /dev/null +++ b/tools/generate_font.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Generate the compact 1-bit font module used by the Pico firmware.""" + +import argparse +import math +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +FIRST_CODE_POINT = 32 +LAST_CODE_POINT = 126 +SOURCE_SIZE = 96 +THRESHOLD = 96 +PIXEL_HEIGHTS = (12, 20, 30, 44, 54, 64, 74, 84) + + +def _pack_bitmap(image): + width, height = image.size + stride = (width + 7) // 8 + bitmap = bytearray() + for y in range(height): + row = bytearray(stride) + for x in range(width): + if image.getpixel((x, y)) >= THRESHOLD: + row[x >> 3] |= 0x80 >> (x & 7) + bitmap.extend(row) + return bitmap + + +def generate(font_path): + font = ImageFont.truetype(str(font_path), SOURCE_SIZE) + characters = [chr(code) for code in range(FIRST_CODE_POINT, LAST_CODE_POINT + 1)] + bounds = [font.getbbox(character) for character in characters] + top = min(bound[1] for bound in bounds) + bottom = max(bound[3] for bound in bounds) + source_height = bottom - top + source_glyphs = [] + + for character, bound in zip(characters, bounds): + left = min(0, bound[0]) + right = max(float(bound[2]), font.getlength(character)) + width = max(1, int(math.ceil(right - left))) + image = Image.new("L", (width, source_height), 0) + draw = ImageDraw.Draw(image) + draw.text((-left, -top), character, font=font, fill=255) + source_glyphs.append(image) + + generated = [] + for target_height in PIXEL_HEIGHTS: + widths = bytearray() + offsets = bytearray() + bitmaps = bytearray() + + for source_glyph in source_glyphs: + target_width = max( + 1, + int(round(source_glyph.width * target_height / source_height)), + ) + glyph = source_glyph.resize( + (target_width, target_height), + Image.Resampling.LANCZOS, + ) + widths.append(target_width) + offset = len(bitmaps) + offsets.extend((offset >> 8, offset & 0xFF)) + bitmaps.extend(_pack_bitmap(glyph)) + + generated.append((target_height, bytes(widths), bytes(offsets), bytes(bitmaps))) + + return generated + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("font", type=Path, help="source OpenType or TrueType font") + parser.add_argument("output", type=Path, help="generated MicroPython module") + args = parser.parse_args() + + generated = generate(args.font) + bitmap_files = [] + bitmap_sizes = [] + widths = [] + offsets = [] + for height, size_widths, size_offsets, bitmaps in generated: + if height == 64: + bitmap_path = args.output.with_suffix(".bin") + else: + bitmap_path = args.output.with_name( + "{}_{}.bin".format(args.output.stem, height), + ) + bitmap_path.write_bytes(bitmaps) + bitmap_files.append(bitmap_path.name) + bitmap_sizes.append(len(bitmaps)) + widths.append(size_widths) + offsets.append(size_offsets) + + module = '''"""Generated TrackTimer Sans metadata. + +Derived from Montserrat SemiBold, Copyright 2024 The Montserrat.Git Project +Authors. Licensed under the SIL Open Font License 1.1; see FONT_LICENSE.txt. +Regenerate with ``tools/generate_font.py`` rather than editing this file. +""" + +FIRST_CODE_POINT = {first} +LAST_CODE_POINT = {last} +PIXEL_HEIGHTS = {pixel_heights!r} +WIDTHS = {widths!r} +OFFSETS = {offsets!r} +BITMAP_FILES = {bitmap_files!r} +BITMAP_SIZES = {bitmap_sizes!r} +'''.format( + first=FIRST_CODE_POINT, + last=LAST_CODE_POINT, + pixel_heights=PIXEL_HEIGHTS, + widths=tuple(widths), + offsets=tuple(offsets), + bitmap_files=tuple(bitmap_files), + bitmap_sizes=tuple(bitmap_sizes), + ) + args.output.write_text(module, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/touch_drive.py b/touch_drive.py index dcb2984..6d0a4a7 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -1,5 +1,5 @@ # Touch drive -# v3.1 +# v3.3 from machine import Pin,I2C,SPI,PWM,Timer,ADC import framebuf import time @@ -110,10 +110,10 @@ def BootScreen(self, LCD, sleep=4, version_number="0.0"): self.mode = 0 self.Set_Mode(self.Mode) LCD.fill(LCD.red) - LCD.write_text('Track',30,66,3,LCD.green) - LCD.write_text('Session',44,96,3,LCD.green) - LCD.write_text('Timer',65,126,3,LCD.green) - LCD.write_text(('Version:' + version_number),65,197,1,LCD.green) + LCD.write_centered('Track',55,3,LCD.green) + LCD.write_centered('Session',90,3,LCD.green) + LCD.write_centered('Timer',125,3,LCD.green) + LCD.write_centered(('Version ' + version_number),195,1,LCD.green) LCD.show() def SetBackColour(self, LCD, backColour): @@ -176,8 +176,11 @@ def ControlScreen(self, LCD, text_array=None, back_colour=None): if len(text) != 5: raise ValueError(f"Each text array must contain exactly 5 elements: {text}") string_val, x, y, size, color = text - # Validate each parameter (e.g., ensure x, y are within screen bounds) - LCD.write_text(string_val, x, y, size, self.SetTextColour(LCD, color)) + text_color = self.SetTextColour(LCD, color) + if x is None: + LCD.write_centered(string_val, y, size, text_color) + else: + LCD.write_text(string_val, x, y, size, text_color) # Refresh the LCD to display the changes LCD.show() @@ -186,7 +189,7 @@ def GoScreen(self, LCD, text='..GO!'): #self.mode = 0 #self.Set_Mode(self.Mode) LCD.fill(LCD.green) - LCD.write_text(text,12,96,4,LCD.white) + LCD.write_centered(text,92,4,LCD.white) LCD.show() time.sleep(1) @@ -205,8 +208,8 @@ def LiveScreen(self, LCD, textsize_rem=None, backColour=None, textColour=None, e self.mode = 0 self.Set_Mode(self.Mode) LCD.fill(backColour) - LCD.write_text(remaining,1,96,textsize_rem,textColour) - LCD.write_text(elapsed,62,185,3,textColour) + LCD.write_centered(remaining,82,textsize_rem,textColour) + LCD.write_centered(elapsed,180,3,textColour) LCD.show() @@ -289,8 +292,8 @@ def SetDurationGesture(self, LCD, duration, duration_values=None): # Plot the revised duration on the screen LCD.fill(LCD.green) dur_str = f"{duration:02}" - LCD.write_text(str(dur_str),55,90,8,LCD.white) - LCD.write_text('minutes',67,190,2,LCD.black) + LCD.write_centered(str(dur_str),75,8,LCD.white) + LCD.write_centered('minutes',185,2,LCD.black) LCD.show() # Delay to stop interface bounce, and flying through all of the array values. @@ -373,44 +376,44 @@ def Touch_Gesture(): Touch.Set_Mode(Touch.Mode) LCD.fill(LCD.white) # LCD.show() - LCD.write_text('Gesture test',70,90,1,LCD.black) - LCD.write_text('Complete as prompted',35,120,1,LCD.black) + LCD.write_centered('Gesture test',90,1,LCD.black) + LCD.write_centered('Complete as prompted',120,1,LCD.black) LCD.show() time.sleep(1) LCD.fill(LCD.white) while Touch.Gestures != 0x01: LCD.fill(LCD.white) - LCD.write_text('UP',100,110,3,LCD.black) + LCD.write_centered('UP',105,3,LCD.black) LCD.show() time.sleep(0.1) while Touch.Gestures != 0x02: LCD.fill(LCD.white) - LCD.write_text('DOWM',70,110,3,LCD.black) + LCD.write_centered('DOWN',105,3,LCD.black) LCD.show() time.sleep(0.1) while Touch.Gestures != 0x03: LCD.fill(LCD.white) - LCD.write_text('LEFT',70,110,3,LCD.black) + LCD.write_centered('LEFT',105,3,LCD.black) LCD.show() time.sleep(0.1) while Touch.Gestures != 0x04: LCD.fill(LCD.white) - LCD.write_text('RIGHT',60,110,3,LCD.black) + LCD.write_centered('RIGHT',105,3,LCD.black) LCD.show() time.sleep(0.1) while Touch.Gestures != 0x0C: LCD.fill(LCD.white) - LCD.write_text('Long Press',40,110,2,LCD.black) + LCD.write_centered('Long Press',105,2,LCD.black) LCD.show() time.sleep(0.1) while Touch.Gestures != 0x0B: LCD.fill(LCD.white) - LCD.write_text('Double Click',25,110,2,LCD.black) + LCD.write_centered('Double Click',105,2,LCD.black) LCD.show() time.sleep(0.1) @@ -427,27 +430,25 @@ def DOF_READ(): LCD.fill(LCD.white) LCD.fill_rect(0,0,240,40,LCD.red) - LCD.text("Waveshare",80,25,LCD.white) + LCD.write_centered("Waveshare",18,1,LCD.white) LCD.fill_rect(0,40,240,40,LCD.blue) - # LCD.text("Long Press to Quit",20,57,LCD.white) - LCD.write_text("Long Press to Quit",50,57,1,LCD.white) + LCD.write_centered("Long Press to Quit",54,1,LCD.white) LCD.fill_rect(0,80,120,120,0x1805) - LCD.text("ACC_X={:+.2f}".format(xyz[0]),20,100-3,LCD.white) - LCD.text("ACC_Y={:+.2f}".format(xyz[1]),20,140-3,LCD.white) - LCD.text("ACC_Z={:+.2f}".format(xyz[2]),20,180-3,LCD.white) + LCD.write_text("ACC_X={:+.2f}".format(xyz[0]),5,94,1,LCD.white) + LCD.write_text("ACC_Y={:+.2f}".format(xyz[1]),5,134,1,LCD.white) + LCD.write_text("ACC_Z={:+.2f}".format(xyz[2]),5,174,1,LCD.white) LCD.fill_rect(120,80,120,120,0xF073) - LCD.text("GYR_X={:+3.2f}".format(xyz[3]),125,100-3,LCD.white) - LCD.text("GYR_Y={:+3.2f}".format(xyz[4]),125,140-3,LCD.white) - LCD.text("GYR_Z={:+3.2f}".format(xyz[5]),125,180-3,LCD.white) + LCD.write_text("GYR_X={:+3.2f}".format(xyz[3]),125,94,1,LCD.white) + LCD.write_text("GYR_Y={:+3.2f}".format(xyz[4]),125,134,1,LCD.white) + LCD.write_text("GYR_Z={:+3.2f}".format(xyz[5]),125,174,1,LCD.white) LCD.fill_rect(0,200,240,40,0x180f) reading = Vbat.read_u16()*3.3/65535 * 3 - LCD.text("Vbat={:.2f}".format(reading),80,215,LCD.white) + LCD.write_centered("Vbat={:.2f}".format(reading),212,1,LCD.white) LCD.show() if(Touch.Gestures == 0x0C): break -