/** * BluettiBLE implementation — see BluettiBLE.h for the public interface. * * Protocol ported from the Bluetti_ESP32_Bridge project. * Copyright (c) 2026 Scott Penrose — License: MIT */ #include "BluettiBLE.h" #include "BluettiCRC.h" #include "devices/BluettiFields.h" // Per-model register tables (each header defines static const arrays). #include "devices/Device_AC300.h" #include "devices/Device_AC200M.h" #include "devices/Device_EB3A.h" #include "devices/Device_EP500P.h" #include "devices/Device_AC500.h" #include "devices/Device_EP500.h" #include "devices/Device_EP600.h" #include // --- BLE UUIDs (ported from BTooth.h) --- static BLEUUID BLUETTI_SERVICE_UUID("0000ff00-0000-1000-8000-00805f9b34fb"); static BLEUUID BLUETTI_WRITE_UUID ("0000ff02-0000-1000-8000-00805f9b34fb"); static BLEUUID BLUETTI_NOTIFY_UUID ("0000ff01-0000-1000-8000-00805f9b34fb"); static constexpr int HEADER_SIZE = 4; // matches PayloadParser.h // Single active instance — the ESP32 BLE notify/scan callbacks are plain C // function pointers with no user-data argument, so we route through this. static BluettiBLE* s_instance = nullptr; // ============================================================ // Model registry // ============================================================ struct ModelTables { const bluetti_field_t* state; size_t stateCount; const bluetti_poll_t* poll; size_t pollCount; const bluetti_field_t* command; size_t commandCount; }; #define TABLES(P) { P##_state, sizeof(P##_state)/sizeof(bluetti_field_t), \ P##_poll, sizeof(P##_poll)/sizeof(bluetti_poll_t), \ P##_command, sizeof(P##_command)/sizeof(bluetti_field_t) } static bool getModelTables(BluettiModel m, ModelTables& out) { switch (m) { case BLUETTI_AC300: out = (ModelTables)TABLES(AC300); return true; case BLUETTI_AC200M: out = (ModelTables)TABLES(AC200M); return true; case BLUETTI_EB3A: out = (ModelTables)TABLES(EB3A); return true; case BLUETTI_EP500P: out = (ModelTables)TABLES(EP500P); return true; case BLUETTI_AC500: out = (ModelTables)TABLES(AC500); return true; case BLUETTI_EP500: out = (ModelTables)TABLES(EP500); return true; case BLUETTI_EP600: out = { EP600_state, sizeof(EP600_state)/sizeof(bluetti_field_t), EP600_poll, sizeof(EP600_poll)/sizeof(bluetti_poll_t), nullptr, 0 }; return true; default: return false; } } // ============================================================ // Construction / setup // ============================================================ BluettiBLE::BluettiBLE() : deviceCount(0), pBLEScan(nullptr), pClient(nullptr), pWriteChar(nullptr), pNotifyChar(nullptr), foundDevice(nullptr), scanCallbackObj(nullptr), clientCallbackObj(nullptr), callback(nullptr), debugEnabled(false), pollIntervalMs(3000), initialized(false), state(STATE_IDLE), activeIndex(-1), pollTick(0), scanComplete(false), lastPollTime(0), pendingPage(0), pendingOffset(0) { memset(devices, 0, sizeof(devices)); memset(&working, 0, sizeof(working)); } bool BluettiBLE::begin() { if (initialized) return true; s_instance = this; BLEDevice::init("BluettiBLE"); BLEDevice::setMTU(517); pBLEScan = BLEDevice::getScan(); scanCallbackObj = new BluettiBLEAdvertisedDeviceCallbacks(this); clientCallbackObj = new BluettiBLEClientCallbacks(this); pBLEScan->setAdvertisedDeviceCallbacks(scanCallbackObj, true); pBLEScan->setActiveScan(true); pBLEScan->setInterval(1349); pBLEScan->setWindow(449); initialized = true; state = STATE_IDLE; if (debugEnabled) Serial.println("[BluettiBLE] initialised"); return true; } bool BluettiBLE::addDevice(const char* name, const char* bleName, BluettiModel model) { if (deviceCount >= BLUETTI_MAX_DEVICES) return false; if (!bleName || strlen(bleName) == 0) return false; ModelTables t; if (!getModelTables(model, t)) return false; DeviceEntry* e = &devices[deviceCount]; memset(e, 0, sizeof(DeviceEntry)); e->active = true; strncpy(e->device.name, name ? name : bleName, BLUETTI_NAME_LEN - 1); strncpy(e->device.bleName, bleName, BLUETTI_NAME_LEN - 1); e->device.model = model; e->device.rssi = -100; deviceCount++; if (debugEnabled) Serial.printf("[BluettiBLE] Added: %s (%s)\n", e->device.name, e->device.bleName); return true; } bool BluettiBLE::isConnected() const { return state == STATE_CONNECTED && pClient && pClient->isConnected(); } // ============================================================ // Static trampolines // ============================================================ void BluettiBLE::scanDoneTrampoline(BLEScanResults results) { if (s_instance) s_instance->scanComplete = true; } void BluettiBLE::notifyTrampoline(BLERemoteCharacteristic* c, uint8_t* data, size_t length, bool isNotify) { if (s_instance) s_instance->onNotify(data, length); } void BluettiBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) { if (owner) owner->onScanResult(advertisedDevice); } void BluettiBLEClientCallbacks::onDisconnect(BLEClient* c) { if (owner) owner->onDisconnect(); } // ============================================================ // State machine // ============================================================ void BluettiBLE::loop() { if (!initialized || deviceCount == 0) return; switch (state) { case STATE_IDLE: startScan(); break; case STATE_SCANNING: if (scanComplete) { pBLEScan->stop(); if (foundDevice) { state = STATE_CONNECTING; } else { // No match this round — rotate to the next device and retry. activeIndex = (activeIndex + 1) % (int)deviceCount; state = STATE_IDLE; } } break; case STATE_CONNECTING: connectActive(); break; case STATE_CONNECTED: if (!isConnected()) { onDisconnect(); } else if (millis() - lastPollTime >= pollIntervalMs) { sendPoll(); lastPollTime = millis(); } break; } } void BluettiBLE::startScan() { // Choose the next active device to look for. if (activeIndex < 0) activeIndex = 0; while (activeIndex < (int)deviceCount && !devices[activeIndex].active) activeIndex = (activeIndex + 1) % (int)deviceCount; if (foundDevice) { delete foundDevice; foundDevice = nullptr; } scanComplete = false; pBLEScan->clearResults(); if (debugEnabled) Serial.printf("[BluettiBLE] scanning for %s\n", devices[activeIndex].device.bleName); if (pBLEScan->start(5, scanDoneTrampoline, false)) state = STATE_SCANNING; } void BluettiBLE::onScanResult(BLEAdvertisedDevice advertisedDevice) { if (activeIndex < 0 || activeIndex >= (int)deviceCount) return; const char* want = devices[activeIndex].device.bleName; bool hasBluettiService = advertisedDevice.haveServiceUUID() && advertisedDevice.isAdvertisingService(BLUETTI_SERVICE_UUID); std::string nm = advertisedDevice.getName(); // hold a copy (c_str() of a // temporary would dangle) if (debugEnabled) { Serial.printf("[BluettiBLE] seen: name='%s' rssi=%d bluetti=%s%s\n", nm.empty() ? "(no name)" : nm.c_str(), advertisedDevice.getRSSI(), hasBluettiService ? "yes" : "no", (nm == want) ? " <-- MATCHES" : ""); } if (hasBluettiService && nm == want) { if (foundDevice) delete foundDevice; foundDevice = new BLEAdvertisedDevice(advertisedDevice); devices[activeIndex].device.rssi = advertisedDevice.getRSSI(); pBLEScan->stop(); scanComplete = true; } } void BluettiBLE::connectActive() { if (!foundDevice) { state = STATE_IDLE; return; } if (!pClient) { pClient = BLEDevice::createClient(); pClient->setClientCallbacks(clientCallbackObj); } if (debugEnabled) Serial.println("[BluettiBLE] connecting..."); if (!pClient->connect(foundDevice)) { if (debugEnabled) Serial.println("[BluettiBLE] connect failed"); onDisconnect(); return; } BLERemoteService* svc = pClient->getService(BLUETTI_SERVICE_UUID); if (!svc) { if (debugEnabled) Serial.println("[BluettiBLE] no service"); pClient->disconnect(); onDisconnect(); return; } pWriteChar = svc->getCharacteristic(BLUETTI_WRITE_UUID); pNotifyChar = svc->getCharacteristic(BLUETTI_NOTIFY_UUID); if (!pWriteChar || !pNotifyChar) { if (debugEnabled) Serial.println("[BluettiBLE] missing characteristics"); pClient->disconnect(); onDisconnect(); return; } if (pNotifyChar->canNotify()) pNotifyChar->registerForNotify(notifyTrampoline); devices[activeIndex].device.connected = true; memset(&working, 0, sizeof(working)); pollTick = 0; lastPollTime = millis() - pollIntervalMs; // poll immediately state = STATE_CONNECTED; if (debugEnabled) Serial.println("[BluettiBLE] connected"); } void BluettiBLE::onDisconnect() { if (activeIndex >= 0 && activeIndex < (int)deviceCount) devices[activeIndex].device.connected = false; pWriteChar = nullptr; pNotifyChar = nullptr; // Rotate to the next device so multiple registrations get a turn. if (deviceCount > 0) activeIndex = (activeIndex + 1) % (int)deviceCount; state = STATE_IDLE; if (debugEnabled) Serial.println("[BluettiBLE] disconnected"); } // ============================================================ // Polling // ============================================================ void BluettiBLE::sendPoll() { ModelTables t; if (!getModelTables(devices[activeIndex].device.model, t)) return; if (t.pollCount == 0) return; if (pollTick >= t.pollCount) pollTick = 0; const bluetti_poll_t& p = t.poll[pollTick]; bluettiCommand cmd; cmd.prefix = 0x01; cmd.cmd = 0x03; // read cmd.page = p.page; cmd.offset = p.offset; cmd.len = (uint16_t)p.count << 8; // big-endian register count cmd.checkSum = bluettiModbusCrc((uint8_t*)&cmd, 6); pendingPage = p.page; pendingOffset = p.offset; sendCommand(cmd); pollTick++; if (pollTick >= t.pollCount) { // Completed a full cycle: publish the rolling snapshot. pollTick = 0; BluettiDevice& d = devices[activeIndex].device; memcpy(&d.data, &working, sizeof(BluettiData)); d.dataValid = true; d.lastUpdate = millis(); if (pClient) d.rssi = pClient->getRssi(); if (callback) callback(&d); } } bool BluettiBLE::sendCommand(const bluettiCommand& cmd) { if (!pWriteChar) return false; if (debugEnabled) { Serial.print("[BluettiBLE] >> "); const uint8_t* b = (const uint8_t*)&cmd; for (int i = 0; i < 8; i++) Serial.printf("%02x", b[i]); Serial.println(); } pWriteChar->writeValue((uint8_t*)&cmd, sizeof(cmd), true); return true; } // ============================================================ // Response parsing (arithmetic ported from PayloadParser.cpp) // ============================================================ void BluettiBLE::onNotify(uint8_t* pData, size_t length) { if (length < (size_t)HEADER_SIZE) return; parseResponse(pendingPage, pendingOffset, pData, length); } void BluettiBLE::parseResponse(uint8_t reqPage, uint8_t reqOffset, uint8_t* pData, size_t length) { if (pData[1] != 0x03) return; // only handle range-read responses ModelTables t; if (!getModelTables(devices[activeIndex].device.model, t)) return; for (size_t i = 0; i < t.stateCount; i++) { const bluetti_field_t& f = t.state[i]; if (f.page != reqPage) continue; if (f.offset < reqOffset) continue; int delta = (int)f.offset - (int)reqOffset; if ((2 * delta) + HEADER_SIZE > (int)length) continue; if ((2 * (delta + f.size)) + HEADER_SIZE > (int)length) continue; // 1-based indexing quirk from the reference: data begins at pData[2*delta+3] int dataStart = (2 * delta) + HEADER_SIZE; const uint8_t* bytes = &pData[dataStart - 1]; applyField(f.name, f.scale, bytes, f.size); } } void BluettiBLE::applyField(int fieldName, uint8_t scale, const uint8_t* b, int sizeRegs) { auto u16 = [&](int i) -> uint16_t { return ((uint16_t)b[i] << 8) | (uint16_t)b[i + 1]; }; // Cell voltages occupy a contiguous enum range. if (fieldName >= BF_CELL01_VOLTAGE && fieldName <= BF_CELL16_VOLTAGE) { working.cellVoltage[fieldName - BF_CELL01_VOLTAGE] = u16(0) / powf(10, scale); return; } switch (fieldName) { case BF_DEVICE_TYPE: { int n = 2 * sizeRegs; if (n > BLUETTI_MODEL_LEN - 1) n = BLUETTI_MODEL_LEN - 1; memcpy(working.model, b, n); working.model[n] = '\0'; // trim trailing spaces/nulls for (int i = n - 1; i >= 0 && (working.model[i] == ' ' || working.model[i] == 0); i--) working.model[i] = '\0'; break; } case BF_SERIAL_NUMBER: { uint16_t v1 = u16(0), v2 = u16(2), v3 = u16(4), v4 = u16(6); working.serialNumber = ((uint64_t)v1) | ((uint64_t)v2 << 16) | ((uint64_t)v3 << 32) | ((uint64_t)v4 << 48); break; } case BF_ARM_VERSION: case BF_DSP_VERSION: { uint16_t low = u16(0), high = u16(2); long val = (long)low | ((long)high << 16); float ver = (float)val / 100.0f; if (fieldName == BF_ARM_VERSION) working.armVersion = ver; else working.dspVersion = ver; break; } case BF_DC_INPUT_POWER: working.dcInputPower = u16(0); break; case BF_AC_INPUT_POWER: working.acInputPower = u16(0); break; case BF_AC_OUTPUT_POWER: working.acOutputPower = u16(0); break; case BF_DC_OUTPUT_POWER: working.dcOutputPower = u16(0); break; case BF_POWER_GENERATION: working.powerGeneration = u16(0) / powf(10, scale); break; case BF_TOTAL_BATTERY_PERCENT: working.totalBatteryPercent = (uint8_t)u16(0); break; case BF_AC_OUTPUT_ON: working.acOutputOn = (b[1] == 1); break; case BF_DC_OUTPUT_ON: working.dcOutputOn = (b[1] == 1); break; case BF_AC_INPUT_VOLTAGE: working.acInputVoltage = u16(0) / powf(10, scale); break; case BF_AC_INPUT_FREQUENCY: working.acInputFrequency = u16(0) / powf(10, scale); break; case BF_INTERNAL_AC_VOLTAGE: working.internalAcVoltage = u16(0) / powf(10, scale); break; case BF_INTERNAL_AC_FREQUENCY: working.internalAcFrequency = u16(0) / powf(10, scale); break; case BF_INTERNAL_DC_INPUT_VOLTAGE: working.internalDcInputVoltage = u16(0) / powf(10, scale); break; case BF_INTERNAL_DC_INPUT_CURRENT: working.internalDcInputCurrent = u16(0) / powf(10, scale); break; case BF_INTERNAL_PACK_VOLTAGE: working.packVoltage = u16(0) / powf(10, scale); break; case BF_PACK_NUM_MAX: working.packNumMax = (uint8_t)u16(0); break; case BF_PACK_NUM: working.packNum = (uint8_t)u16(0); break; case BF_PACK_BATTERY_PERCENT: working.packBatteryPercent = (uint8_t)u16(0); break; default: break; // fields without a struct member are ignored } } // ============================================================ // Control // ============================================================ bool BluettiBLE::writeControl(int fieldName, uint16_t value) { if (!isConnected()) return false; ModelTables t; if (!getModelTables(devices[activeIndex].device.model, t)) return false; for (size_t i = 0; i < t.commandCount; i++) { if ((int)t.command[i].name != fieldName) continue; bluettiCommand cmd; cmd.prefix = 0x01; cmd.cmd = 0x06; // write cmd.page = t.command[i].page; cmd.offset = t.command[i].offset; cmd.len = bluettiSwapBytes(value); // value big-endian on the wire cmd.checkSum = bluettiModbusCrc((uint8_t*)&cmd, 6); return sendCommand(cmd); } return false; // model has no such writable control } bool BluettiBLE::setACOutput(bool on) { return writeControl(BF_AC_OUTPUT_ON, on ? 1 : 0); } bool BluettiBLE::setDCOutput(bool on) { return writeControl(BF_DC_OUTPUT_ON, on ? 1 : 0); }