#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" WebServer server(80); HardwareSerial DashboardSerial(2); String uartLineBuffer; 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"] = "placeholder"; battery["connected"] = false; battery["soc"] = 0; battery["voltage"] = 0; battery["current"] = 0; battery["remaining_ah"] = 0; battery["capacity_ah"] = 150; battery["runtime_hours"] = 0; battery["temperature_f"] = 0; battery["cycle_count"] = 0; battery["cell_count"] = 0; battery["ntc_count"] = 0; } JsonObject temps = doc.createNestedObject("temps"); if (sensors.fridgeZone1Online) { temps["fridge_zone_1"] = sensors.fridgeZone1; } else { temps["fridge_zone_1"] = nullptr; } if (sensors.fridgeZone2Online) { temps["fridge_zone_2"] = sensors.fridgeZone2; } else { temps["fridge_zone_2"] = nullptr; } if (sensors.rearSeatOnline) { temps["rear_seat"] = sensors.rearSeat; } else { temps["rear_seat"] = nullptr; } if (sensors.outsideAirOnline) { temps["outside"] = sensors.outsideAir; } else { temps["outside"] = nullptr; } JsonObject sensorHealth = doc.createNestedObject("sensor_health"); sensorHealth["fridge_zone_1"] = sensors.fridgeZone1Online; sensorHealth["fridge_zone_2"] = sensors.fridgeZone2Online; sensorHealth["rear_seat"] = sensors.rearSeatOnline; sensorHealth["outside"] = sensors.outsideAirOnline; JsonObject relayObj = doc.createNestedObject("relays"); relayObj["starlink"] = relays.starlink; relayObj["fridge"] = relays.fridge; JsonObject vehicle = doc.createNestedObject("vehicle"); vehicle["ignition_on"] = digitalRead(IGNITION_PIN); JsonObject network = doc.createNestedObject("network"); network["wifi_enabled"] = true; network["uart_connected"] = true; 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(2048); 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 setRelayByName(const char* relayName, bool enabled) { if (strcmp(relayName, "starlink") == 0) { relays.starlink = enabled; } else if (strcmp(relayName, "fridge") == 0) { relays.fridge = enabled; } else { return false; } updateRelayOutputs(); return true; } void sendRelayResponse(Stream& output, const char* relayName, bool enabled) { DynamicJsonDocument doc(256); doc["type"] = MSG_RELAY_RESPONSE; doc["relay"] = relayName; doc["enabled"] = enabled; doc["ok"] = true; serializeJson(doc, output); output.println(); } void handleUartMessage(const String& line) { DynamicJsonDocument doc(512); DeserializationError error = deserializeJson(doc, line); if (error) { sendError(DashboardSerial, "invalid_json"); return; } const char* type = doc["type"] | ""; if (strcmp(type, MSG_STATUS_REQUEST) == 0) { sendStatus(DashboardSerial); return; } if (strcmp(type, MSG_SET_RELAY) == 0) { const char* relayName = doc["relay"] | ""; bool enabled = doc["enabled"] | false; if (!setRelayByName(relayName, enabled)) { sendError(DashboardSerial, "unknown_relay"); return; } sendRelayResponse(DashboardSerial, relayName, enabled); 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 == "relay starlink on") { setRelayByName("starlink", true); Serial.println("OK starlink on"); return; } if (command == "relay starlink off") { setRelayByName("starlink", false); Serial.println("OK starlink off"); return; } if (command == "relay fridge on") { setRelayByName("fridge", true); Serial.println("OK fridge on"); return; } if (command == "relay fridge off") { setRelayByName("fridge", false); Serial.println("OK fridge off"); 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 == "factory reset") { factoryResetConfig(); Serial.println("OK factory reset complete"); 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); 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"; if (result->name.length() > 0) { appConfig.bms.name = result->name; } else { appConfig.bms.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, relay starlink on/off, relay fridge on/off"); } } 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 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 handleStatus() { DynamicJsonDocument doc(2048); buildStatusDocument(doc); String output; serializeJson(doc, output); server.send(200, "application/json", output); } void handleRelayHttp(const char* relayName, bool enabled) { if (!setRelayByName(relayName, enabled)) { server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}"); return; } DynamicJsonDocument doc(256); doc["type"] = MSG_RELAY_RESPONSE; doc["relay"] = relayName; doc["enabled"] = enabled; doc["ok"] = true; String output; serializeJson(doc, output); server.send(200, "application/json", output); } void handleStarlinkOn() { handleRelayHttp("starlink", true); } void handleStarlinkOff() { handleRelayHttp("starlink", false); } void handleFridgeOn() { handleRelayHttp("fridge", true); } void handleFridgeOff() { handleRelayHttp("fridge", false); } void setup() { Serial.begin(115200); DashboardSerial.begin( DASHBOARD_UART_BAUD, SERIAL_8N1, DASHBOARD_UART_RX_PIN, DASHBOARD_UART_TX_PIN ); Serial.println(); Serial.println("=================================="); loadConfig(); printConfig(); Serial.println("Xterra Controller Booting"); Serial.println("=================================="); loadConfig(); printConfig(); pinMode(IGNITION_PIN, INPUT); initRelays(); initSensors(); initBms(); WiFi.mode(WIFI_AP); bool apResult = WiFi.softAP("XterraController"); if (apResult) { Serial.println("AP Started"); Serial.print("IP: "); Serial.println(WiFi.softAPIP()); } server.on("/status", handleStatus); server.on("/config", HTTP_GET, handleGetConfig); 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("/relay/starlink/on", handleStarlinkOn); server.on("/relay/starlink/off", handleStarlinkOff); server.on("/relay/fridge/on", handleFridgeOn); server.on("/relay/fridge/off", handleFridgeOff); server.begin(); Serial.println("Web Server Started"); Serial.println("Dashboard UART Started"); } void loop() { server.handleClient(); handleDebugSerial(); updateBms(); updateAlarms(); pollDashboardUart(); static unsigned long lastSensorUpdate = 0; if (millis() - lastSensorUpdate > 5000) { updateSensors(); lastSensorUpdate = millis(); logDebug("Sensor Update"); } }