GROUU · Projects · WellBouy
Active · V3 · deployed & live

WellBouy — well-water quality node

An open reference design for monitoring a water source over LoRaWAN — temperature, dissolved solids, turbidity and pH. All four sensors are calibrated and validated, the node deep-sleeps and streams to a live dashboard, and the dedicated board is fabricated.

XIAO ESP32-S3 Wio-SX1262 EU868 · OTAA CayenneLPP PlatformIO
The well-bouy01 carrier board
01 · Overview

What it is

WellBouy is the V3 GROUU node for water — a deployed, battery-powered LoRaWAN sensor that measures temperature, dissolved solids, turbidity and pH at a source and streams to a live dashboard. Firmware and the data pipeline are complete and running; the dedicated board is fabricated. It is one node in a wider, shared system.

Ecosystem
One node among many

WellBouy feeds the shared GROUU server stack; the same infrastructure serves gardens, greenhouses and wells.

Node · at the well
WellBouy
temp · TDS · turbidity · pH · XIAO ESP32-S3 + Wio-SX1262 · ADS1115 · switched 5 V · deep sleep 30 min
LoRaWANEU868
Network
The Things Network
OTAA · CayenneLPP → semantic fields
MQTTTLS 8883
GROUU server · V2 stack
Node-RED → InfluxDB
parse → bucket "wellbouy" · Raspberry Pi
+ garden nodes · greenhouse nodes · other wells — same uplink, same stack
Grafana
internal dashboards
Public dashboard
grouuwell01.hacktoimprove.com
Open data
other researchers · municipalities

← downlink: Node-RED → TTN queue → node RX window · 0x01 set interval · 0x02 reboot — applied next wake (Class A).

02 · Design

Architecture & enclosure

How the system is shaped: the radio-per-context architecture it belongs to, and the board + enclosure modelled in Fusion.

Architecture · V2 + V3
Radio per context

Modular nodes choose their radio by context: Wi-Fi where a network exists, LoRaWAN where it doesn't. Both land in the same server stack — the 2014 vision (V0), finally modular.

Context A · network available
Wi-Fi module
garden · greenhouse · home · mains or solar · MQTT direct · rich payloads
Context B · off-grid
LoRaWAN module
well · field · remote · battery + deep sleep · TTN uplink · few bytes, km range — WellBouy's case
MQTT
TTN → MQTT
Shared · unchanged since V2
One server stack
MQTT broker · Node-RED · InfluxDB · Grafana · public web — Raspberry Pi edge, or any host
Industrial design
Enclosure & live 3D model

The board and enclosure are modelled in Autodesk Fusion; the viewer always shows the latest version. Component layout and sketching studies are in the gallery below.

03 · Hardware

Sensors, front-end, power

All modules are off-the-shelf, so the design is reproducible without custom silicon. Every driver is written and calibrated.

01 · Temperature✓ calibrated
DS18B20 probe

Waterproof digital probe on OneWire, 4.7 kΩ pull-up, 3.3 V. Clean °C to serial and uplink.

02 · TDS✓ calibrated
Seeed Grove TDS

Analog Grove TDS on the switched 5 V rail, output into the ADS (A0). DFRobot Gravity two-point k-value algorithm, temp-compensated ppm.

03 · Turbidity✓ validated
Seeed Grove turbidity

Optical head, cabled; a 10k / 6.8k divider brings the 5 V output into ADC range (A1). Reported as a clear-anchored relative index (0 = clear); absolute NTU pending a formazin standard.

04 · pH✓ calibrated
Phidgets 1130 + BNC probe

GigaΩ analog conditioner (pH 7 ≈ 2.5 V) through a 10k / 18k divider into the ADS (A2); three-point calibration (pH 4 / 7 / 10, segmented at pH 7).

Shared ADC ADS1115 16-bit I²C, addr 0x48, at 3.3 V — analog sensors on A0–A2, DS18B20 stays digital.
3.3 V

Always-on — MCU, radio, ADS1115 and the DS18B20.

5 V

Switched — analog conditioners powered ~1 s per cycle through a high-side load switch (GPIO1 / D0).

Bat

1S LiPo (4500 mAh), mains-rechargeable — a fixed well site needs no panel. The board still exposes battery + solar inputs, so the same design runs off-grid elsewhere.

Fabricated well-bouy01 boards from JLCPCB
The fabricated well-bouy01 boards.
PartRoleSource
Seeed XIAO ESP32-S3 + Wio-SX1262MCU + LoRa radio (SX1262 via RadioLib)Seeed ↗
ADS1115Shared 16-bit I²C ADC @ 0x48 — analog sensors on A0–A2
DS18B20 (waterproof)Water temperature — 1-Wire (GPIO4) + 4.7k pull-upAdafruit ↗in stock
Seeed Grove TDSDissolved solids (A0) — DFRobot Gravity algorithm, temp-compensatedSeeed ↗in stock
Seeed Grove turbidityTurbidity (A1) via 10k/6.8k divider, 5 V — clear-anchored relative indexSeeed ↗in stock
Phidgets ASP200 pH probepH 0–14, gel-filled BNC probePhidgets ↗in stock
Phidgets 1130 pH/ORP adapterBNC → analog (A2) via 10k/18k divider, 5 VPhidgets ↗in stock
Grove Shield for XIAOBring-up carrier; final build uses the fabricated well-bouy01 boardSeeed ↗to order
LiPo 1S 4500 mAhBattery — mains-rechargeable, no solar (B1)Mauser ↗to order
Dividers 10k/6.8k · 10k/18kLevel-shift turbidity & pH into ADC range
M12 glands · BNC bulkhead · O-ringsHull pass-throughs & sealto order
pH buffers 4 / 7 / 10 · TDS KCl 84 / 1413 µS/cmpH three-point + TDS two-point calibration standardsto order
04 · Software

Firmware & interface

One PlatformIO project — staged bring-up sketches plus the integrated production firmware — and the dashboard that renders it.

Lifecycle
Node lifecycle — state machine

Wake, measure, transmit, sleep — as implemented in src/main.cpp: sensors are read and powered down before the radio wakes.

DeepSleep
RTC timer · txIntervalS · default 30 min · downlink-tunable (floor 60 s)
Wake
5 V rail on · warm-up 1000 ms
ReadSensors
temp first (compensates TDS) · 60 ms/channel · ×8 avg · rail off
Encode
CayenneLPP · ch1 temp · ch2 pH · ch3 turb · ch4 TDS · ch5 GPS (boot 1 + every 24th)
RTC session presentSessionResume
no session · first bootJoinOTAA
sendReceive() · unconfirmed · ADR on
Uplink
DownlinkCheck
dnLen > 0HandleCommand0x01 set interval · 0x02 reboot
none
save session to RTC · sleep txIntervalS ↺
DeepSleep
Module map
Firmware UML

Procedural C++ (PlatformIO), one cycle per wake: main.cpp orchestrates the duty cycle, each sensor is a small two-function module, every field-tunable constant lives in config.h.

main.cpp
RTC_DATA_ATTR (survives sleep): bootCount · haveSession · rtcNonces[] · rtcSession[] · txIntervalS
+ setup() — whole cycle · − readAllSensors() · − adsMilliVolts(ch) ×8 · − sensorRailOn/Off (GPIO1) · − loraConnect/SaveSession · − handleDownlink · − goToSleep · + loop() never reached
radio
SX1262 + LoRaWANNode · RadioLib 6.6.0 (pinned) · NSS 41 · DIO1 39 · RST 42 · BUSY 40 · EU868 · OTAA · ADR on · dwell-time off · appKey to both key slots (TTN 1.0.x)
src/sensors/ — one module per ladder build
temp_sensor · b02
tempSetup() · readTemperature() — DS18B20 · 1-Wire GPIO4 · read first (compensates TDS) · NaN → 25 °C
tds_sensor · b03
tdsPpmFromMilliVolts(mv, tempC) — ADS A0 direct · DFRobot algo @ 25 °C · TDS_KVALUE 0.944 (cal'd to 705 ppm @ 1413 µS/cm)
turbidity_sensor · b04
turbidityFromMilliVolts(mv, cal) — Seeed Grove · ADS A1 · 10k/6.8k divider (0.405) · per-sensor TurbidityCal · clear anchor 3.79 V → relative index
ph_sensor · b05
phFromMilliVolts(mv) — ADS A2 · 10k/18k divider · segmented at pH 7 · V4 0.987 / V7 1.519 / V10 1.980 V · 177 / 154 mV/pH
analog_util
readAvgMilliVolts(pin) — legacy direct-ADC path, kept for the breadboard rig
config.h
pins · TX_INTERVAL_S 1800 (floor 60) · warm-up 1000 ms · gap 60 ms · ×8 samples · calibration constants · DL opcodes · fixed GPS · DBG() macros
secrets.h
JoinEUI · DevEUI · AppKey — git-ignored · secrets.h.example template
libraries: RadioLib 6.6.0 · Adafruit ADS1X15 · CayenneLPP
src/bringup/01…05 + inject — the ladder: each build compiles alone, adds one sensor

src/main.cpp — CayenneLPP encode + uplink (TDS & turbidity ride the Luminosity type, 0–65535, because Analog Input overflows above 327.67)

CayenneLPP lpp(51);
lpp.reset();
lpp.addTemperature(1, isnan(r.temperatureC) ? 0.0f : r.temperatureC);
lpp.addAnalogInput(2, r.ph);
lpp.addLuminosity (3, (uint16_t)(r.turbidityIdx + 0.5f));   // turbidity (relative index)
lpp.addLuminosity (4, (uint16_t)(r.tdsPpm + 0.5f));         // TDS
if (bootCount == 1 || (bootCount % 24) == 0)
  lpp.addGPS(5, FIXED_LATITUDE, FIXED_LONGITUDE, FIXED_ALTITUDE);

int16_t state = node.sendReceive(lpp.getBuffer(), lpp.getSize(),
                                 LPP_FPORT, dnData, &dnLen, /*confirmed=*/false);
Interface
Online dashboard — implementation

InfluxDB feeds both the internal Grafana boards and the public page.

InfluxDB
2.x · bucket "wellbouy" · fields named by the TTN formatter
Flux queries ▼
Grafana
internal · Flux · gauges + history · link-quality row
Express · GET /api/summary?range=6h|24h|7d ▼
Public web page
grouuwell01.hacktoimprove.com · static HTML/JS · Cloudflare tunnel
Temp
17.4 °C
TDS
312 ppm
Turbidity
3 idx
pH
6.8

Values illustrative. Live: temp · TDS · turbidity · pH · RSSI/SNR — battery + packet-loss pending.

05 · Process

Done, how, and what's left

The firmware is organised so anyone can rebuild it incrementally — flash one stage, confirm it on serial and TTN, move on. Register your own device on TTN (EU868, LoRaWAN 1.0.x, OTAA), set the payload formatter to CayenneLPP, and keep your keys in an untracked secrets.h.

Stage 0
TTN setup
Gateway, application + device, keys, CayenneLPP formatter
✓ documented
Stage 1
Firmware bring-up
LoRaWAN ✓ · temp ✓ · TDS ✓ · turbidity ✓ · pH ✓ · integrated
✓ complete
Stage 2
Dedicated board (atopile → KiCad)
Schematic + layout done, board fabricated; re-run bring-up on the board
✓ fabricated
Stage 3
Enclosure
Sensor glands, antenna, sealing; print & fit check
○ planned
Stage 4
Field assembly & soak test
End-to-end on TTN, tune interval, verify power budget
○ planned
Gallery

Renders, sketching & bench

WellBouy components laid out in the Fusion model
Component layout
WellBouy component arrangement, alternate view
Arrangement
WellBouy component arrangement packed for the hull
Packing study
WellBouy probes (DS18B20 temperature, TDS, turbidity, pH) dipped into a mug of water during bench calibration
Bench bring-up — probes calibrating in a mug
The well-bouy01 board wired to the XIAO and sensors on the bench during staged sensor bring-up
New-board bring-up setup
Three round well-bouy01 boards at different assembly stages — casualties of hand-soldering the fine-pitch ADS1115
Hand-solder casualties — the ADS1115 rework toll
Seeed Grove turbidity sensor opened up, showing the optical transmittance head, 3-wire cable and diffuser cap
Seeed Grove turbidity sensor, opened up
Timelapse — sketching enclosure shapes over the component layout (iPad).