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

  1. 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.
  2. "Intent in, Control decides, state out." The UI displays what control.state says, 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).
  3. 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…").
  4. Redis stays on localhost. The web service is the only thing exposed on the LAN.
  5. 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 backflushRequested flag) until control.state shows backflush has finished. The local flag covers the gap before the first state update arrives. It re-enables when heat_mode and heat_phase are no longer backflush. 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. Setting heat_mode = sleep is 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:

  1. Temp daemon subscribes to a new channel temps.command with ops setup_start (back up then clear the probe map, enable setup mode), setup_finish (disable setup mode; only valid when setup_state == ready), setup_cancel (restore the backed-up map, disable setup mode). Publish the active flag in temps:setup (e.g. "setup_active": true).
  2. Web service maps /api/sensor-setup/{start,finish,cancel} to those ops.
  3. 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_state becomes need_water (hot-in learned) or need_hot_in with 4 tub roles present (water learned).
    • Step 3 Finish: enable when setup_state == ready and all five roles report values. Then call finish.
  4. 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:

  1. Backflush never finishes when the tub is at or above target. check_heat_allowed refuses heat when tub_avg_f >= target_f, and the backflush branch returns before checking the timer. Result: heat_mode stays backflush with the pump off forever, and the UI's Backflush button stays disabled. Observed: tick 1..3: mode=backflush phase=backflush pump=off.
  2. 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 sees heat_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.command subscription, setup start/finish/cancel with probe-map backup + tests.
  • Update src/temp/README.md and INSTALL.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-flask via apt, matches the existing temp-sensor/app.py). SSE works with a streaming generator response; run threaded (threaded=True or waitress) because each SSE client holds a connection. Simplest to deploy.
  • FastAPI + uvicorn (python3-fastapi, python3-uvicorn in 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 web with PYTHONPATH=src; runs as python3 -m web.
  • Runs as system user hot-tub; config in /etc/hot-tub/web.json; log to /var/log/hot-tub/web.log via stdlib logging + 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; reuse mockup/styles.css.
  • api.js: connect() → new EventSource("/api/events") with addEventListener("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 if ts is older than ~15 s even while connected (upstream daemon died).
  • Dev: Vite dev server with server.proxy for /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: commit dist/ (or have deploy/web/install.sh require 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:web present.

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.