Skip to content

Commit d297d49

Browse files
committed
feat: add password-protected OTA firmware update manager
Introduces OTAManager with HTTP Basic Auth (username "admin", password stored in NVS namespace "ota"). Routes registered on the shared AsyncWebServer: GET /ota — upload page (LittleFS /ota.html or built-in form) GET /ota/status — public JSON: fw version, build date, last result POST /ota/update — authenticated multipart firmware upload + reboot GET /ota/config — authenticated: reports whether default pwd is set POST /ota/config — authenticated: change OTA password (min 8 chars) Default password on first boot is "admin" — change it immediately after flashing via POST /ota/config. Password persisted to NVS and survives reboots. OTAManager is excluded from nRF52 and native (unit-test) builds via platformio.ini build_src_filter rules. https://claude.ai/code/session_01KcwgryFgMCJubfjtwqMnUc
1 parent dbc5929 commit d297d49

6 files changed

Lines changed: 339 additions & 1 deletion

File tree

include/NetworkManager.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
// Forward declarations — avoid pulling ESP32-only headers into every TU.
1111
class DiscoveryManager;
12+
class OTAManager;
1213
namespace mcp { class BusHistory; }
1314

1415
enum class NetworkState {
@@ -56,6 +57,10 @@ class NetworkManager {
5657
// Call before begin().
5758
void setBusHistory(mcp::BusHistory* bh);
5859

60+
// Wire in an OTAManager so its /ota/* routes are registered on the server.
61+
// Call before begin().
62+
void setOTAManager(OTAManager* ota);
63+
5964
// Process one pending network request synchronously (used in tests
6065
// where the FreeRTOS background task is not running).
6166
void handleClient();
@@ -79,6 +84,7 @@ class NetworkManager {
7984
NetworkCredentials credentials;
8085
DiscoveryManager* discovery_ = nullptr;
8186
mcp::BusHistory* busHistory_ = nullptr;
87+
OTAManager* ota_ = nullptr;
8288

8389
void setupWebServer();
8490
void handleRequest(const NetworkRequest& request);

include/OTAManager.h

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#pragma once
2+
3+
#if BOARD_HAS_WIFI
4+
5+
#include <Arduino.h>
6+
#include <Preferences.h>
7+
#include <Update.h>
8+
#include <ESPAsyncWebServer.h>
9+
10+
// ---------------------------------------------------------------------------
11+
// OTAManager — password-protected Over-The-Air firmware update service.
12+
//
13+
// Routes registered on the shared AsyncWebServer:
14+
// GET /ota — Serves the OTA upload page (from LittleFS /ota.html,
15+
// or a built-in minimal HTML form if the file is absent).
16+
// GET /ota/status — Public JSON endpoint: running fw version + last result.
17+
// POST /ota/update — Accepts a multipart firmware binary. Requires HTTP
18+
// Basic Auth (username "admin", password as configured).
19+
// GET /ota/config — Requires auth. Returns OTA configuration JSON.
20+
// POST /ota/config — Requires auth with current password. Accepts form
21+
// field "password" to update the OTA password.
22+
//
23+
// Password storage:
24+
// Stored in NVS namespace "ota", key "password".
25+
// Default on first boot: "admin" (change immediately after flashing!).
26+
//
27+
// Usage:
28+
// OTAManager otaManager;
29+
// otaManager.begin();
30+
// otaManager.registerRoutes(server); // call before server.begin()
31+
// ---------------------------------------------------------------------------
32+
33+
class OTAManager {
34+
public:
35+
OTAManager();
36+
37+
// Load password from NVS. Call before registerRoutes().
38+
void begin();
39+
40+
// Directly set the OTA password and persist it to NVS.
41+
void setPassword(const String& newPassword);
42+
43+
// Register all /ota/* routes on the provided web server.
44+
void registerRoutes(AsyncWebServer& server);
45+
46+
// JSON representation of the last OTA attempt and firmware info.
47+
String getStatusJson() const;
48+
49+
private:
50+
static constexpr const char* OTA_USERNAME = "admin";
51+
static constexpr const char* NVS_NS = "ota";
52+
static constexpr const char* NVS_PWD_KEY = "password";
53+
static constexpr const char* DEFAULT_PWD = "admin";
54+
55+
Preferences preferences_;
56+
String password_; // current OTA password (plaintext in NVS)
57+
bool uploadError_ = false;
58+
String lastErrorMsg_;
59+
bool uploadDone_ = false;
60+
61+
// Returns true when the request carries valid Basic Auth credentials.
62+
bool authenticate(AsyncWebServerRequest* request) const;
63+
64+
// HTTP handler helpers.
65+
void handlePageGet(AsyncWebServerRequest* request) const;
66+
void handleStatusGet(AsyncWebServerRequest* request) const;
67+
void handleUpdatePost(AsyncWebServerRequest* request);
68+
void handleUpdateUpload(AsyncWebServerRequest* request,
69+
const String& filename,
70+
size_t index, uint8_t* data, size_t len, bool final);
71+
void handleConfigGet(AsyncWebServerRequest* request) const;
72+
void handleConfigPost(AsyncWebServerRequest* request);
73+
};
74+
75+
#endif // BOARD_HAS_WIFI

platformio.ini

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ build_src_filter =
4141
-<uLogger.cpp>
4242
-<MetricsSystem.cpp>
4343
-<BusHistory.cpp>
44+
-<OTAManager.cpp>
4445
+<main_nrf.cpp>
4546
build_unflags = -std=gnu++11
4647
build_flags =
@@ -108,11 +109,12 @@ lib_deps =
108109
throwtheswitch/Unity@^2.5.2
109110
bblanchon/ArduinoJson
110111
test_build_src = yes
111-
; Exclude main.cpp and main_nrf.cpp from native test builds.
112+
; Exclude main.cpp, main_nrf.cpp and ESP32-only modules from native test builds.
112113
build_src_filter =
113114
+<*>
114115
-<main.cpp>
115116
-<main_nrf.cpp>
117+
-<OTAManager.cpp>
116118
build_flags =
117119
-std=gnu++17
118120
-D UNITY_INCLUDE_CONFIG_H

src/NetworkManager.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "NetworkManager.h"
22
#include "DiscoveryManager.h"
33
#include "BusHistory.h"
4+
#include "OTAManager.h"
45
#include <esp_random.h>
56
#include <ArduinoJson.h>
67

@@ -21,6 +22,10 @@ void NetworkManager::setBusHistory(mcp::BusHistory* bh) {
2122
busHistory_ = bh;
2223
}
2324

25+
void NetworkManager::setOTAManager(OTAManager* ota) {
26+
ota_ = ota;
27+
}
28+
2429
void NetworkManager::begin() {
2530
// Initialize LittleFS if not already initialized
2631
if (!LittleFS.begin(false)) {
@@ -114,6 +119,10 @@ void NetworkManager::setupWebServer() {
114119
}
115120
});
116121

122+
if (ota_) {
123+
ota_->registerRoutes(server);
124+
}
125+
117126
server.begin();
118127
}
119128

src/OTAManager.cpp

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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 &amp; 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

Comments
 (0)