fbb92442bf4ad471513f4381bd2297e40df57841
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.") |