296 lines
11 KiB
Markdown
296 lines
11 KiB
Markdown
# 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.*` | ⏳ **Ready, blocked on key** | 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 ⏳ Ready, blocked on key
|
|
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, but it
|
|
needs the device's **16-byte AES key**, which Bluetti has not yet exposed in the
|
|
app for these models. Ready to run the moment a key is available. See
|
|
[Advertisement mode](#advertisement-mode-bluettiadv).
|
|
|
|
### 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 <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
|
|
|
|
```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 two things from the owner's side:
|
|
|
|
1. The **16-byte AES key** (32 hex chars) — copy it from the BLUETTI app or the
|
|
device's Webserver (Bluetooth-data / developer section).
|
|
2. The device's **BLE MAC address** — a scanner app shows it; confirm the unit
|
|
broadcasts manufacturer data starting `06 0F` (company ID `0x0F06`).
|
|
|
|
```cpp
|
|
#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", "AA:BB:CC:DD:EE:FF",
|
|
"112233445566778899aabbccddeeff00");
|
|
}
|
|
|
|
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).
|