From 8c7b28a82b08631dd9b2c1535832a8d8951d3fa6 Mon Sep 17 00:00:00 2001 From: Scott Penrose Date: Sat, 6 Jun 2026 18:23:23 +1000 Subject: [PATCH] Work more on Bluetti --- README.md | 54 ++++++++--- examples/AdvMonitor/src/main.cpp | 8 +- src/BluettiADV.cpp | 31 +++++- src/BluettiADV.h | 5 +- tools/decode.mjs | 158 +++++++++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 tools/decode.mjs diff --git a/README.md b/README.md index 1cf6981..c66c4ba 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ three; which one you use depends on your model's generation. | # | Channel | Where | Status | Use for | |---|---|---|---|---| | 1 | **`BluettiBLE`** — BLE GATT connection | `src/BluettiBLE.*` | ✅ **Working** (builds, on-hardware-pending per model) | Older/plaintext models — full read **+ control** | -| 2 | **`BluettiADV`** — BLE advertisement broadcast | `src/BluettiADV.*` | ⏳ **Ready, blocked on key** | Newer encrypted models — read-only, local | +| 2 | **`BluettiADV`** — BLE advertisement broadcast | `src/BluettiADV.*` | 🔑 **Key obtained, on-hardware validation pending** | Newer encrypted models — read-only, local | | 3 | **Cloud Open API** — HTTPS | `cloud/bluetti.mjs` | ⏳ **Ready, pending account approval** | Any model incl. EL300 — read **+ control**, via cloud | ### 1. `BluettiBLE` — local GATT connection ✅ Working @@ -45,13 +45,15 @@ plaintext generation** (AC300, AC200M, EB3A, EP500P, …). The library compiles links; per-model field maps are ported from the reference project and want a final on-hardware sanity check. See [Quick start](#quick-start). -### 2. `BluettiADV` — local advertisement broadcast ⏳ Ready, blocked on key +### 2. `BluettiADV` — local advertisement broadcast 🔑 Key obtained Passively decodes the encrypted monitoring broadcast that **newer** units emit (Elite / V2 / EP600 / AC180 / AC200L …). Read-only, no connection. The decoder is -implemented (AES-128-CTR + the official BLE-ADV field layout) and builds, but it -needs the device's **16-byte AES key**, which Bluetti has not yet exposed in the -app for these models. Ready to run the moment a key is available. See -[Advertisement mode](#advertisement-mode-bluettiadv). +implemented (AES-128-CTR + the official BLE-ADV field layout) and builds. The +device's **16-byte AES key** is now available (exported from the app — see +[Advertisement mode](#advertisement-mode-bluettiadv)); the AdvMonitor example is +wired with it and matches the first broadcaster automatically. Remaining step is an +on-hardware capture to confirm the key/nonce/offsets — validate offline with +`tools/decode.mjs` against a captured packet. ### 3. Cloud Open API — HTTPS ⏳ Ready, pending account approval For encrypted models (incl. the **EL300**) whose local channels are locked, the @@ -228,12 +230,38 @@ 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: +You need the **16-byte AES key** (32 hex chars) — from the BLUETTI app or the +device's Webserver (Bluetooth-data / developer section). The export is a small CSV +whose **3rd line is the AES key**; e.g.: -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`). +``` +bluetti +1780650490894 # timestamp / PIN (unused) +53afcd9aef4ff020f472686c00283ce5 # <-- the 16-byte AES key +6bc305f0… # token (unused for advertisements) +``` + +The **BLE MAC is optional**: pass `""` and the first Bluetti broadcaster the ESP32 +hears is adopted automatically (set a real MAC only if several Bluetti units are +in range). Confirm the unit broadcasts manufacturer data starting `06 0F` (company +ID `0x0F06`) with a scanner, or just run the example with debug on. + +> **Validate the key offline first (no hardware):** capture a `raw:060f…` line +> from the AdvMonitor debug output and run +> `node tools/decode.mjs ''`. It reads the key from the CSV, AES-CTR +> decrypts the packet, and prints the decoded fields with sanity checks — so you +> know the key works before relying on it. + +> **First check the unit actually broadcasts telemetry.** Run AdvMonitor with +> debug on and look at your Bluetti's line: +> - `… mfg:060f… <- BLUETTI TELEMETRY (0x0F06)` → telemetry is being broadcast; +> `BluettiADV` decodes it. +> - `… mfg:424c… <- Bluetti nameplate only, NO telemetry` → the unit advertises a +> static "BLUETT" nameplate (company id `0x4C42`) but **no monitoring payload**. +> Observed on EL300 / EL100 V2: the BLE-ADV broadcast feature isn't enabled on +> that firmware. Look for a "Bluetooth broadcast / open data" toggle in the app; +> if there's none, use the **Cloud Open API** instead. (On these units the +> exported AES key is the GATT secure-channel key, not an advertisement key.) ```cpp #include @@ -254,8 +282,8 @@ void setup() { bluetti.begin(5); bluetti.setDebug(true); bluetti.setCallback(onAdv); - bluetti.addDevice("My Elite", "AA:BB:CC:DD:EE:FF", - "112233445566778899aabbccddeeff00"); + bluetti.addDevice("My Elite", "" /* empty MAC = match first broadcaster */, + "53afcd9aef4ff020f472686c00283ce5"); } void loop() { bluetti.loop(); } diff --git a/examples/AdvMonitor/src/main.cpp b/examples/AdvMonitor/src/main.cpp index f9aa265..5fd837f 100644 --- a/examples/AdvMonitor/src/main.cpp +++ b/examples/AdvMonitor/src/main.cpp @@ -80,9 +80,11 @@ void setup() { 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"); + // AES key = line 3 of the BLUETTI key-export CSV (32 hex chars / 16 bytes). + // MAC left empty ("") so it matches the first Bluetti broadcaster it sees — + // set a real MAC here if more than one Bluetti is in range. + bluetti.addDevice("My Elite", "", + "53afcd9aef4ff020f472686c00283ce5"); Serial.printf("Configured %d device(s). Scanning advertisements...\n", (int)bluetti.getDeviceCount()); diff --git a/src/BluettiADV.cpp b/src/BluettiADV.cpp index defc4d7..36e2d74 100644 --- a/src/BluettiADV.cpp +++ b/src/BluettiADV.cpp @@ -93,11 +93,15 @@ bool BluettiADV::begin(uint32_t scanDur) { 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; + // MAC is optional: an empty MAC is a wildcard that matches (and then latches + // onto) the first Bluetti broadcaster seen — convenient for a single device. + char normMac[BLUETTI_ADV_MAC_LEN] = {0}; + bool hasMac = (mac && strlen(mac) > 0); + if (hasMac) { + normalizeMAC(mac, normMac); + if (findDevice(normMac)) return false; + } DeviceEntry* e = &devices[deviceCount]; memset(e, 0, sizeof(DeviceEntry)); @@ -150,6 +154,12 @@ void BluettiADV::processDevice(BLEAdvertisedDevice& adv) { 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]); + uint16_t cid = r.length() >= 2 ? ((uint8_t)r[0] | ((uint16_t)(uint8_t)r[1] << 8)) : 0; + if (cid == BLUETTI_COMPANY_ID) + Serial.print(" <- BLUETTI TELEMETRY (0x0F06) — decoding"); + else if (cid == BLUETTI_NAMEPLATE_ID) + Serial.print(" <- Bluetti nameplate only, NO telemetry " + "(enable Bluetooth broadcast in the app, or use the cloud API)"); } else { Serial.print("(none)"); } @@ -170,6 +180,19 @@ void BluettiADV::processDevice(BLEAdvertisedDevice& adv) { char normMac[BLUETTI_ADV_MAC_LEN]; normalizeMAC(adv.getAddress().toString().c_str(), normMac); DeviceEntry* e = findDevice(normMac); + if (!e) { + // No exact MAC match — adopt a wildcard entry (empty MAC) and latch it to + // this broadcaster so subsequent packets match by MAC normally. + for (size_t i = 0; i < deviceCount; i++) { + if (devices[i].active && devices[i].device.mac[0] == '\0') { + e = &devices[i]; + memcpy(e->device.mac, normMac, BLUETTI_ADV_MAC_LEN); + if (debugEnabled) Serial.printf("[BluettiADV] latched %s onto wildcard device '%s'\n", + normMac, e->device.name); + break; + } + } + } 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(): diff --git a/src/BluettiADV.h b/src/BluettiADV.h index e544492..fa931b0 100644 --- a/src/BluettiADV.h +++ b/src/BluettiADV.h @@ -27,7 +27,10 @@ #include "mbedtls/aes.h" // --- Constants --- -static constexpr uint16_t BLUETTI_COMPANY_ID = 0x0F06; // Poweroak / Bluetti +static constexpr uint16_t BLUETTI_COMPANY_ID = 0x0F06; // Poweroak: encrypted telemetry +// Newer Elite/V2 units advertise a static "BLUETT" nameplate (company id 0x4C42) +// instead of the 0x0F06 telemetry broadcast — i.e. no monitoring data over BLE ADV. +static constexpr uint16_t BLUETTI_NAMEPLATE_ID = 0x4C42; 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; diff --git a/tools/decode.mjs b/tools/decode.mjs new file mode 100644 index 0000000..42f3840 --- /dev/null +++ b/tools/decode.mjs @@ -0,0 +1,158 @@ +// decode.mjs — offline validator for Bluetti BLE-ADV (advertisement) packets. +// +// Mirrors src/BluettiADV.cpp (AES-128-CTR, IV = 2-byte LE nonce + 14 zero bytes) +// and its record parsers, so you can confirm the AES key + nonce construction + +// field offsets WITHOUT an ESP32. Node 18+, no npm install. +// +// node tools/decode.mjs '' +// node tools/decode.mjs --key <32hex> 060f.... # explicit key +// node tools/decode.mjs --csv path/to/key.csv 060f.... # key from a CSV line 3 +// +// The key defaults to line 3 of the first *.csv in the repo root. + +import { createDecipheriv } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +// ---- args ---- +const args = process.argv.slice(2); +let key = null, csvPath = null; +const packets = []; +for (let i = 0; i < args.length; i++) { + if (args[i] === '--key') key = args[++i]; + else if (args[i] === '--csv') csvPath = args[++i]; + else packets.push(args[i]); +} + +// ---- locate key ---- +function keyFromCsv(path) { + const lines = readFileSync(path, 'utf8').split(/\r?\n/).map(s => s.trim()).filter(Boolean); + const hex = lines.find(l => /^[0-9a-fA-F]{32}$/.test(l)); // 16-byte AES key + if (!hex) throw new Error(`no 32-hex key line found in ${path}`); + return hex; +} +if (!key) { + try { + if (!csvPath) { + const csv = readdirSync(repoRoot).find(f => f.endsWith('.csv')); + if (csv) csvPath = join(repoRoot, csv); + } + if (csvPath) { key = keyFromCsv(csvPath); console.error(`key: ${key} (from ${csvPath})`); } + } catch (e) { console.error(e.message); } +} +if (!key || !/^[0-9a-fA-F]{32}$/.test(key)) { + console.error('Need a 16-byte AES key (32 hex). Use --key or a CSV with the key on its own line.'); + process.exit(1); +} +const keyBuf = Buffer.from(key, 'hex'); + +if (!packets.length) { + console.error('\nPaste an advertisement: node tools/decode.mjs 060f0080....'); + console.error('(Get it from the AdvMonitor debug "raw:" line; the company id is 060f.)'); + process.exit(1); +} + +// ---- helpers (LSB-first bit reader, matches readBits() in BluettiADV.cpp) ---- +const bits = (buf, start, n) => { + let v = 0n; + for (let i = 0; i < n; i++) { + const bit = start + i; + if ((bit >> 3) >= buf.length) break; + const b = (buf[bit >> 3] >> (bit & 7)) & 1; + v |= BigInt(b) << BigInt(i); + } + return Number(v); +}; +const sext = (v, n) => (v & (1 << (n - 1))) ? v - (1 << n) : v; +const u = (raw) => raw.replace(/^raw:/i, '').replace(/0x/gi, '').replace(/[^0-9a-fA-F]/g, ''); + +// AES-128-CTR with IV = nonce_lo, nonce_hi, then 14 zero bytes. +function ctrDecrypt(cipher, nonce) { + const iv = Buffer.alloc(16); + iv[0] = nonce & 0xff; + iv[1] = (nonce >> 8) & 0xff; + const d = createDecipheriv('aes-128-ctr', keyBuf, iv); + return Buffer.concat([d.update(cipher), d.final()]); +} + +const RECORD = { 0x02: 'Battery', 0x0b: 'Inverter', 0x80: 'Monitoring', 0x81: 'Config' }; + +function parse(rt, d) { + const out = {}, warn = []; + if (rt === 0x80) { // Monitoring + out.soc = bits(d, 0, 8); + out.estTimeMin = bits(d, 8, 16); + out.eventLine = '0x' + bits(d, 24, 16).toString(16); + out.inputPowerW = bits(d, 40, 16); + out.outputPowerW = bits(d, 56, 16); + out.alarm = !!bits(d, 72, 1); + out.batteryState = ['idle', 'charging', 'discharging', '?'][bits(d, 74, 2)]; + out.stormStatus = bits(d, 76, 2); + if (out.soc > 100 && out.soc !== 0xff) warn.push(`SoC=${out.soc} out of range`); + if (out.inputPowerW > 65000 && out.inputPowerW !== 0xffff) warn.push('inputPower implausible'); + } else if (rt === 0x02) { // Battery + out.dischargeEmptyMin = bits(d, 0, 16); + const v = bits(d, 16, 16); out.totalVoltageV = v === 0x7fff ? null : sext(v, 16) / 10; + out.alarmCode = '0x' + bits(d, 32, 16).toString(16); + const tk = bits(d, 48, 16); out.avgTempC = tk === 0xffff ? null : +(tk * 0.01 - 273.15).toFixed(2); + const c = bits(d, 68, 22); out.totalCurrentA = c === 0x3fffff ? null : sext(c, 22) / 1000; + out.dischargeEnergyAh = bits(d, 90, 20); + const s = bits(d, 110, 10); out.soc = s === 0x3ff ? null : s / 10; + if (out.soc != null && (out.soc < 0 || out.soc > 100)) warn.push(`SoC=${out.soc} out of range`); + } else if (rt === 0x0b) { // Inverter + out.alarmCode = '0x' + bits(d, 0, 16).toString(16); + const c = bits(d, 16, 16); out.batteryCurrentA = c === 0x7fff ? null : sext(c, 16) / 10; + const v = bits(d, 32, 14); out.batteryVoltageV = v === 0x3fff ? null : v / 10; + out.activeAcPortIndex = bits(d, 46, 2); + out.activeAcPortPowerW = sext(bits(d, 48, 16), 16); + out.acOutputPowerW = sext(bits(d, 64, 16), 16); + out.pvPowerW = bits(d, 80, 16); + const y = bits(d, 96, 16); out.yieldTodayKwh = y === 0xffff ? null : y / 100; + } else if (rt === 0x81) { // Config + out.timestamp = bits(d, 0, 32); + out.inverterMode = bits(d, 32, 8); + out.powerOutages = bits(d, 72, 8); + out.screenSleep = bits(d, 80, 4); + out.temperatureUnit = bits(d, 84, 2); + out.acEcoMode = bits(d, 88, 2); + out.dcEcoMode = bits(d, 90, 2); + out.chargingMode = bits(d, 92, 3); + out.powerLifting = !!bits(d, 95, 1); + out.outputMemory = !!bits(d, 96, 1); + } + return { out, warn }; +} + +for (const raw of packets) { + const hex = u(raw); + console.log('\n──────────────────────────────────────────'); + const buf = Buffer.from(hex, 'hex'); + if (buf.length < 11) { console.log(`too short (${buf.length} bytes)`); continue; } + const company = buf[0] | (buf[1] << 8); + if (company !== 0x0f06) { + console.log(`company id 0x${company.toString(16)} (expected 0x0f06) — is this Bluetti manufacturer data?`); + continue; + } + const prodAdv = buf[2] | (buf[3] << 8); + const modelId = buf[4] | (buf[5] << 8); + const rt = buf[6]; + const nonce = buf[7] | (buf[8] << 8); + const keyByte0 = buf[9]; + const cipher = buf.subarray(10); + + console.log(`model=0x${modelId.toString(16)} connected=${(prodAdv & 0x8000) ? 1 : 0} ` + + `record=0x${rt.toString(16)}(${RECORD[rt] || '?'}) nonce=0x${nonce.toString(16).padStart(4, '0')}`); + const keyOk = keyByte0 === keyBuf[0]; + console.log(`key-byte-0: adv=0x${keyByte0.toString(16)} key[0]=0x${keyBuf[0].toString(16)} ` + + `${keyOk ? 'MATCH ✓' : 'MISMATCH ✗ (wrong key?)'}`); + + const plain = ctrDecrypt(cipher, nonce); + console.log(`plaintext: ${plain.toString('hex')}`); + const { out, warn } = parse(rt, plain); + console.log(out); + if (warn.length) console.log('⚠ ', warn.join('; '), '\n → values look wrong: key may be for the GATT channel, or nonce/offsets need adjusting.'); + else if (RECORD[rt]) console.log('✓ values in plausible ranges — key + parser look correct.'); +}