programmingSeptember 11, 2026 / 6 min

Watering my plants with an ESP32, MQTT and a laptop in the corner

I kept killing plants by forgetting to water them for a week at a time. I had an ESP32 and a relay in a box, so I built the thing I kept talking about: a small pump my laptop controls, with a web button I can press from my phone.

The setup has been running for a bit now. My mint is alive. Here is how I wired it and what runs where.

Parts on my desk

My controller is an ESP32-WROOM-32 board with an ESP32-D0WD-V3 chip, dual core at 240MHz, 520KB SRAM and 4MB flash. It talks to a 0.96 inch SSD1306 OLED over I2C and to a single channel Songle relay board with one GPIO.

The pump is a tiny Electronic Spices submersible that draws around 0.3 to 0.5A at 12V and lifts about half a meter. I tried a car washer pump first. It wanted up to 7A and kept tripping my 12V 2A XPOWER adapter into a restart loop, so that one went back on the shelf.

Power is split. A 5V 2A adapter feeds the ESP32 over Micro-USB. A separate 12V 2A adapter feeds only the pump through the relay contacts. The two sides meet at ground and nowhere else.

Wiring

OLED goes to 3V3, GND, GPIO 22 for SCL and GPIO 21 for SDA, address 0x3C. Relay control goes to VIN (5V), GND, and IN on GPIO 19. My early notes said GPIO 18 and the firmware still has a comment about it. I moved the wire to 19 during testing and kept the define there.

The relay is active low, so HIGH at boot means off. On the load side, 12V+ goes to COM, pump + goes to NO, NC stays empty, and 12V- goes straight to pump -.

flowchart TD
    subgraph esp32[ESP32-WROOM-32]
        direction TB
        USB[5V/2A Adapter] --> MCU
        MCU[ESP32] -->|3V3| OLED_VCC[OLED VCC]
        MCU -->|GND| OLED_GND[OLED GND]
        MCU -->|GPIO 21| OLED_SDA[OLED SDA]
        MCU -->|GPIO 22| OLED_SCL[OLED SCL]
        MCU -->|GPIO 19| RELAY_IN[Relay IN]
        MCU -->|VIN 5V| RELAY_VCC[Relay VCC]
    end

    subgraph power[12V/2A SMPS]
        direction TB
        SMPS[12V/2A SMPS] -->|COM| RELAY[Relay COM/NO]
        RELAY -->|NO| PUMP[Pump +]
        SMPS -.->|GND| PUMP_GND[Pump -]
        RELAY_GND[Relay GND] -.-> PUMP_GND
    end

    OLED_VCC -.->|shared GND| RELAY_GND

I powered USB first and left the 12V unplugged until the OLED came up and I could hear the relay click. That order saved me once already.

What the firmware does

I run PlatformIO with the Arduino framework on an esp32dev board. The loop never blocks. WiFi reconnect, MQTT reconnect, pump auto stop, schedule checks, publishes, and the OLED redraw at 30 FPS all run on millis() timers.

On boot it connects to WiFi as a station, starts NTP for IST-5:30, pulls the watering schedule over HTTP from http://192.168.1.220:8000/api/schedule, and connects to MQTT at 192.168.1.220:1883 with client id plant-esp32-01. It subscribes to plant/watering/command and plant/watering/schedule, and it sets a last will on plant/watering/lwt to offline.

Topics look like this:

TopicDirectionWhat I put in it
plant/watering/statusESP32 to server{"relay":"ON","uptime_s":123,"rssi":-62} every 10s
plant/watering/telemetryESP32 to serverheap, reconnect counts, chip temp, loop_ms, seq every 5s
plant/watering/eventESP32 to serverpump_start or pump_stop with duration and source
plant/watering/sysinfoESP32 to serverchip model, flash size, MAC, IP, firmware version, retained
plant/watering/commandserver to ESP32{"cmd":"ON","dur_s":30} or {"cmd":"OFF"}
plant/watering/scheduleserver to ESP32retained schedule doc with interval and duration

command and event use QoS 1. Telemetry uses QoS 0. Status, sysinfo, and schedule are retained so a UI that connects later still sees something.

The schedule survives reboots. I store enabled, interval in seconds, duration in seconds, and next run time in NVS. Default in code is every 21600 seconds for 30 seconds. If the server is down, the ESP32 still fires from its local copy using wall clock when NTP is synced, or a millis() fallback when it is not. When the server pushes a schedule fire over MQTT, the firmware advances its own next run so it does not water twice.

The OLED shows pump state large, plus date and time as 08SEP 20:36, a countdown to next run like 5h 12m, SSID and IP in the footer, and a temp field that reads OK until the chip passes 65C. I show the value only when it runs hot because the internal sensor reads warm and noisy.

How the pieces fit

The ESP32 stays simple. My Ubuntu laptop at 192.168.1.220 on WiFi (.221 on ethernet as fallback) holds Mosquitto, Postgres, the Spring Boot server, and the Next.js UI. MQTT carries control into the laptop. HTTP and a websocket carry state out to my browser.

flowchart LR
    subgraph LAN["Home LAN (192.168.1.x)"]
        direction TB
        Router[Home WiFi Router 192.168.1.1]
        ESP32[ESP32-WROOM-32 DHCP]
        Laptop[Ubuntu Laptop ajinkya<br/>WiFi .220 / Eth .221]
    end

    subgraph LaptopServices["Laptop Services"]
        direction TB
        Mosquitto[mosquitto:1883]
        Ingestor[ingestor Spring + Paho]
        Postgres[(postgres TimescaleDB)]
        Server[Spring Boot :8000<br/>HTTP API + WS]
        UI[Next.js :3000]
    end

    Router --- ESP32
    Router --- Laptop

    ESP32 -->|MQTT plant/watering/#| Mosquitto
    Mosquitto -->|publish status| Ingestor
    Ingestor -->|write| Postgres
    Postgres -->|read| Server
    Server -->|REST + WS| UI
    Server -->|subscribe cmd| Mosquitto
    Mosquitto -->|plant/watering/command| ESP32
    ESP32 -->|HTTP GET /api/schedule| Server

On the laptop, infra/docker-compose.yml runs four containers: mosquitto on 1883, postgres with TimescaleDB internally, server on 8000, and ui on 3000. The server subscribes to plant/watering/# with Paho, writes to device_events and device_status, and exposes GET /api/status, GET /api/events?limit=50, POST /api/command, and GET /api/schedule. The UI loads over REST once, then follows WS /ws for snapshots and a 10 second heartbeat. It shows a live badge when the socket is open.

Telemetry lands in a TimescaleDB hypertable with a 7 day raw window and a 1 minute rollup kept for 90 days. Status keeps one row per device for the live view. Events keep every pump start and stop with source (mqtt, schedule, timer, manual) so I can tell whether I pressed the button or the timer fired.

Reaching it from outside

I used Tailscale for a while and then moved to a Cloudflare tunnel. No inbound ports, no port forwards, UPnP off on the router. The laptop runs cloudflared and opens four outbound connections to the edge.

Public traffic hits https://home.abwork.shop, passes an Access check with email OTP, and then the tunnel routes / to 127.0.0.1:3000 and /api/* plus /ws to 127.0.0.1:8000. One host means one login covers UI, API, and socket. MQTT stays on the LAN at 192.168.1.220:1883. My phone never talks MQTT directly.

flowchart TB
    subgraph Client["phone / laptop anywhere"]
        User[User]
    end

    subgraph Edge["Cloudflare Edge"]
        direction TB
        Access[Access OTP]
        Tunnel[tunnel home-to-internet]
    end

    subgraph Home["Home Network"]
        direction TB
        UI[(Next.js :3000)]
        API[(Spring Boot :8000<br/>API + WS)]
        Mosquitto[(mosquitto:1883)]
        Postgres[(Postgres internal)]
    end

    User -- HTTPS 443 --> Edge
    Edge -- allow after login --> Tunnel
    Tunnel --> UI
    Tunnel --> API
    API -- tcp://mosquitto:1883 --> Mosquitto
    API -- jdbc --> Postgres
    ESP32[ESP32 LAN] -- 192.168.1.220:1883 --> Mosquitto
    ESP32 -- 192.168.1.220:8000/api/schedule --> API

Two mistakes I will not repeat: I enabled UFW with default deny before allowing port 22 and locked myself out of SSH (the tunnel kept working, which felt unfair), and I once rebuilt the UI without purging the zone cache, so the edge served old JS with an old API URL and Chrome flagged the page. Rebuild plus purge_everything fixed it.

What still bugs me

POST /api/command has no app login. Access gates it from the internet and my LAN is just me, so I live with it for now. Mosquitto allows anonymous on the LAN and Postgres still uses plant/plant from compose defaults. Changing either means updating firmware/include/secrets.h and reflashing at the same time, which is why I keep putting it off.

I also have no soil sensor yet. The pump must stay submerged or it burns the seal fast, and the 300 hour life rating on this little pump means I will replace it at some point. For now the countdown on the OLED plus the event log tells me enough. When the mint droops I press the button and hear the click from the other room.