This is the "putting it all together" article. You will build a complete multi-sensor node with an ESP32, wire up three different sensors, publish all their data to Home Assistant via MQTT auto-discovery, add a Home Assistant dashboard to display the readings, and enable deep sleep for battery operation. When you are done, you will have a self-contained sensor node that can run for weeks on a battery.
🔗What We Are Building
A sensor node with three sensors that reports to Home Assistant:
| Sensor | What It Measures | Interface |
|---|---|---|
| BME280 | Temperature, humidity, pressure | I2C |
| BH1750 | Light level (lux) | I2C |
| Capacitive soil moisture | Soil moisture (%) | Analog (ADC) |
graph LR
subgraph "Sensor Node"
E[ESP32] --- BME[BME280]
E --- BH[BH1750]
E --- SM[Soil Moisture]
end
E -->|WiFi + MQTT| HA[Home Assistant]
HA --> D[Dashboard]All six entities (temperature, humidity, pressure, light, soil moisture, battery voltage) will appear automatically in Home Assistant through MQTT auto-discovery. The ESP32 will sleep between readings to conserve power.
🔗Parts List
| Component | Quantity | Approx. Cost |
|---|---|---|
| ESP32-WROOM-32 DevKit | 1 | 5 USD |
| BME280 breakout module | 1 | 3 USD |
| BH1750 breakout module | 1 | 2 USD |
| Capacitive soil moisture sensor v1.2 | 1 | 2 USD |
| 18650 Li-Ion battery + holder | 1 | 4 USD |
| TP4056 charging module (optional) | 1 | 1 USD |
| Breadboard and jumper wires | 1 set | 3 USD |
| Total | ~20 USD |
🔗Wiring
Both the BME280 and BH1750 use I2C, so they share the same SDA and SCL lines. The soil moisture sensor uses an analog output.
| Component | Component Pin | ESP32 Pin | Notes |
|---|---|---|---|
| BME280 | VIN | 3.3V | Power |
| BME280 | GND | GND | Ground |
| BME280 | SDA | GPIO 21 | Shared I2C data |
| BME280 | SCL | GPIO 22 | Shared I2C clock |
| BH1750 | VCC | 3.3V | Power |
| BH1750 | GND | GND | Ground |
| BH1750 | SDA | GPIO 21 | Shared I2C data |
| BH1750 | SCL | GPIO 22 | Shared I2C clock |
| Soil moisture | VCC | GPIO 25 | Powered from GPIO (see note) |
| Soil moisture | GND | GND | Ground |
| Soil moisture | AOUT | GPIO 34 | Analog input (ADC1_CH6) |
| Battery (+) | Vin / 5V | Through voltage divider for monitoring | |
| Battery (-) | GND | Ground |
Why power the soil moisture sensor from a GPIO pin? Capacitive soil moisture sensors draw a small amount of current even when idle. By powering them from a GPIO pin, you can turn the sensor on only when taking a reading, which significantly extends battery life in deep sleep mode.
🔗Battery Voltage Monitoring
To monitor the battery voltage, use a simple voltage divider with two resistors:
Battery (+) ---[ 100kΩ ]---+---[ 100kΩ ]--- GND
|
GPIO 35 (ADC input)With equal resistors, the voltage at GPIO 35 is half the battery voltage. The ESP32's ADC can read up to $3.3\,\text{V}$, which means this divider safely measures batteries up to $6.6\,\text{V}$ -- well within the range of a single 18650 cell ($3.0$ to $4.2\,\text{V}$).
The measured battery voltage is:
$$V_{bat} = V_{ADC} \times 2$$
ADC note: The ESP32's ADC is not highly accurate. For better precision, you can calibrate it by measuring the actual battery voltage with a multimeter and adjusting the multiplier in code. For battery level monitoring (full/half/empty), the default accuracy is adequate.
🔗Required Libraries
Install these through the Arduino Library Manager:
- PubSubClient by Nick O'Leary
- Adafruit BME280 Library by Adafruit
- Adafruit Unified Sensor by Adafruit
- BH1750 by Christopher Laws
- ArduinoJson by Benoit Blanchon
🔗Complete Code
#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <BH1750.h>
#include <ArduinoJson.h>
// ---- Configuration ----
const char* ssid = "YourNetworkName";
const char* password = "YourPassword";
const char* mqtt_server = "192.168.1.100"; // Home Assistant IP
const int mqtt_port = 1883;
const char* mqtt_user = "mqtt_user";
const char* mqtt_pass = "mqtt_pass";
const char* device_id = "garden_sensor";
const char* device_name = "Garden Sensor Node";
// Pin assignments
#define SOIL_POWER_PIN 25 // Powers the soil sensor
#define SOIL_ADC_PIN 34 // Reads the soil sensor
#define BATTERY_ADC_PIN 35 // Reads the battery voltage divider
// Deep sleep duration (in microseconds)
// 5 minutes = 5 * 60 * 1,000,000
#define SLEEP_DURATION_US (5ULL * 60ULL * 1000000ULL)
// Soil moisture calibration (raw ADC values)
// Measure these with your specific sensor:
// DRY_VALUE = ADC reading when sensor is in air
// WET_VALUE = ADC reading when sensor is in water
#define SOIL_DRY_VALUE 3200
#define SOIL_WET_VALUE 1400
// ---- Objects ----
WiFiClient espClient;
PubSubClient mqtt(espClient);
Adafruit_BME280 bme;
BH1750 lightMeter;
// ---- WiFi Connection ----
bool connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
delay(250);
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("WiFi connected. IP: %s\n",
WiFi.localIP().toString().c_str());
return true;
}
Serial.println("WiFi connection failed.");
return false;
}
// ---- MQTT Discovery ----
void publishDiscovery(const char* component, const char* objectId,
const char* name, const char* unit,
const char* deviceClass, const char* icon) {
char topic[128];
snprintf(topic, sizeof(topic),
"homeassistant/%s/%s/%s/config",
component, device_id, objectId);
char stateTopic[128];
snprintf(stateTopic, sizeof(stateTopic),
"%s/%s/%s/state", device_id, component, objectId);
char uniqueId[64];
snprintf(uniqueId, sizeof(uniqueId), "%s_%s", device_id, objectId);
JsonDocument doc;
doc["name"] = name;
doc["state_topic"] = stateTopic;
doc["unique_id"] = uniqueId;
doc["value_template"] = "{{ value }}";
doc["expire_after"] = 900; // Mark unavailable after 15 min (for deep sleep)
if (unit) doc["unit_of_measurement"] = unit;
if (deviceClass) doc["device_class"] = deviceClass;
if (icon) doc["icon"] = icon;
JsonObject dev = doc["device"].to<JsonObject>();
dev["identifiers"][0] = device_id;
dev["name"] = device_name;
dev["model"] = "ESP32 Multi-Sensor Node";
dev["manufacturer"] = "DIY";
char payload[512];
serializeJson(doc, payload, sizeof(payload));
mqtt.publish(topic, payload, true);
}
void publishAllDiscovery() {
publishDiscovery("sensor", "temperature", "Temperature",
"°C", "temperature", nullptr);
publishDiscovery("sensor", "humidity", "Humidity",
"%", "humidity", nullptr);
publishDiscovery("sensor", "pressure", "Pressure",
"hPa", "pressure", nullptr);
publishDiscovery("sensor", "light", "Light Level",
"lx", "illuminance", nullptr);
publishDiscovery("sensor", "soil_moisture", "Soil Moisture",
"%", "moisture", "mdi:water-percent");
publishDiscovery("sensor", "battery", "Battery Voltage",
"V", "voltage", nullptr);
}
// ---- MQTT Connection ----
bool connectMQTT() {
char clientId[32];
snprintf(clientId, sizeof(clientId), "%s_%04x",
device_id, (uint16_t)random(0xFFFF));
int attempts = 0;
while (!mqtt.connected() && attempts < 5) {
if (mqtt.connect(clientId, mqtt_user, mqtt_pass)) {
Serial.println("MQTT connected.");
return true;
}
Serial.printf("MQTT failed (rc=%d). Retrying...\n", mqtt.state());
delay(2000);
attempts++;
}
return mqtt.connected();
}
// ---- Read Sensors ----
float readSoilMoisture() {
// Power on the soil sensor
digitalWrite(SOIL_POWER_PIN, HIGH);
delay(500); // Let the sensor stabilize
// Take multiple readings and average
long total = 0;
const int samples = 10;
for (int i = 0; i < samples; i++) {
total += analogRead(SOIL_ADC_PIN);
delay(10);
}
float raw = total / (float)samples;
// Power off the soil sensor
digitalWrite(SOIL_POWER_PIN, LOW);
// Convert to percentage (0% = dry, 100% = wet)
float moisture = map(raw, SOIL_DRY_VALUE, SOIL_WET_VALUE, 0, 100);
moisture = constrain(moisture, 0, 100);
Serial.printf("Soil ADC: %.0f -> %.0f%%\n", raw, moisture);
return moisture;
}
float readBatteryVoltage() {
long total = 0;
const int samples = 10;
for (int i = 0; i < samples; i++) {
total += analogRead(BATTERY_ADC_PIN);
delay(10);
}
float raw = total / (float)samples;
// Convert ADC reading to voltage
// ESP32 ADC: 0-4095 = 0-3.3V, multiply by 2 for voltage divider
float voltage = (raw / 4095.0) * 3.3 * 2.0;
return voltage;
}
// ---- Publish Sensor Data ----
void publishValue(const char* component, const char* objectId,
float value, int decimals) {
char topic[128];
snprintf(topic, sizeof(topic),
"%s/%s/%s/state", device_id, component, objectId);
char payload[16];
snprintf(payload, sizeof(payload), "%.*f", decimals, value);
mqtt.publish(topic, payload, true);
}
void readAndPublishAll() {
// BME280
float temperature = bme.readTemperature();
float humidity = bme.readHumidity();
float pressure = bme.readPressure() / 100.0F;
// BH1750
float lux = lightMeter.readLightLevel();
// Soil moisture
float soilMoisture = readSoilMoisture();
// Battery
float batteryV = readBatteryVoltage();
// Publish all values
publishValue("sensor", "temperature", temperature, 1);
publishValue("sensor", "humidity", humidity, 1);
publishValue("sensor", "pressure", pressure, 1);
publishValue("sensor", "light", lux, 0);
publishValue("sensor", "soil_moisture", soilMoisture, 0);
publishValue("sensor", "battery", batteryV, 2);
Serial.printf("Temp: %.1f°C | Hum: %.1f%% | Press: %.1f hPa\n",
temperature, humidity, pressure);
Serial.printf("Light: %.0f lx | Soil: %.0f%% | Batt: %.2fV\n",
lux, soilMoisture, batteryV);
}
// ---- Deep Sleep ----
void goToSleep() {
Serial.printf("Sleeping for %llu seconds...\n",
SLEEP_DURATION_US / 1000000ULL);
Serial.flush();
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
esp_sleep_enable_timer_wakeup(SLEEP_DURATION_US);
esp_deep_sleep_start();
}
// ---- Setup (runs once per wake cycle) ----
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n--- Garden Sensor Node ---");
// Initialize pins
pinMode(SOIL_POWER_PIN, OUTPUT);
digitalWrite(SOIL_POWER_PIN, LOW);
// Initialize I2C sensors
Wire.begin(21, 22);
if (!bme.begin(0x76)) {
Serial.println("BME280 not found! Check wiring.");
} else {
Serial.println("BME280 initialized.");
}
if (lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE)) {
Serial.println("BH1750 initialized.");
} else {
Serial.println("BH1750 not found! Check wiring.");
}
// Connect and publish
if (connectWiFi()) {
mqtt.setServer(mqtt_server, mqtt_port);
mqtt.setBufferSize(512);
if (connectMQTT()) {
publishAllDiscovery();
delay(100); // Give broker time to process discovery
readAndPublishAll();
delay(100); // Ensure messages are sent
mqtt.loop();
delay(100);
}
}
// Sleep
goToSleep();
}
void loop() {
// Never reached -- deep sleep restarts from setup()
}🔗Key Design Decisions
Deep sleep restarts from setup(). When the ESP32 wakes from deep sleep, it starts fresh -- loop() is never called. The entire workflow happens in setup(): connect, read sensors, publish, sleep.
expire_after in discovery. The "expire_after": 900 field tells Home Assistant to mark the entity as unavailable if no update is received within 15 minutes ($900\,\text{s}$). Since the node publishes every 5 minutes, this gives a comfortable margin for missed wake-ups. Without this, Home Assistant would show stale data indefinitely.
Retained messages. Sensor values are published with the retain flag set to true. This means Home Assistant will receive the last known value immediately when it restarts, rather than waiting for the next wake cycle.
Soil sensor power control. The soil moisture sensor is powered from GPIO 25, which is turned on only during readings. This prevents unnecessary current draw during deep sleep.
🔗Calibrating the Soil Moisture Sensor
The raw ADC values for "dry" and "wet" vary between individual sensors. To calibrate yours:
- Upload the code with some temporary
Serial.println()statements to see the raw ADC values. - Hold the sensor in air (completely dry) and note the ADC value. This is your
SOIL_DRY_VALUE. - Submerge the sensor in a glass of water (only up to the marked line, not the electronics) and note the ADC value. This is your
SOIL_WET_VALUE. - Update the
#definevalues in the code.
Typical values for a capacitive soil moisture sensor v1.2:
- Dry (in air): 3000-3400
- Wet (in water): 1200-1600
🔗Deep Sleep and Battery Life
The ESP32 draws about $10\,\mu\text{A}$ in deep sleep and around $160\,\text{mA}$ when WiFi is active. Each wake cycle (connect WiFi, read sensors, publish MQTT, disconnect) takes roughly 3-5 seconds.
For a rough battery life estimate with a $2500\,\text{mAh}$ 18650 cell and 5-minute sleep intervals:
$$t_{awake} = 5\,\text{s}, \quad I_{awake} = 160\,\text{mA}$$ $$t_{sleep} = 295\,\text{s}, \quad I_{sleep} = 0.01\,\text{mA}$$
Average current:
$$I_{avg} = \frac{I_{awake} \times t_{awake} + I_{sleep} \times t_{sleep}}{t_{awake} + t_{sleep}}$$
$$I_{avg} = \frac{160 \times 5 + 0.01 \times 295}{300} \approx 2.68\,\text{mA}$$
Estimated battery life:
$$\text{Battery life} = \frac{2500\,\text{mAh}}{2.68\,\text{mA}} \approx 933\,\text{hours} \approx 39\,\text{days}$$
To extend battery life further:
- Increase the sleep interval to 10 or 15 minutes
- Use static IP assignment (saves DHCP negotiation time -- shaves 1-2 seconds off each wake)
- Use a low-quiescent-current voltage regulator instead of the DevKit board's onboard regulator
🔗Using Static IP for Faster Wake
Replacing DHCP with a static IP can cut 1-2 seconds from each wake cycle:
IPAddress staticIP(192, 168, 1, 200);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
IPAddress dns(192, 168, 1, 1);
bool connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.config(staticIP, gateway, subnet, dns);
WiFi.begin(ssid, password);
// ... rest of connection code
}🔗Home Assistant Dashboard
Once the sensor node is publishing data, add a dashboard card to display the readings. Go to your dashboard, click Edit Dashboard, and add a card.
🔗Entities Card
The simplest option -- a list of all entities from the device:
type: entities
title: Garden Sensor
entities:
- entity: sensor.garden_sensor_node_temperature
name: Temperature
- entity: sensor.garden_sensor_node_humidity
name: Humidity
- entity: sensor.garden_sensor_node_pressure
name: Pressure
- entity: sensor.garden_sensor_node_light_level
name: Light
- entity: sensor.garden_sensor_node_soil_moisture
name: Soil Moisture
- entity: sensor.garden_sensor_node_battery_voltage
name: BatteryEntity naming: Home Assistant generates entity IDs from the device name and entity name. The exact ID depends on your
device_nameand thenamefield in discovery. Check Settings > Devices & Services > MQTT > (your device) for the actual entity IDs.
🔗Gauge Cards
For a more visual layout, use gauge cards for critical values:
type: horizontal-stack
cards:
- type: gauge
entity: sensor.garden_sensor_node_soil_moisture
name: Soil Moisture
min: 0
max: 100
severity:
green: 40
yellow: 20
red: 0
- type: gauge
entity: sensor.garden_sensor_node_temperature
name: Temperature
min: -10
max: 50
severity:
green: 15
yellow: 30
red: 35🔗History Graph
To see trends over time:
type: history-graph
title: Garden Conditions (24h)
hours_to_show: 24
entities:
- entity: sensor.garden_sensor_node_temperature
name: Temperature
- entity: sensor.garden_sensor_node_humidity
name: Humidity
- entity: sensor.garden_sensor_node_soil_moisture
name: Soil Moisture🔗Using ESPHome Instead
The same sensor node can be built with ESPHome if you prefer YAML over Arduino code. Here is the equivalent ESPHome configuration:
esphome:
name: garden-sensor
friendly_name: Garden Sensor Node
esp32:
board: esp32dev
wifi:
ssid: "YourNetworkName"
password: "YourPassword"
api:
encryption:
key: "your-key-here"
ota:
- platform: esphome
logger:
i2c:
sda: GPIO21
scl: GPIO22
deep_sleep:
run_duration: 10s
sleep_duration: 5min
sensor:
- platform: bme280_i2c
address: 0x76
temperature:
name: "Temperature"
humidity:
name: "Humidity"
pressure:
name: "Pressure"
update_interval: 5s
- platform: bh1750
name: "Light Level"
address: 0x23
update_interval: 5s
- platform: adc
pin: GPIO34
name: "Soil Moisture"
update_interval: 5s
attenuation: 11db
filters:
- calibrate_linear:
- 2.8 -> 0
- 1.2 -> 100
unit_of_measurement: "%"
icon: "mdi:water-percent"
- platform: adc
pin: GPIO35
name: "Battery Voltage"
update_interval: 5s
attenuation: 11db
filters:
- multiply: 2.0
unit_of_measurement: "V"
device_class: voltageThe ESPHome version is significantly shorter, but the Arduino version gives you more control over timing, power management, and error handling.
🔗Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Entities show "Unavailable" | Sleep interval too long or WiFi issues | Check expire_after value; ensure reliable WiFi coverage |
| Soil moisture reads 0% or 100% | Calibration values wrong | Re-calibrate SOIL_DRY_VALUE and SOIL_WET_VALUE |
| Battery voltage reads wrong | ADC inaccuracy | Measure actual voltage with multimeter, adjust multiplier |
| BH1750 not found | Wrong I2C address | Try address 0x23 (ADDR pin low) or 0x5C (ADDR pin high) |
| Short battery life | WiFi connection taking too long | Use static IP; check WiFi signal strength at sensor location |
| Discovery not working | Buffer too small | Verify mqtt.setBufferSize(512) is called before connecting |
🔗What is Next?
Your sensor node is now feeding live data to Home Assistant. The next article shows you how to use that data to create automations -- automatically water the garden when soil moisture drops, send notifications when temperatures spike, and more.