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:
| Mode | Sequence | Waveform | Differential? |
|---|---|---|---|
FAST_REFRESH | 0xFC | stock partial | yes, only pixels that differ |
HALF_REFRESH | 0xD7 | warmed full clean, single pass | no |
FULL_REFRESH | 0xF7 | OTP full, multi-flash | no |
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:
| Mode | Waveform time |
|---|---|
FAST_REFRESH, whole panel or windowed | 500 ms |
HALF_REFRESH, whole panel | 1,684 ms |
FULL_REFRESH | never 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:
| State | Free | Min free since boot | Largest block |
|---|---|---|---|
| boot idle, no map, no BLE | 124,564 B | 124,480 B | 114,676 B |
| map screen, BLE up, one phone, before the BLE config trim | 49,472 B | 49,376 B | 45,044 B |
| map screen, BLE up, one phone, after it | 58,540 B | 58,444 B | 55,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
| Test | What it proves | Status |
|---|---|---|
| Cold boot to the home screen | the firmware actually starts | done, every day |
| ROM bootloader answers after a hung build | a bad flash is never a bricked reader | done, 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 esptool | the stock firmware is never a one-way loss | done, the standard first step on every install |
| Restoring that backup onto a device coming from stock | the backup is actually usable, not just taken | open: 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
| Test | What it proves | Status |
|---|---|---|
| Map screen holds full 160 MHz clock, never throttles | whether the map idles into a slow CPU mid-frame | done, 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 mode | the idle path engages where it is safe to | done, 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 160 | whether the map itself can run slower without breaking | done, 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 today | done, 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 up | go/no-go for the next real power state below the current floor | open : 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 connected | the last untried path to a sub-milliamp parked floor | open : one build option, same bench as the row above |
| Deep sleep with the battery latch held, to price the board’s own floor | separates the SoC’s own draw from everything else on the board that never powers down | open 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 crystal | the deepest published sleep current for this chip family | ruled 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
| Test | What it proves | Status |
|---|---|---|
| Advertise, phone connects | the link comes up at all | done, every ride |
| Position packets received, marker follows | the core loop this whole product depends on | done, works |
| BLE modem sleep, on vs off | the single biggest power lever found so far | done, measured : see the power rows above, a third less draw |
| Map files pushed onto the SD card over BLE | tiles can arrive with no cable | done, works |
| Reconnect after the phone reboots | a dropped link recovers without a manual pairing dance | done, 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 not | open: 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 latency | whether the resume-from-parked promise costs the rider real seconds | open : unrun, five stopwatch trials against a known phone |
| Wider BLE connection interval, throughput vs. power | what the current throughput fix costs when nothing is transferring | open : needs one build per interval option |
| Freshness check and autosync, on vs. off | two rider-facing toggles nobody has priced | open : no flash needed, just a controlled pair of legs |
Display and refresh
| Test | What it proves | Status |
|---|---|---|
| Fast refresh timing | the number the map's redraw budget is built on | done, 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 flashes | done, measured: 1,684 ms. FULL_REFRESH itself was never timed: nothing in this firmware should ask for it |
| Four grey levels render correctly | the panel does what its datasheet says | done, verified on the panel |
| Windowed partial update vs. a full fast refresh | whether redrawing less of the screen is actually cheaper | done, 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 site | done, 100 clean captures out of 100 |
Storage
| Test | What it proves | Status |
|---|---|---|
| Tiles streamed off the SD card, no in-memory cache | the map can run in the heap it actually has | done, measured : 60–68 bytes per tile load |
| One map session’s total heap allocation | the shape of the code's own footprint, separate from the tiles | done, 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.