#include #include #include #include #include #include #include #define OLED_ENABLED 0 #if OLED_ENABLED #include #include #include #endif #include #include "config.h" #include "protocol.h" #include "relays.h" #include "sensors.h" #include "bms.h" #include "alarms.h" #include "system_status.h" #include "app_config.h" #include "logger.h" const char INDEX_HTML[] PROGMEM = R"rawliteral( Overland Controller

Overland Controller

Local camp dashboard
API Connecting...
Battery
--%
Voltage
--
Current
--
Runtime
--
Relays
Temperatures
Network / Alarms
STA--
STA IP--
Battery Detail
--
Source--
Remaining--
Capacity--
Battery temp--
Cycles--
Cells
Cell count--
Cell delta--
BMS Control
BMS Polling--
Settings
Configure the Cargo ESP32 controller. The Cargo ESP remains the source of truth for relays, sensors, WiFi, and AP settings.
General
Controller identity shown in the WebUI and dashboard.
System
Read-only firmware and network status.
Firmware--
Uptime--
AP IP--
Access Point
This is the local camp network hosted by the Cargo ESP32. Phones and the dashboard connect here when no Starlink/home WiFi is available.
Current AP--
Security--
SSID must be 1-32 characters.
Password must be 8-63 characters. The current password is never displayed.
Saving AP settings restarts the access point. Your phone may disconnect and need to reconnect to the new SSID/password. Dashboard auto-migration is planned but not implemented yet.
Home / Starlink WiFi
Optional client networks the Cargo ESP can join. Lower priority number is tried first. The AP remains available for local access.
Relay Outputs
Rename relay outputs. IDs stay fixed for API/UART compatibility.
Temperature Assignment
Scan probes, then assign discovered sensors to configured temp slots.
Temperature Sensor Config
Set how many temp sensors are enabled, rename each slot, and pick the weather badge sensor.
BMS Config
JBD/Xiaoxiang BLE settings. Use this for initial setup, bench mode, or replacing the battery/BMS.
BMS Polling--
Maintenance
Reset and recovery actions. These can disrupt the controller.
)rawliteral"; WebServer server(80); #define API_V1(path) "/api/v1" path int findTempConfigIndexById(const String& id) { for (int i = 0; i < MAX_TEMP_SENSORS; i++) { if (appConfig.tempSensors[i].id == id) { return i; } } return -1; } #if DASHBOARD_UART_ENABLED HardwareSerial DashboardSerial(2); String uartLineBuffer; #endif Preferences wifiPrefs; Preferences apPrefs; String apSsid = "OverlandController"; String apPassword = ""; const int AP_PASSWORD_MIN_LEN = 8; const int AP_PASSWORD_MAX_LEN = 63; const int MAX_WIFI_NETWORKS = 3; String staSsids[MAX_WIFI_NETWORKS]; String staPasswords[MAX_WIFI_NETWORKS]; int staPriorities[MAX_WIFI_NETWORKS]; int wifiNetworkCount = 0; String activeStaSsid = ""; unsigned long lastStaReconnectAttempt = 0; const unsigned long STA_RECONNECT_INTERVAL_MS = 30000; void loadWifiConfig() { wifiPrefs.begin("wifi", true); wifiNetworkCount = wifiPrefs.getInt("count", 0); if (wifiNetworkCount < 0) wifiNetworkCount = 0; if (wifiNetworkCount > MAX_WIFI_NETWORKS) wifiNetworkCount = MAX_WIFI_NETWORKS; for (int i = 0; i < MAX_WIFI_NETWORKS; i++) { staSsids[i] = wifiPrefs.getString(("ssid" + String(i)).c_str(), ""); staPasswords[i] = wifiPrefs.getString(("pass" + String(i)).c_str(), ""); staPriorities[i] = wifiPrefs.getInt(("priority" + String(i)).c_str(), i + 1); } // Migration path from old single-network config if (wifiNetworkCount == 0) { String oldSsid = wifiPrefs.getString("ssid", ""); String oldPassword = wifiPrefs.getString("password", ""); if (oldSsid.length() > 0) { staSsids[0] = oldSsid; staPasswords[0] = oldPassword; staPriorities[0] = 1; wifiNetworkCount = 1; } } wifiPrefs.end(); } void saveWifiConfig() { wifiPrefs.begin("wifi", false); wifiPrefs.putInt("count", wifiNetworkCount); for (int i = 0; i < MAX_WIFI_NETWORKS; i++) { wifiPrefs.putString(("ssid" + String(i)).c_str(), staSsids[i]); wifiPrefs.putString(("pass" + String(i)).c_str(), staPasswords[i]); wifiPrefs.putInt(("priority" + String(i)).c_str(), staPriorities[i]); } // Keep legacy keys updated for easier debugging wifiPrefs.putString("ssid", wifiNetworkCount > 0 ? staSsids[0] : ""); wifiPrefs.putString("password", wifiNetworkCount > 0 ? staPasswords[0] : ""); wifiPrefs.end(); } void clearWifiConfig() { wifiPrefs.begin("wifi", false); wifiPrefs.clear(); wifiPrefs.end(); wifiNetworkCount = 0; activeStaSsid = ""; for (int i = 0; i < MAX_WIFI_NETWORKS; i++) { staSsids[i] = ""; staPasswords[i] = ""; staPriorities[i] = i + 1; } } String generateDefaultApPassword() { uint64_t mac = ESP.getEfuseMac(); uint32_t a = esp_random(); uint32_t b = ((uint32_t)(mac >> 16)) ^ esp_random(); char buffer[17]; snprintf(buffer, sizeof(buffer), "%08lX%08lX", (unsigned long)a, (unsigned long)b); return String(buffer); } bool validApSsid(const String& ssid) { return ssid.length() > 0 && ssid.length() <= 32; } bool validApPassword(const String& password) { return password.length() >= AP_PASSWORD_MIN_LEN && password.length() <= AP_PASSWORD_MAX_LEN; } void loadApConfig() { apPrefs.begin("ap", false); apSsid = apPrefs.getString("ssid", "OverlandController"); apPassword = apPrefs.getString("password", ""); if (!validApSsid(apSsid)) { apSsid = "OverlandController"; apPrefs.putString("ssid", apSsid); } if (!validApPassword(apPassword)) { apPassword = generateDefaultApPassword(); apPrefs.putString("password", apPassword); } apPrefs.end(); } void saveApConfig(const String& ssid, const String& password) { apSsid = ssid; apPassword = password; apPrefs.begin("ap", false); apPrefs.putString("ssid", apSsid); apPrefs.putString("password", apPassword); apPrefs.end(); } void resetApConfigToDefaults() { String newPassword = generateDefaultApPassword(); saveApConfig("OverlandController", newPassword); } bool startAccessPoint() { bool ok = WiFi.softAP(apSsid.c_str(), apPassword.c_str()); if (ok) { Serial.println("AP Started"); Serial.print("AP SSID: "); Serial.println(apSsid); Serial.print("AP Password: "); Serial.println(apPassword); Serial.print("AP IP: "); Serial.println(WiFi.softAPIP()); } else { Serial.println("AP failed to start"); } return ok; } void restartAccessPoint() { WiFi.softAPdisconnect(true); delay(250); startAccessPoint(); } void sendSimpleJsonError(int status, const char* error, const char* detail) { DynamicJsonDocument doc(512); doc["ok"] = false; doc["error"] = error; if (detail && detail[0]) { JsonArray details = doc.createNestedArray("details"); details.add(detail); } String output; serializeJson(doc, output); server.send(status, "application/json", output); } const char OTA_HTML[] PROGMEM = R"rawliteral( Cargo ESP OTA

Cargo ESP OTA

Upload only firmware built for the Cargo ESP. The controller will reboot after a successful update.

Tip: Arduino IDE can create this with Sketch → Export Compiled Binary.

)rawliteral"; static bool otaUploadHadError = false; void handleOtaPage() { server.send_P(200, "text/html", OTA_HTML); } void handleOtaUploadDone() { DynamicJsonDocument doc(512); doc["ok"] = !otaUploadHadError && !Update.hasError(); doc["firmware_name"] = FIRMWARE_NAME; doc["message"] = doc["ok"].as() ? "OTA update complete. Rebooting." : "OTA update failed."; String output; serializeJson(doc, output); if (doc["ok"].as()) { server.send(200, "application/json", output); delay(500); ESP.restart(); } else { server.send(500, "application/json", output); } } void handleOtaUploadStream() { HTTPUpload& upload = server.upload(); if (upload.status == UPLOAD_FILE_START) { otaUploadHadError = false; Serial.print("OTA: upload start: "); Serial.println(upload.filename); if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { otaUploadHadError = true; Update.printError(Serial); } } else if (upload.status == UPLOAD_FILE_WRITE) { if (!otaUploadHadError && Update.write(upload.buf, upload.currentSize) != upload.currentSize) { otaUploadHadError = true; Update.printError(Serial); } } else if (upload.status == UPLOAD_FILE_END) { if (!otaUploadHadError && Update.end(true)) { Serial.print("OTA: success, bytes="); Serial.println(upload.totalSize); } else { otaUploadHadError = true; Update.printError(Serial); } } else if (upload.status == UPLOAD_FILE_ABORTED) { otaUploadHadError = true; Update.abort(); Serial.println("OTA: upload aborted"); } } void handleResetApConfig() { resetApConfigToDefaults(); restartAccessPoint(); DynamicJsonDocument response(512); response["ok"] = true; response["ssid"] = apSsid; response["password_set"] = true; response["auth"] = "wpa2"; response["ap_ip"] = WiFi.softAPIP().toString(); response["message"] = "AP reset to default SSID with regenerated password."; String output; serializeJson(response, output); server.send(200, "application/json", output); } void handleGetApConfig() { DynamicJsonDocument response(512); response["ok"] = true; response["ssid"] = apSsid; response["password_set"] = validApPassword(apPassword); response["password_min_length"] = AP_PASSWORD_MIN_LEN; response["password_max_length"] = AP_PASSWORD_MAX_LEN; response["auth"] = "wpa2"; String output; serializeJson(response, output); server.send(200, "application/json", output); } void handleUpdateApConfig() { DynamicJsonDocument doc(512); DeserializationError err = deserializeJson(doc, server.arg("plain")); if (err) { sendSimpleJsonError(400, "invalid_json", err.c_str()); return; } String ssid = doc["ssid"] | apSsid; String password = doc["password"] | ""; ssid.trim(); password.trim(); if (!validApSsid(ssid)) { sendSimpleJsonError(400, "invalid_ap_ssid", "AP SSID must be 1-32 characters."); return; } if (!validApPassword(password)) { sendSimpleJsonError(400, "invalid_ap_password", "AP password must be 8-63 characters."); return; } saveApConfig(ssid, password); restartAccessPoint(); DynamicJsonDocument response(512); response["ok"] = true; response["ssid"] = apSsid; response["password_set"] = true; response["auth"] = "wpa2"; response["ap_ip"] = WiFi.softAPIP().toString(); String output; serializeJson(response, output); server.send(200, "application/json", output); } #if OLED_ENABLED const int OLED_SCREEN_WIDTH = 128; const int OLED_SCREEN_HEIGHT = 64; const int OLED_RESET_PIN = -1; const uint8_t OLED_I2C_ADDR = 0x3C; // Default ESP32 I2C pins. Change these if the cargo controller wiring uses a different I2C bus. const int OLED_I2C_SDA = OLED_I2C_SDA_PIN; const int OLED_I2C_SCL = OLED_I2C_SCL_PIN; // Optional future setup/status button. Leave disabled until hardware is wired. const int OLED_BUTTON_PIN = OLED_SETUP_BUTTON_PIN; Adafruit_SSD1306 oled(OLED_SCREEN_WIDTH, OLED_SCREEN_HEIGHT, &Wire, OLED_RESET_PIN); bool oledReady = false; unsigned long lastOledUpdateMs = 0; unsigned long lastOledPageChangeMs = 0; uint8_t oledPage = 0; void oledClear(const char* title) { oled.clearDisplay(); oled.setTextSize(1); oled.setTextColor(SSD1306_WHITE); oled.setCursor(0, 0); oled.println(title); oled.drawLine(0, 10, OLED_SCREEN_WIDTH - 1, 10, SSD1306_WHITE); } void oledPrintTrimmed(const String& value, int maxChars = 21) { if (value.length() <= maxChars) { oled.println(value); } else { oled.println(value.substring(0, maxChars)); } } void oledShowBoot() { if (!oledReady) return; oledClear("Overland Controller"); oled.setCursor(0, 18); oled.println("Booting..."); oled.println(); oled.println("Cargo Controller"); oled.display(); } void oledShowSetupPage() { if (!oledReady) return; oledClear("Setup / AP"); oled.setCursor(0, 14); oled.print("SSID: "); oledPrintTrimmed(apSsid, 15); oled.print("PASS: "); oledPrintTrimmed(apPassword, 15); oled.print("IP: "); oled.println(WiFi.softAPIP()); oled.display(); } void oledShowBatteryPage() { if (!oledReady) return; oledClear("Battery"); oled.setCursor(0, 16); if (bmsData.valid) { oled.print("SOC: "); oled.print(bmsData.soc); oled.println("%"); oled.print("V: "); oled.print(bmsData.voltage, 2); oled.println("V"); oled.print("A: "); oled.print(bmsData.current, 1); oled.println("A"); oled.print("Temp: "); oled.print(bmsData.temperatureF, 1); oled.println("F"); } else { oled.println("BMS unavailable"); oled.println(); oled.println("Check BLE/config"); } oled.display(); } void oledShowRelayPage() { if (!oledReady) return; oledClear("Relays"); oled.setCursor(0, 16); oled.print(appConfig.relays[0].name); oled.print(": "); oled.println(relays.state[0] ? "ON" : "OFF"); oled.print(appConfig.relays[1].name); oled.print(": "); oled.println(relays.state[1] ? "ON" : "OFF"); oled.display(); } void oledShowNetworkPage() { if (!oledReady) return; oledClear("Network"); oled.setCursor(0, 14); oled.print("AP: "); oledPrintTrimmed(apSsid, 17); oled.print("AP IP: "); oled.println(WiFi.softAPIP()); oled.print("STA: "); if (WiFi.status() == WL_CONNECTED) { oled.println(WiFi.localIP()); } else { oled.println("not connected"); } oled.display(); } void oledShowAlarmPage() { if (!oledReady) return; oledClear("Alarms"); oled.setCursor(0, 14); bool any = false; if (alarms.lowSoc) { oled.println("LOW SOC"); any = true; } if (alarms.criticalSoc) { oled.println("CRITICAL SOC"); any = true; } if (alarms.bmsDisconnected) { oled.println("BMS DISCONNECTED"); any = true; } if (alarms.highBatteryTemp) { oled.println("HIGH BATT TEMP"); any = true; } if (alarms.cellImbalance) { oled.println("CELL IMBALANCE"); any = true; } if (!any) { oled.println("No active alarms"); } oled.display(); } void oledRenderPage() { switch (oledPage) { case 0: oledShowBatteryPage(); break; case 1: oledShowRelayPage(); break; case 2: oledShowNetworkPage(); break; case 3: oledShowAlarmPage(); break; default: oledPage = 0; oledShowBatteryPage(); break; } } void oledBegin() { Wire.begin(OLED_I2C_SDA, OLED_I2C_SCL); oledReady = oled.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR); if (!oledReady) { Serial.println("OLED not detected"); return; } Serial.println("OLED initialized"); oledShowBoot(); } void oledLoop() { if (!oledReady) return; unsigned long now = millis(); if (now - lastOledPageChangeMs > 5000) { oledPage = (oledPage + 1) % 4; lastOledPageChangeMs = now; } if (now - lastOledUpdateMs > 1000) { oledRenderPage(); lastOledUpdateMs = now; } } #else void oledBegin() {} void oledShowSetupPage() {} void oledLoop() {} #endif int findNextWifiIndexByPriority(bool tried[]) { int bestIndex = -1; int bestPriority = 1000000; for (int i = 0; i < wifiNetworkCount; i++) { if (tried[i]) continue; if (staSsids[i].length() == 0) continue; int priority = staPriorities[i]; if (priority <= 0) priority = i + 1; if (priority < bestPriority) { bestPriority = priority; bestIndex = i; } } return bestIndex; } void connectStaWifi() { if (wifiNetworkCount <= 0) { Serial.println("STA WiFi not configured."); return; } WiFi.disconnect(false); activeStaSsid = ""; bool tried[MAX_WIFI_NETWORKS] = {false, false, false}; for (int attempt = 0; attempt < wifiNetworkCount; attempt++) { int i = findNextWifiIndexByPriority(tried); if (i < 0) { break; } tried[i] = true; Serial.print("Connecting STA WiFi priority "); Serial.print(staPriorities[i]); Serial.print(" to: "); Serial.println(staSsids[i]); WiFi.disconnect(false, false); delay(500); WiFi.mode(WIFI_AP_STA); delay(250); WiFi.begin(staSsids[i].c_str(), staPasswords[i].c_str()); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 8000) { server.handleClient(); oledLoop(); delay(100); Serial.print("."); } Serial.println(); if (WiFi.status() == WL_CONNECTED) { activeStaSsid = staSsids[i]; Serial.println("STA WiFi connected"); Serial.print("STA SSID: "); Serial.println(activeStaSsid); Serial.print("STA IP: "); Serial.println(WiFi.localIP()); return; } wl_status_t status = WiFi.status(); Serial.print("STA WiFi attempt failed. Status: "); Serial.println((int)status); wifi_ap_record_t apInfo; if (esp_wifi_sta_get_ap_info(&apInfo) == ESP_OK) { Serial.print("Connected AP RSSI: "); Serial.println(apInfo.rssi); } else { Serial.println("No AP association info available."); } } Serial.println("All STA WiFi attempts failed. AP remains available."); } void maintainStaWifi() { if (wifiNetworkCount <= 0) return; if (WiFi.status() == WL_CONNECTED) return; if (millis() - lastStaReconnectAttempt < STA_RECONNECT_INTERVAL_MS) return; lastStaReconnectAttempt = millis(); Serial.println("STA WiFi disconnected. Trying saved networks by priority..."); connectStaWifi(); } void printNetworkStatus() { Serial.println("Network:"); Serial.print(" AP IP: "); Serial.println(WiFi.softAPIP()); Serial.print(" Saved STA Networks: "); Serial.println(wifiNetworkCount); Serial.print(" Active STA SSID: "); Serial.println(activeStaSsid.length() ? activeStaSsid : "(none)"); Serial.print(" STA Connected: "); Serial.println(WiFi.status() == WL_CONNECTED ? "true" : "false"); if (WiFi.status() == WL_CONNECTED) { Serial.print(" STA IP: "); Serial.println(WiFi.localIP()); } } void buildConfigDocument(JsonObject doc) { doc["device_name"] = appConfig.deviceName; doc["hardware_profile"] = HARDWARE_PROFILE; doc["output_count"] = MAX_RELAYS; 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["role"] = appConfig.relays[i].role; relay["hardware_channel"] = appConfig.relays[i].hardwareChannel; relay["hardware_profile"] = HARDWARE_PROFILE; relay["pin"] = appConfig.relays[i].pin; relay["enabled"] = appConfig.relays[i].enabled; relay["available"] = appConfig.relays[i].available; } 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; temp["group"] = appConfig.tempSensors[i].group; temp["priority"] = appConfig.tempSensors[i].priority; temp["high_alert_enabled"] = appConfig.tempSensors[i].highAlertEnabled; temp["high_alert_f"] = appConfig.tempSensors[i].highAlertF; } } 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(); if (temp["group"].is()) appConfig.tempSensors[index].group = temp["group"].as(); if (temp["priority"].is()) appConfig.tempSensors[index].priority = temp["priority"].as(); if (temp["high_alert_enabled"].is()) appConfig.tempSensors[index].highAlertEnabled = temp["high_alert_enabled"].as(); if (temp["high_alert_f"].is()) appConfig.tempSensors[index].highAlertF = temp["high_alert_f"].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; float dischargeAmps = -bmsData.current; if (dischargeAmps <= 0.1) return 0; return bmsData.remainingAh / dischargeAmps; } const int STATUS_FIELD_COUNT = 8; const char* STATUS_FIELDS[STATUS_FIELD_COUNT] = { "battery", "temps", "relays", "vehicle", "network", "alarms", "system", "config" }; int findStatusFieldIndex(const String& field) { for (int i = 0; i < STATUS_FIELD_COUNT; i++) { if (field == STATUS_FIELDS[i]) return i; } return -1; } void buildStatusDocument(JsonDocument& doc) { doc["type"] = MSG_STATUS_RESPONSE; doc["timestamp"] = millis(); JsonObject battery = doc.createNestedObject("battery"); if (bmsData.valid) { battery["source"] = "jbd_bms"; battery["connected"] = bmsData.connected; battery["soc"] = bmsData.soc; battery["voltage"] = bmsData.voltage; battery["current"] = bmsData.current; battery["remaining_ah"] = bmsData.remainingAh; battery["capacity_ah"] = bmsData.capacityAh; battery["runtime_hours"] = calculateRuntimeHours(); battery["temperature_f"] = bmsData.temperatureF; battery["cycle_count"] = bmsData.cycleCount; battery["cell_count"] = bmsData.cellCount; battery["ntc_count"] = bmsData.ntcCount; JsonArray cells = battery.createNestedArray("cell_voltages"); if (bmsData.cellsValid) { for (int i = 0; i < bmsData.cellCount; i++) cells.add(bmsData.cellVoltages[i]); } battery["cell_min_voltage"] = bmsData.cellMinVoltage; battery["cell_max_voltage"] = bmsData.cellMaxVoltage; battery["cell_delta_mv"] = bmsData.cellDeltaMv; battery["cells_valid"] = bmsData.cellsValid; } else { if (appConfig.bms.enabled && appConfig.bms.address.length() > 0) { battery["source"] = "jbd_bms"; } else { if (appConfig.bms.enabled && appConfig.bms.address.length() > 0) { battery["source"] = "jbd_bms"; } else { if (appConfig.bms.enabled && appConfig.bms.address.length() > 0) { battery["source"] = "jbd_bms"; } else { battery["source"] = "unconfigured"; } } } battery["connected"] = false; battery["soc"] = 0; battery["voltage"] = 0; battery["current"] = 0; battery["remaining_ah"] = 0; battery["capacity_ah"] = 0; battery["runtime_hours"] = 0; battery["temperature_f"] = 0; battery["cycle_count"] = 0; battery["cell_count"] = 0; battery["ntc_count"] = 0; battery["cell_delta_mv"] = 0; battery["cells_valid"] = false; } JsonArray temps = doc.createNestedArray("temps"); int tempCount = appConfig.tempSensorCount > 4 ? 4 : appConfig.tempSensorCount; for (int i = 0; i < tempCount; i++) { JsonObject temp = temps.createNestedObject(); temp["id"] = appConfig.tempSensors[i].id; temp["name"] = appConfig.tempSensors[i].name; temp["enabled"] = appConfig.tempSensors[i].enabled; temp["weather"] = appConfig.tempSensors[i].weather; temp["group"] = appConfig.tempSensors[i].group; temp["priority"] = appConfig.tempSensors[i].priority; temp["high_alert_enabled"] = appConfig.tempSensors[i].highAlertEnabled; temp["high_alert_f"] = appConfig.tempSensors[i].highAlertF; temp["online"] = sensors.online[i]; if (sensors.online[i]) { temp["temperature_f"] = sensors.tempsF[i]; temp["high_alert"] = appConfig.tempSensors[i].highAlertEnabled && sensors.tempsF[i] > -100.0 && sensors.tempsF[i] > appConfig.tempSensors[i].highAlertF; } else { temp["temperature_f"] = nullptr; } } JsonArray relayStates = doc.createNestedArray("relays"); for (int i = 0; i < MAX_RELAYS; i++) { JsonObject relay = relayStates.createNestedObject(); relay["id"] = appConfig.relays[i].id; relay["name"] = appConfig.relays[i].name; relay["role"] = appConfig.relays[i].role; relay["hardware_channel"] = appConfig.relays[i].hardwareChannel; relay["hardware_profile"] = HARDWARE_PROFILE; relay["pin"] = appConfig.relays[i].pin; relay["enabled"] = appConfig.relays[i].enabled; relay["available"] = appConfig.relays[i].available; relay["state"] = relays.state[i]; } JsonObject vehicle = doc.createNestedObject("vehicle"); vehicle["ignition_on"] = digitalRead(IGNITION_PIN); JsonObject network = doc.createNestedObject("network"); network["wifi_enabled"] = true; network["uart_connected"] = true; network["ap_enabled"] = true; network["ap_ssid"] = apSsid; network["ap_auth"] = "wpa2"; network["ap_ip"] = WiFi.softAPIP().toString(); network["ap_clients"] = WiFi.softAPgetStationNum(); 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() : ""; network["sta_rssi_dbm"] = WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0; network["saved_network_count"] = wifiNetworkCount; JsonArray savedNetworks = network.createNestedArray("saved_networks"); for (int i = 0; i < wifiNetworkCount; i++) { JsonObject saved = savedNetworks.createNestedObject(); saved["index"] = i + 1; saved["ssid"] = staSsids[i]; saved["priority"] = staPriorities[i]; saved["active"] = staSsids[i] == activeStaSsid; } JsonObject alarmObj = doc.createNestedObject("alarms"); alarmObj["low_soc"] = alarms.lowSoc; alarmObj["critical_soc"] = alarms.criticalSoc; alarmObj["low_voltage"] = alarms.lowVoltage; alarmObj["high_battery_temp"] = alarms.highBatteryTemp; alarmObj["cell_imbalance"] = alarms.cellImbalance; alarmObj["bms_disconnected"] = alarms.bmsDisconnected; JsonObject system = doc.createNestedObject("system"); system["firmware_name"] = FIRMWARE_NAME; system["firmware_version"] = FIRMWARE_VERSION; system["build_date"] = __DATE__; system["build_time"] = __TIME__; system["uptime_seconds"] = millis() / 1000; system["free_heap"] = ESP.getFreeHeap(); system["min_free_heap"] = ESP.getMinFreeHeap(); system["chip_model"] = ESP.getChipModel(); system["cpu_mhz"] = ESP.getCpuFreqMHz(); JsonObject configObj = doc.createNestedObject("config"); buildConfigDocument(configObj); } void sendStatus(Stream& output, bool pretty = false) { DynamicJsonDocument doc(4096); buildStatusDocument(doc); if (pretty) serializeJsonPretty(doc, output); else serializeJson(doc, output); output.println(); } void sendError(Stream& output, const char* message) { DynamicJsonDocument doc(256); doc["type"] = MSG_ERROR; doc["message"] = message; serializeJson(doc, output); output.println(); } int findRelayConfigIndex(const String& id); bool setRelayById(const String& relayId, bool enabled) { int index = findRelayConfigIndex(relayId); if (index < 0 || !appConfig.relays[index].available) { return false; } relays.state[index] = enabled; updateRelayOutputs(); return true; } void sendRelayResponse(Stream& output, const String& relayId, bool enabled) { DynamicJsonDocument doc(256); doc["type"] = MSG_RELAY_RESPONSE; doc["id"] = relayId; doc["state"] = enabled; doc["ok"] = true; serializeJson(doc, output); output.println(); } void sendConfigResponse(Stream& output, bool ok = true) { DynamicJsonDocument doc(4096); doc["type"] = "config_response"; doc["ok"] = ok; JsonObject config = doc.createNestedObject("config"); config["device_name"] = appConfig.deviceName; config["hardware_profile"] = HARDWARE_PROFILE; config["output_count"] = MAX_RELAYS; JsonArray relaysArray = config.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["role"] = appConfig.relays[i].role; relay["hardware_channel"] = appConfig.relays[i].hardwareChannel; relay["hardware_profile"] = HARDWARE_PROFILE; relay["pin"] = appConfig.relays[i].pin; relay["enabled"] = appConfig.relays[i].enabled; relay["available"] = appConfig.relays[i].available; } JsonObject bms = config.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 = config.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; temp["group"] = appConfig.tempSensors[i].group; temp["priority"] = appConfig.tempSensors[i].priority; temp["high_alert_enabled"] = appConfig.tempSensors[i].highAlertEnabled; temp["high_alert_f"] = appConfig.tempSensors[i].highAlertF; } serializeJson(doc, output); output.println(); } void sendSimpleOk(Stream& output, const char* type) { DynamicJsonDocument doc(256); doc["type"] = type; doc["ok"] = true; serializeJson(doc, output); output.println(); } void sendBleScanResponse(Stream& output) { DynamicJsonDocument doc(2048); doc["type"] = "ble_scan_response"; JsonArray devices = doc.createNestedArray("devices"); int count = getBleScanResultCount(); for (int i = 0; i < count; i++) { const BleScanResult* result = getBleScanResult(i); if (!result) { continue; } JsonObject device = devices.createNestedObject(); device["index"] = i + 1; device["name"] = result->name; device["address"] = result->address; device["rssi"] = result->rssi; } serializeJson(doc, output); output.println(); } #if DASHBOARD_UART_ENABLED int findRelayConfigIndexForUart(const String& id) { for (int i = 0; i < MAX_RELAYS; i++) { if (appConfig.relays[i].id == id) { return i; } } return -1; } void handleUartMessage(const String& line) { DynamicJsonDocument doc(1024); DeserializationError error = deserializeJson(doc, line); if (error) { sendError(DashboardSerial, "invalid_json"); return; } const char* type = doc["type"] | ""; if (strcmp(type, MSG_STATUS_REQUEST) == 0 || strcmp(type, "status_request") == 0) { sendStatus(DashboardSerial); return; } if (strcmp(type, "config_request") == 0) { sendConfigResponse(DashboardSerial); return; } if (strcmp(type, MSG_SET_RELAY) == 0 || strcmp(type, "set_relay") == 0) { String relayId = doc["id"] | ""; if (relayId.length() == 0) { relayId = doc["relay"] | ""; } bool enabled = doc["state"] | false; if (doc["enabled"].is()) { enabled = doc["enabled"].as(); } if (!setRelayById(relayId, enabled)) { sendError(DashboardSerial, "unknown_relay"); return; } sendRelayResponse(DashboardSerial, relayId, enabled); return; } if (strcmp(type, "config_device") == 0) { if (!doc["device_name"].is()) { sendError(DashboardSerial, "missing_device_name"); return; } appConfig.deviceName = doc["device_name"].as(); sendConfigResponse(DashboardSerial); return; } if (strcmp(type, "config_relay") == 0) { String id = doc["id"] | ""; int index = findRelayConfigIndexForUart(id); if (index < 0) { sendError(DashboardSerial, "unknown_relay"); return; } if (doc["name"].is()) { appConfig.relays[index].name = doc["name"].as(); } if (doc["enabled"].is()) { appConfig.relays[index].enabled = doc["enabled"].as(); } sendConfigResponse(DashboardSerial); return; } if (strcmp(type, "config_temp") == 0) { String id = doc["id"] | ""; int index = findTempConfigIndexById(id); if (index < 0) { sendError(DashboardSerial, "unknown_temp_sensor"); return; } if (doc["name"].is()) { appConfig.tempSensors[index].name = doc["name"].as(); } if (doc["address"].is()) { appConfig.tempSensors[index].address = doc["address"].as(); } if (doc["enabled"].is()) { appConfig.tempSensors[index].enabled = doc["enabled"].as(); } sendConfigResponse(DashboardSerial); return; } if (strcmp(type, "config_bms") == 0) { if (doc["enabled"].is()) { appConfig.bms.enabled = doc["enabled"].as(); } if (doc["name"].is()) { appConfig.bms.name = doc["name"].as(); } if (doc["address"].is()) { appConfig.bms.address = doc["address"].as(); } if (doc["address_type"].is()) { appConfig.bms.addressType = doc["address_type"].as(); } sendConfigResponse(DashboardSerial); return; } if (strcmp(type, "wifi_request") == 0) { DynamicJsonDocument response(1024); response["type"] = "wifi_config_response"; response["ok"] = true; JsonObject wifi = response.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(); 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["password_set"] = staPasswords[i].length() > 0; } if (WiFi.status() == WL_CONNECTED) { wifi["sta_ip"] = WiFi.localIP().toString(); } else { wifi["sta_ip"] = ""; } serializeJson(response, DashboardSerial); DashboardSerial.println(); return; } if (strcmp(type, "config_wifi") == 0) { 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(); if (ssid.length() == 0) continue; staSsids[wifiNetworkCount] = ssid; staPasswords[wifiNetworkCount] = password; wifiNetworkCount++; } } else { if (doc["ssid"].is()) { staSsids[0] = doc["ssid"].as(); if (wifiNetworkCount < 1) wifiNetworkCount = 1; } if (doc["password"].is()) { staPasswords[0] = doc["password"].as(); if (wifiNetworkCount < 1) wifiNetworkCount = 1; } } saveWifiConfig(); DynamicJsonDocument response(512); response["type"] = "wifi_config_response"; response["ok"] = true; response["network_count"] = wifiNetworkCount; serializeJson(response, DashboardSerial); DashboardSerial.println(); return; } if (strcmp(type, "wifi_connect") == 0) { connectStaWifi(); DynamicJsonDocument response(512); response["type"] = "wifi_config_response"; response["ok"] = true; response["sta_connected"] = WiFi.status() == WL_CONNECTED; response["sta_ip"] = WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : ""; serializeJson(response, DashboardSerial); DashboardSerial.println(); return; } if (strcmp(type, "wifi_clear") == 0) { clearWifiConfig(); DynamicJsonDocument response(512); response["type"] = "wifi_config_response"; response["ok"] = true; response["ssid"] = ""; response["password_set"] = false; serializeJson(response, DashboardSerial); DashboardSerial.println(); return; } if (strcmp(type, "scan_temps") == 0) { scanTempSensors(); DynamicJsonDocument response(2048); response["type"] = "temp_scan_response"; response["ok"] = true; JsonArray devices = response.createNestedArray("devices"); int count = getTempScanResultCount(); for (int i = 0; i < count; i++) { JsonObject device = devices.createNestedObject(); device["index"] = i + 1; device["address"] = getTempScanResultAddress(i); } serializeJson(response, DashboardSerial); DashboardSerial.println(); return; } if (strcmp(type, "assign_temp") == 0) { String id = doc["id"] | ""; int slot = doc["slot"] | 0; int index = doc["index"] | 0; int configIndex = -1; if (id.length() > 0) { configIndex = findTempConfigIndexById(id); } else if (slot >= 1 && slot <= MAX_TEMP_SENSORS) { configIndex = slot - 1; } if (configIndex < 0) { sendError(DashboardSerial, "unknown_temp_sensor"); return; } if (!assignTempSensorByIndex(configIndex, index - 1)) { sendError(DashboardSerial, "invalid_temp_selection"); return; } saveConfig(); sendConfigResponse(DashboardSerial); return; } if (strcmp(type, "clear_temp") == 0) { String id = doc["id"] | ""; int slot = doc["slot"] | 0; int configIndex = -1; if (id.length() > 0) { configIndex = findTempConfigIndexById(id); } else if (slot >= 1 && slot <= MAX_TEMP_SENSORS) { configIndex = slot - 1; } if (configIndex < 0) { sendError(DashboardSerial, "unknown_temp_sensor"); return; } if (!clearTempSensor(configIndex)) { sendError(DashboardSerial, "unknown_temp_sensor"); return; } saveConfig(); sendConfigResponse(DashboardSerial); return; } if (strcmp(type, "save_config") == 0) { saveConfig(); sendConfigResponse(DashboardSerial); return; } if (strcmp(type, "factory_reset") == 0) { factoryResetConfig(); sendConfigResponse(DashboardSerial); return; } if (strcmp(type, "enter_bms_setup") == 0) { enterBmsSetupMode(); sendSimpleOk(DashboardSerial, "bms_setup_response"); return; } if (strcmp(type, "exit_bms_setup") == 0) { exitBmsSetupMode(); sendSimpleOk(DashboardSerial, "bms_setup_response"); return; } if (strcmp(type, "scan_ble") == 0) { scanBleDevices(20); sendBleScanResponse(DashboardSerial); return; } if (strcmp(type, "select_bms") == 0) { int selected = doc["index"] | 0; if (selected < 1 || selected > getBleScanResultCount()) { sendError(DashboardSerial, "invalid_bms_selection"); return; } const BleScanResult* result = getBleScanResult(selected - 1); if (!result) { sendError(DashboardSerial, "invalid_bms_selection"); return; } appConfig.bms.enabled = true; appConfig.bms.address = result->address; appConfig.bms.addressType = "public"; if (result->name.length() > 0) { appConfig.bms.name = result->name; } else { appConfig.bms.name = "BMS"; } saveConfig(); exitBmsSetupMode(); DynamicJsonDocument response(512); response["type"] = "bms_setup_response"; response["ok"] = true; response["name"] = appConfig.bms.name; response["address"] = appConfig.bms.address; serializeJson(response, DashboardSerial); DashboardSerial.println(); return; } sendError(DashboardSerial, "unknown_message_type"); } void pollDashboardUart() { while (DashboardSerial.available()) { char c = DashboardSerial.read(); if (c == '\n') { uartLineBuffer.trim(); if (uartLineBuffer.length() > 0) handleUartMessage(uartLineBuffer); uartLineBuffer = ""; } else if (c != '\r') { uartLineBuffer += c; if (uartLineBuffer.length() > 512) { uartLineBuffer = ""; sendError(DashboardSerial, "message_too_long"); } } } } #endif void handleDebugSerial() { if (!Serial.available()) return; String command = Serial.readStringUntil('\n'); command.trim(); if (command == "status") { sendStatus(Serial, true); return; } if (command == "config") { printConfig(); return; } if (command == "log quiet") { setLogLevel(LOG_QUIET); Serial.println("OK log level quiet"); return; } if (command == "log info") { setLogLevel(LOG_INFO); Serial.println("OK log level info"); return; } if (command == "log debug") { setLogLevel(LOG_DEBUG); Serial.println("OK log level debug"); return; } if (command == "wifi status") { printNetworkStatus(); return; } if (command == "wifi list") { Serial.println("Saved WiFi networks:"); for (int i = 0; i < wifiNetworkCount; i++) { Serial.print(" "); Serial.print(i + 1); Serial.print(") priority "); Serial.print(staPriorities[i]); Serial.print(" / "); Serial.print(staSsids[i]); Serial.print(" / password_set "); Serial.println(staPasswords[i].length() > 0 ? "true" : "false"); } return; } if (command.startsWith("wifi ssid ")) { String ssid = command.substring(10); ssid.trim(); staSsids[0] = ssid; staPriorities[0] = 1; if (wifiNetworkCount < 1) wifiNetworkCount = 1; Serial.println("OK WiFi SSID 1 updated"); Serial.println("Run: wifi save"); return; } if (command.startsWith("wifi pass ")) { String password = command.substring(10); password.trim(); staPasswords[0] = password; staPriorities[0] = 1; if (wifiNetworkCount < 1) wifiNetworkCount = 1; Serial.println("OK WiFi password 1 updated"); Serial.println("Run: wifi save"); return; } if (command.startsWith("wifi add ")) { String rest = command.substring(9); int split = rest.indexOf('|'); if (split < 0) { Serial.println("Invalid command. Use: wifi add SSID|PASSWORD"); return; } if (wifiNetworkCount >= MAX_WIFI_NETWORKS) { Serial.println("WiFi network list full"); return; } String ssid = rest.substring(0, split); String passAndPriority = rest.substring(split + 1); int secondSplit = passAndPriority.indexOf('|'); String password = passAndPriority; int priority = wifiNetworkCount + 1; if (secondSplit >= 0) { password = passAndPriority.substring(0, secondSplit); priority = passAndPriority.substring(secondSplit + 1).toInt(); if (priority <= 0) priority = wifiNetworkCount + 1; } ssid.trim(); password.trim(); staSsids[wifiNetworkCount] = ssid; staPasswords[wifiNetworkCount] = password; staPriorities[wifiNetworkCount] = priority; wifiNetworkCount++; Serial.println("OK WiFi network added"); Serial.println("Run: wifi save"); return; } if (command == "wifi save") { saveWifiConfig(); Serial.println("OK WiFi config saved"); return; } if (command == "wifi connect") { connectStaWifi(); return; } if (command == "wifi clear") { clearWifiConfig(); Serial.println("OK WiFi config cleared"); return; } if (command == "factory reset") { factoryResetConfig(); Serial.println("OK factory reset complete"); return; } if (command == "save") { saveConfig(); Serial.println("OK config saved"); return; } if (command.startsWith("relay ")) { int firstSpace = command.indexOf(' '); int secondSpace = command.indexOf(' ', firstSpace + 1); if (secondSpace > 0) { int relayNumber = command.substring(firstSpace + 1, secondSpace).toInt(); String action = command.substring(secondSpace + 1); String relayId = "relay_" + String(relayNumber); if (relayNumber >= 1 && relayNumber <= MAX_RELAYS && (action == "on" || action == "off")) { bool enabled = action == "on"; if (setRelayById(relayId, enabled)) { Serial.print("OK "); Serial.print(relayId); Serial.print(" "); Serial.println(action); return; } } } Serial.println("Invalid relay command. Use: relay 1 on, relay 1 off, relay 2 on, relay 2 off"); return; } if (command.startsWith("devicename ")) { appConfig.deviceName = command.substring(11); Serial.println("OK device name updated"); return; } if (command.startsWith("relayname ")) { int firstSpace = command.indexOf(' '); int secondSpace = command.indexOf(' ', firstSpace + 1); if (secondSpace > 0) { int relayNumber = command.substring(firstSpace + 1, secondSpace).toInt(); if (relayNumber >= 1 && relayNumber <= MAX_RELAYS) { appConfig.relays[relayNumber - 1].name = command.substring(secondSpace + 1); Serial.println("OK relay name updated"); return; } } } if (command == "scan temps") { scanTempSensors(); printSensorAddresses(); return; } if (command.startsWith("assign temp ")) { String rest = command.substring(12); int space = rest.indexOf(' '); if (space < 0) { Serial.println("Invalid command. Use: assign temp "); return; } int slot = rest.substring(0, space).toInt(); int index = rest.substring(space + 1).toInt(); if (!assignTempSensorByIndex(slot - 1, index - 1)) { Serial.println("Invalid temp slot or sensor number."); return; } saveConfig(); Serial.println("OK temp sensor assigned"); return; } if (command.startsWith("clear temp ")) { int slot = command.substring(11).toInt(); if (!clearTempSensor(slot - 1)) { Serial.println("Invalid temp slot."); return; } saveConfig(); Serial.println("OK temp sensor cleared"); return; } if (command.startsWith("tempname ")) { int firstSpace = command.indexOf(' '); int secondSpace = command.indexOf(' ', firstSpace + 1); if (secondSpace > 0) { int sensorNumber = command.substring(firstSpace + 1, secondSpace).toInt(); if (sensorNumber >= 1 && sensorNumber <= MAX_TEMP_SENSORS) { appConfig.tempSensors[sensorNumber - 1].name = command.substring(secondSpace + 1); Serial.println("OK temp sensor name updated"); return; } } } if (command.startsWith("bmsname ")) { appConfig.bms.name = command.substring(8); Serial.println("OK bms name updated"); return; } if (command.startsWith("bmsaddr ")) { appConfig.bms.address = command.substring(8); appConfig.bms.enabled = appConfig.bms.address.length() > 0; Serial.println("OK bms address updated"); return; } if (command == "enter setup") { enterBmsSetupMode(); return; } if (command == "exit setup") { exitBmsSetupMode(); return; } if (command == "scan ble") { scanBleDevices(20); return; } if (command.startsWith("select bms ")) { int selected = command.substring(11).toInt(); if (selected < 1 || selected > getBleScanResultCount()) { Serial.println("Invalid BMS selection. Run: scan ble"); return; } const BleScanResult* result = getBleScanResult(selected - 1); if (!result) { Serial.println("Invalid BMS selection. Run: scan ble"); return; } appConfig.bms.enabled = true; appConfig.bms.address = result->address; appConfig.bms.addressType = "public"; appConfig.bms.name = result->name.length() > 0 ? result->name : "BMS"; saveConfig(); Serial.print("OK selected BMS: "); Serial.print(appConfig.bms.name); Serial.print(" / "); Serial.println(appConfig.bms.address); Serial.println("BMS will reconnect on next update cycle."); exitBmsSetupMode(); return; } if (command.length() > 0) { Serial.print("Unknown command: "); Serial.println(command); Serial.println("Commands: status, config, save, relay 1 on/off, relay 2 on/off, relayname N , tempname N , bmsname , bmsaddr , enter setup, scan ble, select bms N, exit setup"); } } void sendConfigJson() { DynamicJsonDocument doc(4096); JsonObject root = doc.to(); buildConfigDocument(root); String output; serializeJson(doc, output); server.send(200, "application/json", output); } void sendOkConfig() { saveConfig(); sendConfigJson(); } int findRelayConfigIndex(const String& id) { for (int i = 0; i < MAX_RELAYS; i++) { if (appConfig.relays[i].id == id) return i; } return -1; } int findTempSensorConfigIndex(const String& id) { for (int i = 0; i < MAX_TEMP_SENSORS; i++) { if (appConfig.tempSensors[i].id == id) return i; } return -1; } 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_ssid"] = apSsid; network["ap_auth"] = "wpa2"; network["ap_ip"] = WiFi.softAPIP().toString(); network["ap_clients"] = WiFi.softAPgetStationNum(); 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"); endpoints.add("POST /api/v1/system/ota"); 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")); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } if (doc["device_name"].is()) { appConfig.deviceName = doc["device_name"].as(); } sendOkConfig(); } void handleUpdateRelayConfig() { DynamicJsonDocument doc(1024); DeserializationError error = deserializeJson(doc, server.arg("plain")); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } String id = doc["id"] | ""; int index = findRelayConfigIndex(id); if (index < 0) { server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}"); return; } if (doc["name"].is()) appConfig.relays[index].name = doc["name"].as(); if (doc["enabled"].is()) appConfig.relays[index].enabled = doc["enabled"].as(); sendOkConfig(); } void handleUpdateBmsConfig() { DynamicJsonDocument doc(1024); DeserializationError error = deserializeJson(doc, server.arg("plain")); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } if (doc["enabled"].is()) appConfig.bms.enabled = doc["enabled"].as(); if (doc["name"].is()) appConfig.bms.name = doc["name"].as(); if (doc["address"].is()) appConfig.bms.address = doc["address"].as(); if (doc["address_type"].is()) appConfig.bms.addressType = doc["address_type"].as(); sendOkConfig(); } void handleUpdateTempSensorConfig() { DynamicJsonDocument doc(1024); DeserializationError error = deserializeJson(doc, server.arg("plain")); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } String id = doc["id"] | ""; int index = findTempSensorConfigIndex(id); if (index < 0) { server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_temp_sensor\"}"); return; } if (doc["name"].is()) appConfig.tempSensors[index].name = doc["name"].as(); if (doc["address"].is()) appConfig.tempSensors[index].address = doc["address"].as(); if (doc["enabled"].is()) appConfig.tempSensors[index].enabled = doc["enabled"].as(); if (doc["weather"].is()) appConfig.tempSensors[index].weather = doc["weather"].as(); if (doc["group"].is()) appConfig.tempSensors[index].group = doc["group"].as(); if (doc["priority"].is()) appConfig.tempSensors[index].priority = doc["priority"].as(); if (doc["high_alert_enabled"].is()) appConfig.tempSensors[index].highAlertEnabled = doc["high_alert_enabled"].as(); if (doc["high_alert_f"].is()) appConfig.tempSensors[index].highAlertF = doc["high_alert_f"].as(); sendOkConfig(); } void handleFactoryResetConfig() { factoryResetConfig(); sendConfigJson(); } void sendWifiConfigJson() { DynamicJsonDocument doc(2048); doc["type"] = "wifi_config_response"; doc["ok"] = true; JsonObject wifi = doc.createNestedObject("wifi"); buildWifiConfigDocument(wifi, false); String output; serializeJson(doc, output); server.send(200, "application/json", output); } void handleGetWifiConfig() { sendWifiConfigJson(); } void handleUpdateWifiConfig() { DynamicJsonDocument doc(2048); DeserializationError error = deserializeJson(doc, server.arg("plain")); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } JsonArray networks = doc["networks"].as(); if (!networks.isNull()) { applyWifiNetworks(networks); } else { if (doc["ssid"].is()) { staSsids[0] = doc["ssid"].as(); staPriorities[0] = 1; if (wifiNetworkCount < 1) wifiNetworkCount = 1; } if (doc["password"].is()) { staPasswords[0] = doc["password"].as(); staPriorities[0] = 1; if (wifiNetworkCount < 1) wifiNetworkCount = 1; } } saveWifiConfig(); sendWifiConfigJson(); } void handleWifiConnect() { connectStaWifi(); sendWifiConfigJson(); } void handleWifiClear() { clearWifiConfig(); sendWifiConfigJson(); } void sendTempScanJson() { DynamicJsonDocument doc(2048); doc["type"] = "temp_scan_response"; doc["ok"] = true; JsonArray devices = doc.createNestedArray("devices"); int count = getTempScanResultCount(); for (int i = 0; i < count; i++) { JsonObject device = devices.createNestedObject(); device["index"] = i + 1; device["address"] = getTempScanResultAddress(i); } String output; serializeJson(doc, output); server.send(200, "application/json", output); } void handleTempScan() { scanTempSensors(); sendTempScanJson(); } void handleTempAssign() { DynamicJsonDocument doc(512); DeserializationError error = deserializeJson(doc, server.arg("plain")); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } String id = doc["id"] | ""; int slot = doc["slot"] | 0; int index = doc["index"] | 0; int configIndex = -1; if (id.length() > 0) { configIndex = findTempConfigIndexById(id); } else if (slot >= 1 && slot <= MAX_TEMP_SENSORS) { configIndex = slot - 1; } if (configIndex < 0) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"unknown_temp_sensor\"}"); return; } if (!assignTempSensorByIndex(configIndex, index - 1)) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_temp_selection\"}"); return; } saveConfig(); sendConfigJson(); } void handleTempClear() { String body = server.arg("plain"); if (body.length() == 0) { for (int i = 0; i < MAX_TEMP_SENSORS; i++) { appConfig.tempSensors[i].address = ""; appConfig.tempSensors[i].enabled = false; appConfig.tempSensors[i].weather = false; appConfig.tempSensors[i].group = "cabin"; appConfig.tempSensors[i].priority = i + 1; appConfig.tempSensors[i].highAlertEnabled = false; appConfig.tempSensors[i].highAlertF = 90.0; sensors.tempsF[i] = -127.0; sensors.online[i] = false; } saveConfig(); sendConfigJson(); return; } DynamicJsonDocument doc(512); DeserializationError error = deserializeJson(doc, body); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } String id = doc["id"] | ""; int slot = doc["slot"] | 0; int configIndex = -1; if (id.length() > 0) { configIndex = findTempConfigIndexById(id); } else if (slot >= 1 && slot <= MAX_TEMP_SENSORS) { configIndex = slot - 1; } if (configIndex < 0) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"unknown_temp_sensor\"}"); return; } appConfig.tempSensors[configIndex].address = ""; appConfig.tempSensors[configIndex].enabled = false; appConfig.tempSensors[configIndex].weather = false; appConfig.tempSensors[configIndex].group = "cabin"; appConfig.tempSensors[configIndex].priority = configIndex + 1; appConfig.tempSensors[configIndex].highAlertEnabled = false; appConfig.tempSensors[configIndex].highAlertF = 90.0; sensors.tempsF[configIndex] = -127.0; sensors.online[configIndex] = false; saveConfig(); sendConfigJson(); } void handleSaveConfig() { saveConfig(); sendConfigJson(); } void handleEnterBmsSetup() { enterBmsSetupMode(); server.send(200, "application/json", "{\"type\":\"bms_setup_response\",\"ok\":true,\"mode\":\"setup\"}"); } void handleExitBmsSetup() { exitBmsSetupMode(); server.send(200, "application/json", "{\"type\":\"bms_setup_response\",\"ok\":true,\"mode\":\"normal\"}"); } void handleBleScan() { scanBleDevices(20); DynamicJsonDocument doc(2048); doc["type"] = "ble_scan_response"; JsonArray devices = doc.createNestedArray("devices"); int count = getBleScanResultCount(); for (int i = 0; i < count; i++) { const BleScanResult* result = getBleScanResult(i); if (!result) { continue; } JsonObject device = devices.createNestedObject(); device["index"] = i + 1; device["name"] = result->name; device["address"] = result->address; device["rssi"] = result->rssi; } String output; serializeJson(doc, output); server.send(200, "application/json", output); } void handleSelectBms() { DynamicJsonDocument doc(512); DeserializationError error = deserializeJson(doc, server.arg("plain")); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } int selected = doc["index"] | 0; if (selected < 1 || selected > getBleScanResultCount()) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_bms_selection\"}"); return; } const BleScanResult* result = getBleScanResult(selected - 1); if (!result) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_bms_selection\"}"); return; } appConfig.bms.enabled = true; appConfig.bms.address = result->address; appConfig.bms.addressType = "public"; if (result->name.length() > 0) { appConfig.bms.name = result->name; } else { appConfig.bms.name = "BMS"; } saveConfig(); exitBmsSetupMode(); DynamicJsonDocument response(512); response["type"] = "bms_setup_response"; response["ok"] = true; response["name"] = appConfig.bms.name; response["address"] = appConfig.bms.address; String output; serializeJson(response, output); server.send(200, "application/json", output); } void handleSetRelayPost() { unsigned long startMs = millis(); DynamicJsonDocument doc(512); DeserializationError error = deserializeJson(doc, server.arg("plain")); if (error) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}"); return; } String relayId = doc["id"] | ""; bool state = doc["state"] | false; unsigned long applyStartMs = millis(); if (!setRelayById(relayId, state)) { server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}"); return; } unsigned long appliedMs = millis() - applyStartMs; DynamicJsonDocument response(512); response["type"] = "relay_response"; response["ok"] = true; response["id"] = relayId; response["state"] = state; response["applied_ms"] = appliedMs; response["total_ms"] = millis() - startMs; String output; serializeJson(response, output); server.send(200, "application/json", output); Serial.print("Relay "); Serial.print(relayId); Serial.print(" -> "); Serial.print(state ? "ON" : "OFF"); Serial.print(" applied="); Serial.print(appliedMs); Serial.print("ms total="); Serial.print(millis() - startMs); Serial.println("ms"); } void handleGenericRelayRoute() { String uri = server.uri(); String prefix = "/relay/"; if (!uri.startsWith(prefix)) { server.send(404, "application/json", "{\"ok\":false,\"error\":\"invalid_relay_route\"}"); return; } String rest = uri.substring(prefix.length()); int slash = rest.indexOf('/'); if (slash < 0) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"missing_relay_action\"}"); return; } String relayId = rest.substring(0, slash); String action = rest.substring(slash + 1); bool enabled; if (action == "on") enabled = true; else if (action == "off") enabled = false; else { server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_relay_action\"}"); return; } if (!setRelayById(relayId, enabled)) { server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}"); return; } DynamicJsonDocument doc(512); doc["ok"] = true; doc["id"] = relayId; doc["state"] = enabled; String output; serializeJson(doc, output); server.send(200, "application/json", output); } void handleStatus() { DynamicJsonDocument doc(4096); buildStatusDocument(doc); if (server.hasArg("fields")) { String fields = server.arg("fields"); DynamicJsonDocument filtered(4096); filtered["type"] = doc["type"]; filtered["timestamp"] = doc["timestamp"]; int start = 0; while (start <= fields.length()) { int comma = fields.indexOf(',', start); if (comma < 0) comma = fields.length(); String field = fields.substring(start, comma); field.trim(); int fieldIndex = findStatusFieldIndex(field); if (fieldIndex < 0) { DynamicJsonDocument errorDoc(512); errorDoc["ok"] = false; errorDoc["error"] = "invalid_field"; JsonArray details = errorDoc.createNestedArray("details"); details.add(String("Unknown field '") + field + "'"); String errorOutput; serializeJson(errorDoc, errorOutput); server.send(400, "application/json", errorOutput); return; } filtered[field] = doc[field]; start = comma + 1; if (comma == fields.length()) break; } doc.clear(); doc.set(filtered); } String output; serializeJson(doc, output); server.send(200, "application/json", output); } void heapCheckpoint(const char* label) { Serial.print("HEAP CHECK "); Serial.print(label); Serial.print(" free="); Serial.print(ESP.getFreeHeap()); Serial.print(" min="); Serial.println(ESP.getMinFreeHeap()); if (!heap_caps_check_integrity_all(true)) { Serial.print("HEAP CORRUPTION DETECTED AT: "); Serial.println(label); while (true) delay(1000); } } void setup() { Serial.begin(115200); delay(2000); Serial.println(); Serial.println("LilyGO clean isolation: config+relays+sensors+wifi+bare server"); heapCheckpoint("setup:start"); loadConfig(); heapCheckpoint("after:loadConfig"); printConfig(); initRelays(); heapCheckpoint("after:initRelays"); initSensors(); heapCheckpoint("after:initSensors"); heapCheckpoint("before:initBms"); initBms(); heapCheckpoint("after:initBms"); loadWifiConfig(); heapCheckpoint("after:loadWifiConfig"); loadApConfig(); heapCheckpoint("after:loadApConfig"); WiFi.persistent(false); WiFi.setSleep(false); WiFi.mode(WIFI_AP_STA); startAccessPoint(); heapCheckpoint("after:startAccessPoint"); server.on("/", HTTP_GET, []() { Serial.println("HTTP GET / dashboard"); server.send_P(200, "text/html", INDEX_HTML); }); server.on(API_V1("/health"), HTTP_GET, handleHealth); server.on(API_V1("/capabilities"), HTTP_GET, handleCapabilities); server.on(API_V1("/status"), handleStatus); 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/wifi"), HTTP_GET, handleGetWifiConfig); server.on(API_V1("/config/ap"), HTTP_GET, handleGetApConfig); server.on(API_V1("/config/device"), HTTP_POST, handleUpdateDeviceConfig); server.on(API_V1("/config/relay"), HTTP_POST, handleUpdateRelayConfig); server.on(API_V1("/config/bms"), HTTP_POST, handleUpdateBmsConfig); server.on(API_V1("/config/temp"), HTTP_POST, handleUpdateTempSensorConfig); server.on(API_V1("/config/factory-reset"), HTTP_POST, handleFactoryResetConfig); server.on(API_V1("/config/save"), HTTP_POST, handleSaveConfig); server.on(API_V1("/config/wifi"), HTTP_POST, handleUpdateWifiConfig); server.on(API_V1("/wifi/connect"), HTTP_POST, handleWifiConnect); server.on(API_V1("/wifi/clear"), HTTP_POST, handleWifiClear); server.on(API_V1("/bms/scan"), HTTP_POST, handleBleScan); server.on(API_V1("/bms/select"), HTTP_POST, handleSelectBms); // Legacy compatibility routes. server.on("/relay/relay_1/on", HTTP_GET, handleGenericRelayRoute); server.on("/relay/relay_1/off", HTTP_GET, handleGenericRelayRoute); server.on("/relay/relay_2/on", HTTP_GET, handleGenericRelayRoute); server.on("/relay/relay_2/off", HTTP_GET, handleGenericRelayRoute); server.on("/bms/scan", HTTP_POST, handleBleScan); server.on("/bms/select", HTTP_POST, handleSelectBms); server.begin(); Serial.println("Bare WebServer started"); heapCheckpoint("setup:end"); } void originalProjectSetupDisabled() { Serial.begin(115200); #if DASHBOARD_UART_ENABLED DashboardSerial.begin( DASHBOARD_UART_BAUD, SERIAL_8N1, 0, 0 ); Serial.println("Legacy dashboard UART enabled"); #endif Serial.println(); Serial.println("=================================="); Serial.println("Overland Controller Booting"); Serial.println("=================================="); oledBegin(); loadConfig(); printConfig(); pinMode(IGNITION_PIN, INPUT); initRelays(); initSensors(); initBms(); loadWifiConfig(); loadApConfig(); WiFi.mode(WIFI_AP_STA); startAccessPoint(); oledShowSetupPage(); // Do not block setup on STA WiFi. Start the AP and web server first so the // local Web UI/API remains available even when no saved WiFi networks exist. lastStaReconnectAttempt = millis() - STA_RECONNECT_INTERVAL_MS; server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", INDEX_HTML); }); server.on("/ota", HTTP_GET, handleOtaPage); server.on(API_V1("/system/ota"), HTTP_POST, handleOtaUploadDone, handleOtaUploadStream); 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/ap"), HTTP_GET, handleGetApConfig); server.on(API_V1("/config/ap"), HTTP_POST, handleUpdateApConfig); server.on(API_V1("/config/ap/reset"), HTTP_POST, handleResetApConfig); server.on(API_V1("/config/wifi"), HTTP_POST, handleUpdateWifiConfig); server.on(API_V1("/wifi/connect"), HTTP_POST, handleWifiConnect); server.on(API_V1("/wifi/clear"), HTTP_POST, handleWifiClear); server.on(API_V1("/config/device"), HTTP_POST, handleUpdateDeviceConfig); server.on(API_V1("/config/relay"), HTTP_POST, handleUpdateRelayConfig); server.on(API_V1("/config/bms"), HTTP_POST, handleUpdateBmsConfig); server.on(API_V1("/config/temp"), HTTP_POST, handleUpdateTempSensorConfig); server.on(API_V1("/config/factory-reset"), HTTP_POST, handleFactoryResetConfig); server.on(API_V1("/config/save"), HTTP_POST, handleSaveConfig); server.on(API_V1("/temps/scan"), HTTP_POST, handleTempScan); server.on(API_V1("/temps/assign"), HTTP_POST, handleTempAssign); server.on(API_V1("/temps/clear"), HTTP_POST, handleTempClear); server.on(API_V1("/bms/setup/enter"), HTTP_POST, handleEnterBmsSetup); server.on(API_V1("/bms/setup/exit"), HTTP_POST, handleExitBmsSetup); server.on(API_V1("/bms/scan"), HTTP_POST, handleBleScan); server.on(API_V1("/bms/select"), HTTP_POST, handleSelectBms); // Compatibility aliases for pre-versioned local clients. server.on("/status", handleStatus); server.on("/relay/set", HTTP_POST, handleSetRelayPost); server.on("/relay/relay_1/on", HTTP_GET, handleGenericRelayRoute); server.on("/relay/relay_1/off", HTTP_GET, handleGenericRelayRoute); server.on("/relay/relay_2/on", HTTP_GET, handleGenericRelayRoute); server.on("/relay/relay_2/off", HTTP_GET, handleGenericRelayRoute); server.on("/config", HTTP_GET, handleGetConfig); server.on("/config/wifi", HTTP_GET, handleGetWifiConfig); server.on("/config/wifi", HTTP_POST, handleUpdateWifiConfig); server.on("/wifi/connect", HTTP_POST, handleWifiConnect); server.on("/wifi/clear", HTTP_POST, handleWifiClear); server.on("/config/device", HTTP_POST, handleUpdateDeviceConfig); server.on("/config/relay", HTTP_POST, handleUpdateRelayConfig); server.on("/config/bms", HTTP_POST, handleUpdateBmsConfig); server.on("/config/temp", HTTP_POST, handleUpdateTempSensorConfig); server.on("/config/factory-reset", HTTP_POST, handleFactoryResetConfig); server.on("/config/save", HTTP_POST, handleSaveConfig); server.on("/temps/scan", HTTP_POST, handleTempScan); server.on("/temps/assign", HTTP_POST, handleTempAssign); server.on("/temps/clear", HTTP_POST, handleTempClear); server.on("/bms/setup/enter", HTTP_POST, handleEnterBmsSetup); server.on("/bms/setup/exit", HTTP_POST, handleExitBmsSetup); server.on("/bms/scan", HTTP_POST, handleBleScan); server.on("/bms/select", HTTP_POST, handleSelectBms); server.begin(); Serial.println("Web Server Started"); } void loop() { server.handleClient(); maintainStaWifi(); if (millis() > 30000) { updateBms(); } #if DASHBOARD_UART_ENABLED pollDashboardUart(); #endif delay(2); }