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).

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.

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.