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
|
roles field keyed by role; probes still by ROM id |
UI uses roles via probeValues() (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 |
POST /api/sensor-setup/* → temps.command
|
Implemented; 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
"roles": { "hot_in": 112.0, "tub1": 101.2, "tub2": null, "tub3": 100.9, "tub4": 101.0 },
"probes": { "28-aaa": 101.2, "28-bbb": null }, // ROM id (diagnostics)
"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 when it changes (and every poll while setup_active). Useful for the wizard.
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 |
— | Publishes setup_start on temps.command
|
POST |
/api/sensor-setup/finish |
— | Publishes setup_finish (temp rejects if not ready) |
POST |
/api/sensor-setup/cancel |
— | Publishes setup_cancel
|
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 — done
The temp daemon publishes roles on each temps.reading / temps:latest (see role_readings() in src/temp/identity.py). The React UI reads probes via probeValues() (roles first, ROM probes fallback).
Gap 2: runtime sensor setup mode — done
- Temp daemon:
temps.commandopssetup_start/setup_finish/setup_cancel(src/temp/commands.py). - Web:
POST /api/sensor-setup/{start,finish,cancel}→ Redis publish. - Wizard: three steps in
SensorSetupPage.tsx; step 1 blocks Continue until no probes report; finish waits forsetup_state === ready(with timeout if temp rejects finish). - Control: heat stays off via
safety.py;health:controlisdegradeduntilsetup_state === readyandtub_avg_fis present (src/control/daemon.py).
Main Control shows a Sensors not ready banner when setup_state !== ready. See sensor-connect-workflow.md.
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.