Installation: INSTALL.md

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 with heat_mode and heat_pump, and leaves room later for something like filter_phase if 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           # wait-with-timeout loop → reconcile → GPIO → publish
  systemd_notify.py   # sd_notify READY/WATCHDOG (no-op without NOTIFY_SOCKET)
  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.

Main loop

One thread in run_loop() (daemon.py):

  1. Block on Redis pub/sub (control.command, temps.reading) until a message arrives or loop_interval_s elapses (default 1 s).
  2. Message handlers update persisted intents and the latest temperature reading only.
  3. Every wake runs tick(): heat FSM, filter policy, safety, then GPIO (reconcile).
  4. Publish control.state / control:latest and health:control when the snapshot changes or every heartbeat_s (default 5 s).
  5. Call sd_notify("WATCHDOG=1") each pass; systemd unit uses Type=notify and WatchdogSec=10.

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.

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.

Published state

On every meaningful change (and on a slow heartbeat), publish the full snapshot:

  • SET control:latest
  • PUBLISH 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_startup is cleared — set heat_phase → idle and clear the published error for 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):

  1. Set heat_phase = startup_N (N = attempt 1..3), turn heat_pump on.
  2. After 20 seconds, compare sensors:
    • Success if hot_in_f >= tub_avg_f + HOT_IN_RISE_F (default HOT_IN_RISE_F = 10).
    • Failure otherwise (missing hot_in_f counts as failure).
  3. On failure: turn heat_pump off, brief settle delay (config, e.g. a few seconds), increment attempt, repeat steps 1–2.
  4. After 3 failed attempts: heat_pump off, heat_phase = error_startup, log + publish error (e.g. code: "startup_failed", detail suggesting empty propane or backflush needed). Stay latched until the operator sets heat_mode = sleep (which clears the error).
  5. On success: heat_phase = heating, keep heat_pump on until tub_avg_f >= target_f, then pump off and heat_phase = idle (still in heat_mode = maintain, waiting until avg falls to target_f - 5 or 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.

  1. Enter heat_phase = backflush, heat_pump on.
  2. After 10 seconds, heat_pump off, heat_phase = idle.
  3. Return heat_mode to sleep (recommended) or leave backflush as a one-shot that auto-reverts — pick one and document in config; auto-revert to sleep avoids 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:

  1. While filter_mode = auto, each day at filter_start_time set filter_pump on and record the run start.
  2. When elapsed runtime reaches filter_run_time, set filter_pump off until the next day's start.
  3. 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.
  4. Switching to on or off cancels the schedule for as long as that mode is active; returning to auto re-evaluates the current window immediately.
  5. 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 setting heat_mode = sleep first)

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
  • JSON fields: service, ts (UTC ISO-8601), status, detail, pid
  • status: ok degraded error (Health Daemon maps these to green / orange / red on the Control LED)
  • ok while loop running, Redis/GPIO usable, and temperature reading is fresh with setup_state == ready and a valid tub_avg_f
  • degraded if active error_* phase; stale or missing temps; setup_state is need_hot_in, need_water, or need_reinstall; or no tub_avg_f on an otherwise fresh reading
  • error on GPIO failure or Redis publish failure after reconnect

Health Daemon staleness for Control uses the ts field: >10 s → orange LED, >60 s → red (see health/overview.md).

Operational requirements

systemd (start on boot)

  • Unit: hot-tub-control.service (see deploy/control/hot-tub-control.service)
  • Type=notify, WatchdogSec=10, NotifyAccess=main
  • 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

Loop design details: Main loop above.

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,
  "temps_stale_s": 15,
  "loop_interval_s": 1.0,
  "heartbeat_s": 5.0
}

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_f in the UI vs keep it config-only.
  • Whether Web can edit filter_start_time / filter_run_time or those stay config-file-only for v1.
  • Timezone source if the Pi clock is wrong (local vs explicit IANA zone + NTP requirement).