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)
- 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.
- 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).
-
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-tempsflag). Clearing the probe map (or a “reinstall sensors” command) is how you force a fresh teach-in. -
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
- Load config (interval default 5 seconds, Redis localhost) and persisted probe map (hot-in id + tub ids).
- Open file logger at
/var/log/hot-tub/temp.log. - Loop forever:
- Discover visible
28-*ids; run identity / setup rules (learn orneed_reinstall). - Read temperatures; classify using the map (
tubvshot_in). - Build a reading payload (
ts, per-probe °F,tub_avg_ffrom 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.readingwith the same JSON. -
Setup: update
temps:setup/temps.setupwhen learn state changes. -
Health:
SET health:temps+PUBLISH health.temps(ok/degraded/error). Freshhealth:temps(or a non-staletemps:latest) is what the Health Daemon uses for the temperature status LED.
- Discover visible
- 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 },
"roles": { "hot_in": 112.0, "tub1": 101.2, "tub2": 101.0, "tub3": null, "tub4": 100.9 },
"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(orredis-server.service); daemon should still retry Redis if it starts first. -
ExecStartruns the module as a dedicated user (or root only if required for/sys/ log dir — prefer ahot-tubuser 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
loggingmodule with aRotatingFileHandleror a plainFileHandlerand 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.
-
redisPython package (or equivalent). - 1-Wire overlay enabled (
dtoverlay=w1-gpioin boot config); nogpiozerorequired 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