From 49ddb01a42d6d936b14f9c133324ff2adcb0513d Mon Sep 17 00:00:00 2001 From: Nick Ward Date: Thu, 4 Jun 2026 00:36:28 -0600 Subject: [PATCH] Integrate JBD BMS telemetry into ESP32 status --- firmware/esp32/xterra-controller/bms/bms.cpp | 204 ++++++++++++++++++ firmware/esp32/xterra-controller/bms/bms.h | 22 ++ .../xterra-controller/xterra-controller.ino | 55 +++-- 3 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 firmware/esp32/xterra-controller/bms/bms.cpp create mode 100644 firmware/esp32/xterra-controller/bms/bms.h diff --git a/firmware/esp32/xterra-controller/bms/bms.cpp b/firmware/esp32/xterra-controller/bms/bms.cpp new file mode 100644 index 0000000..0e8caa8 --- /dev/null +++ b/firmware/esp32/xterra-controller/bms/bms.cpp @@ -0,0 +1,204 @@ +#include "bms.h" + +#include +#include + +static const char* BMS_ADDRESS = "a5:c2:37:2c:05:dc"; +static const char* BMS_SERVICE_UUID = "ff00"; +static const char* BMS_NOTIFY_UUID = "ff01"; +static const char* BMS_WRITE_UUID = "ff02"; + +static NimBLEClient* client = nullptr; +static NimBLERemoteCharacteristic* notifyChar = nullptr; +static NimBLERemoteCharacteristic* writeChar = nullptr; + +static uint8_t responseBuffer[128]; +static size_t responseLength = 0; +static bool responseReady = false; + +static unsigned long lastReadAttempt = 0; +static const unsigned long READ_INTERVAL_MS = 5000; + +BmsData bmsData; + +static uint8_t JBD_STATUS_REQUEST[] = { + 0xDD, 0xA5, 0x03, 0x00, 0xFF, 0xFD, 0x77 +}; + +static uint16_t readU16(const uint8_t* data, int index) { + return ((uint16_t)data[index] << 8) | data[index + 1]; +} + +static int16_t readS16(const uint8_t* data, int index) { + return (int16_t)readU16(data, index); +} + +static void resetResponseBuffer() { + responseLength = 0; + responseReady = false; +} + +static void handleNotify( + NimBLERemoteCharacteristic* characteristic, + uint8_t* data, + size_t length, + bool isNotify +) { + if (responseLength + length > sizeof(responseBuffer)) { + resetResponseBuffer(); + return; + } + + memcpy(responseBuffer + responseLength, data, length); + responseLength += length; + + if (responseLength > 0 && responseBuffer[responseLength - 1] == 0x77) { + responseReady = true; + } +} + +static bool connectBms() { + if (client && client->isConnected()) { + return true; + } + + Serial.println("BMS: Connecting..."); + + NimBLEAddress address(std::string(BMS_ADDRESS), BLE_ADDR_PUBLIC); + + client = NimBLEDevice::createClient(); + + if (!client->connect(address)) { + Serial.println("BMS: Connect failed"); + bmsData.connected = false; + return false; + } + + NimBLERemoteService* service = client->getService(BMS_SERVICE_UUID); + if (!service) { + Serial.println("BMS: Service ff00 not found"); + client->disconnect(); + bmsData.connected = false; + return false; + } + + notifyChar = service->getCharacteristic(BMS_NOTIFY_UUID); + writeChar = service->getCharacteristic(BMS_WRITE_UUID); + + if (!notifyChar || !writeChar) { + Serial.println("BMS: Characteristics not found"); + client->disconnect(); + bmsData.connected = false; + return false; + } + + if (!notifyChar->subscribe(true, handleNotify)) { + Serial.println("BMS: Notify subscribe failed"); + client->disconnect(); + bmsData.connected = false; + return false; + } + + Serial.println("BMS: Connected"); + bmsData.connected = true; + return true; +} + +static bool parseBasicInfo() { + if (responseLength < 40) { + Serial.println("BMS: Response too short"); + return false; + } + + if (responseBuffer[0] != 0xDD || responseBuffer[1] != 0x03) { + Serial.println("BMS: Unexpected response header"); + return false; + } + + uint16_t voltageRaw = readU16(responseBuffer, 4); + int16_t currentRaw = readS16(responseBuffer, 6); + uint16_t remainingRaw = readU16(responseBuffer, 8); + uint16_t capacityRaw = readU16(responseBuffer, 10); + uint16_t cyclesRaw = readU16(responseBuffer, 12); + + uint8_t socRaw = responseBuffer[23]; + uint8_t mosTempAndCellOffset = 24; + + uint8_t cellCount = responseBuffer[mosTempAndCellOffset + 1]; + uint8_t ntcCount = responseBuffer[mosTempAndCellOffset + 2]; + + int tempOffset = mosTempAndCellOffset + 3; + float tempF = 0; + + if (ntcCount > 0 && responseLength > (size_t)(tempOffset + 1)) { + uint16_t tempRaw = readU16(responseBuffer, tempOffset); + float tempC = (tempRaw - 2731) / 10.0; + tempF = tempC * 9.0 / 5.0 + 32.0; + } + + bmsData.voltage = voltageRaw / 100.0; + bmsData.current = currentRaw / 100.0; + bmsData.remainingAh = remainingRaw / 100.0; + bmsData.capacityAh = capacityRaw / 100.0; + bmsData.cycleCount = cyclesRaw; + bmsData.soc = socRaw; + bmsData.cellCount = cellCount; + bmsData.ntcCount = ntcCount; + bmsData.temperatureF = tempF; + bmsData.valid = true; + + Serial.print("BMS: SOC "); + Serial.print(bmsData.soc); + Serial.print("%, V "); + Serial.print(bmsData.voltage); + Serial.print(", A "); + Serial.print(bmsData.current); + Serial.print(", Ah "); + Serial.println(bmsData.remainingAh); + + return true; +} + +void initBms() { + NimBLEDevice::init("XterraESP32"); + NimBLEDevice::setPower(ESP_PWR_LVL_P9); + resetResponseBuffer(); +} + +void updateBms() { + if (millis() - lastReadAttempt < READ_INTERVAL_MS) { + return; + } + + lastReadAttempt = millis(); + + if (!connectBms()) { + return; + } + + resetResponseBuffer(); + + bool ok = writeChar->writeValue( + JBD_STATUS_REQUEST, + sizeof(JBD_STATUS_REQUEST), + false + ); + + if (!ok) { + Serial.println("BMS: Write failed"); + bmsData.connected = false; + return; + } + + unsigned long start = millis(); + while (!responseReady && millis() - start < 2000) { + delay(10); + } + + if (!responseReady) { + Serial.println("BMS: Read timeout"); + return; + } + + parseBasicInfo(); +} diff --git a/firmware/esp32/xterra-controller/bms/bms.h b/firmware/esp32/xterra-controller/bms/bms.h new file mode 100644 index 0000000..96e151e --- /dev/null +++ b/firmware/esp32/xterra-controller/bms/bms.h @@ -0,0 +1,22 @@ +#pragma once + +struct BmsData { + bool connected = false; + bool valid = false; + + float soc = 0; + float voltage = 0; + float current = 0; + float remainingAh = 0; + float capacityAh = 0; + float temperatureF = 0; + + int cycleCount = 0; + int cellCount = 0; + int ntcCount = 0; +}; + +extern BmsData bmsData; + +void initBms(); +void updateBms(); diff --git a/firmware/esp32/xterra-controller/xterra-controller.ino b/firmware/esp32/xterra-controller/xterra-controller.ino index 71536ce..0ce6edf 100644 --- a/firmware/esp32/xterra-controller/xterra-controller.ino +++ b/firmware/esp32/xterra-controller/xterra-controller.ino @@ -6,30 +6,59 @@ #include "protocol.h" #include "relays.h" #include "sensors.h" +#include "bms/bms.h" WebServer server(80); HardwareSerial DashboardSerial(2); String uartLineBuffer; -float batterySOC = 82.0; -float batteryVoltage = 13.2; -float batteryCurrent = -6.4; -float batteryRemainingAh = 82.0; -float batteryRuntimeHours = 12.0; -float batteryTemp = 76.0; +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"); - battery["soc"] = batterySOC; - battery["voltage"] = batteryVoltage; - battery["current"] = batteryCurrent; - battery["remaining_ah"] = batteryRemainingAh; - battery["runtime_hours"] = batteryRuntimeHours; - battery["temperature_f"] = batteryTemp; + + 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; + } 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"); @@ -237,6 +266,7 @@ void setup() { initRelays(); initSensors(); + initBms(); WiFi.mode(WIFI_AP); @@ -264,6 +294,7 @@ void setup() { void loop() { server.handleClient(); + updateBms(); pollDashboardUart(); static unsigned long lastSensorUpdate = 0;