d761477a2b72f1affb1d16d49a0eaf8595993986
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 |