Temperature Daemon

Polls DS18B20 probes on a fixed schedule, logs every sample, and publishes temperature (and health) to a local Redis instance. This is the only process that should touch the 1-Wire bus.

See software-architecture.md for how this fits with Control, Web, and Health. Probe wiring lives in hardware-setup.md. CLI diagnostics without Redis: ../read_temps.py.

Pi installation: INSTALL.md

Diagnostic simulator (no hardware): ../temp_sim/README.md — manual python -m temp_sim; stop hot-tub-temps first.

Responsibility

Does Does not
Read /sys/bus/w1/devices/28-* (or configured path) Drive heat/filter relays
Learn / persist which ROM ids are tub vs hot-in Interpret modes / target temp
Average tub probes only for tub_avg_f Serve HTTP or drive status LEDs
Publish readings + health (+ setup errors) to Redis Own GPIO of any kind
Log useful operational output  

Probe bundles and identity setup

Probes are wired as two pluggable bundles (e.g. separate M12 drops), not five independent mystery cables:

Bundle Count Role
Water temperature exactly 4 DS18B20s Tub probes → averaged as tub_avg_f
Hot-water input exactly 1 DS18B20 Inlet / heater-side → hot_in_f

Complete system = 5 probes (4 tub + 1 hot-in). ROM ids are not printed on the probes in a useful way, so the daemon learns identity during a guided install: the installer connects one bundle at a time, and the daemon records whatever appears on the bus.

Learned ids are persisted on disk (e.g. /etc/hot-tub/temp-probes.json or alongside the service config) so a normal boot with both bundles connected can classify every sensor.

Learning rules (sound with the caveats below)

Visible on bus Stored state Action
Exactly 1 probe Hot-in id not yet stored Learn hot-in — persist that ROM id as hot_in
Exactly 4 probes Water ids not yet stored; and this looks like the water bundle alone (see caveat) Learn water — persist all four ROM ids as tub
Hot-in already known; exactly 4 unknown ids also present (5 total) Water not stored Learn water = the four ids that are not hot-in
Water already known (4 ids); exactly one unknown id present (5 total) Hot-in unknown Learn hot-in = that unknown id
Both sets stored; bus shows all 5 expected ids Complete Normal operation — classify by stored map
All 5 probes present but neither hot-in nor water ids stored Empty Error — cannot tell which id is hot-in. Publish to Redis (key + pub/sub) that a guided sensor reinstall is required
Both sets stored; an unknown extra id appears Complete Log warning / degraded; do not silently reassign roles
Count is 2 or 3 (or 4 while hot-in is already stored and water not yet learned from a clean 4-alone step) Incomplete Wait / need_water or need_hot_in — do not guess

Why this works

  • Bundles are connected separately, so count alone disambiguates during setup: 1 → hot-in; 4 → water set. Fixed sizes remove the old 2–4 ambiguity.
  • Once either side is stored, plugging in the other bundle to reach 5 lets the daemon derive the missing side by set difference.
  • All 5 present, nothing stored → mathematically ambiguous → must not guess; signal reinstall / enter setup.

Caveats (follow these so the rules stay valid)

  1. Setup order (recommended): disconnect everything → connect hot-in bundle alone (1 probe) → wait for learn → connect water bundle alone (4 probes) or connect water while hot-in is already known (bus shows 5) → confirm both persisted → leave both connected for production.
  2. Do not treat “4 probes ⇒ water” if hot-in might already be on the bus with only part of the water bundle. Example: hot-in + 3 tub = 4 devices → naive rule would mis-label hot-in as a tub probe. Only apply bare “exactly 4 = water” when hot-in is not stored and setup mode is active; once hot-in is known, learn water only when there are exactly four non-hot-in ids (typically 5 on the bus).
  3. Learning vs steady state. Do not re-run “1 probe ⇒ hot-in” on every boot just because someone unplugged the water bundle for service — that would overwrite a good map with a tub probe. Auto-learn only when the relevant ids are missing, or when an explicit setup / learn mode is enabled (Web UI or hot-tub-temps flag). Clearing the probe map (or a “reinstall sensors” command) is how you force a fresh teach-in.
  4. Partial water bundle: fewer than 4 tub probes visible after learning is degraded / fault for Control — not a cue to rewrite the map. Fix wiring; do not teach-in a 3-probe water set.

Redis signaling for setup / identity errors

When identity is incomplete or ambiguous, still publish whatever readings you can, but mark health / a dedicated setup field so Control and Web can react:

Kind Name Purpose
Key temps:setup (or fields inside temps:latest) state: need_hot_in | need_water | ready | need_reinstall
Channel temps.setup Pub/sub when setup state changes (installer UI / Web)
Health health:temps degraded while learning; error on need_reinstall or unusable bus

Example error payload when all probes are up but nothing was learned:

{
  "ts": "2026-10-19T20:00:00Z",
  "state": "need_reinstall",
  "detail": "Multiple probes present but no stored hot_in/tub ids; connect bundles one at a time to re-teach",
  "visible_ids": ["28-aaa", "28-bbb", "28-ccc", "28-ddd", "28-eee"]
}

Control should not use an undifferentiated average of all five as tub temperature while need_reinstall is set.

Planned layout

src/temp/
  README.md          ← this file
  __init__.py
  __main__.py        # python -m temp  /  systemd ExecStart
  daemon.py          # main loop: poll → identify → build payload → log → Redis
  reader.py          # find devices, read °C/°F, CRC / 85°C sentinel handling
  identity.py        # learn/persist hot_in vs tub ids; setup state machine
  config.py          # load interval, Redis URL, paths; load/save probe map
  redis_out.py       # SET/PUBLISH temps + health + setup errors
  logging_setup.py   # file logger → /var/log/hot-tub/temp.log
  config.example.json

Reuse read helpers from src/read_temps.py where practical (or move shared bits into a small common package later). Keep this package importable as temp when src/ is on PYTHONPATH.

Behavior

  1. Load config (interval default 5 seconds, Redis localhost) and persisted probe map (hot-in id + tub ids).
  2. Open file logger at /var/log/hot-tub/temp.log.
  3. Loop forever:
    • Discover visible 28-* ids; run identity / setup rules (learn or need_reinstall).
    • Read temperatures; classify using the map (tub vs hot_in).
    • Build a reading payload (ts, per-probe °F, tub_avg_f from tub probes only, hot_in_f, ok/fail counts, spread_f, setup state, …).
    • Log the sample (and any probe / setup failures) to the log file.
    • Redis: SET temps:latest + PUBLISH temps.reading with the same JSON.
    • Setup: update temps:setup / temps.setup when learn state changes.
    • Health: SET health:temps + PUBLISH health.temps (ok / degraded / error). Fresh health:temps (or a non-stale temps:latest) is what the Health Daemon uses for the temperature status LED.
  4. On SIGTERM/SIGINT: log shutdown and exit cleanly (systemd restart policy handles respawn).

Redis keys / channels

Kind Name Purpose
Key temps:latest Current reading for Control / Web / ad-hoc GET
Channel temps.reading Pub/sub event on every successful poll cycle
Key health:temps Heartbeat for Health Daemon (+ Web diagnostics)
Channel health.temps Optional immediate wake for Health Daemon

Suggested reading shape (aligned with the architecture doc):

{
  "ts": "2026-10-19T20:00:00Z",
  "probes": { "28-...": 101.2, "28-...": null },
  "tub_avg_f": 101.0,
  "hot_in_f": null,
  "outdoor_f": null,
  "ok_count": 3,
  "fail_count": 1,
  "spread_f": 1.4
}

Operational requirements

systemd (start on boot)

  • Unit name suggestion: hot-tub-temps.service.
  • WantedBy=multi-user.target, Restart=on-failure.
  • After=redis.service (or redis-server.service); daemon should still retry Redis if it starts first.
  • ExecStart runs the module as a dedicated user (or root only if required for /sys / log dir — prefer a hot-tub user with group access).
  • Ensure /var/log/hot-tub/ exists with write permission for that user (tmpfiles.d or install script).

Logging

  • All useful output goes to /var/log/hot-tub/temp.log (samples, probe CRC failures, Redis reconnects, startup/shutdown). Avoid relying on journal-only prints for day-to-day ops; journal may still get a short “started / fatal” line from systemd.
  • Use the stdlib logging module with a RotatingFileHandler or a plain FileHandler and rely on system logrotate (preferred for consistency with other hot-tub services).

logrotate

Example /etc/logrotate.d/hot-tub-temp:

/var/log/hot-tub/temp.log {
    weekly
    rotate 8
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
}

copytruncate avoids needing the daemon to reopen the file after rotate. Adjust cadence if 5 s polling makes the log grow faster than expected.

Poll interval

  • Default 5 seconds (config override allowed).
  • One DS18B20 conversion is ~750 ms; multiple probes on one bus are read sequentially via sysfs — budget for that inside the interval, don’t stack overlapping reads.

Implementation plan

Phase Work
1 reader.py + thin CLI/__main__ dry-run: print one sample JSON (no Redis), reuse read_temps logic
2 identity.py + persisted probe map: teach-in with one bundle at a time; unit-test 1→hot-in, 4→tub, 5+one-side-known→derive, 5+empty→need_reinstall, and “4 mixed probes must not mis-learn”
3 logging_setup.py → write samples to /var/log/hot-tub/temp.log; create log dir in docs/install notes
4 redis_out.py: SET + PUBLISH reading, health, and setup/need_reinstall; reconnect with backoff
5 daemon.py 5 s loop; config.example.json; wire identity → payload (tub_avg_f vs hot_in_f)
6 systemd unit + logrotate snippet under deploy/ (or src/temp/deploy/); enable on Pi
7 Smoke test: guided learn both bundles; GET temps:latest / temps:setup; kill map and plug all in → confirm need_reinstall event

Config sketch

Service settings (config.example.json):

{
  "interval_s": 5,
  "devices_path": "/sys/bus/w1/devices",
  "redis_url": "redis://127.0.0.1:6379/0",
  "log_path": "/var/log/hot-tub/temp.log",
  "probe_map_path": "/etc/hot-tub/temp-probes.json",
  "setup_mode": false
}

Learned map (auto-written; do not hand-edit unless recovering):

{
  "hot_in": "28-zzzzzzzzzzzz",
  "tub": [
    "28-aaaaaaaaaaaa",
    "28-bbbbbbbbbbbb",
    "28-cccccccccccc",
    "28-dddddddddddd"
  ]
}

setup_mode: true (or empty/partial map) enables aggressive teach-in from bus counts; with a complete map and setup_mode: false, the daemon only classifies and reports missing/unknown probes.

Dependencies

  • Python 3.11+ (or whatever the Pi image ships).
  • Redis server on localhost.
  • redis Python package (or equivalent).
  • 1-Wire overlay enabled (dtoverlay=w1-gpio in boot config); no gpiozero required for this daemon.

Manual checks (on the Pi)

# One-shot style while developing
cd /path/to/hot-tub/src && PYTHONPATH=. python3 -m temp

# Redis
redis-cli GET temps:latest
redis-cli GET health:temps
redis-cli SUBSCRIBE temps.reading

# Logs
tail -f /var/log/hot-tub/temp.log