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.
See Hardware Setup, Overview, and Pi Zero Tub-Side Control Panel 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.
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.
Why this device is architecturally different from the tub-side console
Tub-side console (pi-zero-control-panel.md) |
Indoor monitor (this doc) | |
|---|---|---|
| Location | At the tub, outdoor-adjacent | Indoors |
| Power | Mains via cable | 2× AA batteries |
| Core | Raspberry Pi Zero W (Linux) | ESP32-C3 (microcontroller, deep sleep) |
| Link | Wi-Fi (always associated) | LoRa (short request/response, no association) |
| Display | SPI color TFT (Pimoroni Display HAT Mini) | E-paper (low power, already purchased) |
| Role | Can enable/disable heating, adjust target, control filter | Status glance + target ±1°F only |
| Duty cycle | Always on | Wakes briefly, sleeps most of the time |
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.
Hardware
Reused from the original thermometer console
- Plastic case + existing cutout/window
- 3 physical buttons (
MIN,CH,LIG) and their shared/common ribbon wire (previously labeledVDDon the original board — treat it as just the switches' common terminal once disconnected from the original PCB) - 2×AA battery compartment (if intact; otherwise replace with an equivalent holder)
Discarded
- Original main PCB (COB chip, RF receiver, antenna)
- Original LCD glass + zebra strip
New parts
| Item | Qty | Notes |
|---|---|---|
| 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 |
| LoRa transceiver module (SX1276/RFM95-class) | 1 | SPI-connected; needs CS, RST, DIO0 in addition to shared SPI lines — see wiring below |
| LoRa transceiver module, second unit | 1 | For the Pi 3 side — see "Pi 3 side" note below |
| E-paper SPI display | 1 | Already purchased — confirm it exposes CS, DC, RST, BUSY, CLK/SCK, DIN/MOSI, GND, VCC |
| Low-quiescent-current boost regulator, 3.3V out | 1 | See power section below — this is the component that determines real-world battery life |
| 2× AA battery holder | 1 | Reuse original if usable |
| Small perfboard or protoboard | 1 | To mount ESP32-C3 + regulator + LoRa module inside the case |
| Jumper wire / thin hookup wire | — | For button ribbon, display, and LoRa module connections |
Pi 3 side
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.
Boost regulator — don't default to a generic dev-board module
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.
For the real build, pick one of:
- 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.
- 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.
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.
Electrical Wiring
Power path
2×AA ──► boost regulator (3.3V out) ──┬──► ESP32-C3 3V3
└──► e-paper VCC
Buttons
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:
Ribbon "VDD" (common) ────────────── ESP32-C3 GND
Ribbon "CH" (silkscreen "+") ──── GPIO2 (RTC-capable — increase target)
Ribbon "MIN" (silkscreen "−") ──── GPIO3 (RTC-capable — decrease target)
Ribbon "LIG" (silkscreen "SET")──── GPIO4 (RTC-capable — force refresh / reserved)
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).
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.
E-paper display (generic — confirm against your specific module's datasheet)
| Signal | Suggested ESP32-C3 GPIO |
|---|---|
| CS | GPIO7 |
| DC | GPIO8 (note: strapping pin, see caution below) |
| RST | GPIO10 |
| BUSY | GPIO11 |
| CLK / SCK | GPIO6 |
| DIN / MOSI | GPIO5 (shares RTC range — fine, just don't also use it for a button) |
| GND | GND |
| VCC | 3.3V (from regulator) |
LoRa module (shares the SPI bus with the e-paper display)
Multiple SPI devices can share CLK/MOSI/MISO as long as each has its own CS line — standard practice, not a conflict:
| Signal | Suggested ESP32-C3 GPIO |
|---|---|
| CS | GPIO9 (strapping pin — see caution below, or move if it causes boot issues) |
| RST | GPIO20 |
| DIO0 (interrupt) | GPIO21 |
| CLK / SCK | GPIO6 (shared with e-paper) |
| MOSI | GPIO5 (shared with e-paper) |
| 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 |
| GND | GND |
| VCC | 3.3V (from regulator) |
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.
Software Architecture
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:
┌─────────────────────────────────────────┐
│ boot (from deep sleep or power-on) │
│ │ │
│ ▼ │
│ read button state (which, if any) │
│ │ │
│ ▼ │
│ send LoRa request packet │
│ (includes cmd: none/target+1/target-1) │
│ │ │
│ ▼ │
│ open short RX window, await response │
│ │ │
│ ▼ │
│ render to e-paper (GxEPD2 + Adafruit GFX)│
│ (fresh data, or "stale" if no response) │
│ │ │
│ ▼ │
│ esp_deep_sleep_start() │
│ wake sources: timer (N min) OR any button│
└─────────────────────────────────────────┘
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.
Libraries
- GxEPD2 — e-paper SPI driver, supports most Waveshare/Good Display panels
- Adafruit GFX — drawing primitives (text, shapes, bitmaps) on top of GxEPD2
- A LoRa library matching your module (e.g. the common
RadioHeadorRadioLiblibraries support SX1276/RFM95-class radios on Arduino/ESP32)
Rendering approach
Two layers, matching the "design ahead of time, blit on device" pattern:
-
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(). -
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'sfontconverttool) so it doesn't look like default blocky text.
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.
LoRa packet format
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:
Uplink (ESP32 → Pi 3), request/command:
[msg_type][cmd][seq]
msg_type = 0x01 (status/command request)
cmd = 0x00 none | 0x01 target+1 | 0x02 target-1 | 0x03 refresh-only
seq = incrementing byte, for de-dup / debugging
Downlink (Pi 3 → ESP32), status response:
[average_f][hot_input_f][target_f][heating_enabled][heat_pump_on][filter_pump_on][ts...]
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).
Pi 3 side implementation note
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).
Button behavior
-
CH(silkscreen "+") — sendtarget+1, wake immediately (RTC GPIO wake, not waiting for the next timer tick) -
MIN(silkscreen "−") — sendtarget-1, wake immediately -
SET/LIG— force an immediate refresh-only request (cmd = refresh-only), or reserved for a future secondary screen (open decision, see below)
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.
Staleness handling
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.
Power Budget (rough planning numbers)
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:
| State | Approx. current |
|---|---|
| LoRa sleep | < 0.2 µA |
| LoRa standby | ~1.6 mA |
| LoRa active receive (RX window) | ~10–13 mA |
| LoRa transmit (request packet, short) | ~20–120 mA depending on TX power, for a brief burst |
| (For comparison) Wi-Fi connect + HTTP fetch | ~150–250+ mA for a second or more |
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:
| Refresh interval | Rough battery life (2×AA) — LoRa |
|---|---|
| Every 60s | ~1–2 months |
| Every 5 min | ~4–8 months |
| Every 15 min | ~1 year+ |
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.
Build Phases
Phase 1: Bench bring-up (no case, no battery yet)
- Wire the ESP32-C3 + e-paper display on a breadboard, powered via USB.
- Get a basic GxEPD2 + Adafruit GFX sketch drawing static text to confirm the panel and pin mapping.
- 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.
Phase 2: LoRa link + data
- 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. - 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.
- Implement the real request/response packet format; confirm the ESP32 can send a
cmdbyte and receive back a status struct reliably at the intended distance (same room vs. across the house, through walls). - Wire up the "adjust target" shared function on the Pi 3 so both the LoRa command path and the existing
POST /targetendpoint call into it. - Design the static background bitmap on a computer, convert and embed it, confirm it displays correctly alongside live text.
Phase 3: Power management
- Add
esp_deep_sleep_enable_gpio_wakeup()for all 3 buttons (bitmask of GPIO2/3/4) plus a timer wake source. - 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.
- 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.
Phase 4: Integration into the case
- Mount the ESP32-C3 + regulator + perfboard inside the original case.
- 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).
- Reconnect the button ribbon to the new GPIOs permanently.
- Close up the case, confirm batteries are accessible via the original door for easy replacement.
Testing Checklist
- Each button is read correctly with the case fully closed (button travel/alignment can shift once reassembled).
-
CHsendstarget+1and wakes the device immediately from deep sleep;MINdoes the same fortarget-1. - The displayed target only changes after the Pi 3's response confirms it — not optimistically before confirmation.
- Timer wake fires at the configured interval without any button press.
- Display shows correct average temp, hot-input temp, target, and pump states.
- 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.
- 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.
- Rapid repeated presses of
CH/MINproduce one step per press, not duplicated or dropped steps from bounce or retransmission. - Battery life roughly matches the planning estimate over a multi-day soak test at the chosen refresh interval.
- 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).
Open Decisions
- Final behavior for
SET/LIG— force-refresh vs. reserved for a future secondary screen (e.g. min/max over a period). - LoRa frequency/channel selection and antenna placement for reliable range across the house.
- Exact refresh interval — balance "freshness" against battery-change frequency once real current draw and RX-window timing are measured.
- Whether to add a simple low-battery icon/warning once board-level battery voltage sensing is wired up (not included in this first pass).
- Whether repeated-press target adjustments should accelerate (e.g. holding
CHsteps faster) — out of scope for the first pass, which assumes one discrete press = one ±1°F step.