437 lines
17 KiB
C++
437 lines
17 KiB
C++
/**
|
|
* 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 <math.h>
|
|
|
|
// --- 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); }
|