#include #include #include #include #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
Connecting...
Battery
--%
Voltage--
Current--
Remaining--
BMS
--
Battery temp--
Cell delta--
Cycles--
Temperatures
Relays
WiFi
AP stays available at 192.168.4.1. STA networks are tried in order.
STA--
STA IP--
Alarms / System
Firmware--
Uptime--
)rawliteral"; WebServer server(80); HardwareSerial DashboardSerial(2); String uartLineBuffer; Preferences wifiPrefs; 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; } } 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 < 10000) { delay(500); 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()); } } float calculateRuntimeHours() { if (!bmsData.valid || bmsData.current >= 0) return 0; float dischargeAmps = -bmsData.current; if (dischargeAmps <= 0.1) return 0; return bmsData.remainingAh / dischargeAmps; } 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 { 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; } const float tempValues[4] = {sensors.temp1, sensors.temp2, sensors.temp3, sensors.temp4}; const bool tempOnline[4] = {sensors.temp1Online, sensors.temp2Online, sensors.temp3Online, sensors.temp4Online}; 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["online"] = tempOnline[i]; if (tempOnline[i]) { temp["temperature_f"] = tempValues[i]; } else { temp["temperature_f"] = nullptr; } } JsonArray relayStates = doc.createNestedArray("relays"); JsonObject relay1 = relayStates.createNestedObject(); relay1["id"] = appConfig.relays[0].id; relay1["name"] = appConfig.relays[0].name; relay1["pin"] = appConfig.relays[0].pin; relay1["enabled"] = appConfig.relays[0].enabled; relay1["state"] = relays.relay1; JsonObject relay2 = relayStates.createNestedObject(); relay2["id"] = appConfig.relays[1].id; relay2["name"] = appConfig.relays[1].name; relay2["pin"] = appConfig.relays[1].pin; relay2["enabled"] = appConfig.relays[1].enabled; relay2["state"] = relays.relay2; 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_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() : ""; 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; 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; } } 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(); } bool setRelayById(const String& relayId, bool enabled) { if (relayId == "relay_1") relays.relay1 = enabled; else if (relayId == "relay_2") relays.relay2 = enabled; else return false; updateRelayOutputs(); return true; } void sendRelayResponse(Stream& output, const String& relayId, bool enabled) { DynamicJsonDocument doc(256); doc["type"] = MSG_RELAY_RESPONSE; doc["relay"] = relayId; doc["enabled"] = 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; 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["pin"] = appConfig.relays[i].pin; relay["enabled"] = appConfig.relays[i].enabled; } 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; } 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(); } int findRelayConfigIndexForUart(const String& id) { for (int i = 0; i < MAX_RELAYS; i++) { if (appConfig.relays[i].id == id) { return i; } } return -1; } int findTempConfigIndexForUart(const String& id) { for (int i = 0; i < MAX_TEMP_SENSORS; i++) { if (appConfig.tempSensors[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["relay"] | ""; bool enabled = doc["enabled"] | false; 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 = findTempConfigIndexForUart(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, "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"); } } } } 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.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); 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; } 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 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(); sendOkConfig(); } void handleFactoryResetConfig() { factoryResetConfig(); sendConfigJson(); } void sendWifiConfigJson() { DynamicJsonDocument doc(2048); doc["type"] = "wifi_config_response"; 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; } 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()) { 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; } if (password.length() == 0) { for (int oldIndex = 0; oldIndex < MAX_WIFI_NETWORKS; oldIndex++) { if (staSsids[oldIndex] == ssid && staPasswords[oldIndex].length() > 0) { password = staPasswords[oldIndex]; break; } } } int priority = network["priority"] | (wifiNetworkCount + 1); if (priority <= 0) priority = wifiNetworkCount + 1; staSsids[wifiNetworkCount] = ssid; staPasswords[wifiNetworkCount] = password; staPriorities[wifiNetworkCount] = priority; wifiNetworkCount++; } for (int i = wifiNetworkCount; i < MAX_WIFI_NETWORKS; i++) { staSsids[i] = ""; staPasswords[i] = ""; staPriorities[i] = i + 1; } } else { 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 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() { 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; if (!setRelayById(relayId, state)) { server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}"); return; } DynamicJsonDocument response(512); response["type"] = "relay_response"; response["ok"] = true; response["relay"] = relayId; response["state"] = state; response["enabled"] = state; String output; serializeJson(response, output); server.send(200, "application/json", output); } 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); String output; serializeJson(doc, output); server.send(200, "application/json", output); } void setup() { Serial.begin(115200); DashboardSerial.begin( DASHBOARD_UART_BAUD, SERIAL_8N1, DASHBOARD_UART_RX_PIN, DASHBOARD_UART_TX_PIN ); Serial.println(); Serial.println("=================================="); Serial.println("Overland Controller Booting"); Serial.println("=================================="); loadConfig(); printConfig(); pinMode(IGNITION_PIN, INPUT); initRelays(); initSensors(); initBms(); loadWifiConfig(); WiFi.mode(WIFI_AP_STA); bool apResult = WiFi.softAP("OverlandController"); if (apResult) { Serial.println("AP Started"); Serial.print("AP IP: "); Serial.println(WiFi.softAPIP()); } connectStaWifi(); server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", INDEX_HTML); }); 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("/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"); Serial.println("Dashboard UART Started"); } void loop() { server.handleClient(); maintainStaWifi(); handleDebugSerial(); updateBms(); updateAlarms(); pollDashboardUart(); static unsigned long lastSensorUpdate = 0; if (millis() - lastSensorUpdate > 5000) { updateSensors(); lastSensorUpdate = millis(); logDebug("Sensor Update"); } }