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 52.7%
  • C++ 42%
  • JavaScript 1.6%
  • Python 1.2%
  • Shell 0.9%
  • Other 1.6%
Find a file
Djuri Baars 9ee982e487
All checks were successful
Lint / format (push) Successful in 40s
Lint / schema-defaults (push) Successful in 4s
Lint / tidy (push) Successful in 4m42s
Release pipeline / host_tests (push) Successful in 3m45s
Host tests / host_tests (push) Successful in 4m59s
Host tests / coverage (push) Successful in 6m8s
Host tests / sanitize (push) Successful in 8m22s
Release pipeline / webui_and_lfs (push) Successful in 4m4s
Release pipeline / firmware (REV_A, 2_13, 4mb, 0x370000, build-rev-a, rev-a) (push) Successful in 21m18s
Release pipeline / firmware (V8, 2_13, 16mb, 0xDF0000, build-v8, v8) (push) Successful in 21m12s
Release pipeline / firmware (REV_A, 2_9, 4mb, 0x370000, build-rev-a-29, rev-a-29) (push) Successful in 21m25s
Release pipeline / firmware (REV_B, 2_13, 8mb, 0x6F0000, build-rev-b, rev-b) (push) Successful in 21m8s
Release pipeline / release (push) Successful in 1m15s
test(bitaxe): follow the file's optional-deref idiom in the new cases
clang-tidy runs with WarningsAsErrors: '*', and
bugprone-unchecked-optional-access does not understand doctest's REQUIRE
as a guard — every deref in this file is already written as
REQUIRE(x.has_value()) plus an explicit NOLINTNEXTLINE. The three cases
added in dbfce6a used a bare REQUIRE(opt) and tripped the tidy job.

Firmware output is unaffected: this is test-only, so the Rev A binary in
dist-rev-a/ stays byte-identical in behaviour.
2026-08-22 21:50:34 +02:00
.forgejo fix(settings): read pool/bitaxe defaults from the schema, not literals 2026-08-22 20:51:19 +02:00
.github/workflows fix(settings): read pool/bitaxe defaults from the schema, not literals 2026-08-22 20:51:19 +02:00
components fix(bitaxe): raise the response cap and stop bad polls wiping a reading 2026-08-22 21:44:14 +02:00
data@a173b2f357 chore(webui): bump data submodule to the v4 tip (a173b2f) 2026-08-22 21:17:23 +02:00
docs fix(leds): gate the new-block flash on ledFlashOnUpd 2026-08-22 15:57:14 +02:00
main fix(settings): read pool/bitaxe defaults from the schema, not literals 2026-08-22 20:51:19 +02:00
test_host test(bitaxe): follow the file's optional-deref idiom in the new cases 2026-08-22 21:50:34 +02:00
tools fix(settings): read pool/bitaxe defaults from the schema, not literals 2026-08-22 20:51:19 +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 fix(leds): gate the new-block flash on ledFlashOnUpd 2026-08-22 15:57:14 +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 perf(mbedtls): ship the CMN CA bundle, reclaim ~50 KiB of app partition 2026-08-02 14:13:56 +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: