#include "webserver.h" // Create AsyncWebServer object on port 80 AsyncWebServer server(80); // Measure OTA progress unsigned long ota_progress_millis = 0; const char index_html[] PROGMEM = R"rawliteral( Battery Emulator

Battery Emulator

%PLACEHOLDER% )rawliteral"; String wifi_state; bool wifi_connected; // Wifi connect time declarations and definition unsigned long wifi_connect_start_time; unsigned long wifi_connect_current_time; const long wifi_connect_timeout = 5000; // Timeout for WiFi connect in milliseconds void init_webserver() { // Configure WiFi if (AccessPointEnabled) { WiFi.mode(WIFI_AP_STA); // Simultaneous WiFi AP and Router connection init_WiFi_AP(); init_WiFi_STA(ssid, password); } else { WiFi.mode(WIFI_STA); // Only Router connection init_WiFi_STA(ssid, password); } // 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 editing Wh server.on("/updateBatterySize", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); BATTERY_WH_MAX = value.toInt(); 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; 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; 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; 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; 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(); battery_voltage = 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(); } void init_WiFi_AP() { Serial.print("Creating Access Point: "); Serial.println(ssidAP); Serial.print("With password: "); Serial.println(passwordAP); WiFi.softAP(ssidAP, passwordAP); IPAddress IP = WiFi.softAPIP(); Serial.println("Access Point created."); Serial.print("IP address: "); Serial.println(IP); } void init_WiFi_STA(const char* ssid, const char* password) { // Connect to Wi-Fi network with SSID and password Serial.print("Connecting to "); Serial.println(ssid); WiFi.begin(ssid, password); wifi_connect_start_time = millis(); wifi_connect_current_time = wifi_connect_start_time; while ((wifi_connect_current_time - wifi_connect_start_time) <= wifi_connect_timeout && WiFi.status() != WL_CONNECTED) { // do this loop for up to 5000ms // to break the loop when the connection is not established (wrong ssid or password). delay(500); Serial.print("."); wifi_connect_current_time = millis(); } if (WiFi.status() == WL_CONNECTED) { // WL_CONNECTED is assigned when connected to a WiFi network wifi_connected = true; wifi_state = "Connected"; // Print local IP address and start web server Serial.println(""); Serial.print("Connected to WiFi network: "); Serial.println(ssid); Serial.print("IP address: "); Serial.println(WiFi.localIP()); } else { wifi_connected = false; wifi_state = "Not connected"; Serial.print("Not connected to WiFi network: "); Serial.println(ssid); Serial.println("Please check WiFi network name and password, and if WiFi network is available."); } } 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 == "PLACEHOLDER") { String content = ""; //Page format content += ""; // Start a new block with a specific background color content += "
"; // Show version number content += "

Software version: " + String(versionNumber) + "

"; // Display LED color content += "

LED color: "; switch (LEDcolor) { case GREEN: content += "GREEN

"; break; case YELLOW: content += "YELLOW"; break; case BLUE: content += "BLUE"; break; case RED: content += "RED"; break; case TEST_ALL_COLORS: content += "RGB Testing loop"; break; default: break; } // Display ssid of network connected to and, if connected to the WiFi, its own IP content += "

SSID: " + String(ssid) + "

"; content += "

Wifi status: " + wifi_state + "

"; if (wifi_connected == true) { content += "

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

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

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

"; } // 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 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 switch (LEDcolor) { case GREEN: content += "
"; break; case YELLOW: content += "
"; break; case BLUE: content += "
"; break; case RED: content += "
"; break; case TEST_ALL_COLORS: //Blue in test mode content += "
"; break; default: //Some new color, make background green content += "
"; break; } // Display battery statistics within this block float socFloat = static_cast(SOC) / 100.0; // Convert to float and divide by 100 float sohFloat = static_cast(StateOfHealth) / 100.0; // Convert to float and divide by 100 float voltageFloat = static_cast(battery_voltage) / 10.0; // Convert to float and divide by 10 float currentFloat = 0; if (battery_current > 32767) { //Handle negative values on this unsigned value currentFloat = static_cast(-(65535 - battery_current)) / 10.0; // Convert to float and divide by 10 } else { currentFloat = static_cast(battery_current) / 10.0; // Convert to float and divide by 10 } float powerFloat = 0; if (stat_batt_power > 32767) { //Handle negative values on this unsigned value powerFloat = static_cast(-(65535 - stat_batt_power)); } else { powerFloat = static_cast(stat_batt_power); } float tempMaxFloat = 0; float tempMinFloat = 0; if (temperature_max > 32767) { //Handle negative values on this unsigned value tempMaxFloat = static_cast(-(65536 - temperature_max)) / 10.0; // Convert to float and divide by 10 } else { tempMaxFloat = static_cast(temperature_max) / 10.0; // Convert to float and divide by 10 } if (temperature_min > 32767) { //Handle negative values on this unsigned value tempMinFloat = static_cast(-(65536 - temperature_min)) / 10.0; // Convert to float and divide by 10 } else { tempMinFloat = static_cast(temperature_min) / 10.0; // Convert to float and divide by 10 } content += "

SOC: " + String(socFloat, 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", capacity_Wh, "h", 0); content += formatPowerValue("Remaining capacity", remaining_capacity_Wh, "h", 1); content += formatPowerValue("Max discharge power", max_target_discharge_power, "", 1); content += formatPowerValue("Max charge power", max_target_charge_power, "", 1); content += "

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

"; content += "

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

"; content += "

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

"; content += "

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

"; if (bms_status == 3) { content += "

BMS Status: OK

"; } else { content += "

BMS Status: FAULT

"; } if (bms_char_dis_status == 2) { content += "

Battery charging!

"; } else if (bms_char_dis_status == 1) { content += "

Battery discharging!

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

Battery idle

"; } 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 += "

"; 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) + "

"; content += "

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

"; 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(ACvol, 2) + "VAC

"; // Close the block content += "
"; #endif content += ""; content += " "; content += ""; content += " "; content += ""; content += ""; //Script for refreshing page content += ""; return content; } return String(); } String settings_processor(const String& var) { if (var == "PLACEHOLDER") { String content = ""; //Page format content += ""; // Start a new block with a specific background color content += "
"; // Show current settings with edit buttons and input fields content += "

Battery capacity: " + String(BATTERY_WH_MAX) + " Wh

"; content += "

SOC max percentage: " + String(MAXPERCENTAGE / 10.0, 1) + "

"; content += "

SOC min percentage: " + String(MINPERCENTAGE / 10.0, 1) + "

"; content += "

Max charge speed: " + String(MAXCHARGEAMP / 10.0, 1) + " A

"; content += "

Max discharge speed: " + String(MAXDISCHARGEAMP / 10.0, 1) + " A

"; // Close the block content += "
"; #ifdef TEST_FAKE_BATTERY // Start a new block with blue background color content += "
"; float voltageFloat = static_cast(battery_voltage) / 10.0; // Convert to float and divide by 10 content += "

Fake battery voltage: " + String(voltageFloat, 1) + " V

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

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

"; content += "

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

"; content += "

Charger Voltage Setpoint: " + String(charger_setpoint_HV_VDC, 1) + " V

"; content += "

Charger Current Setpoint: " + String(charger_setpoint_HV_IDC, 1) + " A

"; // Close the block content += "
"; #endif content += ""; content += ""; content += ""; return content; } return String(); } void onOTAStart() { // Log when OTA has started Serial.println("OTA update started!"); ESP32Can.CANStop(); bms_status = UPDATING; //Inform inverter that we are updating LEDcolor = BLUE; } void onOTAProgress(size_t current, size_t final) { bms_status = UPDATING; //Inform inverter that we are updating LEDcolor = BLUE; // Log every 1 second if (millis() - ota_progress_millis > 1000) { ota_progress_millis = millis(); Serial.printf("OTA Progress Current: %u bytes, Final: %u bytes\n", current, final); } } void onOTAEnd(bool success) { // Log when OTA has finished if (success) { Serial.println("OTA update finished successfully!"); } else { Serial.println("There was an error during OTA update!"); } bms_status = UPDATING; //Inform inverter that we are updating LEDcolor = BLUE; } 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) { 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; }