OTA Firmware Updates
Firmware vulnerabilities discovered after deployment can only be fixed through OTA (Over-The-Air) updates. Planning OTA from day one is not optional — it is the only way to maintain a secure, functional IoT fleet without physically touching every device.
Why OTA Matters
Without OTA:
Device deployed → Vulnerability discovered → No fix possible
→ Every device in the field is permanently vulnerable
→ Only fix: physical recall (expensive, often impossible)
With OTA:
Device deployed → Vulnerability discovered → Patch developed
→ Staged rollout to fleet → All devices patched in hours
→ Security maintained throughout device lifecycleESP32 OTA — Arduino Framework
The Arduino framework provides built-in OTA via the Update class and the ArduinoOTA library.
Basic HTTP OTA
cpp
// Arduino ESP32 — Pull-based OTA (device checks for updates)
#include <WiFi.h>
#include <HTTPClient.h>
#include <Update.h>
#include <ArduinoJson.h>
const char* FIRMWARE_SERVER = "https://firmware.bigxstar.com";
const char* DEVICE_ID = "esp32-001";
const char* CURRENT_VERSION = "1.4.2"; // Hardcoded at compile time
struct FirmwareInfo {
String version;
String url;
String sha256;
bool available = false;
};
FirmwareInfo checkForUpdate() {
FirmwareInfo info;
HTTPClient http;
String url = String(FIRMWARE_SERVER) + "/manifest.json?device=" + DEVICE_ID;
http.begin(url);
int code = http.GET();
if (code == 200) {
StaticJsonDocument<512> doc;
deserializeJson(doc, http.getString());
String latest = doc["version"].as<String>();
if (latest != CURRENT_VERSION) {
info.version = latest;
info.url = doc["url"].as<String>();
info.sha256 = doc["sha256"].as<String>();
info.available = true;
}
}
http.end();
return info;
}
bool performUpdate(String firmwareUrl) {
HTTPClient http;
http.begin(firmwareUrl);
http.addHeader("X-Device-ID", DEVICE_ID);
http.addHeader("X-Current-Version", CURRENT_VERSION);
int code = http.GET();
if (code != 200) {
Serial.printf("Firmware fetch failed: %d\n", code);
http.end();
return false;
}
int contentLength = http.getSize();
WiFiClient* stream = http.getStreamPtr();
Serial.printf("Updating: %d bytes\n", contentLength);
if (!Update.begin(contentLength)) {
Serial.printf("Update.begin failed: %s\n", Update.errorString());
return false;
}
size_t written = Update.writeStream(*stream);
http.end();
if (written != contentLength) {
Serial.printf("Written %d/%d bytes\n", written, contentLength);
Update.abort();
return false;
}
if (!Update.end()) {
Serial.printf("Update.end failed: %s\n", Update.errorString());
return false;
}
Serial.println("Update complete — restarting");
delay(1000);
ESP.restart();
return true; // Never reached
}
void setup() {
Serial.begin(115200);
WiFi.begin("MySSID", "MyPassword");
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.printf("Firmware v%s running\n", CURRENT_VERSION);
FirmwareInfo update = checkForUpdate();
if (update.available) {
Serial.printf("Update available: v%s\n", update.version.c_str());
performUpdate(update.url);
} else {
Serial.println("Firmware is up to date");
}
}
void loop() {
// Check for updates every 6 hours
static unsigned long lastCheck = 0;
if (millis() - lastCheck > 6UL * 3600 * 1000) {
lastCheck = millis();
FirmwareInfo update = checkForUpdate();
if (update.available) performUpdate(update.url);
}
// ... main application code
}MicroPython OTA
python
# ota.py — MicroPython OTA updater
import urequests
import uhashlib
import ubinascii
import os
import machine
FIRMWARE_SERVER = "https://firmware.bigxstar.com"
CURRENT_VERSION = "1.4.2"
DEVICE_ID = "esp32-node-001"
def check_update():
"""Check if a newer firmware is available."""
url = f"{FIRMWARE_SERVER}/manifest.json"
try:
r = urequests.get(url, timeout=10)
manifest = r.json()
r.close()
latest = manifest.get("version")
if latest and latest != CURRENT_VERSION:
return manifest
except Exception as e:
print(f"Update check failed: {e}")
return None
def download_file(url, dest_path, expected_sha256=None):
"""Download a file and optionally verify its SHA256 hash."""
print(f"Downloading {url} → {dest_path}")
tmp_path = dest_path + ".tmp"
try:
r = urequests.get(url, timeout=30)
if r.status_code != 200:
print(f"Download failed: HTTP {r.status_code}")
return False
# Stream to file
hasher = uhashlib.sha256()
with open(tmp_path, 'wb') as f:
chunk_size = 4096
data = r.content # MicroPython: read all at once (memory permitting)
f.write(data)
hasher.update(data)
r.close()
# Verify hash
if expected_sha256:
actual_hash = ubinascii.hexlify(hasher.digest()).decode()
if actual_hash.lower() != expected_sha256.lower():
print(f"Hash mismatch! Expected {expected_sha256}, got {actual_hash}")
os.remove(tmp_path)
return False
print(f"Hash verified: {actual_hash}")
# Atomic replace
try:
os.remove(dest_path)
except:
pass
os.rename(tmp_path, dest_path)
return True
except Exception as e:
print(f"Download error: {e}")
try: os.remove(tmp_path)
except: pass
return False
def apply_update(manifest):
"""Download and apply new firmware files."""
print(f"Applying update to v{manifest['version']}")
files = manifest.get("files", [])
downloaded = []
for file_info in files:
url = FIRMWARE_SERVER + "/" + file_info["path"]
dest = "/" + file_info["path"]
sha = file_info.get("sha256")
if download_file(url, dest, sha):
downloaded.append(dest)
print(f"Updated: {dest}")
else:
print(f"FAILED: {dest}")
# Cleanup downloaded files
for f in downloaded:
try: os.remove(f)
except: pass
return False
# Write new version marker
with open('/version.txt', 'w') as f:
f.write(manifest['version'])
print(f"Update to v{manifest['version']} complete. Rebooting...")
machine.reset()
def check_and_update():
"""Main OTA entry point — call from boot.py."""
manifest = check_update()
if manifest:
print(f"Update available: v{manifest['version']}")
apply_update(manifest)
else:
print(f"Firmware v{CURRENT_VERSION} is current")Firmware Manifest Server (Python / FastAPI)
python
# firmware_server.py — serve firmware manifests and binaries
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import FileResponse
import hashlib, os, json
from pathlib import Path
app = FastAPI()
FIRMWARE_DIR = Path("./firmware")
FIRMWARE_DIR.mkdir(exist_ok=True)
def sha256_file(path):
h = hashlib.sha256()
with open(path, 'rb') as f:
while chunk := f.read(8192):
h.update(chunk)
return h.hexdigest()
@app.get("/manifest.json")
async def get_manifest(
x_device_id: str = Header(None),
x_current_version: str = Header(None)
):
"""Return the latest firmware manifest."""
latest_version = "1.5.0"
firmware_file = FIRMWARE_DIR / f"esp32_firmware_{latest_version}.bin"
if not firmware_file.exists():
return {"version": latest_version, "up_to_date": True}
if x_current_version == latest_version:
return {"version": latest_version, "up_to_date": True}
return {
"version": latest_version,
"url": f"/firmware/esp32_firmware_{latest_version}.bin",
"sha256": sha256_file(firmware_file),
"size": firmware_file.stat().st_size,
"mandatory": False, # Set True to force update
"changelog": "Bug fixes and security patches"
}
@app.get("/firmware/{filename}")
async def download_firmware(filename: str, x_device_id: str = Header(None)):
"""Serve firmware binary with logging."""
path = FIRMWARE_DIR / filename
if not path.exists() or not path.is_relative_to(FIRMWARE_DIR):
raise HTTPException(404)
print(f"Firmware download: {filename} by device={x_device_id}")
return FileResponse(path, media_type="application/octet-stream")Staged Rollout
python
# rollout_manager.py — gradual fleet update rollout
import json
import random
from datetime import datetime
class RolloutManager:
"""
Staged firmware rollout with automatic rollback on error rate.
Rollout stages: 1% → 10% → 50% → 100%
"""
def __init__(self):
self.stages = [
{"percentage": 1, "min_hours": 1, "max_error_rate": 0.10},
{"percentage": 10, "min_hours": 4, "max_error_rate": 0.05},
{"percentage": 50, "min_hours": 12, "max_error_rate": 0.02},
{"percentage": 100, "min_hours": 24, "max_error_rate": 0.01},
]
self.current_stage = 0
self.deployed = {} # device_id → version
self.errors = {} # version → error count
def should_update(self, device_id, device_type="default"):
"""Decide whether this device should receive the update now."""
stage = self.stages[self.current_stage]
# Deterministic but spread: hash device_id to get a percentage
bucket = int(hashlib.md5(device_id.encode()).hexdigest(), 16) % 100
return bucket < stage["percentage"]
def record_update_result(self, device_id, version, success):
if version not in self.errors:
self.errors[version] = {"total": 0, "failures": 0}
self.errors[version]["total"] += 1
if not success:
self.errors[version]["failures"] += 1
error_rate = self.errors[version]["failures"] / self.errors[version]["total"]
stage = self.stages[self.current_stage]
if error_rate > stage["max_error_rate"] and self.errors[version]["total"] >= 10:
print(f"ROLLBACK! Error rate {error_rate:.1%} exceeds threshold "
f"{stage['max_error_rate']:.0%}")
self.current_stage = max(0, self.current_stage - 1)
else:
print(f"Update v{version}: {self.errors[version]['total']} devices, "
f"{error_rate:.1%} error rate")
import hashlib
mgr = RolloutManager()
devices = [f"esp32-{i:04d}" for i in range(1000)]
for device in devices:
if mgr.should_update(device):
success = random.random() > 0.02 # 2% failure rate
mgr.record_update_result(device, "1.5.0", success)Raspberry Pi OTA (systemd)
bash
#!/bin/bash
# update.sh — deployed on Raspberry Pi via cron or systemd timer
CURRENT=$(cat /etc/app/version.txt)
MANIFEST=$(curl -sf https://firmware.bigxstar.com/pi/manifest.json)
LATEST=$(echo "$MANIFEST" | python3 -c "import json,sys; print(json.load(sys.stdin)['version'])")
if [ "$CURRENT" = "$LATEST" ]; then
echo "Already on v$LATEST"
exit 0
fi
echo "Updating from v$CURRENT to v$LATEST"
URL=$(echo "$MANIFEST" | python3 -c "import json,sys; print(json.load(sys.stdin)['url'])")
# Download to temp
curl -sf "$URL" -o /tmp/app_update.tar.gz
# Verify hash
EXPECTED=$(echo "$MANIFEST" | python3 -c "import json,sys; print(json.load(sys.stdin)['sha256'])")
ACTUAL=$(sha256sum /tmp/app_update.tar.gz | cut -d' ' -f1)
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Hash mismatch! Aborting update."
exit 1
fi
# Stop app, update, restart
sudo systemctl stop iot-app
tar -xzf /tmp/app_update.tar.gz -C /opt/iot-app
echo "$LATEST" > /etc/app/version.txt
sudo systemctl start iot-app
echo "Updated to v$LATEST successfully"Summary
- OTA updates are non-negotiable for production IoT — plan the update mechanism before first deployment
- Always verify firmware integrity with SHA256 before applying any update
- Use atomic updates (write to temp file, then rename) to prevent corruption on power loss mid-download
- Staged rollouts (1% → 10% → 50% → 100%) catch regressions before they affect the whole fleet
- Set an automatic rollback threshold — if error rates exceed N% during rollout, revert the staged percentage
- Raspberry Pi updates use standard Linux tooling (apt, systemd, shell scripts); ESP32 uses the
Updateclass orurequests
Next, we'll look at production deployment — scaling your IoT system from 10 devices to 10,000.