ESP32-S3 / ESP-IDF C++ firmware for BTClock — block height, price, fees, halving, sats-per-currency, mining-pool and Bitaxe stats on a multi-panel e-paper display, with NeoPixel and frontlight feedback.
  • C 53.2%
  • C++ 41.6%
  • JavaScript 1.6%
  • Python 1.1%
  • Shell 0.9%
  • Other 1.6%
Find a file
Djuri Baars 23a8f4c26b
All checks were successful
Lint / format (push) Successful in 1m50s
Host tests / host_tests (push) Successful in 5m25s
Host tests / coverage (push) Successful in 6m53s
Lint / tidy (push) Successful in 5m9s
Host tests / sanitize (push) Successful in 9m9s
fix(currencies): expose the fetched upstream catalogue in /api/settings
availableCurrencies was stuck on the seeded 7-code subset even though
RefreshUpstreamCurrencies fetched the full /api/v2/currencies list (165
codes) on the first STA connect. The fetch updated the AppCtx vector,
but GET /api/settings builds its DeviceContext from the control server's
own config copy, which was seeded at boot and never refreshed — so the
WebUI currency dropdown never saw the upstream set.

- Add ControlServer::SetAvailableCurrencies and call it from
  RefreshUpstreamCurrencies so the catalogue propagates to /api/settings
  (same main-writes / httpd-reads shape as SetCurrencies).
- Harden FetchAvailableCurrencies: the 1 KiB body cap sat at ~97%
  (992 B / 165 codes) — one more code upstream would have truncated the
  whole list back to the seed. Raise to 8 KiB, and retry the one-shot
  first-connect fetch a few times so a transient TLS/timeout at GOT_IP
  doesn't strand the device on the seeded set until a reboot.

Verified on Rev B: /api/settings.availableCurrencies now returns 165.
2026-07-02 00:24:53 +02:00
.forgejo chore(idf): bump toolchain to ESP-IDF v6.0.2 2026-07-01 17:45:40 +02:00
.github/workflows chore(idf): bump toolchain to ESP-IDF v6.0.2 2026-07-01 17:45:40 +02:00
components fix(currencies): expose the fetched upstream catalogue in /api/settings 2026-07-02 00:24:53 +02:00
data@ae396280c6 chore(webui): bump submodule to v4 ae396280 2026-07-01 18:06:35 +02:00
docs chore(idf): bump toolchain to ESP-IDF v6.0.2 2026-07-01 17:45:40 +02:00
main fix(currencies): expose the fetched upstream catalogue in /api/settings 2026-07-02 00:24:53 +02:00
test_host fix(screens): make panel-text builders runtime-N 2026-07-01 21:11:11 +02:00
tools chore(idf): bump toolchain to ESP-IDF v6.0.2 2026-07-01 17:45:40 +02:00
.clang-format Settings single-source-of-truth refactor, mining-pool logo retry with exponential backoff, lint stack (clang-format + clang-tidy + ASan + UBSan + gcovr coverage CI), suffix/share-dot fixes, supply tail width, debug-overlay polish 2026-05-06 10:00:00 +02:00
.clang-tidy Settings single-source-of-truth refactor, mining-pool logo retry with exponential backoff, lint stack (clang-format + clang-tidy + ASan + UBSan + gcovr coverage CI), suffix/share-dot fixes, supply tail width, debug-overlay polish 2026-05-06 10:00:00 +02:00
.git-blame-ignore-revs Repository polish + Nostr schnorr verification + mDNS live re-advertisement + button-mapping fix + NeoPixel pause/resume sweeps with braking metaphor 2026-05-06 10:00:01 +02:00
.gitignore fix(btclock_data): bounce hub on silent v2 blockheight subscription drop 2026-05-23 00:55:36 +02:00
.gitmodules chore(gitmodules): track WebUI submodule branch v4 2026-05-09 18:01:46 +02:00
CLAUDE.md chore(idf): bump toolchain to ESP-IDF v6.0.2 2026-07-01 17:45:40 +02:00
CMakeLists.txt feat(epd): add BTCLOCK_EPD_SPI3 option to move EPD bus to SPI3 2026-07-01 17:20:28 +02:00
maintainers.yaml chore: add maintainers.yaml for Nostr git client discovery 2026-05-06 10:00:30 +02:00
Makefile Comprehensive documentation overhaul: WebUI screenshot pipeline, off-device WASM screen renderer, mkdocs-material site with i18n, A5 booklet via pandoc + xelatex, QUICKSTART/HANDBOOK/SETTINGS/ARCHITECTURE/STORY pages, NL/DE/ES translations 2026-05-06 10:00:01 +02:00
mkdocs-requirements.txt Comprehensive documentation overhaul: WebUI screenshot pipeline, off-device WASM screen renderer, mkdocs-material site with i18n, A5 booklet via pandoc + xelatex, QUICKSTART/HANDBOOK/SETTINGS/ARCHITECTURE/STORY pages, NL/DE/ES translations 2026-05-06 10:00:01 +02:00
mkdocs.yml docs(mkdocs): exclude build/ from the site build 2026-05-26 14:20:02 +02:00
partitions_4mb.csv docs(partitions): correct the "reserved for future growth" tail comment 2026-05-06 10:00:19 +02:00
partitions_8mb.csv docs(partitions): correct the "reserved for future growth" tail comment 2026-05-06 10:00:19 +02:00
partitions_16mb.csv Mining pools, WASM preview scaffold, TLS gate, LittleFS, frontlight (PCA9685), control API, Nostr component skeleton 2026-05-06 09:59:57 +02:00
README.md chore(idf): bump toolchain to ESP-IDF v6.0.2 2026-07-01 17:45:40 +02:00
sdkconfig.defaults chore(idf): bump toolchain to ESP-IDF v6.0.2 2026-07-01 17:45:40 +02:00
sdkconfig.defaults.rev_a build(rev_a): strip Enterprise WiFi + high-speed TCP retransmission 2026-05-17 17:43:41 +02:00
sdkconfig.defaults.rev_b Initial ESP-IDF C++ scaffold: EPD bring-up, WiFi captive portal, fonts, REV A/B/V8 boards, host tests, first screens 2026-05-06 09:59:57 +02:00
sdkconfig.defaults.v8 chore(idf): bump toolchain to ESP-IDF v6.0.2 2026-07-01 17:45:40 +02:00

BTClock v4

Firmware for the BTClock — an ESP32-S3 device that displays Bitcoin network data (block height, price, fee rate, halving countdown, sats-per-currency, market cap, supply, mining-pool stats, Bitaxe metrics) on e-paper panels with NeoPixel and frontlight feedback.

Three hardware variants share one codebase:

Variant Flash PSRAM Default panel Notes
Rev A 4 MB 2 MB 2.13" no BH1750, no frontlight
Rev B 8 MB 2 MB 2.13" BH1750 ambient sensor, frontlight
V8 16 MB 8 MB 2.13" 8 panels

Arduino-era history lives in the old repo; v4 was a clean ESP-IDF C++ rewrite of that codebase.

Build

Source the IDF environment, then build per variant. BTCLOCK_BOARD picks the pin map; BTCLOCK_PANEL picks the EPD geometry — the two are independent, so any board × any panel combo configures (default panel is 2_13 for every board). BTCLOCK_PANEL=2_9 swaps to the 2.9" GDEY029T94; BTCLOCK_PANEL=7_5 (GDEY075T7, 800×480) is scaffolded but un-flashed today. Each variant keeps its own sdkconfig so they don't poison each other:

source ~/esp/v6.0/esp-idf/export.sh

idf.py -B build-rev-a    -D BTCLOCK_BOARD=REV_A -D BTCLOCK_PANEL=2_13 -D SDKCONFIG=build-rev-a/sdkconfig    build
idf.py -B build-rev-a-29 -D BTCLOCK_BOARD=REV_A -D BTCLOCK_PANEL=2_9  -D SDKCONFIG=build-rev-a-29/sdkconfig build
idf.py -B build-rev-b    -D BTCLOCK_BOARD=REV_B -D BTCLOCK_PANEL=2_13 -D SDKCONFIG=build-rev-b/sdkconfig    build
idf.py -B build-v8       -D BTCLOCK_BOARD=V8    -D BTCLOCK_PANEL=2_13 -D SDKCONFIG=build-v8/sdkconfig       build

Required toolchain: ESP-IDF v6.0.2 (v5.5.4 still works as a fallback).

Flash

Identify your device's serial port (typically /dev/ttyUSB* or /dev/ttyACM* on Linux, /dev/cu.usbmodem* on macOS, COM* on Windows) — ports are not stable across sessions, so re-check each time.

Then flash with the build's flash_args file:

cd build-rev-a && \
  esptool.py --chip esp32s3 --port <PORT> -b 460800 \
    --before default_reset --after hard_reset write_flash "@flash_args"

OTA is also supported as a fallback when USB-JTAG is contested by the running firmware:

curl -X POST -H "Content-Type: application/octet-stream" \
  --data-binary @build-rev-b/btclock_v4.bin \
  http://<IP>/upload/firmware

OTA respects httpAuthEnabled — pass -u user:pass when auth is on.

Crash diagnostics (coredump)

Rev B and V8 capture panic backtraces to a dedicated coredump partition (64 KiB at the end of flash). When a crash happens, the next boot logs coredump from previous run present (N bytes). Pull it off the device as an ELF and decode with espcoredump.py:

curl -o dump.elf http://<IP>/api/coredump        # 404 if no dump
espcoredump.py info_corefile -c dump.elf build-rev-b/btclock_v4.elf
curl -X DELETE http://<IP>/api/coredump          # clear after decode

Both endpoints respect httpAuthEnabled — add -u user:pass when auth is on. Rev A disables coredump capture (the 4 MB flash leaves no headroom in the app partition); panics on Rev A still print to serial but aren't persisted across reboots.

WebUI (LittleFS image)

The WebUI ships as a separate LittleFS partition. Source assets live under data/build_gz/www/; the firmware serves them from /lfs/www. Pack and flash per variant:

MKLFS=tools/mklittlefs/mklittlefs

# Rev A (4 MB)
$MKLFS --create data/build_gz --size 0x67000  --block 4096 --page 256 build-rev-a/storage.bin
# Rev B (8 MB)
$MKLFS --create data/build_gz --size 0xCD000  --block 4096 --page 256 build-rev-b/storage.bin
# V8 (16 MB)
$MKLFS --create data/build_gz --size 0x200000 --block 4096 --page 256 build-v8/storage.bin

# Flash at the per-variant offset (Rev A shown):
python -m esptool --chip esp32s3 --port <PORT> -b 460800 \
  write_flash 0x370000 build-rev-a/storage.bin

Per-variant offsets: Rev A 0x370000, Rev B 0x6F0000, V8 0xDF0000.

If the vendored mklittlefs binary is missing on a fresh clone, run tools/mklittlefs/fetch.sh to fetch it.

Host tests

A subset of the codebase (rendering layout, fee-rate parsing, panel-text formatting, settings PATCH validation, LED prefs migration, partition-table sanity) runs on the host without the IDF toolchain:

cmake -S test_host -B build-host && cmake --build build-host && \
  ./build-host/btclock_host_tests

Do not source the IDF env for these — they use the system toolchain.

Linting

Style is enforced by clang-format (config at .clang-format) and clang-tidy (config at .clang-tidy):

tools/lint/format.sh           # format in place
tools/lint/format.sh --check   # CI-style verify
tools/lint/tidy.sh             # static analysis (advisory today)

format.sh covers components/, main/, test_host/ (excluding vendor/ and build*/). tidy.sh runs against the host-test sources because they're the only TU set with a tractable include graph; for firmware-side TUs run clang-tidy -p build-rev-b path/to/file.cpp locally. macOS users need brew install clang-format llvm; the LLVM formula provides clang-tidy. CI is pinned to LLVM 22 — output between majors drifts (Include block grouping, line wrapping heuristics), so use a matching version locally to avoid sweep churn. Brew currently ships LLVM 22.

Both the format check and the tidy job run in CI (lint.yaml) and gate merges. WarningsAsErrors is * in .clang-tidy, so any new violation of the configured checks fails the build — fix or NOLINT before merging.

Sanitizers

ASan + UBSan catch use-after-free, OOB reads, signed overflow, and misaligned loads that boot fine on macOS local but misbehave on the ESP32-S3. The host-test suite has an opt-in sanitizer build (default OFF, gated in CI):

cmake -S test_host -B build-host-san -DBTCLOCK_HOST_TESTS_SANITIZE=ON
cmake --build build-host-san
ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1 \
  ./build-host-san/btclock_host_tests

macOS ASan does not implement leak detection — drop detect_leaks=1 locally on macOS; CI runs Linux where leaks are flagged.

Fuzzing

libFuzzer harnesses cover the hand-rolled parsers most exposed to external bytes — the NIP-01 envelope parser (components/nostr/src/parser.cpp) and the HTTP query-string decoder (components/webserver/url_decode.cpp). Off by default; clang-only. Apple clang ships without libFuzzer, so on macOS use Homebrew LLVM:

cmake -S test_host -B build-fuzz -DBTCLOCK_FUZZ=ON \
  -DCMAKE_C_COMPILER=/opt/homebrew/opt/llvm/bin/clang \
  -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++
cmake --build build-fuzz
mkdir -p build-fuzz/url_decode_workdir
./build-fuzz/url_decode_fuzzer build-fuzz/url_decode_workdir \
  test_host/fuzz_corpus/url_decode/ -max_total_time=300

Run with a workdir that is not the seed-corpus directory — libFuzzer auto-saves discoveries into the first positional arg, and pointing it at the version-controlled seed dir would pollute the tree with thousands of hash-named files. Replace url_decode_fuzzer

  • url_decode/ with nostr_parser_fuzzer + nostr_parser/ to fuzz the Nostr parser instead.

Coverage

Optional gcov instrumentation produces an HTML coverage report. CI runs this as an informational job and uploads the HTML as an artifact (retention 14 days).

cmake -S test_host -B build-host-cov -DBTCLOCK_HOST_TESTS_COVERAGE=ON
cmake --build build-host-cov && ./build-host-cov/btclock_host_tests
gcovr --root . --filter 'components/' --filter 'main/' \
  --exclude 'test_host/' --exclude '.*vendor/' \
  --html-details build-host-cov/coverage.html --print-summary \
  build-host-cov/
open build-host-cov/coverage.html

macOS local needs brew install gcovr. Coverage flags work with both gcc and clang.

Layout

  • main/ — application entry, screen renderers, board headers
  • components/ — reusable subsystems (data sources, EPD driver, LEDs, settings, web server, Nostr, etc.)
  • data/ — WebUI submodule (Svelte; built into data/build_gz/)
  • tools/ — flash helpers, WASM preview, font/timezone/NVS generators, pool-logo converter, EPD bring-up sketch, Nostr zap watcher, mklittlefs wrapper
  • test_host/ — host-side regression suite
  • partitions_*mb.csv — partition tables per flash size

CI

Forgejo Actions drive two pipelines:

  • host_tests.yaml — runs the host regression suite on every push and PR; also runs an ASan + UBSan build (gating) and a gcov coverage build (informational, uploads HTML report as an artifact).
  • lint.yaml — clang-format check (gating) + clang-tidy (advisory).
  • release.yaml — tag-triggered ([0-9]*); gates on host tests, builds the WebUI once, packs per-size LittleFS images, then matrix-builds firmware for rev-a, rev-a-29, rev-b, v8 and attaches the flat per-variant btclock_<variant>_ota.bin (+ .sha256), shared support binaries, and a top-level manifest.json (per-variant board / panel / firmware SHA / WebUI submodule SHA / IDF version / sha256s + md5s for esp-web-tools flash verify) to the Forgejo release. CI currently pins espressif/idf:v6.0.2.

Documentation

User-facing:

Developer-facing: