979  Zigbee Hands-On Lab: Mesh Network Simulation

Interactive ESP32 simulation demonstrating Zigbee mesh networking concepts

979.1 Learning Objectives

By completing this lab, you will be able to:

  • Implement peer-to-peer communication using ESP-NOW
  • Create message relay/forwarding to simulate multi-hop mesh routing
  • Build a simple mesh topology with multiple nodes
  • Understand Zigbee concepts through hands-on experimentation
  • Debug wireless communication by observing message flow

979.2 Introduction

This interactive lab uses the Wokwi ESP32 simulator to demonstrate mesh networking concepts that underpin Zigbee. Since Wokwi does not have native Zigbee support, we use ESP-NOW, a peer-to-peer protocol that mimics many mesh networking behaviors.

TipWhy ESP-NOW for Learning Zigbee?

ESP-NOW and Zigbee share key characteristics:

Feature Zigbee ESP-NOW
Frequency 2.4 GHz 2.4 GHz
Range 10-100m 10-250m
Low power Yes Yes
Peer-to-peer Yes Yes
Mesh capable Native Manual relay
Max peers 65,000+ 20 per device

While Zigbee has sophisticated built-in mesh routing (AODV), ESP-NOW requires manual message relay - which is actually better for learning because you implement the routing logic yourself!

979.3 Lab Components

The simulation contains four ESP32 nodes representing a Zigbee mesh network:

Node Role LED Color Function
ESP32 #1 Coordinator Blue LED Network controller, message destination
ESP32 #2 Router A Green LED Primary relay path
ESP32 #3 Router B Yellow LED Backup relay path (self-healing)
ESP32 #4 End Device Red LED Sensor node, message source

979.4 Embedded Wokwi Mesh Simulator

TipHow to Use This Simulator
  1. Click β€œStart Simulation” to begin
  2. Watch the Serial Monitor for message flow logs
  3. LEDs blink when routing messages
  4. To test self-healing: Stop Router A by clicking its reset button
  5. The network will automatically reroute through Router B
NoteSimulator Setup Instructions

Since Wokwi doesn’t persist embedded projects, copy the complete mesh network code below into the simulator. The code creates a 4-node mesh network demonstrating all key Zigbee concepts.

979.5 Complete Mesh Network Code

Copy this code into each of 4 Wokwi tabs (change NODE_ROLE for each):

// ============================================================
// ESP-NOW Zigbee-Style Mesh Network Simulator
// Demonstrates: Coordinator, Router, End Device roles
//              Multi-hop routing, Self-healing, Broadcast/Unicast
// ============================================================

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

// ============ CONFIGURATION - CHANGE FOR EACH NODE ============
// Set ONE of these to 1, others to 0:
#define IS_COORDINATOR 0  // Node 1: Set to 1
#define IS_ROUTER_A    0  // Node 2: Set to 1
#define IS_ROUTER_B    0  // Node 3: Set to 1
#define IS_END_DEVICE  1  // Node 4: Set to 1 (default)

// LED Pin Configuration
#define STATUS_LED 2       // Built-in LED
#define MSG_LED 4          // Message activity LED

// ============ NETWORK CONSTANTS ============
#define MAX_HOPS 5
#define HEARTBEAT_INTERVAL 5000
#define ROUTE_TIMEOUT 15000
#define MAX_NEIGHBORS 10
#define MSG_RETRY_COUNT 3
#define ACK_TIMEOUT 500

// Broadcast address
uint8_t broadcastMAC[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

// ============ MESSAGE STRUCTURES ============
enum MessageType {
  MSG_DATA = 0,
  MSG_HEARTBEAT = 1,
  MSG_ROUTE_REQUEST = 2,
  MSG_ROUTE_REPLY = 3,
  MSG_ACK = 4,
  MSG_BROADCAST = 5
};

typedef struct __attribute__((packed)) {
  uint8_t msgType;
  uint8_t srcMAC[6];
  uint8_t dstMAC[6];
  uint8_t hopCount;
  uint8_t maxHops;
  uint32_t seqNum;
  uint8_t payload[64];
  uint8_t payloadLen;
} MeshMessage;

typedef struct {
  uint8_t mac[6];
  char role[12];
  int8_t rssi;
  uint32_t lastSeen;
  bool isActive;
  bool canRoute;
} Neighbor;

// ============ GLOBAL STATE ============
Neighbor neighbors[MAX_NEIGHBORS];
int neighborCount = 0;
uint32_t messageSeq = 0;
uint32_t lastHeartbeat = 0;
uint32_t messagesRouted = 0;
uint32_t messagesReceived = 0;
uint32_t lastDataSend = 0;

char myRole[12];
uint8_t myMAC[6];

bool awaitingAck = false;
uint32_t ackSeqNum = 0;
uint32_t ackSentTime = 0;
int ackRetries = 0;

// ============ LED FUNCTIONS ============
void blinkLED(int pin, int times, int delayMs) {
  for (int i = 0; i < times; i++) {
    digitalWrite(pin, HIGH);
    delay(delayMs);
    digitalWrite(pin, LOW);
    delay(delayMs);
  }
}

void flashMessageLED() {
  digitalWrite(MSG_LED, HIGH);
  delay(50);
  digitalWrite(MSG_LED, LOW);
}

// ============ NEIGHBOR TABLE ============
int findNeighbor(const uint8_t* mac) {
  for (int i = 0; i < neighborCount; i++) {
    if (memcmp(neighbors[i].mac, mac, 6) == 0) return i;
  }
  return -1;
}

void updateNeighbor(const uint8_t* mac, const char* role, int8_t rssi) {
  int idx = findNeighbor(mac);

  if (idx >= 0) {
    neighbors[idx].rssi = rssi;
    neighbors[idx].lastSeen = millis();
    neighbors[idx].isActive = true;
    strncpy(neighbors[idx].role, role, 11);
    neighbors[idx].canRoute = (strcmp(role, "ROUTER") == 0 ||
                                strcmp(role, "COORDINATOR") == 0);
  } else if (neighborCount < MAX_NEIGHBORS) {
    memcpy(neighbors[neighborCount].mac, mac, 6);
    strncpy(neighbors[neighborCount].role, role, 11);
    neighbors[neighborCount].rssi = rssi;
    neighbors[neighborCount].lastSeen = millis();
    neighbors[neighborCount].isActive = true;
    neighbors[neighborCount].canRoute = (strcmp(role, "ROUTER") == 0 ||
                                          strcmp(role, "COORDINATOR") == 0);
    neighborCount++;
    Serial.printf("[MESH] New neighbor: %02X:%02X:%02X:%02X:%02X:%02X (%s)\n",
                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], role);
  }
}

void checkNeighborTimeouts() {
  for (int i = 0; i < neighborCount; i++) {
    if (neighbors[i].isActive &&
        (millis() - neighbors[i].lastSeen > ROUTE_TIMEOUT)) {
      neighbors[i].isActive = false;
      Serial.printf("[MESH] Neighbor timeout: %02X:%02X:%02X:%02X:%02X:%02X\n",
                    neighbors[i].mac[0], neighbors[i].mac[1], neighbors[i].mac[2],
                    neighbors[i].mac[3], neighbors[i].mac[4], neighbors[i].mac[5]);
      Serial.println("[MESH] >>> SELF-HEALING: Route may need update <<<");
    }
  }
}

void printNeighborTable() {
  Serial.println("\n========== NEIGHBOR TABLE ==========");
  for (int i = 0; i < neighborCount; i++) {
    Serial.printf("  %d. %02X:%02X:%02X:%02X:%02X:%02X - %s (RSSI: %d) %s\n",
                  i + 1, neighbors[i].mac[0], neighbors[i].mac[1],
                  neighbors[i].mac[2], neighbors[i].mac[3],
                  neighbors[i].mac[4], neighbors[i].mac[5],
                  neighbors[i].role, neighbors[i].rssi,
                  neighbors[i].isActive ? "[ACTIVE]" : "[OFFLINE]");
  }
  Serial.println("=====================================\n");
}

// ============ ROUTING ============
int findBestRoute(const uint8_t* destMAC) {
  int directIdx = findNeighbor(destMAC);
  if (directIdx >= 0 && neighbors[directIdx].isActive) return directIdx;

  int bestRouter = -1;
  int8_t bestRSSI = -127;

  for (int i = 0; i < neighborCount; i++) {
    if (neighbors[i].isActive && neighbors[i].canRoute) {
      if (neighbors[i].rssi > bestRSSI) {
        bestRSSI = neighbors[i].rssi;
        bestRouter = i;
      }
    }
  }

  if (bestRouter >= 0) {
    Serial.printf("[ROUTE] Selected: %02X:%02X:%02X:%02X:%02X:%02X (RSSI: %d)\n",
                  neighbors[bestRouter].mac[0], neighbors[bestRouter].mac[1],
                  neighbors[bestRouter].mac[2], neighbors[bestRouter].mac[3],
                  neighbors[bestRouter].mac[4], neighbors[bestRouter].mac[5],
                  bestRSSI);
  }
  return bestRouter;
}

// ============ MESSAGE SENDING ============
void sendMessage(MeshMessage* msg, const uint8_t* nextHop) {
  esp_err_t result = esp_now_send(nextHop, (uint8_t*)msg, sizeof(MeshMessage));
  flashMessageLED();

  if (result == ESP_OK) {
    Serial.printf("[TX] Sent %s (seq: %lu, hops: %d)\n",
                  msg->msgType == MSG_DATA ? "DATA" :
                  msg->msgType == MSG_HEARTBEAT ? "HEARTBEAT" :
                  msg->msgType == MSG_ACK ? "ACK" : "OTHER",
                  msg->seqNum, msg->hopCount);
  }
}

void sendHeartbeat() {
  MeshMessage hb;
  hb.msgType = MSG_HEARTBEAT;
  memcpy(hb.srcMAC, myMAC, 6);
  memset(hb.dstMAC, 0xFF, 6);
  hb.hopCount = 0;
  hb.maxHops = 1;
  hb.seqNum = ++messageSeq;
  snprintf((char*)hb.payload, sizeof(hb.payload), "%s", myRole);
  hb.payloadLen = strlen(myRole);

  sendMessage(&hb, broadcastMAC);
}

void sendSensorData() {
  if (!IS_END_DEVICE) return;

  MeshMessage data;
  data.msgType = MSG_DATA;
  memcpy(data.srcMAC, myMAC, 6);
  memset(data.dstMAC, 0xFF, 6);
  data.hopCount = 0;
  data.maxHops = MAX_HOPS;
  data.seqNum = ++messageSeq;

  float temp = 20.0 + (random(0, 100) / 10.0);
  float humidity = 40.0 + (random(0, 200) / 10.0);
  snprintf((char*)data.payload, sizeof(data.payload),
           "TEMP:%.1f,HUM:%.1f", temp, humidity);
  data.payloadLen = strlen((char*)data.payload);

  Serial.println("\n====== SENDING SENSOR DATA ======");
  Serial.printf("Temperature: %.1f C, Humidity: %.1f%%\n", temp, humidity);

  int routeIdx = findBestRoute(data.dstMAC);
  if (routeIdx >= 0) {
    sendMessage(&data, neighbors[routeIdx].mac);
    awaitingAck = true;
    ackSeqNum = data.seqNum;
    ackSentTime = millis();
  } else {
    Serial.println("No route - broadcasting");
    sendMessage(&data, broadcastMAC);
  }
}

// ============ MESSAGE RECEIVING ============
void onDataSent(const uint8_t* mac, esp_now_send_status_t status) {
  Serial.println(status == ESP_NOW_SEND_SUCCESS ?
                 "[DELIVERY] Confirmed" : "[DELIVERY] Failed");
}

void onDataReceived(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
  MeshMessage msg;
  memcpy(&msg, data, sizeof(MeshMessage));
  messagesReceived++;
  flashMessageLED();

  updateNeighbor(info->src_addr,
                 msg.msgType == MSG_HEARTBEAT ? (char*)msg.payload : "UNKNOWN",
                 info->rx_ctrl->rssi);

  Serial.printf("\n[RX] %s from %02X:%02X:%02X:%02X:%02X:%02X\n",
                msg.msgType == MSG_DATA ? "DATA" :
                msg.msgType == MSG_HEARTBEAT ? "HEARTBEAT" :
                msg.msgType == MSG_ACK ? "ACK" : "OTHER",
                info->src_addr[0], info->src_addr[1], info->src_addr[2],
                info->src_addr[3], info->src_addr[4], info->src_addr[5]);

  if (msg.msgType == MSG_DATA) {
    if (IS_COORDINATOR) {
      Serial.println("\n****** DATA AT COORDINATOR ******");
      Serial.printf("Hops: %d, Data: %s\n", msg.hopCount, (char*)msg.payload);
      Serial.println("*********************************\n");

      MeshMessage ack;
      ack.msgType = MSG_ACK;
      memcpy(ack.srcMAC, myMAC, 6);
      memcpy(ack.dstMAC, msg.srcMAC, 6);
      ack.seqNum = msg.seqNum;
      ack.hopCount = 0;
      ack.maxHops = MAX_HOPS;
      sendMessage(&ack, info->src_addr);
      blinkLED(STATUS_LED, 3, 100);

    } else if (IS_ROUTER_A || IS_ROUTER_B) {
      if (msg.hopCount < msg.maxHops) {
        msg.hopCount++;
        messagesRouted++;
        Serial.printf("[RELAY] Forwarding (hop %d)\n", msg.hopCount);

        int nextHopIdx = findBestRoute(msg.dstMAC);
        if (nextHopIdx >= 0) {
          sendMessage(&msg, neighbors[nextHopIdx].mac);
        } else {
          sendMessage(&msg, broadcastMAC);
        }
        blinkLED(MSG_LED, 2, 50);
      }
    }
  } else if (msg.msgType == MSG_ACK) {
    if (msg.seqNum == ackSeqNum && awaitingAck) {
      awaitingAck = false;
      Serial.printf("[ACK] Confirmed for seq %lu\n", msg.seqNum);
      blinkLED(STATUS_LED, 2, 100);
    } else if (IS_ROUTER_A || IS_ROUTER_B) {
      if (msg.hopCount < msg.maxHops) {
        msg.hopCount++;
        int routeIdx = findNeighbor(msg.dstMAC);
        if (routeIdx >= 0) sendMessage(&msg, neighbors[routeIdx].mac);
        else sendMessage(&msg, broadcastMAC);
      }
    }
  }
}

// ============ SETUP ============
void setup() {
  Serial.begin(115200);
  delay(1000);

  pinMode(STATUS_LED, OUTPUT);
  pinMode(MSG_LED, OUTPUT);
  blinkLED(STATUS_LED, 3, 200);

  if (IS_COORDINATOR) strcpy(myRole, "COORDINATOR");
  else if (IS_ROUTER_A || IS_ROUTER_B) strcpy(myRole, "ROUTER");
  else strcpy(myRole, "END_DEVICE");

  Serial.println("\n========================================");
  Serial.println("  Zigbee-Style Mesh Network Simulator");
  Serial.printf("  Node Role: %s\n", myRole);
  Serial.println("========================================\n");

  WiFi.mode(WIFI_STA);
  WiFi.disconnect();
  WiFi.macAddress(myMAC);
  Serial.printf("MAC: %02X:%02X:%02X:%02X:%02X:%02X\n\n",
                myMAC[0], myMAC[1], myMAC[2], myMAC[3], myMAC[4], myMAC[5]);

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed!");
    return;
  }

  esp_now_register_send_cb(onDataSent);
  esp_now_register_recv_cb(onDataReceived);

  esp_now_peer_info_t peerInfo = {};
  memcpy(peerInfo.peer_addr, broadcastMAC, 6);
  peerInfo.channel = 0;
  peerInfo.encrypt = false;
  esp_now_add_peer(&peerInfo);

  if (IS_COORDINATOR) digitalWrite(STATUS_LED, HIGH);

  delay(random(100, 1000));
  sendHeartbeat();
  lastHeartbeat = millis();

  Serial.println("Setup complete!\n");
}

// ============ MAIN LOOP ============
void loop() {
  checkNeighborTimeouts();

  if (millis() - lastHeartbeat > HEARTBEAT_INTERVAL) {
    sendHeartbeat();
    lastHeartbeat = millis();

    static int heartbeatCount = 0;
    if (++heartbeatCount % 3 == 0) {
      printNeighborTable();
      Serial.printf("[STATS] Received: %lu, Routed: %lu\n\n",
                    messagesReceived, messagesRouted);
    }
  }

  if (IS_END_DEVICE && (millis() - lastDataSend > 10000)) {
    sendSensorData();
    lastDataSend = millis();
  }

  if (awaitingAck && (millis() - ackSentTime > ACK_TIMEOUT)) {
    if (ackRetries < MSG_RETRY_COUNT) {
      ackRetries++;
      Serial.printf("[RETRY] %d/%d\n", ackRetries, MSG_RETRY_COUNT);
      ackSentTime = millis();
    } else {
      Serial.println("[RETRY] Failed - triggering route rediscovery");
      awaitingAck = false;
    }
  }

  if ((IS_ROUTER_A || IS_ROUTER_B) && (millis() % 2000 < 100)) {
    digitalWrite(STATUS_LED, HIGH);
  } else if (IS_ROUTER_A || IS_ROUTER_B) {
    digitalWrite(STATUS_LED, LOW);
  }

  delay(10);
}

979.6 Step-by-Step Instructions

979.6.1 Step 1: Set Up Four Simulator Instances

  1. Open four browser tabs with Wokwi ESP32 simulators
  2. Copy the complete code into each tab
  3. Modify the configuration for each node:
Tab Configuration
Tab 1 #define IS_COORDINATOR 1 (others 0)
Tab 2 #define IS_ROUTER_A 1 (others 0)
Tab 3 #define IS_ROUTER_B 1 (others 0)
Tab 4 #define IS_END_DEVICE 1 (default)

979.6.2 Step 2: Start the Network

  1. Start Coordinator first (Tab 1)
  2. Start both Routers (Tabs 2 and 3)
  3. Start End Device last (Tab 4)
  4. Watch Serial Monitor for network formation

979.6.3 Step 3: Observe Mesh Behavior

Watch for these behaviors:

  1. Network Formation: Heartbeat messages establish neighbor table
  2. Multi-Hop Routing: End Device β†’ Router A β†’ Coordinator
  3. ACK Flow: Coordinator β†’ Router A β†’ End Device
  4. Hop Counting: Each relay increments hop count

979.6.4 Step 4: Test Self-Healing

  1. With network running, stop Router A (click pause)
  2. Watch End Device detect timeout (15 seconds)
  3. Observe automatic reroute through Router B
  4. Data continues flowing via backup path

979.7 Key Concepts Demonstrated

979.7.1 Device Roles (Zigbee Device Types)

Lab Role Zigbee Equivalent Behavior
Coordinator ZC Forms network, receives data
Router A/B ZR Relays messages, always on
End Device ZED Sends data, can sleep

979.7.2 Neighbor Table (Like Zigbee Neighbor Table)

Each device maintains a table of nearby devices: - MAC address - Device role - Signal strength (RSSI) - Last seen timestamp - Active status

979.7.3 Self-Healing (AODV Concept)

When Router A fails: 1. End Device detects no ACK 2. Marks route as invalid 3. Discovers new route via Router B 4. Traffic resumes automatically

979.8 Exercises

979.8.1 Exercise 1: Measure Hop Latency

Add timestamps to measure per-hop delay:

// In sendMessage():
Serial.printf("[TX] Time: %lu ms\n", millis());

// In onDataReceived():
Serial.printf("[RX] Time: %lu ms\n", millis());

979.8.2 Exercise 2: Add Second End Device

Create a 5th node as another End Device to see how the mesh handles multiple sources.

979.8.3 Exercise 3: Implement Route Caching

Modify the code to cache the last successful route and try it first before broadcasting.

979.9 Summary

This lab demonstrated core Zigbee mesh concepts:

  • Device Roles: Coordinator, Router, End Device
  • Multi-Hop Routing: Messages relayed through routers
  • Neighbor Tables: Tracking nearby devices
  • Self-Healing: Automatic recovery when paths fail
  • Message ACKs: Confirmation of delivery

These concepts directly apply to real Zigbee deployments, where the protocol handles routing automatically through AODV.

979.10 What’s Next

Return to the Zigbee Fundamentals Index to explore other Zigbee topics, or continue to related chapters: