wifi: add WPA2 AP setup API and pairing roadmap

This commit is contained in:
2026-06-07 22:11:40 -06:00
parent 594edc3dd9
commit b665bf1c82
5 changed files with 289 additions and 7 deletions
+41
View File
@@ -850,3 +850,44 @@ POST /api/v1/system/restart
``` ```
If added later, update this document and add contract tests. If added later, update this document and add contract tests.
### AP configuration
The Cargo ESP32 access point uses WPA2 authentication.
On first boot, if no AP password exists, the controller generates and saves a unique password. Until the OLED setup display is added, that generated password is printed to Serial during boot.
#### GET /api/v1/config/ap
Returns AP metadata. The password is never returned.
Example response:
{
"ok": true,
"ssid": "OverlandController",
"password_set": true,
"password_min_length": 8,
"password_max_length": 63,
"auth": "wpa2"
}
#### POST /api/v1/config/ap
Updates the AP SSID/password and restarts the access point.
Example request:
{
"ssid": "Xterra-Camp",
"password": "new-secure-password"
}
Rules:
- SSID must be 1-32 characters.
- Password must be 8-63 characters.
- Password is stored in controller preferences.
- Password is not returned by status or config APIs.
- Future dashboard pairing will use Cargo-led credential migration so the dashboard follows AP credential changes automatically.
+23
View File
@@ -170,3 +170,26 @@ Future optional architecture:
-> Cargo ESP32 outbound connection -> Cargo ESP32 outbound connection
Remote features must not break local operation. Remote features must not break local operation.
## Cargo-led AP Pairing and Credential Migration
The Cargo ESP32 owns the local overland network. It is the AP owner, source of truth, and configuration authority.
Target behavior:
1. Cargo ESP32 starts a WPA2-protected AP.
2. Cargo OLED shows setup credentials and critical recovery/status information.
3. Phone connects to the Cargo AP and uses the WebUI for setup/admin.
4. Dashboard ESP32-S3 joins the Cargo AP as a client.
5. If the user changes the Cargo AP SSID/password, the Cargo ESP stages the new credentials and coordinates dashboard migration.
6. Dashboard stores the new credentials before the Cargo AP restarts.
7. Cargo AP applies the new credentials.
8. Dashboard reconnects automatically.
The dashboard should not own network authority. A temporary dashboard setup AP may exist only as a recovery/fallback mechanism, not the normal pairing path.
Recovery target:
- A setup/status button on the Cargo ESP enclosure can show AP credentials on the OLED.
- A long hold can restore factory AP credentials or enter recovery mode.
+43
View File
@@ -60,3 +60,46 @@ UI direction:
- Dashboard alert sounds - Dashboard alert sounds
- More vehicle PIDs - More vehicle PIDs
- Historical charts - Historical charts
## AP Security, OLED Setup, and Dashboard Pairing
### Phase 1 — Cargo AP security
- Require WPA2 on the Cargo ESP32 AP.
- Generate a unique AP password on first boot.
- Store AP SSID/password in Preferences.
- Add /api/v1/config/ap for AP configuration.
- Add AP auth/SSID metadata to status responses.
- Do not expose AP password in API responses.
### Phase 2 — Cargo OLED setup display
- Add 128x64 SSD1306 OLED support on the Cargo ESP32.
- Show AP SSID/password/IP during first boot and setup mode.
- Show critical operational state during normal operation:
- SOC
- voltage/current
- relay state
- WiFi/IP
- alarms
- Add setup/status button:
- short press cycles pages
- long press shows AP credentials
- extended hold enters recovery/reset mode
### Phase 3 — Dashboard credential migration
- Dashboard ESP32-S3 connects as a client to the Cargo ESP AP.
- Cargo ESP remains the network/configuration authority.
- When AP credentials are changed from the phone WebUI, Cargo stages the new credentials.
- Dashboard fetches and saves pending credentials before Cargo restarts AP.
- Cargo applies the new AP settings.
- Dashboard reconnects automatically.
- Temporary dashboard setup AP is fallback/recovery only.
### Phase 4 — User-friendly setup polish
- Add QR code display for joining Cargo AP.
- Add dashboard settings UI.
- Add LVGL on-screen keyboard only after core dashboard functionality is stable.
@@ -2,6 +2,7 @@
#include <WebServer.h> #include <WebServer.h>
#include <Preferences.h> #include <Preferences.h>
#include <esp_wifi.h> #include <esp_wifi.h>
#include <esp_system.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include "config.h" #include "config.h"
@@ -781,6 +782,13 @@ HardwareSerial DashboardSerial(2);
String uartLineBuffer; String uartLineBuffer;
Preferences wifiPrefs; Preferences wifiPrefs;
Preferences apPrefs;
String apSsid = "OverlandController";
String apPassword = "";
const int AP_PASSWORD_MIN_LEN = 8;
const int AP_PASSWORD_MAX_LEN = 63;
const int MAX_WIFI_NETWORKS = 3; const int MAX_WIFI_NETWORKS = 3;
String staSsids[MAX_WIFI_NETWORKS]; String staSsids[MAX_WIFI_NETWORKS];
@@ -854,6 +862,147 @@ void clearWifiConfig() {
} }
} }
String generateDefaultApPassword() {
uint64_t mac = ESP.getEfuseMac();
uint32_t a = esp_random();
uint32_t b = ((uint32_t)(mac >> 16)) ^ esp_random();
char buffer[17];
snprintf(buffer, sizeof(buffer), "%08lX%08lX", (unsigned long)a, (unsigned long)b);
return String(buffer);
}
bool validApSsid(const String& ssid) {
return ssid.length() > 0 && ssid.length() <= 32;
}
bool validApPassword(const String& password) {
return password.length() >= AP_PASSWORD_MIN_LEN && password.length() <= AP_PASSWORD_MAX_LEN;
}
void loadApConfig() {
apPrefs.begin("ap", false);
apSsid = apPrefs.getString("ssid", "OverlandController");
apPassword = apPrefs.getString("password", "");
if (!validApSsid(apSsid)) {
apSsid = "OverlandController";
apPrefs.putString("ssid", apSsid);
}
if (!validApPassword(apPassword)) {
apPassword = generateDefaultApPassword();
apPrefs.putString("password", apPassword);
}
apPrefs.end();
}
void saveApConfig(const String& ssid, const String& password) {
apSsid = ssid;
apPassword = password;
apPrefs.begin("ap", false);
apPrefs.putString("ssid", apSsid);
apPrefs.putString("password", apPassword);
apPrefs.end();
}
bool startAccessPoint() {
bool ok = WiFi.softAP(apSsid.c_str(), apPassword.c_str());
if (ok) {
Serial.println("AP Started");
Serial.print("AP SSID: ");
Serial.println(apSsid);
Serial.print("AP Password: ");
Serial.println(apPassword);
Serial.print("AP IP: ");
Serial.println(WiFi.softAPIP());
} else {
Serial.println("AP failed to start");
}
return ok;
}
void restartAccessPoint() {
WiFi.softAPdisconnect(true);
delay(250);
startAccessPoint();
}
void sendSimpleJsonError(int status, const char* error, const char* detail) {
DynamicJsonDocument doc(512);
doc["ok"] = false;
doc["error"] = error;
if (detail && detail[0]) {
JsonArray details = doc.createNestedArray("details");
details.add(detail);
}
String output;
serializeJson(doc, output);
server.send(status, "application/json", output);
}
void handleGetApConfig() {
DynamicJsonDocument response(512);
response["ok"] = true;
response["ssid"] = apSsid;
response["password_set"] = validApPassword(apPassword);
response["password_min_length"] = AP_PASSWORD_MIN_LEN;
response["password_max_length"] = AP_PASSWORD_MAX_LEN;
response["auth"] = "wpa2";
String output;
serializeJson(response, output);
server.send(200, "application/json", output);
}
void handleUpdateApConfig() {
DynamicJsonDocument doc(512);
DeserializationError err = deserializeJson(doc, server.arg("plain"));
if (err) {
sendSimpleJsonError(400, "invalid_json", err.c_str());
return;
}
String ssid = doc["ssid"] | apSsid;
String password = doc["password"] | "";
ssid.trim();
password.trim();
if (!validApSsid(ssid)) {
sendSimpleJsonError(400, "invalid_ap_ssid", "AP SSID must be 1-32 characters.");
return;
}
if (!validApPassword(password)) {
sendSimpleJsonError(400, "invalid_ap_password", "AP password must be 8-63 characters.");
return;
}
saveApConfig(ssid, password);
restartAccessPoint();
DynamicJsonDocument response(512);
response["ok"] = true;
response["ssid"] = apSsid;
response["password_set"] = true;
response["auth"] = "wpa2";
response["ap_ip"] = WiFi.softAPIP().toString();
String output;
serializeJson(response, output);
server.send(200, "application/json", output);
}
int findNextWifiIndexByPriority(bool tried[]) { int findNextWifiIndexByPriority(bool tried[]) {
int bestIndex = -1; int bestIndex = -1;
int bestPriority = 1000000; int bestPriority = 1000000;
@@ -1327,6 +1476,8 @@ void buildStatusDocument(JsonDocument& doc) {
network["wifi_enabled"] = true; network["wifi_enabled"] = true;
network["uart_connected"] = true; network["uart_connected"] = true;
network["ap_enabled"] = true; network["ap_enabled"] = true;
network["ap_ssid"] = apSsid;
network["ap_auth"] = "wpa2";
network["ap_ip"] = WiFi.softAPIP().toString(); network["ap_ip"] = WiFi.softAPIP().toString();
network["sta_enabled"] = wifiNetworkCount > 0; network["sta_enabled"] = wifiNetworkCount > 0;
network["sta_connected"] = WiFi.status() == WL_CONNECTED; network["sta_connected"] = WiFi.status() == WL_CONNECTED;
@@ -2229,6 +2380,8 @@ void handleHealth() {
JsonObject network = doc.createNestedObject("network"); JsonObject network = doc.createNestedObject("network");
network["ap_enabled"] = true; network["ap_enabled"] = true;
network["ap_ssid"] = apSsid;
network["ap_auth"] = "wpa2";
network["ap_ip"] = WiFi.softAPIP().toString(); network["ap_ip"] = WiFi.softAPIP().toString();
network["sta_enabled"] = wifiNetworkCount > 0; network["sta_enabled"] = wifiNetworkCount > 0;
network["sta_connected"] = WiFi.status() == WL_CONNECTED; network["sta_connected"] = WiFi.status() == WL_CONNECTED;
@@ -2836,15 +2989,10 @@ void setup() {
initBms(); initBms();
loadWifiConfig(); loadWifiConfig();
loadApConfig();
WiFi.mode(WIFI_AP_STA); WiFi.mode(WIFI_AP_STA);
bool apResult = WiFi.softAP("OverlandController"); startAccessPoint();
if (apResult) {
Serial.println("AP Started");
Serial.print("AP IP: ");
Serial.println(WiFi.softAPIP());
}
connectStaWifi(); connectStaWifi();
@@ -2861,6 +3009,8 @@ void setup() {
server.on(API_V1("/config/export"), HTTP_GET, handleExportConfig); server.on(API_V1("/config/export"), HTTP_GET, handleExportConfig);
server.on(API_V1("/config/import"), HTTP_POST, handleImportConfig); server.on(API_V1("/config/import"), HTTP_POST, handleImportConfig);
server.on(API_V1("/config/wifi"), HTTP_GET, handleGetWifiConfig); server.on(API_V1("/config/wifi"), HTTP_GET, handleGetWifiConfig);
server.on(API_V1("/config/ap"), HTTP_GET, handleGetApConfig);
server.on(API_V1("/config/ap"), HTTP_POST, handleUpdateApConfig);
server.on(API_V1("/config/wifi"), HTTP_POST, handleUpdateWifiConfig); server.on(API_V1("/config/wifi"), HTTP_POST, handleUpdateWifiConfig);
server.on(API_V1("/wifi/connect"), HTTP_POST, handleWifiConnect); server.on(API_V1("/wifi/connect"), HTTP_POST, handleWifiConnect);
server.on(API_V1("/wifi/clear"), HTTP_POST, handleWifiClear); server.on(API_V1("/wifi/clear"), HTTP_POST, handleWifiClear);
+25
View File
@@ -304,3 +304,28 @@ def test_embedded_webui_uses_partial_status_for_overview_polling():
assert 'fetch(statusUrl(),{cache:"no-store"})' in source assert 'fetch(statusUrl(),{cache:"no-store"})' in source
assert 'function mergeStatus(previous,next)' in source assert 'function mergeStatus(previous,next)' in source
assert 'api("/status?fields=config")' in source assert 'api("/status?fields=config")' in source
def test_cargo_ap_uses_wpa2_password():
source = firmware_source()
assert "Preferences apPrefs;" in source
assert "generateDefaultApPassword" in source
assert "validApPassword" in source
assert "loadApConfig();" in source
assert "startAccessPoint()" in source
assert "WiFi.softAP(apSsid.c_str(), apPassword.c_str())" in source
assert 'network["ap_auth"] = "wpa2";' in source
assert 'server.on(API_V1("/config/ap"), HTTP_GET, handleGetApConfig);' in source
assert 'server.on(API_V1("/config/ap"), HTTP_POST, handleUpdateApConfig);' in source
def test_cargo_ap_password_is_not_returned_by_get_config_ap():
source = firmware_source()
get_start = source.index("void handleGetApConfig()")
get_end = source.index("void handleUpdateApConfig()")
get_body = source[get_start:get_end]
assert 'response["password_set"]' in get_body
assert 'response["password"]' not in get_body