projects/hot-tub/deploy/temp/hot-tub-temp.logrotate
... ...
@@ -0,0 +1,9 @@
1
+/var/log/hot-tub/temp.log {
2
+ weekly
3
+ rotate 8
4
+ compress
5
+ delaycompress
6
+ missingok
7
+ notifempty
8
+ copytruncate
9
+}
projects/hot-tub/deploy/temp/hot-tub-temp.tmpfiles.conf
... ...
@@ -0,0 +1,3 @@
1
+d /var/log/hot-tub 0755 hot-tub hot-tub -
2
+d /etc/hot-tub 0755 root root -
3
+d /var/lib/hot-tub 0755 hot-tub hot-tub -
projects/hot-tub/deploy/temp/hot-tub-temps.service
... ...
@@ -0,0 +1,17 @@
1
+[Unit]
2
+Description=Hot Tub Temperature Daemon
3
+After=network-online.target redis-server.service
4
+Wants=network-online.target
5
+Documentation=file://INSTALL.md
6
+
7
+[Service]
8
+Type=simple
9
+User=hot-tub
10
+Group=hot-tub
11
+Environment=PYTHONPATH=__INSTALL_ROOT__/src
12
+ExecStart=/usr/bin/python3 -m temp --config /etc/hot-tub/temp.json
13
+Restart=on-failure
14
+RestartSec=5
15
+
16
+[Install]
17
+WantedBy=multi-user.target
projects/hot-tub/deploy/temp/install.sh
... ...
@@ -0,0 +1,66 @@
1
+#!/usr/bin/env bash
2
+# Install hot-tub temperature daemon on Raspberry Pi OS (run with sudo).
3
+set -euo pipefail
4
+
5
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
7
+INSTALL_ROOT="${INSTALL_ROOT:-${REPO_ROOT}}"
8
+
9
+if [[ "$(id -u)" -ne 0 ]]; then
10
+ echo "Run as root: sudo $0" >&2
11
+ exit 1
12
+fi
13
+
14
+echo "==> Installing packages (redis-server, python3-redis)..."
15
+export DEBIAN_FRONTEND=noninteractive
16
+apt-get update -qq
17
+apt-get install -y -qq redis-server python3-redis
18
+
19
+echo "==> Creating hot-tub system user..."
20
+if ! id hot-tub &>/dev/null; then
21
+ useradd --system --home /var/lib/hot-tub --shell /usr/sbin/nologin hot-tub
22
+fi
23
+
24
+echo "==> Installing tmpfiles, log directory, config paths..."
25
+install -m 0644 "${SCRIPT_DIR}/hot-tub-temp.tmpfiles.conf" /etc/tmpfiles.d/hot-tub-temp.conf
26
+systemd-tmpfiles --create /etc/tmpfiles.d/hot-tub-temp.conf
27
+mkdir -p /var/log/hot-tub /etc/hot-tub /var/lib/hot-tub
28
+chown hot-tub:hot-tub /var/log/hot-tub /var/lib/hot-tub
29
+
30
+if [[ ! -f /etc/hot-tub/temp.json ]]; then
31
+ install -m 0644 "${INSTALL_ROOT}/src/temp/config.example.json" /etc/hot-tub/temp.json
32
+ echo " Created /etc/hot-tub/temp.json from example"
33
+else
34
+ echo " Keeping existing /etc/hot-tub/temp.json"
35
+fi
36
+
37
+chown -R hot-tub:hot-tub "${INSTALL_ROOT}/src/temp" 2>/dev/null || true
38
+
39
+echo "==> Installing systemd unit..."
40
+sed "s|__INSTALL_ROOT__|${INSTALL_ROOT}|g" "${SCRIPT_DIR}/hot-tub-temps.service" \
41
+ > /etc/systemd/system/hot-tub-temps.service
42
+
43
+echo "==> Installing logrotate..."
44
+install -m 0644 "${SCRIPT_DIR}/hot-tub-temp.logrotate" /etc/logrotate.d/hot-tub-temp
45
+
46
+echo "==> Enabling redis and hot-tub-temps..."
47
+systemctl enable redis-server 2>/dev/null || systemctl enable redis 2>/dev/null || true
48
+systemctl start redis-server 2>/dev/null || systemctl start redis 2>/dev/null || true
49
+systemctl daemon-reload
50
+systemctl enable hot-tub-temps.service
51
+systemctl restart hot-tub-temps.service || systemctl start hot-tub-temps.service
52
+
53
+echo ""
54
+echo "Installation complete."
55
+echo " Repo / code root: ${INSTALL_ROOT}"
56
+echo " Config: /etc/hot-tub/temp.json"
57
+echo " Probe map: /etc/hot-tub/temp-probes.json (created during teach-in)"
58
+echo " Log: /var/log/hot-tub/temp.log"
59
+echo ""
60
+echo "1-Wire: ensure boot config contains: dtoverlay=w1-gpio"
61
+echo " (Raspberry Pi OS: /boot/firmware/config.txt or /boot/config.txt)"
62
+echo " Reboot after enabling, then verify: ls /sys/bus/w1/devices/28-*"
63
+echo ""
64
+echo "Status: systemctl status hot-tub-temps"
65
+echo "Logs: tail -f /var/log/hot-tub/temp.log"
66
+echo "Redis: redis-cli GET temps:latest"
projects/hot-tub/src/temp/INSTALL.md
... ...
@@ -0,0 +1,124 @@
1
+# Temperature Daemon — Installation (Raspberry Pi)
2
+
3
+Install the temperature daemon on the Pi 3 controller. Design details: [README.md](README.md).
4
+
5
+## Prerequisites
6
+
7
+- Raspberry Pi OS with Python 3
8
+- DS18B20 probes wired to 1-Wire (GPIO4 default), 4.7 kΩ pull-up to 3.3 V
9
+- Two probe bundles: **1** hot-water input, **4** tub water (see README teach-in)
10
+- Network not required for the daemon itself; **Redis** runs locally
11
+
12
+## 1. Enable 1-Wire
13
+
14
+Add to boot config (Pi OS often uses `/boot/firmware/config.txt`; older images use `/boot/config.txt`):
15
+
16
+```text
17
+dtoverlay=w1-gpio
18
+```
19
+
20
+Reboot, then verify:
21
+
22
+```bash
23
+ls /sys/bus/w1/devices/28-*
24
+python3 /path/to/hot-tub/src/read_temps.py
25
+```
26
+
27
+## 2. Install service
28
+
29
+From the hot-tub repository on the Pi:
30
+
31
+```bash
32
+cd /path/to/hot-tub
33
+sudo deploy/temp/install.sh
34
+```
35
+
36
+Optional: install from a fixed path:
37
+
38
+```bash
39
+sudo INSTALL_ROOT=/opt/hot-tub deploy/temp/install.sh
40
+```
41
+
42
+The script installs `redis-server`, `python3-redis`, user `hot-tub`, config under `/etc/hot-tub/`, systemd unit `hot-tub-temps`, and logrotate for `/var/log/hot-tub/temp.log`.
43
+
44
+## 3. Guided probe teach-in
45
+
46
+ROM ids are learned once and stored in `/etc/hot-tub/temp-probes.json`.
47
+
48
+**Recommended order**
49
+
50
+1. Disconnect both M12 bundles.
51
+2. Connect **hot-in bundle only** (1 probe).
52
+3. Run teach-in (either stop the service and run once, or set `"setup_mode": true` in `/etc/hot-tub/temp.json` and restart):
53
+
54
+ ```bash
55
+ sudo systemctl stop hot-tub-temps
56
+ cd /path/to/hot-tub/src
57
+ sudo PYTHONPATH=. python3 -m temp --config /etc/hot-tub/temp.json --setup --once \
58
+ --devices-path /sys/bus/w1/devices
59
+ ```
60
+
61
+ Confirm log or Redis shows hot-in learned (`setup_state` moves toward `need_water`).
62
+
63
+4. Connect **water bundle only** (4 probes), or connect both if hot-in id is already stored.
64
+
65
+ ```bash
66
+ sudo PYTHONPATH=. python3 -m temp --config /etc/hot-tub/temp.json --setup --once
67
+ ```
68
+
69
+5. Set `"setup_mode": false` in `/etc/hot-tub/temp.json` when `setup_state` is `ready`.
70
+
71
+6. Start the service:
72
+
73
+ ```bash
74
+ sudo systemctl start hot-tub-temps
75
+ ```
76
+
77
+**Failure:** If all **5** probes are connected but the map file is empty, the daemon publishes `need_reinstall`. Clear the map and repeat teach-in one bundle at a time:
78
+
79
+```bash
80
+sudo rm /etc/hot-tub/temp-probes.json
81
+```
82
+
83
+## 4. Verify
84
+
85
+```bash
86
+systemctl status hot-tub-temps
87
+tail -f /var/log/hot-tub/temp.log
88
+redis-cli GET temps:latest
89
+redis-cli GET temps:setup
90
+redis-cli GET health:temps
91
+redis-cli SUBSCRIBE temps.reading
92
+```
93
+
94
+Expect updates about every **5 seconds** (config `interval_s`).
95
+
96
+## 5. Development (off the Pi)
97
+
98
+```bash
99
+cd src
100
+PYTHONPATH=. python3 -m temp --dry-run --setup --once --devices-path /path/to/fake/w1
101
+python3 -m unittest temp.tests.test_identity temp.tests.test_reader -v
102
+```
103
+
104
+Dry-run writes to `/tmp/hot-tub-temp.log` when the configured log path is under `/var`.
105
+
106
+## File locations
107
+
108
+| Path | Purpose |
109
+|---|---|
110
+| `/etc/hot-tub/temp.json` | Daemon config |
111
+| `/etc/hot-tub/temp-probes.json` | Learned probe ids |
112
+| `/var/log/hot-tub/temp.log` | Application log |
113
+| `/etc/systemd/system/hot-tub-temps.service` | systemd unit |
114
+| `/etc/logrotate.d/hot-tub-temp` | Log rotation |
115
+
116
+## Uninstall
117
+
118
+```bash
119
+sudo systemctl disable --now hot-tub-temps
120
+sudo rm /etc/systemd/system/hot-tub-temps.service
121
+sudo systemctl daemon-reload
122
+```
123
+
124
+Config and probe map under `/etc/hot-tub/` are left in place unless you remove them manually.
projects/hot-tub/src/temp/README.md
... ...
@@ -4,6 +4,10 @@ Polls DS18B20 probes on a fixed schedule, logs every sample, and publishes tempe
4 4
5 5
See [software-architecture.md](../../software-architecture.md) for how this fits with Control, Web, and Health. Probe wiring lives in [hardware-setup.md](../../hardware-setup.md). CLI diagnostics without Redis: [`../read_temps.py`](../read_temps.py).
6 6
7
+**Pi installation:** [INSTALL.md](INSTALL.md)
8
+
9
+**Diagnostic simulator (no hardware):** [../temp_sim/README.md](../temp_sim/README.md) — manual `python -m temp_sim`; stop `hot-tub-temps` first.
10
+
7 11
## Responsibility
8 12
9 13
| Does | Does not |
projects/hot-tub/src/temp/__init__.py
... ...
@@ -0,0 +1,3 @@
1
+"""Hot tub temperature daemon — DS18B20 polling, probe identity, Redis publish."""
2
+
3
+__version__ = "0.1.0"
projects/hot-tub/src/temp/__main__.py
... ...
@@ -0,0 +1,94 @@
1
+"""CLI entry: python -m temp"""
2
+
3
+from __future__ import annotations
4
+
5
+import argparse
6
+import json
7
+import sys
8
+from pathlib import Path
9
+
10
+from .config import load_config
11
+from .daemon import run_loop, run_once
12
+from .logging_setup import setup_logging
13
+
14
+
15
+def _path_writable(dir_path: Path) -> bool:
16
+ try:
17
+ dir_path.mkdir(parents=True, exist_ok=True)
18
+ test = dir_path / ".write_test"
19
+ test.write_text("")
20
+ test.unlink()
21
+ return True
22
+ except OSError:
23
+ return False
24
+
25
+
26
+def main() -> None:
27
+ parser = argparse.ArgumentParser(description="Hot tub temperature daemon")
28
+ parser.add_argument(
29
+ "--config",
30
+ type=Path,
31
+ default=Path("/etc/hot-tub/temp.json"),
32
+ help="Path to JSON config (default: /etc/hot-tub/temp.json)",
33
+ )
34
+ parser.add_argument(
35
+ "--once",
36
+ action="store_true",
37
+ help="Read once, publish/log once, exit",
38
+ )
39
+ parser.add_argument(
40
+ "--dry-run",
41
+ action="store_true",
42
+ help="No Redis; log/print only",
43
+ )
44
+ parser.add_argument(
45
+ "--setup",
46
+ action="store_true",
47
+ help="Enable setup_mode for this run (probe teach-in)",
48
+ )
49
+ parser.add_argument(
50
+ "--devices-path",
51
+ type=Path,
52
+ default=None,
53
+ help="Override 1-Wire devices path from config",
54
+ )
55
+ parser.add_argument(
56
+ "-v",
57
+ "--verbose",
58
+ action="store_true",
59
+ help="Debug logging",
60
+ )
61
+ args = parser.parse_args()
62
+
63
+ cfg_path = args.config if args.config.exists() else None
64
+ if cfg_path is None and not args.config.exists():
65
+ # Fall back to example next to package for dev
66
+ dev_example = Path(__file__).resolve().parent / "config.example.json"
67
+ cfg_path = dev_example if dev_example.exists() else None
68
+
69
+ cfg = load_config(cfg_path)
70
+ if args.setup:
71
+ cfg.setup_mode = True
72
+ if args.devices_path is not None:
73
+ cfg.devices_path = args.devices_path
74
+
75
+ if args.dry_run or not _path_writable(cfg.log_path.parent):
76
+ cfg.log_path = Path("/tmp/hot-tub-temp.log")
77
+ if args.dry_run or not _path_writable(cfg.probe_map_path.parent):
78
+ cfg.probe_map_path = Path("/tmp/hot-tub-temp-probes.json")
79
+
80
+ setup_logging(cfg.log_path, verbose=args.verbose)
81
+
82
+ if args.once:
83
+ reading = run_once(cfg, dry_run=args.dry_run)
84
+ print(json.dumps(reading, indent=2))
85
+ return
86
+
87
+ run_loop(cfg, dry_run=args.dry_run)
88
+
89
+
90
+if __name__ == "__main__":
91
+ try:
92
+ main()
93
+ except KeyboardInterrupt:
94
+ sys.exit(0)
projects/hot-tub/src/temp/config.example.json
... ...
@@ -0,0 +1,8 @@
1
+{
2
+ "interval_s": 5,
3
+ "devices_path": "/sys/bus/w1/devices",
4
+ "redis_url": "redis://127.0.0.1:6379/0",
5
+ "log_path": "/var/log/hot-tub/temp.log",
6
+ "probe_map_path": "/etc/hot-tub/temp-probes.json",
7
+ "setup_mode": false
8
+}
projects/hot-tub/src/temp/config.py
... ...
@@ -0,0 +1,39 @@
1
+"""Load temperature daemon configuration."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+from dataclasses import dataclass
7
+from pathlib import Path
8
+
9
+from .reader import DEFAULT_DEVICES_PATH
10
+
11
+
12
+@dataclass
13
+class TempConfig:
14
+ interval_s: float = 5.0
15
+ devices_path: Path = DEFAULT_DEVICES_PATH
16
+ redis_url: str = "redis://127.0.0.1:6379/0"
17
+ log_path: Path = Path("/var/log/hot-tub/temp.log")
18
+ probe_map_path: Path = Path("/etc/hot-tub/temp-probes.json")
19
+ setup_mode: bool = False
20
+
21
+
22
+def load_config(path: Path | None) -> TempConfig:
23
+ cfg = TempConfig()
24
+ if path is None or not path.exists():
25
+ return cfg
26
+ data = json.loads(path.read_text())
27
+ if "interval_s" in data:
28
+ cfg.interval_s = float(data["interval_s"])
29
+ if "devices_path" in data:
30
+ cfg.devices_path = Path(data["devices_path"])
31
+ if "redis_url" in data:
32
+ cfg.redis_url = str(data["redis_url"])
33
+ if "log_path" in data:
34
+ cfg.log_path = Path(data["log_path"])
35
+ if "probe_map_path" in data:
36
+ cfg.probe_map_path = Path(data["probe_map_path"])
37
+ if "setup_mode" in data:
38
+ cfg.setup_mode = bool(data["setup_mode"])
39
+ return cfg
projects/hot-tub/src/temp/daemon.py
... ...
@@ -0,0 +1,159 @@
1
+"""Temperature daemon main loop."""
2
+
3
+from __future__ import annotations
4
+
5
+import logging
6
+import os
7
+import signal
8
+import time
9
+from datetime import datetime, timezone
10
+from typing import Any
11
+
12
+from .config import TempConfig
13
+from .identity import (
14
+ TUB_COUNT,
15
+ classify_readings,
16
+ evaluate_identity,
17
+ load_probe_map,
18
+ save_probe_map,
19
+)
20
+from .reader import read_all_f
21
+from .redis_out import RedisPublisher
22
+
23
+logger = logging.getLogger("hot_tub.temp")
24
+
25
+_stop = False
26
+
27
+
28
+def _handle_signal(signum: int, _frame: object) -> None:
29
+ global _stop
30
+ logger.info("Received signal %s, shutting down", signum)
31
+ _stop = True
32
+
33
+
34
+def _utc_now() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
36
+
37
+
38
+def _health_status(setup_state: str, ok_count: int, visible_count: int) -> str:
39
+ if setup_state == "need_reinstall":
40
+ return "error"
41
+ if setup_state in ("need_hot_in", "need_water"):
42
+ return "degraded"
43
+ if visible_count == 0:
44
+ return "error"
45
+ if ok_count < TUB_COUNT:
46
+ return "degraded"
47
+ return "ok"
48
+
49
+
50
+def build_sample(
51
+ cfg: TempConfig,
52
+) -> tuple[dict[str, Any], Any, dict[str, Any], dict[str, Any]]:
53
+ from pathlib import Path
54
+
55
+ path = cfg.probe_map_path
56
+ setup_mode = cfg.setup_mode
57
+ probes_f = read_all_f(cfg.devices_path)
58
+ visible = list(probes_f.keys())
59
+ pmap = load_probe_map(path)
60
+ identity = evaluate_identity(visible, pmap, setup_mode=setup_mode)
61
+ if identity.map_changed:
62
+ save_probe_map(path, identity.probe_map)
63
+ logger.info("Probe map updated: %s", identity.detail)
64
+
65
+ labeled, tub_avg, hot_in_f, ok_count, fail_count, spread = classify_readings(
66
+ probes_f, identity.probe_map
67
+ )
68
+
69
+ reading: dict[str, Any] = {
70
+ "ts": _utc_now(),
71
+ "setup_state": identity.setup_state,
72
+ "probes": labeled,
73
+ "tub_avg_f": tub_avg,
74
+ "hot_in_f": hot_in_f,
75
+ "outdoor_f": None,
76
+ "ok_count": ok_count,
77
+ "fail_count": fail_count,
78
+ "spread_f": spread,
79
+ }
80
+
81
+ setup_payload = {
82
+ "ts": reading["ts"],
83
+ "state": identity.setup_state,
84
+ "detail": identity.detail,
85
+ "visible_ids": identity.visible_ids,
86
+ }
87
+
88
+ health_payload = {
89
+ "service": "temps",
90
+ "ts": reading["ts"],
91
+ "status": _health_status(identity.setup_state, ok_count, len(visible)),
92
+ "detail": identity.detail,
93
+ "pid": os.getpid(),
94
+ }
95
+
96
+ return reading, identity, setup_payload, health_payload
97
+
98
+
99
+def run_once(
100
+ cfg: TempConfig,
101
+ *,
102
+ dry_run: bool = False,
103
+ redis_pub: RedisPublisher | None = None,
104
+) -> dict[str, Any]:
105
+ reading, identity, setup_payload, health_payload = build_sample(cfg)
106
+ logger.info(
107
+ "sample setup=%s tub_avg=%s hot_in=%s ok=%s/%s visible=%s",
108
+ identity.setup_state,
109
+ reading["tub_avg_f"],
110
+ reading["hot_in_f"],
111
+ reading["ok_count"],
112
+ TUB_COUNT,
113
+ len(identity.visible_ids),
114
+ )
115
+ if dry_run:
116
+ return reading
117
+ pub = redis_pub or RedisPublisher(cfg.redis_url)
118
+ try:
119
+ pub.publish_reading(reading)
120
+ pub.publish_setup(setup_payload)
121
+ pub.publish_health(health_payload)
122
+ finally:
123
+ if redis_pub is None:
124
+ pub.close()
125
+ return reading
126
+
127
+
128
+def run_loop(cfg: TempConfig, *, dry_run: bool = False) -> None:
129
+ global _stop
130
+ _stop = False
131
+ signal.signal(signal.SIGTERM, _handle_signal)
132
+ signal.signal(signal.SIGINT, _handle_signal)
133
+
134
+ logger.info(
135
+ "Temperature daemon starting (dry_run=%s interval=%ss setup_mode=%s)",
136
+ dry_run,
137
+ cfg.interval_s,
138
+ cfg.setup_mode,
139
+ )
140
+
141
+ redis_pub: RedisPublisher | None = None
142
+ if not dry_run:
143
+ redis_pub = RedisPublisher(cfg.redis_url)
144
+
145
+ try:
146
+ while not _stop:
147
+ loop_start = time.monotonic()
148
+ try:
149
+ run_once(cfg, dry_run=dry_run, redis_pub=redis_pub)
150
+ except Exception:
151
+ logger.exception("Error in poll cycle")
152
+ elapsed = time.monotonic() - loop_start
153
+ sleep_s = max(0.0, cfg.interval_s - elapsed)
154
+ if sleep_s > 0 and not _stop:
155
+ time.sleep(sleep_s)
156
+ finally:
157
+ if redis_pub is not None:
158
+ redis_pub.close()
159
+ logger.info("Temperature daemon stopped")
projects/hot-tub/src/temp/identity.py
... ...
@@ -0,0 +1,181 @@
1
+"""Probe identity: learn hot-in vs tub ROM ids, setup state machine."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+from dataclasses import dataclass, field
7
+from pathlib import Path
8
+from typing import Literal
9
+
10
+SetupState = Literal["need_hot_in", "need_water", "ready", "need_reinstall"]
11
+
12
+TUB_COUNT = 4
13
+
14
+
15
+@dataclass
16
+class ProbeMap:
17
+ hot_in: str | None = None
18
+ tub: list[str] = field(default_factory=list)
19
+
20
+ def is_complete(self) -> bool:
21
+ return self.hot_in is not None and len(self.tub) == TUB_COUNT
22
+
23
+ def known_ids(self) -> set[str]:
24
+ ids: set[str] = set()
25
+ if self.hot_in:
26
+ ids.add(self.hot_in)
27
+ ids.update(self.tub)
28
+ return ids
29
+
30
+
31
+def load_probe_map(path: Path) -> ProbeMap:
32
+ if not path.exists():
33
+ return ProbeMap()
34
+ try:
35
+ data = json.loads(path.read_text())
36
+ except (OSError, json.JSONDecodeError):
37
+ return ProbeMap()
38
+ hot_in = data.get("hot_in")
39
+ tub = data.get("tub") or []
40
+ if not isinstance(hot_in, str) and hot_in is not None:
41
+ hot_in = None
42
+ if not isinstance(tub, list):
43
+ tub = []
44
+ tub = [x for x in tub if isinstance(x, str)]
45
+ return ProbeMap(hot_in=hot_in, tub=tub)
46
+
47
+
48
+def save_probe_map(path: Path, probe_map: ProbeMap) -> None:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ payload = {"hot_in": probe_map.hot_in, "tub": sorted(probe_map.tub)}
51
+ tmp = path.with_suffix(path.suffix + ".tmp")
52
+ tmp.write_text(json.dumps(payload, indent=2) + "\n")
53
+ tmp.replace(path)
54
+
55
+
56
+@dataclass
57
+class IdentityResult:
58
+ setup_state: SetupState
59
+ probe_map: ProbeMap
60
+ map_changed: bool
61
+ detail: str
62
+ visible_ids: list[str]
63
+
64
+
65
+def _try_learn(
66
+ visible: list[str],
67
+ pmap: ProbeMap,
68
+ *,
69
+ setup_mode: bool,
70
+) -> tuple[ProbeMap, bool, str]:
71
+ """Return updated map, whether it changed, and a detail string."""
72
+ n = len(visible)
73
+ vis = set(visible)
74
+ changed = False
75
+ detail = ""
76
+
77
+ if pmap.is_complete():
78
+ return pmap, False, detail
79
+
80
+ learning = setup_mode or not pmap.is_complete()
81
+ if not learning:
82
+ return pmap, False, detail
83
+
84
+ hot_known = pmap.hot_in is not None
85
+ tub_complete = len(pmap.tub) == TUB_COUNT
86
+
87
+ if not hot_known and n == 1:
88
+ pmap = ProbeMap(hot_in=visible[0], tub=list(pmap.tub))
89
+ return pmap, True, f"Learned hot_in={visible[0]}"
90
+
91
+ if not tub_complete and not hot_known and n == TUB_COUNT and setup_mode:
92
+ pmap = ProbeMap(hot_in=pmap.hot_in, tub=sorted(visible))
93
+ return pmap, True, f"Learned {TUB_COUNT} tub probes"
94
+
95
+ if hot_known and not tub_complete and n == TUB_COUNT + 1:
96
+ tub_ids = sorted(vis - {pmap.hot_in})
97
+ if len(tub_ids) == TUB_COUNT:
98
+ pmap = ProbeMap(hot_in=pmap.hot_in, tub=tub_ids)
99
+ return pmap, True, "Derived tub ids (hot_in already known)"
100
+
101
+ if tub_complete and not hot_known and n == TUB_COUNT + 1:
102
+ unknown = vis - set(pmap.tub)
103
+ if len(unknown) == 1:
104
+ hid = next(iter(unknown))
105
+ pmap = ProbeMap(hot_in=hid, tub=list(pmap.tub))
106
+ return pmap, True, f"Derived hot_in={hid}"
107
+
108
+ if hot_known and not tub_complete and n == TUB_COUNT and pmap.hot_in not in vis:
109
+ pmap = ProbeMap(hot_in=pmap.hot_in, tub=sorted(visible))
110
+ return pmap, True, "Learned 4 tub probes (water bundle alone)"
111
+
112
+ return pmap, changed, detail
113
+
114
+
115
+def _compute_setup_state(visible: list[str], pmap: ProbeMap) -> SetupState:
116
+ n = len(visible)
117
+ if n == TUB_COUNT + 1 and not pmap.hot_in and len(pmap.tub) == 0:
118
+ return "need_reinstall"
119
+ if pmap.is_complete():
120
+ return "ready"
121
+ if pmap.hot_in and len(pmap.tub) < TUB_COUNT:
122
+ return "need_water"
123
+ if len(pmap.tub) == TUB_COUNT and not pmap.hot_in:
124
+ return "need_hot_in"
125
+ if not pmap.hot_in and not pmap.tub:
126
+ if n >= TUB_COUNT + 1:
127
+ return "need_reinstall"
128
+ if n == TUB_COUNT:
129
+ return "need_hot_in"
130
+ if n == 1:
131
+ return "need_hot_in"
132
+ return "need_hot_in"
133
+ return "need_water"
134
+
135
+
136
+def evaluate_identity(
137
+ visible_ids: list[str],
138
+ probe_map: ProbeMap,
139
+ *,
140
+ setup_mode: bool,
141
+) -> IdentityResult:
142
+ visible = sorted(set(visible_ids))
143
+ pmap, changed, detail = _try_learn(visible, probe_map, setup_mode=setup_mode)
144
+ state = _compute_setup_state(visible, pmap)
145
+ if state == "need_reinstall" and not detail:
146
+ detail = "All 5 probes visible but no stored ids; connect bundles one at a time"
147
+ if not detail:
148
+ detail = state
149
+ return IdentityResult(
150
+ setup_state=state,
151
+ probe_map=pmap,
152
+ map_changed=changed,
153
+ detail=detail,
154
+ visible_ids=visible,
155
+ )
156
+
157
+
158
+def classify_readings(
159
+ probes_f: dict[str, float | None],
160
+ probe_map: ProbeMap,
161
+) -> tuple[dict[str, float | None], float | None, float | None, int, int, float | None]:
162
+ """Return labeled probes, tub_avg, hot_in_f, ok/fail counts, spread."""
163
+ labeled: dict[str, float | None] = dict(probes_f)
164
+ tub_temps: list[float] = []
165
+ hot_in_f: float | None = None
166
+
167
+ tub_set = set(probe_map.tub)
168
+ for device_id, temp_f in probes_f.items():
169
+ if device_id == probe_map.hot_in and temp_f is not None:
170
+ hot_in_f = temp_f
171
+ elif device_id in tub_set and temp_f is not None:
172
+ tub_temps.append(temp_f)
173
+
174
+ ok_count = len(tub_temps)
175
+ fail_count = max(0, len(probe_map.tub) - ok_count) if probe_map.tub else 0
176
+ tub_avg: float | None = sum(tub_temps) / len(tub_temps) if tub_temps else None
177
+ spread: float | None = None
178
+ if len(tub_temps) >= 2:
179
+ spread = max(tub_temps) - min(tub_temps)
180
+
181
+ return labeled, tub_avg, hot_in_f, ok_count, fail_count, spread
projects/hot-tub/src/temp/logging_setup.py
... ...
@@ -0,0 +1,20 @@
1
+"""File logging for the temperature daemon."""
2
+
3
+from __future__ import annotations
4
+
5
+import logging
6
+from pathlib import Path
7
+
8
+
9
+def setup_logging(log_path: Path, *, verbose: bool = False) -> logging.Logger:
10
+ log_path.parent.mkdir(parents=True, exist_ok=True)
11
+ logger = logging.getLogger("hot_tub.temp")
12
+ logger.setLevel(logging.DEBUG if verbose else logging.INFO)
13
+ logger.handlers.clear()
14
+
15
+ handler = logging.FileHandler(log_path, encoding="utf-8")
16
+ handler.setFormatter(
17
+ logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
18
+ )
19
+ logger.addHandler(handler)
20
+ return logger
projects/hot-tub/src/temp/reader.py
... ...
@@ -0,0 +1,53 @@
1
+"""Read DS18B20 devices from Linux 1-Wire sysfs."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+
7
+DEFAULT_DEVICES_PATH = Path("/sys/bus/w1/devices")
8
+
9
+
10
+def c_to_f(temp_c: float) -> float:
11
+ return temp_c * 9.0 / 5.0 + 32.0
12
+
13
+
14
+def find_devices(devices_path: Path) -> list[str]:
15
+ if not devices_path.exists():
16
+ return []
17
+ return sorted(p.name for p in devices_path.iterdir() if p.name.startswith("28-"))
18
+
19
+
20
+def read_temp_c(devices_path: Path, device_id: str) -> float | None:
21
+ """Return temperature in Celsius, or None if the read/parse fails."""
22
+ slave_file = devices_path / device_id / "w1_slave"
23
+ try:
24
+ text = slave_file.read_text()
25
+ except OSError:
26
+ return None
27
+
28
+ lines = text.strip().splitlines()
29
+ if len(lines) < 2 or "YES" not in lines[0]:
30
+ return None
31
+
32
+ marker = "t="
33
+ if marker not in lines[1]:
34
+ return None
35
+
36
+ try:
37
+ milli_c = int(lines[1].split(marker, 1)[1])
38
+ except ValueError:
39
+ return None
40
+
41
+ if milli_c == 85000:
42
+ return None
43
+
44
+ return milli_c / 1000.0
45
+
46
+
47
+def read_all_f(devices_path: Path) -> dict[str, float | None]:
48
+ """Return device_id -> °F (or None on failure)."""
49
+ out: dict[str, float | None] = {}
50
+ for device_id in find_devices(devices_path):
51
+ temp_c = read_temp_c(devices_path, device_id)
52
+ out[device_id] = None if temp_c is None else c_to_f(temp_c)
53
+ return out
projects/hot-tub/src/temp/redis_out.py
... ...
@@ -0,0 +1,88 @@
1
+"""Publish temperature readings, setup, and health to Redis."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+import logging
7
+import time
8
+from typing import Any
9
+
10
+try:
11
+ import redis
12
+except ImportError:
13
+ redis = None # type: ignore[assignment]
14
+
15
+logger = logging.getLogger("hot_tub.temp")
16
+
17
+KEY_LATEST = "temps:latest"
18
+CHAN_READING = "temps.reading"
19
+KEY_SETUP = "temps:setup"
20
+CHAN_SETUP = "temps.setup"
21
+KEY_HEALTH = "health:temps"
22
+CHAN_HEALTH = "health.temps"
23
+
24
+
25
+class RedisPublisher:
26
+ def __init__(self, redis_url: str) -> None:
27
+ if redis is None:
28
+ raise RuntimeError("redis package not installed (apt: python3-redis)")
29
+ self._url = redis_url
30
+ self._client: redis.Redis | None = None
31
+ self._last_setup_json: str | None = None
32
+ self._backoff_s = 1.0
33
+
34
+ def _connect(self) -> redis.Redis:
35
+ if self._client is None:
36
+ self._client = redis.from_url(self._url, decode_responses=True)
37
+ return self._client
38
+
39
+ def _ensure(self) -> redis.Redis | None:
40
+ try:
41
+ client = self._connect()
42
+ client.ping()
43
+ self._backoff_s = 1.0
44
+ return client
45
+ except Exception as exc:
46
+ logger.warning("Redis unavailable: %s (retry in %.0fs)", exc, self._backoff_s)
47
+ self._client = None
48
+ time.sleep(min(self._backoff_s, 30.0))
49
+ self._backoff_s = min(self._backoff_s * 2, 30.0)
50
+ return None
51
+
52
+ def publish_reading(self, payload: dict[str, Any]) -> bool:
53
+ client = self._ensure()
54
+ if client is None:
55
+ return False
56
+ body = json.dumps(payload)
57
+ client.set(KEY_LATEST, body)
58
+ client.publish(CHAN_READING, body)
59
+ return True
60
+
61
+ def publish_setup(self, payload: dict[str, Any]) -> bool:
62
+ client = self._ensure()
63
+ if client is None:
64
+ return False
65
+ body = json.dumps(payload)
66
+ if body == self._last_setup_json:
67
+ return True
68
+ self._last_setup_json = body
69
+ client.set(KEY_SETUP, body)
70
+ client.publish(CHAN_SETUP, body)
71
+ return True
72
+
73
+ def publish_health(self, payload: dict[str, Any]) -> bool:
74
+ client = self._ensure()
75
+ if client is None:
76
+ return False
77
+ body = json.dumps(payload)
78
+ client.set(KEY_HEALTH, body)
79
+ client.publish(CHAN_HEALTH, body)
80
+ return True
81
+
82
+ def close(self) -> None:
83
+ if self._client is not None:
84
+ try:
85
+ self._client.close()
86
+ except Exception:
87
+ pass
88
+ self._client = None
projects/hot-tub/src/temp/tests/__init__.py
projects/hot-tub/src/temp/tests/__pycache__/__init__.cpython-314.pyc
... ...
Binary files /dev/null and b/projects/hot-tub/src/temp/tests/__pycache__/__init__.cpython-314.pyc differ
projects/hot-tub/src/temp/tests/__pycache__/test_identity.cpython-314.pyc
... ...
Binary files /dev/null and b/projects/hot-tub/src/temp/tests/__pycache__/test_identity.cpython-314.pyc differ
projects/hot-tub/src/temp/tests/__pycache__/test_reader.cpython-314.pyc
... ...
Binary files /dev/null and b/projects/hot-tub/src/temp/tests/__pycache__/test_reader.cpython-314.pyc differ
projects/hot-tub/src/temp/tests/test_identity.py
... ...
@@ -0,0 +1,82 @@
1
+"""Unit tests for probe identity teach-in."""
2
+
3
+from __future__ import annotations
4
+
5
+import tempfile
6
+import unittest
7
+from pathlib import Path
8
+
9
+from temp.identity import (
10
+ ProbeMap,
11
+ evaluate_identity,
12
+ load_probe_map,
13
+ save_probe_map,
14
+)
15
+
16
+
17
+class TestProbeMapPersistence(unittest.TestCase):
18
+ def test_save_load_roundtrip(self) -> None:
19
+ with tempfile.TemporaryDirectory() as tmp:
20
+ path = Path(tmp) / "probes.json"
21
+ pm = ProbeMap(hot_in="28-h", tub=["28-a", "28-b", "28-c", "28-d"])
22
+ save_probe_map(path, pm)
23
+ loaded = load_probe_map(path)
24
+ self.assertEqual(loaded.hot_in, "28-h")
25
+ self.assertEqual(sorted(loaded.tub), sorted(pm.tub))
26
+
27
+
28
+class TestEvaluateIdentity(unittest.TestCase):
29
+ def test_one_probe_learns_hot_in(self) -> None:
30
+ r = evaluate_identity(["28-hot"], ProbeMap(), setup_mode=True)
31
+ self.assertTrue(r.map_changed)
32
+ self.assertEqual(r.probe_map.hot_in, "28-hot")
33
+ self.assertEqual(r.setup_state, "need_water")
34
+
35
+ def test_four_alone_learns_tub_with_setup_mode(self) -> None:
36
+ ids = [f"28-t{i}" for i in range(4)]
37
+ r = evaluate_identity(ids, ProbeMap(), setup_mode=True)
38
+ self.assertTrue(r.map_changed)
39
+ self.assertEqual(sorted(r.probe_map.tub), sorted(ids))
40
+ self.assertEqual(r.setup_state, "need_hot_in")
41
+
42
+ def test_five_empty_map_need_reinstall(self) -> None:
43
+ ids = [f"28-x{i}" for i in range(5)]
44
+ r = evaluate_identity(ids, ProbeMap(), setup_mode=True)
45
+ self.assertFalse(r.map_changed)
46
+ self.assertEqual(r.setup_state, "need_reinstall")
47
+
48
+ def test_five_derive_tub_when_hot_known(self) -> None:
49
+ hot = "28-hot"
50
+ tubs = [f"28-t{i}" for i in range(4)]
51
+ pm = ProbeMap(hot_in=hot, tub=[])
52
+ r = evaluate_identity([hot, *tubs], pm, setup_mode=False)
53
+ self.assertTrue(r.map_changed)
54
+ self.assertEqual(sorted(r.probe_map.tub), sorted(tubs))
55
+ self.assertEqual(r.setup_state, "ready")
56
+
57
+ def test_five_derive_hot_when_tub_known(self) -> None:
58
+ hot = "28-hot"
59
+ tubs = [f"28-t{i}" for i in range(4)]
60
+ pm = ProbeMap(hot_in=None, tub=tubs)
61
+ r = evaluate_identity([hot, *tubs], pm, setup_mode=False)
62
+ self.assertTrue(r.map_changed)
63
+ self.assertEqual(r.probe_map.hot_in, hot)
64
+ self.assertEqual(r.setup_state, "ready")
65
+
66
+ def test_four_mixed_does_not_mislearn_when_hot_on_bus(self) -> None:
67
+ hot = "28-hot"
68
+ pm = ProbeMap(hot_in=hot, tub=[])
69
+ # hot + 3 tub = 4 devices — must not assign all 4 as tub
70
+ r = evaluate_identity([hot, "28-t1", "28-t2", "28-t3"], pm, setup_mode=True)
71
+ self.assertFalse(r.map_changed)
72
+ self.assertEqual(r.probe_map.tub, [])
73
+
74
+ def test_complete_map_no_overwrite_on_single_probe(self) -> None:
75
+ pm = ProbeMap(hot_in="28-h", tub=[f"28-t{i}" for i in range(4)])
76
+ r = evaluate_identity(["28-h"], pm, setup_mode=False)
77
+ self.assertFalse(r.map_changed)
78
+ self.assertEqual(r.setup_state, "ready")
79
+
80
+
81
+if __name__ == "__main__":
82
+ unittest.main()
projects/hot-tub/src/temp/tests/test_reader.py
... ...
@@ -0,0 +1,42 @@
1
+"""Unit tests for sysfs reader."""
2
+
3
+from __future__ import annotations
4
+
5
+import tempfile
6
+import unittest
7
+from pathlib import Path
8
+
9
+from temp.reader import c_to_f, find_devices, read_all_f, read_temp_c
10
+
11
+
12
+GOOD_SLAVE = "a1 01 4b 46 7f ff 0c 10 fe : crc=fe YES\nt=25000\n"
13
+BAD_SLAVE = "a1 01 4b 46 7f ff 0c 10 fe : crc=fe NO\nt=25000\n"
14
+
15
+
16
+class TestReader(unittest.TestCase):
17
+ def test_c_to_f(self) -> None:
18
+ self.assertAlmostEqual(c_to_f(0.0), 32.0)
19
+ self.assertAlmostEqual(c_to_f(100.0), 212.0)
20
+
21
+ def test_read_fake_sysfs(self) -> None:
22
+ with tempfile.TemporaryDirectory() as tmp:
23
+ base = Path(tmp)
24
+ dev = base / "28-000000000001"
25
+ dev.mkdir()
26
+ (dev / "w1_slave").write_text(GOOD_SLAVE)
27
+ self.assertEqual(find_devices(base), ["28-000000000001"])
28
+ self.assertAlmostEqual(read_temp_c(base, "28-000000000001"), 25.0)
29
+ all_f = read_all_f(base)
30
+ self.assertAlmostEqual(all_f["28-000000000001"], 77.0)
31
+
32
+ def test_crc_fail(self) -> None:
33
+ with tempfile.TemporaryDirectory() as tmp:
34
+ base = Path(tmp)
35
+ dev = base / "28-000000000002"
36
+ dev.mkdir()
37
+ (dev / "w1_slave").write_text(BAD_SLAVE)
38
+ self.assertIsNone(read_temp_c(base, "28-000000000002"))
39
+
40
+
41
+if __name__ == "__main__":
42
+ unittest.main()
projects/hot-tub/src/temp_sim/README.md
... ...
@@ -0,0 +1,102 @@
1
+# Diagnostic Temperature Simulator (`temp_sim`)
2
+
3
+Manual troubleshooting tool that **pretends to be the Temperature Daemon** without reading 1-Wire or GPIO. It publishes the same Redis keys/channels as the real daemon ([`temp/redis_out.py`](../temp/redis_out.py)) so Control, Health, and Web can be tested on a desk or Pi without probes.
4
+
5
+## What it is / is not
6
+
7
+| Yes | No |
8
+|---|---|
9
+| Reads `control:latest` from Redis | Reads DS18B20 / sysfs |
10
+| Publishes `temps:latest`, `temps.reading`, `temps:setup`, `health:temps`, etc. | Drives relays or GPIO |
11
+| Prints every tick to stdout | systemd service (run manually only) |
12
+| Simulates tub + hot-in dynamics from Control heat state | Replaces Control daemon |
13
+
14
+**Important:** Stop the real temperature service before running the simulator, or both will fight over `temps:latest`:
15
+
16
+```bash
17
+sudo systemctl stop hot-tub-temps
18
+```
19
+
20
+## Prerequisites
21
+
22
+- Redis running locally (`redis-server`)
23
+- `python3-redis` (Debian: `sudo apt install python3-redis`)
24
+- For realistic behavior: Control daemon publishing `control:latest`, **or** inject fake control state yourself (below)
25
+
26
+## Run
27
+
28
+From the repo `src` directory:
29
+
30
+```bash
31
+cd /path/to/hot-tub/src
32
+PYTHONPATH=. python3 -m temp_sim
33
+```
34
+
35
+Options:
36
+
37
+```bash
38
+PYTHONPATH=. python3 -m temp_sim --interval 2
39
+PYTHONPATH=. python3 -m temp_sim --redis-url redis://127.0.0.1:6379/0
40
+```
41
+
42
+Each line on stdout shows mode (`HEAT` / `COOL`), Control `heat_phase` / `heat_pump`, simulated `tub` and `hot_in` °F, and Redis publish status. Ctrl+C to exit.
43
+
44
+When finished testing, restart the real daemon if needed:
45
+
46
+```bash
47
+sudo systemctl start hot-tub-temps
48
+```
49
+
50
+## Simulated physics
51
+
52
+Starting values: **tub average 70 °F**, **hot-in 70 °F**. Step size: **5 °F** per interval (default interval **5 s**).
53
+
54
+| Condition | Hot-in | Tub average (each tick) |
55
+|---|---|---|
56
+| **Heat-up** — `heat_phase` is `startup_1`, `startup_2`, `startup_3`, or `heating`, **and** `heat_pump` is not `off` | Fixed **130 °F** | **+5 °F** (capped at 140 °F) |
57
+| **Cool-down** — `heat_phase` is `idle`, `error_startup`, or anything else; **or** `heat_pump` is `off`; **or** no `control:latest` | **Matches current tub** | **−5 °F** until floor **70 °F** |
58
+
59
+Four fake tub probes in Redis all equal `tub_avg_f`; hot probe equals `hot_in_f`. Setup is always `ready` with stable fake ROM ids (`28-sim-tub1` … `28-sim-hot`).
60
+
61
+## Inject Control state (no Control daemon)
62
+
63
+Minimal heating example:
64
+
65
+```bash
66
+redis-cli SET control:latest '{"heat_phase":"heating","heat_pump":"on","heat_mode":"maintain","target_f":102}'
67
+```
68
+
69
+Cool / idle example:
70
+
71
+```bash
72
+redis-cli SET control:latest '{"heat_phase":"idle","heat_pump":"off","heat_mode":"sleep","target_f":102}'
73
+```
74
+
75
+Startup attempt (Control checks hot-in rise after ~20 s):
76
+
77
+```bash
78
+redis-cli SET control:latest '{"heat_phase":"startup_1","heat_pump":"on","heat_mode":"maintain","target_f":102}'
79
+```
80
+
81
+Failed startup:
82
+
83
+```bash
84
+redis-cli SET control:latest '{"heat_phase":"error_startup","heat_pump":"off","heat_mode":"maintain","target_f":102}'
85
+```
86
+
87
+Run `temp_sim` in one terminal; change `control:latest` in another and watch stdout / `temps:latest`.
88
+
89
+## Verify Redis output
90
+
91
+```bash
92
+redis-cli GET temps:latest
93
+redis-cli GET health:temps
94
+redis-cli SUBSCRIBE temps.reading
95
+```
96
+
97
+Health payload includes `"detail": "temp_sim (diagnostic simulator)"` so you can tell it apart from the real daemon.
98
+
99
+## Related docs
100
+
101
+- Real daemon: [../temp/README.md](../temp/README.md), [../temp/INSTALL.md](../temp/INSTALL.md)
102
+- Control state fields: [../control/README.md](../control/README.md)
projects/hot-tub/src/temp_sim/__init__.py
... ...
@@ -0,0 +1,3 @@
1
+"""Diagnostic temperature simulator — Redis-only, no hardware."""
2
+
3
+__version__ = "0.1.0"
projects/hot-tub/src/temp_sim/__main__.py
... ...
@@ -0,0 +1,39 @@
1
+"""CLI: python -m temp_sim"""
2
+
3
+from __future__ import annotations
4
+
5
+import argparse
6
+import sys
7
+
8
+from .simulator import run_loop
9
+
10
+
11
+def main() -> None:
12
+ parser = argparse.ArgumentParser(
13
+ description="Diagnostic temperature simulator (Redis only, no hardware)"
14
+ )
15
+ parser.add_argument(
16
+ "--interval",
17
+ type=float,
18
+ default=5.0,
19
+ help="Seconds between simulated readings (default: 5)",
20
+ )
21
+ parser.add_argument(
22
+ "--redis-url",
23
+ default="redis://127.0.0.1:6379/0",
24
+ help="Redis URL (default: redis://127.0.0.1:6379/0)",
25
+ )
26
+ args = parser.parse_args()
27
+
28
+ if args.interval <= 0:
29
+ print("--interval must be > 0", file=sys.stderr)
30
+ sys.exit(2)
31
+
32
+ run_loop(redis_url=args.redis_url, interval_s=args.interval)
33
+
34
+
35
+if __name__ == "__main__":
36
+ try:
37
+ main()
38
+ except KeyboardInterrupt:
39
+ sys.exit(0)
projects/hot-tub/src/temp_sim/__pycache__/__init__.cpython-314.pyc
... ...
Binary files /dev/null and b/projects/hot-tub/src/temp_sim/__pycache__/__init__.cpython-314.pyc differ
projects/hot-tub/src/temp_sim/__pycache__/simulator.cpython-314.pyc
... ...
Binary files /dev/null and b/projects/hot-tub/src/temp_sim/__pycache__/simulator.cpython-314.pyc differ
projects/hot-tub/src/temp_sim/simulator.py
... ...
@@ -0,0 +1,167 @@
1
+"""Simulate tub/hot-in temps from Control state; publish like the real temp daemon."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+import os
7
+import signal
8
+import time
9
+from dataclasses import dataclass
10
+from datetime import datetime, timezone
11
+from typing import Any
12
+
13
+try:
14
+ import redis
15
+except ImportError:
16
+ redis = None # type: ignore[assignment]
17
+
18
+from temp.redis_out import RedisPublisher
19
+
20
+KEY_CONTROL_LATEST = "control:latest"
21
+
22
+DEFAULT_TUB_F = 70.0
23
+DEFAULT_HOT_F = 70.0
24
+HOT_ACTIVE_F = 130.0
25
+STEP_F = 5.0
26
+TUB_MAX_F = 140.0
27
+
28
+TUB_PROBE_IDS = (
29
+ "28-sim-tub1",
30
+ "28-sim-tub2",
31
+ "28-sim-tub3",
32
+ "28-sim-tub4",
33
+)
34
+HOT_PROBE_ID = "28-sim-hot"
35
+
36
+HEATING_PHASES = frozenset({"startup_1", "startup_2", "startup_3", "heating"})
37
+
38
+_stop = False
39
+
40
+
41
+def _utc_now() -> str:
42
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
43
+
44
+
45
+def _handle_signal(signum: int, _frame: object) -> None:
46
+ global _stop
47
+ print(f"\nSignal {signum}, stopping.")
48
+ _stop = True
49
+
50
+
51
+@dataclass
52
+class SimState:
53
+ tub_avg_f: float = DEFAULT_TUB_F
54
+ hot_in_f: float = DEFAULT_HOT_F
55
+
56
+
57
+def fetch_control_state(redis_url: str) -> dict[str, Any] | None:
58
+ if redis is None:
59
+ raise RuntimeError("redis package not installed (apt: python3-redis)")
60
+ client = redis.from_url(redis_url, decode_responses=True)
61
+ try:
62
+ raw = client.get(KEY_CONTROL_LATEST)
63
+ finally:
64
+ client.close()
65
+ if not raw:
66
+ return None
67
+ try:
68
+ data = json.loads(raw)
69
+ except json.JSONDecodeError:
70
+ return None
71
+ return data if isinstance(data, dict) else None
72
+
73
+
74
+def should_heat_up(control: dict[str, Any] | None) -> tuple[bool, str, str]:
75
+ """Return (heat_up, heat_phase, heat_pump) for display."""
76
+ if control is None:
77
+ return False, "—", "—"
78
+ phase = str(control.get("heat_phase") or "idle")
79
+ pump = str(control.get("heat_pump") or "off")
80
+ if phase in HEATING_PHASES and pump != "off":
81
+ return True, phase, pump
82
+ return False, phase, pump
83
+
84
+
85
+def tick_sim(state: SimState, heat_up: bool) -> None:
86
+ if heat_up:
87
+ state.hot_in_f = HOT_ACTIVE_F
88
+ state.tub_avg_f = min(state.tub_avg_f + STEP_F, TUB_MAX_F)
89
+ else:
90
+ state.tub_avg_f = max(DEFAULT_TUB_F, state.tub_avg_f - STEP_F)
91
+ state.hot_in_f = state.tub_avg_f
92
+
93
+
94
+def build_payloads(state: SimState) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
95
+ ts = _utc_now()
96
+ probes = {pid: state.tub_avg_f for pid in TUB_PROBE_IDS}
97
+ probes[HOT_PROBE_ID] = state.hot_in_f
98
+
99
+ reading = {
100
+ "ts": ts,
101
+ "setup_state": "ready",
102
+ "probes": probes,
103
+ "tub_avg_f": state.tub_avg_f,
104
+ "hot_in_f": state.hot_in_f,
105
+ "outdoor_f": None,
106
+ "ok_count": 4,
107
+ "fail_count": 0,
108
+ "spread_f": 0.0,
109
+ }
110
+ setup_payload = {
111
+ "ts": ts,
112
+ "state": "ready",
113
+ "detail": "temp_sim diagnostic simulator",
114
+ "visible_ids": list(TUB_PROBE_IDS) + [HOT_PROBE_ID],
115
+ }
116
+ health_payload = {
117
+ "service": "temps",
118
+ "ts": ts,
119
+ "status": "ok",
120
+ "detail": "temp_sim (diagnostic simulator)",
121
+ "pid": os.getpid(),
122
+ }
123
+ return reading, setup_payload, health_payload
124
+
125
+
126
+def run_loop(*, redis_url: str, interval_s: float) -> None:
127
+ global _stop
128
+ _stop = False
129
+ signal.signal(signal.SIGTERM, _handle_signal)
130
+ signal.signal(signal.SIGINT, _handle_signal)
131
+
132
+ state = SimState()
133
+ pub = RedisPublisher(redis_url)
134
+
135
+ print(
136
+ f"temp_sim starting: interval={interval_s}s redis={redis_url} "
137
+ f"defaults tub={DEFAULT_TUB_F}F hot={DEFAULT_HOT_F}F"
138
+ )
139
+ print("Stop with Ctrl+C. Do not run while hot-tub-temps.service is active.\n")
140
+
141
+ try:
142
+ while not _stop:
143
+ loop_start = time.monotonic()
144
+ control = fetch_control_state(redis_url)
145
+ heat_up, phase, pump = should_heat_up(control)
146
+ mode = "HEAT" if heat_up else "COOL"
147
+ tick_sim(state, heat_up)
148
+
149
+ reading, setup_payload, health_payload = build_payloads(state)
150
+ ok_read = pub.publish_reading(reading)
151
+ ok_setup = pub.publish_setup(setup_payload)
152
+ ok_health = pub.publish_health(health_payload)
153
+ redis_ok = ok_read and ok_setup and ok_health
154
+
155
+ print(
156
+ f"{reading['ts']} {mode:4} phase={phase:12} pump={pump:3} "
157
+ f"tub={state.tub_avg_f:5.1f}F hot_in={state.hot_in_f:5.1f}F "
158
+ f"redis={'OK' if redis_ok else 'FAIL'}"
159
+ )
160
+
161
+ elapsed = time.monotonic() - loop_start
162
+ sleep_s = max(0.0, interval_s - elapsed)
163
+ if sleep_s > 0 and not _stop:
164
+ time.sleep(sleep_s)
165
+ finally:
166
+ pub.close()
167
+ print("temp_sim stopped.")