Skip to content

← Back to list

The local web UI: FastAPI inside the app

A physical knob would be optimal. Building one, not. I put the UI in the browser and made it reachable from any device on the LAN.

3 min read

A physical knob box would be optimal. Building one, not. I put the UI in the browser and made it reachable from any device on the local network. A pragmatic call, not a manifesto.

Schematic of the local web UI: header with connection status, preview canvas, tuning sliders (FPS, gamma, kelvin, brightness), and a metrics strip.

The context

Lumware runs on the laptop or a Raspberry Pi. But I want to adjust brightness from my phone while I’m physically touching the strip three meters from the screen. A native app per platform is absurd for a personal project. A local web server fixes everything at once:

  • Zero install on the control device — just a browser.
  • Same URL from phone, tablet, or laptop.
  • Zero app-store permissions, zero cross builds.

The cost is adding a server to the host process. But the host is already a long-running process — one more server doesn’t change the model.

Preview = strip. With --transport virtual the host boots without an Arduino: the final frame lands in a memory ring buffer, and the UI receives exactly the same bytes the strip would receive. On first boot the app seeds the bundled calib-*.png patterns into the resources directory so you can press Play immediately.

The decision

FastAPI for HTTP + WebSocket. React + Vite + Tailwind for the frontend, built to static and served by FastAPI itself. Everything packed with PyInstaller into a single binary.

Two WebSocket channels

/ws/framesraw binary bytes. Each message is a frame: N × 3 RGB bytes, no JSON, no header, no codec. Exactly the same byte sequence the codec sends to the transport, but before the binary-frame encoding (no SOF, no CRC). Why: this way the UI preview is by design the same thing the strip sees. There cannot be a “preview doesn’t match strip” bug because it’s literally the same source.

/ws/eventsbus events serialized to JSON. The UI gets PLAYBACK_METRICS, TRANSPORT_*, SETTINGS_CHANGED, etc. It draws charts, updates the connection indicator, shows logs. Want a new view? Subscribe to an event that already exists.

REST for what doesn’t change 30 times a second

  • GET /api/health — alive?
  • GET /api/ports — serial port autodetection.
  • POST /api/connection/connect{port, baud}.
  • POST /api/playback/{play,pause,stop}.
  • GET /api/resources — list images/videos.
  • PATCH /api/settings — partial Settings patch.

Each handler takes its dependencies via FastAPI’s Depends(). Tests use TestClient and build the deps manually — the create_app(...) factory is the seam.

LAN-only by design. No auth. CORS is wide open. That looks like a vulnerability until you remember the real scenario: one user, a private network, a physical device wired to the host over USB. If someone is on your LAN and wants to mess with you, they have bigger problems than an API that nudges your strip’s gamma. Adding login complicates the code, complicates the UX (re-auth on the phone every time?), and adds no real security to the threat model. The server listens on 0.0.0.0:8080 but the README is explicit: do not expose to the public internet.

The frontend is React out of pragmatism

Not aesthetics. Vite + React + Tailwind is what I can stand up fastest with tests (vitest), types (tsconfig.app), and deterministic builds. Story 07-07 bundles Vite’s dist/ inside the PyInstaller package — the final binary holds frontend and backend in one file. Until that build exists, FastAPI serves a placeholder HTML with the version and a note: “build the frontend with npm run build”.

What comes next

The whole system is on the table. What’s left is the moment of truth: the numbers. How many FPS we actually hit, what the cable’s latency is, where the bottleneck lives. Final post of the series: framerate and bottlenecks.