An automatic KH tester for reef aquariums. It takes a sample of tank water, titrates it with acid on its own schedule, and sends the result to your Telegram. A live dashboard runs in your browser, and every reading can also be logged to labaqua.net.
- Measures KH (carbonate hardness) by real titration to pH 4.10, no test kit or colour chart
- Runs on a schedule, rinses itself, and re-checks any result that looks off
- Controlled from Telegram, a web page on your network, or USB
- Updates its own firmware over Wi-Fi
- Open hardware: 3D-printed parts, ESP32 board, schematics and parts list in this repo
The web dashboard: latest KH, a live diagram of the station, the titration curve and the console. Click for full size.
![]() |
![]() |
| One whole run on the dashboard, from rinse to result | A built station next to the tank |
Questions, feedback, or just want to see what other people are building? Join the Arduino Aquarium titrator group chat.
Contents: What it does · Hardware · Getting started · Web dashboard · Commands · Firmware updates · Building from source
It runs a real titration — the same kind you'd do by hand with a syringe and a test kit — except it does it by itself, on a timer, and tells you the number afterwards.
One run looks like this:
- Home — both syringes move to their limit switches, so the machine knows exactly where they are before it starts.
- Draw reagent — the reagent syringe pulls in 10 ml of HCl while the water side is busy with the next steps, so no time is wasted.
- Rinse — the reactor gets flushed with tank water and drained, so nothing from the last test is left behind.
- Take a sample — 25 ml of tank water goes into the reactor.
- Settle — the stirrer runs for a bit so the electrode has time to give a steady reading.
- Dose — acid goes in step by step, starting big (3 ml) and getting finer (down to 0.01 ml) as the pH gets close to the endpoint. After each drop, it waits for the reading to actually settle instead of just waiting a fixed number of seconds.
- Endpoint — dosing stops at pH 4.10.
- Wrap up — both syringes go home, the reactor drains, and the result is sent out.
The math behind the number:
KH = (2804 × acid used, ml × acid concentration) / (sample size, ml / 1.026)
Defaults are 0.01 mol/L HCl and a 25 ml sample, both of which you can change.
If a result is more than maxDeviation (0.30 dKH by default) away from the last one,
the station doesn't just report it — it runs again to double-check. If two retries in a
row still don't agree, it tells you something's off instead of guessing.
Full build guide with photos: Assembling. Printed parts: STL. Board: PCB.
- ESP32 (DOIT DEVKIT V1 or similar, 4 MB flash)
- Two syringe pumps — one for reagent (10 ml), one for the water sample (25 ml). Each has its own stepper motor, a limit switch for homing, and a small servo-driven valve that switches between filling and injecting.
- pH electrode, read through an amplifier board into the ESP32's ADC
- Magnetic stirrer
- Drain pump to empty the reactor between runs
Pin map (include/PIN_definition.h):
| Function | GPIO | Function | GPIO | |
|---|---|---|---|---|
| Reagent EN / STEP / DIR | 13 / 12 / 23 | Water EN / STEP / DIR | 27 / 26 / 19 | |
| Reagent endstop / valve | 16 / 18 | Water endstop / valve | 5 / 17 | |
| pH input | 35 | pH loopback for bench testing | 25 | |
| Drain pump | 33 | Stirrer | 32 | |
| Buzzer | 4 |
The two pumps don't all move at the same speed — on purpose:
| Move | Speed | Why it's set this way |
|---|---|---|
| Dosing | 1000 Hz | This is the number that becomes your KH result, so it moves carefully |
| Filling | 1500 Hz | Also matters for accuracy, but less critical, so a bit faster |
| Homing | 3000 Hz | Just driving to a limit switch, so it can go fast |
You can change any of these later (setdosehz_, setfillhz_, sethomehz_) if you've
tested your build at a higher speed and trust it.
The limit switches are optical (a light beam across a slot). Direct sunlight, or a
bright lamp shining into the slot, blinds them: the station then cannot tell that a
syringe has reached home, and homing fails or ends in an error. This has happened on a
real station. Place the station out of direct sun, or shade the endstops, keeping the
slot itself clear for the flag that moves through it.
If you see Home can not be reached or endstop reads HOME but ... steps are still loaded in the console, check the light on the endstops first.
Wi-Fi first. On its first start the station opens its own Wi-Fi access point called AutoConnectAP. Join it from your phone, pick your network and type its password — the station remembers it as Wi-Fi 1 and connects from then on.
It looks after the connection by itself (firmware 3.1.0 and newer):
- Router restarted, or Wi-Fi dropped for a while? The station keeps looking for its networks every ~15 seconds, for as long as it takes, and reconnects on its own.
- Power cut? The station usually starts faster than the router. It begins titrating offline and joins as soon as the router is back; its start-up message then arrives as usual, with a line saying how long the network took to appear.
- AutoConnectAP comes back only if none of the saved networks has been in range for 10 minutes — for example after moving the station to a new place — or if no network is saved at all. Even then it keeps titrating and keeps looking for its networks every minute.
Then Telegram. You need a bot of your own and your numeric Telegram ID — Setting up Telegram walks you through both in about five minutes.
With those in hand, give it the rest — either on the page
http://kh-station.local/settings (linked at the bottom of the dashboard; it shows what
is already set), or over USB with a serial monitor at 115200 baud,
one line at a time:
bottoken_<the token @BotFather gave you>
settlgrmid_<your numeric Telegram id>
ukey_<the token labaqua.net emailed you>
ukey_ restarts the station, so send it last. Don't skip settlgrmid_ — it's what
keeps strangers from being able to control your station over Telegram. If everything
went through, the bot will say "KH station started" once it restarts. Since firmware
2.5.13 that message also says why the station restarted — power cut, a dip in the
power supply, a crash or a command — and lastreset repeats it. If your station keeps
restarting on its own, that line is the thing to send when you ask for help. Bot
silent? See If the bot stays silent.
Optional: a second Wi-Fi network (firmware 3.1.0 and newer). The station keeps two,
Wi-Fi 1 and Wi-Fi 2, and neither is the "main" one: it joins whichever is in
range — the stronger one if both are — and stays on it while it works. Set them on the
/settings page, or with wifi1_<network name>#<password> and
wifi2_<network name>#<password>. wifinets shows both and which one is in use,
wifiswitch changes over by hand. A network chosen in AutoConnectAP becomes Wi-Fi 1 and
the previous Wi-Fi 1 moves to Wi-Fi 2. (Firmware 2.5.x had a main network and a backup
set with wifibackup_; that command still works and sets Wi-Fi 2. Updating to 3.1.0
keeps both networks: the main one becomes Wi-Fi 1, the backup Wi-Fi 2.)
This is the step that matters most for accuracy, so it's worth doing properly before you trust any reading.
- In Telegram: Calibrations → Calibrate pumps
- Put the tube over a cup on a scale and run Run <pump> pump test
- When it beeps three times, weigh what came out
- Not quite right? Send the real weight back with Set real <pump> mass, e.g.
24.87 - Repeat until it's spot on
Target: reagent 10.00 ± 0.05 g, water 25.00 ± 0.05 g.
The station ships with a placeholder calibration — you'll want to set your own with two buffer solutions, 4.01 and 6.86:
- Rinse the electrode and put it in the 4.01 buffer
- Press Start reading pH (web) or send
readph(serial), and wait for the number to stop moving - Send
calph_4.01 - Rinse, switch to the 6.86 buffer, and send
calph_6.86
Don't skip the "wait for it to settle" part in step 2 — calibrating on a reading that hasn't stabilized yet locks in a slightly wrong number, and every KH result after that inherits the error.
Just open http://<device-ip>/ — or http://kh-station.local/ — nothing to install, no
internet connection needed.
(Screenshots are at the top.)
The bottle on the left is your reagent stock — the level drops as it gets used, and it
tells you how many millilitres are left. The stir bar moves while the stirrer is
running, and the two home lamps are the syringe limit switches: green when a syringe
is parked, dark when it is not, amber if the switch cannot make up its mind.
- Header — the latest KH, what the station is doing, and on the right the firmware version, the Wi-Fi network (Wi-Fi 1 or 2) and its signal as 0–5 bars
- Station diagram — both syringes, the reactor, the tubing, the stirrer, plus the numbers you actually care about: pH, KH, ml dosed
- Titration curve — pH plotted against volume as it doses, so you can see the shape of the run; between runs it just plots pH over time
- Console — a live feed of what the station is doing, not a stale snapshot
- Readings — every stored value, including the correction index and how much reagent you have left
- Pumps / Settings — tucked away since you'll rarely need them day to day
Every button asks for confirmation before it actually moves anything.
There's no login on the dashboard. Keep the station on a network you trust, and don't expose it to the internet.
These work the same way over serial, Telegram, and
http://<device-ip>/commands?param=<command>. Anything marked ⚠ moves hardware and
won't run while a titration is in progress.
Titration
| Command | What it does |
|---|---|
titr_1 ⚠ |
Start a titration right now |
stoptitr |
Stop the current run; both syringes return home |
washreactor ⚠ |
Rinse and drain the reactor |
refilreagent ⚠ |
Top up the reagent line |
allhome ⚠ |
Send both syringes home |
lastkh, counttitr, lastlog |
Last result, run count, log from the last run |
pH
| Command | What it does |
|---|---|
readph / stopreadph |
Start / stop live reading |
calph_4.01, calph_6.86 |
Save a calibration point |
phwait_<ms>_<eps>_<n>_<timeout> |
Tune how the station decides a reading has settled |
phfilter_<mea>_<est>_<q> |
Advanced: filter tuning — changes the measured pH itself |
phsim_1 / phsim_0 |
Use a simulated reading instead of the real electrode — until the next restart; the station always starts on the real electrode (firmware 2.5.15 and newer) |
phloop_1 / phloop_0 |
Bench-test mode without a probe at all (see below) |
Settings
| Command | Default |
|---|---|
maxdeviation_<dKH> |
0.30 |
retitrhour_<1..24> |
3 |
setwvolume_<ml> |
25.00 |
setrvolume_<L> |
5.0 (reagent stock) |
setdosehz_ / setfillhz_ / sethomehz_ |
1000 / 1500 / 3000 |
stirrerd_<0..255> |
220 |
settings, getcalvalues |
Show everything that's currently stored |
Wi-Fi (firmware 3.1.0 and newer)
| Command | What it does |
|---|---|
wifinets |
Both saved networks, which one is in use, signal and IP |
wifi1_<name>#<password>, wifi2_<name>#<password> |
Save Wi-Fi 1 / Wi-Fi 2 (nothing after # for an open network) |
wifi1clear, wifi2clear |
Forget one of them (the only saved network can't be removed) |
wifiswitch |
Move to the other saved network now |
System
restart, getip, freeheap, gettime, checkupdate, updatedevice, rollback,
lastreset, sendlogs
The station checks firmware/bin_version.txt in this repo, and if there's a newer
version, it'll offer to update from the Telegram Update menu. Sending
updatedevice downloads firmware/firmware.bin over a secure connection and installs
it — no cable needed.
Firmware 3.x (3.1.0, October 2026) is a rewrite of 2.5.x with the same commands,
dashboard and Telegram menus; updating from 2.5.x keeps every setting and calibration.
If anything looks wrong after an update, rollback goes back to the firmware you had
before in a few seconds, without a download — the previous version is still on the
board.
If your station is still on firmware 2.2.x, read this before updating.
Older firmware saved a few settings (your sample size, last result, reagent used so far) under different internal names than the current firmware looks for. Update straight over the air and those won't carry over — the station will quietly reset them to factory defaults instead of telling you anything changed.
tools/kh_loader/kh_loader.ino fixes this for you. Flash it once from the Arduino IDE
and it will:
- Print out everything currently stored, so you have a record
- Carry over your old settings to where the new firmware expects them
- Connect to Wi-Fi and install the latest firmware
Your pump and pH calibration are untouched either way — no need to redo those.
It's safe to run more than once: it only ever fills in a setting that's missing. If both an old and a new copy of a setting already exist, it leaves both alone and just shows you what it found.
What to set in the Arduino IDE first (the second one trips people up):
- Board → ESP32 Dev Module
- Partition Scheme → Minimal SPIFFS (1.9MB APP with OTA/128KB SPIFFS) — the default scheme doesn't leave enough room and the install will fail right at the end
- Upload Speed → 115200
- Erase All Flash Before Sketch Upload → Disabled — otherwise the upload itself wipes the calibration this sketch exists to keep
It connects to the Wi-Fi network your station already uses, so there is usually
nothing to type into the sketch. To use a different network, fill in WIFI_SSID and
WIFI_PASS at the top.
Already on 2.3 or newer? You can just update normally from Telegram.
| Path | What's in it |
|---|---|
firmware/ |
firmware.bin and bin_version.txt — what your station downloads when it updates itself |
PCB/ |
schematics (main board and pH front end) and the parts list |
STL/ |
every 3D-printed part: pump bodies, carriage, reactor holder, valve rotors, stirrer |
Assembling/ |
build photos and notes |
docs/ |
step-by-step guides — creating your Telegram bot and finding your ID |
tools/kh_loader/ |
a small helper sketch for upgrading an older station (see Upgrading) |
tools/readme_images/ |
the script that draws the labelled station pictures in docs/ |
pio run # build
pio run -t upload # flash over USBplatformio.ini points at a specific fork of the ESP32 platform:
platform = https://github.com/pioarduino/platform-espressif32.git#55.03.312-1That's intentional — the mainstream platform hasn't caught up to the newer Arduino core yet, so this project uses the pioarduino fork instead, pinned to an exact version so a future update can't change the build under you.
Upload speed needs to stay at 115200 — faster speeds don't work reliably with this board and cable combination.
Nothing secret lives in this repository. Your Wi-Fi password, Telegram token, chat ID and labaqua key are all stored on the device itself, set with the commands above — never in the source code.
Send phloop_1 and the ESP32 will simulate an electrode for you: it generates a
voltage internally and reads it back, so everything downstream — filtering,
calibration, the math — runs exactly as it would with a real probe. You'll need a
jumper wire from GPIO25 to GPIO35 for this to work; if it doesn't detect one, it'll
tell you.
It's meant for testing only and is never saved — every restart goes back to the real electrode, so you can't accidentally leave a station running on fake readings.
- Won't dose into a sample that looks wrong. If the starting pH doesn't look like water — say, the probe isn't actually connected — it stops and tells you, instead of quietly reporting a KH of 0.00.
- Won't report a run that used zero reagent. That's never a real result.
- Won't publish a number it doesn't trust. If a result disagrees with the last one, it retries; after two disagreements in a row, it tells you rather than guessing.
- Always returns home after a stop. Hit
stoptitrand both syringes go back to a known position — nothing is left loaded or half-moved.
GPL-3.0. See LICENSE.




