Wireless Sensor Network Expert

Build a multi-node ESP32 mesh network using ESP-NOW with a central MQTT gateway

🔗Goal

Build a wireless sensor network with multiple ESP32 boards that communicate using the ESP-NOW protocol. Two sensor nodes collect environmental data (temperature/humidity and soil moisture), then transmit it wirelessly to a central gateway node. The gateway forwards everything to an MQTT broker over WiFi.

graph LR
    subgraph Node 1
        A1[BME280] -->|I2C| B1[ESP32]
    end
    subgraph Node 2
        A2[Soil Moisture] -->|Analog| B2[ESP32]
    end
    subgraph Gateway
        B3[ESP32]
    end
    B1 -->|ESP-NOW| B3
    B2 -->|ESP-NOW| B3
    B3 -->|WiFi| C[MQTT Broker]
    C --> D[Dashboard / Home Assistant]

The key advantage of this design is that the sensor nodes do not need WiFi. They use ESP-NOW, a lightweight peer-to-peer protocol that is faster to transmit, uses far less power, and does not require a router. Only the gateway connects to WiFi, which means sensor nodes can run on batteries with deep sleep for weeks or even months.

🔗Why ESP-NOW?

ESP-NOW is a connectionless protocol developed by Espressif (the company behind the ESP32). Here is how it compares to WiFi for sensor networks:

FeatureESP-NOWWiFi + MQTT
Connection time< 1 ms2-8 seconds
Power per transmission~0.3 mA~150-350 mA
Range (open air)~200 m~50-100 m
Requires routerNoYes
Max payload250 bytesUnlimited
EncryptionOptional (CCMP)WPA2
Max peers20 (ESP32)N/A

ESP-NOW sends small packets (up to 250 bytes) directly between ESP32 boards using their MAC addresses. There is no connection handshake, no DHCP, no TCP overhead. The transmitter wakes up, sends the packet, gets an acknowledgment, and goes back to sleep. This makes it ideal for battery-powered sensor nodes.

🔗Prerequisites

You will need the following components:

🔗Gateway Node

ComponentQtyNotes
ESP32 dev board1Must stay powered (USB or wall adapter)
USB cable1For power and programming

🔗Sensor Node 1 (Temperature/Humidity)

ComponentQtyNotes
ESP32 dev board1Any ESP32-WROOM-32 DevKit
BME280 sensor module1I2C breakout board (not BMP280)
Jumper wires4Male-to-female
Battery holder1Optional: 2x AA or 18650 shield

🔗Sensor Node 2 (Soil Moisture)

ComponentQtyNotes
ESP32 dev board1Any ESP32-WROOM-32 DevKit
Capacitive soil moisture sensor v1.21Prefer capacitive over resistive
Jumper wires3Male-to-female
Battery holder1Optional: 2x AA or 18650 shield

🔗Software

You will also need:

  • An MQTT broker (e.g., Mosquitto running on a Raspberry Pi, or a cloud broker like HiveMQ)
  • Arduino IDE with these libraries installed via Library Manager:
    • Adafruit BME280 Library (also installs Adafruit Unified Sensor)
    • PubSubClient by Nick O'Leary (for MQTT on the gateway)

The ESP-NOW library (esp_now.h) is built into the ESP32 Arduino core. No extra installation needed.

🔗System Architecture

The network uses a star topology: all sensor nodes transmit to a single gateway.

graph TD
    subgraph Sensor Nodes
        N1[Node 1: BME280<br/>Deep sleep 5 min]
        N2[Node 2: Soil Moisture<br/>Deep sleep 15 min]
        N3[Node N: Any sensor<br/>Add as needed]
    end

    subgraph Gateway
        GW[Gateway ESP32<br/>Always on]
    end

    subgraph Network
        MQTT[MQTT Broker]
        HA[Dashboard]
    end

    N1 -->|ESP-NOW| GW
    N2 -->|ESP-NOW| GW
    N3 -.->|ESP-NOW| GW
    GW -->|WiFi + MQTT| MQTT
    MQTT --> HA

Each sensor node follows this cycle:

graph LR
    A[Wake up] --> B[Read sensor]
    B --> C[Send ESP-NOW packet]
    C --> D{ACK received?}
    D -->|Yes| E[Deep sleep]
    D -->|No| F[Retry up to 3x]
    F --> D

🔗Data Packet Structure

Every node sends a structured packet so the gateway knows what it is receiving:

FieldTypeSizePurpose
nodeIduint8_t1 byteUnique ID (1-255)
sensorTypeuint8_t1 byte1=BME280, 2=Soil, etc.
temperaturefloat4 bytesDegrees Celsius
humidityfloat4 bytesRelative humidity %
value1float4 bytesExtra reading (e.g., pressure, soil moisture)
batteryfloat4 bytesBattery voltage
Total18 bytesWell under 250-byte ESP-NOW limit

🔗Tutorial

🔗Step 1: Get the Gateway MAC Address

Before programming the sensor nodes, you need to know the gateway ESP32's MAC address. Upload this short sketch to the board you will use as the gateway:

#include <WiFi.h>

void setup() {
    Serial.begin(115200);
    delay(1000);
    WiFi.mode(WIFI_STA);
    Serial.print("Gateway MAC Address: ");
    Serial.println(WiFi.macAddress());
}

void loop() {}

Open the Serial Monitor at 115200 baud. You will see something like:

Gateway MAC Address: 24:6F:28:A1:B2:C3

Write this down. Every sensor node needs this address to know where to send data.

🔗Step 2: Wire Sensor Node 1 (BME280)

Connect the BME280 to the first sensor ESP32:

BME280 PinESP32 Pin
VCC3.3V
GNDGND
SDAGPIO 21
SCLGPIO 22

The BME280 uses I2C with a default address of 0x76. If your module uses 0x77, change it in the code.

🔗Step 3: Wire Sensor Node 2 (Soil Moisture)

Connect the capacitive soil moisture sensor to the second sensor ESP32:

Sensor PinESP32 Pin
VCC3.3V
GNDGND
AOUTGPIO 34

GPIO 34 is input-only and supports ADC, making it a reliable choice for analog sensors. Avoid ADC2 pins (GPIO 0, 2, 4, 12-15, 25-27) as they conflict with WiFi (even though sensor nodes do not use WiFi, it is good practice).

🔗Step 4: Sensor Node 1 Code (BME280)

Upload this sketch to the first sensor node. Replace the gateway MAC address with the one you noted in Step 1.

#include <esp_now.h>
#include <WiFi.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

// ---- CONFIGURATION ----
#define NODE_ID          1
#define SENSOR_TYPE      1        // 1 = BME280
#define SLEEP_SECONDS    300      // 5 minutes between readings
#define MAX_RETRIES      3

// Replace with YOUR gateway's MAC address
uint8_t gatewayMAC[] = {0x24, 0x6F, 0x28, 0xA1, 0xB2, 0xC3};

// ---- DATA STRUCTURE (must match gateway) ----
typedef struct __attribute__((packed)) {
    uint8_t  nodeId;
    uint8_t  sensorType;
    float    temperature;
    float    humidity;
    float    value1;       // Pressure in hPa for BME280
    float    battery;
} SensorPacket;

SensorPacket packet;
Adafruit_BME280 bme;

bool sendSuccess = false;

// Callback: called when ESP-NOW send completes
void onDataSent(const uint8_t *mac, esp_now_send_status_t status) {
    sendSuccess = (status == ESP_NOW_SEND_SUCCESS);
    Serial.print("Send status: ");
    Serial.println(sendSuccess ? "OK" : "FAIL");
}

// Read battery voltage via ADC (voltage divider on GPIO 35)
float readBatteryVoltage() {
    // If you connect a voltage divider (100K + 100K) to GPIO 35:
    // V_batt = ADC_reading / 4095.0 * 3.3 * 2.0
    // Without a divider, return 0 to indicate USB power
    return 0.0;
}

void setup() {
    Serial.begin(115200);
    delay(100);

    // Initialize BME280
    Wire.begin(21, 22);
    if (!bme.begin(0x76)) {
        Serial.println("BME280 not found! Check wiring.");
        // Sleep and try again later
        esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
    }

    // Force a reading (first reading after wake can be stale)
    bme.takeForcedMeasurement();
    delay(100);

    // Build the packet
    packet.nodeId      = NODE_ID;
    packet.sensorType  = SENSOR_TYPE;
    packet.temperature = bme.readTemperature();
    packet.humidity    = bme.readHumidity();
    packet.value1      = bme.readPressure() / 100.0F;  // Pa to hPa
    packet.battery     = readBatteryVoltage();

    Serial.printf("Node %d: T=%.1fC H=%.1f%% P=%.1fhPa\n",
                  packet.nodeId, packet.temperature,
                  packet.humidity, packet.value1);

    // Initialize ESP-NOW
    WiFi.mode(WIFI_STA);
    WiFi.disconnect();   // Not connecting to any AP

    if (esp_now_init() != ESP_OK) {
        Serial.println("ESP-NOW init failed");
        esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
    }

    esp_now_register_send_cb(onDataSent);

    // Register the gateway as a peer
    esp_now_peer_info_t peerInfo = {};
    memcpy(peerInfo.peer_addr, gatewayMAC, 6);
    peerInfo.channel = 0;    // Use current channel
    peerInfo.encrypt = false;

    if (esp_now_add_peer(&peerInfo) != ESP_OK) {
        Serial.println("Failed to add gateway peer");
        esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
    }

    // Send with retries
    for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
        sendSuccess = false;
        esp_now_send(gatewayMAC, (uint8_t *)&packet, sizeof(packet));
        delay(100);  // Wait for callback

        if (sendSuccess) break;
        Serial.printf("Retry %d/%d\n", attempt + 1, MAX_RETRIES);
    }

    // Go to deep sleep
    Serial.printf("Sleeping for %d seconds...\n", SLEEP_SECONDS);
    esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
}

void loop() {
    // Never reached — setup sends data then sleeps
}

🔗Step 5: Sensor Node 2 Code (Soil Moisture)

Upload this sketch to the second sensor node. Again, replace the gateway MAC address.

#include <esp_now.h>
#include <WiFi.h>

// ---- CONFIGURATION ----
#define NODE_ID          2
#define SENSOR_TYPE      2        // 2 = Soil moisture
#define SENSOR_PIN       34
#define SLEEP_SECONDS    900      // 15 minutes between readings
#define MAX_RETRIES      3

// Replace with YOUR gateway's MAC address
uint8_t gatewayMAC[] = {0x24, 0x6F, 0x28, 0xA1, 0xB2, 0xC3};

// ---- DATA STRUCTURE (must match gateway) ----
typedef struct __attribute__((packed)) {
    uint8_t  nodeId;
    uint8_t  sensorType;
    float    temperature;
    float    humidity;
    float    value1;       // Soil moisture (0-100%)
    float    battery;
} SensorPacket;

SensorPacket packet;
bool sendSuccess = false;

void onDataSent(const uint8_t *mac, esp_now_send_status_t status) {
    sendSuccess = (status == ESP_NOW_SEND_SUCCESS);
    Serial.print("Send status: ");
    Serial.println(sendSuccess ? "OK" : "FAIL");
}

float readSoilMoisture() {
    // Take multiple readings and average them for stability
    long total = 0;
    const int samples = 10;
    for (int i = 0; i < samples; i++) {
        total += analogRead(SENSOR_PIN);
        delay(10);
    }
    int raw = total / samples;

    // Map to percentage (calibrate these values for your sensor)
    // Typical capacitive sensor: ~3400 in air, ~1400 in water
    int dryValue = 3400;
    int wetValue = 1400;
    float percent = map(constrain(raw, wetValue, dryValue),
                        dryValue, wetValue, 0, 100);
    return percent;
}

float readBatteryVoltage() {
    return 0.0;  // Return 0 if powered via USB
}

void setup() {
    Serial.begin(115200);
    delay(100);

    // Read soil moisture
    float moisture = readSoilMoisture();

    // Build the packet
    packet.nodeId      = NODE_ID;
    packet.sensorType  = SENSOR_TYPE;
    packet.temperature = 0.0;   // Not measured on this node
    packet.humidity    = 0.0;
    packet.value1      = moisture;
    packet.battery     = readBatteryVoltage();

    Serial.printf("Node %d: Soil moisture = %.1f%%\n",
                  packet.nodeId, packet.value1);

    // Initialize ESP-NOW
    WiFi.mode(WIFI_STA);
    WiFi.disconnect();

    if (esp_now_init() != ESP_OK) {
        Serial.println("ESP-NOW init failed");
        esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
    }

    esp_now_register_send_cb(onDataSent);

    // Register gateway peer
    esp_now_peer_info_t peerInfo = {};
    memcpy(peerInfo.peer_addr, gatewayMAC, 6);
    peerInfo.channel = 0;
    peerInfo.encrypt = false;

    if (esp_now_add_peer(&peerInfo) != ESP_OK) {
        Serial.println("Failed to add gateway peer");
        esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
    }

    // Send with retries
    for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
        sendSuccess = false;
        esp_now_send(gatewayMAC, (uint8_t *)&packet, sizeof(packet));
        delay(100);

        if (sendSuccess) break;
        Serial.printf("Retry %d/%d\n", attempt + 1, MAX_RETRIES);
    }

    Serial.printf("Sleeping for %d seconds...\n", SLEEP_SECONDS);
    esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
}

void loop() {
    // Never reached
}

🔗Step 6: Gateway Code

The gateway does two things: it listens for ESP-NOW packets from all sensor nodes, and it forwards the data to an MQTT broker over WiFi.

Upload this sketch to the gateway ESP32. Update the WiFi credentials and MQTT broker address.

#include <esp_now.h>
#include <WiFi.h>
#include <PubSubClient.h>

// ---- WiFi CONFIGURATION ----
const char* ssid     = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";

// ---- MQTT CONFIGURATION ----
const char* mqttServer   = "192.168.1.100";  // Your MQTT broker IP
const int   mqttPort     = 1883;
const char* mqttUser     = "";               // Leave empty if no auth
const char* mqttPassword = "";

// ---- DATA STRUCTURE (must match sensor nodes) ----
typedef struct __attribute__((packed)) {
    uint8_t  nodeId;
    uint8_t  sensorType;
    float    temperature;
    float    humidity;
    float    value1;
    float    battery;
} SensorPacket;

WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);

// Buffer for incoming packets (simple queue)
#define QUEUE_SIZE 10
SensorPacket packetQueue[QUEUE_SIZE];
int queueHead = 0;
int queueTail = 0;
volatile bool newData = false;

// Callback: called when an ESP-NOW packet arrives
void onDataRecv(const uint8_t *mac, const uint8_t *data, int len) {
    if (len != sizeof(SensorPacket)) {
        Serial.printf("Bad packet size: %d (expected %d)\n",
                       len, sizeof(SensorPacket));
        return;
    }

    // Add to queue
    int nextHead = (queueHead + 1) % QUEUE_SIZE;
    if (nextHead != queueTail) {  // Queue not full
        memcpy(&packetQueue[queueHead], data, sizeof(SensorPacket));
        queueHead = nextHead;
        newData = true;
    } else {
        Serial.println("WARNING: Packet queue full, dropping packet");
    }

    // Log the sender
    Serial.printf("Received from %02X:%02X:%02X:%02X:%02X:%02X - Node %d\n",
                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
                  ((SensorPacket*)data)->nodeId);
}

void connectWiFi() {
    Serial.print("Connecting to WiFi");
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, password);

    int attempts = 0;
    while (WiFi.status() != WL_CONNECTED && attempts < 30) {
        delay(500);
        Serial.print(".");
        attempts++;
    }

    if (WiFi.status() == WL_CONNECTED) {
        Serial.printf("\nConnected! IP: %s\n",
                       WiFi.localIP().toString().c_str());
        Serial.printf("MAC: %s\n", WiFi.macAddress().c_str());
        Serial.printf("Channel: %d\n", WiFi.channel());
    } else {
        Serial.println("\nWiFi connection failed! Restarting...");
        ESP.restart();
    }
}

void connectMQTT() {
    while (!mqtt.connected()) {
        Serial.print("Connecting to MQTT...");
        String clientId = "ESP32-Gateway-" + String(random(0xffff), HEX);

        bool connected;
        if (strlen(mqttUser) > 0) {
            connected = mqtt.connect(clientId.c_str(), mqttUser, mqttPassword);
        } else {
            connected = mqtt.connect(clientId.c_str());
        }

        if (connected) {
            Serial.println("connected!");
        } else {
            Serial.printf("failed (rc=%d). Retrying in 5s...\n",
                           mqtt.state());
            delay(5000);
        }
    }
}

void publishPacket(SensorPacket &pkt) {
    char topic[64];
    char payload[256];

    // Publish to topic: sensors/node<ID>/<type>
    const char* sensorName;
    switch (pkt.sensorType) {
        case 1: sensorName = "bme280";        break;
        case 2: sensorName = "soil_moisture";  break;
        default: sensorName = "unknown";       break;
    }

    // Publish individual values for easy dashboard integration
    if (pkt.sensorType == 1) {  // BME280
        snprintf(topic, sizeof(topic), "sensors/node%d/temperature", pkt.nodeId);
        snprintf(payload, sizeof(payload), "%.1f", pkt.temperature);
        mqtt.publish(topic, payload, true);  // retained

        snprintf(topic, sizeof(topic), "sensors/node%d/humidity", pkt.nodeId);
        snprintf(payload, sizeof(payload), "%.1f", pkt.humidity);
        mqtt.publish(topic, payload, true);

        snprintf(topic, sizeof(topic), "sensors/node%d/pressure", pkt.nodeId);
        snprintf(payload, sizeof(payload), "%.1f", pkt.value1);
        mqtt.publish(topic, payload, true);
    }
    else if (pkt.sensorType == 2) {  // Soil moisture
        snprintf(topic, sizeof(topic), "sensors/node%d/soil_moisture", pkt.nodeId);
        snprintf(payload, sizeof(payload), "%.1f", pkt.value1);
        mqtt.publish(topic, payload, true);
    }

    // Always publish battery voltage
    snprintf(topic, sizeof(topic), "sensors/node%d/battery", pkt.nodeId);
    snprintf(payload, sizeof(payload), "%.2f", pkt.battery);
    mqtt.publish(topic, payload, true);

    // Publish a JSON summary for convenience
    snprintf(topic, sizeof(topic), "sensors/node%d/json", pkt.nodeId);
    snprintf(payload, sizeof(payload),
             "{\"node\":%d,\"type\":\"%s\",\"temp\":%.1f,"
             "\"hum\":%.1f,\"val\":%.1f,\"batt\":%.2f}",
             pkt.nodeId, sensorName, pkt.temperature,
             pkt.humidity, pkt.value1, pkt.battery);
    mqtt.publish(topic, payload, true);

    Serial.printf("Published Node %d data to MQTT\n", pkt.nodeId);
}

void setup() {
    Serial.begin(115200);
    delay(1000);
    Serial.println("\n=== ESP-NOW Gateway ===");

    // Connect WiFi first (need to know the channel)
    connectWiFi();

    // Initialize ESP-NOW (WiFi must be in STA mode, which it already is)
    if (esp_now_init() != ESP_OK) {
        Serial.println("ESP-NOW init failed! Restarting...");
        ESP.restart();
    }

    esp_now_register_recv_cb(onDataRecv);
    Serial.println("ESP-NOW ready. Waiting for sensor data...");

    // Connect MQTT
    mqtt.setServer(mqttServer, mqttPort);
    connectMQTT();
}

void loop() {
    // Keep MQTT alive
    if (!mqtt.connected()) {
        connectMQTT();
    }
    mqtt.loop();

    // Process any queued packets
    while (queueTail != queueHead) {
        publishPacket(packetQueue[queueTail]);
        queueTail = (queueTail + 1) % QUEUE_SIZE;
    }

    delay(10);
}

🔗Step 7: Important -- WiFi Channel Alignment

ESP-NOW and WiFi share the same radio. When the gateway connects to your WiFi router, it locks to whatever channel the router uses. The sensor nodes must transmit on the same channel, or the gateway will not receive their packets.

By default, WiFi.mode(WIFI_STA) with no connection uses channel 1. If your router uses a different channel (say, channel 6), the sensor nodes will be sending on channel 1 while the gateway listens on channel 6.

Solution: Set the sensor nodes to use the same channel. Add this line in the sensor node code, right after WiFi.mode(WIFI_STA):

esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE);  // Match your router

You will need to add #include <esp_wifi.h> at the top of the sensor node sketches.

To find your router's channel, check the gateway Serial Monitor output (it prints the channel), or look in your router's admin page.

🔗Step 8: Upload and Test

  1. Upload the gateway sketch first. Open the Serial Monitor and confirm it connects to WiFi and MQTT.
  2. Upload Node 1 sketch (BME280). Watch the Serial Monitor -- it should print the sensor reading, send it, and go to sleep.
  3. Upload Node 2 sketch (soil moisture). Same process.
  4. On the gateway Serial Monitor, you should see messages arriving from each node.
  5. Use an MQTT client (like MQTT Explorer or mosquitto_sub) to verify data reaches the broker:
mosquitto_sub -h 192.168.1.100 -t "sensors/#" -v

You should see output like:

sensors/node1/temperature 23.4
sensors/node1/humidity 45.2
sensors/node1/pressure 1013.2
sensors/node2/soil_moisture 67.3

🔗Deep Sleep and Battery Life

The ESP32 draws about $150\,\text{mA}$ when WiFi is active, but only about $10\,\mu\text{A}$ in deep sleep. Since the sensor nodes only wake up briefly to take a reading and send it (roughly 200 ms of active time), the average current is extremely low.

For a node sleeping 5 minutes between readings:

$$I_{avg} = \frac{t_{active} \cdot I_{active} + t_{sleep} \cdot I_{sleep}}{t_{active} + t_{sleep}}$$

$$I_{avg} = \frac{0.2 \times 0.08 + 300 \times 0.00001}{0.2 + 300} \approx 0.063\,\text{mA}$$

With a pair of AA batteries (roughly $2500\,\text{mAh}$):

$$\text{Battery life} = \frac{2500}{0.063} \approx 39{,}700\,\text{hours} \approx 4.5\,\text{years}$$

In practice, battery self-discharge and sensor power consumption will reduce this, but you can reasonably expect several months to over a year on batteries.

Tip: To maximize battery life, power the BME280 from a GPIO pin instead of 3.3V. Set the pin HIGH before reading and LOW before sleeping. This eliminates the sensor's quiescent current during deep sleep.

🔗Calibration

🔗BME280

The BME280 is factory-calibrated and generally accurate to $\pm 1°\text{C}$ and $\pm 3\%$ RH. If you want to check accuracy, compare readings against a known thermometer.

🔗Soil Moisture

The capacitive soil moisture sensor needs manual calibration:

  1. Read the raw ADC value with the sensor in dry air (this is your "0% moisture" baseline)
  2. Read the raw ADC value with the sensor in a glass of water (this is your "100% moisture" baseline)
  3. Update dryValue and wetValue in the code

🔗Adding More Nodes

To add a third sensor node (for example, a light sensor):

  1. Choose a unique NODE_ID (e.g., 3)
  2. Define a new SENSOR_TYPE (e.g., 3 for light)
  3. Use the same SensorPacket structure -- put the light reading in value1
  4. Flash the new node with the same gateway MAC address
  5. On the gateway, add a new case in the publishPacket() switch statement:
case 3: sensorName = "light"; break;

Then add the MQTT publish logic:

else if (pkt.sensorType == 3) {  // Light sensor
    snprintf(topic, sizeof(topic), "sensors/node%d/light", pkt.nodeId);
    snprintf(payload, sizeof(payload), "%.0f", pkt.value1);
    mqtt.publish(topic, payload, true);
}

No changes are needed on the existing nodes. The network scales easily up to 20 peers (the ESP-NOW limit on ESP32).

🔗Common Issues and Solutions

ProblemCauseFix
Gateway never receives packetsWiFi channel mismatchSet sensor nodes to the same channel as your router (see Step 7)
"ESP-NOW init failed"WiFi not in STA modeMake sure WiFi.mode(WIFI_STA) is called before esp_now_init()
Send callback reports FAILGateway MAC address wrongDouble-check the MAC address bytes. Use the MAC printer sketch from Step 1
BME280 not foundWrong I2C address or bad wiringTry address 0x77 instead of 0x76. Check SDA/SCL connections
Soil moisture reads 0 or 4095Wrong pin or sensor not connectedUse an ADC1 pin (GPIO 32-39). Check wiring
MQTT not publishingBroker unreachable or wrong IPVerify broker IP and port. Test with mosquitto_pub from another machine
Node wakes too oftenSLEEP_SECONDS too lowIncrease the sleep duration. 300-900 seconds is typical
Packets arrive but data is garbageStruct mismatch between node and gatewayMake sure the SensorPacket struct is identical on all boards (same field order, same types, same __attribute__((packed)))
Node works on USB but not batteryBattery voltage too lowESP32 needs at least 3.0V. Use a voltage regulator or 2S 18650 pack with a buck converter

🔗Extensions

  • Battery monitoring: Add a voltage divider (two 100K resistors) from the battery to GPIO 35 to track battery level. Publish it via MQTT so you know when to recharge.
  • OLED on the gateway: Wire an SSD1306 OLED to the gateway to display the last reading from each node.
  • Home Assistant integration: MQTT topics map directly to Home Assistant sensors. Add them via configuration.yaml or MQTT auto-discovery.
  • Encryption: ESP-NOW supports CCMP encryption. Add a 16-byte key in peerInfo.lmk and set peerInfo.encrypt = true on both sides.
  • Two-way communication: The gateway can send commands back to nodes (e.g., change sleep interval). Nodes would need to briefly listen after transmitting.
  • Solar power: Add a small solar panel and TP4056 charge controller to make outdoor sensor nodes completely self-sustaining.