#include "webserver.h" #include #include #include #include "../../battery/BATTERIES.h" #include "../../battery/Battery.h" #include "../../charger/CHARGERS.h" #include "../../communication/can/comm_can.h" #include "../../communication/contactorcontrol/comm_contactorcontrol.h" #include "../../communication/nvm/comm_nvm.h" #include "../../datalayer/datalayer.h" #include "../../datalayer/datalayer_extended.h" #include "../../inverter/INVERTERS.h" #include "../../lib/bblanchon-ArduinoJson/ArduinoJson.h" #include "../sdcard/sdcard.h" #include "../utils/events.h" #include "../utils/led_handler.h" #include "../utils/timer.h" #include "esp_task_wdt.h" #include extern std::string http_username; extern std::string http_password; bool webserver_enabled = true; // Global flag to enable or disable the webserver //Old method to disable was with #ifdef WEBSERVER bool webserver_auth = false; // Create AsyncWebServer object on port 80 AsyncWebServer server(80); // Measure OTA progress unsigned long ota_progress_millis = 0; #include "advanced_battery_html.h" #include "can_logging_html.h" #include "can_replay_html.h" #include "cellmonitor_html.h" #include "debug_logging_html.h" #include "events_html.h" #include "index_html.h" #include "settings_html.h" MyTimer ota_timeout_timer = MyTimer(15000); bool ota_active = false; const char get_firmware_info_html[] = R"rawliteral(%X%)rawliteral"; String importedLogs = ""; // Store the uploaded logfile contents in RAM bool isReplayRunning = false; // Global flag to track replay state // True when user has updated settings that need a reboot to be effective. bool settingsUpdated = false; CAN_frame currentFrame = {.FD = true, .ext_ID = false, .DLC = 64, .ID = 0x12F, .data = {0}}; void handleFileUpload(AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final) { if (!index) { importedLogs = ""; // Clear previous logs logging.printf("Receiving file: %s\n", filename.c_str()); } // Append received data to the string (RAM storage) importedLogs += String((char*)data).substring(0, len); if (final) { logging.println("Upload Complete!"); request->send(200, "text/plain", "File uploaded successfully"); } } void canReplayTask(void* param) { std::vector messages; messages.reserve(1000); // Pre-allocate memory to reduce fragmentation if (!importedLogs.isEmpty()) { int lastIndex = 0; while (true) { int nextIndex = importedLogs.indexOf("\n", lastIndex); if (nextIndex == -1) { messages.push_back(importedLogs.substring(lastIndex)); break; } messages.push_back(importedLogs.substring(lastIndex, nextIndex)); lastIndex = nextIndex + 1; } do { float firstTimestamp = -1.0; float lastTimestamp = 0.0; bool firstMessageSent = false; // Track first message for (size_t i = 0; i < messages.size(); i++) { String line = messages[i]; line.trim(); if (line.length() == 0) continue; int timeStart = line.indexOf("(") + 1; int timeEnd = line.indexOf(")"); if (timeStart == 0 || timeEnd == -1) continue; float currentTimestamp = line.substring(timeStart, timeEnd).toFloat(); if (firstTimestamp < 0) { firstTimestamp = currentTimestamp; } // Send first message immediately if (!firstMessageSent) { firstMessageSent = true; firstTimestamp = currentTimestamp; // Adjust reference time } else { // Delay only if this isn't the first message float deltaT = (currentTimestamp - lastTimestamp) * 1000; vTaskDelay((int)deltaT / portTICK_PERIOD_MS); } lastTimestamp = currentTimestamp; int interfaceStart = timeEnd + 2; int interfaceEnd = line.indexOf(" ", interfaceStart); if (interfaceEnd == -1) continue; int idStart = interfaceEnd + 1; int idEnd = line.indexOf(" [", idStart); if (idStart == -1 || idEnd == -1) continue; String messageID = line.substring(idStart, idEnd); int dlcStart = idEnd + 2; int dlcEnd = line.indexOf("]", dlcStart); if (dlcEnd == -1) continue; String dlc = line.substring(dlcStart, dlcEnd); int dataStart = dlcEnd + 2; String dataBytes = line.substring(dataStart); currentFrame.ID = strtol(messageID.c_str(), NULL, 16); currentFrame.DLC = dlc.toInt(); int byteIndex = 0; char* token = strtok((char*)dataBytes.c_str(), " "); while (token != NULL && byteIndex < currentFrame.DLC) { currentFrame.data.u8[byteIndex++] = strtol(token, NULL, 16); token = strtok(NULL, " "); } currentFrame.FD = (datalayer.system.info.can_replay_interface == CANFD_NATIVE) || (datalayer.system.info.can_replay_interface == CANFD_ADDON_MCP2518); currentFrame.ext_ID = (currentFrame.ID > 0x7F0); transmit_can_frame_to_interface(¤tFrame, datalayer.system.info.can_replay_interface); } } while (datalayer.system.info.loop_playback); messages.clear(); // Free vector memory messages.shrink_to_fit(); // Release excess memory } isReplayRunning = false; // Mark replay as stopped vTaskDelete(NULL); } void def_route_with_auth(const char* uri, AsyncWebServer& serv, WebRequestMethodComposite method, std::function handler) { serv.on(uri, method, [handler](AsyncWebServerRequest* request) { if (webserver_auth && !request->authenticate(http_username.c_str(), http_password.c_str())) { return request->requestAuthentication(); } handler(request); }); } void init_webserver() { server.on("/logout", HTTP_GET, [](AsyncWebServerRequest* request) { request->send(401); }); // Route for firmware info from ota update page def_route_with_auth("/GetFirmwareInfo", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "application/json", get_firmware_info_html, get_firmware_info_processor); }); // Route for root / web page def_route_with_auth("/", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/html", index_html, processor); }); // Route for going to settings web page def_route_with_auth("/settings", server, HTTP_GET, [](AsyncWebServerRequest* request) { // Using make_shared to ensure lifetime for the settings object during send() lambda execution auto settings = std::make_shared(true); request->send(200, "text/html", settings_html, [settings](const String& content) { return settings_processor(content, *settings); }); }); // Route for going to advanced battery info web page def_route_with_auth("/advanced", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/html", index_html, advanced_battery_processor); }); // Route for going to CAN logging web page def_route_with_auth("/canlog", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(request->beginResponse(200, "text/html", can_logger_processor())); }); // Route for going to CAN replay web page def_route_with_auth("/canreplay", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(request->beginResponse(200, "text/html", can_replay_processor())); }); def_route_with_auth("/startReplay", server, HTTP_GET, [](AsyncWebServerRequest* request) { // Prevent multiple replay tasks from being created if (isReplayRunning) { request->send(400, "text/plain", "Replay already running!"); return; } datalayer.system.info.loop_playback = request->hasParam("loop") && request->getParam("loop")->value().toInt() == 1; isReplayRunning = true; // Set flag before starting task xTaskCreatePinnedToCore(canReplayTask, "CAN_Replay", 8192, NULL, 1, NULL, 1); request->send(200, "text/plain", "CAN replay started!"); }); // Route for stopping the CAN replay def_route_with_auth("/stopReplay", server, HTTP_GET, [](AsyncWebServerRequest* request) { datalayer.system.info.loop_playback = false; request->send(200, "text/plain", "CAN replay stopped!"); }); // Route to handle setting the CAN interface for CAN replay def_route_with_auth("/setCANInterface", server, HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("interface")) { String canInterface = request->getParam("interface")->value(); // Convert the received value to an integer int interfaceValue = canInterface.toInt(); // Update the datalayer with the selected interface datalayer.system.info.can_replay_interface = interfaceValue; // Respond with success message request->send(200, "text/plain", "New interface selected"); } else { request->send(400, "text/plain", "Error: updating interface failed"); } }); if (datalayer.system.info.web_logging_active || datalayer.system.info.SD_logging_active) { // Route for going to debug logging web page server.on("/log", HTTP_GET, [](AsyncWebServerRequest* request) { AsyncWebServerResponse* response = request->beginResponse(200, "text/html", debug_logger_processor()); request->send(response); }); } // Define the handler to stop can logging server.on("/stop_can_logging", HTTP_GET, [](AsyncWebServerRequest* request) { datalayer.system.info.can_logging_active = false; request->send(200, "text/plain", "Logging stopped"); }); // Define the handler to import can log server.on( "/import_can_log", HTTP_POST, [](AsyncWebServerRequest* request) { request->send(200, "text/plain", "Ready to receive file."); // Response when request is made }, handleFileUpload); if (datalayer.system.info.CAN_SD_logging_active) { // Define the handler to export can log server.on("/export_can_log", HTTP_GET, [](AsyncWebServerRequest* request) { pause_can_writing(); request->send(SD_MMC, CAN_LOG_FILE, String(), true); resume_can_writing(); }); // Define the handler to delete can log server.on("/delete_can_log", HTTP_GET, [](AsyncWebServerRequest* request) { delete_can_log(); request->send(200, "text/plain", "Log file deleted"); }); } else { // Define the handler to export can log server.on("/export_can_log", HTTP_GET, [](AsyncWebServerRequest* request) { String logs = String(datalayer.system.info.logged_can_messages); if (logs.length() == 0) { logs = "No logs available."; } // Get the current time time_t now = time(nullptr); struct tm timeinfo; localtime_r(&now, &timeinfo); // Ensure time retrieval was successful char filename[32]; if (strftime(filename, sizeof(filename), "canlog_%H-%M-%S.txt", &timeinfo)) { // Valid filename created } else { // Fallback filename if automatic timestamping failed strcpy(filename, "battery_emulator_can_log.txt"); } // Use request->send with dynamic headers AsyncWebServerResponse* response = request->beginResponse(200, "text/plain", logs); response->addHeader("Content-Disposition", String("attachment; filename=\"") + String(filename) + "\""); request->send(response); }); } if (datalayer.system.info.SD_logging_active) { // Define the handler to delete log file server.on("/delete_log", HTTP_GET, [](AsyncWebServerRequest* request) { delete_log(); request->send(200, "text/plain", "Log file deleted"); }); // Define the handler to export debug log server.on("/export_log", HTTP_GET, [](AsyncWebServerRequest* request) { pause_log_writing(); request->send(SD_MMC, LOG_FILE, String(), true); resume_log_writing(); }); } else { // Define the handler to export debug log server.on("/export_log", HTTP_GET, [](AsyncWebServerRequest* request) { String logs = String(datalayer.system.info.logged_can_messages); if (logs.length() == 0) { logs = "No logs available."; } // Get the current time time_t now = time(nullptr); struct tm timeinfo; localtime_r(&now, &timeinfo); // Ensure time retrieval was successful char filename[32]; if (strftime(filename, sizeof(filename), "log_%H-%M-%S.txt", &timeinfo)) { // Valid filename created } else { // Fallback filename if automatic timestamping failed strcpy(filename, "battery_emulator_log.txt"); } // Use request->send with dynamic headers AsyncWebServerResponse* response = request->beginResponse(200, "text/plain", logs); response->addHeader("Content-Disposition", String("attachment; filename=\"") + String(filename) + "\""); request->send(response); }); } // Route for going to cellmonitor web page def_route_with_auth("/cellmonitor", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/html", index_html, cellmonitor_processor); }); // Route for going to event log web page def_route_with_auth("/events", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/html", index_html, events_processor); }); // Route for clearing all events def_route_with_auth("/clearevents", server, HTTP_GET, [](AsyncWebServerRequest* request) { reset_all_events(); // Send back a response that includes an instant redirect to /events String response = ""; response += ""; // Instant redirect response += ""; request->send(200, "text/html", response); }); def_route_with_auth("/factoryReset", server, HTTP_POST, [](AsyncWebServerRequest* request) { // Reset all settings to factory defaults BatteryEmulatorSettingsStore settings; settings.clearAll(); request->send(200, "text/html", "OK"); }); struct BoolSetting { const char* name; bool existingValue; bool newValue; }; const char* boolSettingNames[] = { "DBLBTR", "CNTCTRL", "CNTCTRLDBL", "PWMCNTCTRL", "PERBMSRESET", "SDLOGENABLED", "REMBMSRESET", "EXTPRECHARGE", "USBENABLED", "CANLOGUSB", "WEBENABLED", "CANFDASCAN", "CANLOGSD", "WIFIAPENABLED", "MQTTENABLED", "NOINVDISC", "HADISC", "MQTTTOPICS", "MQTTCELLV", "INVICNT", "GTWRHD", "DIGITALHVIL", "PERFPROFILE", "INTERLOCKREQ", }; // Handles the form POST from UI to save settings of the common image server.on("/saveSettings", HTTP_POST, [boolSettingNames](AsyncWebServerRequest* request) { BatteryEmulatorSettingsStore settings; std::vector boolSettings; for (auto& name : boolSettingNames) { boolSettings.push_back({name, settings.getBool(name, name == std::string("WIFIAPENABLED")), false}); } int numParams = request->params(); for (int i = 0; i < numParams; i++) { auto p = request->getParam(i); if (p->name() == "inverter") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("INVTYPE", (int)type); } else if (p->name() == "INVCOMM") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("INVCOMM", (int)type); } else if (p->name() == "battery") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("BATTTYPE", (int)type); } else if (p->name() == "BATTCHEM") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("BATTCHEM", (int)type); } else if (p->name() == "BATTCOMM") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("BATTCOMM", (int)type); } else if (p->name() == "BATTPVMAX") { auto type = p->value().toFloat() * 10.0; settings.saveUInt("BATTPVMAX", (int)type); } else if (p->name() == "BATTPVMIN") { auto type = p->value().toFloat() * 10.0; settings.saveUInt("BATTPVMIN", (int)type); } else if (p->name() == "BATTCVMAX") { auto type = atoi(p->value().c_str()); settings.saveUInt("BATTCVMAX", type); } else if (p->name() == "BATTCVMIN") { auto type = atoi(p->value().c_str()); settings.saveUInt("BATTCVMIN", type); } else if (p->name() == "charger") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("CHGTYPE", (int)type); } else if (p->name() == "CHGCOMM") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("CHGCOMM", (int)type); } else if (p->name() == "EQSTOP") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("EQSTOP", (int)type); } else if (p->name() == "BATT2COMM") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("BATT2COMM", (int)type); } else if (p->name() == "shunt") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("SHUNTTYPE", (int)type); } else if (p->name() == "SHUNTCOMM") { auto type = static_cast(atoi(p->value().c_str())); settings.saveUInt("SHUNTCOMM", (int)type); } else if (p->name() == "MAXPRETIME") { auto type = atoi(p->value().c_str()); settings.saveUInt("MAXPRETIME", type); } else if (p->name() == "WIFICHANNEL") { auto type = atoi(p->value().c_str()); settings.saveUInt("WIFICHANNEL", type); } else if (p->name() == "HOSTNAME") { settings.saveString("HOSTNAME", p->value().c_str()); } else if (p->name() == "MQTTSERVER") { settings.saveString("MQTTSERVER", p->value().c_str()); } else if (p->name() == "MQTTPORT") { auto port = atoi(p->value().c_str()); settings.saveUInt("MQTTPORT", port); } else if (p->name() == "MQTTUSER") { settings.saveString("MQTTUSER", p->value().c_str()); } else if (p->name() == "MQTTPASSWORD") { settings.saveString("MQTTPASSWORD", p->value().c_str()); } else if (p->name() == "MQTTTOPIC") { settings.saveString("MQTTTOPIC", p->value().c_str()); } else if (p->name() == "MQTTTIMEOUT") { settings.saveString("MQTTTIMEOUT", p->value().c_str()); } else if (p->name() == "MQTTOBJIDPREFIX") { settings.saveString("MQTTOBJIDPREFIX", p->value().c_str()); } else if (p->name() == "MQTTDEVICENAME") { settings.saveString("MQTTDEVICENAME", p->value().c_str()); } else if (p->name() == "HADEVICEID") { settings.saveString("HADEVICEID", p->value().c_str()); } else if (p->name() == "SOFAR_ID") { auto type = atoi(p->value().c_str()); settings.saveUInt("SOFAR_ID", type); } else if (p->name() == "INVCELLS") { auto type = atoi(p->value().c_str()); settings.saveUInt("INVCELLS", type); } else if (p->name() == "INVMODULES") { auto type = atoi(p->value().c_str()); settings.saveUInt("INVMODULES", type); } else if (p->name() == "INVCELLSPER") { auto type = atoi(p->value().c_str()); settings.saveUInt("INVCELLSPER", type); } else if (p->name() == "INVVLEVEL") { auto type = atoi(p->value().c_str()); settings.saveUInt("INVVLEVEL", type); } else if (p->name() == "INVCAPACITY") { auto type = atoi(p->value().c_str()); settings.saveUInt("INVCAPACITY", type); } else if (p->name() == "INVBTYPE") { auto type = atoi(p->value().c_str()); settings.saveUInt("INVBTYPE", (int)type); } else if (p->name() == "CANFREQ") { auto type = atoi(p->value().c_str()); settings.saveUInt("CANFREQ", type); } else if (p->name() == "PRECHGMS") { auto type = atoi(p->value().c_str()); settings.saveUInt("PRECHGMS", type); } else if (p->name() == "PWMFREQ") { auto type = atoi(p->value().c_str()); settings.saveUInt("PWMFREQ", type); } else if (p->name() == "PWMHOLD") { auto type = atoi(p->value().c_str()); settings.saveUInt("PWMHOLD", type); } else if (p->name() == "GTWCOUNTRY") { auto type = atoi(p->value().c_str()); settings.saveUInt("GTWCOUNTRY", type); } else if (p->name() == "GTWMAPREG") { auto type = atoi(p->value().c_str()); settings.saveUInt("GTWMAPREG", type); } else if (p->name() == "GTWCHASSIS") { auto type = atoi(p->value().c_str()); settings.saveUInt("GTWCHASSIS", type); } else if (p->name() == "GTWPACK") { auto type = atoi(p->value().c_str()); settings.saveUInt("GTWPACK", type); } else if (p->name() == "LEDMODE") { auto type = atoi(p->value().c_str()); settings.saveUInt("LEDMODE", type); } for (auto& boolSetting : boolSettings) { if (p->name() == boolSetting.name) { boolSetting.newValue = p->value() == "on"; } } } for (auto& boolSetting : boolSettings) { if (boolSetting.existingValue != boolSetting.newValue) { settings.saveBool(boolSetting.name, boolSetting.newValue); } } settingsUpdated = settings.were_settings_updated(); request->redirect("/settings"); }); // Route for editing SSID def_route_with_auth("/updateSSID", server, HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); if (value.length() <= 63) { // Check if SSID is within the allowable length ssid = value.c_str(); store_settings(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "SSID must be 63 characters or less"); } } else { request->send(400, "text/plain", "Bad Request"); } }); // Route for editing Password def_route_with_auth("/updatePassword", server, HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); if (value.length() > 8) { // Check if password is within the allowable length password = value.c_str(); store_settings(); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Password must be atleast 8 characters"); } } else { request->send(400, "text/plain", "Bad Request"); } }); auto update_string = [](const char* route, std::function setter, std::function validator = nullptr) { def_route_with_auth(route, server, HTTP_GET, [=](AsyncWebServerRequest* request) { if (request->hasParam("value")) { String value = request->getParam("value")->value(); if (validator && !validator(value)) { request->send(400, "text/plain", "Invalid value"); return; } setter(value); request->send(200, "text/plain", "Updated successfully"); } else { request->send(400, "text/plain", "Bad Request"); } }); }; auto update_string_setting = [=](const char* route, std::function setter, std::function validator = nullptr) { update_string( route, [setter](String value) { setter(value); store_settings(); }, validator); }; auto update_int_setting = [=](const char* route, std::function setter) { update_string_setting(route, [setter](String value) { setter(value.toInt()); }); }; // Route for editing Wh update_int_setting("/updateBatterySize", [](int value) { datalayer.battery.info.total_capacity_Wh = value; }); // Route for editing USE_SCALED_SOC update_int_setting("/updateUseScaledSOC", [](int value) { datalayer.battery.settings.soc_scaling_active = value; }); // Route for editing SOCMax update_string_setting("/updateSocMax", [](String value) { datalayer.battery.settings.max_percentage = static_cast(value.toFloat() * 100); }); // Route for pause/resume Battery emulator update_string("/pause", [](String value) { setBatteryPause(value == "true" || value == "1", false); }); // Route for equipment stop/resume update_string("/equipmentStop", [](String value) { if (value == "true" || value == "1") { setBatteryPause(true, false, true); //Pause battery, do not pause CAN, equipment stop on (store to flash) } else { setBatteryPause(false, false, false); } }); // Route for editing SOCMin update_string_setting("/updateSocMin", [](String value) { datalayer.battery.settings.min_percentage = static_cast(value.toFloat() * 100); }); // Route for editing MaxChargeA update_string_setting("/updateMaxChargeA", [](String value) { datalayer.battery.settings.max_user_set_charge_dA = static_cast(value.toFloat() * 10); }); // Route for editing MaxDischargeA update_string_setting("/updateMaxDischargeA", [](String value) { datalayer.battery.settings.max_user_set_discharge_dA = static_cast(value.toFloat() * 10); }); for (const auto& cmd : battery_commands) { auto route = String("/") + cmd.identifier; server.on( route.c_str(), HTTP_PUT, [cmd](AsyncWebServerRequest* request) { if (webserver_auth && !request->authenticate(http_username.c_str(), http_password.c_str())) { return request->requestAuthentication(); } }, nullptr, [cmd](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { String battIndex = ""; if (len > 0) { battIndex += (char)data[0]; } Battery* batt = battery; if (battIndex == "1") { batt = battery2; } if (batt) { cmd.action(batt); } request->send(200, "text/plain", "Command performed."); }); } // Route for editing BATTERY_USE_VOLTAGE_LIMITS update_int_setting("/updateUseVoltageLimit", [](int value) { datalayer.battery.settings.user_set_voltage_limits_active = value; }); // Route for editing MaxChargeVoltage update_string_setting("/updateMaxChargeVoltage", [](String value) { datalayer.battery.settings.max_user_set_charge_voltage_dV = static_cast(value.toFloat() * 10); }); // Route for editing MaxDischargeVoltage update_string_setting("/updateMaxDischargeVoltage", [](String value) { datalayer.battery.settings.max_user_set_discharge_voltage_dV = static_cast(value.toFloat() * 10); }); // Route for editing BMSresetDuration update_string_setting("/updateBMSresetDuration", [](String value) { datalayer.battery.settings.user_set_bms_reset_duration_ms = static_cast(value.toFloat() * 1000); }); // Route for editing FakeBatteryVoltage update_string_setting("/updateFakeBatteryVoltage", [](String value) { battery->set_fake_voltage(value.toFloat()); }); // Route for editing balancing enabled update_int_setting("/TeslaBalAct", [](int value) { datalayer.battery.settings.user_requests_balancing = value; }); // Route for editing balancing max time update_string_setting("/BalTime", [](String value) { datalayer.battery.settings.balancing_time_ms = static_cast(value.toFloat() * 60000); }); // Route for editing balancing max power update_string_setting("/BalFloatPower", [](String value) { datalayer.battery.settings.balancing_float_power_W = static_cast(value.toFloat()); }); // Route for editing balancing max pack voltage update_string_setting("/BalMaxPackV", [](String value) { datalayer.battery.settings.balancing_max_pack_voltage_dV = static_cast(value.toFloat() * 10); }); // Route for editing balancing max cell voltage update_string_setting("/BalMaxCellV", [](String value) { datalayer.battery.settings.balancing_max_cell_voltage_mV = static_cast(value.toFloat()); }); // Route for editing balancing max cell voltage deviation update_string_setting("/BalMaxDevCellV", [](String value) { datalayer.battery.settings.balancing_max_deviation_cell_voltage_mV = static_cast(value.toFloat()); }); if (charger) { // Route for editing ChargerTargetV update_string_setting( "/updateChargeSetpointV", [](String value) { datalayer.charger.charger_setpoint_HV_VDC = value.toFloat(); }, [](String value) { float val = value.toFloat(); return (val <= CHARGER_MAX_HV && val >= CHARGER_MIN_HV) && (val * datalayer.charger.charger_setpoint_HV_IDC <= CHARGER_MAX_POWER); }); // Route for editing ChargerTargetA update_string_setting( "/updateChargeSetpointA", [](String value) { datalayer.charger.charger_setpoint_HV_IDC = value.toFloat(); }, [](String value) { float val = value.toFloat(); return (val <= CHARGER_MAX_A) && (val <= datalayer.battery.settings.max_user_set_charge_dA) && (val * datalayer.charger.charger_setpoint_HV_VDC <= CHARGER_MAX_POWER); }); // Route for editing ChargerEndA update_string_setting("/updateChargeEndA", [](String value) { datalayer.charger.charger_setpoint_HV_IDC_END = value.toFloat(); }); // Route for enabling/disabling HV charger update_int_setting("/updateChargerHvEnabled", [](int value) { datalayer.charger.charger_HV_enabled = (bool)value; }); // Route for enabling/disabling aux12v charger update_int_setting("/updateChargerAux12vEnabled", [](int value) { datalayer.charger.charger_aux12V_enabled = (bool)value; }); } // Send a GET request to /update def_route_with_auth("/debug", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/plain", "Debug: all OK."); }); // Route to handle reboot command def_route_with_auth("/reboot", server, HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/plain", "Rebooting server..."); //Equipment STOP without persisting the equipment state before restart // Max Charge/Discharge = 0; CAN = stop; contactors = open setBatteryPause(true, true, true, false); delay(1000); ESP.restart(); }); // Initialize ElegantOTA init_ElegantOTA(); // Start server server.begin(); } 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 ota_monitor() { if (ota_active && ota_timeout_timer.elapsed()) { // OTA timeout, try to restore can and clear the update event set_event(EVENT_OTA_UPDATE_TIMEOUT, 0); onOTAEnd(false); } } // Function to initialize ElegantOTA void init_ElegantOTA() { ElegantOTA.begin(&server); // Start ElegantOTA // ElegantOTA callbacks ElegantOTA.onStart(onOTAStart); ElegantOTA.onProgress(onOTAProgress); ElegantOTA.onEnd(onOTAEnd); } String get_firmware_info_processor(const String& var) { if (var == "X") { String content = ""; static JsonDocument doc; doc["hardware"] = esp32hal->name(); doc["firmware"] = String(version_number); serializeJson(doc, content); return content; } return String(); } String get_uptime() { uint64_t milliseconds; uint32_t remaining_seconds_in_day; uint32_t remaining_seconds; uint32_t remaining_minutes; uint32_t remaining_hours; uint16_t total_days; milliseconds = millis64(); //convert passed millis to days, hours, minutes, seconds total_days = milliseconds / (1000 * 60 * 60 * 24); remaining_seconds_in_day = (milliseconds / 1000) % (60 * 60 * 24); remaining_hours = remaining_seconds_in_day / (60 * 60); remaining_minutes = (remaining_seconds_in_day % (60 * 60)) / 60; remaining_seconds = remaining_seconds_in_day % 60; return (String)total_days + " days, " + (String)remaining_hours + " hours, " + (String)remaining_minutes + " minutes, " + (String)remaining_seconds + " seconds"; } String processor(const String& var) { if (var == "X") { String content = ""; content += ""; // Compact header content += "

Battery Emulator

"; // Start content block content += "
"; content += "

Software: " + String(version_number); // Show hardware used: #ifdef HW_LILYGO content += " Hardware: LilyGo T-CAN485"; #endif // HW_LILYGO #ifdef HW_LILYGO2CAN content += " Hardware: LilyGo T_2CAN"; #endif // HW_LILYGO2CAN #ifdef HW_STARK content += " Hardware: Stark CMR Module"; #endif // HW_STARK content += " @ " + String(datalayer.system.info.CPU_temperature, 1) + " °C

"; content += "

Uptime: " + get_uptime() + "

"; if (datalayer.system.info.performance_measurement_active) { // Load information content += "

Core task max load: " + String(datalayer.system.status.core_task_max_us) + " us

"; content += "

Core task max load last 10 s: " + String(datalayer.system.status.core_task_10s_max_us) + " us

"; content += "

MQTT function (MQTT task) max load last 10 s: " + String(datalayer.system.status.mqtt_task_10s_max_us) + " us

"; content += "

WIFI function (MQTT task) max load last 10 s: " + String(datalayer.system.status.wifi_task_10s_max_us) + " us

"; content += "

Max load @ worst case execution of core task:

"; content += "

10ms function timing: " + String(datalayer.system.status.time_snap_10ms_us) + " us

"; content += "

Values function timing: " + String(datalayer.system.status.time_snap_values_us) + " us

"; content += "

CAN/serial RX function timing: " + String(datalayer.system.status.time_snap_comm_us) + " us

"; content += "

CAN TX function timing: " + String(datalayer.system.status.time_snap_cantx_us) + " us

"; content += "

OTA function timing: " + String(datalayer.system.status.time_snap_ota_us) + " us

"; } 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.c_str()); if (status == WL_CONNECTED) { // Get and display the signal strength (RSSI) and channel content += " RSSI:" + String(WiFi.RSSI()) + " dBm Ch: " + String(WiFi.channel()); } content += "

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

Hostname: " + String(WiFi.getHostname()) + "

"; content += "

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

"; } else { content += "

Wifi state: " + getConnectResultString(status) + "

"; } // Close the block content += "
"; if (inverter || battery || shunt || charger) { // Start a new block with a specific background color content += "
"; // Display which components are used if (inverter) { content += "

Inverter protocol: "; content += inverter->name(); content += " "; content += datalayer.system.info.inverter_brand; content += "

"; } if (battery) { content += "

Battery protocol: "; content += datalayer.system.info.battery_protocol; if (battery2) { content += " (Double battery)"; } if (datalayer.battery.info.chemistry == battery_chemistry_enum::LFP) { content += " (LFP)"; } content += "

"; } if (shunt) { content += "

Shunt protocol: "; content += datalayer.system.info.shunt_protocol; content += "

"; } if (charger) { content += "

Charger protocol: "; content += charger->name(); content += "

"; } // Close the block content += "
"; } if (battery) { if (battery2) { // Start a new block with a specific background color. Color changes depending on BMS status content += "
"; content += "
(datalayer.battery.status.real_soc) / 100.0; // Convert to float and divide by 100 float socScaledFloat = static_cast(datalayer.battery.status.reported_soc) / 100.0; // Convert to float and divide by 100 float sohFloat = static_cast(datalayer.battery.status.soh_pptt) / 100.0; // Convert to float and divide by 100 float voltageFloat = static_cast(datalayer.battery.status.voltage_dV) / 10.0; // Convert to float and divide by 10 float currentFloat = static_cast(datalayer.battery.status.current_dA) / 10.0; // Convert to float and divide by 10 float powerFloat = static_cast(datalayer.battery.status.active_power_W); // Convert to float float tempMaxFloat = static_cast(datalayer.battery.status.temperature_max_dC) / 10.0; // Convert to float float tempMinFloat = static_cast(datalayer.battery.status.temperature_min_dC) / 10.0; // Convert to float float maxCurrentChargeFloat = static_cast(datalayer.battery.status.max_charge_current_dA) / 10.0; // Convert to float float maxCurrentDischargeFloat = static_cast(datalayer.battery.status.max_discharge_current_dA) / 10.0; // Convert to float uint16_t cell_delta_mv = datalayer.battery.status.cell_max_voltage_mV - datalayer.battery.status.cell_min_voltage_mV; if (datalayer.battery.settings.soc_scaling_active) content += "

Scaled SOC: " + String(socScaledFloat, 2) + "% (real: " + String(socRealFloat, 2) + "%)

"; else content += "

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

"; content += "

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

"; content += "

Voltage: " + String(voltageFloat, 1) + " V   Current: " + String(currentFloat, 1) + " A

"; content += formatPowerValue("Power", powerFloat, "", 1); if (datalayer.battery.settings.soc_scaling_active) content += "

Scaled total capacity: " + formatPowerValue(datalayer.battery.info.reported_total_capacity_Wh, "h", 1) + " (real: " + formatPowerValue(datalayer.battery.info.total_capacity_Wh, "h", 1) + ")

"; else content += formatPowerValue("Total capacity", datalayer.battery.info.total_capacity_Wh, "h", 1); if (datalayer.battery.settings.soc_scaling_active) content += "

Scaled remaining capacity: " + formatPowerValue(datalayer.battery.status.reported_remaining_capacity_Wh, "h", 1) + " (real: " + formatPowerValue(datalayer.battery.status.remaining_capacity_Wh, "h", 1) + ")

"; else content += formatPowerValue("Remaining capacity", datalayer.battery.status.remaining_capacity_Wh, "h", 1); if (datalayer.system.settings.equipment_stop_active) { content += formatPowerValue("Max discharge power", datalayer.battery.status.max_discharge_power_W, "", 1, "red"); content += formatPowerValue("Max charge power", datalayer.battery.status.max_charge_power_W, "", 1, "red"); content += "

Max discharge current: " + String(maxCurrentDischargeFloat, 1) + " A

"; content += "

Max charge current: " + String(maxCurrentChargeFloat, 1) + " A

"; } else { content += formatPowerValue("Max discharge power", datalayer.battery.status.max_discharge_power_W, "", 1); content += formatPowerValue("Max charge power", datalayer.battery.status.max_charge_power_W, "", 1); content += "

Max discharge current: " + String(maxCurrentDischargeFloat, 1) + " A"; if (datalayer.battery.settings.user_settings_limit_discharge) { content += " (Manual)

"; } else { content += " (BMS)"; } content += "

Max charge current: " + String(maxCurrentChargeFloat, 1) + " A"; if (datalayer.battery.settings.user_settings_limit_charge) { content += " (Manual)

"; } else { content += " (BMS)"; } } content += "

Cell min/max: " + String(datalayer.battery.status.cell_min_voltage_mV) + " mV / " + String(datalayer.battery.status.cell_max_voltage_mV) + " mV

"; if (cell_delta_mv > datalayer.battery.info.max_cell_voltage_deviation_mV) { content += "

Cell delta: " + String(cell_delta_mv) + " mV

"; } else { content += "

Cell delta: " + String(cell_delta_mv) + " mV

"; } content += "

Temperature min/max: " + String(tempMinFloat, 1) + " °C / " + String(tempMaxFloat, 1) + " °C

"; content += "

System status: "; switch (datalayer.battery.status.bms_status) { case ACTIVE: content += String("OK"); break; case UPDATING: content += String("UPDATING"); break; case FAULT: content += String("FAULT"); break; case INACTIVE: content += String("INACTIVE"); break; case STANDBY: content += String("STANDBY"); break; default: content += String("??"); break; } content += "

"; if (battery && battery->supports_real_BMS_status()) { content += "

Battery BMS status: "; switch (datalayer.battery.status.real_bms_status) { case BMS_ACTIVE: content += String("OK"); break; case BMS_FAULT: content += String("FAULT"); break; case BMS_DISCONNECTED: content += String("DISCONNECTED"); break; case BMS_STANDBY: content += String("STANDBY"); break; default: content += String("??"); break; } content += "

"; } if (datalayer.battery.status.current_dA == 0) { content += "

Battery idle

"; } else if (datalayer.battery.status.current_dA < 0) { content += "

Battery discharging!"; if (datalayer.battery.settings.inverter_limits_discharge) { content += " (Inverter limiting)

"; } else { if (datalayer.battery.settings.user_settings_limit_discharge) { content += " (Settings limiting)"; } else { content += " (Battery limiting)"; } } content += ""; } else { // > 0 , positive current content += "

Battery charging!"; if (datalayer.battery.settings.inverter_limits_charge) { content += " (Inverter limiting)

"; } else { if (datalayer.battery.settings.user_settings_limit_charge) { content += " (Settings limiting)"; } else { content += " (Battery limiting)"; } } } // Close the block content += "
"; if (battery2) { content += "
"; // Display battery statistics within this block socRealFloat = static_cast(datalayer.battery2.status.real_soc) / 100.0; // Convert to float and divide by 100 //socScaledFloat; // Same value used for bat2 sohFloat = static_cast(datalayer.battery2.status.soh_pptt) / 100.0; // Convert to float and divide by 100 voltageFloat = static_cast(datalayer.battery2.status.voltage_dV) / 10.0; // Convert to float and divide by 10 currentFloat = static_cast(datalayer.battery2.status.current_dA) / 10.0; // Convert to float and divide by 10 powerFloat = static_cast(datalayer.battery2.status.active_power_W); // Convert to float tempMaxFloat = static_cast(datalayer.battery2.status.temperature_max_dC) / 10.0; // Convert to float tempMinFloat = static_cast(datalayer.battery2.status.temperature_min_dC) / 10.0; // Convert to float cell_delta_mv = datalayer.battery2.status.cell_max_voltage_mV - datalayer.battery2.status.cell_min_voltage_mV; if (datalayer.battery.settings.soc_scaling_active) content += "

Scaled SOC: " + String(socScaledFloat, 2) + "% (real: " + String(socRealFloat, 2) + "%)

"; else content += "

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

"; content += "

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

"; content += "

Voltage: " + String(voltageFloat, 1) + " V   Current: " + String(currentFloat, 1) + " A

"; content += formatPowerValue("Power", powerFloat, "", 1); if (datalayer.battery.settings.soc_scaling_active) content += "

Scaled total capacity: " + formatPowerValue(datalayer.battery2.info.reported_total_capacity_Wh, "h", 1) + " (real: " + formatPowerValue(datalayer.battery2.info.total_capacity_Wh, "h", 1) + ")

"; else content += formatPowerValue("Total capacity", datalayer.battery2.info.total_capacity_Wh, "h", 1); if (datalayer.battery.settings.soc_scaling_active) content += "

Scaled remaining capacity: " + formatPowerValue(datalayer.battery2.status.reported_remaining_capacity_Wh, "h", 1) + " (real: " + formatPowerValue(datalayer.battery2.status.remaining_capacity_Wh, "h", 1) + ")

"; else content += formatPowerValue("Remaining capacity", datalayer.battery2.status.remaining_capacity_Wh, "h", 1); if (datalayer.system.settings.equipment_stop_active) { content += formatPowerValue("Max discharge power", datalayer.battery2.status.max_discharge_power_W, "", 1, "red"); content += formatPowerValue("Max charge power", datalayer.battery2.status.max_charge_power_W, "", 1, "red"); content += "

Max discharge current: " + String(maxCurrentDischargeFloat, 1) + " A

"; content += "

Max charge current: " + String(maxCurrentChargeFloat, 1) + " A

"; } else { content += formatPowerValue("Max discharge power", datalayer.battery2.status.max_discharge_power_W, "", 1); content += formatPowerValue("Max charge power", datalayer.battery2.status.max_charge_power_W, "", 1); content += "

Max discharge current: " + String(maxCurrentDischargeFloat, 1) + " A

"; content += "

Max charge current: " + String(maxCurrentChargeFloat, 1) + " A

"; } content += "

Cell min/max: " + String(datalayer.battery2.status.cell_min_voltage_mV) + " mV / " + String(datalayer.battery2.status.cell_max_voltage_mV) + " mV

"; if (cell_delta_mv > datalayer.battery2.info.max_cell_voltage_deviation_mV) { content += "

Cell delta: " + String(cell_delta_mv) + " mV

"; } else { content += "

Cell delta: " + String(cell_delta_mv) + " mV

"; } content += "

Temperature min/max: " + String(tempMinFloat, 1) + " °C / " + String(tempMaxFloat, 1) + " °C

"; if (datalayer.battery.status.bms_status == ACTIVE) { content += "

System status: OK

"; } else if (datalayer.battery.status.bms_status == UPDATING) { content += "

System status: UPDATING

"; } else { content += "

System status: FAULT

"; } if (datalayer.battery2.status.current_dA == 0) { content += "

Battery idle

"; } else if (datalayer.battery2.status.current_dA < 0) { content += "

Battery discharging!

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

Battery charging!

"; } content += "
"; content += "
"; } } // Block for Contactor status and component request status // Start a new block with gray background color content += "
"; if (emulator_pause_status == NORMAL) { content += "

Power status: " + String(get_emulator_pause_status().c_str()) + "

"; } else { content += "

Power status: " + String(get_emulator_pause_status().c_str()) + "

"; } content += "

Emulator allows contactor closing: "; if (datalayer.battery.status.bms_status == FAULT) { content += ""; } else { content += ""; } content += " Inverter allows contactor closing: "; if (datalayer.system.status.inverter_allows_contactor_closing == true) { content += "

"; } else { content += ""; } if (battery2) { content += "

Secondary battery allowed to join "; if (datalayer.system.status.battery2_allowed_contactor_closing == true) { content += ""; } else { content += "✕ (voltage mismatch)"; } } if (!contactor_control_enabled) { content += "
"; content += "

Contactors not fully controlled via emulator [?]

"; content += "This means you are either running CAN controlled contactors OR manually " "powering the contactors. Battery-Emulator will have limited amount of control over the contactors!"; content += "
"; } else { //contactor_control_enabled TRUE content += "

Contactors controlled by emulator, state: "; if (datalayer.system.status.contactors_engaged == 0) { content += "PRECHARGE"; } else if (datalayer.system.status.contactors_engaged == 1) { content += "ON"; } else if (datalayer.system.status.contactors_engaged == 2) { content += "OFF"; content += " [!]"; content += "Emulator spent too much time in critical FAULT event. Investigate event " "causing this via Events page. Reboot required to resume operation!"; } content += "

"; if (contactor_control_enabled_double_battery && battery2) { content += "

Secondary battery contactor, state: "; if (pwm_contactor_control) { if (datalayer.system.status.contactors_battery2_engaged) { content += "Economized"; } else { content += "OFF"; } } else if ( esp32hal->SECOND_BATTERY_CONTACTORS_PIN() != GPIO_NUM_NC) { // No PWM_CONTACTOR_CONTROL , we can read the pin and see feedback. Helpful if channel overloaded if (digitalRead(esp32hal->SECOND_BATTERY_CONTACTORS_PIN()) == HIGH) { content += "ON"; } else { content += "OFF"; } } //no PWM_CONTACTOR_CONTROL content += "

"; } } // Close the block content += "
"; if (charger) { // Start a new block with orange background color content += "
"; content += "

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

"; content += "

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

"; auto chgPwrDC = charger->outputPowerDC(); auto chgEff = charger->efficiency(); content += formatPowerValue("Charger Output Power", chgPwrDC, "", 1); if (charger->efficiencySupported()) { content += "

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

"; } float HVvol = charger->HVDC_output_voltage(); float HVcur = charger->HVDC_output_current(); float LVvol = charger->LVDC_output_voltage(); float LVcur = charger->LVDC_output_current(); 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) + "

"; float ACcur = charger->AC_input_current(); float ACvol = charger->AC_input_voltage(); content += "

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

"; content += "

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

"; content += "
"; } if (emulator_pause_request_ON) content += " "; else content += " "; content += " "; content += " "; content += " "; content += " "; content += " "; if (datalayer.system.info.web_logging_active || datalayer.system.info.SD_logging_active) { content += " "; } content += " "; content += " "; content += ""; if (webserver_auth) content += ""; if (!datalayer.system.settings.equipment_stop_active) content += "

"; else content += "

"; content += ""; //Script for refreshing page content += ""; return content; } return String(); } void onOTAStart() { //try to Pause the battery setBatteryPause(true, true); // Log when OTA has started 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(); logging.printf("OTA Progress Current: %u bytes, Final: %u bytes\n", current, final); // Reset the "watchdog" ota_timeout_timer.reset(); } } void onOTAEnd(bool success) { ota_active = false; clear_event(EVENT_OTA_UPDATE); // Log when OTA has finished if (success) { //Equipment STOP without persisting the equipment state before restart // Max Charge/Discharge = 0; CAN = stop; contactors = open setBatteryPause(true, true, true, false); // a reboot will be done by the OTA library. no need to do anything here logging.println("OTA update finished successfully!"); } else { logging.println("There was an error during OTA update!"); //try to Resume the battery pause and CAN communication setBatteryPause(false, false); } } 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 color) { String result = "

" + label + ": "; result += formatPowerValue(value, unit, precision); result += "

"; return result; } template // This function makes power values appear as W when under 1000, and kW when over String formatPowerValue(T value, String unit, int precision) { String result = ""; 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; }