From 890feae0608d01a869da819776d9ea13f617b229 Mon Sep 17 00:00:00 2001 From: Scott Penrose Date: Thu, 4 Jun 2026 22:32:27 +1000 Subject: [PATCH] Improved attempts, still need encyption --- README.md | 76 ++++++- examples/AdvMonitor/platformio.ini | 19 ++ examples/AdvMonitor/src/main.cpp | 93 +++++++++ keywords.txt | 17 ++ library.json | 7 +- src/BluettiADV.cpp | 318 +++++++++++++++++++++++++++++ src/BluettiADV.h | 200 ++++++++++++++++++ src/BluettiBLE.cpp | 10 +- 8 files changed, 733 insertions(+), 7 deletions(-) create mode 100644 examples/AdvMonitor/platformio.ini create mode 100644 examples/AdvMonitor/src/main.cpp create mode 100644 src/BluettiADV.cpp create mode 100644 src/BluettiADV.h diff --git a/README.md b/README.md index bff26c6..a0e6866 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,23 @@ The protocol is ported from the excellent time in round-robin. - Zero external dependencies — uses the stock ESP32 Arduino BLE stack. +## Two modes: connection vs. advertisement + +Bluetti exposes two different BLE channels, and this library has a class for each: + +| | `BluettiBLE` (connection) | `BluettiADV` (advertisement) | +|---|---|---| +| Transport | GATT connect + Modbus poll | passive advertisement scan | +| Data | full register set + **control** | monitoring snapshot, **read-only** | +| Encryption | none (older models only) | **AES-128-CTR**, key from the app | +| Works on | AC300, AC200M, EB3A, EP500P, … (older/plaintext generation) | newer **encrypted** generation: Elite / V2 / EP600, etc. | + +If `BluettiBLE` connects but never returns data and the unit disconnects after a +few seconds, your device is the newer encrypted generation — use **`BluettiADV`** +instead (see [Advertisement mode](#advertisement-mode-bluettiadv) below). The +connection/control channel on those models is locked behind a proprietary +handshake and is not supported; the advertisement channel is open and documented. + ## Supported devices | Model | Enum | Status (from reference project) | @@ -182,11 +199,68 @@ struct BluettiData { Control writes use the same frame with `cmd=0x06` and the value placed big-endian in the length field. +## Advertisement mode (`BluettiADV`) + +Newer Bluetti units (Elite, *V2, EP600, AC180, AC200L, …) refuse the plaintext +connection but **broadcast** an encrypted monitoring snapshot in their BLE +advertisements — no connection, no pairing, multiple listeners, low power. This is +the same scheme Victron uses. `BluettiADV` reads it. + +You need two things from the owner's side: + +1. The **16-byte AES key** (32 hex chars) — copy it from the BLUETTI app or the + device's Webserver (Bluetooth-data / developer section). +2. The device's **BLE MAC address** — a scanner app shows it; confirm the unit + broadcasts manufacturer data starting `06 0F` (company ID `0x0F06`). + +```cpp +#include +#include "BluettiADV.h" + +BluettiADV bluetti; + +void onAdv(const BluettiAdvDevice* dev) { + if (dev->lastRecordType == BLUETTI_ADV_MONITORING) { + const auto& m = dev->monitoring; + Serial.printf("SoC %u%% in %u W out %u W\n", + m.soc, m.inputPower, m.outputPower); + } +} + +void setup() { + Serial.begin(115200); + bluetti.begin(5); + bluetti.setDebug(true); + bluetti.setCallback(onAdv); + bluetti.addDevice("My Elite", "AA:BB:CC:DD:EE:FF", + "112233445566778899aabbccddeeff00"); +} + +void loop() { bluetti.loop(); } +``` + +The device cycles through several **record types**; each callback sets +`dev->lastRecordType` and refreshes one of the sub-structs: + +- `BLUETTI_ADV_MONITORING` (`0x80`) — SoC, in/out power, charge state, event flags +- `BLUETTI_ADV_BATTERY` (`0x02`) — pack voltage, current, temperature, SoC +- `BLUETTI_ADV_INVERTER` (`0x0B`) — battery V/A, AC output, PV power, yield +- `BLUETTI_ADV_CONFIG` (`0x81`) — modes (inverter, ECO, charging), settings + +This mode is **read-only** — there is no advertisement-based control. + +> Note: the advertisement bit-layout is implemented from Bluetti's official BLE +> ADV spec (V1.0, 2025-07-10). The exact AES nonce/counter construction and a few +> field offsets are best confirmed against a real packet — run with +> `setDebug(true)` (it dumps the decrypted payload) and sanity-check a value or +> two. Please report corrections. + ## Examples -- **BasicRead** — connect to one device and print all values. +- **BasicRead** — connect to one (older/plaintext) device and print all values. - **Control** — read, then toggle AC/DC output on a schedule. - **Logger** — print only when values change (snapshot/change-detection pattern). +- **AdvMonitor** — read a newer (encrypted) device via `BluettiADV` advertisements. ## Adding a new model diff --git a/examples/AdvMonitor/platformio.ini b/examples/AdvMonitor/platformio.ini new file mode 100644 index 0000000..6dc5b7d --- /dev/null +++ b/examples/AdvMonitor/platformio.ini @@ -0,0 +1,19 @@ +[env] +lib_extra_dirs = ../.. + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +[env:esp32-s3] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder +build_flags = + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 diff --git a/examples/AdvMonitor/src/main.cpp b/examples/AdvMonitor/src/main.cpp new file mode 100644 index 0000000..f9aa265 --- /dev/null +++ b/examples/AdvMonitor/src/main.cpp @@ -0,0 +1,93 @@ +/** + * BluettiADV Monitor Example + * + * READ-ONLY monitoring of newer (encrypted) Bluetti devices — Elite / V2 / + * EP600 etc. — via their BLE advertisement broadcast. No connection, no pairing. + * + * Setup: + * 1. Get the 16-byte AES key (32 hex chars) from the BLUETTI app or the device + * Webserver (look for the Bluetooth data / developer section). + * 2. Get the device's BLE MAC address (a BLE scanner app shows it; the unit + * broadcasts manufacturer data starting "06 0F"). + * 3. Fill both in below. + */ + +#include +#include "BluettiADV.h" + +BluettiADV bluetti; + +static const char* chargeStatusName(uint8_t s) { + switch (s) { + case 0: return "idle"; + case 1: return "charging"; + case 2: return "discharging"; + default: return "?"; + } +} + +void onBluettiAdv(const BluettiAdvDevice* dev) { + Serial.printf("\n=== %s (model 0x%04X, rssi %d, %s) record 0x%02X ===\n", + dev->name, dev->modelId, dev->rssi, + dev->connectedFlag ? "app-connected" : "free", dev->lastRecordType); + + switch (dev->lastRecordType) { + case BLUETTI_ADV_MONITORING: { + const auto& m = dev->monitoring; + Serial.printf("SoC: %u%%\n", m.soc); + Serial.printf("Input: %u W Output: %u W\n", m.inputPower, m.outputPower); + Serial.printf("Battery: %s\n", chargeStatusName(m.batteryChargeStatus)); + if (m.estimatedTimeMin && m.estimatedTimeMin != 0xFFFF) + Serial.printf("Est. time: %uh %um\n", m.estimatedTimeMin / 60, m.estimatedTimeMin % 60); + if (m.alarm) Serial.println("ALARM"); + Serial.printf("Events: 0x%04X\n", m.eventLine); + break; + } + case BLUETTI_ADV_BATTERY: { + const auto& b = dev->battery; + Serial.printf("SoC: %.1f%%\n", b.soc); + Serial.printf("Pack: %.1f V %.3f A %.1f C\n", + b.totalVoltage, b.totalCurrent, b.avgTemperatureC); + if (b.alarmCode) Serial.printf("Alarm code: 0x%04X\n", b.alarmCode); + break; + } + case BLUETTI_ADV_INVERTER: { + const auto& i = dev->inverter; + Serial.printf("Battery: %.1f V %.1f A\n", i.batteryVoltage, i.batteryCurrent); + Serial.printf("AC out: %d W PV: %u W\n", i.acOutputPower, i.pvPower); + Serial.printf("Yield today: %.2f kWh\n", i.yieldToday); + break; + } + case BLUETTI_ADV_CONFIG: { + const auto& c = dev->config; + Serial.printf("Inverter mode: %u Charging mode: %u\n", c.inverterMode, c.chargingMode); + Serial.printf("AC ECO: %u DC ECO: %u Power lifting: %s\n", + c.acEcoMode, c.dcEcoMode, c.powerLifting ? "on" : "off"); + break; + } + } +} + +void setup() { + Serial.begin(115200); + delay(1000); + + Serial.println("\n\n============================="); + Serial.println("BluettiADV Monitor Example"); + Serial.println("=============================\n"); + + bluetti.begin(5); + bluetti.setDebug(true); // prints discovered devices + decrypted payloads + bluetti.setCallback(onBluettiAdv); + + // CHANGE THESE: your device's BLE MAC and the 16-byte AES key (32 hex chars). + bluetti.addDevice("My Elite", "AA:BB:CC:DD:EE:FF", + "112233445566778899aabbccddeeff00"); + + Serial.printf("Configured %d device(s). Scanning advertisements...\n", + (int)bluetti.getDeviceCount()); +} + +void loop() { + bluetti.loop(); +} diff --git a/keywords.txt b/keywords.txt index 93b377e..1493025 100644 --- a/keywords.txt +++ b/keywords.txt @@ -11,6 +11,13 @@ BluettiDevice KEYWORD1 BluettiData KEYWORD1 BluettiCallback KEYWORD1 bluettiCommand KEYWORD1 +BluettiADV KEYWORD1 +BluettiAdvDevice KEYWORD1 +BluettiAdvCallback KEYWORD1 +BluettiAdvBattery KEYWORD1 +BluettiAdvInverter KEYWORD1 +BluettiAdvMonitoring KEYWORD1 +BluettiAdvConfig KEYWORD1 ####################################### # Methods and Functions (KEYWORD2) @@ -26,6 +33,16 @@ getDeviceCount KEYWORD2 loop KEYWORD2 setACOutput KEYWORD2 setDCOutput KEYWORD2 +setMinInterval KEYWORD2 + +####################################### +# Advertisement record types (LITERAL1) +####################################### + +BLUETTI_ADV_BATTERY LITERAL1 +BLUETTI_ADV_INVERTER LITERAL1 +BLUETTI_ADV_MONITORING LITERAL1 +BLUETTI_ADV_CONFIG LITERAL1 ####################################### # Constants (LITERAL1) diff --git a/library.json b/library.json index 3bc42b5..ac16a44 100644 --- a/library.json +++ b/library.json @@ -19,7 +19,7 @@ "homepage": "https://gitea.sh3d.com.au/Sh3d/BluettiBLE", "frameworks": ["arduino", "espidf"], "platforms": ["espressif32"], - "headers": ["BluettiBLE.h"], + "headers": ["BluettiBLE.h", "BluettiADV.h"], "dependencies": [], "examples": [ { @@ -27,6 +27,11 @@ "base": "examples/BasicRead", "files": ["src/main.cpp"] }, + { + "name": "AdvMonitor", + "base": "examples/AdvMonitor", + "files": ["src/main.cpp"] + }, { "name": "Control", "base": "examples/Control", diff --git a/src/BluettiADV.cpp b/src/BluettiADV.cpp new file mode 100644 index 0000000..defc4d7 --- /dev/null +++ b/src/BluettiADV.cpp @@ -0,0 +1,318 @@ +/** + * BluettiADV implementation — see BluettiADV.h for the public interface. + * + * Decrypts and parses Bluetti BLE advertisement ("BLE ADV") monitoring data. + * AES-CTR decrypt mirrors VictronBLE; bit-field layout follows the official + * Bluetti API docs (BLE ADV section). All multi-bit fields are little-endian, + * LSB-first, as the spec states. + * + * Copyright (c) 2026 Scott Penrose — License: MIT + */ + +#include "BluettiADV.h" +#include + +// ============================================================ +// Small helpers +// ============================================================ + +// Read nBits starting at bit offset startBit (LSB-first) from a byte buffer. +// Returns 0 if the field runs past the available data. +static uint64_t readBits(const uint8_t* d, size_t lenBytes, int startBit, int nBits) { + if (nBits <= 0 || nBits > 64) return 0; + if ((size_t)(startBit + nBits) > lenBytes * 8) return 0; + uint64_t val = 0; + for (int i = 0; i < nBits; i++) { + int bit = startBit + i; + uint8_t b = (d[bit >> 3] >> (bit & 7)) & 0x1; + val |= ((uint64_t)b << i); + } + return val; +} + +// Sign-extend an n-bit two's-complement value held in a uint. +static int64_t signExtend(uint64_t v, int bits) { + uint64_t signBit = (uint64_t)1 << (bits - 1); + if (v & signBit) return (int64_t)(v | (~((uint64_t)0) << bits)); + return (int64_t)v; +} + +bool BluettiADV::hexToBytes(const char* hex, uint8_t* out, size_t len) { + if (!hex) return false; + for (size_t i = 0; i < len; i++) { + char hi = hex[i * 2], lo = hex[i * 2 + 1]; + if (!isxdigit((int)hi) || !isxdigit((int)lo)) return false; + auto nib = [](char c) -> uint8_t { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return c - 'A' + 10; + }; + out[i] = (nib(hi) << 4) | nib(lo); + } + return true; +} + +void BluettiADV::normalizeMAC(const char* input, char* output) { + size_t o = 0; + for (size_t i = 0; input[i] && o < BLUETTI_ADV_MAC_LEN - 1; i++) { + char c = input[i]; + if (c == ':' || c == '-') continue; + output[o++] = (c >= 'A' && c <= 'F') ? (c - 'A' + 'a') : c; + } + output[o] = '\0'; +} + +// ============================================================ +// Setup +// ============================================================ +BluettiADV::BluettiADV() + : deviceCount(0), pBLEScan(nullptr), scanCallbackObj(nullptr), + callback(nullptr), debugEnabled(false), scanDuration(5), + minIntervalMs(1000), initialized(false) { + memset(devices, 0, sizeof(devices)); +} + +bool BluettiADV::begin(uint32_t scanDur) { + if (initialized) return true; + scanDuration = scanDur; + + BLEDevice::init("BluettiADV"); + pBLEScan = BLEDevice::getScan(); + scanCallbackObj = new BluettiADVScanCallbacks(this); + pBLEScan->setAdvertisedDeviceCallbacks(scanCallbackObj, true); + // Active scan also captures scan-response data (device names and, on some + // devices, the manufacturer data), so we don't miss the broadcast. + pBLEScan->setActiveScan(true); + pBLEScan->setInterval(100); + pBLEScan->setWindow(99); + + initialized = true; + return true; +} + +bool BluettiADV::addDevice(const char* name, const char* mac, const char* hexKey) { + if (deviceCount >= BLUETTI_ADV_MAX_DEVICES) return false; + if (!hexKey || strlen(hexKey) != 32) return false; + if (!mac || strlen(mac) == 0) return false; + + char normMac[BLUETTI_ADV_MAC_LEN]; + normalizeMAC(mac, normMac); + if (findDevice(normMac)) return false; + + DeviceEntry* e = &devices[deviceCount]; + memset(e, 0, sizeof(DeviceEntry)); + e->active = true; + strncpy(e->device.name, name ? name : "", BLUETTI_ADV_NAME_LEN - 1); + memcpy(e->device.mac, normMac, BLUETTI_ADV_MAC_LEN); + e->device.rssi = -100; + if (!hexToBytes(hexKey, e->key, 16)) return false; + + deviceCount++; + if (debugEnabled) Serial.printf("[BluettiADV] Added: %s (%s)\n", e->device.name, normMac); + return true; +} + +BluettiADV::DeviceEntry* BluettiADV::findDevice(const char* normalizedMAC) { + for (size_t i = 0; i < deviceCount; i++) + if (devices[i].active && strcmp(devices[i].device.mac, normalizedMAC) == 0) + return &devices[i]; + return nullptr; +} + +// ============================================================ +// Scan loop (non-blocking, mirrors VictronBLE) +// ============================================================ +static bool s_advScanning = false; +static void onAdvScanDone(BLEScanResults results) { s_advScanning = false; } + +void BluettiADV::loop() { + if (!initialized) return; + if (!s_advScanning) { + pBLEScan->clearResults(); + s_advScanning = pBLEScan->start(scanDuration, onAdvScanDone, false); + } +} + +void BluettiADVScanCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) { + if (owner) owner->processDevice(advertisedDevice); +} + +void BluettiADV::processDevice(BLEAdvertisedDevice& adv) { + // Verbose diagnostic: dump EVERY advertised device and its raw manufacturer + // data. Confirms scanning works and shows whether the Bluetti broadcasts the + // 0x0F06 company ID at all. + if (debugEnabled) { + std::string nm = adv.getName(); + Serial.printf("[BluettiADV] dev %s '%s' rssi %d mfg:", + adv.getAddress().toString().c_str(), + nm.empty() ? "" : nm.c_str(), adv.getRSSI()); + if (adv.haveManufacturerData()) { + auto m = adv.getManufacturerData(); + std::string r(m.c_str(), m.length()); + for (size_t i = 0; i < r.length(); i++) Serial.printf("%02x", (uint8_t)r[i]); + } else { + Serial.print("(none)"); + } + Serial.println(); + } + + if (!adv.haveManufacturerData()) return; + + auto mfg = adv.getManufacturerData(); + std::string raw(mfg.c_str(), mfg.length()); + // company(2) + prodAdv(2) + model(2) + record(1) + nonce(2) + keyByte(1) = 10 + if (raw.length() < 11) return; + + const uint8_t* b = (const uint8_t*)raw.data(); + uint16_t companyID = (uint8_t)b[0] | ((uint16_t)(uint8_t)b[1] << 8); + if (companyID != BLUETTI_COMPANY_ID) return; + + char normMac[BLUETTI_ADV_MAC_LEN]; + normalizeMAC(adv.getAddress().toString().c_str(), normMac); + DeviceEntry* e = findDevice(normMac); + if (!e) { + // A Bluetti device that broadcasts but isn't registered. In debug mode, + // dump enough to confirm the broadcast exists and to set up addDevice(): + // the MAC, the record type, and the key-match byte the device expects. + if (debugEnabled) { + Serial.printf("[BluettiADV] Bluetti broadcast from %s rssi %d rec:0x%02X keyByte0:0x%02X raw:", + normMac, adv.getRSSI(), + raw.length() > 6 ? (uint8_t)b[6] : 0, + raw.length() > 9 ? (uint8_t)b[9] : 0); + for (size_t i = 0; i < raw.length(); i++) Serial.printf("%02x", (uint8_t)b[i]); + Serial.println(); + } + return; + } + + uint16_t prodAdv = (uint8_t)b[2] | ((uint16_t)(uint8_t)b[3] << 8); + uint16_t modelId = (uint8_t)b[4] | ((uint16_t)(uint8_t)b[5] << 8); + uint8_t recordType = b[6]; + uint16_t nonce = (uint8_t)b[7] | ((uint16_t)(uint8_t)b[8] << 8); + uint8_t keyByte0 = b[9]; + const uint8_t* cipher = b + 10; + size_t cipherLen = raw.length() - 10; + if (cipherLen == 0 || cipherLen > BLUETTI_ADV_MAX_PAYLOAD) return; + + // Validate against key byte 0 before doing AES work. + if (keyByte0 != e->key[0]) { + if (debugEnabled) Serial.printf("[BluettiADV] key byte mismatch (adv 0x%02X vs key 0x%02X)\n", + keyByte0, e->key[0]); + return; + } + + // Skip unchanged data (nonce is the rolling counter) and respect interval. + uint32_t now = millis(); + if (e->haveNonce && nonce == e->lastNonce) { e->device.rssi = adv.getRSSI(); return; } + if (e->device.dataValid && (now - e->device.lastUpdate) < minIntervalMs) return; + + uint8_t plain[BLUETTI_ADV_MAX_PAYLOAD]; + if (!decrypt(cipher, cipherLen, e->key, nonce, plain)) { + if (debugEnabled) Serial.println("[BluettiADV] decrypt failed"); + return; + } + + if (debugEnabled) { + Serial.printf("[BluettiADV] %s rec:0x%02X nonce:0x%04X plain:", e->device.name, recordType, nonce); + for (size_t i = 0; i < cipherLen; i++) Serial.printf("%02x", plain[i]); + Serial.println(); + } + + e->device.connectedFlag = (prodAdv & 0x8000) != 0; + e->device.modelId = modelId; + e->device.lastRecordType = recordType; + + switch (recordType) { + case BLUETTI_ADV_BATTERY: parseBattery(plain, cipherLen, e->device.battery); break; + case BLUETTI_ADV_INVERTER: parseInverter(plain, cipherLen, e->device.inverter); break; + case BLUETTI_ADV_MONITORING: parseMonitoring(plain, cipherLen, e->device.monitoring); break; + case BLUETTI_ADV_CONFIG: parseConfig(plain, cipherLen, e->device.config); break; + default: + if (debugEnabled) Serial.printf("[BluettiADV] unknown record type 0x%02X\n", recordType); + return; + } + + e->lastNonce = nonce; + e->haveNonce = true; + e->device.rssi = adv.getRSSI(); + e->device.lastUpdate = now; + e->device.dataValid = true; + if (callback) callback(&e->device); +} + +// ============================================================ +// AES-128-CTR decrypt (IV = 2-byte nonce LE + 14 zero bytes) +// ============================================================ +bool BluettiADV::decrypt(const uint8_t* in, size_t len, const uint8_t* key, + uint16_t nonce, uint8_t* out) { + mbedtls_aes_context aes; + mbedtls_aes_init(&aes); + if (mbedtls_aes_setkey_enc(&aes, key, 128) != 0) { mbedtls_aes_free(&aes); return false; } + + uint8_t nonce_counter[16] = {0}; + nonce_counter[0] = nonce & 0xFF; + nonce_counter[1] = (nonce >> 8) & 0xFF; + + size_t nc_off = 0; + uint8_t stream_block[16] = {0}; + int ret = mbedtls_aes_crypt_ctr(&aes, len, &nc_off, nonce_counter, stream_block, in, out); + mbedtls_aes_free(&aes); + return ret == 0; +} + +// ============================================================ +// Record parsers (bit offsets per the BLE ADV spec) +// ============================================================ +void BluettiADV::parseBattery(const uint8_t* d, size_t len, BluettiAdvBattery& r) { + uint16_t t = (uint16_t)readBits(d, len, 0, 16); + r.dischargeEmptyMin = t; + uint16_t v = (uint16_t)readBits(d, len, 16, 16); + r.totalVoltage = (v == 0x7FFF) ? NAN : signExtend(v, 16) / 10.0f; + r.alarmCode = (uint16_t)readBits(d, len, 32, 16); + uint16_t tk = (uint16_t)readBits(d, len, 48, 16); + r.avgTemperatureC = (tk == 0xFFFF) ? NAN : (tk * 0.01f - 273.15f); + uint32_t c = (uint32_t)readBits(d, len, 68, 22); + r.totalCurrent = (c == 0x3FFFFF) ? NAN : signExtend(c, 22) / 1000.0f; + r.dischargeEnergyAh = (uint32_t)readBits(d, len, 90, 20); + uint16_t s = (uint16_t)readBits(d, len, 110, 10); + r.soc = (s == 0x3FF) ? NAN : s / 10.0f; +} + +void BluettiADV::parseInverter(const uint8_t* d, size_t len, BluettiAdvInverter& r) { + r.alarmCode = (uint16_t)readBits(d, len, 0, 16); + uint16_t c = (uint16_t)readBits(d, len, 16, 16); + r.batteryCurrent = (c == 0x7FFF) ? NAN : signExtend(c, 16) / 10.0f; + uint16_t v = (uint16_t)readBits(d, len, 32, 14); + r.batteryVoltage = (v == 0x3FFF) ? NAN : v / 10.0f; + r.activeAcPortIndex = (uint8_t)readBits(d, len, 46, 2); + r.activeAcPortPower = (int16_t)signExtend(readBits(d, len, 48, 16), 16); + r.acOutputPower = (int16_t)signExtend(readBits(d, len, 64, 16), 16); + r.pvPower = (uint16_t)readBits(d, len, 80, 16); + uint16_t y = (uint16_t)readBits(d, len, 96, 16); + r.yieldToday = (y == 0xFFFF) ? NAN : y / 100.0f; +} + +void BluettiADV::parseMonitoring(const uint8_t* d, size_t len, BluettiAdvMonitoring& r) { + r.soc = (uint8_t)readBits(d, len, 0, 8); + r.estimatedTimeMin = (uint16_t)readBits(d, len, 8, 16); + r.eventLine = (uint16_t)readBits(d, len, 24, 16); + r.inputPower = (uint16_t)readBits(d, len, 40, 16); + r.outputPower = (uint16_t)readBits(d, len, 56, 16); + r.alarm = readBits(d, len, 72, 1) != 0; + r.batteryChargeStatus = (uint8_t)readBits(d, len, 74, 2); + r.stormStatus = (uint8_t)readBits(d, len, 76, 2); +} + +void BluettiADV::parseConfig(const uint8_t* d, size_t len, BluettiAdvConfig& r) { + r.timestamp = (uint32_t)readBits(d, len, 0, 32); + r.inverterMode = (uint8_t)readBits(d, len, 32, 8); + r.moneySavingParams = (uint32_t)readBits(d, len, 40, 32); + r.powerOutages = (uint8_t)readBits(d, len, 72, 8); + r.screenSleep = (uint8_t)readBits(d, len, 80, 4); + r.temperatureUnit = (uint8_t)readBits(d, len, 84, 2); + r.acEcoMode = (uint8_t)readBits(d, len, 88, 2); + r.dcEcoMode = (uint8_t)readBits(d, len, 90, 2); + r.chargingMode = (uint8_t)readBits(d, len, 92, 3); + r.powerLifting = readBits(d, len, 95, 1) != 0; + r.outputMemory = readBits(d, len, 96, 1) != 0; +} diff --git a/src/BluettiADV.h b/src/BluettiADV.h new file mode 100644 index 0000000..e544492 --- /dev/null +++ b/src/BluettiADV.h @@ -0,0 +1,200 @@ +/** + * BluettiADV - ESP32 passive BLE-advertisement reader for Bluetti devices + * + * The newer Bluetti generation (Elite / V2 / EP600 etc.) broadcasts encrypted + * monitoring data in its BLE *advertisements* — no GATT connection, no pairing, + * no proprietary handshake. The payload is AES-128-CTR encrypted with a 16-byte + * key the owner copies from the BLUETTI mobile app (or the device Webserver). + * + * This is the same scheme VictronBLE uses, so BluettiADV mirrors that design: + * passive scan, per-device AES key, a single function-pointer callback, a + * non-blocking loop(). It is READ-ONLY (monitoring); output control still + * requires the closed GATT channel and is not provided here. + * + * Reference: official Bluetti API docs, "BLE ADV" section (V1.0, 2025-07-10). + * + * Copyright (c) 2026 Scott Penrose + * License: MIT + */ + +#ifndef BLUETTI_ADV_H +#define BLUETTI_ADV_H + +#include +#include +#include +#include +#include "mbedtls/aes.h" + +// --- Constants --- +static constexpr uint16_t BLUETTI_COMPANY_ID = 0x0F06; // Poweroak / Bluetti +static constexpr int BLUETTI_ADV_MAX_DEVICES = 4; +static constexpr int BLUETTI_ADV_MAC_LEN = 13; // 12 hex chars + null +static constexpr int BLUETTI_ADV_NAME_LEN = 32; +static constexpr int BLUETTI_ADV_MAX_PAYLOAD = 20; // encrypted bytes + +// Advertisement record types (the "Record type" byte in Poweroak data). +enum BluettiAdvRecordType { + BLUETTI_ADV_BATTERY = 0x02, + BLUETTI_ADV_INVERTER = 0x0B, + BLUETTI_ADV_MONITORING = 0x80, // portable power station - monitoring + BLUETTI_ADV_CONFIG = 0x81 // portable power station - configuration +}; + +// ============================================================ +// Parsed record payloads (one per record type). A device cycles +// through several record types; each carries different fields. +// Floats are NAN when the device reported the "invalid" sentinel. +// ============================================================ + +struct BluettiAdvBattery { + uint16_t dischargeEmptyMin; // minutes (0xFFFF invalid) + float totalVoltage; // V + uint16_t alarmCode; + float avgTemperatureC; // C (converted from 0.01K) + float totalCurrent; // A (signed) + uint32_t dischargeEnergyAh; // Ah + float soc; // % +}; + +struct BluettiAdvInverter { + uint16_t alarmCode; + float batteryCurrent; // A (signed) + float batteryVoltage; // V + uint8_t activeAcPortIndex; + int16_t activeAcPortPower; // W (signed) + int16_t acOutputPower; // W (signed) + uint16_t pvPower; // W + float yieldToday; // kWh +}; + +struct BluettiAdvMonitoring { + uint8_t soc; // % + uint16_t estimatedTimeMin; // minutes (charge/discharge) + uint16_t eventLine; // bitfield, see BluettiAdvEvent + uint16_t inputPower; // W + uint16_t outputPower; // W + bool alarm; + uint8_t batteryChargeStatus; // 0 idle, 1 charging, 2 discharging + uint8_t stormStatus; // 0/2 invalid, 1 enabled +}; + +struct BluettiAdvConfig { + uint32_t timestamp; + uint8_t inverterMode; // see docs (0 default … 9 charge-discharge) + uint32_t moneySavingParams; + uint8_t powerOutages; + uint8_t screenSleep; // 1:15s 2:30s 3:1min 4:5min 5:always + uint8_t temperatureUnit; // 1:C 2:F + uint8_t acEcoMode; // 0 off, 1 normal, 2 deep + uint8_t dcEcoMode; + uint8_t chargingMode; // 0 standard … 4 user-defined + bool powerLifting; + bool outputMemory; +}; + +// EventLine bit meanings (BluettiAdvMonitoring.eventLine) +enum BluettiAdvEvent { + BLUETTI_EVT_PV_TO_BATTERY = 1 << 0, + BLUETTI_EVT_GRID_TO_BATTERY = 1 << 1, + BLUETTI_EVT_BATTERY_TO_GRID = 1 << 2, + BLUETTI_EVT_AC_LOAD = 1 << 3, + BLUETTI_EVT_DC_LOAD = 1 << 4, + BLUETTI_EVT_BATTERY_TO_INVERT = 1 << 5, + BLUETTI_EVT_INVERT_TO_BATTERY = 1 << 6, + BLUETTI_EVT_GRID_TO_AC_LOAD = 1 << 7, + BLUETTI_EVT_PV_ICON = 1 << 8, + BLUETTI_EVT_GRID_ICON = 1 << 9, + BLUETTI_EVT_LOAD_ICON = 1 << 10, + BLUETTI_EVT_PV_TO_GRID = 1 << 11, + BLUETTI_EVT_PV_TO_AC_LOAD = 1 << 12, + BLUETTI_EVT_BAT_TO_AC_LOAD = 1 << 13, + BLUETTI_EVT_GRID_POWER_NEGATIVE= 1 << 14 // 0 = export(+), 1 = import(-) +}; + +// ============================================================ +// Device descriptor passed to the callback. All record sub-structs +// are retained so the consumer can read the latest of each; the +// lastRecordType field says which one this callback just refreshed. +// ============================================================ +struct BluettiAdvDevice { + char name[BLUETTI_ADV_NAME_LEN]; + char mac[BLUETTI_ADV_MAC_LEN]; + int8_t rssi; + uint32_t lastUpdate; + bool dataValid; + bool connectedFlag; // bit 15 of Product Advertisement type + uint16_t modelId; // Device Model Identification + uint8_t lastRecordType; // record type updated by this callback + + BluettiAdvBattery battery; + BluettiAdvInverter inverter; + BluettiAdvMonitoring monitoring; + BluettiAdvConfig config; +}; + +typedef void (*BluettiAdvCallback)(const BluettiAdvDevice* device); + +class BluettiADVScanCallbacks; + +// ============================================================ +// Main BluettiADV class — passive advertisement reader +// ============================================================ +class BluettiADV { +public: + BluettiADV(); + + bool begin(uint32_t scanDuration = 5); + // hexKey: 32 hex chars (16-byte AES key) from the BLUETTI app / Webserver. + bool addDevice(const char* name, const char* mac, const char* hexKey); + void setCallback(BluettiAdvCallback cb) { callback = cb; } + void setDebug(bool enable) { debugEnabled = enable; } + void setMinInterval(uint32_t ms) { minIntervalMs = ms; } + size_t getDeviceCount() const { return deviceCount; } + void loop(); + +private: + friend class BluettiADVScanCallbacks; + + struct DeviceEntry { + BluettiAdvDevice device; + uint8_t key[16]; + uint16_t lastNonce; + bool active; + bool haveNonce; + }; + + DeviceEntry devices[BLUETTI_ADV_MAX_DEVICES]; + size_t deviceCount; + BLEScan* pBLEScan; + BluettiADVScanCallbacks* scanCallbackObj; + BluettiAdvCallback callback; + bool debugEnabled; + uint32_t scanDuration; + uint32_t minIntervalMs; + bool initialized; + + static bool hexToBytes(const char* hex, uint8_t* out, size_t len); + static void normalizeMAC(const char* input, char* output); + DeviceEntry* findDevice(const char* normalizedMAC); + + void processDevice(BLEAdvertisedDevice& dev); + bool decrypt(const uint8_t* in, size_t len, const uint8_t* key, + uint16_t nonce, uint8_t* out); + + void parseBattery(const uint8_t* d, size_t len, BluettiAdvBattery& r); + void parseInverter(const uint8_t* d, size_t len, BluettiAdvInverter& r); + void parseMonitoring(const uint8_t* d, size_t len, BluettiAdvMonitoring& r); + void parseConfig(const uint8_t* d, size_t len, BluettiAdvConfig& r); +}; + +// BLE scan callback (required by the ESP32 BLE API). +class BluettiADVScanCallbacks : public BLEAdvertisedDeviceCallbacks { +public: + BluettiADVScanCallbacks(BluettiADV* parent) : owner(parent) {} + void onResult(BLEAdvertisedDevice advertisedDevice) override; +private: + BluettiADV* owner; +}; + +#endif // BLUETTI_ADV_H diff --git a/src/BluettiBLE.cpp b/src/BluettiBLE.cpp index 4e145d0..916a7e2 100644 --- a/src/BluettiBLE.cpp +++ b/src/BluettiBLE.cpp @@ -204,17 +204,17 @@ void BluettiBLE::onScanResult(BLEAdvertisedDevice advertisedDevice) { 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) { - const char* nm = advertisedDevice.getName().c_str(); Serial.printf("[BluettiBLE] seen: name='%s' rssi=%d bluetti=%s%s\n", - (nm && nm[0]) ? nm : "(no name)", + nm.empty() ? "(no name)" : nm.c_str(), advertisedDevice.getRSSI(), hasBluettiService ? "yes" : "no", - (strcmp(nm, want) == 0) ? " <-- MATCHES" : ""); + (nm == want) ? " <-- MATCHES" : ""); } - if (hasBluettiService && - strcmp(advertisedDevice.getName().c_str(), want) == 0) { + if (hasBluettiService && nm == want) { if (foundDevice) delete foundDevice; foundDevice = new BLEAdvertisedDevice(advertisedDevice); devices[activeIndex].device.rssi = advertisedDevice.getRSSI();