BluettiBLE
ESP32 library for reading and controlling Bluetti power stations over Bluetooth Low Energy (BLE).
It is a companion to 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 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_DEVICESregistered 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.
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); 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. 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
BluettiBLEconnects 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
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
#include <Arduino.h>
#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
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:
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:
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:
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
- Scan — active BLE scan for a device advertising the Bluetti service UUID
0000ff00-…whose advertised name matches a registered device. - Connect — open a GATT client, negotiate MTU 517, resolve the write
(
0000ff02-…) and notify (0000ff01-…) characteristics, subscribe to notify. - 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. - 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 runnode 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;BluettiADVdecodes it.… mfg:424c… <- Bluetti nameplate only, NO telemetry→ the unit advertises a static "BLUETT" nameplate (company id0x4C42) 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.)
#include <Arduino.h>
#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 flagsBLUETTI_ADV_BATTERY(0x02) — pack voltage, current, temperature, SoCBLUETTI_ADV_INVERTER(0x0B) — battery V/A, AC output, PV power, yieldBLUETTI_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
BluettiADVadvertisements.
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.