ExplorInk
GitHub

hardware · xteink x4

Xteink X4, measured

The X4 is a pocket e-ink reader with an ESP32-C3 in it. We replaced its software with our own, so we had to find out how the panel, the heap and the radio actually behave. These are our own numbers off our own device, each one marked measured (off this hardware) or read (off the driver source).

Download this firmware. The same binary runs an X3, and one was flashed on 9 September 2026; the install page has the full device table, the backup step and the checksums.

Refresh: three modes, and one of them is a trap

Read. The driver exposes FAST_REFRESH, HALF_REFRESH and FULL_REFRESH. The names suggest a thoroughness ladder. They are not one. On the X4 each maps to a controller sequence:

ModeSequenceWaveformDifferential?
FAST_REFRESH0xFCstock partialyes, only pixels that differ
HALF_REFRESH0xD7warmed full clean, single passno
FULL_REFRESH0xF7OTP full, multi-flashno

HALF and FULL both clear the panel absolutely and both reseed the differential baseline. The only difference is that 0xF7 inverts the panel several times on the way. So FULL does not clean better than HALF; it cleans the same and flashes. Stock X4 firmware never runs 0xF7 in normal operation. Picking it because a frame “needs a proper clean” cost our map screen a visible multi-flash on every entry until we found this on 2026-08-17.

Measured on the X4, 2026-08-17, off the driver’s own timing line:

ModeWaveform time
FAST_REFRESH, whole panel or windowed500 ms
HALF_REFRESH, whole panel1,684 ms
FULL_REFRESHnever timed; nothing should ask for it

Those are waveform numbers only. They do not include the controller RAM writes, or the tile reads and rendering that come before them. A whole map frame is seconds: what a frame actually costs.

One more thing worth knowing before you time anything: asking for FAST is not the same as getting it. The driver promotes a fast request to a clean one on the first paint after boot or wake (a differential refresh cannot clear an image it never saw) and when leaving grayscale mode. A timing taken on the first frame after boot is not a fast number.

Four grey levels, and why our map ignores them

Read. The panel does four levels (black, dark grey, light grey, white) and grey is not a value you write to a pixel. It is a black-and-white base frame plus a weak differential waveform applied through two extra bit planes. Both greys are black in the base frame; the extra planes nudge them lighter.

The consequence bites: lose the grey and the pixel reads full black, not white. Contrast collapses, it does not wash out. And the map has a second reason to stay out: the grayscale path calls its draw callback 13 times for one frame, and our callback streams tiles off the SD card. A grey map frame would cost 13 tile loads, not one extra waveform pass. So the map uses 2×2 dither for areas and keeps the greys for other screens.

The heap is the real budget

Measured 2026-08-10 on the X4, off the firmware’s own 10-second heap line, with the map screen driven over USB serial:

StateFreeMin free since bootLargest block
boot idle, no map, no BLE124,564 B124,480 B114,676 B
map screen, BLE up, one phone, before the BLE config trim49,472 B49,376 B45,044 B
map screen, BLE up, one phone, after it58,540 B58,444 B55,284 B

Total heap is 247,156 bytes after that trim, not the 380 KB of SRAM the chip is sold with. Static DRAM, IRAM and the framebuffer come off it before the allocator sees anything. Bringing BLE up is the single biggest cost on that screen: 56,972 bytes after the trim, 64,544 before it, which is why the stack is torn down on the way out. The trim also put the map screen above the project’s own 50 KB heap gate; before it, the screen idled below it. Fragmentation is about 3.3 KB: 58,540 free with a largest block of 55,284, so a single 56 KB allocation fails on a screen reporting 58 KB free. One earlier pre-trim capture also showed a transient ~11.7 KB under the resident figure: free flat at 49,460 while min free read 37,764. Not attributed; open.

Two smaller numbers from the same capture, because they set the shape of the code: one map session allocates 7,696 bytes once (the tile source is 6,696 of it), and loading a tile costs 60–68 bytes. Tiles are streamed and forgotten, there is no tile cache, and there cannot be one.

The hardware test matrix

Every test we run against real hardware, or plan to, grouped by what it checks. Done means run on a real X4 with our own firmware. Open says what is missing before it can run: usually a firmware build we have not flashed yet, sometimes an instrument we do not own. Blocked means the test needs something this project has ruled out, stated so it stays ruled out on purpose rather than by neglect. Power numbers come off the battery’s own voltage curve, not an inline meter, so they are a range rather than a lab spec, and %/h is the figure this table trusts most: a raw mA number needs the cell’s spec-sheet capacity to convert, and two of the runs it would be built from did different amounts of panel and loop work, which this table does not paper over.

Boot and recovery

TestWhat it provesStatus
Cold boot to the home screenthe firmware actually startsdone, every day
ROM bootloader answers after a hung builda bad flash is never a bricked readerdone, measured : a build that compiled clean once hung the device solid; the ROM bootloader was the only way back in
Full 16 MB flash backup over esptoolthe stock firmware is never a one-way lossdone, the standard first step on every install
Restoring that backup onto a device coming from stockthe backup is actually usable, not just takenopen: every flash so far went onto a reader that already ran this firmware; a true stock-to-stock restore has not been done by us

CPU power states and sleep

TestWhat it provesStatus
Map screen holds full 160 MHz clock, never throttleswhether the map idles into a slow CPU mid-framedone, measured on 2026-08-11: full_clock_ms rose for the whole window while throttled_ms stayed frozen. Superseded 2026-08-17 by the row below; kept here because it is what the 2026-08-11 heap numbers above were taken under
Home screen enters low-power CPU modethe idle path engages where it is safe todone, log-observed: [PWR] Going to low-power mode 3 seconds after boot. The actual idle current in that state is not measured: it is the cheapest number this campaign is still missing
Map throttled to an 80 MHz floor instead of 160whether the map itself can run slower without breakingdone, current firmware, confirmed on a build from 2026-08-21; not yet isolated from other changes in the same build
10 MHz floor with the radio off (observation mode)the cheapest state the map can reach todaydone, measured : the widest, most trusted run in this whole matrix: 1.76%/h, about 57 hours on a full charge
Light sleep (CONFIG_PM_ENABLE) while a BLE link stays upgo/no-go for the next real power state below the current flooropen : needs a build with the light-sleep config set and a bench run against a phone
An internal-RC BLE clock (RTC_SLOW), advertising only, never connectedthe last untried path to a sub-milliamp parked flooropen : one build option, same bench as the row above
Deep sleep with the battery latch held, to price the board’s own floorseparates the SoC’s own draw from everything else on the board that never powers downopen on a bare development board with a meter in series at its battery connector; blocked on a retail X4 : opening a device to reach its cell is not something this project does, ever
A 32.768 kHz low-power crystalthe deepest published sleep current for this chip familyruled out on X4 : both crystal pins are already the button ladder and the battery ADC, read off the SoC’s own pin muxing, no board needed to know it

BLE radio

TestWhat it provesStatus
Advertise, phone connectsthe link comes up at alldone, every ride
Position packets received, marker followsthe core loop this whole product depends ondone, works
BLE modem sleep, on vs offthe single biggest power lever found so fardone, measured : see the power rows above, a third less draw
Map files pushed onto the SD card over BLEtiles can arrive with no cabledone, works
Reconnect after the phone rebootsa dropped link recovers without a manual pairing dancedone, verified with the phone rebooted and the reader waking it on its own
Reconnect after a real Android force-stop (not a swipe-away)the case that actually blocks a background wake, not the one that does notopen: only tested against the swipe-equivalent kill state; a true force-stop sets a flag that blocks background restart and has not been tried on hardware
Fast vs. slow advertising, discovery latencywhether the resume-from-parked promise costs the rider real secondsopen : unrun, five stopwatch trials against a known phone
Wider BLE connection interval, throughput vs. powerwhat the current throughput fix costs when nothing is transferringopen : needs one build per interval option
Freshness check and autosync, on vs. offtwo rider-facing toggles nobody has pricedopen : no flash needed, just a controlled pair of legs

Display and refresh

TestWhat it provesStatus
Fast refresh timingthe number the map's redraw budget is built ondone, measured: 500 ms
Half refresh timing (a clean panel wipe, no multi-flash)the cost of a clean wipe without the full waveform's extra flashesdone, measured: 1,684 ms. FULL_REFRESH itself was never timed: nothing in this firmware should ask for it
Four grey levels render correctlythe panel does what its datasheet saysdone, verified on the panel
Windowed partial update vs. a full fast refreshwhether redrawing less of the screen is actually cheaperdone, measured and published: it is not; the waveform pass dominates either way
Raw and grey framebuffer dumps over serial (CMD:SCREENSHOT)the tool behind every device screenshot on this sitedone, 100 clean captures out of 100

Storage

TestWhat it provesStatus
Tiles streamed off the SD card, no in-memory cachethe map can run in the heap it actually hasdone, measured : 60–68 bytes per tile load
One map session’s total heap allocationthe shape of the code's own footprint, separate from the tilesdone, measured : 7,696 bytes once per session

One binary, two devices

Read. X3 and X4 are both ESP32-C3 with the same pinout and different panel controllers (UC8253 792×528 and SSD1677 800×480). The firmware probes the X3-only I2C chips twice, scores the hits and picks its profile at runtime, so one build covers both. The renderer works in 480×800 portrait while the SSD1677 scans 800×480 landscape. That rotation is the display layer’s problem, not the map’s. What another device would cost.

Getting in and getting back out

The X4 exposes USB serial and the ESP32 ROM bootloader, which is what makes this project possible at all: a full flash backup with esptool before anything else, and a way back when a build hangs the device. That is not hypothetical: a build that compiled clean once hung our device solid (serial dead, buttons dead, screen frozen) and the ROM bootloader was the only way back in, because it sits below the application that hung.

The device also carries a command console over USB serial and over BLE, which is how these numbers were taken: drive the map screen to a coordinate, pull the real framebuffer off the panel, read the heap line. Every screenshot on this site came out that way.