Work more on Bluetti
This commit is contained in:
@@ -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