I keep a red-eared slider named Bobuerto. The one number that actually matters in his tank is water temperature, and the two things I kept guessing at were "is it time for a water change" and "why is the water suddenly cloudy". So I built a small water monitor around a Seeed XIAO ESP32-C3 with three cheap probes. No soldering, one jumper-wire kit, and the whole thing runs off a phone charger.
This post is the build: the parts, the wiring (with the one gotcha that fries boards), the firmware shape, and an ESPHome YAML that drops all three readings straight into Home Assistant with no code.
Honesty up front: the wiring and firmware are my build; the ESPHome route is written from the HA docs against the same pin map, not from a second build on my tank. Treat the numbers as starting points and check them on your board.
What the three probes actually tell you
These are not a test kit, and I don't want you to think they are.
| Probe | Measures | What it means for turtle water |
|---|---|---|
| DS18B20 | Water temperature, ±0.5 °C | Whether the heater is doing its job. The one reading that is a real alarm |
| TDS | Total dissolved solids (conductivity, in ppm) | How much has built up since the last water change. Rises as waste dissolves, drops when you change water. A trend meter, not an ammonia test |
| Turbidity | How much light gets through the water | Cloudiness: a clogged filter, a bacterial bloom, a feeding that went everywhere. Also a trend meter |
So the sensor answers: is the water warm enough, is it time for a water change, and did something just go cloudy. Your liquid test kit still answers "is the water safe".
Parts
Everything is a plug-together module.
- Seeed Studio XIAO ESP32C3 (get the pre-soldered one). Native USB-C, Wi-Fi, and two analog pins on ADC1 that keep working while Wi-Fi is on.
- DS18B20 waterproof temperature probe kit (BOJACK). The kit's little adapter board already has the 4.7 kΩ pull-up, so nothing to add.
- Keyestudio TDS Meter V1 module + probe, with its 3-pin cable. Runs on 3.3 V.
- Keyestudio Turbidity Sensor V1 module + probe. Has an analog/digital switch; use analog.
- HiLetgo 0–25 V voltage sensor module. This is just a prebuilt 5:1 resistor divider with screw terminals. It is the one part that needs explaining (below).
- Dupont jumper wires (you need M-M, M-F and F-F), and a 400-point breadboard used only as a power strip.
- A USB-C data cable and any 5 V phone charger.
The rule that keeps the XIAO alive
Two voltages come out of the XIAO: 5V (USB passed straight through) and 3V3 (regulated for the chip). The chip's pins can only take 3.3 V on their inputs.
The temperature adapter and the TDS board both run happily on 3.3 V and produce signals under 3.3 V. The turbidity board needs 5 V to run and its output can reach about 4.5 V. That is why it is the only sensor with a divider between it and the XIAO, and the only one powered from the 5V pin.
Don't plug a sensor into 5V "because it is more power". More volts in means more volts out, and a 4.5 V signal into a D pin is the board gone.
Wiring
Keep the USB cable unplugged the whole time you wire. It goes in last.
Step 0, the power strip. The XIAO has exactly one GND pin and one 3V3 pin, and four boards need each. So: black M-F jumper from XIAO GND to the breadboard's − rail, orange M-F jumper from XIAO 3V3 to the + rail. Put tape on the + rail that says "3V3, not 5V".
Sensor 1, temperature (D4). Screw the probe's three wires into the adapter (red→VCC, yellow→DAT, black→GND). Adapter VCC from the + rail, adapter GND from the − rail, and a yellow F-F jumper from XIAO D4 to the adapter's DAT. The metal probe and cable can go fully underwater; the adapter stays dry.
Sensor 2, TDS (D1). Plug the 3-wire cable into the board. Black (−) to the − rail, red (+) to the 3V3 rail (yes, 3V3, not 5V), and the yellow (A) signal to XIAO D1. Only the probe wand goes in the water.
Sensor 3, turbidity through the divider (D2). Slide the board's switch to A (analog). Red F-F jumper from XIAO 5V to the cable's V pin (this is the only 5V wire in the whole build, and it skips the power strip). G to the − rail. The S (signal) wire goes into the divider's VCC screw terminal, not the XIAO.
Then the divider: black M-M jumper from the − rail into the divider's GND screw terminal, black M-F jumper from the − rail to the divider's − pin, and a blue F-F jumper from the divider's S pin to XIAO D2. Leave the divider's + pin empty.
The divider's labels confuse everyone the first time: the terminal marked VCC is the input (IN+), the terminal marked GND is IN−, the pin marked S is the output, and − is ground.
This module divides by 5, so 4.5 V comes out as 0.9 V. Safe, with less resolution, because the ESP32's ADC is accurate only to about 2.5 V and you're using a third of it. Two 10 kΩ resistors as a ÷2 divider (max 2.25 V out) gives more detail. Either way, you multiply the reading back by the ratio in code.
The whole map:
| XIAO pin | GPIO | Connects to | Device |
|---|---|---|---|
| 3V3 | — | + rail, then VCC / + | Temp adapter, TDS board |
| GND | — | − rail, then all grounds | Temp, TDS, turbidity, divider (×2) |
| 5V | — | V | Turbidity board only |
| D4 | GPIO6 | DAT | Temp adapter |
| D1 | GPIO3 (ADC1) | A | TDS board |
| D2 | GPIO4 (ADC1) | S (output) | Voltage divider |
Before you plug in USB: the − rail has six wires, the + rail has three (nothing from 5V), only one wire on 5V, turbidity S goes to the divider and D2 comes from the divider's S pin, every board is dry and above the water line, and each cable hangs in a drip loop below the electronics.
The firmware, in outline (PlatformIO)
The XIAO's job is small: read the three probes every 30 seconds, clean the readings, and send one averaged reading every five minutes.
[env:xiao_c3] platform = espressif32 board = seeed_xiao_esp32c3 framework = arduino monitor_speed = 115200 build_flags = -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1 lib_deps = paulstoffregen/OneWire milesburton/DallasTemperature bblanchon/ArduinoJson@^7 The two build_flags make Serial work over the XIAO's own USB-C port, so you can watch readings with nothing but the cable. The pin map:
#define TEMP_PIN D4 // GPIO6, DS18B20 DAT (adapter has the pull-up) #define TDS_PIN D1 // GPIO3, ADC1 #define TURB_PIN D2 // GPIO4, ADC1, through the /5 divider module #define TURB_RATIO 5.0f // 2.0f if you used the two-resistor divider Both analog pins are on ADC1 on purpose: on an ESP32, ADC2 stops working while Wi-Fi is on. Read them with analogReadMilliVolts(), which applies the chip's factory calibration.
static float medianMv(int pin) { // 32 reads, keep the median uint16_t v[32]; for (int i = 0; i < 32; i++) { v[i] = analogReadMilliVolts(pin); delay(10); } std::sort(v, v + 32); return (v[15] + v[16]) / 2.0f; } static void sampleOnce() { temp.requestTemperatures(); // ~750 ms at 12-bit float t = temp.getTempCByIndex(0); if (t > -100 && t != 85.0f) { accT += t; nT++; } // drop -127 and 85 accTds += medianMv(TDS_PIN) / 1000.0f; accTurb += medianMv(TURB_PIN) / 1000.0f * TURB_RATIO; nA++; } Three things in there are worth knowing:
- Median, not average, on the analog pins. The filter pump and the lamp timer put spikes on the water and on the supply. 32 reads 10 ms apart with the median taken throws those out; an average lets one spike move the number.
- Reject the DS18B20's lies. The Dallas library returns −127 °C with no probe and exactly 85 °C at power-on before the first conversion. Both are "no reading", not readings. A loose DAT wire shows up as a steady 85 °C.
- One probe at a time. The TDS and turbidity probes both put a small voltage into the same water, and reading them together makes the TDS number jumpy. Sequential reads fix most of it.
The main loop keeps sampling and sending on separate timers, with a hardware watchdog and a reboot after eight failed sends in a row:
void loop() { esp_task_wdt_reset(); uint32_t now = millis(); if (now - lastSample >= SAMPLE_MS) { lastSample = now; sampleOnce(); } if (now - lastSend >= sendEveryMs) { lastSend = now; pushReading(); if (WiFi.status() != WL_CONNECTED) connectWifi(); if (WiFi.status() == WL_CONNECTED && sendBuffered()) failures = 0; else if (++failures >= 8) ESP.restart(); } delay(200); } Don't read the probes inside the send function "to get fresh numbers": a failed TLS handshake can take 15 seconds, and now your reading cadence depends on the network.
The firmware sends raw volts for TDS and turbidity and °C for temperature. The maths lives on the server (or in HA), with the calibration constants as settings, so a recalibration is a settings change and not a reflash.
Volts to numbers
TDS, temperature-compensated, is the standard formula the Keyestudio and DFRobot boards document:
coeff = 1 + 0.02 * (T - 25) Vc = V / coeff ppm = (133.42*Vc^3 - 255.86*Vc^2 + 857.39*Vc) * 0.5 * K K starts at 1.0 and gets set once against a handheld TDS pen or a 342 ppm calibration solution.
Turbidity I report as a clarity percentage, not NTU, because the cheap probe isn't accurate enough for real NTU and I'd rather show an honest relative number:
sensorV = adcV * 5 (the divider ratio) clarity % = 100 * (V - V_dirty) / (V_clear - V_dirty), clamped to 0-100 V_clear is the probe in clean tap water in a dark cup; V_dirty is a cup of water with a pinch of soil in it. Both are settings.
Straight into Home Assistant with ESPHome
If you run Home Assistant you can skip the custom firmware entirely. ESPHome turns a YAML file into firmware and flashes it from the browser; the wiring above does not change. This YAML is for exactly that pin map:
esphome: name: turtle-water esp32: board: seeed_xiao_esp32c3 framework: type: esp-idf wifi: ssid: !secret wifi_ssid password: !secret wifi_password api: encryption: key: !secret api_key ota: - platform: esphome password: !secret ota_password one_wire: - platform: gpio pin: GPIO6 # XIAO D4 sensor: - platform: dallas_temp name: "Tank water temperature" update_interval: 30s filters: - filter_out: 85.0 - filter_out: -127.0 - platform: adc pin: GPIO3 # XIAO D1, TDS board A name: "Tank TDS volts" attenuation: 12db update_interval: 30s filters: - median: window_size: 15 send_every: 10 - platform: adc pin: GPIO4 # XIAO D2, divider output name: "Tank turbidity volts" attenuation: 12db update_interval: 30s filters: - multiply: 5.0 # the divider ratio - median: window_size: 15 send_every: 10 That gives you three entities with 30-second updates, the same median filtering, the same rejection of the DS18B20's 85 and −127 values, and OTA updates for free. The TDS and clarity formulas become two template sensors, with the calibration constants as input_number helpers so you can calibrate from your phone.
Automations worth having:
- Temperature out of band: numeric state trigger below 73 °F or above 85 °F with
for: 10 minutes, so one odd reading doesn't page you. If the heater is on a smart plug, the same automation can switch it off above 85 °F. - Water change reminder: an input button that stores the current TDS as a baseline, a template sensor for "TDS above baseline", and an alert at +300 ppm.
- Heater on a smart plug with energy monitoring, even if you never automate it. A heater drawing zero watts in a cold tank has failed, and that shows up before the water cools.
When a reading is wrong
The two that catch almost everyone: the temperature probe reading 85 °C (never got a successful read, usually a loose DAT wire), and the turbidity board's switch left on D (digital), which gives a flat reading no matter what the water does. Sanity checks before you write any network code: TDS reads near zero in air and a steady number in the tank, turbidity reads higher in clear water and drops when it gets cloudy, and the temperature is a sensible number.
The care numbers I built around
Water 76–82 °F (24.5–28 °C) is fine, warn outside that, alert under 73 or over 85 °F. Basking surface 90–95 °F (keep the electronics away from it). UVB 10–12 hours a day on a timer, bulb replaced on schedule. 25–50 % water change weekly for a small tank. And the test kit still runs: ammonia 0, nitrite 0, nitrate under 40 ppm.
This is the condensed version of the sensor half of my book Smart Turtle Tank, which also covers the turtle cam (ESP32-CAM, with an AI check on each snapshot), the 3D-printed enclosure that holds all of it, the full firmware with the offline buffer and TLS, and a weekend build checklist.
- Free sample (first chapters): https://payhip.com/b/2FNPO
- The full book, PDF + EPUB: https://payhip.com/b/k0nzS
Questions about the wiring or the ESPHome config, ask below and I'll answer.


