Control Daemon
Sole owner of heat and filter pump relays on the Pi. Consumes temperature readings and user commands from Redis, applies mode / safety logic, drives GPIO, and publishes canonical system state (and health) back to Redis.
See software-architecture.md for system context. GPIO pin defaults match hardware-setup.md (heat relay GPIO22, filter relay GPIO23 unless config overrides).
Responsibility
| Does | Does not |
|---|---|
| Subscribe to Redis commands + temperature readings | Read the 1-Wire bus itself |
| Own heat / filter modes (intent) and pump states (actual on/off) | Drive status LEDs (Health Daemon) |
| Run heat phase FSM (startup, heating, backflush, error, …) | Serve the web UI |
| Enforce safety interlocks before energizing heat | Guess probe identity |
| Persist mode / target / last error across restarts | |
| Log useful operational output |
Naming: modes vs pump state vs heat phase
You effectively have three layers for heat (filter is simpler):
| Layer | Suggested name | Meaning | Examples |
|---|---|---|---|
| Intent | heat_mode |
What the user/system asked for |
sleep, maintain, backflush
|
| Actuator | heat_pump |
Physical relay / pump right now |
on, off
|
| Progress | heat_phase |
Where the controller is inside the current heat behavior |
idle, startup_1…startup_3, heating, backflush, error_startup
|
Recommended name for the third parameter: heat_phase (not a generic “operational state”).
- “Operational state” is understandable but vague — it could mean the whole appliance, including filter, network, etc.
- Almost every value you listed (startup attempts, heating, propane/backflush error, timed backflush) is a phase of the heat control state machine.
- Keeping the name under
heat_*pairs cleanly withheat_modeandheat_pump, and leaves room later for something likefilter_phaseif auto-filter grows a schedule FSM.
Aliases if you prefer different wording in the UI: show “Status: Starting (attempt 2)” while the Redis field remains heat_phase.
Filter side for v1:
| Field | Values |
|---|---|
filter_mode |
on | auto | off (intent) |
filter_pump |
on | off (actual) |
(auto = daily timed run; start time and duration live in Control config / eventually Web settings.)
Planned layout
src/control/
README.md ← this file
__init__.py
__main__.py # python -m control / systemd ExecStart
daemon.py # loop: commands + temps + timers → decide → GPIO → publish
commands.py # parse / validate control.command payloads
state.py # in-memory + persist heat_mode, filter_mode, target, heat_phase, …
heat_fsm.py # maintain startup sequence, heating, backflush timer, errors
filter_policy.py # on/off/auto; daily schedule for auto
safety.py # max temp, stale temps, probe faults (float later)
gpio_out.py # heat / filter relays (gpiozero or RPi.GPIO)
redis_io.py # subscribe commands/temps; SET/PUBLISH state + health
logging_setup.py # file logger → /var/log/hot-tub/control.log
config.example.json
Inputs (Redis pub/sub)
Subscribe to a command channel (e.g. control.command). Each message is a small JSON envelope:
{
"ts": "2026-10-19T20:00:00Z",
"source": "web",
"id": "unique-request-id",
"op": "set_heat_mode",
"args": { "mode": "maintain" }
}
Command ops
op |
Args | Effect |
|---|---|---|
set_heat_mode |
mode: sleep | maintain | backflush
|
Set heat intent; may start/stop FSM |
set_target_temp |
target_f: number |
Absolute target (°F), clamped to safe range |
target_temp_increment |
optional delta_f (default 1) |
target_f += delta |
target_temp_decrement |
optional delta_f (default 1) |
target_f -= delta |
set_filter_mode |
mode: on | auto | off
|
Set filter intent |
ack_error (optional later) |
— | Clear other latching errors if added; error_startup clears via sleep
|
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.
Published state
On every meaningful change (and on a slow heartbeat), publish the full snapshot:
SET control:latestPUBLISH control.state
Suggested fields:
{
"ts": "2026-10-19T20:00:05Z",
"heat_mode": "maintain",
"filter_mode": "auto",
"target_f": 102.0,
"tub_avg_f": 98.5,
"hot_in_f": 112.0,
"heat_pump": "on",
"filter_pump": "off",
"heat_phase": "heating",
"startup_attempt": 1,
"error": null,
"last_command_id": "…"
}
| Field | Role |
|---|---|
heat_mode / filter_mode
|
Intent |
heat_pump / filter_pump
|
Exact actuator reality (on/off) |
heat_phase |
FSM progress / fault class |
error |
Machine-readable code + human detail when phase is an error |
heat_phase values (initial set)
| Phase | Meaning |
|---|---|
idle |
No heat activity (sleep, or maintain satisfied / waiting to need heat) |
startup_1 … startup_3
|
Maintain startup attempt in progress (pump on, waiting for hot-in rise check) |
heating |
Startup succeeded; pump on until tub reaches target_f
|
backflush |
Timed backflush running (heat pump on for countdown) |
error_startup |
Three startup attempts failed — likely empty propane or needs backflush; cleared by sleep
|
error_safety |
Optional: blocked by safety interlock (max temp, stale probes, …) |
Heat behavior
heat_mode = sleep
-
heat_pump→off - Clear startup attempt counters
-
error_startupis cleared — setheat_phase→idleand clear the publishederrorfor that fault (log the reset). Entering sleep is the operator reset path; no separate ack required for startup failures.
heat_mode = maintain
target_f is always the maximum temperature for heating — the pump must turn off at or above target. There is no symmetric band above target.
Maintain band (v1 fixed, may become configurable later):
| Condition | Action |
|---|---|
tub_avg_f <= target_f - 5 |
Need heat → run startup (if pump off) or keep heating |
tub_avg_f >= target_f |
Target reached → heat_pump off, heat_phase = idle
|
Between target_f - 5 and target_f
|
If already heating, keep going until target; if pump is off, stay idle (do not start a new cycle until at or below the low edge) |
So the active window is [target_f - 5, target_f] with hysteresis at the bottom: restart only at/below target − 5, stop at target.
When maintain wants heat and heat_pump is currently off, run the startup sequence (do not jump straight to indefinite heating):
- Set
heat_phase = startup_N(N= attempt 1..3), turnheat_pumpon. - After 20 seconds, compare sensors:
- Success if
hot_in_f >= tub_avg_f + HOT_IN_RISE_F(defaultHOT_IN_RISE_F = 10). - Failure otherwise (missing
hot_in_fcounts as failure).
- Success if
- On failure: turn
heat_pumpoff, brief settle delay (config, e.g. a few seconds), increment attempt, repeat steps 1–2. - After 3 failed attempts:
heat_pumpoff,heat_phase = error_startup, log + publish error (e.g.code: "startup_failed", detail suggesting empty propane or backflush needed). Stay latched until the operator setsheat_mode = sleep(which clears the error). - On success:
heat_phase = heating, keepheat_pumpon untiltub_avg_f >= target_f, then pump off andheat_phase = idle(still inheat_mode = maintain, waiting until avg falls totarget_f - 5or below).
If temperature later falls to target_f - 5 or below, start a new startup sequence from attempt 1 (cold propane / flame-out is possible between cycles).
maintain + need heat + pump off
│
v
startup_1 ──fail──> off ──> startup_2 ──fail──> off ──> startup_3
│ success │ success │ success
v v v
heating <─────────────── heating <────────── heating
│ │ fail
│ target reached v
v error_startup
idle (mode still maintain)
heat_mode = backflush
Separate intent: clear / purge path using the heat circulation pump on a short timer.
- Enter
heat_phase = backflush,heat_pumpon. - After 10 seconds,
heat_pumpoff,heat_phase = idle. - Return
heat_modetosleep(recommended) or leavebackflushas a one-shot that auto-reverts — pick one and document in config; auto-revert tosleepavoids leaving a sticky mode that does nothing.
Do not run the maintain hot-in rise check during backflush (no expectation of flame). Other safety interlocks still apply.
Filter behavior
filter_mode |
filter_pump |
|---|---|
off |
off |
on |
on (continuous until mode changes) |
auto |
on only during the daily run window; otherwise off
|
filter_mode = auto
Runs once per calendar day on a wall-clock schedule (Pi local timezone unless config specifies otherwise):
| Setting | Default | Meaning |
|---|---|---|
filter_start_time |
00:00 (midnight) |
Time of day the daily filter cycle starts |
filter_run_time |
2 hours |
How long the filter pump stays on after start |
Behavior:
- While
filter_mode = auto, each day atfilter_start_timesetfilter_pumpon and record the run start. - When elapsed runtime reaches
filter_run_time, setfilter_pumpoff until the next day's start. - If the daemon starts (or mode switches to
auto) during today's window, turn the pump on for the remaining time only (do not run a full extra 2 hours past the window end). If the window for today is already over, stay off until tomorrow's start. - Switching to
onoroffcancels the schedule for as long as that mode is active; returning toautore-evaluates the current window immediately. - Filter schedule is independent of heat — maintain/backflush do not force the filter on or off.
Publish optional convenience fields on control.state (e.g. filter_window_active, filter_next_start) so the UI can show when the next/current auto run is.
Backflush remains a heat_mode (heat circ pump), not filter_mode — naming matches the propane heater loop, not the cartridge filter pump.
Safety (always wins)
Before any heat_pump = on:
- Valid, fresh tub average (and hot-in when required for startup check)
-
tub_avg_f < target_f(target is the max — never heat at or above target) - Probe map / temps not in
need_reinstall - Not latched in
error_startup(clear by settingheat_mode = sleepfirst)
Water level: there is no float sensor for now. Adequate water is presumed. Do not gate heat on a float input in v1; leave a clear hook in safety.py for a future low-water check when hardware is added (hardware-setup.md still describes GPIO17 as the eventual pin).
On safety trip: force heat pump off, set heat_phase = error_safety (or stay idle with error set — prefer explicit phase for UI LEDs/banners).
Health
Publish on an interval and on status changes:
-
SET health:control+PUBLISH health.control -
okwhile loop running and Redis/GPIO usable -
degradedif activeerror_*phase or stale temps while mode wants heat -
errorif GPIO/Redis hard-fail
Operational requirements
systemd (start on boot)
- Unit suggestion:
hot-tub-control.service -
WantedBy=multi-user.target,Restart=on-failure -
After=redis.service(and preferably after temps is up, but tolerate missing temps with heat held off) - Exclusive use of heat/filter GPIO — do not run experimental relay scripts alongside this service
Logging
-
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 - Prefer stdlib
logging+ system logrotate (same pattern as other hot-tub services)
logrotate
Example /etc/logrotate.d/hot-tub-control:
/var/log/hot-tub/control.log {
weekly
rotate 8
compress
delaycompress
missingok
notifempty
copytruncate
}
Persistence
Persist at least: heat_mode, filter_mode, target_f, last error / whether error_startup is latched. On boot: relays off, reload intents, resume carefully (if heat_mode=maintain and tub is cold, start a fresh startup sequence rather than assuming flame is lit).
Config sketch
{
"redis_url": "redis://127.0.0.1:6379/0",
"log_path": "/var/log/hot-tub/control.log",
"state_path": "/var/lib/hot-tub/control-state.json",
"gpio": {
"heat_pump": 22,
"filter_pump": 23,
"active_high": true
},
"maintain": {
"band_f": 5.0,
"startup_delay_s": 20,
"hot_in_rise_f": 10.0,
"startup_attempts": 3,
"retry_off_s": 5
},
"backflush": {
"duration_s": 10,
"revert_to_heat_mode": "sleep"
},
"filter_auto": {
"start_time": "00:00",
"run_time_hours": 2,
"timezone": "local"
},
"target_f_default": 102,
"target_f_min": 50,
"target_f_max": 110
}
band_f is the gap below target that restarts heating: on at target_f - band_f, off at target_f. V1 ships with band_f = 5; making it configurable is a later nicety. target_f_min / target_f_max only clamp what the user is allowed to set as target — once set, that target is the heating ceiling.
Implementation plan
| Phase | Work |
|---|---|
| 1 |
state.py + commands.py: apply ops in memory; no GPIO |
| 2 |
redis_io.py: subscribe commands; publish control.state / control:latest + health |
| 3 |
gpio_out.py + dry-run flag; map heat_pump / filter_pump to relays |
| 4 |
heat_fsm.py: maintain startup (20 s, +10°F, 3 tries), heating to target, backflush 10 s |
| 5 |
safety.py + consume temps:latest; refuse heat when stale/faulty |
| 6 |
logging_setup.py, systemd unit, logrotate; persist state file |
| 7 | Integration: drive modes from redis-cli PUBLISH; confirm phase transitions in log + GET control:latest
|
Manual checks (on the Pi)
cd /path/to/hot-tub/src && PYTHONPATH=. python3 -m control
redis-cli PUBLISH control.command '{"op":"set_target_temp","args":{"target_f":102},"id":"1","source":"cli"}'
redis-cli PUBLISH control.command '{"op":"set_heat_mode","args":{"mode":"maintain"},"id":"2","source":"cli"}'
redis-cli GET control:latest
redis-cli SUBSCRIBE control.state
tail -f /var/log/hot-tub/control.log
Open decisions
- Settle time between startup attempts and whether hot-in check uses instantaneous samples or a short average.
- Active-high vs active-low relay modules once hardware is finalized.
- When to add the low-water float and wire it into
safety.py. - When (if ever) to expose
band_fin the UI vs keep it config-only. - Whether Web can edit
filter_start_time/filter_run_timeor those stay config-file-only for v1. - Timezone source if the Pi clock is wrong (
localvs explicit IANA zone + NTP requirement).