diff --git a/cloud/README.md b/cloud/README.md new file mode 100644 index 0000000..a3a28e2 --- /dev/null +++ b/cloud/README.md @@ -0,0 +1,60 @@ +# Bluetti Cloud (Open Platform) client + +A tiny Node.js client for reading device telemetry from Bluetti's **cloud** Open +Platform API. Use this for the **newer encrypted models** (Elite 300 / EL300, V2, +EP600, AC180, AC200L …) where the local BLE channel is locked — the cloud path +works today and needs no AES key. + +> This is *not* the ESP32 library — it's a separate, optional helper that talks to +> Bluetti's cloud over HTTPS. For older/plaintext models read locally with +> `BluettiBLE`; `BluettiADV` will cover newer models locally once Bluetti exposes +> the broadcast AES key. + +Implemented from the official spec at +(`basePath: https://open.bluetti.com/open`). + +## Auth: signed requests (not OAuth) + +Each request carries four headers; the **AppSecret is hashed in, never sent**: + +``` +Authorization = SHA-256( "appKey=&appSecret=&nonceStr=&timeStamp=" ) +x-app-key: +ETag: # random hex, new per request +Date: # must match the timeStamp used in the hash +``` + +## Setup + +1. Register at , create an application to get + an **AppKey** and **AppSecret**, and **apply for the IoT/telemetry API + permission**. Your device SN must also be authorized to the account, or the + telemetry endpoints return a permission error even with a valid signature. +2. Run it (Node 18+, no `npm install`): + + ```bash + BLUETTI_APPKEY=xxx BLUETTI_APPSECRET=yyy BLUETTI_SN=EL3002546110146262 node bluetti.mjs + ``` + +## Endpoints used + +| Purpose | Path (POST) | Body | +|---|---|---| +| List devices | `/open/bluiotdata/device/telemetry/v1/userTelemetryDeviceList` | `{pageNo, pageSize}` | +| Reported data | `/open/bluiotdata/device/telemetry/v1/telemetryDeviceReportedData` | `{deviceSn, beginTimestamp, endTimestamp, pageNo, pageSize}` (ms) | +| Control (set) | `/open/bluiotdata/device/telemetry/v1/telemetryDeviceSetUp` | `{deviceSn, functionCode, setValue}` | + +Responses use the `UnifyResponse` envelope — `{ code, msgCode, message, data, ... }`, +where `msgCode == 0` means success. `data` for reported data is a paginated list +(`content[]`) of `key→value` telemetry maps. + +## When it works / doesn't + +- **403 / permission error** → your app lacks the telemetry permission, or the SN + isn't authorized to your account. Apply on the developer portal. +- **Signature failure** → check that the `Date` header equals the `timeStamp` + string used inside the SHA‑256 input, and that you hashed the literal + `appKey=…&appSecret=…&nonceStr=…&timeStamp=…` string. + +Once you can see a sample telemetry response, paste it back and the script can be +tightened into a poller that pulls out SoC/power and logs on change. diff --git a/cloud/bluetti.mjs b/cloud/bluetti.mjs new file mode 100644 index 0000000..bbd7503 --- /dev/null +++ b/cloud/bluetti.mjs @@ -0,0 +1,76 @@ +// bluetti.mjs — Bluetti Open Platform (cloud) client, signed per the official spec. +// +// Node 18+ only (built-in fetch + node:crypto; no npm install): +// BLUETTI_APPKEY=xxx BLUETTI_APPSECRET=yyy BLUETTI_SN=EL300... node bluetti.mjs +// +// Auth (from open.bluetti.com OpenAPI "Signature" section): +// Authorization = SHA-256( "appKey=&appSecret=&nonceStr=&timeStamp=" ) +// sent with headers: x-app-key=, ETag=, Date=. +// The AppSecret is hashed in, never transmitted. +// +// Register an app + request the IoT/telemetry API permission at +// https://open.bluetti.com/developers — and the device SN must be authorized to +// your account, or the telemetry endpoints return a permission error. + +import { createHash, randomBytes } from 'node:crypto'; + +const BASE = process.env.BLUETTI_BASE || 'https://open.bluetti.com'; +const APP_KEY = process.env.BLUETTI_APPKEY || process.env.BLUETTI_ID; +const APP_SECRET = process.env.BLUETTI_APPSECRET || process.env.BLUETTI_SECRET; +const DEVICE_SN = process.env.BLUETTI_SN || 'EL3002546110146262'; + +if (!APP_KEY || !APP_SECRET) { + console.error('Set BLUETTI_APPKEY and BLUETTI_APPSECRET (from open.bluetti.com).'); + process.exit(1); +} + +function authHeaders() { + const timeStamp = String(Math.floor(Date.now() / 1000)); + const nonceStr = randomBytes(16).toString('hex'); + const signStr = `appKey=${APP_KEY}&appSecret=${APP_SECRET}&nonceStr=${nonceStr}&timeStamp=${timeStamp}`; + const signature = createHash('sha256').update(signStr).digest('hex').toUpperCase(); + return { + 'x-app-key': APP_KEY, + 'ETag': nonceStr, + 'Date': timeStamp, + 'Authorization': signature, + 'Content-Type': 'application/json', + 'Accept-Language': 'en_US', + }; +} + +// POST a JSON body to an /open/... path; returns the UnifyResponse `data`. +async function call(path, body = {}) { + const r = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify(body), + }); + const text = await r.text(); + let j; + try { j = JSON.parse(text); } catch { throw new Error(`HTTP ${r.status}: ${text}`); } + // UnifyResponse: msgCode 0 == success + if (j.msgCode !== 0 && j.code !== 200) { + throw new Error(`API error msgCode=${j.msgCode} code=${j.code}: ${j.message || text}`); + } + return j.data; +} + +// List the telemetry devices bound to this account. +function listDevices(pageNo = 1, pageSize = 20) { + return call('/open/bluiotdata/device/telemetry/v1/userTelemetryDeviceList', { pageNo, pageSize }); +} + +// Reported (telemetry) data for one device over a time window (timestamps in ms). +function reportedData(deviceSn, beginTimestamp, endTimestamp, pageNo = 1, pageSize = 20) { + return call('/open/bluiotdata/device/telemetry/v1/telemetryDeviceReportedData', + { deviceSn, beginTimestamp, endTimestamp, pageNo, pageSize }); +} + +const devices = await listDevices(); +console.log('Devices:', JSON.stringify(devices, null, 2)); + +const now = Date.now(); +const data = await reportedData(DEVICE_SN, now - 10 * 60 * 1000, now); +console.log(`\nReported data for ${DEVICE_SN}:`); +console.log(JSON.stringify(data, null, 2));