This document describes the software running on the Raspberry Pi 3 controller: four cooperating services plus a local Redis instance used for pub/sub and short-lived shared state. Related hardware detail lives in hardware-setup.md. Optional clients (tub-side console, indoor monitor) are covered in pi-zero-control-panel.md and indoor-monitor.md.
Design Principles
- Single decision-maker. Only the Control Daemon may drive the heat and filter relays (GPIO). Every other process or remote device sends intents (mode, target temperature, enable/disable). The Control Daemon validates those intents against safety rules before changing outputs.
- Sensors are read-only. The Temperature Daemon owns the Pi's own 1-Wire interface (direct GPIO4, no separate sensor-hub microcontroller — see hardware-setup.md). Nothing else bit-bangs the DS18B20 bus.
- Redis is the local bus. Services do not call each other over HTTP for core control. They publish events and subscribe to what they need. Redis also holds the latest snapshot so a newly started subscriber can catch up without waiting for the next publish.
- Clients are interchangeable. Web UI, HTTP API, tub-side console, and indoor monitor all express the same kinds of intents. They never talk to GPIO or sensors directly.
- Fail safe. On Control Daemon crash, restart, or loss of valid temperature data, heat turns off (or stays off). Filter behavior is mode-dependent but must never leave heat running unsupervised after a fault.
- Health is explicit and visible. Temperature, Control, and Web each publish heartbeats/health to Redis. A dedicated Health Daemon watches those signals and drives three status LEDs (one per service). No other process owns those LEDs.
System Context
+---------------------------+
| Remote clients |
| - browser / phone |
| - tub-side Pi Zero |
| - indoor LoRa monitor |
+-------------+-------------+
|
Wi-Fi / LoRa bridge
|
+---------------------------------v---------------------------------+
| Raspberry Pi 3 (waterproof enclosure) |
| |
| +----------------+ readings +------------------+ |
| | Temperature | -----------> | | |
| | Daemon | health | Redis | |
| +----------------+ -----------> | (local) | |
| | channels + | |
| +----------------+ state / | key/value | |
| | Control | commands | | |
| | Daemon | <----------> | | |
| | (relays, modes)| health | | |
| +--------+-------+ -----------> | | |
| | GPIO | | |
| v | | |
| Heat / filter relays | | |
| Float / flow inputs (FUTURE, | | |
| not installed) | | |
| | | |
| +----------------+ health | | |
| | Web / API | -----------> | | |
| | service | <----------- | | |
| +----------------+ state +--------+---------+ |
| ^ |
| | health:* |
| +------------+--------+ |
| | Health Daemon | |
| | (watches 3 services)| |
| +----------+----------+ |
| | GPIO |
| v |
| LED temps / control / web |
+-------------------------------------------------------------------+
|
v
Enclosed AC relay outlets --> heat circ pump / filter pump
GPIO numbers for relays and safety inputs match hardware-setup.md. If field wiring differs, change pin constants only in the daemon that owns that pin (Control for relays/sensors; Health for status LEDs).
Services
1. Temperature Daemon
Responsibility: sample temperature probes on a fixed schedule and publish a structured reading. No control logic, no user-facing API, no GPIO relays.
Inputs (hardware):
- Five DS18B20 probes directly on the Pi's own 1-Wire bus (
w1-gpiooverlay, GPIO4) — 4 tub probes + 1 hot-water-input probe. No separate microcontroller sensor hub is used in the shipped design. - Optional: outside-air probe on the same bus (labeled distinctly in config).
Outputs (Redis):
- Publish each sample to a channel (e.g.
temps.reading). - Write the same payload to a key (e.g.
temps:latest) so late joiners canGETwithout waiting. - Publish health / heartbeat to
health.tempsand keyhealth:temps(see Health publishing).
Suggested payload fields:
| Field | Meaning |
|---|---|
ts |
ISO-8601 or Unix timestamp of the sample |
probes |
Map of device id → °F (or null on CRC/fault) |
tub_avg_f |
Average of configured tub probes only (excludes hot-input / outdoor) |
hot_in_f |
Optional hot-water inlet probe |
outdoor_f |
Optional outdoor probe |
ok_count / fail_count
|
How many tub probes contributed / failed |
spread_f |
Max − min among valid tub probes (for disagreement checks) |
Schedule: default on the order of 1–5 seconds. Faster than the Control Daemon's decision loop is fine; Redis is cheap. Conversion time for DS18B20 (~750 ms at 12-bit) is owned entirely inside this daemon — subscribers never block on the bus.
Hardware considerations:
- One 4.7 kΩ pull-up on the data line (or per bus if multiple
w1-gpiooverlays are used). - Probe identity (ROM id → role: tub1…tub4, hot-in, outdoor) lives in a small config file owned by this daemon, not hardcoded in Control or Web.
- If probes are behind M12 connectors at the enclosure, electrical topology is still a single multidrop bus; the daemon does not care about connectors.
Does not:
- Drive relays or status LEDs.
- Interpret modes or target temperature.
- Serve HTTP.
2. Control Daemon
Responsibility: sole owner of actuation and operating modes. Subscribes to temperature readings and user intents, applies safety rules, updates relay outputs, records history, and publishes system state.
Inputs:
| Source | Content |
|---|---|
Redis temps.reading
|
Latest probe package from Temperature Daemon |
Redis command channel (e.g. control.command) |
User intents: set mode, set target °F, acknowledge fault, etc. |
| GPIO inputs |
FUTURE, not installed today: low-water float (GPIO17), optional flow switch (GPIO27) — see hardware-setup.md. src/control/safety.py has a TODO hook only; water level is presumed adequate for now. |
| Local config / Redis keys | Hard max temp, hysteresis band, probe disagreement limit, no-rise timeout |
Outputs:
| Destination | Content |
|---|---|
| GPIO22 | Heat circulation pump relay |
| GPIO23 | Filter pump relay |
Redis control.state (pub) + control:latest (key) |
Canonical system state snapshot |
Redis health.control (pub) + health:control (key) |
Heartbeat / health (see Health publishing) |
| Local store (SQLite or append-only log) | Temperature history and mode/relay change events |
Modes (initial set — names can evolve):
| Mode | Intent | Typical actuator behavior |
|---|---|---|
sleep / off
|
Idle; safe default after boot | Heat off; filter off (or rare duty cycle later if desired) |
maintain |
Hold tub near target temperature | Heat on/off using hysteresis around target; filter per schedule or always-on policy |
filter |
Filter only | Heat off; filter on |
backflush |
Manual filter maintenance | Heat off; filter on (possibly timed); UI may show a countdown |
heat (optional explicit) |
Heat toward target without implying long-term maintain | Same heat logic as maintain; useful if UI wants a distinct label |
Exact filter scheduling inside maintain (always on vs periodic) is a Control Daemon policy, not a Web concern.
Decision loop (conceptual):
Single-threaded loop in daemon.py — not separate event and timer processes.
each iteration:
wait on Redis pub/sub (control.command + temps.reading)
until a message arrives OR loop_interval_s elapses (default 1 s)
on message: update in-memory intents / latest temps only (no GPIO)
always: reconcile (heat FSM + filter policy + safety) → GPIO → publish if changed or heartbeat due
sd_notify WATCHDOG=1
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.
Control loop timing:
| Setting | Default | Role |
|---|---|---|
loop_interval_s |
1.0 | Maximum time between reconcile passes when idle |
heartbeat_s |
5.0 | Minimum interval for health:control refresh when state unchanged |
systemd WatchdogSec
|
10 | Restart if the loop stops notifying |
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.
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.
Safety rules (enforced here, not in the Web UI):
- (Future — not implemented; no float hardware installed.) No heat if low-water float indicates dry/low. Water level is presumed adequate today.
- No heat if too few tub probes are valid (configurable minimum).
- No heat if probe spread exceeds a hard limit.
- No heat above a hard maximum °F (absolute ceiling, independent of user target).
- No heat if temperature is not rising for N minutes while heat has been commanded on (stuck/no-fuel/no-flow).
- (Future — not implemented.) Optional: no heat if flow switch says no flow while heat pump should be running.
- User target is clamped to a safe range before use.
- On daemon start: relays initialized off until the first valid sample and mode evaluation.
Hardware considerations:
- Relays are low-voltage control inputs to enclosed AC modules — the Pi never switches mains directly.
- Confirm active-high vs active-low for the chosen IoT Relay modules; invert in one place in this daemon.
- GPIO access should be exclusive to this process (do not also run
control_poc.pyor other experiments against the same pins). - (Future — not installed today.) Float/flow inputs, once added, would use internal pull-ups; treat open/closed polarity in config.
Persistence:
- Hot state (mode, target, last fault): Redis keys for fast restart and a small on-disk file so a Redis flush does not forget the user's mode.
- History: SQLite (or similar) written by Control Daemon — timestamps, tub average, per-probe optional, heat/filter on/off, mode. Web reads history via API (or a read-only DB connection), not by scraping logs.
Does not:
- Own the 1-Wire bus.
- Drive status LEDs (Health Daemon owns those).
- Render HTML (may expose a tiny local debug socket later, but primary UI is the Web service).
3. Web Interface (+ API)
Responsibility: human-facing UI and machine-facing HTTP API. Translates clicks/API calls into Redis commands; mirrors Redis state into the browser (SSE, WebSocket, or short polling).
Inputs:
- Redis
control.state/temps.reading(or justcontrol:latestif Control embeds the last temp summary). - HTTP from browsers and local-network clients (tub-side console can use HTTP if preferred over speaking Redis directly).
Outputs:
- Publish to
control.command(never write GPIO relays or LEDs). - Publish health / heartbeat to
health.weband keyhealth:web. - Optional: serve static UI assets, OpenAPI/simple JSON docs.
UI capabilities (v1):
- Live tub average + per-probe breakdown + outdoor/hot-in if present.
- Current mode, heat/filter relay state, target temperature, fault banners.
- Controls: select mode, adjust target, clear/acknowledge non-latching faults where allowed.
- Diagnostics: last sample age, probe fail flags, per-service health from Redis (
health:*), recent history chart. - Setup: probe role labeling (or link to editing Temperature Daemon config), Wi-Fi status, software version.
API sketch (illustrative):
| Method | Path | Role |
|---|---|---|
GET |
/api/status |
Latest combined state (from Redis keys) |
GET |
/api/history?from=&to= |
Temperature / event history |
POST |
/api/mode |
{ "mode": "maintain" } → Redis command |
POST |
/api/target |
{ "target_f": 102 } → Redis command |
GET |
/api/health |
Process + Redis + stale-data checks |
Auth for v1 can be "local network only" (Pi not exposed to the internet) or a simple shared token; do not invent a full user system until needed.
Does not:
- Decide whether heat may run.
- Bypass Control Daemon safety by writing pins or "force heat" without going through Control (if a diagnostic force exists, it still lands as a command Control must accept or reject).
- Drive status LEDs.
4. Health Daemon
Responsibility: watch the health of the three functional services (Temperature, Control, Web) via Redis, and drive three status indicator LEDs — one LED per service. This is the only process that owns those LED GPIOs.
Inputs:
| Source | Content |
|---|---|
Redis health:temps, health:control, health:web
|
Latest heartbeat from each service (polled on a short timer) |
Redis health.temps, health.control, health.web (optional) |
Pub/sub wakeups so LEDs update immediately on heartbeat |
Outputs:
| Destination | Content |
|---|---|
| 3× GPIO (status LEDs) | On / off / blink pattern per service |
Redis health:summary (optional) |
Aggregated OK/degraded/down for the Web diagnostics page |
Evaluation rules (suggested defaults):
| Condition | LED meaning |
|---|---|
Fresh heartbeat, status: ok
|
Solid ON (service healthy) |
Fresh heartbeat, status: degraded
|
Blink slow (running but impaired — e.g. temps with failing probes) |
| No heartbeat within timeout (e.g. 10–15 s) | Solid OFF or blink fast (service down / stuck) |
| Health Daemon itself starting | All LEDs off until first evaluation pass |
Timeouts should be longer than each service's heartbeat interval (typically 2–5 s) so a single delayed loop does not flicker the LED.
For Control specifically (see 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.
Hardware considerations:
- 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).
- Assign free GPIOs that do not collide with Control's heat/filter/float/flow pins or with any HAT used later. Exact BCM numbers live in Health Daemon config (open decision until the enclosure LED layout is fixed).
- Exclusive GPIO ownership: Health Daemon only. Control must not also toggle these pins.
- If the Health Daemon crashes, LEDs freeze in their last state (or go dark if the process cleans up). That is acceptable — pump safety remains Control's job, not the LEDs'.
Does not:
- Read temperature probes or drive heat/filter relays.
- Interpret operating modes or accept user commands.
- Restart other services by itself (optional future: call systemd; v1 is observe + LEDs only).
Health Publishing
Temperature, Control, and Web each emit a small health document on a fixed interval and whenever their health status changes (e.g. temps goes from ok → degraded when probes drop out).
Suggested health payload
{
"service": "temps",
"ts": "2026-10-19T19:00:00Z",
"status": "ok",
"detail": "4/4 tub probes",
"pid": 1234
}
| Field | Meaning |
|---|---|
service |
temps | control | web
|
ts |
Time of this heartbeat |
status |
ok | degraded | error
|
detail |
Short human string for UI / logs |
pid |
Optional; helps confirm the process was replaced after restart |
Per-service status guidance
| Service | ok |
degraded |
error |
|---|---|---|---|
| Temperature | At least the minimum tub probes reading | Some probes failing but still publishing averages | Cannot read bus / zero valid probes |
| Control | Loop running; Redis reachable; GPIO initialized | Running with an active safety fault (heat held off) | Cannot use Redis or GPIO; loop stuck |
| Web | Accepting HTTP; Redis reachable | Redis flaky or stale upstream state | Cannot bind port / Redis down |
Heartbeats alone are not enough for Control safety (Control still applies its own stale-temps:latest rules). Health is for operators and LEDs, not a substitute for the heat interlocks.
Redis as the Local Bus
Run Redis bound to localhost only (bind 127.0.0.1). No password required if the Pi is a single-trust host on a private LAN and Redis is not reachable off-box; revisit if the Pi ever joins an untrusted network.
Channels (pub/sub)
| Channel | Publisher | Subscribers | Purpose |
|---|---|---|---|
temps.reading |
Temperature Daemon | Control, Web | New temperature sample |
control.state |
Control Daemon | Web, (optional bridges) | Mode, relays, faults, embedded temp summary |
control.command |
Web, bridges, CLI tools | Control Daemon | User / client intents |
health.temps |
Temperature Daemon | Health Daemon, Web | Temperature service heartbeat |
health.control |
Control Daemon | Health Daemon, Web | Control service heartbeat |
health.web |
Web service | Health Daemon | Web service heartbeat |
Keys (latest snapshots)
| Key | Writer | Readers |
|---|---|---|
temps:latest |
Temperature Daemon | Control, Web |
control:latest |
Control Daemon | Web, bridges |
control:config (optional) |
Control / setup tools | Control |
health:temps |
Temperature Daemon | Health Daemon, Web |
health:control |
Control Daemon | Health Daemon, Web |
health:web |
Web service | Health Daemon, Web |
health:summary (optional) |
Health Daemon | Web |
Pub/sub delivers change notifications; keys make restarts and page loads cheap. Prefer publish then SET (or SET then publish the same JSON) so a subscriber that misses a message can still recover on the next poll/heartbeat.
Command envelope (suggested)
{
"ts": "2026-10-19T19:00:00Z",
"source": "web",
"id": "uuid-or-monotonic",
"op": "set_target",
"args": { "target_f": 102 }
}
Control publishes an ack or reflected state that includes the last applied id so UIs can confirm.
Process Model and Deployment
All four services + Redis run on the Pi 3 under systemd (or equivalent), restart on failure.
| Unit | Notes |
|---|---|
redis |
Distro package; localhost only |
hot-tub-temps |
Temperature Daemon; publishes readings + health |
hot-tub-control |
Control Daemon; After= redis preferred; tolerate temps starting late |
hot-tub-web |
Web/API; binds to LAN interface (e.g. :8080); publishes health |
hot-tub-health |
Health Daemon; After= redis; drives three status LEDs from health:* keys |
Shared Python package under src/ (config loading, message schemas, temp helpers, health helpers) is fine; keep each service's entrypoint thin.
Ordering on boot:
- Redis up; Health Daemon starts and holds LEDs off until heartbeats appear.
- Relays held off by Control's initial GPIO state.
- Temperature Daemon begins publishing readings + health → temps LED comes on.
- Control waits for a fresh
temps:latestbefore allowing heat; publishes its own health → control LED. - Web comes up whenever; publishes health → web LED; UI shows "waiting for data" until state keys exist.
External Clients (Non-Service)
These are not the three core services. They speak to the Pi 3 as clients.
| Client | Link | Allowed intents |
|---|---|---|
| Browser / phone | HTTP to Web service | Modes, target, diagnostics |
| Tub-side Pi Zero console | HTTP (or Redis only if on same host — it is not) | Enable/disable heating-oriented modes, target ±, status display |
| Indoor LoRa monitor | LoRa ↔ small bridge process on Pi 3 ↔ Redis commands / state | Target ±1°F and status only |
A LoRa (or BLE) bridge is an optional extra process if needed: translate radio packets ↔ Redis. It must not own pump GPIO or status LEDs. It publishes the same control.command messages the Web would. (It is not one of the four core services and does not get its own status LED unless added later.)
Architectural rule reused everywhere: intent in, Control decides, state out.
Hardware Ownership Map
| Hardware | Owning software | Notes |
|---|---|---|
| DS18B20 bus (5 probes, direct Pi 1-Wire, GPIO4) | Temperature Daemon | Exclusive; no sensor hub in shipped design |
| Heat relay GPIO | Control Daemon | Exclusive |
| Filter relay GPIO | Control Daemon | Exclusive |
| Float / flow GPIO | Control Daemon | FUTURE — not installed; would be read in safety path once added |
| Status LED — Temperature | Health Daemon | Exclusive; reflects health:temps
|
| Status LED — Control | Health Daemon | Exclusive; reflects health:control
|
| Status LED — Web | Health Daemon | Exclusive; reflects health:web
|
| Enclosure Ethernet/Wi-Fi | OS / Web | Web binds here |
| LoRa SPI module on Pi (if used) | Bridge process | Not Control / not Health |
Relationship to Existing PoC Code
| Artifact | Role going forward |
|---|---|
src/read_temps.py |
CLI diagnostic; Temperature Daemon can reuse its read helpers |
src/control_poc.py |
Throwaway loop proving GPIO + average + hysteresis — replace with Control Daemon; do not run alongside it |
temp-sensor/app.py |
Earlier Flask-on-demand design; superseded by Temperature Daemon + Redis for the Pi 3 architecture (still useful if a remote sensor node is ever revived) |
Data Flow Examples
Maintain temperature
User sets mode=maintain, target=102 via Web
→ control.command
→ Control stores mode/target, publishes control.state
Temperature Daemon publishes tub_avg_f=99.2
→ Control: avg below band → heat GPIO ON, publish state
→ Web updates UI / SSE
Temperature Daemon publishes tub_avg_f=104.5
→ Control: avg above band → heat GPIO OFF, publish state
Low water while heating — FUTURE, not implemented today
No float switch hardware is installed, and src/control/safety.py only has a TODO hook for it — this scenario describes the planned behavior once that hardware exists, not current behavior. Today, water level is presumed adequate in software.
Float opens (low water)
→ Control safety: heat forced OFF, fault=low_water, publish state
→ Web shows fault; further "maintain" requests do not energize heat until clear
Indoor monitor nudge
LoRa packet: target +1
→ bridge → control.command set_target(current+1)
→ Control clamps, applies, publishes control.state (with new target)
→ bridge returns status downlink to monitor
Service dies → LED drops
Temperature Daemon stops (crash or kill)
→ health:temps timestamp goes stale
→ Health Daemon timeout fires → Temperature LED OFF (or fast blink)
→ Control independently sees stale temps:latest → heat forced OFF
→ Control health may stay ok (or degraded if it reports "no temps")
→ Web can show "temps down" from health:temps / health:summary
Suggested Build Phases (Software)
-
Redis + Temperature Daemon — publish real probe averages + health; verify with
redis-cliandread_temps.py. -
Control Daemon — subscribe, implement
sleep/maintain/filter, GPIO, safety stubs, state + health publish. Retirecontrol_poc.pyfor day-to-day use. -
Web status page — read-only mirror of
control:latest/temps:latest; publish web health. -
Health Daemon + three LEDs — watch
health:*, drive indicators; confirm kill of each service extinguishes the matching LED. - Web controls — mode + target commands; history charts; show health summary.
- Client bridges — tub-side HTTP client; LoRa bridge for indoor monitor.
- Hardening — Control already uses systemd watchdog + notify; remaining: stale-data tuning, backup of config/history, structured logging across all services.
Open Decisions
- Exact mode list and whether
backflushis timed or latching until cancelled. - History store: SQLite file path and retention (days/weeks).
- Whether Web embeds a WebSocket or uses Server-Sent Events for live updates.
- Auth model for non-local access (prefer none initially; VPN or Tailscale if remote).
- Probe role config format (JSON file vs Redis
control:configedited by setup UI). - Active-high vs active-low relay polarity once modules are chosen.
- BCM pin numbers for the three status LEDs (must not collide with Control GPIO).
- LED patterns for
degradedvsdown(slow blink vs fast blink vs off). - Health heartbeat interval and stale timeout values.
Document History
- Initial architecture: three services (Temperature, Control, Web) + local Redis pub/sub, aligned with Pi 3 as sole actuator owner.
- 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).
- Control Daemon: documented and implemented single-threaded wait-with-timeout loop (
loop_interval_s1 s), immediate command handling, non-blocking Redis reconnect, and systemd watchdog for Control.