Remote firmware updates are the cornerstone of any production IoT deployment. When your ESP32 devices are deployed in the field without WiFi — on a vehicle, a remote sensor station, or an industrial controller — you need a reliable way to push new firmware over a cellular connection. This tutorial walks through a complete, production-ready OTA update system using the SIM7600, SIM7000, or SIM7070 4G/LTE modem with raw AT commands and the ESP32's built-in Update library.

// What you'll build: A complete cellular OTA system where your ESP32 checks a remote HTTP server for a newer firmware version, downloads the binary over 4G using AT commands, writes it to the OTA partition with the Update library, and automatically reboots into the new firmware — with rollback on failure.
FULL BUILD DEMO ON YOUTUBE

Watch the complete build on the ESPSTACK YouTube channel →

// AD SLOT — IN-CONTENT RESPONSIVE

Prerequisites

ItemDetails
ESP32 Dev BoardAny ESP32 with UART2 free (e.g., ESP32 DevKitC)
Cellular ModemSIM7600, SIM7000G, SIM7070G, or SIM800L
Active SIM CardData-enabled, nano/micro SIM depending on modem
Logic Level ShifterSIM7600 is 3.3V; SIM7000/SIM800 may need 3.3V→5V conversion
Power SupplyAt least 2A at 3.8–4.2V for modem; separate from ESP32 3.3V rail
HTTP ServerAny server serving a /version endpoint and /firmware.bin
Arduino IDE2.x with ESP32 board package ≥ 2.0.0 by Espressif

Step 1: Hardware Wiring

Connect the SIM7600/SIM7000 modem to the ESP32 UART2 port. The standard wiring for a 3.3V modem (SIM7600) is straightforward — for SIM7000 operating at 5V logic, use a level shifter on TX/RX lines.

// WIRING — SIM7600/SIM7000 → ESP32
// Modem TX  → ESP32 GPIO16 (UART2 RX)
// Modem RX  → ESP32 GPIO17 (UART2 TX)
// Modem GND → ESP32 GND
// Modem VCC → External 4.0V / 3.8V supply (2A capable)
// Modem PWRKEY → ESP32 GPIO4 (optional, for soft power on)
// SIM7600: 3.3V logic — no level shifter needed
// SIM7000: 3.3V logic — no level shifter needed
// SIM800L: 4.0V logic — level shifter recommended

#define MODEM_TX 17   // ESP32 UART2 TX → Modem RX
#define MODEM_RX 16   // ESP32 UART2 RX → Modem TX
#define MODEM_PWRKEY 4
HardwareSerial modemSerial(2);

Step 2: Modem Initialization with AT Commands

Before making any HTTP request, the modem must register on the cellular network and activate a PDP data context. This is done via a sequence of AT commands sent over UART2.

// modem_init() — full AT command initialization
bool modemInit() {
  modemSerial.begin(115200, SERIAL_8N1, MODEM_RX, MODEM_TX);
  delay(3000); // Wait for modem boot

  // Power on if PWRKEY wired
  pinMode(MODEM_PWRKEY, OUTPUT);
  digitalWrite(MODEM_PWRKEY, LOW);
  delay(1000);
  digitalWrite(MODEM_PWRKEY, HIGH);
  delay(3000);

  if (!sendAT("AT", "OK", 3000))        return false;
  if (!sendAT("ATE0", "OK", 1000))      return false; // Echo off
  if (!sendAT("AT+CMEE=2", "OK", 1000)) return false; // Verbose errors
  if (!sendAT("AT+CPIN?", "READY", 5000)) return false; // SIM ready

  // Wait for network registration (AT+CREG: 0,1 or 0,5)
  for (int i = 0; i < 20; i++) {
    String resp = sendATRead("AT+CREG?", 2000);
    if (resp.indexOf(",1") != -1 || resp.indexOf(",5") != -1) break;
    delay(1000);
    if (i == 19) return false; // Network timeout
  }

  // Configure PDP context — replace APN with your carrier's APN
  sendAT("AT+CGDCONT=1,\"IP\",\"internet\"", "OK", 2000);

  // Activate data context (SIM7600/SIM7000)
  if (!sendAT("AT+CGACT=1,1", "OK", 10000)) return false;

  // For SIM7000: use AT+CNACT=0,1 to activate app network
  // sendAT("AT+CNACT=0,1", "OK", 10000);

  Serial.println("Modem initialized and data active.");
  return true;
}

bool sendAT(const char* cmd, const char* expected, uint32_t timeout) {
  modemSerial.println(cmd);
  uint32_t start = millis();
  String response = "";
  while (millis() - start < timeout) {
    while (modemSerial.available()) {
      response += (char)modemSerial.read();
    }
    if (response.indexOf(expected) != -1) return true;
  }
  return false;
}

String sendATRead(const char* cmd, uint32_t timeout) {
  modemSerial.println(cmd);
  uint32_t start = millis();
  String response = "";
  while (millis() - start < timeout) {
    while (modemSerial.available()) {
      response += (char)modemSerial.read();
    }
  }
  return response;
}
// SIM7000 vs SIM7600: SIM7600 uses AT+CGACT=1,1 for PDP activation. SIM7000 uses AT+CNACT=0,1. SIM7070 supports both Cat-M1 and NB-IoT; set band with AT+CBAND="ALL_MODE" before activation.

Step 3: Version Check Before Download

Always check the remote server's current version before downloading the full binary. This saves bandwidth and modem time if the firmware is already up to date.

// version_check.ino — GET /version endpoint
#define SERVER_HOST "your-server.com"
#define SERVER_PORT 80
#define VERSION_PATH "/version"
#define FIRMWARE_PATH "/firmware.bin"
#define CURRENT_VERSION "1.0.0"

String getRemoteVersion() {
  // Open TCP connection
  String cmd = "AT+CIPOPEN=0,\"TCP\",\"";
  cmd += SERVER_HOST;
  cmd += "\",";
  cmd += SERVER_PORT;
  if (!sendAT(cmd.c_str(), "OK", 10000)) return "";

  delay(500);

  // Build HTTP GET request
  String req = "GET " + String(VERSION_PATH) + " HTTP/1.1\r\n";
  req += "Host: " + String(SERVER_HOST) + "\r\n";
  req += "Connection: close\r\n\r\n";

  // Send data (SIM7600 AT+CIPSEND)
  String sendCmd = "AT+CIPSEND=0," + String(req.length());
  modemSerial.println(sendCmd);
  delay(500);
  modemSerial.print(req);

  // Read response
  String response = "";
  uint32_t start = millis();
  while (millis() - start < 10000) {
    while (modemSerial.available()) {
      response += (char)modemSerial.read();
    }
    if (response.indexOf("\r\n\r\n") != -1) break;
  }

  sendAT("AT+CIPCLOSE=0", "OK", 3000);

  // Extract body after headers
  int bodyStart = response.indexOf("\r\n\r\n");
  if (bodyStart == -1) return "";
  return response.substring(bodyStart + 4);
}

bool isNewerVersion(String remote, String current) {
  // Simple semver comparison: compare major.minor.patch
  remote.trim();
  return (remote != current);
}

Step 4: Download Firmware Binary via HTTP GET

With the modem connected, open a TCP socket to your server and stream the binary response directly into the ESP32's OTA partition using Update.write(). The key is to parse and skip the HTTP headers, then pass only the binary body to the Update library.

// ota_download.ino — HTTP GET + Update.write()
#include <Update.h>

bool performOTA() {
  Serial.println("Starting OTA download...");

  // Open TCP
  String openCmd = "AT+CIPOPEN=0,\"TCP\",\"";
  openCmd += SERVER_HOST;
  openCmd += "\",80";
  if (!sendAT(openCmd.c_str(), "OK", 10000)) {
    Serial.println("TCP open failed");
    return false;
  }
  delay(500);

  // HTTP GET for firmware
  String req = "GET " + String(FIRMWARE_PATH) + " HTTP/1.1\r\n";
  req += "Host: " + String(SERVER_HOST) + "\r\n";
  req += "Connection: close\r\n\r\n";

  String sendCmd = "AT+CIPSEND=0," + String(req.length());
  modemSerial.println(sendCmd);
  delay(300);
  modemSerial.print(req);

  // Read HTTP response headers to find Content-Length
  String headers = "";
  uint32_t start = millis();
  while (millis() - start < 15000) {
    while (modemSerial.available()) {
      char c = modemSerial.read();
      headers += c;
      if (headers.endsWith("\r\n\r\n")) goto headers_done;
    }
  }
  headers_done:

  // Parse Content-Length
  int clIdx = headers.indexOf("Content-Length:");
  if (clIdx == -1) { sendAT("AT+CIPCLOSE=0","OK",2000); return false; }
  int clEnd = headers.indexOf("\r\n", clIdx);
  String clStr = headers.substring(clIdx + 15, clEnd);
  clStr.trim();
  size_t fileSize = clStr.toInt();
  Serial.printf("Firmware size: %u bytes\n", fileSize);

  if (!Update.begin(fileSize)) {
    Serial.printf("Update.begin failed: %s\n", Update.errorString());
    sendAT("AT+CIPCLOSE=0","OK",2000);
    return false;
  }

  // Stream binary body into Update
  uint8_t buf[512];
  size_t written = 0;
  start = millis();

  while (written < fileSize && millis() - start < 60000) {
    int available = modemSerial.available();
    if (available > 0) {
      int toRead = min((int)sizeof(buf), min(available, (int)(fileSize - written)));
      int bytesRead = modemSerial.readBytes(buf, toRead);
      size_t w = Update.write(buf, bytesRead);
      written += w;
      Serial.printf("Progress: %u / %u bytes\r", written, fileSize);
    }
  }

  sendAT("AT+CIPCLOSE=0","OK",2000);

  if (!Update.end()) {
    Serial.printf("Update.end failed: %s\n", Update.errorString());
    return false;
  }

  if (!Update.isFinished()) {
    Serial.println("Update not finished — possible corruption.");
    return false;
  }

  Serial.println("\nOTA complete! Rebooting...");
  ESP.restart();
  return true;
}

Step 5: Error Handling and Rollback

A robust OTA implementation must handle errors gracefully. If the new firmware fails to boot, the ESP32 should automatically roll back to the previous firmware. Use esp_ota_mark_app_invalid_rollback_and_reboot() in your startup validation function.

// rollback.ino — validation + automatic rollback
#include <esp_ota_ops.h>

void validateFirmware() {
  const esp_partition_t* running = esp_ota_get_running_partition();
  esp_ota_img_states_t otaState;

  if (esp_ota_get_state_partition(running, &otaState) == ESP_OK) {
    if (otaState == ESP_OTA_IMG_PENDING_VERIFY) {
      // Run self-tests here (sensor check, network ping, etc.)
      bool selfTestOk = runSelfTest();
      if (selfTestOk) {
        esp_ota_mark_app_valid_cancel_rollback();
        Serial.println("Firmware validated. Rollback cancelled.");
      } else {
        Serial.println("Self-test FAILED. Rolling back...");
        esp_ota_mark_app_invalid_rollback_and_reboot();
      }
    }
  }
}

bool runSelfTest() {
  // Check WiFi or modem connectivity
  // Check sensors initialized
  // Return false to trigger rollback
  return true; // Replace with real checks
}

void handleOTAErrors() {
  int errorCode = Update.getError();
  switch (errorCode) {
    case UPDATE_ERROR_AUTHENTICATE: Serial.println("OTA_AUTH_ERROR: MD5 mismatch"); break;
    case UPDATE_ERROR_NO_PARTITION: Serial.println("OTA_BEGIN_ERROR: No OTA partition"); break;
    case UPDATE_ERROR_SPACE:        Serial.println("OTA_SPACE_ERROR: Binary too large"); break;
    case UPDATE_ERROR_WRITE:        Serial.println("OTA_WRITE_ERROR: Flash write failed"); break;
    default: Serial.printf("OTA error code: %d\n", errorCode); break;
  }
}

Step 6: Main Loop — Periodic OTA Check

// main.ino — complete OTA check loop
#include <Arduino.h>
#include <Update.h>
#include <esp_ota_ops.h>

#define OTA_CHECK_INTERVAL 3600000UL // Check every 1 hour

unsigned long lastOTACheck = 0;

void setup() {
  Serial.begin(115200);
  validateFirmware(); // Check if new firmware needs validation

  if (!modemInit()) {
    Serial.println("Modem init failed! Halting OTA service.");
  }
  lastOTACheck = millis();
}

void loop() {
  // Your normal application code here

  // Periodic OTA check
  if (millis() - lastOTACheck > OTA_CHECK_INTERVAL) {
    lastOTACheck = millis();
    Serial.println("Checking for firmware update...");

    String remoteVer = getRemoteVersion();
    Serial.printf("Remote: %s | Current: %s\n", remoteVer.c_str(), CURRENT_VERSION);

    if (remoteVer.length() > 0 && isNewerVersion(remoteVer, CURRENT_VERSION)) {
      Serial.println("New firmware available! Starting OTA...");
      performOTA();
    } else {
      Serial.println("Firmware is up to date.");
    }
  }
}
// AD SLOT — MID CONTENT

Troubleshooting

ProblemCauseFix
AT commands not respondingBaud rate mismatch or UART wiring wrongTry 9600/57600/115200; swap TX/RX pins
CREG shows "0,2" (searching)No network coverage or wrong bandCheck antenna; set band: AT+CNBANDCFG=1,3
CGACT returns ERRORWrong APN or SIM not data-enabledConfirm APN with your carrier; check AT+CGSOCKCONT?
Update.begin() failsOTA partition not configured in partition tableUse "Huge APP" or custom partition table with OTA_0/OTA_1
Firmware corrupted after downloadBinary bytes garbled in UART bufferReduce UART baud to 57600; increase buffer size with modemSerial.setRxBufferSize(4096)

Next Steps

  • Add HTTPS/TLS to secure the download — see our ESP32 OTA via 4G with SSL post
  • Build a web dashboard to manage firmware versions and trigger remote updates
  • Implement differential/delta OTA to reduce download size
  • Add MQTT reporting of OTA status back to a cloud broker
  • Use the Preferences library to store OTA check timestamps in NVS across reboots