Work more on Bluetti
This commit is contained in:
@@ -35,7 +35,7 @@ three; which one you use depends on your model's generation.
|
|||||||
| # | Channel | Where | Status | Use for |
|
| # | 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** |
|
| 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 |
|
| 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
|
### 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
|
links; per-model field maps are ported from the reference project and want a
|
||||||
final on-hardware sanity check. See [Quick start](#quick-start).
|
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
|
Passively decodes the encrypted monitoring broadcast that **newer** units emit
|
||||||
(Elite / V2 / EP600 / AC180 / AC200L …). Read-only, no connection. The decoder is
|
(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
|
implemented (AES-128-CTR + the official BLE-ADV field layout) and builds. The
|
||||||
needs the device's **16-byte AES key**, which Bluetti has not yet exposed in the
|
device's **16-byte AES key** is now available (exported from the app — see
|
||||||
app for these models. Ready to run the moment a key is available. See
|
[Advertisement mode](#advertisement-mode-bluettiadv)); the AdvMonitor example is
|
||||||
[Advertisement mode](#advertisement-mode-bluettiadv).
|
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
|
### 3. Cloud Open API — HTTPS ⏳ Ready, pending account approval
|
||||||
For encrypted models (incl. the **EL300**) whose local channels are locked, the
|
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
|
advertisements — no connection, no pairing, multiple listeners, low power. This is
|
||||||
the same scheme Victron uses. `BluettiADV` reads it.
|
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).
|
bluetti
|
||||||
2. The device's **BLE MAC address** — a scanner app shows it; confirm the unit
|
1780650490894 # timestamp / PIN (unused)
|
||||||
broadcasts manufacturer data starting `06 0F` (company ID `0x0F06`).
|
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 '<paste it>'`. 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
|
```cpp
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
@@ -254,8 +282,8 @@ void setup() {
|
|||||||
bluetti.begin(5);
|
bluetti.begin(5);
|
||||||
bluetti.setDebug(true);
|
bluetti.setDebug(true);
|
||||||
bluetti.setCallback(onAdv);
|
bluetti.setCallback(onAdv);
|
||||||
bluetti.addDevice("My Elite", "AA:BB:CC:DD:EE:FF",
|
bluetti.addDevice("My Elite", "" /* empty MAC = match first broadcaster */,
|
||||||
"112233445566778899aabbccddeeff00");
|
"53afcd9aef4ff020f472686c00283ce5");
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() { bluetti.loop(); }
|
void loop() { bluetti.loop(); }
|
||||||
|
|||||||
@@ -80,9 +80,11 @@ void setup() {
|
|||||||
bluetti.setDebug(true); // prints discovered devices + decrypted payloads
|
bluetti.setDebug(true); // prints discovered devices + decrypted payloads
|
||||||
bluetti.setCallback(onBluettiAdv);
|
bluetti.setCallback(onBluettiAdv);
|
||||||
|
|
||||||
// CHANGE THESE: your device's BLE MAC and the 16-byte AES key (32 hex chars).
|
// AES key = line 3 of the BLUETTI key-export CSV (32 hex chars / 16 bytes).
|
||||||
bluetti.addDevice("My Elite", "AA:BB:CC:DD:EE:FF",
|
// MAC left empty ("") so it matches the first Bluetti broadcaster it sees —
|
||||||
"112233445566778899aabbccddeeff00");
|
// 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",
|
Serial.printf("Configured %d device(s). Scanning advertisements...\n",
|
||||||
(int)bluetti.getDeviceCount());
|
(int)bluetti.getDeviceCount());
|
||||||
|
|||||||
+25
-2
@@ -93,11 +93,15 @@ bool BluettiADV::begin(uint32_t scanDur) {
|
|||||||
bool BluettiADV::addDevice(const char* name, const char* mac, const char* hexKey) {
|
bool BluettiADV::addDevice(const char* name, const char* mac, const char* hexKey) {
|
||||||
if (deviceCount >= BLUETTI_ADV_MAX_DEVICES) return false;
|
if (deviceCount >= BLUETTI_ADV_MAX_DEVICES) return false;
|
||||||
if (!hexKey || strlen(hexKey) != 32) return false;
|
if (!hexKey || strlen(hexKey) != 32) return false;
|
||||||
if (!mac || strlen(mac) == 0) return false;
|
|
||||||
|
|
||||||
char normMac[BLUETTI_ADV_MAC_LEN];
|
// 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);
|
normalizeMAC(mac, normMac);
|
||||||
if (findDevice(normMac)) return false;
|
if (findDevice(normMac)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
DeviceEntry* e = &devices[deviceCount];
|
DeviceEntry* e = &devices[deviceCount];
|
||||||
memset(e, 0, sizeof(DeviceEntry));
|
memset(e, 0, sizeof(DeviceEntry));
|
||||||
@@ -150,6 +154,12 @@ void BluettiADV::processDevice(BLEAdvertisedDevice& adv) {
|
|||||||
auto m = adv.getManufacturerData();
|
auto m = adv.getManufacturerData();
|
||||||
std::string r(m.c_str(), m.length());
|
std::string r(m.c_str(), m.length());
|
||||||
for (size_t i = 0; i < r.length(); i++) Serial.printf("%02x", (uint8_t)r[i]);
|
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 {
|
} else {
|
||||||
Serial.print("(none)");
|
Serial.print("(none)");
|
||||||
}
|
}
|
||||||
@@ -170,6 +180,19 @@ void BluettiADV::processDevice(BLEAdvertisedDevice& adv) {
|
|||||||
char normMac[BLUETTI_ADV_MAC_LEN];
|
char normMac[BLUETTI_ADV_MAC_LEN];
|
||||||
normalizeMAC(adv.getAddress().toString().c_str(), normMac);
|
normalizeMAC(adv.getAddress().toString().c_str(), normMac);
|
||||||
DeviceEntry* e = findDevice(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) {
|
if (!e) {
|
||||||
// A Bluetti device that broadcasts but isn't registered. In debug mode,
|
// A Bluetti device that broadcasts but isn't registered. In debug mode,
|
||||||
// dump enough to confirm the broadcast exists and to set up addDevice():
|
// dump enough to confirm the broadcast exists and to set up addDevice():
|
||||||
|
|||||||
+4
-1
@@ -27,7 +27,10 @@
|
|||||||
#include "mbedtls/aes.h"
|
#include "mbedtls/aes.h"
|
||||||
|
|
||||||
// --- Constants ---
|
// --- 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_MAX_DEVICES = 4;
|
||||||
static constexpr int BLUETTI_ADV_MAC_LEN = 13; // 12 hex chars + null
|
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_NAME_LEN = 32;
|
||||||
|
|||||||
@@ -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 '<paste a raw:060f... line from AdvMonitor debug>'
|
||||||
|
// 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.');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user