Project · TokenTV riazrahaman.comHome → SourceCode →

A $5 clock that
never gets
reflashed.

TokenTV puts live AI usage — and a share watchlist — on a tiny Wi-Fi desk clock, without replacing a single byte of its firmware. The trick is that the clock never knows. It thinks it is showing a holiday photo. 🕰️

What this page covers → the design, the code flow, the hardware, and exactly what was built on top of someone else's good idea.
Base → forked from click6067-ship-it/token-tv (MIT)
Edition → October 2026 · v0.4.1
Prologue

The clock thinks it is a photo frame 🖼️

These clocks ship with vendor firmware, no source, and no warranty worth the name. Replacing it means opening the case and soldering. So TokenTV doesn't. It uses a feature the stock firmware already has: a photo album with an HTTP upload endpoint, and a theme that shows a photo full-screen.

🖥️
A computer that stays on

Reads and draws

Reads your usage through each provider's own login, then draws a 240×240 picture with Pillow.

📤
Plain HTTP

Uploads a photo

Posts the image to the clock's album and selects the photo theme. No firmware, no agent, no SDK.

🕰️
The clock

Displays a picture

It has no idea what it is showing. That is the whole point: nothing on the device can break.

Why this constraint is the design. Everything awkward in the project descends from one fact: the only way to change a single pixel is to upload a whole image and have the device store it in flash. That is why there is a write budget, why countdowns are rounded, and why identical frames are never re-sent.
Chapter 01

What is actually inside 🔌

Identified by disassembling the vendor firmware and cross-checking against open-source ports for the same board. The USB-C port is power only — there is no serial chip — which is why the device is driven entirely over Wi-Fi.

PartWhat it is
MCUESP8266, ESP-12F class, running the Arduino ESP8266 core on the NONOS SDK — not an ESP32
Flash4 MB GD25Q32C, brought up in QIO mode
Panel240×240 IPS, ST7789 family, SPI mode 3 at 40 MHz, BGR order, inversion on
PinsSCLK 14, MOSI 13, DC 0, RST 2, backlight 5 (active-low), CS unused
FilesystemLittleFS, ~2.98 MB, holding photos, fonts and the web pages
SettingsEmulated EEPROM — a single 4 KB sector with no wear levelling
InputNone. No buttons, no touch. It is a display.
DiscoveryNone. No mDNS, no MQTT, no WebSocket — you read its address off a 1.5″ screen
The RAM wall. A full 240×240 16-bit frame buffer is 115,200 bytes. The ESP8266 has roughly 40–50 KB of usable heap. No firmware for this board — vendor or open-source — can buffer a whole frame, so JPEGs are decoded in small tiles and pushed straight at the panel. It is also why rendering on a real computer and shipping one finished image is the right division of labour.
Chapter 02

From an API call to a picture 🔀

Python 3.10+ with Pillow as the only runtime dependency. No database, no queue, no container, no cloud. Persistent state is a handful of JSON files and the last image uploaded.

HOST COMPUTER · PYTHON + PILLOW Anthropic Ollama Antigravity Finnhub sources.py state.py render device.py dashboard state files feeder snapshot Clock HTTP LAN only
One process owns the clock. The watchlist runner is a feeder: it writes a snapshot file and never touches the device, which is what stops two publishers fighting over the same photo.

↔ scroll the diagram sideways

One clock, one owner. A second publisher pointed at the same clock fights over the filename and over which saved backup is authoritative. So the watchlist runner drops its device address entirely and becomes a pure feeder, writing only a snapshot file. The usage runner reads that file and stays the single process that backs up, uploads and re-asserts the display. The conflict is removed by construction, not by coordination.
Unknown is never zero. A failed read keeps the previous values and marks the row stale rather than reporting 0%. A missing reset time renders as a dash. An identity mismatch blanks the numbers instead of showing another account's. A wrong number is worse than no number on a display you glance at.
Chapter 03

Eight faces, one snapshot 🎨

Every face is a Python function that receives the same normalised reading and draws a 240×240 image. Swapping face is a dropdown, not a redeploy. All renders below are from the project's sample data.

The combo face: three AI usage rows with gauges above four share rows with prices and day changes
combo

Both datasets on one screen. Three AI rows with usage gauges and the window each percentage is of, then four share rows. It only draws the market half while the market is open — outside the session it hands the whole screen back to the usage face, so the readable layout is what you see most of the day.

The pixel face
pixel

Hand-drawn 5×7 glyphs, no font files

The digital face
digital

Seven-segment, with a clock for short windows

The neon face
neon

Glow and rings

The retro face
retro

Pixel scenes per provider

The hud face
hud

Instrument panel

The gameboy face
gameboy

Four shades of green

There is a ninth, space, which returns an animated GIF rather than a JPEG — so the publisher picks the filename from the image's magic bytes rather than from the style name.

Chapter 04

The part that was actually hard 💾

The clock keeps every frame in flash, and flash wears out. The obvious guard — skip the upload when the bytes are identical — turns out not to be enough, and the reason is worth stating because it is easy to get wrong.

The dedupe never fires when it matters. When a short usage window leads, the face shows a countdown in minutes. That countdown changes on every poll, so every frame differs and the byte comparison never matches. Measured at the default five-minute poll: twelve distinct frames an hour, every hour you are working.
  1. Byte dedupe — an identical frame is never rewritten.
  2. Countdown bucketing — the countdown is rounded up to whole buckets, so the face changes when the remaining time crosses an edge rather than every minute. A steady hour drops from twelve writes to four.
  3. A spaced write budget — uploads are spaced rather than given a quota. A quota lets a busy hour spend its whole allowance in the first twenty-five minutes and then freeze the display on a stale frame, which is the opposite of useful.
  4. A data-change override — the readings are fingerprinted separately from the countdown, so a percentage or price that actually moved bypasses the budget and goes out at once. A budget that defers real news is worse than no budget.
poll render frame upload skip defer bytes same? data changed? budget allows? no yes no yes yes no
The two red paths both end in an upload. A reading that genuinely moved never waits for the budget — the cap exists to absorb idle churn, not to withhold new numbers.

↔ scroll the diagram sideways

A watchdog, because once was not enough. The call that selects the photo theme used to run only when the output filename changed — that is, once. A clock that reboots, or that someone opens in its own web UI, silently reverts to its own theme while uploads keep succeeding into a photo nobody is looking at. It is now re-asserted periodically.
A quality ladder, because the limit is hard. The firmware rejects an oversized upload outright. A busy face can cross that limit on its worst frame and simply stop updating with no visible error, so the encoder steps quality down, then chroma, until the frame fits, and records what it chose.

Two flash regions, and only one of them forgives you

Disassembling the firmware settled a question that had been shaping the design. The photo album is LittleFS — copy-on-write and wear-levelled, so a rewritten file lands on fresh blocks. That moved the wear estimate from months to the order of a decade, and demoted the write budget from a safety mechanism to good hygiene.

The settings are a different story. Everything written through the settings endpoint — brightness, the night window, the accent colours — lands in the ESP8266's emulated EEPROM: one 4 KB sector, rewritten whole on every commit, no wear levelling, holding the Wi-Fi credentials beside the display settings. That asymmetry kills an idea that looks attractive from the outside: using the accent colours as a cheap status channel, since they need no image upload. They are not cheap. They are the most fragile flash on the device.

Chapter 05

What this was built from 🧬

TokenTV is a fork, and the original idea is not mine. This section is the honest ledger of which side of the line each piece came from.

Upstream · MIT © 2026 click6067-ship-it

🌱 The original

  • The core insight — drive a stock-firmware clock by uploading pictures to its photo album
  • The device layer for both clock API profiles, and the backup/restore flow
  • Five clock faces and the web dashboard
  • The Apple Watch endpoint
  • The published demo and the project's Hackaday write-up
  • SmallTV-Ultra support contributed by Ye Thi Ha Htwe
Research prior art

📚 Standing on

Chapter 06

What I contributed 🔨

Twenty-three commits over three days, on top of the fork point. Two new providers, a second data domain, the flash-write discipline, and the hardware study that corrected the assumption underneath it all.

23commits, excluding merges
+2,385lines of Python, including tests
129tests passing
2providers replaced
How this was built. In sessions with Claude Code, which is part of the point — the project is as much an exercise in working with an AI agent as it is a desk clock. The firmware disassembly, the flash-wear measurements and the design arguments on this page came out of that back-and-forth; the commits, the hardware and the calls on what to build are mine.
Chapter 07

What would come next 🔭

Proposed · not built

The limitation worth attacking is not wear. It is the contract: the only way to change a pixel is to store a whole image. A custom firmware could change the device from a picture frame you rewrite into a display you draw on — accepting a frame over HTTP, decoding it straight to the panel in tiles, and never touching the filesystem at all. The write budget would stop existing, and every existing face would keep working, because the rendering stays on the host.

It is a weekend's work and a £5 serial adapter, plus a full flash backup first, because the vendor image restores the firmware but not the photos. Whether that is worth opening the case is a separate question from whether it would work.

TokenTV v0.4.1· MIT· github.com/riazrahaman/riaz-token-tv· forked from click6067-ship-it/token-tv· built with Claude Code