wifi: add WPA2 AP setup API and pairing roadmap
This commit is contained in:
+41
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user