HALO OTA AI Integration Guide

HALO OTA Integration Guide for AI Coding Assistants

Purpose: This document provides step-by-step instructions for an AI coding assistant to integrate OTA (Over-The-Air) update functionality into existing HALO firmware codebases.

Target Audience: AI coding assistants (Claude, GPT-4, Copilot, etc.)

Last Updated: February 7, 2026


Table of Contents

  1. Overview
  2. Prerequisites
  3. File Structure to Add
  4. Core OTA Files
  5. Integration Steps for Sense Board
  6. Integration Steps for LCD Board
  7. Configuration
  8. Integration Points
  9. Testing the Integration
  10. Common Pitfalls

1. Overview

What This Integration Adds

The Two HALO Boards

Board Chip OTA Binary Size Manifest Location
Sense XIAO ESP32-S3 ~1.4 MB /halo/ota/{env}/manifest_latest.json
LCD ESP32-S3 ~3.9 MB /halo/ota/{env}/lcd/manifest_latest.json

OTA Flow Summary

1. Device boots/wakes
2. Connects to WiFi
3. Syncs time (required for TLS)
4. Fetches manifest from S3 (with retries)
5. Compares manifest version vs current version
6. If newer: downloads binary (with retries)
7. Writes to OTA partition in chunks
8. Sets boot partition and reboots
9. New firmware marks itself valid after successful boot

2. Prerequisites

Required Libraries

Add these to the project’s platformio.ini or ensure they’re available:

lib_deps =
    bblanchon/ArduinoJson@^7.0.0
    # WiFi and HTTP are part of ESP32 Arduino core

Required Includes

The OTA code requires these ESP-IDF headers (included with ESP32 Arduino):

#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_system.h>
#include <time.h>

Partition Table Requirements

The device MUST have an OTA-capable partition scheme:

For Sense (XIAO ESP32-S3): - Use default partition scheme - OTA partition size: ~1.9 MB - FQBN: esp32:esp32:XIAO_ESP32S3:USBMode=hwcdc,CDCOnBoot=default

For LCD (ESP32-S3 16MB): - Use default_8MB partition scheme - OTA partition size: ~3.9 MB
- FQBN: esp32:esp32:esp32s3:FlashSize=16M,PartitionScheme=default_8MB,PSRAM=opi,CDCOnBoot=cdc


3. File Structure to Add

Create the following files in your project:

your_firmware/
├── your_main.ino              # Existing main sketch
├── ota/
│   ├── ota_config.h           # WiFi credentials and S3 URLs
│   ├── ota_core.h             # OTA function declarations
│   └── ota_core.cpp           # OTA implementation
└── version.h                  # Firmware version definition

4. Core OTA Files

4.1 version.h

#ifndef VERSION_H
#define VERSION_H

// INCREMENT THIS VERSION FOR EACH RELEASE
// Format: MAJOR.MINOR.PATCH
#define FIRMWARE_VERSION "1.0.0"

// Board identifier - must match manifest "board" field (if used)
// Use "sense" for Sense board, "lcd" for LCD board
#define BOARD_NAME "sense"  // or "lcd"

#endif // VERSION_H

4.2 ota/ota_config.h

#ifndef OTA_CONFIG_H
#define OTA_CONFIG_H

//=============================================================================
// WiFi Configuration
//=============================================================================

// Primary WiFi credentials (loaded from NVS if provisioned)
// These are fallbacks for testing
#define WIFI_SSID_DEFAULT     "YourWiFiSSID"
#define WIFI_PASSWORD_DEFAULT "YourWiFiPassword"
#define WIFI_TIMEOUT_MS       30000

//=============================================================================
// OTA S3 Configuration
//=============================================================================

#define OTA_BUCKET     "halo-ota-dev"
#define OTA_REGION     "us-east-1"
#define OTA_ENV        "dev"  // "dev", "staging", or "prod"

// Manifest URLs (computed from above)
#define OTA_BASE_URL "https://" OTA_BUCKET ".s3." OTA_REGION ".amazonaws.com/halo/ota/" OTA_ENV

// Board-specific manifest URLs
#define SENSE_MANIFEST_URL OTA_BASE_URL "/manifest_latest.json"
#define LCD_MANIFEST_URL   OTA_BASE_URL "/lcd/manifest_latest.json"

// Select correct manifest based on board
#if defined(BOARD_NAME) && strcmp(BOARD_NAME, "lcd") == 0
  #define OTA_MANIFEST_URL LCD_MANIFEST_URL
#else
  #define OTA_MANIFEST_URL SENSE_MANIFEST_URL
#endif

//=============================================================================
// OTA Behavior Configuration  
//=============================================================================

// Retry configuration
#define OTA_MANIFEST_RETRIES    3
#define OTA_DOWNLOAD_RETRIES    3
#define OTA_RETRY_DELAY_MS      5000

// Timeout configuration
#define OTA_HTTP_CONNECT_TIMEOUT_MS   60000   // 1 minute
#define OTA_HTTP_READ_TIMEOUT_MS      300000  // 5 minutes
#define OTA_NO_DATA_TIMEOUT_MS        90000   // 90 seconds

// Download buffer size (adjust based on free heap)
#define OTA_DOWNLOAD_BUFFER_SIZE 2048

//=============================================================================
// Time Sync (Required for TLS)
//=============================================================================

#define NTP_SERVER "pool.ntp.org"

//=============================================================================
// Amazon Root CA Certificate
//=============================================================================

const char OTA_ROOT_CA[] PROGMEM = R"(
-----BEGIN CERTIFICATE-----
MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF
ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6
b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL
MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv
b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj
ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM
9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw
IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6
VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L
93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm
jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA
A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI
U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs
N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv
o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU
5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy
rqXRfboQnoZsG4q5WTP468SQvvG5
-----END CERTIFICATE-----
)";

#endif // OTA_CONFIG_H

4.3 ota/ota_core.h

#ifndef OTA_CORE_H
#define OTA_CORE_H

#include <Arduino.h>

//=============================================================================
// OTA Result Codes
//=============================================================================

enum class OtaResult {
    SUCCESS,              // Update applied successfully
    ALREADY_UP_TO_DATE,   // No update needed
    WIFI_FAILED,          // WiFi connection failed
    TIME_SYNC_FAILED,     // NTP sync failed
    MANIFEST_FAILED,      // Could not fetch manifest
    VERSION_PARSE_ERROR,  // Invalid version in manifest
    DOWNLOAD_FAILED,      // Binary download failed
    PARTITION_ERROR,      // OTA partition error
    WRITE_FAILED,         // Failed to write to partition
    VERIFY_FAILED,        // Verification failed
    REBOOT_REQUIRED       // Success - reboot pending
};

//=============================================================================
// OTA Manifest Structure
//=============================================================================

struct OtaManifest {
    char version[32];
    char url[256];
    char sha256[65];
    size_t size;
    bool valid;
};

//=============================================================================
// Public Functions
//=============================================================================

/**
 * Initialize OTA subsystem.
 * Call once in setup() after WiFi is potentially available.
 */
void ota_init();

/**
 * Check for and apply OTA updates.
 * 
 * This function:
 * 1. Ensures WiFi is connected
 * 2. Syncs time if needed
 * 3. Fetches manifest from S3
 * 4. Compares versions
 * 5. Downloads and applies update if available
 * 
 * @param force  If true, bypass cooldowns and version checks
 * @return OtaResult indicating what happened
 */
OtaResult ota_check_and_apply(bool force = false);

/**
 * Check for updates without applying.
 * Useful for checking if an update is available without downloading.
 * 
 * @param manifest  Output: populated with manifest data if found
 * @return true if update is available, false otherwise
 */
bool ota_check_available(OtaManifest& manifest);

/**
 * Get current firmware version.
 */
const char* ota_get_current_version();

/**
 * Compare two semantic versions.
 * @return negative if v1 < v2, 0 if equal, positive if v1 > v2
 */
int ota_compare_versions(const char* v1, const char* v2);

/**
 * Mark current firmware as valid.
 * Call this after successful boot to prevent rollback.
 */
void ota_mark_valid();

/**
 * Check if WiFi is connected, connect if not.
 * Uses credentials from NVS or defaults.
 */
bool ota_ensure_wifi();

/**
 * Sync system time via NTP.
 * Required for TLS certificate validation.
 */
bool ota_sync_time();

#endif // OTA_CORE_H

4.4 ota/ota_core.cpp

#include "ota_core.h"
#include "ota_config.h"
#include "../version.h"

#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_system.h>
#include <time.h>
#include <Preferences.h>

//=============================================================================
// Private Variables
//=============================================================================

static bool g_time_synced = false;
static bool g_ota_initialized = false;

//=============================================================================
// Logging Macros (customize for your logging system)
//=============================================================================

#define OTA_LOG(fmt, ...) Serial.printf("[OTA] " fmt "\n", ##__VA_ARGS__)
#define OTA_LOG_ERROR(fmt, ...) Serial.printf("[OTA][ERROR] " fmt "\n", ##__VA_ARGS__)
#define OTA_LOG_WARN(fmt, ...) Serial.printf("[OTA][WARN] " fmt "\n", ##__VA_ARGS__)

//=============================================================================
// Implementation
//=============================================================================

void ota_init() {
    if (g_ota_initialized) return;
    
    // Log partition info
    const esp_partition_t* running = esp_ota_get_running_partition();
    const esp_partition_t* boot = esp_ota_get_boot_partition();
    
    OTA_LOG("Initialized - version=%s board=%s", FIRMWARE_VERSION, BOARD_NAME);
    OTA_LOG("Partitions: running=%s boot=%s", 
            running ? running->label : "?",
            boot ? boot->label : "?");
    
    g_ota_initialized = true;
}

const char* ota_get_current_version() {
    return FIRMWARE_VERSION;
}

int ota_compare_versions(const char* v1, const char* v2) {
    int major1 = 0, minor1 = 0, patch1 = 0;
    int major2 = 0, minor2 = 0, patch2 = 0;
    
    sscanf(v1, "%d.%d.%d", &major1, &minor1, &patch1);
    sscanf(v2, "%d.%d.%d", &major2, &minor2, &patch2);
    
    if (major1 != major2) return major1 - major2;
    if (minor1 != minor2) return minor1 - minor2;
    return patch1 - patch2;
}

void ota_mark_valid() {
    esp_ota_mark_app_valid_cancel_rollback();
    OTA_LOG("Firmware marked as valid");
}

//=============================================================================
// WiFi Management
//=============================================================================

bool ota_ensure_wifi() {
    if (WiFi.status() == WL_CONNECTED) {
        return true;
    }
    
    OTA_LOG("Connecting to WiFi...");
    
    // Try to get credentials from NVS first
    Preferences prefs;
    prefs.begin("wifi", true);  // read-only
    String ssid = prefs.getString("ssid", WIFI_SSID_DEFAULT);
    String password = prefs.getString("password", WIFI_PASSWORD_DEFAULT);
    prefs.end();
    
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid.c_str(), password.c_str());
    
    unsigned long startTime = millis();
    while (WiFi.status() != WL_CONNECTED) {
        if (millis() - startTime > WIFI_TIMEOUT_MS) {
            OTA_LOG_ERROR("WiFi connection timeout");
            return false;
        }
        delay(500);
        Serial.print(".");
    }
    Serial.println();
    
    OTA_LOG("WiFi connected - IP: %s, RSSI: %d dBm", 
            WiFi.localIP().toString().c_str(), WiFi.RSSI());
    return true;
}

//=============================================================================
// Time Sync
//=============================================================================

bool ota_sync_time() {
    if (g_time_synced) {
        // Verify time is still valid
        time_t now = time(nullptr);
        if (now > 1700000000) {  // After 2023
            return true;
        }
    }
    
    OTA_LOG("Syncing time via NTP...");
    configTime(0, 0, NTP_SERVER);
    
    time_t now = 0;
    struct tm timeinfo = {0};
    int retries = 0;
    
    while (timeinfo.tm_year < (2024 - 1900) && retries < 15) {
        delay(1000);
        time(&now);
        localtime_r(&now, &timeinfo);
        retries++;
    }
    
    if (timeinfo.tm_year < (2024 - 1900)) {
        OTA_LOG_ERROR("NTP sync failed after %d attempts", retries);
        return false;
    }
    
    char timeStr[64];
    strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S UTC", &timeinfo);
    OTA_LOG("Time synced: %s", timeStr);
    
    g_time_synced = true;
    return true;
}

//=============================================================================
// Manifest Fetching
//=============================================================================

static bool fetch_manifest_internal(OtaManifest& manifest) {
    OTA_LOG("Fetching manifest: %s", OTA_MANIFEST_URL);
    
    for (int attempt = 1; attempt <= OTA_MANIFEST_RETRIES; attempt++) {
        OTA_LOG("Manifest attempt %d/%d...", attempt, OTA_MANIFEST_RETRIES);
        
        WiFiClientSecure client;
        client.setCACert(OTA_ROOT_CA);
        client.setTimeout(30);
        
        HTTPClient http;
        if (!http.begin(client, OTA_MANIFEST_URL)) {
            OTA_LOG_ERROR("HTTP begin failed");
            delay(OTA_RETRY_DELAY_MS);
            continue;
        }
        
        http.setConnectTimeout(OTA_HTTP_CONNECT_TIMEOUT_MS);
        http.setTimeout(OTA_HTTP_READ_TIMEOUT_MS);
        
        int httpCode = http.GET();
        if (httpCode != 200) {
            OTA_LOG_ERROR("HTTP GET failed: %d", httpCode);
            http.end();
            delay(OTA_RETRY_DELAY_MS);
            continue;
        }
        
        String payload = http.getString();
        http.end();
        
        // Parse JSON
        JsonDocument doc;
        DeserializationError error = deserializeJson(doc, payload);
        if (error) {
            OTA_LOG_ERROR("JSON parse error: %s", error.c_str());
            delay(OTA_RETRY_DELAY_MS);
            continue;
        }
        
        // Extract fields
        const char* version = doc["version"] | "";
        const char* url = doc["url"] | doc["bin_url"] | "";
        const char* sha256 = doc["sha256"] | "";
        size_t size = doc["size"] | 0;
        
        if (strlen(version) == 0 || strlen(url) == 0) {
            OTA_LOG_ERROR("Invalid manifest (missing version/url)");
            delay(OTA_RETRY_DELAY_MS);
            continue;
        }
        
        // Populate manifest
        strncpy(manifest.version, version, sizeof(manifest.version) - 1);
        strncpy(manifest.url, url, sizeof(manifest.url) - 1);
        strncpy(manifest.sha256, sha256, sizeof(manifest.sha256) - 1);
        manifest.size = size;
        manifest.valid = true;
        
        OTA_LOG("Manifest: version=%s, size=%zu", manifest.version, manifest.size);
        return true;
    }
    
    OTA_LOG_ERROR("All manifest attempts failed");
    return false;
}

bool ota_check_available(OtaManifest& manifest) {
    manifest.valid = false;
    
    if (!fetch_manifest_internal(manifest)) {
        return false;
    }
    
    int cmp = ota_compare_versions(manifest.version, FIRMWARE_VERSION);
    OTA_LOG("Version check: current=%s, available=%s, cmp=%d",
            FIRMWARE_VERSION, manifest.version, cmp);
    
    return cmp > 0;  // Update available if manifest > current
}

//=============================================================================
// Binary Download and Apply
//=============================================================================

static bool apply_ota_internal(const OtaManifest& manifest) {
    OTA_LOG("Downloading: %s", manifest.url);
    OTA_LOG("Expected size: %zu bytes", manifest.size);
    
    // Get target partition
    const esp_partition_t* update_partition = esp_ota_get_next_update_partition(NULL);
    if (!update_partition) {
        OTA_LOG_ERROR("No OTA partition available");
        return false;
    }
    
    OTA_LOG("Target partition: %s (0x%06x, %u bytes)",
            update_partition->label, 
            update_partition->address, 
            update_partition->size);
    
    if (manifest.size > 0 && manifest.size > update_partition->size) {
        OTA_LOG_ERROR("Firmware too large: %zu > %u", 
                      manifest.size, update_partition->size);
        return false;
    }
    
    // Begin OTA
    esp_ota_handle_t ota_handle;
    size_t ota_size = manifest.size > 0 ? manifest.size : OTA_SIZE_UNKNOWN;
    esp_err_t err = esp_ota_begin(update_partition, ota_size, &ota_handle);
    if (err != ESP_OK) {
        OTA_LOG_ERROR("esp_ota_begin failed: %s", esp_err_to_name(err));
        return false;
    }
    
    OTA_LOG("OTA begin OK, free heap: %lu", (unsigned long)ESP.getFreeHeap());
    
    // Setup HTTP client
    WiFiClientSecure client;
    client.setCACert(OTA_ROOT_CA);
    client.setTimeout(60);
    
    HTTPClient http;
    if (!http.begin(client, manifest.url)) {
        OTA_LOG_ERROR("HTTP begin failed");
        esp_ota_abort(ota_handle);
        return false;
    }
    
    http.setConnectTimeout(OTA_HTTP_CONNECT_TIMEOUT_MS);
    http.setTimeout(OTA_HTTP_READ_TIMEOUT_MS);
    
    int httpCode = http.GET();
    if (httpCode != 200) {
        OTA_LOG_ERROR("HTTP GET failed: %d", httpCode);
        http.end();
        esp_ota_abort(ota_handle);
        return false;
    }
    
    int contentLength = http.getSize();
    OTA_LOG("Content length: %d bytes", contentLength);
    
    // Download and write in chunks
    WiFiClient* stream = http.getStreamPtr();
    uint8_t buff[OTA_DOWNLOAD_BUFFER_SIZE];
    size_t written = 0;
    int lastPct = -1;
    unsigned long lastDataTime = millis();
    
    while (written < (size_t)contentLength) {
        // Check WiFi
        if (WiFi.status() != WL_CONNECTED) {
            OTA_LOG_ERROR("WiFi disconnected during download");
            break;
        }
        
        // Check for data timeout
        size_t available = stream->available();
        if (available == 0) {
            if (millis() - lastDataTime > OTA_NO_DATA_TIMEOUT_MS) {
                OTA_LOG_ERROR("Download timeout - no data for %lu ms", 
                              OTA_NO_DATA_TIMEOUT_MS);
                break;
            }
            delay(10);
            continue;
        }
        lastDataTime = millis();
        
        // Read and write chunk
        size_t toRead = min(available, sizeof(buff));
        size_t bytesRead = stream->readBytes(buff, toRead);
        
        if (bytesRead > 0) {
            err = esp_ota_write(ota_handle, buff, bytesRead);
            if (err != ESP_OK) {
                OTA_LOG_ERROR("esp_ota_write failed: %s", esp_err_to_name(err));
                break;
            }
            written += bytesRead;
            
            // Progress report every 10%
            int pct = (written * 100) / contentLength;
            if (pct / 10 > lastPct / 10) {
                OTA_LOG("Progress: %d%% (%zu / %d bytes)", pct, written, contentLength);
                lastPct = pct;
            }
        }
    }
    
    http.end();
    
    // Verify complete download
    if (written != (size_t)contentLength) {
        OTA_LOG_ERROR("Incomplete download: %zu / %d bytes", written, contentLength);
        esp_ota_abort(ota_handle);
        return false;
    }
    
    // Finalize OTA
    err = esp_ota_end(ota_handle);
    if (err != ESP_OK) {
        OTA_LOG_ERROR("esp_ota_end failed: %s", esp_err_to_name(err));
        return false;
    }
    
    OTA_LOG("Download complete, setting boot partition...");
    
    // Set boot partition
    err = esp_ota_set_boot_partition(update_partition);
    if (err != ESP_OK) {
        OTA_LOG_ERROR("esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
        return false;
    }
    
    OTA_LOG("Boot partition set to: %s", update_partition->label);
    return true;
}

//=============================================================================
// Main OTA Function
//=============================================================================

OtaResult ota_check_and_apply(bool force) {
    OTA_LOG("=== OTA Check Started (force=%d) ===", force);
    
    // Ensure WiFi
    if (!ota_ensure_wifi()) {
        return OtaResult::WIFI_FAILED;
    }
    
    // Sync time
    if (!ota_sync_time()) {
        return OtaResult::TIME_SYNC_FAILED;
    }
    
    // Fetch manifest
    OtaManifest manifest = {};
    if (!fetch_manifest_internal(manifest)) {
        return OtaResult::MANIFEST_FAILED;
    }
    
    // Compare versions
    OTA_LOG("Current: %s, Available: %s", FIRMWARE_VERSION, manifest.version);
    
    if (!force) {
        int cmp = ota_compare_versions(manifest.version, FIRMWARE_VERSION);
        if (cmp <= 0) {
            OTA_LOG("Already up to date");
            return OtaResult::ALREADY_UP_TO_DATE;
        }
    }
    
    OTA_LOG("Update available! Starting download...");
    
    // Download with retries
    bool success = false;
    for (int attempt = 1; attempt <= OTA_DOWNLOAD_RETRIES && !success; attempt++) {
        OTA_LOG("Download attempt %d/%d...", attempt, OTA_DOWNLOAD_RETRIES);
        
        if (apply_ota_internal(manifest)) {
            success = true;
        } else {
            OTA_LOG_WARN("Download failed, retrying in %d ms...", OTA_RETRY_DELAY_MS);
            
            // Reconnect WiFi before retry
            WiFi.disconnect();
            delay(1000);
            if (!ota_ensure_wifi()) {
                OTA_LOG_ERROR("WiFi reconnect failed");
            }
            delay(OTA_RETRY_DELAY_MS);
        }
    }
    
    if (!success) {
        OTA_LOG_ERROR("All download attempts failed");
        return OtaResult::DOWNLOAD_FAILED;
    }
    
    OTA_LOG("=== OTA Update Successful! Rebooting... ===");
    delay(1000);
    esp_restart();
    
    // Never reached
    return OtaResult::REBOOT_REQUIRED;
}

5. Integration Steps for Sense Board

Step 5.1: Add Required Includes

At the top of your main .ino file, add:

#include "version.h"
#include "ota/ota_core.h"

Step 5.2: Modify setup()

Find your existing setup() function and add OTA initialization and check:

void setup() {
    Serial.begin(115200);
    delay(2000);  // Wait for serial + USB CDC
    
    // === EXISTING CODE: Your hardware init, sensor setup, etc. ===
    // ... your existing setup code ...
    
    // === ADD: OTA Initialization ===
    ota_init();
    
    // === ADD: Mark firmware valid after successful boot ===
    // This prevents rollback to previous firmware
    // Call this AFTER critical hardware is verified working
    ota_mark_valid();
    
    // === ADD: OTA Check ===
    // Option A: Check on every boot (recommended for development)
    OtaResult result = ota_check_and_apply();
    if (result == OtaResult::ALREADY_UP_TO_DATE) {
        Serial.println("[MAIN] Firmware up to date");
    }
    
    // Option B: Check only on cold boot (for production)
    // esp_sleep_wakeup_cause_t wakeup = esp_sleep_get_wakeup_cause();
    // if (wakeup == ESP_SLEEP_WAKEUP_UNDEFINED) {  // Cold boot
    //     ota_check_and_apply();
    // }
    
    // === EXISTING CODE: Continue with your main logic ===
    // ... rest of your setup ...
}

Step 5.3: Modify loop() for OTA Commands (Optional)

If you want to support MQTT-triggered OTA:

// Global flag for MQTT OTA trigger
volatile bool g_ota_check_requested = false;

void loop() {
    // === EXISTING CODE ===
    // ... your existing loop code ...
    
    // === ADD: Handle OTA command ===
    if (g_ota_check_requested) {
        g_ota_check_requested = false;
        Serial.println("[MAIN] OTA check requested via MQTT");
        ota_check_and_apply(true);  // force=true bypasses version check
    }
}

// Call this from your MQTT message handler when you receive "ota/force_now"
void handle_ota_command() {
    g_ota_check_requested = true;
}

Step 5.4: Integration with Deep Sleep

If your firmware uses deep sleep, check for OTA before sleeping:

void goToSleep() {
    // === ADD: Optional OTA check before sleep ===
    // Uncomment if you want periodic OTA checks
    // static uint32_t sleep_count = 0;
    // if (++sleep_count % 10 == 0) {  // Every 10th wake
    //     ota_check_and_apply();
    // }
    
    // === EXISTING CODE ===
    Serial.println("[MAIN] Entering deep sleep...");
    Serial.flush();
    
    esp_sleep_enable_timer_wakeup(SLEEP_DURATION_US);
    esp_deep_sleep_start();
}

6. Integration Steps for LCD Board

The LCD board integration is similar but has some differences:

Step 6.1: Update version.h for LCD

#define FIRMWARE_VERSION "1.0.0"
#define BOARD_NAME "lcd"

Step 6.2: Modify ota_config.h

The LCD uses a different manifest URL path. Ensure this is defined:

#define LCD_MANIFEST_URL OTA_BASE_URL "/lcd/manifest_latest.json"

Step 6.3: Integration with LVGL/Display

For LCD firmware with display, you might want to show OTA progress:

void setup() {
    // Initialize display first
    initDisplay();
    showBootScreen();
    
    // OTA check with progress display
    ota_init();
    ota_mark_valid();
    
    // Show "Checking for updates..."
    showStatusText("Checking for updates...");
    
    OtaResult result = ota_check_and_apply();
    
    switch (result) {
        case OtaResult::ALREADY_UP_TO_DATE:
            showStatusText("Firmware up to date");
            break;
        case OtaResult::WIFI_FAILED:
            showStatusText("WiFi connection failed");
            break;
        case OtaResult::DOWNLOAD_FAILED:
            showStatusText("Update failed");
            break;
        default:
            break;
    }
    
    delay(1000);
    // Continue with main UI...
}

7. Configuration

7.1: Setting Firmware Version

CRITICAL: Increment the version in version.h for EVERY release:

// Before release:
#define FIRMWARE_VERSION "1.0.0"

// After making changes, bump the version:
#define FIRMWARE_VERSION "1.0.1"  // patch for bug fixes
#define FIRMWARE_VERSION "1.1.0"  // minor for new features
#define FIRMWARE_VERSION "2.0.0"  // major for breaking changes

7.2: Configuring WiFi Credentials

Option A: Hardcoded (Development)

#define WIFI_SSID_DEFAULT "MyNetwork"
#define WIFI_PASSWORD_DEFAULT "MyPassword"

Option B: From NVS (Production) The code automatically reads from NVS namespace “wifi” with keys “ssid” and “password”. Your provisioning flow should store credentials there.

7.3: Switching Environments

Change OTA_ENV in ota_config.h:

#define OTA_ENV "dev"      // Development
#define OTA_ENV "staging"  // Staging/QA
#define OTA_ENV "prod"     // Production

8. Integration Points

8.1: When to Call OTA Check

Scenario When to Check Code
Every boot In setup() ota_check_and_apply();
Cold boot only In setup() if (wakeup == ESP_SLEEP_WAKEUP_UNDEFINED) ota_check_and_apply();
Periodic Every N sleeps if (++count % N == 0) ota_check_and_apply();
On command MQTT trigger if (g_ota_requested) ota_check_and_apply(true);
Scheduled Time-based if (isMaintenanceWindow()) ota_check_and_apply();

8.2: MQTT Integration

If your firmware uses MQTT, add a command handler:

void onMqttMessage(const char* topic, const char* payload) {
    // Parse JSON payload
    JsonDocument doc;
    deserializeJson(doc, payload);
    
    const char* cmd = doc["cmd"] | "";
    
    if (strcmp(cmd, "ota/force_now") == 0) {
        Serial.println("[MQTT] OTA force command received");
        g_ota_check_requested = true;
    }
    else if (strcmp(cmd, "ota/status/get") == 0) {
        // Respond with current version
        publishOtaStatus();
    }
}

void publishOtaStatus() {
    JsonDocument doc;
    doc["version"] = ota_get_current_version();
    doc["board"] = BOARD_NAME;
    
    char buffer[256];
    serializeJson(doc, buffer);
    mqttPublish("halo/device/status", buffer);
}

8.3: AWS Integration

If your firmware connects to AWS IoT, OTA can be triggered via device shadow or custom topics. See the MQTT section for command format.


9. Testing the Integration

9.1: Compile and Flash

# Sense board
arduino-cli compile --fqbn "esp32:esp32:XIAO_ESP32S3:USBMode=hwcdc,CDCOnBoot=default" .
arduino-cli upload --fqbn "esp32:esp32:XIAO_ESP32S3:USBMode=hwcdc,CDCOnBoot=default" --port /dev/cu.usbmodem* .

# LCD board
arduino-cli compile --fqbn "esp32:esp32:esp32s3:FlashSize=16M,PartitionScheme=default_8MB,PSRAM=opi,CDCOnBoot=cdc" .
arduino-cli upload --fqbn "esp32:esp32:esp32s3:FlashSize=16M,PartitionScheme=default_8MB,PSRAM=opi,CDCOnBoot=cdc" --port /dev/cu.usbmodem* .

9.2: Monitor Serial Output

# Watch for OTA messages
screen /dev/cu.usbmodem* 115200

Expected output:

[OTA] Initialized - version=1.0.0 board=sense
[OTA] Partitions: running=app0 boot=app0
[OTA] Firmware marked as valid
[OTA] === OTA Check Started (force=0) ===
[OTA] Connecting to WiFi...
[OTA] WiFi connected - IP: 192.168.1.100, RSSI: -65 dBm
[OTA] Syncing time via NTP...
[OTA] Time synced: 2026-02-07 22:30:00 UTC
[OTA] Fetching manifest: https://halo-ota-dev.s3.us-east-1.amazonaws.com/...
[OTA] Manifest attempt 1/3...
[OTA] Manifest: version=1.0.1, size=1482736
[OTA] Current: 1.0.0, Available: 1.0.1
[OTA] Update available! Starting download...
[OTA] Download attempt 1/3...
[OTA] Target partition: app1 (0x210000, 1966080 bytes)
[OTA] OTA begin OK, free heap: 245000
[OTA] Content length: 1482736 bytes
[OTA] Progress: 10% (148273 / 1482736 bytes)
[OTA] Progress: 20% (296547 / 1482736 bytes)
...
[OTA] Progress: 100% (1482736 / 1482736 bytes)
[OTA] Download complete, setting boot partition...
[OTA] Boot partition set to: app1
[OTA] === OTA Update Successful! Rebooting... ===

9.3: Deploy a Test Update

  1. Modify your code slightly (e.g., add a log message)
  2. Bump version in version.h to 1.0.1
  3. Compile
  4. Upload binary to S3
  5. Update manifest
  6. Reboot device
  7. Verify device downloads and applies update

10. Common Pitfalls

❌ Pitfall 1: Forgetting to Increment Version

Problem: Device thinks it’s up to date because manifest version ≤ firmware version.

Solution: ALWAYS increment FIRMWARE_VERSION in version.h before uploading to S3.

❌ Pitfall 2: Wrong Partition Scheme

Problem: esp_ota_begin fails with “no OTA partition”.

Solution: Verify you’re using an OTA-capable partition scheme: - Sense: default scheme - LCD: default_8MB scheme

❌ Pitfall 3: Binary Too Large

Problem: “Firmware too large” error.

Solution: - Check binary size vs partition size - Optimize code (remove debug strings, use PROGMEM) - Use larger partition scheme if available

❌ Pitfall 4: TLS Certificate Failure

Problem: “SSL handshake failed” or timeout on HTTPS.

Solution: - Ensure time is synced via NTP (TLS requires valid time) - Verify Amazon Root CA is correct in ota_config.h - Try client.setInsecure() for debugging (NOT for production)

❌ Pitfall 5: WiFi Drops During Download

Problem: Download fails at random percentages.

Solution: - The code includes retry logic (3 attempts by default) - Increase OTA_NO_DATA_TIMEOUT_MS for very slow connections - Consider stronger WiFi or wired connection for updates

❌ Pitfall 6: Boot Loop After Bad Update

Problem: Device constantly reboots after OTA.

Solution: - ESP32 has automatic rollback - if new firmware crashes before ota_mark_valid(), it rolls back - Call ota_mark_valid() only AFTER verifying critical hardware works - Check serial output during boot for errors

❌ Pitfall 7: Manifest URL Mismatch

Problem: 404 error fetching manifest.

Solution: - Verify S3 bucket name, region, and path - Check bucket is publicly readable or credentials are configured - Test manifest URL in browser: curl <manifest_url>


Quick Reference

Files to Create

File Purpose
version.h Firmware version definition
ota/ota_config.h WiFi, S3, and OTA configuration
ota/ota_core.h OTA function declarations
ota/ota_core.cpp OTA implementation

Key Functions

Function Purpose
ota_init() Initialize OTA subsystem
ota_check_and_apply() Check for and apply updates
ota_mark_valid() Mark firmware as valid (prevent rollback)
ota_ensure_wifi() Connect to WiFi
ota_sync_time() Sync time via NTP
ota_get_current_version() Get current firmware version

Version Checklist Before Release


This guide was created for the HALO project by Trepo Engineering.