Indoor Remote Monitor — Build-Out Plan

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 labeled VDD on 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 RadioHead or RadioLib libraries support SX1276/RFM95-class radios on Arduino/ESP32)

Rendering approach

Two layers, matching the "design ahead of time, blit on device" pattern:

  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().
  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.

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 "+") — send target+1, wake immediately (RTC GPIO wake, not waiting for the next timer tick)
  • MIN (silkscreen "−") — send target-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)

  1. Wire the ESP32-C3 + e-paper display on a breadboard, powered via USB.
  2. Get a basic GxEPD2 + Adafruit GFX sketch drawing static text to confirm the panel and pin mapping.
  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.

Phase 2: LoRa link + data

  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.
  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.
  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).
  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.
  5. Design the static background bitmap on a computer, convert and embed it, confirm it displays correctly alongside live text.

Phase 3: Power management

  1. Add esp_deep_sleep_enable_gpio_wakeup() for all 3 buttons (bitmask of GPIO2/3/4) plus a timer wake source.
  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.
  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.

Phase 4: Integration into the case

  1. Mount the ESP32-C3 + regulator + perfboard inside the original case.
  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).
  3. Reconnect the button ribbon to the new GPIOs permanently.
  4. 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).
  • CH sends target+1 and wakes the device immediately from deep sleep; MIN does the same for target-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/MIN produce 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 CH steps faster) — out of scope for the first pass, which assumes one discrete press = one ±1°F step.