projects/hot-tub/sensor-connect-workflow.md
... ...
@@ -0,0 +1,25 @@
1
+# Sensor Connect Workflow
2
+
3
+- System boots with no sensors connected
4
+- temp daemon reports health status to redis indicating no sensors connected
5
+- temp daemon reports regular schedule of events to pub-sub with no temp data
6
+- Control daemon reads temp updates with no sensors, reports health warning that sensors are not ready
7
+- User loads web interface
8
+- Web reads health from control daemon and temp daemon, shows warning to user that sensors aren't connected
9
+- User clicks "Diagnostic" -> "Sensor Setup"
10
+- User sees all 5 lights off
11
+- User connects either heat input sensor or water temp sensors
12
+- temp daemon reads the sensor(s) connected and records it in the config as either the heat input sensor or the water temp sensors, and reports the temperature (see `src/temp/README.md` "Probe bundles and identity setup")
13
+- Control daemon continues to report a warning that sensors are not configured
14
+- Web reads temp update showing some sensors reporting, shows appropriate lights on, allows user to continue to next step
15
+- User connects remaining sensor set
16
+- On the next tick, temp daemon reads from all sensors, noting the id(s) of the newly connected ones, and reports temperature data as normal (see `src/temp/README.md` "Probe bundles and identity setup")
17
+- Control Daemon reads temp events and sees all sensors connected, reports healthy status
18
+- Web notes temp events now have full sensor data and allows user to finish sensor connect diagnostics
19
+- system should operate as normal now
20
+
21
+## Implementation notes
22
+
23
+- **Temp:** `health:temps` reflects missing/incomplete probes; `temps.reading` includes `setup_state` and role-keyed `roles` (see `src/temp/README.md`).
24
+- **Control:** `health:control` is **`degraded`** while the latest reading has `setup_state` other than `ready`, stale/missing temps, or no `tub_avg_f`; **`ok`** when setup is complete and readings are fresh.
25
+- **Web:** Main Control page shows a warning banner with **Open Sensor Setup** when `setup_state !== ready`. Diagnostic → Sensor Setup wizard calls `/api/sensor-setup/*` → `temps.command`. Step 1 Continue requires all probe lights off before teach-in starts.
projects/hot-tub/src/control/INSTALL.md
... ...
@@ -71,7 +71,8 @@ cd src
71 71
PYTHONPATH=. python3 -m control --dry-run --once
72 72
PYTHONPATH=. python3 -m control --no-gpio # Redis live, GPIO faked (stack test with temp_sim + web)
73 73
PYTHONPATH=. python3 -m unittest control.tests.test_commands control.tests.test_safety \
74
- control.tests.test_filter_policy control.tests.test_heat_fsm control.tests.test_redis_io -v
74
+ control.tests.test_filter_policy control.tests.test_heat_fsm control.tests.test_redis_io \
75
+ control.tests.test_health_status -v
75 76
```
76 77
77 78
Dry-run uses `/tmp/hot-tub-control.log` and `/tmp/hot-tub-control-state.json` when system paths are not writable.
projects/hot-tub/src/control/README.md
... ...
@@ -256,8 +256,8 @@ Publish on an interval and on status changes:
256 256
- `SET health:control` + `PUBLISH health.control`
257 257
- JSON fields: `service`, `ts` (UTC ISO-8601), `status`, `detail`, `pid`
258 258
- `status`: `ok` | `degraded` | `error` (Health Daemon maps these to green / orange / red on the Control LED)
259
-- `ok` while loop running and Redis/GPIO usable
260
-- `degraded` if active `error_*` phase or stale temps while mode wants heat
259
+- `ok` while loop running, Redis/GPIO usable, and temperature reading is fresh with `setup_state == ready` and a valid `tub_avg_f`
260
+- `degraded` if active `error_*` phase; stale or missing temps; `setup_state` is `need_hot_in`, `need_water`, or `need_reinstall`; or no `tub_avg_f` on an otherwise fresh reading
261 261
- `error` on GPIO failure or Redis publish failure after reconnect
262 262
263 263
Health Daemon staleness for Control uses the `ts` field: **>10 s** → orange LED, **>60 s** → red (see [health/overview.md](../health/overview.md)).
projects/hot-tub/src/control/daemon.py
... ...
@@ -49,8 +49,15 @@ def _health_status(
49 49
return "error", "redis publish failed"
50 50
if state.heat_phase.startswith("error_"):
51 51
return "degraded", state.heat_phase
52
- if state.heat_mode == "maintain" and not temps_fresh(reading, cfg):
53
- return "degraded", "stale temps while maintain"
52
+ if reading is None or not temps_fresh(reading, cfg):
53
+ return "degraded", "stale or missing temps"
54
+ setup = reading.get("setup_state")
55
+ if setup == "need_reinstall":
56
+ return "degraded", "need_reinstall"
57
+ if setup in ("need_hot_in", "need_water"):
58
+ return "degraded", f"sensors not configured ({setup})"
59
+ if reading.get("tub_avg_f") is None:
60
+ return "degraded", "no tub temperature"
54 61
return "ok", "loop running"
55 62
56 63
projects/hot-tub/src/control/tests/test_health_status.py
... ...
@@ -0,0 +1,68 @@
1
+"""Tests for control health status vs temperature setup state."""
2
+
3
+from __future__ import annotations
4
+
5
+import unittest
6
+from datetime import datetime, timezone
7
+
8
+from control.config import ControlConfig
9
+from control.daemon import _health_status
10
+from control.state import ControlState
11
+
12
+
13
+def _reading(**kwargs: object) -> dict[str, object]:
14
+ base: dict[str, object] = {
15
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
16
+ "setup_state": "ready",
17
+ "tub_avg_f": 98.0,
18
+ }
19
+ base.update(kwargs)
20
+ return base
21
+
22
+
23
+class TestHealthStatus(unittest.TestCase):
24
+ def setUp(self) -> None:
25
+ self.cfg = ControlConfig()
26
+ self.state = ControlState(target_f=102.0)
27
+
28
+ def test_ready_ok(self) -> None:
29
+ status, detail = _health_status(self.state, self.cfg, _reading())
30
+ self.assertEqual(status, "ok")
31
+ self.assertEqual(detail, "loop running")
32
+
33
+ def test_need_hot_in_degraded(self) -> None:
34
+ status, detail = _health_status(
35
+ self.state, self.cfg, _reading(setup_state="need_hot_in", tub_avg_f=None)
36
+ )
37
+ self.assertEqual(status, "degraded")
38
+ self.assertIn("need_hot_in", detail)
39
+
40
+ def test_need_water_degraded(self) -> None:
41
+ status, _ = _health_status(self.state, self.cfg, _reading(setup_state="need_water", tub_avg_f=None))
42
+ self.assertEqual(status, "degraded")
43
+
44
+ def test_need_reinstall_degraded(self) -> None:
45
+ status, detail = _health_status(
46
+ self.state, self.cfg, _reading(setup_state="need_reinstall", tub_avg_f=None)
47
+ )
48
+ self.assertEqual(status, "degraded")
49
+ self.assertEqual(detail, "need_reinstall")
50
+
51
+ def test_no_tub_avg_degraded(self) -> None:
52
+ status, detail = _health_status(self.state, self.cfg, _reading(tub_avg_f=None))
53
+ self.assertEqual(status, "degraded")
54
+ self.assertEqual(detail, "no tub temperature")
55
+
56
+ def test_stale_degraded(self) -> None:
57
+ status, detail = _health_status(
58
+ self.state,
59
+ self.cfg,
60
+ _reading(ts="2020-01-01T00:00:00Z"),
61
+ )
62
+ self.assertEqual(status, "degraded")
63
+ self.assertEqual(detail, "stale or missing temps")
64
+
65
+ def test_missing_reading_degraded(self) -> None:
66
+ status, detail = _health_status(self.state, self.cfg, None)
67
+ self.assertEqual(status, "degraded")
68
+ self.assertEqual(detail, "stale or missing temps")
projects/hot-tub/src/web/README.md
... ...
@@ -127,10 +127,10 @@ These are the traps when swapping in the real backend:
127 127
128 128
| Topic | Mockup | Real system | What to do |
129 129
|---|---|---|---|
130
-| 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](#gap-1-role-labeled-probe-readings)). |
130
+| 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](#gap-1-role-labeled-probe-readings)). |
131 131
| 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. |
132 132
| 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. |
133
-| Setup mode | `startSensorSetup()` etc. do nothing | No runtime way to enter teach-in today | See [Gap 2](#gap-2-runtime-sensor-setup-mode). |
133
+| Setup mode | `startSensorSetup()` etc. do nothing | **`POST /api/sensor-setup/*`** → `temps.command` | Implemented; see [Gap 2](#gap-2-runtime-sensor-setup-mode). |
134 134
| 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`. |
135 135
| Heating rate | 0.15 °F/s (demo speed) | Real propane heater: very slow | — |
136 136
| 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. |
... ...
@@ -169,7 +169,8 @@ Published on every change and at least every 5 s (`heartbeat_s`). Times are UTC
169 169
{
170 170
"ts": "2026-10-19T20:00:00Z",
171 171
"setup_state": "ready", // ready | need_hot_in | need_water | need_reinstall
172
- "probes": { "28-aaa": 101.2, "28-bbb": null }, // by ROM id today, see Gap 1
172
+ "roles": { "hot_in": 112.0, "tub1": 101.2, "tub2": null, "tub3": 100.9, "tub4": 101.0 },
173
+ "probes": { "28-aaa": 101.2, "28-bbb": null }, // ROM id (diagnostics)
173 174
"tub_avg_f": 101.0,
174 175
"hot_in_f": 112.0,
175 176
"outdoor_f": null,
... ...
@@ -187,7 +188,7 @@ Published every 5 s (`interval_s`). The control daemon treats readings older tha
187 188
{ "ts": "…", "state": "need_water", "detail": "Learned hot_in=28-zzz", "visible_ids": ["28-zzz"] }
188 189
```
189 190
190
-Published only when it changes. Useful for the wizard (see [Gap 2](#gap-2-runtime-sensor-setup-mode)).
191
+Published when it changes (and every poll while `setup_active`). Useful for the wizard.
191 192
192 193
### Commands → `control.command` (validated in `src/control/commands.py`)
193 194
... ...
@@ -233,9 +234,9 @@ Recommended contract. Settle it before writing the React client:
233 234
| `POST` | `/api/command` | `{ "op": "...", "args": {...} }` | Validate, add `ts` / `source: "web"` / `id`, publish to `control.command`. Return `202 { "id": "..." }` or `400 { "error": "..." }`. |
234 235
| `GET` | `/api/status` | — | `{ "control": <control:latest>, "temps": <temps:latest>, "health": { temps, control, web } }` for polling clients |
235 236
| `GET` | `/api/health` | — | Web service health + ages of upstream keys |
236
-| `POST` | `/api/sensor-setup/start` | — | Depends on Gap 2 |
237
-| `POST` | `/api/sensor-setup/finish` | — | Depends on Gap 2 |
238
-| `POST` | `/api/sensor-setup/cancel` | — | Depends on Gap 2 |
237
+| `POST` | `/api/sensor-setup/start` | — | Publishes `setup_start` on `temps.command` |
238
+| `POST` | `/api/sensor-setup/finish` | — | Publishes `setup_finish` (temp rejects if not `ready`) |
239
+| `POST` | `/api/sensor-setup/cancel` | — | Publishes `setup_cancel` |
239 240
| `GET` | `/` and static assets | — | Built React app |
240 241
241 242
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".
... ...
@@ -244,42 +245,18 @@ SSE was chosen over WebSockets because traffic is one-way (server to browser), b
244 245
245 246
## Gaps in other services that the UI depends on
246 247
247
-### Gap 1: role-labeled probe readings
248
+### Gap 1: role-labeled probe readings — **done**
248 249
249
-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`).
250
+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).
250 251
251
-**Recommended fix (temp daemon):** add a role-keyed field to the reading, e.g.
252
+### Gap 2: runtime sensor setup mode — **done**
252 253
253
-```json
254
-"roles": { "hot_in": 112.0, "tub1": 101.2, "tub2": 101.0, "tub3": null, "tub4": 100.9 }
255
-```
256
-
257
-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`.
258
-
259
-Translating in the web service using `/etc/hot-tub/temp-probes.json` is possible but duplicates identity logic in a second process. Avoid that.
260
-
261
-### Gap 2: runtime sensor setup mode
262
-
263
-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.
264
-
265
-How teach-in works (from `src/temp/identity.py`, `_try_learn`), which constrains the design:
266
-
267
-- Nothing is learned once the probe map is complete. **Starting setup must clear the probe map**, not just set `setup_mode`.
268
-- With an empty map: **1 probe visible → learned as hot-in**; **4 probes visible (setup mode only) → learned as the water set**.
269
-- With one side known: **5 visible → the other side is derived** by set difference.
270
-- **5 visible with an empty map → `need_reinstall`.** This is exactly why wizard step 1 makes the user unplug everything first.
271
-
272
-**Recommended design:**
273
-
274
-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`).
275
-2. Web service maps `/api/sensor-setup/{start,finish,cancel}` to those ops.
276
-3. Wizard gating should use **`setup_state` / learned roles**, not just "a reading appeared":
277
- - Step 1 → 2: user confirms disconnection; call `start`. Show a warning if probes are still visible (the map would otherwise be learned wrong).
278
- - Step 2 Continue: enable when `setup_state` becomes `need_water` (hot-in learned) or `need_hot_in` with 4 tub roles present (water learned).
279
- - Step 3 Finish: enable when `setup_state == ready` and all five roles report values. Then call `finish`.
280
-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".
254
+- Temp daemon: `temps.command` ops `setup_start` / `setup_finish` / `setup_cancel` (`src/temp/commands.py`).
255
+- Web: `POST /api/sensor-setup/{start,finish,cancel}` → Redis publish.
256
+- Wizard: three steps in `SensorSetupPage.tsx`; step 1 blocks Continue until no probes report; finish waits for `setup_state === ready` (with timeout if temp rejects finish).
257
+- Control: heat stays off via `safety.py`; **`health:control` is `degraded`** until `setup_state === ready` and `tub_avg_f` is present (`src/control/daemon.py`).
281 258
282
-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.
259
+Main Control shows a **Sensors not ready** banner when `setup_state !== ready`. See [sensor-connect-workflow.md](../../sensor-connect-workflow.md).
283 260
284 261
## Known issues to fix first
285 262
projects/hot-tub/src/web/frontend/src/App.tsx
... ...
@@ -52,7 +52,7 @@ export default function App() {
52 52
) : (
53 53
<Routes>
54 54
<Route path="/" element={<Navigate to="/control" replace />} />
55
- <Route path="/control" element={<ControlPage control={control} />} />
55
+ <Route path="/control" element={<ControlPage control={control} temps={temps} />} />
56 56
<Route
57 57
path="/diagnostic"
58 58
element={
projects/hot-tub/src/web/frontend/src/components/ControlPage.tsx
... ...
@@ -1,6 +1,7 @@
1
+import { useNavigate } from 'react-router-dom'
1 2
import { sendCommand } from '../api'
2
-import { TARGET_MAX, TARGET_MIN, filterLabel, fmtF, fmtTarget, has, phaseLabel } from '../helpers'
3
-import type { ControlState } from '../types'
3
+import { TARGET_MAX, TARGET_MIN, filterLabel, fmtF, fmtTarget, has, isPayloadStale, phaseLabel } from '../helpers'
4
+import type { ControlState, TempsState } from '../types'
4 5
import { Pump } from './Pump'
5 6
import { TubGraphic } from './TubGraphic'
6 7
... ...
@@ -17,9 +18,27 @@ function ErrorBanner({ error, onReset }: { error: NonNullable<ControlState['erro
17 18
)
18 19
}
19 20
20
-export function ControlPage({ control }: { control: ControlState }) {
21
+function SensorWarningBanner({ setupState, onOpenSetup }: { setupState: string; onOpenSetup: () => void }) {
22
+ const label = setupState.replace(/_/g, ' ')
23
+ return (
24
+ <div className="banner banner-warning" role="status">
25
+ <span>
26
+ <strong>Sensors not ready.</strong> Temperature sensors are not configured ({label}). Heating stays off until
27
+ setup is complete.
28
+ </span>
29
+ <button type="button" className="btn btn-small" onClick={onOpenSetup}>
30
+ Open Sensor Setup
31
+ </button>
32
+ </div>
33
+ )
34
+}
35
+
36
+export function ControlPage({ control, temps }: { control: ControlState; temps: TempsState }) {
37
+ const navigate = useNavigate()
21 38
const tub = control.tub_avg_f
22 39
const steaming = control.heat_pump === 'on' || (has(tub) && tub >= 95)
40
+ const showSensorWarning =
41
+ !control.error && !isPayloadStale(temps.ts) && temps.setup_state !== 'ready'
23 42
24 43
return (
25 44
<div className="control-layout">
... ...
@@ -27,6 +46,11 @@ export function ControlPage({ control }: { control: ControlState }) {
27 46
<div className="banner-slot">
28 47
{control.error ? (
29 48
<ErrorBanner error={control.error} onReset={() => sendCommand('set_heat_mode', { mode: 'sleep' })} />
49
+ ) : showSensorWarning ? (
50
+ <SensorWarningBanner
51
+ setupState={temps.setup_state}
52
+ onOpenSetup={() => navigate('/diagnostic/sensor-setup/1')}
53
+ />
30 54
) : null}
31 55
</div>
32 56
<TubGraphic tempF={tub} steaming={steaming} />
projects/hot-tub/src/web/frontend/src/components/SensorSetupPage.tsx
... ...
@@ -1,9 +1,12 @@
1
+import { useEffect, useRef, useState } from 'react'
1 2
import { useNavigate, useParams } from 'react-router-dom'
2 3
import { cancelSensorSetup, finishSensorSetup, startSensorSetup } from '../api'
3 4
import { PROBES, WATER_KEYS, fmtF, has, probeValues } from '../helpers'
4 5
import type { FirstBundle, TempsState } from '../types'
5 6
import { StatusLight } from './StatusLight'
6 7
8
+const FINISH_WAIT_MS = 15_000
9
+
7 10
function Stepper({ step }: { step: number }) {
8 11
const labels = ['Disconnect', 'First set', 'Second set']
9 12
return (
... ...
@@ -34,6 +37,10 @@ function ProbeRow({
34 37
)
35 38
}
36 39
40
+function allProbesReporting(values: Record<string, number | null | undefined>) {
41
+ return PROBES.every((p) => has(values[p.key]))
42
+}
43
+
37 44
export function SensorSetupPage({
38 45
temps,
39 46
firstBundle,
... ...
@@ -47,15 +54,81 @@ export function SensorSetupPage({
47 54
const { step: stepParam } = useParams()
48 55
const step = Math.min(3, Math.max(1, Number(stepParam) || 1))
49 56
const values = probeValues(temps)
57
+ const [setupError, setSetupError] = useState<string | null>(null)
58
+ const [pendingFinish, setPendingFinish] = useState(false)
59
+ const finishDeadline = useRef<number | null>(null)
50 60
51 61
const goDiagnostic = () => navigate('/diagnostic')
52 62
63
+ useEffect(() => {
64
+ if (!pendingFinish) return
65
+ if (temps.setup_state === 'ready' && allProbesReporting(probeValues(temps))) {
66
+ setPendingFinish(false)
67
+ finishDeadline.current = null
68
+ navigate('/diagnostic')
69
+ }
70
+ }, [pendingFinish, temps, navigate])
71
+
72
+ useEffect(() => {
73
+ if (!pendingFinish) return
74
+ finishDeadline.current = finishDeadline.current ?? Date.now() + FINISH_WAIT_MS
75
+ const id = window.setInterval(() => {
76
+ if (finishDeadline.current !== null && Date.now() >= finishDeadline.current) {
77
+ setPendingFinish(false)
78
+ finishDeadline.current = null
79
+ setSetupError('Setup not complete yet. Check sensors and try again.')
80
+ }
81
+ }, 500)
82
+ return () => clearInterval(id)
83
+ }, [pendingFinish])
84
+
85
+ useEffect(() => {
86
+ if (step === 2 && temps.setup_state === 'need_reinstall') {
87
+ setSetupError('Too many sensors detected. Unplug all bundles, cancel, and start again from step 1.')
88
+ }
89
+ }, [step, temps.setup_state])
90
+
91
+ const handleStart = async () => {
92
+ setSetupError(null)
93
+ try {
94
+ await startSensorSetup()
95
+ navigate('/diagnostic/sensor-setup/2')
96
+ } catch {
97
+ setSetupError('Could not start sensor setup. Check the controller and try again.')
98
+ }
99
+ }
100
+
101
+ const handleFinish = async () => {
102
+ setSetupError(null)
103
+ try {
104
+ await finishSensorSetup()
105
+ if (temps.setup_state === 'ready' && allProbesReporting(probeValues(temps))) {
106
+ goDiagnostic()
107
+ } else {
108
+ finishDeadline.current = Date.now() + FINISH_WAIT_MS
109
+ setPendingFinish(true)
110
+ }
111
+ } catch {
112
+ setSetupError('Could not finish sensor setup. Check the controller and try again.')
113
+ }
114
+ }
115
+
53 116
return (
54 117
<section className="card setup-card">
55 118
<h1>Sensor setup</h1>
119
+ {setupError ? (
120
+ <div className="banner banner-error setup-error" role="alert">
121
+ {setupError}
122
+ </div>
123
+ ) : null}
124
+ {pendingFinish ? <p className="hint">Waiting for setup to complete…</p> : null}
56 125
<Stepper step={step} />
57 126
{step === 1 ? (
58
- <SetupStep1 values={values} onCancel={() => cancelSensorSetup().finally(goDiagnostic)} onContinue={() => startSensorSetup().then(() => navigate('/diagnostic/sensor-setup/2'))} />
127
+ <SetupStep1
128
+ values={values}
129
+ onCancel={() => cancelSensorSetup().finally(goDiagnostic)}
130
+ onContinue={handleStart}
131
+ />
59 132
) : null}
60 133
{step === 2 ? (
61 134
<SetupStep2
... ...
@@ -75,8 +148,9 @@ export function SensorSetupPage({
75 148
setupState={temps.setup_state}
76 149
values={values}
77 150
firstBundle={firstBundle}
151
+ pendingFinish={pendingFinish}
78 152
onCancel={() => cancelSensorSetup().finally(goDiagnostic)}
79
- onFinish={() => finishSensorSetup().then(goDiagnostic)}
153
+ onFinish={handleFinish}
80 154
/>
81 155
) : null}
82 156
</section>
... ...
@@ -93,6 +167,7 @@ function SetupStep1({
93 167
onCancel: () => void
94 168
}) {
95 169
const connected = PROBES.filter((p) => has(values[p.key])).length
170
+ const canContinue = connected === 0
96 171
return (
97 172
<div className="setup-step">
98 173
<h2>Disconnect all sensors</h2>
... ...
@@ -108,7 +183,7 @@ function SetupStep1({
108 183
<button type="button" className="btn btn-secondary" onClick={onCancel}>
109 184
Cancel
110 185
</button>
111
- <button type="button" className="btn btn-primary" onClick={onContinue}>
186
+ <button type="button" className="btn btn-primary" disabled={!canContinue} onClick={onContinue}>
112 187
Continue
113 188
</button>
114 189
</div>
... ...
@@ -164,12 +239,14 @@ function SetupStep3({
164 239
setupState,
165 240
values,
166 241
firstBundle,
242
+ pendingFinish,
167 243
onFinish,
168 244
onCancel,
169 245
}: {
170 246
setupState: string
171 247
values: Record<string, number | null | undefined>
172 248
firstBundle: FirstBundle
249
+ pendingFinish: boolean
173 250
onFinish: () => void
174 251
onCancel: () => void
175 252
}) {
... ...
@@ -179,7 +256,7 @@ function SetupStep3({
179 256
: firstBundle === 'water'
180 257
? 'the hot-input sensor'
181 258
: 'any remaining sensors'
182
- const all = setupState === 'ready' && PROBES.every((p) => has(values[p.key]))
259
+ const all = setupState === 'ready' && allProbesReporting(values)
183 260
return (
184 261
<div className="setup-step">
185 262
<h2>Plug in the other set</h2>
... ...
@@ -190,11 +267,11 @@ function SetupStep3({
190 267
))}
191 268
</ul>
192 269
<div className="actions">
193
- <button type="button" className="btn btn-secondary" onClick={onCancel}>
270
+ <button type="button" className="btn btn-secondary" onClick={onCancel} disabled={pendingFinish}>
194 271
Cancel
195 272
</button>
196
- <button type="button" className="btn btn-primary" disabled={!all} onClick={onFinish}>
197
- Finish
273
+ <button type="button" className="btn btn-primary" disabled={!all || pendingFinish} onClick={onFinish}>
274
+ {pendingFinish ? 'Finishing…' : 'Finish'}
198 275
</button>
199 276
</div>
200 277
</div>
projects/hot-tub/src/web/frontend/src/styles.css
... ...
@@ -202,6 +202,12 @@ body {
202 202
color: #991b1b;
203 203
}
204 204
205
+.banner-warning {
206
+ border: 1px solid #fde68a;
207
+ background: #fffbeb;
208
+ color: #92400e;
209
+}
210
+
205 211
.tub {
206 212
display: block;
207 213
width: 100%;