Arduino library for APADevices PHX v2 pool monitoring board
pH and ORP/RX measurement using the ADS1115 16-bit ADC with full isolation, two-point calibration, temperature compensation and non-blocking state machine design.
- What this library does
- Hardware overview
- Wiring
- Installation
- Quick start
- Calibration
- API reference
- Debug system
- Message callback
- Platform notes
- Examples
- Board quick spec
- License
The APADevices PHX v2 board measures two water quality parameters simultaneously:
| Channel | Measurement | Range | Accuracy | ADC gain |
|---|---|---|---|---|
| pH | Acid/base balance | 0 β 14 pH | Β±0.002 pH | Gain 2 (Β±2.048V, 62.5ΞΌV LSB) |
| ORP/RX | Oxidation-reduction potential | Β±2000 mV | Β±0.5 mV | Gain 1 (Β±4.096V, 125ΞΌV LSB) |
Both probes connect to isolated, precision analog frontends on the PHX v2 board. The ADS1115 16-bit delta-sigma ADC reads each channel differentially β the library handles all register configuration, timing, calibration and unit conversion.
Key features:
- Non-blocking state machine β measurement never blocks
loop() - Two-point calibration with EEPROM persistence β survives power cycles
- Temperature compensation for pH (Pasco 2001 formula, normalised to 25Β°C)
- Rolling average filter β configurable window 1β10 readings
- Message callback β route library messages to Serial, LCD or any display
- Compile-time + runtime debug system
- Zero heap allocation after
begin()β safe on AVR
The PHX v2 board contains two fully isolated measurement modules on a single PCB:
- pH module β ADS1115 at I2C address
0x49, Gain 2 - ORP/RX module β ADS1115 at I2C address
0x48, Gain 1
Each module has its own ADUM1251 I2C isolator and LMP7721 precision op-amp frontend. Both share the same I2C bus on the MCU side but are electrically isolated from each other and from the MCU on the analog side.
The board has two separate power domains that must both be connected:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β MCU side (logic) β Analog side (isolated) β
β Connector P1 β Connector CN2 β
β β β
β Vcc-MC1: 3.3V or 5V β +12V IN: external 12V DC β
β GND-MC1: MCU ground β 12V/GND: 12V ground β
β SCL, SDA: I2C bus β β
β (same voltage as MCU) β Powers all analog circuits β
β β behind ADUM1251 isolators β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β οΈ Both power connections are required. The board will not function with only one supply connected.
β οΈ The two grounds (GND-MC1 and 12V/GND) are galvanically isolated. Do not connect them together.
Connector P1 β IDC Γγ°γ€5 pin header (2.54mm pitch)
Γγ°γ€5 pin header (2.54mm pitch)" href="#connector-p1--idc-25-pin-header-254mm-pitch">P1 Pin β Signal β Connect to
βββββββββΌββββββββββββββΌββββββββββββββββββββββββββββββββββββββ
10 β GND-MC1 β MCU GND
9 β Vcc-MC1 β MCU supply (3.3V or 5V β match MCU)
8 β SCL-MC1 β MCU I2C SCL
7 β SDA-MC1 β MCU I2C SDA
6 β RX-Alert β MCU pin (optional β ALERT/RDY from ORP ADS1115)
5 β pH-Alert β MCU pin (optional β ALERT/RDY from pH ADS1115)
4 β 12V GND β 12V supply negative (tied to pin 2)
3 β 12V+ β 12V supply positive (tied to pin 1)
2 β 12V GND β 12V supply negative (tied to pin 4)
1 β 12V+ β 12V supply positive (tied to pin 3)
Pins 1+3 and 2+4 are paired β connect both pins of each pair to the 12V supply.
CN2 Pin β Signal β Connect to
ββββββββββΌβββββββββββΌββββββββββββββββββ
1 β 12V+ β 12V supply positive
2 β 12V/GND β 12V supply negative
Use either P1 (pins 1β4) or CN2 for the 12V supply β both connect to the same internal rail.
The board includes onboard 4.7kΞ© I2C pullup resistors (R36, R37).
Enable them with jumper H3 if your MCU board does not already have I2C pullups.
Disable H3 if external pullups are present to avoid parallel resistance lowering the pullup value.
Arduino Uno β PHX v2 P1
βββββββββββββββββββββββββ
GND β Pin 10 (GND-MC1)
5V β Pin 9 (Vcc-MC1)
A5 (SCL) β Pin 8 (SCL-MC1)
A4 (SDA) β Pin 7 (SDA-MC1)
External PSU β PHX v2 CN2
βββββββββββββββββββββββββ
12V+ β Pin 1
GND β Pin 2
H3 jumper: ON (enable onboard pullups, unless your Uno shield has them)
- Open Sketch β Include Library β Manage Libraries
- Search for
APAPHX2_ADS1115 - Click Install
- Download the repository as ZIP from GitHub
- Open Sketch β Include Library β Add .ZIP Library
- Select the downloaded ZIP
; platformio.ini lib_deps = https://github.com/apadevices/APAPHX2_ADS1115
#include "APAPHX2_ADS1115.h" ADS1115_PHX_PH phSensor(0x49); // pH β I2C address 0x49 ADS1115_PHX_RX rxSensor(0x48); // ORP β I2C address 0x48 void setup() { Serial.begin(115200); phSensor.begin(); rxSensor.begin(); if (!phSensor.isCalibrated()) { Serial.println("pH not calibrated β run calibration first"); } if (!rxSensor.isCalibrated()) { Serial.println("ORP not calibrated β run calibration first"); } } void loop() { // Non-blocking measurement static PHXConfig cfg; static bool cfgReady = false; if (!cfgReady) { cfg.samples = 10; cfg.delay_ms = 5; cfg.avg_buffer = 1; cfgReady = true; } phSensor.startReading(cfg); while (phSensor.getState() != PHXState::IDLE) { phSensor.updateReading(); } rxSensor.startReading(cfg); while (rxSensor.getState() != PHXState::IDLE) { rxSensor.updateReading(); } Serial.print("pH: "); Serial.print(phSensor.getLastReading(), 3); Serial.print(" ORP: "); Serial.print(rxSensor.getLastReading(), 1); Serial.println(" mV"); delay(2000); }
Note:
begin()automatically loads calibration from EEPROM. If no valid calibration is stored, readings return raw mV until calibration is performed.
Calibration is the most important step for accurate measurements. The library uses two-point calibration β two known reference solutions are measured and the library maps all subsequent readings between them.
Electrochemical probes do not produce a universal voltage for a given pH or ORP value. Each probe has a unique offset and slope that depends on its age, condition and temperature. Without calibration the library returns raw differential mV from the ADC β useful for diagnostics but not for chemistry.
pH calibration:
- pH 4.0 buffer solution (liquid β avoid powder, insufficient precision)
- pH 7.0 buffer solution (liquid)
- Distilled or deionised water for rinsing
ORP/RX calibration:
- 475 mV ORP reference solution
- 650 mV ORP reference solution
Any commercially available liquid standard buffer solution works. No specific brand is required.
The library provides a simple two-step guided calibration. Each step blocks for approximately 200 seconds while the probe equilibrates in the buffer solution β this wait is mandatory for accurate results.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Calibration timeline (per point) β
β β
β Place probe β Press enter β [200s soak] β [stable check] β
β β β
β "Cal: wait 200s..." β probe equilibrates β
β β β
β "Calibration: stable!" β
β Result stored in RAM β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#include "APAPHX2_ADS1115.h" ADS1115_PHX_PH phSensor(0x49); // Route library messages to Serial void onMessage(const __FlashStringHelper* m) { Serial.println(m); } void setup() { Serial.begin(115200); phSensor.begin(); phSensor.setMessageCallback(onMessage); // ββ STEP 1: First buffer solution βββββββββββββββββββββββ Serial.println("Rinse probe with distilled water."); Serial.println("Place probe in pH 4.0 buffer. Press ENTER when ready."); waitForEnter(); float mV1 = phSensor.calibratePoint1(4.0f); Serial.print("Point 1 captured: "); Serial.print(mV1, 3); Serial.println(" mV"); // ββ STEP 2: Second buffer solution ββββββββββββββββββββββ Serial.println("Rinse probe with distilled water."); Serial.println("Place probe in pH 7.0 buffer. Press ENTER when ready."); waitForEnter(); bool ok = phSensor.calibratePoint2(7.0f); if (ok) { phSensor.saveCalibration(); // persist to EEPROM Serial.println("Calibration saved."); } else { Serial.println("Calibration failed β check buffer solutions."); } } void waitForEnter() { while (Serial.available()) Serial.read(); while (!Serial.available()) { ; } while (Serial.available()) Serial.read(); } void loop() { }
For custom workflows, each step can be called individually:
// Capture stable mV reading (blocking β ~200s minimum) float mV = phSensor.calibratePHXReading(); // Build calibration struct manually PHX_Calibration cal; cal.ref1_mV = mV_at_buffer1; cal.ref1_value = 4.0f; cal.ref2_mV = mV_at_buffer2; cal.ref2_value = 7.0f; // Validate and store to RAM bool ok = phSensor.calibratePHX(cal); // Persist to EEPROM (call explicitly β not automatic) if (ok) phSensor.saveCalibration();
- The 200-second soak is mandatory. pH electrodes need 60β180 seconds to equilibrate in a new buffer solution. Starting the stability check too early produces a wrong calibration that looks valid but gives incorrect readings.
- Calibration is NOT auto-saved. After
calibratePoint2()succeeds, you must callsaveCalibration()explicitly. This is by design β it lets you verify the calibration before committing to EEPROM. - Auto-load on power-up.
begin()automatically loads the last saved calibration from EEPROM. If no valid calibration is found,isCalibrated()returns false and readings return raw mV. - Recalibrate after sensor change. Any probe replacement requires fresh calibration. The saved EEPROM data is specific to each probe.
- ORP calibration follows the same process β use
ADS1115_PHX_RXinstance with 475 mV and 650 mV reference solutions.
phSensor.begin(); if (phSensor.isCalibrated()) { PHX_Calibration cal = phSensor.getCalibration(); Serial.print("ref1: "); Serial.print(cal.ref1_mV); Serial.print(" mV β "); Serial.println(cal.ref1_value); Serial.print("ref2: "); Serial.print(cal.ref2_mV); Serial.print(" mV β "); Serial.println(cal.ref2_value); } else { Serial.println("No calibration found."); }
Calibration data is stored at fixed addresses:
| Instance | Base address | Occupies |
|---|---|---|
pH (ADS1115_PHX_PH) |
128 | 128β144 |
ORP (ADS1115_PHX_RX) |
161 | 161β177 |
Custom base addresses can be set via the constructor. Minimum EEPROM size required: 178 bytes (all AVR devices with EEPROM satisfy this).
// pH sensor β default address 0x49, EEPROM base 128 ADS1115_PHX_PH phSensor(0x49); // ORP/RX sensor β default address 0x48, EEPROM base 161 ADS1115_PHX_RX rxSensor(0x48); // With ALERT pin ADS1115_PHX_PH phSensor(0x49, A0); // With custom EEPROM base (advanced β multiple boards) ADS1115_PHX_PH phSensor(0x49, ADS1115_PHX::NO_ALERT, 200);
sensor.begin(); // init I2C, load calibration from EEPROM sensor.begin(false); // skip Wire.begin() β if Wire already started
PHXConfig cfg; cfg.samples = 10; // ADC samples per cycle (1β25) cfg.delay_ms = 5; // pause between samples in ms (0 = immediate) cfg.avg_buffer = 1; // rolling average window (1 = off, 2β10 = active) sensor.startReading(cfg); // begin cycle sensor.updateReading(); // call repeatedly in loop() sensor.cancelReading(); // abort current cycle PHXState s = sensor.getState(); // IDLE / COLLECTING / PROCESSING bool done = sensor.isReadingComplete(); float val = sensor.getLastReading(); // calibrated pH or ORP mV float raw = sensor.getLastRawMV(); // raw differential mV (pre-calibration)
// Guided β recommended float mV = sensor.calibratePoint1(4.0f); // step 1: first buffer bool ok = sensor.calibratePoint2(7.0f); // step 2: second buffer + finalise // Low-level float mV = sensor.calibratePHXReading(); // blocking stable reading bool ok = sensor.calibratePHX(cal); // validate + store to RAM bool ok = sensor.saveCalibration(); // write to EEPROM bool ok = sensor.loadCalibration(); // read from EEPROM // Status bool cal = sensor.isCalibrated(); PHX_Calibration cal = sensor.getCalibration();
sensor.enableTemperatureCompensation(true); sensor.setTemperature(25.0f); // Β°C, valid range 0β50Β°C float t = sensor.getCurrentTemperature(); bool e = sensor.isTemperatureCompensationEnabled();
sensor.setRollingAverage(5); // window size 2β10 (1 = off) sensor.clearRollingAverage(); // reset ring, keep window bool ready = sensor.isRollingAverageReady(); // true once ring is full
sensor.setGain(ADS1115_GAIN_1); // default: GAIN_2 for pH, GAIN_1 for ORP sensor.setDataRate(ADS1115_DR_128); // default: 128 SPS (~7.8ms/conversion) int16_t raw = sensor.readADC(); // single blocking read float range = sensor.getVoltageRange(); // Β±V for current gain
PHXError e = sensor.getLastError(); // PHXError::NONE / PH_LOW / PH_HIGH / RX_LOW / RX_HIGH // TEMP_INVALID / CALIB_INVALID
The debug system is compile-time gated β it adds zero overhead when disabled.
Uncomment the #define in APAPHX2_ADS1115.h:
// In APAPHX2_ADS1115.h: #define ADS1115_DEBUG // <-- uncomment to enable debug output
Then enable at runtime and optionally redirect the output stream:
phSensor.enableDebug(true); phSensor.setDebugStream(Serial); // default β any Stream works // e.g. Serial1, SoftwareSerial
When enabled, the library prints detailed diagnostics for every operation β I2C register writes, sample-by-sample ADC values, calibration windows, EEPROM operations and rolling average state. This is intended for Serial Monitor use during development.
Note: Debug output is Stream-based (Serial, UART). It is verbose and not suitable for LCD display. For user-facing messages on LCD, use
setMessageCallback()instead.
Note:
#define ADS1115_DEBUGmust be set at compile time. The runtimeenableDebug()toggle only works when the define is active.
The library emits three short user-facing messages during calibration. By default the library is completely silent. Register a callback to receive these messages and route them to any output device.
All messages are β€ 20 characters β designed to fit one row of a Γγ°γ€4 LCD.
| Message | When |
|---|---|
"Cal: wait 200s..." |
Probe soak started |
"Calibration: stable!" |
Stable reading captured |
"Cal: timeout-check! " |
Stability timeout β check probe |
void onMessage(const __FlashStringHelper* m) { Serial.println(m); } phSensor.setMessageCallback(onMessage); rxSensor.setMessageCallback(onMessage);
LiquidCrystal_I2C lcd(0x27, 20, 4); void onMessage(const __FlashStringHelper* m) { lcd.setCursor(0, 3); // bottom row lcd.print(F(" ")); // clear row lcd.setCursor(0, 3); lcd.print(m); } phSensor.setMessageCallback(onMessage);
- The callback receives library status messages only β calibration progress, stable confirmation, timeout warning.
- User interaction prompts ("place probe in buffer", "press button") are always the responsibility of your sketch, not the library. The library has no knowledge of your input method (Serial, button, touchscreen).
- If
setMessageCallback()is never called, the library is completely silent β noSerial.begin()required.
Fully supported. Zero heap allocation after begin(). Tested on Arduino Mega 2560.
Minimum recommended board: Arduino Uno / Nano (32KB flash, 2KB SRAM).
The library uses ~500 bytes SRAM at runtime (fixed buffers, no heap).
#include <Wire.h> #include "APAPHX2_ADS1115.h" // Wire runs at 100kHz by default on AVR β no changes needed
Supported. The calibration soak loop includes yield() calls to feed the watchdog.
#include <Wire.h> #include "APAPHX2_ADS1115.h" // Wire.begin(SDA_PIN, SCL_PIN) if using non-default pins
Supported with STM32duino Arduino core.
#include <Wire.h> #include "APAPHX2_ADS1115.h" // Wire.setSDA(PB7); Wire.setSCL(PB6); // if needed for your board // Wire.begin();
The ADUM1251 isolator supports both Standard-mode (100kHz) and Fast-mode (400kHz).
The library defaults to 100kHz which is sufficient for all PHX measurement needs.
To enable 400kHz:
Wire.begin(); Wire.setClock(400000); phSensor.begin(false); // false = skip Wire.begin() inside library rxSensor.begin(false);
| # | Name | Class | What it demonstrates |
|---|---|---|---|
| 01 | BasicReading |
Simple | readADC(), begin(), direct blocking read |
| 02 | StateMachine |
Simple | Non-blocking startReading() / updateReading() |
| 03 | DualSensor |
Simple | pH + ORP together, continuous loop pattern |
| 04 | Calibration |
Middle | Full guided calibration with Serial prompts |
| 05 | TemperatureComp |
Middle | Temperature compensation for pH |
| 06 | RollingAverage |
Middle | Rolling average filter, warm-up, window sizing |
| 07 | DebugOutput |
Advanced | Debug system, setDebugStream(), runtime toggle |
| 08 | LCDDisplay |
Advanced | setMessageCallback() with LCD Γγ°γ€4 |
| 09 | PoolMonitor |
Advanced | Complete pool automation sketch |
All examples are in the examples/ folder, organised by class:
examples/
simple/
01_BasicReading/
02_StateMachine/
03_DualSensor/
middle/
04_Calibration/
05_TemperatureComp/
06_RollingAverage/
advanced/
07_DebugOutput/
08_LCDDisplay/
09_PoolMonitor/
| Parameter | Value |
|---|---|
| pH ADC | ADS1115, 16-bit, I2C 0x49 |
| ORP ADC | ADS1115, 16-bit, I2C 0x48 |
| pH frontend | LMP7721 precision op-amp |
| ORP frontend | LMP7721 precision op-amp |
| Isolation | ADUM1251 I2C isolator (per channel) |
| MCU supply | 3.3V or 5V (match MCU logic level) |
| Analog supply | 12V DC external |
| Max analog supply | 16V (PolyPTC fuse rated) |
| I2C speed | 100kHz standard, 400kHz fast-mode supported |
| pH gain | Gain 2 (Β±2.048V), LSB = 62.5ΞΌV |
| ORP gain | Gain 1 (Β±4.096V), LSB = 125ΞΌV |
| pH resolution | ~947 counts/pH unit |
| ORP resolution | ~8 counts/mV |
| pH accuracy | Β±0.002 pH (calibrated, probe settled) |
| ORP accuracy | Β±0.5 mV (calibrated, probe settled) |
| Electrode slope check | 40β70 mV/pH accepted (>95% Nernst = excellent) |
| EEPROM usage | 17 bytes per sensor (pH: 128β144, ORP: 161β177) |
| Onboard I2C pullups | 4.7kΞ© Γγ°γ€ 2, switchable via H3 jumper |
| Reverse polarity protection | Schottky diode on 12V rail |
| Overcurrent protection | PolyPTC fuse 500mA |
| Transient protection | TVS 15V/24.4V on 12V rail |
MIT License β see LICENSE file.
Copyright Β© 2025 APADevices [@kecup]