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.3%
  • C++ 41.5%
  • JavaScript 1.6%
  • Python 1.1%
  • Shell 0.9%
  • Other 1.6%
Find a file
Djuri Baars 26d64f9e8e
All checks were successful
Lint / format (push) Successful in 39s
Lint / tidy (push) Successful in 2m10s
Host tests / sanitize (push) Successful in 26s
Host tests / coverage (push) Successful in 28s
Host tests / host_tests (push) Successful in 2m4s
Release pipeline / host_tests (push) Successful in 33s
Release pipeline / webui_and_lfs (push) Successful in 1m4s
Release pipeline / firmware (REV_A, 2_13, 4mb, 0x370000, build-rev-a, rev-a) (push) Successful in 1m47s
Release pipeline / firmware (REV_B, 2_13, 8mb, 0x6F0000, build-rev-b, rev-b) (push) Successful in 1m50s
Release pipeline / firmware (V8, 2_13, 16mb, 0xDF0000, build-v8, v8) (push) Successful in 1m45s
Release pipeline / firmware (REV_A, 2_9, 4mb, 0x370000, build-rev-a-29, rev-a-29) (push) Successful in 8m54s
Release pipeline / release (push) Successful in 58s
feat(wifi): non-blocking boot + concurrent APSTA provisioning fallback
Boot no longer blocks waiting for a WiFi IP. With saved creds the STA
connect is kicked off non-blocking and boot proceeds; NetworkCoordinator
(ticked by the event loop) then drives the rest:
- first STA connect: start SNTP + the upstream currency fetch; run the boot
  tail (FinishBoot) once the first data lands, so the spinner spins until
  data is in;
- ~20s grace with no connection: bring up the SoftAP + captive portal
  CONCURRENTLY (APSTA) while STA keeps retrying forever -- no reboot, no
  credential wipe;
- reconnect: tear the portal down and return to STA-only.

The old WaitForConnected blocking wait + 10-strike cred-wipe-and-reboot
escalation are gone.

Key pieces:
- io/provisioning_fallback.hpp: pure grace/teardown predicates (host-tested)
- app/network_coordinator.{hpp,cpp}: runtime driver
- wifi: sta_auto_retry_ decoupled from ap_mode_ so STA keeps retrying in
  APSTA; StopSoftAp(); TryConnect restores the saved-network association
  after a portal verify
- ControlServer::StopHttpd() + re-callable Start(): hand port 80 to the
  fallback portal and reclaim it on teardown (the two httpds can't coexist)
- data sources stay wired at boot (they retry offline); only SNTP + the
  blocking currency fetch defer to first connect (RefreshUpstreamCurrencies)
- LED fault watchdog held off until boot finishes, so connecting shows the
  blue sweep, not a premature red; FinishBoot publishes status so
  /api/status.data[] reflects the first frame

Verified on Rev B hardware: non-blocking boot, connect+data path, and the
grace->fallback->reconnect path (no reboot, no cred wipe).
2026-06-10 23:41:50 +02:00
.forgejo ci(host-tests): skip on submodule-pin / docs / CI-workflow-only commits 2026-06-06 02:00:39 +02:00
.github/workflows ci(host-tests): skip on submodule-pin / docs / CI-workflow-only commits 2026-06-06 02:00:39 +02:00
components feat(wifi): non-blocking boot + concurrent APSTA provisioning fallback 2026-06-10 23:41:50 +02:00
data@4ca5e6afe0 chore(webui): bump WebUI submodule to v4 4ca5e6a 2026-06-09 13:39:31 +02:00
docs docs(quickstart): add firmware-update guide + flesh out card page 2 2026-06-03 13:49:51 +02:00
main feat(wifi): non-blocking boot + concurrent APSTA provisioning fallback 2026-06-10 23:41:50 +02:00
test_host feat(wifi): non-blocking boot + concurrent APSTA provisioning fallback 2026-06-10 23:41:50 +02:00
tools docs(quickstart): add firmware-update guide + flesh out card page 2 2026-06-03 13:49:51 +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(webui): bump submodule, drop bundle.js from docs 2026-05-08 03:04:42 +02:00
CMakeLists.txt fix(nwc): stop double frontlight flash / LED zap on payment notification 2026-06-03 16:36:12 +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 feat(settings): priceSymMode NVS + availableFonts hasBtcSymbol catalog 2026-05-09 19:44:48 +02:00
sdkconfig.defaults fix(webserver): raise httpd header buffer to 2 KB to avoid HTTP 431 2026-05-25 14:59:49 +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 fix(v8): pin SPIRAM_MALLOC_ALWAYSINTERNAL=0 against sdkconfig drift 2026-05-23 17:47:39 +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.1 (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.1.

Documentation

User-facing:

Developer-facing: