Isaac Chan · Build analysis ESP32 · E-paper Hardware + firmware Oct 2026

A diary that writes back

Tom Riddle's diary is a one-input device: you write, something writes back. Everything else — WiFi, DNS, JSON, an LLM — exists only to make that one exchange feel like ink appearing on paper. This is the whole build: the ESP32 firmware that shipped, the ink engine behind the browser mirror, and the redesign that removes the phone, the buzzer and the screen altogether.

Firmware
579 lines C++
Compiled
1.03 MB bin
Panel (V2)
122×250 2.13″ e-paper
Reply budget
300 characters
Standby
~69 days / 500 mAh
Bill of materials
¥17–28 V1 shipped
The constraint that shaped everything

“We're not using phones, we're using the book itself only.” — that single line deleted the entire first design. V1 needed a phone to be the keyboard, the display and the settings panel. V2 has to be a closed object: a board, a panel, a battery, and your hand.

01 · Three versions of the same idea

1

V1 — OLED + captive portal (built, flashed)

ESP32-C3 SuperMini, a 0.96″/2.42″ SSD1306-family OLED on I²C, and a WiFi access point called RiddlesDiary. Your phone joins it, a captive portal forces the parchment page open, you type, and the answer inks onto the OLED with a typewriter effect while a buzzer whispers. Works on iOS, Android, Windows, macOS — the portal intercepts all of them.

2

V1.5 — the browser twin

A standalone HTML version of the same page, with a real ink engine on a canvas (see §04). It is the design reference for what the physical page should feel like — and it is what the full mirror serves.

3

V2 — the book is the interface (in port)

LilyGO T5 V2.4: an ESP32 and a 2.13″ e-paper panel on one PCB. The reply is no longer pixels fading — it is bistable ink that stays on the page with the power off. Two things were cut deliberately: the phone (no portal, no typing) and the buzzer (a silent book is creepier than a chirping one).

02 · What V1 actually does

phone joins RiddlesDiary ──▶ DHCP 192.168.4.x │ ├─ DNS wildcard * → 192.168.4.1 (every lookup, not just one) ├─ OS probe hits /generate_204, /hotspot-detect.html, /connecttest.txt … │ all answered with the diary page, so the portal opens itself └─ POST /ask text/plain ──▶ HTTPClient ──▶ OpenAI-compatible API ◀── reply ──▶ OLED + typewriter on the page

Seven routes exist purely so that no operating system wins the argument: Android probes /generate_204, iOS probes /hotspot-detect.html and /library/test/success.html, Windows probes /connecttest.txt and /ncsi.txt, Firefox probes /canonical.html, Microsoft adds /redirect — and onNotFound 302s anything else back to the page. A captive portal is not one feature; it is seven compatibility shims and a wildcard DNS server.

SurfaceContractNotes
POST /asktext/plain in, text/plain outNo JSON on this hop — the page sends the raw sentence, the device returns the raw reply. One less parser to fail.
POST /settingsJSON {key,endpoint,model}Key is held in RAM only; the browser keeps its own copy in localStorage and re-syncs on load.
GET /184-line HTML in PROGMEMWhole UI lives in flash as R"rawliteral(...)" — no filesystem, no SPIFFS image to flash separately.
everything else302 → http://192.168.4.1/The captive-portal catch-all.

03 · Firmware anatomy

Boot is a fixed sequence with no scheduler and no tasks: display, buzzer, AP, DNS, routes, loop. loop() does exactly three things — pump DNS, pump HTTP, animate the “thinking” dots every 300 ms. That is the entire runtime.

ConcernImplementationWhy it matters
PinsOLED SDA 10 / SCL 7 / addr 0x3C; buzzer 1GPIO2/8/9 are C3 strapping pins — using them means a board that sometimes won't boot.
Text layout21 chars × 6 lines, word-safe wrap, “…” overflow128 px at the default 6×8 font is 21 columns. Wrapping at the last space/punctuation beats clipping mid-word.
HTTP callHTTPClient, 15 s timeout, max_tokens 200, temp 0.9200 tokens ≈ 300 characters — the size of the page. Timeout keeps a stalled API from freezing the loop forever.
JSONStaticJsonDocument<2048> request, <1024> responseStatic allocation: no heap fragmentation over days of uptime.
Statessplash → thinking → reply / errorEach state owns the whole screen; there is no cursor and no partial redraw to get wrong.
Errors“The ink fades…” + HTTP codeFailure is written in character, but the status code is still printed — you can debug without a serial cable.

One decision worth calling out: the API key lives on the browser page, not in the firmware. The device accepts it over /settings and keeps it in RAM. Flash a diary for someone else and it ships with no credential inside it — and nothing survives a power cycle.

04 · The ink engine (browser twin)

The web version is not a text box with a nice font. Strokes are rebuilt from three overlapping passes so that handwriting reads as ink rather than as a drawn line:

Pass 1 — bleed

Same path, +2.5 px wider, alpha 0.12. The paper-fibre halo around wet ink.

Pass 2 — body

Full-width stroke with per-segment colour jitter: rgb(26–34, 15–20, 5–10). No two strokes are the same brown-black.

Pass 3 — centre

A 0.3× width dark line on top. This is what makes a thick stroke look pressed, not painted.

Width from speed

Fast movement thins the nib to 1.2 px, slow movement fattens it to 3.5 px — computed per segment from distance/time, then smoothed with a quadratic curve through point midpoints.

It also does the unglamorous things: rescales the canvas to devicePixelRatio and re-blits on resize, uses setPointerCapture so a stroke survives leaving the canvas, suppresses touch scrolling over the page, and clears to a blank leaf after each answer.

Honest limitation: the canvas is captured as a PNG data-URL and then thrown away. All the code does with the pixels is scan the alpha channel to answer “has the user written anything?” — the model never sees your handwriting. If you draw without typing, it receives a fixed instruction saying the writer has drawn something illegible and asks you to say it in words. The handwriting is theatre, not OCR; making it real means either a vision model on the image or an on-device handwriting recogniser, and both are a different project.

05 · The persona layer

The character is one constant, and it is doing more engineering work than it looks like:

You are Tom Riddle's diary — a Horcrux containing a fragment of young Voldemort's soul from his Hogwarts years. You are charismatic, intelligent, manipulative, and speak in elegant, slightly old-fashioned English. You are intrigued by the person writing to you. You want to learn their secrets while revealing little of your own. You write back in flowing ink that appears on the page. NEVER break character. NEVER mention being an AI. You ARE the diary. Respond in 2-4 sentences, as if your words are magically appearing on the diary's parchment page. Keep responses under 300 characters.

Three constraints are doing quiet work: 2–4 sentences keeps the typewriter from becoming a wait, under 300 characters is exactly what a 2.13″ page can hold at a readable size on the V2 panel, and never mention being an AI is what stops a model from apologising its way out of the illusion. Temperature 0.9 buys variation between identical greetings; the persona prompt is what keeps that variation in character.

06 · V2 — paper instead of pixels

ParameterLilyGO T5 V2.4Consequence for this build
SoCESP32 dual-core LX6 @ 240 MHz, 4 MB flash, no PSRAMSame Arduino/PlatformIO toolchain as V1; plenty for a 30 KB framebuffer.
Panel2.13″ e-paper, 122 × 250, SPIPortrait page. A 122 px line is ~15 characters at a readable font — portrait suits short replies better than a wide letterbox.
VariantsDEPG0213BN (2 grey) / GDEM0213B74 (4 grey)2-grey is fine — this is black ink on paper, not a photo frame. Know which one you bought before setting the driver.
Refresh~2 s full, partial refresh supportedFull refresh on “page turn”, partial refresh while the answer inks in.
Power~300 µA, panel power gated by GPIO12Deep sleep between writes; the image survives with the panel unpowered.
ExtrasTF slot, CH9102 USB-UART, 3.3 VTF slot could hold a transcript log — the diary remembering what you told it.
What the T5 buys you: the panel, driver, level shifting, USB-UART and power path arrive on one PCB. V1's six jumper wires to a bare e-paper module (MOSI/SCK/CS/DC/RST/BUSY, all chosen to dodge strapping pins) disappear. The trade is money — roughly +¥45 over a C3 + bare panel — and the loss of pin freedom.

07 · The input problem nobody warns you about

E-paper solved the display and created the input problem. The T5 has no touch. If the phone is banned, something on the book has to accept a line of handwriting, and the options are all imperfect:

OptionCostReality
2.13″ 4-wire resistive overlay + XPT2046¥11–17Fits the panel, works with any plastic stylus, needs 4 more GPIOs. Resistive = pressure, so a palm never registers. Resolution is the panel's, not the controller's.
2.4″ SPI module, 14-pin header¥25–40This is usually not e-paper. A 14-pin SPI touch module is typically ILI9341 TFT + XPT2046 (VCC, GND, CS, RST, DC, MOSI, SCK, LED, MISO, T_CLK, T_CS, T_DIN, T_DO, T_IRQ). Glossy backlit LCD — the opposite of a paper page. Check the listing says 墨水屏 before buying.
Three buttons (page / ask / erase)¥0Zero risk, zero magic. You'd write in the air and press a button, which is a different toy.

The geometry is the real constraint. At 122 px across a 2.13″ panel you get roughly 130 dpi — a 1.5 mm pen nib is about 8 pixels wide. That is two or three legible words per line. So the honest design for V2 is: the panel is the answer surface, and the overlay captures a short line — a question, a name, a single sentence — which the firmware sends as text. Trying to render live handwriting on 122 px of bistable e-paper would look like a fax from 1988 and refresh at the speed of one.

08 · What e-paper costs you

Latency becomes characterisation

A ~2 s full refresh is a disaster for a UI and perfect for a Horcrux. The page goes blank, then the answer is simply there — which is closer to the book in the film than a typewriter effect ever was.

Ghosting is now a feature you must control

Partial refresh leaves faint remnants of the previous line. Alternate partial refreshes with an occasional full one, or the page slowly accumulates every conversation you've had.

Power budget, roughly

300 µA standby on a 500 mAh cell ≈ 69 days untouched. One exchange costs about 0.2 mAh (WiFi TX ~200 mA for ~3 s); twenty a day is ~4 mAh — the idle floor dominates, so deep sleep between writes matters more than anything the radio does.

Environment

Rated 0–50 °C. E-paper also draws a current spike at refresh, so a tired cell browns out mid-page; gate the refresh on battery voltage or you get half a sentence.

09 · Failure modes

SymptomCauseHandling
“You haven't dipped the quill yet…”No API key in RAMIn-character refusal; settings panel is the fix. Never crashes, never sends an empty Bearer.
“The diary's magic falters… HTTP 401”Bad/expired keyStatus code echoed for debugging, wrapped in character for the experience.
“The diary's magic flickers… (parse error)”Endpoint returns non-OpenAI JSONThe only assumption the firmware makes is choices[0].message.content.
Portal never opensOS probe path not shimmedSeven probe routes exist for this; onNotFound is the backstop.
Mirror shows nothing after AskBrowser CORS on the API hostThe browser twin calls the endpoint directly, so the host must allow it. The canned demo exists for exactly this case.
Half-printed page (V2)Brown-out during refreshVoltage check before refresh + full refresh retry.

10 · Bill of materials

V1 — shipped¥V2 — planned¥
ESP32-C3 SuperMini (pre-soldered)8–12LilyGO T5 V2.4 (ESP32 + 2.13″ panel)~100 est.
0.96″/2.42″ SSD1306-family OLED8–142.13″ 4-wire resistive overlay8–12
Passive buzzer (cut in V2)1–2XPT2046 touch controller3–5
Dupont wiresowned500 mAh LiPo (502030)8–12
Total≈¥17–28Total≈¥120–130

V2 prices are Pinduoduo-type estimates, not quotes — the T5 board is the one line worth checking at checkout, because clones and the 4-grey variant price differently. The V1 column is what was actually spent.

11 · Status, and what the mirror is

ArtifactStateEvidence
V1 firmwareCompiles, flashedfirmware.bin 1,026,592 B · main.cpp 20,320 B / 579 lines
Full mirrorLive, byte-identicalsha256 b04e2f6a…9839b8 · 20,363 B — open it
Zero-setup demoLiveCanned replies, no key needed — open it
V2 e-paper portIn progressDriver swap to GxEPD2, 122×250 layout, partial-refresh ink pass; not yet flashed
Stylus inputOpen decisionOverlay + XPT2046, or three buttons. See §07.

About the mirror

The mirror is the whole application, not a screenshot: the leather-and-parchment page, the three-pass ink engine, retina scaling, pointer capture, the typewriter, and the settings panel that holds your key in localStorage. It is the same file byte for byte — the hash above is the same on my disk and in the deployment. What the server returns is 757 bytes longer, and those 757 bytes are two tags Cloudflare appends on the way out: this site's skin script and its analytics beacon. Strip both and the body is identical.

It talks to the API from your browser, so it needs a key pasted into ⚙ and an endpoint that permits browser origins. If you just want to feel the page, use the canned demo — same ink, no network.

What's inside the mirror

  • Ink: quadratic smoothing, speed→width, bleed pass
  • Page: 560×360 canvas, 0.64 aspect, DPR-aware
  • Persona: Tom Riddle system prompt, 200 tokens, temp 0.9
  • Secrets: key in localStorage only — never in the page source
  • Cut: no buzzer, no phone, no portal

12 · What's next

Three things stand between V2-as-plan and V2-as-object: finish the GxEPD2 port (framebuffer → partial refresh pass → page-turn full refresh), decide the input surface, and put the whole thing in a book cover so the electronics are the secret rather than the product. The nice property of this project is that the hard part is already done — the persona, the protocol and the ink all behave; what's left is making the container feel like paper.