A robust Arduino library for reading 4-20mA submersible hydrostatic pressure sensors (e.g., TL-136-like) via an ADS1115 16-bit I2C ADC, with EMA smoothing, flexible tank geometry support, and persistent EEPROM calibration. These sensors commonly use a supply voltage in the 12–32 V DC range.
- 16-bit ADS1115 ADC — Much higher resolution than onboard Arduino/ESP ADCs
- Two tank geometry modes:
- Vertical — Any constant cross-section (cylinder, rectangle, custom shape). Volume = baseSurface × height
- Horizontal cylinder — Calculates circular segment area at each level. Handles the complex non-linear volume curve
- Exponential Moving Average (EMA) smoothing — Configurable smoothing factor for noisy sensor signals
- EEPROM persistence — Save and restore all calibration parameters across power cycles
- Configurable ADS1115 settings — Channel selection (0–3), I2C address, and PGA gain (±6.144 V to ±0.256 V)
- Works on ESP8266, ESP32, and AVR (Arduino Uno/Nano)
- ADS1115 module — 16-bit I2C ADC breakout (widely available, ~$3–5 USD)
- 4-20mA submersible hydrostatic pressure sensor — Any industrial sensor in this current range (e.g., TL-136-like sensors rated 0–5 m, 0–10 m, etc.). Typical supply voltage: 12–32 V DC.
- 150Ω shunt resistor — Converts 4–20 mA to 0.6–3.0 V (standard for 150Ω)
- Can use 100Ω (0.4–2.0 V), 50Ω (0.2–1.0 V), or other values — adjust
setAdsGain()and calibration accordingly
- Can use 100Ω (0.4–2.0 V), 50Ω (0.2–1.0 V), or other values — adjust
- 10µF capacitor — Noise filtering across the shunt resistor
- 12–32 V DC power supply — For the sensor (specs depend on your specific sensor; TL-136-like sensors often accept this range)
- ESP8266, ESP32, or Arduino Uno/Nano — For the microcontroller
- Adafruit ADS1X15 — Install via Arduino Library Manager or manually
In the Arduino IDE:
- Sketch → Include Library → Manage Libraries
- Search:
Adafruit ADS1X15 - Click Install
Or via command line (using Arduino CLI):
arduino-cli lib install "Adafruit ADS1X15"Copy the SubmersibleSensor folder to your Arduino libraries directory:
- macOS / Linux:
~/Arduino/libraries/ - Windows:
Documents\Arduino\libraries\
Or use Arduino IDE: Sketch → Include Library → Add .ZIP Library and select the folder.
The ADS1115 communicates via I2C (TWI) — all boards use the same I2C protocol, but pins differ.
| ADS1115 Pin | Wemos Pin | Notes |
|---|---|---|
| VDD | 3.3V | Power |
| GND | GND | Ground |
| SCL | D1 | I2C clock |
| SDA | D2 | I2C data |
| ADDR | GND | I2C address = 0x48 (default) |
| A0 | (analog) | Sensor signal (see diagram) |
| ADS1115 Pin | ESP32 Pin | Notes |
|---|---|---|
| VDD | 3.3V | Power |
| GND | GND | Ground |
| SCL | GPIO22 | I2C clock (can change via Wire.begin(SDA, SCL)) |
| SDA | GPIO21 | I2C data |
| ADDR | GND | I2C address = 0x48 (default) |
| ADS1115 Pin | Arduino Pin | Notes |
|---|---|---|
| VDD | 5V | Power |
| GND | GND | Ground |
| SCL | A5 | I2C clock |
| SDA | A4 | I2C data |
| ADDR | GND | I2C address = 0x48 (default) |
12-24V PSU (+) ------> Sensor RED wire
(internal 4-20mA loop)
Sensor BLACK/GREEN --+--> 150Ω shunt resistor --+--> GND
| (converts 4-20mA to |
| 0.6-3.0V) |
| 10µF capacitor (noise) |
| |
+------------> ADS1115 A0 pin |
|
12-24V PSU (-) ------> Common GND <---------------+
Key points:
- The 4-20mA sensor loop is independent from microcontroller power
- The shunt resistor and capacitor sit between the sensor output and the ADS1115 input
- All grounds (PSU, MCU, sensor) must be common
- The ADDR pin determines the I2C address:
- GND →
0x48(default) - VDD →
0x49 - SDA →
0x4A - SCL →
0x4B
- GND →
#include <SubSensor.h>
// Create a sensor on ADS1115 channel 0, default I2C address 0x48
SubSensor sensor;
void setup() {
Serial.begin(115200);
delay(500);
// Initialize the ADS1115
if (!sensor.begin()) {
Serial.println("ERROR: ADS1115 not found!");
while (1) delay(100);
}
// Try to load saved calibration from EEPROM
if (!sensor.loadConfig()) {
// First boot — configure the sensor
sensor.setAdsGain(GAIN_ONE); // ±4.096V for 150Ω shunt
sensor.setVoltageMin(0.60); // 4 mA reading
sensor.setVoltageMax(3.00); // 20 mA reading
sensor.setSensorRange(5.0); // sensor rated to 5 meters
// Vertical tank: cylinder with 28.5 cm diameter, 36.5 cm height
sensor.setTankType(TANK_VERTICAL);
sensor.setBaseSurface(0.0638); // π * (0.285/2)² m²
sensor.setTankHeight(0.365);
sensor.setEmaAlpha(0.2); // EMA smoothing
sensor.saveConfig(); // Save to EEPROM
Serial.println("Calibration saved.");
}
}
void loop() {
TankReading r = sensor.read();
if (r.valid) {
Serial.print("Level: "); Serial.print(r.levelCm); Serial.println(" cm");
Serial.print("Fill: "); Serial.print(r.fillPercent); Serial.println(" %");
Serial.print("Volume: "); Serial.print(r.volumeLiters); Serial.println(" L");
}
delay(2000);
}#include <SubSensor.h>
SubSensor sensor;
void setup() {
Serial.begin(115200);
delay(500);
if (!sensor.begin()) {
Serial.println("ERROR: ADS1115 not found!");
while (1) delay(100);
}
if (!sensor.loadConfig()) {
// Configure for a horizontal cylindrical tank
sensor.setAdsGain(GAIN_ONE);
sensor.setVoltageMin(0.60);
sensor.setVoltageMax(3.00);
sensor.setSensorRange(5.0);
// Horizontal cylinder: internal diameter 60 cm, length 120 cm
sensor.setTankType(TANK_HORIZONTAL_CYLINDER);
sensor.setTankHeight(0.60); // internal diameter
sensor.setTankLength(1.20); // axial length
sensor.setEmaAlpha(0.2);
sensor.saveConfig();
}
}
void loop() {
TankReading r = sensor.read();
if (r.valid) {
Serial.print("Level: "); Serial.print(r.levelCm); Serial.println(" cm");
Serial.print("Volume: "); Serial.print(r.volumeLiters); Serial.println(" L");
}
delay(2000);
}The sensor outputs a 4–20 mA current proportional to the liquid level. The shunt resistor converts this to voltage:
- At empty (0 m): 4 mA × 150Ω = 0.60 V
- At full sensor range: 20 mA × 150Ω = 3.00 V
If using a different shunt resistor, adjust accordingly:
- 100Ω shunt: 0.40–2.00 V
- 50Ω shunt: 0.20–1.00 V
To calibrate:
- Place the sensor at a known empty level (e.g., on a bench, not in liquid)
- Measure the voltage with a multimeter or read
Serial.print(r.voltage)in raw mode - Call
sensor.setVoltageMin(measured_voltage) - Repeat at full level with
setVoltageMax() - Save with
sensor.saveConfig()
The Programmable Gain Amplifier determines the full-scale voltage range and resolution:
| Gain | Full-scale | Resolution | Use Case |
|---|---|---|---|
GAIN_TWOTHIRDS |
±6.144 V | 0.1875 mV/bit | Large shunt voltages (> 5 V) |
GAIN_ONE |
±4.096 V | 0.125 mV/bit | 150Ω shunt (0.6–3.0 V) ← default |
GAIN_TWO |
±2.048 V | 0.0625 mV/bit | 100Ω shunt (0.4–2.0 V) |
GAIN_FOUR |
±1.024 V | 0.03125 mV/bit | 50Ω shunt (0.2–1.0 V) |
GAIN_EIGHT |
±0.512 V | 0.015625 mV/bit | Very small signals |
GAIN_SIXTEEN |
±0.256 V | 0.0078125 mV/bit | Extreme precision |
Choose a gain where your expected voltage range (0.6–3.0 V for a 150Ω shunt) occupies the middle 50–80% of the full scale. This maximizes precision while avoiding clipping.
The Exponential Moving Average reduces jitter:
sensor.setEmaAlpha(0.05); // Heavy smoothing, slow to respond
sensor.setEmaAlpha(0.2); // Moderate smoothing (default)
sensor.setEmaAlpha(1.0); // No smoothing, raw readingsLower alpha = more smoothing (slower response). Adjust based on sensor noise and application latency requirements.
For any constant cross-section (upright cylinder, rectangle, custom shape):
sensor.setTankType(TANK_VERTICAL);
sensor.setBaseSurface(area_in_m2); // Cross-sectional area
sensor.setTankHeight(height_in_m); // Usable fill heightExample: 28.5 cm diameter cylinder
float radius = 0.285 / 2.0;
float area = 3.14159 * radius * radius; // ≈ 0.0638 m²
sensor.setBaseSurface(area);Example: Rectangular tank 1.5 m × 0.5 m
sensor.setBaseSurface(1.5 * 0.5); // 0.75 m²For a cylinder lying on its side:
sensor.setTankType(TANK_HORIZONTAL_CYLINDER);
sensor.setTankHeight(internal_diameter_m); // Also the max fill level
sensor.setTankLength(axial_length_m); // Length of the cylinderThe library calculates the circular segment area at each level using:
A(h) = r² × acos((r - h)/r) - (r - h) × √(2rh - h²)
V(h) = A(h) × length
where r = diameter / 2 and h is the liquid level from the bottom.
// Initialize the ADS1115 on the I2C bus
bool begin(); // Returns false if chip not found
// Take a reading
TankReading read(); // Returns a TankReading struct
// Reset the EMA smoothing filter (call after changing channel or gain)
void resetSmoothing();void setVoltageMin(float v); // Voltage at empty (V)
void setVoltageMax(float v); // Voltage at full (V)
void setSensorRange(float meters); // Sensor rated max range (m)
void setEmaAlpha(float alpha); // EMA smoothing factor (0.01–1.0)void setTankType(TankType type); // TANK_VERTICAL or TANK_HORIZONTAL_CYLINDER
void setBaseSurface(float m2); // Cross-sectional area (vertical only) (m²)
void setTankHeight(float meters); // Fill height (vertical) or diameter (horizontal)
void setTankLength(float meters); // Axial length (horizontal cylinder only)void setAdsChannel(uint8_t ch); // Change input channel (0–3)
void setAdsGain(adsGain_t gain); // Set PGA gain (GAIN_ONE, etc.)void setEepromAddress(uint8_t addr); // Set EEPROM start byte (before load/save)
bool loadConfig(); // Restore calibration from EEPROM
void saveConfig(); // Persist calibration to EEPROM// All-in-one helper for vertical tanks
void configure(float voltMin, float voltMax, float sensorRange,
float baseSurface, float tankHeight, float emaAlpha);float getVoltageMin() const;
float getVoltageMax() const;
float getSensorRange() const;
TankType getTankType() const;
float getBaseSurface() const;
float getTankHeight() const;
float getTankLength() const;
float getEmaAlpha() const;
uint8_t getAdsChannel() const;
adsGain_t getAdsGain() const;
uint8_t getEepromAddress() const;struct TankReading {
int16_t rawADC; // Raw 16-bit ADC value from ADS1115
float voltage; // EMA-smoothed voltage (V)
float levelMeters; // Liquid level (m)
float levelCm; // Liquid level (cm)
float fillPercent; // Fill % by height (0–100)
float volumeLiters; // Volume (litres)
float volumeGallons;// Volume (US gallons)
bool valid; // false if begin() not called or reading failed
};Symptom: begin() returns false; "ADS1115 not found" message
Checklist:
- I2C wiring: Verify SCL and SDA are connected to the correct pins for your board (see Wiring section)
- Power: Ensure ADS1115 VDD is connected (3.3V or 5V, depending on board)
- ADDR pin: Confirm it's tied to GND (or whichever voltage sets the I2C address you expect — default 0x48)
- Pull-ups: I2C requires 4.7kΩ pull-up resistors on SCL and SDA to VDD. Many ADS1115 modules include these; if not, add them
- I2C address conflict: Try setting a different ADDR pin state (tie to VDD for 0x49, SDA for 0x4A, SCL for 0x4B)
Symptom: Readings are clipped, stuck at min/max, or wildly inaccurate
Checklist:
- Shunt value: Verify you're using the correct shunt resistor (150Ω standard). Measure the actual voltage with a multimeter
- Gain setting: Ensure the selected gain accommodates your voltage range. Raise the gain (lower ±V) if voltage is too high; lower the gain if too low
- Sensor wiring: Confirm the 4-20mA loop is complete — sensor positive (red) to PSU+, sensor negative (black/green) through shunt to GND
- Capacitor: The 10µF capacitor should be across the shunt resistor (not in series)
Symptom: r.voltage jumps around; r.levelCm fluctuates wildly
Fixes:
- Increase EMA smoothing: Lower
setEmaAlpha()(e.g., 0.05 instead of 0.2) - Add RC filter: Place a 1kΩ resistor + 100nF capacitor on the ADS1115 A0 input (low-pass filter)
- Cable shielding: Use shielded wire between the shunt and ADS1115 A0 pin; ground the shield at the ADS1115 end only
- Check capacitor: Verify the 10µF capacitor is in good condition (multimeter ESR test)
Symptom: Calibration settings reset after power cycle
Fixes:
- Call
saveConfig(): Ensure you explicitly callsensor.saveConfig()after setting parameters (not automatic) - EEPROM address conflict: If using shared EEPROM space, change the address:
sensor.setEepromAddress(32)beforeloadConfig()/saveConfig() - EEPROM limits: AVR boards have ~1 KB EEPROM; ESP8266/ESP32 have more but fragmentation can occur — use non-overlapping addresses
- Submersible pressure sensors: Most common for tanks. Typically 4-20mA output, 0–5 m, 0–10 m, or custom ranges
- Common manufacturers: Seafloor Systems, Blue Robotics, Keller, Druck, Wika
- Accuracy: Look for ±0.5% or better (full scale)
- Cable: Shielded 2-wire or 4-wire (2-wire is typical for current-loop sensors)
- Cost: Budget $50–200 USD for a quality industrial sensor
See examples/BasicReading/BasicReading.ino for a complete working example that demonstrates both vertical and horizontal cylinder tank configurations. The example uses the SubSensor API.
This library is provided as-is for hobbyist and educational use.
For issues or questions:
- Check the Troubleshooting section
- Verify the Wiring diagram for your specific board
- Test with a multimeter to isolate hardware vs. software issues
- Review the Adafruit ADS1X15 library documentation for I2C-specific issues