Building a Custom Sensor Node for Home Assistant

Complete walkthrough: ESP32 sensor node that appears as entities in Home Assistant

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:

SensorWhat It MeasuresInterface
BME280Temperature, humidity, pressureI2C
BH1750Light level (lux)I2C
Capacitive soil moistureSoil 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

ComponentQuantityApprox. Cost
ESP32-WROOM-32 DevKit15 USD
BME280 breakout module13 USD
BH1750 breakout module12 USD
Capacitive soil moisture sensor v1.212 USD
18650 Li-Ion battery + holder14 USD
TP4056 charging module (optional)11 USD
Breadboard and jumper wires1 set3 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.

ComponentComponent PinESP32 PinNotes
BME280VIN3.3VPower
BME280GNDGNDGround
BME280SDAGPIO 21Shared I2C data
BME280SCLGPIO 22Shared I2C clock
BH1750VCC3.3VPower
BH1750GNDGNDGround
BH1750SDAGPIO 21Shared I2C data
BH1750SCLGPIO 22Shared I2C clock
Soil moistureVCCGPIO 25Powered from GPIO (see note)
Soil moistureGNDGNDGround
Soil moistureAOUTGPIO 34Analog input (ADC1_CH6)
Battery (+)Vin / 5VThrough voltage divider for monitoring
Battery (-)GNDGround

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:

  1. PubSubClient by Nick O'Leary
  2. Adafruit BME280 Library by Adafruit
  3. Adafruit Unified Sensor by Adafruit
  4. BH1750 by Christopher Laws
  5. 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:

  1. Upload the code with some temporary Serial.println() statements to see the raw ADC values.
  2. Hold the sensor in air (completely dry) and note the ADC value. This is your SOIL_DRY_VALUE.
  3. 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.
  4. Update the #define values 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: Battery

Entity naming: Home Assistant generates entity IDs from the device name and entity name. The exact ID depends on your device_name and the name field 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: voltage

The ESPHome version is significantly shorter, but the Arduino version gives you more control over timing, power management, and error handling.

🔗Troubleshooting

ProblemCauseSolution
Entities show "Unavailable"Sleep interval too long or WiFi issuesCheck expire_after value; ensure reliable WiFi coverage
Soil moisture reads 0% or 100%Calibration values wrongRe-calibrate SOIL_DRY_VALUE and SOIL_WET_VALUE
Battery voltage reads wrongADC inaccuracyMeasure actual voltage with multimeter, adjust multiplier
BH1750 not foundWrong I2C addressTry address 0x23 (ADDR pin low) or 0x5C (ADDR pin high)
Short battery lifeWiFi connection taking too longUse static IP; check WiFi signal strength at sensor location
Discovery not workingBuffer too smallVerify 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.