The Tiny Tapeout stack¶
https://tinytapeout.fpgas.online/ is the public front end for the Tiny Tapeout
boards in the fleet. Every board page carries a live camera and an embedded
Tiny Tapeout Commander — a fork of the upstream app whose only hardware
dependency is a byte stream, so it drives a board over a WebSocket instead of
WebSerial. The bytes come from fpgas-tt, a daemon on that board’s Raspberry Pi
which owns the demo board’s USB serial port and fans it out to every viewer at
once. This page covers the software: the daemon, the board catalogue the daemon
and the site share, the demo bitstreams, and the Commander fork. The hardware is
on Tiny Tapeout FPGA demo board and
Tiny Tapeout chip demo boards; the Django side of the
site is The web application.
Overview¶
The whole path from browser to chip, redrawn from the design spec with the Welland host and switch names generalised:
browser ──https──▶ gateway (nginx, vhost tinytapeout.fpgas.online)
├─ /, /board/<slug>/, /docs/ Django `ttsite` (gunicorn)
├─ /static/tt-commander/<ver>/ built Commander embed bundle
├─ /live/<pi-hostname>.m3u8 existing HLS camera (unchanged)
├─ /ws/pistat/<pi>/ existing daphne status socket (unchanged)
├─ /ws/board/<slug>/serial ─ws─▶ 10.21.<s>.<p>:8765/serial fpgas-tt daemon on the Pi
└─ /api/board/<slug>/… ─http▶ 10.21.<s>.<p>:8765/… │
├─ /dev/ttboard → RP2040 (MicroPython SDK) → TT chip / iCE40
snmp toggle (existing) ──▶ switch PoE ──▶ Pi power └─ (later) PMOD HAT GPIO, Verilog build
Four properties of that picture matter more than the boxes:
No address is stored anywhere. A board is identified by
(switch, port)and everything else is derived from it — hostnamepi-sw<s>-p<p>, address10.21.<s>.<p>, camera stream/live/pi-sw<s>-p<p>.m3u8. That is the per-port VLAN scheme in Two addressing schemes; the older10.21.0.100+portconvention is not used on this host.The gateway runs nginx and Django only. The WebSocket path is a plain nginx proxy with no Python in the data path; the HTTP API paths are thin Django proxies that do the slug-to-address lookup, apply size limits and enforce CSRF. Nothing compiles and nothing opens a serial port there.
The Pi runs the daemon.
fpgas-ttcomes from thefpgas-online-ttdeb, baked into the shared read-only NFS root; nothing is installed or fetched after boot.Port 8765 is not public. The daemon listens on
0.0.0.0:8765and only the gateway can reach it, because each Pi sits alone in its own port VLAN and thefirewallrole allows the gateway to those interfaces on tcp/8765 while the Pis still cannot reach each other.
The fpgas-tt daemon¶
fpgas-tt opens /dev/ttboard at 115200 baud and keeps retrying every second
until a board appears, so a Pi whose board is unplugged simply waits. The device
node is a udev symlink for the demo board’s RP2040/RP2350 USB-CDC port; the rule
that creates it ships in the same package and is described under
Serial consoles. What the daemon holding that port open
means for anything else that wants it — and how to take it back, and why to give
it straight back — is under
Serial port ownership. The steps for an FPGA demo board are
How to stop the fpgas-tt daemon and start it again. Those for a chip board are
How to free a Tiny Tapeout board’s serial port from fpgas-tt.
One object owns that port. WS /serial is the bridge: every connected client
receives the same bytes from the board and may write bytes to it, with no
locking and no arbitration. A client whose send queue falls more than 256 KiB
behind is dropped, so the board reader is never blocked by a slow browser. On
shutdown every open /serial socket is closed with code 1001
(server shutdown); when the board goes away they are closed with 1011
(board disconnected) and the daemon starts re-opening the device once a
second. Internal tasks are clients of the same bridge, never a second owner of
the port — which is why a design load and a human typing in the terminal can
collide, and the task is the one that fails.
GET /health answers {"board": {"present", "device", "vid_pid", "usb_serial", "chip"}, "kind", "kind_reason", "clients", "idle_display": {"design", "state"} or null, "uptime_s", "version"}. vid_pid is the board’s USB
idVendor:idProduct and usb_serial its USB serial number (the value on the board’s label), both read from
sysfs and null when the device is not a USB tty. chip is what the board told the boot check it carries, and
kind follows from it; kind_reason says where that came from, or why it is not known. The site’s
/board/<slug>/status.json is a cached summary of it, with reachable added, which is what the status pill on a
board page reflects.
What the board is comes from the board, never from where it is plugged in. No file the daemon reads says
which Pi or which switch port carries which board, so to the daemon a board moved to another port keeps
everything it had. (The site’s catalogue, below, does map boards to switch ports, for the site.) The daemon reads the
USB serial of the device behind /dev/ttboard and looks that board up in the boot check’s report on the same Pi
(--report, default /run/fpgas-online/verify.json, written by fpgas-verify). The kind is what the board
itself told the check it carries: the FPGA breakout gives kind: fpga, anything else (a Tiny Tapeout chip) gives
other, and until the report says it the kind is unknown (no boot check yet this boot, a board plugged in
since, or a check that found the board and could not read it). The report is read at each request, so a boot
check that finishes after the daemon has started needs no restart. The daemon never asks the board what it is.
The serial bridge works whatever the kind.
FPGA-board routes¶
Nothing writes to the demo board’s filesystem. That is the rule for every Tiny Tapeout demo board (no code of
ours writes, replaces or deletes a file on one; an FPGA on a demo board is loaded by streaming only), and it is
what the daemon does: every design is a file on the Pi, a packaged demo (--demos-dir) or a visitor’s upload
(--uploads-dir). Loading one sends its bytes over the board’s raw MicroPython REPL into a buffer in the
RP2350’s memory and has the SDK’s own loader clock that buffer into the iCE40, so the board ends in the state the
SDK’s tt.shuttle.<design>.enable() leaves, with no file involved. The REPL work is a task run through the
bridge like any other client, never a second owner of the port.
On a kind: fpga board three more routes list, load and accept designs:
Route |
What it does |
|---|---|
|
Lists every demo whose |
|
Sends the bitstream and loads it. If the board’s SDK is not running it first does what the Commander does in that case (a soft reset from the REPL, which runs the board’s own |
|
Accepts an upload (multipart form |
The exact request and reply shapes of each route are in the daemon’s README.
A board that told the check it carries a chip gets 404 {"error": "not an fpga board", "detail": why} on all
three, and one the report does not say that for yet gets 503 {"error": "board not identified yet", "detail": why}. Every other failure has the same {"error", "detail"} shape: 503 board not present, 409 another task
is running (or a demo-name collision on upload), 404 no such design (including a name that could never be
valid, rejected before the board is asked), 502 REPL task failed with the board’s traceback in detail, 400
for validation failures and 500 for anything unexpected.
A load checks the buffer on the board against the Pi’s SHA-256 before the FPGA is touched; a load that fails, or
in which the SDK’s loader did not read every byte, leaves enabled as null.
--demos-dir (default /usr/share/fpgas-tt/demos) points at the demo bitstream set (index.json and one
<name>.bin per design). --uploads-dir (default /var/lib/fpgas-tt/uploads) keeps uploads; on the fleet’s Pis
that is lost at a reboot, like everything else a visitor leaves on a Pi.
Files from before. Until October 2026 the daemon copied every demo and every upload to the board’s
/bitstreams and loaded from there. Boards from that time still hold those files; the daemon neither reads nor
removes them. The SDK on the board does not know about any of this: its own list (tt.shuttle.projects) is still
the board’s /bitstreams, so tt.shuttle.<name>.enable() typed at the board’s prompt loads the board’s old copy
of that name, not the Pi’s, and uploads are not in that list at all.
The idle display. The display of an FPGA board nobody is using is kept moving: when no serial client is
connected and no client, Run or upload has happened for --idle-after seconds (default 60), the daemon asks the board once what
it has loaded, and if that is the SDK’s start state it streams the boot check’s display design again, the way a
load from the page does. Nothing is written to the board. Nothing is typed at the board while a serial client is
connected, and a design a visitor loaded is never replaced unless --idle-replace-after is given. /health
says what happened in idle_display.state. The README has the cases in full.
Not recorded: whether anything removes the old files under /bitstreams on a board, and what
tt.shuttle.projects shows on a board that never held them.
Source for this section and the one before: the daemon’s README
on main, as packaged in fpgas-online-tt 0.0.post71; read on 6 October 2026. This page described an earlier
daemon until that day.
Installation¶
The daemon is a deb, and nobody installs it by hand. onpi/tasks/tt.yml adds
fpgas-online-tt and fpgas-online-tt-demos to the shared Pi NFS root and
enables fpgas-tt.service there, and it does that in the provisioning chroot on
the gateway rather than on a booted Pi. Installing either package on a running
Pi would write into the tmpfs overlay above that read-only root and be gone at
the next reboot, and enabling the unit by hand would only duplicate what Ansible
already manages. What the two packages contain, how the install is gated, and
how fpgas-tt.service is invoked are in the package and
service tables on What runs on a Pi host.
The board catalogue¶
One file describes every board: /etc/fpgas-online/tt-boards.yaml. The infra
ttsite role renders it onto the gateway for the web tier. It is the site’s alone: the Pi NFS root carries
no catalogue, and the daemon reads none (onpi/tasks/tt.yml on main, read on 7 October 2026;
the daemon’s README). It is a list of mappings
with these keys:
Key |
What it carries |
|---|---|
|
The board’s identity: its |
|
Which switch port the Pi is plugged into. Everything on the network side is derived from the pair. A null |
|
|
|
Which Tiny Tapeout shuttle the chip came from; blank on FPGA emulation boards. |
|
Display text for the tile and the board info card. |
|
Lists of |
|
False keeps the slot rendered as “coming soon”. |
|
Ordering within the board’s section on the landing page. |
|
Selects a non-default Commander embed flavour for a board whose firmware the current bundle cannot drive. |
Note what is not in there: no IP address, no port 8765, and no design list.
Addresses are derived from (switch, port), and designs are read live from the
daemon rather than modelled in the database.
On the gateway the file is loaded into the site database by a management command:
$ uv run python manage.py ttsite_loadboards /etc/fpgas-online/tt-boards.yaml --prune
The load is an idempotent upsert by slug. --prune additionally deletes rows
whose slug has vanished from the file, and refuses to run against an empty board
list unless --allow-empty is given as well — so a template that renders to
nothing cannot silently empty the catalogue. The path the role takes to get
there, and the interpreter to use on the deploy host, are under
Deployment.
The daemon reads only the boot check’s report to learn what its board is (its README), and the Pi root
carries no catalogue (onpi/tasks/tt.yml): see
The fpgas-tt daemon. The site uses catalogue → (switch, port) to build the Pi’s
address for the WebSocket and API proxies, and to hand the board’s switch port
to the PoE views behind the “Power-cycle board” button — the same
/snmp/toggle endpoint the classic pages use, described under
PoE power control.
Demo bitstreams¶
FPGA emulation boards ship with a curated set of ready-to-load bitstreams so a
visitor can run something without synthesising anything. They live in the
tinytapeout-fpga-demos repository, are built in CI, and reach the Pi as the
fpgas-online-tt-demos deb, which installs index.json and one .bin per
design under /usr/share/fpgas-tt/demos/. Nothing is fetched at run time, and nothing is
copied to the board: the daemon streams a design from that directory into the FPGA when it is
loaded (FPGA-board routes).
The current set is five designs: tt_um_factory_test, tt_um_counter_7seg,
tt_um_pwm_breathe, tt_um_uart_hello and tt_um_vga_pattern. Each one is a
normal Tiny Tapeout template project — info.yaml, src/, docs/info.md and
an optional cocotb test/ — so it is a valid starting point for a real shuttle
submission, built here instead of taped out.
The build turns each project into an iCE40UP5K bitstream. A harness copied from
TinyTapeout/tt-fpga-compiler (tt_fpga_top.v plus a FabricFox .pcf) wraps
the demo’s top module so it can be synthesised standalone, and then:
$ yosys -p "synth_ice40 -top tt_fpga_top -json out.json" tt_fpga_top.v <sources>
$ nextpnr-ice40 --up5k --package sg48 --pcf harness/tt_fpga_fabricfox.pcf --pcf-allow-unconstrained --json out.json --asc out.asc
$ icepack out.asc <name>.bin
The resulting .bin is checked for the iCE40 bitstream preamble
7E AA 99 7E before it is accepted. The same tool writes bundle/index.json —
the shape the daemon reads back from /usr/share/fpgas-tt/demos/index.json —
and bundle/VERSION. CI lints, runs the index-derivation unit tests, runs the
cocotb bench of every demo that has one, and builds the full bundle and a trial
deb on every push.
Todo
The design spec lists six demos plus a stretch goal (tt_um_wokwi_example and
tt_um_kianv_fpga in addition to the five above), and describes the build as
tt_fpga.py harden per demo. What shipped is the five designs above, built by
the repository’s own tools/build.py with the tt-fpga-compiler harness.
Reconcile docs/superpowers/specs/2026-08-22-tinytapeout-fpgas-online-design.md
§8 with tinytapeout-fpga-demos/README.md, or record that the spec is
superseded there.
The Commander fork¶
The embedded Commander is fpgas-online/tt-commander-app, a fork of
TinyTapeout/tt-commander-app. Its main branch mirrors upstream untouched;
fpgas-online is the default branch and every change lands there, additively,
behind a transport abstraction so it stays offerable upstream.
A host page mounts the widget with mountCommander(el, opts), which returns
{ unmount, refreshDesigns }. unmount() tears the widget down and clears the
mount point. refreshDesigns() re-fetches the daemon’s design list into the
running widget, so an upload made elsewhere on the page — the site’s own upload
form — shows up in the project dropdown and the pinout tab without a remount; on
non-fpga boards it resolves immediately and does nothing. The options:
option |
meaning |
|---|---|
|
|
|
|
|
e.g. |
|
|
|
shows maintenance controls (Reset to Bootloader); default |
|
|
The embed deliberately keeps to itself: it injects no global styles and ships no CSS reset, scoping what it needs to its own root element, and it does not fetch web fonts — the host page has to provide Roboto.
On an fpga board the Config tab is backed by the daemon instead of the Tiny
Tapeout shuttle index. The project list comes from GET ${apiBase}/designs,
fetched on mount, again on every connect and reconnect, and on
refreshDesigns(); a failure shows a Retry button. “Load design” issues
POST ${apiBase}/designs/<name>/enable and stays busy until the daemon answers,
so a slow reprogram cannot be fired twice, and a rejection appears as a
dismissible alert. There is no mux index or subtile field, because the daemon
addresses designs by name and the selection is re-derived from the daemon’s
enabled name after every load. The pinout tab renders from the selected
design’s own metadata with no fetch to index.tinytapeout.com, and only the
docs_url and repo_url the daemon supplies are linked — an FPGA design has no
chip page and no feedback form.
The wire protocol on WS /serial is small: binary frames are board bytes in
both directions; server-to-client text frames are JSON events such as
{"event":"board","present":true,"device":"/dev/ttboard"} and
{"event":"error","error":"board not present"}; the close codes are
1011 board disconnected, 1008 client too slow and 1001 server shutdown. On
a close the embed shows the code and reason, tears the old device down, and
retries with exponential back-off — minDelayMs * 2 ** (attempt - 1) capped at
maxDelayMs, with ±20 % jitter so a roomful of viewers does not stampede a
daemon that has just come back. The attempt counter only resets after a
connection that stayed up for 10 s, so a board that flaps backs off instead of
hammering. Each new connection re-runs the REPL bootstrap.
Nothing arbitrates who is driving. The fork’s README puts it plainly:
The daemon fans the board’s bytes out to everyone connected, so everyone connected sees and can drive the same REPL — one viewer’s
select_design()changes what every other viewer sees. The Commander already tolerates unsolicitedtt.*=lines, so state converges instead of fighting. Nothing arbitrates access; the host page should say so, e.g. “other people may be driving this board too”.
Mount one widget per page. The board, shuttle and design stores are
module-global, so a second mountCommander(...) on the same page shares them
with the first and the two fight over the selected design.
Releases are tagged embed-vX.Y.Z on the fpgas-online branch and publish a
GitHub Release carrying tt-commander-embed-X.Y.Z.tar.gz and its .sha256,
which the infra ttsite role downloads and unpacks under the static root — no
node on the gateway. Two versions exist: 0.1.0, the initial fork with the
SerialTransport seam, the WebSocketTransport bridge and the embeddable build
(asic boards only), and 0.2.0, which adds the fpga kind, the
refreshDesigns() handle, and the reconnect and firmware-version fixes from the
fork’s issue #7. Which version a deployment serves is pinned by the
TTSITE_COMMANDER_VERSION setting; when it is empty the board pages show a
“bundle not deployed” notice instead of the embed. FPGA boards need 0.2.0 or
newer, because the design gallery calls refreshDesigns() after a run or an
upload. The download, the checksum pin and the static layout are under
Deployment.
Todo
The fork’s README documents only the embed-vX.Y.Z release line and does not
mention the second, legacy embed bundle that the ttsite role also downloads
and that the catalogue’s commander key selects — see
Deployment. Meanwhile
Tiny Tapeout shuttles and their demo boards still records the pre-2.x
board as unsupported by the Commander pending that work. Confirm which is current and document the legacy
bundle in README.fpgas-online.md, or drop it here.
Rolling releases¶
fpgas-online-tt and fpgas-online-tt-demos are rolling releases with no
manual version bumps: every green CI run on main builds the deb, attaches it
to the current series release, and the APT repository picks it up on its next
poll. Series tags are two-component vX.Y and are always pushed by a person,
never by CI. The mechanics — the version derivation from git describe, the
pull into the repository, and how a Pi installs from it — are on
Packages. The Commander embed does not go through APT at all:
it is a release tarball fetched by the infra role, pinned by version and SHA-256
in the inventory.
Not yet implemented¶
Phases 1 and 2 of the design spec — the ASIC boards end to end, then the FPGA emulation boards — have shipped. What has not:
KianV boot-to-Linux. The spec’s phase 3 is a one-click boot of the KianV RISC-V SoC with the Linux console shown in the Commander terminal, bridged from the chip’s UART by the demo board’s controller. It needs a hardware spike first — whether the pins land on a hardware UART or need a PIO one, and what baud is achievable — before the sequence is encoded as a daemon task. Neither
POST /kianv/bootnor the fork’skianvkind exists yet, and both READMEs say so.Verilog compiled on the Pi. The spec reserves
POST /buildas later work, taking Verilog instead of a finished bitstream. It needs an arm64 NFS root, because oss-cad-suite only shipslinux-arm64, and the root is still built from an armhf image. The spec numbers this step inconsistently: §11 lists it under phase 4, while §5.3 and §7.3 still label it phase 2. Either way it has not shipped.PMOD HAT features. The spec’s topology has each Pi carrying a PMOD HAT alongside the USB link, explicitly reserved and unused by everything above. Driving pins or capturing logic from the Pi is listed as later work.
The gateway API. A separate service is meant to take over the slug-to-address derivation and the per-board pass-through so that no front end touches the private network. It is planned, not deployed — see Gateway API.
The transport abstraction upstream. Offering the
SerialTransportseam back toTinyTapeout/tt-commander-appis listed as later work in the spec; the fork is kept rebaseable on upstream for that reason.
Todo
The design spec’s own open items are still open: the KianV UART bridge spike, recording which demo-board revision and firmware version sits on each port in the catalogue, the heads-up to the Tiny Tapeout team about hosting a public Commander instance under their logo, and the armhf-versus-arm64 decision for the Pi root. Track them where they belong rather than in the spec.
Sources¶
fpgas.online-infra, main:
docs/superpowers/specs/2026-08-22-tinytapeout-fpgas-online-design.md— the component diagram reproduced above, the addressing and camera-name derivations, the “nginx and Django only” split of the gateway, the single-owner bridge rule, the firewall rule that limits tcp/8765 to the gateway, the board model’s fields, the--pruneseeding step, the power-cycle button on the board page, the phasing, and the not-yet-implemented list includingPOST /build, the PMOD HAT reservation and the open items.ansible/roles/onpi/tasks/tt.yml— the two packages installed atstate: latest, that no catalogue goes into the Pi root (it was rendered from thettsiterole’s own template before), andfpgas-tt.serviceenabled only where the site defines Tiny Tapeout boards.ansible/inventory/host_vars/fpgas.online.yml— the shape of thett_boardslist only: which keys exist and what each one is for. No values from that file are reproduced here; the boards themselves are listed on the Welland page.
fpgas.online-tt, main:
README.md(as packaged infpgas-online-tt0.0.post71, read on 6 October 2026) — the device open and retry, theWS /serialfan-out with its 256 KiB slow-client drop, the 1001 and 1011 close codes, the/healthpayload, how the daemon learns what the board is from the boot check’s report, the three FPGA routes with their error shapes, that nothing writes to the demo board’s filesystem, the files left from before October 2026, the idle display, the install commands, and the rolling-release description.
tinytapeout-fpga-demos, main:
README.md— the package and its install path, the current five-demo set, the per-demo project layout, the harness and its origin, the yosys/nextpnr-ice40/icepack commands, the7E AA 99 7Epreamble check,index.jsonandVERSION, and the CI jobs.
tt-commander-app, fpgas-online:
README.fpgas-online.md— the fork’s branch layout,mountCommanderand its handle, the options table reproduced verbatim above, what the embed does and does not do to the host page, thefpgakind’s Config and Pinout behaviour, the wire protocol and close codes, the reconnect back-off, the multi-viewer paragraph quoted above, the one-widget-per-page rule, and theembed-vX.Y.Zreleases 0.1.0 and 0.2.0.
fpgas.online-site, main:
README.md— thettsite_loadboardsinvocation, what--pruneand--allow-emptydo, theTTSITE_COMMANDER_VERSIONpin with its “bundle not deployed” notice and the 0.2.0 floor for FPGA boards, and the three daemon-proxy endpoints. The rest of that README is covered on The web application.