projects/hot-tub/deploy/control/hot-tub-control.service
... ...
@@ -5,7 +5,9 @@ Wants=network-online.target
5 5
Documentation=file://INSTALL.md
6 6
7 7
[Service]
8
-Type=simple
8
+Type=notify
9
+NotifyAccess=main
10
+WatchdogSec=10
9 11
User=hot-tub
10 12
Group=hot-tub
11 13
Environment=PYTHONPATH=__INSTALL_ROOT__/src
projects/hot-tub/software-architecture.md
... ...
@@ -143,16 +143,29 @@ Exact filter scheduling inside `maintain` (always on vs periodic) is a Control D
143 143
144 144
**Decision loop (conceptual):**
145 145
146
+Single-threaded loop in `daemon.py` — not separate event and timer processes.
147
+
146 148
```text
147
-on temps.reading OR on control.command OR on timer:
148
- load latest temps + GPIO safety inputs + current mode/target
149
- if safety violation:
150
- force heat OFF; set fault flags; publish state; return
151
- apply mode policy → desired heat_on / filter_on
152
- if outputs changed: write GPIO; log event
153
- publish control.state
149
+each iteration:
150
+ wait on Redis pub/sub (control.command + temps.reading)
151
+ until a message arrives OR loop_interval_s elapses (default 1 s)
152
+ on message: update in-memory intents / latest temps only (no GPIO)
153
+ always: reconcile (heat FSM + filter policy + safety) → GPIO → publish if changed or heartbeat due
154
+ sd_notify WATCHDOG=1
154 155
```
155 156
157
+Message handlers never touch relays. Every wake — whether from a command, a temperature reading, or the 1 s timeout — runs the same reconcile step. Filter auto schedules, stale-temperature cutoff, and startup timers depend on that timeout path; the Health Daemon does not publish events that Control waits on for safety.
158
+
159
+**Control loop timing:**
160
+
161
+| Setting | Default | Role |
162
+|---|---|---|
163
+| `loop_interval_s` | 1.0 | Maximum time between reconcile passes when idle |
164
+| `heartbeat_s` | 5.0 | Minimum interval for `health:control` refresh when state unchanged |
165
+| systemd `WatchdogSec` | 10 | Restart if the loop stops notifying |
166
+
167
+Commands are applied as soon as pub/sub delivers them (typically milliseconds). Redis reconnect uses non-blocking backoff so reconcile still runs about once per second when Redis is down; heat fails safe on stale temps inside Control.
168
+
156 169
Heat hysteresis example (compatible with the current PoC thinking): turn heat **on** below `target − band_low`, **off** above `target + band_high` (e.g. target 102°F with a 100–104°F band, or a symmetric ±1.5°F around target). The Web UI adjusts *target*; the band lives in Control config.
157 170
158 171
**Safety rules (enforced here, not in the Web UI):**
... ...
@@ -254,6 +267,8 @@ Auth for v1 can be "local network only" (Pi not exposed to the internet) or a si
254 267
255 268
Timeouts should be longer than each service's heartbeat interval (typically 2–5 s) so a single delayed loop does not flicker the LED.
256 269
270
+For **Control** specifically (see [src/health/overview.md](src/health/overview.md)): Control publishes `health:control` every `heartbeat_s` (default **5 s**) with `status` of `ok`, `degraded`, or `error`. The Health Daemon drives the Control LED green / orange / red from that payload, and treats the heartbeat timestamp as stale after **10 s** (orange) or **60 s** (red). Health only drives LEDs — it does not send safety events to Control.
271
+
257 272
**Hardware considerations:**
258 273
259 274
- Three LEDs with series resistors (~330 Ω at 3.3V), anodes to GPIO (or cathodes to GPIO if active-low — pick one convention and document it).
... ...
@@ -457,7 +472,7 @@ Temperature Daemon stops (crash or kill)
457 472
4. **Health Daemon + three LEDs** — watch `health:*`, drive indicators; confirm kill of each service extinguishes the matching LED.
458 473
5. **Web controls** — mode + target commands; history charts; show health summary.
459 474
6. **Client bridges** — tub-side HTTP client; LoRa bridge for indoor monitor.
460
-7. **Hardening** — systemd watchdog, stale-data timeouts, backup of config/history, structured logging.
475
+7. **Hardening** — Control already uses systemd watchdog + notify; remaining: stale-data tuning, backup of config/history, structured logging across all services.
461 476
462 477
## Open Decisions
463 478
... ...
@@ -475,3 +490,4 @@ Temperature Daemon stops (crash or kill)
475 490
476 491
- Initial architecture: three services (Temperature, Control, Web) + local Redis pub/sub, aligned with Pi 3 as sole actuator owner.
477 492
- Added Health Daemon as fourth service: Temperature / Control / Web publish health to Redis; Health Daemon monitors those heartbeats and owns three status LEDs (one per service).
493
+- Control Daemon: documented and implemented single-threaded wait-with-timeout loop (`loop_interval_s` 1 s), immediate command handling, non-blocking Redis reconnect, and systemd watchdog for Control.
projects/hot-tub/src/control/INSTALL.md
... ...
@@ -42,6 +42,8 @@ Edit `/etc/hot-tub/control.json` if needed (GPIO pins, maintain band, filter aut
42 42
43 43
Persisted operator state lives in `/var/lib/hot-tub/control-state.json` (`heat_mode`, `filter_mode`, `target_f`, latched startup error).
44 44
45
+The installed unit uses **`Type=notify`** and **`WatchdogSec=10`**: Control runs a 1 s wait-with-timeout loop and pings systemd each pass. Full loop behavior is documented in [README.md](README.md#main-loop).
46
+
45 47
## 4. Verify
46 48
47 49
```bash
... ...
@@ -69,7 +71,7 @@ cd src
69 71
PYTHONPATH=. python3 -m control --dry-run --once
70 72
PYTHONPATH=. python3 -m control --no-gpio # Redis live, GPIO faked (stack test with temp_sim + web)
71 73
PYTHONPATH=. python3 -m unittest control.tests.test_commands control.tests.test_safety \
72
- control.tests.test_filter_policy control.tests.test_heat_fsm -v
74
+ control.tests.test_filter_policy control.tests.test_heat_fsm control.tests.test_redis_io -v
73 75
```
74 76
75 77
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
... ...
@@ -51,7 +51,8 @@ src/control/
51 51
README.md ← this file
52 52
__init__.py
53 53
__main__.py # python -m control / systemd ExecStart
54
- daemon.py # loop: commands + temps + timers → decide → GPIO → publish
54
+ daemon.py # wait-with-timeout loop → reconcile → GPIO → publish
55
+ systemd_notify.py # sd_notify READY/WATCHDOG (no-op without NOTIFY_SOCKET)
55 56
commands.py # parse / validate control.command payloads
56 57
state.py # in-memory + persist heat_mode, filter_mode, target, heat_phase, …
57 58
heat_fsm.py # maintain startup sequence, heating, backflush timer, errors
... ...
@@ -90,6 +91,20 @@ Subscribe to a command channel (e.g. `control.command`). Each message is a small
90 91
91 92
Also consume temperature updates (e.g. `temps.reading` / `temps:latest`) for `tub_avg_f` and `hot_in_f`. Do not act on heat if temps are stale or setup is `need_reinstall`.
92 93
94
+## Main loop
95
+
96
+One thread in `run_loop()` (`daemon.py`):
97
+
98
+1. Block on Redis pub/sub (`control.command`, `temps.reading`) until a message arrives **or** `loop_interval_s` elapses (default **1 s**).
99
+2. Message handlers update persisted intents and the latest temperature reading only.
100
+3. Every wake runs `tick()`: heat FSM, filter policy, safety, then GPIO (reconcile).
101
+4. Publish `control.state` / `control:latest` and `health:control` when the snapshot changes or every `heartbeat_s` (default **5 s**).
102
+5. Call `sd_notify("WATCHDOG=1")` each pass; systemd unit uses `Type=notify` and `WatchdogSec=10`.
103
+
104
+Commands apply as soon as Redis delivers them (typically milliseconds). Timer-based behavior (stale temps, filter auto window, startup delays) relies on the 1 s timeout when no messages arrive — not on a separate timer thread or on Health Daemon events.
105
+
106
+If Redis is unavailable, reconnect uses exponential backoff **without blocking the loop**; reconcile still runs about once per second and heat fails safe when temperature data is stale.
107
+
93 108
## Published state
94 109
95 110
On every meaningful change (and on a slow heartbeat), publish the full snapshot:
... ...
@@ -239,19 +254,26 @@ On safety trip: force heat pump off, set `heat_phase = error_safety` (or stay `i
239 254
Publish on an interval and on status changes:
240 255
241 256
- `SET health:control` + `PUBLISH health.control`
257
+- JSON fields: `service`, `ts` (UTC ISO-8601), `status`, `detail`, `pid`
258
+- `status`: `ok` | `degraded` | `error` (Health Daemon maps these to green / orange / red on the Control LED)
242 259
- `ok` while loop running and Redis/GPIO usable
243 260
- `degraded` if active `error_*` phase or stale temps while mode wants heat
244
-- `error` if GPIO/Redis hard-fail
261
+- `error` on GPIO failure or Redis publish failure after reconnect
262
+
263
+Health Daemon staleness for Control uses the `ts` field: **>10 s** → orange LED, **>60 s** → red (see [health/overview.md](../health/overview.md)).
245 264
246 265
## Operational requirements
247 266
248 267
### systemd (start on boot)
249 268
250
-- Unit suggestion: `hot-tub-control.service`
269
+- Unit: `hot-tub-control.service` (see [deploy/control/hot-tub-control.service](../../deploy/control/hot-tub-control.service))
270
+- `Type=notify`, `WatchdogSec=10`, `NotifyAccess=main`
251 271
- `WantedBy=multi-user.target`, `Restart=on-failure`
252 272
- `After=redis.service` (and preferably after temps is up, but tolerate missing temps with heat held off)
253 273
- Exclusive use of heat/filter GPIO — do not run experimental relay scripts alongside this service
254 274
275
+Loop design details: [Main loop](#main-loop) above.
276
+
255 277
### Logging
256 278
257 279
- **All useful output** goes to `/var/log/hot-tub/control.log`: mode changes, commands received, startup attempt pass/fail (with temps), phase transitions, GPIO writes, safety trips, Redis reconnects, startup/shutdown
... ...
@@ -307,7 +329,10 @@ Persist at least: `heat_mode`, `filter_mode`, `target_f`, last `error` / whether
307 329
},
308 330
"target_f_default": 102,
309 331
"target_f_min": 50,
310
- "target_f_max": 110
332
+ "target_f_max": 110,
333
+ "temps_stale_s": 15,
334
+ "loop_interval_s": 1.0,
335
+ "heartbeat_s": 5.0
311 336
}
312 337
```
313 338
projects/hot-tub/src/control/daemon.py
... ...
@@ -2,6 +2,7 @@
2 2
3 3
from __future__ import annotations
4 4
5
+import json
5 6
import logging
6 7
import os
7 8
import signal
... ...
@@ -17,6 +18,7 @@ from .heat_fsm import tick_heat
17 18
from .redis_io import CHAN_COMMAND, CHAN_TEMPS_READING, ControlRedis
18 19
from .safety import temps_fresh
19 20
from .state import ControlState, FsmContext, load_persisted_state, save_persisted_state, state_snapshot
21
+from .systemd_notify import notify as sd_notify
20 22
21 23
logger = logging.getLogger("hot_tub.control")
22 24
... ...
@@ -33,7 +35,18 @@ def _utc_now() -> str:
33 35
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
34 36
35 37
36
-def _health_status(state: ControlState, cfg: ControlConfig, reading: dict[str, Any] | None) -> tuple[str, str]:
38
+def _health_status(
39
+ state: ControlState,
40
+ cfg: ControlConfig,
41
+ reading: dict[str, Any] | None,
42
+ *,
43
+ gpio_error: bool = False,
44
+ publish_failed: bool = False,
45
+) -> tuple[str, str]:
46
+ if gpio_error:
47
+ return "error", "gpio failure"
48
+ if publish_failed:
49
+ return "error", "redis publish failed"
37 50
if state.heat_phase.startswith("error_"):
38 51
return "degraded", state.heat_phase
39 52
if state.heat_mode == "maintain" and not temps_fresh(reading, cfg):
... ...
@@ -63,6 +76,7 @@ class ControlEngine:
63 76
self.state = load_persisted_state(cfg.state_path, default_target=cfg.target_f_default)
64 77
self.ctx = FsmContext()
65 78
self.reading: dict[str, Any] | None = None
79
+ self.gpio_error = False
66 80
self.relays = RelayOutputs(cfg.gpio, dry_run=dry_run or gpio_dry_run)
67 81
self.relays.all_off()
68 82
self._last_persist_hash: str | None = None
... ...
@@ -96,10 +110,21 @@ class ControlEngine:
96 110
logger.exception("command handling failed")
97 111
98 112
def tick(self) -> dict[str, Any]:
99
- tick_heat(self.state, self.ctx, self.cfg, self.reading)
100
- filt = desired_filter_pump(self.state, self.cfg, self.ctx)
101
- self.state.filter_pump = filt
102
- _apply_gpio(self.relays, self.state)
113
+ if self.gpio_error:
114
+ self.state.heat_pump = "off"
115
+ self.state.filter_pump = "off"
116
+ return state_snapshot(self.state, ts=_utc_now())
117
+ try:
118
+ tick_heat(self.state, self.ctx, self.cfg, self.reading)
119
+ filt = desired_filter_pump(self.state, self.cfg, self.ctx)
120
+ self.state.filter_pump = filt
121
+ _apply_gpio(self.relays, self.state)
122
+ except Exception:
123
+ logger.exception("tick failed (GPIO or FSM)")
124
+ self.gpio_error = True
125
+ self.relays.all_off()
126
+ self.state.heat_pump = "off"
127
+ self.state.filter_pump = "off"
103 128
return state_snapshot(self.state, ts=_utc_now())
104 129
105 130
def should_publish(self, snapshot: dict[str, Any], now_mono: float) -> bool:
... ...
@@ -113,6 +138,19 @@ class ControlEngine:
113 138
self._last_publish_mono = now_mono
114 139
115 140
141
+def _ingest_redis_messages(engine: ControlEngine, msgs: list[tuple[str, str]]) -> None:
142
+ for channel, data in msgs:
143
+ if channel == CHAN_COMMAND:
144
+ engine.handle_command_raw(data)
145
+ elif channel == CHAN_TEMPS_READING:
146
+ try:
147
+ payload = json.loads(data)
148
+ if isinstance(payload, dict):
149
+ engine.ingest_temps(payload)
150
+ except json.JSONDecodeError:
151
+ logger.warning("invalid temps.reading JSON")
152
+
153
+
116 154
def run_once(
117 155
cfg: ControlConfig,
118 156
*,
... ...
@@ -155,28 +193,32 @@ def run_loop(
155 193
if latest:
156 194
engine.ingest_temps(latest)
157 195
196
+ sd_notify("READY=1")
197
+ next_due = time.monotonic()
198
+
158 199
try:
159 200
while not _stop:
160
- loop_start = time.monotonic()
161 201
if redis_io is not None:
162
- for channel, data in redis_io.poll_messages(timeout_s=min(0.5, cfg.loop_interval_s)):
163
- if channel == CHAN_COMMAND:
164
- engine.handle_command_raw(data)
165
- elif channel == CHAN_TEMPS_READING:
166
- try:
167
- import json
168
-
169
- payload = json.loads(data)
170
- if isinstance(payload, dict):
171
- engine.ingest_temps(payload)
172
- except json.JSONDecodeError:
173
- logger.warning("invalid temps.reading JSON")
202
+ timeout = max(0.0, next_due - time.monotonic())
203
+ msgs = redis_io.wait_messages(timeout_s=timeout)
204
+ _ingest_redis_messages(engine, msgs)
205
+ else:
206
+ delay = max(0.0, next_due - time.monotonic())
207
+ if delay > 0 and not _stop:
208
+ time.sleep(delay)
174 209
175 210
snapshot = engine.tick()
176 211
now_mono = time.monotonic()
177 212
213
+ publish_failed = False
178 214
if engine.should_publish(snapshot, now_mono):
179
- status, detail = _health_status(engine.state, cfg, engine.reading)
215
+ status, detail = _health_status(
216
+ engine.state,
217
+ cfg,
218
+ engine.reading,
219
+ gpio_error=engine.gpio_error,
220
+ publish_failed=False,
221
+ )
180 222
health = {
181 223
"service": "control",
182 224
"ts": snapshot["ts"],
... ...
@@ -187,14 +229,29 @@ def run_loop(
187 229
if dry_run:
188 230
logger.debug("state %s", snapshot)
189 231
elif redis_io is not None:
190
- redis_io.publish_state(snapshot)
191
- redis_io.publish_health(health)
192
- engine.mark_published(snapshot, now_mono)
193
-
194
- elapsed = time.monotonic() - loop_start
195
- sleep_s = max(0.0, cfg.loop_interval_s - elapsed)
196
- if sleep_s > 0 and not _stop:
197
- time.sleep(sleep_s)
232
+ ok_state = redis_io.publish_state(snapshot)
233
+ ok_health = redis_io.publish_health(health)
234
+ if ok_state and ok_health:
235
+ engine.mark_published(snapshot, now_mono)
236
+ else:
237
+ status, detail = _health_status(
238
+ engine.state,
239
+ cfg,
240
+ engine.reading,
241
+ gpio_error=engine.gpio_error,
242
+ publish_failed=True,
243
+ )
244
+ health["status"] = status
245
+ health["detail"] = detail
246
+ health["ts"] = _utc_now()
247
+ redis_io.publish_health(health)
248
+ else:
249
+ engine.mark_published(snapshot, now_mono)
250
+
251
+ if now_mono >= next_due:
252
+ next_due = now_mono + cfg.loop_interval_s
253
+
254
+ sd_notify("WATCHDOG=1")
198 255
finally:
199 256
if redis_io is not None:
200 257
redis_io.close()
projects/hot-tub/src/control/redis_io.py
... ...
@@ -22,6 +22,8 @@ KEY_TEMPS_LATEST = "temps:latest"
22 22
CHAN_COMMAND = "control.command"
23 23
CHAN_TEMPS_READING = "temps.reading"
24 24
25
+_MAX_DRAIN = 32
26
+
25 27
26 28
class ControlRedis:
27 29
def __init__(self, redis_url: str) -> None:
... ...
@@ -31,23 +33,33 @@ class ControlRedis:
31 33
self._client: redis.Redis | None = None
32 34
self._pubsub: redis.client.PubSub | None = None
33 35
self._backoff_s = 1.0
36
+ self._retry_after_mono = 0.0
34 37
35 38
def _connect(self) -> redis.Redis:
36 39
if self._client is None:
37
- self._client = redis.from_url(self._url, decode_responses=True)
40
+ self._client = redis.from_url(
41
+ self._url,
42
+ decode_responses=True,
43
+ socket_connect_timeout=2,
44
+ socket_timeout=2,
45
+ )
38 46
return self._client
39 47
40 48
def _ensure(self) -> redis.Redis | None:
49
+ now = time.monotonic()
50
+ if now < self._retry_after_mono:
51
+ return None
41 52
try:
42 53
client = self._connect()
43 54
client.ping()
44 55
self._backoff_s = 1.0
56
+ self._retry_after_mono = 0.0
45 57
return client
46 58
except Exception as exc:
47 59
logger.warning("Redis unavailable: %s (retry in %.0fs)", exc, self._backoff_s)
48 60
self._client = None
49 61
self._pubsub = None
50
- time.sleep(min(self._backoff_s, 30.0))
62
+ self._retry_after_mono = now + min(self._backoff_s, 30.0)
51 63
self._backoff_s = min(self._backoff_s * 2, 30.0)
52 64
return None
53 65
... ...
@@ -80,43 +92,64 @@ class ControlRedis:
80 92
self._client = None
81 93
return None
82 94
83
- def poll_messages(self, timeout_s: float = 0.5) -> list[tuple[str, str]]:
95
+ @staticmethod
96
+ def _append_message(out: list[tuple[str, str]], msg: dict[str, Any] | None) -> bool:
97
+ if msg is None:
98
+ return False
99
+ if msg.get("type") != "message":
100
+ return False
101
+ channel = msg.get("channel")
102
+ data = msg.get("data")
103
+ if isinstance(channel, str) and isinstance(data, str):
104
+ out.append((channel, data))
105
+ return True
106
+ return False
107
+
108
+ def wait_messages(self, timeout_s: float) -> list[tuple[str, str]]:
109
+ """Block until the first message or timeout, then drain queued messages."""
84 110
pubsub = self.subscribe_commands_and_temps()
85 111
if pubsub is None:
86 112
return []
87 113
out: list[tuple[str, str]] = []
88
- deadline = time.monotonic() + timeout_s
89
- while time.monotonic() < deadline:
90
- msg = pubsub.get_message(timeout=0.1)
114
+ first = pubsub.get_message(timeout=max(0.0, timeout_s))
115
+ self._append_message(out, first)
116
+ while len(out) < _MAX_DRAIN:
117
+ msg = pubsub.get_message(timeout=0)
91 118
if msg is None:
92
- continue
93
- if msg.get("type") != "message":
94
- continue
95
- channel = msg.get("channel")
96
- data = msg.get("data")
97
- if isinstance(channel, str) and isinstance(data, str):
98
- out.append((channel, data))
99
- if len(out) >= 32:
100 119
break
120
+ if not self._append_message(out, msg):
121
+ continue
101 122
return out
102 123
103 124
def publish_state(self, payload: dict[str, Any]) -> bool:
104 125
client = self._ensure()
105 126
if client is None:
106 127
return False
107
- body = json.dumps(payload)
108
- client.set(KEY_CONTROL_LATEST, body)
109
- client.publish(CHAN_CONTROL_STATE, body)
110
- return True
128
+ try:
129
+ body = json.dumps(payload)
130
+ client.set(KEY_CONTROL_LATEST, body)
131
+ client.publish(CHAN_CONTROL_STATE, body)
132
+ return True
133
+ except Exception as exc:
134
+ logger.warning("publish state failed: %s", exc)
135
+ self._client = None
136
+ self._pubsub = None
137
+ return False
111 138
112 139
def publish_health(self, payload: dict[str, Any]) -> bool:
113 140
client = self._ensure()
114 141
if client is None:
115 142
return False
116
- body = json.dumps(payload)
117
- client.set(KEY_HEALTH, body)
118
- client.publish(CHAN_HEALTH, body)
119
- return True
143
+ try:
144
+ body = json.dumps(payload)
145
+ client.set(KEY_HEALTH, body)
146
+ client.publish(CHAN_HEALTH, body)
147
+ return True
148
+ except Exception as exc:
149
+ logger.warning("publish health failed: %s", exc)
150
+ self._client = None
151
+ self._pubsub = None
152
+ return False
120 153
121 154
def close(self) -> None:
122 155
if self._pubsub is not None:
projects/hot-tub/src/control/systemd_notify.py
... ...
@@ -0,0 +1,35 @@
1
+"""Minimal systemd sd_notify via NOTIFY_SOCKET (no systemd-python dependency)."""
2
+
3
+from __future__ import annotations
4
+
5
+import os
6
+import socket
7
+
8
+
9
+def notify(state: str) -> bool:
10
+ """Send a state string to systemd (e.g. READY=1, WATCHDOG=1). Returns True if sent."""
11
+ addr = os.environ.get("NOTIFY_SOCKET")
12
+ if not addr:
13
+ return False
14
+ if addr[0] == "@":
15
+ addr = "\0" + addr[1:]
16
+ payload = state.encode("utf-8")
17
+ if addr.startswith("\0"):
18
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
19
+ try:
20
+ sock.connect(addr)
21
+ sock.sendall(payload)
22
+ return True
23
+ except OSError:
24
+ return False
25
+ finally:
26
+ sock.close()
27
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
28
+ try:
29
+ sock.connect(addr)
30
+ sock.sendall(payload)
31
+ return True
32
+ except OSError:
33
+ return False
34
+ finally:
35
+ sock.close()
projects/hot-tub/src/control/tests/test_redis_io.py
... ...
@@ -0,0 +1,88 @@
1
+"""Tests for ControlRedis wait_messages and non-blocking reconnect."""
2
+
3
+from __future__ import annotations
4
+
5
+import sys
6
+import time
7
+import unittest
8
+from unittest.mock import MagicMock, patch
9
+
10
+sys.modules.setdefault("redis", MagicMock())
11
+
12
+from control.redis_io import CHAN_COMMAND, ControlRedis
13
+
14
+
15
+class FakePubSub:
16
+ def __init__(self, script: list[object]) -> None:
17
+ self._script = list(script)
18
+ self._i = 0
19
+
20
+ def get_message(self, timeout: float = 0.0) -> dict[str, object] | None:
21
+ if self._i >= len(self._script):
22
+ return None
23
+ item = self._script[self._i]
24
+ self._i += 1
25
+ if isinstance(item, float):
26
+ time.sleep(item)
27
+ return None
28
+ return item
29
+
30
+
31
+class TestWaitMessages(unittest.TestCase):
32
+ @patch.object(ControlRedis, "subscribe_commands_and_temps")
33
+ def test_returns_promptly_on_first_message(self, sub_mock: MagicMock) -> None:
34
+ msg = {"type": "message", "channel": CHAN_COMMAND, "data": "{}"}
35
+ sub_mock.return_value = FakePubSub([msg])
36
+ cr = ControlRedis("redis://127.0.0.1:6379/0")
37
+ t0 = time.monotonic()
38
+ out = cr.wait_messages(timeout_s=5.0)
39
+ elapsed = time.monotonic() - t0
40
+ self.assertEqual(out, [(CHAN_COMMAND, "{}")])
41
+ self.assertLess(elapsed, 0.5)
42
+
43
+ @patch.object(ControlRedis, "subscribe_commands_and_temps")
44
+ def test_timeout_returns_empty(self, sub_mock: MagicMock) -> None:
45
+ sub_mock.return_value = FakePubSub([0.15])
46
+ cr = ControlRedis("redis://127.0.0.1:6379/0")
47
+ t0 = time.monotonic()
48
+ out = cr.wait_messages(timeout_s=0.1)
49
+ elapsed = time.monotonic() - t0
50
+ self.assertEqual(out, [])
51
+ self.assertGreaterEqual(elapsed, 0.08)
52
+ self.assertLess(elapsed, 0.35)
53
+
54
+ @patch.object(ControlRedis, "subscribe_commands_and_temps")
55
+ def test_drains_queued_messages(self, sub_mock: MagicMock) -> None:
56
+ m1 = {"type": "message", "channel": CHAN_COMMAND, "data": "a"}
57
+ m2 = {"type": "message", "channel": CHAN_COMMAND, "data": "b"}
58
+ sub_mock.return_value = FakePubSub([m1, m2, None])
59
+ cr = ControlRedis("redis://127.0.0.1:6379/0")
60
+ out = cr.wait_messages(timeout_s=1.0)
61
+ self.assertEqual(out, [(CHAN_COMMAND, "a"), (CHAN_COMMAND, "b")])
62
+
63
+
64
+class TestNonBlockingReconnect(unittest.TestCase):
65
+ def test_ensure_returns_immediately_before_retry_after(self) -> None:
66
+ cr = ControlRedis("redis://127.0.0.1:6379/0")
67
+ cr._retry_after_mono = time.monotonic() + 60.0
68
+ t0 = time.monotonic()
69
+ client = cr._ensure()
70
+ elapsed = time.monotonic() - t0
71
+ self.assertIsNone(client)
72
+ self.assertLess(elapsed, 0.05)
73
+
74
+ @patch.object(ControlRedis, "_connect")
75
+ def test_ensure_backoff_without_sleep(self, connect_mock: MagicMock) -> None:
76
+ client = MagicMock()
77
+ client.ping.side_effect = OSError("down")
78
+ connect_mock.return_value = client
79
+ cr = ControlRedis("redis://127.0.0.1:6379/0")
80
+ t0 = time.monotonic()
81
+ self.assertIsNone(cr._ensure())
82
+ elapsed = time.monotonic() - t0
83
+ self.assertLess(elapsed, 0.05)
84
+ self.assertGreater(cr._retry_after_mono, time.monotonic())
85
+
86
+
87
+if __name__ == "__main__":
88
+ unittest.main()
projects/hot-tub/src/health/led_test.py
... ...
@@ -0,0 +1,131 @@
1
+#!/usr/bin/env python3
2
+"""Cycle each health status LED through red, orange, green, then off.
3
+
4
+Hardware smoke test for the three RGB LEDs (red + green channels only).
5
+Pin map matches overview.md. Do not run while a health daemon owns these GPIOs.
6
+
7
+On the Pi:
8
+ python3 health/led_test.py
9
+ python3 health/led_test.py --dry-run # off-Pi / no GPIO
10
+
11
+Requires: python3-gpiozero (sudo apt install python3-gpiozero)
12
+"""
13
+
14
+from __future__ import annotations
15
+
16
+import argparse
17
+import signal
18
+import sys
19
+import time
20
+from dataclasses import dataclass
21
+from typing import Literal
22
+
23
+Color = Literal["red", "orange", "green", "off"]
24
+
25
+STEP_S = 1.0
26
+
27
+# (name, red GPIO, green GPIO) — see src/health/overview.md
28
+LEDS: tuple[tuple[str, int, int], ...] = (
29
+ ("Temp", 27, 18),
30
+ ("Control", 25, 24),
31
+ ("Web", 6, 12),
32
+)
33
+
34
+COLORS: tuple[Color, ...] = ("red", "orange", "green", "off")
35
+
36
+_stop = False
37
+
38
+
39
+def _handle_signal(signum: int, _frame: object) -> None:
40
+ global _stop
41
+ print(f"\nReceived signal {signum}, stopping…", flush=True)
42
+ _stop = True
43
+
44
+
45
+@dataclass
46
+class RgLed:
47
+ name: str
48
+ red_pin: int
49
+ green_pin: int
50
+ dry_run: bool
51
+ _red: object = None
52
+ _green: object = None
53
+
54
+ def open(self) -> None:
55
+ if self.dry_run:
56
+ return
57
+ from gpiozero import OutputDevice
58
+
59
+ self._red = OutputDevice(self.red_pin, active_high=True, initial_value=False)
60
+ self._green = OutputDevice(self.green_pin, active_high=True, initial_value=False)
61
+
62
+ def set_color(self, color: Color) -> None:
63
+ red_on = color in ("red", "orange")
64
+ green_on = color in ("orange", "green")
65
+ if self.dry_run:
66
+ print(f" {self.name}: {color}", flush=True)
67
+ return
68
+ assert self._red is not None and self._green is not None
69
+ self._red.on() if red_on else self._red.off()
70
+ self._green.on() if green_on else self._green.off()
71
+ print(f" {self.name}: {color}", flush=True)
72
+
73
+ def off(self) -> None:
74
+ self.set_color("off")
75
+
76
+ def close(self) -> None:
77
+ if self.dry_run:
78
+ return
79
+ if self._red is not None:
80
+ self._red.off()
81
+ self._red.close()
82
+ self._red = None
83
+ if self._green is not None:
84
+ self._green.off()
85
+ self._green.close()
86
+ self._green = None
87
+
88
+
89
+def run_cycle(leds: list[RgLed]) -> None:
90
+ for led in leds:
91
+ if _stop:
92
+ return
93
+ print(f"{led.name} LED", flush=True)
94
+ for color in COLORS:
95
+ if _stop:
96
+ return
97
+ led.set_color(color)
98
+ if color != "off":
99
+ time.sleep(STEP_S)
100
+
101
+
102
+def main() -> int:
103
+ global _stop
104
+ parser = argparse.ArgumentParser(description="Cycle health status LEDs through red/orange/green/off.")
105
+ parser.add_argument(
106
+ "--dry-run",
107
+ action="store_true",
108
+ help="Print sequence only; do not use GPIO.",
109
+ )
110
+ args = parser.parse_args()
111
+
112
+ signal.signal(signal.SIGINT, _handle_signal)
113
+ signal.signal(signal.SIGTERM, _handle_signal)
114
+
115
+ leds = [RgLed(name, r, g, args.dry_run) for name, r, g in LEDS]
116
+ try:
117
+ for led in leds:
118
+ led.open()
119
+ print("Cycling Temp → Control → Web (Ctrl+C to stop)", flush=True)
120
+ while not _stop:
121
+ run_cycle(leds)
122
+ finally:
123
+ for led in leds:
124
+ led.off()
125
+ led.close()
126
+ print("All LED channels off.", flush=True)
127
+ return 0
128
+
129
+
130
+if __name__ == "__main__":
131
+ sys.exit(main())
projects/hot-tub/src/health/overview.md
... ...
@@ -30,12 +30,16 @@ Health for each service should be monitored in the following ways:
30 30
- if there is a warning reported, set LED to orange
31 31
- use the sensor-connected logic above in the case that any sensors are not listed
32 32
- otherwise, set LED to green
33
-- Control - Read control health status from redis. Health should indicate:
34
- - Healthy - Green LED
35
- - Warning reported - Orange LED
36
- - Error reported - Red LED
37
- - Any health status timestamp older than 10 seconds - Orange LED
38
- - Any health status timestamp older than 1 minute - Red LED
33
+- Control - Read `health:control` from Redis (optionally subscribe to `health.control` for faster updates). Evaluate on a short poll loop (same wait-with-timeout pattern as Control is fine when this daemon is built).
34
+ - Payload from Control Daemon: `status` is `ok`, `degraded`, or `error`; `ts` is UTC ISO-8601 when the heartbeat was written.
35
+ - **`status: ok`** → green LED
36
+ - **`status: degraded`** → orange LED (e.g. stale temps while maintain, latched heat error phase)
37
+ - **`status: error`** → red LED (e.g. GPIO failure, Redis publish failure)
38
+ - **Stale heartbeat:** if `ts` is older than **10 seconds**, orange LED (overrides a stale `ok` in the key — roughly two missed Control heartbeats at default `heartbeat_s` 5 s)
39
+ - **Very stale:** if `ts` is older than **60 seconds**, red LED
40
+ - Health Daemon **only drives LEDs**; it does not publish events that Control waits on for safety. Stale temperature cutoff is enforced inside Control’s own 1 s reconcile loop.
41
+ - **Redis down:** Control keeps reconciling locally but cannot refresh `health:control` → Control LED goes orange then red by age (expected).
42
+ - **Control restart:** brief stale window (orange possible) until Control sends `READY=1` and publishes health again.
39 43
- Web
40 44
- Http request to API health endpoint every 5 seconds
41 45
- Healthy - Green LED
... ...
@@ -44,4 +48,41 @@ Health for each service should be monitored in the following ways:
44 48
- Http request to index every 5 seconds
45 49
- On 2xx status - Green LED
46 50
- On error status - Red LED
47
- - on response lasting longer than 1 second - Orange LED
... ...
\ No newline at end of file
0
+ - on response lasting longer than 1 second - Orange LED
1
+
2
+## Hardware Setup
3
+
4
+The LEDs are 3 color LEDs, Red, Green and Blue. To create red, orange and green we will be using some combination of red and green. Blue is never used. To make the colors here are the combinations:
5
+
6
+- Red
7
+ - Red Channel ON
8
+ - Green Channel OFF
9
+- Orange
10
+ - Red Channel ON
11
+ - Green Channel ON
12
+- Green
13
+ - Red Channel OFF
14
+ - Green Channel ON
15
+
16
+Here is the configuration for each LED:
17
+
18
+- Temp:
19
+ - Red - GPIO 27
20
+ - Green - GPIO 18
21
+- Control:
22
+ - Red - GPIO 25
23
+ - Green - GPIO 24
24
+- Web:
25
+ - Red - GPIO 6
26
+ - Green - GPIO 12
27
+
28
+## Hardware smoke test
29
+
30
+From the repo `src` directory on the Pi (stop any health daemon first):
31
+
32
+```bash
33
+python3 health/led_test.py
34
+python3 health/led_test.py --dry-run # print sequence only
35
+```
36
+
37
+[`led_test.py`](led_test.py) loops Temp → Control → Web. For each LED it shows red, orange, and green for one second each, then turns that LED off before moving to the next. Ctrl+C turns all channels off.