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
@@ -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);