IoT Smart Irrigation System is a runnable microcontroller simulation example on SensBench — open the wired circuit, inspect the code, and compile real firmware in your browser, no install needed. Last updated Aug 14, 2026.

FREE SIMULATOR EXAMPLE · ADVANCED

IoT Smart Irrigation System

This example runs a smart irrigation system on the ESP32 — a soil moisture sensor drives a pump through a threshold loop with hysteresis. The sense-decide-actuate pattern behind every automated garden.

Board ESP32 DevKit (Xtensa LX6, 240 MHz)
Difficulty advanced
Category communication
Libraries None — just the Arduino core
Runs in Sensbench browser simulator — free, no account
ESP32 smart irrigation system with soil moisture sensing in the Sensbench simulator

The exact circuit you get when you open this example — already wired, ready to run.

The sketch

# BlynkLib.py
# Copyright (c) 2015-2019 Volodymyr Shymanskyy. See the file LICENSE for copying permission.

__version__ = "1.0.0"

import struct
import time
import sys
import os

try:
    import machine
    gettime = lambda: time.ticks_ms()
    SOCK_TIMEOUT = 0
except ImportError:
    const = lambda x: x
    gettime = lambda: int(time.time() * 1000)
    SOCK_TIMEOUT = 0.05

def dummy(*args):
    pass

MSG_RSP = const(0)
MSG_LOGIN = const(2)
MSG_PING  = const(6)

MSG_TWEET = const(12)
MSG_NOTIFY = const(14)
MSG_BRIDGE = const(15)
MSG_HW_SYNC = const(16)
MSG_INTERNAL = const(17)
MSG_PROPERTY = const(19)
MSG_HW = const(20)
MSG_HW_LOGIN = const(29)
MSG_EVENT_LOG = const(64)

MSG_REDIRECT  = const(41)  # TODO: not implemented
MSG_DBG_PRINT  = const(55) # TODO: not implemented

STA_SUCCESS = const(200)
STA_INVALID_TOKEN = const(9)

DISCONNECTED = const(0)
CONNECTING = const(1)
CONNECTED = const(2)

print("""
    ___  __          __
   / _ )/ /_ _____  / /__
  / _  / / // / _ \\/  '_/
 /____/_/\\_, /_//_/_/\\_\\
        /___/ for Python v""" + __version__ + " (" + sys.platform + ")\n")

class EventEmitter:
    def __init__(self):
        self._cbks = {}

    def on(self, evt, f=None):
        if f:
            self._cbks[evt] = f
        else:
            def D(f):
                self._cbks[evt] = f
                return f
            return D

    def emit(self, evt, *a, **kv):
        if evt in self._cbks:
            self._cbks[evt](*a, **kv)


class BlynkProtocol(EventEmitter):
    def __init__(self, auth, tmpl_id=None, fw_ver=None, heartbeat=50, buffin=1024, log=None):
        EventEmitter.__init__(self)
        self.heartbeat = heartbeat*1000
        self.buffin = buffin
        self.log = log or dummy
        self.auth = auth
        self.tmpl_id = tmpl_id
        self.fw_ver = fw_ver
        self.state = DISCONNECTED
        self.connect()

    def virtual_write(self, pin, *val):
        self._send(MSG_HW, 'vw', pin, *val)

    def send_internal(self, pin, *val):
        self._send(MSG_INTERNAL,  pin, *val)

    def set_property(self, pin, prop, *val):
        self._send(MSG_PROPERTY, pin, prop, *val)

    def sync_virtual(self, *pins):
        self._send(MSG_HW_SYNC, 'vr', *pins)

    def log_event(self, *val):
        self._send(MSG_EVENT_LOG, *val)

    def _send(self, cmd, *args, **kwargs):
        if 'id' in kwargs:
            id = kwargs.get('id')
        else:
            id = self.msg_id
            self.msg_id += 1
            if self.msg_id > 0xFFFF:
                self.msg_id = 1
                
        if cmd == MSG_RSP:
            data = b''
            dlen = args[0]
        else:
            data = ('\0'.join(map(str, args))).encode('utf8')
            dlen = len(data)
        
        self.log('<', cmd, id, '|', *args)
        msg = struct.pack("!BHH", cmd, id, dlen) + data
        self.lastSend = gettime()
        self._write(msg)

    def connect(self):
        if self.state != DISCONNECTED: return
        self.msg_id = 1
        (self.lastRecv, self.lastSend, self.lastPing) = (gettime(), 0, 0)
        self.bin = b""
        self.state = CONNECTING
        self._send(MSG_HW_LOGIN, self.auth)

    def disconnect(self):
        if self.state == DISCONNECTED: return
        self.bin = b""
        self.state = DISCONNECTED
        self.emit('disconnected')

    def process(self, data=None):
        if not (self.state == CONNECTING or self.state == CONNECTED): return
        now = gettime()
        if now - self.lastRecv > self.heartbeat+(self.heartbeat//2):
            return self.disconnect()
        if (now - self.lastPing > self.heartbeat//10 and
            (now - self.lastSend > self.heartbeat or
             now - self.lastRecv > self.heartbeat)):
            self._send(MSG_PING)
            self.lastPing = now
        
        if data != None and len(data):
            self.bin += data

        while True:
            if len(self.bin) < 5:
                break

            cmd, i, dlen = struct.unpack("!BHH", self.bin[:5])
            if i == 0: return self.disconnect()
                      
            self.lastRecv = now
            if cmd == MSG_RSP:
                self.bin = self.bin[5:]

                self.log('>', cmd, i, '|', dlen)
                if self.state == CONNECTING and i == 1:
                    if dlen == STA_SUCCESS:
                        self.state = CONNECTED
                        dt = now - self.lastSend
                        info = ['ver', __version__, 'h-beat', self.heartbeat//1000, 'buff-in', self.buffin, 'dev', sys.platform+'-py']
                        if self.tmpl_id:
                            info.extend(['tmpl', self.tmpl_id])
                            info.extend(['fw-type', self.tmpl_id])
                        if self.fw_ver:
                            info.extend(['fw', self.fw_ver])
                        self._send(MSG_INTERNAL, *info)
                        try:
                            self.emit('connected', ping=dt)
                        except TypeError:
                            self.emit('connected')
                    else:
                        if dlen == STA_INVALID_TOKEN:
                            self.emit("invalid_auth")
                            print("Invalid auth token")
                        return self.disconnect()
            else:
                if dlen >= self.buffin:
                    print("Cmd too big: ", dlen)
                    return self.disconnect()

                if len(self.bin) < 5+dlen:
                    break

                data = self.bin[5:5+dlen]
                self.bin = self.bin[5+dlen:]

                args = list(map(lambda x: x.decode('utf8'), data.split(b'\0')))

                self.log('>', cmd, i, '|', ','.join(args))
                if cmd == MSG_PING:
                    self._send(MSG_RSP, STA_SUCCESS, id=i)
                elif cmd == MSG_HW or cmd == MSG_BRIDGE:
                    if args[0] == 'vw':
                        self.emit("V"+args[1], args[2:])
                        self.emit("V*", args[1], args[2:])
                elif cmd == MSG_INTERNAL:
                    self.emit("internal:"+args[0], args[1:])
                elif cmd == MSG_REDIRECT:
                    self.emit("redirect", args[0], int(args[1]))
                else:
                    print("Unexpected command: ", cmd)
                    return self.disconnect()

import socket

class Blynk(BlynkProtocol):
    def __init__(self, auth, **kwargs):
        self.insecure = kwargs.pop('insecure', False)
        self.server = kwargs.pop('server', 'blynk.cloud')
        self.port = kwargs.pop('port', 80 if self.insecure else 443)
        BlynkProtocol.__init__(self, auth, **kwargs)
        self.on('redirect', self.redirect)

    def redirect(self, server, port):
        self.server = server
        self.port = port
        self.disconnect()
        self.connect()

    def connect(self):
        print('Connecting to %s:%d...' % (self.server, self.port))
        s = socket.socket()
        s.connect(socket.getaddrinfo(self.server, self.port)[0][-1])
        try:
            s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        except:
            pass
        if self.insecure:
            self.conn = s
        else:
            try:
                import ussl
                ssl_context = ussl
            except ImportError:
                import ssl
                ssl_context = ssl.create_default_context()
            self.conn = ssl_context.wrap_socket(s, server_hostname=self.server)
        try:
            self.conn.settimeout(SOCK_TIMEOUT)
        except:
            s.settimeout(SOCK_TIMEOUT)
        BlynkProtocol.connect(self)

    def _write(self, data):
        #print('<', data)
        self.conn.write(data)
        # TODO: handle disconnect

    def run(self):
        data = b''
        try:
            data = self.conn.read(self.buffin)
            #print('>', data)
        except KeyboardInterrupt:
            raise
        except socket.timeout:
            # No data received, call process to send ping messages when needed
            pass
        except: # TODO: handle disconnect
            return
        self.process(data)




# main.py
'''Copyright (c) 2026 Kritish Mohapatra'''

import network
import time
from machine import Pin, ADC
import BlynkLib

# 🔑 Blynk
BLYNK_AUTH = "auth"

# 🌐 WiFi
WIFI_SSID = "kritish"
WIFI_PASS = "@pass"

# 📍 Pins
SOIL_PIN  = 3   # ADC
RELAY_PIN = 1   # Relay (active LOW)

# 🌱 Soil ADC setup
soil = ADC(Pin(SOIL_PIN))
soil.atten(ADC.ATTN_11DB)      # 0–3.3V
soil.width(ADC.WIDTH_12BIT)    # 0–4095

# 💧 Relay setup
relay = Pin(RELAY_PIN, Pin.OUT)
relay.on()   # Pump OFF initially

# 🌐 WiFi connect (safe)
wifi = network.WLAN(network.STA_IF)
wifi.active(False)
time.sleep(1)
wifi.active(True)
time.sleep(1)
wifi.connect(WIFI_SSID, WIFI_PASS)

print("Connecting WiFi", end="")
t0 = time.time()
while not wifi.isconnected():
    print(".", end="")
    time.sleep(1)
    if time.time() - t0 > 20:
        print("\nWiFi failed")
        break

if wifi.isconnected():
    print("\nWiFi OK:", wifi.ifconfig())

# 📲 Blynk init
blynk = BlynkLib.Blynk(BLYNK_AUTH, insecure=True)

# 🔘 Manual Pump Control (V1)
@blynk.on("V1")
def pump_control(value):
    if int(value[0]) == 1:
        relay.off()   # Pump ON
        print("Pump ON")
    else:
        relay.on()    # Pump OFF
        print("Pump OFF")

# 📊 Read soil & send to Blynk
def read_soil():
    raw = soil.read()  # 0–4095
    moisture = int((4095 - raw) * 100 / 4095)
    moisture = max(0, min(100, moisture))
    print("Soil:", moisture, "%")
    blynk.virtual_write(5, moisture)

# 🔁 Main loop
last = time.ticks_ms()
while True:
    if wifi.isconnected():
        blynk.run()

    if time.ticks_diff(time.ticks_ms(), last) > 2000:
        read_soil()
        last = time.ticks_ms()

How it works

The firmware reads the soil moisture sensor on a schedule and compares the value to a threshold. Dry conditions switch the pump on; the loop keeps checking until the soil crosses the wet threshold, then switches off. Hysteresis between the two thresholds prevents rapid pump cycling.

The pump control goes through a driver output — the ESP32’s pin only signals, the driver carries the current. The serial output logs every state change, so the watering schedule is readable from the console.

Before you build it for real

The capacitive soil sensor’s reading depends on soil type and temperature — a threshold calibrated in potting mix is wrong for garden soil, and the sensor’s absolute value drifts with moisture distribution around the probe. The pump needs its own power path: switching a pump through the ESP32’s rail browns out the board at motor startup. And hysteresis is not optional — without the two-threshold band, the pump cycles on and off at the boundary, which destroys both the pump and the reading’s usefulness.

Get the parts

Related examples

FAQ

How does the system decide when to water?

A soil-moisture sensor reports the ground’s wetness, and the firmware compares it against a threshold: dry below it, the pump switches on until the soil reads wet again. The threshold is the one calibration that matters — soil type changes everything.

Why is irrigation a classic IoT project?

Because the decision is simple and the benefit is real: a pump that waters exactly when needed saves water and keeps plants alive unattended. The sensor-pump loop is also the cleanest demonstration of sensing driving actuation.