From 6cc93667ab97e18b544f0e2a1471824d25513556 Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 7 Jun 2026 10:31:41 -0600 Subject: [PATCH] Add maintenance API endpoints --- docs/API.md | 132 ++++- docs/ARCHITECTURE.md | 4 + docs/CONFIGURATION.md | 13 + docs/PARITY_MATRIX.md | 4 + docs/dashboard-esp32s3.md | 13 +- .../overland-controller.ino | 479 ++++++++++++++---- tests/fixtures/http_api/config_response.json | 4 +- tests/fixtures/http_api/status_response.json | 1 + .../http_api/temp_assign_response.json | 4 +- .../http_api/temp_clear_response.json | 4 +- tests/test_http_api_contract.py | 41 +- 11 files changed, 576 insertions(+), 123 deletions(-) diff --git a/docs/API.md b/docs/API.md index 4357769..015c063 100644 --- a/docs/API.md +++ b/docs/API.md @@ -26,11 +26,15 @@ Current firmware version: ```text GET / +GET /api/v1/health +GET /api/v1/capabilities GET /api/v1/status GET /api/v1/config +GET /api/v1/config/export POST /api/v1/relay/set +POST /api/v1/config/import POST /api/v1/config/device POST /api/v1/config/relay POST /api/v1/config/temp @@ -75,6 +79,70 @@ POST /api/v1/relay/set The dashboard is a client only. It may cache last-known values for display, but the Cargo ESP32 remains the source of truth for relay state, BMS state, alarms, and configuration. +## Health + +### GET /api/v1/health + +Returns a lightweight liveness response for dashboards and service tools. + +```json +{ + "type": "health_response", + "ok": true, + "api_version": "v1", + "firmware_name": "overland-controller", + "firmware_version": "0.4.0", + "uptime_seconds": 123, + "network": { + "ap_enabled": true, + "ap_ip": "192.168.4.1", + "sta_enabled": true, + "sta_connected": false, + "sta_ssid": "", + "sta_ip": "" + }, + "bms": { + "configured": true, + "connected": false + } +} +``` + +## Capabilities + +### GET /api/v1/capabilities + +Returns API and feature discovery data for clients. + +```json +{ + "type": "capabilities_response", + "ok": true, + "api_version": "v1", + "firmware_name": "overland-controller", + "firmware_version": "0.4.0", + "endpoints": [ + "GET /api/v1/status", + "POST /api/v1/relay/set" + ], + "limits": { + "relay_count": 2, + "temperature_sensor_count": 8, + "runtime_temperature_status_count": 4, + "wifi_network_count": 3 + }, + "features": { + "relay_control": true, + "temperature_scan": true, + "wifi_config": true, + "config_backup_restore": true, + "bms_setup": true, + "uart_json": true, + "root_compatibility_aliases": true + } +} +``` + ## Status ### GET /api/v1/status @@ -261,19 +329,19 @@ Returns saved controller configuration. "address": "AA:BB:CC:DD:EE:FF", "address_type": "public" }, + "temperature_sensor_count": 4, "temperature_sensors": [ { "id": "temp_1", "name": "Cabin", "address": "28:AA:BB:CC:DD:EE:FF:00", - "enabled": true + "enabled": true, + "weather": false } ] } ``` -Note: current `GET /api/v1/config` omits the `weather` field, while `/api/v1/status.config.temperature_sensors[]` includes it. - ### POST /api/v1/config/device Updates the controller display name. @@ -368,6 +436,63 @@ Clears saved configuration and restores firmware defaults. Returns the reset config. +### GET /api/v1/config/export + +Returns a backup envelope containing controller config and saved WiFi networks. + +Important: export includes saved WiFi passwords so the backup can be restored. Treat the response as sensitive. + +```json +{ + "type": "config_export_response", + "ok": true, + "api_version": "v1", + "firmware_name": "overland-controller", + "firmware_version": "0.4.0", + "config": { + "device_name": "Overland Controller", + "temperature_sensor_count": 4, + "relays": [], + "bms": {}, + "temperature_sensors": [] + }, + "wifi": { + "networks": [ + { + "index": 1, + "ssid": "Starlink", + "priority": 1, + "password_set": true, + "password": "password_here" + } + ] + } +} +``` + +### POST /api/v1/config/import + +Restores a config backup. The preferred request shape is the same envelope returned by `GET /api/v1/config/export`. + +```json +{ + "config": { + "device_name": "Overland Controller", + "temperature_sensor_count": 4, + "relays": [], + "bms": {}, + "temperature_sensors": [] + }, + "wifi": { + "networks": [] + } +} +``` + +The endpoint also accepts the bare config object without an outer `config` field. When `wifi.networks` is included, saved STA WiFi networks are replaced and persisted. It does not initiate STA reconnect; use `POST /api/v1/wifi/connect` after import if needed. + +Returns the updated config. + ## Relay Control ### POST /api/v1/relay/set @@ -671,6 +796,7 @@ Known error codes: ```text invalid_json +invalid_config unknown_relay unknown_temp_sensor invalid_temp_selection diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7b61e3e..020443a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -93,8 +93,12 @@ HTTP is the primary integration contract for: Preferred direction: + GET /api/v1/health + GET /api/v1/capabilities GET /api/v1/status GET /api/v1/config + GET /api/v1/config/export + POST /api/v1/config/import POST /api/v1/relay/set Pre-versioned root routes remain registered as compatibility aliases for existing local clients. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 92af0ce..09b0062 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -166,6 +166,19 @@ After factory reset: - Temperature names return to generic defaults - BMS is unconfigured until selected or manually entered +## Backup And Restore + +Configuration backup includes controller config and saved STA WiFi networks. + +HTTP endpoints: + + GET /api/v1/config/export + POST /api/v1/config/import + +`config/export` includes saved WiFi passwords so the backup can be restored. Treat exported JSON as sensitive. + +`config/import` persists the restored config. If WiFi networks are imported, run `POST /api/v1/wifi/connect` afterward to attempt STA connection. + ## Configuration Persistence Configuration is stored using ESP32 non-volatile storage. diff --git a/docs/PARITY_MATRIX.md b/docs/PARITY_MATRIX.md index 7084c70..27455c7 100644 --- a/docs/PARITY_MATRIX.md +++ b/docs/PARITY_MATRIX.md @@ -12,8 +12,12 @@ Control surfaces: | Feature | USB Serial | UART JSON | HTTP API | Notes | |---|---:|---:|---:|---| +| Health | No | No | Yes | `/api/v1/health` is lightweight HTTP liveness | +| Capabilities | No | No | Yes | `/api/v1/capabilities` supports client discovery | | Status | Yes | Yes | Yes | `/api/v1/status` equals `status_request` | | Config view | Yes | Yes | Yes | `/api/v1/config` equals `config_request` | +| Config export | No | No | Yes | `/api/v1/config/export` includes WiFi passwords | +| Config import | No | No | Yes | `/api/v1/config/import` persists restored config | | Relay control | Yes | Yes | Yes | Preferred HTTP endpoint is `POST /api/v1/relay/set` | | Device name config | Yes | Yes | Yes | `devicename`, `config_device`, `/api/v1/config/device` | | Relay config | Yes | Yes | Yes | `relayname`, `config_relay`, `/api/v1/config/relay` | diff --git a/docs/dashboard-esp32s3.md b/docs/dashboard-esp32s3.md index 184499d..dee3210 100644 --- a/docs/dashboard-esp32s3.md +++ b/docs/dashboard-esp32s3.md @@ -47,11 +47,15 @@ All grounds remain common. ## API endpoints consumed Minimum dashboard MVP: +- GET /api/v1/health - GET /api/v1/status - POST /api/v1/relay/set Potential later use: +- GET /api/v1/capabilities - GET /api/v1/config +- GET /api/v1/config/export +- POST /api/v1/config/import - POST /api/v1/config/save - POST /api/v1/temps/scan - POST /api/v1/temps/assign @@ -120,10 +124,11 @@ Then interactive: 1. Dashboard boots. 2. Connects to cargo ESP32 AP. -3. Polls GET /api/v1/status. -4. Shows disconnected state until valid JSON is received. -5. Updates dashboard on a fixed interval. -6. Relay buttons call POST /api/v1/relay/set. +3. Checks GET /api/v1/health. +4. Polls GET /api/v1/status. +5. Shows disconnected state until valid JSON is received. +6. Updates dashboard on a fixed interval. +7. Relay buttons call POST /api/v1/relay/set. ## Polling Guidance diff --git a/firmware/esp32/overland-controller/overland-controller.ino b/firmware/esp32/overland-controller/overland-controller.ino index dab79f9..111f39e 100644 --- a/firmware/esp32/overland-controller/overland-controller.ino +++ b/firmware/esp32/overland-controller/overland-controller.ino @@ -861,6 +861,240 @@ void printNetworkStatus() { } +void buildConfigDocument(JsonObject doc) { + doc["device_name"] = appConfig.deviceName; + + JsonArray relaysArray = doc.createNestedArray("relays"); + for (int i = 0; i < MAX_RELAYS; i++) { + JsonObject relay = relaysArray.createNestedObject(); + relay["id"] = appConfig.relays[i].id; + relay["name"] = appConfig.relays[i].name; + relay["pin"] = appConfig.relays[i].pin; + relay["enabled"] = appConfig.relays[i].enabled; + } + + JsonObject bms = doc.createNestedObject("bms"); + bms["enabled"] = appConfig.bms.enabled; + bms["name"] = appConfig.bms.name; + bms["address"] = appConfig.bms.address; + bms["address_type"] = appConfig.bms.addressType; + + doc["temperature_sensor_count"] = appConfig.tempSensorCount; + + JsonArray temps = doc.createNestedArray("temperature_sensors"); + for (int i = 0; i < MAX_TEMP_SENSORS; i++) { + JsonObject temp = temps.createNestedObject(); + temp["id"] = appConfig.tempSensors[i].id; + temp["name"] = appConfig.tempSensors[i].name; + temp["address"] = appConfig.tempSensors[i].address; + temp["enabled"] = appConfig.tempSensors[i].enabled; + temp["weather"] = appConfig.tempSensors[i].weather; + } +} + +void buildWifiConfigDocument(JsonObject wifi, bool includePasswords) { + wifi["ap_enabled"] = true; + wifi["sta_enabled"] = wifiNetworkCount > 0; + wifi["network_count"] = wifiNetworkCount; + wifi["active_ssid"] = activeStaSsid; + wifi["sta_connected"] = WiFi.status() == WL_CONNECTED; + wifi["ap_ip"] = WiFi.softAPIP().toString(); + + if (WiFi.status() == WL_CONNECTED) { + wifi["sta_ip"] = WiFi.localIP().toString(); + } else { + wifi["sta_ip"] = ""; + } + + JsonArray networks = wifi.createNestedArray("networks"); + for (int i = 0; i < wifiNetworkCount; i++) { + JsonObject network = networks.createNestedObject(); + network["index"] = i + 1; + network["ssid"] = staSsids[i]; + network["priority"] = staPriorities[i]; + network["password_set"] = staPasswords[i].length() > 0; + if (includePasswords) { + network["password"] = staPasswords[i]; + } + } +} + +void applyWifiNetworks(JsonArray networks) { + wifiNetworkCount = 0; + + for (JsonObject network : networks) { + if (wifiNetworkCount >= MAX_WIFI_NETWORKS) break; + + String ssid = network["ssid"] | ""; + String password = network["password"] | ""; + + ssid.trim(); + password.trim(); + + if (ssid.length() == 0) { + continue; + } + + if (password.length() == 0) { + for (int oldIndex = 0; oldIndex < MAX_WIFI_NETWORKS; oldIndex++) { + if (staSsids[oldIndex] == ssid && staPasswords[oldIndex].length() > 0) { + password = staPasswords[oldIndex]; + break; + } + } + } + + int priority = network["priority"] | (wifiNetworkCount + 1); + if (priority <= 0) priority = wifiNetworkCount + 1; + + staSsids[wifiNetworkCount] = ssid; + staPasswords[wifiNetworkCount] = password; + staPriorities[wifiNetworkCount] = priority; + wifiNetworkCount++; + } + + for (int i = wifiNetworkCount; i < MAX_WIFI_NETWORKS; i++) { + staSsids[i] = ""; + staPasswords[i] = ""; + staPriorities[i] = i + 1; + } +} + +int findRelayConfigIndex(const String& id); +int findTempSensorConfigIndex(const String& id); + +bool validateConfigImport(JsonObject doc, const char*& errorCode) { + if (doc["device_name"].isNull() || !doc["device_name"].is()) { + errorCode = "invalid_config"; + return false; + } + + if (!doc["relays"].isNull() && !doc["relays"].is()) { + errorCode = "invalid_config"; + return false; + } + + if (!doc["temperature_sensors"].isNull() && !doc["temperature_sensors"].is()) { + errorCode = "invalid_config"; + return false; + } + + if (!doc["bms"].isNull() && !doc["bms"].is()) { + errorCode = "invalid_config"; + return false; + } + + if (!doc["wifi"].isNull() && !doc["wifi"].is()) { + errorCode = "invalid_config"; + return false; + } + + if (doc["temperature_sensor_count"].is()) { + int count = doc["temperature_sensor_count"].as(); + if (count < 0 || count > MAX_TEMP_SENSORS) { + errorCode = "invalid_config"; + return false; + } + } + + JsonArray relays = doc["relays"].as(); + if (!relays.isNull()) { + for (JsonObject relay : relays) { + String id = relay["id"] | ""; + if (findRelayConfigIndex(id) < 0) { + errorCode = "unknown_relay"; + return false; + } + } + } + + JsonArray temps = doc["temperature_sensors"].as(); + if (!temps.isNull()) { + for (JsonObject temp : temps) { + String id = temp["id"] | ""; + if (findTempSensorConfigIndex(id) < 0) { + errorCode = "unknown_temp_sensor"; + return false; + } + } + } + + JsonObject bms = doc["bms"].as(); + if (!bms.isNull() && bms["address_type"].is()) { + String addressType = bms["address_type"].as(); + addressType.toLowerCase(); + if (addressType != "public" && addressType != "random") { + errorCode = "invalid_config"; + return false; + } + } + + JsonObject wifi = doc["wifi"].as(); + if (!wifi.isNull()) { + JsonArray networks = wifi["networks"].as(); + if (networks.isNull()) { + errorCode = "invalid_config"; + return false; + } + } + + return true; +} + +void applyImportedConfig(JsonObject doc) { + if (doc["device_name"].is()) { + appConfig.deviceName = doc["device_name"].as(); + } + + if (doc["temperature_sensor_count"].is()) { + appConfig.tempSensorCount = doc["temperature_sensor_count"].as(); + } + + JsonArray relays = doc["relays"].as(); + if (!relays.isNull()) { + for (JsonObject relay : relays) { + int index = findRelayConfigIndex(relay["id"] | ""); + if (index < 0) continue; + + if (relay["name"].is()) appConfig.relays[index].name = relay["name"].as(); + if (relay["enabled"].is()) appConfig.relays[index].enabled = relay["enabled"].as(); + } + } + + JsonObject bms = doc["bms"].as(); + if (!bms.isNull()) { + if (bms["enabled"].is()) appConfig.bms.enabled = bms["enabled"].as(); + if (bms["name"].is()) appConfig.bms.name = bms["name"].as(); + if (bms["address"].is()) appConfig.bms.address = bms["address"].as(); + if (bms["address_type"].is()) { + appConfig.bms.addressType = bms["address_type"].as(); + appConfig.bms.addressType.toLowerCase(); + } + } + + JsonArray temps = doc["temperature_sensors"].as(); + if (!temps.isNull()) { + for (JsonObject temp : temps) { + int index = findTempSensorConfigIndex(temp["id"] | ""); + if (index < 0) continue; + + if (temp["name"].is()) appConfig.tempSensors[index].name = temp["name"].as(); + if (temp["address"].is()) appConfig.tempSensors[index].address = temp["address"].as(); + if (temp["enabled"].is()) appConfig.tempSensors[index].enabled = temp["enabled"].as(); + if (temp["weather"].is()) appConfig.tempSensors[index].weather = temp["weather"].as(); + } + } + + JsonObject wifi = doc["wifi"].as(); + if (!wifi.isNull()) { + JsonArray networks = wifi["networks"].as(); + applyWifiNetworks(networks); + saveWifiConfig(); + } + + saveConfig(); +} + float calculateRuntimeHours() { if (!bmsData.valid || bmsData.current >= 0) return 0; @@ -997,32 +1231,7 @@ void buildStatusDocument(JsonDocument& doc) { system["uptime_seconds"] = millis() / 1000; JsonObject configObj = doc.createNestedObject("config"); - configObj["device_name"] = appConfig.deviceName; - - JsonArray configRelays = configObj.createNestedArray("relays"); - for (int i = 0; i < MAX_RELAYS; i++) { - JsonObject relayConfig = configRelays.createNestedObject(); - relayConfig["id"] = appConfig.relays[i].id; - relayConfig["name"] = appConfig.relays[i].name; - relayConfig["pin"] = appConfig.relays[i].pin; - relayConfig["enabled"] = appConfig.relays[i].enabled; - } - - JsonObject bmsConfig = configObj.createNestedObject("bms"); - bmsConfig["enabled"] = appConfig.bms.enabled; - bmsConfig["name"] = appConfig.bms.name; - bmsConfig["address"] = appConfig.bms.address; - bmsConfig["address_type"] = appConfig.bms.addressType; - - JsonArray tempConfigs = configObj.createNestedArray("temperature_sensors"); - for (int i = 0; i < MAX_TEMP_SENSORS; i++) { - JsonObject tempConfig = tempConfigs.createNestedObject(); - tempConfig["id"] = appConfig.tempSensors[i].id; - tempConfig["name"] = appConfig.tempSensors[i].name; - tempConfig["address"] = appConfig.tempSensors[i].address; - tempConfig["enabled"] = appConfig.tempSensors[i].enabled; - tempConfig["weather"] = appConfig.tempSensors[i].weather; - } + buildConfigDocument(configObj); } void sendStatus(Stream& output, bool pretty = false) { @@ -1850,32 +2059,8 @@ void handleDebugSerial() { void sendConfigJson() { DynamicJsonDocument doc(4096); - - doc["device_name"] = appConfig.deviceName; - - JsonArray relaysArray = doc.createNestedArray("relays"); - for (int i = 0; i < MAX_RELAYS; i++) { - JsonObject relay = relaysArray.createNestedObject(); - relay["id"] = appConfig.relays[i].id; - relay["name"] = appConfig.relays[i].name; - relay["pin"] = appConfig.relays[i].pin; - relay["enabled"] = appConfig.relays[i].enabled; - } - - JsonObject bms = doc.createNestedObject("bms"); - bms["enabled"] = appConfig.bms.enabled; - bms["name"] = appConfig.bms.name; - bms["address"] = appConfig.bms.address; - bms["address_type"] = appConfig.bms.addressType; - - JsonArray temps = doc.createNestedArray("temperature_sensors"); - for (int i = 0; i < MAX_TEMP_SENSORS; i++) { - JsonObject temp = temps.createNestedObject(); - temp["id"] = appConfig.tempSensors[i].id; - temp["name"] = appConfig.tempSensors[i].name; - temp["address"] = appConfig.tempSensors[i].address; - temp["enabled"] = appConfig.tempSensors[i].enabled; - } + JsonObject root = doc.to(); + buildConfigDocument(root); String output; serializeJson(doc, output); @@ -1905,6 +2090,131 @@ void handleGetConfig() { sendConfigJson(); } +void handleHealth() { + DynamicJsonDocument doc(1024); + + doc["type"] = "health_response"; + doc["ok"] = true; + doc["api_version"] = "v1"; + doc["firmware_name"] = FIRMWARE_NAME; + doc["firmware_version"] = FIRMWARE_VERSION; + doc["uptime_seconds"] = millis() / 1000; + + JsonObject network = doc.createNestedObject("network"); + network["ap_enabled"] = true; + network["ap_ip"] = WiFi.softAPIP().toString(); + network["sta_enabled"] = wifiNetworkCount > 0; + network["sta_connected"] = WiFi.status() == WL_CONNECTED; + network["sta_ssid"] = activeStaSsid; + network["sta_ip"] = WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : ""; + + JsonObject bms = doc.createNestedObject("bms"); + bms["configured"] = appConfig.bms.enabled && appConfig.bms.address.length() > 0; + bms["connected"] = bmsData.connected; + + String output; + serializeJson(doc, output); + server.send(200, "application/json", output); +} + +void handleCapabilities() { + DynamicJsonDocument doc(2048); + + doc["type"] = "capabilities_response"; + doc["ok"] = true; + doc["api_version"] = "v1"; + doc["firmware_name"] = FIRMWARE_NAME; + doc["firmware_version"] = FIRMWARE_VERSION; + + JsonArray endpoints = doc.createNestedArray("endpoints"); + endpoints.add("GET /api/v1/health"); + endpoints.add("GET /api/v1/capabilities"); + endpoints.add("GET /api/v1/status"); + endpoints.add("GET /api/v1/config"); + endpoints.add("GET /api/v1/config/export"); + endpoints.add("POST /api/v1/config/import"); + endpoints.add("POST /api/v1/relay/set"); + endpoints.add("GET /api/v1/config/wifi"); + endpoints.add("POST /api/v1/config/wifi"); + endpoints.add("POST /api/v1/wifi/connect"); + endpoints.add("POST /api/v1/wifi/clear"); + endpoints.add("POST /api/v1/temps/scan"); + endpoints.add("POST /api/v1/temps/assign"); + endpoints.add("POST /api/v1/temps/clear"); + endpoints.add("POST /api/v1/bms/setup/enter"); + endpoints.add("POST /api/v1/bms/setup/exit"); + endpoints.add("POST /api/v1/bms/scan"); + endpoints.add("POST /api/v1/bms/select"); + + JsonObject limits = doc.createNestedObject("limits"); + limits["relay_count"] = MAX_RELAYS; + limits["temperature_sensor_count"] = MAX_TEMP_SENSORS; + limits["runtime_temperature_status_count"] = appConfig.tempSensorCount > 4 ? 4 : appConfig.tempSensorCount; + limits["wifi_network_count"] = MAX_WIFI_NETWORKS; + + JsonObject features = doc.createNestedObject("features"); + features["relay_control"] = true; + features["temperature_scan"] = true; + features["wifi_config"] = true; + features["config_backup_restore"] = true; + features["bms_setup"] = true; + features["uart_json"] = true; + features["root_compatibility_aliases"] = true; + + String output; + serializeJson(doc, output); + server.send(200, "application/json", output); +} + +void handleExportConfig() { + DynamicJsonDocument doc(6144); + + doc["type"] = "config_export_response"; + doc["ok"] = true; + doc["api_version"] = "v1"; + doc["firmware_name"] = FIRMWARE_NAME; + doc["firmware_version"] = FIRMWARE_VERSION; + + JsonObject config = doc.createNestedObject("config"); + buildConfigDocument(config); + + JsonObject wifi = doc.createNestedObject("wifi"); + buildWifiConfigDocument(wifi, true); + + String output; + serializeJson(doc, output); + server.send(200, "application/json", output); +} + +void handleImportConfig() { + DynamicJsonDocument request(6144); + DeserializationError error = deserializeJson(request, server.arg("plain")); + + if (error) { + server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); + return; + } + + JsonObject config = request["config"].as(); + JsonObject source = config.isNull() ? request.as() : config; + + if (!request["wifi"].isNull()) { + source["wifi"] = request["wifi"]; + } + + const char* errorCode = nullptr; + if (!validateConfigImport(source, errorCode)) { + String response = "{\"ok\":false,\"error\":\""; + response += errorCode ? errorCode : "invalid_config"; + response += "\"}"; + server.send(400, "application/json", response); + return; + } + + applyImportedConfig(source); + sendConfigJson(); +} + void handleUpdateDeviceConfig() { DynamicJsonDocument doc(512); DeserializationError error = deserializeJson(doc, server.arg("plain")); @@ -1995,27 +2305,7 @@ void sendWifiConfigJson() { doc["ok"] = true; JsonObject wifi = doc.createNestedObject("wifi"); - wifi["ap_enabled"] = true; - wifi["sta_enabled"] = wifiNetworkCount > 0; - wifi["network_count"] = wifiNetworkCount; - wifi["active_ssid"] = activeStaSsid; - wifi["sta_connected"] = WiFi.status() == WL_CONNECTED; - wifi["ap_ip"] = WiFi.softAPIP().toString(); - - if (WiFi.status() == WL_CONNECTED) { - wifi["sta_ip"] = WiFi.localIP().toString(); - } else { - wifi["sta_ip"] = ""; - } - - JsonArray networks = wifi.createNestedArray("networks"); - for (int i = 0; i < wifiNetworkCount; i++) { - JsonObject network = networks.createNestedObject(); - network["index"] = i + 1; - network["ssid"] = staSsids[i]; - network["priority"] = staPriorities[i]; - network["password_set"] = staPasswords[i].length() > 0; - } + buildWifiConfigDocument(wifi, false); String output; serializeJson(doc, output); @@ -2038,44 +2328,7 @@ void handleUpdateWifiConfig() { JsonArray networks = doc["networks"].as(); if (!networks.isNull()) { - wifiNetworkCount = 0; - - for (JsonObject network : networks) { - if (wifiNetworkCount >= MAX_WIFI_NETWORKS) break; - - String ssid = network["ssid"] | ""; - String password = network["password"] | ""; - - ssid.trim(); - password.trim(); - - if (ssid.length() == 0) { - continue; - } - - if (password.length() == 0) { - for (int oldIndex = 0; oldIndex < MAX_WIFI_NETWORKS; oldIndex++) { - if (staSsids[oldIndex] == ssid && staPasswords[oldIndex].length() > 0) { - password = staPasswords[oldIndex]; - break; - } - } - } - - int priority = network["priority"] | (wifiNetworkCount + 1); - if (priority <= 0) priority = wifiNetworkCount + 1; - - staSsids[wifiNetworkCount] = ssid; - staPasswords[wifiNetworkCount] = password; - staPriorities[wifiNetworkCount] = priority; - wifiNetworkCount++; - } - - for (int i = wifiNetworkCount; i < MAX_WIFI_NETWORKS; i++) { - staSsids[i] = ""; - staPasswords[i] = ""; - staPriorities[i] = i + 1; - } + applyWifiNetworks(networks); } else { if (doc["ssid"].is()) { staSsids[0] = doc["ssid"].as(); @@ -2435,9 +2688,13 @@ void setup() { }); server.on(API_V1("/status"), handleStatus); + server.on(API_V1("/health"), HTTP_GET, handleHealth); + server.on(API_V1("/capabilities"), HTTP_GET, handleCapabilities); server.on(API_V1("/relay/set"), HTTP_POST, handleSetRelayPost); server.on(API_V1("/config"), HTTP_GET, handleGetConfig); + 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/wifi"), HTTP_POST, handleUpdateWifiConfig); server.on(API_V1("/wifi/connect"), HTTP_POST, handleWifiConnect); diff --git a/tests/fixtures/http_api/config_response.json b/tests/fixtures/http_api/config_response.json index 141b23f..f4777ed 100644 --- a/tests/fixtures/http_api/config_response.json +++ b/tests/fixtures/http_api/config_response.json @@ -20,12 +20,14 @@ "address": "aa:bb:cc:dd:ee:ff", "address_type": "public" }, + "temperature_sensor_count": 4, "temperature_sensors": [ { "id": "temp_1", "name": "Cabin", "address": "28ff001122334455", - "enabled": true + "enabled": true, + "weather": false } ] } diff --git a/tests/fixtures/http_api/status_response.json b/tests/fixtures/http_api/status_response.json index 97391eb..c180936 100644 --- a/tests/fixtures/http_api/status_response.json +++ b/tests/fixtures/http_api/status_response.json @@ -99,6 +99,7 @@ "address": "aa:bb:cc:dd:ee:ff", "address_type": "public" }, + "temperature_sensor_count": 4, "temperature_sensors": [ { "id": "temp_1", diff --git a/tests/fixtures/http_api/temp_assign_response.json b/tests/fixtures/http_api/temp_assign_response.json index 221b1f9..d3c8702 100644 --- a/tests/fixtures/http_api/temp_assign_response.json +++ b/tests/fixtures/http_api/temp_assign_response.json @@ -7,12 +7,14 @@ "address": "aa:bb:cc:dd:ee:ff", "address_type": "public" }, + "temperature_sensor_count": 4, "temperature_sensors": [ { "id": "temp_1", "name": "Cabin", "address": "28ff001122334455", - "enabled": true + "enabled": true, + "weather": false } ] } diff --git a/tests/fixtures/http_api/temp_clear_response.json b/tests/fixtures/http_api/temp_clear_response.json index a42c549..efe4610 100644 --- a/tests/fixtures/http_api/temp_clear_response.json +++ b/tests/fixtures/http_api/temp_clear_response.json @@ -7,12 +7,14 @@ "address": "aa:bb:cc:dd:ee:ff", "address_type": "public" }, + "temperature_sensor_count": 4, "temperature_sensors": [ { "id": "temp_1", "name": "Cabin", "address": "", - "enabled": false + "enabled": false, + "weather": false } ] } diff --git a/tests/test_http_api_contract.py b/tests/test_http_api_contract.py index db9d45b..8ef2642 100644 --- a/tests/test_http_api_contract.py +++ b/tests/test_http_api_contract.py @@ -39,7 +39,11 @@ def test_firmware_registers_current_http_contract_routes(): expected_routes = { ("/api/v1/status", "ANY"), + ("/api/v1/health", "HTTP_GET"), + ("/api/v1/capabilities", "HTTP_GET"), ("/api/v1/config", "HTTP_GET"), + ("/api/v1/config/export", "HTTP_GET"), + ("/api/v1/config/import", "HTTP_POST"), ("/api/v1/relay/set", "HTTP_POST"), ("/api/v1/config/wifi", "HTTP_GET"), ("/api/v1/config/wifi", "HTTP_POST"), @@ -155,10 +159,42 @@ def test_status_fixture_matches_dashboard_contract_shape(): def test_config_fixture_matches_current_config_response_shape(): payload = load_fixture("config_response.json") - assert_keys(payload, ["device_name", "relays", "bms", "temperature_sensors"]) + assert_keys(payload, [ + "device_name", + "relays", + "bms", + "temperature_sensor_count", + "temperature_sensors", + ]) assert_keys(payload["relays"][0], ["id", "name", "pin", "enabled"]) assert_keys(payload["bms"], ["enabled", "name", "address", "address_type"]) - assert_keys(payload["temperature_sensors"][0], ["id", "name", "address", "enabled"]) + assert_keys(payload["temperature_sensors"][0], [ + "id", + "name", + "address", + "enabled", + "weather", + ]) + + +def test_health_and_capabilities_contracts_are_registered(): + source = firmware_source() + + assert '"health_response"' in source + assert '"capabilities_response"' in source + assert '"GET /api/v1/health"' in source + assert '"GET /api/v1/capabilities"' in source + assert '"config_backup_restore"' in source + + +def test_config_backup_restore_contract_is_registered(): + source = firmware_source() + + assert 'API_V1("/config/export")' in source + assert 'API_V1("/config/import")' in source + assert '"config_export_response"' in source + assert '"password"' in source + assert '"invalid_config"' in source def test_relay_set_contract_fixtures_and_firmware_errors(): @@ -236,6 +272,7 @@ def test_http_error_fixture_and_known_error_codes_are_stable(): for error_code in [ "invalid_json", + "invalid_config", "unknown_relay", "unknown_temp_sensor", "invalid_temp_selection",