Setup

Hardware

  • Adafruit Feather ESP32-S3 (4 MB flash, 2 MB PSRAM)

  • MAX17048 fuel gauge (on the Feather: I²C 0x36, SDA GPIO 3, SCL GPIO 4, powered via GPIO 7)

  • Waveshare 4.2” e-Paper Module (SKU 13353, 400×300, b/w, SSD1683), see Wiring

  • A LiPo battery (2000 mAh here) on the Feather’s JST connector

Flashing MicroPython

MicroPython v1.29.0 (ESP32_GENERIC_S3); the standard variant fits 4 MB of flash:

curl -o firmware/ESP32_GENERIC_S3-20260824-v1.29.0.bin \
  https://micropython.org/resources/firmware/ESP32_GENERIC_S3-20260824-v1.29.0.bin
# ROM bootloader: hold BOOT, press Reset, release BOOT
uvx esptool --chip esp32s3 erase-flash
uvx esptool --chip esp32s3 write-flash 0x0 firmware/ESP32_GENERIC_S3-20260824-v1.29.0.bin
# then press Reset once

Debian’s esptool 4.7 package is broken for the ESP32-S3 (missing stub), hence uvx esptool.

Configuration

The settings come in two parts: src/defaults.py holds all defaults and is versioned; src/config.py belongs to your installation and is ignored by git. It starts with from defaults import * and adds the credentials (WLAN, Home Assistant, MQTT) and whatever should differ from the defaults.

cp src/config_example.py src/config.py   # fill it in; it is never committed
make deploy
make watch

WIFI_NETWORKS may list several networks (at home and on a boat, say). The list order is the priority: the first network in range wins, hidden networks (not in the scan) come last. If the first network in the list worked last time (remembered in wlan_last.txt), the board connects without a scan; otherwise it scans on every connect, so that at home it returns to the home network instead of, for example, a travel router brought along.

Development tools

Requires uv; uv sync installs the dependencies (pytest, ruff, mpremote, mpy-cross, …).

make check        # ruff + pytest (including the CPython/MicroPython comparison)
make format       # format the code
make docs         # build this documentation into docs/_build/html
make deploy       # compile the modules (.mpy), copy them to the board via USB, restart
make deploy-wlan  # upload changed files over the WLAN and restart (HOST=…)
make monitor      # print the battery state every 5 s via USB (Ctrl+C ends)
make watch        # query the battery state every 5 s via WLAN (HOST=epaper.local)
make fonts        # regenerate the bitmap fonts from DejaVu Sans
make icons        # regenerate the weather icons (Phosphor Icons Bold)
make micropython  # build the MicroPython unix port (for the comparison in make check)
uv run tools/preview.py preview.png [--live] [--charging] [--link URL] [--alerts ID …]
uv run tools/board_tiles.py pull|push   # tile settings as a local file (tiles.json)
uv run tools/serve_ui.py   # try the web interface on the PC (http://localhost:8080/)
uv run tools/qr_sticker.py sticker.png [--size-mm 30]   # QR code to print
uv run tools/battery_forecast.py [--since …]   # discharge rate and runtime from Home Assistant

When deploying, all modules except boot.py, main.py and config.py are precompiled with mpy-cross; that saves import time on every wake (the large fonts load in 0.2 instead of 1.1 s). Replaced .py files are deleted on the board.

Versions

Versions follow Semantic Versioning and are git tags (v0.2.0). When building the firmware, tools/firmware.py adds version.mpy with the output of git describe: 0.2.0 for a release, 0.2.0-3-g6ec6236 three commits later, with -dirty for uncommitted changes. The board shows it in the boot sequence, in the web interface and as firmware version of the device in Home Assistant; the docs show the same version.

To release, tag and push:

git tag -a v0.2.0 -m "Version 0.2.0"
git push origin main v0.2.0

Updates over the WLAN

make deploy-wlan works at any time. If the board is asleep, it switches on the “Maintenance mode” switch via MQTT and waits until the board sees it at its next data update (during the day within 10 minutes, at night or with a low battery up to an hour) and stays awake; afterwards it switches it off again. --no-reset skips the restart after the upload, for instance during a battery measurement.

The update endpoints are protected with UPDATE_PASSWORD from config.py (or WEBREPL_PASSWORD as a fallback). A watchdog restarts the board if main.py hangs for longer than WATCHDOG_S, even when interrupted via mpremote.