# BluettiBLE ESP32 library for reading and controlling **Bluetti** power stations over Bluetooth Low Energy (BLE). It is a companion to [VictronBLE](https://gitea.sh3d.com.au/Sh3d/VictronBLE) and shares its interface philosophy — a single global object, device registration, one callback, a non-blocking `loop()`, and a flat parsed data struct. The key difference is the transport: Victron broadcasts data in BLE *advertisements* (passive, connectionless), whereas Bluetti requires an active **GATT connection** and a polled, Modbus-style request/response protocol. BluettiBLE therefore runs a `scan → connect → poll → parse` state machine under the hood. The protocol is ported from the excellent [Bluetti_ESP32_Bridge](https://github.com/) project. ## Features - Connects to a Bluetti power station by its BLE advertised name — **no pairing or key required**. - Polls the device on a configurable interval and delivers a parsed snapshot through a single callback. - Reads state of charge, AC/DC input & output power, voltages, frequencies, battery pack and per-cell voltages, model and serial number. - Optional control: toggle AC and DC output. - Supports up to `BLUETTI_MAX_DEVICES` registered devices, connecting to one at a time in round-robin. - Zero external dependencies — uses the stock ESP32 Arduino BLE stack. ## Three ways to read a Bluetti — and their status Bluetti data is reachable over three different channels. This project covers all 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.*` | 🔑 **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 Connects over BLE GATT and polls the plaintext Modbus-style protocol. No pairing or key. Full register read plus AC/DC output control. **Works on the older / plaintext generation** (AC300, AC200M, EB3A, EP500P, …). The library compiles and 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 🔑 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. 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 official cloud Open Platform works today — full read and control, no AES key. The signed client is implemented in [`cloud/bluetti.mjs`](cloud/README.md). It needs a registered developer app + IoT API permission + the device SN authorized to your account; **app approval is currently pending** (Bluetti review, ~1 day). Not ESP32-native (HTTPS/cloud). > **Quick decision:** older Bluetti → use **#1** locally. Newer/Elite (EL300) → > use **#3** (cloud) now, switch to **#2** (local) once the broadcast key ships. > If `BluettiBLE` connects then disconnects within seconds with no data, you have > a newer encrypted unit — that's the #2/#3 path. ## Supported devices | Model | Enum | Status (from reference project) | |----------|------------------|---------------------------------| | AC300 | `BLUETTI_AC300` | Tested | | AC200M | `BLUETTI_AC200M` | Tested (per-cell voltages) | | EB3A | `BLUETTI_EB3A` | Tested | | EP500P | `BLUETTI_EP500P` | Tested (per-cell voltages) | | AC500 | `BLUETTI_AC500` | Minimal / untested | | EP500 | `BLUETTI_EP500` | Untested | | EP600 | `BLUETTI_EP600` | Partial (different register map)| ## Hardware requirements - Any ESP32 (ESP32, ESP32-S3, ESP32-C3 all work) with Bluetooth. ## Installation ### PlatformIO ```ini lib_deps = https://gitea.sh3d.com.au/Sh3d/BluettiBLE.git ``` Or drop the folder into your project's `lib/` directory. ### Arduino IDE Copy this folder into your Arduino `libraries/` directory and restart the IDE. ## Quick start ```cpp #include #include "BluettiBLE.h" BluettiBLE bluetti; void onBluettiData(const BluettiDevice* dev) { const BluettiData& d = dev->data; Serial.printf("\n=== %s (%s) ===\n", dev->name, d.model); Serial.printf("SoC: %u%%\n", d.totalBatteryPercent); Serial.printf("AC out: %u W DC out: %u W\n", d.acOutputPower, d.dcOutputPower); Serial.printf("AC in: %u W DC in: %u W\n", d.acInputPower, d.dcInputPower); Serial.printf("AC output: %s DC output: %s\n", d.acOutputOn ? "ON" : "off", d.dcOutputOn ? "ON" : "off"); Serial.printf("RSSI: %d dBm\n", dev->rssi); } void setup() { Serial.begin(115200); delay(1000); bluetti.begin(); bluetti.setDebug(false); bluetti.setCallback(onBluettiData); // The BLE name is what the unit advertises, e.g. "AC3001234567890". bluetti.addDevice("Shed AC300", "AC3001234567890", BLUETTI_AC300); } void loop() { bluetti.loop(); // non-blocking state machine } ``` ### Finding your device's BLE name Use any BLE scanner app (nRF Connect, LightBlue) and look for a device whose name begins with your model (e.g. `AC300…`, `AC200M…`, `EB3A…`). That full advertised name is the second argument to `addDevice()`. ## API reference ```cpp BluettiBLE(); bool begin(); bool addDevice(const char* name, const char* bleName, BluettiModel model); void setCallback(BluettiCallback cb); void setDebug(bool enable); void setPollInterval(uint32_t ms); // default 3000 bool isConnected() const; size_t getDeviceCount() const; void loop(); // call every loop iteration // Control (acts on the currently connected device) bool setACOutput(bool on); bool setDCOutput(bool on); ``` The callback is a plain function pointer: ```cpp typedef void (*BluettiCallback)(const BluettiDevice* device); ``` It fires once per completed poll cycle for the connected device. ## Data structures `BluettiDevice` describes the device and carries the latest snapshot: ```cpp struct BluettiDevice { char name[32]; // your label char bleName[32]; // advertised name matched on BluettiModel model; int8_t rssi; uint32_t lastUpdate; // millis() of last update bool connected; bool dataValid; BluettiData data; }; ``` `BluettiData` is a flat superset of values across models. Fields a given model does not report stay at `0`: ```cpp struct BluettiData { char model[16]; uint64_t serialNumber; float armVersion, dspVersion; uint8_t totalBatteryPercent; // % uint16_t dcInputPower, acInputPower; // W uint16_t acOutputPower, dcOutputPower; // W float powerGeneration; // kWh total bool acOutputOn, dcOutputOn; float acInputVoltage, acInputFrequency; float internalAcVoltage, internalAcFrequency; float internalDcInputVoltage, internalDcInputCurrent; float packVoltage; uint8_t packNum, packNumMax, packBatteryPercent; float cellVoltage[16]; // V, 0 if unsupported }; ``` ## How it works 1. **Scan** — active BLE scan for a device advertising the Bluetti service UUID `0000ff00-…` whose advertised name matches a registered device. 2. **Connect** — open a GATT client, negotiate MTU 517, resolve the write (`0000ff02-…`) and notify (`0000ff01-…`) characteristics, subscribe to notify. 3. **Poll** — every `pollInterval`, send an 8-byte read command (`prefix, cmd=0x03, page, offset, count, CRC-16/MODBUS`) for the next register range in the model's poll table. 4. **Parse** — notification responses are decoded against the model's register map (big-endian uint16, decimals, version, serial, strings) into a rolling snapshot; a completed cycle fires the callback. 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 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.: ``` 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 #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", "" /* empty MAC = match first broadcaster */, "53afcd9aef4ff020f472686c00283ce5"); } 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 (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 Copy a header in `src/devices/`, port the register/poll/command tables from the Bluetti_ESP32_Bridge project's `Device_*.h`, add a `BluettiModel` enum value, and register it in `getModelTables()` in `BluettiBLE.cpp`. ## License MIT — see [LICENSE](LICENSE).