Caso práctico: Control PID de fermentación RS485 y Ethernet

Caso práctico: Control PID de fermentación RS485 y Ethernet — hero

Objetivo y caso de uso

Qué construirás: Un controlador de temperatura para fermentación utilizando Arduino Mega 2560, RS485 y Ethernet para la comunicación.

Para qué sirve

  • Controlar la temperatura de fermentación en tiempo real mediante un sensor PT100.
  • Transmitir datos de temperatura a través de RS485 para monitoreo a larga distancia.
  • Acceder a los datos de fermentación desde una interfaz web usando Ethernet.
  • Integrar alertas de temperatura fuera de rango mediante MQTT.

Resultado esperado

  • Latencia de respuesta del sistema de control menor a 200 ms.
  • Precisión de temperatura de ±0.5 °C en mediciones.
  • Capacidad de enviar datos a un servidor MQTT a una tasa de 1 paquete cada 5 segundos.
  • Monitoreo de temperatura con un rango de 0 a 100 °C.

Público objetivo: Ingenieros y desarrolladores de sistemas embebidos; Nivel: Avanzado

Arquitectura/flujo: Sensor PT100 → MAX31865 → Arduino Mega → RS485/Ethernet → Interfaz web/MQTT.

Nivel: Avanzado

Prerrequisitos

Sistema operativo y entorno recomendado

  • Linux: Ubuntu 22.04 LTS (jammy) 64-bit
  • Windows (alternativo): Windows 11 Pro 22H2 o superior
  • Red local con DHCP deshabilitado para la IP que asignaremos, o reserva DHCP si prefieres

Toolchain exacta

  • Arduino CLI 0.35.3
  • Núcleo (core) AVR: arduino:avr@1.8.6
  • Librerías (vía Library Manager):
  • Ethernet@2.0.2 (compatible con W5500)
  • Adafruit MAX31865 library@1.5.0
  • Adafruit BusIO@1.14.1
  • PID by Brett Beauregard@1.2.1

Notas:
– Usaremos estrictamente Arduino CLI (no GUI).
– FQBN: arduino:avr:mega (para Arduino Mega 2560).
– Velocidad de monitor serie: 115200 baudios.

Materiales

  • Arduino Mega 2560 R3 (ATmega2560-16AU).
  • W5500 Ethernet Shield (formato Arduino R3; SPI por cabecera ICSP, con CS en D10).
  • Módulo MAX485 RS485 TTL (5 V, con resistencias de polarización y terminación con jumpers).
  • Conversor RTD MAX31865 (placa “MAX31865 RTD” tipo Adafruit o compatible, para PT100 3 hilos).
  • Sonda RTD PT100 de 3 hilos (clase A o B; inmersión para líquidos).
  • Convertidor de nivel lógico bidireccional 4 canales para SPI (5 V ↔ 3.3 V). Importante: el MAX31865 típico trabaja a 3.3 V y no es tolerante a 5 V.
  • Cables Dupont, regleta o protoboard.
  • Fuente para actuador térmico:
  • Opción segura de laboratorio: pad calefactor de silicona 12 V y SSR DC-DC (entrada 3–32 VDC, salida 12 VDC, ≥10 A) o MOSFET DC.
  • Evita cargas de red/mains en prácticas de aula; si las usas, sigue normativa y aislamiento adecuados.
  • Relé de estado sólido (SSR) DC-DC para el pad calefactor (controlado por señal de 5 V).
  • Dongle USB–RS485 para el PC (validación del bus RS485).
  • Cable Ethernet CAT5e/CAT6.
  • Cable USB para el Mega.
  • Resistencias de terminación/bias si el módulo MAX485 no las incorpora (típicamente 120 Ω en extremos del bus; polarización con ~680 Ω–1 kΩ a Vcc y GND, aunque muchos módulos ya las incluyen).

Preparación y conexión

Consideraciones de bus y alimentación

  • El MAX485 funciona a 5 V (lado TTL) y con A/B diferencial al bus RS485. Asegura:
  • Terminación de 120 Ω SOLO en los extremos del bus.
  • Polarización (bias) de reposo en uno de los extremos, si no lo provee tu módulo.
  • El MAX31865, en su mayoría de placas, funciona a 3.3 V y NO tolera 5 V en las líneas SPI:
  • Alimenta el MAX31865 con 3.3 V desde el Mega (consumo típico bajo).
  • Usa conversión de nivel 5 V → 3.3 V para SCK, MOSI, CS y DRDY. MISO (3.3 V) hacia el Mega suele ser reconocido como alto; aun así, puedes pasarlo por el conversor para homogeneizar.

Asignación de pines y cableado

  • El W5500 usa SPI por ICSP en el shield y CS en D10 (no modificar). La SD del shield (si existe) suele usar D4; mantenla deshabilitada (D4 como salida en HIGH).
  • SPI adicional para el MAX31865 compartido (MISO/MOSI/SCK) con CS independiente (D9).

Tabla de pines y puertos

Subsistema Señal Arduino Mega 2560 Módulo Notas
Ethernet (W5500 Shield) SPI (MISO/MOSI/SCK) ICSP 6 pines W5500 El shield hace el ruteo por ICSP
Ethernet (W5500 Shield) CS D10 W5500 Reservado para W5500
SD (si presente en shield) CS D4 SD Poner D4 OUTPUT HIGH para deshabilitar
MAX31865 3.3 V 3.3 V VCC No usar 5 V
MAX31865 GND GND GND Común con Mega
MAX31865 SCK 52 (vía LV Shifter) SCLK 5 V → 3.3 V
MAX31865 MOSI 51 (vía LV Shifter) SDI 5 V → 3.3 V
MAX31865 MISO 50 (opcional LV Shifter) SDO 3.3 V → 5 V (aceptado)
MAX31865 CS D9 (vía LV Shifter) CS 5 V → 3.3 V
MAX31865 DRDY D48 (vía LV Shifter) DRDY 5 V → 3.3 V
RTD PT100 3 hilos A/B/C según manual MAX31865 Configurar jumpers para 3 hilos
RS485 (MAX485) VCC 5 V VCC 5 V
RS485 (MAX485) GND GND GND Común con Mega
RS485 (MAX485) DI D16 (TX2) DI UART2 TX
RS485 (MAX485) RO D17 (RX2) RO UART2 RX
RS485 (MAX485) DE y RE D22 (juntos) DE/RE Alto: transmitir; Bajo: recibir
RS485 (MAX485) A/B Bus A/B Diferencial, cable trenzado
Actuador térmico (SSR DC-DC) IN+ D6 SSR IN+ Control por PWM de periodo-ventana
Actuador térmico (SSR DC-DC) IN- GND SSR IN- Retorno a GND

Puntos clave:
– Asegura que D9 (CS MAX31865) y D10 (CS W5500) estén en HIGH por defecto; solo el dispositivo activo debe tener CS LOW.
– Configura el MAX31865 para 3 hilos (cambia el jumper/cableado según la placa).
– En el bus RS485, habilita terminación solo si estás en extremo; caso contrario, desactívala.

Código completo (Arduino C++ para Mega 2560)

El siguiente sketch implementa:
– Lectura de temperatura PT100 vía MAX31865 (SPI).
– Control PID de una resistencia calefactora mediante “time-proportional output” en ventana de 2 s.
– Servidor HTTP simple sobre W5500 con endpoints / y /status, y cambio de setpoint vía /set?sp=XX.X.
– Interfaz RS485 (Serial2) modo esclavo con protocolo ASCII simple: GET PV|SP|OUT|ALL, SET SP x.y, MODE AUTO|MAN, SET MAN x.

Explicación rápida:
– setup(): inicializa SPI, Ethernet, MAX31865, PID, RS485.
– loop(): lee temperatura, ejecuta PID, conmuta SSR, atiende RS485 y HTTP.
– RS485: línea por línea con ‘
‘; respuesta tras elevar DE/RE.
– Seguridad: si hay fallo del MAX31865, desactiva salida y reporta FAULT.

Crea la carpeta rs485-fermentation-pid-control y dentro el archivo rs485-fermentation-pid-control.ino con este contenido:

/*
  rs485-fermentation-pid-control
  Dispositivo: Arduino Mega 2560 + W5500 Ethernet Shield + MAX485 RS485 + MAX31865 RTD
  Toolchain:
    - Arduino CLI 0.35.3
    - Core arduino:avr@1.8.6
    - Ethernet@2.0.2
    - Adafruit MAX31865 library@1.5.0 (+ Adafruit BusIO@1.14.1)
    - PID@1.2.1
*/

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

// ---------------------- Configuración de hardware ----------------------
// W5500: CS en pin 10 (mantenido por el shield)
const uint8_t PIN_CS_ETH = 10;
const uint8_t PIN_CS_SD  = 4;   // si hay SD en el shield

// MAX31865 (SPI compartido)
const uint8_t PIN_CS_MAX31865 = 9;
const uint8_t PIN_DRDY        = 48; // opcional, pero útil

// RS485 con MAX485 en Serial2
const uint8_t PIN_RS485_DE_RE = 22; // DE y RE unidos
#define RS485_SERIAL Serial2

// SSR de calefacción (control por ventana temporal)
const uint8_t PIN_SSR = 6;

// Parámetros RTD
#define RREF      430.0   // resistencia de referencia (ver tu placa)
#define RNOMINAL  100.0   // PT100 nominal a 0 °C

// Ethernet (MAC e IP fijas)
byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0x01 };
IPAddress ip(192, 168, 1, 77);
IPAddress dns(192, 168, 1, 1);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
EthernetServer server(80);

// MAX31865 objeto (3 hilos)
Adafruit_MAX31865 max31865 = Adafruit_MAX31865(PIN_CS_MAX31865);

// PID
double PV = 0.0;          // Process Variable (temperatura)
double SP = 20.0;         // Setpoint (°C)
double OUT = 0.0;         // Salida PID (0-100 %)
double MAN = 0.0;         // Mando manual (0-100 %)
PID pid(&PV, &OUT, &SP, 8.0, 0.6, 20.0, DIRECT); // Kp, Ki, Kd iniciales (ajustar en tu planta)

// Control por ventana
const unsigned long WINDOW_MS = 2000UL; // 2 s
unsigned long windowStart = 0;

// Filtros
const float alpha = 0.2f; // EMA para suavizar lectura
bool firstRead = true;

// RS485 buffer
static const size_t RS485_BUFSZ = 128;
char rs485_buf[RS485_BUFSZ];
size_t rs485_len = 0;

// Estado
enum Mode { MODE_AUTO, MODE_MANUAL };
Mode mode = MODE_AUTO;
bool max_fault = false;

// ---------------------- Utilidades ----------------------
void rs485SetTX(bool tx) {
  digitalWrite(PIN_RS485_DE_RE, tx ? HIGH : LOW);
}

void rs485SendLine(const char* s) {
  rs485SetTX(true);
  RS485_SERIAL.print(s);
  RS485_SERIAL.print("\r\n");
  RS485_SERIAL.flush();
  rs485SetTX(false);
}

void httpSendHeaderOK(EthernetClient& client, const char* mime) {
  client.println("HTTP/1.1 200 OK");
  client.print("Content-Type: ");
  client.println(mime);
  client.println("Connection: close");
  client.println();
}

void httpSend404(EthernetClient& client) {
  client.println("HTTP/1.1 404 Not Found");
  client.println("Connection: close");
  client.println();
  client.println("404");
}

String urlDecode(const String& s) {
  String r;
  for (uint16_t i = 0; i < s.length(); i++) {
    char c = s[i];
    if (c == '+') r += ' ';
    else if (c == '%' && i + 2 < s.length()) {
      char h1 = s[i+1], h2 = s[i+2];
      auto hex = [&](char h)->int { if (h>='0'&&h<='9') return h-'0'; if (h>='A'&&h<='F') return h-'A'+10; if (h>='a'&&h<='f') return h-'a'+10; return 0; };
      r += char((hex(h1) << 4) | hex(h2));
      i += 2;
    } else r += c;
  }
  return r;
}

// ---------------------- Inicialización ----------------------
void setup() {
  pinMode(PIN_SSR, OUTPUT);
  digitalWrite(PIN_SSR, LOW);

  pinMode(PIN_RS485_DE_RE, OUTPUT);
  rs485SetTX(false);

  pinMode(PIN_CS_ETH, OUTPUT);
  digitalWrite(PIN_CS_ETH, HIGH);
  pinMode(PIN_CS_SD, OUTPUT);     // deshabilita SD
  digitalWrite(PIN_CS_SD, HIGH);

  pinMode(PIN_CS_MAX31865, OUTPUT);
  digitalWrite(PIN_CS_MAX31865, HIGH);

  pinMode(PIN_DRDY, INPUT_PULLUP);

  Serial.begin(115200);
  RS485_SERIAL.begin(9600); // 8N1 por defecto

  // MAX31865
  if (!max31865.begin(MAX31865_3WIRE)) {
    Serial.println(F("[MAX31865] Error al inicializar (verifica conexiones y 3 hilos)."));
  }
  max31865.enable50Hz(true); // Rechazo 50 Hz (ajusta a tu red)

  // Ethernet
  Ethernet.init(PIN_CS_ETH);
  Ethernet.begin(mac, ip, dns, gateway, subnet);
  delay(1000);
  server.begin();
  Serial.print(F("[ETH] IP: ")); Serial.println(Ethernet.localIP());

  // PID
  pid.SetOutputLimits(0.0, 100.0); // 0-100 %
  pid.SetSampleTime(1000);         // 1 s
  pid.SetMode(AUTOMATIC);

  windowStart = millis();
  Serial.println(F("[BOOT] rs485-fermentation-pid-control listo."));
}

// ---------------------- Lectura de temperatura ----------------------
void readTemperature() {
  // Lectura y chequeo de fallos
  uint8_t fault = max31865.readFault();
  if (fault) {
    max_fault = true;
    Serial.print(F("[MAX31865] FAULT: 0x"));
    Serial.println(fault, HEX);
    max31865.clearFault();
    return;
  } else {
    max_fault = false;
  }

  float t = max31865.temperature(RNOMINAL, RREF);
  if (isnan(t) || t < -50.0 || t > 250.0) {
    max_fault = true;
    return;
  }

  if (firstRead) {
    PV = t;
    firstRead = false;
  } else {
    PV = alpha * t + (1.0f - alpha) * PV; // filtro EMA
  }
}

// ---------------------- Control de salida (SSR) ----------------------
void driveOutput() {
  unsigned long now = millis();
  if (now - windowStart >= WINDOW_MS) {
    windowStart += WINDOW_MS;
  }

  double duty = (mode == MODE_AUTO) ? OUT : MAN;
  if (max_fault) duty = 0.0; // seguridad

  unsigned long onTime = (unsigned long)( (duty / 100.0) * WINDOW_MS );
  if ((now - windowStart) < onTime) {
    digitalWrite(PIN_SSR, HIGH);
  } else {
    digitalWrite(PIN_SSR, LOW);
  }
}

// ---------------------- Protocolo RS485 ASCII ----------------------
void processRS485Line(char* line) {
  // Elimina CR/LF finales
  size_t n = strlen(line);
  while (n > 0 && (line[n-1] == '\r' || line[n-1] == '\n' || line[n-1] == ' ')) { line[--n] = 0; }
  // Convierte a mayúsculas para comparar (mantén valores numéricos)
  for (size_t i = 0; i < n; i++) if (line[i] >= 'a' && line[i] <= 'z') line[i] -= 32;

  if (strncmp(line, "GET ", 4) == 0) {
    const char* what = line + 4;
    if (strcmp(what, "PV") == 0) {
      char out[32]; snprintf(out, sizeof(out), "PV:%.2f", PV);
      rs485SendLine(out);
    } else if (strcmp(what, "SP") == 0) {
      char out[32]; snprintf(out, sizeof(out), "SP:%.2f", SP);
      rs485SendLine(out);
    } else if (strcmp(what, "OUT") == 0) {
      char out[32]; snprintf(out, sizeof(out), "OUT:%.1f MODE:%s FAULT:%d", (mode==MODE_AUTO?OUT:MAN), (mode==MODE_AUTO?"AUTO":"MAN"), max_fault?1:0);
      rs485SendLine(out);
    } else if (strcmp(what, "ALL") == 0) {
      char out[96];
      snprintf(out, sizeof(out), "PV:%.2f SP:%.2f OUT:%.1f MODE:%s FAULT:%d", PV, SP, (mode==MODE_AUTO?OUT:MAN), (mode==MODE_AUTO?"AUTO":"MAN"), max_fault?1:0);
      rs485SendLine(out);
    } else {
      rs485SendLine("ERR:UNKNOWN_GET");
    }
  } else if (strncmp(line, "SET ", 4) == 0) {
    const char* rest = line + 4;
    if (strncmp(rest, "SP ", 3) == 0) {
      double v = atof(rest + 3);
      if (v >= -10.0 && v <= 40.0) { // típico rango de fermentación
        SP = v;
        rs485SendLine("OK");
      } else {
        rs485SendLine("ERR:SP_RANGE");
      }
    } else if (strncmp(rest, "MAN ", 4) == 0) {
      double v = atof(rest + 4);
      if (v >= 0.0 && v <= 100.0) {
        MAN = v;
        rs485SendLine("OK");
      } else {
        rs485SendLine("ERR:MAN_RANGE");
      }
    } else {
      rs485SendLine("ERR:UNKNOWN_SET");
    }
  } else if (strncmp(line, "MODE ", 5) == 0) {
    const char* m = line + 5;
    if (strcmp(m, "AUTO") == 0) {
      mode = MODE_AUTO;
      pid.SetMode(AUTOMATIC);
      rs485SendLine("OK");
    } else if (strcmp(m, "MAN") == 0 || strcmp(m, "MANUAL") == 0) {
      mode = MODE_MANUAL;
      pid.SetMode(MANUAL);
      rs485SendLine("OK");
    } else {
      rs485SendLine("ERR:UNKNOWN_MODE");
    }
  } else {
    rs485SendLine("ERR:UNKNOWN_CMD");
  }
}

void pollRS485() {
  while (RS485_SERIAL.available()) {
    char c = (char)RS485_SERIAL.read();
    if (c == '\n') {
      rs485_buf[rs485_len] = 0;
      processRS485Line(rs485_buf);
      rs485_len = 0;
    } else if (rs485_len + 1 < RS485_BUFSZ) {
      rs485_buf[rs485_len++] = c;
    } else {
      rs485_len = 0; // overflow -> reset
    }
  }
}

// ---------------------- HTTP server ----------------------
void handleHttpClient(EthernetClient& client) {
  // Lee la primera línea del request
  String req = client.readStringUntil('\r');
  client.readStringUntil('\n'); // consume LF
  if (req.length() == 0) return;

  // Muy simple: parsea método y ruta
  int sp1 = req.indexOf(' ');
  int sp2 = req.indexOf(' ', sp1 + 1);
  if (sp1 < 0 || sp2 < 0) { httpSend404(client); return; }
  String method = req.substring(0, sp1);
  String path = req.substring(sp1 + 1, sp2);

  // Lee y descarta cabeceras restantes hasta línea vacía
  while (true) {
    String h = client.readStringUntil('\r');
    client.readStringUntil('\n');
    if (h.length() == 0) break;
  }

  if (method != "GET") {
    httpSend404(client);
    return;
  }

  if (path == "/" || path.startsWith("/index")) {
    httpSendHeaderOK(client, "text/html; charset=utf-8");
    client.println("<!DOCTYPE html><html><head><meta charset='utf-8'><title>Fermentation PID</title></head><body>");
    client.println("<h1>Fermentation PID (RS485 + W5500)</h1>");
    client.print("<p>PV: "); client.print(PV, 2); client.println(" &deg;C</p>");
    client.print("<p>SP: "); client.print(SP, 2); client.println(" &deg;C</p>");
    client.print("<p>OUT: "); client.print((mode==MODE_AUTO?OUT:MAN), 1); client.println(" %</p>");
    client.print("<p>MODE: "); client.print((mode==MODE_AUTO?"AUTO":"MAN")); client.println("</p>");
    client.print("<p>FAULT: "); client.print(max_fault ? "YES" : "NO"); client.println("</p>");
    client.println("<p>Endpoints: <code>/status</code>, <code>/set?sp=XX.X</code>, <code>/mode?m=AUTO|MAN</code>, <code>/man?v=0..100</code></p>");
    client.println("</body></html>");
    return;
  }

  if (path.startsWith("/status")) {
    httpSendHeaderOK(client, "text/plain; charset=utf-8");
    client.print("PV:"); client.println(PV, 2);
    client.print("SP:"); client.println(SP, 2);
    client.print("OUT:"); client.println((mode==MODE_AUTO?OUT:MAN), 1);
    client.print("MODE:"); client.println((mode==MODE_AUTO?"AUTO":"MAN"));
    client.print("FAULT:"); client.println(max_fault ? 1 : 0);
    return;
  }

  if (path.startsWith("/set")) {
    int q = path.indexOf('?');
    if (q >= 0) {
      String qs = path.substring(q + 1);
      if (qs.startsWith("sp=")) {
        String v = urlDecode(qs.substring(3));
        double spv = v.toFloat();
        if (spv >= -10.0 && spv <= 40.0) {
          SP = spv;
          httpSendHeaderOK(client, "text/plain; charset=utf-8");
          client.println("OK");
          return;
        }
      }
    }
    httpSend404(client);
    return;
  }

  if (path.startsWith("/mode")) {
    int q = path.indexOf('?');
    if (q >= 0) {
      String qs = path.substring(q + 1);
      if (qs.startsWith("m=")) {
        String m = urlDecode(qs.substring(2));
        if (m == "AUTO") {
          mode = MODE_AUTO;
          pid.SetMode(AUTOMATIC);
          httpSendHeaderOK(client, "text/plain; charset=utf-8");
          client.println("OK");
          return;
        } else if (m == "MAN") {
          mode = MODE_MANUAL;
          pid.SetMode(MANUAL);
          httpSendHeaderOK(client, "text/plain; charset=utf-8");
          client.println("OK");
          return;
        }
      }
    }
    httpSend404(client);
    return;
  }

  if (path.startsWith("/man")) {
    int q = path.indexOf('?');
    if (q >= 0) {
      String qs = path.substring(q + 1);
      if (qs.startsWith("v=")) {
        String s = urlDecode(qs.substring(2));
        double v = s.toFloat();
        if (v >= 0.0 && v <= 100.0) {
          MAN = v;
          httpSendHeaderOK(client, "text/plain; charset=utf-8");
          client.println("OK");
          return;
        }
      }
    }
    httpSend404(client);
    return;
  }

  httpSend404(client);
}

// ---------------------- Bucle principal ----------------------
void loop() {
  // 1) Medición
  readTemperature();

  // 2) Control
  if (!max_fault && mode == MODE_AUTO) {
    pid.Compute();
  }

  // 3) Actuación
  driveOutput();

  // 4) Comunicación RS485
  pollRS485();

  // 5) HTTP
  EthernetClient client = server.available();
  if (client) {
    handleHttpClient(client);
    delay(1);
    client.stop();
  }

  // 6) Debug periódico
  static unsigned long t0 = 0;
  unsigned long now = millis();
  if (now - t0 >= 5000) {
    t0 = now;
    Serial.print(F("[DBG] PV=")); Serial.print(PV, 2);
    Serial.print(F(" SP=")); Serial.print(SP, 2);
    Serial.print(F(" OUT=")); Serial.print((mode==MODE_AUTO?OUT:MAN), 1);
    Serial.print(F(" MODE=")); Serial.print((mode==MODE_AUTO?"AUTO":"MAN"));
    Serial.print(F(" FAULT=")); Serial.println(max_fault ? 1 : 0);
  }
}

Puntos clave a revisar en el código:
– MAX31865: ajusta RREF a tu placa (430 Ω es común en Adafruit). RNOMINAL=100 (PT100).
– Filtro: suaviza lecturas (EMA).
– PID: coeficientes iniciales para un proceso térmico lento; debes afinarlos en tu sistema.
– Salida: estrategia de ventana fija para controlar SSR DC (recomendado frente a PWM de alta frecuencia).
– RS485: protocolo ASCII minimalista, fácil de probar con un adaptador USB–RS485.
– HTTP: endpoints planos para integrar rápidamente con cURL o scripts.

Compilación, “flash” y ejecución

Instalación de Arduino CLI y core

Linux (Ubuntu 22.04):

# 1) Instalar Arduino CLI 0.35.3 en ~/bin
mkdir -p ~/bin
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh -s -- -b ~/bin 0.35.3
export PATH="$HOME/bin:$PATH"

# 2) Verificar versión
arduino-cli version
# Debería mostrar: arduino-cli Version: 0.35.3

# 3) Inicializar configuración (si es primera vez)
arduino-cli config init

# 4) Actualizar índice e instalar core AVR 1.8.6
arduino-cli core update-index
arduino-cli core install arduino:avr@1.8.6

# 5) Instalar librerías exactas
arduino-cli lib install "Ethernet@2.0.2" "Adafruit MAX31865 library@1.5.0" "Adafruit BusIO@1.14.1" "PID@1.2.1"

Windows (PowerShell) es similar; asegúrate de añadir arduino-cli.exe al PATH y usar las mismas versiones.

Compilación

Coloca el sketch en una carpeta con el mismo nombre del .ino:

# Estructura:
# rs485-fermentation-pid-control/
# └── rs485-fermentation-pid-control.ino

arduino-cli compile --fqbn arduino:avr:mega --warnings all --export-binaries ./rs485-fermentation-pid-control

Salida esperada: ruta a .hex en ./rs485-fermentation-pid-control/build/arduino.avr.mega/…

Subida (flash)

Identifica el puerto serie del Mega (ej.: /dev/ttyACM0 en Linux, COM5 en Windows).

# Linux
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:mega ./rs485-fermentation-pid-control

# Windows (ejemplo)
arduino-cli upload -p COM5 --fqbn arduino:avr:mega .\rs485-fermentation-pid-control

Ejecución y monitor serie

arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200

Deberías ver logs de arranque y depuración cada 5 s.

Validación paso a paso

1) Verificación de red (W5500)

  • Conecta el cable Ethernet al switch/router de tu laboratorio.
  • Ping:
  • Linux/Windows: ping 192.168.1.77
  • HTTP con cURL:
  • Estado: curl http://192.168.1.77/status
    • Debe devolver texto con PV, SP, OUT, MODE y FAULT.
  • Página: curl http://192.168.1.77/
    • Debe devolver HTML básico.
  • Cambiar setpoint: curl «http://192.168.1.77/set?sp=21.0» → OK
  • Cambiar modo: curl «http://192.168.1.77/mode?m=MAN» → OK
  • Salida manual: curl «http://192.168.1.77/man?v=25» → OK

Resultados esperados:
– /status: valores numéricos coherentes; FAULT=0; PV estable o lentamente cambiante.

2) Verificación del MAX31865 y RTD

  • A temperatura ambiente, PV ~ 18–25 °C (según sala).
  • Toca con la mano la sonda RTD: la temperatura debe subir algunos grados en ~10–20 s.
  • Si FAULT cambia a 1:
  • Revisa cableado 3 hilos, jumpers de 3-wire, continuidad de la sonda, y nivel lógico.

3) Verificación de RS485

  • Usa un adaptador USB–RS485 en el PC. Conecta A↔A, B↔B, GND común.
  • Configura terminación si tu tramo es corto y solo tienes 2 nodos: termina en ambos extremos (módulo del Mega y el dongle).
  • Abre un terminal serie a 9600 8N1 (por ejemplo, screen/minicom):
  • Linux: screen /dev/ttyUSB0 9600
  • Envía comandos:
  • GET PV
  • GET SP
  • SET SP 19.0
  • MODE MAN
  • SET MAN 30
  • GET ALL

Resultados esperados:
– Respuestas con “OK” o datos, p. ej.: PV:20.35, SP:19.00, OUT:xx.x MODE:MAN FAULT:0.

4) Validación del control PID (sin riesgo)

  • Usa un pad calefactor 12 V con SSR DC-DC y un recipiente aislado con agua (0.5–1 L).
  • Coloca la PT100 sumergida, evitando tocar directamente el pad/calefactor.
  • Modo AUTO:
  • Ajusta SP a 25.0 °C con curl o RS485.
  • Observa /status cada 30–60 s: PV debe aproximarse a SP sin oscilaciones grandes.
  • OUT tenderá a bajar conforme PV se acerque a SP.
  • Modo MAN:
  • Cambia a MAN y fija MAN=0, 20, 50, 100.
  • Observa que el SSR enciende durante una fracción de la ventana (0–100 % del tiempo de 2 s).

Criterios de éxito:
– El sistema responde a SP y mantiene PV en ±0.3–0.8 °C a entorno estable (depende de aislamiento y agitación).
– RS485 y HTTP devuelven valores consistentes con monitor serie.

Troubleshooting (5–8 errores típicos y soluciones)

1) Ethernet no responde (sin ping o /status no carga)
– Causas:
– MAC duplicada en la red.
– IP en subred distinta o conflicto de IP.
– CS de SD (D4) sin deshabilitar, interfiriendo con SPI.
– Soluciones:
– Cambia MAC a una diferente (p. ej., último byte).
– Ajusta IP/gateway/subnet a tu red.
– Asegura pinMode(4, OUTPUT) y digitalWrite(4, HIGH) para deshabilitar SD.

2) Lectura de temperatura errática o FAULT recurrente
– Causas:
– MAX31865 a 3.3 V sin conversión de nivel; SCK/MOSI/CS/DRDY recibiendo 5 V.
– Cableado 3 hilos incorrecto o jumper en modo 2/4 hilos.
– RREF no coincide con tu placa (error en cálculo).
– Soluciones:
– Usa level shifter para todas las líneas SPI y DRDY.
– Verifica configuración 3-wire en el MAX31865.
– Ajusta RREF (comúnmente 430.0 Ω en Adafruit; revisa tu módulo).

3) RS485 sin respuestas o caracteres basura
– Causas:
– A/B invertidos.
– DE/RE no controlados correctamente (siempre en RX o TX).
– Falta de terminación o exceso de terminaciones en tramo corto.
– Soluciones:
– Intercambia A/B y prueba de nuevo.
– Revisa PIN_RS485_DE_RE; debe ir HIGH al transmitir y LOW al recibir.
– Activa solo dos terminaciones (en extremos del bus).

4) El SSR no conmuta como se espera
– Causas:
– SSR incompatible (AC-AC siendo conducido con DC).
– Nivel lógico insuficiente en entrada SSR o polaridad invertida.
– Ventana de tiempo muy corta o SP/OUT en cero por FAULT.
– Soluciones:
– Usa SSR DC-DC para carga DC; verifica especificaciones.
– Comprueba D6→IN+ y GND→IN- y el LED del SSR (si lo tiene).
– Aumenta WINDOW_MS o revisa que max_fault sea 0.

5) Errores de compilación por librerías
– Causas:
– Nombres diferentes en Library Manager o versiones no coinciden.
– Soluciones:
– Reinstala con los nombres exactos: «Ethernet», «Adafruit MAX31865 library», «Adafruit BusIO», «PID».

6) Fallo al subir (upload) el sketch
– Causas:
– Puerto incorrecto o permisos (Linux).
– FQBN erróneo.
– Soluciones:
– Lista puertos: arduino-cli board list.
– Añade tu usuario a dialout: sudo usermod -aG dialout $USER; reinicia sesión.
– Usa –fqbn arduino:avr:mega.

7) Oscilaciones o sobreimpulso térmico excesivo
– Causas:
– Ganancias PID no sintonizadas para tu sistema térmico (inercia alta).
– Soluciones:
– Disminuye Kp, aumenta ligeramente Ki (cuidado con windup), ajusta Kd.
– Incrementa WINDOW_MS si la conmutación es muy rápida para tu carga.

8) Congestión SPI (W5500 y MAX31865)
– Causa:
– CS mal gestionados; dos dispositivos activos simultáneamente.
– Solución:
– Revisa que cada CS esté en HIGH salvo el dispositivo activo.
– Evita acceso concurrente en interrupciones (no usamos aquí).

Mejoras/variantes

  • Modbus RTU sobre RS485:
  • Sustituye protocolo ASCII por Modbus RTU (esclavo) usando una librería Modbus conforme. Expone holding registers: PV (scaled), SP, OUT, MODE, FAULT.
  • Registro de datos y MQTT:
  • Añade cliente MQTT por TCP en W5500 para publicar PV/SP/OUT hacia un broker local.
  • PID autotune:
  • Integra un algoritmo de autotuning (Ziegler-Nichols, Tyreus-Luyben) para obtener parámetros específicos de tu planta térmica.
  • Multipunto RS485:
  • Dirección de nodo y anti-colisión. Soporta múltiples controladores en un bus largo con direccionamiento.
  • Seguridad y enclavamientos:
  • Watchdog, corte por sobretemperatura, validación de sensor dual (RTD + termistor backup).
  • Interfaz web mejorada:
  • Página web con sliders y gráficos en tiempo real (sin depender de ArduinoJson si usas endpoints planos).
  • Redundancia de alimentación:
  • Separación de alimentación lógica y potencia para minimizar ruido en la medición.

Checklist de verificación

  • [ ] Ubuntu 22.04 o Windows 11 instalados y actualizados.
  • [ ] Arduino CLI 0.35.3 en PATH y verificado con arduino-cli version.
  • [ ] Core arduino:avr@1.8.6 instalado.
  • [ ] Librerías instaladas: Ethernet@2.0.2, Adafruit MAX31865 library@1.5.0, Adafruit BusIO@1.14.1, PID@1.2.1.
  • [ ] Cableado del W5500 Shield montado correctamente sobre el Mega (ICSP OK, CS en D10).
  • [ ] MAX31865 alimentado a 3.3 V y con conversión de nivel en SCK/MOSI/CS/DRDY.
  • [ ] RTD PT100 cableado en 3 hilos y jumpers de 3-wire configurados en el MAX31865.
  • [ ] RS485: DI→TX2 (D16), RO→RX2 (D17), DE/RE→D22, A/B al bus, terminación correcta.
  • [ ] SSR DC-DC comandado desde D6; carga de 12 V segura para pruebas.
  • [ ] Compilación exitosa con arduino-cli compile –fqbn arduino:avr:mega.
  • [ ] Subida exitosa a través del puerto correcto.
  • [ ] Respuesta a ping 192.168.1.77 y endpoints /, /status.
  • [ ] RS485 responde a GET/SET/MODE con OK/datos esperados.
  • [ ] Control PID estabiliza PV cerca de SP sin oscilaciones significativas.
  • [ ] FAULT=0 en operación normal; FAULT=1 ante desconexión de RTD y salida desactivada.

Apéndice: Script de validación RS485 (opcional)

Si prefieres automatizar la validación por RS485 desde tu PC con Python 3 y pyserial:

#!/usr/bin/env python3
import serial, time

PORT = "/dev/ttyUSB0"  # ajusta a tu dongle RS485
BAUD = 9600

def cmd(ser, s):
    ser.write((s+"\n").encode())
    ser.flush()
    line = ser.readline().decode().strip()
    print(f"> {s}\n< {line}")
    time.sleep(0.2)

with serial.Serial(PORT, BAUD, timeout=1) as ser:
    cmd(ser, "GET ALL")
    cmd(ser, "SET SP 20.0")
    cmd(ser, "GET SP")
    cmd(ser, "MODE MAN")
    cmd(ser, "SET MAN 30")
    cmd(ser, "GET OUT")
    cmd(ser, "MODE AUTO")
    cmd(ser, "GET ALL")

Con este caso práctico has desplegado un controlador PID de fermentación robusto sobre Arduino Mega 2560, integrando campo RS485 (MAX485) y monitoreo/control por Ethernet (W5500), con medición precisa de temperatura vía RTD PT100 y MAX31865. El flujo de trabajo, la toolchain exacta y el código son reproducibles y extensibles para usos industriales ligeros o de laboratorio avanzado.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es el sistema operativo recomendado para este proyecto?




Pregunta 2: ¿Qué versión de Arduino CLI se debe utilizar?




Pregunta 3: ¿Cuál es el FQBN correcto para Arduino Mega 2560?




Pregunta 4: ¿Qué librería es necesaria para el módulo MAX31865?




Pregunta 5: ¿Qué tipo de conexión utiliza el W5500 Ethernet Shield?




Pregunta 6: ¿Cuál es la velocidad de monitor serie recomendada?




Pregunta 7: ¿Qué tipo de sonda RTD se debe utilizar?




Pregunta 8: ¿Qué tipo de convertidor se necesita para la comunicación SPI entre 5 V y 3.3 V?




Pregunta 9: ¿Qué resistencias son necesarias para el módulo MAX485?




Pregunta 10: ¿Qué tipo de relé se recomienda para controlar el pad calefactor?




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

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


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:


Caso práctico: Rastreador GNSS/GSM Arduino MKR GSM 1400

Caso práctico: Rastreador GNSS/GSM Arduino MKR GSM 1400 — hero

Objetivo y caso de uso

Qué construirás: Un rastreador de activos GNSS-GSM utilizando Arduino MKR GSM 1400, NEO-M8N y BNO055 para enviar telemetría por GSM.

Para qué sirve

  • Rastreo en tiempo real de vehículos o activos utilizando GNSS y GSM.
  • Monitoreo de condiciones ambientales mediante el sensor BNO055.
  • Envío de datos de ubicación a un broker MQTT para análisis remoto.
  • Integración con aplicaciones móviles para visualización de datos.

Resultado esperado

  • Actualizaciones de ubicación cada 10 segundos con precisión de 2.5 metros.
  • Latencia en la transmisión de datos no superior a 5 segundos.
  • Capacidad de enviar hasta 100 paquetes de datos por hora.
  • Consumo de energía por debajo de 100 mA en modo activo.

Público objetivo: Desarrolladores y entusiastas de IoT; Nivel: Avanzado

Arquitectura/flujo: GNSS (NEO-M8N) -> Procesador (Arduino MKR GSM 1400) -> Transmisión (GSM) -> Broker MQTT.

Nivel: Avanzado

Prerrequisitos

Sistemas operativos soportados

  • Linux: Ubuntu 22.04 LTS (Jammy) actualizado a fecha reciente
  • Windows 11 23H2 con PowerShell 7.4.x
  • macOS 14 (Sonoma)

Para este caso práctico se mostrarán los comandos con prioridad en Linux/macOS (bash/zsh) y notas equivalentes para Windows.

Toolchain exacta y versiones

Usaremos Arduino CLI (no el IDE gráfico) con el core SAMD oficial de Arduino y librerías específicas. Versionado exacto:

  • Arduino CLI: v0.35.6
  • Core de placas: arduino:samd v1.8.13
  • Compilador embebido: arm-none-eabi-gcc provisto por el core arduino:samd v1.8.13 (administrado automáticamente por Arduino CLI)
  • Librerías Arduino:
  • MKRGSM v1.5.0
  • TinyGPSPlus v1.0.3
  • Adafruit BNO055 v1.6.3
  • Adafruit Unified Sensor v1.1.14
  • PubSubClient v2.8.0
  • (Opcional para depuración) ArduinoHttpClient v0.6.0

Requisitos de red/operador

  • SIM funcional con datos 2G/3G (GPRS/UMTS) activos
  • APN, usuario y contraseña del operador
  • Cobertura GSM en la zona de pruebas
  • Broker MQTT de pruebas (usaremos test.mosquitto.org:1883 para validación)

Drivers y permisos

  • Linux:
  • Añade el usuario al grupo dialout: sudo usermod -aG dialout $USER
  • Desconecta y reconecta la placa tras reiniciar sesión
  • Windows:
  • Los MKR SAMD usan USB CDC estándar; el driver lo instala Windows Update automáticamente
  • macOS:
  • No requiere drivers adicionales

Materiales

  • Placa principal:
  • Arduino MKR GSM 1400 (SAMD21 + módem u‑blox SARA‑U201)
  • Sensores:
  • Receptor GNSS u‑blox NEO‑M8N (módulo con interfaz UART y antena activa/patch; alimentación a 3.3 V)
  • IMU/Orientación: Bosch BNO055 (módulo I2C, 3.3 V)
  • Antenas:
  • Antena GSM adecuada para la MKR GSM 1400 (conector u.FL)
  • Antena GNSS para NEO‑M8N (u.FL o SMA según módulo)
  • Alimentación:
  • Batería LiPo 3.7 V con conector JST‑PH (≥ 1200 mAh recomendado para picos del módem)
  • Cable USB‑C/USB‑A a Micro‑USB (la MKR GSM 1400 es micro‑USB)
  • Cables:
  • Jumpers macho‑hembra para UART/I2C
  • Almacenamiento:
  • Tarjeta SIM con datos y PIN conocido (o PIN deshabilitado)

Nota importante: la MKR GSM 1400 trabaja a 3.3 V. Asegúrate de que los módulos NEO‑M8N y BNO055 que utilices admiten 3.3 V lógicos y de alimentación (la mayoría de breakouts modernos lo hacen).

Preparación y conexión

Conexión eléctrica y de señales

  • Alimentación:
  • Conecta la batería LiPo a la MKR GSM 1400 (conector JST). Mantén además el cable USB para programación.
  • Conecta la antena GSM al conector u.FL de la MKR GSM 1400.
  • Conecta la antena GNSS al NEO‑M8N.
  • GNSS (NEO‑M8N) por UART a 9600 bps:
  • Usaremos Serial1 del MKR (UART hardware en pines RX/TX).
  • BNO055 por I2C a 100 kHz (por defecto), dirección 0x28 (ADR a GND).

Tabla de cableado propuesto:

Componente Señal Arduino MKR GSM 1400 NEO‑M8N BNO055 Notas
Alimentación 3V3 3V3 VCC VIN Mantener 3.3 V estable
Alimentación GND GND GND GND Masa común
UART GNSS RX D13 (RX/Ser1) TX NEO TX -> MKR RX
UART GNSS TX D14 (TX/Ser1) RX NEO RX -> MKR TX
I2C SDA SDA SDA Pull‑ups suelen venir en el breakout
I2C SCL SCL SCL
I2C Addr ADR GND Dirección BNO055 = 0x28
Antena GSM u.FL Conector GSM Imprescindible para registro en red
Antena GNSS u.FL/SMA Conector GNSS Imprescindible para captar satélites

Recomendaciones:
– Sitúa la antena GNSS con vista al cielo.
– Evita cables largos en UART/I2C.
– Si el módulo NEO‑M8N incluye LED PPS, útil para ver fix.

Código completo (C++ para Arduino MKR GSM 1400)

Este sketch implementa un “gnss-gsm-asset-tracker” que:
– Lee GNSS (lat/lon/alt/velocidad/satélites) desde NEO‑M8N por Serial1 y lo parsea con TinyGPS++.
– Lee orientación absoluta (yaw/pitch/roll) desde BNO055 por I2C.
– Conecta a la red GSM/GPRS con MKRGSM, obtiene IMEI y publica telemetría JSON por MQTT (TCP/1883).
– Envía paquetes periódicos y reconecta robustamente ante cortes.

Antes de compilar, edita los defines APN, GPRS_USER, GPRS_PASS y, si aplica, PINNUMBER.

Guarda el archivo como gnss_gsm_asset_tracker/gnss_gsm_asset_tracker.ino

/*
  gnss_gsm_asset_tracker.ino
  Dispositivo: Arduino MKR GSM 1400 + NEO-M8N (UART) + BNO055 (I2C)
  Objetivo: Asset tracker GNSS + orientación vía GSM/GPRS (MQTT)
  Toolchain: Arduino CLI v0.35.6 + arduino:samd@1.8.13
  Librerías:
    MKRGSM@1.5.0, TinyGPSPlus@1.0.3, Adafruit BNO055@1.6.3,
    Adafruit Unified Sensor@1.1.14, PubSubClient@2.8.0
*/

#include <MKRGSM.h>
#include <TinyGPSPlus.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BNO055.h>
#include <PubSubClient.h>

// --------- Configuración de red GSM/GPRS ----------
#define APN        "internet"     // <-- Cambia por el APN de tu operador
#define GPRS_USER  ""             // <-- Usuario APN (si aplica)
#define GPRS_PASS  ""             // <-- Contraseña APN (si aplica)
#define PINNUMBER  ""             // <-- PIN SIM ("" si deshabilitado)

// --------- MQTT (broker de pruebas) ---------------
const char* MQTT_SERVER = "test.mosquitto.org";
const uint16_t MQTT_PORT = 1883;
const char* MQTT_BASE_TOPIC = "gnss-gsm-asset-tracker";

// --------- UART GNSS ------------------------------
#define GNSS_BAUD 9600

// --------- Periodos -------------------------------
const unsigned long TELEMETRY_PERIOD_MS = 15000;  // envíos cada 15 s
const unsigned long GNSS_PRINT_PERIOD_MS = 2000;  // logs GNSS cada 2 s

// Objetos globales
GSM gsmAccess;
GPRS gprs;
GSMClient netClient;
GSMModem modem;
PubSubClient mqtt(netClient);

TinyGPSPlus gps;
Adafruit_BNO055 bno = Adafruit_BNO055(55, 0x28);

String deviceImei = "unknown";
char mqttTopic[128];
char payload[384];

unsigned long lastTelemetry = 0;
unsigned long lastGnssPrint = 0;

// ------------- Utilidades -------------------------
static void log(const char* msg) {
  Serial.println(msg);
}

void readGnssStream() {
  while (Serial1.available()) {
    char c = (char)Serial1.read();
    gps.encode(c);
  }
}

bool initBNO055() {
  if (!bno.begin()) {
    log("BNO055: fallo al inicializar. Verifica I2C y alimentación.");
    return false;
  }
  delay(10);
  bno.setExtCrystalUse(true);
  log("BNO055: inicializado en modo NDOF (orientación absoluta).");
  return true;
}

bool connectGSM() {
  log("Módem: inicializando...");
  if (!modem.begin()) {
    log("Módem: no responde (revisa alimentación/antena).");
    return false;
  }

  String modemInfo = modem.getModemInfo();
  Serial.print("Módem info: "); Serial.println(modemInfo);

  deviceImei = modem.getIMEI();
  if (deviceImei.length() == 0) deviceImei = "unknown";
  Serial.print("IMEI: "); Serial.println(deviceImei);

  log("GSM: registrando en red...");
  for (int i = 0; i < 15; i++) {
    if (gsmAccess.begin(PINNUMBER) == GSM_READY) {
      log("GSM: registrado.");
      break;
    }
    delay(1000);
    if (i == 14) {
      log("GSM: no se pudo registrar (cobertura/PIN?).");
      return false;
    }
  }

  log("GPRS: adjuntando...");
  for (int i = 0; i < 10; i++) {
    if (gprs.attachGPRS(APN, GPRS_USER, GPRS_PASS) == GPRS_READY) {
      log("GPRS: adjuntado OK.");
      return true;
    }
    delay(1500);
  }
  log("GPRS: fallo al adjuntar (APN/credenciales?).");
  return false;
}

bool ensureMqttConnected() {
  if (mqtt.connected()) return true;

  mqtt.setServer(MQTT_SERVER, MQTT_PORT);
  // ClientID derivado del IMEI
  char clientId[48];
  snprintf(clientId, sizeof(clientId), "mkrgsm1400-%s", deviceImei.c_str());

  Serial.print("MQTT: conectando a ");
  Serial.print(MQTT_SERVER);
  Serial.print(":");
  Serial.println(MQTT_PORT);

  // Intentos con backoff simple
  for (int attempt = 1; attempt <= 5; attempt++) {
    if (mqtt.connect(clientId)) {
      log("MQTT: conectado.");
      return true;
    }
    Serial.print("MQTT: intento ");
    Serial.print(attempt);
    Serial.println(" fallido.");
    delay(2000 * attempt);
  }
  log("MQTT: no se pudo conectar.");
  return false;
}

void publishTelemetry() {
  // Construir tópico: gnss-gsm-asset-tracker/<IMEI>/telemetry
  snprintf(mqttTopic, sizeof(mqttTopic), "%s/%s/telemetry", MQTT_BASE_TOPIC, deviceImei.c_str());

  // Extraer datos GNSS
  double lat = gps.location.isValid() ? gps.location.lat() : NAN;
  double lon = gps.location.isValid() ? gps.location.lng() : NAN;
  double alt = gps.altitude.isValid() ? gps.altitude.meters() : NAN;
  double spd = gps.speed.isValid() ? gps.speed.kmph() : NAN;
  uint32_t sats = gps.satellites.isValid() ? gps.satellites.value() : 0;
  double hdop = gps.hdop.isValid() ? gps.hdop.hdop() : NAN;
  bool fix = gps.location.isValid() && gps.location.age() < 2000;

  // Extraer orientación BNO055 (Euler: heading/yaw, roll, pitch)
  sensors_event_t orientationData, angVelocityData, linearAccelData, magnetometerData, accelerometerData, gravityData;
  bno.getEvent(&orientationData, Adafruit_BNO055::VECTOR_EULER);
  float yaw   = orientationData.orientation.x; // grados
  float roll  = orientationData.orientation.y;
  float pitch = orientationData.orientation.z;

  // Timestamp aproximado por millis (ideal: RTC/GNSS time)
  unsigned long ts = millis();

  // Construir JSON compacto
  // Nota: evitamos snprintf con %f por AVR, pero en SAMD está soportado; de todos modos convertimos manualmente para control
  char latStr[16], lonStr[16], altStr[16], spdStr[16], hdopStr[16];
  char yawStr[16], pitchStr[16], rollStr[16];

  auto fmt = [](char* buf, size_t sz, double v) {
    if (isnan(v)) { strncpy(buf, "null", sz); }
    else { dtostrf(v, 0, 6, buf); }
  };

  fmt(latStr, sizeof(latStr), lat);
  fmt(lonStr, sizeof(lonStr), lon);
  fmt(altStr, sizeof(altStr), alt);
  fmt(spdStr, sizeof(spdStr), spd);
  fmt(hdopStr, sizeof(hdopStr), hdop);
  fmt(yawStr, sizeof(yawStr), yaw);
  fmt(pitchStr, sizeof(pitchStr), pitch);
  fmt(rollStr, sizeof(rollStr), roll);

  snprintf(payload, sizeof(payload),
    "{"
      "\"imei\":\"%s\","
      "\"ts\":%lu,"
      "\"gnss\":{\"fix\":%s,\"lat\":%s,\"lon\":%s,\"alt\":%s,\"speed_kmh\":%s,\"sats\":%lu,\"hdop\":%s},"
      "\"ori\":{\"yaw_deg\":%s,\"pitch_deg\":%s,\"roll_deg\":%s}"
    "}",
    deviceImei.c_str(), ts,
    fix ? "true" : "false", latStr, lonStr, altStr, spdStr, (unsigned long)sats, hdopStr,
    yawStr, pitchStr, rollStr
  );

  Serial.print("Publicando MQTT -> ");
  Serial.print(mqttTopic);
  Serial.print(" : ");
  Serial.println(payload);

  if (!mqtt.publish(mqttTopic, payload)) {
    log("MQTT: publish fallido (¿desconexión?).");
  }
}

void setup() {
  Serial.begin(115200);
  while (!Serial) { ; }

  log("\n== MKR GSM 1400 GNSS-GSM Asset Tracker ==");
  log("Iniciando GNSS UART...");
  Serial1.begin(GNSS_BAUD);

  log("Iniciando BNO055...");
  bool bnoOk = initBNO055();
  if (!bnoOk) {
    log("Continuando sin BNO055 (se enviarán 'null' en orientación).");
  }

  if (!connectGSM()) {
    log("ERROR: No se pudo conectar GSM/GPRS. Reintentando en bucle...");
  }

  mqtt.setKeepAlive(30);
  mqtt.setSocketTimeout(15);
  // callback MQTT no usado (solo publicación), pero podría implementarse

  lastTelemetry = millis();
  lastGnssPrint = millis();
}

void loop() {
  // Alimentar parser GNSS
  readGnssStream();

  // Mantener MQTT
  if (!mqtt.connected()) {
    ensureMqttConnected();
  } else {
    mqtt.loop();
  }

  unsigned long now = millis();

  // Logging GNSS periódico
  if (now - lastGnssPrint > GNSS_PRINT_PERIOD_MS) {
    lastGnssPrint = now;
    if (gps.location.isValid()) {
      Serial.print("GNSS: ");
      Serial.print(gps.location.lat(), 6);
      Serial.print(", ");
      Serial.print(gps.location.lng(), 6);
      Serial.print(" alt(m)=");
      Serial.print(gps.altitude.meters());
      Serial.print(" sats=");
      Serial.print(gps.satellites.value());
      Serial.print(" hdop=");
      Serial.print(gps.hdop.hdop());
      Serial.print(" spd(kmh)=");
      Serial.println(gps.speed.kmph());
    } else {
      Serial.println("GNSS: buscando fix...");
    }
  }

  // Publicación periódica
  if (now - lastTelemetry > TELEMETRY_PERIOD_MS) {
    lastTelemetry = now;
    if (mqtt.connected()) {
      publishTelemetry();
    }
  }

  // Si GPRS cae, reintenta
  if (gprs.getStatus() != GPRS_READY) {
    log("GPRS: desconectado, reintentando attach...");
    gprs.attachGPRS(APN, GPRS_USER, GPRS_PASS);
  }
}

Explicación breve de partes clave

  • Inicialización de periféricos:
  • Serial1.begin(9600): UART GNSS (NEO‑M8N por defecto a 9600 bps).
  • bno.begin() + setExtCrystalUse(true): BNO055 en modo NDOF con cristal externo para mejor estabilidad.
  • Conexión celular:
  • modem.begin() y getIMEI(): inicializa el SARA‑U201 y lee IMEI para identificar el dispositivo.
  • gsmAccess.begin(PIN): registro en red GSM.
  • gprs.attachGPRS(APN,…): adjunta PDP context para datos.
  • Transporte MQTT:
  • PubSubClient con GSMClient (TCP plano en 1883).
  • ensureMqttConnected(): reconexión robusta con backoff.
  • Fusión de datos:
  • GNSS via TinyGPS++: lat/lon/alt/velocidad/satélites/HDOP.
  • Orientación BNO055: Euler yaw/pitch/roll.
  • Composición JSON: payload compacto sin dependencias adicionales.
  • Publicación:
  • Tópico: gnss-gsm-asset-tracker//telemetry
  • Periodo: 15 s (ajustable).

Compilación, flash y ejecución

Asegúrate de tener Arduino CLI v0.35.6 en PATH. En Linux/macOS:

# 1) Instalar Arduino CLI (si no lo tienes)
# Linux x64:
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh
# Mover a PATH (ajústalo a tu preferencia)
sudo mv bin/arduino-cli /usr/local/bin/
arduino-cli version  # Debe mostrar 0.35.6

# 2) Inicializar configuración y actualizar índices
arduino-cli config init
arduino-cli core update-index

# 3) Instalar el core SAMD exacto
arduino-cli core install arduino:samd@1.8.13

# 4) Instalar librerías con versiones exactas
arduino-cli lib install "MKRGSM@1.5.0"
arduino-cli lib install "TinyGPSPlus@1.0.3"
arduino-cli lib install "Adafruit BNO055@1.6.3"
arduino-cli lib install "Adafruit Unified Sensor@1.1.14"
arduino-cli lib install "PubSubClient@2.8.0"

# 5) Crear la estructura del proyecto
mkdir -p ~/proyectos/gnss-gsm-asset-tracker
cd ~/proyectos/gnss-gsm-asset-tracker
# Crea el archivo gnss_gsm_asset_tracker.ino y pega el código

# 6) Identificar el puerto serie de la MKR GSM 1400
arduino-cli board list
# Ejemplo de salida (Linux): /dev/ttyACM0  Arduino MKR GSM 1400  arduino:samd:mkrgsm1400

# 7) Compilar para la FQBN apropiada
arduino-cli compile --fqbn arduino:samd:mkrgsm1400 .

# 8) Subir el firmware (ajusta el puerto si difiere)
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:samd:mkrgsm1400 .

# 9) Abrir monitor serie a 115200 baudios
arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200

Notas:
– En Windows, sustituye el puerto por COMx, por ejemplo:
– arduino-cli upload -p COM6 –fqbn arduino:samd:mkrgsm1400 .
– arduino-cli monitor -p COM6 -c baudrate=115200
– Si el APN requiere credenciales, edita GPRS_USER/GPRS_PASS en el sketch.
– Si tu SIM tiene PIN, pon PINNUMBER acorde.

Validación paso a paso

1) Encendido y logs iniciales
– Conecta antena GSM, antena GNSS y SIM.
– Alimenta con LiPo y USB.
– Abre el monitor serie a 115200.
– Debes ver mensajes similares:
– «== MKR GSM 1400 GNSS-GSM Asset Tracker ==»
– «Iniciando GNSS UART…»
– «BNO055: inicializado…» (o advertencia si no)
– «Módem: inicializando…» seguido de «Módem info: …» y «IMEI: …»
– «GSM: registrado.» y «GPRS: adjuntado OK.»
– «MQTT: conectado.»

2) Fix GNSS
– Coloca la antena GNSS con vista al cielo.
– En el monitor, verás «GNSS: buscando fix…» hasta lograr fix, luego coordenadas:
– GNSS: 40.416775, -3.703790 alt(m)=650.12 sats=10 hdop=0.9 spd(kmh)=0.12

3) Publicación MQTT
– Cada ~15 s, el sketch imprime «Publicando MQTT -> gnss-gsm-asset-tracker//telemetry : {…}».
– Desde otro equipo (o el mismo) suscríbete al tópico para verificar:
– Linux/macOS con mosquitto-clients:
bash
# Instalar (si hace falta)
# Ubuntu: sudo apt-get install -y mosquitto-clients
mosquitto_sub -h test.mosquitto.org -t "gnss-gsm-asset-tracker/+/telemetry" -v

– Debes recibir líneas con el JSON enviado. Ejemplo:
gnss-gsm-asset-tracker/356612345678901/telemetry {"imei":"356612345678901","ts":123456,"gnss":{"fix":true,"lat":40.416775,"lon":-3.703790,"alt":650.120000,"speed_kmh":0.120000,"sats":10,"hdop":0.900000},"ori":{"yaw_deg":123.125000,"pitch_deg":-2.500000,"roll_deg":1.000000}}

4) Validación de orientación
– Mueve físicamente el montaje; observa cambios en yaw/pitch/roll en el JSON y en el monitor serie.

5) Robustez de reconexión
– Desatornilla (temporalmente) la antena GSM o apantalla la señal para simular caída:
– Observa reintentos de GPRS/MQTT. Al recuperar señal, el envío debe reanudarse sin reset manual.

6) Tasa de publicación y latencia
– Confirma el periodo aproximado entre mensajes (≈15 s).
– La latencia típica TCP/2G puede ser de varios segundos; considera esto normal.

Troubleshooting (5–8 problemas típicos y soluciones)

1) No hay registro GSM: “GSM: no se pudo registrar”
– Causas:
– Antena GSM no conectada o defectuosa.
– SIM sin servicio o PIN activo incorrecto.
– Cobertura insuficiente.
– Soluciones:
– Revisa conexión u.FL cuidadosamente.
– Verifica PINNUMBER y/o deshabilita PIN en un teléfono.
– Prueba en otra ubicación / operador que mantenga 2G/3G operativo.

2) “GPRS: fallo al adjuntar (APN/credenciales?)”
– Causas:
– APN incorrecto o credenciales requeridas.
– Servicio de datos no activo.
– Soluciones:
– Confirma APN exacto de tu operador (may/min correctos).
– Rellena GPRS_USER/GPRS_PASS si aplica.
– Contacta al operador para habilitar datos.

3) MQTT no conecta a test.mosquitto.org
– Causas:
– Bloqueo del puerto 1883 en la red del operador.
– DNS no resuelto por el módem.
– Intermitencia 2G.
– Soluciones:
– Prueba con IP directa del broker (ojo: puede cambiar):
– mosquitto.org IP pública en el momento de prueba (resuélvela con nslookup en tu PC).
– Monta un broker propio en VPS/puerto alternativo abierto por el operador.
– Incrementa los timeouts y reintentos en ensureMqttConnected().

4) GNSS sin fix prolongado
– Causas:
– Antena GNSS mal posicionada.
– Tiempo de arranque en frío (TTFF) elevado sin asistencia.
– Soluciones:
– Sitúa la antena a cielo abierto, alejando interferencias.
– Espera 5–15 minutos el primer fix.
– Si tu módulo soporta batería backup, asegúrala para retener efemérides.

5) Lecturas BNO055 nulas o erráticas
– Causas:
– I2C sin alimentación/masa compartida.
– Dirección errónea (ADR no en GND -> 0x29).
– Soluciones:
– Verifica GND común.
– Comprueba que ADR esté a GND para 0x28 o ajusta el constructor a 0x29.
– Reduce vibraciones; BNO055 requiere estabilización para calibraciones.

6) Subida por USB falla (“No device found”)
– Causas:
– Puerto equivocado.
– Regla de permisos en Linux.
– Soluciones:
– Revisa arduino-cli board list.
– Añade tu usuario a dialout y reconecta.
– Pulsa reset dos veces para forzar bootloader (puede cambiar el puerto).

7) Reinicios al transmitir GSM
– Causas:
– Caídas de tensión por picos de corriente del módem (hasta ~2 A pico).
– Soluciones:
– Usa LiPo ≥1200 mAh y cable USB corto/calidad.
– Evita alimentar solo por USB del PC; combina con LiPo.
– Añade condensadores de bulk (p.ej. 470–1000 µF) cerca del módem si tu carrier board lo permite.

8) JSON truncado o publicación falla
– Causas:
– Buffer MQTT/payload insuficiente.
– Soluciones:
– Aumenta el tamaño de payload[] si añadiste campos.
– Verifica que mqtt.publish devuelve true; reintenta al reconectar.

Mejoras/variantes

  • Cifrado TLS:
  • Cambiar a GSMSSL (cliente SSL) y usar un broker MQTT con TLS en 8883.
  • Requiere más RAM y gestión de raíces de confianza; evaluar huellas SHA1 estáticas.
  • Compresión/optimización:
  • Enviar payload binario CBOR/MessagePack para reducir bytes en 2G.
  • Añadir downsampling y delta encoding entre posiciones cercanas.
  • Geofencing local:
  • Implementar lógica de “salida/entrada” de zonas; publicar eventos además de telemetría periódica.
  • Filtrado sensor:
  • Complementar GNSS con acelerómetro/BNO055 para detectar movimiento (start/stop) y despertar el módem solo al moverse.
  • Gestión de energía:
  • Uso del modo de bajo consumo del SAMD21 y encendido controlado del módem para extender batería.
  • Almacenamiento offline:
  • Buffer circular en flash/SD para enviar lotes cuando haya cobertura.
  • Formato/Backends:
  • Enviar a HTTP/REST con ArduinoHttpClient (si el endpoint soporta http sin TLS o se usa SSL).
  • Integración con plataformas IoT (ThingsBoard, AWS IoT, etc.) usando MQTT con topics y certificados.

Checklist de verificación

  • [ ] He instalado Arduino CLI v0.35.6 y el core arduino:samd@1.8.13
  • [ ] He instalado las librerías exactas: MKRGSM@1.5.0, TinyGPSPlus@1.0.3, Adafruit BNO055@1.6.3, Adafruit Unified Sensor@1.1.14, PubSubClient@2.8.0
  • [ ] He conectado correctamente: MKR GSM 1400 + NEO‑M8N (UART D13 RX / D14 TX), BNO055 (I2C SDA/SCL), masas y 3.3 V
  • [ ] Antena GSM a la MKR y antena GNSS al NEO‑M8N están bien acopladas
  • [ ] SIM con datos y APN configurado en el código (PIN si aplica)
  • [ ] Compila sin errores con FQBN arduino:samd:mkrgsm1400
  • [ ] Subida correcta al puerto serie detectado (ACM/COM correspondiente)
  • [ ] Monitor serie muestra registro GSM y adjunto GPRS exitosos
  • [ ] Se recibe fix GNSS (coordenadas válidas) en el monitor
  • [ ] Se observan publicaciones cada ~15 s hacia el tópico MQTT gnss-gsm-asset-tracker//telemetry
  • [ ] Verificado con mosquitto_sub que llegan los JSON con lat/lon/orientación
  • [ ] Probada la reconexión tras simular pérdida de señal

Con todo lo anterior, habrás construido un “gnss-gsm-asset-tracker” consistente y reproducible sobre el modelo exacto Arduino MKR GSM 1400 + NEO‑M8N + BNO055, compilado con Arduino CLI y versiones de toolchain/librerías especificadas, y validado extremo a extremo con un broker MQTT público.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es la versión de Arduino CLI utilizada en el artículo?




Pregunta 2: ¿Qué sistema operativo no es mencionado como soportado?




Pregunta 3: ¿Cuál es el core de placas indicado para Arduino?




Pregunta 4: ¿Qué librería es opcional para depuración según el artículo?




Pregunta 5: ¿Qué tipo de cobertura se necesita para las pruebas?




Pregunta 6: ¿Qué comando se debe usar para añadir un usuario al grupo dialout en Linux?




Pregunta 7: ¿Cuál es la versión de la librería Adafruit BNO055 mencionada?




Pregunta 8: ¿Qué tipo de SIM se requiere según los requisitos de red?




Pregunta 9: ¿Qué tipo de antena se requiere para el receptor GNSS NEO-M8N?




Pregunta 10: ¿Qué broker MQTT se sugiere para validación en las pruebas?




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

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


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:


Caso práctico: Hexápodo Arduino Mega 2560 + PCA9685 + XBee

Caso práctico: Hexápodo Arduino Mega 2560 + PCA9685 + XBee — hero

Objetivo y caso de uso

Qué construirás: Un robot hexápodo controlado por Zigbee utilizando Arduino Mega 2560, PCA9685 y XBee S2C.

Para qué sirve

  • Control remoto de un hexápodo para exploración en terrenos difíciles.
  • Implementación de evitación de obstáculos utilizando el sensor VL53L0X.
  • Demostraciones de robótica educativa en ferias tecnológicas.
  • Proyectos de investigación en movilidad robótica y comunicación inalámbrica.

Resultado esperado

  • Latencia de control remoto inferior a 100 ms.
  • Capacidad de respuesta a comandos en tiempo real con un mínimo de 10 FPS.
  • Precisión de detección de obstáculos de hasta 30 cm con el sensor ToF.
  • Consumo de energía del sistema menor a 500 mA durante la operación.

Público objetivo: Estudiantes avanzados y entusiastas de la robótica; Nivel: Avanzado

Arquitectura/flujo: Arduino Mega 2560 controla el PCA9685 para manejar los servos, mientras que el XBee S2C se encarga de la comunicación Zigbee y el VL53L0X proporciona datos de distancia para la evitación de obstáculos.

Nivel: Avanzado

Prerrequisitos

  • Sistema operativo recomendado:
  • Ubuntu 22.04.4 LTS (64-bit) o equivalente en Linux con soporte para USB CDC.
  • Alternativa: Windows 10/11 o macOS 12+, con adaptaciones de puerto serie.

  • Toolchain exacta (probada):

  • Arduino CLI 0.35.3
  • Core Arduino AVR Boards arduino:avr@1.8.6
  • avr-gcc 7.3.0-atmel3.6.1
  • avrdude 6.3
  • Bibliotecas Arduino (Arduino Library Manager):
  • Adafruit PWM Servo Driver Library@2.4.1
  • Adafruit BusIO@1.14.5
  • Adafruit Unified Sensor@1.1.14
  • Adafruit VL53L0X@1.3.1
  • Python 3.10.12 (opcional, para script de comandos por ZigBee)
  • minicom 2.7.1 (o picocom) para terminal serie en Linux, o PuTTY en Windows

  • Hardware principal del caso:

  • Arduino Mega 2560
  • PCA9685 (driver 16 canales servo por I2C)
  • XBee S2C (Zigbee) en modo transparente (AP=0)
  • VL53L0X (sensor ToF por I2C)
  • Objetivo del proyecto: servo-hexapod-zigbee (hexápodo de 18 servos con control remoto Zigbee y evitación de obstáculos con ToF).

  • Notas de compatibilidad:

  • Arduino Mega 2560 usa 5 V lógicos; el XBee S2C opera a 3.3 V. Es obligatorio un shield con conversión de nivel o un conversor bidireccional (TX/RX).
  • PCA9685 y VL53L0X comparten el bus I2C (SDA/SCL); asegúrate de que sus direcciones no colisionen (por defecto PCA9685 0x40, VL53L0X 0x29).

Materiales

  • Electrónica y módulos:
  • 1x Arduino Mega 2560 (original o compatible, con USB).
  • 1x PCA9685 16-channel 12-bit PWM Servo Driver (dirección por defecto 0x40).
  • 1x XBee S2C Zigbee (serie S2C, 2.4 GHz).
  • 1x Shield/adaptador XBee para Arduino Mega (con conversión de nivel a 3.3 V) o conversor de nivel bidireccional para UART.
  • 1x VL53L0X (módulo ToF, 940 nm).
  • 18x servos estándar para robótica (ej.: SG90/MG90S para prototipo o servos metálicos 12–20 kg·cm para carga real).
  • 1x Fuente DC 6V–7.4 V con capacidad ≥ 5–10 A (según servos).
  • 1x Portapilas LiPo 2S/3S o fuente conmutada 6 V regulada de alta corriente.
  • Cableado Dupont, regletas, tornillería y bridas.
  • 1x Chasis de hexápodo (6 patas, 3 DOF por pata: coxa, fémur, tibia).
  • 1x Fusible/limitador o protector de corriente para la línea de servos (recomendado).
  • 1x Interruptor general en el bus de servos (recomendado).

  • Opcionales útiles:

  • 1x Módulo UBEC 5 V/6 V de 5–10 A para servos.
  • Pasta térmica/adhesivo para disipación del PCA9685 (no siempre necesario, pero útil).

  • Software:

  • Arduino CLI 0.35.3 (instalar por script oficial).
  • Librerías especificadas (vías Arduino CLI).
  • minicom/picocom o Python para pruebas Zigbee.

Preparación y conexión

Consideraciones eléctricas

  • Los servos NO deben alimentarse desde el pin 5V del Arduino Mega. Usa una fuente externa para la línea V+ del PCA9685 (pines “V+” de potencia).
  • Une siempre GND del Arduino, GND del PCA9685 y GND de la fuente de servos. Masa común es imprescindible para estabilidad y PWM correcto.
  • El XBee S2C debe recibir 3.3 V y señales TX/RX a 3.3 V. Emplea un shield XBee o conversores de nivel. No conectes el XBee directamente a 5 V.

Mapeo de pines/puertos

  • I2C en Arduino Mega 2560:
  • SDA: pin 20
  • SCL: pin 21

  • UART para XBee:

  • Serial1: RX1 pin 19, TX1 pin 18 (recomendado para dejar Serial0/USB para logs).

  • Alimentación:

  • PCA9685: VCC a 5 V del Mega (lógica), GND común; V+ (poder) a 6 V–7.4 V externa para servos.
  • VL53L0X: VCC a 5 V si el módulo tiene regulador/level shifting (común en breakout comerciales), o 3.3 V si es bare.

Tabla de conexiones

Módulo Pin/Señal módulo Conecta a (Mega/otro) Notas
PCA9685 VCC 5V Mega Lógica
PCA9685 GND GND común Común a todo
PCA9685 SDA SDA (20) Mega I2C
PCA9685 SCL SCL (21) Mega I2C
PCA9685 V+ (barral) +6–7.4 V externa Potencia servos
PCA9685 GND (barral) GND fuente servos Común
VL53L0X VCC 5V (si trae regulador) Ver ficha módulo
VL53L0X GND GND común
VL53L0X SDA SDA (20) Mega I2C
VL53L0X SCL SCL (21) Mega I2C
XBee S2C VCC 3.3 V del shield No 5 V
XBee S2C GND GND común
XBee S2C DOUT (TX del XBee) RX1 (19) Mega (via shield/level) UART
XBee S2C DIN (RX del XBee) TX1 (18) Mega (via shield/level) UART
Servos (18) Señal Canales PCA9685 0–17 Ver mapeo abajo
Servos +V V+ PCA9685 6–7.4 V
Servos GND GND PCA9685

Mapeo de servos a canales (hexápodo 6 patas x 3 DOF)

  • Convención de patas: L0, L1, L2 (lado izquierdo front–middle–rear), R0, R1, R2 (lado derecho front–middle–rear).
  • DOF por pata: coxa (rotación horizontal), fémur (levanta/baja), tibia (extiende).
Pata Coxa (canal) Fémur (canal) Tibia (canal)
L0 0 1 2
L1 3 4 5
L2 6 7 8
R0 9 10 11
R1 12 13 14
R2 15 16 17
  • Ajusta los canales si tu cableado físico difiere, pero mantén una estructura coherente para simplificar el control y las gait tables.

Configuración básica del XBee S2C (modo transparente)

  • Conecta el XBee remoto a tu PC por un adaptador USB-XBee (3.3 V).
  • Entra en modo de comandos “AT” a 115200 bps (por defecto puede venir a 9600 bps; ajusta según sea necesario):
  • Abre minicom/picocom: a 9600 o 115200 según módulo.
  • Teclea “+++” y espera “OK”.
  • Configura (ejemplo: PAN en 0x1234, canal C, router/coordinator según topología). Para control remoto simple, haz que el robot sea “Router” (CE=0) y el XBee de la PC “Coordinator” (CE=1):
  • ATRE
  • ATAP0 (modo transparente)
  • ATBD7 (115200 bps)
  • ATCE1 (coordinator en el remoto de PC) o ATCE0 (router en el robot)
  • ATID1234 (PAN ID)
  • ATCH0C (canal 0x0C, opcional)
  • ATWR (guardar)
  • ATCN (salir)
  • En el lado del robot (XBee en el shield del Mega), aplica:
  • ATAP0
  • ATBD7
  • ATCE0
  • ATID1234
  • ATWR
  • Nota: si prefieres API mode (AP=2), modifica el código para usar frames; en este caso práctico usamos transparente para mantener el enfoque en gait + sensórica.

Código completo (Arduino C++)

El siguiente sketch controla:
– PCA9685 a 50 Hz para 18 servos.
– Gait tipo “trípode” con temporización no bloqueante.
– Parser de comandos por Zigbee (Serial1) con texto: FWD, BACK, LEFT, RIGHT, STOP, STEP, SPEED n, CALIB leg dof offset.
– VL53L0X para evitación: detiene y notifica si hay obstáculo < 200 mm.

Guarda el archivo como servo_hexapod_zigbee/servo_hexapod_zigbee.ino.

#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
#include <Adafruit_VL53L0X.h>
#include <Adafruit_Sensor.h>

// ---------- Configuración PCA9685 ----------
static const uint8_t PCA_ADDR = 0x40;
static const float SERVO_FREQ = 50.0f;     // Hz

Adafruit_PWMServoDriver pca = Adafruit_PWMServoDriver(PCA_ADDR);

// Rango típico de servo en microsegundos:
static const int SERVO_MIN_US = 500;
static const int SERVO_MAX_US = 2500;

// Canales por pata (coxa, femur, tibia)
struct LegMap { uint8_t coxa, femur, tibia; };
LegMap legs[6] = {
  {0, 1, 2},     // L0
  {3, 4, 5},     // L1
  {6, 7, 8},     // L2
  {9, 10, 11},   // R0
  {12, 13, 14},  // R1
  {15, 16, 17}   // R2
};

// Offsets por servo para centrado (us). Ajusta tras calibración.
int16_t servoOffsetUs[18] = {
  0,0,0,  0,0,0,  0,0,0,  0,0,0,  0,0,0,  0,0,0
};

// Inversión por servo (1 normal, -1 invertido)
int8_t servoDir[18] = {
  1, -1, -1,   // L0: coxa normal, femur invertido, tibia invertida
  1, -1, -1,   // L1
  1, -1, -1,   // L2
  -1, -1, -1,  // R0: coxa invertida (simetría), femur invertido, tibia invertida
  -1, -1, -1,  // R1
  -1, -1, -1   // R2
};

// ---------- Cinemática sencilla (3 DOF por pata) ----------
struct Vec3 { float x, y, z; };

// Longitudes (mm) de cada link. Ajusta a tu chasis.
static const float L_COXA = 28.0f;
static const float L_FEMUR = 55.0f;
static const float L_TIBIA = 85.0f;

// Límites de ángulo por DOF (grados)
static const float COXA_MIN = -45.0f, COXA_MAX = 45.0f;
static const float FEMUR_MIN = -20.0f, FEMUR_MAX = 75.0f;
static const float TIBIA_MIN = -70.0f, TIBIA_MAX = 20.0f;

// Conversión de ángulo (grados) a microsegundos
int angleToUs(float angleDeg) {
  // Mapear [0..180] a [SERVO_MIN_US..SERVO_MAX_US]
  // Primero acotamos a 0..180 desde una referencia 90 en neutro
  float a = angleDeg;
  if (a < 0) a = 0;
  if (a > 180) a = 180;
  float us = SERVO_MIN_US + (a / 180.0f) * (SERVO_MAX_US - SERVO_MIN_US);
  return (int)us;
}

// Conversión us -> ticks PCA9685
uint16_t usToTicks(int us) {
  float ticks = (SERVO_FREQ * 4096.0f * (float)us) / 1000000.0f;
  if (ticks < 0) ticks = 0;
  if (ticks > 4095) ticks = 4095;
  return (uint16_t)ticks;
}

// Set de un canal con ángulo absoluto (0..180), más offset e inversión
void setServoAngle(uint8_t ch, float angleDeg) {
  // Inversión por hardware lógico
  float a = angleDeg;
  if (servoDir[ch] < 0) a = 180.0f - a;
  int baseUs = angleToUs(a);
  int us = baseUs + servoOffsetUs[ch];
  uint16_t t = usToTicks(us);
  pca.setPWM(ch, 0, t);
}

// IK de una pata: retorna ángulos en grados (coxa, femur, tibia)
bool ikSolve(const Vec3 &target, float &aCoxa, float &aFemur, float &aTibia, bool isRightSide) {
  // Ajuste horizontal: coxa rota en planta por atan2(y, x)
  float a = atan2f(target.y, target.x); // rad
  aCoxa = a * 180.0f / M_PI; // grados

  // Proyección al plano de fémur-tibia: dist desde el eje de coxa
  float r = sqrtf(target.x*target.x + target.y*target.y) - L_COXA;
  float z = target.z;

  // Distancia al pie en el plano (r,z)
  float d = sqrtf(r*r + z*z);

  // Ley del coseno para aFemur y aTibia
  float cosTibia = (L_FEMUR*L_FEMUR + L_TIBIA*L_TIBIA - d*d) / (2.0f*L_FEMUR*L_TIBIA);
  if (cosTibia < -1.0f || cosTibia > 1.0f) return false;
  float tibiaRad = acosf(cosTibia); // 0..pi

  float cosFemur = (L_FEMUR*L_FEMUR + d*d - L_TIBIA*L_TIBIA) / (2.0f*L_FEMUR*d);
  if (cosFemur < -1.0f || cosFemur > 1.0f) return false;
  float femurRad = acosf(cosFemur);

  // Elevación natural: ángulo de la línea r-z
  float elev = atan2f(z, r);

  // Convenciones (ajusta según montaje mecánico):
  aFemur = (elev + femurRad) * 180.0f / M_PI - 10.0f;
  aTibia = (M_PI - tibiaRad) * 180.0f / M_PI - 10.0f;

  // Ajuste de simetría si es lado derecho
  if (isRightSide) {
    aCoxa = -aCoxa;
  }

  // Limitar a rangos seguros
  if (aCoxa < COXA_MIN) aCoxa = COXA_MIN;
  if (aCoxa > COXA_MAX) aCoxa = COXA_MAX;
  if (aFemur < FEMUR_MIN) aFemur = FEMUR_MIN;
  if (aFemur > FEMUR_MAX) aFemur = FEMUR_MAX;
  if (aTibia < TIBIA_MIN) aTibia = TIBIA_MIN;
  if (aTibia > TIBIA_MAX) aTibia = TIBIA_MAX;

  // Convertir a referencia 0..180 para servo:
  // Suponemos mid (90) como neutro mecánico aproximado.
  aCoxa = 90.0f + aCoxa;
  aFemur = 90.0f + aFemur;
  aTibia = 90.0f + aTibia;

  return true;
}

// ---------- Gait: trípode ----------
// Dos trípodes: {L0, R1, L2} y {R0, L1, R2}
uint8_t tripodA[3] = {0, 4, 2}; // índices de patas L0(0), R1(4), L2(2)
uint8_t tripodB[3] = {3, 1, 5}; // R0(3), L1(1), R2(5)

// Parámetros de movimiento:
float stepHeight = 25.0f;   // mm elevación
float stepLength = 40.0f;   // mm avance
float defaultX = 80.0f;     // mm delantero
float defaultY = 50.0f;     // mm lateral (separación)
float defaultZ = -60.0f;    // mm vertical (negativo hacia abajo)
uint16_t gaitPeriodMs = 550; // velocidad (ms por semiperiodo)

// Estado de control
enum Mode {MODE_STOP, MODE_FWD, MODE_BACK, MODE_LEFT, MODE_RIGHT, MODE_STEP};
Mode mode = MODE_STOP;

// Parser de comandos
String cmdBuffer;

// Sensor VL53L0X
Adafruit_VL53L0X lox;
uint16_t obstacleThresholdMm = 200; // parar si < 200 mm
bool obstacleDetected = false;

// Temporización
uint32_t lastGaitTick = 0;

// Posiciones base de cada pata en marco del cuerpo
Vec3 basePos[6];

void initBasePos() {
  // Asumir simetría: Y positivo lado izquierdo, negativo derecho
  basePos[0] = { defaultX,  defaultY, defaultZ}; // L0
  basePos[1] = { 0.0f,      defaultY, defaultZ}; // L1
  basePos[2] = {-defaultX,  defaultY, defaultZ}; // L2
  basePos[3] = { defaultX, -defaultY, defaultZ}; // R0
  basePos[4] = { 0.0f,     -defaultY, defaultZ}; // R1
  basePos[5] = {-defaultX, -defaultY, defaultZ}; // R2
}

void applyLegAngles(uint8_t legIdx, float aCoxa, float aFemur, float aTibia) {
  const LegMap &lm = legs[legIdx];
  setServoAngle(lm.coxa, aCoxa);
  setServoAngle(lm.femur, aFemur);
  setServoAngle(lm.tibia, aTibia);
}

void moveLegTo(uint8_t legIdx, const Vec3 &p) {
  float aC,aF,aT;
  bool rightSide = (legIdx >= 3);
  if (ikSolve(p, aC, aF, aT, rightSide)) {
    applyLegAngles(legIdx, aC, aF, aT);
  }
}

// Genera objetivo por pata según fase del trípode
void computeTargets(float phase, Vec3 outTargets[6]) {
  // phase: 0..1 (semiperiodo) alterna trípodes A/B
  // movimiento según modo:
  float dx = 0, dy = 0;
  if (mode == MODE_FWD) dx = stepLength;
  else if (mode == MODE_BACK) dx = -stepLength;
  else if (mode == MODE_LEFT) dy = stepLength;
  else if (mode == MODE_RIGHT) dy = -stepLength;

  // Para cada pata, si pertenece al grupo activo en esta mitad, elevar y avanzar, si no, retroceder a ras de suelo
  auto inTripod = [&](uint8_t leg, const uint8_t group[3]) {
    return (leg == group[0] || leg == group[1] || leg == group[2]);
  };

  const uint8_t* activeGroup = ((millis() / gaitPeriodMs) % 2 == 0) ? tripodA : tripodB;

  for (uint8_t i=0; i<6; ++i) {
    Vec3 base = basePos[i];
    Vec3 p = base;
    float t = phase; // 0..1 normalizado

    if (mode == MODE_STOP) {
      // Mantener base
      // Si hay STEP, se maneja externamente
    } else {
      if (inTripod(i, activeGroup)) {
        // Fase de swing (elevar y mover hacia adelante)
        float lift = stepHeight * sinf(t * M_PI); // campana
        p.z = base.z + lift;
        p.x = base.x + dx * (t - 0.5f); // centrado
        p.y = base.y + dy * (t - 0.5f);
      } else {
        // Fase de stance (contacto, mover hacia atrás de forma complementaria)
        p.z = base.z; // suelo
        p.x = base.x - dx * (t - 0.5f);
        p.y = base.y - dy * (t - 0.5f);
      }
    }

    outTargets[i] = p;
  }
}

void allLegsToHome() {
  for (uint8_t i=0; i<6; ++i) {
    moveLegTo(i, basePos[i]);
  }
}

// ---------- Comunicación por Zigbee (Serial1, transparente) ----------
void handleSerial1() {
  while (Serial1.available()) {
    char c = (char)Serial1.read();
    if (c == '\r') continue;
    if (c == '\n') {
      processCommand(cmdBuffer);
      cmdBuffer = "";
    } else {
      if (cmdBuffer.length() < 64)
        cmdBuffer += c;
    }
  }
}

void sendStatus(const String &s) {
  Serial1.print("[HEX] ");
  Serial1.println(s);
  Serial.print("[HEX] ");
  Serial.println(s);
}

void processCommand(const String &cmd) {
  String up = cmd;
  up.trim();
  up.toUpperCase();

  if (up == "FWD") { mode = MODE_FWD; sendStatus("MODE=FWD"); }
  else if (up == "BACK") { mode = MODE_BACK; sendStatus("MODE=BACK"); }
  else if (up == "LEFT") { mode = MODE_LEFT; sendStatus("MODE=LEFT"); }
  else if (up == "RIGHT") { mode = MODE_RIGHT; sendStatus("MODE=RIGHT"); }
  else if (up == "STOP") { mode = MODE_STOP; sendStatus("MODE=STOP"); allLegsToHome(); }
  else if (up.startsWith("SPEED ")) {
    int v = up.substring(6).toInt();
    if (v >= 300 && v <= 1200) {
      gaitPeriodMs = (uint16_t)v;
      sendStatus("SPEED(ms)=" + String(gaitPeriodMs));
    } else {
      sendStatus("ERR SPEED [300..1200]");
    }
  } else if (up.startsWith("STEP")) {
    mode = MODE_STEP;
    sendStatus("MODE=STEP (single)");
  } else if (up.startsWith("CALIB ")) {
    // Formato: CALIB leg dof offset_us
    // leg=0..5, dof=0(coxa)/1(femur)/2(tibia)
    int leg=-1, dof=-1, off=0;
    int n = sscanf(up.c_str(), "CALIB %d %d %d", &leg, &dof, &off);
    if (n==3 && leg>=0 && leg<6 && dof>=0 && dof<3 && off>-500 && off<500) {
      uint8_t ch = (dof==0) ? legs[leg].coxa : (dof==1 ? legs[leg].femur : legs[leg].tibia);
      servoOffsetUs[ch] = off;
      sendStatus("CALIB OK leg="+String(leg)+" dof="+String(dof)+" off="+String(off));
      // Reaplica home con nuevo offset
      moveLegTo(leg, basePos[leg]);
    } else {
      sendStatus("ERR CALIB usage: CALIB leg(0..5) dof(0..2) offset(-499..499)");
    }
  } else {
    sendStatus("UNKNOWN CMD: " + up);
  }
}

// ---------- VL53L0X ----------
bool readObstacle(uint16_t &mm) {
  VL53L0X_RangingMeasurementData_t measure;
  lox.rangingTest(&measure, false); // no debug
  if (measure.RangeStatus == 4) { // out of range
    return false;
  } else {
    mm = measure.RangeMilliMeter;
    return true;
  }
}

// ---------- Setup ----------
void setup() {
  Serial.begin(115200);
  Serial1.begin(115200); // XBee
  delay(50);

  Wire.begin();
  Wire.setClock(400000); // I2C rápido
  pca.begin();
  pca.setPWMFreq(SERVO_FREQ);
  delay(10);

  if (!lox.begin()) {
    Serial.println("VL53L0X no detectado. Verifique conexiones.");
  } else {
    Serial.println("VL53L0X listo.");
  }

  initBasePos();
  allLegsToHome();

  Serial.println("Hexapod listo. Comandos: FWD/BACK/LEFT/RIGHT/STOP/SPEED n/STEP/CALIB leg dof off");
  sendStatus("READY");
}

// ---------- Loop ----------
void loop() {
  handleSerial1();

  // Sensado de obstáculo
  uint16_t dist;
  if (readObstacle(dist)) {
    if (dist < obstacleThresholdMm) {
      if (!obstacleDetected) {
        obstacleDetected = true;
        mode = MODE_STOP;
        sendStatus("OBSTACLE " + String(dist) + "mm -> STOP");
        allLegsToHome();
      }
    } else {
      if (obstacleDetected) {
        obstacleDetected = false;
        sendStatus("PATH CLEAR");
      }
    }
  }

  // Gait update
  uint32_t now = millis();
  if (now - lastGaitTick >= 20) { // 50 Hz update
    lastGaitTick = now;

    // Calcular fase del semiperiodo
    uint32_t t = now % gaitPeriodMs;
    float phase = (float)t / (float)gaitPeriodMs; // 0..1

    Vec3 targets[6];
    computeTargets(phase, targets);

    if (mode == MODE_STEP) {
      // Ejecuta una mitad de ciclo y luego STOP
      static bool stepStarted = false;
      static uint32_t stepStartTime = 0;
      if (!stepStarted) {
        stepStarted = true;
        stepStartTime = now;
      }
      for (uint8_t i=0; i<6; ++i) moveLegTo(i, targets[i]);
      if (now - stepStartTime >= gaitPeriodMs) {
        mode = MODE_STOP; stepStarted = false;
        allLegsToHome();
        sendStatus("STEP DONE");
      }
    } else if (mode == MODE_STOP) {
      // Mantener home
      // no-op para evitar jitter
    } else {
      for (uint8_t i=0; i<6; ++i) moveLegTo(i, targets[i]);
    }
  }
}

Breve explicación de las partes clave:
– Configuración del PCA9685 a 50 Hz (SERVO_FREQ = 50) y conversión de microsegundos a ticks (usToTicks).
– Estructura LegMap para asociar canales a cada DOF de cada pata.
– IK simplificada: separa rotación de coxa (atan2 en planta) y resuelve fémur/tibia con ley del coseno en el plano r-z.
– Gait trípode: alterna grupos A y B; una mitad eleva y avanza, la otra mitad mantiene contacto y “arrastra” en sentido contrario para el ciclo de paso.
– Parser de comandos (Serial1) para XBee en modo transparente a 115200 bps.
– VL53L0X: al detectar un obstáculo por debajo de 200 mm, detiene el movimiento y centra.
– Calibración en caliente por offset por-servo (microsegundos) con comando CALIB.

Compilación/flash/ejecución

Asegúrate de tener conectado el Arduino Mega 2560 por USB. Identifica el puerto (Linux suele ser /dev/ttyACM0 o /dev/ttyUSB0).

1) Instalar Arduino CLI 0.35.3 (Linux):

# Descarga e instalación
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | BINDIR=$HOME/.local/bin sh -s 0.35.3

# Verificar versión
$HOME/.local/bin/arduino-cli version
# arduino-cli Version: 0.35.3

# Añadir al PATH (opcional, si no lo tienes):
echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.bashrc
source ~/.bashrc

2) Configurar core y bibliotecas exactas:

arduino-cli core update-index
arduino-cli core install arduino:avr@1.8.6

arduino-cli lib install "Adafruit PWM Servo Driver Library@2.4.1"
arduino-cli lib install "Adafruit BusIO@1.14.5"
arduino-cli lib install "Adafruit Unified Sensor@1.1.14"
arduino-cli lib install "Adafruit VL53L0X@1.3.1"

3) Compilar (FQBN para Mega 2560: arduino:avr:mega):

# Posicionarse en el directorio que contiene la carpeta servo_hexapod_zigbee/
arduino-cli compile --fqbn arduino:avr:mega servo_hexapod_zigbee

4) Subir al Mega 2560:

# Sustituye /dev/ttyACM0 por el puerto correcto de tu sistema
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:mega servo_hexapod_zigbee

5) Monitor serie (USB) para logs de depuración:

arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200

6) Puente Zigbee (lado PC) con minicom (para enviar comandos al robot):

# Detecta el puerto del adaptador XBee USB, por ejemplo /dev/ttyUSB0
sudo apt-get install -y minicom
minicom -b 115200 -D /dev/ttyUSB0
# Escribe comandos: FWD, BACK, LEFT, RIGHT, STOP, SPEED 600, STEP, CALIB 0 1 20, etc.

Opcional (Python 3.10.12) para transmitir comandos al XBee remoto:

# archivo: send_zigbee.py
import sys, time, serial

port = "/dev/ttyUSB0"  # Cambia según tu sistema
baud = 115200
cmd = "FWD"            # o cualquier comando

if len(sys.argv) > 1:
    cmd = " ".join(sys.argv[1:])

with serial.Serial(port, baud, timeout=1) as ser:
    ser.write((cmd + "\n").encode("ascii"))
    time.sleep(0.1)
    # Leer respuesta del robot (eco por Zigbee)
    while ser.in_waiting:
        print(ser.readline().decode(errors="ignore").rstrip())

Ejecuta:

python3 send_zigbee.py "SPEED 600"
python3 send_zigbee.py "FWD"

Validación paso a paso

1) Verificación de I2C:
– Al abrir el monitor serie (USB), el programa debe imprimir “VL53L0X listo.” si el sensor es detectado. Si no, revisa SDA/SCL y VCC/GND.
– Los servos deberían llevar cada pata a su postura base (home) sin vibraciones notables.

2) Verificación de PCA9685 y servos:
– Debes observar un movimiento suave a posiciones centradas al iniciar.
– Si alguna articulación se mueve en sentido incorrecto, ajusta servoDir[] en el código y vuelve a subir.
– Ajusta offsets con CALIB para centrar mecánicamente:
– Ejemplo: “CALIB 0 1 20” corrige +20 us el fémur de L0.

3) Verificación del XBee (modo transparente):
– Desde minicom en el XBee remoto, envía “STOP”. Debes ver un eco “[HEX] MODE=STOP” tanto en minicom como en el monitor USB del Arduino.
– Envía “SPEED 700” y luego “FWD”. El robot debe iniciar un paso de trípode hacia adelante sin bloqueos.

4) Validación de locomoción:
– Con “FWD” el robot alterna trípodes. Observa que tres patas se elevan mientras las otras tres soportan.
– Cambia a “LEFT” o “RIGHT” y confirma el giro sobre el plano (ajuste lateral con dy).

5) Evitación con VL53L0X:
– Acerca un objeto al sensor frontal. Cuando esté a menos de ~200 mm, el robot debe emitir “[HEX] OBSTACLE xxxmm -> STOP” y volver a home.
– Al retirar el obstáculo, verás “[HEX] PATH CLEAR” y podrás reanudar con “FWD”.

6) Comando “STEP”:
– Ejecuta un semiperiodo de la marcha y vuelve a STOP automáticamente con “STEP DONE”.

7) Estabilidad eléctrica:
– Durante “FWD” sostenido, verifica que la fuente de servos no cae de tensión (ni reinicios del Mega).
– Toca el PCA9685: no debe estar excesivamente caliente. Si lo está, revisa la corriente de los servos y el duty.

8) Revisión de sincronía:
– Modifica “SPEED n” y evalúa la correspondencia entre n y la rapidez del paso. Valores muy bajos (<300 ms) pueden causar movimientos bruscos.

Troubleshooting

1) Servos tiemblan o se resetea el Mega al mover:
– Causa: falta de corriente o caída de tensión en V+ o masa no común.
– Solución: usa una fuente de 6–7.4 V con suficiente amperaje; une GND de fuente y GND del Mega; emplea cables gruesos y cortos.

2) VL53L0X no detectado (“no detectado” al iniciar):
– Causa: SDA/SCL invertidos, falta de VCC, mal módulo (3.3 V-only), nivel I2C incorrecto.
– Solución: en el Mega, SDA=20 y SCL=21; verifica VCC según módulo; prueba a 3.3 V si tu breakout no es 5V tolerant; revisa soldaduras.

3) Un grupo de servos se mueve al revés (pata gira al lado opuesto):
– Causa: inversión lógica no ajustada.
– Solución: cambia el signo en servoDir[ch] correspondiente (1 a -1). Recalibra con CALIB si es necesario.

4) No hay comunicación Zigbee (no respuestas en minicom):
– Causa: PAN ID distinto, baudios no coinciden, no están en misma red (coordinator/router).
– Solución: reconfigura ambos XBee con ATID igual, ATBD7 (115200), uno con ATCE1 (coordinator, lado PC) y el del robot con ATCE0 (router). Guarda con ATWR.

5) Compilación falla por bibliotecas:
– Causa: versiones no instaladas o dependencias faltantes.
– Solución: reinstala exactamente:
– arduino-cli lib install «Adafruit PWM Servo Driver Library@2.4.1»
– arduino-cli lib install «Adafruit BusIO@1.14.5»
– arduino-cli lib install «Adafruit Unified Sensor@1.1.14»
– arduino-cli lib install «Adafruit VL53L0X@1.3.1»

6) Servos no se mueven pero log por USB es correcto:
– Causa: PCA9685 sin V+ de potencia o sin GND común.
– Solución: aplica 6–7.4 V en V+ del PCA9685, conecta GND de la fuente a GND del PCA y del Mega.

7) Movimientos violentos al iniciar:
– Causa: offsets no calibrados o límites de ángulo mal configurados.
– Solución: ajusta servoOffsetUs[] y límites (COXA/FEMUR/TIBIA min/max). Empieza con allLegsToHome() antes de gait.

8) Lecturas erráticas de ToF:
– Causa: superficie brillante, ambiente IR saturado, tiempo de integración corto.
– Solución: promedia lecturas, sube el umbral o filtra por estados. Asegúrate de no tapar el sensor.

Mejoras/variantes

  • Migrar a API mode (AP=2) en XBee:
  • Permite direccionamiento a múltiples nodos, ACK y telemetría estructurada. Usa una librería XBee para frame parsing y añade CRC/timeout.

  • Calibración avanzada:

  • Guardar offsets y parámetros en EEPROM del Mega.
  • Añadir rutina de auto-calibración por guías impresas y un láser de referencia.

  • Gaits adicionales:

  • Ripple gait (estabilidad máxima), wave gait (lento y estable), amble (suavidad).
  • Transiciones suaves entre gaits según terreno.

  • Planificación local:

  • Usar el VL53L0X para mantener distancia objetivo, no solo stop. Integrar dos VL53L0X (frontal y lateral), cambiando dirección I2C de uno con pin XSHUT.

  • Telemetría:

  • Reporte periódico de estado (modo, distancia, velocidad, tensión batería).
  • Mando Zigbee con joystick o GUI en PC.

  • Gestión de energía:

  • Añadir sensor de corriente y voltaje (INA219) para limitar esfuerzos.
  • Modo reposo automático si no hay comando en N segundos.

  • Suavizado de trayectorias:

  • Interpolación spline/coseno para cada DOF, control feedforward de velocidad y aceleración.

  • Seguridad:

  • Watchdog (WDT) en el ATmega2560, kill-switch por comando, y botón físico de emergencia en línea V+.

Checklist de verificación

  • [ ] Arduino Mega 2560 detectado por el sistema (puerto serie visible).
  • [ ] Arduino CLI 0.35.3 instalado y en PATH.
  • [ ] Core arduino:avr@1.8.6 instalado sin errores.
  • [ ] Bibliotecas instaladas con versiones: Adafruit PWM Servo Driver 2.4.1, BusIO 1.14.5, Unified Sensor 1.1.14, VL53L0X 1.3.1.
  • [ ] PCA9685 conectado a SDA(20)/SCL(21), VCC=5 V, GND común; V+ con 6–7.4 V externa.
  • [ ] VL53L0X conectado al mismo bus I2C y detectado (mensaje “VL53L0X listo.”).
  • [ ] XBee S2C en el robot conectado a Serial1 (TX1=18, RX1=19) a 115200 bps vía shield/conversor nivel.
  • [ ] XBee remoto en PC configurado con el mismo PAN ID y baudios; se reciben ecos [HEX] en minicom al enviar comandos.
  • [ ] “STOP” recentra patas; “FWD/BACK/LEFT/RIGHT” mueven con trípode; “SPEED n” ajusta ritmo (300–1200 ms).
  • [ ] Acercar un objeto < 200 mm detiene el robot con mensaje de obstáculo.
  • [ ] Offsets calibrados por pata/DOF con “CALIB leg dof off” para posturas neutras.

Con esta práctica culminada, dispones de un servo-hexápodo controlado por Zigbee con un pipeline de toolchain reproducible (Arduino CLI), sensado ToF VL53L0X e integración estable de PCA9685 para 18 servos en un Arduino Mega 2560. El proyecto es una base sólida para evolucionar hacia control cinemático más avanzado, gaits adaptativos y redes Zigbee multipunto.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es el sistema operativo recomendado para el proyecto?




Pregunta 2: ¿Qué versión de Arduino CLI se debe utilizar?




Pregunta 3: ¿Qué hardware se utiliza como driver de 16 canales servo?




Pregunta 4: ¿Cuál es la dirección por defecto del PCA9685?




Pregunta 5: ¿Qué tipo de sensor es el VL53L0X?




Pregunta 6: ¿Qué tipo de conversor se necesita para conectar el XBee S2C al Arduino Mega 2560?




Pregunta 7: ¿Qué biblioteca de Arduino se necesita para controlar el PCA9685?




Pregunta 8: ¿Qué módulo se utiliza para la comunicación Zigbee?




Pregunta 9: ¿Cuál es la opción de terminal serie recomendada para Linux?




Pregunta 10: ¿Cuántos servos se utilizan en el hexápodo del proyecto?




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

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


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: