/** * 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 #include #include #include #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