The most minimal possible firmware for the Xteink X3 / X4 (ESP32-C3): a tiny
SD-card firmware flasher, nothing else. It boots, lets you browse the SD card
for a .bin, and reflashes the device with it.
It exists as a recovery / escape hatch — if a main firmware build is broken, flash this once and you can always reflash from the SD card with three buttons.
- Auto-detects X3 vs X4 at boot via an I²C fingerprint of the X3-only peripherals (BQ27220 gauge / DS3231 RTC / QMI8658 IMU on SDA20/SCL0) and selects the matching panel driver — one binary drives both.
- Boots to a small menu — Flash Firmware and Button Test.
- Flash Firmware mounts the SD card and shows a file browser of folders
and
.binfiles (built from FreeInkUI'slist), then flashes the selected image straight into the inactive OTA partition with interleaved erase/write, switchesotadata, and reboots into it. - Button Test is a hardware diagnostic: press any button to see its name and the live ADC value of the resistor-ladder GPIO it drives. Double-tap Back to return to the menu.
- Boot Other Slot points the bootloader at the other OTA app partition and
reboots into it (e.g. back into your main firmware) — without reflashing. It
first checks that slot actually starts with a valid app image (
0xE9magic) and refuses if it's empty, so it can't strand you on a blank partition. - EFuse / Security reads (never burns) the chip's security efuses — Secure
Boot, flash encryption, serial-download / JTAG disables, chip revision — and
gives a one-line verdict on whether a custom-bootloader recovery is possible
and recoverable on this device. See
docs/recovery-bootloader-feasibility.md.
The button hints along the bottom of every screen are drawn as icons (the Lucide-derived set shared with the inkdeck firmware) rather than text labels.
A short boot splash seeds the panel on power-up. Hold the power button
(~1.5 s) from any screen to show a sleep screen and enter deep sleep; pressing
the power button again wakes the device (it resets into a fresh boot). The wake
source is configured per-SoC by the SDK's PowerManager.
The flash path is the exact SD-flash process from
crosspoint-reader — FirmwareFlasher (full ESP32
image validation: magic, segment table, XOR checksum, SHA-256 trailer; raw
esp_partition_erase_range/write) plus OtaBootSwitch (raw otadata slot
write that bypasses esp_image_verify, which rejects the patched X4 image).
Those two files are ported verbatim; only the file-read seam was repointed from
CrossPoint's HalStorage to the FreeInk SDCardManager (FsFile).
| Button | Action |
|---|---|
| Up | Move selection up |
| Down | Move selection down |
| OK | Select a menu item / enter a folder / pick a .bin |
| Back | Go up a folder; at the root, return to the menu |
On the confirm screen, OK flashes and reboots; Back returns to the list. In Button Test, every press shows that button's name and ADC reading; double-tap Back to exit to the menu.
Hold Power (~1.5 s) on any screen to deep-sleep; tap Power again to wake.
The FreeInk SDK is vendored as a git
submodule at freeink-sdk/, and the committed platformio.ini builds against it
via relative symlink://freeink-sdk/libs/... paths. Clone with the submodule:
git clone --recurse-submodules <repo-url>
# already cloned without it?
git submodule update --init freeink-sdkThen:
pio run # build (env: default, X3+X4 dual binary)
pio run -t upload # flash over USB
pio device monitor # 115200 baud logsTo iterate on the SDK without bumping the submodule, create a
platformio.local.ini (gitignored, machine-specific — same pattern as
crosspoint-reader) that overrides [base].lib_deps with absolute
symlink:///abs/path/to/freeink-sdk/libs/... paths. PlatformIO loads it
automatically via extra_configs, so the local copy takes precedence over the
submodule. Once changes are committed and pushed to the SDK remote, bump the
pinned version here:
git -C freeink-sdk pull # advance the submodule to the new SDK commit
git add freeink-sdk && git commit -m "Bump freeink-sdk submodule"This project adds one small library to the SDK,
libs/hardware/XteinkDetect, providing freeink::detectXteinkIsX3() /
freeink::selectXteinkDevice() — the canonical Xteink X3/X4 runtime fingerprint
the SDK previously left to each consumer. Any dual X3/X4 app can reuse it.
partitions.csv matches the device's stock 16 MB dual-OTA layout (otadata +
ota_0 + ota_1) so the factory bootloader and otadata pick up whatever this
tool writes, and so Escape Hatch can live in one OTA slot while it flashes a full
firmware into the other. Escape Hatch is uploaded to ota_0 (offset_address = 0x10000); a flashed firmware lands in ota_1.
The SDK lib RecoveryBoot provides freeink::recovery::checkBootCombo():
held at reset, the combo Back + Up repoints otadata at ota_0 and reboots
into Escape Hatch. It's called as the first line of setup() here.
The stock second-stage bootloader can't read buttons — only the firmware that
boots can — so this check has to run inside each firmware, as the first line of
setup(). Calling it in Escape Hatch itself is a no-op (it's already ota_0);
the payoff is calling the same function from your main firmware(s) (it lives
in the shared SDK so any firmware can #include <RecoveryBoot.h>), so a held
combo there bounces you back here. It's safe to call unconditionally: it does
nothing unless the combo is held, ota_0 holds a valid app, and you're not
already running from ota_0.
Why Back + Up specifically: the buttons are an ADC resistor ladder with Back/Confirm/Left/Right on GPIO1 and Up/Down on GPIO2. Two buttons on the same pin (e.g. Back+Right) collapse to one reading and can't be told apart, so a detectable combo must take one button from each pin.
Two limits worth knowing:
- A firmware that crashes in ROM / early SDK init before reaching the check can't be escaped this way. A corrupt app image, though, is caught for free: the bootloader falls back to the other OTA slot on its own.
- Truly unconditional GPIO recovery (independent of the running app) would need a custom second-stage bootloader — which Escape Hatch deliberately never reflashes, since a bad bootloader is the one thing it couldn't recover from.