|
| 1 | +#include "board_config.h" |
| 2 | + |
| 3 | +#if BOARD_HAS_WIFI |
| 4 | + |
| 5 | +#include "OTAManager.h" |
| 6 | +#include <LittleFS.h> |
| 7 | +#include <ArduinoJson.h> |
| 8 | +#include <esp_ota_ops.h> |
| 9 | + |
| 10 | +// --------------------------------------------------------------------------- |
| 11 | +OTAManager::OTAManager() {} |
| 12 | + |
| 13 | +void OTAManager::begin() { |
| 14 | + preferences_.begin(NVS_NS, /*readOnly=*/true); |
| 15 | + password_ = preferences_.getString(NVS_PWD_KEY, DEFAULT_PWD); |
| 16 | + preferences_.end(); |
| 17 | + Serial.printf("[OTA] Manager ready (password %s)\n", |
| 18 | + password_ == DEFAULT_PWD ? "is default — change it!" : "is set"); |
| 19 | +} |
| 20 | + |
| 21 | +void OTAManager::setPassword(const String& newPassword) { |
| 22 | + if (newPassword.isEmpty()) return; |
| 23 | + preferences_.begin(NVS_NS, /*readOnly=*/false); |
| 24 | + preferences_.putString(NVS_PWD_KEY, newPassword); |
| 25 | + preferences_.end(); |
| 26 | + password_ = newPassword; |
| 27 | +} |
| 28 | + |
| 29 | +// --------------------------------------------------------------------------- |
| 30 | +// Route registration |
| 31 | +// --------------------------------------------------------------------------- |
| 32 | + |
| 33 | +void OTAManager::registerRoutes(AsyncWebServer& server) { |
| 34 | + // Public: OTA upload page |
| 35 | + server.on("/ota", HTTP_GET, [this](AsyncWebServerRequest* req) { |
| 36 | + handlePageGet(req); |
| 37 | + }); |
| 38 | + |
| 39 | + // Public: firmware status |
| 40 | + server.on("/ota/status", HTTP_GET, [this](AsyncWebServerRequest* req) { |
| 41 | + handleStatusGet(req); |
| 42 | + }); |
| 43 | + |
| 44 | + // Protected: firmware upload (multipart POST) |
| 45 | + server.on("/ota/update", HTTP_POST, |
| 46 | + [this](AsyncWebServerRequest* req) { handleUpdatePost(req); }, |
| 47 | + [this](AsyncWebServerRequest* req, const String& fn, |
| 48 | + size_t idx, uint8_t* data, size_t len, bool final) { |
| 49 | + handleUpdateUpload(req, fn, idx, data, len, final); |
| 50 | + }); |
| 51 | + |
| 52 | + // Protected: read / change OTA config |
| 53 | + server.on("/ota/config", HTTP_GET, [this](AsyncWebServerRequest* req) { |
| 54 | + handleConfigGet(req); |
| 55 | + }); |
| 56 | + server.on("/ota/config", HTTP_POST, [this](AsyncWebServerRequest* req) { |
| 57 | + handleConfigPost(req); |
| 58 | + }); |
| 59 | +} |
| 60 | + |
| 61 | +// --------------------------------------------------------------------------- |
| 62 | +// Authentication |
| 63 | +// --------------------------------------------------------------------------- |
| 64 | + |
| 65 | +bool OTAManager::authenticate(AsyncWebServerRequest* request) const { |
| 66 | + return request->authenticate(OTA_USERNAME, password_.c_str()); |
| 67 | +} |
| 68 | + |
| 69 | +// --------------------------------------------------------------------------- |
| 70 | +// GET /ota — minimal upload form (or LittleFS /ota.html if present) |
| 71 | +// --------------------------------------------------------------------------- |
| 72 | + |
| 73 | +void OTAManager::handlePageGet(AsyncWebServerRequest* request) const { |
| 74 | + if (LittleFS.exists("/ota.html")) { |
| 75 | + request->send(LittleFS, "/ota.html", "text/html"); |
| 76 | + return; |
| 77 | + } |
| 78 | + // Built-in fallback: a minimal inline form. |
| 79 | + static const char HTML[] = |
| 80 | + "<!DOCTYPE html><html><head><meta charset='utf-8'>" |
| 81 | + "<title>OTA Update</title></head><body>" |
| 82 | + "<h2>ESP32 OTA Firmware Update</h2>" |
| 83 | + "<form method='POST' action='/ota/update' enctype='multipart/form-data'>" |
| 84 | + "<p>Firmware binary (.bin):</p>" |
| 85 | + "<input type='file' name='firmware' accept='.bin' required><br><br>" |
| 86 | + "<input type='submit' value='Upload & Flash'>" |
| 87 | + "</form>" |
| 88 | + "<p><em>Authentication required: username <b>admin</b>, password as configured.</em></p>" |
| 89 | + "<p><a href='/ota/status'>Status JSON</a></p>" |
| 90 | + "</body></html>"; |
| 91 | + request->send(200, "text/html", HTML); |
| 92 | +} |
| 93 | + |
| 94 | +// --------------------------------------------------------------------------- |
| 95 | +// GET /ota/status — public JSON |
| 96 | +// --------------------------------------------------------------------------- |
| 97 | + |
| 98 | +String OTAManager::getStatusJson() const { |
| 99 | + JsonDocument doc; |
| 100 | + |
| 101 | + const esp_partition_t* part = esp_ota_get_running_partition(); |
| 102 | + if (part) { |
| 103 | + doc["partition"] = part->label; |
| 104 | + } |
| 105 | + |
| 106 | + const esp_app_desc_t* appDesc = esp_app_get_description(); |
| 107 | + if (appDesc) { |
| 108 | + doc["version"] = appDesc->version; |
| 109 | + doc["idfVersion"] = appDesc->idf_ver; |
| 110 | + doc["buildDate"] = appDesc->date; |
| 111 | + doc["buildTime"] = appDesc->time; |
| 112 | + } |
| 113 | + |
| 114 | + if (uploadDone_) { |
| 115 | + doc["lastUpdate"] = uploadError_ ? "failed" : "success"; |
| 116 | + } |
| 117 | + if (uploadError_ && !lastErrorMsg_.isEmpty()) { |
| 118 | + doc["lastError"] = lastErrorMsg_; |
| 119 | + } |
| 120 | + |
| 121 | + String out; |
| 122 | + serializeJson(doc, out); |
| 123 | + return out; |
| 124 | +} |
| 125 | + |
| 126 | +void OTAManager::handleStatusGet(AsyncWebServerRequest* request) const { |
| 127 | + AsyncResponseStream* resp = request->beginResponseStream("application/json"); |
| 128 | + resp->print(getStatusJson()); |
| 129 | + request->send(resp); |
| 130 | +} |
| 131 | + |
| 132 | +// --------------------------------------------------------------------------- |
| 133 | +// POST /ota/update — authenticated firmware upload |
| 134 | +// --------------------------------------------------------------------------- |
| 135 | + |
| 136 | +void OTAManager::handleUpdatePost(AsyncWebServerRequest* request) { |
| 137 | + if (!authenticate(request)) { |
| 138 | + request->requestAuthentication(); |
| 139 | + return; |
| 140 | + } |
| 141 | + |
| 142 | + bool ok = !Update.hasError(); |
| 143 | + String msg = ok ? "Update successful — rebooting." : ("Update failed: " + lastErrorMsg_); |
| 144 | + |
| 145 | + AsyncWebServerResponse* resp = request->beginResponse( |
| 146 | + ok ? 200 : 500, "text/plain", msg); |
| 147 | + resp->addHeader("Connection", "close"); |
| 148 | + request->send(resp); |
| 149 | + |
| 150 | + if (ok) { |
| 151 | + delay(500); |
| 152 | + ESP.restart(); |
| 153 | + } |
| 154 | +} |
| 155 | + |
| 156 | +void OTAManager::handleUpdateUpload(AsyncWebServerRequest* request, |
| 157 | + const String& /*filename*/, |
| 158 | + size_t index, uint8_t* data, |
| 159 | + size_t len, bool final) { |
| 160 | + // Re-check auth on each chunk to prevent unauthenticated stream injection. |
| 161 | + if (!authenticate(request)) return; |
| 162 | + |
| 163 | + if (index == 0) { |
| 164 | + uploadError_ = false; |
| 165 | + uploadDone_ = false; |
| 166 | + lastErrorMsg_ = ""; |
| 167 | + Serial.printf("[OTA] Upload started, free heap: %u\n", (unsigned)ESP.getFreeHeap()); |
| 168 | + |
| 169 | + if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) { |
| 170 | + uploadError_ = true; |
| 171 | + lastErrorMsg_ = Update.errorString(); |
| 172 | + Serial.printf("[OTA] begin() failed: %s\n", lastErrorMsg_.c_str()); |
| 173 | + return; |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + if (uploadError_) return; // skip remaining chunks after an error |
| 178 | + |
| 179 | + if (Update.write(data, len) != len) { |
| 180 | + uploadError_ = true; |
| 181 | + lastErrorMsg_ = Update.errorString(); |
| 182 | + Serial.printf("[OTA] write() failed: %s\n", lastErrorMsg_.c_str()); |
| 183 | + return; |
| 184 | + } |
| 185 | + |
| 186 | + if (final) { |
| 187 | + uploadDone_ = true; |
| 188 | + if (!Update.end(true)) { |
| 189 | + uploadError_ = true; |
| 190 | + lastErrorMsg_ = Update.errorString(); |
| 191 | + Serial.printf("[OTA] end() failed: %s\n", lastErrorMsg_.c_str()); |
| 192 | + } else { |
| 193 | + Serial.println("[OTA] Upload complete — awaiting reboot."); |
| 194 | + } |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +// --------------------------------------------------------------------------- |
| 199 | +// GET /ota/config — protected |
| 200 | +// --------------------------------------------------------------------------- |
| 201 | + |
| 202 | +void OTAManager::handleConfigGet(AsyncWebServerRequest* request) const { |
| 203 | + if (!authenticate(request)) { |
| 204 | + request->requestAuthentication(); |
| 205 | + return; |
| 206 | + } |
| 207 | + JsonDocument doc; |
| 208 | + doc["username"] = OTA_USERNAME; |
| 209 | + doc["passwordIsSet"] = true; |
| 210 | + doc["isDefaultPwd"] = (password_ == DEFAULT_PWD); |
| 211 | + String json; |
| 212 | + serializeJson(doc, json); |
| 213 | + AsyncResponseStream* resp = request->beginResponseStream("application/json"); |
| 214 | + resp->print(json); |
| 215 | + request->send(resp); |
| 216 | +} |
| 217 | + |
| 218 | +// --------------------------------------------------------------------------- |
| 219 | +// POST /ota/config — change password (requires current credentials) |
| 220 | +// --------------------------------------------------------------------------- |
| 221 | + |
| 222 | +void OTAManager::handleConfigPost(AsyncWebServerRequest* request) { |
| 223 | + if (!authenticate(request)) { |
| 224 | + request->requestAuthentication(); |
| 225 | + return; |
| 226 | + } |
| 227 | + if (!request->hasParam("password", true)) { |
| 228 | + request->send(400, "text/plain", "Missing 'password' field"); |
| 229 | + return; |
| 230 | + } |
| 231 | + String newPwd = request->getParam("password", true)->value(); |
| 232 | + if (newPwd.length() < 8) { |
| 233 | + request->send(400, "text/plain", "Password must be at least 8 characters"); |
| 234 | + return; |
| 235 | + } |
| 236 | + setPassword(newPwd); |
| 237 | + request->send(200, "text/plain", "OTA password updated"); |
| 238 | +} |
| 239 | + |
| 240 | +#endif // BOARD_HAS_WIFI |
0 commit comments