Add maintenance API endpoints
This commit is contained in:
+129
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
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<JsonObject>();
|
||||
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>();
|
||||
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() {
|
||||
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<JsonArray>();
|
||||
|
||||
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<String>()) {
|
||||
staSsids[0] = doc["ssid"].as<String>();
|
||||
@@ -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);
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"address": "aa:bb:cc:dd:ee:ff",
|
||||
"address_type": "public"
|
||||
},
|
||||
"temperature_sensor_count": 4,
|
||||
"temperature_sensors": [
|
||||
{
|
||||
"id": "temp_1",
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user