#include "webserver.h" #include #include "../utils/events.h" #include "../utils/timer.h" // Create AsyncWebServer object on port 80 AsyncWebServer server(80); // Measure OTA progress unsigned long ota_progress_millis = 0; #include "cellmonitor_html.h" #include "events_html.h" #include "index_html.cpp" #include "settings_html.h" enum WifiState { INIT, //before connecting first time RECONNECTING, //we've connected before, but lost connection CONNECTED //we are connected }; WifiState wifi_state = INIT; MyTimer ota_timeout_timer = MyTimer(5000); bool ota_active = false; unsigned const long WIFI_MONITOR_INTERVAL_TIME = 15000; unsigned const long INIT_WIFI_CONNECT_TIMEOUT = 8000; // Timeout for initial WiFi connect in milliseconds unsigned const long DEFAULT_WIFI_RECONNECT_INTERVAL = 1000; // Default WiFi reconnect interval in ms unsigned const long MAX_WIFI_RETRY_INTERVAL = 30000; // Maximum wifi retry interval in ms unsigned long last_wifi_monitor_time = millis(); //init millis so wifi monitor doesn't run immediately unsigned long wifi_reconnect_interval = DEFAULT_WIFI_RECONNECT_INTERVAL; unsigned long last_wifi_attempt_time = millis(); //init millis so wifi monitor doesn't run immediately void init_webserver() { // Configure WiFi if (AccessPointEnabled) { WiFi.mode(WIFI_AP_STA); // Simultaneous WiFi AP and Router connection init_WiFi_AP(); } else { WiFi.mode(WIFI_STA); // Only Router connection } init_WiFi_STA(ssid, password, wifi_channel); String content = index_html; // Route for root / web page server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", index_html, processor); }); // Route for going to settings web page server.on("/settings", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", index_html, settings_processor); }); // Route for going to cellmonitor web page server.on("/cellmonitor", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", index_html, cellmonitor_processor); }); // Route for going to event log web page server.on("/events", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", index_html, events_processor); }); // Route for editing Wh server.on("/updateBatterySize", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); BATTERY_WH_MAX = value.toInt(); storeSettings(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); // Route for editing USE_SCALED_SOC server.on("/updateUseScaledSOC", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); USE_SCALED_SOC = value.toInt(); storeSettings(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); // Route for editing SOCMax server.on("/updateSocMax", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); MAXPERCENTAGE = value.toInt() * 10; storeSettings(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); // Route for editing SOCMin server.on("/updateSocMin", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); MINPERCENTAGE = value.toInt() * 10; storeSettings(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); // Route for editing MaxChargeA server.on("/updateMaxChargeA", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); MAXCHARGEAMP = value.toInt() * 10; storeSettings(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); // Route for editing MaxDischargeA server.on("/updateMaxDischargeA", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); MAXDISCHARGEAMP = value.toInt() * 10; storeSettings(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); #ifdef TEST_FAKE_BATTERY // Route for editing FakeBatteryVoltage server.on("/updateFakeBatteryVoltage", HTTP_GET, [](AsyncWebServerRequest* request) { if (!request->hasParam("value")) { request->send(400, "text/plain", "Bad Request"); } String value = request->getParam("value")->value(); float val = value.toFloat(); system_battery_voltage_dV = val * 10; request->send(200, "text/plain", "Updated successfully"); }); #endif #if defined CHEVYVOLT_CHARGER || defined NISSANLEAF_CHARGER // Route for editing ChargerTargetV server.on("/updateChargeSetpointV", HTTP_GET, [](AsyncWebServerRequest* request) { if (!request->hasParam("value")) { request->send(400, "text/plain", "Bad Request"); } String value = request->getParam("value")->value(); float val = value.toFloat(); if (!(val <= CHARGER_MAX_HV && val >= CHARGER_MIN_HV)) { request->send(400, "text/plain", "Bad Request"); } if (!(val * charger_setpoint_HV_IDC <= CHARGER_MAX_POWER)) { request->send(400, "text/plain", "Bad Request"); } charger_setpoint_HV_VDC = val; request->send(200, "text/plain", "Updated successfully"); }); // Route for editing ChargerTargetA server.on("/updateChargeSetpointA", HTTP_GET, [](AsyncWebServerRequest* request) { if (!request->hasParam("value")) { request->send(400, "text/plain", "Bad Request"); } String value = request->getParam("value")->value(); float val = value.toFloat(); if (!(val <= MAXCHARGEAMP && val <= CHARGER_MAX_A)) { request->send(400, "text/plain", "Bad Request"); } if (!(val * charger_setpoint_HV_VDC <= CHARGER_MAX_POWER)) { request->send(400, "text/plain", "Bad Request"); } charger_setpoint_HV_IDC = value.toFloat(); request->send(200, "text/plain", "Updated successfully"); }); // Route for editing ChargerEndA server.on("/updateChargeEndA", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); charger_setpoint_HV_IDC_END = value.toFloat(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); // Route for enabling/disabling HV charger server.on("/updateChargerHvEnabled", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); charger_HV_enabled = (bool)value.toInt(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); // Route for enabling/disabling aux12v charger server.on("/updateChargerAux12vEnabled", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); charger_aux12V_enabled = (bool)value.toInt(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); #endif // Send a GET request to /update server.on("/debug", HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/plain", "Debug: all OK."); }); // Route to handle reboot command server.on("/reboot", HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/plain", "Rebooting server..."); //TODO: Should we handle contactors gracefully? Ifdef CONTACTOR_CONTROL then what? delay(1000); ESP.restart(); }); // Initialize ElegantOTA init_ElegantOTA(); // Start server server.begin(); #ifdef MQTT // Init MQTT init_mqtt(); #endif } void init_WiFi_AP() { #ifdef DEBUG_VIA_USB Serial.println("Creating Access Point: " + String(ssidAP)); Serial.println("With password: " + String(passwordAP)); #endif WiFi.softAP(ssidAP, passwordAP); IPAddress IP = WiFi.softAPIP(); #ifdef DEBUG_VIA_USB Serial.println("Access Point created."); Serial.print("IP address: "); Serial.println(IP); #endif } String getConnectResultString(wl_status_t status) { switch (status) { case WL_CONNECTED: return "Connected"; case WL_NO_SHIELD: return "No shield"; case WL_IDLE_STATUS: return "Idle status"; case WL_NO_SSID_AVAIL: return "No SSID available"; case WL_SCAN_COMPLETED: return "Scan completed"; case WL_CONNECT_FAILED: return "Connect failed"; case WL_CONNECTION_LOST: return "Connection lost"; case WL_DISCONNECTED: return "Disconnected"; default: return "Unknown"; } } void wifi_monitor() { unsigned long currentMillis = millis(); if (currentMillis - last_wifi_monitor_time > WIFI_MONITOR_INTERVAL_TIME) { last_wifi_monitor_time = currentMillis; wl_status_t status = WiFi.status(); if (status != WL_CONNECTED && status != WL_IDLE_STATUS) { #ifdef DEBUG_VIA_USB Serial.println(getConnectResultString(status)); #endif if (wifi_state == INIT) { //we haven't been connected yet, try the init logic init_WiFi_STA(ssid, password, wifi_channel); } else { //we were connected before, try the reconnect logic if (currentMillis - last_wifi_attempt_time > wifi_reconnect_interval) { last_wifi_attempt_time = currentMillis; #ifdef DEBUG_VIA_USB Serial.println("WiFi not connected, trying to reconnect..."); #endif wifi_state = RECONNECTING; WiFi.reconnect(); wifi_reconnect_interval = min(wifi_reconnect_interval * 2, MAX_WIFI_RETRY_INTERVAL); } } } else if (status == WL_CONNECTED && wifi_state != CONNECTED) { wifi_state = CONNECTED; wifi_reconnect_interval = DEFAULT_WIFI_RECONNECT_INTERVAL; // Print local IP address and start web server #ifdef DEBUG_VIA_USB Serial.print("Connected to WiFi network: " + String(ssid)); Serial.print(" IP address: " + WiFi.localIP().toString()); Serial.print(" Signal Strength: " + String(WiFi.RSSI()) + " dBm"); Serial.println(" Channel: " + String(WiFi.channel())); Serial.println(" Hostname: " + String(WiFi.getHostname())); #endif } } if (ota_active && ota_timeout_timer.elapsed()) { // OTA timeout, try to restore can and clear the update event ESP32Can.CANInit(); clear_event(EVENT_OTA_UPDATE); set_event(EVENT_OTA_UPDATE_TIMEOUT, 0); ota_active = false; } } void init_WiFi_STA(const char* ssid, const char* password, const uint8_t wifi_channel) { // Connect to Wi-Fi network with SSID and password #ifdef DEBUG_VIA_USB Serial.print("Connecting to "); Serial.println(ssid); #endif WiFi.begin(ssid, password, wifi_channel); WiFi.setAutoReconnect(true); // Enable auto reconnect wl_status_t result = static_cast(WiFi.waitForConnectResult(INIT_WIFI_CONNECT_TIMEOUT)); } // Function to initialize ElegantOTA void init_ElegantOTA() { ElegantOTA.begin(&server); // Start ElegantOTA // ElegantOTA callbacks ElegantOTA.onStart(onOTAStart); ElegantOTA.onProgress(onOTAProgress); ElegantOTA.onEnd(onOTAEnd); } String processor(const String& var) { if (var == "ABC") { String content = ""; //Page format content += ""; // Start a new block with a specific background color content += "
"; // Show version number content += "

Software: " + String(version_number) + "

"; wl_status_t status = WiFi.status(); // Display ssid of network connected to and, if connected to the WiFi, its own IP content += "

SSID: " + String(ssid) + "

"; if (status == WL_CONNECTED) { content += "

IP: " + WiFi.localIP().toString() + "

"; // Get and display the signal strength (RSSI) content += "

Signal Strength: " + String(WiFi.RSSI()) + " dBm

"; content += "

Channel: " + String(WiFi.channel()) + "

"; } else { content += "

Wifi state: " + getConnectResultString(status) + "

"; } // Close the block content += "
"; // Start a new block with a specific background color content += "
"; // Display which components are used content += "

Inverter protocol: "; #ifdef BYD_CAN content += "BYD Battery-Box Premium HVS over CAN Bus"; #endif #ifdef BYD_MODBUS content += "BYD 11kWh HVM battery over Modbus RTU"; #endif #ifdef LUNA2000_MODBUS content += "Luna2000 battery over Modbus RTU"; #endif #ifdef PYLON_CAN content += "Pylontech battery over CAN bus"; #endif #ifdef SERIAL_LINK_TRANSMITTER content += "Serial link to another LilyGo board"; #endif #ifdef SMA_CAN content += "BYD Battery-Box H 8.9kWh, 7 mod over CAN bus"; #endif #ifdef SOFAR_CAN content += "Sofar Energy Storage Inverter High Voltage BMS General Protocol (Extended Frame) over CAN bus"; #endif #ifdef SOLAX_CAN content += "SolaX Triple Power LFP over CAN bus"; #endif content += "

"; content += "

Battery protocol: "; #ifdef BMW_I3_BATTERY content += "BMW i3"; #endif #ifdef CHADEMO_BATTERY content += "Chademo V2X mode"; #endif #ifdef IMIEV_CZERO_ION_BATTERY content += "I-Miev / C-Zero / Ion Triplet"; #endif #ifdef KIA_HYUNDAI_64_BATTERY content += "Kia/Hyundai 64kWh"; #endif #ifdef NISSAN_LEAF_BATTERY content += "Nissan LEAF"; #endif #ifdef RENAULT_KANGOO_BATTERY content += "Renault Kangoo"; #endif #ifdef RENAULT_ZOE_BATTERY content += "Renault Zoe"; #endif #ifdef SERIAL_LINK_RECEIVER content += "Serial link to another LilyGo board"; #endif #ifdef TESLA_MODEL_3_BATTERY content += "Tesla Model S/3/X/Y"; #endif #ifdef VOLVO_SPA_BATTERY content += "Volvo / Polestar 78kWh battery"; #endif #ifdef TEST_FAKE_BATTERY content += "Fake battery for testing purposes"; #endif content += "

"; #if defined CHEVYVOLT_CHARGER || defined NISSANLEAF_CHARGER content += "

Charger protocol: "; #ifdef CHEVYVOLT_CHARGER content += "Chevy Volt Gen1 Charger"; #endif #ifdef NISSANLEAF_CHARGER content += "Nissan LEAF 2013-2024 PDM charger"; #endif content += "

"; #endif // Close the block content += "
"; // Start a new block with a specific background color. Color changes depending on BMS status content += "
"; // Display battery statistics within this block float socRealFloat = static_cast(system_real_SOC_pptt) / 100.0; // Convert to float and divide by 100 float socScaledFloat = static_cast(system_scaled_SOC_pptt) / 100.0; // Convert to float and divide by 100 float sohFloat = static_cast(system_SOH_pptt) / 100.0; // Convert to float and divide by 100 float voltageFloat = static_cast(system_battery_voltage_dV) / 10.0; // Convert to float and divide by 10 float currentFloat = static_cast(system_battery_current_dA) / 10.0; // Convert to float and divide by 10 float powerFloat = static_cast(system_active_power_W); // Convert to float float tempMaxFloat = static_cast(system_temperature_max_dC) / 10.0; // Convert to float float tempMinFloat = static_cast(system_temperature_min_dC) / 10.0; // Convert to float content += "

Real SOC: " + String(socRealFloat, 2) + "

"; content += "

Scaled SOC: " + String(socScaledFloat, 2) + "

"; content += "

SOH: " + String(sohFloat, 2) + "

"; content += "

Voltage: " + String(voltageFloat, 1) + " V

"; content += "

Current: " + String(currentFloat, 1) + " A

"; content += formatPowerValue("Power", powerFloat, "", 1); content += formatPowerValue("Total capacity", system_capacity_Wh, "h", 0); content += formatPowerValue("Remaining capacity", system_remaining_capacity_Wh, "h", 1); content += formatPowerValue("Max discharge power", system_max_discharge_power_W, "", 1); content += formatPowerValue("Max charge power", system_max_charge_power_W, "", 1); content += "

Cell max: " + String(system_cell_max_voltage_mV) + " mV

"; content += "

Cell min: " + String(system_cell_min_voltage_mV) + " mV

"; content += "

Temperature max: " + String(tempMaxFloat, 1) + " C

"; content += "

Temperature min: " + String(tempMinFloat, 1) + " C

"; if (system_bms_status == ACTIVE) { content += "

BMS Status: OK

"; } else if (system_bms_status == UPDATING) { content += "

BMS Status: UPDATING

"; } else { content += "

BMS Status: FAULT

"; } if (system_battery_current_dA == 0) { content += "

Battery idle

"; } else if (system_battery_current_dA < 0) { content += "

Battery discharging!

"; } else { // > 0 content += "

Battery charging!

"; } content += "

Automatic contactor closing allowed:

"; content += "

Battery: "; if (batteryAllowsContactorClosing) { content += ""; } else { content += ""; } content += " Inverter: "; if (inverterAllowsContactorClosing) { content += "

"; } else { content += ""; } // Close the block content += "
"; #if defined CHEVYVOLT_CHARGER || defined NISSANLEAF_CHARGER // Start a new block with orange background color content += "
"; content += "

Charger HV Enabled: "; if (charger_HV_enabled) { content += ""; } else { content += ""; } content += "

"; content += "

Charger Aux12v Enabled: "; if (charger_aux12V_enabled) { content += ""; } else { content += ""; } content += "

"; #ifdef CHEVYVOLT_CHARGER float chgPwrDC = static_cast(charger_stat_HVcur * charger_stat_HVvol); float chgPwrAC = static_cast(charger_stat_ACcur * charger_stat_ACvol); float chgEff = chgPwrDC / chgPwrAC * 100; float ACcur = charger_stat_ACcur; float ACvol = charger_stat_ACvol; float HVvol = charger_stat_HVvol; float HVcur = charger_stat_HVcur; float LVvol = charger_stat_LVvol; float LVcur = charger_stat_LVcur; content += formatPowerValue("Charger Output Power", chgPwrDC, "", 1); content += "

Charger Efficiency: " + String(chgEff) + "%

"; content += "

Charger HVDC Output V: " + String(HVvol, 2) + " V

"; content += "

Charger HVDC Output I: " + String(HVcur, 2) + " A

"; content += "

Charger LVDC Output I: " + String(LVcur, 2) + "

"; content += "

Charger LVDC Output V: " + String(LVvol, 2) + "

"; content += "

Charger AC Input V: " + String(ACvol, 2) + " VAC

"; content += "

Charger AC Input I: " + String(ACcur, 2) + " A

"; #endif #ifdef NISSANLEAF_CHARGER float chgPwrDC = static_cast(charger_stat_HVcur * 100); charger_stat_HVcur = chgPwrDC / (system_battery_voltage_dV / 10); // P/U=I charger_stat_HVvol = static_cast(system_battery_voltage_dV / 10); float ACvol = charger_stat_ACvol; float HVvol = charger_stat_HVvol; float HVcur = charger_stat_HVcur; content += formatPowerValue("Charger Output Power", chgPwrDC, "", 1); content += "

Charger HVDC Output V: " + String(HVvol, 2) + " V

"; content += "

Charger HVDC Output I: " + String(HVcur, 2) + " A

"; content += "

Charger AC Input V: " + String(ACvol, 2) + " VAC

"; #endif // Close the block content += "
"; #endif content += ""; content += " "; content += ""; content += " "; content += ""; content += " "; content += ""; content += " "; content += ""; content += ""; //Script for refreshing page content += ""; return content; } return String(); } void onOTAStart() { // Log when OTA has started ESP32Can.CANStop(); set_event(EVENT_OTA_UPDATE, 0); // If already set, make a new attempt clear_event(EVENT_OTA_UPDATE_TIMEOUT); ota_active = true; ota_timeout_timer.reset(); } void onOTAProgress(size_t current, size_t final) { // Log every 1 second if (millis() - ota_progress_millis > 1000) { ota_progress_millis = millis(); #ifdef DEBUG_VIA_USB Serial.printf("OTA Progress Current: %u bytes, Final: %u bytes\n", current, final); #endif // Reset the "watchdog" ota_timeout_timer.reset(); } } void onOTAEnd(bool success) { // Log when OTA has finished if (success) { #ifdef DEBUG_VIA_USB Serial.println("OTA update finished successfully!"); #endif } else { #ifdef DEBUG_VIA_USB Serial.println("There was an error during OTA update!"); #endif // If we fail without a timeout, try to restore CAN ESP32Can.CANInit(); } ota_active = false; clear_event(EVENT_OTA_UPDATE); } template // This function makes power values appear as W when under 1000, and kW when over String formatPowerValue(String label, T value, String unit, int precision) { String result = "

" + label + ": "; if (std::is_same::value || std::is_same::value || std::is_same::value) { float convertedValue = static_cast(value); if (convertedValue >= 1000.0 || convertedValue <= -1000.0) { result += String(convertedValue / 1000.0, precision) + " kW"; } else { result += String(convertedValue, 0) + " W"; } } result += unit + "

"; return result; }