Web Interface + API — Developer Guide
Handoff document for whoever builds the real web service and React UI (human or AI agent). It collects the architecture rules, the data contracts the UI depends on, what the HTML mockup already settles, what is still missing elsewhere in the system, and a suggested order of work.
Status (2026-10-19): Flask web service, React UI (frontend/), systemd install, and temp-daemon setup commands are implemented. See INSTALL.md.
Read these first
| Document | Why it matters |
|---|---|
| interface-requirements.md | The user's requirements for the UI. Source of truth for screens and behavior. |
| mockup/ | Interactive HTML mockup of every screen, with a fake backend. Visual and behavioral reference. |
| ../../software-architecture.md | System design: four services + Redis, ownership rules, the original Web/API section. |
| ../control/README.md | Control daemon behavior: modes, heat_phase values, commands, published state. Supersedes the mode list in the architecture doc.
|
| ../temp/README.md | Temperature daemon: probe bundles, teach-in rules, temps:latest / temps:setup. |
| ../temp_sim/README.md | Simulator that publishes fake temperatures to Redis; useful for testing the web service without probes. |
| ../../pi-zero-control-panel.md, ../../indoor-monitor.md | Other clients that will talk to this API (see API contract). |
Where the web service fits
Browser / phone / tub console
│ HTTP (LAN)
▼
┌──────────────────────┐ PUBLISH control.command ┌─────────────────┐
│ Web service │ ──────────────────────────▶ │ Control daemon │──▶ GPIO relays
│ (this package) │ │ (src/control) │
│ │ ◀── SUBSCRIBE control.state ─┤ │
│ - serves React app │ GET control:latest └─────────────────┘
│ - /api/events (SSE) │
│ - /api/command │ ◀── SUBSCRIBE temps.reading ┌─────────────────┐
│ - health heartbeat │ GET temps:latest/setup │ Temp daemon │──▶ 1-Wire probes
└──────────┬───────────┘ ── (new) temps.command ────▶ │ (src/temp) │
│ └─────────────────┘
└── SET/PUBLISH health:web / health.web ──▶ Health daemon (LEDs, not built yet)
Rules that must not be broken
- The web service never touches GPIO or the 1-Wire bus. It only publishes intents to Redis. The control daemon is the only process that decides whether heat may run, and it re-validates everything (target clamping, safety interlocks). See "Design Principles" in the architecture doc.
-
"Intent in, Control decides, state out." The UI displays what
control.statesays, not what it asked for. Don't flip the UI optimistically and leave it there; wait for the next state snapshot (it arrives within about one second). - Never fake a fresh-looking number. If the live connection drops or data is stale, the UI must say so (the mockup greys the page and shows "Reconnecting…").
- Redis stays on localhost. The web service is the only thing exposed on the LAN.
- No auth in v1. The Pi is on a private LAN. If remote access is ever wanted, use a VPN (e.g. Tailscale) rather than building a user system (architecture doc, "Open Decisions").
What exists today
| Piece | State | Location |
|---|---|---|
| Temperature daemon | Built, tested, has systemd/install |
src/temp/, deploy/temp/
|
| Control daemon | Built, unit tests, has systemd/install |
src/control/, deploy/control/
|
| Temperature simulator | Built (manual tool) | src/temp_sim/ |
| Health daemon (LEDs) | Not built | — |
| Web service + React UI | Built |
src/web/, deploy/web/
|
| UI mockup | Built | src/web/mockup/ |
The mockup
Running it
No build step and no dependencies; mock-backend.js runs inside the browser.
# Same machine: just open the file
xdg-open src/web/mockup/index.html
# From a phone on the LAN
cd src/web/mockup && python3 -m http.server 8080 --bind 0.0.0.0
# then browse to http://<this-machine-ip>:8080/
Each browser tab runs its own independent simulated tub and resets on reload.
Files
| File | Role | Fate in the real app |
|---|---|---|
index.html |
Shell that loads the two scripts | Becomes Vite's index.html
|
styles.css |
All styling, CSS variables, responsive breakpoints, animations | Reuse almost verbatim (global CSS or split per component) |
app.js |
UI: state, routing, components, event handling | Port to React components (see map below) |
mock-backend.js |
Fake API + fake tub physics + the dashed "Mock backend" panel | Delete; replace with a real API client. Optionally keep as a dev-only mock. |
Component map (mockup function → React component)
Every PascalCase function in app.js takes a props object and returns markup, so each maps 1:1 to a React component:
| Mockup function | Purpose |
|---|---|
App, Page
|
Layout + route switch |
Header, ConnectionBadge
|
Brand, the two nav tabs, live/reconnecting badge |
StatusLight |
Reusable green / yellow (pulsing) / red / grey indicator |
ControlPage |
Main Control screen |
ErrorBanner |
"Heater problem" banner with "Reset (set Sleep)" |
TubGraphic, Duck
|
Horse-trough SVG with animated water, steam, duck; temp shown in the water |
Pump |
Fan SVG; blades spin + green light when running |
TargetTemp |
Target °F with −/+ buttons (disabled at 50 / 110 °F) |
ModeSelects |
Heat mode (Sleep / Maintain) and filter mode (Auto / On / Off) dropdowns |
StatusSummary |
Heater phase label, hot-input temp, filter status |
DiagnosticPage |
Backflush + Sensor Setup cards |
BackflushAnimation |
Droplet with spinning arrows + bubbles |
SensorReadings, ProbeRow
|
Per-probe readings with status lights |
SensorSetupPage, Stepper, SetupStep1..3
|
Three-step sensor teach-in wizard |
Non-component pieces and their React equivalents:
| Mockup | React |
|---|---|
Single state object + setState
|
One useReducer at the top (or a small context). State is small; no Redux needed. |
data-action attributes + delegated click/change handlers |
onClick / onChange props |
Hash routes #/control, #/diagnostic, #/diagnostic/sensor-setup/:step
|
react-router. Hash routing (HashRouter) avoids needing server-side fallback routes; browser routing also works if the server returns index.html for unknown paths. |
patch() DOM differ |
Delete. It exists only so CSS animations don't restart on each update; React does this already. Keep SVG structure stable across renders (toggle classes, don't conditionally mount the fan blades or water) or animations will restart. |
window.HotTubApi |
An api.js module + a useHotTubEvents() hook wrapping EventSource
|
UI decisions made in the mockup
-
Two mode dropdowns, not one. The requirements say "a dropdown to set the mode", but the control daemon has two independent modes (
heat_mode,filter_mode), so the mockup shows both. - Backflush is not in the heat-mode dropdown. It is started only from the Diagnostic page. While it runs, the dropdown shows a disabled "Backflush (running)" option so the select still displays a valid value.
-
Backflush button state: disabled from the moment it's pressed (local
backflushRequestedflag) untilcontrol.stateshows backflush has finished. The local flag covers the gap before the first state update arrives. It re-enables whenheat_modeandheat_phaseare no longerbackflush. There's no countdown because the control daemon doesn't publish remaining time. -
Startup failure (
heat_phase = error_startup): red banner with the error detail and a "Reset (set Sleep)" button. Settingheat_mode = sleepis the control daemon's documented reset path. - Steam shows when the heat pump is on or water is ≥ 95 °F (purely decorative).
-
Animations respect
prefers-reduced-motion. - Responsive breakpoints: ≥ 900 px two columns on Main Control; ≥ 760 px two columns on Diagnostic; < 600 px nav tabs move to a full-width second header row.
Where the mockup's fake data differs from the real system
These are the traps when swapping in the real backend:
| Topic | Mockup | Real system | What to do |
|---|---|---|---|
Probe keys in temps.reading
|
probes: { hot_in, tub1..tub4 } keyed by role
|
probes keyed by ROM id ("28-0123…") |
Temp daemon should publish by role (see Gap 1). |
| Unplugged probe | Present with value null
|
Absent from probes. null means "on the bus but the read failed" (CRC error / 85 °C sentinel) |
UI must treat "missing key" and "null" as different states. |
| Detection delay | Instant | The Linux 1-Wire driver can take several seconds to add or remove devices after a plug change, and readings arrive every 5 s | Wizard lights may lag; don't add timeouts that assume instant detection. |
| Setup mode |
startSensorSetup() etc. do nothing |
No runtime way to enter teach-in today | See Gap 2. |
| Startup check timing | 3 s per attempt | 20 s per attempt + 5 s pause between attempts (maintain.startup_delay_s, retry_off_s) |
A failed startup takes about a minute and a half to reach error_startup. |
| Heating rate | 0.15 °F/s (demo speed) | Real propane heater: very slow | — |
| Command acknowledgement |
sendCommand resolves with {id}
|
Control daemon doesn't reply; it sets last_command_id in its next control.state
|
Web service generates the id, returns it from POST; UI can match it against last_command_id if it wants confirmation. |
error_safety phase |
Never produced | Produced when temps go stale / need_reinstall while heating |
UI already has a label for it ("Blocked by safety check"). Consider a banner too. |
Data contracts
The UI should only depend on these shapes. They're produced by the other daemons today (except where marked).
control.state / control:latest (from src/control/state.py, state_snapshot)
{
"ts": "2026-10-19T20:00:05Z",
"heat_mode": "maintain", // sleep | maintain | backflush
"filter_mode": "auto", // on | auto | off
"target_f": 102.0,
"tub_avg_f": 98.5, // null if no valid temps
"hot_in_f": 112.0, // null if missing
"heat_pump": "on", // on | off (actual relay state)
"filter_pump": "off",
"heat_phase": "heating", // idle | startup_1..3 | heating | backflush | error_startup | error_safety
"startup_attempt": 1,
"error": null, // or { "code": "startup_failed" | "safety", "detail": "..." }
"last_command_id": "…",
"filter_window_active": false,
"filter_next_start": "2026-10-20T06:00:00Z" // UTC; null unless filter_mode=auto and outside window
}
Published on every change and at least every 5 s (heartbeat_s). Times are UTC with a Z suffix; display in local time.
temps.reading / temps:latest (from src/temp/daemon.py, build_sample)
{
"ts": "2026-10-19T20:00:00Z",
"setup_state": "ready", // ready | need_hot_in | need_water | need_reinstall
"probes": { "28-aaa": 101.2, "28-bbb": null }, // by ROM id today, see Gap 1
"tub_avg_f": 101.0,
"hot_in_f": 112.0,
"outdoor_f": null,
"ok_count": 4,
"fail_count": 0,
"spread_f": 0.6
}
Published every 5 s (interval_s). The control daemon treats readings older than 15 s as stale.
temps.setup / temps:setup
{ "ts": "…", "state": "need_water", "detail": "Learned hot_in=28-zzz", "visible_ids": ["28-zzz"] }
Published only when it changes. Useful for the wizard (see Gap 2).
Commands → control.command (validated in src/control/commands.py)
Envelope:
{ "ts": "2026-10-19T20:00:00Z", "source": "web", "id": "uuid", "op": "set_heat_mode", "args": { "mode": "maintain" } }
op |
args |
Used by UI for |
|---|---|---|
set_heat_mode |
mode: sleep | maintain | backflush
|
Heat dropdown, Backflush button, Reset button |
set_target_temp |
target_f |
(absolute set; tub console / API) |
target_temp_increment |
optional delta_f (default 1) |
+ button |
target_temp_decrement |
optional delta_f (default 1) |
− button |
set_filter_mode |
mode: on | auto | off
|
Filter dropdown |
Unknown ops or bad args are logged and ignored by the control daemon; there is no error reply. The web service should validate the same rules itself and return HTTP 400, so the UI gets immediate feedback.
Health → health.web / health:web (the web service must publish this)
Same shape as the other services:
{ "service": "web", "ts": "…", "status": "ok", "detail": "serving; redis ok", "pid": 1234 }
status |
When |
|---|---|
ok |
Accepting HTTP, Redis reachable |
degraded |
Redis flaky, or control:latest / temps:latest stale |
error |
Redis down |
Publish every ~5 s and on change. Use SET + PUBLISH with identical JSON, like src/temp/redis_out.py.
HTTP API
Recommended contract. Settle it before writing the React client:
| Method | Path | Body | Response / behavior |
|---|---|---|---|
GET |
/api/events |
— | Server-Sent Events. On connect, immediately send current control:latest and temps:latest (and temps:setup), then forward each Redis message. Event names = channel names: control.state, temps.reading, temps.setup. Send a comment line every ~15 s as keepalive. |
POST |
/api/command |
{ "op": "...", "args": {...} } |
Validate, add ts / source: "web" / id, publish to control.command. Return 202 { "id": "..." } or 400 { "error": "..." }. |
GET |
/api/status |
— |
{ "control": <control:latest>, "temps": <temps:latest>, "health": { temps, control, web } } for polling clients |
GET |
/api/health |
— | Web service health + ages of upstream keys |
POST |
/api/sensor-setup/start |
— | Depends on Gap 2 |
POST |
/api/sensor-setup/finish |
— | Depends on Gap 2 |
POST |
/api/sensor-setup/cancel |
— | Depends on Gap 2 |
GET |
/ and static assets |
— | Built React app |
SSE was chosen over WebSockets because traffic is one-way (server to browser), browsers reconnect EventSource automatically, and commands are plain POSTs. The architecture doc lists this as an open decision, and the requirements accept "SSE or websockets".
Conflicting older contract: pi-zero-control-panel.md sketches GET /status, POST /heating {enabled}, POST /target {target_f | delta_f} with differently named fields (average_f, heating_enabled, …). It predates the control daemon. Either update that doc to use /api/status + /api/command, or add thin compatibility routes that translate (enabled: true → set_heat_mode maintain, false → sleep). Decide before the tub console is built. indoor-monitor.md only needs target ±1 and status, routed through the same command path.
Gaps in other services that the UI depends on
Gap 1: role-labeled probe readings
The Diagnostic readings list and wizard step 3 show Hot input, Tub 1–4, but temps:latest.probes is keyed by ROM id. The temperature daemon already knows the roles (ProbeMap in src/temp/identity.py).
Recommended fix (temp daemon): add a role-keyed field to the reading, e.g.
"roles": { "hot_in": 112.0, "tub1": 101.2, "tub2": 101.0, "tub3": null, "tub4": 100.9 }
Order tub1..4 by the sorted ROM ids in the probe map (already sorted when saved). Distinguish "not on bus" from "read failed": either omit missing roles or add a parallel "present": {...} map. Keep probes as-is so nothing else breaks. Add unit tests next to src/temp/tests/test_identity.py.
Translating in the web service using /etc/hot-tub/temp-probes.json is possible but duplicates identity logic in a second process. Avoid that.
Gap 2: runtime sensor setup mode
Teach-in currently requires editing setup_mode in /etc/hot-tub/temp.json (or the --setup flag) and restarting hot-tub-temps. The wizard needs to start and stop it at runtime.
How teach-in works (from src/temp/identity.py, _try_learn), which constrains the design:
- Nothing is learned once the probe map is complete. Starting setup must clear the probe map, not just set
setup_mode. - With an empty map: 1 probe visible → learned as hot-in; 4 probes visible (setup mode only) → learned as the water set.
- With one side known: 5 visible → the other side is derived by set difference.
-
5 visible with an empty map →
need_reinstall. This is exactly why wizard step 1 makes the user unplug everything first.
Recommended design:
- Temp daemon subscribes to a new channel
temps.commandwith opssetup_start(back up then clear the probe map, enable setup mode),setup_finish(disable setup mode; only valid whensetup_state == ready),setup_cancel(restore the backed-up map, disable setup mode). Publish the active flag intemps:setup(e.g."setup_active": true). - Web service maps
/api/sensor-setup/{start,finish,cancel}to those ops. - Wizard gating should use
setup_state/ learned roles, not just "a reading appeared":- Step 1 → 2: user confirms disconnection; call
start. Show a warning if probes are still visible (the map would otherwise be learned wrong). - Step 2 Continue: enable when
setup_statebecomesneed_water(hot-in learned) orneed_hot_inwith 4 tub roles present (water learned). - Step 3 Finish: enable when
setup_state == readyand all five roles report values. Then callfinish.
- Step 1 → 2: user confirms disconnection; call
- While setup is active the control daemon sees no valid tub average and holds heat off. That's the desired behavior; the UI should say "Heating paused during sensor setup".
The interface requirements say Continue/Finish enable "when temperature readings start coming in". Gating on learned roles meets that intent and avoids letting the user continue before the daemon has actually stored the IDs.
Known issues to fix first
Found while writing this guide (reproduced with a direct tick_heat script). Both are in src/control/heat_fsm.py, in the heat_mode == "backflush" branch, and both directly affect the Backflush button:
-
Backflush never finishes when the tub is at or above target.
check_heat_allowedrefuses heat whentub_avg_f >= target_f, and the backflush branch returns before checking the timer. Result:heat_modestaysbackflushwith the pump off forever, and the UI's Backflush button stays disabled. Observed:tick 1..3: mode=backflush phase=backflush pump=off. -
With stale or missing temps, the heat pump turns on and off every second. The safety trip sets
heat_phase = error_safety; on the next tick the branch seesheat_phase != "backflush"and starts a new backflush with the pump on. Observed:pump=on, off, on, off.
Likely fix direction: backflush should not use the maintain "below target" gate (the control README says only other safety interlocks apply). On a real safety trip it should end the backflush and revert heat_mode to sleep instead of re-entering it. Add regression tests to src/control/tests/test_heat_fsm.py for both cases.
Implementation plan
Suggested order. Each step is independently testable.
Step 0: Fix control backflush bugs
See above. Without this, the Backflush button can get stuck disabled.
Step 1: Temperature daemon changes (Gaps 1 and 2)
- Role-keyed readings + tests.
-
temps.commandsubscription, setup start/finish/cancel with probe-map backup + tests. - Update
src/temp/README.mdandINSTALL.md(teach-in can now be done from the UI).
Step 2: Web service (Python)
Suggested layout, mirroring src/temp/ and src/control/:
src/web/
README.md ← this file
interface-requirements.md
mockup/ ← keep as reference
__init__.py
__main__.py # python -m web --config /etc/hot-tub/web.json [--dev]
config.py # bind host/port, redis_url, log_path, static_dir, stale thresholds
logging_setup.py # /var/log/hot-tub/web.log (same pattern as other daemons)
redis_io.py # GET snapshots, pubsub fan-out, publish command, health
commands.py # validate op/args (mirror src/control/commands.py rules)
server.py # HTTP routes + SSE
health.py # web health computation + heartbeat thread
config.example.json
tests/ # unittest: command validation, SSE framing, health rules
frontend/ # React app (Step 3)
deploy/web/
hot-tub-web.service # User=hot-tub, After=redis-server.service, Restart=on-failure
hot-tub-web.logrotate
install.sh
src/web/INSTALL.md
Framework choice (open, recommendation first):
-
Flask (
python3-flaskvia apt, matches the existingtemp-sensor/app.py). SSE works with a streaming generator response; run threaded (threaded=Trueorwaitress) because each SSE client holds a connection. Simplest to deploy. -
FastAPI + uvicorn (
python3-fastapi,python3-uvicornin Debian bookworm). Cleaner async SSE, more moving parts.
Either way, use one Redis pubsub connection that fans out to per-client queues, not one subscription per browser tab. Config defaults: bind 0.0.0.0:8080, Redis redis://127.0.0.1:6379/0.
Conventions to keep consistent with the other services:
- Package importable as
webwithPYTHONPATH=src; runs aspython3 -m web. - Runs as system user
hot-tub; config in/etc/hot-tub/web.json; log to/var/log/hot-tub/web.logvia stdliblogging+ system logrotate (copytruncate). - Redis reconnect with exponential backoff (copy the pattern in
src/temp/redis_out.py/src/control/redis_io.py). - Install script is idempotent and doesn't reinstall Redis if present (see
deploy/control/install.sh). - Tests use stdlib
unittest, runnable off the Pi:PYTHONPATH=. python3 -m unittest discover -s web/tests -v.
Step 3: React app
-
src/web/frontend/, Vite + React (TypeScript optional but recommended for the payload types above). - Port components from
mockup/app.js; reusemockup/styles.css. -
api.js:connect()→new EventSource("/api/events")withaddEventListener("control.state", …)etc.;sendCommand(op, args)→fetch("/api/command", { method: "POST", … }). - Connection badge:
open→ Live;EventSource.onerror→ Reconnecting (it auto-retries). Also mark data stale iftsis older than ~15 s even while connected (upstream daemon died). - Dev: Vite dev server with
server.proxyfor/api→ the Python service (on the Pi, or locally with Redis +temp_sim+ control daemon in--dry-run). -
Build on a dev machine, not on the Pi 3. Output to
src/web/frontend/dist/; the Python service serves that directory. Recommended: commitdist/(or havedeploy/web/install.shrequire it) so the Pi never needs Node.
Step 4: End-to-end on the Pi
- Install order: Redis →
hot-tub-temps→hot-tub-control→hot-tub-web. - Verify from a phone: live temps, ± target, mode changes reflected in
redis-cli GET control:latest, backflush completes and re-enables, wizard with real bundles,health:webpresent.
Testing without hardware
Target setup for running the whole stack off the Pi (everything except real probes and relays):
redis-server & # local Redis
cd src
PYTHONPATH=. python3 -m temp_sim # fake temps; reacts to control state
PYTHONPATH=. python3 -m control ... # needs a "Redis on, GPIO faked" mode (see below)
PYTHONPATH=. python3 -m web # to be built
Missing piece: python3 -m control --dry-run currently skips Redis and GPIO, so it can't take part in this loop, and without --dry-run it requires gpiozero and real pins. Add a small flag to the control daemon (e.g. --no-gpio) that builds RelayOutputs(dry_run=True) but keeps ControlRedis. Until then, inject state by hand with redis-cli SET control:latest '…' (examples in temp_sim/README.md).
Useful manual checks:
redis-cli SUBSCRIBE control.command # see what the web service publishes
redis-cli PUBLISH control.command '{"op":"set_heat_mode","args":{"mode":"sleep"},"id":"t1","source":"cli"}'
redis-cli GET control:latest
curl -N http://localhost:8080/api/events # watch the SSE stream
temp_sim always reports setup_state: ready with fake IDs, so it cannot exercise the wizard. Wizard testing needs real probes or an extension to temp_sim.
Open decisions
| Decision | Recommendation |
|---|---|
| Flask vs FastAPI | Flask + threaded server (apt-installable, simplest) |
| Hash vs browser routing | Hash routing (no server fallback needed) |
Commit built dist/ or build during install |
Commit dist/ (Pi 3 shouldn't run Node) |
Old Pi Zero API (/status, /heating, /target) |
Update that doc to the /api/* contract; add compatibility routes only if needed |
Expose filter auto schedule / band_f in UI |
Not in v1; config-file only (control README "Open decisions") |
| History charts (architecture "UI capabilities") | Out of scope until the control daemon writes history (SQLite not built) |
| Health summary on the Diagnostic page | Nice to have once the Health daemon exists; health:* keys are readable now |
| Auth | None in v1, LAN only |
Acceptance checklist (from the requirements)
- Responsive: large, medium, phone.
- Live updates via SSE; visible reconnecting/stale state.
- Main Control: trough graphic with current °F in the water.
- Heat and filter fans below the tub; spin + green light when running.
- Target °F with + / − buttons.
- Mode dropdown(s).
- Diagnostic: big Backflush button: starts backflush, disables, shows animation, re-enables when finished.
- Diagnostic: big Sensor Setup button leading to the 3-step wizard with indicator lights and gated Continue / Finish, returning to Diagnostic.
-
Web publishes
health:web. -
systemd unit, logrotate, install script,
INSTALL.md.