Siddhant Kumar
Project 040 Β· Environment

River Water Quality Buoy.

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.

Advanced 14–22 hours 35 min read WaterSensorsTelemetry
Jump to source Bill of materials
River Water Quality Buoy β€” reference build illustration MCU VCC Β· GND Β· SIG Β· NC
Difficulty
Advanced
Build time
14–22 hours
Indicative cost
β‚Ή7,500 – β‚Ή11,000
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Environment
Last updated
28 July 2026
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.

A photovoltaic solar panel in sunlight
A solar panel and battery let the buoy log a river continuously for months with no mains power. Photograph sourced from Wikimedia Commons β€” Solar panel.jpg. Reused under the licence stated on that page; please check it before republishing.

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

SettingHow it is used
Pollution watchdog / regulatorContinuous monitoring below industrial or urban outfalls to catch and time discharge events that monthly sampling misses.
Community river-keeper groupsCitizen-science networks watching a catchment and building evidence of pollution trends and events.
Drinking-water intake protectionEarly warning upstream of an abstraction point so operators can react to a contamination slug before it reaches the intake.
Ecological and fisheries monitoringTracking 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

AttributeValue
Difficulty levelAdvanced
Estimated completion time14–22 hours
Indicative build costβ‚Ή7,500 – β‚Ή11,000
Primary disciplineEnvironment
Reference platformESP32 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.

ComponentKey specificationQtyApprox. 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 DAC1β‚Ή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 consumable1β‚Ή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 min1β‚Ή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 measurement1β‚Ή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 ID1β‚Ή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 probe1β‚Ή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–SF121β‚Ή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 frame1β‚Ή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 pack1β‚Ή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 discharge1β‚Ή450
Buoy hull + mooring
Must survive debris and floods; the platform is half the project
Sealed floating hull, ballast, anchor line rated for flood flow1β‚Ή1,800
Waterproof probe glands + sonde housingIP68 pass-throughs; probes below the waterline, electronics dry above1β‚Ή700
Anti-fouling measures
Fouling is the number-one long-term failure mode
Copper tape/mesh or wiper on optical faces to slow biofilm1β‚Ή300
Cellular modem (optional)
LoRa preferred where a gateway exists
For reaches without LoRa gateway coverage1β‚Ή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

PartSpecificationSupplyInterfaceReference
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 DAC3.3 V logic / 5 V USBUART, SPI, IΒ²C, IΒ²S, CAN, PWMDatasheet
Dissolved-oxygen probe (galvanic)0–20 mg/L, Β±0.3 mg/L, galvanic, no warm-up, membrane cap consumable3.3–5 VAnalogueDatasheet
Analogue pH sensor kit (E-201-C probe + BNC board)pH 0–14, Β±0.1 pH at 25 Β°C, 5–60 Β°C, response < 1 min5 VAnalogue (offset trimmer)Datasheet
Turbidity sensor (TSD-10 style)0–3000 NTU, analogue 0–4.5 V, IR transmission measurement5 VAnalogue + digitalDatasheet
DS18B20 waterproof temperature probeβˆ’55 to +125 Β°C, Β±0.5 Β°C from βˆ’10 to +85 Β°C, 9–12-bit resolution, unique 64-bit ROM ID3.0–5.5 V1-Wire (multi-drop)Datasheet
Analogue TDS / conductivity probe0–1000 ppm, Β±10 % F.S., 0–2.3 V analogue, waterproof probe3.3–5.5 VAnalogueDatasheet
SX1278 LoRa 433 MHz module (Ra-02)βˆ’148 dBm sensitivity, +20 dBm output, up to 10 km line of sight, SF7–SF123.3 VSPIDatasheet
20 W 12 V polycrystalline solar panelVmp 17.5 V, Imp 1.14 A, Voc 21.6 V, 350 Γ— 290 mm, aluminium frame12 V nominalMC4 / screw terminalsDatasheet
CN3791 MPPT solar charge controller4.5–28 V in, MPPT set by resistor divider, 2 A charge to a 1S Li-ion pack4.5–28 VSolder padsDatasheet
18650 Li-ion cell 3400 mAh + holder3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge3.0–4.2 VHolder / spot-welded tabsDatasheet

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.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA β€” size the regulator accordingly.
Dissolved-oxygen probe (galvanic)3.3–5 V5The membrane and electrolyte are consumables β€” budget a replacement every 6–12 months.
Analogue pH sensor kit (E-201-C probe + BNC board)5 V8Two-point calibrate with pH 4.00 and pH 6.86 buffers; store the probe wet.
Turbidity sensor (TSD-10 style)5 V30Optical window fouls quickly β€” plan a wiper or weekly clean.
DS18B20 waterproof temperature probe3.0–5.5 V1.5Dozens can share one GPIO β€” you address them by ROM code.
Analogue TDS / conductivity probe3.3–5.5 V4Conductivity rises about 2 % per Β°C β€” always temperature-compensate the reading.
SX1278 LoRa 433 MHz module (Ra-02)3.3 V120Never power the radio without an antenna β€” the PA will destroy itself.
20 W 12 V polycrystalline solar panel12 V nominal1140Rated watts assume 1000 W/mΒ² β€” plan for 60–70 % of nameplate in real installs.
CN3791 MPPT solar charge controller4.5–28 V2000Set 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.json under 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 dialout group: sudo usermod -aG dialout $USER and 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

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager β†’ "PubSubClient" by Nick O'Leary
OneWire + DallasTemperature 2.3.x / 3.9.xBus enumeration and conversion commands for DS18B20 probes.Library Manager β†’ "DallasTemperature" (pulls OneWire)
Adafruit Unified Sensor 1.1.xCommon sensor event abstraction; a dependency of most Adafruit drivers.Library Manager β†’ "Adafruit Unified Sensor"
LoRa (sandeepmistry) 0.8.0SX127x radio configuration, packet TX/RX and callbacks.Library Manager β†’ "LoRa" by Sandeep Mistry
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager β†’ "ArduinoJson" by Benoit Blanchon
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
NTPClient / configTime bundledWall-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.

River Water Quality Buoy β€” system block diagramFunctional block diagram of the River Water Quality Buoy system. In the waterDissolved Oβ‚‚aquatic-life masterpHdischarge signalTurbidityrun-off/effluentWater tempDS18B20On the buoyESP32compensate + detectLoglocal, timestampedLinkLoRa/cellularremote reachWatcherDashboardriver trendsAlertevent β†’ grab samplerightrightnone
River Water Quality Buoy β€” system block diagram

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.

River Water Quality Buoy β€” wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBDO probeGPIO 34 (ADC)Dissolved oxygenpH probeGPIO 35 (ADC)pH via BNC ampTurbidityGPIO 32 (ADC)Optical cloudiness(NTU)DS18B20GPIO 4Water temperatureTDS/ECGPIO 33 (ADC)Dissolved solids(optional)LoRa/cellularSPI / UARTTelemetry uplinkMPPT + panelBattery busSolar charging18650 pack3V3 regBuffered supply
River Water Quality Buoy β€” wiring schematic
PeripheralPeripheral pinController pinSignal
DO probeAOUTGPIO 34 (ADC)Dissolved oxygen
pH probeAOUTGPIO 35 (ADC)pH via BNC amp
TurbidityAOUTGPIO 32 (ADC)Optical cloudiness (NTU)
DS18B20DQGPIO 4Water temperature
TDS/ECAOUTGPIO 33 (ADC)Dissolved solids (optional)
LoRa/cellularbusSPI / UARTTelemetry uplink
MPPT + panelOUTBattery busSolar charging
18650 pack+/–3V3 regBuffered 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.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
ESP32 module reading the submerged DO, pH and turbidity probes and detecting discharge events. Photograph sourced from Wikimedia Commons β€” ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

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.

River Water Quality Buoy β€” architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) Β· Dissolved-oxygen probe (galvanic) Β·Analogue pH sensor kit (E-201-C probe + BNC board) Β· Turbidity sensor(TSD-10 style)Driver layerwifi Β· pubsub Β· onewire Β· unifiedApplication logicsampling loop Β· filtering Β· thresholds Β· state machineTransport layerLoRa or cellular β†’ gateway β†’ river dashboard Β· TLS Β· retry and backoffPresentation layerdashboard Β· mobile notifications Β· historical charts
River Water Quality Buoy β€” architecture stack

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)

plainDO 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

plainTurbidity 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

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

River Water Quality Buoy β€” firmware flowchartControl flow through the main program loop. Wake on scheduleRead DO, pH, turbidity, tempTemperature-compensate DO + pHAbrupt change or dangerzone?Alert: possible discharge eventLog trendAlert: possible dischargeeventLog trendTransmit + local logSleep until next interval
River Water Quality Buoy β€” firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

  1. 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.

  2. 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.

  3. 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.

  1. 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.

  2. 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.ino
    struct 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.
  3. 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.

cppriver-quality-buoy.ino
/* ═══════════════════════════════════════════════════════════════
   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()
RTC_DATA_ATTR struct { float base, var; bool primed; } cDOEach channel's adaptive baseline and variance persist in RTC memory across deep sleep, so event detection keeps its sense of "normal" without re-learning every wake.
float avgADC(int pin)Averages 64 ADC samples per probe because in-water electrochemical and optical signals are noisy, and dosing decisions and event flags must not ride on a single jittery sample.
return v * DO_CAL * (1.0f - 0.023f * (t - 20.0f))Applies temperature compensation to the dissolved-oxygen reading, since the same probe voltage corresponds to different oxygen at different water temperatures.
logLocal(); // record firstThe reading is written locally before it is transmitted, so a dropped LoRa link degrades to a backlog to forward β€” never a hole in the river's record.
bool ev = detect(cDOThe three fast-responding channels are each checked for a sustained departure from their own baseline, and any one tripping raises the possible-discharge event flag.

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.

  1. 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.

  2. Turbidity

    Calibrate against formazin (or supplied) standards; note the clean-optics baseline so you can later distinguish real turbidity from fouling drift.

  3. 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

River Water Quality Buoy β€” network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsRiver buoyESP32 sondeUpstream buoyreach networkLoRa / cellularBank gatewayor direct cellularMQTT 1883Broker + dashboardtrends + eventsDashboardreach trendsPhone/SMSevent alerts
River Water Quality Buoy β€” network topology

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 / endpointDirectionPayload
river/buoy/1/readingnode β†’ brokerDO, DO%, pH, turbidity, EC, temp
river/buoy/1/eventnode β†’ brokerpossible-discharge event with which channels
river/buoy/1/statusnode β†’ brokerbattery, 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.

TestWhat you should see
Immerse in a known DO/pH standardReadings match the standard after temperature compensation
Add sediment to raise turbidityTurbidity rises; a sustained step raises an event flag
Simulate a pH discharge stepEvent detected once the step persists beyond the noise band
Drop the LoRa link during a readReading logged locally; backlog forwards on reconnect
Leave deployed for weeksAny 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.

jsonbuoy-packet.json
{
  "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.

A LoRa radio transceiver module
A LoRa (or cellular) radio carries each reading from a remote reach to the river-quality dashboard. Photograph sourced from Wikimedia Commons β€” LoRa module.jpg. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

Turbidity baseline creeps up with no rain

Likely cause. Biofouling of the optical face

Fix. Clean the optics; deploy anti-fouling/wiper; treat the drift flag as a maintenance prompt

DO reads progressively low over weeks

Likely cause. Fouled/ageing DO membrane

Fix. Service or replace the membrane; recalibrate; compare to a spot grab sample

pH noisy or drifting

Likely cause. Amplifier noise, electrode ageing, or ground coupling from the modem

Fix. Improve grounding/shielding; recalibrate; replace an old electrode

Data gaps

Likely cause. Link outage without local logging

Fix. Ensure local logging and backlog-forwarding are enabled; the log is the source of truth

Buoy drifting or listing

Likely cause. Mooring dragging or ballast/leak issue

Fix. Re-set the mooring for flood load; pressure-test seals; correct ballast

The sketch will not upload β€” "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools β†’ Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it β€” it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 Β΅F electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise β€” shorten the cable and keep motor wiring away from the USB lead.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device β€” check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

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 a millis() 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_t where 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

Can it replace lab testing?

No β€” a certified lab is more accurate per sample. The buoy's value is continuous presence: it catches transient events monthly sampling misses and tells you exactly when to take a certified sample.

Why continuous instead of daily?

Rivers change hourly β€” storm run-off, night-time discharges, diurnal oxygen swings. Only continuous monitoring captures the timing and magnitude of these, which is what identifies and evidences pollution events.

What is the biggest long-term problem?

Biofouling. Algae and biofilm coat submerged probes within weeks, so the design fights it with anti-fouling and flags slow drift so a fouled sensor is cleaned rather than believed.

Why report both mg/L and % saturation for oxygen?

mg/L tells you whether fish can breathe; % saturation, which accounts for temperature, tells you about the river's metabolism and eutrophication. Together they diagnose more than either alone.

How does it flag a pollution event?

It watches each parameter's rate of change against its own adaptive baseline. A river normally drifts; a discharge causes a step. A sustained step beyond the normal noise raises an event flag.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. Dissolved oxygen and aquatic life β€” overviewReference
  2. Turbidity β€” measurement and meaningReference
  3. USGS β€” continuous water-quality monitoringUSGS
  4. Eutrophication and diurnal DO swingsReference
  5. Biofouling of water-quality sensors β€” literatureReference