Contents β 26 sections
Project Overview
A self-powered floating station that logs pH, turbidity and dissolved oxygen along a river, catching pollution events that a monthly grab-sample would never see.
A river's health is usually judged from a grab sample: someone drives to a bridge once a month, dips a bottle, and sends it to a lab. It is accurate for that instant, but a river is not a constant β a factory discharge at 2 a.m., a slug of run-off after a storm, a diurnal swing in oxygen as aquatic plants photosynthesise by day and respire by night β all come and go between visits, invisible to monthly sampling. This buoy trades a little laboratory accuracy for continuous presence: it sits in the water day and night, logging the key water-quality signals, so a pollution event that lasts hours is captured with its timing and magnitude instead of being missed entirely.
It measures the three parameters that reveal most about a river cheaply and continuously: dissolved oxygen, the master variable for aquatic life, whose crashes signal pollution or eutrophication; pH, which shifts with industrial discharges, acid run-off and biological activity; and turbidity, the cloudiness that tracks sediment, run-off and many effluents and is often the first visible sign something has entered the water. Water temperature is logged alongside because it governs oxygen solubility and biological rate, and EC/TDS optionally tracks dissolved salts and pollution load. Together these paint a live picture of the river's state and, crucially, its changes.
Surviving in a river is its own engineering problem, and the design takes it seriously: the buoy is solar-powered because there is no mains in a river, communicates over LoRa or cellular because there is no Wi-Fi, is sealed and ruggedised against constant immersion, fouling and debris, and logs locally so a lost link never loses data. It timestamps everything, flags sensor faults and fouling, and raises an alert when a parameter crosses into a danger zone or changes abruptly β the signature of a discharge event. It cannot replace a certified lab, but it can tell you when to send someone with a bottle, which is often the difference between catching a polluter and cleaning up after them.
What this project does
- Continuously logs dissolved oxygen, pH, turbidity and water temperature
- Optionally logs EC/TDS as a dissolved-pollution-load proxy
- Timestamps all data and detects abrupt changes that signal discharge events
- Alerts when a parameter enters a danger zone (e.g. DO crash, pH swing)
- Runs on solar + battery as a moored floating station
- Reports over LoRa or cellular and logs locally through link outages
- Flags sensor faults and biofouling rather than reporting bad data
Real-World Applications
| Setting | How it is used |
|---|---|
| Pollution watchdog / regulator | Continuous monitoring below industrial or urban outfalls to catch and time discharge events that monthly sampling misses. |
| Community river-keeper groups | Citizen-science networks watching a catchment and building evidence of pollution trends and events. |
| Drinking-water intake protection | Early warning upstream of an abstraction point so operators can react to a contamination slug before it reaches the intake. |
| Ecological and fisheries monitoring | Tracking dissolved-oxygen regimes and their diurnal swings that determine whether a reach can support fish. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Continuous presence that catches transient pollution events
- DO, pH and turbidity β the highest-value continuous river signals
- Change-detection for discharge events, not just fixed thresholds
- Temperature-compensated DO and pH
- Rugged, sealed, solar, moored design for life in a river
- Local logging + LoRa/cellular for remote reaches
- Fouling/fault flags to keep the record honest
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Advanced |
| Estimated completion time | 14β22 hours |
| Indicative build cost | βΉ7,500 β βΉ11,000 |
| Primary discipline | Environment |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Calibrating and temperature-compensating pH, DO and turbidity probes
- Designing a rugged, sealed, solar-powered floating platform
- Change/anomaly detection for event flagging
- LoRa/cellular telemetry with local logging fallback
- Managing biofouling and its effect on submerged sensors
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift β treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA β size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18Γ 12-bit ADC, 2Γ 8-bit DAC | 1 | βΉ450 |
| Dissolved-oxygen probe (galvanic) The membrane and electrolyte are consumables β budget a replacement every 6β12 months. | 0β20 mg/L, Β±0.3 mg/L, galvanic, no warm-up, membrane cap consumable | 1 | βΉ5,800 |
| Analogue pH sensor kit (E-201-C probe + BNC board) Two-point calibrate with pH 4.00 and pH 6.86 buffers; store the probe wet. | pH 0β14, Β±0.1 pH at 25 Β°C, 5β60 Β°C, response < 1 min | 1 | βΉ2,400 |
| Turbidity sensor (TSD-10 style) Optical window fouls quickly β plan a wiper or weekly clean. | 0β3000 NTU, analogue 0β4.5 V, IR transmission measurement | 1 | βΉ900 |
| DS18B20 waterproof temperature probe Dozens can share one GPIO β you address them by ROM code. | β55 to +125 Β°C, Β±0.5 Β°C from β10 to +85 Β°C, 9β12-bit resolution, unique 64-bit ROM ID | 1 | βΉ160 |
| Analogue TDS / conductivity probe Conductivity rises about 2 % per Β°C β always temperature-compensate the reading. | 0β1000 ppm, Β±10 % F.S., 0β2.3 V analogue, waterproof probe | 1 | βΉ780 |
| SX1278 LoRa 433 MHz module (Ra-02) Never power the radio without an antenna β the PA will destroy itself. | β148 dBm sensitivity, +20 dBm output, up to 10 km line of sight, SF7βSF12 | 1 | βΉ480 |
| 20 W 12 V polycrystalline solar panel Rated watts assume 1000 W/mΒ² β plan for 60β70 % of nameplate in real installs. | Vmp 17.5 V, Imp 1.14 A, Voc 21.6 V, 350 Γ 290 mm, aluminium frame | 1 | βΉ1,200 |
| CN3791 MPPT solar charge controller Set the MPPT point to ~80 % of panel Voc for polycrystalline modules. | 4.5β28 V in, MPPT set by resistor divider, 2 A charge to a 1S Li-ion pack | 1 | βΉ320 |
| 18650 Li-ion cell 3400 mAh + holder Never charge below 0 Β°C; always use a protected cell or a BMS. | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 1 | βΉ450 |
| Buoy hull + mooring Must survive debris and floods; the platform is half the project | Sealed floating hull, ballast, anchor line rated for flood flow | 1 | βΉ1,800 |
| Waterproof probe glands + sonde housing | IP68 pass-throughs; probes below the waterline, electronics dry above | 1 | βΉ700 |
| Anti-fouling measures Fouling is the number-one long-term failure mode | Copper tape/mesh or wiper on optical faces to slow biofilm | 1 | βΉ300 |
| Cellular modem (optional) LoRa preferred where a gateway exists | For reaches without LoRa gateway coverage | 1 | βΉ900 |
Estimated total: βΉ16,640, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 Β°C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter β continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18Γ 12-bit ADC, 2Γ 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, IΒ²C, IΒ²S, CAN, PWM | Datasheet |
| Dissolved-oxygen probe (galvanic) | 0β20 mg/L, Β±0.3 mg/L, galvanic, no warm-up, membrane cap consumable | 3.3β5 V | Analogue | Datasheet |
| Analogue pH sensor kit (E-201-C probe + BNC board) | pH 0β14, Β±0.1 pH at 25 Β°C, 5β60 Β°C, response < 1 min | 5 V | Analogue (offset trimmer) | Datasheet |
| Turbidity sensor (TSD-10 style) | 0β3000 NTU, analogue 0β4.5 V, IR transmission measurement | 5 V | Analogue + digital | Datasheet |
| DS18B20 waterproof temperature probe | β55 to +125 Β°C, Β±0.5 Β°C from β10 to +85 Β°C, 9β12-bit resolution, unique 64-bit ROM ID | 3.0β5.5 V | 1-Wire (multi-drop) | Datasheet |
| Analogue TDS / conductivity probe | 0β1000 ppm, Β±10 % F.S., 0β2.3 V analogue, waterproof probe | 3.3β5.5 V | Analogue | Datasheet |
| SX1278 LoRa 433 MHz module (Ra-02) | β148 dBm sensitivity, +20 dBm output, up to 10 km line of sight, SF7βSF12 | 3.3 V | SPI | Datasheet |
| 20 W 12 V polycrystalline solar panel | Vmp 17.5 V, Imp 1.14 A, Voc 21.6 V, 350 Γ 290 mm, aluminium frame | 12 V nominal | MC4 / screw terminals | Datasheet |
| CN3791 MPPT solar charge controller | 4.5β28 V in, MPPT set by resistor divider, 2 A charge to a 1S Li-ion pack | 4.5β28 V | Solder pads | Datasheet |
| 18650 Li-ion cell 3400 mAh + holder | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 3.0β4.2 V | Holder / spot-welded tabs | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA β size the regulator accordingly. |
| Dissolved-oxygen probe (galvanic) | 3.3β5 V | 5 | The membrane and electrolyte are consumables β budget a replacement every 6β12 months. |
| Analogue pH sensor kit (E-201-C probe + BNC board) | 5 V | 8 | Two-point calibrate with pH 4.00 and pH 6.86 buffers; store the probe wet. |
| Turbidity sensor (TSD-10 style) | 5 V | 30 | Optical window fouls quickly β plan a wiper or weekly clean. |
| DS18B20 waterproof temperature probe | 3.0β5.5 V | 1.5 | Dozens can share one GPIO β you address them by ROM code. |
| Analogue TDS / conductivity probe | 3.3β5.5 V | 4 | Conductivity rises about 2 % per Β°C β always temperature-compensate the reading. |
| SX1278 LoRa 433 MHz module (Ra-02) | 3.3 V | 120 | Never power the radio without an antenna β the PA will destroy itself. |
| 20 W 12 V polycrystalline solar panel | 12 V nominal | 1140 | Rated watts assume 1000 W/mΒ² β plan for 60β70 % of nameplate in real installs. |
| CN3791 MPPT solar charge controller | 4.5β28 V | 2000 | Set the MPPT point to ~80 % of panel Voc for polycrystalline modules. |
Summed typical draw is 3468.5 mA. With a 1.5Γ design margin the supply should deliver at least 5300 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File β Preferences β Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools β Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud β every sketch here logs its state there.
- Keep File β Preferences β Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager β "PubSubClient" by Nick O'Leary |
| OneWire + DallasTemperature 2.3.x / 3.9.x | Bus enumeration and conversion commands for DS18B20 probes. | Library Manager β "DallasTemperature" (pulls OneWire) |
| Adafruit Unified Sensor 1.1.x | Common sensor event abstraction; a dependency of most Adafruit drivers. | Library Manager β "Adafruit Unified Sensor" |
| LoRa (sandeepmistry) 0.8.0 | SX127x radio configuration, packet TX/RX and callbacks. | Library Manager β "LoRa" by Sandeep Mistry |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager β "ArduinoJson" by Benoit Blanchon |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-clock time from an NTP server for timestamping. | Bundled (`configTime()` on ESP32) |
Block Diagram
The block diagram shows the functional decomposition of the system β what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| DO probe | AOUT | GPIO 34 (ADC) | Dissolved oxygen |
| pH probe | AOUT | GPIO 35 (ADC) | pH via BNC amp |
| Turbidity | AOUT | GPIO 32 (ADC) | Optical cloudiness (NTU) |
| DS18B20 | DQ | GPIO 4 | Water temperature |
| TDS/EC | AOUT | GPIO 33 (ADC) | Dissolved solids (optional) |
| LoRa/cellular | bus | SPI / UART | Telemetry uplink |
| MPPT + panel | OUT | Battery bus | Solar charging |
| 18650 pack | +/β | 3V3 reg | Buffered supply |
Wire one row at a time and tick it off β most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- Mount the probes below the waterline through IP68 glands, with all electronics in a sealed dry compartment above; a single leak drowns the station.
- Put the pH electrode on a proper high-impedance BNC amplifier and keep analogue probe grounds quiet and separate from the modem's current spikes.
- Place the DS18B20 at the same depth as the DO/pH probes so its temperature genuinely compensates them.
- Fit the turbidity sensor's optical faces where a wiper or anti-fouling can keep them clear; a fouled optical face reads ever-rising false turbidity.
- Use an MPPT charger so the panel keeps the pack topped through short winter days and long overcast spells on the water.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
The buoy's power is temporal, not analytical. A certified lab beats it on accuracy for any single sample, but the lab sees the river once a month; the buoy sees it every few minutes, forever. Rivers change on every timescale β a storm slug of turbid run-off over hours, an illicit discharge over a single night, the daily rise and fall of dissolved oxygen as photosynthesis by day gives way to respiration by night. These are exactly the phenomena grab-sampling is blind to, and exactly what continuous monitoring reveals. The design accepts modest per-reading accuracy to gain the thing that actually catches pollution: presence.
Dissolved oxygen is the river's master health variable, and its behaviour is doubly informative. Its absolute level determines whether fish and invertebrates can live in a reach; its pattern diagnoses the river's metabolism. A healthy stream shows a gentle diurnal DO swing; a river choked with nutrients (eutrophic) shows violent swings β supersaturated by afternoon, crashing to near-zero before dawn β and an organic pollution event drives DO down as microbes consume oxygen breaking down the waste. Because oxygen solubility falls as water warms, DO must always be interpreted together with temperature, and reported both as an absolute (mg/L, for the fish) and as a percentage of saturation (for the metabolism).
pH and turbidity are the two cheapest, most responsive tell-tales of an intrusion. Many industrial and mining discharges shift pH sharply, and biological activity moves it more slowly; a sudden pH step that does not match the diurnal rhythm is a strong discharge signature. Turbidity β how much the water scatters light β tracks suspended sediment and a great many effluents, and because it often changes fast and visibly, a turbidity spike is frequently the very first sign that something has entered the water, even before you know what. Watching the rate of change of these signals, not just fixed thresholds, is what lets the buoy flag an event: a normal river drifts; a discharge steps.
The hard part is not the sensing but the survival. Submerged probes foul β algae and biofilm coat them within weeks, and a fouled optical turbidity face reads a steadily rising false signal while a fouled DO membrane reads progressively low β so the design fights fouling (copper, wipers) and, just as importantly, flags suspected drift so a slow lie is caught. The platform must ride floods and shrug off debris without sinking or dragging its mooring; the electronics must stay dry through constant immersion; and the whole thing must run for months on the sun and log locally so a dropped link never loses the record. A river buoy that reports beautiful data for three weeks and then quietly fouls, floods or floats away has failed at the only thing that mattered β being there when the pollution happened.
The maths behind it
DO percent saturation (temperature-aware)
Report both absolute and relative oxygen:
DO_pct = DO_meas(mg/L) / DO_sat(T) Γ 100
DO_sat falls with temperature (~9 mg/L at 20 Β°C to
~7 mg/L at 30 Β°C, freshwater). Diurnal DO_pct swinging
from >120% (afternoon) to <40% (pre-dawn) signals a
nutrient-enriched, metabolically stressed river.
Turbidity from scattered light
Optical turbidity sensor: cloudier water scatters more
light to the detector.
NTU = f(V_scatter) (calibrated against formazin standards)
Rising baseline over weeks with no rain event = biofouling
of the optical face, not real turbidity β flag & clean.
Event (rate-of-change) detection
For each parameter x, compare to a slow baseline:
base β base + Ξ±Β·(x β base) (Ξ± small)
event if |x β base| > kΒ·Ο_recent sustained N reads
kΒ·Ο scales the threshold to each parameter's normal noise.
A step that exceeds the diurnal rhythm's expected range is
flagged as a possible discharge β the cue for a grab sample.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Build the buoy and mooring
Assemble a sealed, ballasted hull that floats upright with the probes below the waterline and the electronics compartment dry above. Design the mooring for flood flow and debris load β a scope and anchor that will not drag or snap.
Bring the probe cables into the dry compartment through IP68 glands; pressure-test the seals before deployment.
Install and protect the probes
Fit the DO, pH, turbidity and temperature probes at a representative depth. Apply anti-fouling (copper around optical faces, a wiper if available) and position them where a service visit can reach them.
Amplify the pH electrode properly and keep analogue grounds away from the modem's switching current.
Set up solar, storage and telemetry
Mount the solar panel on top clear of splash, charge the pack through an MPPT controller, and fit the LoRa (or cellular) modem with its antenna high and dry. Confirm local logging works before you rely on the link.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Calibrate every probe against standards
pH against 4/7/(10) buffers; DO against zero and air-saturated water; turbidity against formazin (or a supplied) standard; EC against a standard solution. Store constants and dates.
Compensate and detect events
Temperature-compensate DO and pH, maintain a slow baseline per parameter, and flag a sustained departure beyond a noise-scaled threshold as a possible discharge event.
cppriver-events.inostruct Chan { float base; float var; bool primed; }; Chan cDO, cpH, cTurb; // Update a slow baseline and running variance; return true on event. bool detect(Chan &c, float x, float k, uint8_t &nOver) { if (!c.primed) { c.base = x; c.var = 1; c.primed = true; return false; } float d = x - c.base; c.var = 0.98f * c.var + 0.02f * d * d; // running variance c.base += 0.02f * d; // slow baseline float sigma = sqrtf(c.var) + 1e-3f; if (fabsf(d) > k * sigma) { nOver++; } // step beyond normal noise else nOver = 0; return nOver >= 3; // sustained β event } float doPercentSat(float doMgL, float tempC) { // DO_sat approximation (freshwater); replace with a fuller table if needed. float sat = 14.6f - 0.41f*tempC + 0.008f*tempC*tempC; return doMgL / sat * 100.0f; }c.var = 0.98f * c.varEach channel tracks its own running variance, so the event threshold adapts to how noisy that parameter naturally is rather than using one fixed number for all.if (fabsf(d) > k * sigma)An event is a departure from the slow baseline scaled by the channel's own noise β a step that stands out above the river's normal drift and diurnal wobble.return nOver >= 3A single noisy sample does not raise an alarm; the departure must persist for several reads, which distinguishes a real discharge from sensor noise.float doPercentSat(Converts absolute oxygen to percent saturation using a temperature-dependent solubility, giving the metabolic view of the river alongside the absolute value the fish care about.Log locally, transmit and sleep
Write every reading to local storage first, then transmit; on a dropped link, keep logging and forward the backlog on reconnect. Sleep between intervals to make the solar budget.
Complete Source Code
The listing below is complete and compiles as written β there are no elided sections. Read the annotations under each block before you upload it.
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
River Water Quality Buoy β ESP32, DO/pH/turbidity/temp, LoRa, solar
Continuously logs the high-value river signals, temperature-
compensates DO and pH, detects abrupt changes that signal discharge
events, and reports over LoRa with local-logging fallback.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
#include <WiFi.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <LoRa.h>
#include <SPI.h>
#include <Preferences.h>
#include <math.h>
#define PIN_DO 34
#define PIN_PH 35
#define PIN_TURB 32
#define PIN_EC 33
#define OW_PIN 4
#define LORA_CS 5
#define LORA_RST 14
#define LORA_DIO0 2
#define SLEEP_S 600 // 10 min
OneWire ow(OW_PIN);
DallasTemperature water(&ow);
Preferences prefs;
float PH_SLOPE, PH_OFFSET, DO_CAL, TURB_CAL, EC_CAL;
RTC_DATA_ATTR struct { float base, var; bool primed; } cDO, cpH, cTurb;
float avgADC(int pin) {
long s = 0; for (int i = 0; i < 64; i++) s += analogRead(pin);
return (s / 64.0f) / 4095.0f * 3.3f;
}
float readpH(float t) {
float v = avgADC(PIN_PH);
float tc = (t + 273.15f) / 298.15f;
return 7.0f + (PH_OFFSET - v) / (PH_SLOPE * tc);
}
float readDO(float t) {
float v = avgADC(PIN_DO);
return v * DO_CAL * (1.0f - 0.023f * (t - 20.0f));
}
float readTurb() { return avgADC(PIN_TURB) * TURB_CAL; } // β NTU
float readEC() { return avgADC(PIN_EC) * EC_CAL; }
float doPercentSat(float doMgL, float t) {
float sat = 14.6f - 0.41f*t + 0.008f*t*t;
return doMgL / sat * 100.0f;
}
bool detect(decltype(cDO) &c, float x, float k, uint8_t &nOver) {
if (!c.primed) { c.base = x; c.var = 1; c.primed = true; return false; }
float d = x - c.base;
c.var = 0.98f * c.var + 0.02f * d * d;
c.base += 0.02f * d;
float sigma = sqrtf(c.var) + 1e-3f;
if (fabsf(d) > k * sigma) nOver++; else nOver = 0;
return nOver >= 3;
}
void loadCal() {
prefs.begin("river", true);
PH_SLOPE = prefs.getFloat("phS", 0.18f);
PH_OFFSET = prefs.getFloat("phO", 1.65f);
DO_CAL = prefs.getFloat("doC", 3.0f);
TURB_CAL = prefs.getFloat("tbC", 1000.0f);
EC_CAL = prefs.getFloat("ecC", 1000.0f);
prefs.end();
}
void transmit(float doMgL, float doPct, float pH, float turb,
float ec, float t, bool event) {
LoRa.beginPacket();
LoRa.printf("{\"buoy\":1,\"do\":%.2f,\"do_pct\":%.0f,\"ph\":%.2f,"
"\"turb\":%.0f,\"ec\":%.0f,\"t\":%.1f,\"event\":%d}",
doMgL, doPct, pH, turb, ec, t, event ? 1 : 0);
LoRa.endPacket();
}
void logLocal(/* to SD/flash */) { /* append timestamped record */ }
void setup() {
Serial.begin(115200);
loadCal();
water.begin();
water.requestTemperatures();
float t = water.getTempCByIndex(0);
float doMgL = readDO(t);
float pH = readpH(t);
float turb = readTurb();
float ec = readEC();
float doPct = doPercentSat(doMgL, t);
static uint8_t nDO, nPH, nTB;
bool ev = detect(cDO, doMgL, 3.0f, nDO)
| detect(cpH, pH, 3.0f, nPH)
| detect(cTurb, turb, 3.0f, nTB);
logLocal(); // record first β never lose data
SPI.begin();
LoRa.setPins(LORA_CS, LORA_RST, LORA_DIO0);
LoRa.begin(433E6);
LoRa.setSpreadingFactor(10);
transmit(doMgL, doPct, pH, turb, ec, t, ev);
esp_sleep_enable_timer_wakeup((uint64_t)SLEEP_S * 1000000ULL);
esp_deep_sleep_start();
}
void loop() {} // deep sleep restarts setup()
Configuration & Calibration
Configuration steps
- Load probe calibration constants and the event-threshold multiplier (kΒ·Ο) per channel; store calibration dates.
- Set the danger-zone limits (e.g. a hard low-DO floor) that alert regardless of rate-of-change.
- Choose the sampling interval (10 min captures diurnal cycles) and the LoRa/cellular telemetry settings.
- Configure local logging and backlog-forwarding for link outages.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
pH / DO
Calibrate pH against buffers and DO against zero and air-saturated water at a known temperature; verify the temperature compensation reproduces the reference across a range.
Turbidity
Calibrate against formazin (or supplied) standards; note the clean-optics baseline so you can later distinguish real turbidity from fouling drift.
Fouling baseline
Record each probe's clean-water reading at deployment; a slow one-directional drift with no rain/event is the fingerprint of biofouling.
Network Architecture & Connectivity
Communication protocol
Readings publish every ~10 minutes; event flags publish immediately. Local logging is authoritative and forwards any backlog on reconnect, so a remote reach with patchy coverage still yields a complete record.
| Topic / endpoint | Direction | Payload |
|---|---|---|
river/buoy/1/reading | node β broker | DO, DO%, pH, turbidity, EC, temp |
river/buoy/1/event | node β broker | possible-discharge event with which channels |
river/buoy/1/status | node β broker | battery, fouling/cal flags, RSSI |
Message contract between the device and the broker.
Cloud platform configuration
A broker feeds a dashboard that trends each reach and, with several buoys, shows a pollutant slug travelling downstream β timing an event between stations to help locate its source.
Dashboard setup
Per-buoy trend panels with event markers and fouling/calibration-age indicators, plus a reach map when multiple buoys are deployed.
Mobile app integration
Immediate alerts on a detected event or a hard danger-zone breach (e.g. DO below the fish-kill floor), prompting a grab sample.
Security considerations
- Sign readings per buoy so enforcement evidence cannot be spoofed.
- Authenticate any configuration/calibration push to the buoy.
- Alert on a buoy going silent β a drowned or vandalised station must be noticed quickly.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop β the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Immerse in a known DO/pH standard | Readings match the standard after temperature compensation |
| Add sediment to raise turbidity | Turbidity rises; a sustained step raises an event flag |
| Simulate a pH discharge step | Event detected once the step persists beyond the noise band |
| Drop the LoRa link during a read | Reading logged locally; backlog forwards on reconnect |
| Leave deployed for weeks | Any slow one-way drift flags suspected fouling for a clean/recal |
| Run through a flood/debris event (or bench proxy) | Buoy stays sealed and moored; logging continues |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
The dashboard trends DO (absolute and % saturation), pH, turbidity, EC and temperature, marks detected events, and shows each probe's calibration age and fouling flag.
{
"buoy": 1,
"do": 4.1,
"do_pct": 52,
"ph": 6.3,
"turb": 210,
"ec": 640,
"t": 27.4,
"event": 1
}
Here a simultaneous DO drop, pH dip and turbidity spike has tripped the event flag β the classic signature of an organic/chemical discharge, and the cue to dispatch someone with a certified sample bottle.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Deep-sleep between 10-minute reads; the modem transmit is the main power cost, so keep packets small.
- Average many ADC samples per probe to beat in-water electrical noise.
- Keep adaptive baselines in RTC memory so event detection survives sleep without re-priming.
- Log locally first and batch-forward backlogs rather than blocking on the link.
- Replace every
delay()with amillis()comparison β blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 Β΅A asleep, which is the difference between days and months of runtime.
Safety Precautions
- Continuous data indicates when to take a certified grab sample; it is not laboratory evidence on its own.
- Deploy and service buoys with proper water safety β moving water is dangerous; never work alone in the current.
- Secure the lithium pack and seal the electronics against constant immersion.
- Mark the buoy for navigation and moor it so it cannot become a hazard to boats in flood.
- Lithium cells vent and burn when abused. Only use protected cells or a proper BMS, never charge below 0 Β°C, and never leave a charging pack unattended on a wooden desk.
- Never power an RF module without its antenna fitted β the reflected power destroys the output stage. Check your local licence-free band and duty-cycle limits before transmitting.
- Water and electronics: mount all boards above the maximum possible water line, use drip loops on every cable, and pressure-test plumbing before wiring anything up.
- Wear eye protection when soldering or cutting, and solder in a ventilated space β rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Clean and recalibrate probes on a schedule; fouling is the dominant long-term error.
- Inspect the mooring and hull seals after every significant flood.
- Replace ageing DO membranes and pH electrodes before they drift out of use.
- Verify local logs and backlog-forwarding, and keep the solar panel clear of splash-fouling.
- Re-check every screw terminal and header after the first week β thermal cycling loosens connections that felt tight on day one.
- Clean the sensing element on a schedule. Optical and electrochemical sensors foul, and a fouled sensor reports plausible nonsense rather than failing outright.
- Log pack voltage. When resting voltage after a full charge drops below about 4.0 V, the cell is near end of life β replace it.
- Wash the panel every few weeks in dusty conditions; a visible dust film costs 15β25 % of the harvest.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware β not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add automated wiper anti-fouling to extend service intervals.
- Deploy several buoys along a reach to triangulate a discharge to a source outfall.
- Add nitrate/ammonium ion-selective sensing for nutrient-pollution specificity.
- Fuse flow and rainfall data to separate storm run-off from illicit discharges automatically.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add over-the-air firmware updates so you never have to physically reach a deployed node again.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.