Practical case: ESP32 NFC access with PN532, 2.9 e-paper

Practical case: ESP32 NFC access with PN532, 2.9 e-paper — hero

Objective and use case

What you’ll build: This project involves creating a standalone NFC access control terminal using an ESP32 NodeMCU-32S, PN532 NFC reader, and a Waveshare 2.9″ E-Paper display. The system will authenticate MIFARE/ISO14443A cards and provide visual feedback on access status.

Why it matters / Use cases

  • Implementing secure access control in residential or commercial buildings using NFC technology.
  • Creating a user-friendly interface for managing access rights with an E-Paper display, reducing the need for traditional LCDs.
  • Utilizing offline capabilities in environments where internet connectivity is unreliable or unavailable.
  • Enhancing skills in microcontroller projects, focusing on SPI bus management and JSON data structures.

Expected outcome

  • Successful authentication of NFC cards with a response time of less than 500ms.
  • Reliable display of access status (“GRANTED” or “DENIED”) on the E-Paper with minimal flicker.
  • Ability to manage an access control list (ACL) with at least 50 entries stored in LittleFS.
  • Demonstration of dual SPI bus functionality with stable communication between the ESP32 and PN532.

Audience: Advanced hobbyists and developers; Level: Advanced

Architecture/flow: ESP32 NodeMCU-32S communicates with PN532 via SPI, processes NFC card data, updates ACL stored in LittleFS, and displays results on Waveshare 2.9″ E-Paper.

ESP32 Advanced Practical: NFC E‑Paper Access Control (ESP32 NodeMCU‑32S + Waveshare 2.9″ E‑Paper SSD1680 + PN532)

This hands‑on case builds a standalone NFC access control terminal that authenticates MIFARE/ISO14443A cards via a PN532 reader and displays clear “GRANTED”/“DENIED” feedback on a Waveshare 2.9″ E‑Paper (SSD1680), driven by an ESP32 NodeMCU‑32S. We’ll implement a persistent access control list (ACL) using LittleFS (flash) with enrollment via a “master” card and optional serial commands. The solution is entirely offline and focuses on robust wiring, clean SPI bus sharing, and reliable rendering on E‑Paper.

The project is targeted at an advanced level: you’ll use PlatformIO, dual SPI buses on the ESP32, a JSON-based ACL, and e‑paper partial/full refresh considerations. All steps are deterministic with exact versions, paths, and commands.


Prerequisites

  • Skills: Comfortable with PlatformIO, ESP32 Arduino framework, SPI bus, JSON handling, and basic C++ on microcontrollers.
  • OS: Windows 10/11, macOS 12+, or Linux (Ubuntu 22.04+).
  • Drivers:
  • CP210x (Silicon Labs) or CH34x depending on your NodeMCU‑32S USB‑UART bridge.
    • Windows: Install Silicon Labs CP210x driver from silabs.com, or WCH CH34x driver from wch.cn.
    • macOS: CP210x typically works natively on recent macOS; CH34x may need a signed driver.
    • Linux: Usually built‑in; verify with dmesg and check /dev/ttyUSB0 or /dev/tty.SLAB_USBtoUART.
  • PlatformIO Core:
  • Python 3.9+ installed and in PATH.
  • PlatformIO CLI installed: pipx install platformio (or pip install platformio).
  • Basic familiarity with PN532 cards (MIFARE Classic, NTAG213/215/216, etc.).

Materials

  • Microcontroller:
  • ESP32 NodeMCU‑32S (exact board: “NodeMCU‑32S”, PlatformIO board ID: nodemcu-32s)
  • Display:
  • Waveshare 2.9″ E‑Paper (SSD1680), 296×128, 3‑wire/4‑wire SPI, 3.3V logic
  • NFC reader:
  • PN532 breakout (supports SPI mode, 3.3V logic)
  • Cables/power:
  • Micro‑USB cable for ESP32
  • Jumper wires (female‑to‑female recommended)
  • Stable 5V USB power source (≥1A recommended)
  • NFC tags/cards:
  • At least two ISO14443A tags
  • One tag designated as the “master card” for enrollment
  • Optional: 3D‑printed case or mounting materials

Setup / Connection

We will use two SPI buses to keep things deterministic and avoid shared bus timing surprises:

  • VSPI (default) for the E‑Paper
  • HSPI for the PN532

This separation is robust and avoids chip select conflicts. All logic must be 3.3V.

ESP32 NodeMCU‑32S to E‑Paper (SSD1680) on VSPI

  • Bus:
  • SCLK: GPIO 18
  • MOSI: GPIO 23
  • MISO: GPIO 19 (unused by e‑paper; leave unconnected)
  • Control lines:
  • CS: GPIO 5
  • DC: GPIO 17
  • RST: GPIO 16
  • BUSY: GPIO 4
  • Power:
  • VCC: 3.3V
  • GND: GND

ESP32 NodeMCU‑32S to PN532 (SPI) on HSPI

  • Bus:
  • SCLK: GPIO 14
  • MOSI: GPIO 13
  • MISO: GPIO 12
  • Control:
  • SS (NSS): GPIO 15
  • RST (if breakout exposes it): GPIO 27 (optional but recommended)
  • Power:
  • VCC: 3.3V (ensure your PN532 breakout is 3.3V logic; many accept 3.3–5V, but logic must still be 3.3V)
  • GND: GND

Pin Mapping Summary

Peripheral Signal ESP32 NodeMCU‑32S Pin Notes
E‑Paper (SSD1680) SCLK GPIO 18 VSPI SCK
E‑Paper (SSD1680) MOSI GPIO 23 VSPI MOSI
E‑Paper (SSD1680) MISO GPIO 19 Not used by display
E‑Paper (SSD1680) CS GPIO 5 Chip Select
E‑Paper (SSD1680) DC GPIO 17 Data/Command
E‑Paper (SSD1680) RST GPIO 16 Reset line
E‑Paper (SSD1680) BUSY GPIO 4 Busy input
PN532 SCLK GPIO 14 HSPI SCK
PN532 MOSI GPIO 13 HSPI MOSI
PN532 MISO GPIO 12 HSPI MISO
PN532 SS (NSS) GPIO 15 Chip Select
PN532 RST GPIO 27 Optional reset (active low)
Power 3.3V 3V3 Power both modules at 3.3V
Power GND GND Common ground

Notes:
– Never feed 5V logic into ESP32 pins. Use 3.3V logic only.
– Keep the PN532 antenna away from the e‑paper ribbon cable to minimize RF coupling glitches.


Full Code

Create a new PlatformIO project and paste the following files. The project provides a robust access control loop with persistent ACL in LittleFS, master card enrollment, and serial command management.

platformio.ini

; File: platformio.ini
[env:nodemcu-32s]
platform = espressif32@6.6.0
board = nodemcu-32s
framework = arduino
monitor_speed = 115200
board_build.filesystem = littlefs
board_build.partitions = partitions.csv
build_flags =
  -DCORE_DEBUG_LEVEL=0

lib_deps =
  zinggjm/GxEPD2 @ ^1.5.9
  adafruit/Adafruit GFX Library @ ^1.11.9
  adafruit/Adafruit BusIO @ ^1.16.1
  adafruit/Adafruit PN532 @ ^1.3.0
  bblanchon/ArduinoJson @ ^7.0.4

partitions.csv

# Name,   Type, SubType, Offset,  Size,     Flags
nvs,      data, nvs,     0x9000,  0x5000,
otadata,  data, ota,     0xE000,  0x2000,
app0,     app,  ota_0,   0x10000, 0x180000,
app1,     app,  ota_1,   0x190000, 0x180000,
spiffs,   data, spiffs,  0x310000, 0x0F0000,

src/main.cpp

// File: src/main.cpp
#include <Arduino.h>
#include <SPI.h>
#include <FS.h>
#include <LittleFS.h>
#include <ArduinoJson.h>

// PN532
#include <PN532_SPI.h>
#include "PN532.h"

// E-Paper
#include <GxEPD2_BW.h>
#include <GxEPD2_3C.h> // not used, but harmless
#include <Adafruit_GFX.h>

// ------------------ Pin Assignments ------------------
// E-Paper on VSPI
#define EPAPER_CS   5
#define EPAPER_DC   17
#define EPAPER_RST  16
#define EPAPER_BUSY 4
// VSPI pins: SCLK=18, MISO=19, MOSI=23 (hardware default)

// PN532 on HSPI
#define PN532_SS    15
#define HSPI_SCK    14
#define HSPI_MISO   12
#define HSPI_MOSI   13
#define PN532_RST   27 // optional; tie to ESP32 for reliable resets

// ------------------ E-Paper Display ------------------
#include <GxEPD2_BW.h>
GxEPD2_BW<GxEPD2_290, GxEPD2_290::HEIGHT> display(GxEPD2_290(EPAPER_CS, EPAPER_DC, EPAPER_RST, EPAPER_BUSY));

// ------------------ PN532 Setup ----------------------
SPIClass hspi(HSPI);
PN532_SPI pn532spi(hspi, PN532_SS);
PN532 nfc(pn532spi);

// ------------------ ACL/Config -----------------------
static const char* ACL_PATH = "/config/acl.json";

// Change this to your own master card UID (HEX, no spaces). Use Serial monitor to read your cards.
// Example: "04AABBCCDD"
static const char* MASTER_UID_HEX = "04AABBCCDD";

// We'll hold ACL in RAM
static const size_t MAX_UIDS = 64;
String aclList[MAX_UIDS];
size_t aclCount = 0;

bool enrollMode = false;
unsigned long enrollModeUntil = 0;
uint32_t eventCounter = 0;

// ------------------ Helpers --------------------------
String toHex(const uint8_t* data, size_t len, bool upper = true) {
  const char* p = upper ? "0123456789ABCDEF" : "0123456789abcdef";
  String out; out.reserve(len * 2);
  for (size_t i = 0; i < len; ++i) {
    out += p[(data[i] >> 4) & 0xF];
    out += p[data[i] & 0xF];
  }
  return out;
}

bool aclContains(const String& uidHex) {
  for (size_t i = 0; i < aclCount; ++i) {
    if (aclList[i].equalsIgnoreCase(uidHex)) return true;
  }
  return false;
}

bool aclAdd(const String& uidHex) {
  if (aclCount >= MAX_UIDS) return false;
  if (aclContains(uidHex)) return true; // idempotent
  aclList[aclCount++] = uidHex;
  return true;
}

bool aclRemove(const String& uidHex) {
  for (size_t i = 0; i < aclCount; ++i) {
    if (aclList[i].equalsIgnoreCase(uidHex)) {
      // compact
      for (size_t j = i + 1; j < aclCount; ++j) {
        aclList[j - 1] = aclList[j];
      }
      aclCount--;
      return true;
    }
  }
  return false;
}

bool loadACL() {
  if (!LittleFS.exists(ACL_PATH)) {
    Serial.println(F("[ACL] No file, creating default."));
    LittleFS.mkdir("/config");
    File f = LittleFS.open(ACL_PATH, FILE_WRITE);
    if (!f) return false;
    // seed with an empty list
    f.print("{\"uids\":[]}");
    f.close();
  }
  File f = LittleFS.open(ACL_PATH, FILE_READ);
  if (!f) {
    Serial.println(F("[ACL] Failed to open ACL file."));
    return false;
  }
  DynamicJsonDocument doc(2048);
  auto err = deserializeJson(doc, f);
  f.close();
  if (err) {
    Serial.print(F("[ACL] JSON error: "));
    Serial.println(err.c_str());
    return false;
  }
  aclCount = 0;
  if (doc.containsKey("uids") && doc["uids"].is<JsonArray>()) {
    for (JsonVariant v : doc["uids"].as<JsonArray>()) {
      if (aclCount < MAX_UIDS) {
        aclList[aclCount++] = String(v.as<const char*>());
      }
    }
  }
  Serial.printf("[ACL] Loaded %u UIDs\n", (unsigned)aclCount);
  return true;
}

bool saveACL() {
  DynamicJsonDocument doc(2048);
  JsonArray arr = doc.createNestedArray("uids");
  for (size_t i = 0; i < aclCount; ++i) {
    arr.add(aclList[i]);
  }
  File f = LittleFS.open(ACL_PATH, FILE_WRITE);
  if (!f) {
    Serial.println(F("[ACL] Failed to write ACL file."));
    return false;
  }
  if (serializeJsonPretty(doc, f) == 0) {
    Serial.println(F("[ACL] Failed to serialize JSON."));
    f.close();
    return false;
  }
  f.close();
  Serial.println(F("[ACL] Saved."));
  return true;
}

// ------------------ Display --------------------------
void drawBoot() {
  display.setRotation(1); // landscape
  display.setFullWindow();
  display.firstPage();
  do {
    display.fillScreen(GxEPD_WHITE);
    display.setTextColor(GxEPD_BLACK);
    display.setTextSize(2);
    display.setCursor(10, 30);
    display.print("NFC Access Control");
    display.setTextSize(1);
    display.setCursor(10, 55);
    display.print("ESP32 NodeMCU-32S + PN532 + 2.9\" E-Paper");
    display.setCursor(10, 75);
    display.print("ACL loaded: ");
    display.print(aclCount);
    display.setCursor(10, 95);
    display.print("Master: ");
    display.print(MASTER_UID_HEX);
    display.setCursor(10, 120);
    display.print("Present tag to test...");
  } while (display.nextPage());
}

// Renders status for each read
void drawEvent(const String& uidHex, bool granted, bool isMaster, bool updatedACL) {
  display.setRotation(1);
  display.setFullWindow();
  display.firstPage();
  do {
    display.fillScreen(GxEPD_WHITE);
    display.setTextColor(GxEPD_BLACK);

    // Header
    display.setTextSize(2);
    display.setCursor(10, 28);
    display.print(granted ? "ACCESS GRANTED" : "ACCESS DENIED");

    // UID
    display.setTextSize(1);
    display.setCursor(10, 55);
    display.print("UID: ");
    display.print(uidHex);

    // Flags
    display.setCursor(10, 75);
    display.print("Role: ");
    display.print(isMaster ? "MASTER" : (granted ? "AUTHORIZED" : "UNKNOWN"));

    // Event counter
    display.setCursor(10, 95);
    display.print("Event #");
    display.print(eventCounter);

    // Enroll status
    display.setCursor(10, 115);
    if (enrollMode) {
      display.print("Enroll mode ON (");
      unsigned long remain = (enrollModeUntil > millis()) ? ((enrollModeUntil - millis()) / 1000) : 0;
      display.print(remain);
      display.print("s)");
      if (updatedACL) {
        display.print(" [ACL UPDATED]");
      }
    } else {
      display.print("Enroll mode OFF");
    }
  } while (display.nextPage());
}

// ------------------ Serial Command Interface ---------
String lineBuf;
void handleSerial() {
  while (Serial.available() > 0) {
    char c = (char)Serial.read();
    if (c == '\r') continue;
    if (c == '\n') {
      lineBuf.trim();
      if (lineBuf.length() > 0) {
        Serial.print(F("[CMD] "));
        Serial.println(lineBuf);
        // Commands: list, add <UIDHEX>, del <UIDHEX>, wipe, help
        if (lineBuf.equalsIgnoreCase("list")) {
          Serial.printf("ACL (%u):\n", (unsigned)aclCount);
          for (size_t i = 0; i < aclCount; ++i) {
            Serial.printf("  - %s\n", aclList[i].c_str());
          }
        } else if (lineBuf.startsWith("add ")) {
          String u = lineBuf.substring(4);
          u.trim();
          u.toUpperCase();
          if (u.length() >= 8 && u.length() <= 14) {
            bool ok = aclAdd(u);
            Serial.println(ok ? F("Added.") : F("Failed (full?) or already exists."));
            saveACL();
          } else {
            Serial.println(F("Bad UID length."));
          }
        } else if (lineBuf.startsWith("del ")) {
          String u = lineBuf.substring(4);
          u.trim();
          u.toUpperCase();
          bool ok = aclRemove(u);
          Serial.println(ok ? F("Removed.") : F("Not found."));
          saveACL();
        } else if (lineBuf.equalsIgnoreCase("wipe")) {
          aclCount = 0;
          saveACL();
          Serial.println(F("ACL cleared."));
        } else if (lineBuf.equalsIgnoreCase("help")) {
          Serial.println(F("Commands: list | add <UIDHEX> | del <UIDHEX> | wipe | help"));
        } else {
          Serial.println(F("Unknown command. Type 'help'."));
        }
      }
      lineBuf = "";
    } else {
      if (lineBuf.length() < 80) lineBuf += c;
    }
  }
}

// ------------------ Setup & Loop ---------------------
void setup() {
  Serial.begin(115200);
  delay(50);
  Serial.println("\n[BOOT] NFC E-Paper Access Control");

  if (!LittleFS.begin(true)) {
    Serial.println("[FS] LittleFS mount failed.");
    while (true) delay(1000);
  }
  loadACL();

  // E-Paper
  display.init(115200); // SPI speed for init debug
  display.setRotation(1);
  drawBoot();

  // PN532 SPI begin on HSPI with explicit pins
  hspi.begin(HSPI_SCK, HSPI_MISO, HSPI_MOSI, PN532_SS);
  pinMode(PN532_RST, OUTPUT);
  digitalWrite(PN532_RST, HIGH); // keep high
  nfc.begin();
  uint32_t ver = nfc.getFirmwareVersion();
  if (!ver) {
    Serial.println("[PN532] No reader found. Check wiring.");
  } else {
    Serial.printf("[PN532] Found. IC=0x%02X, Ver=%u.%u\n",
                  (unsigned)((ver >> 24) & 0xFF),
                  (unsigned)((ver >> 16) & 0xFF),
                  (unsigned)((ver >> 8) & 0xFF));
  }
  nfc.SAMConfig(); // normal mode
  nfc.setPassiveActivationRetries(0xFF);

  Serial.println("[READY] Present a card. 'help' for serial commands.");
}

void loop() {
  handleSerial();

  uint8_t uid[7]; // common max
  uint8_t uidLen = 0;

  // 1000ms timeout
  bool ok = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLen, 1000);

  if (ok && uidLen >= 4) {
    eventCounter++;
    String uidHex = toHex(uid, uidLen, true);
    bool isMaster = uidHex.equalsIgnoreCase(MASTER_UID_HEX);

    Serial.printf("[TAG] UID=%s len=%u\n", uidHex.c_str(), uidLen);

    bool updatedACL = false;

    // Master toggles enroll mode for 30 seconds
    if (isMaster) {
      enrollMode = !enrollMode;
      enrollModeUntil = millis() + 30000;
      Serial.printf("[MODE] Enroll mode %s for 30s\n", enrollMode ? "ENABLED" : "DISABLED");
      drawEvent(uidHex, true, true, false);
      delay(500);
      return;
    }

    // If time elapsed, turn off enroll
    if (enrollMode && millis() > enrollModeUntil) {
      enrollMode = false;
    }

    // Enrollment logic
    if (enrollMode) {
      if (aclContains(uidHex)) {
        // Remove existing
        updatedACL = aclRemove(uidHex);
        if (updatedACL) saveACL();
        drawEvent(uidHex, false, false, updatedACL);
        Serial.println("[ENROLL] Removed from ACL (toggle).");
      } else {
        updatedACL = aclAdd(uidHex);
        if (updatedACL) saveACL();
        drawEvent(uidHex, true, false, updatedACL);
        Serial.println("[ENROLL] Added to ACL (toggle).");
      }
    } else {
      bool granted = aclContains(uidHex);
      drawEvent(uidHex, granted, false, false);
      Serial.printf("[AUTH] %s\n", granted ? "GRANTED" : "DENIED");
    }

    delay(500); // small debounce between reads
  }
}

data/config/acl.json (optional seed)

Create the following file so the firmware has a deterministic starting point (empty ACL). PlatformIO will upload it to LittleFS.

{
  "uids": [
  ]
}

Directory structure to maintain:

  • YourProject/
  • platformio.ini
  • partitions.csv
  • src/
    • main.cpp
  • data/
    • config/
    • acl.json

Build, Flash, and Run Commands

Create and initialize the project (replace the path as needed):

# 1) Create project directory
mkdir -p ~/esp/nfc-epaper-access-control
cd ~/esp/nfc-epaper-access-control

# 2) Initialize PlatformIO project for NodeMCU-32S
pio project init --board nodemcu-32s

# 3) Place files
#   - Save platformio.ini and partitions.csv into this directory
#   - Create src/main.cpp with the code above
#   - Create data/config/acl.json (optional seed)

# 4) Install libraries (optional; lib_deps in platformio.ini will auto-resolve)
pio pkg install

# 5) Build
pio run

# 6) Upload firmware
pio run -t upload

# 7) Upload LittleFS data (ACL file)
pio run -t uploadfs

# 8) Open serial monitor
pio device monitor -b 115200 --eol LF --filter time

If Windows assigns a different COM port, specify it:

pio device monitor -p COM7 -b 115200 --eol LF --filter time

On macOS:

pio device monitor -p /dev/tty.SLAB_USBtoUART -b 115200 --eol LF --filter time

Step‑by‑Step Validation

  1. Power and Cable Checks
  2. Confirm the ESP32 enumerates as a serial device.
  3. Ensure PN532 VCC to 3.3V, not 5V. Common ground among ESP32, E‑Paper, and PN532.

  4. PlatformIO Monitor

  5. Open the serial monitor at 115200 baud.
  6. On boot, expect:

    • [FS] mount success
    • [ACL] Loaded 0 UIDs (unless you added some)
    • PN532 firmware version print
    • [READY] prompt
  7. E‑Paper Boot Screen

  8. The display shows “NFC Access Control,” the number of loaded UIDs, and your master UID value.
  9. If the screen is blank, check BUSY/DC/CS pin mapping and VCC.

  10. Acquire a Card UID

  11. Present any ISO14443A card to the PN532 antenna.
  12. In the serial monitor, you should see the UID hex string.
  13. The e‑paper refreshes with GRANTED or DENIED.

  14. Set a Master UID

  15. For production, set MASTER_UID_HEX in src/main.cpp to your actual “master” card’s UID (read it first using the monitor).
  16. Rebuild and upload. The boot screen will show the master UID.

  17. Enrollment Mode

  18. Tap the master card once; you’ll see “Enroll mode ON (30s)” on the e‑paper and a console message.
  19. Present a standard card/tag:
    • If it was not in the ACL, it will be ADDED (GRANTED displayed).
    • If it was already in the ACL, it will be REMOVED (DENIED displayed).
  20. Each toggle persists immediately to LittleFS.

  21. Exit Enrollment

  22. Wait 30 seconds or tap the master card again to disable enrollment.
  23. Present the newly authorized card: it should show GRANTED with UID and event counter incrementing.

  24. Serial Commands (optional)

  25. In the monitor, type:
    • help
    • list
    • add 04A1B2C3D4
    • del 04A1B2C3D4
    • wipe
  26. Confirm ACL changes persist and reflect on the e‑paper after next scans.

  27. Persistence Check

  28. Power‑cycle the ESP32.
  29. The boot screen should show the correct ACL count.
  30. Authorized cards remain authorized.

  31. Negative Test

    • Present a random card not in the ACL (and not the master).
    • Expect “ACCESS DENIED” on e‑paper.
  32. Stress Test

    • Perform repeated scans; the event counter increases.
    • Confirm the e‑paper updates cleanly without ghosting. Occasional full updates via complete redraw are already used here; GxEPD2 handles waveforms.

Troubleshooting

  • PN532 “No reader found. Check wiring.”
  • Confirm HSPI wiring: SCK=14, MISO=12, MOSI=13, SS=15 to PN532.
  • Ensure the PN532 is in SPI mode (some boards use jumpers to select SPI vs I2C/UART).
  • Check VCC is 3.3V, solid ground, and that SS is not floating.
  • Try resetting PN532 by toggling PN532_RST low for >10ms at boot.

  • E‑Paper blank/stuck on BUSY

  • Check BUSY is GPIO 4 and wired to the module’s BUSY pin (not miswired to RST).
  • Confirm EPAPER_CS=5, DC=17, RST=16, BUSY=4.
  • Ensure 3.3V rail can supply peak current during refresh (~30–60mA transient).
  • If ghosting is heavy, add a full refresh every N events (GxEPD2 supports full/partial; current code uses full refresh per event via firstPage/nextPage).

  • Serial Monitor shows gibberish

  • Baud mismatch; use 115200.
  • Wrong port or noisy USB cable.

  • Filesystem write fails

  • Confirm partitions.csv was applied (board_build.partitions) and that uploadfs was run.
  • Running out of space: the provided partition allocates ~960KB for LittleFS. Check size.

  • Master card not recognized

  • UIDs are case‑insensitive but must be exact. Use the serial log to capture the correct UID and update MASTER_UID_HEX.
  • Some tags rotate random IDs (privacy mode). Use tags with stable UIDs (e.g., most MIFARE Classic/Ultralight/NTAG).

  • PN532 interference with E‑Paper

  • Keep PN532 antenna and E‑Paper flex cable physically separated.
  • Use short, twisted‑pair jumpers for SPI where possible to reduce ringing.

  • NodeMCU‑32S not recognized by PC

  • Install CP210x or CH34x driver depending on your board.
  • Try a different USB cable/port. Some power‑only cables won’t carry data.

Improvements

  • Secure ACL
  • Store HMAC‑SHA256 hashes of UIDs rather than plain UIDs. Use a device‑unique key stored in NVS (Preferences). Compare HMAC(UID) on read.
  • Encrypt the ACL file using a symmetric key to prevent easy tampering if flash is read.

  • RTC/NTP Timekeeping

  • Connect to Wi‑Fi and synchronize with NTP to timestamp events.
  • Render human‑readable timestamps on the e‑paper and log to LittleFS.

  • Event Logging

  • Append structured logs (JSON lines) to /log/events.jsonl.
  • Provide a serial or Wi‑Fi endpoint to retrieve logs.

  • Visual UX

  • Use larger fonts from Adafruit_GFX FreeFonts and custom icons for GRANTED/DENIED.
  • Implement partial updates for small regions to reduce flicker and power.

  • Hardware Robustness

  • Add a MOSFET‑controlled power switch for PN532 to hard‑reset the reader if it becomes unresponsive.
  • Level‑shift only if required; keep all signals at 3.3V to the ESP32.

  • Integration

  • Add MQTT or HTTPS to push events to a central server with TLS.
  • Implement OTA updates with signed binaries harnessing the dual OTA partitions defined.

  • Power Optimization

  • Use light sleep between scans, wake on a periodic timer to poll PN532.
  • Batch e‑paper updates or use partial refresh for minimal power draw.

Final Checklist

  • Materials
  • ESP32 NodeMCU‑32S, Waveshare 2.9″ E‑Paper (SSD1680), PN532 (SPI), NFC tags/cards
  • Verified 3.3V logic, common ground

  • Wiring

  • E‑Paper on VSPI: 18/23/19 + CS=5, DC=17, RST=16, BUSY=4
  • PN532 on HSPI: 14/13/12 + SS=15, RST=27
  • All modules powered from 3.3V, common ground

  • Software

  • PlatformIO Core installed
  • platformio.ini references:
    • espressif32@6.6.0
    • board nodemcu-32s
    • LittleFS enabled with partitions.csv
    • Libraries: GxEPD2, Adafruit GFX, Adafruit BusIO, Adafruit PN532, ArduinoJson
  • Build/upload success, serial monitor at 115200

  • Filesystem

  • data/config/acl.json present (optional seed)
  • Upload LittleFS via: pio run -t uploadfs

  • Functionality

  • PN532 firmware detected in logs
  • E‑paper boot screen correct
  • Master card toggles enroll mode (30s)
  • Enrollment add/remove persists across reboots
  • GRANTED/DENIED displayed correctly with event counter

  • Validation

  • Negative test with unauthorized card shows DENIED
  • Power cycle retains ACL
  • No bus contention; stable readings and display updates

You now have a robust, standalone NFC‑E‑Paper access control system on ESP32 NodeMCU‑32S with clean SPI separation, persistent ACL, and clear visual feedback suitable for prototyping access points, cabinets, or lab equipment.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What type of cards does the NFC access control terminal authenticate?




Question 2: Which display technology is used in this project?




Question 3: What is the primary microcontroller used in this project?




Question 4: Which file system is used for the access control list?




Question 5: What programming environment is recommended for this project?




Question 6: What is the purpose of the 'master' card in this project?




Question 7: Which operating systems are compatible with this project?




Question 8: What is required to install PlatformIO?




Question 9: Which driver may need to be installed for Windows users?




Question 10: What does the project focus on regarding wiring?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: ESP32 PID DC motor control, AS5600, TB6612

Practical case: ESP32 PID DC motor control, AS5600, TB6612 — hero

Objective and use case

What you’ll build: Implement a closed-loop PID controller that regulates the velocity of a DC motor using an AS5600 magnetic encoder for feedback, driven by a TB6612FNG H-bridge, all controlled by an ESP32 DevKitC.

Why it matters / Use cases

  • Automated robotics: Precise control of motor speed for robotic arms or mobile robots to enhance movement accuracy.
  • 3D printers: Maintaining consistent motor velocity for improved print quality and reduced layer misalignment.
  • Conveyor systems: Ensuring uniform speed in material handling systems to optimize production efficiency.
  • Electric vehicles: Fine-tuning motor response for better handling and energy efficiency in electric drive systems.

Expected outcome

  • Achieve a motor speed regulation accuracy within ±5 RPM.
  • Latency in control response under 50 ms for real-time adjustments.
  • Maintain a stable control loop with less than 10% overshoot during speed changes.
  • Monitor and report motor performance metrics such as speed (RPM), current (A), and temperature (°C) for diagnostics.

Audience: Advanced users familiar with embedded systems; Level: Advanced

Architecture/flow: ESP32 DevKitC processes encoder feedback from AS5600 and adjusts motor speed via TB6612FNG based on PID control logic.

Advanced Practical: PID Velocity Control of a DC Motor using ESP32 DevKitC + TB6612FNG + AS5600

Goal: Implement a closed-loop PID controller that regulates DC motor shaft velocity using a magnetic encoder (AS5600) as feedback and drives the motor via the TB6612FNG H‑bridge. The controller runs on an ESP32 DevKitC, with a reproducible build using PlatformIO.

Focus: pid-dc-motor-encoder-velocity

Exact device model: ESP32 DevKitC + TB6612FNG + AS5600


Prerequisites

  • Skill level: Advanced (comfortable with embedded C/C++, control theory basics, and I2C)
  • Development environment:
  • PlatformIO Core (CLI) 6.x installed via pip or the PlatformIO installer
  • Python 3.8+ on your host machine
  • A serial terminal (PlatformIO’s built-in monitor is used below)
  • ESP32 USB-UART driver:
  • ESP32 DevKitC commonly uses Silicon Labs CP2102N
    • Windows: Install “CP210x Universal Windows Driver”
    • macOS: Modern versions typically do not require a driver; if needed, install CP210x VCP driver
    • Linux: The cp210x kernel module is usually present; verify with dmesg after plugging in
  • Clones may use WCH CH340; if so, install the CH34x driver instead

Materials (with exact model)

  • 1x ESP32 DevKitC (official Espressif DevKitC)
  • 1x TB6612FNG dual H‑bridge breakout (logic 3.3–5 V compatible)
  • 1x AS5600 magnetic rotary position sensor breakout (I2C mode, 3.3–5 V supply)
  • 1x DC motor with shaft magnet for AS5600 (use a diametrically magnetized magnet per AS5600 datasheet)
  • Power supply for motor (VM): e.g., 6–12 V DC with current rating exceeding motor stall current
  • Breadboard and wires
  • Common ground between ESP32 and motor driver power
  • Optional: 100 nF ceramic decoupling caps near AS5600 and TB6612FNG VCC pins, bulk cap (e.g., 220 µF) at motor supply

Setup / Connection

  • Logic power: TB6612FNG VCC at 3.3 V from ESP32; AS5600 at 3.3 V (recommended)
  • Motor power: TB6612FNG VM from your motor PSU (e.g., 9 V). Grounds must be common
  • I2C: AS5600 default I2C address is 0x36, use 400 kHz bus for faster sampling

Recommended ESP32 pin assignments:

  • I2C: SDA = GPIO21, SCL = GPIO22 (ESP32 DevKitC default)
  • TB6612FNG (single channel, A-side):
  • PWMA (PWM) -> GPIO25 (LEDC)
  • AIN1 (direction) -> GPIO26
  • AIN2 (direction) -> GPIO27
  • STBY (standby enable) -> GPIO14 (set HIGH to enable driver)
  • AO1/AO2 -> Motor terminals
  • VCC -> 3.3 V; VM -> Motor PSU; GND -> Common
  • AS5600:
  • VCC -> 3.3 V
  • GND -> GND
  • SDA -> GPIO21
  • SCL -> GPIO22

Notes:
– Avoid ESP32 input-only pins (GPIO 34–39) for outputs.
– Avoid boot strapping pins for control lines that change at boot (GPIO 0, 2, 15) unless you understand the implications.
– TB6612FNG supports high PWM frequency (e.g., 20 kHz) to reduce audible noise.

Connection Table

Module/Signal Pin on Module Pin on ESP32 DevKitC Notes
TB6612FNG Logic VCC VCC 3.3 V Logic supply (2.7–5.5 V). Use 3.3 V
TB6612FNG Standby STBY GPIO14 Drive HIGH to enable driver
TB6612FNG A PWM PWMA GPIO25 LEDC PWM, 20 kHz recommended
TB6612FNG A Dir1 AIN1 GPIO26 Motor direction
TB6612FNG A Dir2 AIN2 GPIO27 Motor direction
TB6612FNG Motor Power VM Motor PSU + 6–12 V typical; not from ESP32
TB6612FNG Ground GND GND Common ground with ESP32
TB6612FNG Motor Out AO1/AO2 Motor Connect both motor leads
AS5600 Power VCC 3.3 V Power for sensor
AS5600 Ground GND GND Common ground
AS5600 SDA SDA GPIO21 I2C data
AS5600 SCL SCL GPIO22 I2C clock

Mechanical:
– Mount the AS5600 module coaxially with the motor shaft; keep gap ~1–3 mm depending on magnet and module.
– Use a diametrically magnetized magnet on the shaft; ensure centered alignment for best signal.


Full Code

This project uses an Arduino-based PlatformIO environment. The controller runs a 500 Hz loop in a FreeRTOS task, reads angle from AS5600 (I2C 0x36), computes velocity via angle unwrapping, and applies a PID to regulate the speed using the TB6612FNG. A serial command interface lets you set the setpoint and PID gains and log data.

Place the following files in your PlatformIO project:

1) platformio.ini

; File: platformio.ini
; PlatformIO reproducible environment for ESP32 DevKitC + TB6612FNG + AS5600
[env:esp32devkitc]
platform = espressif32 @ 6.6.0
board = esp32dev
framework = arduino

monitor_speed = 115200
monitor_filters = time, colorize

; Optionally set upload port if auto-detection fails:
; upload_port = /dev/ttyUSB0
; upload_port = COM5

build_flags =
  -DCORE_DEBUG_LEVEL=2
  -Wall
  -Wextra

; No external libs needed; Wire comes with framework-arduinoespressif32

2) src/main.cpp

// File: src/main.cpp
#include <Arduino.h>
#include <Wire.h>

// ===================== Hardware Mapping ======================
static constexpr int PIN_I2C_SDA = 21;
static constexpr int PIN_I2C_SCL = 22;

// TB6612FNG (A-channel)
static constexpr int PIN_TB_STBY = 14;
static constexpr int PIN_TB_PWMA  = 25; // LEDC
static constexpr int PIN_TB_AIN1  = 26;
static constexpr int PIN_TB_AIN2  = 27;

// LEDC PWM
static constexpr int PWM_CH      = 0;
static constexpr int PWM_FREQ    = 20000;  // 20 kHz
static constexpr int PWM_RES_BITS = 12;    // 12-bit resolution
static constexpr int PWM_MAX      = (1 << PWM_RES_BITS) - 1;

// AS5600 (I2C address & registers)
static constexpr uint8_t AS5600_ADDR = 0x36;
static constexpr uint8_t AS5600_REG_RAW_ANGLE = 0x0C; // 12-bit raw angle
static constexpr float   AS5600_CPR = 4096.0f;
static constexpr float   TWO_PI = 6.283185307179586476925286766559f;

// Control loop
static constexpr float LOOP_HZ = 500.0f;      // 500 Hz control loop
static constexpr float TS = 1.0f / LOOP_HZ;   // 0.002 s

// Logging
static volatile uint32_t g_logDecim = 10; // Log at LOOP_HZ/g_logDecim (e.g., 50 Hz)

// ===================== State Variables =======================
static volatile bool g_run = false;
static volatile float g_setpoint_rps = 0.0f;   // setpoint in rotations per second
static volatile float g_kp = 0.08f;
static volatile float g_ki = 5.00f;
static volatile float g_kd = 0.000f;           // derivative term (on measurement), default off
static volatile float g_output_limit = 1.0f;   // command saturation [-1,1]
static volatile float g_integrator_limit = 0.75f;

// Angle/Velocity estimation
static float angle_prev = 0.0f;      // [rad], unwrapped incrementally
static float vel_rps = 0.0f;         // [rev/s]
static float vel_prev_rps = 0.0f;    // for derivative on measurement

// PID states
static float integrator = 0.0f;

// Task handle
static TaskHandle_t controlTaskHandle = nullptr;

// ===================== I2C Helpers ===========================
uint16_t as5600_readRawAngle() {
  Wire.beginTransmission(AS5600_ADDR);
  Wire.write(AS5600_REG_RAW_ANGLE);
  if (Wire.endTransmission(false) != 0) {
    return 0xFFFF; // signal error
  }
  int n = Wire.requestFrom(AS5600_ADDR, (uint8_t)2);
  if (n != 2) {
    return 0xFFFF;
  }
  uint8_t msb = Wire.read();
  uint8_t lsb = Wire.read();
  uint16_t raw = ((uint16_t)msb << 8) | lsb;
  raw &= 0x0FFF; // 12-bit
  return raw;
}

// Returns wrapped angle [rad] from raw counts
float as5600_readAngleRad() {
  uint16_t raw = as5600_readRawAngle();
  if (raw == 0xFFFF) {
    return NAN;
  }
  return (float)raw * (TWO_PI / AS5600_CPR);
}

// ===================== Motor Driver ==========================
void motor_init() {
  pinMode(PIN_TB_STBY, OUTPUT);
  pinMode(PIN_TB_AIN1, OUTPUT);
  pinMode(PIN_TB_AIN2, OUTPUT);
  digitalWrite(PIN_TB_STBY, HIGH); // enable

  ledcSetup(PWM_CH, PWM_FREQ, PWM_RES_BITS);
  ledcAttachPin(PIN_TB_PWMA, PWM_CH);
  ledcWrite(PWM_CH, 0);

  // default brake (coast)
  digitalWrite(PIN_TB_AIN1, LOW);
  digitalWrite(PIN_TB_AIN2, LOW);
}

void motor_command(float u) {
  // u in [-1, 1]
  if (u > g_output_limit) u = g_output_limit;
  if (u < -g_output_limit) u = -g_output_limit;

  bool forward = (u >= 0.0f);
  float mag = fabsf(u);

  uint32_t duty = (uint32_t)roundf(mag * PWM_MAX);
  if (duty > PWM_MAX) duty = PWM_MAX;

  if (mag < 1e-4f) {
    // Coast
    digitalWrite(PIN_TB_AIN1, LOW);
    digitalWrite(PIN_TB_AIN2, LOW);
    ledcWrite(PWM_CH, 0);
    return;
  }

  if (forward) {
    digitalWrite(PIN_TB_AIN1, HIGH);
    digitalWrite(PIN_TB_AIN2, LOW);
  } else {
    digitalWrite(PIN_TB_AIN1, LOW);
    digitalWrite(PIN_TB_AIN2, HIGH);
  }
  ledcWrite(PWM_CH, duty);
}

// ===================== Control Task ==========================
void controlLoop(void*) {
  TickType_t lastWake = xTaskGetTickCount();
  uint32_t loopCount = 0;

  // Initialize angle_prev with first reading
  float a0 = as5600_readAngleRad();
  while (isnan(a0)) {
    vTaskDelay(pdMS_TO_TICKS(10));
    a0 = as5600_readAngleRad();
  }
  angle_prev = a0;
  vel_prev_rps = 0.0f;

  for (;;) {
    vTaskDelayUntil(&lastWake, pdMS_TO_TICKS((int)(1000.0f / LOOP_HZ)));

    // 1) Read angle and compute velocity
    float a = as5600_readAngleRad();
    if (isnan(a)) {
      // Sensor read error: cut motor for safety
      motor_command(0.0f);
      continue;
    }
    float da = a - angle_prev;
    // unwrap across 0/2pi
    if (da > (TWO_PI * 0.5f)) da -= TWO_PI;
    else if (da < -(TWO_PI * 0.5f)) da += TWO_PI;

    // Convert to rotations per second
    float vel = (da / TWO_PI) / TS; // rev/s
    angle_prev = a;
    vel_rps = vel;

    // 2) PID control on velocity
    float sp = g_setpoint_rps;
    float e = sp - vel; // velocity error

    // Tustin integrator with anti-windup
    float integrator_candidate = integrator + 0.5f * g_ki * TS * (e + e); // since prev e not stored, approx Euler
    // Clamp integrator
    if (integrator_candidate > g_integrator_limit) integrator_candidate = g_integrator_limit;
    if (integrator_candidate < -g_integrator_limit) integrator_candidate = -g_integrator_limit;
    integrator = integrator_candidate;

    // Derivative on measurement (noise-robust)
    float dterm = 0.0f;
    if (g_kd != 0.0f) {
      float d_meas = (vel - vel_prev_rps) / TS;
      dterm = -g_kd * d_meas;
    }
    vel_prev_rps = vel;

    float u = g_kp * e + integrator + dterm;

    // 3) Apply command or cut if not running
    if (g_run) {
      motor_command(u);
    } else {
      motor_command(0.0f);
      integrator = 0.0f; // reset integrator when stopped
    }

    // 4) Logging
    if ((loopCount++ % g_logDecim) == 0) {
      // CSV: t_ms,sp_rps,vel_rps,cmd
      uint32_t t = millis();
      Serial.print("T,"); Serial.print(t);
      Serial.print(",SP,"); Serial.print(sp, 4);
      Serial.print(",VEL,"); Serial.print(vel_rps, 4);
      Serial.print(",U,"); Serial.print(u, 4);
      Serial.print(",E,"); Serial.print(e, 4);
      Serial.println();
    }
  }
}

// ===================== Command Parser ========================
static String inbuf;

void printHelp() {
  Serial.println(F("Commands:"));
  Serial.println(F("  RUN                -> enable control"));
  Serial.println(F("  STOP               -> disable control"));
  Serial.println(F("  SP <rpm>           -> set setpoint in RPM (can be negative)"));
  Serial.println(F("  SPRPS <rps>        -> set setpoint in RPS"));
  Serial.println(F("  KP <val>           -> set Kp"));
  Serial.println(F("  KI <val>           -> set Ki"));
  Serial.println(F("  KD <val>           -> set Kd (derivative on measurement)"));
  Serial.println(F("  LIM <u> <i>        -> set |U|max and |I|max (e.g., LIM 1.0 0.75)"));
  Serial.println(F("  LOG <hz>           -> log rate (<= loop rate), e.g., LOG 50"));
  Serial.println(F("  ?                  -> print current config"));
}

void printStatus() {
  Serial.print(F("RUN=")); Serial.print(g_run ? "1" : "0");
  Serial.print(F(" SP(RPS)=")); Serial.print(g_setpoint_rps, 4);
  Serial.print(F(" KP=")); Serial.print(g_kp, 4);
  Serial.print(F(" KI=")); Serial.print(g_ki, 4);
  Serial.print(F(" KD=")); Serial.print(g_kd, 6);
  Serial.print(F(" Ulim=")); Serial.print(g_output_limit, 3);
  Serial.print(F(" Imax=")); Serial.print(g_integrator_limit, 3);
  Serial.print(F(" LoopHz=")); Serial.print(LOOP_HZ);
  Serial.print(F(" LogHz=")); Serial.println(LOOP_HZ / (float)g_logDecim, 1);
}

void handleLine(const String& line) {
  if (line.length() == 0) return;
  String cmd = line;
  cmd.trim();
  cmd.toUpperCase();

  if (cmd == "RUN") {
    g_run = true;
    Serial.println(F("OK RUN"));
    return;
  }
  if (cmd == "STOP") {
    g_run = false;
    Serial.println(F("OK STOP"));
    return;
  }
  if (cmd == "?") {
    printStatus();
    return;
  }
  if (cmd.startsWith("SP ")) {
    float rpm = cmd.substring(3).toFloat();
    g_setpoint_rps = rpm / 60.0f;
    Serial.print(F("OK SP RPM=")); Serial.println(rpm, 3);
    return;
  }
  if (cmd.startsWith("SPRPS ")) {
    float rps = cmd.substring(6).toFloat();
    g_setpoint_rps = rps;
    Serial.print(F("OK SP RPS=")); Serial.println(rps, 4);
    return;
  }
  if (cmd.startsWith("KP ")) {
    g_kp = cmd.substring(3).toFloat();
    Serial.print(F("OK KP=")); Serial.println(g_kp, 6);
    return;
  }
  if (cmd.startsWith("KI ")) {
    g_ki = cmd.substring(3).toFloat();
    Serial.print(F("OK KI=")); Serial.println(g_ki, 6);
    return;
  }
  if (cmd.startsWith("KD ")) {
    g_kd = cmd.substring(3).toFloat();
    Serial.print(F("OK KD=")); Serial.println(g_kd, 6);
    return;
  }
  if (cmd.startsWith("LIM ")) {
    int s1 = cmd.indexOf(' ');
    int s2 = cmd.indexOf(' ', s1 + 1);
    if (s2 > 0) {
      g_output_limit = cmd.substring(s1 + 1, s2).toFloat();
      g_integrator_limit = cmd.substring(s2 + 1).toFloat();
      Serial.print(F("OK Ulim=")); Serial.print(g_output_limit, 3);
      Serial.print(F(" Imax=")); Serial.println(g_integrator_limit, 3);
      return;
    }
  }
  if (cmd.startsWith("LOG ")) {
    float hz = cmd.substring(4).toFloat();
    if (hz <= 0) hz = 1.0f;
    if (hz > LOOP_HZ) hz = LOOP_HZ;
    g_logDecim = (uint32_t)max(1.0f, roundf(LOOP_HZ / hz));
    Serial.print(F("OK LogHz=")); Serial.println(LOOP_HZ / (float)g_logDecim, 1);
    return;
  }

  Serial.println(F("ERR Unknown command"));
  printHelp();
}

void serialPoll() {
  while (Serial.available()) {
    char c = (char)Serial.read();
    if (c == '\r') continue;
    if (c == '\n') {
      handleLine(inbuf);
      inbuf = "";
    } else {
      inbuf += c;
      if (inbuf.length() > 120) inbuf = ""; // simple overflow guard
    }
  }
}

// ===================== Arduino Setup/Loop ====================
void setup() {
  Serial.begin(115200);
  delay(100);
  Serial.println(F("\nESP32 DevKitC + TB6612FNG + AS5600 - PID DC Motor Velocity"));

  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 400000); // 400 kHz
  delay(20);

  // Probe AS5600
  Wire.beginTransmission(AS5600_ADDR);
  if (Wire.endTransmission() != 0) {
    Serial.println(F("AS5600 not responding at 0x36. Check wiring and power."));
  } else {
    Serial.println(F("AS5600 detected at 0x36"));
  }

  motor_init();

  xTaskCreatePinnedToCore(controlLoop, "ctrl", 4096, nullptr, 2, &controlTaskHandle, 1);

  printHelp();
  printStatus();
}

void loop() {
  serialPoll();
  // Leave loop light; control is on its own task
}

Build / Flash / Run Commands

Execute these in a terminal within your project directory (where platformio.ini resides):

pio --version

# Initialize project (if not created yet); selects ESP32 DevKit environment
pio project init -b esp32dev

# Build firmware
pio run

# Flash to the board (auto-detects port; specify upload_port if needed)
pio run -t upload

# Open serial monitor at 115200 baud
pio device monitor -b 115200

Notes:
– If upload port is not detected, set upload_port in platformio.ini or pass --upload-port in the command:
– Example (Linux): pio run -t upload --upload-port /dev/ttyUSB0
– Example (Windows): pio run -t upload --upload-port COM5


Step‑by‑step Validation

This section validates velocity PID control against the AS5600 feedback. Keep fingers and loose items away from the spinning motor.

1) Pre-flight checks:
– Ensure common ground between ESP32, TB6612FNG, and AS5600.
– Confirm motor power supply is adequate and connected to VM on TB6612FNG.
– Open the serial monitor:
pio device monitor -b 115200
– On boot, you should see “AS5600 detected at 0x36”.

2) Sensor sanity check:
– With the motor unpowered (RUN not enabled), gently rotate the shaft by hand.
– Temporary diagnostic: enter RUN then STOP to verify driver readiness (should not spin when STOP).
– Add a logging command: LOG 20 to reduce output rate for readability.
– Enter SP 0 (default) to keep setpoint zero. Ensure VEL in logs is near zero when stationary, and changes sign when you rotate the shaft both ways.

3) Static offset and deadband test:
– Enter RUN with SP 10 (10 RPM ~ 0.1667 rps). Observe logs for SP and VEL.
– If the motor does not start due to static friction, increase Kp slightly:
KP 0.12 then KP 0.16 if needed (increment in small steps).
– If the motor overshoots wildly, reduce Kp or increase Ki damping (lower Ki).

4) Step response characterization:
– Start with conservative gains (default: KP=0.08, KI=5.0, KD=0.0). Set LOG 50.
– Enable control: RUN.
– Command a step: SP 60 (60 RPM = 1.0 rps).
– Observe the time to reach and stabilize near 1.0 rps, overshoot magnitude, and steady-state error.
– If steady-state error persists, increase Ki gradually (e.g., KI 6.0, KI 8.0).
– If oscillations occur, reduce Kp slightly (e.g., KP 0.07) or add small KD (e.g., KD 0.001).

5) Bidirectional test:
– While running at SP 60, change to SP -60. The motor should reverse smoothly and track approximately −1.0 rps.
– Evaluate reversal overshoot; if large, consider a small KD term.

6) Saturation and anti‑windup:
– Set a high setpoint near motor capability: SP 600 (600 RPM = 10 rps). You may see U saturating near 1.0.
– Drop to SP 0 abruptly. If the system brakes slowly or exhibits after-effect, reduce integrator limit: LIM 1.0 0.4.

7) Low-speed tracking:
– Test SP 5 RPM and SP 2 RPM. Low speeds are challenging due to friction and quantization.
– If the motor dithers, reduce Ki and consider reducing LOOP_HZ to 250 Hz (requires code change) or applying a minimum duty feedforward (see Improvements).

8) Log capture and quick analysis:
– In PlatformIO monitor, copy CSV-like log lines starting with T,.
– Paste into a spreadsheet and plot VEL vs SP over time to assess performance (rise time, overshoot, steady error).

9) Safety stop:
– Test STOP. Motor should coast (AIN1=AIN2=LOW, PWM=0).
– Resume with RUN and prior setpoint intact.

Expected results:
– At moderate speeds (e.g., 50–200 RPM), velocity tracking within a few percent with minimal overshoot after tuning.
– Stable bidirectional operation with no runaway or oscillation under nominal load.


Troubleshooting

  • AS5600 not detected:
  • Symptom: “AS5600 not responding at 0x36.”
  • Actions:

    • Verify VCC=3.3 V and GND.
    • Confirm SDA=GPIO21, SCL=GPIO22; check for swapped lines.
    • Ensure magnet is present only for measurement; detection does not depend on magnet.
    • Check pull-ups: most AS5600 boards have on-board pull-ups. If using a bare sensor, add 4.7 kΩ to 3.3 V on SDA/SCL.
    • Try a slower I2C speed: change Wire.begin third argument to 100000.
  • Motor does not spin when RUN and SP > 0:

  • Check TB6612FNG STBY is driven HIGH (GPIO14).
  • Ensure VM is connected to the motor PSU and is sufficient (check polarity).
  • Confirm AO1/AO2 wired to the motor terminals, not VCC pins.
  • Increase Kp slightly and test again; static friction may require more initial torque.

  • Motor spins only one direction or jerks:

  • Verify AIN1/AIN2 wiring and logic levels.
  • Ensure PWM pin is correctly attached to PWMA and LEDC channel is setup.
  • Remove conflicting uses of those GPIOs.

  • Unstable control or oscillations:

  • Lower Kp by small increments.
  • Decrease Ki to reduce integrator aggressiveness.
  • Add a small derivative on measurement (KD 0.0005–0.002) to dampen.

  • Noisy velocity measurement:

  • Reduce log rate (LOG 20) to focus.
  • Increase LOOP_HZ filter by implementing a low-pass on vel (see Improvements).
  • Ensure mechanical alignment of magnet; off-axis misalignment increases jitter.

  • Brownouts or resets:

  • The motor PSU may inject noise; add a bulk capacitor near TB6612FNG VM (e.g., 220–470 µF).
  • Keep motor and logic grounds well-connected but route high current away from sensor lines.
  • Use twisted pair for motor leads and keep AS5600 wiring short.

  • Upload issues (cannot connect to ESP32):

  • Hold BOOT button while initiating upload (if needed).
  • Select correct upload port in platformio.ini or command line.
  • Install CP210x or CH34x driver as applicable.

Improvements

  • Add velocity low-pass filtering:
  • Implement a first-order filter on vel_rps: vel_filt = alpha*vel + (1-alpha)*vel_filt_prev with alpha chosen based on bandwidth.
  • Add feedforward:
  • Estimate a simple static gain u_ff = kf * sp to reduce steady-state error; PID then corrects small residuals.
  • Rate limit the setpoint:
  • Avoid step changes beyond what the motor can track without saturating: ramp the setpoint (slew-rate limiter).
  • Advanced anti-windup:
  • Use back-calculation: integrator += (Ki * e + Kb * (u_sat - u_unsat)) * Ts.
  • Adaptive gains:
  • Increase Kp/Ki at higher speeds where friction is less dominant; reduce at low speeds.
  • Use a hardware timer or ESP-IDF esp_timer with dedicated I2C transaction scheduling for tighter loop timing.
  • Store tuned parameters in NVS (non-volatile storage) and add commands SAVE/LOAD.
  • Implement a more robust AS5600 read with retries and error counters.
  • Support both channels of TB6612FNG for two-motor control or differential drive.
  • Add a watchdog that disables the motor on sensor failure or communication timeout.

Final Checklist

  • Electrical:
  • ESP32 DevKitC powered via USB; driver installed (CP210x/CH34x as needed).
  • TB6612FNG: VCC=3.3 V, STBY tied to GPIO14 (HIGH), PWMA->GPIO25, AIN1->GPIO26, AIN2->GPIO27, VM to motor PSU, GND common.
  • AS5600: VCC=3.3 V, SDA->GPIO21, SCL->GPIO22, GND common.
  • Bulk capacitor on VM; clean wiring and short I2C lines.

  • Software:

  • PlatformIO environment created with board = esp32dev, platform = espressif32 @ 6.6.0.
  • Built and uploaded with pio run -t upload.
  • Serial monitor opened at 115200 baud.

  • Operation:

  • AS5600 detected at 0x36 on boot.
  • Control loop at 500 Hz; logging at 20–50 Hz as configured.
  • RUN, STOP, SP, KP, KI, KD, LIM, LOG, ? commands working.
  • Motor tracks setpoint in RPM/RPS with stable PID response after tuning.

  • Validation:

  • Step response tested at 60 RPM and −60 RPM; overshoot and settling acceptable.
  • High setpoint saturation behavior verified; anti-windup tuned.
  • Low-speed performance assessed; further improvements planned if needed.

With the ESP32 DevKitC + TB6612FNG + AS5600 integrated as above, you now have a reproducible, commandable, and tunable PID velocity controller for a DC motor that you can extend with filtering, feedforward, and adaptive strategies for production-grade performance.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main goal of the project described in the article?




Question 2: Which microcontroller is used in this project?




Question 3: What type of sensor is the AS5600?




Question 4: What is the required skill level for this project?




Question 5: Which development environment is mentioned for the project?




Question 6: What type of driver is commonly used for the ESP32 DevKitC?




Question 7: What is the voltage supply range for the motor mentioned in the article?




Question 8: What is an optional component suggested for use near the AS5600?




Question 9: What type of motor driver is used in the project?




Question 10: What communication protocol does the AS5600 use?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: Low-power LoRa ESP32 agricultural telemetry

Practical case: Low-power LoRa ESP32 agricultural telemetry — hero

Objective and use case

What you’ll build: This project involves creating a low-power agricultural telemetry node using the TTGO LoRa32 (ESP32 + SX1276) along with BME280 and ADS1115 sensors. The node will monitor environmental conditions and transmit data over LoRaWAN.

Why it matters / Use cases

  • Real-time monitoring of soil moisture levels to optimize irrigation schedules and conserve water resources.
  • Tracking temperature and humidity for better crop management and yield prediction.
  • Utilizing low-power modes to extend the operational lifespan of sensor nodes in remote agricultural fields.
  • Implementing a scalable solution for farmers to monitor multiple fields simultaneously using LoRaWAN.

Expected outcome

  • Data transmission intervals of 15 minutes with a battery life of over 6 months on a single charge.
  • Measurement accuracy of ±1°C for temperature, ±3% for humidity, and ±0.1V for battery voltage.
  • Successful uplink of sensor data with less than 2 seconds latency to The Things Stack.
  • Ability to handle up to 100 packets per hour without data loss.

Audience: Advanced developers and engineers; Level: Advanced

Architecture/flow: The system architecture includes the TTGO LoRa32 microcontroller interfacing with BME280 and ADS1115 sensors via I2C, utilizing deep sleep modes for low power consumption, and sending data through LoRaWAN.

Advanced Hands-on Practical: LoRa Agro Telemetry with Low Power on TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115

Objective: lora-agro-telemetry-lowpower

This advanced, end-to-end project builds a low-power agricultural telemetry node using the EXACT device model “TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115.” It measures environmental conditions (temperature, humidity, pressure) and analog soil moisture/battery voltage, encodes the data as a compact binary payload, and transmits it over LoRaWAN (ABP mode) to The Things Stack (TTN). The firmware emphasizes low power via sensor sleep modes and ESP32 deep sleep.

The workflow uses PlatformIO for reproducibility, pin mapping for the TTGO LoRa32 v1, I2C for BME280/ADS1115, and an event-driven LoRaWAN uplink followed by deep sleep.


Prerequisites

  • Proficiency level: Advanced (you should be comfortable with PlatformIO, embedded C++ on ESP32, and LoRaWAN basics).
  • OS: Windows 10/11, macOS 12+, or Ubuntu 20.04+.
  • PlatformIO Core (CLI) 6.1+ or PlatformIO IDE (VS Code). You will use CLI commands for deterministic builds.
  • USB drivers:
  • If your TTGO LoRa32 enumerates as CP210x (common): install CP210x USB-to-UART bridge drivers.
  • Some clones may use CH340/CH34x: install WCH drivers.
  • The Things Stack (TTN) account with an application and ABP device (DevAddr/NwkSKey/AppSKey).
  • Basic soldering/wiring, multimeter for current measurement, and if possible, a bench supply with uA resolution.
  • Region: This tutorial is set up for EU868. If you need US915 or others, adapt the LMIC region macros as noted.

Materials (with exact model)

  • 1x TTGO LoRa32 (ESP32 + SX1276) board (a.k.a. TTGO LoRa32 v1)
  • 1x BME280 temperature/humidity/pressure module (I2C, 0x76 or 0x77)
  • 1x ADS1115 16-bit ADC module (I2C, default address 0x48)
  • 1x Breadboard or terminal strip
  • Jumper wires (male-female, male-male)
  • Power source and battery (optional but recommended for field testing)
  • Optional: Voltage divider (e.g., R1=100 kΩ, R2=100 kΩ) for battery voltage sensing to ADS1115 A1
  • Soil moisture probe (analog output) connected to ADS1115 A0

Setup/Connection

We will use the ESP32’s default I2C pins (SDA=21, SCL=22). The LoRa radio (SX1276) is onboard and uses SPI with a standard TTGO pin map.

Notes on board:
– The TTGO LoRa32 v1 typically uses CP2102 USB-to-UART.
– SX1276 is onboard; you do not wire it separately.
– Some variants include an OLED; we will ignore it.

Wiring (text description):
– Connect BME280 VCC to 3V3 on the TTGO.
– Connect BME280 GND to GND.
– Connect BME280 SDA to ESP32 GPIO21.
– Connect BME280 SCL to ESP32 GPIO22.
– Connect ADS1115 VDD to 3V3.
– Connect ADS1115 GND to GND.
– Connect ADS1115 SDA to GPIO21 (shared I2C).
– Connect ADS1115 SCL to GPIO22 (shared I2C).
– Connect soil moisture sensor analog output to ADS1115 A0.
– Connect battery divider mid-point to ADS1115 A1. The divider top goes to battery positive (VBAT), bottom to GND. With R1=R2=100k, scale factor is 2. Ensure VBAT never exceeds ADS1115 max input (respect divider values).

Pin and address summary:

Subsystem Function TTGO LoRa32 Pin External Module Pin Notes
I2C Bus SDA GPIO21 SDA Shared by BME280 and ADS1115
I2C Bus SCL GPIO22 SCL Shared by BME280 and ADS1115
BME280 Power 3V3, GND VCC, GND Use 3.3 V BME280 variant
BME280 I2C Address 0x76 (default) or 0x77 (jumper-selectable)
ADS1115 Power 3V3, GND VDD, GND Default I2C addr is 0x48
ADS1115 Channel A0 (Soil) A0 Soil sensor analog output to A0
ADS1115 Channel A1 (VBAT) A1 Battery divider midpoint to A1
LoRa SX1276 NSS (CS) GPIO18 (On-board) SPI CS
LoRa SX1276 RST GPIO14 (On-board) Reset
LoRa SX1276 DIO0 GPIO26 (On-board) IRQ 0
LoRa SX1276 DIO1 GPIO33 (On-board) IRQ 1
LoRa SX1276 DIO2 GPIO32 (On-board) IRQ 2
SPI Bus SCK/MISO/MOSI 5/19/27 (On-board) Mapped by board definition

If your BME280 enumerates at 0x77, we will detect it in software.


Full Code

We’ll build with PlatformIO (Arduino framework) and the MCCI LoRaWAN LMIC library in ABP mode for reliable deep sleep (no rejoin per boot). The code uses forced-mode BME280 sampling and single-shot ADS1115 to minimize energy, then deep sleeps the ESP32 between uplinks.

Place these files in a new PlatformIO project:

1) platformio.ini

; File: platformio.ini
; Reproducible build pinned to specific platforms and libs.

[env:ttgo-lora32-v1]
platform = espressif32 @ 6.5.0
board = ttgo-lora32-v1
framework = arduino
monitor_speed = 115200
upload_speed = 921600

; Ensure we build for EU868 (adapt for your region if needed).
build_flags =
  -DARDUINO_LMIC_PROJECT_CONFIG_H
  -Dcfg_eu868=1
  -DLMIC_DEBUG_LEVEL=1
  -DLMIC_ENABLE_DeviceTimeReq=0
  -DLMIC_PRINTF_TO=Serial
  -DLMIC_USE_INTERRUPTS

lib_deps =
  mcci-catena/MCCI LoRaWAN LMIC library @ 4.1.1
  adafruit/Adafruit BME280 Library @ 2.2.4
  adafruit/Adafruit Unified Sensor @ 1.1.14
  adafruit/Adafruit ADS1X15 @ 2.4.0

2) include/lmic_project_config.h (region config header required by MCCI LMIC)

// File: include/lmic_project_config.h
#pragma once

// Select region - this project uses EU868 by default via build_flags.
// Other options: cfg_us915, cfg_as923, cfg_au915, etc.
// Ensure only one is defined at a time (build_flags sets cfg_eu868=1).

#define DISABLE_PING              1
#define DISABLE_BEACONS           1
#define LMIC_ENABLE_arbitrary_clock_error 1
#define LMIC_ENABLE_DeviceTimeReq 0

3) src/main.cpp

// File: src/main.cpp
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <Adafruit_ADS1X15.h>

#include <lmic.h>
#include <hal/hal.h>

// --------------- User Configuration ---------------

// Deep sleep interval (minutes). Keep TTN duty-cycle and FUP (Fair Use Policy) in mind.
// Example: 15 minutes = 900 seconds
#define SLEEP_MINUTES 15

// Select your EU868 LoRaWAN ABP credentials:
// - DevAddr: 32-bit (big-endian in TTN console, we provide as hex here).
// - NwkSKey: 16-byte key.
// - AppSKey: 16-byte key.
// Replace the 0x.. placeholders with your real ABP keys from The Things Stack.
static const PROGMEM u1_t NWKSKEY[16] = { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 };
static const PROGMEM u1_t APPSKEY[16] = { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 };
static const u4_t DEVADDR = 0x26011111; // Example. Replace with your DevAddr.

// Region: EU868 channel setup for TTN (8 channels).
// If you use another region, adapt channel plan accordingly.

// ADC and BME280 settings
#define I2C_SDA 21
#define I2C_SCL 22

// Battery divider scale (e.g., 100k over 100k => x2)
#define VBAT_DIVIDER 2.0f

// ADS1115 gain: +/- 4.096 V => 1 LSB = 125 uV
// Keep within safe range for your wiring.
#define ADS_GAIN GAIN_ONE

// Payload port
#define LORAWAN_PORT 1

// --------------- Globals ---------------

Adafruit_BME280 bme;
Adafruit_ADS1115 ads;

RTC_DATA_ATTR uint32_t rtc_seqnoUp = 0; // Persist uplink counter across deep sleep (ABP requirement).
RTC_DATA_ATTR uint32_t bootCount = 0;

static osjob_t sendjob;
volatile bool txDone = false;

// TTGO LoRa32 v1 pin mapping
const lmic_pinmap lmic_pins = {
  .nss = 18,
  .rxtx = LMIC_UNUSED_PIN,
  .rst = 14,
  .dio = { 26, 33, 32 },
  .busy = LMIC_UNUSED_PIN,
  .spi = { .mosi = 27, .miso = 19, .sck = 5 }
};

// --------------- Utility: low power preparation ---------------

void prepareSensorsLowPower() {
  // BME280 goes to sleep when sampling mode is FORCED and no measurement is outstanding.
  // For minimum consumption, ensure we are not in NORMAL mode.
  // Adafruit library: setSampling allows forced mode configuration; we do that in setup().
  // ADS1115: in single-shot mode, chip powers down between conversions automatically.
}

void enterDeepSleep(uint32_t minutes) {
  Serial.flush();

  // Optionally reduce power draw by configuring GPIO hold or pulldowns if your board benefits.
  // Disable Bluetooth/WiFi stacks (usually not active in Arduino unless used).
#ifdef CONFIG_BT_ENABLED
  btStop();
#endif

  // Sleep timer
  uint64_t sleep_us = (uint64_t)minutes * 60ULL * 1000000ULL;
  esp_sleep_enable_timer_wakeup(sleep_us);

  // Deep sleep
  Serial.println(F("Entering deep sleep..."));
  delay(50);
  esp_deep_sleep_start();
}

// --------------- Sensor Reading ---------------

struct Telemetry {
  float tempC;
  float humidity;
  float pressure_hPa;
  float soilVolts;
  float vbatVolts;
  bool bmeOk;
  bool adsOk;
};

Telemetry readSensors() {
  Telemetry t{};
  t.bmeOk = bme.begin(0x76) || bme.begin(0x77);
  if (t.bmeOk) {
    // Forced mode: single-shot measurement for minimal power.
    bme.setSampling(Adafruit_BME280::MODE_FORCED,
                    Adafruit_BME280::SAMPLING_X1,  // Temp
                    Adafruit_BME280::SAMPLING_X1,  // Pressure
                    Adafruit_BME280::SAMPLING_X1,  // Humidity
                    Adafruit_BME280::FILTER_OFF,
                    Adafruit_BME280::STANDBY_MS_0_5);
    // Trigger single-shot
    bme.takeForcedMeasurement();
    t.tempC = bme.readTemperature();
    t.humidity = bme.readHumidity();
    t.pressure_hPa = bme.readPressure() / 100.0f;
  } else {
    Serial.println(F("BME280 not found on 0x76/0x77"));
  }

  t.adsOk = ads.begin(0x48);
  if (t.adsOk) {
    ads.setGain(ADS_GAIN);  // +/-4.096 V FS
    // Single-ended readings; library performs single-shot per call.
    int16_t rawSoil = ads.readADC_SingleEnded(0);
    int16_t rawBat  = ads.readADC_SingleEnded(1);
    t.soilVolts = ads.computeVolts(rawSoil);           // Volts at A0
    float vbatDiv = ads.computeVolts(rawBat);          // Volts at A1 (divided)
    t.vbatVolts = vbatDiv * VBAT_DIVIDER;              // Reconstruct battery voltage
  } else {
    Serial.println(F("ADS1115 not found on 0x48"));
  }

  return t;
}

// --------------- Payload Encoding ---------------
// Minimal, compact binary payload (12 bytes):
// [0..1]  int16 temp_c_x100
// [2..3]  uint16 humidity_x2 (0.5% resolution)
// [4..5]  uint16 pressure_hPa_x10
// [6..7]  uint16 soil_mV
// [8..9]  uint16 vbat_mV
// [10]    flags bitfield: b0=bmeOk, b1=adsOk
// [11]    reserved (0)

uint8_t payload[12];

void buildPayload(const Telemetry& t) {
  int16_t temp_x100 = (int16_t)roundf(t.tempC * 100.0f);
  uint16_t hum_x2 = (uint16_t)roundf(t.humidity * 2.0f);  // 0.5% steps
  uint16_t pres_x10 = (uint16_t)roundf(t.pressure_hPa * 10.0f);
  uint16_t soil_mV = (uint16_t)roundf(t.soilVolts * 1000.0f);
  uint16_t vbat_mV = (uint16_t)roundf(t.vbatVolts * 1000.0f);

  payload[0]  = (temp_x100 >> 8) & 0xFF;
  payload[1]  = temp_x100 & 0xFF;
  payload[2]  = (hum_x2 >> 8) & 0xFF;
  payload[3]  = hum_x2 & 0xFF;
  payload[4]  = (pres_x10 >> 8) & 0xFF;
  payload[5]  = pres_x10 & 0xFF;
  payload[6]  = (soil_mV >> 8) & 0xFF;
  payload[7]  = soil_mV & 0xFF;
  payload[8]  = (vbat_mV >> 8) & 0xFF;
  payload[9]  = vbat_mV & 0xFF;
  uint8_t flags = 0;
  if (t.bmeOk) flags |= 0x01;
  if (t.adsOk) flags |= 0x02;
  payload[10] = flags;
  payload[11] = 0x00;
}

// --------------- LMIC Event Handling ---------------

void onEvent(ev_t ev) {
  switch (ev) {
    case EV_TXCOMPLETE:
      Serial.println(F("EV_TXCOMPLETE (RX windows closed)"));
      // Persist the current uplink counter.
      rtc_seqnoUp = LMIC.seqnoUp;
      txDone = true;
      break;
    default:
      // Optional: print additional events during development
      break;
  }
}

void do_send(osjob_t* j) {
  if (LMIC.opmode & OP_TXRXPEND) {
    Serial.println(F("OP_TXRXPEND, not sending"));
    return;
  }
  // Build and queue the uplink
  Telemetry t = readSensors();
  buildPayload(t);
  Serial.print(F("Uplink bytes: "));
  for (size_t i = 0; i < sizeof(payload); i++) {
    if (payload[i] < 16) Serial.print('0');
    Serial.print(payload[i], HEX);
    Serial.print(' ');
  }
  Serial.println();

  // Unconfirmed uplink on LORAWAN_PORT
  LMIC_setTxData2(LORAWAN_PORT, payload, sizeof(payload), 0);
  Serial.println(F("Packet queued"));
}

void setupLoraABP() {
  // Reset state
  os_init();
  LMIC_reset();

  // Set static session parameters.
  // Note: LMIC expects keys in big-endian.
  LMIC_setSession (0x1, DEVADDR, (xref2u1_t)NWKSKEY, (xref2u1_t)APPSKEY);

#if defined(CFG_eu868)
  // Disable default channels, then use TTN EU868 recommended channels
  LMIC_disableChannel(0);
  LMIC_disableChannel(1);
  LMIC_disableChannel(2);
  // Add TTN EU868 channels
  LMIC_setupChannel(0, 868100000, DR_RANGE_MAP(DR_SF12, DR_SF7),  BAND_CENTI);
  LMIC_setupChannel(1, 868300000, DR_RANGE_MAP(DR_SF12, DR_SF7),  BAND_CENTI);
  LMIC_setupChannel(2, 868500000, DR_RANGE_MAP(DR_SF12, DR_SF7),  BAND_CENTI);
  LMIC_setupChannel(3, 867100000, DR_RANGE_MAP(DR_SF12, DR_SF7),  BAND_CENTI);
  LMIC_setupChannel(4, 867300000, DR_RANGE_MAP(DR_SF12, DR_SF7),  BAND_CENTI);
  LMIC_setupChannel(5, 867500000, DR_RANGE_MAP(DR_SF12, DR_SF7),  BAND_CENTI);
  LMIC_setupChannel(6, 867700000, DR_RANGE_MAP(DR_SF12, DR_SF7),  BAND_CENTI);
  LMIC_setupChannel(7, 867900000, DR_RANGE_MAP(DR_SF12, DR_SF7),  BAND_CENTI);
  // Set Rx2 frequency (TTN default)
  LMIC.dn2Dr = DR_SF9;
#endif

  // Data rate and TX power
  LMIC_setDrTxpow(DR_SF7, 14);
  // Enable ADR in field deployments (optional)
  LMIC_setAdrMode(1);

  // Link check off to save airtime
  LMIC_setLinkCheckMode(0);

  // Restore uplink counter from RTC to keep ABP counters in sync with TTN
  if (rtc_seqnoUp > 0) {
    LMIC.seqnoUp = rtc_seqnoUp;
    Serial.printf("Restored seqnoUp from RTC: %u\n", rtc_seqnoUp);
  } else {
    Serial.println(F("First boot: seqnoUp starts at 0"));
  }
}

void setup() {
  Serial.begin(115200);
  delay(100);
  ++bootCount;
  Serial.printf("\nBoot #%u - LoRa Agro Telemetry Low Power (ABP)\n", bootCount);

  // Initialize I2C
  Wire.begin(I2C_SDA, I2C_SCL);

  // Pre-config sensor modes for low power (BME forced, ADS single-shot via reads)
  // No extensive setup needed; handled in readSensors().

  // LoRa init
  setupLoraABP();

  // Queue first send
  do_send(&sendjob);
}

void loop() {
  // Run LMIC tasks
  os_runloop_once();

  // After TX completes, enter deep sleep
  if (txDone) {
    prepareSensorsLowPower(); // sensors already in low-power friendly modes
    // Update RTC seqno and sleep
    Serial.printf("Next wake in %d minutes. Current seqnoUp=%u\n", SLEEP_MINUTES, LMIC.seqnoUp);
    // Small delay to flush serial
    delay(50);
    enterDeepSleep(SLEEP_MINUTES);
  }
}

Payload decoding hint (for TTN uplink formatter):
– Temperature = int16(payload[0..1]) / 100.0 (°C)
– Humidity = uint16(payload[2..3]) / 2.0 (%RH)
– Pressure = uint16(payload[4..5]) / 10.0 (hPa)
– Soil voltage = uint16(payload[6..7]) mV / 1000.0 (V)
– Battery voltage = uint16(payload[8..9]) mV / 1000.0 (V)
– Flags: bit0 = BME ok, bit1 = ADS ok

Optional TTN JavaScript/JS decoder (The Things Stack v3 Custom Javascript Formatter):

function decodeUplink(input) {
  const b = input.bytes;
  if (!b || b.length < 12) {
    return { errors: ["payload too short"] };
  }
  const temp = ((b[0] << 8) | b[1]);
  const tempC = (temp >= 0x8000 ? temp - 0x10000 : temp) / 100.0;
  const hum = ((b[2] << 8) | b[3]) / 2.0;
  const pres = ((b[4] << 8) | b[5]) / 10.0;
  const soil = ((b[6] << 8) | b[7]) / 1000.0;
  const vbat = ((b[8] << 8) | b[9]) / 1000.0;
  const flags = b[10];
  return {
    data: {
      tempC, humidity: hum, pressure_hPa: pres,
      soilV: soil, vbatV: vbat,
      bmeOk: !!(flags & 0x01),
      adsOk: !!(flags & 0x02)
    }
  };
}

Build/Flash/Run commands

Create the project:

mkdir -p ~/projects/lora-agro-telemetry-lowpower
cd ~/projects/lora-agro-telemetry-lowpower

# 2) Initialize for TTGO LoRa32 v1
pio project init -b ttgo-lora32-v1

# 3) Create folders for includes and source
mkdir -p include src

# 4) Place platformio.ini, include/lmic_project_config.h, and src/main.cpp as shown above
# (Use your editor or copy files into these paths accordingly.)

# 5) Build
pio run

# 6) Upload (board in bootloader mode typically auto-handled; if not, hold BOOT and press EN)
pio run -t upload

# 7) Monitor serial at 115200 baud
pio device monitor -b 115200

Driver notes:
– Windows: Install CP210x drivers if serial device isn’t recognized.
– macOS: Grant terminal access to USB serial and install CP210x package if necessary.
– Linux: Add your user to dialout group and re-login: sudo usermod -a -G dialout $USER


Step-by-step Validation

1) TTN application/device (ABP):
– In The Things Stack console, create an Application (e.g., app-id: agro-telemetry).
– Add an end device with Activation: ABP. Select Frequency plan: Europe 863-870 MHz (TTN) for EU868.
– Record DevAddr, NwkSKey, AppSKey (MSB/hex). Insert them into src/main.cpp in the placeholders.
– Ensure “Frame counter width” and “Frame counter check” are configured sensibly. For production, keep frame counter checks enabled. Our code persists the uplink counter in RTC memory to maintain continuity across deep sleep. Power-cycling will reset the counter, so avoid it between field runs, or resynchronize counters in the console.

2) Wiring sanity check:
– Confirm the I2C devices appear on address scan (optional): you can run a quick I2C scanner sketch to verify 0x76/0x77 and 0x48 appear.
– Ensure soil sensor output is safe (0–3.3 V) for ADS1115 input; if higher, adjust gain/divider.

3) Flash and monitor:
– Build and upload using the commands above.
– Open serial monitor at 115200. First boot prints:
– “First boot: seqnoUp starts at 0” or restored seqnoUp if not first.
– “Uplink bytes: …” with hex dump.
– “Packet queued”
– “EV_TXCOMPLETE …”
– Then “Entering deep sleep…”
– On subsequent wakeups (every SLEEP_MINUTES), you’ll see seqnoUp increasing.

4) TTN data:
– In the TTN application console, open your device live data.
– You should see uplinks at your schedule interval (respect duty cycle and gateway coverage).
– If using the provided decoder script, you will see fields:
– tempC, humidity, pressure_hPa, soilV, vbatV, bmeOk, adsOk.

5) Verify measurements:
– Touch the BME280 to vary temperature slightly and confirm the tempC reading changes.
– Place the soil probe in water vs. air and confirm soilV increases accordingly.
– If a battery is connected via divider to ADS1115 A1, confirm vbatV matches a multimeter measurement within expected tolerance.

6) Validate low power:
– Insert a DMM in series with board supply. After EV_TXCOMPLETE and a few tens of milliseconds, current should drop as ESP32 enters deep sleep.
– Typical values (board-dependent):
– Active TX: tens of mA peak (plus LoRa TX power spike).
– Deep sleep: ~100–300 µA for many TTGO LoRa32 v1 boards (limited by LDO, LoRa, and any pull-ups). Your result may vary.
– If deep sleep is higher than ~1 mA, review Troubleshooting (GPIO states, sensor power rails, pull-ups).

7) Airtime/duty-cycle check (EU868):
– At SF7BW125 with small payload, the airtime is modest; every 15 minutes is within duty-cycle norms. Do not decrease intervals without calculating duty-cycle compliance. Use tools like LoRa airtime calculators.


Troubleshooting

  • No serial port appears:
  • Install CP210x or CH34x driver as appropriate.
  • Try a different cable/USB port. On Linux, ensure you’re in the dialout group.

  • Upload fails or stalls:

  • Hold BOOT (IO0) while tapping EN (RST) to force bootloader mode, then “pio run -t upload”.
  • Reduce upload speed in platformio.ini if necessary (e.g., upload_speed = 460800).

  • No uplinks in TTN console:

  • Confirm frequency plan (EU868) matches your location and gateways nearby.
  • Verify ABP keys and DevAddr are correct and in MSB order. In MCCI LMIC, keys are big-endian hex arrays as provided by TTN (MSB).
  • Ensure channel plan matches TTN EU868. In other regions (e.g., US915), you must change build flags to -Dcfg_us915=1 and configure subband (LMIC_selectSubBand()).
  • Antenna: make sure a proper antenna is attached to the TTGO board.

  • “BME280 not found” or “ADS1115 not found”:

  • Check 3V3 and GND continuity.
  • Ensure SDA=21, SCL=22; avoid mixing with OLED pins if your board has a display.
  • Scan I2C addresses to confirm presence. If BME280 is at 0x77, the code already tries 0x76 then 0x77.

  • Payload looks wrong:

  • Double-check the TTN decoder math and byte order.
  • Confirm units: tempC100, humidity2, pressure*10, mV for soil and vbat.

  • Battery reading off by factor:

  • Verify divider ratio. If not 100k/100k, update VBAT_DIVIDER accordingly.
  • Ensure ADS1115 gain (GAIN_ONE for ±4.096 V) doesn’t saturate. If using higher input voltages on divided input, verify FS range.

  • Deep sleep current too high:

  • Some TTGO LoRa32 variants have higher quiescent currents due to LDOs and the radio not fully powered down. LMIC should leave SX1276 idle after TX, but its standby current adds overhead.
  • Ensure no external pull-up rails keep sensors powered unintentionally.
  • Consider powering sensors via a switched rail or Vext (if your board revision supports it) controlled by a GPIO, disabling it before sleep.
  • Use RTC GPIO holds on lines that would otherwise float.

  • Frame counter errors (ABP):

  • Our code persists seqnoUp across deep sleep in RTC memory. Power cycles clear RTC memory, breaking continuity.
  • If you power-cycle, either reset frame counters in TTN (not recommended in production) or persist seqnoUp in NVS/flash for full robustness.

  • Duty cycle non-compliance:

  • Do not reduce SLEEP_MINUTES without calculating airtime and duty cycle. For EU868, stay within 1% or the specific sub-band limits.

Improvements

  • OTAA with session persistence:
  • Switch to OTAA (more secure) and store LMIC session keys, DevNonce, and counters in NVS or RTC memory. On wake, restore the session to avoid rejoins each cycle.

  • Confirmed uplinks with retry backoff:

  • Use confirmed messages occasionally to verify link quality. Balance energy cost and airtime fairness.

  • Adaptive sampling intervals:

  • Increase reporting during rapid environmental change; otherwise, sleep longer to save battery.

  • Battery-backed RTC and time-based features:

  • Use DeviceTimeReq (if enabled) or external RTC for timestamping. Not enabled in this minimal low-power code.

  • Sensor power gating:

  • If your board variant has Vext or you can add a P-MOSFET high-side switch, fully power down BME280/ADS1115 between samples to reduce quiescent draw to microamps.

  • Payload compression and delta encoding:

  • Reduce airtime by sending deltas or using a compact scheme like Cayenne LPP (already compact) or custom 10-byte layouts.

  • Region portability:

  • For US915/AS923, use appropriate LMIC region macros and channel/sub-band setups. For US915, call LMIC_selectSubBand(1) or your specific subband.

  • Field robustness:

  • Add brown-out safe guards, watchdog timer, and fallback intervals in case of repeated TX failures.
  • Debounce moisture sensor inputs and calibrate curves to translate volts to volumetric water content (VWC).

  • Security:

  • Keep keys in a separate header excluded from version control, and consider hardware secure elements for key storage.

Final Checklist

  • Materials:
  • TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115 wired over I2C (SDA=21, SCL=22), soil sensor to ADS1115 A0, battery divider to A1.

  • Software:

  • PlatformIO installed; project initialized with board = ttgo-lora32-v1.
  • platform = espressif32 @ 6.5.0, Arduino framework.
  • Libraries pinned: MCCI LMIC 4.1.1, Adafruit BME280 2.2.4, Adafruit ADS1X15 2.4.0.

  • Credentials:

  • ABP DevAddr/NwkSKey/AppSKey copied from TTN into main.cpp in MSB order.

  • Region:

  • EU868 macros set via build_flags; TTN EU868 channels configured in code.

  • Build/Flash:

  • pio run and pio run -t upload succeed; serial monitor at 115200 shows EV_TXCOMPLETE before sleep.

  • Payload:

  • TTN live data shows uplinks. Custom JS decoder applied; fields display correctly.

  • Power:

  • Deep sleep current measured and within acceptable range for your board (typically 100–300 µA, variant-dependent).
  • Sleep interval SLEEP_MINUTES respects regional duty cycle.

  • Robustness:

  • ABP frame counters persist across deep sleep (RTC). Avoid power-cycling, or implement NVS persistence.

With this setup, you have a reproducible, low-power LoRa agro telemetry node on the TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115 platform, suitable for field deployment and further optimization.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the primary objective of the project?




Question 2: Which device model is used in this project?




Question 3: What environmental conditions are measured by the telemetry node?




Question 4: What firmware feature emphasizes low power?




Question 5: Which programming environment is recommended for this project?




Question 6: What type of connection is used for the BME280 and ADS1115 modules?




Question 7: What is required to install for the TTGO LoRa32 if it enumerates as CP210x?




Question 8: What is the minimum proficiency level required for this project?




Question 9: Which region is the tutorial set up for?




Question 10: What type of measurement tool is suggested for current measurement?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: Beamforming & DoA on ESP32-S3, I2S INMP441

Practical case: Beamforming & DoA on ESP32-S3, I2S INMP441 — hero

Objective and use case

What you’ll build: An advanced acoustic direction-finding system using the ESP32-S3-DevKitC-1 and two INMP441 I2S microphones to capture stereo audio and estimate the direction of sound sources.

Why it matters / Use cases

  • Enhancing audio capture in smart home devices to accurately detect and respond to user commands based on their location.
  • Improving surveillance systems by identifying the direction of sounds, aiding in situational awareness.
  • Facilitating advanced robotics applications, allowing robots to navigate and interact based on auditory cues from their environment.
  • Supporting research in acoustics and audio signal processing by providing a practical platform for experimentation.

Expected outcome

  • Accurate estimation of sound source direction with a reported accuracy of ±5 degrees.
  • Real-time audio processing with latencies under 50 ms for immediate feedback in applications.
  • Successful detection of sound sources at distances up to 10 meters with consistent performance.
  • Data throughput of 48 kHz stereo audio captured over I2S, ensuring high fidelity in sound reproduction.

Audience: Developers and engineers interested in audio processing; Level: Intermediate to advanced.

Architecture/flow: The system utilizes I2S for audio capture, with synchronized sampling and processing to compute TDOA for direction estimation.

ESP32-S3-DevKitC-1 + 2x INMP441 I2S Mics: Beamforming Direction Finding (Advanced)

This hands-on case guides you through building a two-microphone acoustic direction-finding (DOA) system using the ESP32-S3-DevKitC-1 and two INMP441 I2S microphones. We’ll acquire synchronized stereo audio over I2S, estimate time-difference-of-arrival (TDOA) with normalized cross-correlation, and compute the azimuth angle of the sound source relative to the two-mic baseline. The workflow emphasizes reproducibility with PlatformIO and the ESP-IDF framework.

The central idea:
– Strap one INMP441 as “Left” and the other as “Right,” feed both with the same I2S BCLK and WS, and share their SD line (they tri-state when not in their assigned slot). This produces true stereo capture on a single I2S peripheral.
– Compute the relative delay between left and right signals. For a microphone spacing d, sampling rate fs, and speed of sound c, the DOA angle is theta = asin(c·tau/d).
– Validate with simple clap/noise tests at known angles and distances.


Prerequisites

  • Comfortable with ESP-IDF concepts (tasks, logging, peripherals) and C programming.
  • Familiarity with I2S concepts: bit clock (BCLK), word select (WS/LRCLK), data (SD), slot width, and Philips I2S standard.
  • Basic DSP: filtering, windowing, correlation, and time-delay estimation.

Software on your development PC:
– Python 3.8+ and pip
– PlatformIO Core 6.1.13 (pinned to a concrete version for reproducibility)
– A serial terminal (PlatformIO monitor is fine)

Operating system notes:
– Windows 10/11, macOS 12+, or Linux (Ubuntu 22.04 tested)
– USB drivers:
– Many ESP32-S3-DevKitC-1 boards use native USB-Serial/JTAG (no external bridge driver needed).
– If your DevKitC variant has a CP210x or CH34x USB-to-UART bridge, install the appropriate driver:
– CP210x: https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers
– CH34x: https://www.wch-ic.com/downloads/CH341SER_EXE.html


Materials (with exact model)

  • 1x ESP32-S3-DevKitC-1 (ESP32-S3-WROOM-1). Example variants: ESP32-S3-DevKitC-1-N8 or N8R2. Any flash/PSRAM size is fine for this project.
  • 2x INMP441 omnidirectional digital I2S microphone modules (pins typically: VDD, GND, SCK/BCLK, WS/LRCLK, SD/DOUT, L/R).
  • Breadboard and male-male Dupont wires (20+ pieces recommended).
  • 1x USB-C cable (data-capable).

Setup/Connection

We capture two channels (Left/Right) using a single I2S peripheral in Philips standard mode. Both microphones share BCLK and WS. Their SD outputs are tied together and wired to a single ESP32-S3 I2S DIN pin. You must strap one mic as Left and the other as Right using the L/R pin on the INMP441 board.

Important physical guidance:
– Mount the two mics along a straight line with known center-to-center spacing d (e.g., 6.5–8.0 cm). The line defines the system’s local x-axis for azimuth. The “Left” mic should be the negative x end, “Right” mic the positive x end, relative to the board’s orientation for easier interpretation.

Recommended GPIO mapping (ESP32-S3-DevKitC-1):

  • I2S BCLK: GPIO 7
  • I2S WS (LRCLK): GPIO 15
  • I2S DIN (from mic SD): GPIO 16
  • MCLK not used (INMP441 does not need MCLK)

Electrical constraints:
– INMP441 VDD: 3.3 V only (do not use 5 V)
– GND common between all units

Mic strapping:
– Mic A (Left channel): L/R pin → GND
– Mic B (Right channel): L/R pin → 3.3 V

Both mics connect to the same I2S bus:
– Both BCLK pins → ESP32-S3 GPIO 7
– Both WS pins → ESP32-S3 GPIO 15
– Both SD pins tied together → ESP32-S3 GPIO 16
– VDD → 3.3 V on DevKitC-1; GND → GND

The table below enumerates the wiring.

Function ESP32-S3-DevKitC-1 Pin Mic A (Left) Mic B (Right) Notes
3.3 V 3V3 VDD VDD Do not use 5 V on INMP441
GND GND GND GND Common ground
I2S BCLK GPIO 7 SCK/BCLK SCK/BCLK Shared
I2S WS/LRCLK GPIO 15 WS/LRCLK WS/LRCLK Shared
I2S DIN (to ESP) GPIO 16 SD/DOUT SD/DOUT Tie both SD lines together
Channel strap L/R → GND L/R → 3.3 V Sets Left/Right slot

Practical notes:
– Keep the SD wire short and tidy to avoid contention or coupling noise.
– If you hear clipping or see saturating values, reduce environmental noise or add soft padding in software.
– Orient the mics away from the ESP32’s switching noise sources (DC regulators).


Full Code

This ESP-IDF-based code configures I2S for stereo capture at 48 kHz (32-bit slots, 24-bit data left-justified), reads frames, deinterleaves Left/Right data, applies simple DC removal, estimates TDOA via normalized cross-correlation over a small lag window (bounded by geometry and sampling rate), and computes the DOA angle. It prints the angle (degrees) and correlation peak.

Notes:
– Beamforming method: TDOA with normalized cross-correlation (NCC). With two microphones and small array spacing, this is a practical and robust estimator. You can swap in a GCC-PHAT FFT method later for more reverberant environments.
– Performance: With a max lag ±12 and frame length 1024, computation is modest and real-time on S3.

Place this file at src/main.c.

// src/main.c
#include <stdio.h>
#include <string.h>
#include <math.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "driver/i2s_std.h"
#include "esp_check.h"

static const char *TAG = "i2s_doa";

// ====== User configuration ======
#define I2S_SAMPLE_RATE       (48000)
#define I2S_BITS_PER_SAMPLE   (32)     // INMP441 outputs 24-bit MSB aligned in 32-bit slots
#define I2S_SLOT_MODE         I2S_SLOT_MODE_STEREO
#define I2S_DMA_DESC          (8)
#define I2S_DMA_FRAME_NUM     (256)

#define GPIO_I2S_BCLK         (7)
#define GPIO_I2S_WS           (15)
#define GPIO_I2S_SD           (16)

#define FRAME_LEN             (1024)   // Stereo frames per processing block
#define MIC_BASELINE_M        (0.075f) // 7.5 cm mic spacing; adjust to your build
#define SPEED_OF_SOUND        (343.0f) // m/s at ~20°C
#define MAX_ABS_ANGLE_DEG     (90.0f)

#define ENABLE_ANGLE_SMOOTHING 1
#define ANGLE_ALPHA            (0.85f) // 0..1; higher = more smoothing

// If channels are reversed (e.g., wiring or LR strap mismatch), set this to 1 to swap in software.
#define SWAP_CHANNELS         0

// ====== Derived parameters ======
static inline int calc_max_lag_samples(void) {
    // Maximum possible sample delay from geometry: ceil(d * fs / c)
    float max_delay = MIC_BASELINE_M * ((float)I2S_SAMPLE_RATE) / SPEED_OF_SOUND;
    int ml = (int)ceilf(max_delay);
    // Add a safety margin of one sample
    return ml + 1;
}

// ====== Simple DC removal (1st-order high-pass) ======
typedef struct {
    float x1;
    float y1;
    float a;
} hp_state_t;

static void hp_init(hp_state_t *st, float fc) {
    // a = exp(-2*pi*fc/fs)
    float a = expf(-2.0f * (float)M_PI * fc / (float)I2S_SAMPLE_RATE);
    st->x1 = 0.0f;
    st->y1 = 0.0f;
    st->a  = a;
}

static inline float hp_process(hp_state_t *st, float x) {
    float y = st->a * (st->y1 + x - st->x1);
    st->x1 = x;
    st->y1 = y;
    return y;
}

// ====== NCC-based TDOA estimation over limited lags ======
static float ncc_tdoa(const float *x, const float *y, int N, int maxLag, int *bestLag, float *peakCorr) {
    // Compute per-lag normalized cross-correlation r(l) for l in [-maxLag..+maxLag]
    float bestR = -2.0f;
    int bestL = 0;

    for (int lag = -maxLag; lag <= maxLag; ++lag) {
        int startX = 0;
        int startY = 0;
        int count = N;

        if (lag > 0) {
            startY = lag;
            count = N - lag;
        } else if (lag < 0) {
            startX = -lag;
            count = N + lag; // since lag is negative
        }
        if (count <= 16) continue; // ignore tiny overlaps

        double num = 0.0;
        double ex = 0.0, ey = 0.0;
        int end = startX + count;
        int ix = startX;
        int iy = startY;

        for (; ix < end; ++ix, ++iy) {
            float xx = x[ix];
            float yy = y[iy];
            num += (double)xx * (double)yy;
            ex  += (double)xx * (double)xx;
            ey  += (double)yy * (double)yy;
        }

        double denom = sqrt(ex * ey) + 1e-12;
        float r = (float)(num / denom);

        if (r > bestR) {
            bestR = r;
            bestL = lag;
        }
    }

    *bestLag = bestL;
    *peakCorr = bestR;
    // Return TDOA in seconds
    return ((float)bestL) / (float)I2S_SAMPLE_RATE;
}

// ====== Angle computation ======
static float tdoa_to_angle_deg(float tdoa_s) {
    float s = (SPEED_OF_SOUND * tdoa_s) / MIC_BASELINE_M; // argument to asin
    if (s > 1.0f)  s = 1.0f;
    if (s < -1.0f) s = -1.0f;
    float theta = asinf(s) * (180.0f / (float)M_PI);
    // Clamp to [-90, 90] just in case
    if (theta >  MAX_ABS_ANGLE_DEG) theta =  MAX_ABS_ANGLE_DEG;
    if (theta < -MAX_ABS_ANGLE_DEG) theta = -MAX_ABS_ANGLE_DEG;
    return theta;
}

static void print_banner(void) {
    printf("\n=== ESP32-S3 I2S 2-Mic DOA ===\n");
    printf("Sample rate: %d Hz, Frame: %d, Mic spacing: %.3f m\n", I2S_SAMPLE_RATE, FRAME_LEN, MIC_BASELINE_M);
    printf("Max lag (samples): %d\n\n", calc_max_lag_samples());
}

void app_main(void) {
    esp_err_t err;

    print_banner();

    // 1) Create I2S RX channel
    i2s_chan_handle_t rx_chan;
    i2s_chan_config_t chan_cfg = {
        .id = I2S_NUM_0,
        .role = I2S_ROLE_MASTER,
        .dma_desc_num = I2S_DMA_DESC,
        .dma_frame_num = I2S_DMA_FRAME_NUM,
        .auto_clear = true,
        .intr_priority = 0
    };
    ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &rx_chan));

    // 2) Configure standard Philips I2S mode, stereo, 32-bit slots
    i2s_std_config_t std_cfg = {
        .clk_cfg  = I2S_STD_CLK_DEFAULT_CONFIG(I2S_SAMPLE_RATE),
        .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_BITS_PER_SAMPLE, I2S_SLOT_MODE),
        .gpio_cfg = {
            .mclk = I2S_GPIO_UNUSED,
            .bclk = GPIO_I2S_BCLK,
            .ws   = GPIO_I2S_WS,
            .dout = I2S_GPIO_UNUSED,
            .din  = GPIO_I2S_SD,
            .invert_flags = {
                .mclk_inv = false,
                .bclk_inv = false,
                .ws_inv   = false
            }
        }
    };

    // Ensure both slots captured
    std_cfg.slot_cfg.slot_mask = I2S_STD_SLOT_LEFT | I2S_STD_SLOT_RIGHT;

    ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_chan, &std_cfg));
    ESP_ERROR_CHECK(i2s_channel_enable(rx_chan));
    ESP_LOGI(TAG, "I2S RX channel enabled");

    // 3) Allocate buffers
    const size_t bytes_per_frame = (I2S_BITS_PER_SAMPLE / 8) * 2; // stereo
    const size_t buf_bytes = FRAME_LEN * bytes_per_frame;

    uint8_t *i2s_raw = (uint8_t *)heap_caps_malloc(buf_bytes, MALLOC_CAP_DEFAULT);
    float   *left    = (float *)heap_caps_malloc(FRAME_LEN * sizeof(float), MALLOC_CAP_DEFAULT);
    float   *right   = (float *)heap_caps_malloc(FRAME_LEN * sizeof(float), MALLOC_CAP_DEFAULT);
    if (!i2s_raw || !left || !right) {
        ESP_LOGE(TAG, "Buffer allocation failed");
        vTaskDelay(pdMS_TO_TICKS(1000));
        esp_restart();
        return;
    }

    // 4) Initialize pre-processing
    hp_state_t hpL, hpR;
    hp_init(&hpL, 20.0f); // 20 Hz, gentle DC removal
    hp_init(&hpR, 20.0f);

    const int maxLag = calc_max_lag_samples();
    float angle_deg_smoothed = 0.0f;
    bool first_angle = true;

    // 5) Main loop
    while (1) {
        size_t got = 0;
        err = i2s_channel_read(rx_chan, i2s_raw, buf_bytes, &got, portMAX_DELAY);
        if (err != ESP_OK || got != buf_bytes) {
            ESP_LOGW(TAG, "i2s read: err=%d, got=%u/%u bytes", err, (unsigned)got, (unsigned)buf_bytes);
            continue;
        }

        // Deinterleave: data is interleaved 32-bit L, 32-bit R in Philips I2S
        const int32_t *p = (const int32_t *)i2s_raw;
        for (int n = 0; n < FRAME_LEN; ++n) {
            int32_t sampleL = p[2*n + 0];
            int32_t sampleR = p[2*n + 1];

#if SWAP_CHANNELS
            int32_t tmp = sampleL;
            sampleL = sampleR;
            sampleR = tmp;
#endif
            // INMP441 is 24-bit in 32-bit container; right-shift to normalize
            sampleL >>= 8;
            sampleR >>= 8;

            // Scale to [-1, 1] with 24-bit full-scale = 8388608
            float fl = (float)sampleL / 8388608.0f;
            float fr = (float)sampleR / 8388608.0f;

            // Simple DC removal
            left[n]  = hp_process(&hpL, fl);
            right[n] = hp_process(&hpR, fr);
        }

        // Optionally, could add band-pass filtering here (1–4 kHz) to improve robustness

        // Compute TDOA via NCC across small lag window
        int bestLag = 0;
        float peakR = 0.0f;
        float tdoa_s = ncc_tdoa(left, right, FRAME_LEN, maxLag, &bestLag, &peakR);
        float angle_deg = tdoa_to_angle_deg(tdoa_s);

#if ENABLE_ANGLE_SMOOTHING
        if (first_angle) {
            angle_deg_smoothed = angle_deg;
            first_angle = false;
        } else {
            angle_deg_smoothed = ANGLE_ALPHA * angle_deg_smoothed + (1.0f - ANGLE_ALPHA) * angle_deg;
        }
        float out_angle = angle_deg_smoothed;
#else
        float out_angle = angle_deg;
#endif

        // Diagnostics: RMS levels and correlation peak
        double rmsL = 0.0, rmsR = 0.0;
        for (int i = 0; i < FRAME_LEN; ++i) {
            rmsL += (double)left[i] * (double)left[i];
            rmsR += (double)right[i] * (double)right[i];
        }
        rmsL = sqrt(rmsL / FRAME_LEN);
        rmsR = sqrt(rmsR / FRAME_LEN);

        // Print one line per frame
        printf("lag=%+3d samp  tdoa=%.5f ms  angle=%.1f deg  peak=%.3f  rmsL=%.3f  rmsR=%.3f\n",
               bestLag,
               1e3f * tdoa_s,
               out_angle,
               peakR,
               (float)rmsL,
               (float)rmsR);
        // ~20–30 lines/s depending on loop. Add a tiny delay if needed
        // vTaskDelay(pdMS_TO_TICKS(1));
    }
}

And the PlatformIO project configuration at the project root:

; platformio.ini
[env:esp32-s3-devkitc-1]
platform = espressif32@6.5.0
board = esp32-s3-devkitc-1
framework = espidf

; Serial monitor and build options
monitor_speed = 115200
monitor_filters = direct, time

; Ensure CDC over USB is recognized reliably
board_build.flash_mode = dio

; Optional: reduce log chatter
build_flags =
  -D CONFIG_LOG_DEFAULT_LEVEL=3

; If you need to pin exact ESP-IDF version (optional, advanced)
; platform_packages =
;   framework-espidf@~5.1.2

This configuration targets PlatformIO’s espressif32 6.5.0 (consistent, widely available) and uses ESP-IDF (not Arduino). The default USB-CDC interface on the ESP32-S3-DevKitC-1 will present a serial port to your OS for flashing and monitoring.


Build/Flash/Run Commands

Install PlatformIO Core 6.1.13 and verify versions:

python3 -m pip install --upgrade pip
python3 -m pip install "platformio==6.1.13"
pio --version

Create and initialize the project:

mkdir -p ~/esp/esp32s3-i2s-beamform
cd ~/esp/esp32s3-i2s-beamform
pio project init --board esp32-s3-devkitc-1 --project-option "framework=espidf"

Place the files as follows:
– platformio.ini at ~/esp/esp32s3-i2s-beamform/platformio.ini
– src/main.c at ~/esp/esp32s3-i2s-beamform/src/main.c

Build, flash, and monitor:

pio run

# Flash (auto-detects the ESP32-S3 USB-CDC port)
pio run -t upload

# If needed, explicitly specify port, e.g., Linux /dev/ttyACM0, Windows COM6, macOS /dev/cu.usbmodemXYZ
# pio run -t upload --upload-port /dev/ttyACM0

# Serial monitor
pio device monitor

If you’re on Linux and don’t see the device, add your user to the dialout group and re-login:

sudo usermod -a -G dialout $USER
# Log out/in or reboot

Step-by-step Validation

1) Power-on and serial check:
– Plug the board via USB-C. The serial device should appear (Windows: COMx; Linux: /dev/ttyACM0; macOS: /dev/cu.usbmodem…).
– Run pio device monitor and watch the banner:
– “Sample rate: 48000 Hz, Frame: 1024, Mic spacing: 0.075 m”
– “Max lag (samples): ~12”

2) Basic I2S capture sanity:
– With a quiet room, the printed rmsL and rmsR should be small (e.g., 0.005–0.030).
– Clap once near the Left mic, then near the Right mic:
– When clapping near Left, expect negative bestLag (Left leads Right) and angle around -60 to -90 deg depending on position.
– When clapping near Right, expect positive bestLag and angle +60 to +90 deg.

3) Confirm Left/Right mapping:
– Cover the Left mic gently with a finger and clap near it. rmsL should drop significantly while rmsR remains.
– If the channel attribution seems inverted (positive angle when clapping left), set SWAP_CHANNELS = 1 in src/main.c, rebuild, and retry.

4) Static angle measurements:
– Arrange the two mics along the x-axis and mark:
– -90°: directly at Left side perpendicular to array
– 0°: on the broadside (perpendicular to the baseline, centered)
– +90°: directly at Right side perpendicular to array
– Place a tone/noise source ~50 cm away:
– At 0°, average out_angle should hover near 0°.
– At -60°, expect a negative angle around that value.
– At +60°, expect a positive angle around that value.
– Record 10–20 readings and compute the mean and standard deviation to assess stability.

5) Range and lag sanity check:
– With mic spacing d = 0.075 m and fs = 48 kHz, the maximum physical lag is:
– maxLag ≈ ceil(d·fs/c) ≈ ceil(0.075·48000/343) ≈ 11 samples
– If peak lags consistently saturate at ±maxLag, either the source is near endfire beyond the array’s resolution, your spacing is mismatched, or there’s significant reverberation.

6) Sensitivity and gain:
– INMP441 output amplitude varies with distance and sound pressure. If rms values are always tiny (< 0.002) or clipping (> 0.5), adjust the test conditions:
– Move the source closer/farther.
– Use noises with flatter spectra (pink/white noise) for easier DOA detection.

7) Stability and smoothing:
– The angle can jitter frame-to-frame due to noise. With ANGLE_ALPHA = 0.85 you should see a stable running estimate.
– If jitter persists, increase the frame length (e.g., 2048) and enable a small delay between reads, or add median filtering across 3–5 frames.

8) Quantitative error estimation:
– Place the source at known azimuths (e.g., -60°, -30°, 0°, +30°, +60°) and compute error = estimate – ground truth.
– Typical near-field reverberant room error with two mics is 3–8 degrees if the SNR is reasonable and reflections are moderate.


Troubleshooting

  • No serial output:
  • Ensure the correct serial port in pio device monitor.
  • On Linux, add user to dialout and reconnect USB.
  • For some DevKitC-1 variants with CP210x/CH34x bridges, install drivers.

  • I2S read timeouts or zero samples:

  • Confirm wiring: BCLK→GPIO 7, WS→GPIO 15, SD (both mics)→GPIO 16.
  • Verify that both mics share BCLK and WS and that SD lines are tied together.
  • Check that the L/R strap is correct: one mic L/R to GND (Left), the other to 3.3 V (Right).
  • Ensure the mic modules are powered from 3.3 V (not 5 V).

  • One channel always near zero:

  • The L/R strap might be wrong; both mics could be transmitting on the same slot or neither on one slot.
  • Try swapping the mic modules’ L/R strap configuration and re-test.
  • Set SWAP_CHANNELS = 1 if the mapping is inverted.

  • Distorted or saturating readings:

  • The source might be too loud or too close. Increase distance or reduce source volume.
  • Check power supply noise; use short wires and avoid ground loops.

  • Angle stuck at ±90 degrees:

  • If bestLag saturates at ±maxLag, the source may be almost endfire or reflections dominate.
  • Increase spacing slightly (e.g., to 8 cm), and use a more directional test signal (hand clap works well).
  • Move away from walls/reflectors or hang absorption.

  • Build errors referencing i2s_std:

  • Ensure ESP-IDF 5.x is in use. PlatformIO espressif32@6.5.0 uses IDF 5.1 by default.
  • If you pinned framework-espidf, ensure it’s ~5.1.2 or newer.

  • Inconsistent timing:

  • USB-CDC prints can perturb timing if you print too much. Keep output modest or buffer results.

Improvements

  • GCC-PHAT in frequency domain:
  • Replace NCC with GCC-PHAT for better robustness in reverberant environments. You’ll need a small FFT (e.g., KISS FFT or esp-dsp) and PHAT weighting: R_xy = F^-1{X(f)Y(f)/|X(f)Y(f)|}.
  • PlatformIO + ESP-IDF can pull esp-dsp via a “components” folder or CMake fetch; integrate it for efficient FFT and filters.

  • Band-pass filtering:

  • Add a 2nd-order or 4th-order IIR band-pass around 600–4000 Hz to emphasize speech and reduce low-frequency room modes.

  • Windowing and overlap:

  • Use Hann windows and 50% overlap for smoother updates and reduced spectral leakage.

  • Multi-frame peak tracking:

  • Implement a Kalman filter or an alpha-beta tracker for angle to suppress jitter and spurious peaks.

  • Geometry calibration:

  • Calibrate exact mic spacing and offset by measuring TDOA for a sound at known positions, then fitting d and left/right latency.

  • Web or BLE UI:

  • Serve a tiny web page over Wi-Fi to display a polar needle in real time, or stream data over BLE UART.

  • More microphones and beam patterns:

  • With 3–4 mics, use conventional beamformers (Delay-and-Sum, Bartlett/Capon/MVDR) for improved spatial selectivity.

  • Power and performance:

  • Lower sample rate (e.g., 32 kHz) to reduce CPU usage if your application is speech-only.
  • Use PSRAM for larger buffers; pin memory types as needed.

Final Checklist

  • Hardware:
  • ESP32-S3-DevKitC-1 powered via USB-C.
  • Two INMP441 mics powered at 3.3 V, common GND.
  • BCLK wired to both mics and GPIO 7.
  • WS wired to both mics and GPIO 15.
  • SD from both mics tied together and wired to GPIO 16.
  • L/R strap: one mic to GND (Left), the other to 3.3 V (Right).
  • Mic spacing measured and set in code (MIC_BASELINE_M).

  • Software:

  • PlatformIO Core 6.1.13 installed.
  • platformio.ini uses espressif32@6.5.0 with ESP-IDF.
  • src/main.c compiled without errors; i2s_std used and channel enabled.

  • Run/Validation:

  • Serial output shows lag, tdoa, angle, peak correlation, RMS per channel.
  • Near-Left sound → negative angle; near-Right → positive angle.
  • 0° broadside yields angle near zero.
  • Angle stable with smoothing; adjust ANGLE_ALPHA as needed.

If all boxes are checked, you have a working two-mic I2S beamforming direction-finding demonstrator on the ESP32-S3-DevKitC-1, ready to evolve into a more sophisticated GCC-PHAT or multi-microphone beamformer.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main purpose of the ESP32-S3-DevKitC-1 in the project?




Question 2: What type of microphones are used in the project?




Question 3: What does TDOA stand for?




Question 4: Which programming language is primarily used in this project?




Question 5: What kind of tests are used to validate the system?




Question 6: What is the formula for calculating the DOA angle?




Question 7: Which framework is emphasized for reproducibility in the workflow?




Question 8: What is the required sampling rate for the microphones?




Question 9: What is the spacing between the two microphones in the project?




Question 10: What is the speed of sound assumed in the calculations?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: Pan/Tilt WebSocket OV2640 ESP32-CAM PCA9685

Practical case: Pan/Tilt WebSocket OV2640 ESP32-CAM PCA9685 — hero

Objective and use case

What you’ll build: Transform your ESP32-CAM into a WebSocket streaming camera with pan-tilt control using PCA9685. This project allows for real-time video streaming and camera positioning through a web interface.

Why it matters / Use cases

  • Remote surveillance: Use the pan-tilt camera for monitoring areas where physical presence is not possible.
  • Robotics: Integrate the camera into robotic systems for visual feedback and navigation.
  • Telepresence: Enable users to interact remotely with environments through live video feed and camera control.
  • Smart home applications: Implement the camera in home automation systems for security and monitoring.

Expected outcome

  • Stream video at 30 FPS with latency under 200 ms.
  • Achieve pan/tilt response time of less than 100 ms for user commands.
  • Maintain a stable connection with less than 5% packet loss over WebSocket.
  • Provide a clear user interface with less than 1 second delay in control feedback.

Audience: Developers and hobbyists; Level: Intermediate

Architecture/flow: ESP32-CAM captures images, streams via WebSocket, and receives commands for pan/tilt control through PCA9685 over I2C.

ESP32-CAM (OV2640) + PCA9685 Advanced Practical: WebSocket Pan–Tilt Streaming (ov2640-websocket-pan-tilt)

This hands-on project turns an ESP32-CAM (OV2640) into a WebSocket-based streaming camera that simultaneously controls a pan–tilt mechanism via a PCA9685 servo driver. The ESP32 captures JPEG frames through the OV2640, streams them to the browser over a WebSocket as binary frames, and receives pan/tilt commands (also via WebSocket) to position the camera using two servos. The entire workflow is built with PlatformIO for reproducibility.

You will build, flash, and validate a self-contained firmware that:

  • Initializes the OV2640 with PSRAM for stable JPEG capture at low latency
  • Runs an HTTP server for a small embedded web client
  • Streams camera frames to the web client over a WebSocket
  • Accepts pan/tilt commands from the client and drives two servos through the PCA9685 over I2C
  • Provides a clear wiring plan for ESP32-CAM and PCA9685 with an isolated 5 V servo supply

The focus objective is “ov2640-websocket-pan-tilt,” emphasizing WebSocket streaming and pan–tilt control over I2C via the PCA9685.


Prerequisites

  • Host OS:
  • Windows 10/11, macOS 12+ (Monterey or newer), or Linux (Ubuntu 20.04+)
  • Software:
  • VS Code 1.92+ with PlatformIO Core 6.1+ and PlatformIO IDE extension 3.3+
  • Python 3.10+ (installed automatically by PlatformIO in most cases)
  • A modern browser with WebSocket and Canvas support (Chrome, Edge, Firefox)
  • USB–Serial drivers (depending on your adapter):
  • CP210x (Silicon Labs): version 10.1.10+ (Windows), or corresponding macOS/Linux driver
  • CH34x (WCH): version 3.8+ (Windows), or corresponding macOS/Linux driver
  • Network:
  • 2.4 GHz Wi-Fi network (STA mode) OR plan to use ESP32-CAM SoftAP fallback

Materials (with exact model)

  • 1x ESP32-CAM (AI-Thinker) with OV2640 camera module
  • 1x PCA9685 16-Channel 12-bit PWM Servo Driver (Adafruit or compatible) — default I2C address 0x40
  • 2x Servos for pan–tilt:
  • SG90 (micro) or MG90S (metal gear) recommended
  • 1x Pan–tilt bracket (compatible with 9g micro servos)
  • 1x External 5 V power supply for servos (5 V, 2–3 A recommended)
  • 1x USB-to-UART adapter (3.3 V logic) using CP2102 or CH340/CH34x
  • 1x 5 V supply for ESP32-CAM (can be the same as external supply if properly distributed; ensure stable 5 V and common ground)
  • Jumper wires (male–female and female–female as needed)
  • Breadboard or screw terminal breakout for power distribution
  • Optional: 1000 µF electrolytic capacitor across 5 V servo supply to reduce brownouts and jitter

Setup/Connection

Important notes first:

  • The ESP32-CAM’s default camera pin mapping consumes many pins; we’ll not use the SD card in this project. We will repurpose GPIO14 (SCL) and GPIO15 (SDA) for I2C only if SD is not used.
  • Power the servos from a dedicated 5 V rail to avoid brownouts on the ESP32-CAM. Always connect grounds together (ESP32-CAM GND, PCA9685 GND, and servo PSU GND).
  • The PCA9685 has two power domains:
  • VCC: logic power (3.3 V–5 V). Use 3.3 V from the ESP32-CAM to keep I2C logic at 3.3 V.
  • V+: servo power (5 V from external PSU). Do not power servos from the ESP32-CAM 5 V pin.

Wire Mapping

  • ESP32-CAM to PCA9685:
  • ESP32-CAM 3V3 → PCA9685 VCC
  • ESP32-CAM GND → PCA9685 GND
  • External 5 V PSU (+) → PCA9685 V+ (servo power rail)
  • External 5 V PSU (–) → PCA9685 GND (common ground)
  • ESP32-CAM GPIO14 → PCA9685 SCL
  • ESP32-CAM GPIO15 → PCA9685 SDA
  • Servos:
  • Pan servo signal → PCA9685 Channel 0 (S0)
  • Tilt servo signal → PCA9685 Channel 1 (S1)
  • Servo power leads → PCA9685 V+ and GND (respect polarity)

  • ESP32-CAM flashing connections to USB–UART adapter:

  • ESP32-CAM U0TXD (GPIO1) → USB–UART RX
  • ESP32-CAM U0RXD (GPIO3) → USB–UART TX
  • ESP32-CAM GND → USB–UART GND
  • ESP32-CAM 5V → 5 V from USB–UART or a stable 5 V PSU (many UART adapters can’t deliver enough current; if unsure, use a separate 5 V PSU with common ground)
  • For bootloader (flash) mode: connect GPIO0 to GND while resetting/powering to enter programming. Remove GND from GPIO0 and reset to run after flashing.

Pin/Connection Table

Function ESP32-CAM Pin PCA9685/Servo Pin Notes
I2C SCL GPIO14 SCL Reuse only if SD is not used
I2C SDA GPIO15 SDA Reuse only if SD is not used
PCA9685 logic VCC 3V3 VCC 3.3 V logic
PCA9685/Servos GND GND GND Common ground with ESP32-CAM and 5 V servo PSU
Servo power External 5 V V+ 5 V, 2–3 A recommended
Pan servo signal Channel 0 (S0) Signal wire only; power from V+
Tilt servo signal Channel 1 (S1) Signal wire only; power from V+
UART TX (to adapter) U0TXD (GPIO1) USB–UART RX Flashing/monitor
UART RX (from adap.) U0RXD (GPIO3) USB–UART TX Flashing/monitor
Boot strap GPIO0 GND (for flash) Tie to GND for programming mode; release to run

Power-on rule: Always power the PCA9685’s V+ (servo power) before commanding servos, and keep grounds common. Add bulk capacitance across V+ and GND near the PCA9685.


Full Code

The project consists of a PlatformIO configuration and a single main.cpp firmware. The firmware:

  • Initializes the OV2640 camera (AI-Thinker mapping) at QVGA (320×240) for smoother streaming
  • Sets up AsyncWebServer and AsyncWebSocket at / and /ws
  • Spawns a capture task that pushes binary JPEG frames to all connected clients when available
  • Initializes PCA9685 at 50 Hz and exposes pan/tilt via channels 0 and 1
  • Parses simple JSON messages like {«pan»:90,»tilt»:45} over the WebSocket
  • Falls back to SoftAP if Wi-Fi STA connection fails

platformio.ini

[env:esp32cam]
platform = espressif32@6.5.0
board = esp32cam
framework = arduino

; Ensure PSRAM is enabled and camera is stable
build_flags =
  -DBOARD_HAS_PSRAM
  -mfix-esp32-psram-cache-issue
  -DCAMERA_MODEL_AI_THINKER
  -DCORE_DEBUG_LEVEL=1
  -DWIFI_SSID=\"YourSSID\"
  -DWIFI_PASS=\"YourPassword\"

monitor_speed = 115200
upload_speed = 921600

lib_deps =
  me-no-dev/ESP Async WebServer@^1.2.3
  me-no-dev/AsyncTCP@^1.1.1
  adafruit/Adafruit PWM Servo Driver Library@^3.0.2

Replace WIFI_SSID and WIFI_PASS with your network credentials (keep quotes escaped as shown). If you prefer not to pass credentials via build flags, you can hardcode them in main.cpp.

src/main.cpp

#include <Arduino.h>
#include "esp_camera.h"
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>

// -------------------- Wi-Fi --------------------
#ifndef WIFI_SSID
#define WIFI_SSID "ESP32-CAM-AP"
#endif
#ifndef WIFI_PASS
#define WIFI_PASS "esp32cam1234"
#endif

static const char* STA_SSID = WIFI_SSID;
static const char* STA_PASS = WIFI_PASS;

// -------------------- I2C / PCA9685 --------------------
#define I2C_SDA 15     // ESP32-CAM repurposed SDA (no SD card in use)
#define I2C_SCL 14     // ESP32-CAM repurposed SCL (no SD card in use)
#define PCA9685_ADDR 0x40

Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(PCA9685_ADDR, Wire);

// Servo configuration
static const uint8_t CH_PAN = 0;
static const uint8_t CH_TILT = 1;
static const int SERVO_FREQ = 50;           // Hz for hobby servos
static const int SERVO_MIN_US = 500;        // microseconds at 0 deg (calibrate)
static const int SERVO_MAX_US = 2500;       // microseconds at 180 deg (calibrate)
static const int PAN_MIN_DEG = 0, PAN_MAX_DEG = 180;
static const int TILT_MIN_DEG = 0, TILT_MAX_DEG = 180;

// Current angles
volatile int panDeg = 90;
volatile int tiltDeg = 90;

// -------------------- Camera pins (AI-Thinker) --------------------
#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27

#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

// -------------------- Web --------------------
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
volatile uint32_t wsClients = 0;

// Embedded web page (minimal UI):
// - Canvas for JPEG frames
// - Range inputs for pan/tilt; send JSON over WS
// - Arrow keys control (W/S for tilt, A/D for pan)
static const char index_html[] PROGMEM = R"HTML(
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ESP32-CAM WebSocket Pan-Tilt</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; background:#111; color:#eee; }
  #row { display:flex; gap:1rem; flex-wrap:wrap; }
  #view { background:#000; }
  .card { padding:1rem; background:#222; border-radius:8px; }
  input[type=range]{ width:300px; }
  button{ margin: 0.2rem; }
</style>
</head>
<body>
<h2>ESP32-CAM (OV2640) WebSocket Pan–Tilt</h2>
<div id="row">
  <div class="card">
    <canvas id="view" width="320" height="240"></canvas>
    <div><small id="status">Connecting...</small></div>
  </div>
  <div class="card">
    <div>Pan: <input id="pan" type="range" min="0" max="180" value="90"/></div>
    <div>Tilt: <input id="tilt" type="range" min="0" max="180" value="90"/></div>
    <div>
      <button onclick="nudge(-5,0)">Pan -</button>
      <button onclick="nudge(5,0)">Pan +</button>
      <button onclick="nudge(0,-5)">Tilt -</button>
      <button onclick="nudge(0,5)">Tilt +</button>
    </div>
    <div><small>Tip: use keys A/D (pan) and W/S (tilt).</small></div>
  </div>
</div>
<script>
let ws;
let canvas = document.getElementById('view');
let ctx = canvas.getContext('2d');
let statusEl = document.getElementById('status');
let pan = document.getElementById('pan');
let tilt = document.getElementById('tilt');

function connect() {
  ws = new WebSocket(`ws://${location.host}/ws`);
  ws.binaryType = 'arraybuffer';
  ws.onopen = () => { statusEl.textContent = 'Connected'; sendAngles(); };
  ws.onclose = () => { statusEl.textContent = 'Disconnected. Reconnecting...'; setTimeout(connect, 1000); };
  ws.onerror = (e) => { console.error(e); };
  ws.onmessage = (ev) => {
    if (typeof ev.data !== 'object') return;
    let blob = new Blob([ev.data], {type:'image/jpeg'});
    let img = new Image();
    img.onload = () => {
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      URL.revokeObjectURL(img.src);
    };
    img.src = URL.createObjectURL(blob);
  };
}
connect();

function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }

function sendAngles() {
  if (!ws || ws.readyState !== 1) return;
  let payload = JSON.stringify({ pan: parseInt(pan.value), tilt: parseInt(tilt.value) });
  ws.send(payload);
}

pan.oninput = sendAngles;
tilt.oninput = sendAngles;

function nudge(dp, dt) {
  pan.value = clamp(parseInt(pan.value) + dp, 0, 180);
  tilt.value = clamp(parseInt(tilt.value) + dt, 0, 180);
  sendAngles();
}

window.addEventListener('keydown', (e) => {
  if (e.key === 'a' || e.key === 'A') nudge(-5, 0);
  if (e.key === 'd' || e.key === 'D') nudge( 5, 0);
  if (e.key === 'w' || e.key === 'W') nudge( 0,-5);
  if (e.key === 's' || e.key === 'S') nudge( 0, 5);
});
</script>
</body>
</html>
)HTML";

// -------------------- Utilities --------------------
static inline uint16_t usToTicks(uint16_t us) {
  // PCA9685: 12-bit (4096 steps) across 1/f seconds
  // ticks = us * 4096 * freq / 1e6
  float ticks = (float)us * 4096.0f * (float)SERVO_FREQ / 1000000.0f;
  if (ticks < 0) ticks = 0;
  if (ticks > 4095) ticks = 4095;
  return (uint16_t)(ticks);
}

void writeServoUs(uint8_t ch, uint16_t us) {
  uint16_t ticks = usToTicks(us);
  // For PCA9685, setPWM(channel, onTick, offTick) — we use 0..ticks
  pwm.setPWM(ch, 0, ticks);
}

uint16_t angleToUs(int angle) {
  angle = constrain(angle, 0, 180);
  return (uint16_t)(SERVO_MIN_US + (long)(SERVO_MAX_US - SERVO_MIN_US) * angle / 180L);
}

void setPan(int deg) {
  deg = constrain(deg, PAN_MIN_DEG, PAN_MAX_DEG);
  panDeg = deg;
  writeServoUs(CH_PAN, angleToUs(deg));
}

void setTilt(int deg) {
  deg = constrain(deg, TILT_MIN_DEG, TILT_MAX_DEG);
  tiltDeg = deg;
  writeServoUs(CH_TILT, angleToUs(deg));
}

void initPCA9685() {
  Wire.begin(I2C_SDA, I2C_SCL, 400000); // 400kHz for snappy I2C
  if (!pwm.begin()) {
    Serial.println("[PCA9685] init failed (check address/power)");
  }
  pwm.setOscillatorFrequency(27000000); // Adafruit default nominal
  pwm.setPWMFreq(SERVO_FREQ);
  delay(10);
  setPan(panDeg);
  setTilt(tiltDeg);
  Serial.println("[PCA9685] ready at 0x40, 50Hz");
}

// -------------------- Camera --------------------
bool initCamera() {
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer   = LEDC_TIMER_0;
  config.pin_d0       = Y2_GPIO_NUM;
  config.pin_d1       = Y3_GPIO_NUM;
  config.pin_d2       = Y4_GPIO_NUM;
  config.pin_d3       = Y5_GPIO_NUM;
  config.pin_d4       = Y6_GPIO_NUM;
  config.pin_d5       = Y7_GPIO_NUM;
  config.pin_d6       = Y8_GPIO_NUM;
  config.pin_d7       = Y9_GPIO_NUM;
  config.pin_xclk     = XCLK_GPIO_NUM;
  config.pin_pclk     = PCLK_GPIO_NUM;
  config.pin_vsync    = VSYNC_GPIO_NUM;
  config.pin_href     = HREF_GPIO_NUM;
  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn     = PWDN_GPIO_NUM;
  config.pin_reset    = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;

  if (psramFound()) {
    config.frame_size = FRAMESIZE_QVGA; // 320x240
    config.jpeg_quality = 12;           // 10–30; lower is better quality
    config.fb_count = 2;
    config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
  } else {
    config.frame_size = FRAMESIZE_QQVGA; // fallback if no PSRAM
    config.jpeg_quality = 20;
    config.fb_count = 1;
    config.grab_mode = CAMERA_GRAB_LATEST;
  }

  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("[CAM] Init failed 0x%x\n", err);
    return false;
  }
  sensor_t * s = esp_camera_sensor_get();
  s->set_framesize(s, (framesize_t)config.frame_size);
  s->set_brightness(s, 0);
  s->set_contrast(s, 0);
  s->set_saturation(s, 0);
  s->set_whitebal(s, 1);
  s->set_gain_ctrl(s, 1);
  s->set_exposure_ctrl(s, 1);
  s->set_hmirror(s, 0);
  s->set_vflip(s, 0);

  Serial.println("[CAM] Initialized OV2640");
  return true;
}

// -------------------- WebSocket handlers --------------------
void handleWsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client,
                   AwsEventType type, void * arg, uint8_t *data, size_t len) {
  switch (type) {
    case WS_EVT_CONNECT:
      wsClients++;
      Serial.printf("[WS] Client %u connected, total=%u\n", client->id(), wsClients);
      break;
    case WS_EVT_DISCONNECT:
      if (wsClients > 0) wsClients--;
      Serial.printf("[WS] Client %u disconnected, total=%u\n", client->id(), wsClients);
      break;
    case WS_EVT_DATA: {
      // Expect JSON like {"pan":90,"tilt":45}
      data[len] = 0;
      const char* txt = (const char*)data;

      // Minimal parsing for "pan" and "tilt" integers
      int p = panDeg, t = tiltDeg;
      const char* ppos = strstr(txt, "\"pan\"");
      const char* tpos = strstr(txt, "\"tilt\"");
      if (ppos) {
        const char* colon = strchr(ppos, ':');
        if (colon) p = atoi(colon + 1);
        setPan(p);
      }
      if (tpos) {
        const char* colon = strchr(tpos, ':');
        if (colon) t = atoi(colon + 1);
        setTilt(t);
      }
      break;
    }
    case WS_EVT_PONG:
    case WS_EVT_ERROR:
      break;
  }
}

// Capture task: send frame to all clients when connected
void frameTask(void* param) {
  const TickType_t shortDelay = pdMS_TO_TICKS(10);
  const TickType_t idleDelay  = pdMS_TO_TICKS(250);
  for (;;) {
    if (wsClients == 0) {
      vTaskDelay(idleDelay);
      continue;
    }
    camera_fb_t* fb = esp_camera_fb_get();
    if (!fb) {
      vTaskDelay(shortDelay);
      continue;
    }
    // Send as binary JPEG. AsyncWebSocket will fragment as needed.
    ws.binaryAll(fb->buf, fb->len);
    esp_camera_fb_return(fb);
    vTaskDelay(pdMS_TO_TICKS(33)); // ~30 FPS cap; actual depends on JPEG size and network
  }
}

// -------------------- Wi-Fi --------------------
bool startWiFiSTA(unsigned long timeoutMs = 10000) {
  WiFi.mode(WIFI_STA);
  WiFi.begin(STA_SSID, STA_PASS);
  Serial.printf("[WiFi] Connecting to %s\n", STA_SSID);
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - start) < timeoutMs) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    Serial.printf("[WiFi] Connected: %s, IP=%s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
    return true;
  }
  Serial.println("[WiFi] STA connect failed, fallback to SoftAP");
  return false;
}

void startSoftAP() {
  WiFi.mode(WIFI_AP);
  const char* ap_ssid = "ESP32-CAM-PT";
  const char* ap_pass = "esp32cam";
  bool ok = WiFi.softAP(ap_ssid, ap_pass);
  if (ok) {
    Serial.printf("[AP] SSID=%s PASS=%s IP=%s\n", ap_ssid, ap_pass, WiFi.softAPIP().toString().c_str());
  } else {
    Serial.println("[AP] Failed to start SoftAP");
  }
}

// -------------------- Setup/Loop --------------------
void setup() {
  Serial.begin(115200);
  Serial.setDebugOutput(false);
  delay(200);

  Serial.println("\n--- ESP32-CAM OV2640 WebSocket Pan–Tilt ---");

  if (!initCamera()) {
    Serial.println("[FATAL] Camera init failed; rebooting...");
    delay(2000);
    ESP.restart();
  }

  initPCA9685();

  if (!startWiFiSTA()) {
    startSoftAP();
  }

  // Web server routes
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    AsyncWebServerResponse * resp = request->beginResponse_P(200, "text/html", index_html);
    request->send(resp);
  });

  ws.onEvent(handleWsEvent);
  server.addHandler(&ws);

  // Simple health check
  server.on("/healthz", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(200, "application/json", "{\"ok\":true}");
  });

  server.begin();
  Serial.println("[HTTP] Server started on port 80");

  // Start frame task pinned to core 0 (leave core 1 for Wi-Fi)
  xTaskCreatePinnedToCore(frameTask, "frameTask", 4096, nullptr, 1, nullptr, 0);
}

void loop() {
  // Nothing here; Async + FreeRTOS task do the work
}

Notes:

  • If you plan to use the SD card, do not use GPIO14/15 for I2C. For this build, SD is disabled to free 14/15.
  • The JSON parser is intentionally minimal to keep binary size low; adjust if you add more commands.
  • If your servos move in an unexpected direction, swap the pan or tilt channels, or invert angle mapping in setPan/setTilt.

Build/Flash/Run Commands

Run these from the project root (where platformio.ini lives).

1) Initialize PlatformIO project (only once if you are starting from scratch):

pio project init --board esp32cam

2) Build:

pio run

3) Put the ESP32-CAM into bootloader mode:
– Disconnect power.
– Tie GPIO0 to GND.
– Power or reset the board.

4) Upload (replace the port with your system’s detected port):
– Windows: COM3, COM4, etc.
– macOS: /dev/tty.SLAB_USBtoUART or /dev/tty.usbserial-*
– Linux: /dev/ttyUSB0 or /dev/ttyACM0

pio run -t upload --upload-port /dev/ttyUSB0

5) Return to run mode:
– Remove GPIO0 from GND.
– Press reset (EN) or power-cycle the ESP32-CAM.

6) Open the serial monitor:

pio device monitor -b 115200

You should see the camera initialization log, PCA9685 readiness, and Wi-Fi status. If STA connects, note the IP address shown (e.g., 192.168.1.87). If STA fails, an AP will be started and the AP IP will be printed.


Step-by-step Validation

1) Power integrity
– Ensure PCA9685 V+ is fed by a stable 5 V supply and that grounds are common.
– If your servos twitch on boot, add a 1000 µF capacitor across V+ and GND at the PCA9685 and avoid commanding motion until after initPCA9685() completes.

2) Serial logs
– With pio device monitor open, confirm:
– “[CAM] Initialized OV2640”
– “[PCA9685] ready at 0x40, 50Hz”
– Wi-Fi “Connected” with a valid IP OR “[AP] SSID=ESP32-CAM-PT PASS=esp32cam IP=…”

3) Browse to the device
– STA mode: http:///
– SoftAP: connect to the SSID “ESP32-CAM-PT” (password “esp32cam”), then visit http://192.168.4.1/
– The page shows a canvas and controls. Status should show “Connected” shortly.

4) Video stream
– Within ~1–2 seconds, frames should appear on the canvas. Expected frame rate at QVGA is roughly 8–20 fps depending on network and JPEG size (quality=12).
– If you see a blank canvas:
– Check the browser console for WebSocket errors.
– Check the serial monitor for camera framebuffer errors.

5) Pan–tilt control
– Move the sliders or press the nudge buttons; servos should respond smoothly with minimal jitter.
– Keyboard control: A/D for pan left/right, W/S for tilt up/down.
– Confirm no resets occur. If you see brownout logs or resets:
– Increase servo supply capacity.
– Add bulk capacitance.
– Avoid commanding rapid large movements.

6) WebSocket health
– With at least one client connected, the serial log should show clients count increments.
– Disconnect/reload the page to see clients count decrease/increase.

7) Long run
– Let it stream for 5–10 minutes. Watch for:
– Memory fragmentation (rare with current settings).
– Overheating (unlikely at QVGA and 20 MHz XCLK).
– Network drops; the page should automatically reconnect.

8) Optional CLI validation (WebSocket)
– Install wscat (Node.js required):
npm i -g wscat
– Connect to the WebSocket endpoint:
wscat -c ws://<esp-ip>/ws
– Send a control JSON:
{"pan":120,"tilt":30}
– You won’t receive frame data (binary images) nicely in the terminal, but you can confirm that the connection is accepted and control messages are sent without error.


Troubleshooting

  • Camera init failed 0x200 or 0x105
  • Ensure PSRAM is detected. The esp32cam board definition enables PSRAM; still, cheap modules can have faulty PSRAM. Try lowering frame size to QQVGA and fb_count=1.
  • Confirm 5 V input to ESP32-CAM is stable (500–700 mA peak may be needed during Wi-Fi bursts).

  • Continuous resets or “Brownout detector was triggered”

  • Servos drawing current through the ESP32-CAM regulator → not allowed. Power servos from an external 5 V PSU, connect grounds.
  • Add bulk capacitor across V+ and GND near PCA9685.

  • No video in browser, WebSocket connects then closes

  • Large frames at high quality may overflow or fragment poorly on a congested network. Test FRAMESIZE_QQVGA and increase jpeg_quality (e.g., 14–16).
  • Move closer to the AP, reduce 2.4 GHz interference.

  • Servos not moving

  • Check I2C wiring: SDA to GPIO15 and SCL to GPIO14; ensure pull-ups exist (PCA9685 breakout usually has them). Verify PCA9685 VCC is at 3.3 V and V+ is at 5 V.
  • Confirm I2C address 0x40. If you changed A0–A5 address pins, update PCA9685_ADDR.
  • Use an I2C scanner if needed (temporary sketch) to confirm address.

  • Servos jitter or move erratically

  • Insufficient power or noisy 5 V rail; add capacitance and use thicker wires.
  • SERVO_MIN_US and SERVO_MAX_US might not match your servo. Calibrate to avoid commanding beyond mechanical limits.

  • “Guru Meditation Error: Core x panic’ed”

  • Stack/heap pressure. Try reducing frame size to QQVGA, reduce fb_count, and decrease frameTask stack if you changed code. Ensure PSRAM is enabled and stable.

  • ESP32-CAM won’t flash

  • Ensure GPIO0 is tied to GND at reset to enter bootloader.
  • Swap RX/TX wires to the USB–UART adapter if you see no ROM messages.
  • Install appropriate CP210x/CH34x driver; check Device Manager (Windows) or /dev/tty* nodes (macOS/Linux).

  • Web page loads but no frames

  • Mixed content if using HTTPS reverse proxies. This example serves HTTP only; access the device directly via its IP with http://.
  • Browser privacy settings or extensions may block WebSocket; test in a fresh profile.

Improvements

  • Adaptive streaming
  • Dynamically adjust frame size (QVGA/QQVGA) and JPEG quality based on client round-trip time or dropped frames to maintain smooth playback on weak Wi-Fi.

  • Authentication and HTTPS

  • Add basic auth or token on the WebSocket and HTTP endpoints.
  • Terminate TLS on an upstream reverse proxy (e.g., Nginx) and proxy to the ESP32 over HTTP.

  • Motion profiles and easing

  • Implement acceleration limiting to reduce mechanical shock and power spikes. Interpolate angle changes over time via a per-servo scheduler.

  • Save calibration

  • Store per-servo min/max microseconds and center offsets in NVS, with a small calibration page for interactive tuning.

  • Multi-client handling

  • Track each client’s last ACK; throttle frameTask to the slowest client or implement per-client queues.

  • LED torch control

  • GPIO4 is tied to the on-board LED flash; add a UI toggle for low-light scenes, with duty cycle control.

  • JSON schema and structured protocol

  • Replace minimal parser with ArduinoJson; add versioning, error responses, and server-to-client status (e.g., current angles, fps, heap metrics).

  • IDF-based build

  • For larger deployments, migrate to ESP-IDF’s native WebSocket and HTTP components for finer memory control.

Final Checklist

  • Hardware
  • ESP32-CAM (AI-Thinker) with OV2640 attached securely
  • PCA9685 wired to ESP32-CAM: SDA=GPIO15, SCL=GPIO14; VCC=3.3 V, V+=5 V
  • Servos on PCA9685 channels 0 (pan) and 1 (tilt)
  • Common GND across ESP32-CAM, PCA9685, and 5 V servo PSU
  • Adequate 5 V PSU for servos with bulk capacitor near PCA9685

  • Firmware

  • PlatformIO environment set to board=esp32cam
  • PSRAM flags enabled; camera configured for AI-Thinker pins
  • WebSocket endpoint “/ws” serves binary JPEG frames; control via JSON
  • PCA9685 initialized at 50 Hz; angles mapped to 500–2500 µs

  • Build/Flash

  • Drivers installed (CP210x/CH34x)
  • GPIO0 grounded for upload, released to run
  • Upload succeeds at 921600 (or lower if your adapter is unstable)
  • Serial logs confirm camera, PCA9685, and Wi-Fi

  • Validation

  • Browser shows live video at http:///
  • Pan/tilt controls respond without resets or severe jitter
  • WebSocket reconnects automatically after transient drops

  • Documentation

  • Keep your calibrated SERVO_MIN_US/SERVO_MAX_US, PAN/TILT bounds, and I2C address documented in case of hardware changes.

With this build, your ESP32-CAM streams OV2640 JPEG frames via WebSocket to a browser and precisely controls pan–tilt through a PCA9685 servo driver—realizing the ov2640-websocket-pan-tilt objective with a reproducible PlatformIO setup.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What camera module is used in the ESP32-CAM project?




Question 2: Which protocol is used for streaming in this project?




Question 3: What type of driver is the PCA9685?




Question 4: What is the purpose of the PSRAM in the OV2640 initialization?




Question 5: Which operating system is NOT listed as a prerequisite?




Question 6: What is the main programming environment used for this project?




Question 7: What type of commands does the ESP32-CAM accept from the web client?




Question 8: What is required for the camera to stream to the web client?




Question 9: What voltage is recommended for the servo supply?




Question 10: What is the main function of the PCA9685 in this project?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: ESP32 Ethernet Modbus RS485 Energy Logger

Practical case: ESP32 Ethernet Modbus RS485 Energy Logger — hero

Objective and use case

What you’ll build: Transform your ESP32-Ethernet-Kit into a Modbus RTU master for energy logging. This project will enable you to read energy metrics from a Modbus energy meter and expose the data over Ethernet.

Why it matters / Use cases

  • Monitor energy consumption in real-time for industrial applications using the ESP32-Ethernet-Kit.
  • Integrate energy data into home automation systems via a local REST API.
  • Log energy metrics to InfluxDB for historical analysis and visualization.
  • Utilize RS485 communication for long-distance energy meter connections in commercial settings.
  • Implement energy monitoring solutions in remote locations where Ethernet is available.

Expected outcome

  • Read energy metrics with a polling frequency of 1 second.
  • Achieve data transmission rates of up to 115200 bps over RS485.
  • Expose energy metrics via a REST endpoint with response times under 100 ms.
  • Log energy data with an accuracy of ±1% from the Eastron SDM-series meter.
  • Maintain latencies below 50 ms for Modbus communication between the ESP32 and the energy meter.

Audience: Intermediate developers; Level: Advanced

Architecture/flow: ESP32-Ethernet-Kit communicates with MAX3485 RS485 transceiver, which interfaces with the Modbus energy meter, while data is served over Ethernet.

ESP32-Ethernet-Kit + LAN8720 + MAX3485 RS485: Modbus Energy Logger over Ethernet (Advanced)

This hands-on project turns an ESP32-Ethernet-Kit into a Modbus RTU master over RS485 that periodically reads energy/ power metrics from a Modbus energy meter and exposes the data over Ethernet via a local REST endpoint and (optionally) pushes metrics to InfluxDB. It is built with PlatformIO for reproducibility, uses the LAN8720 PHY on the ESP32-Ethernet-Kit for wired networking, and a MAX3485 transceiver for half-duplex RS485.

The exact device model targeted here is: ESP32-Ethernet-Kit + LAN8720 + MAX3485 RS485.

You will wire the MAX3485 to ESP32 UART2 and control DE/RE for half‑duplex timing in software. The ESP32 acts as a Modbus RTU master (client) and polls a typical Eastron SDM-series energy meter (or equivalent Modbus RTU meter). The same approach works for other meters with minor register-map adjustments.


Prerequisites

  • Comfortable with:
  • PlatformIO (CLI) and the Arduino framework on ESP32
  • Modbus RTU addressing and register maps
  • Basic RS485 physical layer characteristics (termination, biasing, A/B polarity)
  • Host OS: Windows 10/11, macOS 12+, or Ubuntu 22.04 LTS
  • PlatformIO Core: 6.1.x or newer
  • Git: 2.30+ (optional but useful)
  • USB-UART driver:
  • ESP32-Ethernet-Kit typically uses CP210x.
  • Windows: install Silicon Labs CP210x VCP driver.
  • macOS: usually not needed (built-in), otherwise install CP210x driver package.
  • Linux: kernel driver usually present; ensure user is in dialout group.

Materials (exact models)

  • 1x ESP32-Ethernet-Kit (with LAN8720 PHY, e.g., ESP32-Ethernet-Kit V1.1)
  • 1x MAX3485 RS485 transceiver module (3.3 V logic)
  • 1x RJ45 Ethernet cable (CAT5e+), connected to a DHCP-enabled switch/router
  • 1x USB cable (micro-USB for ESP32-Ethernet-Kit)
  • 2x twisted-pair wires for RS485 A/B to meter
  • 1x 120 Ω resistor for RS485 termination (if your segment requires it)
  • 2x 680 Ω resistors for biasing (optional if your meter network already provides bias)
  • Optional: 1x Eastron SDM120/SDM220/SDM630 Modbus meter (or equivalent Modbus RTU energy meter)
  • Optional: 1x InfluxDB 2.x instance reachable over your LAN

Setup/Connection

Ethernet (LAN8720 on ESP32-Ethernet-Kit)

  • The ESP32-Ethernet-Kit integrates the LAN8720 PHY. Typical pin assignments used by the Arduino core for ESP32 with this kit:
  • MDIO: GPIO18
  • MDC: GPIO23
  • PHY power: GPIO12
  • RMII clock: from GPIO0 (ETH_CLOCK_GPIO0_IN)
  • Ensure the board jumpers match RMII clock from GPIO0. On many kits, this is the default. If your kit has a selection header for RMII 50 MHz clock, set it to “GPIO0_IN”.

RS485 (MAX3485) wiring to ESP32

  • UART selection: use UART2 on the ESP32 to avoid conflicts with USB serial and RMII pins.
  • Pins on ESP32:
  • UART2 TX: GPIO17
  • UART2 RX: GPIO16
  • RS485 DE/RE control: GPIO33
  • MAX3485 connections:
  • VCC -> 3.3 V on ESP32-Ethernet-Kit
  • GND -> GND on ESP32-Ethernet-Kit
  • RO (Receiver Out) -> ESP32 GPIO16 (RX2)
  • DI (Driver In) -> ESP32 GPIO17 (TX2)
  • RE# (Receiver Enable, active low) -> tie to DE, then -> ESP32 GPIO33
  • DE (Driver Enable, active high) -> tie to RE#, then -> ESP32 GPIO33
  • A/B differential pair -> meter RS485 A/B
  • RS485 network notes:
  • Termination: place a 120 Ω resistor across A and B at the physical ends of the RS485 segment (if not provided by the meter).
  • Biasing: on a single-master, single-slave short cable, bias can be omitted if the transceiver or meter provides it. For longer bus or multiple devices, implement biasing (e.g., 680 Ω pull-up on A to 3.3 V and 680 Ω pull-down on B to GND) once per bus.
  • Polarity: If you receive no responses, swap A and B at either end (never cross VCC/GND).

Power and USB

  • Power the ESP32-Ethernet-Kit via micro-USB from your PC.
  • The MAX3485 module must be supplied with 3.3 V (verify the module is 3.3 V logic; some are 5 V only—do not use those).
  • Plug in RJ45 to a DHCP-capable LAN.

Pin and Register Reference

ESP32 <-> MAX3485 wiring summary

Function ESP32-Ethernet-Kit Pin MAX3485 Pin Notes
UART2 TX GPIO17 DI ESP32 TX to transceiver DI
UART2 RX GPIO16 RO ESP32 RX from transceiver RO
DE/RE# control GPIO33 DE + RE# Tie DE and RE# together to GPIO33
Power 3.3 V VCC Ensure 3.3 V MAX3485 variant
Ground GND GND Common ground
RS485 A line n/a A Twisted pair to meter A
RS485 B line n/a B Twisted pair to meter B

Typical Eastron SDM-series Modbus input registers (FC4)

Below are commonly used input registers (2x 16-bit words holding IEEE-754 float, big-endian words). Adjust per your meter’s datasheet.

Measurement Address (0-based) Words Type Notes
Voltage (L-N, single phase) 0x0000 2 float Volts
Current 0x0006 2 float Amperes
Active Power 0x000C 2 float Watts
Frequency 0x0046 2 float Hertz
Import Active Energy Total 0x0156 2 float kWh

Note: Many SDM meters use Input Registers (function 04). Some meters store similar values in Holding Registers (function 03). Always confirm with your device manual.


Full Code

The following code:

  • Initializes Ethernet on the ESP32-Ethernet-Kit (LAN8720)
  • Establishes a Modbus RTU master over RS485 using UART2
  • Polls a configurable set of registers
  • Exposes a simple REST endpoint /api/metrics with JSON
  • Optionally pushes to InfluxDB via HTTP line protocol (can be disabled)

platformio.ini

Create a new PlatformIO project in an empty folder and use this configuration.

; File: platformio.ini
[env:esp32-ethernet-kit]
platform = espressif32@6.5.0
board = esp32-ethernet-kit
framework = arduino
monitor_speed = 115200
upload_speed = 460800
monitor_filters = direct
build_unflags = -Os
build_flags =
  -DCORE_DEBUG_LEVEL=3
  -D CONFIG_ETHERNET_SPI_ETHERNET=0
  -D ETH_PHY_LAN8720=1
  -D ETH_PHY_ADDR=0
  -D ETH_PHY_POWER=12
  -D ETH_MDC=23
  -D ETH_MDIO=18
  -D ETH_CLK_MODE=ETH_CLOCK_GPIO0_IN

lib_deps =
  emelianov/modbus-esp8266 @ ^4.1.0
  bblanchon/ArduinoJson @ ^6.21.3

; Set your serial port if needed:
; upload_port = COM7
; monitor_port = COM7

Notes:
– We pin espressif32@6.5.0 to ensure consistent toolchain/core (Arduino-ESP32 2.0.17-based).
– The ETH_* macros reflect ESP32-Ethernet-Kit defaults (LAN8720 at addr 0). If your kit differs, adjust.
– We depend on “modbus-esp8266” that fully supports ESP32 (the class name is ModbusRTU).
– ArduinoJson is used to build stable JSON responses.

src/main.cpp

// File: src/main.cpp
#include <Arduino.h>
#include <ETH.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <ModbusRTU.h>
#include <ArduinoJson.h>

// --------- Ethernet (LAN8720) configuration ----------
#ifndef ETH_PHY_ADDR
#define ETH_PHY_ADDR 0
#endif
#ifndef ETH_PHY_POWER
#define ETH_PHY_POWER 12
#endif
#ifndef ETH_MDC
#define ETH_MDC 23
#endif
#ifndef ETH_MDIO
#define ETH_MDIO 18
#endif
#ifndef ETH_CLK_MODE
#define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN
#endif

// --------- RS485 / Modbus RTU configuration ----------
static const int UART_RX_PIN = 16; // UART2 RX
static const int UART_TX_PIN = 17; // UART2 TX
static const int RS485_DE_RE_PIN = 33; // DE & RE# tied together
static const uint32_t MODBUS_BAUD = 9600; // typical for many meters
static const uint8_t MODBUS_SLAVE_ID = 1; // adjust to your meter address

// Eastron-like input registers (float, 2x16bit, big-endian words)
static const uint16_t REG_VOLTAGE     = 0x0000; // Volts
static const uint16_t REG_CURRENT     = 0x0006; // Amps
static const uint16_t REG_ACTIVE_PWR  = 0x000C; // Watts
static const uint16_t REG_FREQUENCY   = 0x0046; // Hz
static const uint16_t REG_IMPORT_KWH  = 0x0156; // kWh

// Polling
static const uint32_t POLL_INTERVAL_MS = 3000;

// InfluxDB (optional)
static const bool INFLUX_ENABLE = false; // set true to enable
static const char* INFLUX_URL = "http://192.168.1.50:8086/api/v2/write?org=yourorg&bucket=energy&precision=s";
static const char* INFLUX_TOKEN = "your_influx_api_token";
static const char* INFLUX_MEAS = "energy_meter";
static const char* INFLUX_TAGS = "site=lab,phase=all";

// Globals
WebServer server(80);
ModbusRTU mb;

// State
struct Measurements {
  float voltage = NAN;
  float current = NAN;
  float activePower = NAN;
  float frequency = NAN;
  float importKWh = NAN;
  uint64_t lastUpdateMs = 0;
  bool modbusOk = false;
} meas;

// Ethernet state
volatile bool eth_connected = false;

void handleRoot() {
  server.send(200, "text/plain", "ESP32 Modbus Energy Logger (Ethernet). See /api/metrics");
}

void handleMetrics() {
  StaticJsonDocument<512> doc;
  doc["modbus_ok"] = meas.modbusOk;
  doc["last_update_ms"] = meas.lastUpdateMs;

  JsonObject data = doc.createNestedObject("data");
  data["voltage_V"] = meas.voltage;
  data["current_A"] = meas.current;
  data["active_power_W"] = meas.activePower;
  data["frequency_Hz"] = meas.frequency;
  data["import_kWh"] = meas.importKWh;

  String out;
  serializeJson(doc, out);
  server.send(200, "application/json", out);
}

bool readFloatInputRegister(uint8_t slave, uint16_t reg, float &value) {
  // Read 2 input registers (function 04), big-endian word order typical for SDM
  uint16_t result[2];
  if (!mb.readIreg(slave, reg, result, 2)) {
    return false;
  }
  // The library performs the transaction in task() asynchronously.
  // We must wait until the request is complete.
  uint32_t start = millis();
  while (mb.isTransaction()) {
    mb.task();
    if (millis() - start > 500) { // 500ms timeout per read
      return false;
    }
  }
  // After completion, the library copies data into result
  // But many implementations require callback usage. Here we do synchronous polling.
  // If result[] isn't filled, use the callback form in lib's examples.
  // However, in this version, readIreg(buffer) with pointer should fill it upon completion.

  // Word order: [HiWord, LoWord]
  uint32_t raw = ((uint32_t)result[0] << 16) | result[1];
  float f;
  memcpy(&f, &raw, sizeof(f));
  // Note: If your meter uses swapped word order, swap result[0] and result[1] before combining.
  value = f;
  return true;
}

bool pollMeter() {
  bool ok = true;
  float v, c, p, f, e;

  ok &= readFloatInputRegister(MODBUS_SLAVE_ID, REG_VOLTAGE, v);
  ok &= readFloatInputRegister(MODBUS_SLAVE_ID, REG_CURRENT, c);
  ok &= readFloatInputRegister(MODBUS_SLAVE_ID, REG_ACTIVE_PWR, p);
  ok &= readFloatInputRegister(MODBUS_SLAVE_ID, REG_FREQUENCY, f);
  ok &= readFloatInputRegister(MODBUS_SLAVE_ID, REG_IMPORT_KWH, e);

  if (ok) {
    meas.voltage = v;
    meas.current = c;
    meas.activePower = p;
    meas.frequency = f;
    meas.importKWh = e;
    meas.lastUpdateMs = millis();
    meas.modbusOk = true;
  } else {
    meas.modbusOk = false;
  }
  return ok;
}

void postInflux() {
  if (!INFLUX_ENABLE) return;
  if (!eth_connected) return;

  // influx line protocol
  // energy_meter,site=...,phase=... field=value
  String line;
  line.reserve(256);
  line += INFLUX_MEAS;
  line += ",";
  line += INFLUX_TAGS;
  line += " voltage_V="; line += String(meas.voltage, 3);
  line += ",current_A="; line += String(meas.current, 3);
  line += ",active_power_W="; line += String(meas.activePower, 3);
  line += ",frequency_Hz="; line += String(meas.frequency, 3);
  line += ",import_kWh="; line += String(meas.importKWh, 3);

  HTTPClient http;
  http.begin(INFLUX_URL);
  http.addHeader("Authorization", String("Token ") + INFLUX_TOKEN);
  http.addHeader("Content-Type", "text/plain; charset=utf-8");
  int code = http.POST(line);
  if (code <= 0) {
    Serial.printf("Influx POST failed: %s\n", http.errorToString(code).c_str());
  } else {
    Serial.printf("Influx POST: HTTP %d\n", code);
  }
  http.end();
}

static bool eth_connected_once = false;

void WiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_ETH_START:
      Serial.println("ETH started");
      ETH.setHostname("esp32-energy-logger");
      break;
    case ARDUINO_EVENT_ETH_CONNECTED:
      Serial.println("ETH connected (link up)");
      break;
    case ARDUINO_EVENT_ETH_GOT_IP:
      Serial.printf("ETH got IP: %s\n", ETH.localIP().toString().c_str());
      eth_connected = true;
      eth_connected_once = true;
      break;
    case ARDUINO_EVENT_ETH_DISCONNECTED:
      Serial.println("ETH disconnected (link down)");
      eth_connected = false;
      break;
    case ARDUINO_EVENT_ETH_STOP:
      Serial.println("ETH stopped");
      eth_connected = false;
      break;
    default:
      break;
  }
}

void setupEthernet() {
  WiFi.onEvent(WiFiEvent);
  // Initialize Ethernet
  // ETH.begin(phyType, phyAddr, powerPin, mdcPin, mdioPin, clkMode)
  bool ok = ETH.begin(ETH_PHY_LAN8720, ETH_PHY_ADDR, ETH_PHY_POWER, ETH_MDC, ETH_MDIO, ETH_CLK_MODE);
  if (!ok) {
    Serial.println("ETH begin failed");
  } else {
    Serial.println("ETH begin OK, waiting for IP...");
  }
}

void setupModbus() {
  Serial2.begin(MODBUS_BAUD, SERIAL_8N1, UART_RX_PIN, UART_TX_PIN);
  // The library controls DE/RE for half-duplex when given a driver enable pin
  mb.begin(&Serial2, RS485_DE_RE_PIN);
  mb.master();
}

uint32_t lastPoll = 0;

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println("\nESP32 Modbus Energy Logger (Ethernet + RS485)");

  pinMode(RS485_DE_RE_PIN, OUTPUT);
  digitalWrite(RS485_DE_RE_PIN, LOW); // default to receive

  setupEthernet();
  setupModbus();

  server.on("/", HTTP_GET, handleRoot);
  server.on("/api/metrics", HTTP_GET, handleMetrics);
  server.begin();
}

void loop() {
  // Ethernet/WebServer
  server.handleClient();

  // Modbus task processing (non-blocking)
  mb.task();

  // Polling
  uint32_t now = millis();
  if (now - lastPoll >= POLL_INTERVAL_MS) {
    lastPoll = now;
    if (!pollMeter()) {
      Serial.println("Modbus polling failed (timeout or CRC)");
    } else {
      Serial.printf("V=%.2f V, I=%.3f A, P=%.1f W, f=%.2f Hz, E=%.3f kWh\n",
                    meas.voltage, meas.current, meas.activePower, meas.frequency, meas.importKWh);
      postInflux();
    }
  }
}

Key points:
– We use ETH.h with LAN8720 and board-defined pins to bring up Ethernet via lwIP.
– ModbusRTU library is used in master mode over Serial2 with a DE/RE control pin.
– The readFloatInputRegister() method performs a two-register read and combines them into a float assuming big-endian word order (common in SDM meters). If your meter uses swapped words, swap result[0]/result[1] before combining.


Build/Flash/Run Commands

Assuming you have PlatformIO Core installed (pip install -U platformio), run:

pio --version

# Create the project structure (run in an empty folder)
pio project init --board esp32-ethernet-kit

# Place platformio.ini and src/main.cpp as shown above

# Build
pio run

# List serial ports (Windows: use COMx)
pio device list

# Flash (replace with your port if needed)
pio run -t upload

# Monitor serial output
pio device monitor -b 115200

Optional: pin the upload and monitor ports in platformio.ini with upload_port/monitor_port for convenience.


Step-by-step Validation

Follow these steps to validate Ethernet connectivity, Modbus polling, and the REST endpoint.

1) Physical checks
– Ensure the RJ45 cable is connected, and link/activity LEDs on your switch are lit.
– Confirm MAX3485 VCC is 3.3 V, GND is common, and DI/RO/DE-RE pins are correctly wired.
– RS485 A/B lines go to the meter’s A/B. Add a 120 Ω terminator across A-B if needed. Avoid star topologies; RS485 prefers bus topology.

2) Serial monitor bring-up
– Open the serial monitor:
– You should see:
– “ESP32 Modbus Energy Logger (Ethernet + RS485)”
– “ETH begin OK, waiting for IP…”
– “ETH connected (link up)”
– “ETH got IP: 192.168.x.y”
– If you don’t see “got IP”, check DHCP on your LAN. You can set a static IP if required using ETH.config() before ETH.begin().

3) Ping the device
– From your PC:

ping 192.168.x.y
  • Expect replies. If not, verify cabling and switch/router.

4) REST API endpoint
– Query the root and metrics endpoint:

curl -s http://192.168.x.y/
curl -s http://192.168.x.y/api/metrics | jq .
  • You should see JSON like:
  • modbus_ok: true/false
  • last_update_ms: numeric
  • data fields: voltage_V, current_A, active_power_W, frequency_Hz, import_kWh

5) Modbus validation against the energy meter
– Set your meter address to 1 (or adjust MODBUS_SLAVE_ID in code).
– Confirm baud rate (default used: 9600 8N1). Match meter settings (change MODBUS_BAUD if necessary).
– If values look unrealistic or modbus_ok=false:
– Swap A/B lines and retry.
– Verify termination and bias. If the line is very short and there is built-in termination at the meter, remove external terminator.
– Check the register map for your specific meter. If your device uses function 03 (Holding Registers), replace readIreg with readHreg and adjust addresses.
– If numbers are nonsensical (e.g., extremely large or small), swap the 16-bit words before combining to float inside readFloatInputRegister().

6) Cross-check values with a USB-RS485 dongle (optional)
– Using a PC and a USB-RS485 adapter, run a Modbus client (e.g., “modpoll”):

# Example reading 2 input registers at 0 (voltage) from slave 1 at 9600 baud
modpoll -b 9600 -p none -m rtu -a 1 -r 0 -c 2 -1 /dev/ttyUSB0
  • Compare results with the ESP32 output to confirm correctness and endianness.

7) InfluxDB (optional)
– Set INFLUX_ENABLE = true and configure INFLUX_URL, INFLUX_TOKEN, bucket/org in code.
– Rebuild/flash. On successful POST you should see “Influx POST: HTTP 204/204” in serial logs.
– Confirm data appears in InfluxDB via the UI (query the specified bucket).


Troubleshooting

  • No IP address:
  • Check RJ45 cable, switch port, and DHCP. Confirm that link LEDs turn on.
  • Ensure ETH_CLK_MODE is correct. For ESP32-Ethernet-Kit, ETH_CLOCK_GPIO0_IN is typical. Some kit variants require GPIO17_OUT. If link won’t come up, try:
    • Change build_flags to -D ETH_CLK_MODE=ETH_CLOCK_GPIO17_OUT and ensure your board wiring supports it.
  • Verify PHY address (usually 0). If unknown, try 1.

  • Ethernet begins but disconnects repeatedly:

  • Power/USB current might be insufficient. Use a powered USB hub or stable 5 V source.
  • Check that GPIO12 is actually wired to PHY power enable on your hardware revision. If not, set power pin to -1 in ETH.begin().

  • Modbus polling fails (modbus_ok=false):

  • A/B reversed; swap them.
  • Baud/parity mismatch; update MODBUS_BAUD and the SERIAL_8N1 format if your meter uses e.g., 8E1 (use SERIAL_8E1).
  • Missing termination or bias: add 120 Ω across A-B at the bus end and bias resistors if needed.
  • Slave ID mismatch: set MODBUS_SLAVE_ID to the meter’s address.
  • Different function code: if your meter uses Holding Registers, replace readIreg with readHreg and confirm addresses.

  • Garbage or unrealistic values:

  • Endianness issue. Try swapping the 16-bit words:
    • raw = ((uint32_t)result[1] << 16) | result[0];
  • Wrong register map. Re-check the meter’s manual.

  • Boot issues:

  • Some GPIOs are strapping pins (e.g., GPIO0, GPIO12). Avoid driving them at boot. We use GPIO33 for DE/RE to avoid conflicts.

  • Serial upload fails (Windows/macOS):

  • Install CP210x VCP drivers.
  • Close other serial terminal apps. Use pio device list to find the correct COM/tty.
  • Press and hold BOOT if auto-bootloader fails (rare on this board).

Improvements

  • Modbus TCP bridge: expose a Modbus TCP server on port 502 that proxies to the RS485 RTU bus, enabling SCADA tools to poll over Ethernet.
  • Multi-slave polling: support multiple meters with different slave IDs and distinct register sets; cache results and provide them via separate REST endpoints (/api/metrics/).
  • Persistent configuration: add a JSON config stored in NVS, editable via a minimal web UI (meter address, baud/parity, poll interval, register list).
  • Time synchronization: use NTP over Ethernet to timestamp metrics and send with ns precision to InfluxDB.
  • Security: move REST to HTTPS (client-side TLS is supported via WiFiClientSecure over Ethernet stack) and add API tokens.
  • Prometheus exporter: expose /metrics in Prometheus text format for scraping by Prometheus/Grafana.
  • Watchdog and diagnostics: add a periodic task to detect Modbus stalls and reinitialize UART/driver. Provide counters for CRC errors, timeouts, and successful polls.

Final Checklist

  • PlatformIO environment:
  • platformio.ini created with board=esp32-ethernet-kit and pinned platform/framework versions
  • Libraries installed: modbus-esp8266, ArduinoJson
  • Hardware connections:
  • ESP32-Ethernet-Kit connected to LAN with DHCP
  • MAX3485 powered at 3.3 V, GND common
  • RO->GPIO16, DI->GPIO17, DE+RE#->GPIO33
  • RS485 A/B to meter, termination and bias set appropriately
  • Code configuration:
  • Set MODBUS_SLAVE_ID and MODBUS_BAUD to match your meter
  • Confirm register addresses for your meter; adjust if using Holding Registers or different map
  • If needed, swap word order in float decoding
  • Optional: configure InfluxDB URL/token and enable INFLUX_ENABLE
  • Build/flash:
  • pio run, pio run -t upload, pio device monitor -b 115200
  • Validation:
  • Serial log shows “ETH got IP: …”
  • curl http:///api/metrics returns JSON with reasonable values
  • Optional: InfluxDB receives line-protocol writes (HTTP 204)
  • Troubleshooting readiness:
  • Know how to swap A/B, adjust termination, change UART parity, and try ETH clock mode alternatives if link issues arise

With this setup, your ESP32-Ethernet-Kit + LAN8720 + MAX3485 RS485 serves as a robust Modbus RTU energy logger over Ethernet, ready for integration into monitoring systems or dashboards.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the primary function of the ESP32-Ethernet-Kit in this project?




Question 2: Which transceiver is used for half-duplex RS485 communication?




Question 3: What is the purpose of the LAN8720 in the project?




Question 4: Which programming environment is suggested for this project?




Question 5: What type of metrics does the Modbus energy meter read?




Question 6: What is required to control DE/RE for half-duplex timing?




Question 7: Which operating systems are compatible with this project?




Question 8: What is the minimum version of PlatformIO Core required?




Question 9: What type of cable is needed for Ethernet connection?




Question 10: What role does the ESP32 play in the Modbus communication?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me: