projects/hot-tub/indoor-monitor.md
... ...
@@ -0,0 +1,266 @@
1
+# Indoor Remote Monitor — Build-Out Plan
2
+
3
+A battery-powered, LoRa-connected status display that lives in the house (not at the tub) and shows live tub status at a glance. Built from a repurposed **Urageuxy / WS083T wireless pool thermometer** console: its case and 3 physical buttons are reused, its original PCB/LCD/antenna are discarded, and a small e-paper display + ESP32-C3 + LoRa radio take over.
4
+
5
+See [Hardware Setup](hardware-setup.md), [Overview](overview.md), and [Pi Zero Tub-Side Control Panel](pi-zero-control-panel.md) for the rest of the system. This device is mostly a **read-only status client** of the Pi 3 controller — it has no sensors and drives no pumps. Its only write capability is a narrow, low-stakes one: nudging the target temperature up/down by 1°F using the two original `+`/`−` buttons. It cannot enable/disable heating or touch the filter pump — that authority stays with the tub-side console and the web/app. The Pi 3's own safety logic (missing probes, max temp, no-rise timeout, etc.) still gates whatever actually happens with the target this device requests, exactly as it does for every other client.
6
+
7
+**Why LoRa instead of Wi-Fi:** this device wakes briefly, checks in, and sleeps — same low-duty-cycle pattern throughout. Wi-Fi's association + DHCP + TCP/HTTP overhead costs 150–250+ mA for a second or more on every wake. A LoRa radio skips all of that: no IP stack, no association — just a short raw-packet exchange at ~10–13 mA receive / tens of mA transmit. For a "wake, ask, sleep" device, that difference compounds into a meaningfully longer battery life. See the Power Budget section below.
8
+
9
+## Why this device is architecturally different from the tub-side console
10
+
11
+| | Tub-side console (`pi-zero-control-panel.md`) | Indoor monitor (this doc) |
12
+|---|---|---|
13
+| Location | At the tub, outdoor-adjacent | Indoors |
14
+| Power | Mains via cable | 2× AA batteries |
15
+| Core | Raspberry Pi Zero W (Linux) | ESP32-C3 (microcontroller, deep sleep) |
16
+| Link | Wi-Fi (always associated) | LoRa (short request/response, no association) |
17
+| Display | SPI color TFT (Pimoroni Display HAT Mini) | E-paper (low power, already purchased) |
18
+| Role | Can enable/disable heating, adjust target, control filter | Status glance + target ±1°F only |
19
+| Duty cycle | Always on | Wakes briefly, sleeps most of the time |
20
+
21
+A Pi Zero's always-on Linux + Wi‑Fi draw (~100–200+ mA) would drain 2×AA in hours. An ESP32-C3 in deep sleep draws microamps between wake cycles, which is the only way "2×AA, lives in the house, don't think about it" is realistic.
22
+
23
+## Hardware
24
+
25
+### Reused from the original thermometer console
26
+
27
+- Plastic case + existing cutout/window
28
+- 3 physical buttons (`MIN`, `CH`, `LIG`) and their shared/common ribbon wire (previously labeled `VDD` on the original board — treat it as just the switches' common terminal once disconnected from the original PCB)
29
+- 2×AA battery compartment (if intact; otherwise replace with an equivalent holder)
30
+
31
+### Discarded
32
+
33
+- Original main PCB (COB chip, RF receiver, antenna)
34
+- Original LCD glass + zebra strip
35
+
36
+### New parts
37
+
38
+| Item | Qty | Notes |
39
+|---|---|---|
40
+| ESP32-C3 dev board ("Super Mini" form factor recommended) | 1 | Small enough to fit inside the case; has RTC-capable GPIO0–5 for deep-sleep wake |
41
+| LoRa transceiver module (SX1276/RFM95-class) | 1 | SPI-connected; needs CS, RST, DIO0 in addition to shared SPI lines — see wiring below |
42
+| LoRa transceiver module, second unit | 1 | For the Pi 3 side — see "Pi 3 side" note below |
43
+| E-paper SPI display | 1 | Already purchased — confirm it exposes CS, DC, RST, BUSY, CLK/SCK, DIN/MOSI, GND, VCC |
44
+| Low-quiescent-current boost regulator, 3.3V out | 1 | See power section below — this is the component that determines real-world battery life |
45
+| 2× AA battery holder | 1 | Reuse original if usable |
46
+| Small perfboard or protoboard | 1 | To mount ESP32-C3 + regulator + LoRa module inside the case |
47
+| Jumper wire / thin hookup wire | — | For button ribbon, display, and LoRa module connections |
48
+
49
+### Pi 3 side
50
+
51
+The Pi 3 needs its own LoRa module (same SX1276/RFM95-class radio, on the same frequency/channel) wired to its GPIO/SPI header, plus a small always-on listener thread in the control daemon. Since the Pi 3 is mains-powered, there's no power-budget concern on that end — it can simply sit in receive mode continuously waiting for this device's short requests.
52
+
53
+### Boost regulator — don't default to a generic dev-board module
54
+
55
+A generic MT3608-style boost module is fine for bench testing but often has a continuously-lit indicator LED and ~100–200 µA quiescent draw — both of which quietly ruin battery life on a device that's supposed to sleep almost all the time.
56
+
57
+For the real build, pick one of:
58
+
59
+- **Ready-made low-Iq module** (simplest): a boost board specifically marketed for 1–2 AA/alkaline-cell IoT use with quiescent current in the single-digit-to-low-double-digit microamp range, 3.3V fixed output, no always-on LED.
60
+- **Bare IC on your own small board** (best efficiency, more work): a boost IC such as **TPS61021A** or **MCP1640** (tens of µA quiescent) or, for the most aggressive battery life, something like **SGM66099A** (sub-µA quiescent) — these need a basic inductor/capacitor circuit per the datasheet reference design.
61
+
62
+Either way, put a reasonably sized bulk capacitor (on the order of 100–470 µF) right at the ESP32's 3.3V input. LoRa transmit bursts (and, to a lesser extent, the e-paper refresh) pull short current spikes above the regulator's steady-state rating; local capacitance absorbs that without browning out the chip mid-transmission.
63
+
64
+## Electrical Wiring
65
+
66
+### Power path
67
+
68
+```text
69
+2×AA ──► boost regulator (3.3V out) ──┬──► ESP32-C3 3V3
70
+ └──► e-paper VCC
71
+```
72
+
73
+### Buttons
74
+
75
+Treat the ribbon as 3 plain switches sharing one common line (confirm with a continuity test after disconnecting from the original board — this works fine for mechanical switches, unlike the original LCD segments). All 3 land on **RTC-capable GPIO0–5**, so *any* of them can wake the device immediately from deep sleep rather than waiting for the next timer tick — important now that two of them send commands, not just a local display toggle:
76
+
77
+```text
78
+Ribbon "VDD" (common) ────────────── ESP32-C3 GND
79
+Ribbon "CH" (silkscreen "+") ──── GPIO2 (RTC-capable — increase target)
80
+Ribbon "MIN" (silkscreen "−") ──── GPIO3 (RTC-capable — decrease target)
81
+Ribbon "LIG" (silkscreen "SET")──── GPIO4 (RTC-capable — force refresh / reserved)
82
+```
83
+
84
+Confirm against the physical case: the original silkscreen on devices like this is commonly `MIN/MAX/−`, `SET`, `CH/+` — if that matches yours, the "−" and "+" printed right on the case line up naturally with decrease/increase target, which is why that's the proposed mapping (see Button Behavior below).
85
+
86
+One caution: **GPIO2 is a strapping pin** with an external pull-up already present on most boards. It still works fine for a button-to-GND switch, but double-check your specific board's schematic before relying on it, and avoid driving it externally during boot.
87
+
88
+### E-paper display (generic — confirm against your specific module's datasheet)
89
+
90
+| Signal | Suggested ESP32-C3 GPIO |
91
+|---|---|
92
+| CS | GPIO7 |
93
+| DC | GPIO8 (note: strapping pin, see caution below) |
94
+| RST | GPIO10 |
95
+| BUSY | GPIO11 |
96
+| CLK / SCK | GPIO6 |
97
+| DIN / MOSI | GPIO5 (shares RTC range — fine, just don't also use it for a button) |
98
+| GND | GND |
99
+| VCC | 3.3V (from regulator) |
100
+
101
+### LoRa module (shares the SPI bus with the e-paper display)
102
+
103
+Multiple SPI devices can share `CLK`/`MOSI`/`MISO` as long as each has its own `CS` line — standard practice, not a conflict:
104
+
105
+| Signal | Suggested ESP32-C3 GPIO |
106
+|---|---|
107
+| CS | GPIO9 (strapping pin — see caution below, or move if it causes boot issues) |
108
+| RST | GPIO20 |
109
+| DIO0 (interrupt) | GPIO21 |
110
+| CLK / SCK | GPIO6 (shared with e-paper) |
111
+| MOSI | GPIO5 (shared with e-paper) |
112
+| MISO | GPIO — add one free pin here; e-paper modules are typically write-only and don't need MISO, so this is likely free for the LoRa module to use alone |
113
+| GND | GND |
114
+| VCC | 3.3V (from regulator) |
115
+
116
+Avoid GPIO12–17 (reserved for SPI flash on most ESP32-C3 modules) and GPIO18–19 (USB-JTAG) for anything display-, button-, or radio-related. If GPIO8/GPIO9 strapping behavior causes boot issues in practice, move DC/CS to free non-strapping pins instead — double-check the final pin count against your specific ESP32-C3 board before wiring permanently, since buttons + e-paper + LoRa together now use most of the available GPIOs.
117
+
118
+## Software Architecture
119
+
120
+One Arduino-framework sketch, following a wake → ask → render → sleep cycle. Note this no longer uses Wi-Fi at all — the whole exchange happens over LoRa:
121
+
122
+```text
123
+┌─────────────────────────────────────────┐
124
+│ boot (from deep sleep or power-on) │
125
+│ │ │
126
+│ ▼ │
127
+│ read button state (which, if any) │
128
+│ │ │
129
+│ ▼ │
130
+│ send LoRa request packet │
131
+│ (includes cmd: none/target+1/target-1) │
132
+│ │ │
133
+│ ▼ │
134
+│ open short RX window, await response │
135
+│ │ │
136
+│ ▼ │
137
+│ render to e-paper (GxEPD2 + Adafruit GFX)│
138
+│ (fresh data, or "stale" if no response) │
139
+│ │ │
140
+│ ▼ │
141
+│ esp_deep_sleep_start() │
142
+│ wake sources: timer (N min) OR any button│
143
+└─────────────────────────────────────────┘
144
+```
145
+
146
+This is "Pattern B" from the LoRa power discussion: the ESP32 always initiates, so it controls exactly when the exchange happens and only needs a short, fixed RX window afterward — no need to guess when an independently-scheduled broadcast might land, and no clock-drift tolerance to budget for.
147
+
148
+### Libraries
149
+
150
+- **GxEPD2** — e-paper SPI driver, supports most Waveshare/Good Display panels
151
+- **Adafruit GFX** — drawing primitives (text, shapes, bitmaps) on top of GxEPD2
152
+- A LoRa library matching your module (e.g. the common `RadioHead` or `RadioLib` libraries support SX1276/RFM95-class radios on Arduino/ESP32)
153
+
154
+### Rendering approach
155
+
156
+Two layers, matching the "design ahead of time, blit on device" pattern:
157
+
158
+1. **Static background** (labels, borders, icons) — composed once on a computer (e.g. Python + Pillow, same tool already used elsewhere in this project), exported as a 1-bit bitmap, converted to a C byte array via a tool like image2cpp, and drawn once with `drawBitmap()`.
159
+2. **Live values** (current temp, target, heat/filter state) — drawn on top each wake cycle using `setCursor()` / `print()` with a custom font (converted from any TTF via Adafruit GFX's `fontconvert` tool) so it doesn't look like default blocky text.
160
+
161
+This avoids re-sending a full custom image every wake cycle — only the small live-value region needs to redraw, which also helps avoid unnecessary full-screen flicker/ghosting accumulation.
162
+
163
+### LoRa packet format
164
+
165
+Keep it a compact fixed-size binary struct rather than JSON — LoRa payloads are small, and this data easily fits in a handful of bytes:
166
+
167
+```text
168
+Uplink (ESP32 → Pi 3), request/command:
169
+ [msg_type][cmd][seq]
170
+ msg_type = 0x01 (status/command request)
171
+ cmd = 0x00 none | 0x01 target+1 | 0x02 target-1 | 0x03 refresh-only
172
+ seq = incrementing byte, for de-dup / debugging
173
+
174
+Downlink (Pi 3 → ESP32), status response:
175
+ [average_f][hot_input_f][target_f][heating_enabled][heat_pump_on][filter_pump_on][ts...]
176
+```
177
+
178
+The response always carries the **post-command** `target_f`. If the ESP32 sent "+1" and the response shows the target went up by exactly one degree, the button press landed. If a packet is lost and nothing comes back, the display shows "no response" rather than silently assuming the command worked (see Staleness handling below).
179
+
180
+### Pi 3 side implementation note
181
+
182
+Route the `target+1` / `target-1` commands through the **same internal "adjust target" function** that the web/app's `POST /target` endpoint already calls (see `pi-zero-control-panel.md`) — one function, multiple callers (HTTP and LoRa), so clamping/validation lives in exactly one place regardless of which device asked. This device issues no enable/disable or filter commands at all — only `target±1` and a no-op status check, by design (see the scope note in the intro).
183
+
184
+### Button behavior
185
+
186
+- **`CH` (silkscreen "+")** — send `target+1`, wake immediately (RTC GPIO wake, not waiting for the next timer tick)
187
+- **`MIN` (silkscreen "−")** — send `target-1`, wake immediately
188
+- **`SET`/`LIG`** — force an immediate refresh-only request (`cmd = refresh-only`), or reserved for a future secondary screen (open decision, see below)
189
+
190
+Debounce each button in firmware so one physical press produces exactly one `cmd` byte on the next transmission — not one per bounce, and not a repeat if the user is still holding the button down when the wake fires.
191
+
192
+### Staleness handling
193
+
194
+If the LoRa request gets no response within the RX window (radio hiccup, Pi 3 unreachable, out of range), render a small "last seen Xm ago" indicator using the last successfully received data rather than blanking the screen — e-paper retains its last image with zero power anyway, so a stale-but-labeled reading is harmless and more useful than a blank screen. This also covers the case where a button command's result can't be confirmed — show "no response" rather than assuming the press took effect.
195
+
196
+## Power Budget (rough planning numbers)
197
+
198
+Actual numbers depend on your specific ESP32-C3 board, LoRa module, regulator choice, and how wide the RX window needs to be — measure once you have hardware in hand. As a planning baseline:
199
+
200
+| State | Approx. current |
201
+|---|---|
202
+| LoRa sleep | < 0.2 µA |
203
+| LoRa standby | ~1.6 mA |
204
+| LoRa active receive (RX window) | ~10–13 mA |
205
+| LoRa transmit (request packet, short) | ~20–120 mA depending on TX power, for a brief burst |
206
+| (For comparison) Wi-Fi connect + HTTP fetch | ~150–250+ mA for a second or more |
207
+
208
+Because the request/response exchange replaces the old Wi-Fi association+HTTP step with a short TX burst plus a short (sub-second) RX window, each wake cycle costs meaningfully less energy than the Wi-Fi version of this plan:
209
+
210
+| Refresh interval | Rough battery life (2×AA) — LoRa |
211
+|---|---|
212
+| Every 60s | ~1–2 months |
213
+| Every 5 min | ~4–8 months |
214
+| Every 15 min | ~1 year+ |
215
+
216
+These are rough, optimistic-end planning numbers assuming a tight RX window (no clock-drift padding needed, since the ESP32 controls timing) and a good low-Iq regulator — confirm with real measurement once built. Tub water temperature changes slowly, so there's little practical reason to refresh faster than every few minutes for a "glance at it occasionally" house display. Bias toward the slower end unless you specifically want near-live numbers.
217
+
218
+## Build Phases
219
+
220
+### Phase 1: Bench bring-up (no case, no battery yet)
221
+
222
+1. Wire the ESP32-C3 + e-paper display on a breadboard, powered via USB.
223
+2. Get a basic GxEPD2 + Adafruit GFX sketch drawing static text to confirm the panel and pin mapping.
224
+3. Wire the 3 buttons (loose, off the original ribbon) to GPIO2/3/4 + GND, confirm each is read correctly with simple `digitalRead` / interrupt test code.
225
+
226
+### Phase 2: LoRa link + data
227
+
228
+1. Wire a second LoRa module to the Pi 3, add a small always-on listener thread to the control daemon that can echo back a mock status response — easiest to bring up as a standalone test script before wiring it into the real daemon, same spirit as `temp-sensor/app.py`'s mock mode.
229
+2. Get basic LoRa send/receive working between the ESP32 and Pi 3 with trivial payloads first (e.g. a counter) before using the real binary packet format — confirms frequency, module wiring, and antenna setup independently of any application logic.
230
+3. Implement the real request/response packet format; confirm the ESP32 can send a `cmd` byte and receive back a status struct reliably at the intended distance (same room vs. across the house, through walls).
231
+4. Wire up the "adjust target" shared function on the Pi 3 so both the LoRa command path and the existing `POST /target` endpoint call into it.
232
+5. Design the static background bitmap on a computer, convert and embed it, confirm it displays correctly alongside live text.
233
+
234
+### Phase 3: Power management
235
+
236
+1. Add `esp_deep_sleep_enable_gpio_wakeup()` for all 3 buttons (bitmask of GPIO2/3/4) plus a timer wake source.
237
+2. Swap bench USB power for the boost regulator + 2×AA (still outside the case) and confirm startup/LoRa exchange still works at battery voltage, including as the cells age toward their low end.
238
+3. If possible, measure actual sleep, TX, and RX current draw (a USB power meter or a cheap inline multimeter in µA-capable mode) to validate against the planning table above, and tune the RX window width and refresh interval if needed.
239
+
240
+### Phase 4: Integration into the case
241
+
242
+1. Mount the ESP32-C3 + regulator + perfboard inside the original case.
243
+2. Fit the e-paper display behind (or in place of) the original window; confirm it's physically secure without relying on the original zebra-strip bezel pressure (that mechanism was for the old glass, not this one).
244
+3. Reconnect the button ribbon to the new GPIOs permanently.
245
+4. Close up the case, confirm batteries are accessible via the original door for easy replacement.
246
+
247
+## Testing Checklist
248
+
249
+- Each button is read correctly with the case fully closed (button travel/alignment can shift once reassembled).
250
+- `CH` sends `target+1` and wakes the device immediately from deep sleep; `MIN` does the same for `target-1`.
251
+- The displayed target only changes after the Pi 3's response confirms it — not optimistically before confirmation.
252
+- Timer wake fires at the configured interval without any button press.
253
+- Display shows correct average temp, hot-input temp, target, and pump states.
254
+- Walking the device to the far side of the house / through walls still gets a response within the RX window at the intended install location.
255
+- Stopping the Pi 3's LoRa listener (or powering off the Pi 3 module) results in a "stale"/"no response" indicator, not a blank or frozen-looking screen, and not a silently-applied command.
256
+- Rapid repeated presses of `CH`/`MIN` produce one step per press, not duplicated or dropped steps from bounce or retransmission.
257
+- Battery life roughly matches the planning estimate over a multi-day soak test at the chosen refresh interval.
258
+- Low-battery behavior is graceful (confirm the regulator's dropout behavior and that the device doesn't brown-out mid-transmission as the batteries age).
259
+
260
+## Open Decisions
261
+
262
+- Final behavior for `SET`/`LIG` — force-refresh vs. reserved for a future secondary screen (e.g. min/max over a period).
263
+- LoRa frequency/channel selection and antenna placement for reliable range across the house.
264
+- Exact refresh interval — balance "freshness" against battery-change frequency once real current draw and RX-window timing are measured.
265
+- Whether to add a simple low-battery icon/warning once board-level battery voltage sensing is wired up (not included in this first pass).
266
+- Whether repeated-press target adjustments should accelerate (e.g. holding `CH` steps faster) — out of scope for the first pass, which assumes one discrete press = one ±1°F step.
projects/hot-tub/pi-zero-control-panel.md
... ...
@@ -0,0 +1,195 @@
1
+# Pi Zero Tub-Side Control Panel — Build-Out Plan
2
+
3
+This document covers the second Raspberry Pi Zero W: a small physical console mounted at the tub. It displays live status and lets the user adjust target temperature and enable/disable heating without a phone.
4
+
5
+See [Hardware Setup](hardware-setup.md) and [Overview](overview.md) for the rest of the system. This device is a **client** of the Pi 3 controller — it has no sensors and does not drive any pumps directly.
6
+
7
+## Role Recap
8
+
9
+- Pi 3: source of truth, owns sensors/relays, runs the control daemon and web/API.
10
+- Pi Zero #1: optional wireless temp node (if used) — not this document.
11
+- **Pi Zero #2 (this document):** at-tub display + 3 buttons, talks to the Pi 3 over the local network (or a dedicated Pi-3 hotspot if Wi-Fi coverage at the tub is unreliable).
12
+
13
+Interaction contract:
14
+
15
+- **Power (short press):** enable heating (`heating_enabled = true`) on the Pi 3.
16
+- **Power (hold ~3s):** send `heating_enabled = false` to the Pi 3 first, confirm/acknowledge, **then** shut the Zero down.
17
+- **+ / −:** adjust target temperature on the Pi 3 by a fixed step (e.g. 1°F per press).
18
+
19
+The console never writes to GPIO relays and never assumes offline = stop; only an explicit Power-hold sends the stop signal.
20
+
21
+## Recommended Hardware
22
+
23
+### Primary pick: Pimoroni Display HAT Mini
24
+
25
+A single HAT gives you the screen and all the buttons you need with no extra wiring or soldering.
26
+
27
+- 2.0" 320×240 IPS LCD (ST7789V2), SPI
28
+- **4 tactile buttons** (A/B/X/Y) — map 3 of them to Power / Up / Down, leave one spare (e.g. backlight toggle or a future "filter" shortcut)
29
+- RGB status LED (handy for "heating" / "fault" color cues)
30
+- Plugs directly onto the Zero's 40-pin header, no soldering if your Zero already has header pins
31
+- Python library: `displayhatmini` (PyPI), actively maintained
32
+
33
+GPIO use (BCM numbering), for reference — all handled by the library, no manual wiring:
34
+
35
+| Function | BCM pin |
36
+|---|---|
37
+| Button A | 5 |
38
+| Button B | 6 |
39
+| Button X | 16 |
40
+| Button Y | 24 |
41
+| RGB LED R/G/B | 17 / 27 / 22 |
42
+| Backlight | 13 |
43
+| SPI (display) | 7, 9, 10, 11 |
44
+
45
+Buy:
46
+
47
+- 1 Raspberry Pi Zero W (you already have two) **with 40-pin header soldered**, or a header strip + a few minutes of soldering if yours is headerless
48
+- 1 Pimoroni Display HAT Mini
49
+- 1 microSD card (8 GB+ is plenty)
50
+- 1 small enclosure or 3D-printed stand-off case sized for Zero + HAT (Pimoroni ships 2 standoffs; add more for a tub-side mount)
51
+- 1 USB power source rated for outdoor/damp-adjacent use (see enclosure note below)
52
+
53
+### Budget / fallback alternative: Adafruit Mini PiTFT 1.3"
54
+
55
+If the Display HAT Mini isn't available:
56
+
57
+- 240×240 IPS TFT, SPI, snaps onto the Zero
58
+- Only **2** onboard buttons (GPIO23/24) — you'll need **1 extra momentary push-button** wired to a free GPIO + GND for the third control
59
+- Slightly smaller footprint than the Display HAT Mini
60
+
61
+Extra parts if you go this route:
62
+
63
+- 1 momentary push-button (panel-mount, waterproof-ish if console lives outdoors)
64
+- Short jumper wires
65
+
66
+### Either way: enclosure note
67
+
68
+This console sits "at the tub," which likely means outdoor/splash exposure. Follow the same philosophy as the main hardware doc:
69
+
70
+- Small IP-rated box or 3D-printed splash shield
71
+- Cable glands for the power cord
72
+- Keep it out of direct spray range even if "splash-resistant"
73
+
74
+## Bill of Materials Summary
75
+
76
+| Item | Qty | Notes |
77
+|---|---|---|
78
+| Raspberry Pi Zero W (have) | 1 | needs 40-pin header |
79
+| Pimoroni Display HAT Mini | 1 | screen + 4 buttons + LED |
80
+| microSD card | 1 | 8 GB+ |
81
+| USB power supply / cable | 1 | match run length to tub location |
82
+| Small splash-resistant enclosure | 1 | box or printed shell |
83
+| Cable gland | 1 | for power cable entry |
84
+
85
+## Software Architecture
86
+
87
+One small Python service on the console Zero:
88
+
89
+```text
90
+┌───────────────────────────────┐
91
+│ console.py (one process) │
92
+│ │
93
+│ poll loop ──► GET /status ───┼──► Pi 3 API
94
+│ │ (every 1-2s) │
95
+│ ▼ │
96
+│ render(display, state) │
97
+│ │
98
+│ button callbacks: │
99
+│ - Power short → POST enable │
100
+│ - Power hold → POST disable │
101
+│ → then shutdown
102
+│ - Up/Down → POST target │
103
+└───────────────────────────────┘
104
+```
105
+
106
+### Pi 3 API this console needs (add to the control daemon)
107
+
108
+Minimal surface:
109
+
110
+- `GET /status` → `{ average_f, hot_input_f, target_f, heating_enabled, heat_pump_on, filter_pump_on, failed_sensors, ts }`
111
+- `POST /heating` → `{ enabled: bool }`
112
+- `POST /target` → `{ target_f: number }` (or `{ delta_f: number }` if you prefer relative steps)
113
+
114
+Keep these idempotent and fast; the console polls frequently and must stay responsive.
115
+
116
+### Console display layout (320×240, Display HAT Mini)
117
+
118
+```text
119
+┌──────────────────────────────┐
120
+│ Tub: 101.8°F → 104°F │ current vs target, large text
121
+│ Hot-in: 118°F │
122
+│ │
123
+│ Heat: [ON] Filter: [OFF]│ status chips, color via RGB LED too
124
+│ │
125
+│ (stale data / fault banner) │ only shown if age_ms too high
126
+└──────────────────────────────┘
127
+A = Power X = − Y = + B = (spare)
128
+```
129
+
130
+Use the RGB LED for an at-a-glance cue (e.g. green = heating normally, amber = heating disabled, red = stale/fault data) so the user doesn't need to read text from across the yard.
131
+
132
+### Button behavior details
133
+
134
+- **Debounce** every button in software (the library exposes press callbacks; ignore repeats under ~150 ms).
135
+- **Power short press:** `POST /heating {enabled: true}`.
136
+- **Power hold (~3s):**
137
+ 1. `POST /heating {enabled: false}`
138
+ 2. Wait for a 2xx response (short timeout, e.g. 2s; one retry)
139
+ 3. Show "Heating stopped" on screen briefly
140
+ 4. Run `sudo shutdown -h now`
141
+ 5. If the request fails after retry, show an error and **do not** shut down automatically — require a second deliberate hold to force power-off, so "stop heating" isn't silently skipped.
142
+- **Up / Down:** `POST /target` with a fixed step (e.g. ±1°F), update the displayed target optimistically, then reconcile on the next `/status` poll.
143
+
144
+### Staleness handling
145
+
146
+If `/status` hasn't succeeded in some threshold (e.g. 10–15s), show a "no connection to controller" banner instead of a stale number. Never show a confident-looking number that might be minutes old.
147
+
148
+### Power-off service shape
149
+
150
+Don't use the kernel `gpio-shutdown` overlay for the Power button here — that overlay shuts the board down directly on a GPIO edge with no chance to call the Pi 3 first. Instead, handle the button entirely in `console.py` (via the HAT's button callbacks) so the stop-heating request always fires before `shutdown -h now`.
151
+
152
+## Build Phases
153
+
154
+### Phase 1: Bench bring-up (no tub involved)
155
+
156
+1. Flash Raspberry Pi OS Lite (headless) to the SD card; enable SSH + Wi-Fi via `raspi-config` imaging tool.
157
+2. Boot the Zero, confirm SSH access.
158
+3. `sudo raspi-config nonint do_spi 0` (enable SPI), reboot.
159
+4. Attach the Display HAT Mini, `pip3 install displayhatmini`.
160
+5. Run the library's example script, confirm screen + all 4 buttons register presses over SSH logs.
161
+
162
+### Phase 2: Talk to the Pi 3 (can mock the Pi 3 side first)
163
+
164
+1. Stand up the minimal `/status`, `/heating`, `/target` endpoints on the Pi 3 control daemon (or a temporary mock Flask app for early testing, same pattern as `temp-sensor/app.py`'s mock mode).
165
+2. Write `console.py`: background poll thread → in-memory `state`, render loop drawing `state` to the HAT.
166
+3. Confirm the console shows live numbers from the (mocked) Pi 3.
167
+
168
+### Phase 3: Wire up real button behavior
169
+
170
+1. Map Power/Up/Down to the three endpoints.
171
+2. Implement the hold-to-power-off sequence exactly as above (stop signal → confirm → shutdown).
172
+3. Add the staleness banner and RGB LED status cue.
173
+
174
+### Phase 4: Enclosure + field install
175
+
176
+1. Mount Zero + HAT in the splash-resistant box; route power through a cable gland.
177
+2. Run power to the tub location.
178
+3. Confirm Wi-Fi signal is solid at the install spot; if not, consider the Pi 3 running its own access point, or a Wi-Fi repeater near the tub.
179
+4. Set `console.py` to run as a `systemd` service on boot (`Restart=on-failure`), same pattern you'll use for the Pi 3 daemons.
180
+
181
+## Testing Checklist
182
+
183
+- Screen shows correct average, hot-input, target, and pump states.
184
+- Up/Down change target on the Pi 3 and reflect back within one poll cycle.
185
+- Power short-press enables heating; confirm on the Pi 3 / web UI.
186
+- Power hold: watch the Pi 3 log the disable call, then watch the Zero actually power down.
187
+- Kill Wi-Fi mid-session: console shows a stale/fault banner instead of frozen "good-looking" numbers.
188
+- Force the Pi 3 `/heating` endpoint to fail (e.g. stop the daemon) and confirm the hold sequence refuses to silently power off without feedback.
189
+- Re-power the Zero after a hold-off, confirm it can re-enable heating (or confirm via phone instead).
190
+
191
+## Open Decisions
192
+
193
+- Exact target-temp step size (suggest 1°F per press, repeat-on-hold if you want faster adjustment later).
194
+- Whether the spare 4th button (B, in the primary HAT pick) should do something now (e.g. force filter toggle) or stay reserved.
195
+- Whether console offline for an extended period should trigger any Pi-3-side alert (separate from the explicit stop signal) — worth deciding once the console has run for a while in practice.
projects/hot-tub/software-architecture.md
... ...
@@ -0,0 +1,474 @@
1
+# Software Architecture
2
+
3
+This document describes the software running on the Raspberry Pi 3 controller: four cooperating services plus a local Redis instance used for pub/sub and short-lived shared state. Related hardware detail lives in [hardware-setup.md](hardware-setup.md). Optional clients (tub-side console, indoor monitor) are covered in [pi-zero-control-panel.md](pi-zero-control-panel.md) and [indoor-monitor.md](indoor-monitor.md).
4
+
5
+## Design Principles
6
+
7
+1. **Single decision-maker.** Only the Control Daemon may drive the heat and filter relays (GPIO). Every other process or remote device sends *intents* (mode, target temperature, enable/disable). The Control Daemon validates those intents against safety rules before changing outputs.
8
+2. **Sensors are read-only.** The Temperature Daemon owns the 1-Wire (or sensor-hub) interface. Nothing else bit-bangs the DS18B20 bus.
9
+3. **Redis is the local bus.** Services do not call each other over HTTP for core control. They publish events and subscribe to what they need. Redis also holds the latest snapshot so a newly started subscriber can catch up without waiting for the next publish.
10
+4. **Clients are interchangeable.** Web UI, HTTP API, tub-side console, and indoor monitor all express the same kinds of intents. They never talk to GPIO or sensors directly.
11
+5. **Fail safe.** On Control Daemon crash, restart, or loss of valid temperature data, heat turns off (or stays off). Filter behavior is mode-dependent but must never leave heat running unsupervised after a fault.
12
+6. **Health is explicit and visible.** Temperature, Control, and Web each publish heartbeats/health to Redis. A dedicated Health Daemon watches those signals and drives three status LEDs (one per service). No other process owns those LEDs.
13
+
14
+## System Context
15
+
16
+```text
17
+ +---------------------------+
18
+ | Remote clients |
19
+ | - browser / phone |
20
+ | - tub-side Pi Zero |
21
+ | - indoor LoRa monitor |
22
+ +-------------+-------------+
23
+ |
24
+ Wi-Fi / LoRa bridge
25
+ |
26
++---------------------------------v---------------------------------+
27
+| Raspberry Pi 3 (waterproof enclosure) |
28
+| |
29
+| +----------------+ readings +------------------+ |
30
+| | Temperature | -----------> | | |
31
+| | Daemon | health | Redis | |
32
+| +----------------+ -----------> | (local) | |
33
+| | channels + | |
34
+| +----------------+ state / | key/value | |
35
+| | Control | commands | | |
36
+| | Daemon | <----------> | | |
37
+| | (relays, modes)| health | | |
38
+| +--------+-------+ -----------> | | |
39
+| | GPIO | | |
40
+| v | | |
41
+| Heat / filter relays | | |
42
+| Float / flow inputs | | |
43
+| | | |
44
+| +----------------+ health | | |
45
+| | Web / API | -----------> | | |
46
+| | service | <----------- | | |
47
+| +----------------+ state +--------+---------+ |
48
+| ^ |
49
+| | health:* |
50
+| +------------+--------+ |
51
+| | Health Daemon | |
52
+| | (watches 3 services)| |
53
+| +----------+----------+ |
54
+| | GPIO |
55
+| v |
56
+| LED temps / control / web |
57
++-------------------------------------------------------------------+
58
+ |
59
+ v
60
+ Enclosed AC relay outlets --> heat circ pump / filter pump
61
+```
62
+
63
+GPIO numbers for relays and safety inputs match [hardware-setup.md](hardware-setup.md). If field wiring differs, change pin constants only in the daemon that owns that pin (Control for relays/sensors; Health for status LEDs).
64
+
65
+## Services
66
+
67
+### 1. Temperature Daemon
68
+
69
+**Responsibility:** sample temperature probes on a fixed schedule and publish a structured reading. No control logic, no user-facing API, no GPIO relays.
70
+
71
+**Inputs (hardware):**
72
+
73
+- DS18B20 probes on the Pi 1-Wire bus (`w1-gpio`), or a USB/UART sensor hub if that path is used instead.
74
+- Optional: outside-air probe on the same bus (labeled distinctly in config).
75
+
76
+**Outputs (Redis):**
77
+
78
+- Publish each sample to a channel (e.g. `temps.reading`).
79
+- Write the same payload to a key (e.g. `temps:latest`) so late joiners can `GET` without waiting.
80
+- Publish health / heartbeat to `health.temps` and key `health:temps` (see [Health publishing](#health-publishing)).
81
+
82
+**Suggested payload fields:**
83
+
84
+| Field | Meaning |
85
+|---|---|
86
+| `ts` | ISO-8601 or Unix timestamp of the sample |
87
+| `probes` | Map of device id → °F (or null on CRC/fault) |
88
+| `tub_avg_f` | Average of configured *tub* probes only (excludes hot-input / outdoor) |
89
+| `hot_in_f` | Optional hot-water inlet probe |
90
+| `outdoor_f` | Optional outdoor probe |
91
+| `ok_count` / `fail_count` | How many tub probes contributed / failed |
92
+| `spread_f` | Max − min among valid tub probes (for disagreement checks) |
93
+
94
+**Schedule:** default on the order of 1–5 seconds. Faster than the Control Daemon's decision loop is fine; Redis is cheap. Conversion time for DS18B20 (~750 ms at 12-bit) is owned entirely inside this daemon — subscribers never block on the bus.
95
+
96
+**Hardware considerations:**
97
+
98
+- One 4.7 kΩ pull-up on the data line (or per bus if multiple `w1-gpio` overlays are used).
99
+- Probe identity (ROM id → role: tub1…tub4, hot-in, outdoor) lives in a small config file owned by this daemon, not hardcoded in Control or Web.
100
+- If probes are behind M12 connectors at the enclosure, electrical topology is still a single multidrop bus; the daemon does not care about connectors.
101
+
102
+**Does not:**
103
+
104
+- Drive relays or status LEDs.
105
+- Interpret modes or target temperature.
106
+- Serve HTTP.
107
+
108
+### 2. Control Daemon
109
+
110
+**Responsibility:** sole owner of actuation and operating modes. Subscribes to temperature readings and user intents, applies safety rules, updates relay outputs, records history, and publishes system state.
111
+
112
+**Inputs:**
113
+
114
+| Source | Content |
115
+|---|---|
116
+| Redis `temps.reading` | Latest probe package from Temperature Daemon |
117
+| Redis command channel (e.g. `control.command`) | User intents: set mode, set target °F, acknowledge fault, etc. |
118
+| GPIO inputs | Low-water float (GPIO17), optional flow switch (GPIO27) |
119
+| Local config / Redis keys | Hard max temp, hysteresis band, probe disagreement limit, no-rise timeout |
120
+
121
+**Outputs:**
122
+
123
+| Destination | Content |
124
+|---|---|
125
+| GPIO22 | Heat circulation pump relay |
126
+| GPIO23 | Filter pump relay |
127
+| Redis `control.state` (pub) + `control:latest` (key) | Canonical system state snapshot |
128
+| Redis `health.control` (pub) + `health:control` (key) | Heartbeat / health (see [Health publishing](#health-publishing)) |
129
+| Local store (SQLite or append-only log) | Temperature history and mode/relay change events |
130
+
131
+**Modes (initial set — names can evolve):**
132
+
133
+| Mode | Intent | Typical actuator behavior |
134
+|---|---|---|
135
+| `sleep` / `off` | Idle; safe default after boot | Heat off; filter off (or rare duty cycle later if desired) |
136
+| `maintain` | Hold tub near target temperature | Heat on/off using hysteresis around target; filter per schedule or always-on policy |
137
+| `filter` | Filter only | Heat off; filter on |
138
+| `backflush` | Manual filter maintenance | Heat off; filter on (possibly timed); UI may show a countdown |
139
+| `heat` (optional explicit) | Heat toward target without implying long-term maintain | Same heat logic as maintain; useful if UI wants a distinct label |
140
+
141
+Exact filter scheduling inside `maintain` (always on vs periodic) is a Control Daemon policy, not a Web concern.
142
+
143
+**Decision loop (conceptual):**
144
+
145
+```text
146
+on temps.reading OR on control.command OR on timer:
147
+ load latest temps + GPIO safety inputs + current mode/target
148
+ if safety violation:
149
+ force heat OFF; set fault flags; publish state; return
150
+ apply mode policy → desired heat_on / filter_on
151
+ if outputs changed: write GPIO; log event
152
+ publish control.state
153
+```
154
+
155
+Heat hysteresis example (compatible with the current PoC thinking): turn heat **on** below `target − band_low`, **off** above `target + band_high` (e.g. target 102°F with a 100–104°F band, or a symmetric ±1.5°F around target). The Web UI adjusts *target*; the band lives in Control config.
156
+
157
+**Safety rules (enforced here, not in the Web UI):**
158
+
159
+- No heat if low-water float indicates dry/low.
160
+- No heat if too few tub probes are valid (configurable minimum).
161
+- No heat if probe spread exceeds a hard limit.
162
+- No heat above a hard maximum °F (absolute ceiling, independent of user target).
163
+- No heat if temperature is not rising for N minutes while heat has been commanded on (stuck/no-fuel/no-flow).
164
+- Optional: no heat if flow switch says no flow while heat pump should be running.
165
+- User target is clamped to a safe range before use.
166
+- On daemon start: relays initialized **off** until the first valid sample and mode evaluation.
167
+
168
+**Hardware considerations:**
169
+
170
+- Relays are low-voltage control inputs to enclosed AC modules — the Pi never switches mains directly.
171
+- Confirm active-high vs active-low for the chosen IoT Relay modules; invert in one place in this daemon.
172
+- GPIO access should be exclusive to this process (do not also run `control_poc.py` or other experiments against the same pins).
173
+- Float/flow inputs use internal pull-ups; treat open/closed polarity in config.
174
+
175
+**Persistence:**
176
+
177
+- **Hot state** (mode, target, last fault): Redis keys for fast restart *and* a small on-disk file so a Redis flush does not forget the user's mode.
178
+- **History:** SQLite (or similar) written by Control Daemon — timestamps, tub average, per-probe optional, heat/filter on/off, mode. Web reads history via API (or a read-only DB connection), not by scraping logs.
179
+
180
+**Does not:**
181
+
182
+- Own the 1-Wire bus.
183
+- Drive status LEDs (Health Daemon owns those).
184
+- Render HTML (may expose a tiny local debug socket later, but primary UI is the Web service).
185
+
186
+### 3. Web Interface (+ API)
187
+
188
+**Responsibility:** human-facing UI and machine-facing HTTP API. Translates clicks/API calls into Redis commands; mirrors Redis state into the browser (SSE, WebSocket, or short polling).
189
+
190
+**Inputs:**
191
+
192
+- Redis `control.state` / `temps.reading` (or just `control:latest` if Control embeds the last temp summary).
193
+- HTTP from browsers and local-network clients (tub-side console can use HTTP if preferred over speaking Redis directly).
194
+
195
+**Outputs:**
196
+
197
+- Publish to `control.command` (never write GPIO relays or LEDs).
198
+- Publish health / heartbeat to `health.web` and key `health:web`.
199
+- Optional: serve static UI assets, OpenAPI/simple JSON docs.
200
+
201
+**UI capabilities (v1):**
202
+
203
+- Live tub average + per-probe breakdown + outdoor/hot-in if present.
204
+- Current mode, heat/filter relay state, target temperature, fault banners.
205
+- Controls: select mode, adjust target, clear/acknowledge non-latching faults where allowed.
206
+- Diagnostics: last sample age, probe fail flags, per-service health from Redis (`health:*`), recent history chart.
207
+- Setup: probe role labeling (or link to editing Temperature Daemon config), Wi-Fi status, software version.
208
+
209
+**API sketch (illustrative):**
210
+
211
+| Method | Path | Role |
212
+|---|---|---|
213
+| `GET` | `/api/status` | Latest combined state (from Redis keys) |
214
+| `GET` | `/api/history?from=&to=` | Temperature / event history |
215
+| `POST` | `/api/mode` | `{ "mode": "maintain" }` → Redis command |
216
+| `POST` | `/api/target` | `{ "target_f": 102 }` → Redis command |
217
+| `GET` | `/api/health` | Process + Redis + stale-data checks |
218
+
219
+Auth for v1 can be "local network only" (Pi not exposed to the internet) or a simple shared token; do not invent a full user system until needed.
220
+
221
+**Does not:**
222
+
223
+- Decide whether heat may run.
224
+- Bypass Control Daemon safety by writing pins or "force heat" without going through Control (if a diagnostic force exists, it still lands as a command Control must accept or reject).
225
+- Drive status LEDs.
226
+
227
+### 4. Health Daemon
228
+
229
+**Responsibility:** watch the health of the three functional services (Temperature, Control, Web) via Redis, and drive **three status indicator LEDs** — one LED per service. This is the only process that owns those LED GPIOs.
230
+
231
+**Inputs:**
232
+
233
+| Source | Content |
234
+|---|---|
235
+| Redis `health:temps`, `health:control`, `health:web` | Latest heartbeat from each service (polled on a short timer) |
236
+| Redis `health.temps`, `health.control`, `health.web` (optional) | Pub/sub wakeups so LEDs update immediately on heartbeat |
237
+
238
+**Outputs:**
239
+
240
+| Destination | Content |
241
+|---|---|
242
+| 3× GPIO (status LEDs) | On / off / blink pattern per service |
243
+| Redis `health:summary` (optional) | Aggregated OK/degraded/down for the Web diagnostics page |
244
+
245
+**Evaluation rules (suggested defaults):**
246
+
247
+| Condition | LED meaning |
248
+|---|---|
249
+| Fresh heartbeat, `status: ok` | Solid ON (service healthy) |
250
+| Fresh heartbeat, `status: degraded` | Blink slow (running but impaired — e.g. temps with failing probes) |
251
+| No heartbeat within timeout (e.g. 10–15 s) | Solid OFF or blink fast (service down / stuck) |
252
+| Health Daemon itself starting | All LEDs off until first evaluation pass |
253
+
254
+Timeouts should be longer than each service's heartbeat interval (typically 2–5 s) so a single delayed loop does not flicker the LED.
255
+
256
+**Hardware considerations:**
257
+
258
+- Three LEDs with series resistors (~330 Ω at 3.3V), anodes to GPIO (or cathodes to GPIO if active-low — pick one convention and document it).
259
+- Assign free GPIOs that do **not** collide with Control's heat/filter/float/flow pins or with any HAT used later. Exact BCM numbers live in Health Daemon config (open decision until the enclosure LED layout is fixed).
260
+- Exclusive GPIO ownership: Health Daemon only. Control must not also toggle these pins.
261
+- If the Health Daemon crashes, LEDs freeze in their last state (or go dark if the process cleans up). That is acceptable — pump safety remains Control's job, not the LEDs'.
262
+
263
+**Does not:**
264
+
265
+- Read temperature probes or drive heat/filter relays.
266
+- Interpret operating modes or accept user commands.
267
+- Restart other services by itself (optional future: call systemd; v1 is observe + LEDs only).
268
+
269
+## Health Publishing
270
+
271
+Temperature, Control, and Web each emit a small health document on a fixed interval **and** whenever their health status changes (e.g. temps goes from `ok` → `degraded` when probes drop out).
272
+
273
+### Suggested health payload
274
+
275
+```json
276
+{
277
+ "service": "temps",
278
+ "ts": "2026-10-19T19:00:00Z",
279
+ "status": "ok",
280
+ "detail": "4/4 tub probes",
281
+ "pid": 1234
282
+}
283
+```
284
+
285
+| Field | Meaning |
286
+|---|---|
287
+| `service` | `temps` \| `control` \| `web` |
288
+| `ts` | Time of this heartbeat |
289
+| `status` | `ok` \| `degraded` \| `error` |
290
+| `detail` | Short human string for UI / logs |
291
+| `pid` | Optional; helps confirm the process was replaced after restart |
292
+
293
+### Per-service `status` guidance
294
+
295
+| Service | `ok` | `degraded` | `error` |
296
+|---|---|---|---|
297
+| Temperature | At least the minimum tub probes reading | Some probes failing but still publishing averages | Cannot read bus / zero valid probes |
298
+| Control | Loop running; Redis reachable; GPIO initialized | Running with an active safety fault (heat held off) | Cannot use Redis or GPIO; loop stuck |
299
+| Web | Accepting HTTP; Redis reachable | Redis flaky or stale upstream state | Cannot bind port / Redis down |
300
+
301
+Heartbeats alone are not enough for Control safety (Control still applies its own stale-`temps:latest` rules). Health is for **operators and LEDs**, not a substitute for the heat interlocks.
302
+
303
+## Redis as the Local Bus
304
+
305
+Run Redis bound to localhost only (`bind 127.0.0.1`). No password required if the Pi is a single-trust host on a private LAN and Redis is not reachable off-box; revisit if the Pi ever joins an untrusted network.
306
+
307
+### Channels (pub/sub)
308
+
309
+| Channel | Publisher | Subscribers | Purpose |
310
+|---|---|---|---|
311
+| `temps.reading` | Temperature Daemon | Control, Web | New temperature sample |
312
+| `control.state` | Control Daemon | Web, (optional bridges) | Mode, relays, faults, embedded temp summary |
313
+| `control.command` | Web, bridges, CLI tools | Control Daemon | User / client intents |
314
+| `health.temps` | Temperature Daemon | Health Daemon, Web | Temperature service heartbeat |
315
+| `health.control` | Control Daemon | Health Daemon, Web | Control service heartbeat |
316
+| `health.web` | Web service | Health Daemon | Web service heartbeat |
317
+
318
+### Keys (latest snapshots)
319
+
320
+| Key | Writer | Readers |
321
+|---|---|---|
322
+| `temps:latest` | Temperature Daemon | Control, Web |
323
+| `control:latest` | Control Daemon | Web, bridges |
324
+| `control:config` (optional) | Control / setup tools | Control |
325
+| `health:temps` | Temperature Daemon | Health Daemon, Web |
326
+| `health:control` | Control Daemon | Health Daemon, Web |
327
+| `health:web` | Web service | Health Daemon, Web |
328
+| `health:summary` (optional) | Health Daemon | Web |
329
+
330
+Pub/sub delivers change notifications; keys make restarts and page loads cheap. Prefer **publish then SET** (or SET then publish the same JSON) so a subscriber that misses a message can still recover on the next poll/heartbeat.
331
+
332
+### Command envelope (suggested)
333
+
334
+```json
335
+{
336
+ "ts": "2026-10-19T19:00:00Z",
337
+ "source": "web",
338
+ "id": "uuid-or-monotonic",
339
+ "op": "set_target",
340
+ "args": { "target_f": 102 }
341
+}
342
+```
343
+
344
+Control publishes an ack or reflected state that includes the last applied `id` so UIs can confirm.
345
+
346
+## Process Model and Deployment
347
+
348
+All four services + Redis run on the Pi 3 under systemd (or equivalent), restart on failure.
349
+
350
+| Unit | Notes |
351
+|---|---|
352
+| `redis` | Distro package; localhost only |
353
+| `hot-tub-temps` | Temperature Daemon; publishes readings + health |
354
+| `hot-tub-control` | Control Daemon; `After=` redis preferred; tolerate temps starting late |
355
+| `hot-tub-web` | Web/API; binds to LAN interface (e.g. `:8080`); publishes health |
356
+| `hot-tub-health` | Health Daemon; `After=` redis; drives three status LEDs from `health:*` keys |
357
+
358
+Shared Python package under `src/` (config loading, message schemas, temp helpers, health helpers) is fine; keep each service's entrypoint thin.
359
+
360
+**Ordering on boot:**
361
+
362
+1. Redis up; Health Daemon starts and holds LEDs off until heartbeats appear.
363
+2. Relays held off by Control's initial GPIO state.
364
+3. Temperature Daemon begins publishing readings + health → temps LED comes on.
365
+4. Control waits for a fresh `temps:latest` before allowing heat; publishes its own health → control LED.
366
+5. Web comes up whenever; publishes health → web LED; UI shows "waiting for data" until state keys exist.
367
+
368
+## External Clients (Non-Service)
369
+
370
+These are **not** the three core services. They speak to the Pi 3 as clients.
371
+
372
+| Client | Link | Allowed intents |
373
+|---|---|---|
374
+| Browser / phone | HTTP to Web service | Modes, target, diagnostics |
375
+| Tub-side Pi Zero console | HTTP (or Redis only if on same host — it is not) | Enable/disable heating-oriented modes, target ±, status display |
376
+| Indoor LoRa monitor | LoRa ↔ small bridge process on Pi 3 ↔ Redis commands / state | Target ±1°F and status only |
377
+
378
+A LoRa (or BLE) **bridge** is an optional extra process if needed: translate radio packets ↔ Redis. It must not own pump GPIO or status LEDs. It publishes the same `control.command` messages the Web would. (It is not one of the four core services and does not get its own status LED unless added later.)
379
+
380
+Architectural rule reused everywhere: **intent in, Control decides, state out.**
381
+
382
+## Hardware Ownership Map
383
+
384
+| Hardware | Owning software | Notes |
385
+|---|---|---|
386
+| DS18B20 bus / sensor hub | Temperature Daemon | Exclusive |
387
+| Heat relay GPIO | Control Daemon | Exclusive |
388
+| Filter relay GPIO | Control Daemon | Exclusive |
389
+| Float / flow GPIO | Control Daemon | Read in safety path |
390
+| Status LED — Temperature | Health Daemon | Exclusive; reflects `health:temps` |
391
+| Status LED — Control | Health Daemon | Exclusive; reflects `health:control` |
392
+| Status LED — Web | Health Daemon | Exclusive; reflects `health:web` |
393
+| Enclosure Ethernet/Wi-Fi | OS / Web | Web binds here |
394
+| LoRa SPI module on Pi (if used) | Bridge process | Not Control / not Health |
395
+
396
+## Relationship to Existing PoC Code
397
+
398
+| Artifact | Role going forward |
399
+|---|---|
400
+| `src/read_temps.py` | CLI diagnostic; Temperature Daemon can reuse its read helpers |
401
+| `src/control_poc.py` | Throwaway loop proving GPIO + average + hysteresis — **replace** with Control Daemon; do not run alongside it |
402
+| `temp-sensor/app.py` | Earlier Flask-on-demand design; superseded by Temperature Daemon + Redis for the Pi 3 architecture (still useful if a remote sensor node is ever revived) |
403
+
404
+## Data Flow Examples
405
+
406
+### Maintain temperature
407
+
408
+```text
409
+User sets mode=maintain, target=102 via Web
410
+ → control.command
411
+ → Control stores mode/target, publishes control.state
412
+
413
+Temperature Daemon publishes tub_avg_f=99.2
414
+ → Control: avg below band → heat GPIO ON, publish state
415
+ → Web updates UI / SSE
416
+
417
+Temperature Daemon publishes tub_avg_f=104.5
418
+ → Control: avg above band → heat GPIO OFF, publish state
419
+```
420
+
421
+### Low water while heating
422
+
423
+```text
424
+Float opens (low water)
425
+ → Control safety: heat forced OFF, fault=low_water, publish state
426
+ → Web shows fault; further "maintain" requests do not energize heat until clear
427
+```
428
+
429
+### Indoor monitor nudge
430
+
431
+```text
432
+LoRa packet: target +1
433
+ → bridge → control.command set_target(current+1)
434
+ → Control clamps, applies, publishes control.state (with new target)
435
+ → bridge returns status downlink to monitor
436
+```
437
+
438
+### Service dies → LED drops
439
+
440
+```text
441
+Temperature Daemon stops (crash or kill)
442
+ → health:temps timestamp goes stale
443
+ → Health Daemon timeout fires → Temperature LED OFF (or fast blink)
444
+ → Control independently sees stale temps:latest → heat forced OFF
445
+ → Control health may stay ok (or degraded if it reports "no temps")
446
+ → Web can show "temps down" from health:temps / health:summary
447
+```
448
+
449
+## Suggested Build Phases (Software)
450
+
451
+1. **Redis + Temperature Daemon** — publish real probe averages + health; verify with `redis-cli` and `read_temps.py`.
452
+2. **Control Daemon** — subscribe, implement `sleep` / `maintain` / `filter`, GPIO, safety stubs, state + health publish. Retire `control_poc.py` for day-to-day use.
453
+3. **Web status page** — read-only mirror of `control:latest` / `temps:latest`; publish web health.
454
+4. **Health Daemon + three LEDs** — watch `health:*`, drive indicators; confirm kill of each service extinguishes the matching LED.
455
+5. **Web controls** — mode + target commands; history charts; show health summary.
456
+6. **Client bridges** — tub-side HTTP client; LoRa bridge for indoor monitor.
457
+7. **Hardening** — systemd watchdog, stale-data timeouts, backup of config/history, structured logging.
458
+
459
+## Open Decisions
460
+
461
+- Exact mode list and whether `backflush` is timed or latching until cancelled.
462
+- History store: SQLite file path and retention (days/weeks).
463
+- Whether Web embeds a WebSocket or uses Server-Sent Events for live updates.
464
+- Auth model for non-local access (prefer none initially; VPN or Tailscale if remote).
465
+- Probe role config format (JSON file vs Redis `control:config` edited by setup UI).
466
+- Active-high vs active-low relay polarity once modules are chosen.
467
+- BCM pin numbers for the three status LEDs (must not collide with Control GPIO).
468
+- LED patterns for `degraded` vs `down` (slow blink vs fast blink vs off).
469
+- Health heartbeat interval and stale timeout values.
470
+
471
+## Document History
472
+
473
+- Initial architecture: three services (Temperature, Control, Web) + local Redis pub/sub, aligned with Pi 3 as sole actuator owner.
474
+- Added Health Daemon as fourth service: Temperature / Control / Web publish health to Redis; Health Daemon monitors those heartbeats and owns three status LEDs (one per service).
projects/hot-tub/src/__pycache__/read_temps.cpython-314.pyc
... ...
Binary files /dev/null and b/projects/hot-tub/src/__pycache__/read_temps.cpython-314.pyc differ
projects/hot-tub/src/control/README.md
... ...
@@ -0,0 +1,346 @@
1
+# Control Daemon
2
+
3
+Sole owner of heat and filter pump relays on the Pi. Consumes temperature readings and user commands from Redis, applies mode / safety logic, drives GPIO, and publishes canonical system state (and health) back to Redis.
4
+
5
+See [software-architecture.md](../../software-architecture.md) for system context. GPIO pin defaults match [hardware-setup.md](../../hardware-setup.md) (heat relay GPIO22, filter relay GPIO23 unless config overrides).
6
+
7
+## Responsibility
8
+
9
+| Does | Does not |
10
+|---|---|
11
+| Subscribe to Redis commands + temperature readings | Read the 1-Wire bus itself |
12
+| Own heat / filter **modes** (intent) and **pump states** (actual on/off) | Drive status LEDs (Health Daemon) |
13
+| Run heat **phase** FSM (startup, heating, backflush, error, …) | Serve the web UI |
14
+| Enforce safety interlocks before energizing heat | Guess probe identity |
15
+| Persist mode / target / last error across restarts | |
16
+| Log useful operational output | |
17
+
18
+## Naming: modes vs pump state vs heat phase
19
+
20
+You effectively have three layers for heat (filter is simpler):
21
+
22
+| Layer | Suggested name | Meaning | Examples |
23
+|---|---|---|---|
24
+| Intent | `heat_mode` | What the user/system asked for | `sleep`, `maintain`, `backflush` |
25
+| Actuator | `heat_pump` | Physical relay / pump right now | `on`, `off` |
26
+| Progress | **`heat_phase`** | Where the controller is inside the current heat behavior | `idle`, `startup_1`…`startup_3`, `heating`, `backflush`, `error_startup` |
27
+
28
+**Recommended name for the third parameter: `heat_phase`** (not a generic “operational state”).
29
+
30
+- “Operational state” is understandable but vague — it could mean the whole appliance, including filter, network, etc.
31
+- Almost every value you listed (startup attempts, heating, propane/backflush error, timed backflush) is a **phase of the heat control state machine**.
32
+- Keeping the name under `heat_*` pairs cleanly with `heat_mode` and `heat_pump`, and leaves room later for something like `filter_phase` if auto-filter grows a schedule FSM.
33
+
34
+Aliases if you prefer different wording in the UI: show “Status: Starting (attempt 2)” while the Redis field remains `heat_phase`.
35
+
36
+Filter side for v1:
37
+
38
+| Field | Values |
39
+|---|---|
40
+| `filter_mode` | `on` \| `auto` \| `off` (intent) |
41
+| `filter_pump` | `on` \| `off` (actual) |
42
+
43
+(`auto` = daily timed run; start time and duration live in Control config / eventually Web settings.)
44
+
45
+## Planned layout
46
+
47
+```text
48
+src/control/
49
+ README.md ← this file
50
+ __init__.py
51
+ __main__.py # python -m control / systemd ExecStart
52
+ daemon.py # loop: commands + temps + timers → decide → GPIO → publish
53
+ commands.py # parse / validate control.command payloads
54
+ state.py # in-memory + persist heat_mode, filter_mode, target, heat_phase, …
55
+ heat_fsm.py # maintain startup sequence, heating, backflush timer, errors
56
+ filter_policy.py # on/off/auto; daily schedule for auto
57
+ safety.py # max temp, stale temps, probe faults (float later)
58
+ gpio_out.py # heat / filter relays (gpiozero or RPi.GPIO)
59
+ redis_io.py # subscribe commands/temps; SET/PUBLISH state + health
60
+ logging_setup.py # file logger → /var/log/hot-tub/control.log
61
+ config.example.json
62
+```
63
+
64
+## Inputs (Redis pub/sub)
65
+
66
+Subscribe to a command channel (e.g. `control.command`). Each message is a small JSON envelope:
67
+
68
+```json
69
+{
70
+ "ts": "2026-10-19T20:00:00Z",
71
+ "source": "web",
72
+ "id": "unique-request-id",
73
+ "op": "set_heat_mode",
74
+ "args": { "mode": "maintain" }
75
+}
76
+```
77
+
78
+### Command ops
79
+
80
+| `op` | Args | Effect |
81
+|---|---|---|
82
+| `set_heat_mode` | `mode`: `sleep` \| `maintain` \| `backflush` | Set heat intent; may start/stop FSM |
83
+| `set_target_temp` | `target_f`: number | Absolute target (°F), clamped to safe range |
84
+| `target_temp_increment` | optional `delta_f` (default `1`) | `target_f += delta` |
85
+| `target_temp_decrement` | optional `delta_f` (default `1`) | `target_f -= delta` |
86
+| `set_filter_mode` | `mode`: `on` \| `auto` \| `off` | Set filter intent |
87
+| `ack_error` (optional later) | — | Clear other latching errors if added; `error_startup` clears via `sleep` |
88
+
89
+Also consume temperature updates (e.g. `temps.reading` / `temps:latest`) for `tub_avg_f` and `hot_in_f`. Do not act on heat if temps are stale or setup is `need_reinstall`.
90
+
91
+## Published state
92
+
93
+On every meaningful change (and on a slow heartbeat), publish the full snapshot:
94
+
95
+- `SET control:latest`
96
+- `PUBLISH control.state`
97
+
98
+Suggested fields:
99
+
100
+```json
101
+{
102
+ "ts": "2026-10-19T20:00:05Z",
103
+ "heat_mode": "maintain",
104
+ "filter_mode": "auto",
105
+ "target_f": 102.0,
106
+ "tub_avg_f": 98.5,
107
+ "hot_in_f": 112.0,
108
+ "heat_pump": "on",
109
+ "filter_pump": "off",
110
+ "heat_phase": "heating",
111
+ "startup_attempt": 1,
112
+ "error": null,
113
+ "last_command_id": "…"
114
+}
115
+```
116
+
117
+| Field | Role |
118
+|---|---|
119
+| `heat_mode` / `filter_mode` | Intent |
120
+| `heat_pump` / `filter_pump` | Exact actuator reality (on/off) |
121
+| `heat_phase` | FSM progress / fault class |
122
+| `error` | Machine-readable code + human `detail` when phase is an error |
123
+
124
+### `heat_phase` values (initial set)
125
+
126
+| Phase | Meaning |
127
+|---|---|
128
+| `idle` | No heat activity (`sleep`, or maintain satisfied / waiting to need heat) |
129
+| `startup_1` … `startup_3` | Maintain startup attempt in progress (pump on, waiting for hot-in rise check) |
130
+| `heating` | Startup succeeded; pump on until tub reaches `target_f` |
131
+| `backflush` | Timed backflush running (heat pump on for countdown) |
132
+| `error_startup` | Three startup attempts failed — likely empty propane or needs backflush; cleared by `sleep` |
133
+| `error_safety` | Optional: blocked by safety interlock (max temp, stale probes, …) |
134
+
135
+## Heat behavior
136
+
137
+### `heat_mode = sleep`
138
+
139
+- `heat_pump` → `off`
140
+- Clear startup attempt counters
141
+- **`error_startup` is cleared** — set `heat_phase` → `idle` and clear the published `error` for that fault (log the reset). Entering sleep is the operator reset path; no separate ack required for startup failures.
142
+
143
+### `heat_mode = maintain`
144
+
145
+**`target_f` is always the maximum temperature** for heating — the pump must turn **off** at or above target. There is no symmetric band above target.
146
+
147
+Maintain band (v1 fixed, may become configurable later):
148
+
149
+| Condition | Action |
150
+|---|---|
151
+| `tub_avg_f <= target_f - 5` | Need heat → run startup (if pump off) or keep heating |
152
+| `tub_avg_f >= target_f` | Target reached → `heat_pump` **off**, `heat_phase = idle` |
153
+| Between `target_f - 5` and `target_f` | If already heating, keep going until target; if pump is off, stay idle (do not start a new cycle until at or below the low edge) |
154
+
155
+So the active window is **`[target_f - 5, target_f]`** with hysteresis at the bottom: restart only at/below **target − 5**, stop at **target**.
156
+
157
+When maintain wants heat and `heat_pump` is currently **off**, run the **startup sequence** (do not jump straight to indefinite heating):
158
+
159
+1. Set `heat_phase = startup_N` (`N` = attempt 1..3), turn **`heat_pump` on**.
160
+2. After **20 seconds**, compare sensors:
161
+ - Success if `hot_in_f >= tub_avg_f + HOT_IN_RISE_F` (default **`HOT_IN_RISE_F = 10`**).
162
+ - Failure otherwise (missing `hot_in_f` counts as failure).
163
+3. On failure: turn **`heat_pump` off**, brief settle delay (config, e.g. a few seconds), increment attempt, repeat steps 1–2.
164
+4. After **3 failed attempts**: `heat_pump` off, `heat_phase = error_startup`, log + publish error (e.g. `code: "startup_failed"`, detail suggesting **empty propane** or **backflush needed**). Stay latched until the operator sets **`heat_mode = sleep`** (which clears the error).
165
+5. On success: `heat_phase = heating`, keep **`heat_pump` on** until `tub_avg_f >= target_f`, then pump off and `heat_phase = idle` (still in `heat_mode = maintain`, waiting until avg falls to `target_f - 5` or below).
166
+
167
+If temperature later falls to **`target_f - 5` or below**, start a **new** startup sequence from attempt 1 (cold propane / flame-out is possible between cycles).
168
+
169
+```text
170
+maintain + need heat + pump off
171
+ │
172
+ v
173
+ startup_1 ──fail──> off ──> startup_2 ──fail──> off ──> startup_3
174
+ │ success │ success │ success
175
+ v v v
176
+ heating <─────────────── heating <────────── heating
177
+ │ │ fail
178
+ │ target reached v
179
+ v error_startup
180
+ idle (mode still maintain)
181
+```
182
+
183
+### `heat_mode = backflush`
184
+
185
+Separate intent: clear / purge path using the heat circulation pump on a short timer.
186
+
187
+1. Enter `heat_phase = backflush`, `heat_pump` **on**.
188
+2. After **10 seconds**, `heat_pump` **off**, `heat_phase = idle`.
189
+3. Return `heat_mode` to `sleep` (recommended) or leave `backflush` as a one-shot that auto-reverts — pick one and document in config; **auto-revert to `sleep`** avoids leaving a sticky mode that does nothing.
190
+
191
+Do not run the maintain hot-in rise check during backflush (no expectation of flame). Other safety interlocks still apply.
192
+
193
+## Filter behavior
194
+
195
+| `filter_mode` | `filter_pump` |
196
+|---|---|
197
+| `off` | `off` |
198
+| `on` | `on` (continuous until mode changes) |
199
+| `auto` | `on` only during the daily run window; otherwise `off` |
200
+
201
+### `filter_mode = auto`
202
+
203
+Runs **once per calendar day** on a wall-clock schedule (Pi local timezone unless config specifies otherwise):
204
+
205
+| Setting | Default | Meaning |
206
+|---|---|---|
207
+| `filter_start_time` | `00:00` (midnight) | Time of day the daily filter cycle starts |
208
+| `filter_run_time` | `2` hours | How long the filter pump stays on after start |
209
+
210
+Behavior:
211
+
212
+1. While `filter_mode = auto`, each day at `filter_start_time` set `filter_pump` **on** and record the run start.
213
+2. When elapsed runtime reaches `filter_run_time`, set `filter_pump` **off** until the next day's start.
214
+3. If the daemon starts (or mode switches to `auto`) **during** today's window, turn the pump on for the **remaining** time only (do not run a full extra 2 hours past the window end). If the window for today is already over, stay off until tomorrow's start.
215
+4. Switching to `on` or `off` cancels the schedule for as long as that mode is active; returning to `auto` re-evaluates the current window immediately.
216
+5. Filter schedule is **independent of heat** — maintain/backflush do not force the filter on or off.
217
+
218
+Publish optional convenience fields on `control.state` (e.g. `filter_window_active`, `filter_next_start`) so the UI can show when the next/current auto run is.
219
+
220
+Backflush remains a **heat_mode** (heat circ pump), not filter_mode — naming matches the propane heater loop, not the cartridge filter pump.
221
+
222
+## Safety (always wins)
223
+
224
+Before any `heat_pump = on`:
225
+
226
+- Valid, fresh tub average (and hot-in when required for startup check)
227
+- `tub_avg_f < target_f` (target is the max — never heat at or above target)
228
+- Probe map / temps not in `need_reinstall`
229
+- Not latched in `error_startup` (clear by setting `heat_mode = sleep` first)
230
+
231
+**Water level:** there is **no float sensor for now**. Adequate water is **presumed**. Do not gate heat on a float input in v1; leave a clear hook in `safety.py` for a future low-water check when hardware is added ([hardware-setup.md](../../hardware-setup.md) still describes GPIO17 as the eventual pin).
232
+
233
+On safety trip: force heat pump off, set `heat_phase = error_safety` (or stay `idle` with `error` set — prefer explicit phase for UI LEDs/banners).
234
+
235
+## Health
236
+
237
+Publish on an interval and on status changes:
238
+
239
+- `SET health:control` + `PUBLISH health.control`
240
+- `ok` while loop running and Redis/GPIO usable
241
+- `degraded` if active `error_*` phase or stale temps while mode wants heat
242
+- `error` if GPIO/Redis hard-fail
243
+
244
+## Operational requirements
245
+
246
+### systemd (start on boot)
247
+
248
+- Unit suggestion: `hot-tub-control.service`
249
+- `WantedBy=multi-user.target`, `Restart=on-failure`
250
+- `After=redis.service` (and preferably after temps is up, but tolerate missing temps with heat held off)
251
+- Exclusive use of heat/filter GPIO — do not run experimental relay scripts alongside this service
252
+
253
+### Logging
254
+
255
+- **All useful output** goes to `/var/log/hot-tub/control.log`: mode changes, commands received, startup attempt pass/fail (with temps), phase transitions, GPIO writes, safety trips, Redis reconnects, startup/shutdown
256
+- Prefer stdlib `logging` + system **logrotate** (same pattern as other hot-tub services)
257
+
258
+### logrotate
259
+
260
+Example `/etc/logrotate.d/hot-tub-control`:
261
+
262
+```text
263
+/var/log/hot-tub/control.log {
264
+ weekly
265
+ rotate 8
266
+ compress
267
+ delaycompress
268
+ missingok
269
+ notifempty
270
+ copytruncate
271
+}
272
+```
273
+
274
+### Persistence
275
+
276
+Persist at least: `heat_mode`, `filter_mode`, `target_f`, last `error` / whether `error_startup` is latched. On boot: relays **off**, reload intents, resume carefully (if `heat_mode=maintain` and tub is cold, start a fresh startup sequence rather than assuming flame is lit).
277
+
278
+## Config sketch
279
+
280
+```json
281
+{
282
+ "redis_url": "redis://127.0.0.1:6379/0",
283
+ "log_path": "/var/log/hot-tub/control.log",
284
+ "state_path": "/var/lib/hot-tub/control-state.json",
285
+ "gpio": {
286
+ "heat_pump": 22,
287
+ "filter_pump": 23,
288
+ "active_high": true
289
+ },
290
+ "maintain": {
291
+ "band_f": 5.0,
292
+ "startup_delay_s": 20,
293
+ "hot_in_rise_f": 10.0,
294
+ "startup_attempts": 3,
295
+ "retry_off_s": 5
296
+ },
297
+ "backflush": {
298
+ "duration_s": 10,
299
+ "revert_to_heat_mode": "sleep"
300
+ },
301
+ "filter_auto": {
302
+ "start_time": "00:00",
303
+ "run_time_hours": 2,
304
+ "timezone": "local"
305
+ },
306
+ "target_f_default": 102,
307
+ "target_f_min": 50,
308
+ "target_f_max": 110
309
+}
310
+```
311
+
312
+`band_f` is the gap **below** target that restarts heating: on at `target_f - band_f`, off at `target_f`. V1 ships with `band_f = 5`; making it configurable is a later nicety. `target_f_min` / `target_f_max` only clamp what the user is allowed to *set* as target — once set, that target is the heating ceiling.
313
+
314
+## Implementation plan
315
+
316
+| Phase | Work |
317
+|---|---|
318
+| 1 | `state.py` + `commands.py`: apply ops in memory; no GPIO |
319
+| 2 | `redis_io.py`: subscribe commands; publish `control.state` / `control:latest` + health |
320
+| 3 | `gpio_out.py` + dry-run flag; map `heat_pump` / `filter_pump` to relays |
321
+| 4 | `heat_fsm.py`: maintain startup (20 s, +10°F, 3 tries), heating to target, backflush 10 s |
322
+| 5 | `safety.py` + consume `temps:latest`; refuse heat when stale/faulty |
323
+| 6 | `logging_setup.py`, systemd unit, logrotate; persist state file |
324
+| 7 | Integration: drive modes from `redis-cli PUBLISH`; confirm phase transitions in log + `GET control:latest` |
325
+
326
+## Manual checks (on the Pi)
327
+
328
+```bash
329
+cd /path/to/hot-tub/src && PYTHONPATH=. python3 -m control
330
+
331
+redis-cli PUBLISH control.command '{"op":"set_target_temp","args":{"target_f":102},"id":"1","source":"cli"}'
332
+redis-cli PUBLISH control.command '{"op":"set_heat_mode","args":{"mode":"maintain"},"id":"2","source":"cli"}'
333
+redis-cli GET control:latest
334
+redis-cli SUBSCRIBE control.state
335
+
336
+tail -f /var/log/hot-tub/control.log
337
+```
338
+
339
+## Open decisions
340
+
341
+- Settle time between startup attempts and whether hot-in check uses instantaneous samples or a short average.
342
+- Active-high vs active-low relay modules once hardware is finalized.
343
+- When to add the low-water float and wire it into `safety.py`.
344
+- When (if ever) to expose `band_f` in the UI vs keep it config-only.
345
+- Whether Web can edit `filter_start_time` / `filter_run_time` or those stay config-file-only for v1.
346
+- Timezone source if the Pi clock is wrong (`local` vs explicit IANA zone + NTP requirement).
projects/hot-tub/src/control_poc.py
... ...
@@ -0,0 +1,211 @@
1
+#!/usr/bin/env python3
2
+"""Proof-of-concept control loop for the hot tub.
3
+
4
+Every LOOP_INTERVAL seconds:
5
+ 1. Read all DS18B20 probes and average the valid readings (F).
6
+ 2. Toggle the filter pump relay.
7
+ 3. Heat pump: ON if avg < HEAT_ON_BELOW_F, OFF if avg > HEAT_OFF_ABOVE_F.
8
+ Between those thresholds the heat relay is left unchanged (hysteresis).
9
+
10
+Usage (on the Pi, with 1-Wire overlay enabled):
11
+ python3 control_poc.py
12
+ python3 control_poc.py --dry-run # print actions, no GPIO
13
+ python3 control_poc.py --interval 5
14
+
15
+Ctrl+C turns both relays OFF before exit.
16
+
17
+GPIO note: this script uses FILTER=22 / HEAT=23 as requested for the PoC.
18
+hardware-setup.md currently lists the reverse (heat=22, filter=23) — change
19
+the constants below if your wiring matches the doc.
20
+"""
21
+
22
+from __future__ import annotations
23
+
24
+import argparse
25
+import sys
26
+import time
27
+from pathlib import Path
28
+
29
+from read_temps import DEFAULT_DEVICES_PATH, c_to_f, find_devices, read_temp_c
30
+
31
+# --- Pin / behavior knobs -----------------------------------------------------
32
+FILTER_GPIO = 22
33
+HEAT_GPIO = 23
34
+
35
+HEAT_ON_BELOW_F = 100.0
36
+HEAT_OFF_ABOVE_F = 104.0
37
+LOOP_INTERVAL_S = 5.0
38
+
39
+
40
+def average_temp_f(devices_path: Path) -> tuple[float | None, list[tuple[str, float | None]]]:
41
+ """Return (avg_f or None, [(device_id, temp_f or None), ...])."""
42
+ readings: list[tuple[str, float | None]] = []
43
+ temps_f: list[float] = []
44
+
45
+ for device_id in find_devices(devices_path):
46
+ temp_c = read_temp_c(devices_path, device_id)
47
+ if temp_c is None:
48
+ readings.append((device_id, None))
49
+ continue
50
+ temp_f = c_to_f(temp_c)
51
+ readings.append((device_id, temp_f))
52
+ temps_f.append(temp_f)
53
+
54
+ if not temps_f:
55
+ return None, readings
56
+ return sum(temps_f) / len(temps_f), readings
57
+
58
+
59
+class RelayOutputs:
60
+ """Thin wrapper so --dry-run can skip real GPIO."""
61
+
62
+ def __init__(self, dry_run: bool) -> None:
63
+ self.dry_run = dry_run
64
+ self.filter_on = False
65
+ self.heat_on = False
66
+ self._filter = None
67
+ self._heat = None
68
+
69
+ if dry_run:
70
+ return
71
+
72
+ try:
73
+ from gpiozero import OutputDevice
74
+ except ImportError as exc:
75
+ raise SystemExit(
76
+ "gpiozero is required on the Pi. Install with:\n"
77
+ " sudo apt install python3-gpiozero\n"
78
+ "Or run with --dry-run to exercise the loop without GPIO."
79
+ ) from exc
80
+
81
+ # active_high=True: GPIO high energizes the relay control input.
82
+ # Invert if your IoT Relay / module is active-low.
83
+ self._filter = OutputDevice(FILTER_GPIO, active_high=True, initial_value=False)
84
+ self._heat = OutputDevice(HEAT_GPIO, active_high=True, initial_value=False)
85
+
86
+ def set_filter(self, on: bool) -> None:
87
+ self.filter_on = on
88
+ if self._filter is not None:
89
+ self._filter.on() if on else self._filter.off()
90
+
91
+ def set_heat(self, on: bool) -> None:
92
+ self.heat_on = on
93
+ if self._heat is not None:
94
+ self._heat.on() if on else self._heat.off()
95
+
96
+ def toggle_filter(self) -> bool:
97
+ self.set_filter(not self.filter_on)
98
+ return self.filter_on
99
+
100
+ def all_off(self) -> None:
101
+ self.set_filter(False)
102
+ self.set_heat(False)
103
+
104
+ def close(self) -> None:
105
+ self.all_off()
106
+ if self._filter is not None:
107
+ self._filter.close()
108
+ if self._heat is not None:
109
+ self._heat.close()
110
+
111
+
112
+def decide_heat(avg_f: float | None, heat_currently_on: bool) -> bool | None:
113
+ """Return desired heat state, or None to leave unchanged.
114
+
115
+ None also covers "no valid average" — heat must not turn on without a reading.
116
+ If heat is already on and sensors disappear, turn it off for safety.
117
+ """
118
+ if avg_f is None:
119
+ return False if heat_currently_on else None
120
+ if avg_f < HEAT_ON_BELOW_F:
121
+ return True
122
+ if avg_f > HEAT_OFF_ABOVE_F:
123
+ return False
124
+ return None
125
+
126
+
127
+def run_loop(devices_path: Path, interval_s: float, dry_run: bool) -> None:
128
+ relays = RelayOutputs(dry_run=dry_run)
129
+ mode = "DRY-RUN" if dry_run else "LIVE"
130
+ print(
131
+ f"control_poc starting ({mode}): "
132
+ f"filter=GPIO{FILTER_GPIO}, heat=GPIO{HEAT_GPIO}, "
133
+ f"heat on <{HEAT_ON_BELOW_F:g}F / off >{HEAT_OFF_ABOVE_F:g}F, "
134
+ f"interval={interval_s:g}s"
135
+ )
136
+ print("Ctrl+C to stop (relays forced OFF).\n")
137
+
138
+ try:
139
+ while True:
140
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
141
+ avg_f, readings = average_temp_f(devices_path)
142
+
143
+ print(f"--- {stamp} ---")
144
+ if not readings:
145
+ print(" no 1-Wire devices found")
146
+ for device_id, temp_f in readings:
147
+ if temp_f is None:
148
+ print(f" {device_id}: --")
149
+ else:
150
+ print(f" {device_id}: {temp_f:6.2f} F")
151
+
152
+ if avg_f is None:
153
+ print(" avg: -- (no valid readings)")
154
+ else:
155
+ print(f" avg: {avg_f:6.2f} F ({len([r for r in readings if r[1] is not None])} probes)")
156
+
157
+ filter_on = relays.toggle_filter()
158
+ print(f" filter: {'ON ' if filter_on else 'OFF'} (toggled)")
159
+
160
+ desired_heat = decide_heat(avg_f, relays.heat_on)
161
+ if desired_heat is None:
162
+ print(f" heat: {'ON ' if relays.heat_on else 'OFF'} (unchanged)")
163
+ else:
164
+ relays.set_heat(desired_heat)
165
+ reason = (
166
+ "no sensors"
167
+ if avg_f is None
168
+ else f"avg {'<' if desired_heat else '>'} "
169
+ f"{HEAT_ON_BELOW_F if desired_heat else HEAT_OFF_ABOVE_F:g}F"
170
+ )
171
+ print(f" heat: {'ON ' if desired_heat else 'OFF'} ({reason})")
172
+
173
+ print()
174
+ time.sleep(interval_s)
175
+ except KeyboardInterrupt:
176
+ print("\nStopping — turning relays OFF.")
177
+ finally:
178
+ relays.close()
179
+ print("Done.")
180
+
181
+
182
+def main() -> None:
183
+ parser = argparse.ArgumentParser(description=__doc__)
184
+ parser.add_argument(
185
+ "--devices-path",
186
+ type=Path,
187
+ default=DEFAULT_DEVICES_PATH,
188
+ help=f"1-Wire devices directory (default: {DEFAULT_DEVICES_PATH})",
189
+ )
190
+ parser.add_argument(
191
+ "--interval",
192
+ type=float,
193
+ default=LOOP_INTERVAL_S,
194
+ help=f"seconds between loop iterations (default: {LOOP_INTERVAL_S})",
195
+ )
196
+ parser.add_argument(
197
+ "--dry-run",
198
+ action="store_true",
199
+ help="print decisions without driving GPIO",
200
+ )
201
+ args = parser.parse_args()
202
+
203
+ if args.interval <= 0:
204
+ print("--interval must be > 0", file=sys.stderr)
205
+ sys.exit(2)
206
+
207
+ run_loop(args.devices_path, args.interval, args.dry_run)
208
+
209
+
210
+if __name__ == "__main__":
211
+ main()
projects/hot-tub/src/read_temps.py
... ...
@@ -0,0 +1,117 @@
1
+#!/usr/bin/env python3
2
+"""Quick command-line test: read every DS18B20 on the 1-Wire bus.
3
+
4
+Usage:
5
+ python3 read_temps.py # read once, print, exit
6
+ python3 read_temps.py --watch # keep reading every 2s until Ctrl+C
7
+ python3 read_temps.py --watch -i 5 # custom interval in seconds
8
+
9
+No external dependencies. Must be run on the Raspberry Pi with the
10
+1-Wire overlay enabled (see hardware-setup.md), as a user that can
11
+read /sys/bus/w1/devices (root, or a member of a group with access).
12
+"""
13
+
14
+from __future__ import annotations
15
+
16
+import argparse
17
+import sys
18
+import time
19
+from pathlib import Path
20
+
21
+DEFAULT_DEVICES_PATH = Path("/sys/bus/w1/devices")
22
+
23
+
24
+def c_to_f(temp_c: float) -> float:
25
+ return temp_c * 9.0 / 5.0 + 32.0
26
+
27
+
28
+def find_devices(devices_path: Path) -> list[str]:
29
+ if not devices_path.exists():
30
+ return []
31
+ return sorted(p.name for p in devices_path.iterdir() if p.name.startswith("28-"))
32
+
33
+
34
+def read_temp_c(devices_path: Path, device_id: str) -> float | None:
35
+ """Return temperature in Celsius, or None if the read/parse fails."""
36
+ slave_file = devices_path / device_id / "w1_slave"
37
+ try:
38
+ text = slave_file.read_text()
39
+ except OSError as exc:
40
+ print(f" ! could not read {slave_file}: {exc}", file=sys.stderr)
41
+ return None
42
+
43
+ lines = text.strip().splitlines()
44
+ if len(lines) < 2 or "YES" not in lines[0]:
45
+ return None # CRC check failed
46
+
47
+ marker = "t="
48
+ if marker not in lines[1]:
49
+ return None
50
+
51
+ try:
52
+ milli_c = int(lines[1].split(marker, 1)[1])
53
+ except ValueError:
54
+ return None
55
+
56
+ if milli_c == 85000:
57
+ # Datasheet power-on / fault sentinel value.
58
+ return None
59
+
60
+ return milli_c / 1000.0
61
+
62
+
63
+def read_all(devices_path: Path) -> None:
64
+ device_ids = find_devices(devices_path)
65
+
66
+ if not device_ids:
67
+ print(f"No 1-Wire devices found under {devices_path}.")
68
+ print("Check: overlay enabled, wiring, and 4.7k pull-up to 3.3V.")
69
+ return
70
+
71
+ print(f"Found {len(device_ids)} device(s):")
72
+ for device_id in device_ids:
73
+ temp_c = read_temp_c(devices_path, device_id)
74
+ if temp_c is None:
75
+ print(f" {device_id}: -- (no reading / CRC fail)")
76
+ else:
77
+ temp_f = c_to_f(temp_c)
78
+ print(f" {device_id}: {temp_c:6.3f} C {temp_f:6.3f} F")
79
+
80
+
81
+def main() -> None:
82
+ parser = argparse.ArgumentParser(description=__doc__)
83
+ parser.add_argument(
84
+ "--devices-path",
85
+ type=Path,
86
+ default=DEFAULT_DEVICES_PATH,
87
+ help=f"1-Wire devices directory (default: {DEFAULT_DEVICES_PATH})",
88
+ )
89
+ parser.add_argument(
90
+ "--watch",
91
+ action="store_true",
92
+ help="keep reading on a loop until Ctrl+C",
93
+ )
94
+ parser.add_argument(
95
+ "-i",
96
+ "--interval",
97
+ type=float,
98
+ default=2.0,
99
+ help="seconds between reads in --watch mode (default: 2.0)",
100
+ )
101
+ args = parser.parse_args()
102
+
103
+ if not args.watch:
104
+ read_all(args.devices_path)
105
+ return
106
+
107
+ try:
108
+ while True:
109
+ print(f"\n--- {time.strftime('%Y-%m-%d %H:%M:%S')} ---")
110
+ read_all(args.devices_path)
111
+ time.sleep(args.interval)
112
+ except KeyboardInterrupt:
113
+ print("\nStopped.")
114
+
115
+
116
+if __name__ == "__main__":
117
+ main()
projects/hot-tub/src/temp/README.md
... ...
@@ -0,0 +1,236 @@
1
+# Temperature Daemon
2
+
3
+Polls DS18B20 probes on a fixed schedule, logs every sample, and publishes temperature (and health) to a local Redis instance. This is the only process that should touch the 1-Wire bus.
4
+
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
+
7
+## Responsibility
8
+
9
+| Does | Does not |
10
+|---|---|
11
+| Read `/sys/bus/w1/devices/28-*` (or configured path) | Drive heat/filter relays |
12
+| Learn / persist which ROM ids are tub vs hot-in | Interpret modes / target temp |
13
+| Average **tub** probes only for `tub_avg_f` | Serve HTTP or drive status LEDs |
14
+| Publish readings + health (+ setup errors) to Redis | Own GPIO of any kind |
15
+| Log useful operational output | |
16
+
17
+## Probe bundles and identity setup
18
+
19
+Probes are wired as **two pluggable bundles** (e.g. separate M12 drops), not five independent mystery cables:
20
+
21
+| Bundle | Count | Role |
22
+|---|---|---|
23
+| Water temperature | **exactly 4** DS18B20s | Tub probes → averaged as `tub_avg_f` |
24
+| Hot-water input | **exactly 1** DS18B20 | Inlet / heater-side → `hot_in_f` |
25
+
26
+**Complete system = 5 probes** (4 tub + 1 hot-in). ROM ids are not printed on the probes in a useful way, so the daemon **learns** identity during a guided install: the installer connects **one bundle at a time**, and the daemon records whatever appears on the bus.
27
+
28
+Learned ids are persisted on disk (e.g. `/etc/hot-tub/temp-probes.json` or alongside the service config) so a normal boot with both bundles connected can classify every sensor.
29
+
30
+### Learning rules (sound with the caveats below)
31
+
32
+| Visible on bus | Stored state | Action |
33
+|---|---|---|
34
+| **Exactly 1** probe | Hot-in id not yet stored | **Learn hot-in** — persist that ROM id as `hot_in` |
35
+| **Exactly 4** probes | Water ids not yet stored; *and* this looks like the water bundle alone (see caveat) | **Learn water** — persist all four ROM ids as `tub` |
36
+| Hot-in already known; **exactly 4 unknown** ids also present (5 total) | Water not stored | **Learn water** = the four ids that are not hot-in |
37
+| Water already known (4 ids); **exactly one** unknown id present (5 total) | Hot-in unknown | **Learn hot-in** = that unknown id |
38
+| Both sets stored; bus shows all 5 expected ids | Complete | **Normal operation** — classify by stored map |
39
+| **All 5** probes present but **neither** hot-in nor water ids stored | Empty | **Error** — cannot tell which id is hot-in. Publish to Redis (key + pub/sub) that a **guided sensor reinstall** is required |
40
+| Both sets stored; an **unknown** extra id appears | Complete | Log warning / `degraded`; do not silently reassign roles |
41
+| Count is 2 or 3 (or 4 while hot-in is already stored and water not yet learned from a clean 4-alone step) | Incomplete | Wait / `need_water` or `need_hot_in` — do **not** guess |
42
+
43
+### Why this works
44
+
45
+- Bundles are connected **separately**, so count alone disambiguates during setup: **1** → hot-in; **4** → water set. Fixed sizes remove the old 2–4 ambiguity.
46
+- Once **either** side is stored, plugging in the other bundle to reach **5** lets the daemon **derive** the missing side by set difference.
47
+- **All 5 present, nothing stored** → mathematically ambiguous → must not guess; signal reinstall / enter setup.
48
+
49
+### Caveats (follow these so the rules stay valid)
50
+
51
+1. **Setup order (recommended):** disconnect everything → connect **hot-in bundle alone** (1 probe) → wait for learn → connect **water bundle alone** (4 probes) *or* connect water while hot-in is already known (bus shows 5) → confirm both persisted → leave both connected for production.
52
+2. **Do not treat “4 probes ⇒ water” if hot-in might already be on the bus with only part of the water bundle.** Example: hot-in + 3 tub = 4 devices → naive rule would mis-label hot-in as a tub probe. Only apply bare “exactly 4 = water” when hot-in is **not** stored **and** setup mode is active; once hot-in is known, learn water only when there are **exactly four non-hot-in** ids (typically 5 on the bus).
53
+3. **Learning vs steady state.** Do not re-run “1 probe ⇒ hot-in” on every boot just because someone unplugged the water bundle for service — that would overwrite a good map with a tub probe. Auto-learn only when the relevant ids are **missing**, or when an explicit **setup / learn** mode is enabled (Web UI or `hot-tub-temps` flag). Clearing the probe map (or a “reinstall sensors” command) is how you force a fresh teach-in.
54
+4. **Partial water bundle:** fewer than 4 tub probes visible after learning is `degraded` / fault for Control — not a cue to rewrite the map. Fix wiring; do not teach-in a 3-probe water set.
55
+
56
+### Redis signaling for setup / identity errors
57
+
58
+When identity is incomplete or ambiguous, still publish whatever readings you can, but mark health / a dedicated setup field so Control and Web can react:
59
+
60
+| Kind | Name | Purpose |
61
+|---|---|---|
62
+| Key | `temps:setup` (or fields inside `temps:latest`) | `state`: `need_hot_in` \| `need_water` \| `ready` \| `need_reinstall` |
63
+| Channel | `temps.setup` | Pub/sub when setup state changes (installer UI / Web) |
64
+| Health | `health:temps` | `degraded` while learning; `error` on `need_reinstall` or unusable bus |
65
+
66
+Example error payload when all probes are up but nothing was learned:
67
+
68
+```json
69
+{
70
+ "ts": "2026-10-19T20:00:00Z",
71
+ "state": "need_reinstall",
72
+ "detail": "Multiple probes present but no stored hot_in/tub ids; connect bundles one at a time to re-teach",
73
+ "visible_ids": ["28-aaa", "28-bbb", "28-ccc", "28-ddd", "28-eee"]
74
+}
75
+```
76
+
77
+Control should **not** use an undifferentiated average of all five as tub temperature while `need_reinstall` is set.
78
+
79
+## Planned layout
80
+
81
+```text
82
+src/temp/
83
+ README.md ← this file
84
+ __init__.py
85
+ __main__.py # python -m temp / systemd ExecStart
86
+ daemon.py # main loop: poll → identify → build payload → log → Redis
87
+ reader.py # find devices, read °C/°F, CRC / 85°C sentinel handling
88
+ identity.py # learn/persist hot_in vs tub ids; setup state machine
89
+ config.py # load interval, Redis URL, paths; load/save probe map
90
+ redis_out.py # SET/PUBLISH temps + health + setup errors
91
+ logging_setup.py # file logger → /var/log/hot-tub/temp.log
92
+ config.example.json
93
+```
94
+
95
+Reuse read helpers from `src/read_temps.py` where practical (or move shared bits into a small common package later). Keep this package importable as `temp` when `src/` is on `PYTHONPATH`.
96
+
97
+## Behavior
98
+
99
+1. Load config (interval default **5 seconds**, Redis localhost) and **persisted probe map** (hot-in id + tub ids).
100
+2. Open file logger at `/var/log/hot-tub/temp.log`.
101
+3. Loop forever:
102
+ - Discover visible `28-*` ids; run **identity / setup** rules (learn or `need_reinstall`).
103
+ - Read temperatures; classify using the map (`tub` vs `hot_in`).
104
+ - Build a reading payload (`ts`, per-probe °F, `tub_avg_f` from tub probes only, `hot_in_f`, ok/fail counts, `spread_f`, setup state, …).
105
+ - **Log** the sample (and any probe / setup failures) to the log file.
106
+ - **Redis:** `SET temps:latest` + `PUBLISH temps.reading` with the same JSON.
107
+ - **Setup:** update `temps:setup` / `temps.setup` when learn state changes.
108
+ - **Health:** `SET health:temps` + `PUBLISH health.temps` (`ok` / `degraded` / `error`). Fresh `health:temps` (or a non-stale `temps:latest`) is what the Health Daemon uses for the temperature status LED.
109
+4. On SIGTERM/SIGINT: log shutdown and exit cleanly (systemd restart policy handles respawn).
110
+
111
+### Redis keys / channels
112
+
113
+| Kind | Name | Purpose |
114
+|---|---|---|
115
+| Key | `temps:latest` | Current reading for Control / Web / ad-hoc `GET` |
116
+| Channel | `temps.reading` | Pub/sub event on every successful poll cycle |
117
+| Key | `health:temps` | Heartbeat for Health Daemon (+ Web diagnostics) |
118
+| Channel | `health.temps` | Optional immediate wake for Health Daemon |
119
+
120
+Suggested reading shape (aligned with the architecture doc):
121
+
122
+```json
123
+{
124
+ "ts": "2026-10-19T20:00:00Z",
125
+ "probes": { "28-...": 101.2, "28-...": null },
126
+ "tub_avg_f": 101.0,
127
+ "hot_in_f": null,
128
+ "outdoor_f": null,
129
+ "ok_count": 3,
130
+ "fail_count": 1,
131
+ "spread_f": 1.4
132
+}
133
+```
134
+
135
+## Operational requirements
136
+
137
+### systemd (start on boot)
138
+
139
+- Unit name suggestion: `hot-tub-temps.service`.
140
+- `WantedBy=multi-user.target`, `Restart=on-failure`.
141
+- `After=redis.service` (or `redis-server.service`); daemon should still retry Redis if it starts first.
142
+- `ExecStart` runs the module as a dedicated user (or root only if required for `/sys` / log dir — prefer a `hot-tub` user with group access).
143
+- Ensure `/var/log/hot-tub/` exists with write permission for that user (tmpfiles.d or install script).
144
+
145
+### Logging
146
+
147
+- **All useful output** goes to `/var/log/hot-tub/temp.log` (samples, probe CRC failures, Redis reconnects, startup/shutdown). Avoid relying on journal-only prints for day-to-day ops; journal may still get a short “started / fatal” line from systemd.
148
+- Use the stdlib `logging` module with a `RotatingFileHandler` *or* a plain `FileHandler` and rely on system **logrotate** (preferred for consistency with other hot-tub services).
149
+
150
+### logrotate
151
+
152
+Example `/etc/logrotate.d/hot-tub-temp`:
153
+
154
+```text
155
+/var/log/hot-tub/temp.log {
156
+ weekly
157
+ rotate 8
158
+ compress
159
+ delaycompress
160
+ missingok
161
+ notifempty
162
+ copytruncate
163
+}
164
+```
165
+
166
+`copytruncate` avoids needing the daemon to reopen the file after rotate. Adjust cadence if 5 s polling makes the log grow faster than expected.
167
+
168
+### Poll interval
169
+
170
+- Default **5 seconds** (config override allowed).
171
+- One DS18B20 conversion is ~750 ms; multiple probes on one bus are read sequentially via sysfs — budget for that inside the interval, don’t stack overlapping reads.
172
+
173
+## Implementation plan
174
+
175
+| Phase | Work |
176
+|---|---|
177
+| 1 | `reader.py` + thin CLI/`__main__` dry-run: print one sample JSON (no Redis), reuse `read_temps` logic |
178
+| 2 | `identity.py` + persisted probe map: teach-in with one bundle at a time; unit-test 1→hot-in, 4→tub, 5+one-side-known→derive, 5+empty→`need_reinstall`, and “4 mixed probes must not mis-learn” |
179
+| 3 | `logging_setup.py` → write samples to `/var/log/hot-tub/temp.log`; create log dir in docs/install notes |
180
+| 4 | `redis_out.py`: SET + PUBLISH reading, health, and setup/`need_reinstall`; reconnect with backoff |
181
+| 5 | `daemon.py` 5 s loop; `config.example.json`; wire identity → payload (`tub_avg_f` vs `hot_in_f`) |
182
+| 6 | systemd unit + logrotate snippet under `deploy/` (or `src/temp/deploy/`); enable on Pi |
183
+| 7 | Smoke test: guided learn both bundles; `GET temps:latest` / `temps:setup`; kill map and plug all in → confirm `need_reinstall` event |
184
+
185
+## Config sketch
186
+
187
+Service settings (`config.example.json`):
188
+
189
+```json
190
+{
191
+ "interval_s": 5,
192
+ "devices_path": "/sys/bus/w1/devices",
193
+ "redis_url": "redis://127.0.0.1:6379/0",
194
+ "log_path": "/var/log/hot-tub/temp.log",
195
+ "probe_map_path": "/etc/hot-tub/temp-probes.json",
196
+ "setup_mode": false
197
+}
198
+```
199
+
200
+Learned map (auto-written; do not hand-edit unless recovering):
201
+
202
+```json
203
+{
204
+ "hot_in": "28-zzzzzzzzzzzz",
205
+ "tub": [
206
+ "28-aaaaaaaaaaaa",
207
+ "28-bbbbbbbbbbbb",
208
+ "28-cccccccccccc",
209
+ "28-dddddddddddd"
210
+ ]
211
+}
212
+```
213
+
214
+`setup_mode: true` (or empty/`partial` map) enables aggressive teach-in from bus counts; with a complete map and `setup_mode: false`, the daemon only classifies and reports missing/unknown probes.
215
+
216
+## Dependencies
217
+
218
+- Python 3.11+ (or whatever the Pi image ships).
219
+- Redis server on localhost.
220
+- `redis` Python package (or equivalent).
221
+- 1-Wire overlay enabled (`dtoverlay=w1-gpio` in boot config); no `gpiozero` required for this daemon.
222
+
223
+## Manual checks (on the Pi)
224
+
225
+```bash
226
+# One-shot style while developing
227
+cd /path/to/hot-tub/src && PYTHONPATH=. python3 -m temp
228
+
229
+# Redis
230
+redis-cli GET temps:latest
231
+redis-cli GET health:temps
232
+redis-cli SUBSCRIBE temps.reading
233
+
234
+# Logs
235
+tail -f /var/log/hot-tub/temp.log
236
+```
projects/hot-tub/temp-sensor/.gitignore
... ...
@@ -0,0 +1,4 @@
1
+.venv/
2
+__pycache__/
3
+config.json
4
+*.pyc
projects/hot-tub/temp-sensor/app.py
... ...
@@ -0,0 +1,250 @@
1
+#!/usr/bin/env python3
2
+"""Pi Zero temperature service: sample DS18B20 probes and serve latest reading."""
3
+
4
+from __future__ import annotations
5
+
6
+import json
7
+import os
8
+import threading
9
+import time
10
+from copy import deepcopy
11
+from datetime import datetime, timezone
12
+from pathlib import Path
13
+from typing import Any
14
+
15
+from flask import Flask, jsonify
16
+
17
+WATER_PROBE_IDS = ("water_1", "water_2", "water_3", "water_4")
18
+HOT_INPUT_ID = "hot_input"
19
+ALL_PROBE_IDS = WATER_PROBE_IDS + (HOT_INPUT_ID,)
20
+
21
+ROOT = Path(__file__).resolve().parent
22
+CONFIG_PATH = Path(os.environ.get("TEMP_SENSOR_CONFIG", ROOT / "config.json"))
23
+MOCK = os.environ.get("TEMP_SENSOR_MOCK", "").lower() in {"1", "true", "yes"}
24
+
25
+_lock = threading.Lock()
26
+_latest: dict[str, Any] = {
27
+ "ts": None,
28
+ "age_ms": None,
29
+ "average_f": None,
30
+ "hot_input_f": None,
31
+ "sensors": [],
32
+ "failed_sensors": [],
33
+}
34
+_started_at = time.time()
35
+_config: dict[str, Any] = {}
36
+
37
+
38
+def c_to_f(temp_c: float) -> float:
39
+ return temp_c * 9.0 / 5.0 + 32.0
40
+
41
+
42
+def load_config(path: Path) -> dict[str, Any]:
43
+ if not path.exists():
44
+ example = ROOT / "config.example.json"
45
+ raise FileNotFoundError(
46
+ f"Missing {path}. Copy {example.name} to config.json and set probe IDs."
47
+ )
48
+ with path.open() as f:
49
+ config = json.load(f)
50
+
51
+ probes = config.get("probes") or {}
52
+ missing = [pid for pid in ALL_PROBE_IDS if pid not in probes]
53
+ if missing:
54
+ raise ValueError(f"config probes missing keys: {', '.join(missing)}")
55
+ return config
56
+
57
+
58
+def list_w1_device_ids(devices_path: Path) -> list[str]:
59
+ if not devices_path.exists():
60
+ return []
61
+ return sorted(
62
+ p.name for p in devices_path.iterdir() if p.name.startswith("28-")
63
+ )
64
+
65
+
66
+def read_w1_temp_c(devices_path: Path, device_id: str) -> float | None:
67
+ slave = devices_path / device_id / "w1_slave"
68
+ try:
69
+ text = slave.read_text()
70
+ except OSError:
71
+ return None
72
+
73
+ lines = text.strip().splitlines()
74
+ if len(lines) < 2 or "YES" not in lines[0]:
75
+ return None
76
+
77
+ marker = "t="
78
+ if marker not in lines[1]:
79
+ return None
80
+
81
+ try:
82
+ milli_c = int(lines[1].split(marker, 1)[1])
83
+ except ValueError:
84
+ return None
85
+
86
+ # Datasheet uses 85000 as the power-on / fault sentinel.
87
+ if milli_c == 85000:
88
+ return None
89
+
90
+ return milli_c / 1000.0
91
+
92
+
93
+def mock_temp_c(probe_id: str, now: float) -> float | None:
94
+ """Deterministic fake temps for local testing without hardware."""
95
+ if probe_id == HOT_INPUT_ID:
96
+ return 48.0 + 0.4 * ((now // 3) % 5)
97
+ index = WATER_PROBE_IDS.index(probe_id)
98
+ # Simulate one missing probe occasionally for status handling.
99
+ if probe_id == "water_3" and int(now) % 30 < 3:
100
+ return None
101
+ return 38.5 + index * 0.15 + 0.05 * ((now // 3) % 7)
102
+
103
+
104
+def read_probe(probe_id: str, device_id: str, devices_path: Path) -> dict[str, Any]:
105
+ if MOCK:
106
+ temp_c = mock_temp_c(probe_id, time.time())
107
+ else:
108
+ temp_c = read_w1_temp_c(devices_path, device_id)
109
+
110
+ if temp_c is None:
111
+ return {
112
+ "id": probe_id,
113
+ "device_id": device_id,
114
+ "role": "hot_input" if probe_id == HOT_INPUT_ID else "water",
115
+ "temp_c": None,
116
+ "temp_f": None,
117
+ "status": "missing",
118
+ }
119
+
120
+ temp_f = round(c_to_f(temp_c), 2)
121
+ return {
122
+ "id": probe_id,
123
+ "device_id": device_id,
124
+ "role": "hot_input" if probe_id == HOT_INPUT_ID else "water",
125
+ "temp_c": round(temp_c, 3),
126
+ "temp_f": temp_f,
127
+ "status": "ok",
128
+ }
129
+
130
+
131
+def build_snapshot(config: dict[str, Any]) -> dict[str, Any]:
132
+ devices_path = Path(config.get("w1_devices_path", "/sys/bus/w1/devices"))
133
+ probes_cfg = config["probes"]
134
+ sensors = [
135
+ read_probe(probe_id, probes_cfg[probe_id], devices_path)
136
+ for probe_id in ALL_PROBE_IDS
137
+ ]
138
+
139
+ water_ok = [
140
+ s["temp_f"]
141
+ for s in sensors
142
+ if s["id"] in WATER_PROBE_IDS and s["status"] == "ok"
143
+ ]
144
+ hot = next(s for s in sensors if s["id"] == HOT_INPUT_ID)
145
+
146
+ average_f = round(sum(water_ok) / len(water_ok), 2) if water_ok else None
147
+ failed = [s["id"] for s in sensors if s["status"] != "ok"]
148
+ ts = datetime.now(timezone.utc).isoformat()
149
+
150
+ return {
151
+ "ts": ts,
152
+ "average_f": average_f,
153
+ "hot_input_f": hot["temp_f"],
154
+ "water_ok_count": len(water_ok),
155
+ "sensors": sensors,
156
+ "failed_sensors": failed,
157
+ }
158
+
159
+
160
+def sampler_loop(config: dict[str, Any], stop: threading.Event) -> None:
161
+ interval = float(config.get("sample_interval_sec", 3))
162
+ while not stop.is_set():
163
+ started = time.time()
164
+ snapshot = build_snapshot(config)
165
+ with _lock:
166
+ global _latest
167
+ _latest = snapshot
168
+ elapsed = time.time() - started
169
+ stop.wait(max(0.1, interval - elapsed))
170
+
171
+
172
+def create_app(config: dict[str, Any]) -> Flask:
173
+ app = Flask(__name__)
174
+
175
+ @app.get("/temps")
176
+ def temps():
177
+ with _lock:
178
+ payload = deepcopy(_latest)
179
+ if payload.get("ts"):
180
+ # age_ms helps the Pi 3 reject stale packages.
181
+ try:
182
+ sampled = datetime.fromisoformat(payload["ts"])
183
+ age_ms = int(
184
+ (datetime.now(timezone.utc) - sampled).total_seconds() * 1000
185
+ )
186
+ except ValueError:
187
+ age_ms = None
188
+ payload["age_ms"] = age_ms
189
+ return jsonify(payload)
190
+
191
+ @app.get("/health")
192
+ def health():
193
+ with _lock:
194
+ latest_ts = _latest.get("ts")
195
+ failed = list(_latest.get("failed_sensors") or [])
196
+ return jsonify(
197
+ {
198
+ "ok": True,
199
+ "mock": MOCK,
200
+ "uptime_sec": round(time.time() - _started_at, 1),
201
+ "latest_ts": latest_ts,
202
+ "failed_sensors": failed,
203
+ "known_w1_devices": list_w1_device_ids(
204
+ Path(config.get("w1_devices_path", "/sys/bus/w1/devices"))
205
+ ),
206
+ }
207
+ )
208
+
209
+ @app.get("/devices")
210
+ def devices():
211
+ """List detected 1-Wire IDs to help fill config.json."""
212
+ return jsonify(
213
+ {
214
+ "devices": list_w1_device_ids(
215
+ Path(config.get("w1_devices_path", "/sys/bus/w1/devices"))
216
+ )
217
+ }
218
+ )
219
+
220
+ return app
221
+
222
+
223
+def main() -> None:
224
+ global _config
225
+ _config = load_config(CONFIG_PATH)
226
+ stop = threading.Event()
227
+ thread = threading.Thread(
228
+ target=sampler_loop, args=(_config, stop), name="temp-sampler", daemon=True
229
+ )
230
+ thread.start()
231
+
232
+ # Prime one sample before serving traffic.
233
+ with _lock:
234
+ global _latest
235
+ _latest = build_snapshot(_config)
236
+
237
+ app = create_app(_config)
238
+ try:
239
+ app.run(
240
+ host=_config.get("host", "0.0.0.0"),
241
+ port=int(_config.get("port", 5000)),
242
+ threaded=True,
243
+ use_reloader=False,
244
+ )
245
+ finally:
246
+ stop.set()
247
+
248
+
249
+if __name__ == "__main__":
250
+ main()
projects/hot-tub/temp-sensor/config.example.json
... ...
@@ -0,0 +1,13 @@
1
+{
2
+ "host": "0.0.0.0",
3
+ "port": 5000,
4
+ "sample_interval_sec": 3,
5
+ "w1_devices_path": "/sys/bus/w1/devices",
6
+ "probes": {
7
+ "water_1": "28-000000000001",
8
+ "water_2": "28-000000000002",
9
+ "water_3": "28-000000000003",
10
+ "water_4": "28-000000000004",
11
+ "hot_input": "28-000000000005"
12
+ }
13
+}
projects/hot-tub/temp-sensor/requirements.txt
... ...
@@ -0,0 +1 @@
1
+flask>=3.0,<4