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.
Prerequisites
| Item | Details |
|---|---|
| ESP32 Dev Board | Any ESP32 with UART2 free (e.g., ESP32 DevKitC) |
| Cellular Modem | SIM7600, SIM7000G, SIM7070G, or SIM800L |
| Active SIM Card | Data-enabled, nano/micro SIM depending on modem |
| Logic Level Shifter | SIM7600 is 3.3V; SIM7000/SIM800 may need 3.3V→5V conversion |
| Power Supply | At least 2A at 3.8–4.2V for modem; separate from ESP32 3.3V rail |
| HTTP Server | Any server serving a /version endpoint and /firmware.bin |
| Arduino IDE | 2.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;
}
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.");
}
}
}
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| AT commands not responding | Baud rate mismatch or UART wiring wrong | Try 9600/57600/115200; swap TX/RX pins |
| CREG shows "0,2" (searching) | No network coverage or wrong band | Check antenna; set band: AT+CNBANDCFG=1,3 |
| CGACT returns ERROR | Wrong APN or SIM not data-enabled | Confirm APN with your carrier; check AT+CGSOCKCONT? |
| Update.begin() fails | OTA partition not configured in partition table | Use "Huge APP" or custom partition table with OTA_0/OTA_1 |
| Firmware corrupted after download | Binary bytes garbled in UART buffer | Reduce 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
Preferenceslibrary to store OTA check timestamps in NVS across reboots