Add maintenance API endpoints

This commit is contained in:
2026-06-07 10:31:41 -06:00
parent d87080d23f
commit 6cc93667ab
11 changed files with 576 additions and 123 deletions
+129 -3
View File
@@ -26,11 +26,15 @@ Current firmware version:
```text ```text
GET / GET /
GET /api/v1/health
GET /api/v1/capabilities
GET /api/v1/status GET /api/v1/status
GET /api/v1/config GET /api/v1/config
GET /api/v1/config/export
POST /api/v1/relay/set POST /api/v1/relay/set
POST /api/v1/config/import
POST /api/v1/config/device POST /api/v1/config/device
POST /api/v1/config/relay POST /api/v1/config/relay
POST /api/v1/config/temp 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. 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 ## Status
### GET /api/v1/status ### GET /api/v1/status
@@ -261,19 +329,19 @@ Returns saved controller configuration.
"address": "AA:BB:CC:DD:EE:FF", "address": "AA:BB:CC:DD:EE:FF",
"address_type": "public" "address_type": "public"
}, },
"temperature_sensor_count": 4,
"temperature_sensors": [ "temperature_sensors": [
{ {
"id": "temp_1", "id": "temp_1",
"name": "Cabin", "name": "Cabin",
"address": "28:AA:BB:CC:DD:EE:FF:00", "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 ### POST /api/v1/config/device
Updates the controller display name. Updates the controller display name.
@@ -368,6 +436,63 @@ Clears saved configuration and restores firmware defaults.
Returns the reset config. 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 ## Relay Control
### POST /api/v1/relay/set ### POST /api/v1/relay/set
@@ -671,6 +796,7 @@ Known error codes:
```text ```text
invalid_json invalid_json
invalid_config
unknown_relay unknown_relay
unknown_temp_sensor unknown_temp_sensor
invalid_temp_selection invalid_temp_selection
+4
View File
@@ -93,8 +93,12 @@ HTTP is the primary integration contract for:
Preferred direction: Preferred direction:
GET /api/v1/health
GET /api/v1/capabilities
GET /api/v1/status GET /api/v1/status
GET /api/v1/config GET /api/v1/config
GET /api/v1/config/export
POST /api/v1/config/import
POST /api/v1/relay/set POST /api/v1/relay/set
Pre-versioned root routes remain registered as compatibility aliases for existing local clients. Pre-versioned root routes remain registered as compatibility aliases for existing local clients.
+13
View File
@@ -166,6 +166,19 @@ After factory reset:
- Temperature names return to generic defaults - Temperature names return to generic defaults
- BMS is unconfigured until selected or manually entered - 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 Persistence
Configuration is stored using ESP32 non-volatile storage. Configuration is stored using ESP32 non-volatile storage.
+4
View File
@@ -12,8 +12,12 @@ Control surfaces:
| Feature | USB Serial | UART JSON | HTTP API | Notes | | 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` | | Status | Yes | Yes | Yes | `/api/v1/status` equals `status_request` |
| Config view | Yes | Yes | Yes | `/api/v1/config` equals `config_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` | | 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` | | 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` | | Relay config | Yes | Yes | Yes | `relayname`, `config_relay`, `/api/v1/config/relay` |
+9 -4
View File
@@ -47,11 +47,15 @@ All grounds remain common.
## API endpoints consumed ## API endpoints consumed
Minimum dashboard MVP: Minimum dashboard MVP:
- GET /api/v1/health
- GET /api/v1/status - GET /api/v1/status
- POST /api/v1/relay/set - POST /api/v1/relay/set
Potential later use: Potential later use:
- GET /api/v1/capabilities
- GET /api/v1/config - GET /api/v1/config
- GET /api/v1/config/export
- POST /api/v1/config/import
- POST /api/v1/config/save - POST /api/v1/config/save
- POST /api/v1/temps/scan - POST /api/v1/temps/scan
- POST /api/v1/temps/assign - POST /api/v1/temps/assign
@@ -120,10 +124,11 @@ Then interactive:
1. Dashboard boots. 1. Dashboard boots.
2. Connects to cargo ESP32 AP. 2. Connects to cargo ESP32 AP.
3. Polls GET /api/v1/status. 3. Checks GET /api/v1/health.
4. Shows disconnected state until valid JSON is received. 4. Polls GET /api/v1/status.
5. Updates dashboard on a fixed interval. 5. Shows disconnected state until valid JSON is received.
6. Relay buttons call POST /api/v1/relay/set. 6. Updates dashboard on a fixed interval.
7. Relay buttons call POST /api/v1/relay/set.
## Polling Guidance ## Polling Guidance
@@ -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<String>()) {
errorCode = "invalid_config";
return false;
}
if (!doc["relays"].isNull() && !doc["relays"].is<JsonArray>()) {
errorCode = "invalid_config";
return false;
}
if (!doc["temperature_sensors"].isNull() && !doc["temperature_sensors"].is<JsonArray>()) {
errorCode = "invalid_config";
return false;
}
if (!doc["bms"].isNull() && !doc["bms"].is<JsonObject>()) {
errorCode = "invalid_config";
return false;
}
if (!doc["wifi"].isNull() && !doc["wifi"].is<JsonObject>()) {
errorCode = "invalid_config";
return false;
}
if (doc["temperature_sensor_count"].is<int>()) {
int count = doc["temperature_sensor_count"].as<int>();
if (count < 0 || count > MAX_TEMP_SENSORS) {
errorCode = "invalid_config";
return false;
}
}
JsonArray relays = doc["relays"].as<JsonArray>();
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<JsonArray>();
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<JsonObject>();
if (!bms.isNull() && bms["address_type"].is<String>()) {
String addressType = bms["address_type"].as<String>();
addressType.toLowerCase();
if (addressType != "public" && addressType != "random") {
errorCode = "invalid_config";
return false;
}
}
JsonObject wifi = doc["wifi"].as<JsonObject>();
if (!wifi.isNull()) {
JsonArray networks = wifi["networks"].as<JsonArray>();
if (networks.isNull()) {
errorCode = "invalid_config";
return false;
}
}
return true;
}
void applyImportedConfig(JsonObject doc) {
if (doc["device_name"].is<String>()) {
appConfig.deviceName = doc["device_name"].as<String>();
}
if (doc["temperature_sensor_count"].is<int>()) {
appConfig.tempSensorCount = doc["temperature_sensor_count"].as<int>();
}
JsonArray relays = doc["relays"].as<JsonArray>();
if (!relays.isNull()) {
for (JsonObject relay : relays) {
int index = findRelayConfigIndex(relay["id"] | "");
if (index < 0) continue;
if (relay["name"].is<String>()) appConfig.relays[index].name = relay["name"].as<String>();
if (relay["enabled"].is<bool>()) appConfig.relays[index].enabled = relay["enabled"].as<bool>();
}
}
JsonObject bms = doc["bms"].as<JsonObject>();
if (!bms.isNull()) {
if (bms["enabled"].is<bool>()) appConfig.bms.enabled = bms["enabled"].as<bool>();
if (bms["name"].is<String>()) appConfig.bms.name = bms["name"].as<String>();
if (bms["address"].is<String>()) appConfig.bms.address = bms["address"].as<String>();
if (bms["address_type"].is<String>()) {
appConfig.bms.addressType = bms["address_type"].as<String>();
appConfig.bms.addressType.toLowerCase();
}
}
JsonArray temps = doc["temperature_sensors"].as<JsonArray>();
if (!temps.isNull()) {
for (JsonObject temp : temps) {
int index = findTempSensorConfigIndex(temp["id"] | "");
if (index < 0) continue;
if (temp["name"].is<String>()) appConfig.tempSensors[index].name = temp["name"].as<String>();
if (temp["address"].is<String>()) appConfig.tempSensors[index].address = temp["address"].as<String>();
if (temp["enabled"].is<bool>()) appConfig.tempSensors[index].enabled = temp["enabled"].as<bool>();
if (temp["weather"].is<bool>()) appConfig.tempSensors[index].weather = temp["weather"].as<bool>();
}
}
JsonObject wifi = doc["wifi"].as<JsonObject>();
if (!wifi.isNull()) {
JsonArray networks = wifi["networks"].as<JsonArray>();
applyWifiNetworks(networks);
saveWifiConfig();
}
saveConfig();
}
float calculateRuntimeHours() { float calculateRuntimeHours() {
if (!bmsData.valid || bmsData.current >= 0) return 0; if (!bmsData.valid || bmsData.current >= 0) return 0;
@@ -997,32 +1231,7 @@ void buildStatusDocument(JsonDocument& doc) {
system["uptime_seconds"] = millis() / 1000; system["uptime_seconds"] = millis() / 1000;
JsonObject configObj = doc.createNestedObject("config"); JsonObject configObj = doc.createNestedObject("config");
configObj["device_name"] = appConfig.deviceName; buildConfigDocument(configObj);
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;
}
} }
void sendStatus(Stream& output, bool pretty = false) { void sendStatus(Stream& output, bool pretty = false) {
@@ -1850,32 +2059,8 @@ void handleDebugSerial() {
void sendConfigJson() { void sendConfigJson() {
DynamicJsonDocument doc(4096); DynamicJsonDocument doc(4096);
JsonObject root = doc.to<JsonObject>();
doc["device_name"] = appConfig.deviceName; buildConfigDocument(root);
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;
}
String output; String output;
serializeJson(doc, output); serializeJson(doc, output);
@@ -1905,6 +2090,131 @@ void handleGetConfig() {
sendConfigJson(); 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>();
JsonObject source = config.isNull() ? request.as<JsonObject>() : 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() { void handleUpdateDeviceConfig() {
DynamicJsonDocument doc(512); DynamicJsonDocument doc(512);
DeserializationError error = deserializeJson(doc, server.arg("plain")); DeserializationError error = deserializeJson(doc, server.arg("plain"));
@@ -1995,27 +2305,7 @@ void sendWifiConfigJson() {
doc["ok"] = true; doc["ok"] = true;
JsonObject wifi = doc.createNestedObject("wifi"); JsonObject wifi = doc.createNestedObject("wifi");
wifi["ap_enabled"] = true; buildWifiConfigDocument(wifi, false);
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;
}
String output; String output;
serializeJson(doc, output); serializeJson(doc, output);
@@ -2038,44 +2328,7 @@ void handleUpdateWifiConfig() {
JsonArray networks = doc["networks"].as<JsonArray>(); JsonArray networks = doc["networks"].as<JsonArray>();
if (!networks.isNull()) { if (!networks.isNull()) {
wifiNetworkCount = 0; applyWifiNetworks(networks);
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;
}
} else { } else {
if (doc["ssid"].is<String>()) { if (doc["ssid"].is<String>()) {
staSsids[0] = doc["ssid"].as<String>(); staSsids[0] = doc["ssid"].as<String>();
@@ -2435,9 +2688,13 @@ void setup() {
}); });
server.on(API_V1("/status"), handleStatus); 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("/relay/set"), HTTP_POST, handleSetRelayPost);
server.on(API_V1("/config"), HTTP_GET, handleGetConfig); 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_GET, handleGetWifiConfig);
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);
+3 -1
View File
@@ -20,12 +20,14 @@
"address": "aa:bb:cc:dd:ee:ff", "address": "aa:bb:cc:dd:ee:ff",
"address_type": "public" "address_type": "public"
}, },
"temperature_sensor_count": 4,
"temperature_sensors": [ "temperature_sensors": [
{ {
"id": "temp_1", "id": "temp_1",
"name": "Cabin", "name": "Cabin",
"address": "28ff001122334455", "address": "28ff001122334455",
"enabled": true "enabled": true,
"weather": false
} }
] ]
} }
+1
View File
@@ -99,6 +99,7 @@
"address": "aa:bb:cc:dd:ee:ff", "address": "aa:bb:cc:dd:ee:ff",
"address_type": "public" "address_type": "public"
}, },
"temperature_sensor_count": 4,
"temperature_sensors": [ "temperature_sensors": [
{ {
"id": "temp_1", "id": "temp_1",
+3 -1
View File
@@ -7,12 +7,14 @@
"address": "aa:bb:cc:dd:ee:ff", "address": "aa:bb:cc:dd:ee:ff",
"address_type": "public" "address_type": "public"
}, },
"temperature_sensor_count": 4,
"temperature_sensors": [ "temperature_sensors": [
{ {
"id": "temp_1", "id": "temp_1",
"name": "Cabin", "name": "Cabin",
"address": "28ff001122334455", "address": "28ff001122334455",
"enabled": true "enabled": true,
"weather": false
} }
] ]
} }
+3 -1
View File
@@ -7,12 +7,14 @@
"address": "aa:bb:cc:dd:ee:ff", "address": "aa:bb:cc:dd:ee:ff",
"address_type": "public" "address_type": "public"
}, },
"temperature_sensor_count": 4,
"temperature_sensors": [ "temperature_sensors": [
{ {
"id": "temp_1", "id": "temp_1",
"name": "Cabin", "name": "Cabin",
"address": "", "address": "",
"enabled": false "enabled": false,
"weather": false
} }
] ]
} }
+39 -2
View File
@@ -39,7 +39,11 @@ def test_firmware_registers_current_http_contract_routes():
expected_routes = { expected_routes = {
("/api/v1/status", "ANY"), ("/api/v1/status", "ANY"),
("/api/v1/health", "HTTP_GET"),
("/api/v1/capabilities", "HTTP_GET"),
("/api/v1/config", "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/relay/set", "HTTP_POST"),
("/api/v1/config/wifi", "HTTP_GET"), ("/api/v1/config/wifi", "HTTP_GET"),
("/api/v1/config/wifi", "HTTP_POST"), ("/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(): def test_config_fixture_matches_current_config_response_shape():
payload = load_fixture("config_response.json") 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["relays"][0], ["id", "name", "pin", "enabled"])
assert_keys(payload["bms"], ["enabled", "name", "address", "address_type"]) 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(): 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 [ for error_code in [
"invalid_json", "invalid_json",
"invalid_config",
"unknown_relay", "unknown_relay",
"unknown_temp_sensor", "unknown_temp_sensor",
"invalid_temp_selection", "invalid_temp_selection",