reachlin

reachlin's development notes

I’ve been building a personal physical terminal on an M5Paper e-ink display that receives messages and approval requests from Claude Code sessions. Calling it pip2026boy, after the Fallout pip-boy — a wrist-mounted device that tells you what’s going on. The M5Paper is 4.7”, 540×960, 16-gray e-ink, with a touch screen, SHT30 sensor, SD card, and ESP32 inside. Perfect for this.

The firmware runs four tabs: HOME shows images from the SD card fill-scaled to the full screen, TEMP shows live temperature and humidity from the onboard sensor plus an NTP-synced clock, MSG is the Ably channel inbox, and ABOUT shows device info and a transport toggle. All navigation is by touch on the tab bar at the top.

The interesting part is the messaging. Any Claude Code session can push a notice or a blocking approval request to the device via a skill:

/m5paper Deploy to prod?

That runs a Python script that publishes to an Ably channel. The M5Paper polls Ably REST every 20 seconds while the MSG tab is open — WiFi stays off on all other tabs to save battery. Approval requests show up with a double-border highlight and arm the APPROVE / REJECT buttons at the bottom. Tap one, the decision publishes back to Ably, and the Claude Code session unblocks.

There’s also a BLE fallback. The ESP32 can advertise as a GATT server; the skill tries Bluetooth first and falls back to Ably if nothing answers a short scan. The two transports are mutually exclusive — running a TLS handshake while BLE’s stack is resident causes heap contention on the ESP32 that crashes the TLS in a spectacular way (“BIGNUM — Memory allocation failed”). The About tab’s toggle writes your choice to SD and takes effect on reboot.

Today was mostly debugging message delivery. I’d been working in the wrong repo — the vault-whisper copy of the firmware was a prototype with a simpler 3-mode touch toggle, while the real firmware with the 4-tab UI lived in the pip2026boy directory. I accidentally flashed the prototype, wiping the actual interface. Lesson: always confirm which copy is production before running pio run -t upload.

The bigger bug was in the Ably polling logic. The original code did a “seeding” pass on the first poll after boot: it fetched the last 30 messages from history, marked them all as seen without displaying them, and only showed messages arriving after that. The intent was to avoid replaying old history. The problem: the seeding poll runs right after entering the MSG tab, so any message that was in Ably’s history at that moment — including ones sent while you were already sitting on the tab — got silently dropped.

The fix was simpler than the seeding logic: drop the seeding entirely, reduce the history fetch to the last 4 messages, and just show anything not already in the session’s seen list. Messages are only duplicated if you see them, reboot, and open MSG again — which is the correct behavior (you probably want to see recent messages after a reboot). The seen list is 32 entries and lives only in RAM, so it naturally resets on boot.

The other silent failure was JSON buffer sizing. Long messages — like a multi-line vault summary covering two accounts — were failing deserializeJson with a StaticJsonDocument<1024> and getting silently skipped. The firmware just continues on parse failure, so there’s no error output. Bumped both the polling and display parse buffers to 4096 bytes, which comfortably fits anything the skill will send.

I also made a project showcase page with screen mockups of all four tabs and the pairing flow — hand-drawn in HTML/CSS at 55% scale, accurate to the actual firmware draw calls.

The device configuration lives on the SD card in a .env-style file: WiFi credentials, Ably API key, and channel name. The channel name is what “pairs” a Claude session to this specific device — the skill reads the channel from the repo’s .env and publishes to it. No app, no QR code required; just copy the file to the SD card once.

Next thing I want to try: a scheduled Claude agent that posts daily vault summaries to the device automatically, so the current positions are always one tab-switch away without pulling out a phone.