diff --git a/docs/API.md b/docs/API.md index e8e2a89..d5e39f3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -850,3 +850,44 @@ POST /api/v1/system/restart ``` 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 020443a..ba5f908 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -170,3 +170,26 @@ Future optional architecture: -> Cargo ESP32 outbound connection 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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7041871..deff2b2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -60,3 +60,46 @@ UI direction: - Dashboard alert sounds - More vehicle PIDs - 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. diff --git a/firmware/esp32/overland-controller/overland-controller.ino b/firmware/esp32/overland-controller/overland-controller.ino index a2c5a80..6d070c2 100644 --- a/firmware/esp32/overland-controller/overland-controller.ino +++ b/firmware/esp32/overland-controller/overland-controller.ino @@ -2,6 +2,7 @@ #include #include #include +#include #include #include "config.h" @@ -781,6 +782,13 @@ HardwareSerial DashboardSerial(2); String uartLineBuffer; 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; 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 bestIndex = -1; int bestPriority = 1000000; @@ -1327,6 +1476,8 @@ void buildStatusDocument(JsonDocument& doc) { network["wifi_enabled"] = true; network["uart_connected"] = true; network["ap_enabled"] = true; + network["ap_ssid"] = apSsid; + network["ap_auth"] = "wpa2"; network["ap_ip"] = WiFi.softAPIP().toString(); network["sta_enabled"] = wifiNetworkCount > 0; network["sta_connected"] = WiFi.status() == WL_CONNECTED; @@ -2229,6 +2380,8 @@ void handleHealth() { JsonObject network = doc.createNestedObject("network"); network["ap_enabled"] = true; + network["ap_ssid"] = apSsid; + network["ap_auth"] = "wpa2"; network["ap_ip"] = WiFi.softAPIP().toString(); network["sta_enabled"] = wifiNetworkCount > 0; network["sta_connected"] = WiFi.status() == WL_CONNECTED; @@ -2836,15 +2989,10 @@ void setup() { initBms(); loadWifiConfig(); + loadApConfig(); WiFi.mode(WIFI_AP_STA); - bool apResult = WiFi.softAP("OverlandController"); - - if (apResult) { - Serial.println("AP Started"); - Serial.print("AP IP: "); - Serial.println(WiFi.softAPIP()); - } + startAccessPoint(); connectStaWifi(); @@ -2861,6 +3009,8 @@ void setup() { server.on(API_V1("/config/export"), HTTP_GET, handleExportConfig); server.on(API_V1("/config/import"), HTTP_POST, handleImportConfig); 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("/wifi/connect"), HTTP_POST, handleWifiConnect); server.on(API_V1("/wifi/clear"), HTTP_POST, handleWifiClear); diff --git a/tests/test_http_api_contract.py b/tests/test_http_api_contract.py index 2442645..34f6e1d 100644 --- a/tests/test_http_api_contract.py +++ b/tests/test_http_api_contract.py @@ -304,3 +304,28 @@ def test_embedded_webui_uses_partial_status_for_overview_polling(): assert 'fetch(statusUrl(),{cache:"no-store"})' in source assert 'function mergeStatus(previous,next)' 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