#include <Arduino.h> #include <WiFi.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> #include <Adafruit_NeoPixel.h> #include <HTTPClient.h> #include <Update.h> #include <ArduinoJson.h> #include <Preferences.h> #include "mbedtls/sha256.h" const char* ssid = "UPB-Guest"; const char* password = ""; // Pull-OTA settings (host these on your local machine) const char* fw_version = "1.0.0"; const char* ota_manifest_url = "http://192.168.0.104:8000/manifest.json"; // adjust to your LAN host const char* ota_telemetry_url = "http://192.168.0.104:8000/telemetry"; // optional: logs update results constexpr unsigned long OTA_CHECK_INTERVAL_MS = 30UL * 1000UL; unsigned long lastOtaCheckMs = 0; constexpr uint8_t NEOPIXEL_PIN = 3; constexpr uint16_t NEOPIXEL_COUNT = 1; Adafruit_NeoPixel statusPixel(NEOPIXEL_COUNT, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800); AsyncWebServer server(80); Preferences prefs; String getPrefStringSafe(const char* key) { if (!prefs.isKey(key)) { return ""; } return prefs.getString(key, ""); } struct OtaManifest { String version; String url; String sha256; }; struct TelemetryState { String lastResult; // success | failed | noop | "" String lastError; // sha_mismatch | manifest_fail | ... | "" String fromVersion; String toVersion; String message; // human-readable summary } telemetry; void loadTelemetryFromNvs() { prefs.begin("ota", false); telemetry.lastResult = getPrefStringSafe("result"); telemetry.lastError = getPrefStringSafe("error"); telemetry.fromVersion = getPrefStringSafe("from"); telemetry.toVersion = getPrefStringSafe("to"); telemetry.message = getPrefStringSafe("msg"); } void storeTelemetryToNvs() { prefs.putString("result", telemetry.lastResult); prefs.putString("error", telemetry.lastError); prefs.putString("from", telemetry.fromVersion); prefs.putString("to", telemetry.toVersion); prefs.putString("msg", telemetry.message); } void recordTelemetry(const char* result, const char* error, const String& fromVer, const String& toVer, const String& msg) { telemetry.lastResult = result ? result : ""; telemetry.lastError = error ? error : ""; telemetry.fromVersion = fromVer; telemetry.toVersion = toVer; telemetry.message = msg; storeTelemetryToNvs(); } String toHex(const uint8_t* data, size_t len) { static const char* hex = "0123456789abcdef"; String out; out.reserve(len * 2); for (size_t i = 0; i < len; ++i) { out += hex[(data[i] >> 4) & 0x0F]; out += hex[data[i] & 0x0F]; } return out; } String urlEncode(const String& input) { String encoded; for (size_t i = 0; i < input.length(); i++) { char c = input[i]; if (isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_' || c == '.' || c == '~') { encoded += c; } else if (c == ' ') { encoded += "%20"; } else { char buf[4]; snprintf(buf, sizeof(buf), "%%%02X", static_cast<unsigned char>(c)); encoded += buf; } } return encoded; } void sendTelemetry(const char* status, const String& detail) { if (ota_telemetry_url == nullptr || strlen(ota_telemetry_url) == 0) { return; } HTTPClient http; String url = String(ota_telemetry_url) + "?status=" + status + "&version=" + fw_version + "&msg=" + urlEncode(detail.substring(0, 60)); // keep it short if (http.begin(url)) { http.GET(); // best-effort; ignore response http.end(); } } bool fetchManifest(OtaManifest& manifest) { HTTPClient http; if (!http.begin(ota_manifest_url)) { sendTelemetry("manifest_begin_fail", "http.begin failed"); recordTelemetry("failed", "manifest_begin_fail", fw_version, "", "Manifest fetch failed"); return false; } const int code = http.GET(); if (code != HTTP_CODE_OK) { sendTelemetry("manifest_http_fail", "code " + String(code)); recordTelemetry("failed", "manifest_http_fail", fw_version, "", "HTTP " + String(code)); http.end(); return false; } StaticJsonDocument<512> doc; DeserializationError err = deserializeJson(doc, http.getString()); http.end(); if (err) { sendTelemetry("manifest_parse_fail", err.f_str()); recordTelemetry("failed", "manifest_parse_fail", fw_version, "", "Manifest parse failed"); return false; } manifest.version = doc["version"] | ""; manifest.url = doc["url"] | ""; manifest.sha256 = doc["sha256"] | ""; if (manifest.version.isEmpty() || manifest.url.isEmpty() || manifest.sha256.isEmpty()) { sendTelemetry("manifest_missing", "missing field"); recordTelemetry("failed", "manifest_missing", fw_version, "", "Manifest missing field"); return false; } return true; } bool downloadAndUpdate(const String& firmwareUrl, const String& expectedSha256, const String& targetVersion) { WiFiClient client; HTTPClient http; if (!http.begin(client, firmwareUrl)) { sendTelemetry("fw_begin_fail", "http.begin"); recordTelemetry("failed", "fw_begin_fail", fw_version, targetVersion, "Firmware HTTP begin failed"); return false; } const int code = http.GET(); if (code != HTTP_CODE_OK) { sendTelemetry("fw_http_fail", "code " + String(code)); recordTelemetry("failed", "fw_http_fail", fw_version, targetVersion, "HTTP " + String(code)); http.end(); return false; } const int contentLength = http.getSize(); if (contentLength <= 0 && contentLength != -1) { sendTelemetry("fw_size_fail", "invalid length"); recordTelemetry("failed", "fw_size_fail", fw_version, targetVersion, "Invalid content length"); http.end(); return false; } if (!Update.begin(contentLength > 0 ? contentLength : UPDATE_SIZE_UNKNOWN)) { sendTelemetry("update_begin_fail", Update.errorString()); recordTelemetry("failed", "update_begin_fail", fw_version, targetVersion, "Update.begin failed"); http.end(); return false; } mbedtls_sha256_context shaCtx; mbedtls_sha256_init(&shaCtx); mbedtls_sha256_starts(&shaCtx, 0); // 0 = SHA-256 WiFiClient* stream = http.getStreamPtr(); uint8_t buff[1024]; size_t written = 0; while (http.connected()) { const size_t avail = stream->available(); if (avail) { const size_t toRead = avail > sizeof(buff) ? sizeof(buff) : avail; const size_t read = stream->readBytes(buff, toRead); if (read == 0) { break; } if (Update.write(buff, read) != read) { sendTelemetry("update_write_fail", Update.errorString()); recordTelemetry("failed", "update_write_fail", fw_version, targetVersion, "Write failed"); Update.abort(); mbedtls_sha256_free(&shaCtx); http.end(); return false; } mbedtls_sha256_update(&shaCtx, buff, read); written += read; } else { delay(1); if (contentLength > 0 && written >= static_cast<size_t>(contentLength)) { break; } } } uint8_t hashResult[32]; mbedtls_sha256_finish(&shaCtx, hashResult); mbedtls_sha256_free(&shaCtx); http.end(); const String computedHash = toHex(hashResult, sizeof(hashResult)); if (!computedHash.equalsIgnoreCase(expectedSha256)) { sendTelemetry("hash_mismatch", computedHash); recordTelemetry("failed", "sha_mismatch", fw_version, targetVersion, "SHA mismatch"); Update.abort(); return false; } if (!Update.end(true)) { // true = even if size is smaller sendTelemetry("update_end_fail", Update.errorString()); recordTelemetry("failed", "update_end_fail", fw_version, targetVersion, "Update end failed"); return false; } if (!Update.isFinished()) { sendTelemetry("update_not_finished", "not finished"); recordTelemetry("failed", "update_not_finished", fw_version, targetVersion, "Update not finished"); return false; } sendTelemetry("update_ok", "restarting"); recordTelemetry("success", "", fw_version, targetVersion, "Update applied; restarting"); delay(100); ESP.restart(); return true; // not reached } void checkForOtaUpdate() { OtaManifest manifest; if (!fetchManifest(manifest)) { return; } if (manifest.version == fw_version) { recordTelemetry("noop", "", fw_version, manifest.version, "No update needed."); return; // already up to date } sendTelemetry("update_found", "v" + manifest.version); downloadAndUpdate(manifest.url, manifest.sha256, manifest.version); } void setup() { Serial.begin(115200); delay(200); loadTelemetryFromNvs(); WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); Serial.print("Connecting to WiFi"); while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); } Serial.println(); Serial.print("Connected. IP: "); Serial.println(WiFi.localIP()); // Simple landing page server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(200, "text/plain", "ESP32-C6 Sparrow OTA ready.\n" "Updates pull from manifest server.\n" "Go to /status for telemetry."); }); server.on("/status", HTTP_GET, [](AsyncWebServerRequest *request) { String payload; payload.reserve(256); payload += "{"; payload += "\"device_version\":\"" + String(fw_version) + "\","; payload += "\"last_result\":\"" + telemetry.lastResult + "\","; payload += "\"last_error\":\"" + telemetry.lastError + "\","; payload += "\"from_version\":\"" + telemetry.fromVersion + "\","; payload += "\"to_version\":\"" + telemetry.toVersion + "\","; payload += "\"message\":\"" + telemetry.message + "\""; payload += "}"; request->send(200, "application/json", payload); }); statusPixel.begin(); statusPixel.setBrightness(40); statusPixel.show(); // initialize to off server.begin(); Serial.println("HTTP server started."); Serial.println("Open http://<device-ip>/status"); // First pull-based OTA check after boot checkForOtaUpdate(); } void loop() { static bool ledOn = false; static unsigned long lastToggle = 0; const unsigned long blinkIntervalMs = 500; const unsigned long now = millis(); if (now - lastToggle >= blinkIntervalMs) { lastToggle = now; ledOn = !ledOn; const uint32_t color = ledOn ? statusPixel.Color(255, 0, 0) : 0; statusPixel.setPixelColor(0, color); statusPixel.show(); } if (WiFi.isConnected() && now - lastOtaCheckMs >= OTA_CHECK_INTERVAL_MS) { lastOtaCheckMs = now; checkForOtaUpdate(); } // Yield to keep the watchdog happy delay(1); }