From 546f33bc6ec509c85776eefb9ecd1e0fbf7d335b Mon Sep 17 00:00:00 2001 From: Adrian G L Date: Fri, 1 May 2026 11:53:56 +0200 Subject: [PATCH] init test --- .gitignore | 6 + CMakeLists.txt | 14 + README.md | 178 +++++++ boards/xiao_nrf54l15_nrf54l15_cpuapp.conf | 8 + boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay | 36 ++ developmentlog.md | 92 ++++ dts/bindings/nrfwarch,epd-ctrl.yaml | 20 + flake.lock | 61 +++ flake.nix | 166 +++++++ prj.conf | 11 + sample.yaml | 7 + sim/CMakeLists.txt | 14 + sim/prj.conf | 6 + sim/src/epd_sim.c | 69 +++ sim/src/main.c | 41 ++ sim/view.py | 92 ++++ src/epd_drv.c | 246 ++++++++++ src/epd_drv.h | 23 + src/fonts.c | 467 +++++++++++++++++++ src/fonts.h | 18 + src/main.c | 52 +++ src/watchface.c | 115 +++++ src/watchface.h | 20 + tests/CMakeLists.txt | 13 + tests/prj.conf | 2 + tests/sample.yaml | 7 + tests/src/main.c | 217 +++++++++ tools/gen_fonts.py | 201 ++++++++ 28 files changed, 2202 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 boards/xiao_nrf54l15_nrf54l15_cpuapp.conf create mode 100644 boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay create mode 100644 developmentlog.md create mode 100644 dts/bindings/nrfwarch,epd-ctrl.yaml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 prj.conf create mode 100644 sample.yaml create mode 100644 sim/CMakeLists.txt create mode 100644 sim/prj.conf create mode 100644 sim/src/epd_sim.c create mode 100644 sim/src/main.c create mode 100644 sim/view.py create mode 100644 src/epd_drv.c create mode 100644 src/epd_drv.h create mode 100644 src/fonts.c create mode 100644 src/fonts.h create mode 100644 src/main.c create mode 100644 src/watchface.c create mode 100644 src/watchface.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/prj.conf create mode 100644 tests/sample.yaml create mode 100644 tests/src/main.c create mode 100644 tools/gen_fonts.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16533d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +build-test/ +build-sim/ +.sdk/ +.zephyr/ +result diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..ab209e7 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.20.0) + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) + +project(nrfwarch) + +target_sources(app PRIVATE + src/main.c + src/epd_drv.c + src/watchface.c + src/fonts.c +) + +target_include_directories(app PRIVATE src) diff --git a/README.md b/README.md new file mode 100644 index 0000000..640a8bf --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# nrfwarch + +Smartwatch firmware for the [Seeed Xiao nRF54L15 Sense](https://www.seeedstudio.com/XIAO-nRF54L15-Sense-p-6494.html) with a GDEY0154D61LT 1.54" e-ink display. + +Built on [Zephyr RTOS](https://docs.zephyrproject.org/) v4.0. Targets the `xiao_nrf54l15/nrf54l15/cpuapp` board. Includes a display simulator that renders the watchface on your desktop using [feh](https://feh.finalrewind.org/). + +## Hardware + +- **MCU**: Nordic nRF54L15 (ARM Cortex-M33, 128KB RAM, 1MB Flash) +- **Board**: Seeed Xiao nRF54L15 Sense (includes LSM6DSO IMU, PDM mic) +- **Display**: Good Display GDEY0154D61LT 1.54" e-ink, 200x200px, SSD1680 controller, SPI + +### E-ink wiring + +| Signal | Xiao Pin | Function | +|--------|----------|----------| +| MOSI | D10 | SPI data out | +| CLK | D8 | SPI clock | +| CS | D1 | Chip select | +| DC | D3 | Data/command | +| RST | D0 | Hardware reset | +| BUSY | D2 | Busy status | + +## Quick start + +### 1. Enter dev shell + +```bash +nix develop +``` + +### 2. Set up Zephyr SDK (first time only) + +Downloads Zephyr v4.0 workspace and SDK 0.17.0, patches binaries for NixOS: + +```bash +nix run .#setup-sdk +``` + +### 3. Run the display simulator + +Builds and runs the watchface simulator. Time advances 1 minute per second. A feh window opens showing the e-ink output in real time. The simulator uses the same rendering code that runs on the real hardware. + +```bash +nix run .#sim +``` + +This pipes the native_sim binary output through `sim/view.py`, which converts the framebuffer to PBM and auto-launches feh with `--auto-reload`. The output file is `/tmp/nrfwarch.pbm`. + +### 4. Run tests + +17 ztest cases covering framebuffer operations, font data, and rendering: + +```bash +nix run .#test +``` + +### 5. Build for hardware + +Open the project in [nRF Connect for VS Code](https://marketplace.visualstudio.com/items?itemName=nordic-semiconductor.nrf-connect) and add the application with board target `xiao_nrf54l15/nrf54l15/cpuapp`. Or build from the command line: + +```bash +west build -b xiao_nrf54l15/nrf54l15/cpuapp . +``` + +## Display simulation + +The simulator lets you develop the watchface visually without any hardware. It runs the actual Zephyr kernel and the real `watchface.c` / `fonts.c` code -- the only difference is the EPD driver writes to a file instead of SPI. + +How it works: + +1. `sim/src/epd_sim.c` implements the same API as `src/epd_drv.c`, but instead of sending SPI commands it dumps the raw framebuffer over stdout as hex, framed with `!F!` / `!E!` markers +2. `sim/view.py` reads the hex frames from stdin, inverts the polarity (framebuffer uses 1=white, PBM uses 1=black), and writes a PBM image to `/tmp/nrfwarch.pbm` +3. feh watches the file with `--auto-reload` and updates the display window + +The simulated time starts at 12:00 and advances 1 minute every 0.8 seconds. Battery decreases by 2% per simulated hour. A full 24-hour cycle runs in about 19 minutes. + +### Manual usage + +If you want to run the sim without feh or pipe the output yourself: + +```bash +# Run the binary directly (outputs hex frames to stdout) +./build-sim/zephyr/zephyr.exe + +# Convert a single frame to PBM manually +./build-sim/zephyr/zephyr.exe | python3 sim/view.py + +# Just view the current frame +feh /tmp/nrfwarch.pbm + +# Convert to PNG for sharing +pnmtopng /tmp/nrfwarch.pbm > watchface.png +``` + +### Iterating on the watchface + +1. Edit `src/watchface.c` or modify digit shapes in `tools/gen_fonts.py` +2. If fonts changed: `python3 tools/gen_fonts.py` +3. Rebuild sim: `cmake -B build-sim -GNinja -DBOARD=qemu_x86 -S sim && ninja -C build-sim` +4. Run: `./build-sim/zephyr/zephyr.exe | python3 sim/view.py` +5. feh auto-reloads when the PBM changes + +## Project layout + +``` +src/ + main.c Entry point, minute-by-minute watch face loop + epd_drv.c/h SSD1680 e-ink driver (SPI, full/partial refresh, deep sleep) + watchface.c/h Framebuffer renderer (200x200 monochrome) + fonts.c/h Bitmap fonts (24x40 digits, 8x40 colon) + +sim/ + src/main.c Simulated main loop (accelerated time, no hardware) + src/epd_sim.c EPD mock - dumps framebuffer as hex over stdout + view.py Reads hex frames, writes PBM, launches feh + CMakeLists.txt Sim build config + prj.conf Sim Kconfig + +boards/ + xiao_nrf54l15_nrf54l15_cpuapp.overlay Devicetree overlay (SPI, GPIO, I2C, BT) + xiao_nrf54l15_nrf54l15_cpuapp.conf Board-specific Kconfig + +dts/bindings/ + nrfwarch,epd-ctrl.yaml Custom binding for EPD GPIO control node + +tests/ + src/main.c 17 ztest cases (framebuffer, pixels, fonts, rendering) + prj.conf Test build config + +prj.conf Main application config +CMakeLists.txt Build definition +flake.nix Nix flake (dev shell, setup-sdk, test, sim) + +tools/ + gen_fonts.py Font generator (run to regenerate src/fonts.*) +``` + +## Flake commands + +| Command | Description | +|---------|-------------| +| `nix develop` | Enter dev shell with all tools | +| `nix run .#setup-sdk` | Download and set up Zephyr SDK + workspace | +| `nix run .#test` | Build and run ztest suite on QEMU | +| `nix run .#sim` | Run display simulator with feh viewer | + +## What works + +- SSD1680 e-ink driver with full refresh and partial refresh +- Framebuffer-based watch face with time rendering (HH:MM) and battery indicator +- Deep sleep / wake for the display +- Display simulator with real-time feh output +- 17 passing tests covering framebuffer operations, font data integrity, and rendering + +## In progress / planned + +- BLE Current Time Service (CTS) client for phone time sync +- GRTC-based RTC for persistent timekeeping across deep sleep +- Battery monitoring via ADC (channel 7, VBAT regulator) +- LSM6DSO IMU wrist-tilt wake detection +- System-off power management with regulator control +- Partial refresh for minute updates (avoid full refresh flicker) +- BLE Battery Service (BAS) and notifications + +## References + +- [Seeed Xiao nRF54L15 Wiki](https://wiki.seeedstudio.com/xiao_nrf54l15_sense_getting_started/) +- [Zephyr RTOS Documentation](https://docs.zephyrproject.org/) +- [Zephyr QEMU x86 Board](https://docs.zephyrproject.org/latest/boards/qemu/x86/doc/index.html) +- [Zephyr ztest Framework](https://docs.zephyrproject.org/latest/develop/test/ztest.html) +- [SSD1680 datasheet](https://www.good-display.com/companyfile/409.html) (GDEY0154D61LT) +- [Sample repo: Toastee0/Seeed-Xiao-nRF54L15](https://github.com/Toastee0/Seeed-Xiao-nRF54L15) +- [E-ink driver reference: jcontrerasf/E-ink-display-Zephyr](https://github.com/jcontrerasf/E-ink-display-Zephyr) + +## License + +Apache 2.0 diff --git a/boards/xiao_nrf54l15_nrf54l15_cpuapp.conf b/boards/xiao_nrf54l15_nrf54l15_cpuapp.conf new file mode 100644 index 0000000..6c6bdcf --- /dev/null +++ b/boards/xiao_nrf54l15_nrf54l15_cpuapp.conf @@ -0,0 +1,8 @@ +CONFIG_SERIAL=y +CONFIG_UART_CONSOLE=y +CONFIG_CONSOLE=y +CONFIG_GPIO=y +CONFIG_SPI=y +CONFIG_I2C=y +CONFIG_ADC=y +CONFIG_ADC_ASYNC=y diff --git a/boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay b/boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay new file mode 100644 index 0000000..162421d --- /dev/null +++ b/boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay @@ -0,0 +1,36 @@ +/ { + aliases { + vbat-reg = &vbat_pwr; + sw0 = &usr_btn; + led0 = &led0; + }; + + chosen { + zephyr,bt-hci = &bt_hci_controller; + }; + + epd_ctrl: epd-ctrl { + compatible = "nrfwarch,epd-ctrl"; + cs-gpios = <&xiao_d 1 GPIO_ACTIVE_LOW>; + dc-gpios = <&xiao_d 3 GPIO_ACTIVE_HIGH>; + rst-gpios = <&xiao_d 0 GPIO_ACTIVE_LOW>; + busy-gpios = <&xiao_d 2 (GPIO_ACTIVE_HIGH | GPIO_PULL_UP)>; + }; +}; + +&xiao_spi { + status = "okay"; + suggested-max-frequency = <4000000>; +}; + +&xiao_i2c { + status = "okay"; + clock-frequency = ; +}; + +&bt_hci_controller { + status = "okay"; +}; +&bt_hci_sdc { + status = "okay"; +}; diff --git a/developmentlog.md b/developmentlog.md new file mode 100644 index 0000000..cff03b2 --- /dev/null +++ b/developmentlog.md @@ -0,0 +1,92 @@ +# Development Log + +## 2026-04-25: Initial Setup + +### Project: nrfwarch +Smartwatch firmware for Seeed Xiao nRF54L15 Sense with GDEY0154D61LT 1.54" e-ink display (SSD1680 controller). + +### Board setup +- Ported SSD1680 e-ink driver from jcontrerasf/E-ink-display-Zephyr (nRF52840/PlatformIO) to Xiao nRF54L15 with modern Zephyr APIs +- Created custom devicetree binding (`nrfwarch,epd-ctrl`) for EPD GPIO pins +- VCOM command (0x2C, value 0xA5) added to init sequence +- Partial update uses `0xCF` control byte (clock + analog + temperature, no LUT reload) + +### Framebuffer renderer +- 200x200 monochrome framebuffer, 5000 bytes +- Y-flip in `wf_set_pixel` compensates for SSD1680 data entry mode 0x01 (Y decrement): `ram_row = (199 - y)` +- `wf_draw_glyph` draws a glyph bitmap onto the framebuffer at (dst_x, dst_y). Font data uses bit=1 for foreground (draw black), bit=0 for background (skip). Background pixels are NOT written so the framebuffer underneath is preserved. +- Stack overflow in `epd_clear` fixed by using static buffer instead of stack allocation + +### Fonts +- `tools/gen_fonts.py` generates `src/fonts.h` and `src/fonts.c` programmatically +- Digits are 24x40px (120 bytes each), colon is 8x40px (40 bytes) +- Digits are drawn as stroked outlines: horizontal/vertical lines + filled rectangles +- Each digit is a seven-segment-style rendering with 2px stroke thickness and 3px margins +- To regenerate: `python3 tools/gen_fonts.py` + +### EPD wiring (Xiao nRF54L15 Sense) +| Signal | Pin | GPIO | +|--------|-----|------| +| MOSI | D10 | SPI MOSI | +| CLK | D8 | SPI SCK | +| CS | D1 | xiao_d 1 | +| DC | D3 | xiao_d 3 | +| RST | D0 | xiao_d 0 | +| BUSY | D2 | xiao_d 2 | + +## 2026-04-25: Display Simulator + +### What was done +- `sim/` directory with display simulator for watchface development without hardware +- `sim/src/epd_sim.c` implements the same API as `src/epd_drv.c`, dumps framebuffer as hex over stdout with `!F!`/`!E!` framing +- `sim/view.py` reads hex frames from stdin, inverts polarity (framebuffer bit=1 is white, PBM bit=1 is black), writes PBM to `/tmp/nrfwarch.pbm` +- feh auto-launches with `--auto-reload` for live display +- Sim runs on Zephyr `native_sim` target (native Linux process, not QEMU). Time advances 1 min/sec. + +### Sim architecture +``` +Zephyr native_sim binary (zephyr.exe) + -> watchface.c renders to framebuffer (same code as hardware) + -> epd_sim.c dumps hex frames to stdout + -> view.py reads stdin, converts to PBM + -> feh displays the image with auto-reload +``` + +### Frame protocol +- `!F!` = frame start, then 5000 bytes as hex (32 bytes/line), `!E!` = frame end +- view.py ignores everything outside markers (handles Zephyr log output mixed in) + +## 2026-04-25: Flake, Tests, and Font Fixes + +### Nix flake +- Converted from shell.nix to flake.nix with three apps: + - `nix run .#setup-sdk` - downloads Zephyr v4.0 + SDK 0.17.0, patches all ELF binaries for NixOS + - `nix run .#test` - builds and runs ztest suite on QEMU x86 + - `nix run .#sim` - builds and runs display simulator with feh viewer +- Dev shell includes: cmake, ninja, west, qemu_full, feh, dtc, patchelf + +### NixOS SDK patching +- Zephyr SDK binaries are dynamically linked with `/lib64/ld-linux-x86-64.so.2` interpreter +- Must patchelf every ELF binary (bin/, libexec/, lib/) to point at NixOS's ld-linux +- The setup-sdk app handles this automatically + +### Test suite (17 tests, all passing) +- **Framebuffer**: clear sets all 0xFF, pixel set/get roundtrip, bounds checking, Y-flip at corners +- **Fonts**: digit/colon array sizes match compile-time constants, all digits contain non-trivial data +- **Rendering**: draw_time modifies buffer, different times produce different buffers, battery indicator renders, 0% vs 100% battery differs +- **Glyph**: all-ones glyph sets pixel to black + +### Bugs fixed this session +- **Inverted fonts**: `wf_draw_glyph` had `black = !(val & bit)` which inverted foreground/background. Fixed to only draw when bit=1 (foreground), skip bit=0 (background). +- **Leading bar artifact**: Background pixels were being explicitly written as white on every glyph byte, overwriting adjacent content. Fixed by skipping background pixels entirely (`continue` on bit=0). +- **Font data mismatch**: Original hand-written font data had wrong byte counts (112 bytes declared as 64). Replaced with programmatic generator. +- **epd_clear stack overflow**: 5000-byte array on stack blew past 4KB main stack. Changed to static buffer. +- **Devicetree binding**: `compatible = "simple-bus"` rejected custom GPIO properties. Created proper `nrfwarch,epd-ctrl.yaml` binding. + +### Next steps +- BLE CTS client for time sync from phone +- GRTC-based RTC for persistent timekeeping across system-off +- Battery monitoring via ADC channel 7 with VBAT regulator +- LSM6DSO IMU wrist-tilt wake detection +- System-off power management with regulator control +- Partial refresh for minute updates (avoid full refresh flicker) diff --git a/dts/bindings/nrfwarch,epd-ctrl.yaml b/dts/bindings/nrfwarch,epd-ctrl.yaml new file mode 100644 index 0000000..b27e2b9 --- /dev/null +++ b/dts/bindings/nrfwarch,epd-ctrl.yaml @@ -0,0 +1,20 @@ +description: EPD GPIO control node for SSD1680 +compatible: "nrfwarch,epd-ctrl" + +properties: + cs-gpios: + type: phandle-array + required: true + description: Chip select GPIO + dc-gpios: + type: phandle-array + required: true + description: Data/command GPIO + rst-gpios: + type: phandle-array + required: true + description: Reset GPIO + busy-gpios: + type: phandle-array + required: true + description: Busy status GPIO diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3f06416 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1777268161, + "narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fa531aa --- /dev/null +++ b/flake.nix @@ -0,0 +1,166 @@ +{ + description = "nrfwarch - smartwatch firmware for Xiao nRF54L15 Sense"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + zephyrEnv = '' + export ZEPHYR_BASE="$(pwd)/.zephyr/zephyr" + export ZEPHYR_SDK_INSTALL_DIR="$(pwd)/.sdk/zephyr-sdk-0.17.0" + export ZEPHYR_TOOLCHAIN_VARIANT=zephyr + ''; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + cmake + ninja + python312 + python312Packages.pip + python312Packages.west + python312Packages.pyelftools + qemu_full + dtc + git + patchelf + gnumake + feh + ]; + + shellHook = zephyrEnv + '' + echo "--- nrfwarch ---" + echo "Zephyr: $ZEPHYR_BASE" + echo "SDK: $ZEPHYR_SDK_INSTALL_DIR" + echo "" + echo "Setup: nix run .#setup-sdk" + echo "Tests: nix run .#test" + echo "Simulate: nix run .#sim" + ''; + + ZEPHYR_BASE = toString ./.zephyr/zephyr; + ZEPHYR_SDK_INSTALL_DIR = toString ./.sdk/zephyr-sdk-0.17.0; + ZEPHYR_TOOLCHAIN_VARIANT = "zephyr"; + }; + + apps = { + setup-sdk = { + type = "app"; + program = toString ( + pkgs.writeShellScript "setup-sdk" '' + set -euo pipefail + + SDK_VER="0.17.0" + SDK_DIR=".sdk/zephyr-sdk-$SDK_VER" + ZEPHYR_DIR=".zephyr" + + export PATH="${pkgs.git}/bin:${pkgs.python312Packages.west}/bin:${pkgs.wget}/bin:${pkgs.xz}/bin:${pkgs.patchelf}/bin:${pkgs.findutils}/bin:${pkgs.gnugrep}/bin:${pkgs.file}/bin:${pkgs.curl}/bin:${pkgs.gnutar}/bin:$PATH" + + if [ ! -d "$ZEPHYR_DIR/zephyr" ]; then + echo "Initializing Zephyr workspace..." + west init -m https://github.com/zephyrproject-rtos/zephyr --mr v4.0.0 "$ZEPHYR_DIR" + (cd "$ZEPHYR_DIR" && west update) + echo "Zephyr workspace ready." + else + echo "Zephyr workspace already exists." + fi + + if [ ! -d "$SDK_DIR" ]; then + echo "Downloading Zephyr SDK $SDK_VER..." + mkdir -p .sdk + curl -fsSL "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v$SDK_VER/zephyr-sdk-''${SDK_VER}_linux-x86_64.tar.xz" \ + -o .sdk/zephyr-sdk.tar.xz + echo "Extracting..." + tar xf .sdk/zephyr-sdk.tar.xz -C .sdk + rm .sdk/zephyr-sdk.tar.xz + + "$SDK_DIR/setup.sh" -t x86_64-zephyr-elf -t aarch64-zephyr-elf + + echo "Patching binaries for NixOS..." + LD_PATH="${pkgs.stdenv.cc.bintools.dynamicLinker}" + find "$SDK_DIR" -type f | while read -r f; do + if file "$f" 2>/dev/null | grep -q "ELF 64-bit.*dynamically linked"; then + patchelf --set-interpreter "$LD_PATH" "$f" 2>/dev/null || true + fi + done + echo "SDK ready." + else + echo "SDK already exists." + fi + + echo "" + echo "All set. Run 'nix develop' to enter the dev shell." + '' + ); + }; + + test = { + type = "app"; + program = toString ( + pkgs.writeShellScript "test" '' + set -euo pipefail + ${zephyrEnv} + + if [ ! -d "$ZEPHYR_BASE" ]; then + echo "Zephyr not found. Run 'nix run .#setup-sdk' first." + exit 1 + fi + + BUILD_DIR="build-test" + if [ ! -f "$BUILD_DIR/build.ninja" ]; then + cmake -B "$BUILD_DIR" -GNinja -DBOARD=qemu_x86 -S tests + fi + + ninja -C "$BUILD_DIR" + ninja -C "$BUILD_DIR" run + '' + ); + }; + + sim = { + type = "app"; + program = toString ( + pkgs.writeShellScript "sim" '' + set -euo pipefail + ${zephyrEnv} + + if [ ! -d "$ZEPHYR_BASE" ]; then + echo "Zephyr not found. Run 'nix run .#setup-sdk' first." + exit 1 + fi + + BUILD_DIR="build-sim" + if [ ! -f "$BUILD_DIR/build.ninja" ]; then + cmake -B "$BUILD_DIR" -GNinja -DBOARD=qemu_x86 -S sim + fi + ninja -C "$BUILD_DIR" + + cleanup() { + kill $(jobs -p) 2>/dev/null || true + wait 2>/dev/null || true + } + trap cleanup EXIT INT TERM + + echo "Starting display sim (Ctrl+C to stop)..." + "${pkgs.python312}/bin/python3" sim/view.py < <( + "${pkgs.ninja}/bin/ninja" -C "$BUILD_DIR" run 2>/dev/null + ) + '' + ); + }; + }; + } + ); +} diff --git a/prj.conf b/prj.conf new file mode 100644 index 0000000..9c61e3a --- /dev/null +++ b/prj.conf @@ -0,0 +1,11 @@ +CONFIG_MAIN_STACK_SIZE=4096 +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048 + +CONFIG_LOG=y +CONFIG_PRINTK=y + +CONFIG_SPI=y +CONFIG_GPIO=y + +CONFIG_NEWLIB_LIBC=y +CONFIG_NEWLIB_LIBC_FLOAT_PRINTF=y diff --git a/sample.yaml b/sample.yaml new file mode 100644 index 0000000..859eefa --- /dev/null +++ b/sample.yaml @@ -0,0 +1,7 @@ +sample: + name: nrfwarch + description: Smartwatch firmware for Xiao nRF54L15 Sense with e-ink display +tests: + test: + build_only: true + platform_allow: xiao_nrf54l15/nrf54l15/cpuapp diff --git a/sim/CMakeLists.txt b/sim/CMakeLists.txt new file mode 100644 index 0000000..eacde5c --- /dev/null +++ b/sim/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.20.0) + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) + +project(nrfwarch_sim) + +target_sources(app PRIVATE + src/main.c + src/epd_sim.c + ../src/watchface.c + ../src/fonts.c +) + +target_include_directories(app PRIVATE ../src) diff --git a/sim/prj.conf b/sim/prj.conf new file mode 100644 index 0000000..c8063ff --- /dev/null +++ b/sim/prj.conf @@ -0,0 +1,6 @@ +CONFIG_SERIAL=y +CONFIG_UART_CONSOLE=y +CONFIG_CONSOLE=y +CONFIG_PRINTK=y +CONFIG_LOG=y +CONFIG_MAIN_STACK_SIZE=8192 diff --git a/sim/src/epd_sim.c b/sim/src/epd_sim.c new file mode 100644 index 0000000..6cc0a77 --- /dev/null +++ b/sim/src/epd_sim.c @@ -0,0 +1,69 @@ +#include "epd_drv.h" + +#include +#include + +#define CHUNK 32 + +static void hex_byte(char *out, uint8_t val) +{ + out[0] = "0123456789abcdef"[val >> 4]; + out[1] = "0123456789abcdef"[val & 0x0F]; +} + +static void dump_frame(const uint8_t *image) +{ + printk("!F!\n"); + char line[CHUNK * 2 + 1]; + for (int i = 0; i < EPD_BUFFER_SIZE; i += CHUNK) { + int n = (EPD_BUFFER_SIZE - i > CHUNK) ? CHUNK : (EPD_BUFFER_SIZE - i); + for (int j = 0; j < n; j++) { + hex_byte(line + j * 2, image[i + j]); + } + line[n * 2] = '\0'; + printk("%s\n", line); + } + printk("!E!\n"); +} + +int epd_init(void) +{ + printk("SIM: epd_init\n"); + return 0; +} + +void epd_full_refresh(const uint8_t *image) +{ + dump_frame(image); +} + +void epd_clear(void) +{ + uint8_t white[EPD_BUFFER_SIZE]; + for (int i = 0; i < EPD_BUFFER_SIZE; i++) { + white[i] = 0xFF; + } + dump_frame(white); +} + +void epd_set_base_image(const uint8_t *image) +{ + dump_frame(image); +} + +void epd_partial_region(const uint8_t *image, size_t len, + int x_start, int x_end, + int y_start, int y_end) +{ + dump_frame(image); +} + +void epd_deep_sleep(void) +{ + printk("SIM: sleep\n"); +} + +void epd_wakeup(void) +{ + printk("SIM: wakeup\n"); +} diff --git a/sim/src/main.c b/sim/src/main.c new file mode 100644 index 0000000..e908c01 --- /dev/null +++ b/sim/src/main.c @@ -0,0 +1,41 @@ +#include +#include + +#include "epd_drv.h" +#include "watchface.h" + +LOG_MODULE_REGISTER(main, LOG_LEVEL_INF); + +void main(void) +{ + int hours = 12; + int minutes = 0; + int battery = 87; + + epd_init(); + LOG_INF("nrfwarch sim started"); + + while (1) { + wf_clear(); + wf_draw_time(hours, minutes); + wf_draw_battery_indicator(battery); + epd_set_base_image(wf_get_buffer()); + + LOG_INF("%02d:%02d bat=%d%%", hours, minutes, battery); + + k_sleep(K_MSEC(800)); + + minutes++; + if (minutes >= 60) { + minutes = 0; + hours++; + if (hours >= 24) { + hours = 0; + } + battery -= 2; + if (battery < 0) { + battery = 87; + } + } + } +} diff --git a/sim/view.py b/sim/view.py new file mode 100644 index 0000000..df36f76 --- /dev/null +++ b/sim/view.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Reads hex-framed display data from stdin, writes PBM, auto-launches feh.""" + +import sys +import os +import signal +import subprocess +import time + +WIDTH = 200 +HEIGHT = 200 +BUFSIZE = (WIDTH * HEIGHT) // 8 +OUT = "/tmp/nrfwarch.pbm" +FRAME_START = "!F!" +FRAME_END = "!E!" + +feh_proc = None + + +def write_pbm(data): + header = f"P4\n{WIDTH} {HEIGHT}\n".encode() + inverted = bytes((~b) & 0xFF for b in data) + tmp = OUT + ".tmp" + with open(tmp, "wb") as f: + f.write(header) + f.write(inverted) + os.replace(tmp, OUT) + + +def launch_feh(): + global feh_proc + if feh_proc is not None and feh_proc.poll() is None: + return + try: + feh_proc = subprocess.Popen( + [ + "feh", + "--force-aliasing", + "--scale", + "auto", + "--auto-reload", + "--title", "nrfwarch sim", + OUT, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except FileNotFoundError: + print("feh not found, writing PBM to " + OUT) + + +def cleanup(*_): + global feh_proc + if feh_proc and feh_proc.poll() is None: + feh_proc.terminate() + sys.exit(0) + + +def main(): + signal.signal(signal.SIGTERM, cleanup) + signal.signal(signal.SIGINT, cleanup) + + write_pbm(bytes([0xFF] * BUFSIZE)) + launch_feh() + + capturing = False + hex_buf = "" + frame_count = 0 + + for line in sys.stdin: + line = line.strip() + if line == FRAME_START: + capturing = True + hex_buf = "" + elif line == FRAME_END: + if capturing and hex_buf: + try: + data = bytes.fromhex(hex_buf) + if len(data) == BUFSIZE: + write_pbm(data) + frame_count += 1 + print(f" frame {frame_count}", file=sys.stderr) + except ValueError: + pass + capturing = False + hex_buf = "" + elif capturing: + hex_buf += line + + +if __name__ == "__main__": + main() diff --git a/src/epd_drv.c b/src/epd_drv.c new file mode 100644 index 0000000..26c9b8d --- /dev/null +++ b/src/epd_drv.c @@ -0,0 +1,246 @@ +#include "epd_drv.h" + +#include +#include +#include +#include +#include +#include + +LOG_MODULE_REGISTER(epd, LOG_LEVEL_INF); + +#define EPD_SPI_BUS DT_NODELABEL(xiao_spi) +#define EPD_CTRL_NODE DT_NODELABEL(epd_ctrl) + +static const struct gpio_dt_spec epd_cs = GPIO_DT_SPEC_GET(EPD_CTRL_NODE, cs_gpios); +static const struct gpio_dt_spec epd_dc = GPIO_DT_SPEC_GET(EPD_CTRL_NODE, dc_gpios); +static const struct gpio_dt_spec epd_rst = GPIO_DT_SPEC_GET(EPD_CTRL_NODE, rst_gpios); +static const struct gpio_dt_spec epd_busy = GPIO_DT_SPEC_GET(EPD_CTRL_NODE, busy_gpios); + +static const struct device *spi_dev; +static struct spi_config spi_cfg; + +#define SWRESET 0x12 +#define DRIVER_OUT 0x01 +#define DATA_ENTRY 0x11 +#define RAM_X_RANGE 0x44 +#define RAM_Y_RANGE 0x45 +#define RAM_X_ADDR 0x4E +#define RAM_Y_ADDR 0x4F +#define BORDER_WF 0x3C +#define TEMP_SENSOR 0x18 +#define UPDATE_CTRL2 0x22 +#define ACTIVATE 0x20 +#define WRITE_RAM_BW 0x24 +#define DEEP_SLEEP 0x10 +#define VCOM 0x2C + +static int wait_busy(void) +{ + int retries = 0; + while (gpio_pin_get_dt(&epd_busy) == 1) { + k_msleep(10); + if (++retries > 3000) { + LOG_ERR("BUSY timeout"); + return -ETIMEDOUT; + } + } + return 0; +} + +static int spi_send_buf(const uint8_t *data, size_t len) +{ + struct spi_buf buf = { .buf = (void *)data, .len = len }; + struct spi_buf_set set = { .buffers = &buf, .count = 1 }; + return spi_write(spi_dev, &spi_cfg, &set); +} + +static int send_command(uint8_t cmd) +{ + gpio_pin_set_dt(&epd_cs, 0); + gpio_pin_set_dt(&epd_dc, 0); + int ret = spi_send_buf(&cmd, 1); + gpio_pin_set_dt(&epd_cs, 1); + return ret; +} + +static int send_data(uint8_t data) +{ + gpio_pin_set_dt(&epd_cs, 0); + gpio_pin_set_dt(&epd_dc, 1); + int ret = spi_send_buf(&data, 1); + gpio_pin_set_dt(&epd_cs, 1); + return ret; +} + +static int send_data_buf(const uint8_t *data, size_t len) +{ + gpio_pin_set_dt(&epd_cs, 0); + gpio_pin_set_dt(&epd_dc, 1); + int ret = spi_send_buf(data, len); + gpio_pin_set_dt(&epd_cs, 1); + return ret; +} + +static void hw_reset(void) +{ + gpio_pin_set_dt(&epd_rst, 0); + k_msleep(20); + gpio_pin_set_dt(&epd_rst, 1); + k_msleep(20); + wait_busy(); +} + +static int init_gpios(void) +{ + if (!gpio_is_ready_dt(&epd_cs) || !gpio_is_ready_dt(&epd_dc) || + !gpio_is_ready_dt(&epd_rst) || !gpio_is_ready_dt(&epd_busy)) { + LOG_ERR("EPD GPIOs not ready"); + return -ENODEV; + } + gpio_pin_configure_dt(&epd_cs, GPIO_OUTPUT_HIGH); + gpio_pin_configure_dt(&epd_dc, GPIO_OUTPUT_LOW); + gpio_pin_configure_dt(&epd_rst, GPIO_OUTPUT_HIGH); + gpio_pin_configure_dt(&epd_busy, GPIO_INPUT); + return 0; +} + +static int init_spi(void) +{ + spi_dev = DEVICE_DT_GET(EPD_SPI_BUS); + if (!device_is_ready(spi_dev)) { + LOG_ERR("SPI device not ready"); + return -ENODEV; + } + memset(&spi_cfg, 0, sizeof(spi_cfg)); + spi_cfg.frequency = 4000000; + spi_cfg.operation = SPI_OP_MODE_MASTER | SPI_TRANSFER_MSB | + SPI_WORD_SET(8) | SPI_LINES_SINGLE; + return 0; +} + +static void set_ram_window(int x_start, int x_end, + int y_start, int y_end) +{ + send_command(RAM_X_RANGE); + send_data(x_start); + send_data(x_end); + send_command(RAM_Y_RANGE); + send_data(y_end & 0xFF); + send_data((y_end >> 8) & 0xFF); + send_data(y_start & 0xFF); + send_data((y_start >> 8) & 0xFF); +} + +static void set_ram_cursor(int x, int y) +{ + send_command(RAM_X_ADDR); + send_data(x); + send_command(RAM_Y_ADDR); + send_data(y & 0xFF); + send_data((y >> 8) & 0xFF); +} + +static void trigger_full_update(void) +{ + send_command(UPDATE_CTRL2); + send_data(0xF7); + send_command(ACTIVATE); + wait_busy(); +} + +static void trigger_partial_update(void) +{ + send_command(UPDATE_CTRL2); + send_data(0xCF); + send_command(ACTIVATE); + wait_busy(); +} + +static void init_panel(void) +{ + send_command(SWRESET); + wait_busy(); + + send_command(DRIVER_OUT); + send_data(0xC7); + send_data(0x00); + send_data(0x00); + + send_command(DATA_ENTRY); + send_data(0x01); + + set_ram_window(0x00, 0x18, 0x00, 0xC7); + + send_command(BORDER_WF); + send_data(0x05); + + send_command(VCOM); + send_data(0xA5); + + send_command(TEMP_SENSOR); + send_data(0x80); +} + +int epd_init(void) +{ + int ret = init_gpios(); + if (ret) return ret; + + ret = init_spi(); + if (ret) return ret; + + hw_reset(); + init_panel(); + LOG_INF("SSD1680 initialized"); + return 0; +} + +void epd_full_refresh(const uint8_t *image) +{ + set_ram_window(0x00, 0x18, 0x00, 0xC7); + set_ram_cursor(0x00, 0xC7); + send_command(WRITE_RAM_BW); + send_data_buf(image, EPD_BUFFER_SIZE); + trigger_full_update(); +} + +static uint8_t white_buf[EPD_BUFFER_SIZE]; +void epd_clear(void) +{ + memset(white_buf, 0xFF, sizeof(white_buf)); + epd_full_refresh(white_buf); +} + +void epd_set_base_image(const uint8_t *image) +{ + set_ram_window(0x00, 0x18, 0x00, 0xC7); + set_ram_cursor(0x00, 0xC7); + send_command(WRITE_RAM_BW); + send_data_buf(image, EPD_BUFFER_SIZE); + trigger_full_update(); +} + +void epd_partial_region(const uint8_t *image, size_t len, + int x_start, int x_end, + int y_start, int y_end) +{ + set_ram_window(x_start, x_end, y_start, y_end); + set_ram_cursor(x_start, y_end); + send_command(WRITE_RAM_BW); + send_data_buf(image, len); + trigger_partial_update(); +} + +void epd_deep_sleep(void) +{ + send_command(DEEP_SLEEP); + send_data(0x01); + k_msleep(100); +} + +void epd_wakeup(void) +{ + hw_reset(); + init_panel(); +} diff --git a/src/epd_drv.h b/src/epd_drv.h new file mode 100644 index 0000000..dd8895d --- /dev/null +++ b/src/epd_drv.h @@ -0,0 +1,23 @@ +#ifndef EPD_DRV_H +#define EPD_DRV_H + +#include +#include + +#define EPD_WIDTH 200 +#define EPD_HEIGHT 200 +#define EPD_BUFFER_SIZE ((EPD_WIDTH * EPD_HEIGHT) / 8) +#define EPD_LINE_BYTES (EPD_WIDTH / 8) +#define EPD_COLUMN_BYTES EPD_HEIGHT + +int epd_init(void); +void epd_full_refresh(const uint8_t *image); +void epd_clear(void); +void epd_set_base_image(const uint8_t *image); +void epd_partial_region(const uint8_t *image, size_t len, + int x_start, int x_end, + int y_start, int y_end); +void epd_deep_sleep(void); +void epd_wakeup(void); + +#endif diff --git a/src/fonts.c b/src/fonts.c new file mode 100644 index 0000000..4a07384 --- /dev/null +++ b/src/fonts.c @@ -0,0 +1,467 @@ +#include "fonts.h" + +const uint8_t font_digits[10][120] = { + { + 0x0F, 0xFF, 0xF0, + 0x0F, 0xFF, 0xF0, + 0x0F, 0xFF, 0xF0, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x0F, 0xFF, 0xF0, + 0x0F, 0xFF, 0xF0, + 0x0F, 0xFF, 0xF0, + }, + { + 0x00, 0x00, 0x00, + 0x0F, 0xFC, 0x00, + 0x0F, 0xFC, 0x00, + 0x0F, 0xFC, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x1C, 0x00, + 0x00, 0x00, 0x00, + }, + { + 0x0F, 0xFF, 0xF0, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x0F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF0, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF0, + 0x0F, 0xFF, 0xF0, + }, + { + 0x0F, 0xFF, 0xF0, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF0, + }, + { + 0x00, 0x00, 0x00, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x00, + }, + { + 0x0F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF0, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF0, + }, + { + 0x0F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF0, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1C, 0x00, 0x00, + 0x1F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF0, + }, + { + 0x0F, 0xFF, 0xF0, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x00, + }, + { + 0x0F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF0, + }, + { + 0x0F, 0xFF, 0xF0, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1C, 0x00, 0x38, + 0x1F, 0xFF, 0xF8, + 0x1F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x00, 0x00, 0x38, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF8, + 0x0F, 0xFF, 0xF0, + }, +}; + +const uint8_t font_colon[40] = { + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x1C, + 0x1C, + 0x1C, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x1C, + 0x1C, + 0x1C, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, +}; diff --git a/src/fonts.h b/src/fonts.h new file mode 100644 index 0000000..44d686b --- /dev/null +++ b/src/fonts.h @@ -0,0 +1,18 @@ +#ifndef FONTS_H +#define FONTS_H + +#include +#include + +#define FONT_DIGIT_W 24 +#define FONT_DIGIT_H 40 +#define FONT_DIGIT_BYTES (FONT_DIGIT_W * FONT_DIGIT_H / 8) + +#define FONT_COLON_W 8 +#define FONT_COLON_H 40 +#define FONT_COLON_BYTES (FONT_COLON_W * FONT_COLON_H / 8) + +extern const uint8_t font_digits[10][FONT_DIGIT_BYTES]; +extern const uint8_t font_colon[FONT_COLON_BYTES]; + +#endif diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..8c0908e --- /dev/null +++ b/src/main.c @@ -0,0 +1,52 @@ +#include +#include + +#include "epd_drv.h" +#include "watchface.h" + +LOG_MODULE_REGISTER(main, LOG_LEVEL_INF); + +int main(void) +{ + int ret; + + LOG_INF("nrfwarch starting"); + + ret = epd_init(); + if (ret) { + LOG_ERR("EPD init failed: %d", ret); + return ret; + } + + wf_clear(); + wf_draw_time(12, 0); + wf_draw_battery_indicator(75); + + LOG_INF("Rendering initial watch face"); + epd_full_refresh(wf_get_buffer()); + LOG_INF("Initial render complete"); + + int hours = 12; + int minutes = 0; + + while (1) { + k_sleep(K_SECONDS(60)); + + minutes++; + if (minutes >= 60) { + minutes = 0; + hours++; + if (hours >= 24) { + hours = 0; + } + } + + wf_clear(); + wf_draw_time(hours, minutes); + wf_draw_battery_indicator(75); + + epd_set_base_image(wf_get_buffer()); + + LOG_INF("Updated: %02d:%02d", hours, minutes); + } +} diff --git a/src/watchface.c b/src/watchface.c new file mode 100644 index 0000000..d683437 --- /dev/null +++ b/src/watchface.c @@ -0,0 +1,115 @@ +#include "watchface.h" +#include "fonts.h" +#include + +static uint8_t framebuffer[WF_BUF_SIZE]; + +void wf_clear(void) +{ + memset(framebuffer, 0xFF, WF_BUF_SIZE); +} + +void wf_set_pixel(int x, int y, int black) +{ + if (x < 0 || x >= WF_WIDTH || y < 0 || y >= WF_HEIGHT) { + return; + } + int ram_row = (WF_HEIGHT - 1) - y; + int byte_idx = ram_row * WF_LINE_BYTES + (x / 8); + int bit = 7 - (x % 8); + + if (black) { + framebuffer[byte_idx] &= ~(1 << bit); + } else { + framebuffer[byte_idx] |= (1 << bit); + } +} + +int wf_get_pixel(int x, int y) +{ + if (x < 0 || x >= WF_WIDTH || y < 0 || y >= WF_HEIGHT) { + return -1; + } + int ram_row = (WF_HEIGHT - 1) - y; + int byte_idx = ram_row * WF_LINE_BYTES + (x / 8); + int bit = 7 - (x % 8); + return (framebuffer[byte_idx] >> bit) & 1; +} + +void wf_draw_glyph(const uint8_t *glyph, int glyph_w, int glyph_h, + int dst_x, int dst_y) +{ + int glyph_line_bytes = glyph_w / 8; + + for (int row = 0; row < glyph_h; row++) { + for (int byte_col = 0; byte_col < glyph_line_bytes; byte_col++) { + uint8_t val = glyph[row * glyph_line_bytes + byte_col]; + for (int bit = 0; bit < 8; bit++) { + if (!(val & (1 << bit))) { + continue; + } + int px = dst_x + byte_col * 8 + (7 - bit); + int py = dst_y + row; + wf_set_pixel(px, py, 1); + } + } + } +} + +#define TIME_DIGIT_W FONT_DIGIT_W +#define TIME_DIGIT_H FONT_DIGIT_H +#define TIME_COLON_W FONT_COLON_W +#define TIME_COLON_H FONT_COLON_H +#define TIME_GAP 4 + +#define TIME_TOTAL_W (4 * TIME_DIGIT_W + TIME_COLON_W + 4 * TIME_GAP) +#define TIME_X_START ((WF_WIDTH - TIME_TOTAL_W) / 2) +#define TIME_Y_START 40 + +void wf_draw_time(int hours, int minutes) +{ + int h1 = (hours / 10) % 10; + int h2 = hours % 10; + int m1 = (minutes / 10) % 10; + int m2 = minutes % 10; + + int x = TIME_X_START; + + wf_draw_glyph(font_digits[h1], TIME_DIGIT_W, TIME_DIGIT_H, x, TIME_Y_START); + x += TIME_DIGIT_W + TIME_GAP; + + wf_draw_glyph(font_digits[h2], TIME_DIGIT_W, TIME_DIGIT_H, x, TIME_Y_START); + x += TIME_DIGIT_W + TIME_GAP; + + wf_draw_glyph(font_colon, TIME_COLON_W, TIME_COLON_H, x, TIME_Y_START); + x += TIME_COLON_W + TIME_GAP; + + wf_draw_glyph(font_digits[m1], TIME_DIGIT_W, TIME_DIGIT_H, x, TIME_Y_START); + x += TIME_DIGIT_W + TIME_GAP; + + wf_draw_glyph(font_digits[m2], TIME_DIGIT_W, TIME_DIGIT_H, x, TIME_Y_START); +} + +void wf_draw_battery_indicator(int percent) +{ + int bar_w = 30; + int bar_h = 8; + int x_start = WF_WIDTH - bar_w - 10; + int y_start = 10; + + for (int x = 0; x < bar_w; x++) { + for (int y = 0; y < bar_h; y++) { + int is_border = (x == 0 || x == bar_w - 1 || + y == 0 || y == bar_h - 1); + int fill_end = 1 + (percent * (bar_w - 2)) / 100; + int is_filled = (x >= 1 && x < fill_end && + y >= 1 && y < bar_h - 1); + wf_set_pixel(x_start + x, y_start + y, is_border || is_filled); + } + } +} + +uint8_t *wf_get_buffer(void) +{ + return framebuffer; +} diff --git a/src/watchface.h b/src/watchface.h new file mode 100644 index 0000000..4669da2 --- /dev/null +++ b/src/watchface.h @@ -0,0 +1,20 @@ +#ifndef WATCHFACE_H +#define WATCHFACE_H + +#include + +#define WF_WIDTH 200 +#define WF_HEIGHT 200 +#define WF_BUF_SIZE ((WF_WIDTH * WF_HEIGHT) / 8) +#define WF_LINE_BYTES (WF_WIDTH / 8) + +void wf_clear(void); +void wf_set_pixel(int x, int y, int black); +int wf_get_pixel(int x, int y); +void wf_draw_glyph(const uint8_t *glyph, int glyph_w, int glyph_h, + int dst_x, int dst_y); +void wf_draw_time(int hours, int minutes); +void wf_draw_battery_indicator(int percent); +uint8_t *wf_get_buffer(void); + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..470c5f5 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.20.0) + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) + +project(nrfwarch_test) + +target_sources(app PRIVATE + src/main.c + ../src/watchface.c + ../src/fonts.c +) + +target_include_directories(app PRIVATE ../src) diff --git a/tests/prj.conf b/tests/prj.conf new file mode 100644 index 0000000..cdca1e9 --- /dev/null +++ b/tests/prj.conf @@ -0,0 +1,2 @@ +CONFIG_ZTEST=y +CONFIG_MAIN_STACK_SIZE=8192 diff --git a/tests/sample.yaml b/tests/sample.yaml new file mode 100644 index 0000000..9e959cc --- /dev/null +++ b/tests/sample.yaml @@ -0,0 +1,7 @@ +sample: + name: nrfwarch tests +tests: + test: + build_only: false + platform_allow: qemu_x86 + tags: testing diff --git a/tests/src/main.c b/tests/src/main.c new file mode 100644 index 0000000..efbb3e1 --- /dev/null +++ b/tests/src/main.c @@ -0,0 +1,217 @@ +#include +#include +#include "watchface.h" +#include "fonts.h" + +ZTEST(nrfwarch, test_wf_clear_sets_all_white) +{ + wf_clear(); + uint8_t *buf = wf_get_buffer(); + for (int i = 0; i < WF_BUF_SIZE; i++) { + zassert_equal(buf[i], 0xFF, + "byte %d should be 0xFF (white), got 0x%02X", i, buf[i]); + } +} + +ZTEST(nrfwarch, test_wf_set_pixel_out_of_bounds) +{ + wf_clear(); + wf_set_pixel(-1, 0, 1); + wf_set_pixel(0, -1, 1); + wf_set_pixel(WF_WIDTH, 0, 1); + wf_set_pixel(0, WF_HEIGHT, 1); + + uint8_t *buf = wf_get_buffer(); + for (int i = 0; i < WF_BUF_SIZE; i++) { + zassert_equal(buf[i], 0xFF, "out-of-bounds pixel modified buffer at byte %d", i); + } +} + +ZTEST(nrfwarch, test_wf_set_pixel_black_top_left) +{ + wf_clear(); + wf_set_pixel(0, WF_HEIGHT - 1, 1); + + uint8_t *buf = wf_get_buffer(); + zassert_equal(buf[0], 0x7F, + "top-left pixel (x=0, y=199) should set bit 7 of byte 0, got 0x%02X", + buf[0]); +} + +ZTEST(nrfwarch, test_wf_set_pixel_black_bottom_left) +{ + wf_clear(); + wf_set_pixel(0, 0, 1); + + uint8_t *buf = wf_get_buffer(); + int last_row = (WF_HEIGHT - 1) * WF_LINE_BYTES; + zassert_equal(buf[last_row], 0x7F, + "bottom-left pixel (x=0, y=0) should set bit 7 of last row byte, got 0x%02X", + buf[last_row]); +} + +ZTEST(nrfwarch, test_wf_set_then_clear_pixel) +{ + wf_clear(); + wf_set_pixel(10, 10, 1); + int after_black = wf_get_pixel(10, 10); + zassert_equal(after_black, 0, "pixel should be black (0), got %d", after_black); + + wf_set_pixel(10, 10, 0); + int after_white = wf_get_pixel(10, 10); + zassert_equal(after_white, 1, "pixel should be white (1), got %d", after_white); +} + +ZTEST(nrfwarch, test_wf_get_pixel_out_of_bounds) +{ + zassert_equal(wf_get_pixel(-1, 0), -1); + zassert_equal(wf_get_pixel(0, -1), -1); + zassert_equal(wf_get_pixel(WF_WIDTH, 0), -1); + zassert_equal(wf_get_pixel(0, WF_HEIGHT), -1); +} + +ZTEST(nrfwarch, test_font_digits_size) +{ + for (int d = 0; d < 10; d++) { + size_t expected = (size_t)(FONT_DIGIT_W * FONT_DIGIT_H / 8); + zassert_equal(sizeof(font_digits[d]), expected, + "digit %d: expected %zu bytes, got %zu", + d, expected, sizeof(font_digits[d])); + } +} + +ZTEST(nrfwarch, test_font_colon_size) +{ + size_t expected = (size_t)(FONT_COLON_W * FONT_COLON_H / 8); + zassert_equal(sizeof(font_colon), expected, + "colon: expected %zu bytes, got %zu", + expected, sizeof(font_colon)); +} + +ZTEST(nrfwarch, test_font_digit_not_all_ff) +{ + for (int d = 0; d < 10; d++) { + int all_ff = 1; + for (size_t i = 0; i < sizeof(font_digits[d]); i++) { + if (font_digits[d][i] != 0xFF) { + all_ff = 0; + break; + } + } + zassert_false(all_ff, "digit %d is all 0xFF (empty/white)", d); + } +} + +ZTEST(nrfwarch, test_font_colon_not_all_ff) +{ + int all_ff = 1; + for (size_t i = 0; i < sizeof(font_colon); i++) { + if (font_colon[i] != 0xFF) { + all_ff = 0; + break; + } + } + zassert_false(all_ff, "colon is all 0xFF (empty/white)"); +} + +ZTEST(nrfwarch, test_wf_draw_time_modifies_buffer) +{ + wf_clear(); + wf_draw_time(12, 34); + + uint8_t *buf = wf_get_buffer(); + int any_black = 0; + for (int i = 0; i < WF_BUF_SIZE; i++) { + if (buf[i] != 0xFF) { + any_black = 1; + break; + } + } + zassert_true(any_black, "draw_time produced no visible pixels"); +} + +ZTEST(nrfwarch, test_wf_draw_time_different_times) +{ + wf_clear(); + wf_draw_time(11, 11); + uint8_t buf_a[WF_BUF_SIZE]; + memcpy(buf_a, wf_get_buffer(), WF_BUF_SIZE); + + wf_clear(); + wf_draw_time(22, 22); + uint8_t *buf_b = wf_get_buffer(); + + int differs = 0; + for (int i = 0; i < WF_BUF_SIZE; i++) { + if (buf_a[i] != buf_b[i]) { + differs = 1; + break; + } + } + zassert_true(differs, "different times produce identical buffers"); +} + +ZTEST(nrfwarch, test_wf_draw_battery_indicator) +{ + wf_clear(); + wf_draw_battery_indicator(100); + + uint8_t *buf = wf_get_buffer(); + int any_black = 0; + for (int i = 0; i < WF_BUF_SIZE; i++) { + if (buf[i] != 0xFF) { + any_black = 1; + break; + } + } + zassert_true(any_black, "battery indicator produced no visible pixels"); +} + +ZTEST(nrfwarch, test_wf_draw_battery_zero_vs_full) +{ + wf_clear(); + wf_draw_battery_indicator(0); + uint8_t buf_empty[WF_BUF_SIZE]; + memcpy(buf_empty, wf_get_buffer(), WF_BUF_SIZE); + + wf_clear(); + wf_draw_battery_indicator(100); + uint8_t *buf_full = wf_get_buffer(); + + int differs = 0; + for (int i = 0; i < WF_BUF_SIZE; i++) { + if (buf_empty[i] != buf_full[i]) { + differs = 1; + break; + } + } + zassert_true(differs, "0%% and 100%% battery look identical"); +} + +ZTEST(nrfwarch, test_epd_buffer_size) +{ + zassert_equal(WF_BUF_SIZE, 5000, "200x200 / 8 should be 5000, got %d", WF_BUF_SIZE); + zassert_equal(WF_LINE_BYTES, 25, "200/8 should be 25, got %d", WF_LINE_BYTES); +} + +ZTEST(nrfwarch, test_wf_draw_glyph_modifies_pixels) +{ + wf_clear(); + + uint8_t test_glyph[] = { 0xFF }; + wf_draw_glyph(test_glyph, 8, 1, 50, 50); + + zassert_equal(wf_get_pixel(50, 50), 0, "pixel should be black after drawing all-ones glyph"); +} + +ZTEST(nrfwarch, test_wf_pixel_symmetry) +{ + wf_clear(); + wf_set_pixel(100, 100, 1); + zassert_equal(wf_get_pixel(100, 100), 0, "center pixel should be black"); + + wf_set_pixel(100, 100, 0); + zassert_equal(wf_get_pixel(100, 100), 1, "center pixel should be white after clear"); +} + +ZTEST_SUITE(nrfwarch, NULL, NULL, NULL, NULL, NULL); diff --git a/tools/gen_fonts.py b/tools/gen_fonts.py new file mode 100644 index 0000000..44a080d --- /dev/null +++ b/tools/gen_fonts.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Generate bitmap digit/colon fonts as C arrays for the e-ink watchface. + +Renders digits as stroked outlines onto a pixel grid. +'#' = black (drawn), '.' = white (background). +Bit=0 is black (drawn), bit=1 is white (background) in the output. +""" + +DIGIT_W = 24 +DIGIT_H = 40 +COLON_W = 8 +COLON_H = 40 + + +def make_grid(w, h): + return [["." for _ in range(w)] for _ in range(h)] + + +def fill_rect(grid, x1, y1, x2, y2): + for y in range(max(0, y1), min(len(grid), y2 + 1)): + for x in range(max(0, x1), min(len(grid[0]), x2 + 1)): + grid[y][x] = "#" + + +def draw_hline(grid, y, x1, x2, thickness=2): + fill_rect(grid, x1, y - thickness // 2, x2, y + thickness // 2) + + +def draw_vline(grid, x, y1, y2, thickness=2): + fill_rect(grid, x - thickness // 2, y1, x + thickness // 2, y2) + + +def draw_digit(d): + g = make_grid(DIGIT_W, DIGIT_H) + W = DIGIT_W + H = DIGIT_H + t = 2 # stroke thickness + m = 3 # margin + + top = t // 2 + bot = H - t // 2 - 1 + left = m + t // 2 + right = W - m - t // 2 - 1 + mid_y = H // 2 + + if d == 0: + draw_vline(g, left, top + t, bot - t, t) + draw_vline(g, right, top + t, bot - t, t) + draw_hline(g, top, left, right, t) + draw_hline(g, bot, left, right, t) + elif d == 1: + draw_vline(g, W // 2, top, bot, t) + fill_rect(g, left, top, W // 2, top + t) + elif d == 2: + draw_hline(g, top, left, right, t) + draw_vline(g, right, top, mid_y, t) + draw_hline(g, mid_y, left, right, t) + draw_vline(g, left, mid_y, bot, t) + draw_hline(g, bot, left, right, t) + elif d == 3: + draw_hline(g, top, left, right, t) + draw_vline(g, right, top, bot, t) + draw_hline(g, mid_y, left, right, t) + draw_hline(g, bot, left, right, t) + elif d == 4: + draw_vline(g, left, top, mid_y, t) + draw_hline(g, mid_y, left, right, t) + draw_vline(g, right, top, bot, t) + elif d == 5: + draw_hline(g, top, left, right, t) + draw_vline(g, left, top, mid_y, t) + draw_hline(g, mid_y, left, right, t) + draw_vline(g, right, mid_y, bot, t) + draw_hline(g, bot, left, right, t) + elif d == 6: + draw_hline(g, top, left, right, t) + draw_vline(g, left, top, bot, t) + draw_hline(g, mid_y, left, right, t) + draw_vline(g, right, mid_y, bot, t) + draw_hline(g, bot, left, right, t) + elif d == 7: + draw_hline(g, top, left, right, t) + draw_vline(g, right, top, bot, t) + elif d == 8: + draw_hline(g, top, left, right, t) + draw_hline(g, mid_y, left, right, t) + draw_hline(g, bot, left, right, t) + draw_vline(g, left, top, bot, t) + draw_vline(g, right, top, bot, t) + elif d == 9: + draw_hline(g, top, left, right, t) + draw_vline(g, left, top, mid_y, t) + draw_hline(g, mid_y, left, right, t) + draw_vline(g, right, top, bot, t) + draw_hline(g, bot, left, right, t) + + return ["".join(row) for row in g] + + +def draw_colon(): + g = make_grid(COLON_W, COLON_H) + cx = COLON_W // 2 + t = 2 + fill_rect(g, cx - 1, COLON_H // 4 - 1, cx + 1, COLON_H // 4 + 1) + fill_rect(g, cx - 1, 3 * COLON_H // 4 - 1, cx + 1, 3 * COLON_H // 4 + 1) + return ["".join(row) for row in g] + + +def pattern_to_bytes(pattern, width): + rows = [] + for row_str in pattern: + for byte_idx in range(width // 8): + byte_val = 0 + for bit in range(8): + col = byte_idx * 8 + bit + if col < len(row_str) and row_str[col] == "#": + byte_val |= 1 << (7 - bit) + rows.append(byte_val) + return rows + + +def format_c_array(name, data, width): + entry_bytes = width // 8 + lines = [] + for i in range(0, len(data), entry_bytes): + chunk = data[i : i + entry_bytes] + lines.append("\t" + ", ".join(f"0x{b:02X}" for b in chunk) + ",") + return f"static const uint8_t {name}[] = {{\n" + "\n".join(lines) + "\n};" + + +def main(): + digit_patterns = {str(d): draw_digit(d) for d in range(10)} + colon_pat = draw_colon() + + h_lines = [ + "#ifndef FONTS_H", + "#define FONTS_H", + "", + "#include ", + "#include ", + "", + f"#define FONT_DIGIT_W {DIGIT_W}", + f"#define FONT_DIGIT_H {DIGIT_H}", + f"#define FONT_DIGIT_BYTES (FONT_DIGIT_W * FONT_DIGIT_H / 8)", + "", + f"#define FONT_COLON_W {COLON_W}", + f"#define FONT_COLON_H {COLON_H}", + f"#define FONT_COLON_BYTES (FONT_COLON_W * FONT_COLON_H / 8)", + "", + "extern const uint8_t font_digits[10][FONT_DIGIT_BYTES];", + "extern const uint8_t font_colon[FONT_COLON_BYTES];", + "", + "#endif", + "", + ] + + c_lines = [ + '#include "fonts.h"', + "", + ] + + entry_bytes = DIGIT_W // 8 + entries = [] + for d in range(10): + data = pattern_to_bytes(digit_patterns[str(d)], DIGIT_W) + rows = [] + for i in range(0, len(data), entry_bytes): + chunk = data[i : i + entry_bytes] + rows.append("\t\t" + ", ".join(f"0x{b:02X}" for b in chunk) + ",") + entries.append("\t{\n" + "\n".join(rows) + "\n\t},") + + total = DIGIT_W * DIGIT_H // 8 + c_lines.append(f"const uint8_t font_digits[10][{total}] = {{") + c_lines.extend(entries) + c_lines.append("};") + c_lines.append("") + + colon_data = pattern_to_bytes(colon_pat, COLON_W) + colon_total = COLON_W * COLON_H // 8 + colon_rows = [] + ceb = COLON_W // 8 + for i in range(0, len(colon_data), ceb): + chunk = colon_data[i : i + ceb] + colon_rows.append("\t" + ", ".join(f"0x{b:02X}" for b in chunk) + ",") + c_lines.append(f"const uint8_t font_colon[{colon_total}] = {{") + c_lines.extend(colon_rows) + c_lines.append("};") + c_lines.append("") + + return "\n".join(h_lines), "\n".join(c_lines) + + +if __name__ == "__main__": + header, source = main() + with open("src/fonts.h", "w") as f: + f.write(header) + with open("src/fonts.c", "w") as f: + f.write(source) + print(f"Generated fonts.h ({len(header)} bytes) and fonts.c ({len(source)} bytes)") + print(f"Digit size: {DIGIT_W}x{DIGIT_H} = {DIGIT_W * DIGIT_H // 8} bytes each") + print(f"Colon size: {COLON_W}x{COLON_H} = {COLON_W * COLON_H // 8} bytes")