A salvaged IBM SurePOS 500 customer display (P/N 15K2012) — a Futaba M202MD10C 2-line × 20-character blue-green vacuum-fluorescent display — reverse- engineered into a self-hosted status board and real-time stereo spectrum analyzer. There's no public datasheet for this panel; everything here was recovered from one published command set and bench-confirmed on the actual unit. It drives the display over a write-only 9600-baud serial link from a long-running daemon, with an optional web control surface and a separate audio process. The constraint — two lines, twenty characters — is the aesthetic.
This README is the comprehensive reference: the physical build, the reverse- engineered protocol, the architecture, and — most valuably — the bench findings, the hard-won "why" that isn't in any datasheet. If you have the same salvaged display, start here. The deeper internal spec lives in
spec.mdandCLAUDE.md.
- Clock —
DD MON YYYY/HH:MM:SS AM/PM, locale-independent, ticking once a second without flicker. - Messages — static (word-wrapped or newline-split across the two lines) or software scroll (per-row source, direction, and speed).
- Hardware marquee — the display's built-in autonomous ticker on the top row.
- Custom glyphs — a 9-slot 5×7 glyph editor + an unlimited saved library, with
{g0}..{g8}placeholders to mix glyphs into text. - Brightness + animations — four brightness levels;
flash/blink/pulse. - Code pages — 12 selectable character sets.
- Stereo spectrum analyzer — a real-time audio analyzer in three layouts (Full / Stereo-V / Stereo-H) × two styles (Bars / Line).
- Web control surface — a Svelte single-page app over a FastAPI backend with a live pixel-accurate phosphor preview of the glass.
Three cooperating processes, coupled only through files and one socket. The daemon is the sole owner of the serial port — nothing else ever touches it.
┌─────────────────────────── web (optional) ───────────────────────────┐
│ Svelte UI (ui/dist) ──HTTP──► FastAPI (web/app.py) │
│ ▲ polls /api/status │ writes state.json │
└──────┼────────────────────────────────────┼───────────────────────────┘
│ ▼
status.json ◄── (daemon writes) ──┐ state.json (web/audioviz read)
▲ │ │
│ ┌─────┴────────▼──────────────────────────┐
│ │ DAEMON (checkout.daemon) │
└───────────────────────┤ ONE fast loop (~30 Hz): │
│ state.json → active frame → renderer │
│ → VFDDriver → SERIAL (sole owner) │
│ emit-diff writes; status.json heartbeat│
└───────────▲───────────────┬─────────────┘
│ │ 9600 8N1, write-only
unix DGRAM socket │ ▼
(20-byte tagged frames, newest-wins) │ ┌────────┐
│ │ VFD │ 2×20
┌──────────────────────────────────────────┴──┐ └────────┘
│ AUDIOVIZ (checkout.audioviz) │
│ parec capture → deinterleave → numpy FFT │
│ → 20/19-band / level → encode → socket │
│ reads audio_* + spectrum_* from state.json │
└──────────────────────────────────────────────┘
Why it's split this way:
- Single serial owner. The 9600-baud port can't be shared — two writers would
interleave bytes and corrupt frames. Exactly one process (the daemon) opens it.
Everyone else influences the display by writing
state.json, which the daemon reads each loop. - File ownership is one-directional. The web UI writes
state.jsonand the daemon writesstatus.json; neither writes the other's file. No locks, no races — just two single-writer files. - Audio capture is local and heavy. The spectrum data is ~21 frames/sec of bar
heights — far too much to route through
state.json. It goes over a unix datagram socket instead (newest-frame-wins, so a slow reader can never lag the display). Only lightweight settings travel viastate.json. Keeping audio in its own process also means it can crash, restart, or be absent without ever touching the serial port.
The physical build, bench-verified on this exact unit (see spec.md
§1 for the full survey). Everything below was measured, not assumed.
- Unit: IBM SurePOS 500 customer display, P/N 15K2012 (iron-gray housing).
- VFD board: Futaba M202MD10C family — 2 lines × 20 chars, blue-green VFD. No public datasheet; the protocol was reverse-engineered (see below).
- Cable: IBM genuine OEM 54Y2454 (RJ-to-DB9, 1 m) — the matched SurePOS 500 harness. Display side is the RJ jack ("port 4"); host side is DB9.
A sealed dongle: a USB cable to the host on one side, a 12 V barrel jack on the other; inside, a USB-RS-232 adapter and the power-injection wiring.
- Adapter: an OIKWAN USB-to-RS-232 (DB9 male, FTDI) adapter →
/dev/ttyUSB0. The display takes true RS-232 directly — no MAX3232 / level shifter / inversion needed (bench-confirmed: normal polarity, inversion OFF). (FTDI FT232R, USB id0403:6001— confirmed viaudevadm info /dev/ttyUSB0.) - Power: a 12 V 1 A regulated supply (5.5×2.1 mm center-positive barrel jack), fused with an inline 1 A fuse on the +12 V line.
- Breakouts/enclosure: 2× panel-mount DB9-female screw-terminal breakouts (one mates the cable, one the adapter) in a Hammond 1591C ABS box; 22 AWG wire.
DB9 pinout (BENCH-CONFIRMED on this exact cable — the generic RS-232 chart is wrong for pins 7/8 here)
| DB9 pin | Measured | Use |
|---|---|---|
| 1 | +11.75 V back-feed | |
| 3 | ~163 mV steady idle | DATA — host TX → here |
| 5 | 0 V | GND |
| 8 | +11.75 V | +12 V power in |
Final wiring: pin 3 = DATA, pin 5 = GND, pin 8 = +12 V, pin 1 left open. Power injection joins DATA (3↔3) and GND (5↔5) across the two breakouts and feeds fused +12 V onto cable pin 8; all grounds common.
⚠️ Bench finding — the back-feed hazard. On this cable pin 1 sources ~12 V back through the harness (it's the "VFD present"/detect line). Wiring it can destroy your adapter's TX pin. Leave pin 1 permanently unconnected. Only pins 3, 5, and 8 are wired.
- Write-only, 9600 8N1. Host RX is unused in this build.
- Bench finding — 9600 is the ceiling. Bring-up confirmed 9600 8N1 works directly; 9600 is treated as the hard cap for this link. Don't try to speed it up — the renderer and frame rate are designed around the ~960 bytes/sec budget (a full 40-cell frame ≈ 40 ms on the wire ⇒ ~21 fps is the practical ceiling).
Single-byte control codes — not ESC/POS (0x1B 0x40 printed a literal "@", so
ESC-prefixed commands don't apply). The command table was recovered from the
SNMetamorph FutabaVfdM202MD10C
source (our exact board) and bench-confirmed. driver.py is the only code that
emits these bytes.
| Command | Bytes |
|---|---|
| Extended mode | 0x00 + 0x01 enable / 0x00 disable |
| Select code page | 0x02 + page byte (12 pages) |
| Define character | 0x03 + code + 7 row bytes + 0x00 |
| Dimming / brightness | 0x04 + 0x20/0x40/0x60/0xFF |
| Print ticker text | 0x05 + text + 0x0D (hardware marquee) |
| Backspace | 0x08 |
| Self test | 0x0F |
| Set cursor position | 0x10 + position byte |
| Disable vertical scroll | 0x11 |
| Enable vertical scroll | 0x12 |
| Cursor on / off | 0x13 / 0x14 |
| Reset | 0x1F |
| Write text | printable ASCII (auto-advances) |
Every open()/reconnect must send:
0x1F reset
0x00 0x01 enable extended mode ← the missing piece
0x11 disable vertical scroll
Bench finding — the whole early saga was a missing init. Without
0x00 0x01+0x11, the display scrolls when the bottom-right cell is written. Earlier versions invented a "39-cell limit", a "0x27phantom scroll", a glyph-anchor rule, and a "no leading clear" rule to work around it — all of them were artifacts of never enabling extended mode. With the init sequence, all 40 cells are writable and addressing is plainposition = column + row*20.VFDDriver.initialize()sends exactly these bytes.
- Top line
0x00–0x13(0–19), bottom line0x14–0x27(20–39). - Cursor-off must be last.
0x14hides the cursor, but any later write re-enables it — there's no persistent off. So0x14is the final byte of every frame. The whole frame is built as one buffered write (no flicker). - Four brightness levels (bench-confirmed under extended mode):
0x04+0x20/0x40/0x60/0xFF(Min/Med/Med+/Max), applied live.
Codes 0x15 0x16 0x17 0x18 0x19 0x1A 0x1C 0x1D 0x1E (slot 0..8; 0x1B is
skipped). Define with 0x03 <code> <7 rows> 0x00; display by writing the code
byte. Rows are 5×7, editor-natural (low 5 bits = columns 1..5); the driver shifts
them <<3 because the panel reads columns from bits 3–7.
Bench finding — the whitespace-glyph trap. Glyph codes
0x1C/0x1D/0x1Eare ASCII control characters that Python'sstr.split()treats as whitespace. A naivesplit()on rendered text silently drops glyph runs (this dropped slots 6–8 in v0.5.x). Split on the space character (" ") only, and count a{gN}as exactly one cell. A future tinkerer will hit this.
The built-in ticker (0x05 + text + 0x0D) is autonomous, but:
Bench finding — you cannot scroll + clock at the same time in hardware. The ticker is TOP-ROW ONLY, runs at a FIXED medium speed (no speed parameter exists — bench probes found none), has a 45-char buffer, and loops. Crucially, a write to the bottom row that lands mid-scroll STOPS the top scroll. So a continuous top-row marquee with a live per-second clock on the bottom row is impossible in hardware — don't re-attempt it. (A single static bottom write is fine; it's the repeated per-second write that halts the scroll.) The software
scrollmode exists precisely for "scrolling text + live clock" cases.
# 1. Install (a virtualenv is recommended)
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
# 2. No hardware — print the outgoing bytes as hex
python -m checkout.daemon --dry-run
# 3. Live against the display
python -m checkout.daemon
# 4. Emit a single frame then exit (leaves it on screen) — handy for hardware tests
python -m checkout.daemon --onceYou should see a centered date on the top line and a ticking HH:MM:SS on the
bottom line.
The fast loop. The daemon runs ONE loop at ~30 Hz (CHECKOUT_LOOP_HZ). Each
iteration it mtime-gates state.json (re-parses only when it changed),
computes the active frame, and emit-diffs to the serial port — it only writes
when the rendered frame actually changed. So static modes (clock between ticks,
idle messages) cost nothing, while spectrum gets its full frame rate from the
same code path. status.json is throttled to ~6 Hz (CHECKOUT_STATUS_HZ).
Bench finding — drain the serial write, or it lags. The port is opened non-blocking, so a bare
serial.write()is fire-and-forget into the OS TX buffer. At ~30 fps renders but only ~21 fps of actual wire drain, frames pile up in that kernel buffer until it's full (~1–1.5 s), and the glass always shows frames that old — a creeping latency drift (the spectrum bars trailing ~1–2 s behind the music). Fix:flush()/tcdrain()after every write so the daemon blocks until the bytes are actually on the wire and paces itself to the real 9600-baud speed. The TX buffer can never back up.
A Svelte SPA served by a FastAPI backend. FastAPI never opens the serial port —
it only reads status.json and writes state.json, reusing checkout.state so
the format matches the daemon byte-for-byte. The preview is a pixel-accurate 2×20
of phosphor dots driven from status.json, so it mirrors the real glass (clock
ticks, marquee motion, brightness, blank, and the spectrum bars).
pip install -r web/requirements.txt
( cd ui && npm install && npm run build ) # build the Svelte UI -> ui/dist
uvicorn web.app:app --port 8000 --no-access-log # serves the UI + /api--no-access-log keeps the console quiet (the UI hot-polls /api/status ~2×/s).
For development: uvicorn web.app:app --reload --no-access-log plus
cd ui && npm run dev (Vite proxies /api).
A real-time audio analyzer at ~21 fps. A separate process captures audio, FFTs
it, and streams frames to the daemon over the unix datagram socket (the daemon
stays the sole serial owner). Capture runs only while mode is spectrum.
pip install -r requirements-audio.txt # numpy + sounddevice (PortAudio)
python -m checkout.audioviz --list # enumerate devices -> devices.json
python -m checkout.audioviz # capture + stream to the daemonThen set mode spectrum (UI or state.json) and pick a source:
system— what's playing, via a PipeWire/PulseAudio monitor source (auto-picks the default sink's.monitor).mic— the default (or chosen) input; falls back tosounddevice/PortAudio if Pulse is absent.
The Device dropdown is minimal — "Auto" plus the handful of real Pulse monitors (system) or inputs (mic), labeled; no raw ALSA/hw nodes.
Volume-independent (auto-gain). Bars normalize against recent broadband loudness, so they fill the display based on content, not system volume — turn the volume down and the bars stay full. A silence floor lets them fall flat (no amplifying hiss). Sensitivity biases the auto-gain; Smoothing is the visual release/decay (0 = snappy, higher = slower fall). Spectrum borrows the 9 glyph slots for its bar glyphs and restores your custom glyphs on exit.
System packages (Arch): pipewire-pulse (pactl / pw-record) and
portaudio (mic fallback / sounddevice).
For several releases the spectrum "popped to the top and fell to zero ~2×/sec with a 1–2 s delay." It looked like a DSP bug every time. The DSP was correct the whole time — each cause was a capture/transport problem, found by direct bench measurement, not by reading docs. In order of discovery:
- Capture tool —
pw-record/pw-catstarve a pipe. Piped from a.monitor, they deliver one good buffer then drop to near-silence (RMS ~0.00003).parecsustains (~0.2). Tool priority is parec-first,pw-recordonly as a fallback. (Note: PortAudio's ALSA backend can't see.monitorsources at all — that's why native capture is used for system audio.) parecblock-buffering. Without a latency hint,parecbuffers ~750 ms and dumps audio in bursts (gaps up to ~2000 ms). With--latency-msec=10, gaps are steady ~21 ms. This burst delivery was the pop-to-top / fall-to-zero pump plus the 1–2 s delay.- Serial TX backlog. Fire-and-forget
serial.write()into a non-blocking port let ~30 fps renders pile into the OS TX buffer (which drains ~21 fps), accumulating ~1–1.5 s of latency drift. Fix:flush()/tcdrainafter each write paces the daemon to the wire (see the fast-loop note above).
The meta-lesson: measure at the boundary. Each of these masqueraded as a DSP
bug across multiple releases; a parec | hexdump and an RMS print found them in
minutes where the datasheet (there isn't one) never could.
Structural latency floor (don't chase it). A small residual offset remains — FFT window + the PipeWire monitor tap (post-output, behind the speakers) + parec
- serial. It's bench-estimated at ~70–90 ms and is irreducible; it's physics, not a bug.
Tuned defaults (all in audioviz.py / config.py, all tunable):
PAREC_LATENCY_MS = 10, BLOCK = 256 (FFT frame), LOOP_HZ = 30.
The Bars/Line style and the Full/Stereo-V/Stereo-H layout toggle independently. Style applies across all layouts; the fine horizontal resolution applies only to Stereo-H (Stereo-V is inherently 7 vertical levels per cell).
| Layout | Top row / bottom row | Resolution |
|---|---|---|
| Full | one mono spectrum across both rows | 20 bands, double-height (0..14) |
| Stereo-V | LEFT spectrum / RIGHT spectrum | 19 bands each, one cell tall (0..7) |
| Stereo-H | LEFT level meter / RIGHT level meter | 19 cells × 5 columns = 95 steps |
- Bars fills solidly to the level (whole cells + a partial leading cell to the exact column); Line lights only the peak — a single row (vertical) or a single gliding column (horizontal, 5 px/cell = 95 fine steps).
- Inverted L/R labels. Cell 0 of each stereo row is a hand-designed inverted glyph (lit frame, dark letter) so it reads as a label, not a bar.
- Shared auto-gain (deliberate). L and R normalize against one reference (the broadband loudness of both channels), not independent per-channel gain — so a louder channel reads visibly louder and you can see the stereo balance. Independent gain would hide it.
- Tagged socket protocol. Every datagram is
byte 0 = layout tag+ a per-layout payload —full→ 20 heights,stereo_v→ 19 + 19,stereo_h→ 2 levels.decode_framereturns a dict orNoneon a malformed / mid-switch frame (the daemon ignores it, never crashes). - 9-glyph-slot budget, redefined on mode change. full = 7 height glyphs; stereo_v = 7 + L + R = 9 (exact fit); stereo_h = 5 column glyphs + L + R = 7. The daemon redefines the slots whenever the layout or style changes.
spectrum_layout and spectrum_style live in state.json (set by the UI), not
as environment variables.
| Variable | Default | Purpose |
|---|---|---|
CHECKOUT_PORT |
/dev/ttyUSB0 |
serial device |
CHECKOUT_BAUD |
9600 |
baud rate (the hard cap — see above) |
CHECKOUT_LOOP_HZ |
30 |
daemon fast-loop rate (Hz) |
CHECKOUT_STATUS_HZ |
6 |
status.json write rate (Hz) |
CHECKOUT_TICK_MS |
250 |
legacy per-tick sleep (kept for --once) |
CHECKOUT_STATE_PATH |
./state.json |
web writes, daemon reads |
CHECKOUT_STATUS_PATH |
./status.json |
daemon writes, web/UI reads |
CHECKOUT_LIBRARY_PATH |
./library.json |
saved messages/glyphs (web-only) |
CHECKOUT_SPECTRUM_SOCK |
$XDG_RUNTIME_DIR/checkout-spectrum.sock |
audioviz → daemon socket |
CHECKOUT_DEVICES_PATH |
./devices.json |
enumerated audio devices (audioviz) |
CHECKOUT_UI_DIST |
ui/dist |
built UI directory (web-only) |
CHECKOUT_DEBUG_TX |
(unset) | =1 hex-logs every serial write |
Per-mode behavior (mode, message, brightness, animation, spectrum layout/style,
audio source/sensitivity/smoothing, glyphs, …) is not env-configured — it lives
in state.json and is driven by the web UI. See the state.json schema in
CLAUDE.md.
The port is write-only at 9600 8N1. Your user must be in the device's group (on
Arch this is uucp) or run with sudo:
sudo usermod -aG uucp "$USER" # then re-logindeploy/ installs check-out as three systemd user services that start when
you log in:
| Service | Runs | Role |
|---|---|---|
checkout-daemon |
python -m checkout.daemon |
owns the serial port, drives the glass |
checkout-audioviz |
python -m checkout.audioviz |
captures audio + FFT → spectrum bars |
checkout-web |
uvicorn web.app:app … |
serves the UI + API on http://127.0.0.1:8000 |
deploy/install.sh # build UI if needed, install + enable + start all three
deploy/uninstall.sh # stop, disable, remove the unit filesThe installer resolves the repo path from its own location and substitutes it
into the unit templates (deploy/systemd/*.service), so nothing personal is
committed — the units ship with a __CHECKOUT_REPO__ placeholder.
Logs / control:
journalctl --user -u checkout-daemon -f # follow a service's log
systemctl --user status checkout-daemon # state
systemctl --user restart checkout-web # restart oneWhy user services, not headless/lingering. These are user units that run
inside your login session (we deliberately do not loginctl enable-linger).
The spectrum system-audio capture taps the user's PipeWire monitor, which
only exists inside an active login session — a headless/lingering box would have
no audio session and the spectrum would read silence. Starting on login gives
audioviz the PipeWire graph it needs. The display, web UI, and all non-audio
modes would work headless too, but a single consistent install is simpler.
- Empirical over assumed. There's no datasheet; the bench is the source of truth. Every hardware/protocol fact here was measured on the actual unit — the hardest bugs (the init-sequence scroll, the capture starvation, the parec burst-buffering, the serial backlog) were all found by direct measurement, never by documentation.
- Serial over USB quirks; one owner. The display takes RS-232 directly (no level shifter). Exactly one process holds the port; everything else is filesystem- and socket-coupled, single-writer-per-file, so there are no races.
- Scope discipline. Things that can't work are removed or hidden, not left half-wired — e.g. the marquee's impossible live-clock bottom row was removed and the UI control hidden, rather than shipping a broken toggle.
- Versioning:
major.minor.patchread as "big.small.bug."
python -m pytest # Python: driver, renderer, daemon, spectrum, state, web
cd ui && npm run verify # JS: svelte-check (types + a11y) + vitest + vite buildnpm run verify is the mandatory gate for any UI change — it must pass with zero
errors and no a11y warnings before commit.
Always-on via systemd.Shipped in v1.3.0 — three systemd user services (daemon, audioviz, web) that start on login (deploy/). User (not lingering/headless) so spectrum's PipeWire monitor capture has an active session. See Running as a service above.- News feed into SCROLL. The per-row content-source enum is already news-ready
(
message|clock, with a documentednewsextension point instate.pyand the daemon's_scroll_row) — wiring a live news source is a drop-in. - Additional display frames + rotation between modes.
- Command set — SNMetamorph/FutabaVfdM202MD10C
(MIT): the authoritative Futaba M202MD10C protocol — command bytes, the
extended-mode init (
0x00 0x01) that fixed the vertical-scroll behavior, the 9 user-glyph codes (0x15–0x1E), and the brightness/code-page/cursor/reset commands. Extended-mode discovery credited toabomin. - Preview charset — Eigenbaukombinat/vfd_kassendisplay (Unlicense): the real 5×7 glyph bitmaps, decoded from its per-character display photos.
These are published facts (command bytes, glyph bitmaps), each independently bench-confirmed on our unit; the driver and all other code here is original.