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.
“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.
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.
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.
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).
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.
| Surface | Contract | Notes |
|---|---|---|
POST /ask | text/plain in, text/plain out | No JSON on this hop — the page sends the raw sentence, the device returns the raw reply. One less parser to fail. |
POST /settings | JSON {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 PROGMEM | Whole UI lives in flash as R"rawliteral(...)" — no filesystem, no SPIFFS image to flash separately. |
| everything else | 302 → http://192.168.4.1/ | The captive-portal catch-all. |
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.
| Concern | Implementation | Why it matters |
|---|---|---|
| Pins | OLED SDA 10 / SCL 7 / addr 0x3C; buzzer 1 | GPIO2/8/9 are C3 strapping pins — using them means a board that sometimes won't boot. |
| Text layout | 21 chars × 6 lines, word-safe wrap, “…” overflow | 128 px at the default 6×8 font is 21 columns. Wrapping at the last space/punctuation beats clipping mid-word. |
| HTTP call | HTTPClient, 15 s timeout, max_tokens 200, temp 0.9 | 200 tokens ≈ 300 characters — the size of the page. Timeout keeps a stalled API from freezing the loop forever. |
| JSON | StaticJsonDocument<2048> request, <1024> response | Static allocation: no heap fragmentation over days of uptime. |
| States | splash → thinking → reply / error | Each state owns the whole screen; there is no cursor and no partial redraw to get wrong. |
| Errors | “The ink fades…” + HTTP code | Failure 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.
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:
Same path, +2.5 px wider, alpha 0.12. The paper-fibre halo around wet ink.
Full-width stroke with per-segment colour jitter: rgb(26–34, 15–20, 5–10). No two strokes are the same brown-black.
A 0.3× width dark line on top. This is what makes a thick stroke look pressed, not painted.
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.
The character is one constant, and it is doing more engineering work than it looks like:
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.
| Parameter | LilyGO T5 V2.4 | Consequence for this build |
|---|---|---|
| SoC | ESP32 dual-core LX6 @ 240 MHz, 4 MB flash, no PSRAM | Same Arduino/PlatformIO toolchain as V1; plenty for a 30 KB framebuffer. |
| Panel | 2.13″ e-paper, 122 × 250, SPI | Portrait page. A 122 px line is ~15 characters at a readable font — portrait suits short replies better than a wide letterbox. |
| Variants | DEPG0213BN (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 supported | Full refresh on “page turn”, partial refresh while the answer inks in. |
| Power | ~300 µA, panel power gated by GPIO12 | Deep sleep between writes; the image survives with the panel unpowered. |
| Extras | TF slot, CH9102 USB-UART, 3.3 V | TF slot could hold a transcript log — the diary remembering what you told it. |
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:
| Option | Cost | Reality |
|---|---|---|
| 2.13″ 4-wire resistive overlay + XPT2046 | ¥11–17 | Fits 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–40 | This 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) | ¥0 | Zero 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.
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.
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.
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.
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.
| Symptom | Cause | Handling |
|---|---|---|
| “You haven't dipped the quill yet…” | No API key in RAM | In-character refusal; settings panel is the fix. Never crashes, never sends an empty Bearer. |
| “The diary's magic falters… HTTP 401” | Bad/expired key | Status code echoed for debugging, wrapped in character for the experience. |
| “The diary's magic flickers… (parse error)” | Endpoint returns non-OpenAI JSON | The only assumption the firmware makes is choices[0].message.content. |
| Portal never opens | OS probe path not shimmed | Seven probe routes exist for this; onNotFound is the backstop. |
| Mirror shows nothing after Ask | Browser CORS on the API host | The 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 refresh | Voltage check before refresh + full refresh retry. |
| V1 — shipped | ¥ | V2 — planned | ¥ |
|---|---|---|---|
| ESP32-C3 SuperMini (pre-soldered) | 8–12 | LilyGO T5 V2.4 (ESP32 + 2.13″ panel) | ~100 est. |
| 0.96″/2.42″ SSD1306-family OLED | 8–14 | 2.13″ 4-wire resistive overlay | 8–12 |
| Passive buzzer (cut in V2) | 1–2 | XPT2046 touch controller | 3–5 |
| Dupont wires | owned | 500 mAh LiPo (502030) | 8–12 |
| Total | ≈¥17–28 | Total | ≈¥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.
| Artifact | State | Evidence |
|---|---|---|
| V1 firmware | Compiles, flashed | firmware.bin 1,026,592 B · main.cpp 20,320 B / 579 lines |
| Full mirror | Live, byte-identical | sha256 b04e2f6a…9839b8 · 20,363 B — open it |
| Zero-setup demo | Live | Canned replies, no key needed — open it |
| V2 e-paper port | In progress | Driver swap to GxEPD2, 122×250 layout, partial-refresh ink pass; not yet flashed |
| Stylus input | Open decision | Overlay + XPT2046, or three buttons. See §07. |
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.
localStorage only — never in the page sourceThree 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.