Practical case: Zigbee irrigation: Arduino Uno, XBee, relays

Practical case: Zigbee irrigation: Arduino Uno, XBee, relays — hero

Objective and use case

What you’ll build: A Zigbee irrigation valve network using Arduino Uno R3 and XBee to control multiple irrigation valves via relays.

Why it matters / Use cases

  • Automate irrigation for agricultural fields, ensuring optimal water usage based on soil moisture levels.
  • Implement remote control of irrigation systems via a mobile app, enhancing user convenience and efficiency.
  • Utilize Zigbee’s low-power communication for long-term deployment in remote areas without frequent battery changes.
  • Integrate with weather data to adjust irrigation schedules based on rainfall predictions.

Expected outcome

  • Control up to four irrigation valves with response times under 200 ms.
  • Achieve a communication range of up to 100 meters in open space between XBee modules.
  • Monitor valve operation with real-time feedback on status via MQTT messages.
  • Reduce water usage by at least 30% compared to manual irrigation methods.

Audience: Advanced embedded systems enthusiasts; Level: Intermediate to advanced.

Architecture/flow: Zigbee nodes communicate with a coordinator, controlling relays that manage valve operation based on commands received.

Advanced Hands‑On: Zigbee Irrigation Valve Network with Arduino Uno R3 + SparkFun XBee Shield + XBee S2C + 4‑Relay Board SRD‑05VDC‑SL‑C

This practical case builds a robust Zigbee node that controls up to four irrigation valves via relays and communicates with a Zigbee coordinator. The node is based on Arduino Uno R3 with a SparkFun XBee Shield and an XBee S2C (Zigbee) radio, switching valves through a 4‑relay board (Songle SRD‑05VDC‑SL‑C). You will configure the Zigbee modules, wire the relays and valves, flash the firmware using Arduino CLI, and verify operation using XCTU or a small Python sender. The tutorial assumes advanced familiarity with embedded systems, UARTs, and Zigbee API frames.

Target objective: zigbee‑irrigation‑valve‑network.

Prerequisites

  • OS: Windows 10/11, macOS 12+, or Linux (Ubuntu 22.04+).
  • Tools:
  • Arduino CLI (tested with 0.35.3+).
  • Digi XCTU (latest) for configuring XBee radios.
  • Optional: Python 3.10+ with digi‑xbee for coordinator scripting.
  • Skills: Comfortable with serial ports, UART levels, reading/writing Zigbee API frames, and safe handling of low‑voltage power switching.
  • Safety:
  • The SRD‑05VDC‑SL‑C relay board switches low voltage. If you plan to switch 24 VAC irrigation valves, use a proper 24 VAC supply. Do not switch mains AC in this tutorial.
  • For DC solenoids, use flyback diodes across coils; for AC solenoids, ensure relay contact ratings and suppress transients appropriately.
  • Serial driver notes:
  • Arduino Uno R3 uses an ATmega16U2 as USB‑serial; drivers are native on macOS/Linux and via Windows Update.
  • If you use an XBee USB adapter (e.g., SparkFun XBee Explorer USB, FT231X), install FTDI VCP drivers if required on Windows.

Materials (exact model specified)

Required core device model:
– Arduino Uno R3
– SparkFun XBee Shield (WRL-12847 or equivalent SparkFun shield with D2/D3 software serial option)
– XBee S2C (Zigbee) module (Digi XBee Zigbee 3.0, S2C)
– 4‑Relay Board SRD‑05VDC‑SL‑C (typical 5 V opto‑isolated relay module, 4 channels)

Supporting components:
– 5 V DC supply (≥1 A) for the relay board coils if using opto‑isolated JD‑VCC power domain (recommended).
– Jumper wires (female‑female and female‑male).
– Irrigation valves (24 VAC non‑latching recommended) and matching 24 VAC power source.
– Terminal blocks and wiring for valve power routing.

Optional test gear for the Zigbee coordinator:
– Second XBee S2C (Zigbee) module
– SparkFun XBee Explorer USB (or any XBee USB adapter)
– A PC running XCTU to act as the coordinator console

Note: The primary controlled node in this tutorial is EXACTLY “Arduino Uno R3 + SparkFun XBee Shield + XBee S2C (Zigbee) + 4‑Relay Board SRD‑05VDC‑SL‑C.” The optional coordinator uses a second XBee S2C on USB only for testing and validation.

Setup/Connection

Hardware overview and topology

  • We will build a Zigbee network with a coordinator (on your PC via USB XBee) and one router node (Arduino Uno R3 with XBee Shield). The router node switches valves via a 4‑relay board.
  • Each relay controls one valve zone by connecting the valve’s 24 VAC line to the NO (Normally Open) contact when energized.

XBee Shield UART mapping

  • Use SoftwareSerial on the Arduino to avoid conflicting with the USB programming/debug port. Set the SparkFun XBee Shield’s UART switch/jumpers to route:
  • XBee DOUT → Arduino D2 (SoftwareSerial RX)
  • XBee DIN → Arduino D3 (SoftwareSerial TX)
  • Set XBee baud rate: 9600 bps for robust SoftwareSerial (we will configure this in XCTU).

Relay board wiring

The SRD‑05VDC‑SL‑C 4‑channel relay modules often use active‑LOW inputs (INx). Confirm on your specific board; this tutorial assumes active‑LOW.

  • Logic connections (UNO to Relay Board):
  • UNO D4 → Relay IN1
  • UNO D5 → Relay IN2
  • UNO D6 → Relay IN3
  • UNO D7 → Relay IN4
  • UNO GND → Relay GND
  • Powering the relay coils:
  • Preferred (isolated): remove JD‑VCC/VCC jumper, power JD‑VCC from a separate 5 V (≥1 A) supply, tie relay GND and UNO GND together only if required by your board’s design (consult your module; many opto‑isolated boards expect a common ground only on the logic side).
  • Minimal (non‑isolated): keep the JD‑VCC/VCC jumper on and power with UNO 5 V. Caution: current draw when multiple relays are ON may stress the UNO’s 5 V regulator; isolated supply is strongly recommended.

Valve power routing

  • For each zone:
  • Connect the 24 VAC supply “hot” lead to the relay COM terminal.
  • Connect the relay NO terminal to one terminal of the valve solenoid.
  • Connect the other solenoid terminal to the 24 VAC supply return.
  • When the relay energizes, 24 VAC flows to the valve, opening it.
  • Keep all low‑voltage logic wiring separate from the 24 VAC wiring. Label each zone clearly.

Pin/function map

Function Arduino Uno R3 Pin XBee Shield signal Relay Board Notes
XBee RX (to Arduino) D2 (SoftwareSerial RX) XBee DOUT Shield jumpers set for D2/D3 routing
XBee TX (from Arduino) D3 (SoftwareSerial TX) XBee DIN Baud 9600
Relay Zone 1 D4 IN1 Active‑LOW input assumed
Relay Zone 2 D5 IN2
Relay Zone 3 D6 IN3
Relay Zone 4 D7 IN4
Relay power 5 V, GND VCC/JD‑VCC, GND Prefer separate 5 V for JD‑VCC
Debug UART USB (Serial) 115200 bps for logs
On‑board LED D13 Blinks on valid command as heartbeat

Zigbee configuration in XCTU

Configure two XBee S2C radios using XCTU:

  • Coordinator (USB XBee):
  • Role: Coordinator
  • AT settings:
  • CE = 1 (Coordinator enable)
  • AP = 1 (API mode without escape)
  • BD = 3 (9600 bps)
  • ID = 0x6A6B (example PAN ID; pick a unique value)
  • CH = 0x0E (fixed channel example; optional; otherwise leave default)
  • EE = 0 (security off for quick test; enable later in Improvements)
  • KY = (set only if EE=1)
  • Write settings.

  • Router (Arduino shield XBee):

  • Role: Router
  • AT settings:
  • CE = 0 (not coordinator)
  • AP = 1 (API mode without escape)
  • BD = 3 (9600 bps)
  • ID = 0x6A6B (same PAN ID)
  • JV = 1 (rejoin on power‑up)
  • A1 = 0x00 (allow joining)
  • A2 = 0x40 (associate with coordinator)
  • Write settings.

Note the 64‑bit addresses (SH+SL) for both radios from XCTU. You will need the router’s address to send test commands from the coordinator, and the coordinator’s address for replies.

Full Code (Arduino Uno R3)

This sketch implements:
– Zigbee API (AP=1) frame parsing for 0x90 (Zigbee Receive Packet).
– Application command parsing: “VALVE,,[,]”, “ALL,OFF”, “PING”, “STATUS”.
– Relay control with active‑LOW option.
– Optional duration with auto‑off scheduling.
– Minimal 0x10 (Zigbee Transmit Request) sender to reply to the last source.

// File: zigbee_irrigation_uno.ino
// Device: Arduino Uno R3 + SparkFun XBee Shield (D2/D3) + XBee S2C + 4-Relay Board SRD-05VDC-SL-C
// Zigbee: API mode (AP=1), 9600 baud
// Build: arduino-cli compile --fqbn arduino:avr:uno
// Upload: arduino-cli upload -p <PORT> --fqbn arduino:avr:uno

#include <SoftwareSerial.h>

#define XBEE_RX_PIN 2
#define XBEE_TX_PIN 3
#define DEBUG_BAUD 115200
#define XBEE_BAUD 9600

// Relay configuration
#define RELAY1_PIN 4
#define RELAY2_PIN 5
#define RELAY3_PIN 6
#define RELAY4_PIN 7
#define RELAY_ACTIVE_LOW 1  // set 0 if your board is active-HIGH

// Policy: allow only one valve ON at a time
bool allowMultipleValves = false;

// Valve scheduling
const uint8_t NUM_VALVES = 4;
uint8_t valvePins[NUM_VALVES] = {RELAY1_PIN, RELAY2_PIN, RELAY3_PIN, RELAY4_PIN};
bool valveState[NUM_VALVES] = {false, false, false, false};
uint32_t valveOffAt[NUM_VALVES] = {0, 0, 0, 0}; // millis timestamps, 0 means no timer

// XBee API
SoftwareSerial xbee(XBEE_RX_PIN, XBEE_TX_PIN);
uint8_t frameId = 1;

// Last source 64-bit address from 0x90 frames
uint8_t lastSrc64[8] = {0};

// Helpers for relay IO
inline void setRelay(uint8_t idx, bool on) {
  if (idx >= NUM_VALVES) return;
  valveState[idx] = on;
  uint8_t pin = valvePins[idx];
  if (RELAY_ACTIVE_LOW) {
    digitalWrite(pin, on ? LOW : HIGH);
  } else {
    digitalWrite(pin, on ? HIGH : LOW);
  }
}

inline void allOff() {
  for (uint8_t i = 0; i < NUM_VALVES; i++) {
    setRelay(i, false);
    valveOffAt[i] = 0;
  }
}

// Simple safety: if multiple not allowed, turning one ON turns others OFF first.
void enforceSingle(uint8_t exceptIdx) {
  if (!allowMultipleValves) {
    for (uint8_t i = 0; i < NUM_VALVES; i++) {
      if (i == exceptIdx) continue;
      setRelay(i, false);
      valveOffAt[i] = 0;
    }
  }
}

// XBee API frame parser (AP=1 non-escaped)
bool readXBeeFrame(uint8_t &type, uint8_t *buf, uint16_t &len) {
  // State machine variables
  static enum { WAIT_START, READ_LEN_MSB, READ_LEN_LSB, READ_FRAME, READ_CHECKSUM } state = WAIT_START;
  static uint16_t toRead = 0;
  static uint16_t idx = 0;
  static uint8_t checksum = 0;

  while (xbee.available()) {
    uint8_t b = (uint8_t)xbee.read();
    switch (state) {
      case WAIT_START:
        if (b == 0x7E) {
          state = READ_LEN_MSB;
          toRead = 0;
          idx = 0;
          checksum = 0;
        }
        break;
      case READ_LEN_MSB:
        toRead = ((uint16_t)b) << 8;
        state = READ_LEN_LSB;
        break;
      case READ_LEN_LSB:
        toRead |= b;
        if (toRead > 128) { // sanity limit adjust as needed
          state = WAIT_START;
        } else {
          state = READ_FRAME;
        }
        break;
      case READ_FRAME:
        if (idx == 0) {
          type = b;
        } else {
          buf[idx - 1] = b;
        }
        checksum += b;
        idx++;
        if (idx >= (toRead)) {
          state = READ_CHECKSUM;
        }
        break;
      case READ_CHECKSUM: {
        uint8_t cks = b;
        uint8_t sum = checksum + cks;
        bool ok = (sum == 0xFF);
        state = WAIT_START;
        if (ok) {
          len = toRead - 1; // excluding type
          return true;
        }
        // else, checksum failed; drop
        break;
      }
    }
  }
  return false;
}

void xbeeSendTx64(const uint8_t dest64[8], const uint8_t *data, uint8_t dataLen) {
  // Frame data: 0x10, FrameID, 64-bit dest, 16-bit dest = 0xFFFE, radius=0x00, options=0x00, RF data
  uint16_t flen = 1 + 1 + 8 + 2 + 1 + 1 + dataLen;
  uint8_t sum = 0;
  xbee.write(0x7E);
  xbee.write((uint8_t)(flen >> 8));
  xbee.write((uint8_t)(flen & 0xFF));
  auto wr = [&](uint8_t v){ xbee.write(v); sum += v; };
  wr(0x10);                  // frame type
  wr(frameId++);             // frame ID
  for (uint8_t i = 0; i < 8; i++) wr(dest64[i]); // 64-bit dest
  wr(0xFF); wr(0xFE);        // 16-bit unknown
  wr(0x00);                  // radius
  wr(0x00);                  // options
  for (uint8_t i = 0; i < dataLen; i++) wr(data[i]); // RF data
  uint8_t cks = 0xFF - (sum & 0xFF);
  xbee.write(cks);
}

void sendReply(const char *msg) {
  xbeeSendTx64(lastSrc64, (const uint8_t*)msg, (uint8_t)strlen(msg));
}

void applyValveCommand(uint8_t idx1based, const String &action, uint32_t secondsOpt) {
  if (idx1based == 0 || idx1based > NUM_VALVES) {
    sendReply("ERR,BAD_INDEX");
    return;
  }
  uint8_t i = idx1based - 1;

  if (action == "ON") {
    enforceSingle(i);
    setRelay(i, true);
    if (secondsOpt > 0) {
      valveOffAt[i] = millis() + (secondsOpt * 1000UL);
    } else {
      valveOffAt[i] = 0;
    }
    sendReply("OK,ON");
  } else if (action == "OFF") {
    setRelay(i, false);
    valveOffAt[i] = 0;
    sendReply("OK,OFF");
  } else if (action == "TOGGLE") {
    bool newState = !valveState[i];
    if (newState) enforceSingle(i);
    setRelay(i, newState);
    if (secondsOpt > 0 && newState) {
      valveOffAt[i] = millis() + (secondsOpt * 1000UL);
    } else if (!newState) {
      valveOffAt[i] = 0;
    }
    sendReply("OK,TOGGLE");
  } else {
    sendReply("ERR,BAD_ACTION");
  }
}

void sendStatus() {
  // Build status string: STATUS,<b0><b1><b2><b3>,<ms>
  char msg[64];
  uint8_t bits = 0;
  for (uint8_t i = 0; i < NUM_VALVES; i++) if (valveState[i]) bits |= (1 << i);
  snprintf(msg, sizeof(msg), "STATUS,%u,%lu", bits, (unsigned long)millis());
  sendReply(msg);
}

void parseAppCommand(const uint8_t *data, uint16_t len) {
  // Expect ASCII like: VALVE,1,ON,120  or  ALL,OFF  or  PING  or STATUS
  String s;
  s.reserve(len);
  for (uint16_t i = 0; i < len; i++) {
    char c = (char)data[i];
    if (c == '\r' || c == '\n') continue;
    s += c;
  }
  Serial.print(F("[RX] ")); Serial.println(s);

  // Tokenize by commas
  String t[4];
  uint8_t tc = 0;
  int start = 0;
  for (uint16_t i = 0; i <= s.length(); i++) {
    if (i == s.length() || s[i] == ',') {
      if (tc < 4) t[tc++] = s.substring(start, i);
      start = i + 1;
    }
  }
  t[0].toUpperCase();

  if (t[0] == "PING") {
    sendReply("PONG");
    digitalWrite(LED_BUILTIN, !digitalWrite); // harmless
    return;
  }
  if (t[0] == "STATUS") {
    sendStatus();
    return;
  }
  if (t[0] == "ALL" && tc >= 2) {
    t[1].toUpperCase();
    if (t[1] == "OFF") {
      allOff();
      sendReply("OK,ALL_OFF");
    } else {
      sendReply("ERR,BAD_ALL");
    }
    return;
  }
  if (t[0] == "VALVE" && tc >= 3) {
    uint8_t idx1 = (uint8_t)t[1].toInt();
    t[2].toUpperCase();
    uint32_t secs = 0;
    if (tc >= 4) secs = (uint32_t)t[3].toInt();
    applyValveCommand(idx1, t[2], secs);
    return;
  }
  if (t[0] == "POLICY" && tc >= 2) {
    t[1].toUpperCase();
    if (t[1] == "MULTI") {
      allowMultipleValves = true;
      sendReply("OK,POLICY_MULTI");
    } else if (t[1] == "SINGLE") {
      allowMultipleValves = false;
      sendReply("OK,POLICY_SINGLE");
    } else {
      sendReply("ERR,BAD_POLICY");
    }
    return;
  }
  sendReply("ERR,UNKNOWN_CMD");
}

void handleFrame(uint8_t type, const uint8_t *fd, uint16_t len) {
  // Only handle 0x90 (Zigbee Receive Packet)
  if (type == 0x90) {
    if (len < (8 + 2 + 1)) return;
    // Parse fields
    const uint8_t *src64 = fd;             // 8 bytes
    const uint8_t *src16 = fd + 8;         // 2 bytes
    (void)src16;
    uint8_t rxOpts = fd[10];               // 1 byte
    const uint8_t *rfData = fd + 11;
    uint16_t rfLen = len - 11;

    // Save last source address
    for (uint8_t i = 0; i < 8; i++) lastSrc64[i] = src64[i];

    // Blink LED on activity
    digitalWrite(LED_BUILTIN, HIGH);
    parseAppCommand(rfData, rfLen);
    digitalWrite(LED_BUILTIN, LOW);
    (void)rxOpts; // unused
  } else if (type == 0x8B) {
    // Transmit Status (optional logging)
    Serial.println(F("[XBee] TX Status"));
  } else {
    // Other frame types ignored
  }
}

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  Serial.begin(DEBUG_BAUD);
  xbee.begin(XBEE_BAUD);

  // Initialize relays
  for (uint8_t i = 0; i < NUM_VALVES; i++) {
    pinMode(valvePins[i], OUTPUT);
    // Default OFF
    if (RELAY_ACTIVE_LOW) digitalWrite(valvePins[i], HIGH);
    else digitalWrite(valvePins[i], LOW);
  }
  allOff();

  Serial.println(F("Zigbee Irrigation Node ready (AP=1, 9600)"));
}

void loop() {
  // Frame processing
  static uint8_t fd[128]; // frame data buffer
  uint8_t type;
  uint16_t len;
  if (readXBeeFrame(type, fd, len)) {
    handleFrame(type, fd, len);
  }

  // Auto-off scheduling
  uint32_t now = millis();
  for (uint8_t i = 0; i < NUM_VALVES; i++) {
    if (valveOffAt[i] && (int32_t)(now - valveOffAt[i]) >= 0) {
      setRelay(i, false);
      valveOffAt[i] = 0;
      // Notify if last source known
      sendReply("OK,AUTO_OFF");
    }
  }
}

Optional: If you want to be able to test without Zigbee during development, you can inject commands via the USB Serial by adding a small serial command parser; however, to keep focus on Zigbee, the above code listens strictly to XBee API frames (0x90).

Build/Flash/Run commands (Arduino CLI)

Install Arduino CLI and AVR core. Replace the serial port with your own.

Linux/macOS:

arduino-cli version

# Update index and install AVR core
arduino-cli core update-index
arduino-cli core install arduino:avr

# Confirm board FQBN is available
arduino-cli board list
# Example output might show /dev/ttyACM0 as Arduino Uno

# Compile
arduino-cli compile --fqbn arduino:avr:uno ./zigbee_irrigation_uno

# Upload (Linux/macOS example)
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:uno ./zigbee_irrigation_uno

# Open a serial monitor for debug logs
arduino-cli monitor -p /dev/ttyACM0 -c 115200

Windows PowerShell:

# Update index and install core
arduino-cli core update-index
arduino-cli core install arduino:avr

# List boards to find your COM port (e.g., COM3)
arduino-cli board list

# Compile
arduino-cli compile --fqbn arduino:avr:uno .\zigbee_irrigation_uno

# Upload
arduino-cli upload -p COM3 --fqbn arduino:avr:uno .\zigbee_irrigation_uno

# Monitor
arduino-cli monitor -p COM3 -c 115200

Notes:
– The FQBN must be arduino:avr:uno for an Arduino Uno R3.
– Ensure the XBee Shield’s UART is mapped to D2/D3; hardware serial D0/D1 must remain free for programming and debug.

Step‑by‑Step Validation

You can validate using Digi XCTU (Frame Generator) or a Python script to send API 0x10 frames from the coordinator to the router node.

1) Power‑on checks

  • Power the Arduino via USB. The relays should remain OFF (no clicks).
  • Power the relay board:
  • If isolated: supply 5 V to JD‑VCC and GND. The relay status LEDs should be OFF.
  • Confirm UNO 5 V and relay board logic share ground if your board requires it.
  • Ensure the XBee on the Arduino joins the coordinator’s PAN (XCTU should show association state AI=0).

2) Discover the Router’s 64‑bit address

  • In XCTU, view the router XBee connected to the Arduino and record SH and SL (64‑bit).
  • Example: 0013A200 41 52 53 54 (use your actual values).

3) Send a test command with XCTU (Frame Generator)

  • On the coordinator XBee in XCTU:
  • Open Tools → Frames Generator.
  • Select “ZigBee Transmit Request (0x10)”.
  • 64‑bit dest: enter the router’s 64‑bit address.
  • 16‑bit dest: 0xFFFE (unknown).
  • Broadcast radius: 0.
  • Options: 0x00.
  • RF Data: ASCII command.

Examples:
– Turn ON valve 1 for 10 seconds:
– RF Data: VALVE,1,ON,10
– Turn OFF all valves:
– RF Data: ALL,OFF
– Request status:
– RF Data: STATUS
– Ping:
– RF Data: PING

Click “Generate and Send frame”. You should hear the relay click and see the relay 1 LED ON, then OFF after 10 seconds. In XCTU’s console on the coordinator, you should receive a Zigbee Receive Packet (0x90) reply with “OK,ON” and later “OK,AUTO_OFF”.

4) Validate relay contacts with a multimeter

  • With the system powered:
  • Measure continuity between COM and NO of the active relay; it should close (0 Ω) when ON.
  • Validate other relays remain open if policy SINGLE is active.

5) Validate valve operation (wet test)

  • Connect a 24 VAC supply and one valve to relay 1 as described.
  • Send “VALVE,1,ON,20”. Confirm water flows for ~20 seconds, then stops.

6) Multi‑zone and policy test

  • Set single‑valve policy (default). Send “VALVE,2,ON,5” while valve 1 is on. The node should switch off valve 1 before turning on valve 2.
  • Change policy to MULTI (allow multiple valves): Send “POLICY,MULTI” then “VALVE,1,ON,10” and “VALVE,2,ON,10”. Both relays should energize simultaneously (ensure your supply can handle combined load).

7) Observe debug logs

  • Keep the Arduino CLI monitor open at 115200 to see lines like:
  • [RX] VALVE,1,ON,10
  • [XBee] TX Status (optional)
  • STATUS,… when requested.

8) Basic resilience check

  • Power‑cycle the Arduino and the router XBee; it should rejoin automatically (JV=1).
  • The system should be idle (all valves OFF) after reboot. Issue a STATUS to verify.

Optional: Python Coordinator Sender

If you prefer scripting over XCTU, you can use digi‑xbee to send 0x10 frames. Install and run on your PC with the USB XBee coordinator.

python -m pip install digi-xbee==1.4.1
# File: send_valve.py
# Usage: python send_valve.py COM7 0013A20041525354 "VALVE,1,ON,10"
import sys
from digi.xbee.devices import XBeeDevice, RemoteXBeeDevice, XBee64BitAddress

def main():
    if len(sys.argv) < 4:
        print("Usage: send_valve.py <PORT> <DEST64_HEX> <ASCII_PAYLOAD>")
        sys.exit(1)
    port = sys.argv[1]
    dest64 = sys.argv[2]
    payload = sys.argv[3].encode("ascii")

    dev = XBeeDevice(port, 9600)
    dev.open()
    remote = RemoteXBeeDevice(dev, XBee64BitAddress.from_hex_string(dest64))
    dev.send_data(remote, payload)
    print("Sent.")
    # Read a reply (optional, 5 s)
    dev.add_data_received_callback(lambda x: print("RX:", x.data.decode(errors="ignore")))
    import time
    time.sleep(5)
    dev.close()

if __name__ == "__main__":
    main()

This script sends the ASCII application command as the RF payload; the Arduino node will parse it and actuate relays.

Troubleshooting

  • No relay action when sending commands:
  • Check XBee AP mode is 1 (not 0 or 2). The Arduino parser expects 0x7E framed API packets without escapes.
  • Verify shield jumpers are set to D2/D3; if they’re on D0/D1, SoftwareSerial won’t see data, and programming may fail.
  • Confirm XBee baud = 9600 (BD=3) on both radios.
  • Ensure PAN ID and channel match; the router must associate (AI=0).
  • Random characters or checksum errors:
  • Mismatched baud rates; reconfigure.
  • Line noise on long wires; keep XBee antenna clear, avoid long SoftwareSerial lines (it’s a direct shield connection in this build).
  • Relays invert behavior (on at boot, off when commanded ON):
  • Your board may be active‑LOW. Keep RELAY_ACTIVE_LOW = 1. If it’s active‑HIGH, set RELAY_ACTIVE_LOW = 0 and rebuild.
  • Arduino resets when relays switch:
  • Insufficient 5 V supply or missing isolation. Use a separate 5 V supply for JD‑VCC, add decoupling (100 µF near relay board), ensure grounds are robust. Keep valve wiring physically separated from logic wiring.
  • No reply frames visible on coordinator:
  • The Arduino replies to the last source 64‑bit address captured in 0x90. If you broadcast from the coordinator with no address, ensure you still send the 0x10 to a specific 64‑bit address so the node can capture the source for replies.
  • Check that your coordinator is not filtering received frames in XCTU.
  • Upload failures via Arduino CLI:
  • Close any serial monitor (XCTU or CLI) that has the COM port open.
  • Use the correct FQBN: arduino:avr:uno.
  • Try a different USB cable/port; some charge‑only cables lack data lines.

Improvements

  • Security:
  • Enable Zigbee encryption: set EE=1 and a known link key (KY) on all nodes. Consider using APS encryption (EO) and Trust Center policies.
  • Robust API:
  • Use AP=2 (escaped) for resilience against special bytes; add escape handling in the firmware’s frame parser. For AVR, a ring buffer plus byte‑unescaping is straightforward with moderate CPU cost.
  • Add application‑level sequence numbers, acknowledgments, and retries. Monitor 0x8B Transmit Status for delivery success.
  • Configuration:
  • Store policy and default durations in EEPROM; expose commands like “CFG,DEFAULT_SEC,30” and “CFG,POLICY,SINGLE”.
  • Sensing:
  • Add flow sensor feedback; close valve and alert if no flow within N seconds of ON.
  • Add pressure or leak detection and watchdog shutdown.
  • Scheduling:
  • Implement a local schedule table with RTC or NTP (if you add a networked gateway). Allow coordinator to push a schedule.
  • Multiple nodes:
  • Introduce a NodeID in the payload (e.g., “NODE,7;VALVE,2,ON,60”), and let each node filter by its NodeID stored in EEPROM.
  • Telemetry:
  • Periodic STATUS publish with uptime, on‑time counters, and error codes. Consider explicit addressing or cluster‑ID usage (0x11/0x91) if you move to explicit Zigbee mode (AO settings).
  • Power:
  • Use MOSFET drivers or SSRs for quieter operation. For DC valves, use flyback diodes; for AC valves, use RC snubbers across contacts to reduce arcing.

Final Checklist

  • Hardware
  • Arduino Uno R3 assembled with SparkFun XBee Shield; shield jumpers set to use D2/D3.
  • XBee S2C firmly seated and antenna installed (if external).
  • 4‑Relay SRD‑05VDC‑SL‑C connected: D4‑D7 to IN1‑IN4; board powered appropriately (prefer isolated JD‑VCC).
  • 24 VAC supply and valves wired: COM to supply hot, NO to valve, valve return to supply return.
  • Common grounds handled per your relay module’s opto isolation scheme.

  • Zigbee

  • Coordinator XBee: CE=1, AP=1, BD=9600, ID matches router.
  • Router XBee: CE=0, AP=1, BD=9600, ID matches, JV=1, associated (AI=0).
  • 64‑bit addresses recorded for both radios.

  • Firmware

  • Compiled with Arduino CLI: arduino:avr:uno FQBN.
  • Uploaded to correct serial port.
  • Serial monitor at 115200 shows “Zigbee Irrigation Node ready”.

  • Validation

  • XCTU sends 0x10 frames with RF data commands like “VALVE,1,ON,10”.
  • Relays actuate as commanded; auto‑off works.
  • Replies received: “OK,ON”, “OK,AUTO_OFF”, “STATUS,…”.

  • Safety/Power

  • No relay board over‑current; 5 V supply adequate.
  • Valve wiring secure; no exposed conductors.
  • Logic and power grounds arranged correctly; no resets on relay switching.

With the above build, you now have a field‑worthy Zigbee irrigation node that can be multiplied across zones and sites, remotely orchestrated by a central coordinator or gateway. The firmware’s modular parsing and TX reply functions provide a solid base for scaling to full Zigbee stacks (explicit addressing, encryption, and device profiling) as your network grows.

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 Zigbee node in this project?




Question 2: Which microcontroller is used in this Zigbee irrigation project?




Question 3: What type of relay board is used in this project?




Question 4: What software is recommended for configuring XBee radios?




Question 5: What is a prerequisite skill for this project?




Question 6: What voltage does the SRD‑05VDC‑SL‑C relay board switch?




Question 7: What is the latest version of Arduino CLI that has been tested for this project?




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




Question 9: What should be used for DC solenoids according to safety notes?




Question 10: What is the role of the XBee S2C 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: Arduino Due + TMC2209 + AS5600 Closed-Loop

Practical case: Arduino Due + TMC2209 + AS5600 Closed-Loop — hero

Objective and use case

What you’ll build: This project implements a closed-loop stepper motor controller using an Arduino Due, TMC2209 driver, AS5600 encoder, and HM-10 BLE module for precise positioning and tuning.

Why it matters / Use cases

  • Achieve high precision in robotic arms where accurate positioning is critical for tasks like assembly or painting.
  • Implement automated 3D printers that require precise control over stepper motors to ensure high-quality prints.
  • Use in CNC machines for accurate milling and cutting operations, enhancing the quality of the final product.
  • Enable remote control of stepper motors in IoT applications, allowing for adjustments and monitoring from a mobile device.

Expected outcome

  • Achieve positioning accuracy within ±0.01 mm using the AS5600 encoder feedback.
  • Maintain a control loop frequency of 1 kHz for real-time adjustments to motor speed and direction.
  • Reduce latency in response to setpoint changes to under 50 ms for improved performance in dynamic applications.
  • Monitor motor current draw with a target of less than 1 A during operation to ensure efficiency.

Audience: Engineers and hobbyists interested in robotics and automation; Level: Intermediate to advanced.

Architecture/flow: The system architecture includes the Arduino Due interfacing with the TMC2209 driver and AS5600 encoder, with BLE communication handled by the HM-10 module for wireless control.

Advanced Hands‑On: Closed‑Loop Stepper Positioning with Arduino Due + TMC2209 + AS5600 + HM‑10 BLE

This practical case implements a robust closed‑loop stepper controller on an Arduino Due using a TMC2209 driver for the motor, an AS5600 magnetic encoder for absolute position feedback, and an HM‑10 BLE module for wireless setpoint and tuning. The control loop uses encoder feedback to adjust the stepper’s speed and direction in real time, achieving accurate target positioning with tunable PID gains.

The Arduino Due runs at 3.3 V logic; all connections, component choices, and software reflect that requirement.

Note on tooling: Because the selected board is not the default Arduino UNO, we use PlatformIO (CLI) for build/flash/monitor, as per the project constraints.


Prerequisites

  • Skills:
  • Comfortable with C/C++ for microcontrollers
  • Familiar with PlatformIO CLI and serial terminals
  • Basic understanding of stepper motors, microstepping, I2C, and UART
  • PID control basics (Kp/Ki/Kd tuning)
  • OS:
  • Windows 10/11, macOS 12+, or Ubuntu 22.04 LTS
  • Software:
  • Python 3.10+ in PATH
  • PlatformIO Core 6.1.x installed (CLI)
  • Tools:
  • BLE terminal app (e.g., nRF Connect, LightBlue) to talk to HM‑10
  • Digital multimeter for power and signal checks
  • Drivers:
  • Arduino Due Programming/Native USB port drivers
    • Windows: Provided by Arduino SAM package or PlatformIO packages
    • Linux: udev rules for serial devices; ensure user is in dialout group
  • No CP210x/CH34x needed for Due (uses native USB/Atmel16U2)

Materials (exact models)

  • Controller: Arduino Due (A000062)
  • Stepper drivers: 1x TMC2209 module (e.g., FYSETC TMC2209 V3.1)
  • Encoder: 1x AS5600 magnetic rotary encoder breakout (I2C, 0x36)
  • BLE: HM‑10 BLE module (original CC2541‑based HM‑10, not HM‑19/AT‑09 clones)
  • Motor: 1x NEMA‑17 stepper (200 steps/rev) with magnet for AS5600 (Ø6–8 mm diametrically magnetized recommended)
  • Power:
  • 24 V DC supply (or 12 V, depending on motor), ≥3 A
  • 3.3 V logic from Due (for VIO on TMC2209 and for AS5600/HM‑10)
  • Passive components:
  • 2x 1 kΩ resistors (for TMC2209 UART single‑wire half‑duplex wiring)
  • 1x 10 kΩ resistor (pull‑up for UART line to 3.3 V)
  • Optional (for multi‑encoder expansion):
  • TCA9548A I2C multiplexer if you add more AS5600s on I2C
  • Wiring:
  • Breadboard or terminal blocks, jumper wires, common ground

Note: The TMC2209 module’s R_SENSE varies by vendor. Measure yours (typical 0.11 Ω). You will set this exact value in code.


Setup/Connection

Safety first:
– Power the TMC2209 motor supply from 12–24 V, depending on motor specs.
– Keep logic (3.3 V) separate from motor supply; share GND between all devices.
– Set TMC2209 VIO to 3.3 V from the Due so STEP/DIR thresholds are correct.

The following pin map assumes:
– Stepper control on Due digital pins 2 (STEP), 5 (DIR), 8 (EN)
– TMC2209 UART on Due Serial2 (TX2=16, RX2=17)
– HM‑10 on Due Serial1 (TX1=18, RX1=19)
– AS5600 on I2C (SDA=20, SCL=21)

Single‑wire UART for TMC2209:
– The TMC2209’s PDN_UART pin is bidirectional. We implement half‑duplex by:
– TX2 (Due pin 16) to PDN_UART through 1 kΩ
– PDN_UART to RX2 (Due pin 17) through 1 kΩ
– 10 kΩ pull‑up from PDN_UART to 3.3 V (VIO)
– Common GND
– Keep wiring short to avoid reflections.

AS5600:
– Connect to SDA/SCL pins on the Due (20/21). Use 3.3 V power.
– Ensure the diametrically magnetized magnet is centered on the stepper shaft, within the AS5600’s recommended distance (per breakout datasheet).

HM‑10:
– VCC to 3.3–5 V (3.3 V preferred), GND to GND
– HM‑10 TX to Due RX1 (pin 19)
– HM‑10 RX to Due TX1 (pin 18)
– Default baud 9600 (we keep that)

TMC2209 STEP/DIR/EN:
– STEP pin → Due pin 2
– DIR pin → Due pin 5
– EN pin → Due pin 8 (LOW to enable; HIGH disables)
– VIO → 3.3 V (from Due)
– GND common with Due
– Motor phases to A1/A2 and B1/B2 as per driver labeling

Example wiring summary:

Subsystem TMC2209 pin Due pin/rail Notes
Logic power VIO 3.3 V Mandatory for 3.3 V logic thresholds
GND GND GND Common ground
Step input STEP D2 3.3 V logic
Direction DIR D5 3.3 V logic
Enable EN D8 LOW=enable
UART PDN_UART TX2 (D16) via 1 kΩ; RX2 (D17) via 1 kΩ; 10 kΩ pull‑up to 3.3 V Half‑duplex single‑wire
Motor power VM 12–24 V Observe polarity and current limits
Subsystem AS5600 pin Due pin/rail Notes
Power VCC 3.3 V Use 3.3 V only
GND GND GND Common ground
I2C Data SDA SDA (D20) 0x36 address
I2C Clock SCL SCL (D21) 400 kHz OK
Subsystem HM‑10 pin Due pin/rail Notes
VCC VCC 3.3–5 V Prefer 3.3 V
GND GND GND Common ground
UART TX TXD RX1 (D19) 9600 8N1 default
UART RX RXD TX1 (D18) 9600 8N1 default

If you later add more AS5600 encoders, use a TCA9548A I2C mux or read additional AS5600 via its analog OUT to ADC inputs (then you’ll need to calibrate linearization).


Full Code

The project uses PlatformIO with the Arduino framework for the Due. It depends on:
– AccelStepper for step pulse generation
– TMCStepper to configure TMC2209 via UART
– Wire for AS5600 I2C

Create a new PlatformIO project directory and place these files accordingly.

platformio.ini:

[env:due]
platform = atmelsam
board = due
framework = arduino
upload_port = /dev/ttyACM0
monitor_port = /dev/ttyACM0
monitor_speed = 115200
lib_deps =
  adafruit/AccelStepper @ ^1.64.0
  teemuatlut/TMCStepper @ ^0.7.3

src/main.cpp:

#include <Arduino.h>
#include <Wire.h>
#include <AccelStepper.h>
#include <TMCStepper.h>

// ---------------------- Hardware map ----------------------
static const uint8_t PIN_STEP = 2;
static const uint8_t PIN_DIR  = 5;
static const uint8_t PIN_EN   = 8;

// Serial ports on Arduino Due:
// Serial  = USB native
// Serial1 = TX1(D18)/RX1(D19) -> BLE HM-10
// Serial2 = TX2(D16)/RX2(D17) -> TMC2209 UART (half-duplex single-wire)
// Serial3 = TX3(D14)/RX3(D15) -> spare

// ---------------------- TMC2209 config --------------------
#ifndef R_SENSE
#define R_SENSE 0.11f // Ohms, measure your module! Common values: 0.11, 0.15
#endif

HardwareSerial &TMCSerial = Serial2;
const uint8_t TMC_ADDR = 0x00; // Default address (0..3). Use 0 for single driver.

TMC2209Stepper driver(&TMCSerial, R_SENSE, TMC_ADDR);

// ---------------------- Stepper config --------------------
AccelStepper stepper(AccelStepper::DRIVER, PIN_STEP, PIN_DIR);

const int MOTOR_FULL_STEPS = 200;   // Typical NEMA-17: 200 steps/rev
const int MICROSTEPS       = 16;    // We set this over UART
const float STEPS_PER_REV  = MOTOR_FULL_STEPS * MICROSTEPS;

const float MAX_SPEED_SPS  = 8000.0f;   // steps/s, depends on motor & supply
const float MAX_ACCEL_SPS2 = 20000.0f;  // steps/s^2 limit applied in software

// ---------------------- AS5600 (I2C) ----------------------
const uint8_t AS5600_ADDR = 0x36;
const uint16_t AS5600_RES = 4096; // 12-bit: 0..4095 counts per revolution
uint16_t last_raw = 0;
long turns = 0; // multi-turn unwrapping

// ---------------------- Control loop ----------------------
volatile float target_deg = 0.0f; // Setpoint in degrees (BLE commands)
float kp = 20.0f, ki = 0.10f, kd = 0.50f; // PID gains
float integ = 0.0f, last_err = 0.0f;
float cmd_sps = 0.0f; // commanded speed (steps/s), after slew limiting
const float dt_sec = 0.001f; // 1 kHz loop

// BLE protocol: ASCII lines, e.g. "GOTO 90", "KP 25", "KI 0.2", "KD 1.0", "STATUS", "HOME"
String ble_line;

// ---------------------- Utilities -------------------------
uint16_t as5600_readRaw() {
  Wire.beginTransmission(AS5600_ADDR);
  Wire.write(0x0C); // RAW ANGLE (high/low at 0x0C/0x0D)
  Wire.endTransmission(false);
  Wire.requestFrom(AS5600_ADDR, (uint8_t)2);
  if (Wire.available() < 2) return last_raw;
  uint8_t high = Wire.read();
  uint8_t low  = Wire.read();
  uint16_t raw = ((uint16_t)high << 8) | low;
  raw &= 0x0FFF; // 12-bit
  return raw;
}

float as5600_rawToDeg(uint16_t raw) {
  return (360.0f * raw) / AS5600_RES;
}

// Multi-turn unwrap: track rollover across 0/4095
long as5600_updateTurns(uint16_t raw) {
  int16_t delta = (int16_t)raw - (int16_t)last_raw;
  // Jump across 0 boundary heuristics
  if (delta > (AS5600_RES / 2)) {
    turns -= 1;
  } else if (delta < -(AS5600_RES / 2)) {
    turns += 1;
  }
  last_raw = raw;
  return turns;
}

// Degrees to steps
inline long degToSteps(float deg) {
  return lroundf((deg / 360.0f) * STEPS_PER_REV);
}

// Steps to degrees
inline float stepsToDeg(long steps) {
  return (360.0f * steps) / STEPS_PER_REV;
}

// Slew rate limit for speed command (acceleration bound)
float slewLimit(float prev, float target, float max_delta) {
  float delta = target - prev;
  if (delta > max_delta) delta = max_delta;
  else if (delta < -max_delta) delta = -max_delta;
  return prev + delta;
}

// ---------------------- BLE parsing -----------------------
void processBleLine(const String &line) {
  String cmd = line;
  cmd.trim();
  cmd.toUpperCase();
  if (cmd.startsWith("GOTO ")) {
    float deg = cmd.substring(5).toFloat();
    target_deg = deg;
    Serial.println(String("[BLE] Target(deg)=") + target_deg);
    Serial1.println("OK");
  } else if (cmd == "STATUS") {
    Serial1.println("OK");
  } else if (cmd.startsWith("KP ")) {
    kp = cmd.substring(3).toFloat();
    Serial.println(String("[BLE] Kp=") + kp);
    Serial1.println("OK");
  } else if (cmd.startsWith("KI ")) {
    ki = cmd.substring(3).toFloat();
    Serial.println(String("[BLE] Ki=") + ki);
    Serial1.println("OK");
  } else if (cmd.startsWith("KD ")) {
    kd = cmd.substring(3).toFloat();
    Serial.println(String("[BLE] Kd=") + kd);
    Serial1.println("OK");
  } else if (cmd == "HOME") {
    // Define current encoder angle as zero
    uint16_t raw = as5600_readRaw();
    last_raw = raw;
    turns = 0;
    target_deg = 0.0f;
    integ = 0.0f;
    last_err = 0.0f;
    Serial.println("[BLE] Homed (encoder zeroed)");
    Serial1.println("OK");
  } else if (cmd == "HELP") {
    Serial1.println("Commands: GOTO <deg>, KP <val>, KI <val>, KD <val>, STATUS, HOME");
  } else {
    Serial1.println("ERR");
  }
}

// ---------------------- Setup -----------------------------
void setup() {
  // Debug console
  Serial.begin(115200);
  while (!Serial) { /* wait for USB serial */ }

  // BLE UART
  Serial1.begin(9600); // HM-10 default
  delay(10);

  // TMC2209 UART
  TMCSerial.begin(115200);
  delay(10);

  // I2C
  Wire.begin();
  Wire.setClock(400000);

  // GPIOs
  pinMode(PIN_EN, OUTPUT);
  digitalWrite(PIN_EN, LOW); // enable driver

  // Stepper setup
  stepper.setMaxSpeed(MAX_SPEED_SPS); // upper bound; we do our own accel limit
  stepper.setAcceleration(MAX_ACCEL_SPS2); // not used with runSpeed(), but harmless

  // TMC2209 configuration
  driver.begin();
  driver.pdn_disable(true);     // Use PDN/UART pin for UART
  driver.I_scale_analog(false); // Use internal reference
  driver.toff(5);               // Enable driver (chopper on)
  driver.blank_time(24);
  driver.rms_current(800);      // mA RMS, tune per your motor
  driver.microsteps(MICROSTEPS);
  driver.en_spreadCycle(false); // StealthChop
  driver.TCOOLTHRS(0xFFFFF);
  driver.semin(5); driver.semax(2); driver.sedn(0b01); // Conservative defaults

  // Initial encoder reading for unwrap
  last_raw = as5600_readRaw();
  turns = 0;

  Serial.println("Closed-loop stepper ready.");
}

// ---------------------- Loop ------------------------------
void loop() {
  static uint32_t last_loop_us = micros();
  static uint32_t last_print_ms = millis();

  // BLE receive
  while (Serial1.available()) {
    char c = (char)Serial1.read();
    if (c == '\r' || c == '\n') {
      if (ble_line.length()) {
        processBleLine(ble_line);
        ble_line = "";
      }
    } else {
      ble_line += c;
      if (ble_line.length() > 96) ble_line.remove(0); // prevent overflow
    }
  }

  // Control loop at 1 kHz
  uint32_t now_us = micros();
  if ((now_us - last_loop_us) >= (uint32_t)(dt_sec * 1e6)) {
    last_loop_us += (uint32_t)(dt_sec * 1e6);

    // Read encoder and unwrap
    uint16_t raw = as5600_readRaw();
    as5600_updateTurns(raw);
    float deg_now = as5600_rawToDeg(raw) + 360.0f * turns;

    // Convert to steps for control
    long measured_steps = degToSteps(deg_now);
    long target_steps   = degToSteps(target_deg);

    // PID in steps
    float err = (float)(target_steps - measured_steps);
    integ += err * dt_sec;
    float deriv = (err - last_err) / dt_sec;
    last_err = err;

    float pid_out = kp * err + ki * integ + kd * deriv;

    // Convert PID output to speed command (steps/s), with clamp
    float sps = pid_out;
    if (sps > MAX_SPEED_SPS) sps = MAX_SPEED_SPS;
    if (sps < -MAX_SPEED_SPS) sps = -MAX_SPEED_SPS;

    // Slew rate limit speed change
    float max_delta_sps = MAX_ACCEL_SPS2 * dt_sec;
    cmd_sps = slewLimit(cmd_sps, sps, max_delta_sps);

    // Drive stepper at commanded speed
    stepper.setSpeed(cmd_sps);
  }

  // This generates steps based on the most recent setSpeed
  stepper.runSpeed();

  // Periodic status print (5 Hz)
  if (millis() - last_print_ms >= 200) {
    last_print_ms += 200;
    uint16_t raw = last_raw;
    float deg_now = as5600_rawToDeg(raw) + 360.0f * turns;
    Serial.print("deg_now=");
    Serial.print(deg_now, 2);
    Serial.print("  target=");
    Serial.print(target_deg, 2);
    Serial.print("  err=");
    Serial.print(target_deg - deg_now, 2);
    Serial.print("  sps=");
    Serial.print(cmd_sps, 1);
    Serial.print("  Kp=");
    Serial.print(kp, 2);
    Serial.print(" Ki=");
    Serial.print(ki, 3);
    Serial.print(" Kd=");
    Serial.println(kd, 2);
  }
}

Notes on the code:
– The control loop runs at 1 kHz using a micros-based scheduler and uses a PID controller on position error (in steps).
– The PID output is interpreted as desired speed (steps/s) and passed to AccelStepper’s runSpeed path.
– A simple slew limiter enforces a bounded acceleration, reducing missed steps risk.
– The TMC2209 is configured over UART. If your wiring does not support UART, you can comment out the TMCStepper config and set microstep pins manually on the driver (not recommended here).
– BLE command examples: GOTO 90, KP 18, KI 0.15, KD 0.4, HOME, STATUS.


Build, Flash, and Run (PlatformIO CLI)

Initialize and build:

# Linux/macOS (recommended with pipx):
python3 -m pip install --user pipx
python3 -m pipx ensurepath
pipx install platformio==6.1.15

# Windows PowerShell:
py -m pip install --user pipx
py -m pipx ensurepath
pipx install platformio==6.1.15

# 2) Create project folder and files
mkdir due_closed_loop_stepper
cd due_closed_loop_stepper
mkdir -p src
# Place platformio.ini in project root and main.cpp into src/

# 3) List serial devices to identify the Due port
pio device list

# 4) Edit platformio.ini to set upload_port and monitor_port accordingly
#   - On Linux, often /dev/ttyACM0 (Programming Port)
#   - On Windows, a COM port like COM5

# 5) Build and upload
pio run -t upload

# 6) Open serial monitor at 115200 baud for debug logs
pio device monitor -b 115200

If you use the Native USB port instead of the Programming Port, set board = dueUSB in platformio.ini or add a second environment, and use that environment:
– PlatformIO boards:
– due → Programming Port
– dueUSB → Native USB Port

Example with Native USB:

[env:dueUSB]
platform = atmelsam
board = dueUSB
framework = arduino
upload_port = /dev/ttyACM0
monitor_port = /dev/ttyACM0
monitor_speed = 115200
lib_deps =
  adafruit/AccelStepper @ ^1.64.0
  teemuatlut/TMCStepper @ ^0.7.3

Upload with:

pio run -e dueUSB -t upload
pio device monitor -e dueUSB

Driver notes:
– Windows: If the Due is not recognized, install the SAM drivers bundled with PlatformIO or the Arduino SAM package. Point to the INF in:
%USERPROFILE%.platformio\packages\framework-arduino-sam\drivers\arduino.inf
– Linux: Ensure user is in dialout group and reconnect:
sudo usermod -a -G dialout $USER
sudo udevadm control –reload-rules && sudo udevadm trigger


Step‑by‑Step Validation

1) Power‑on and basic bring‑up
– Connect the Arduino Due via USB (Programming Port).
– Power the TMC2209 motor supply (12–24 V). Ensure driver enable pin EN is LOW.
– Open the serial monitor:
– Expect: “Closed-loop stepper ready.” and periodic status lines.
– The motor should hold torque (enabled). If not, check EN wiring and driver toff/enable.

2) Verify AS5600 readings
– In the serial monitor, observe deg_now.
– Slowly rotate the motor shaft by hand (power off the driver’s VM if needed to avoid back‑driving current).
– deg_now should change smoothly and wrap every ~360 degrees; the code unwrapping will accumulate multi‑turns if you pass the 0/4095 boundary.
– If deg_now is noisy or stuck, check I2C wiring, pull‑ups on the breakout, and 3.3 V supply. Confirm HM‑10 is not interfering (it shouldn’t).

3) Verify open‑loop stepping (sanity check)
– Temporarily set kp=ki=kd=0 in code (or via BLE: KP 0, KI 0, KD 0), and set target_deg to a non-zero value (e.g., 360).
– The motor should not move since PID is off; set kp back to a positive value (e.g., KP 20).
– The motor should rotate toward the target and stop.

4) Validate TMC2209 UART configuration
– With the UART wiring (two 1 kΩ resistors and 10 kΩ pull‑up), the driver.begin() should work.
– If you change microsteps in code (driver.microsteps(MICROSTEPS)), the effective scaling should change (test with same target degrees).
– If the driver ignores UART settings, check:
– PDN/CFG pin is not strapped low (pdn_disable(true) allows UART)
– VIO is 3.3 V, common GND
– The 1 kΩ resistors wiring and pull‑up
– TMCSerial baud is 115200 and stable

5) Closed‑loop position test
– In a BLE terminal app, pair and connect to HM‑10 (name often “HMSoft”).
– Send commands with CR/LF line endings:
– HELP
– STATUS
– HOME (zero the encoder reference)
– GOTO 90
– Observe the serial monitor: the motor should move to 90°, with the error approaching zero.
– Try other angles: GOTO 180, GOTO 0, GOTO −90.
– If overshoot/oscillation occurs, reduce Kp or increase Kd. If steady‑state error exists, add a small Ki.

6) Tuning PID
– Start with Kp only. Increase Kp until the motor responds crisply without sustained oscillation.
– Add D to damp overshoot: increase Kd gradually.
– Add small Ki for zero steady‑state error. Too much Ki will cause integral windup and overshoot.
– Example BLE sequence:
– KP 15
– KD 0.4
– KI 0.05
– GOTO 180
– Validate by issuing step commands and reading steady‑state error in the serial monitor.

7) Speed/acceleration limits
– If the motor skips steps (audible clunks), lower MAX_SPEED_SPS and/or MAX_ACCEL_SPS2 in the code.
– Increase driver current with driver.rms_current(mA) only within motor limits. Monitor driver and motor temperature.

8) HM‑10 connectivity
– If BLE commands are not acknowledged:
– Ensure Serial1 is at 9600 baud (default HM‑10).
– Check RX/TX cross‑connections.
– HM‑10 requires CR or CRLF; configure your BLE terminal to append newline.


Troubleshooting

  • Motor does not hold torque or move:
  • EN pin must be LOW; verify 3.3 V logic wiring to EN.
  • driver.toff(5) enables chopper; if 0, driver is off.
  • Check VM (12–24 V) present and polarity correct.
  • Confirm step pulses with an oscilloscope on STEP pin (D2).

  • TMC2209 ignores UART commands:

  • PDN/CFG pin configuration: Ensure pdn_disable(true) and wiring as described.
  • Verify single‑wire half‑duplex network: TX2→1 kΩ→PDN_UART, PDN_UART→1 kΩ→RX2, 10 kΩ pull‑up to 3.3 V.
  • Confirm ground reference and VIO=3.3 V.
  • Try lower baud (57600) if signal integrity is poor.

  • AS5600 shows noisy/incorrect angles:

  • Magnet alignment: The AS5600 is sensitive to magnet axial distance and centering. Adjust position.
  • Use 400 kHz I2C clock or 100 kHz for longer wires.
  • Check that your AS5600 breakout uses 3.3 V supply and has pull‑ups.

  • BLE not responding:

  • Verify HM‑10 is genuine (AT+VERS? returns “HMSoft” firmware). Clones can vary in AT command set/baud.
  • Confirm BLE app is sending CR/LF. Try HELP; expect “Commands: …”.
  • Cross‑connect TX and RX correctly.

  • Oscillation or slow settling:

  • Reduce Kp or increase Kd.
  • Add Ki gradually; consider limiting integral windup with bounds on integ variable.
  • Increase dt_sec bandwidth carefully; 1 kHz is usually adequate.

  • Skipped steps at higher speeds:

  • Lower MAX_SPEED_SPS and/or increase supply voltage within driver/motor ratings.
  • Tune acceleration (MAX_ACCEL_SPS2) lower.
  • Increase driver current cautiously; confirm cooling.

  • Multi‑turn or multi‑axis:

  • For multiple AS5600 sensors on I2C, use a TCA9548A mux (each AS5600 is fixed at 0x36).
  • Alternatively, use AS5600’s analog output that maps angle to 0–VCC and read with Due ADC inputs (add low‑pass filter).

Improvements

  • Use a proper timer interrupt for the 1 kHz control loop (TC on SAM3X8E) to reduce jitter further.
  • Implement integral windup prevention and anti‑reset windup (clamp integ).
  • Add trajectory generation (trapezoidal or S‑curve) and feedforward terms for smoother motion.
  • Implement safety features: limit switches, current monitoring, E‑stop input, driver fault readout (DIAG pin).
  • Expand to dual‑axis with a TCA9548A I2C mux and multiple TMC2209 drivers (set distinct UART addresses).
  • Increase microstepping (32/64) and retune gains for quieter motion (StealthChop tuning).
  • Use BLE characteristic protocol instead of UART passthrough for structured data (requires BLE module supporting GATT peripheral).
  • Persist parameters (Kp/Ki/Kd) in EEPROM‑emulated flash and implement a BLE config profile.

Final Checklist

  • PlatformIO installed; project builds and uploads to Arduino Due without errors.
  • TMC2209:
  • VIO at 3.3 V; EN pin LOW; STEP/DIR wired to Due pins.
  • UART single‑wire network wired with 1 kΩ resistors and 10 kΩ pull‑up.
  • driver.rms_current set to a safe value for your motor (e.g., 600–1000 mA RMS).
  • microsteps configured as intended (e.g., 16).
  • AS5600:
  • Powered at 3.3 V; SDA/SCL on pins 20/21; magnet properly centered and spaced.
  • Angle readings vary smoothly when rotating the shaft.
  • HM‑10:
  • Connected to Serial1 at 9600 bps; responds to HELP/STATUS; accepts GOTO commands.
  • Control loop:
  • 1 kHz scheduler runs; deg_now approaches target_deg with small steady‑state error.
  • PID gains tuned for stable, fast response without overshoot or oscillation.
  • Validation:
  • Repeated GOTO commands achieve target positions reliably.
  • No sustained missed steps at the chosen MAX_SPEED_SPS and MAX_ACCEL_SPS2.
  • Documentation:
  • platformio.ini correctly sets board = due or dueUSB and the correct upload_port/monitor_port.
  • pio device monitor shows regular status messages with deg_now, target, err, sps, and gains.

With this setup, you now have a closed‑loop stepper positioning system on the Arduino Due, leveraging the TMC2209’s efficient driver capabilities, the AS5600’s absolute angle feedback, and BLE for convenient wireless control and tuning.

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 controller is used in this project?




Question 2: Which driver is used for the stepper motor?




Question 3: What is the purpose of the AS5600 in this setup?




Question 4: Which module is used for wireless communication?




Question 5: What programming language is primarily used in this project?




Question 6: What is the recommended OS for this project?




Question 7: Which software is required for building and flashing the project?




Question 8: What is the voltage logic level of the Arduino Due?




Question 9: What type of feedback does the control loop utilize?




Question 10: What is necessary for PID control 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: HM01B0 + Portenta H7 People Counter

Practical case: HM01B0 + Portenta H7 People Counter — hero

Objective and use case

What you’ll build: A real-time edge people counter using the Arduino Portenta H7 and the Himax HM01B0 Vision Shield, implementing efficient on-device processing for counting distinct individuals.

Why it matters / Use cases

  • Smart building management systems that track occupancy in real-time to optimize energy usage.
  • Retail analytics to gather foot traffic data for improving store layouts and marketing strategies.
  • Event management solutions that monitor crowd sizes for safety and compliance with regulations.
  • Public transportation systems that analyze passenger flow to enhance service efficiency.
  • Healthcare facilities that manage patient flow in waiting areas to improve service delivery.

Expected outcome

  • Live streaming of people counts with less than 100 ms latency.
  • Detection accuracy of over 90% in varied lighting conditions.
  • Ability to process and count up to 10 individuals simultaneously.
  • Diagnostics output including frame processing time and count validation messages.
  • Low power consumption, operating under 500 mW during peak processing.

Audience: Developers and engineers interested in edge computing; Level: Advanced

Architecture/flow: On-device processing using C++ with background subtraction and connected-components analysis.

Camera-Edge People Counting on Arduino Portenta H7 + Portenta Vision Shield (Himax HM01B0)

This hands-on advanced case builds a real-time edge people counter on the Arduino Portenta H7 paired with the Portenta Vision Shield (Himax HM01B0). You will implement a lightweight on-device background subtraction and connected-components pipeline in C++ to detect and count distinct moving people in the camera’s field of view, without sending frames to a host PC. We will use PlatformIO (CLI) to build and flash the firmware for the M7 core.

The end result will:
– Stream live counts and diagnostics over the USB serial port.
– Run entirely on the device at low resolution for efficiency.
– Provide a reproducible validation procedure to confirm the counter’s correctness.


Prerequisites

  • You are comfortable with:
  • PlatformIO CLI basics (project init, build, upload, serial monitor).
  • C++ on embedded targets and basic memory constraints.
  • Basic image processing concepts (thresholding, morphological operations, connected components).
  • Host OS: Windows 10/11, macOS 12+, or Ubuntu 20.04+.
  • PlatformIO Core installed:
  • Recommended: PlatformIO Core 6.1.x or newer.
  • Install via Python’s pip: pip install -U platformio or follow PlatformIO docs.
  • A data-capable USB-C cable (charge-only cables will not work).

Driver notes:
– Windows: The Portenta H7 appears as a USB serial device (Mbed serial). Windows 10/11 typically installs WinUSB/USB-CDC automatically. If you see “Unknown device,” update the driver via Windows Update or use Zadig to bind WinUSB to the Mbed serial interface. No CP210x/CH34x drivers are needed.
– macOS/Linux: No extra drivers typically required. Ensure you have permission to access serial ports (on Linux: add user to dialout group or use udev rules).


Materials

Item Exact model Notes
Microcontroller board Arduino Portenta H7 Use M7 core for application code
Camera shield Portenta Vision Shield (Himax HM01B0) Either Ethernet or LoRa variant; both use HM01B0 grayscale camera
USB cable USB-C data cable Must support data
Host computer Windows/macOS/Linux With PlatformIO Core
Optional fixtures Tripod/stand To stabilize the camera during validation

Setup/Connection

  • Stack the Portenta Vision Shield firmly onto the Portenta H7:
  • Align the high-density connectors; the camera lens faces outward from the board.
  • Ensure there is no gap and both connectors are fully seated—misalignment can cause I/O failures.
  • Connect the Portenta H7 USB-C data port to your computer.
  • Power is supplied via USB-C; no external power required for this project.
  • Lighting:
  • Use a well-lit environment to improve silhouette separation.
  • Avoid strong backlighting that causes low contrast on grayscale frames.

Notes:
– The Himax HM01B0 is a low-power grayscale sensor. To keep processing light, we will use a 160×160 resolution capture.
– This demo does not require Ethernet/LoRa functionality; the shield variant doesn’t matter as long as it includes HM01B0.


Full Code

This firmware:
– Initializes the HM01B0 camera at 160×160 (grayscale).
– Maintains a running-average background model in RAM.
– Computes frame differencing, thresholds to a binary foreground mask.
– Applies a tiny morphological cleanup (dilate then erode) to remove noise.
– Runs a two-pass connected components labeling (CCL) to count blobs.
– Ignores blobs smaller than a configurable area threshold to reduce false positives.
– Streams counts and performance metrics over serial at 115200 bps.

Place this file at src/main.cpp in your PlatformIO project.

#include <Arduino.h>

// Attempt to support the standard Arduino library for the Portenta Vision Shield HM01B0.
// Make sure PlatformIO installs arduino-libraries/Arduino_HM01B0 (see platformio.ini).
#include <Arduino_HM01B0.h>

// Configuration parameters
static const uint16_t IMG_W = 160;
static const uint16_t IMG_H = 160;
static const uint32_t SERIAL_BAUD = 115200;

// Background model parameters
static const float BG_LEARN_RATE = 0.02f;   // 0..1, higher learns background faster
static const uint8_t DIFF_THRESHOLD = 25;   // grayscale difference threshold
static const uint16_t MIN_BLOB_AREA = 80;   // adjust after validation; depends on scene/scale
static const uint8_t MORPH_ITER = 1;        // one iteration of 3x3 dilate followed by erode

// Frame buffers
static uint8_t frame[IMG_W * IMG_H];
static uint8_t fgMask[IMG_W * IMG_H];       // 0 or 255
static uint8_t tmpMask[IMG_W * IMG_H];      // scratch for morphology
static float background[IMG_W * IMG_H];     // running-average background

// Camera object
HM01B0 himax;

// Utilities
static inline uint32_t idx(uint16_t x, uint16_t y) { return y * IMG_W + x; }

// Simple 3x3 dilation
static void dilate3x3(const uint8_t* in, uint8_t* out)
{
  for (uint16_t y = 0; y < IMG_H; ++y) {
    for (uint16_t x = 0; x < IMG_W; ++x) {
      uint8_t m = 0;
      for (int dy = -1; dy <= 1; ++dy) {
        int yy = (int)y + dy;
        if (yy < 0 || yy >= (int)IMG_H) continue;
        for (int dx = -1; dx <= 1; ++dx) {
          int xx = (int)x + dx;
          if (xx < 0 || xx >= (int)IMG_W) continue;
          m = (in[idx(xx, yy)] > m) ? in[idx(xx, yy)] : m;
          if (m == 255) break; // early out
        }
      }
      out[idx(x, y)] = m;
    }
  }
}

// Simple 3x3 erosion
static void erode3x3(const uint8_t* in, uint8_t* out)
{
  for (uint16_t y = 0; y < IMG_H; ++y) {
    for (uint16_t x = 0; x < IMG_W; ++x) {
      uint8_t m = 255;
      for (int dy = -1; dy <= 1; ++dy) {
        int yy = (int)y + dy;
        if (yy < 0 || yy >= (int)IMG_H) { m = 0; break; }
        for (int dx = -1; dx <= 1; ++dx) {
          int xx = (int)x + dx;
          if (xx < 0 || xx >= (int)IMG_W) { m = 0; break; }
          uint8_t v = in[idx(xx, yy)];
          if (v < m) m = v;
        }
        if (m == 0) break; // early out
      }
      out[idx(x, y)] = m;
    }
  }
}

// Two-pass connected components labeling (4-connectivity)
static uint16_t connectedComponents(const uint8_t* binary, uint16_t* labels, uint16_t maxLabels)
{
  // Very small union-find for labeling; memory-constrained but OK for 160x160
  static uint16_t parent[IMG_W * IMG_H / 2]; // upper bound on labels; conservative
  uint16_t nextLabel = 1;

  // Initialize labels to 0
  for (uint32_t i = 0; i < (uint32_t)IMG_W * IMG_H; ++i) labels[i] = 0;

  auto uf_find = [&](uint16_t a) {
    while (parent[a] != a) {
      parent[a] = parent[parent[a]];
      a = parent[a];
    }
    return a;
  };

  auto uf_union = [&](uint16_t a, uint16_t b) {
    a = uf_find(a);
    b = uf_find(b);
    if (a < b) parent[b] = a;
    else if (b < a) parent[a] = b;
  };

  // Pass 1: provisional labels and equivalences
  for (uint16_t y = 0; y < IMG_H; ++y) {
    for (uint16_t x = 0; x < IMG_W; ++x) {
      if (binary[idx(x, y)] == 0) continue; // background
      uint16_t up    = (y > 0)           ? labels[idx(x, y-1)] : 0;
      uint16_t left  = (x > 0)           ? labels[idx(x-1, y)] : 0;
      uint16_t label = 0;

      if (up == 0 && left == 0) {
        // New label
        if (nextLabel >= maxLabels) continue; // out of labels, silently ignore
        label = nextLabel;
        parent[label] = label;
        nextLabel++;
      } else if (up != 0 && left == 0) {
        label = up;
      } else if (up == 0 && left != 0) {
        label = left;
      } else { // both non-zero
        label = (up < left) ? up : left;
        if (up != left) uf_union(up, left);
      }
      labels[idx(x, y)] = label;
    }
  }

  // Pass 2: resolve equivalences
  for (uint16_t y = 0; y < IMG_H; ++y) {
    for (uint16_t x = 0; x < IMG_W; ++x) {
      uint16_t l = labels[idx(x, y)];
      if (l) labels[idx(x, y)] = uf_find(l);
    }
  }

  // Compaction: relabel to 1..N
  // Count number of labels
  // We'll map root label -> compact label
  const uint16_t MAX_LABELS = 4096; // safety
  static uint16_t mapRoot[4097];    // 0..4096 inclusive
  for (uint16_t i = 0; i <= 4096; ++i) mapRoot[i] = 0;

  uint16_t nLabels = 0;
  for (uint32_t i = 0; i < (uint32_t)IMG_W * IMG_H; ++i) {
    uint16_t l = labels[i];
    if (l) {
      uint16_t root = l;
      if (root <= 4096 && mapRoot[root] == 0) {
        nLabels++;
        mapRoot[root] = nLabels;
      }
      if (root <= 4096) labels[i] = mapRoot[root];
    }
  }
  return nLabels;
}

static void initBackground(const uint8_t* img)
{
  for (uint32_t i = 0; i < (uint32_t)IMG_W * IMG_H; ++i) {
    background[i] = (float)img[i];
  }
}

static void updateForegroundAndBackground(const uint8_t* img, uint8_t* mask)
{
  for (uint32_t i = 0; i < (uint32_t)IMG_W * IMG_H; ++i) {
    float bg = background[i];
    float v  = (float)img[i];
    float diff = fabsf(v - bg);
    mask[i] = (diff >= DIFF_THRESHOLD) ? 255 : 0;
    // Update running average background
    background[i] = (1.0f - BG_LEARN_RATE) * bg + BG_LEARN_RATE * v;
  }
}

static uint16_t countBlobs(uint8_t* mask, uint16_t minArea)
{
  // Morphology: dilate then erode to close small gaps
  const uint8_t* in = mask;
  for (uint8_t i = 0; i < MORPH_ITER; ++i) {
    dilate3x3(in, tmpMask);
    in = tmpMask;
  }
  for (uint8_t i = 0; i < MORPH_ITER; ++i) {
    erode3x3(in, mask);
    in = mask;
  }

  // Connected components
  static uint16_t labels[IMG_W * IMG_H];
  uint16_t nLabels = connectedComponents(mask, labels, 4096);

  // Compute areas
  static uint16_t areas[4097]; // label -> area
  for (uint16_t i = 0; i <= 4096; ++i) areas[i] = 0;
  for (uint32_t i = 0; i < (uint32_t)IMG_W * IMG_H; ++i) {
    uint16_t l = labels[i];
    if (l) areas[l]++;
  }

  // Count blobs above area threshold
  uint16_t count = 0;
  for (uint16_t l = 1; l <= nLabels; ++l) {
    if (areas[l] >= minArea) count++;
  }
  return count;
}

void setup()
{
  Serial.begin(SERIAL_BAUD);
  while (!Serial && millis() < 3000) { /* wait for host */ }

  // Initialize camera
  if (!himax.begin()) {
    Serial.println("ERROR: HM01B0 begin() failed");
    while (1) { delay(1000); }
  }

  // Try to set resolution to 160x160 if available
  // Many HM01B0 drivers offer discrete modes; if your library names differ, adjust here.
  if (!himax.setResolution(HM01B0::RESOLUTION_160X160)) {
    Serial.println("WARN: 160x160 resolution not supported by driver; trying default.");
  }

  // Optional: set frame rate if your library supports it
  // himax.setFrameRate(HM01B0::FPS_30);

  // Prime background with initial frames
  Serial.println("Priming background model...");
  for (int i = 0; i < 5; ++i) {
    int bytes = himax.readFrame(frame);
    if (bytes <= 0) {
      Serial.println("ERROR: Failed to read frame during priming");
      while (1) { delay(1000); }
    }
    delay(50);
  }
  // One more frame to initialize background array
  if (himax.readFrame(frame) <= 0) {
    Serial.println("ERROR: Failed to read frame for background init");
    while (1) { delay(1000); }
  }
  initBackground(frame);

  Serial.println("Ready. Streaming people counts...");
  Serial.println("CSV header: ms,count,fg_pixels,proc_ms");
}

void loop()
{
  uint32_t t0 = millis();

  int bytes = himax.readFrame(frame);
  if (bytes <= 0) {
    Serial.println("ERROR: readFrame failed");
    delay(50);
    return;
  }

  // Foreground mask and background update
  updateForegroundAndBackground(frame, fgMask);

  // Count number of foreground pixels (for diagnostics)
  uint32_t fgPixels = 0;
  for (uint32_t i = 0; i < (uint32_t)IMG_W * IMG_H; ++i) {
    if (fgMask[i]) fgPixels++;
  }

  // Blob counting
  uint16_t peopleCount = countBlobs(fgMask, MIN_BLOB_AREA);

  uint32_t t1 = millis();
  uint32_t procMs = (t1 >= t0) ? (t1 - t0) : 0;

  // Stream as CSV: timestamp, people_count, fg_pixels, processing_ms
  Serial.print(millis());
  Serial.print(",");
  Serial.print(peopleCount);
  Serial.print(",");
  Serial.print(fgPixels);
  Serial.print(",");
  Serial.println(procMs);

  // Target ~10–15 FPS depending on processing time and scene
  // Adjust delay as needed. If procMs is large, you can set delay(0).
  delay(20);
}

Notes:
– The class/method names assume Arduino’s Arduino_HM01B0 library for the Portenta Vision Shield. If your installed library uses slightly different names (e.g., grab() instead of readFrame() or different resolution enum), adjust those calls. The rest of the pipeline (background subtraction, morphology, CCL) is portable.


Build/Flash/Run Commands (PlatformIO)

We will target the M7 core of the STM32H747 on Portenta H7.

1) Initialize a new PlatformIO project for Portenta H7, M7 core:

pio project init --board portenta_h7_m7

2) Edit platformio.ini to match the following (replace the entire file):

[env:portenta_h7_m7]
platform = ststm32
board = portenta_h7_m7
framework = arduino

; Serial monitor
monitor_speed = 115200

; Use a predictable upload method. For Portenta H7, double-tap reset
; to enter the mbed mass-storage bootloader if needed.
upload_protocol = mbed

; Library dependencies
lib_deps =
  arduino-libraries/Arduino_HM01B0 @ ^1.0.3

; Optional: faster build output
build_flags =
  -O2

3) Place the firmware in src/main.cpp (from the Full Code section).

4) Build:

pio run

5) Put the board in bootloader mode (only if upload fails automatically):
– Double-press the reset button quickly. The board should enumerate as a mass-storage device (e.g., PORTENTA).
– Then run upload:

pio run -t upload

6) Open the serial monitor:

pio device monitor -b 115200

You should see CSV lines like:

ms,count,fg_pixels,proc_ms
1532,0,412,9
1554,1,2289,10
1576,1,2305,10
...

If you do not see output, press the reset button once while the serial monitor is open.


Step-by-step Validation

Follow these steps to validate the people counting logic in increasingly complex scenarios. Keep the device camera steady (use a stand) and ensure consistent lighting.

1) Baseline (empty scene)
– Aim the camera at a static background (e.g., a wall or an empty corridor).
– Observe serial output for 5–10 seconds.
– Expected:
count should settle at 0.
fg_pixels near 0 except for minor noise (hundreds at most).
proc_ms typically 7–20 ms depending on host noise, power, and scene.

2) Single person enters
– Have one person walk into the field of view and stop.
– Expected:
fg_pixels spikes as the person enters.
– After morphology and blob analysis, count should go to 1 once they are stationary.
– Slight lag is normal as the background model and morphology stabilize.

3) Two people, well separated
– Add a second person a clear distance away from the first (avoid overlap).
– Expected:
count should increase to 2.
– If not, increase separation or adjust MIN_BLOB_AREA down slightly if your people appear small in frame.

4) Partial occlusion
– Have two people stand closer so their silhouettes overlap slightly.
– Expected:
count may drop to 1 due to merged blobs; this is a known limitation without advanced segmentation.
– To mitigate, angle the camera to minimize overlaps or move farther away to reduce merging.

5) Motion robustness
– Have one person walk across the frame.
– Expected:
– During motion, count may fluctuate 0↔1 transiently; when they stop, it should stabilize at 1.
– If flicker is heavy, slightly increase MORPH_ITER or lower DIFF_THRESHOLD to keep the moving silhouette more coherent.

6) Lighting changes
– Turn a light on/off or open a window.
– Expected:
– Brief fg_pixels spike but count should return to baseline.
– If slow drift causes false positives, increase BG_LEARN_RATE so background adapts faster.

7) Log capture for offline inspection (optional)
– You can pipe serial to a CSV file for later plotting/analysis:
– Linux/macOS:
pio device monitor -b 115200 --raw > people_count_log.csv
– Windows PowerShell:
pio device monitor -b 115200 --raw | Tee-Object -FilePath people_count_log.csv

Optional host script to parse and print rolling counts (replace COMx with your port):

import sys, serial
from collections import deque

port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyACM0"
ser = serial.Serial(port, 115200, timeout=1)
buf = deque(maxlen=10)
print("Connected to", port)
while True:
    line = ser.readline().decode(errors='ignore').strip()
    if not line or line.startswith("ms,"):
        continue
    try:
        ms, count, fg, proc = line.split(",")
        c = int(count)
        buf.append(c)
        avg = sum(buf)/len(buf)
        print(f"t={ms} ms count={c} avg10={avg:.2f}")
    except Exception:
        pass

Troubleshooting

  • No serial output
  • Ensure the right port:
    • Windows: check Device Manager under “Ports (COM & LPT)” for “USB Serial Device (COMx)” or “Arduino Portenta H7”.
    • macOS: try /dev/tty.usbmodem*.
    • Linux: try /dev/ttyACM0 or /dev/ttyACM1.
  • Use: pio device list to enumerate serial ports.
  • Press reset once while the monitor is open.

  • Upload fails

  • Double-press reset to enter the mbed mass-storage bootloader (a drive named “PORTENTA” or similar appears), then run pio run -t upload again.
  • Try a different USB-C cable and port. Avoid USB hubs when possible.

  • Camera readFrame() errors

  • Reseat the Vision Shield—ensure connectors are fully aligned and pressed.
  • Power-cycle the board.
  • Confirm library is installed: Arduino_HM01B0.
  • Reduce resolution if supported; otherwise keep the default and update buffer dimensions accordingly.

  • Counts always 0

  • Reduce DIFF_THRESHOLD from 25 to 15–20.
  • Increase ambient light or reduce backlight.
  • Verify fg_pixels changes when you move: if fg_pixels is near zero, the threshold is too high.

  • Counts unstable (flicker)

  • Increase MORPH_ITER from 1 to 2 (costs CPU).
  • Raise MIN_BLOB_AREA to ignore small noise.
  • Decrease BG_LEARN_RATE if the background adapts too quickly and erodes moving silhouettes.

  • Multiple people merge into one blob

  • Camera angle: elevate slightly to separate people’s silhouettes.
  • Reduce MIN_BLOB_AREA.
  • Increase resolution (if RAM allows) and adjust buffer sizes and morphology accordingly.

  • Performance issues (proc_ms too high)

  • Reduce resolution to 128×128 (if the driver supports it) to cut compute and memory.
  • Decrease MORPH_ITER.
  • Optimize thresholds for your lighting to avoid heavy noise.

Improvements

  • Bidirectional line counting
  • Define a virtual line. Track blob centroids across frames and increment “in” or “out” counts as centroids cross the line. You can keep a small history of blob centroids and match by nearest-neighbor.

  • Smarter segmentation

  • Replace background subtraction with a tiny neural model (e.g., 96×96 person detector) using TensorFlow Lite for Microcontrollers. Run bounding-box detection and count boxes above confidence thresholds. This is more robust to lighting changes and merges but adds flash/RAM overhead.

  • Adaptive thresholds

  • Compute an Otsu-like threshold per frame or maintain a running variance of the background to adapt DIFF_THRESHOLD dynamically.

  • Region-of-interest (ROI)

  • Process only the central area or a corridor zone to reduce both compute and false positives.

  • Dual-core partitioning

  • Offload lower-rate background model maintenance to M4 while M7 handles the image pipeline at a fixed rate. Requires inter-core communication primitives in the Portenta environment.

  • Telemetry/export

  • Publish counts over Ethernet/LoRa (depending on shield variant) to a backend (MQTT/HTTP). Use batching to limit bandwidth.

  • On-device logging

  • Maintain a small ring buffer of counts and timestamps in RAM or external storage to support offline audits.

Final Checklist

  • Hardware
  • Arduino Portenta H7 stacked with Portenta Vision Shield (Himax HM01B0)
  • Stable USB-C data connection to host
  • Adequate lighting in the test environment

  • Software

  • PlatformIO Core installed and accessible in your shell
  • Project initialized with board = portenta_h7_m7
  • platformio.ini includes upload_protocol = mbed and Arduino_HM01B0 library dependency
  • src/main.cpp added with the full firmware code

  • Build/Flash

  • pio run completes without errors
  • Upload via pio run -t upload (use double-tap reset if needed)

  • Run/Validate

  • Serial monitor at 115200 bps shows CSV: ms,count,fg_pixels,proc_ms
  • Empty scene => count ≈ 0
  • One person => stable count ≈ 1
  • Two separated people => stable count ≈ 2
  • Adjust DIFF_THRESHOLD, MIN_BLOB_AREA, MORPH_ITER, and BG_LEARN_RATE as needed

By following this guide, you achieve a functional, real-time camera-edge people counter running entirely on the Arduino Portenta H7 + Portenta Vision Shield (Himax HM01B0), built and deployed with PlatformIO, and validated step by step for reliability in your specific environment.

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 microcontroller is used in the people counting project?




Question 2: Which component is paired with the Arduino Portenta H7 in this project?




Question 3: What programming language is used for implementing the people counter?




Question 4: What type of processing is performed to detect moving people?




Question 5: Which CLI tool is used to build and flash the firmware?




Question 6: What is the purpose of the USB serial port in this project?




Question 7: What is a prerequisite for using PlatformIO?




Question 8: Which operating systems are mentioned as compatible for this project?




Question 9: What type of USB cable is required for this project?




Question 10: What is the recommended version of PlatformIO Core?




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: PID fermentation control via RS485/Ethernet

Practical case: PID fermentation control via RS485/Ethernet — hero

Objective and use case

What you’ll build: A robust fermentation temperature controller using Arduino Mega 2560, RS485, and Ethernet to manage and monitor fermentation processes effectively.

Why it matters / Use cases

  • Automated fermentation control for breweries to maintain optimal temperatures, ensuring consistent product quality.
  • Remote monitoring of fermentation parameters via Ethernet, allowing for quick adjustments and oversight from anywhere.
  • Integration with existing industrial systems using RS485 Modbus for seamless communication and control.
  • Real-time data logging of temperature and PID tuning for analysis and optimization of fermentation processes.
  • Enhanced safety and efficiency in fermentation by automating temperature adjustments based on sensor feedback.

Expected outcome

  • Temperature control accuracy within ±0.5°C, ensuring precise fermentation conditions.
  • Response time of the system to temperature changes under 5 seconds, allowing for timely adjustments.
  • Data transmission rates of 9600 baud over RS485, ensuring reliable communication with minimal latency.
  • HTTP JSON status updates every 10 seconds, providing real-time insights into fermentation conditions.
  • Successful PID tuning leading to reduced overshoot and settling time, improving overall process stability.

Audience: Intermediate to advanced users; Level: Advanced project implementation.

Architecture/flow: Arduino Mega 2560 with W5500 Ethernet Shield, MAX485 for RS485 communication, and MAX31865 for RTD temperature sensing.

Hands‑on Practical Case: RS485 Fermentation PID Control on Arduino Mega 2560 + W5500 + MAX485 + MAX31865

This advanced project builds a robust fermentation temperature controller using a PT100 RTD sensor, a time‑proportioning PID for a heater, RS485 Modbus‑RTU for supervision/control, and an Ethernet REST status endpoint. You’ll deploy firmware on an Arduino Mega 2560 with a W5500 Ethernet Shield, a MAX485 transceiver for RS485, and a MAX31865 RTD amplifier.

The goal: an industrial‑style “rs485‑fermentation‑pid‑control” that exposes setpoints, tunings, and process values over RS485 (Modbus‑RTU), uses a reliable RTD temperature front‑end, and provides an auxiliary HTTP JSON status on Ethernet.

You’ll find precise wiring (text and table), full code, deterministic build/flash/run commands with Arduino CLI, step‑by‑step validation (sensor, PID actuation, RS485, Ethernet), troubleshooting, and improvements.


Prerequisites

  • Familiarity with:
  • SPI devices on shared buses and chip‑select discipline.
  • UART/RS485 half‑duplex with transceiver direction control.
  • PID control concepts and time‑proportioning (SSR windows).
  • Arduino CLI workflow on Linux/macOS/Windows.
  • Tools:
  • A computer with Arduino CLI v0.32.0 or newer (tested with v0.35.x).
  • A USB cable for Arduino Mega 2560.
  • For RS485 validation: a USB‑RS485 adapter and the mbpoll tool.
  • For HTTP validation: curl.
  • Basic hand tools and a multimeter.

Safety note: If you drive a mains heater with an SSR, follow electrical codes, use proper enclosures and fusing, and isolate high voltage wiring. The controller side remains low voltage; never touch mains terminals when powered.


Materials (exact models)

  • Microcontroller: Arduino Mega 2560 (ATmega2560‑16AU)
  • Ethernet: Arduino W5500 Ethernet Shield (compatible with Arduino Ethernet library)
  • RS485: MAX485 module (5V TTL, RO/RE/DE/DI pinout; typical small PCB module)
  • RTD Front‑end: MAX31865 breakout (3.3V, PT100/PT1000 compatible; Adafruit MAX31865 or equivalent with level shifting)
  • Temperature sensor: PT100 (Class B or better), 2‑wire or 3‑wire (preferable), stainless probe
  • Solid State Relay (SSR): Zero‑cross AC SSR rated appropriately for your heater
  • Power: 5V from Arduino; 3.3V for MAX31865
  • Cabling and termination: 120 Ω terminator for RS485 bus end
  • Optional: USB‑RS485 dongle for testing Modbus‑RTU

Setup / Connection

The Arduino Mega 2560 hosts three peripherals:

  • W5500 Ethernet shield: SPI device using the ICSP header (hardware SPI), CS on D10.
  • MAX31865 RTD amplifier: SPI device on the same bus, dedicated CS on D9.
  • MAX485 RS485 transceiver: Half‑duplex UART using Serial1 (TX1/RX1 on pins 18/19), direction control pin on D2.

We also control a heater via an SSR on D6 using a time‑proportion window (on/off within a fixed time window to approximate duty cycle).

SPI considerations

  • On the Mega, hardware SPI is on the 6‑pin ICSP header (not pins 11/12/13 as on the Uno).
  • The W5500 shield already routes MISO/MOSI/SCK through ICSP.
  • The MAX31865 must share SPI lines and use a distinct CS pin. Ensure the non‑active CS stays HIGH to avoid bus contention.

RS485 considerations

  • Use Serial1 for RS485. Connect:
  • Mega TX1 (pin 18) → MAX485 DI
  • Mega RX1 (pin 19) → MAX485 RO
  • DE and RE (active‑high) tied together → Mega D2 (direction control)
  • MAX485 VCC 5V and GND to Mega 5V/GND
  • RS485 A/B lines to twisted pair; 120 Ω termination at the far ends; bias resistors as recommended (many modules include bias).

PT100 wiring to MAX31865

  • Choose 3‑wire PT100 if available; it compensates for lead resistance.
  • Follow your MAX31865 board’s 2/3/4‑wire jumpers.
  • Typical Adafruit MAX31865 has solder jumpers for 2‑wire (bridge) or 3‑wire mode.
  • Reference resistor (Rref) on most PT100 boards is 430.0 Ω. Use the exact value marked on your board for best accuracy.

SSR (heater) wiring

  • Mega D6 → SSR input “+”
  • Mega GND → SSR input “−”
  • SSR load side in series with the heater and mains (performed by a qualified person). Use zero‑cross SSR for resistive heaters.

Pin/Signal Mapping Table

Function Device/Pin Arduino Mega 2560 Pin Notes
SPI SCK W5500 + MAX31865 ICSP‑3 Shared SPI via ICSP
SPI MISO W5500 + MAX31865 ICSP‑1 Shared
SPI MOSI W5500 + MAX31865 ICSP‑4 Shared
W5500 CS W5500 Ethernet Shield D10 Reserved for Ethernet
SD card CS (on shield) microSD (unused) D4 Set HIGH as OUTPUT to deselect
MAX31865 CS MAX31865 D9 Choose any free digital pin
MAX31865 VIN MAX31865 3.3V Prefer 3.3V; ensure board is 5V‑tolerant on logic
MAX31865 GND MAX31865 GND Common ground
RS485 DI MAX485 Mega TX1 (D18) Serial1 TX
RS485 RO MAX485 Mega RX1 (D19) Serial1 RX
RS485 DE+RE MAX485 D2 Tie DE and RE together to D2
RS485 VCC MAX485 5V Power for MAX485
RS485 GND MAX485 GND Common ground
Heater SSR control SSR (input +) D6 Time‑proportion window output
Heater SSR return SSR (input −) GND Return

Notes:
– If your MAX31865 breakout isn’t 5V‑tolerant on logic, keep SPI at 3.3V via level shifting. Many brand‑name boards include level shifting and tolerate 5V logic; check docs.
– Keep SPI CS lines configured as OUTPUT and set HIGH when not selected. We explicitly set D4 HIGH (microSD CS) to avoid interference.


Full Code (Arduino Mega 2560)

Create a folder “fermenter‑pid‑rs485” and save the following as “fermenter‑pid‑rs485/fermenter‑pid‑rs485.ino”.

/*
  Fermentation PID Controller with RS485 (Modbus-RTU) and Ethernet status
  Hardware: Arduino Mega 2560 + W5500 Ethernet Shield + MAX485 + MAX31865 (PT100)
  Features:
    - PT100 via MAX31865 (SPI)
    - PID (time-proportioning window) for heater SSR on D6
    - Modbus-RTU slave on RS485 (Serial1), DE/RE on D2
    - HTTP JSON status on Ethernet (W5500) at /status
*/

#include <SPI.h>
#include <Ethernet.h>
#include <Adafruit_MAX31865.h>
#include <PID_v1.h>
#include <ModbusRTU.h>

// ---------------- Hardware configuration ----------------
static const uint8_t PIN_ETHERNET_CS = 10;  // W5500 CS (from shield)
static const uint8_t PIN_SD_CS       = 4;   // SD CS (unused)
static const uint8_t PIN_MAX31865_CS = 9;   // MAX31865 CS
static const uint8_t PIN_RS485_DIR   = 2;   // MAX485 DE+RE
static const uint8_t PIN_HEATER_SSR  = 6;   // Heater SSR control

// MAX31865 object
Adafruit_MAX31865 rtd = Adafruit_MAX31865(PIN_MAX31865_CS);

// RTD/board constants (adjust to your board’s exact Rref and RTD type)
#define RREF      430.0   // Ohms for PT100 board (typical Adafruit: 430.0)
#define RNOMINAL  100.0   // Ohms for PT100

// Ethernet configuration
byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED }; // Use a unique MAC on your LAN
IPAddress ip(192, 168, 1, 50);
IPAddress dns(192, 168, 1, 1);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);

EthernetServer server(80);

// ---------------- PID configuration ----------------
double setpointC = 20.00;    // default 20.00 C
double inputC = 0.0;
double outputPct = 0.0;      // 0..100 %
double Kp = 30.0, Ki = 0.5, Kd = 200.0;  // conservative starting point (tune in process)

PID pid(&inputC, &outputPct, &setpointC, Kp, Ki, Kd, REVERSE);

// Time-proportioning window (ms)
volatile unsigned long windowSizeMs = 2000UL;
unsigned long windowStart = 0;

// Enable/disable control
volatile bool controlEnabled = true;

// ---------------- Modbus RTU (slave) on RS485 ----------------
ModbusRTU mb;

// Modbus unit id
static const uint8_t MODBUS_ID = 1;

// Holding Register map (16-bit word registers)
// Scale strategy: temperatures in centi-degC, PID terms scaled by x100, output in 0..1000 = 0..100.0%
enum {
  HR_SETPOINT_Cx100 = 0, // write/read
  HR_TEMP_Cx100     = 1, // read
  HR_OUTPUT_x10     = 2, // read (0..1000)
  HR_WINDOW_MS_L    = 3, // write/read low 16 bits
  HR_WINDOW_MS_H    = 4, // write/read high 16 bits
  HR_KP_x100        = 5, // write/read
  HR_KI_x100        = 6, // write/read
  HR_KD_x100        = 7, // write/read
  HR_ENABLE         = 8, // write/read (0=off, 1=on)
  HR_FAULT          = 9, // read (fault bitmap from MAX31865)
  HR_COUNT          = 10
};

uint16_t hregs[HR_COUNT] = {0};

// ---------------- Helpers ----------------
void applyModbusWrites() {
  // Apply changes from hregs to runtime variables
  setpointC = ((int16_t)hregs[HR_SETPOINT_Cx100]) / 100.0;
  uint32_t ws = (uint32_t)hregs[HR_WINDOW_MS_L] | ((uint32_t)hregs[HR_WINDOW_MS_H] << 16);
  if (ws >= 250 && ws <= 60000) {  // limit reasonable window 0.25..60 s
    windowSizeMs = ws;
  }
  bool newEnable = (hregs[HR_ENABLE] != 0);
  controlEnabled = newEnable;
  // Update tunings if changed
  Kp = ((int16_t)hregs[HR_KP_x100]) / 100.0;
  Ki = ((int16_t)hregs[HR_KI_x100]) / 100.0;
  Kd = ((int16_t)hregs[HR_KD_x100]) / 100.0;
  pid.SetTunings(Kp, Ki, Kd);
}

void refreshModbusRegs() {
  // Write runtime values back to holding registers
  int16_t tempCx100 = (int16_t)(inputC * 100.0);
  hregs[HR_TEMP_Cx100] = (uint16_t)tempCx100;
  uint16_t outx10 = (uint16_t)(outputPct * 10.0); // 0..1000
  hregs[HR_OUTPUT_x10] = outx10;
  hregs[HR_WINDOW_MS_L] = (uint16_t)(windowSizeMs & 0xFFFF);
  hregs[HR_WINDOW_MS_H] = (uint16_t)(windowSizeMs >> 16);
  hregs[HR_SETPOINT_Cx100] = (int16_t)(setpointC * 100.0);
  hregs[HR_KP_x100] = (int16_t)(Kp * 100.0);
  hregs[HR_KI_x100] = (int16_t)(Ki * 100.0);
  hregs[HR_KD_x100] = (int16_t)(Kd * 100.0);
  hregs[HR_ENABLE] = controlEnabled ? 1 : 0;
  // Fault register is handled in the sensor read step
}

bool cbReadHreg(TRegister* reg, uint16_t val) {
  // Called after each read; nothing special needed
  (void)reg; (void)val;
  return true;
}

bool cbWriteHreg(TRegister* reg, uint16_t val) {
  // Called on writes; update local mirror and apply changes
  (void)val;
  // Mirror all HREGs from Modbus stack to hregs[]
  for (uint16_t i = 0; i < HR_COUNT; i++) {
    hregs[i] = mb.Hreg(i);
  }
  applyModbusWrites();
  return true;
}

void setupModbus() {
  Serial1.begin(19200, SERIAL_8N1); // RS485 bus speed
  mb.begin(&Serial1, PIN_RS485_DIR);
  mb.slave(MODBUS_ID);

  // Add holding registers
  for (uint16_t i = 0; i < HR_COUNT; i++) {
    mb.addHreg(i, 0, 1); // 1 = allow write (we'll gate at application level)
  }
  // Initialize with defaults
  hregs[HR_SETPOINT_Cx100] = (int16_t)(setpointC * 100.0);
  hregs[HR_WINDOW_MS_L] = (uint16_t)(windowSizeMs & 0xFFFF);
  hregs[HR_WINDOW_MS_H] = (uint16_t)(windowSizeMs >> 16);
  hregs[HR_KP_x100] = (int16_t)(Kp * 100.0);
  hregs[HR_KI_x100] = (int16_t)(Ki * 100.0);
  hregs[HR_KD_x100] = (int16_t)(Kd * 100.0);
  hregs[HR_ENABLE] = controlEnabled ? 1 : 0;

  // Push initial values to stack
  for (uint16_t i = 0; i < HR_COUNT; i++) {
    mb.Hreg(i, hregs[i]);
  }

  // Register callbacks
  mb.onGetHreg(cbReadHreg);
  mb.onSetHreg(cbWriteHreg);
}

void setupEthernet() {
  pinMode(PIN_ETHERNET_CS, OUTPUT);
  digitalWrite(PIN_ETHERNET_CS, HIGH);
  pinMode(PIN_SD_CS, OUTPUT);
  digitalWrite(PIN_SD_CS, HIGH); // Deselect SD
  Ethernet.init(PIN_ETHERNET_CS);
  Ethernet.begin(mac, ip, dns, gateway, subnet);
  delay(1000);
  server.begin();
}

void setupMAX31865() {
  if (!rtd.begin(MAX31865_3WIRE)) { // set to MAX31865_2WIRE, _3WIRE, or _4WIRE
    // If begin fails, continue; faults will be reported
  }
}

void setupPID() {
  pid.SetOutputLimits(0.0, 100.0); // 0..100 %
  pid.SetMode(AUTOMATIC);
  pid.SetSampleTime(1000); // 1 s sample time
  windowStart = millis();
}

// Read temperature and record fault flags
uint8_t lastFault = 0;
void readTemperature() {
  lastFault = rtd.readFault(); // Check before conversion to clear stale flags
  if (lastFault) {
    rtd.clearFault();
  }
  inputC = rtd.temperature(RNOMINAL, RREF);
  // Fault capture for Modbus
  lastFault = rtd.readFault();
  hregs[HR_FAULT] = lastFault;
}

// Apply time-proportioning control to SSR
void applySSR() {
  unsigned long now = millis();
  if (now - windowStart >= windowSizeMs) {
    windowStart += windowSizeMs; // Avoid drift from millis() wrap
  }
  bool heaterOn = false;
  if (controlEnabled) {
    unsigned long onTime = (unsigned long)(outputPct * windowSizeMs / 100.0);
    heaterOn = ((now - windowStart) < onTime);
  } else {
    heaterOn = false;
  }
  digitalWrite(PIN_HEATER_SSR, heaterOn ? HIGH : LOW);
}

void handleHTTP() {
  EthernetClient client = server.available();
  if (!client) return;

  // Very simple HTTP 1.0 parser
  String req = client.readStringUntil('\r');
  client.readStringUntil('\n'); // consume newline
  // Only handle GET /status
  bool ok = req.startsWith("GET /status");
  while (client.available()) client.read(); // drain

  if (!ok) {
    client.println("HTTP/1.0 404 Not Found");
    client.println("Content-Type: text/plain");
    client.println("Connection: close");
    client.println();
    client.println("Not Found");
    client.stop();
    return;
  }

  // Build JSON
  client.println("HTTP/1.0 200 OK");
  client.println("Content-Type: application/json");
  client.println("Cache-Control: no-store");
  client.println("Connection: close");
  client.println();
  client.print("{\"ip\":\"");
  client.print(Ethernet.localIP());
  client.print("\",\"setpoint_c\":");
  client.print(setpointC, 2);
  client.print(",\"temp_c\":");
  client.print(inputC, 2);
  client.print(",\"output_pct\":");
  client.print(outputPct, 1);
  client.print(",\"window_ms\":");
  client.print(windowSizeMs);
  client.print(",\"enabled\":");
  client.print(controlEnabled ? "true" : "false");
  client.print(",\"kp\":");
  client.print(Kp, 2);
  client.print(",\"ki\":");
  client.print(Ki, 2);
  client.print(",\"kd\":");
  client.print(Kd, 2);
  client.print(",\"fault\":");
  client.print(lastFault);
  client.println("}");
  client.stop();
}

void setup() {
  // IO basics
  pinMode(PIN_HEATER_SSR, OUTPUT);
  digitalWrite(PIN_HEATER_SSR, LOW);
  pinMode(PIN_RS485_DIR, OUTPUT);
  digitalWrite(PIN_RS485_DIR, LOW); // receive by default

  // Subsystems
  setupEthernet();
  setupMAX31865();
  setupPID();
  setupModbus();
}

unsigned long lastCompute = 0;
void loop() {
  // 1 s loop for PID; sensor read first
  unsigned long now = millis();
  if (now - lastCompute >= 1000) {
    lastCompute = now;
    readTemperature();
    if (controlEnabled && !hregs[HR_FAULT]) {
      pid.Compute();
    } else {
      outputPct = 0.0;
    }
    refreshModbusRegs();
    // Push HREG mirrors to stack for Modbus clients to read
    for (uint16_t i = 0; i < HR_COUNT; i++) {
      mb.Hreg(i, hregs[i]);
    }
  }

  // Apply SSR time proportioning continuously
  applySSR();

  // Service Modbus RTU
  mb.task();

  // Service HTTP requests
  handleHTTP();
}

Key notes on the code:

  • The Modbus holding registers are scaled to keep integer 16‑bit values:
  • Temperatures are centi‑degrees (e.g., 2000 = 20.00 C).
  • PID tunings multiplied by 100.
  • Output multiplied by 10 (0..1000 equals 0.0..100.0%).
  • Window size stored as a 32‑bit value across two HREGs.
  • PID is “REVERSE” action: when the temperature is below setpoint, the output increases.
  • Time‑proportioning SSR window defaults to 2 s; adjust via Modbus.
  • An HTTP GET /status returns a JSON snapshot for quick integration.

Build / Flash / Run (Arduino CLI)

We use Arduino CLI on the Arduino Mega 2560 (AVR). Commands below are explicit and reproducible. Replace the serial port with your actual port.

Create the project directory and place the .ino as instructed:
– Project path: ~/projects/fermenter-pid-rs485/fermenter-pid-rs485.ino

Install Arduino CLI and libraries:

arduino-cli version

# Update core indices and install AVR core
arduino-cli core update-index
arduino-cli core install arduino:avr

# Verify the board is detected and note the port (e.g., /dev/ttyACM0 or COM5)
arduino-cli board list

# Install required libraries with explicit versions
arduino-cli lib install "Ethernet@2.0.2"
arduino-cli lib install "Adafruit MAX31865 library@1.4.5"
arduino-cli lib install "PID@1.2.1"
arduino-cli lib install "ModbusRTU@2.3.8"

Compile and upload for Arduino Mega 2560:

# Compile (FQBN for Mega 2560)
arduino-cli compile --fqbn arduino:avr:mega ~/projects/fermenter-pid-rs485

# Upload (replace port as detected)
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:mega ~/projects/fermenter-pid-rs485

Run and monitor serial logs if you add prints (this sketch is quiet on serial by design):

# Optional: serial monitor at 115200 if you add debug messages
arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200

Network sanity checks after the board reboots:

# Check reachability
ping -c 3 192.168.1.50

# Fetch JSON status
curl -s http://192.168.1.50/status | jq .

Step‑by‑step Validation

Follow these steps systematically after wiring is complete and firmware is flashed.

1) SPI bus integrity and device selection

  • Power up the system with the W5500 shield attached.
  • Ensure D4 is set HIGH (our code does this) so the SD card is deselected.
  • Confirm the MAX31865 CS line idles HIGH and toggles when reading.
  • If you have a logic probe or LED, verify activity on D9 when the board runs.

Expected: Ethernet responds to pings, HTTP status is reachable, temperature field shows a plausible value.

2) Temperature sensing via MAX31865

  • Run:
  • curl -s http://192.168.1.50/status
  • Verify fields: "temp_c" is near ambient (e.g., 20‑28 C). Touch the PT100 probe: temperature should climb slowly.
  • Fault handling:
  • Disconnect the RTD: fault should become non‑zero (open circuit flags). Reconnect to clear.
  • If temperature reads unrealistically high/low or fault persists, recheck MAX31865 wiring and jumper configuration (2/3/4‑wire).

3) PID output and SSR actuation

  • The heater SSR output is on D6. Without a heater connected, you can still observe LED on SSR modules (if present).
  • Temporarily set setpoint below current temp to force 0% output:
  • Default setpoint is 20.00 C; if ambient is ~23 C, output should be 0.
  • Raise the setpoint via Modbus to 28.00 C and confirm the output rises (see steps in RS485 section).
  • Observe SSR behavior:
  • The SSR will toggle within a 2 s window.
  • At 50% output, it should be ON ~1 s and OFF ~1 s within each window.

4) RS485 Modbus‑RTU: read/write registers

Use a USB‑RS485 adapter and connect it to the RS485 A/B lines with proper polarity and a 120 Ω terminator at the far end of the bus (your controller can be one end, the USB dongle the other).

Install mbpoll (Linux/macOS; Windows builds are also available):

# Debian/Ubuntu
sudo apt-get update && sudo apt-get install -y mbpoll

# macOS (Homebrew)
brew install mbpoll

Reading status registers (unit id 1, 19200 8N1, no parity):

# Read 10 holding registers starting at 0
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 0 -c 10 /dev/ttyUSB0
  • -t 4 selects holding registers.
  • Expect values:
  • Reg0 (setpoint) ~ 2000 for 20.00 C
  • Reg1 (temp) varies around ambient
  • Reg2 (output) 0..1000
  • Reg3/4 (window L/H)
  • Reg5/6/7 for Kp/Ki/Kd x100
  • Reg8 enable (0/1)
  • Reg9 fault bitmap

Write a new setpoint to 28.00 C (2800 in centi‑C):

# Write single register (HR 0) value 2800
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 0 -1 2800 /dev/ttyUSB0

Enable control (HR 8 = 1):

mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 8 -1 1 /dev/ttyUSB0

Change PID tunings (example Kp=35.00, Ki=0.80, Kd=250.00), scale x100:

mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 5 -1 3500 /dev/ttyUSB0
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 6 -1 80   /dev/ttyUSB0
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 7 -1 25000 /dev/ttyUSB0

Confirm that:
– The output (HR 2) rises when the setpoint exceeds the current temperature.
– The HTTP /status JSON reflects the same variables you wrote via Modbus.

5) Ethernet status endpoint

  • Check link lights on the Ethernet shield.
  • Verify IP: ping 192.168.1.50
  • Retrieve JSON:
    bash
    curl -s http://192.168.1.50/status | jq .
  • Fields to verify:
  • setpoint_c, temp_c, output_pct, window_ms, enabled, kp/ki/kd, fault.
  • Cross‑compare with Modbus values for consistency.

6) Closed‑loop test

  • With the heater connected and the probe placed in a water bath or fermentation vessel (safer: a bench test in a cup of water):
  • Start from ambient; set setpoint +2 C above ambient.
  • Observe temperature rising; expect overshoot if Kd is low or Ki is high.
  • Adjust Kp, Ki, Kd via Modbus in small increments:
    • Increase Kp for faster response (watch for oscillation).
    • Increase Kd to reduce overshoot.
    • Adjust Ki to remove steady‑state error (avoid integral windup).
  • Verify that at steady state, output_pct hovers near a value that compensates heat losses.

Troubleshooting

  • No temperature change or fault flagged:
  • Check MAX31865 wiring, power at 3.3V, and that your board is configured for the correct wire count (2/3/4‑wire).
  • Verify Rref value; if your board uses 400 Ω vs 430 Ω, adjust #define RREF.
  • Ensure CS pins: D9 for MAX31865 must be OUTPUT and default HIGH (the library manages this).
  • Ethernet not responding:
  • Ensure D4 (SD CS) is set to OUTPUT and HIGH (our setupEthernet() does this).
  • Check for IP conflicts; change the IP to a free address.
  • Verify cabling and link LEDs.
  • RS485 communication errors (timeouts, CRC):
  • Confirm DE/RE tied together to D2 and that your library is controlling direction (ModbusRTU handles this).
  • Check A/B polarity; swap if needed (A↔B reversed yields no comms).
  • Use 120 Ω terminators only at physical ends of the bus; add bias resistors if your modules don’t include them (typical ~680 Ω–1 kΩ pull‑up on A and pull‑down on B, or follow application note).
  • Match serial settings exactly: 19200, 8N1, no parity.
  • PID seems ineffective:
  • Verify controlEnabled (HR 8) is set to 1.
  • Confirm SSR output on D6 toggles within windows; a DMM won’t show duty—use an LED SSR indicator or scope.
  • Increase window size if your heater is large/inertial (e.g., 5–10 s) to reduce relay chatter and allow heat to integrate smoothly.
  • Temperature noisy or drifting:
  • Improve RTD cabling (twisted pair, shielded).
  • Use 3‑wire RTD to compensate lead resistance.
  • Add simple filtering (moving average) in software if needed (beware of added lag).
  • Library conflicts on SPI:
  • Ensure only one CS is LOW at a time. If adding more SPI devices, define unique CS pins and set them HIGH by default.

Improvements

  • Add cooling output: implement split‑range with two SSRs (heat and cool) and map output above 50% to heat and below 50% to cool, or run two PIDs with interlocks.
  • Auto‑tuning: integrate a relay auto‑tuner to estimate process gain/time constants, then compute PID parameters (e.g., Ziegler–Nichols or Tyreus–Luyben).
  • Modbus‑TCP server: serve the same registers on Ethernet (W5500) for SCADA systems that use TCP instead of RTU.
  • Data logging: publish CSV/JSON to an SD card or a remote InfluxDB via UDP/TCP; add timestamps via NTP.
  • Safety interlocks: add high‑temp cutout, sensor plausibility checks, and watchdog resets.
  • Calibration: implement a two‑point calibration for the RTD path to compensate systematic offset from wiring and Rref tolerances.
  • Multi‑vessel scaling: use the Mega’s resources to manage multiple MAX31865 channels and SSRs, each with its own PID loop and Modbus register block.

Final Checklist

  • Materials
  • Arduino Mega 2560, W5500 shield, MAX485, MAX31865, PT100, SSR, cables, 120 Ω terminator.
  • Wiring
  • SPI: W5500 via ICSP, MAX31865 on CS D9, SD CS D4 pulled HIGH.
  • RS485: TX1→DI, RX1→RO, DE/RE→D2, A/B wired with termination.
  • SSR: D6→SSR(+), GND→SSR(−); mains side wired safely.
  • Firmware
  • Code placed at ~/projects/fermenter-pid-rs485/fermenter-pid-rs485.ino.
  • Libraries installed: Ethernet@2.0.2, Adafruit MAX31865 library@1.4.5, PID@1.2.1, ModbusRTU@2.3.8.
  • Built and uploaded with:
    • arduino-cli core install arduino:avr
    • arduino-cli compile --fqbn arduino:avr:mega
    • arduino-cli upload -p <PORT> --fqbn arduino:avr:mega
  • Validation
  • HTTP: curl http://192.168.1.50/status returns JSON with plausible temps.
  • RS485: mbpoll reads/writes HREGs; setpoint/tunings take effect.
  • PID/SSR: output windowing toggles D6; heater responds in process.
  • Faults: MAX31865 fault bit non‑zero when RTD is disconnected; returns to zero when restored.
  • Tuning
  • Set window_ms to match actuator; tune Kp/Ki/Kd for minimal overshoot and steady control.
  • Documentation
  • Record your Modbus map, IP settings, and PID tunings for future reproducibility.

With this build, you have a field‑ready fermentation controller that speaks Modbus‑RTU over RS485 for integration into supervisory systems and provides a convenient Ethernet status endpoint for quick checks and 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 purpose of the project described in the article?




Question 2: Which microcontroller is used in the project?




Question 3: What type of sensor is utilized for temperature measurement?




Question 4: What communication protocol is employed for supervision/control?




Question 5: Which tool is mentioned for HTTP validation?




Question 6: What is the function of the MAX485 component in the project?




Question 7: What is a prerequisite for this project regarding PID control?




Question 8: What type of connection does the W5500 Ethernet Shield provide?




Question 9: Which version of Arduino CLI is recommended for this project?




Question 10: What safety precaution is mentioned for driving a mains heater?




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: Arduino MKR GSM 1400 GNSS/GSM Tracker

Practical case: Arduino MKR GSM 1400 GNSS/GSM Tracker — hero

Objective and use case

What you’ll build: Build a robust, field-ready GNSS-GSM asset tracker using the Arduino MKR GSM 1400, NEO-M8N, and BNO055. The device will periodically read GNSS position and orientation/acceleration, then post JSON telemetry over GSM to a server endpoint.

Why it matters / Use cases

  • Real-time tracking of assets in remote locations using GNSS data for precise location.
  • Monitoring environmental conditions and movement of vehicles or equipment in logistics.
  • Implementing safety measures by tracking the location of valuable assets in transit.
  • Collecting data for fleet management systems to optimize routes and reduce costs.

Expected outcome

  • Telemetry data sent every 10 seconds with a location accuracy of within 5 meters.
  • GSM connectivity with a minimum uptime of 95% in urban areas.
  • Latency of less than 2 seconds for data transmission to the server.
  • Ability to handle at least 100 packets of telemetry data per hour.

Audience: Developers and engineers interested in IoT applications; Level: Intermediate.

Architecture/flow: The system architecture includes the Arduino MKR GSM 1400 interfacing with the NEO-M8N for GNSS data and the BNO055 for orientation, sending data over GSM to a cloud server.

Advanced Hands‑On: GNSS + GSM Asset Tracker on Arduino MKR GSM 1400 + NEO‑M8N + BNO055

Objective: Build a robust, field‑ready gnss-gsm-asset-tracker using the exact device model Arduino MKR GSM 1400 + NEO-M8N + BNO055. The device periodically reads GNSS position and orientation/acceleration, then posts JSON telemetry over GSM (GPRS/EDGE/3G) to a server endpoint. We’ll use PlatformIO for a reproducible, version‑pinned build and command‑line workflow.

Note: Because we selected a board different from Arduino UNO (we’re using MKR GSM 1400), we will use PlatformIO (not Arduino CLI), and include driver notes and PlatformIO commands accordingly.


Prerequisites

  • OS: Windows 10/11, macOS 12+, or Linux (Ubuntu 20.04+). USB 2.0/3.0 port.
  • Python 3.8+ with pip.
  • PlatformIO Core (CLI) installed via pip.
  • A working micro SIM card with an active data plan; know the APN, APN username, APN password (many providers use blank username/password).
  • A reachable HTTP endpoint for testing. Easiest: generate a unique receiver at https://webhook.site/ (copy the unique path/UUID).
  • Stable internet on the development computer (to fetch libs and platforms).
  • Basic ESD safety: handle boards/modules on a non‑conductive surface.

Driver notes:
– Arduino MKR GSM 1400 uses native USB CDC (no CP210x/CH34x needed). On Windows 10/11, it should enumerate automatically. If not recognized, install “Arduino SAMD Boards” USB driver via Arduino IDE or Zadig. On macOS/Linux, it appears as /dev/cu.usbmodemNNN or /dev/ttyACM0.
– If the port seems to “disappear”, double‑tap the MKR GSM 1400 reset button to force the bootloader COM port for upload.


Materials (exact models)

  • 1 × Arduino MKR GSM 1400 board (SAMD21 + u‑blox SARA‑U201 modem).
  • 1 × GNSS module: u‑blox NEO‑M8N breakout with I2C exposed (address 0x42) and 3.3 V compatibility.
  • 1 × 9‑DoF absolute orientation IMU: Bosch BNO055 breakout (default I2C address 0x28, 3.3 V compatible).
  • 1 × External GSM antenna (u.FL) for MKR GSM 1400.
  • 1 × External GNSS antenna (u.FL or SMA with pigtail, depending on the NEO‑M8N breakout).
  • 1 × Micro SIM card (data plan).
  • Jumper wires (dupont), preferably color‑coded.
  • 1 × Micro USB cable (data‑capable).
  • Optional: 4.7 kΩ pull‑up resistors for SDA/SCL if your breakout does not include them (many do).

Power safety:
– MKR GSM 1400 runs at 3.3 V logic. Ensure both NEO‑M8N and BNO055 breakouts are 3.3 V compatible or include level shifting. Power them from the 3.3 V pin, not 5 V.


Setup/Connection

SIM and Antennas

  1. Power off the board.
  2. Insert the micro SIM (contacts facing down) into the MKR GSM 1400 slot.
  3. Connect the GSM antenna to the u.FL connector on MKR GSM 1400.
  4. Connect the GNSS antenna to NEO‑M8N’s antenna connector (u.FL or adapter as needed).
  5. Ensure both antenna pigtails are fully seated; intermittent antennas are a common cause of “no service” or “no fix”.

Power and I2C Wiring

We will use I2C for both sensors to keep UART free and simplify wiring.

  • Voltage rails:
  • Use only 3.3 V from the MKR GSM 1400 to power the sensors.
  • Common ground between all modules.

  • I2C:

  • MKR GSM 1400 I2C pins: labeled SDA and SCL on the header near 3.3 V/GND (dedicated SDA/SCL pins).
  • NEO‑M8N DDC/I2C: SDA to SDA, SCL to SCL (address 0x42).
  • BNO055: SDA to SDA, SCL to SCL (default address 0x28 with ADR pin low).
  • If your sensor breakouts do not have onboard pull‑ups on SDA and SCL, add 4.7 kΩ from SDA to 3.3 V and from SCL to 3.3 V. Do not pull up to 5 V.

Connection Table

MKR GSM 1400 Pin NEO‑M8N Pin (I2C mode) BNO055 Pin Notes
3.3V VCC (3.3V) VIN (3.3V) Power both sensors from 3.3 V only
GND GND GND Common ground
SDA SDA/DDC_SDA SDA I2C data line
SCL SCL/DDC_SCL SCL I2C clock line
1PPS (optional) Optional timing pin (not used)
TX/RX (unused) We’re not using UART for GNSS in this build

Important: Do not connect any 5 V signals to MKR pins. Ensure the GNSS and IMU breakouts are I2C‑enabled and 3.3 V compatible.


Full Code

We’ll use PlatformIO with pinned library versions for reproducibility. The project structure:

  • platformio.ini
  • src/main.cpp

Replace placeholders for APN, APN_USER, APN_PASS, and WEBHOOK_PATH in the code as instructed.

platformio.ini

[env:mkrgsm1400]
platform = atmelsam
board = mkrgsm1400
framework = arduino
upload_port = auto
monitor_port = auto
monitor_speed = 115200
build_flags =
  -D PIO_FRAMEWORK_ARDUINO_ENABLE_CDC
  -D USBCON
lib_deps =
  arduino-libraries/MKRGSM@^1.5.0
  arduino-libraries/ArduinoHttpClient@^0.6.0
  sparkfun/SparkFun u-blox GNSS v3@^3.1.12
  adafruit/Adafruit BNO055@^1.6.3
  adafruit/Adafruit Unified Sensor@^1.1.14
  bblanchon/ArduinoJson@^6.21.3

src/main.cpp

#include <Arduino.h>
#include <Wire.h>
#include <MKRGSM.h>
#include <ArduinoHttpClient.h>
#include <ArduinoJson.h>

// GNSS
#include <SparkFun_u-blox_GNSS_v3.h> // SFE_UBLOX_GNSS

// IMU
#include <Adafruit_Sensor.h>
#include <Adafruit_BNO055.h>
#include <utility/imumaths.h>

// ====== User configuration (edit these) ======
static const char APN[]      = "internet";   // Replace with your carrier APN
static const char APN_USER[] = "";           // Often empty
static const char APN_PASS[] = "";           // Often empty
static const char SIM_PIN[]  = "";           // SIM PIN if required; else empty

// HTTP endpoint (non-SSL for simplicity). For SSL, see Improvements section.
static const char HOST[]     = "webhook.site";      // Or your server
static const int  PORT       = 80;                  // 80 for HTTP
static const char WEBHOOK_PATH[] = "/YOUR-UNIQUE-PATH"; // e.g., /a1b2c3d4-... from webhook.site
// ============================================

GSM gsmAccess;
GPRS gprs;
GSMClient netClient;
HttpClient httpClient(netClient, HOST, PORT);
GSMModem modem;

SFE_UBLOX_GNSS gnss;        // I2C GNSS at 0x42
Adafruit_BNO055 bno(55, 0x28); // 0x28 default; use 0x29 if ADR high

// Timing
const unsigned long POST_INTERVAL_MS = 30000; // 30s
unsigned long lastPost = 0;

// Simple I2C scan for validation
void i2cScan() {
  Serial.println(F("[I2C] Scanning..."));
  byte count = 0;
  for (byte address = 1; address < 127; address++) {
    Wire.beginTransmission(address);
    byte error = Wire.endTransmission();
    if (error == 0) {
      Serial.print(F(" - Found 0x"));
      if (address < 16) Serial.print('0');
      Serial.println(address, HEX);
      count++;
    }
  }
  Serial.print(F("[I2C] Devices found: "));
  Serial.println(count);
}

// Attempt GSM + GPRS connection with timeout
bool connectCellular(unsigned long timeoutMs = 120000) {
  Serial.println(F("[GSM] Starting modem..."));
  unsigned long start = millis();

  // Initialize GSM access (SIM PIN if any)
  while (gsmAccess.begin(SIM_PIN) != GSM_READY) {
    if (millis() - start > timeoutMs) {
      Serial.println(F("[GSM] ERROR: Modem init timeout"));
      return false;
    }
    Serial.println(F("[GSM] Retrying modem init..."));
    delay(2000);
  }
  Serial.println(F("[GSM] Modem ready"));

  // Retrieve IMEI for device ID
  String imei = modem.getIMEI();
  if (imei.length() > 0) {
    Serial.print(F("[GSM] Modem IMEI: "));
    Serial.println(imei);
  } else {
    Serial.println(F("[GSM] WARNING: Could not read IMEI"));
  }

  Serial.print(F("[GPRS] Attaching to APN: "));
  Serial.println(APN);
  start = millis();
  while (gprs.attachGPRS(APN, APN_USER, APN_PASS) != GPRS_READY) {
    if (millis() - start > timeoutMs) {
      Serial.println(F("[GPRS] ERROR: APN attach timeout"));
      return false;
    }
    Serial.println(F("[GPRS] Retrying GPRS attach..."));
    delay(2000);
  }
  Serial.println(F("[GPRS] GPRS attached."));

  // Optional: show local IP (if supported)
  IPAddress ip = gprs.getIPAddress();
  Serial.print(F("[GPRS] Local IP: "));
  Serial.println(ip);

  return true;
}

bool initGNSS() {
  Serial.println(F("[GNSS] Initializing u-blox over I2C (0x42)..."));
  if (!gnss.begin()) {
    Serial.println(F("[GNSS] ERROR: GNSS not detected. Check wiring and power."));
    return false;
  }

  // Disable NMEA on I2C, use UBX only for efficiency
  gnss.setI2COutput(COM_TYPE_UBX);
  gnss.setNavigationFrequency(1); // 1 Hz update
  // Save config to BBR/Flash if supported
  gnss.saveConfiguration();

  Serial.println(F("[GNSS] OK"));
  return true;
}

bool initBNO() {
  Serial.println(F("[BNO055] Initializing..."));
  if (!bno.begin(OPERATION_MODE_NDOF)) {
    Serial.println(F("[BNO055] ERROR: Could not find BNO055. Check wiring/address."));
    return false;
  }
  delay(20);
  bno.setExtCrystalUse(true); // If breakout has a crystal; harmless if not
  Serial.println(F("[BNO055] OK"));
  return true;
}

// Read GNSS snapshot; returns true if valid fix
bool readGNSS(double &lat, double &lon, double &alt_m, float &speed_kmh, uint8_t &sats, uint8_t &fixType) {
  // Poll latest NAV-PVT
  if (!gnss.getPVT()) {
    return false; // no new data
  }

  fixType = gnss.getFixType(); // 0=no fix, 2=2D, 3=3D, etc.
  sats    = gnss.getSIV();

  long lat_1e7 = gnss.getLatitude();
  long lon_1e7 = gnss.getLongitude();
  long alt_mm  = gnss.getAltitude();    // mm above ellipsoid
  long gnd_mmps = gnss.getGroundSpeed();// mm/s

  lat = lat_1e7 / 1e7;
  lon = lon_1e7 / 1e7;
  alt_m = alt_mm / 1000.0;
  speed_kmh = (gnd_mmps / 1000.0f) * 3.6f;

  return (fixType >= 2); // consider 2D/3D as valid
}

void readIMU(float &heading_deg, float &pitch_deg, float &roll_deg, float &accel_ms2) {
  sensors_event_t orientationData, angularVelocityData, linearAccelData, magnetometerData, accelData, gyroData, tempData;
  bno.getEvent(&orientationData, Adafruit_BNO055::VECTOR_EULER);
  bno.getEvent(&accelData, Adafruit_BNO055::VECTOR_ACCELEROMETER);

  heading_deg = orientationData.orientation.x;
  roll_deg    = orientationData.orientation.y;
  pitch_deg   = orientationData.orientation.z;

  // Magnitude of acceleration vector (m/s^2)
  accel_ms2 = sqrt(accelData.acceleration.x * accelData.acceleration.x +
                   accelData.acceleration.y * accelData.acceleration.y +
                   accelData.acceleration.z * accelData.acceleration.z);
}

bool postTelemetry(const String &payload) {
  Serial.println(F("[HTTP] POST begin"));
  int err = httpClient.post(WEBHOOK_PATH, "application/json", payload);
  if (err != 0) {
    Serial.print(F("[HTTP] ERROR posting: "));
    Serial.println(err);
    return false;
  }

  int statusCode = httpClient.responseStatusCode();
  String response = httpClient.responseBody();

  Serial.print(F("[HTTP] Status: "));
  Serial.println(statusCode);
  Serial.print(F("[HTTP] Body: "));
  Serial.println(response);

  return (statusCode >= 200 && statusCode < 300);
}

String buildJSON(const String &deviceId,
                 double lat, double lon, double alt_m, float speed_kmh,
                 uint8_t sats, uint8_t fixType,
                 float heading_deg, float pitch_deg, float roll_deg, float accel_ms2) {
  StaticJsonDocument<512> doc;
  doc["device"] = deviceId;
  doc["ts"] = millis();

  JsonObject gnssObj = doc.createNestedObject("gnss");
  gnssObj["lat"] = lat;
  gnssObj["lon"] = lon;
  gnssObj["alt_m"] = alt_m;
  gnssObj["speed_kmh"] = speed_kmh;
  gnssObj["sats"] = sats;
  gnssObj["fixType"] = fixType;

  JsonObject imuObj = doc.createNestedObject("imu");
  imuObj["heading_deg"] = heading_deg;
  imuObj["pitch_deg"] = pitch_deg;
  imuObj["roll_deg"] = roll_deg;
  imuObj["accel_ms2"] = accel_ms2;

  String out;
  serializeJson(doc, out);
  return out;
}

String cachedIMEI;

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, LOW);

  Serial.begin(115200);
  while (!Serial && millis() < 3000) { } // Wait up to 3s for USB

  Serial.println(F("\n=== GNSS-GSM Asset Tracker: MKR GSM 1400 + NEO-M8N + BNO055 ==="));

  Wire.begin();
  Wire.setClock(400000); // 400 kHz if supported
  i2cScan();

  if (!initGNSS()) {
    Serial.println(F("[BOOT] GNSS init failed; continuing to retry later."));
  }
  if (!initBNO()) {
    Serial.println(F("[BOOT] BNO055 init failed; check wiring."));
  }

  if (connectCellular()) {
    cachedIMEI = modem.getIMEI();
    if (cachedIMEI.length() == 0) cachedIMEI = "unknown";
  } else {
    Serial.println(F("[BOOT] Cellular attach failed, will retry in loop."));
  }

  lastPost = 0;
  digitalWrite(LED_BUILTIN, HIGH); // indicate boot complete
}

void loop() {
  // Periodic posting
  if (millis() - lastPost >= POST_INTERVAL_MS) {
    lastPost = millis();

    // Ensure cellular is connected
    if (gprs.status() != GPRS_READY) {
      Serial.println(F("[GPRS] Reattaching..."));
      if (!connectCellular()) {
        Serial.println(F("[GPRS] ERROR: Cannot attach. Skipping this cycle."));
        return;
      }
    }

    // GNSS read
    double lat = NAN, lon = NAN, alt_m = NAN;
    float speed_kmh = NAN;
    uint8_t sats = 0, fixType = 0;
    bool hasFix = readGNSS(lat, lon, alt_m, speed_kmh, sats, fixType);

    if (!hasFix) {
      Serial.println(F("[GNSS] No valid fix yet. Ensure antenna has sky view."));
    } else {
      Serial.print(F("[GNSS] FixType=")); Serial.print(fixType);
      Serial.print(F(" Sats=")); Serial.print(sats);
      Serial.print(F(" Lat=")); Serial.print(lat, 7);
      Serial.print(F(" Lon=")); Serial.print(lon, 7);
      Serial.print(F(" Alt(m)=")); Serial.print(alt_m, 2);
      Serial.print(F(" Speed(km/h)=")); Serial.println(speed_kmh, 2);
    }

    // IMU read
    float heading_deg = NAN, pitch_deg = NAN, roll_deg = NAN, accel_ms2 = NAN;
    readIMU(heading_deg, pitch_deg, roll_deg, accel_ms2);
    Serial.print(F("[IMU] Heading/Pitch/Roll="));
    Serial.print(heading_deg, 1); Serial.print('/');
    Serial.print(pitch_deg, 1);  Serial.print('/');
    Serial.println(roll_deg, 1);

    // Build JSON and POST (even without fix, send telemetry so backend can observe device state)
    String payload = buildJSON(cachedIMEI.length() ? cachedIMEI : "unknown",
                               lat, lon, alt_m, speed_kmh, sats, fixType,
                               heading_deg, pitch_deg, roll_deg, accel_ms2);

    Serial.print(F("[JSON] ")); Serial.println(payload);
    bool ok = postTelemetry(payload);
    if (!ok) {
      Serial.println(F("[HTTP] POST failed."));
      digitalWrite(LED_BUILTIN, LOW);
    } else {
      Serial.println(F("[HTTP] POST success."));
      digitalWrite(LED_BUILTIN, HIGH);
    }
  }

  delay(50);
}

Notes:
– This example uses HTTP (port 80) to minimize TLS/certificate friction. You can upgrade to TLS with GSMSSLClient and CA injection; see Improvements.
– For BNO055, orientation output depends on calibration. Allow a minute of device motion for auto‑calibration, or see Adafruit docs for persistent calibration.


Build/Flash/Run Commands

Install PlatformIO Core and build/upload from the terminal. Replace with your actual serial port if needed.

python3 -m pip install --upgrade pip
python3 -m pip install --upgrade platformio

# 2) Verify environment
pio system info

# 3) Create project folder and place files
#   Ensure platformio.ini and src/main.cpp are in your project directory.

# 4) Fetch platforms and libraries (per platformio.ini)
pio pkg update

# 5) Clean, build, and upload (auto-detect port)
pio run -t clean -e mkrgsm1400
pio run -e mkrgsm1400
pio run -t upload -e mkrgsm1400

# If upload fails to find the port, specify it (examples):
#   - Windows: COM5
#   - Linux:   /dev/ttyACM0
#   - macOS:   /dev/cu.usbmodem14101
pio run -t upload -e mkrgsm1400 --upload-port COM5

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

Windows driver tip: If the COM port disconnects during upload, double‑tap the reset button on MKR GSM 1400 to re‑enumerate the bootloader port, then rerun the upload command with that port.


Step‑by‑Step Validation

  1. I2C device detection
  2. Connect the board via USB, open the serial monitor: pio device monitor -b 115200.
  3. On boot, you should see:

    • “[I2C] Scanning…” followed by:
    • “Found 0x28” (BNO055) and “Found 0x42” (NEO‑M8N).
    • If not detected:
    • Verify SDA/SCL wiring and 3.3 V power.
    • Ensure breakout pull‑ups or enable your own (4.7 kΩ).
    • Reboot and scan again.
  4. GNSS initialization

  5. Confirm “[GNSS] OK”. If it fails, it prints an error.
  6. Move the GNSS antenna to a window or outdoors. Cold start to 3D fix could take 30–60 seconds or longer without A‑GNSS.

  7. IMU initialization

  8. Confirm “[BNO055] OK”.
  9. Observe “[IMU] Heading/Pitch/Roll=…”
  10. Move/rotate the device and confirm heading/pitch/roll change.

  11. Cellular attach (GSM/GPRS)

  12. Observe “[GSM] Modem ready” and “[GPRS] GPRS attached.”
  13. If APN is wrong or coverage is poor, you’ll see attach retries and eventually a timeout.
  14. The local IP should print, e.g., “[GPRS] Local IP: 10.x.x.x”.

  15. GNSS fix and telemetry content

  16. Once satellites are visible:
    • “[GNSS] FixType=2/3 Sats=… Lat=… Lon=… Alt(m)=… Speed(km/h)=…”
    • FixType=3 is 3D fix (preferred).
  17. A JSON payload prints under “[JSON] …” each 30 seconds:

    • It includes device (IMEI), ts (millis), gnss lat/lon/alt/speed/sats/fixType, imu heading/pitch/roll/accel.
  18. HTTP POST to server

  19. The code posts to HOST:webhook.site with your unique WEBHOOK_PATH.
  20. Expected:
    • “[HTTP] Status: 200”
    • “[HTTP] POST success.”
  21. On https://webhook.site/ (your token page), you should see incoming requests with JSON payloads. Validate fields visually or inspect the raw body.

  22. Power/performance sanity

  23. LED on when last POST succeeded; off if last POST failed.
  24. Typical current draw increases during GSM transmission bursts. If you transition to battery power later, ensure sufficient supply (peaks can exceed 500 mA).

Troubleshooting

  • Board not detected over USB
  • Use a known good data cable.
  • Double‑tap reset for bootloader mode; reupload with the new port.
  • Windows: Install Arduino SAMD driver if needed. Check Device Manager under “Ports (COM & LPT)”.

  • I2C devices not found

  • Confirm 3.3 V power and GND.
  • Confirm SDA/SCL orientation; do not swap them.
  • Check that breakout boards expose I2C (some NEO‑M8N boards require solder jumpers to enable DDC/I2C).
  • If both devices are missing, suspect SDA/SCL pull‑ups or physical bus fault.

  • GNSS no fix

  • Ensure the GNSS antenna has clear sky view; avoid indoors.
  • Use a high‑gain active antenna if possible; verify the breakout supplies antenna bias if needed.
  • Wait several minutes for a cold start; first fix can be slow without assistance.
  • Confirm you see “[GNSS] FixType” increasing from 0 to 2/3; check number of satellites.

  • GSM cannot attach (GPRS)

  • Confirm antenna is attached to MKR GSM 1400.
  • Verify SIM is active and has data balance.
  • Double‑check APN, username, and password. Some carriers require case‑sensitive APN names.
  • Try with SIM PIN empty or set SIM_PIN accordingly.
  • Move to an area with 2G/3G coverage; some regions have sunset 2G/3G networks.
  • If the SIM is locked, use a phone to disable PIN temporarily and retry.

  • HTTP timeouts or errors

  • If “[HTTP] ERROR posting”, ensure the host/path are correct and network is up.
  • Some networks block port 80; try a different host or test plain TCP connectivity by posting less frequently.
  • If you must use HTTPS, see Improvements for TLS configuration details.

  • BNO055 erratic orientation

  • Allow time for calibration. Move through multiple axes.
  • Use bno.getCalibration() to examine sys/gyro/accel/mag calibration levels.
  • Ensure stable power; noisy VIN can disturb readings.

  • Memory or stability issues

  • Reduce JSON payload size.
  • Lower GNSS rate to 1 Hz (already configured).
  • Avoid creating large Strings repeatedly; consider a StaticJsonDocument with capped capacity (we’re using 512 bytes).

Improvements

  • HTTPS/TLS
  • Switch to GSMSSLClient for encrypted transport:
    • Replace GSMClient netClient; with GSMSSLClient netClient;.
    • Use port 443 and a host supporting modern ciphers for SARA‑U201.
    • Load the server certificate/CA into the modem if validation is required; consult MKRGSM examples (NB: certificate memory is limited and may require PEM to DER conversion and modem storage).
  • Alternatively, terminate TLS on a nearby gateway and keep device HTTP if acceptable for your threat model.

  • MQTT telemetry

  • Use Eclipse Paho MQTT library or arduino-mqtt with GSMClient/GSMSSLClient to publish to an MQTT broker.
  • Topics could include: asset/<imei>/telemetry.

  • Assisted‑GNSS (A‑GNSS)

  • Use u‑blox AssistNow Online/Offline to speed up TTFF.
  • The SparkFun u‑blox GNSS library supports aiding data injection over I2C; fetch assistance over GSM and push to the GNSS module.

  • Power management

  • Increase POST_INTERVAL_MS to reduce data and power usage.
  • Use modem power‑saving modes (PSM/eDRX if available).
  • Sleep SAMD21 between cycles (standby mode) and wake via RTC.

  • Geofencing and event‑driven uploads

  • Post only on movement or exit/entry of geofences to save data.
  • Use BNO055 acceleration thresholds to detect motion start/stop.

  • Local buffering

  • Cache telemetry to flash/EEPROM or external FRAM when GPRS is down; resubmit later.

  • Integrity and signing

  • Add HMAC signature of payload using a pre‑shared key.
  • Use nonce/timestamp validation on server to resist replay.

  • Diagnostics

  • Log RSSI/CSQ, registration status, and GNSS DOP/accuracy fields for server‑side quality grading.
  • Report battery voltage if you add a LiPo and measure via analog input.

  • UBX tuning

  • Configure dynamic model (e.g., portable/automotive) using UBX‑CFG‑NAV5 for better performance depending on use case.
  • Disable unnecessary NMEA streams to conserve bandwidth and parsing time (already using UBX only over I2C).

Final Checklist

  • Hardware
  • [ ] Exact device model used: Arduino MKR GSM 1400 + NEO‑M8N + BNO055.
  • [ ] GSM antenna connected to MKR GSM 1400.
  • [ ] GNSS antenna connected to NEO‑M8N.
  • [ ] NEO‑M8N and BNO055 powered from 3.3 V; common ground to MKR.
  • [ ] I2C wiring: SDA→SDA, SCL→SCL; pull‑ups present (on breakout or external).

  • Software

  • [ ] PlatformIO installed and pio system info works.
  • [ ] platformio.ini and src/main.cpp placed correctly.
  • [ ] Libraries resolved with pio pkg update.
  • [ ] APN and WEBHOOK_PATH set to your values.
  • [ ] Build succeeds: pio run -e mkrgsm1400.

  • Flash and Monitor

  • [ ] Upload succeeds: pio run -t upload -e mkrgsm1400.
  • [ ] Serial monitor at 115200 shows I2C scan with 0x28 and 0x42.
  • [ ] “[GNSS] OK” and “[BNO055] OK” messages on boot.
  • [ ] “[GSM] Modem ready” and “[GPRS] GPRS attached.” and IP printed.

  • Validation

  • [ ] GNSS fixType 2/3 with valid lat/lon.
  • [ ] IMU heading/pitch/roll change with device orientation.
  • [ ] JSON payload printed locally.
  • [ ] HTTP status 200 on POST.
  • [ ] Data visible at your webhook.site endpoint.

With this build, you have a fully working gnss-gsm-asset-tracker that combines positional data and orientation/acceleration, reports over cellular networks, and is ready for production hardening (TLS, buffering, power management, and geofencing).

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 objective of the project described in the article?




Question 2: Which device is used in the project for GSM connectivity?




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




Question 4: Which GNSS module is mentioned in the article?




Question 5: What is required for internet connectivity in the project?




Question 6: Which operating systems are supported for this project?




Question 7: What is the purpose of the HTTP endpoint in the project?




Question 8: Which safety practice is mentioned in the article?




Question 9: What is the recommended way to upload code to the MKR GSM 1400?




Question 10: What is the minimum Python version required for 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: Hexapod, Arduino Mega 2560, PCA9685, XBee

Practical case: Hexapod, Arduino Mega 2560, PCA9685, XBee — hero

Objective and use case

What you’ll build: A Zigbee-controlled hexapod robot utilizing an Arduino Mega 2560, PCA9685, and XBee S2C for wireless communication. This project involves wiring, programming, and troubleshooting a 12-servo walking robot.

Why it matters / Use cases

  • Demonstrates the integration of multiple hardware components, including servos, distance sensors, and wireless modules.
  • Provides a platform for exploring robotics, remote control, and automation in educational settings.
  • Serves as a foundation for more complex robotic applications, such as obstacle avoidance and autonomous navigation.
  • Facilitates hands-on learning of C/C++ programming and I2C communication protocols.

Expected outcome

  • Successful operation of the hexapod with a minimum of 90% gait accuracy during remote control.
  • Latency of less than 100ms between command input and servo response.
  • Ability to detect obstacles within a range of 30cm using the VL53L0X sensor.
  • Consistent wireless communication with a packet delivery success rate of over 95% using XBee S2C.

Audience: Robotics enthusiasts, educators; Level: Intermediate

Architecture/flow: Arduino Mega 2560 controls PCA9685 for servo management, while XBee S2C handles Zigbee communication for remote commands, and VL53L0X provides distance measurement for obstacle detection.

This practical guide walks you through creating a Zigbee‑controlled, 12‑servo hexapod robot using an Arduino Mega 2560, a PCA9685 16‑channel servo driver, a Digi XBee S2C module for wireless control, and an ST VL53L0X time‑of‑flight distance sensor. You will wire, program, and validate the system, then troubleshoot and plan improvements.

The project goal is servo‑hexapod‑zigbee: a walking hexapod whose gait is driven by the PCA9685, receives remote commands over Zigbee via XBee S2C, and uses VL53L0X for simple obstacle awareness.

Note on defaults and tooling: We use Arduino CLI (not the GUI). The Arduino “family default” is UNO with Arduino CLI. We include those exact UNO commands for reference and then adapt them to the chosen model (Arduino Mega 2560).

Prerequisites

  • Skills:
  • Confident with C/C++ for Arduino, serial communications, and I2C devices.
  • Comfortable with power distribution for servos (separate regulated supply, common ground).
  • Familiarity with Digi XCTU for basic XBee S2C configuration in AT (transparent) mode.

  • Host machine:

  • Windows 10/11, macOS 12+, or Ubuntu 22.04 LTS.
  • USB port available for Arduino Mega 2560.

  • Tools and versions:

  • Arduino CLI 0.35.3 or newer.
  • Digi XCTU 6.5+ (for configuring XBee modules).
  • A serial terminal (screen, miniterm.py, PuTTY) for interacting with a second XBee or verifying serial output.
  • Optional drivers:
    • Official Arduino Mega 2560 uses ATmega16U2—driver typically automatic on macOS/Linux; install the Arduino USB Driver on Windows if needed.
    • Many Mega 2560 clones use CH340—install WCH CH34x driver (Windows/macOS) if required.

Materials (exact models)

  • Arduino board:
  • Arduino Mega 2560 R3 (ATmega2560; 5 V logic; hardware serial ports: Serial0/1/2/3).

  • Servo controller:

  • PCA9685 16‑Channel 12‑bit PWM/Servo Driver (e.g., Adafruit #815 or equivalent breakout; default I2C addr 0x40).

  • Wireless:

  • Digi XBee S2C Zigbee 3.0 (TH form factor) for the robot.
  • XBee USB adapter for your PC side (e.g., SparkFun XBee Explorer USB or Adafruit XBee USB Adapter).
  • If connecting XBee S2C directly to the Mega, use a 3.3 V adapter/level translator:

    • SparkFun XBee Explorer Regulated (5 V → 3.3 V regulator + level shifting), or
    • A bidirectional logic level converter (5 V ↔ 3.3 V) plus a clean 3.3 V regulator for the XBee.
  • Distance sensor:

  • VL53L0X Time‑of‑Flight distance sensor breakout (e.g., Pololu #2490 or Adafruit VL53L0X).

  • Servos and power:

  • 12 × MG90S (metal gear micro servos) or equivalent micro servos (2 DOF per leg). For heavier builds use higher‑torque servos and more current.
  • 6 V BEC/regulator rated ≥ 5 A continuous (≥ 10 A peak recommended for 12 micro servos).
  • Power wiring: suitable gauge (e.g., 18–20 AWG) for servo power distribution; servo connectors.

  • Other:

  • Breadboard and jumpers (short, twisted pairs for signal cleanliness).
  • Battery (e.g., 2S LiPo 7.4 V + BEC) or bench supply capable of required current.
  • M2/M3 hardware for mounting.
  • Hexapod frame/chassis that supports 6 legs, 2 DOF per leg.

Setup/Connection

We build a 12‑servo hexapod (2 DOF per leg) to stay within one PCA9685 (16 channels). Channels 0–5 drive coxa (yaw) joints; channels 6–11 drive femur (pitch) joints. The remaining channels are spare.

All grounds must be tied together: Arduino GND, PCA9685 GND, XBee GND, VL53L0X GND, and the servo power supply negative.

Electrical connections overview

  • Arduino Mega 2560 I2C:
  • SDA = pin 20, SCL = pin 21 (5 V tolerant but used for I2C).
  • PCA9685:
  • VCC = 5 V (logic) from Mega’s 5 V pin.
  • V+ (servo power rail) = 6 V from dedicated high‑current BEC.
  • GND = common ground.
  • VL53L0X:
  • VIN = 5 V or 3.3 V per breakout specs (many breakouts accept 5 V and regulate down).
  • SDA/SCL to Mega SDA/SCL (20/21).
  • XSHUT pin optional (tie high to enable; for multi‑sensor you’d control it per sensor).
  • XBee S2C:
  • Use a regulated adapter or logic level converter:
    • XBee VCC = 3.3 V ONLY. Do not power at 5 V.
    • Connect XBee DIN (RX) and DOUT (TX) through a proper level interface to Mega Serial1:
    • Mega pin 18 (TX1) → level shifter → XBee DIN (RX).
    • Mega pin 19 (RX1) ← level shifter ← XBee DOUT (TX).
    • GND = common ground.

Detailed wiring table

Subsystem Signal Mega 2560 Pin Module Pin Notes
PCA9685 VCC (logic) 5 V VCC Power logic at 5 V per breakout spec
PCA9685 GND GND GND Common ground with all subsystems
PCA9685 SDA 20 (SDA) SDA I2C, default address 0x40
PCA9685 SCL 21 (SCL) SCL I2C clock
PCA9685 V+ (servo bus) External 6 V BEC V+ High‑current servo power input
VL53L0X VIN 5 V VIN Confirm breakout accepts 5 V; otherwise use 3.3 V
VL53L0X GND GND GND
VL53L0X SDA 20 (SDA) SDA Shared I2C bus
VL53L0X SCL 21 (SCL) SCL Shared I2C bus
XBee S2C VCC 3.3 V regulator VCC Never 5 V
XBee S2C GND GND GND Common ground
XBee S2C TX → Mega RX1 19 (RX1) via level shifter DOUT 3.3 V logic to 5 V tolerant input via shifter
XBee S2C RX ← Mega TX1 18 (TX1) via level shifter DIN Shift 5 V TX down to 3.3 V
Servos Signal PCA9685 channel 0..11 Servo signal Refer to channel map
Servos +6 V BEC +6 V Servo V+ High‑current rail; do not power from Arduino 5 V
Servos GND BEC GND Servo GND Must be common with Mega and PCA9685 GND

Servo channel map (2 DOF per leg)

  • Leg indices: 0=LF (Left Front), 1=LM, 2=LR, 3=RF, 4=RM, 5=RR.
  • PCA9685 channels:
  • Coxa (yaw): 0..5 map to legs 0..5.
  • Femur (pitch): 6..11 map to legs 0..5 respectively.

Example:
– Channel 0: LF coxa
– Channel 6: LF femur
– Channel 1: LM coxa
– Channel 7: LM femur
– …
– Channel 5: RR coxa
– Channel 11: RR femur

XBee pairing overview (AT/transparent mode)

  • Configure two XBee S2C modules using XCTU:
  • Set both to the same PAN ID (ID).
  • Set one as Coordinator (CE=1), the other as Router/End Device (CE=0).
  • Set serial baud (BD) = 57600 for both.
  • Ensure AP=0 (transparent mode) to pass serial bytes.
  • On robot: mount the Router/End Device XBee to the level‑shifted serial interface on Mega Serial1.
  • On PC: plug the Coordinator XBee into a USB adapter and open it with XCTU or a terminal at 57600 8N1.

Full Code (Arduino Mega 2560)

Create the sketch at: ~/src/servo-hexapod-zigbee/mega_hexapod/mega_hexapod.ino

/*
  Servo Hexapod with Zigbee Control
  Board: Arduino Mega 2560
  Modules: PCA9685, XBee S2C (Serial1), VL53L0X (I2C)
  Libraries:
    - Adafruit PWM Servo Driver Library (for PCA9685)
    - Pololu VL53L0X
  Baud rates:
    - USB Serial (debug): 115200
    - Serial1 (XBee): 57600 (transparent mode)
*/

#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
#include <VL53L0X.h>

// ----- PCA9685 configuration -----
Adafruit_PWMServoDriver pca = Adafruit_PWMServoDriver(0x40); // default I2C addr

// Servo pulse parameters (typical analog servo)
static const uint16_t SERVO_MIN_US = 500;   // microseconds
static const uint16_t SERVO_MAX_US = 2500;  // microseconds
static const float    SERVO_FREQ_HZ = 50.0; // 50Hz for standard servos

// 12 servos: channels 0..5 = coxa, 6..11 = femur
static const uint8_t NUM_LEGS = 6;
static const uint8_t CH_COXA[NUM_LEGS]  = {0,1,2,3,4,5};
static const uint8_t CH_FEMUR[NUM_LEGS] = {6,7,8,9,10,11};

// Each joint can be inverted and offset for calibration per leg
static int8_t  invertCoxa[NUM_LEGS]  = {+1, +1, +1, -1, -1, -1}; // Right side reversed
static int8_t  invertFemur[NUM_LEGS] = {-1, -1, -1, +1, +1, +1}; // Example orientation

// Neutral offsets in degrees for calibration (tune per build)
static int16_t offsetCoxaDeg[NUM_LEGS]  = {0, 0, 0, 0, 0, 0};
static int16_t offsetFemurDeg[NUM_LEGS] = {5, -3, 2, 0, -2, 4};

// Motion limits (deg) to prevent overtravel
static const int16_t COXA_MIN = 45,  COXA_MAX = 135;  // typical yaw range
static const int16_t FEMUR_MIN = 30, FEMUR_MAX = 150; // typical pitch range (down/up)

// Neutral home pose
static const int16_t COXA_NEUTRAL = 90;
static const int16_t FEMUR_NEUTRAL = 90;

// Gait groups: alternating tripod
// Group A: LF(0), RM(4), LR(2)
// Group B: RF(3), LM(1), RR(5)
static const uint8_t GROUP_A[3] = {0,4,2};
static const uint8_t GROUP_B[3] = {3,1,5};

// ----- VL53L0X -----
VL53L0X tof;
static uint16_t obstacle_mm = 250; // stop if closer than 250mm

// ----- Serial/Zigbee command parsing -----
static const uint32_t SERIAL_DEBUG_BAUD = 115200;
static const uint32_t SERIAL_XBEE_BAUD  = 57600;

String cmdLine;

// Utility: constrain and convert degrees to microseconds within servo limits
uint16_t angleToUS(int16_t deg) {
  if (deg < 0) deg = 0;
  if (deg > 180) deg = 180;
  // Map 0..180 deg to SERVO_MIN_US..SERVO_MAX_US
  long us = SERVO_MIN_US + (long)( (SERVO_MAX_US - SERVO_MIN_US) * (deg / 180.0) + 0.5 );
  if (us < SERVO_MIN_US) us = SERVO_MIN_US;
  if (us > SERVO_MAX_US) us = SERVO_MAX_US;
  return (uint16_t)us;
}

void writeServoUS(uint8_t ch, uint16_t us) {
  // Adafruit library helper
  pca.writeMicroseconds(ch, us);
}

void setCoxaDeg(uint8_t leg, int16_t deg) {
  int16_t adjusted = COXA_NEUTRAL + invertCoxa[leg] * (deg - COXA_NEUTRAL) + offsetCoxaDeg[leg];
  if (adjusted < COXA_MIN) adjusted = COXA_MIN;
  if (adjusted > COXA_MAX) adjusted = COXA_MAX;
  writeServoUS(CH_COXA[leg], angleToUS(adjusted));
}

void setFemurDeg(uint8_t leg, int16_t deg) {
  int16_t adjusted = FEMUR_NEUTRAL + invertFemur[leg] * (deg - FEMUR_NEUTRAL) + offsetFemurDeg[leg];
  if (adjusted < FEMUR_MIN) adjusted = FEMUR_MIN;
  if (adjusted > FEMUR_MAX) adjusted = FEMUR_MAX;
  writeServoUS(CH_FEMUR[leg], angleToUS(adjusted));
}

void homePose() {
  for (uint8_t i=0; i<NUM_LEGS; i++) {
    setCoxaDeg(i, COXA_NEUTRAL);
    setFemurDeg(i, FEMUR_NEUTRAL);
  }
}

// Simple easing step toward target degrees
void approachLeg(uint8_t leg, int16_t coxaTarget, int16_t femurTarget, uint8_t steps, uint16_t stepDelayMs) {
  // sample current is unknown; do incremental delta around neutral
  // For simplicity, compute linear path from current command (assume last target) — we hold last state in static
  static int16_t lastCoxa[NUM_LEGS]; static bool initC=false;
  static int16_t lastFemur[NUM_LEGS]; static bool initF=false;
  if (!initC || !initF) {
    for (uint8_t i=0;i<NUM_LEGS;i++){ lastCoxa[i]=COXA_NEUTRAL; lastFemur[i]=FEMUR_NEUTRAL; }
    initC=initF=true;
  }

  float dc = (coxaTarget - lastCoxa[leg]) / float(steps);
  float df = (femurTarget - lastFemur[leg]) / float(steps);

  for (uint8_t s=1; s<=steps; s++) {
    int16_t c = (int16_t)(lastCoxa[leg] + dc*s);
    int16_t f = (int16_t)(lastFemur[leg] + df*s);
    setCoxaDeg(leg, c);
    setFemurDeg(leg, f);
    delay(stepDelayMs);
  }
  lastCoxa[leg] = coxaTarget;
  lastFemur[leg] = femurTarget;
}

void approachGroup(const uint8_t group[3], int16_t coxa, int16_t femur, uint8_t steps, uint16_t stepDelayMs) {
  for (uint8_t i=0;i<3;i++) {
    approachLeg(group[i], coxa, femur, steps, stepDelayMs);
  }
}

// Tripod gait primitive
void tripodStep(int8_t dir, int8_t rot, uint8_t speedPct) {
  // dir: +1 forward, -1 backward, 0 none
  // rot: +1 rotate left, -1 rotate right, 0 none
  // speedPct: 10..100 affects delays
  speedPct = constrain(speedPct, 10, 100);
  uint16_t stepDelay = map(speedPct, 10, 100, 35, 5); // ms per micro-step

  // Amplitudes
  int16_t coxaSwing = 20 * dir + 15 * rot * (+1); // combine translation and rotation rudimentarily
  int16_t femurLift = 20; // lift amount

  // Support/back leg coxa offset: push opposite
  int16_t coxaPush = -coxaSwing;

  // Sequence A: lift/swing group A while group B supports; then swap
  // Phase 1: Group A up and forward; Group B down and back
  for (uint8_t i=0;i<3;i++) {
    uint8_t la = GROUP_A[i];
    approachLeg(la, COXA_NEUTRAL + coxaSwing, FEMUR_NEUTRAL - femurLift, 6, stepDelay);
    uint8_t lb = GROUP_B[i];
    approachLeg(lb, COXA_NEUTRAL + coxaPush, FEMUR_NEUTRAL + 5, 6, stepDelay);
  }

  // Phase 2: Place A down; bring B forward (lift)
  for (uint8_t i=0;i<3;i++) {
    uint8_t la = GROUP_A[i];
    approachLeg(la, COXA_NEUTRAL + coxaSwing, FEMUR_NEUTRAL + 5, 6, stepDelay);
    uint8_t lb = GROUP_B[i];
    approachLeg(lb, COXA_NEUTRAL - coxaSwing, FEMUR_NEUTRAL - femurLift, 6, stepDelay);
  }

  // Phase 3: Place B down at forward
  for (uint8_t i=0;i<3;i++) {
    uint8_t lb = GROUP_B[i];
    approachLeg(lb, COXA_NEUTRAL - coxaSwing, FEMUR_NEUTRAL + 5, 6, stepDelay);
  }
}

// Global state
enum Mode { MODE_IDLE, MODE_FORWARD, MODE_BACK, MODE_LEFT, MODE_RIGHT } mode = MODE_IDLE;
uint8_t speedPct = 50;

// Distance read
uint16_t readDistanceMM() {
  uint16_t d = tof.readRangeContinuousMillimeters();
  if (tof.timeoutOccurred()) {
    return 65535; // indicate error
  }
  return d;
}

void handleObstacle() {
  uint16_t d = readDistanceMM();
  if (d < obstacle_mm) {
    mode = MODE_IDLE;
    Serial.println(F("[WARN] Obstacle detected. Stopping."));
  }
}

// Command syntax (from XBee Serial1):
//  S           -> stop
//  F <n>       -> forward speed n (10..100)
//  B <n>       -> backward speed n
//  L <n>       -> rotate left speed n
//  R <n>       -> rotate right speed n
//  D?          -> report distance in mm
//  H           -> home pose
// Example: "F 60\n"
void processCommand(const String& line) {
  if (line.length() == 0) return;
  char c = toupper(line.charAt(0));
  if (c == 'S') {
    mode = MODE_IDLE;
    Serial1.println(F("ACK S"));
  } else if (c == 'H') {
    mode = MODE_IDLE;
    homePose();
    Serial1.println(F("ACK H"));
  } else if (c == 'D') {
    uint16_t d = readDistanceMM();
    Serial1.print(F("D=")); Serial1.println(d);
  } else if (c == 'F' || c == 'B' || c == 'L' || c == 'R') {
    int n = 50;
    if (line.length() > 1) {
      n = line.substring(1).toInt();
    }
    speedPct = constrain(n, 10, 100);
    switch (c) {
      case 'F': mode = MODE_FORWARD; break;
      case 'B': mode = MODE_BACK;    break;
      case 'L': mode = MODE_LEFT;    break;
      case 'R': mode = MODE_RIGHT;   break;
    }
    Serial1.print(F("ACK ")); Serial1.print(c); Serial1.print(F(" ")); Serial1.println(speedPct);
  } else {
    Serial1.println(F("ERR?"));
  }
}

void pollSerial1() {
  while (Serial1.available()) {
    char ch = (char)Serial1.read();
    if (ch == '\r') continue;
    if (ch == '\n') {
      processCommand(cmdLine);
      cmdLine = "";
    } else {
      cmdLine += ch;
      if (cmdLine.length() > 48) cmdLine = ""; // avoid overflow from garbage
    }
  }
}

void setup() {
  Serial.begin(SERIAL_DEBUG_BAUD);
  Serial1.begin(SERIAL_XBEE_BAUD);

  Wire.begin();
  Wire.setClock(400000);

  // PCA9685 init
  if (!pca.begin()) {
    Serial.println(F("[ERR] PCA9685 not found at 0x40"));
    while (1) delay(100);
  }
  // Optionally calibrate oscillator; default is fine for servos
  pca.setPWMFreq(SERVO_FREQ_HZ);

  // VL53L0X init
  if (!tof.init()) {
    Serial.println(F("[ERR] VL53L0X init failed"));
    while (1) delay(100);
  }
  tof.setTimeout(100);
  tof.startContinuous(30); // 30ms interval (~33Hz)

  homePose();

  Serial.println(F("[OK] System initialized"));
  Serial.println(F("Commands (via XBee @57600): S, F n, B n, L n, R n, D?, H"));
}

void loop() {
  pollSerial1();

  switch (mode) {
    case MODE_IDLE:
      // idle; hold pose; still check obstacle for reporting
      if (millis() % 1000 < 10) {
        uint16_t d = readDistanceMM();
        Serial.print(F("mm=")); Serial.println(d);
      }
      delay(5);
      break;

    case MODE_FORWARD:
      handleObstacle();
      if (mode == MODE_IDLE) break;
      tripodStep(+1, 0, speedPct);
      break;

    case MODE_BACK:
      handleObstacle();
      if (mode == MODE_IDLE) break;
      tripodStep(-1, 0, speedPct);
      break;

    case MODE_LEFT:
      tripodStep(0, +1, speedPct);
      break;

    case MODE_RIGHT:
      tripodStep(0, -1, speedPct);
      break;
  }
}

Notes:
– The gait is deliberately simple and robust for 2‑DOF legs. For 3‑DOF legs, you’d add a tibia joint per leg and compute inverse kinematics for smoother stepping.
– For precision, tune invert arrays and offsets so the neutral 90° pose is symmetrical.

Build/Flash/Run Commands

Set up Arduino CLI. Below we include the family default commands for UNO (as requested), then the adapted commands for Mega 2560 (used in this project).

1) Install Arduino CLI and AVR core

# Verify CLI version
arduino-cli version

# Update core index
arduino-cli core update-index

# Install AVR core (covers UNO and Mega)
arduino-cli core install arduino:avr

2) Install required libraries

# Adafruit PCA9685 library (writeMicroseconds helper available in v3+)
arduino-cli lib install "Adafruit PWM Servo Driver Library@3.0.2"

# Pololu VL53L0X library
arduino-cli lib install "pololu/VL53L0X@1.3.1"

If you want to search and confirm library IDs:

arduino-cli lib search VL53L0X
arduino-cli lib search "PCA9685"

3) Create project folder and place the sketch

mkdir -p ~/src/servo-hexapod-zigbee/mega_hexapod
# Save the provided code as:
# ~/src/servo-hexapod-zigbee/mega_hexapod/mega_hexapod.ino

4) Family default: compile and upload for Arduino UNO (reference)

# Compile for UNO (reference default)
arduino-cli compile --fqbn arduino:avr:uno ~/src/servo-hexapod-zigbee/mega_hexapod

# Detect ports
arduino-cli board list

# Upload to UNO on a given port (example COM5 on Windows or /dev/ttyACM0 on Linux)
arduino-cli upload -p COM5 --fqbn arduino:avr:uno ~/src/servo-hexapod-zigbee/mega_hexapod
# or
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:uno ~/src/servo-hexapod-zigbee/mega_hexapod

5) Adapted for this project: compile and upload for Arduino Mega 2560

# Compile for Mega 2560
arduino-cli compile --fqbn arduino:avr:mega ~/src/servo-hexapod-zigbee/mega_hexapod

# List boards and locate the Mega's port
arduino-cli board list

# Upload (replace port with your actual port)
arduino-cli upload -p COM7 --fqbn arduino:avr:mega ~/src/servo-hexapod-zigbee/mega_hexapod
# or
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:mega ~/src/servo-hexapod-zigbee/mega_hexapod

Driver notes:
– If upload fails on Windows with a clone Mega, install the CH34x driver (WCH) and recheck the COM port in Device Manager.
– On Linux, add your user to the dialout group if needed: sudo usermod -a -G dialout $USER then relog.

6) Run

  • Power the servos from the separate 6 V BEC and ensure GND is common.
  • Open a terminal to the USB serial (115200) to observe debug logs.
  • Open XCTU or a terminal on the PC‑side XBee at 57600 baud and send commands:
  • Example commands:
    • H (home pose)
    • F 60 (forward at 60% speed)
    • L 50 (rotate left at 50%)
    • S (stop)
    • D? (query distance mm)

Example using screen on Linux/macOS:

# Replace with your XBee USB adapter port (e.g., /dev/ttyUSB0 on Linux, /dev/cu.usbserial-XXXXX on macOS)
screen /dev/ttyUSB0 57600
# Type: F 60<Enter>  or  D?<Enter>

Step‑by‑Step Validation

Follow these steps incrementally to isolate issues early.

1) Power and ground sanity
– With the servos disconnected, power the Mega via USB only; no servo supply yet.
– Measure the BEC output: verify ~6.0 V before connecting to PCA9685 V+.
– Ensure all grounds (Mega GND, PCA9685 GND, XBee GND, VL53L0X GND, servo GND) are common.

2) I2C presence check
– Upload the sketch. On the USB serial at 115200, the sketch should print “[OK] System initialized”.
– If you see “[ERR] PCA9685 not found at 0x40” or “[ERR] VL53L0X init failed”, recheck SDA/SCL wiring and power.

3) PCA9685 servo exercise (single servo)
– Connect one servo signal to PCA9685 channel 0, servo power to V+, GND to GND.
– Send H (home). The servo should move to neutral.
– Modify offsetCoxaDeg[0] in the code if neutral is not centered. Recompile/upload and retry.

4) VL53L0X distance readings
– With the robot stationary, send D? via XBee terminal; you should see D=xxx in mm.
– Also check USB debug prints mm=xxx once per second in idle.

5) XBee S2C transparent link
– Confirm both XBee modules share PAN ID and BD=57600. In XCTU, do a loopback test by shorting DIN/DOUT on the robot side (only during test) and typing in the PC terminal; you should see characters echo if the loopback wire is installed (remove after test).
– With the hexapod XBee connected to Serial1 (proper level shifting), send H and confirm “ACK H”.

6) Servo power and pose
– Power servos via the 6 V BEC. Send H. All 12 servos should assume neutral, no twitching or brownout.
– If the Mega resets or the XBee drops, the BEC is inadequate or GND is missing. Upgrade current capacity and keep servo wires short.

7) Gait dry‑run (no legs on the ground)
– With the robot suspended, send F 50. Observe alternating tripod motion. Verify that:
– Group A legs lift and move forward while Group B pushes back, then they swap.
– No servo hits its mechanical limit (listen for strain).
– Adjust invert arrays and offset arrays if motion directions are inverted per leg.

8) Ground test
– Place the robot on a flat surface, send F 50. It should attempt to walk forward. Increase to F 70 carefully.
– Test rotation: L 50, R 50. If the rotation is reversed, swap the sign of the rot contribution in tripodStep.

9) Obstacle stop validation
– Place an object ~150–200 mm in front of the VL53L0X. While moving forward, the robot should stop with a “[WARN] Obstacle detected. Stopping.” message.
– Query D? to confirm measured distance.

10) Long‑duration test
– Run F 50 continuously for several minutes. Monitor:
– Servo temperatures, BEC temperature, and current draw.
– XBee link stability (no dropped commands).
– No I2C timeouts (if they occur, reduce Wire clock to 100 kHz, shorten wires, and add pull‑ups if needed).

Troubleshooting

  • Servos jitter at idle:
  • Ensure PCA9685 frequency is set to 50 Hz. Confirm stable 6 V supply with low ripple.
  • Keep I2C wires short and twisted ground/signal pairs if possible. Add 2.2–4.7 kΩ pull‑ups if your breakout lacks them.
  • Some servos dislike pulses < 1000 µs or > 2000 µs; narrow SERVO_MIN/MAX_US to 800–2200 µs.

  • Mega resets when servos move:

  • Classic brownout from shared ground noise. Never power servos from the Mega 5 V.
  • Use a high‑current BEC (≥ 5 A continuous). Separate the servo power wiring physically from logic wiring.
  • Add bulk capacitance (e.g., 1000 µF low‑ESR electrolytic) across V+ and GND near the PCA9685 servo rail.

  • XBee no response:

  • Double‑check level shifting. The XBee cannot accept 5 V on DIN (RX). Use a proper shifter or an XBee regulated adapter board.
  • Ensure both XBees have the same PAN ID and BD=57600 and are in AP=0 (transparent mode).
  • Verify the coordinator/router roles (CE). If in doubt, reset to defaults and reconfigure in XCTU.

  • VL53L0X always times out:

  • Reduce I2C speed: in setup, change Wire.setClock(100000).
  • Check that XSHUT (if present) is high; many breakouts tie it high by default.
  • Prevent servo noise coupling into the sensor by routing the sensor’s wires away from servo bundles.

  • Arduino CLI upload errors:

  • “No such file or directory” port: use arduino-cli board list to find the actual port.
  • Permission denied on Linux: add user to dialout and relog.
  • If the board enumerates as CH340, install the CH34x driver.

  • Gait looks asymmetric:

  • Adjust invert arrays: invertCoxa and invertFemur so that “increasing coxa” moves the leg forward on both sides (with appropriate inversion).
  • Fine‑tune offsetCoxaDeg and offsetFemurDeg per leg to get a perfect neutral stance.
  • If the robot drifts sideways during forward motion, reduce coxaSwing or compensate rotations.

  • I2C address conflicts:

  • PCA9685 default is 0x40; VL53L0X default is 0x29. If your PCA board is changed via address solder jumpers, update Adafruit_PWMServoDriver(0x4X) accordingly.

Improvements

  • 3 DOF per leg (18 servos):
  • Add a second PCA9685 (different I2C address via A0–A5 jumpers) for tibia control.
  • Implement full inverse kinematics for smoother and higher clearance gaits (ripple, wave, and tripod).

  • XBee API mode:

  • Switch to AP=1 or AP=2, parse API frames, and add checksums and acknowledgments for robust control.
  • Use remote command frames for diagnostics and telemetry (battery, current draw, temperature).

  • Sensor fusion:

  • Use multiple VL53L0X sensors at different angles; control their XSHUT pins to assign unique I2C addresses at startup.
  • Fuse IMU data (e.g., MPU‑6050) for body stabilization and terrain adaptation.

  • Power system enhancements:

  • Add a INA219 current sensor on the servo rail to monitor load and prevent overload conditions.
  • Implement graceful brownout behavior: detect low supply and stop motion.

  • Software architecture:

  • Replace blocking delays with a scheduler or state machine to keep Serial and sensors responsive.
  • Implement trajectory interpolation (cubic easing) for smoother steps.
  • Add EEPROM‑stored calibration and a serial calibration routine.

  • Remote control UI:

  • Build a small Python or web UI that sends high‑level commands over a USB XBee.
  • Stream telemetry (pose, distance, state) back at 5–10 Hz.

Final Checklist

  • Power and wiring:
  • PCA9685 V+ fed from a robust 6 V BEC; grounds common.
  • XBee powered at 3.3 V with proper level shifting to Mega Serial1 (pins 18/19).
  • VL53L0X connected to SDA/SCL (20/21) and powered appropriately.
  • Servos connected to PCA9685 channels 0–11 per the mapping.

  • Software and libraries:

  • Arduino CLI installed; AVR core installed.
  • Libraries installed: “Adafruit PWM Servo Driver Library@3.0.2” and “pololu/VL53L0X@1.3.1”.
  • Sketch compiled for Mega: arduino-cli compile --fqbn arduino:avr:mega ...

  • Upload:

  • Board detected by arduino-cli board list.
  • Upload successful to Mega with the correct port.

  • Validation:

  • H command sets a clean neutral pose.
  • D? returns reasonable mm distances.
  • F n/B n/L n/R n move the robot in expected directions.
  • Obstacle stop works around ~250 mm.

  • Safety and reliability:

  • No brownouts during movement; servos do not overheat.
  • No XBee link drops; serial parsing stable.
  • Mechanical stops respected; motion limits tuned.

If everything in the checklist is satisfied, you have a working servo hexapod controlled over Zigbee, with basic obstacle awareness via VL53L0X—all running on Arduino Mega 2560 with a PCA9685 servo controller.

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 microcontroller is used in the hexapod robot project?




Question 2: Which module is used for wireless control in the hexapod robot?




Question 3: What type of sensor is the ST VL53L0X?




Question 4: Which tool is recommended for configuring XBee modules?




Question 5: What is the minimum required version of Arduino CLI for this project?




Question 6: What programming languages should you be confident with for this project?




Question 7: Which operating systems are compatible with the host machine requirements?




Question 8: What is the purpose of the PCA9685 in the hexapod robot?




Question 9: What type of power distribution is recommended for the servos?




Question 10: What is the main goal of the servo-hexapod-zigbee project?




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

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

Follow me: