ceba3e086adee6141e90532fd762b40c44a9701b
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%; |