204 lines
7.7 KiB
C++
204 lines
7.7 KiB
C++
/**
|
|
* BluettiADV - ESP32 passive BLE-advertisement reader for Bluetti devices
|
|
*
|
|
* The newer Bluetti generation (Elite / V2 / EP600 etc.) broadcasts encrypted
|
|
* monitoring data in its BLE *advertisements* — no GATT connection, no pairing,
|
|
* no proprietary handshake. The payload is AES-128-CTR encrypted with a 16-byte
|
|
* key the owner copies from the BLUETTI mobile app (or the device Webserver).
|
|
*
|
|
* This is the same scheme VictronBLE uses, so BluettiADV mirrors that design:
|
|
* passive scan, per-device AES key, a single function-pointer callback, a
|
|
* non-blocking loop(). It is READ-ONLY (monitoring); output control still
|
|
* requires the closed GATT channel and is not provided here.
|
|
*
|
|
* Reference: official Bluetti API docs, "BLE ADV" section (V1.0, 2025-07-10).
|
|
*
|
|
* Copyright (c) 2026 Scott Penrose
|
|
* License: MIT
|
|
*/
|
|
|
|
#ifndef BLUETTI_ADV_H
|
|
#define BLUETTI_ADV_H
|
|
|
|
#include <Arduino.h>
|
|
#include <BLEDevice.h>
|
|
#include <BLEAdvertisedDevice.h>
|
|
#include <BLEScan.h>
|
|
#include "mbedtls/aes.h"
|
|
|
|
// --- Constants ---
|
|
static constexpr uint16_t BLUETTI_COMPANY_ID = 0x0F06; // Poweroak: encrypted telemetry
|
|
// Newer Elite/V2 units advertise a static "BLUETT" nameplate (company id 0x4C42)
|
|
// instead of the 0x0F06 telemetry broadcast — i.e. no monitoring data over BLE ADV.
|
|
static constexpr uint16_t BLUETTI_NAMEPLATE_ID = 0x4C42;
|
|
static constexpr int BLUETTI_ADV_MAX_DEVICES = 4;
|
|
static constexpr int BLUETTI_ADV_MAC_LEN = 13; // 12 hex chars + null
|
|
static constexpr int BLUETTI_ADV_NAME_LEN = 32;
|
|
static constexpr int BLUETTI_ADV_MAX_PAYLOAD = 20; // encrypted bytes
|
|
|
|
// Advertisement record types (the "Record type" byte in Poweroak data).
|
|
enum BluettiAdvRecordType {
|
|
BLUETTI_ADV_BATTERY = 0x02,
|
|
BLUETTI_ADV_INVERTER = 0x0B,
|
|
BLUETTI_ADV_MONITORING = 0x80, // portable power station - monitoring
|
|
BLUETTI_ADV_CONFIG = 0x81 // portable power station - configuration
|
|
};
|
|
|
|
// ============================================================
|
|
// Parsed record payloads (one per record type). A device cycles
|
|
// through several record types; each carries different fields.
|
|
// Floats are NAN when the device reported the "invalid" sentinel.
|
|
// ============================================================
|
|
|
|
struct BluettiAdvBattery {
|
|
uint16_t dischargeEmptyMin; // minutes (0xFFFF invalid)
|
|
float totalVoltage; // V
|
|
uint16_t alarmCode;
|
|
float avgTemperatureC; // C (converted from 0.01K)
|
|
float totalCurrent; // A (signed)
|
|
uint32_t dischargeEnergyAh; // Ah
|
|
float soc; // %
|
|
};
|
|
|
|
struct BluettiAdvInverter {
|
|
uint16_t alarmCode;
|
|
float batteryCurrent; // A (signed)
|
|
float batteryVoltage; // V
|
|
uint8_t activeAcPortIndex;
|
|
int16_t activeAcPortPower; // W (signed)
|
|
int16_t acOutputPower; // W (signed)
|
|
uint16_t pvPower; // W
|
|
float yieldToday; // kWh
|
|
};
|
|
|
|
struct BluettiAdvMonitoring {
|
|
uint8_t soc; // %
|
|
uint16_t estimatedTimeMin; // minutes (charge/discharge)
|
|
uint16_t eventLine; // bitfield, see BluettiAdvEvent
|
|
uint16_t inputPower; // W
|
|
uint16_t outputPower; // W
|
|
bool alarm;
|
|
uint8_t batteryChargeStatus; // 0 idle, 1 charging, 2 discharging
|
|
uint8_t stormStatus; // 0/2 invalid, 1 enabled
|
|
};
|
|
|
|
struct BluettiAdvConfig {
|
|
uint32_t timestamp;
|
|
uint8_t inverterMode; // see docs (0 default … 9 charge-discharge)
|
|
uint32_t moneySavingParams;
|
|
uint8_t powerOutages;
|
|
uint8_t screenSleep; // 1:15s 2:30s 3:1min 4:5min 5:always
|
|
uint8_t temperatureUnit; // 1:C 2:F
|
|
uint8_t acEcoMode; // 0 off, 1 normal, 2 deep
|
|
uint8_t dcEcoMode;
|
|
uint8_t chargingMode; // 0 standard … 4 user-defined
|
|
bool powerLifting;
|
|
bool outputMemory;
|
|
};
|
|
|
|
// EventLine bit meanings (BluettiAdvMonitoring.eventLine)
|
|
enum BluettiAdvEvent {
|
|
BLUETTI_EVT_PV_TO_BATTERY = 1 << 0,
|
|
BLUETTI_EVT_GRID_TO_BATTERY = 1 << 1,
|
|
BLUETTI_EVT_BATTERY_TO_GRID = 1 << 2,
|
|
BLUETTI_EVT_AC_LOAD = 1 << 3,
|
|
BLUETTI_EVT_DC_LOAD = 1 << 4,
|
|
BLUETTI_EVT_BATTERY_TO_INVERT = 1 << 5,
|
|
BLUETTI_EVT_INVERT_TO_BATTERY = 1 << 6,
|
|
BLUETTI_EVT_GRID_TO_AC_LOAD = 1 << 7,
|
|
BLUETTI_EVT_PV_ICON = 1 << 8,
|
|
BLUETTI_EVT_GRID_ICON = 1 << 9,
|
|
BLUETTI_EVT_LOAD_ICON = 1 << 10,
|
|
BLUETTI_EVT_PV_TO_GRID = 1 << 11,
|
|
BLUETTI_EVT_PV_TO_AC_LOAD = 1 << 12,
|
|
BLUETTI_EVT_BAT_TO_AC_LOAD = 1 << 13,
|
|
BLUETTI_EVT_GRID_POWER_NEGATIVE= 1 << 14 // 0 = export(+), 1 = import(-)
|
|
};
|
|
|
|
// ============================================================
|
|
// Device descriptor passed to the callback. All record sub-structs
|
|
// are retained so the consumer can read the latest of each; the
|
|
// lastRecordType field says which one this callback just refreshed.
|
|
// ============================================================
|
|
struct BluettiAdvDevice {
|
|
char name[BLUETTI_ADV_NAME_LEN];
|
|
char mac[BLUETTI_ADV_MAC_LEN];
|
|
int8_t rssi;
|
|
uint32_t lastUpdate;
|
|
bool dataValid;
|
|
bool connectedFlag; // bit 15 of Product Advertisement type
|
|
uint16_t modelId; // Device Model Identification
|
|
uint8_t lastRecordType; // record type updated by this callback
|
|
|
|
BluettiAdvBattery battery;
|
|
BluettiAdvInverter inverter;
|
|
BluettiAdvMonitoring monitoring;
|
|
BluettiAdvConfig config;
|
|
};
|
|
|
|
typedef void (*BluettiAdvCallback)(const BluettiAdvDevice* device);
|
|
|
|
class BluettiADVScanCallbacks;
|
|
|
|
// ============================================================
|
|
// Main BluettiADV class — passive advertisement reader
|
|
// ============================================================
|
|
class BluettiADV {
|
|
public:
|
|
BluettiADV();
|
|
|
|
bool begin(uint32_t scanDuration = 5);
|
|
// hexKey: 32 hex chars (16-byte AES key) from the BLUETTI app / Webserver.
|
|
bool addDevice(const char* name, const char* mac, const char* hexKey);
|
|
void setCallback(BluettiAdvCallback cb) { callback = cb; }
|
|
void setDebug(bool enable) { debugEnabled = enable; }
|
|
void setMinInterval(uint32_t ms) { minIntervalMs = ms; }
|
|
size_t getDeviceCount() const { return deviceCount; }
|
|
void loop();
|
|
|
|
private:
|
|
friend class BluettiADVScanCallbacks;
|
|
|
|
struct DeviceEntry {
|
|
BluettiAdvDevice device;
|
|
uint8_t key[16];
|
|
uint16_t lastNonce;
|
|
bool active;
|
|
bool haveNonce;
|
|
};
|
|
|
|
DeviceEntry devices[BLUETTI_ADV_MAX_DEVICES];
|
|
size_t deviceCount;
|
|
BLEScan* pBLEScan;
|
|
BluettiADVScanCallbacks* scanCallbackObj;
|
|
BluettiAdvCallback callback;
|
|
bool debugEnabled;
|
|
uint32_t scanDuration;
|
|
uint32_t minIntervalMs;
|
|
bool initialized;
|
|
|
|
static bool hexToBytes(const char* hex, uint8_t* out, size_t len);
|
|
static void normalizeMAC(const char* input, char* output);
|
|
DeviceEntry* findDevice(const char* normalizedMAC);
|
|
|
|
void processDevice(BLEAdvertisedDevice& dev);
|
|
bool decrypt(const uint8_t* in, size_t len, const uint8_t* key,
|
|
uint16_t nonce, uint8_t* out);
|
|
|
|
void parseBattery(const uint8_t* d, size_t len, BluettiAdvBattery& r);
|
|
void parseInverter(const uint8_t* d, size_t len, BluettiAdvInverter& r);
|
|
void parseMonitoring(const uint8_t* d, size_t len, BluettiAdvMonitoring& r);
|
|
void parseConfig(const uint8_t* d, size_t len, BluettiAdvConfig& r);
|
|
};
|
|
|
|
// BLE scan callback (required by the ESP32 BLE API).
|
|
class BluettiADVScanCallbacks : public BLEAdvertisedDeviceCallbacks {
|
|
public:
|
|
BluettiADVScanCallbacks(BluettiADV* parent) : owner(parent) {}
|
|
void onResult(BLEAdvertisedDevice advertisedDevice) override;
|
|
private:
|
|
BluettiADV* owner;
|
|
};
|
|
|
|
#endif // BLUETTI_ADV_H
|