Sump pump controller for the Seeed XIAO ESP32-C3. Reads a resistive water level sender, decides when the pump may run, and reports to MQTT / Home Assistant.
The pump is not driven directly — the relay inhibits or allows it, so the pump's own float switch remains the final authority.
| Condition to allow the pump | |
|---|---|
| Night (22:00–04:59) | level > waterLevelThreshold (5 cm) |
| Day (05:00–21:59) | level > criticalWaterLevel (32 cm) or rising ≥ 1.0 cm/min |
| Always required | MQTT has not published no to the safety topic |
| Refractory | 5 min lockout after each window — bypassed when critical |
| Window | 5 min max, closes early once drained (min 30 s) |
Two failure directions are deliberate: an unknown clock falls back to night rules so a network outage cannot disarm flood protection, and a critical level bypasses the refractory so a real flood is not locked out half the time.
One LED, counted blink codes — N pulses, then a long dark gap.
| LED | Meaning |
|---|---|
| Dark | All good — nothing needs your attention |
| Solid on | Pump allowed right now |
| 2 blips | No WiFi |
| 3 blips | WiFi up, no MQTT broker |
| 4 blips | Connected, clock not NTP-synced |
| 5 blips | MQTT reported unsafe to operate |
| 6 blips | Sender table malformed — pump forced allow |
Seeed XIAO ESP32-C3, a resistive level sender (240 Ω empty → 33 Ω full, the standard automotive range), and a relay module on its own supply.
SUPPLY --[ R_TOP ]--+-- node --[10k]--+-- D1 (ADC)
| |
[ sender ] [100nF]
| |
GND GND
| XIAO pin | Function |
|---|---|
| D1 | Divider node, via 10k series + 100nF to GND |
| D10 | Relay inhibit/allow, 10k pull-up to 3V3 |
| D5 | Status LED anode → 220 Ω → GND |
Non-negotiable details:
- ADC1 only. D0/D1/D2 work; D3 is ADC2 and returns garbage while WiFi runs.
- BAT54 Schottky from D1 to 3V3. An open sender pulls the node to the full supply; the 10k plus the clamp keeps that off the GPIO.
- 10k pull-up on D10.
INHIBIT_ACTIVE_LEVELis HIGH, so a floating-low pin at boot means pump allowed — the wrong failure direction. Internal pull-ups are inactive during reset. - 5V pin or USB, never both. That pin is USB VBUS.
PlatformIO:
cp src/config.example.h src/config.h # then fill in your credentials
pio run -t upload
pio device monitorsrc/config.h is git-ignored and must never be committed.
Arduino IDE also works — copy src/main.cpp to FlushWaterNG.ino alongside
config.h, select board XIAO_ESP32C3 and set USB CDC On Boot: Enabled.
Without that setting Serial is routed to the GPIO20/21 UART, which is not
wired to the USB-C connector, and the monitor stays silent. The source carries
explicit forward declarations, so it compiles as either .ino or .cpp.
Level is looked up by sender resistance, not ADC counts, so the table survives a change of supply voltage, top resistor, or chip.
- Measure
SUPPLY_MVandR_TOP_OHMwith a meter — on the supply you will actually run on. USB VBUS and an external brick do not read the same. - Set
CALIBRATION_VERBOSE 1and log resistance against known water heights. - Replace
senderTable[]. It must be strictly ascending in resistance and descending in level;validateSenderTable()checks this at boot and fails the pump to allowed if it does not hold.
The sender is not linear — roughly 3.5 Ω/cm through the main body but ~12 Ω/cm below 7 cm. Keep the dense rows at the bottom; that is where the decisions are.
| Topic | Direction | Payload |
|---|---|---|
pool/sumppump/safe |
in | no inhibits the pump; anything else allows it |
pool/sumppump/status |
out, retained | allow / inhibit |
pool/sumppump/level |
out | level in cm |
pool/sumppump/alert |
out | sensor faults, ineffective pump, expired safety hold |
pool/sumppump/log |
out | boot and 5-minute heartbeat diagnostics |
A no on the safety topic expires after 30 minutes without a broker update and
fails open. A latch that can never be cleared is a flood waiting to happen.
Copy the entities from configuration.yaml into your HA
config and restart. The broker needs a matching login — see
src/config.example.h.
You get:
| Entity | What it is |
|---|---|
sensor.sump_water_level |
Level in cm, graphable |
binary_sensor.sump_pump_allowed |
Whether the pump may run right now |
switch.sump_safety_hold |
Turn on to inhibit the pump |
sensor.sump_last_alert |
Sensor faults, ineffective pump, expired hold |
sensor.sump_diagnostics |
Boot line and 5-minute heartbeat |
binary_sensor.sump_controller |
Whether the controller is alive at all |
Availability. The controller publishes online retained to
pool/sumppump/availability, and registers an MQTT Last Will so the broker
publishes offline by itself if the controller stops answering keepalives.
Every entity hangs off that topic, so a dead ESP shows as unavailable rather
than silently holding its last reading — which, for a water level, would look
exactly like a calm sump. Expect ~45 s of lag; the will fires after 1.5× the
30 s keepalive.
lovelace-flushwater.yaml has a dashboard card with
the level history and pump state overlaid.
Add the automations too:
automation: !include automations.yamlHow the persistent hold works. Turning the switch on publishes no, but the
controller drops that hold after 30 minutes without a further message — a latch
nothing can clear is a flood waiting to happen. So HA re-publishes it every 10
minutes, and a hold you set on purpose lasts indefinitely.
The two halves matter together: as long as HA is alive the hold sticks, and if HA or the broker dies the hold lapses within 30 minutes and the controller goes back to protecting the basement on its own. The 10-minute interval tolerates two consecutive missed runs. Don't widen it much — at 25 minutes a single missed run releases the pump.
automations.yaml also notifies on any controller alert, and warns if
sensor.sump_diagnostics goes quiet for 20 minutes. The heartbeat is every 5,
so silence means the controller is down and the pump is running unsupervised on
its own float switch.
- Task watchdog on the loop task, 60 s.
- Connectivity watchdog: reboot after 15 min offline, since the task WDT cannot
catch a wedged network stack —
loop()keeps running and feeding it. Defers while a flush is in progress. - Reset reason reported at boot and in heartbeats. Watch for
BROWNOUT: a pump motor starting sags a shared supply and otherwise looks like a random reboot.
See LICENSE.