bbc13710209a3a2387847ce2da1c2534244bd8af
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. |