Improved attempts, still need encyption
This commit is contained in:
@@ -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 <Arduino.h>
|
||||
#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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 <Arduino.h>
|
||||
#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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+6
-1
@@ -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",
|
||||
|
||||
@@ -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 <math.h>
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
@@ -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 <Arduino.h>
|
||||
#include <BLEDevice.h>
|
||||
#include <BLEAdvertisedDevice.h>
|
||||
#include <BLEScan.h>
|
||||
#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
|
||||
+5
-5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user