From 4ea1e12ee987d387a432c129bcee38fc63bd6dc4 Mon Sep 17 00:00:00 2001 From: Scott Penrose Date: Thu, 4 Jun 2026 21:35:07 +1000 Subject: [PATCH] intial test version --- .gitignore | 52 ++++ LICENSE | 21 ++ QUICK_START.md | 92 +++++++ README.md | 199 ++++++++++++++ examples/BasicRead/platformio.ini | 19 ++ examples/BasicRead/src/main.cpp | 76 ++++++ examples/Control/platformio.ini | 19 ++ examples/Control/src/main.cpp | 54 ++++ examples/Logger/platformio.ini | 19 ++ examples/Logger/src/main.cpp | 69 +++++ keywords.txt | 41 +++ library.json | 52 ++++ library.properties | 11 + src/BluettiBLE.cpp | 436 ++++++++++++++++++++++++++++++ src/BluettiBLE.h | 214 +++++++++++++++ src/BluettiCRC.h | 43 +++ src/devices/BluettiFields.h | 115 ++++++++ src/devices/Device_AC200M.h | 60 ++++ src/devices/Device_AC300.h | 61 +++++ src/devices/Device_AC500.h | 36 +++ src/devices/Device_EB3A.h | 41 +++ src/devices/Device_EP500.h | 36 +++ src/devices/Device_EP500P.h | 78 ++++++ src/devices/Device_EP600.h | 33 +++ 24 files changed, 1877 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 QUICK_START.md create mode 100644 README.md create mode 100644 examples/BasicRead/platformio.ini create mode 100644 examples/BasicRead/src/main.cpp create mode 100644 examples/Control/platformio.ini create mode 100644 examples/Control/src/main.cpp create mode 100644 examples/Logger/platformio.ini create mode 100644 examples/Logger/src/main.cpp create mode 100644 keywords.txt create mode 100644 library.json create mode 100644 library.properties create mode 100644 src/BluettiBLE.cpp create mode 100644 src/BluettiBLE.h create mode 100644 src/BluettiCRC.h create mode 100644 src/devices/BluettiFields.h create mode 100644 src/devices/Device_AC200M.h create mode 100644 src/devices/Device_AC300.h create mode 100644 src/devices/Device_AC500.h create mode 100644 src/devices/Device_EB3A.h create mode 100644 src/devices/Device_EP500.h create mode 100644 src/devices/Device_EP500P.h create mode 100644 src/devices/Device_EP600.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40270af --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# PlatformIO +.pio +.pioenvs +.piolibdeps +.clang_complete +.gcc-flags.json +.ccls +compile_commands.json + +# VSCode +.vscode/ +!.vscode/extensions.json +.history/ + +# Build artifacts +*.o +*.a +*.hex +*.bin +*.elf +*.eep +*.lss +*.map +*.sym + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +*~ + +# IDEs +*.swp +*.swo +.idea/ +*.sublime-project +*.sublime-workspace +.atom/ + +# Temporary files +*.tmp +*.bak +*.log + +# Arduino +*.ino.cpp + +*.tar.gz diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed8b463 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Scott Penrose + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..605db36 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,92 @@ +# BluettiBLE — Quick Start + +Get a Bluetti power station reporting to an ESP32 in a few minutes. + +## 1. Find your device's BLE name + +Install a BLE scanner on your phone (nRF Connect or LightBlue) and scan. Your +Bluetti advertises a name that starts with the model, for example: + +- `AC3001234567890` +- `AC200M1234567890` +- `EB3A1234567890` + +Copy the **full** advertised name — that string is how the library finds the unit +(there is no pairing and no encryption key to enter). + +## 2. Install the library + +PlatformIO (`platformio.ini`): + +```ini +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +lib_deps = + https://gitea.sh3d.com.au/Sh3d/BluettiBLE.git +``` + +Or copy this folder into `lib/` (PlatformIO) or `libraries/` (Arduino IDE). + +## 3. Minimal sketch + +```cpp +#include +#include "BluettiBLE.h" + +BluettiBLE bluetti; + +void onData(const BluettiDevice* dev) { + Serial.printf("%s SoC %u%% AC out %u W DC out %u W\n", + dev->data.model, dev->data.totalBatteryPercent, + dev->data.acOutputPower, dev->data.dcOutputPower); +} + +void setup() { + Serial.begin(115200); + bluetti.begin(); + bluetti.setCallback(onData); + bluetti.addDevice("My Bluetti", "AC3001234567890", BLUETTI_AC300); // <- your name + model +} + +void loop() { + bluetti.loop(); +} +``` + +Set the model to match your unit: `BLUETTI_AC300`, `BLUETTI_AC200M`, +`BLUETTI_EB3A`, `BLUETTI_EP500P`, `BLUETTI_AC500`, `BLUETTI_EP500`, +`BLUETTI_EP600`. + +## 4. Upload and watch + +Open the serial monitor at 115200. You should see the library scan, connect, and +then print a line every poll cycle (default every 3 seconds). Turn on debug for +the raw command/connection trace: + +```cpp +bluetti.setDebug(true); +``` + +## 5. Control output (optional) + +Once connected you can toggle the outputs: + +```cpp +if (bluetti.isConnected()) { + bluetti.setACOutput(true); // turn AC output on + bluetti.setDCOutput(false); // turn DC output off +} +``` + +## Troubleshooting + +- **Never connects** — double-check the BLE name is exact (case sensitive), and + that the Bluetti app on your phone is closed (a Bluetti accepts one BLE client + at a time). +- **Connects but no data** — make sure the model enum matches your hardware; the + register map is model-specific. +- **Drops out** — keep `bluetti.loop()` running every iteration; the library + reconnects automatically after a disconnect. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bff26c6 --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# 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. + +## 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. + +## Examples + +- **BasicRead** — connect to one 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). + +## 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). diff --git a/examples/BasicRead/platformio.ini b/examples/BasicRead/platformio.ini new file mode 100644 index 0000000..6dc5b7d --- /dev/null +++ b/examples/BasicRead/platformio.ini @@ -0,0 +1,19 @@ +[env] +lib_extra_dirs = ../.. + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +[env:esp32-s3] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder +build_flags = + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 diff --git a/examples/BasicRead/src/main.cpp b/examples/BasicRead/src/main.cpp new file mode 100644 index 0000000..f271751 --- /dev/null +++ b/examples/BasicRead/src/main.cpp @@ -0,0 +1,76 @@ +/** + * BluettiBLE Basic Read Example + * + * Connects to a single Bluetti power station and prints its values every poll + * cycle. + * + * Setup: + * 1. Find your device's BLE advertised name with a BLE scanner app + * (e.g. "AC3001234567890"). + * 2. Set the name and model below. + */ + +#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[0] ? d.model : "?"); + Serial.printf("Serial: %llu\n", d.serialNumber); + Serial.printf("Firmware: ARM %.2f / DSP %.2f\n", d.armVersion, d.dspVersion); + Serial.printf("State of charge: %u %%\n", d.totalBatteryPercent); + Serial.printf("AC output: %u W (%s)\n", d.acOutputPower, d.acOutputOn ? "ON" : "off"); + Serial.printf("DC output: %u W (%s)\n", d.dcOutputPower, d.dcOutputOn ? "ON" : "off"); + Serial.printf("AC input: %u W DC input: %u W\n", d.acInputPower, d.dcInputPower); + Serial.printf("Generation total: %.1f kWh\n", d.powerGeneration); + + if (d.acInputVoltage > 0) + Serial.printf("AC in: %.1f V @ %.2f Hz\n", d.acInputVoltage, d.acInputFrequency); + if (d.internalDcInputVoltage > 0) + Serial.printf("DC in: %.1f V %.1f A\n", d.internalDcInputVoltage, d.internalDcInputCurrent); + if (d.packNumMax > 0) + Serial.printf("Battery packs: %u (pack #%u %.1f V %u%%)\n", + d.packNumMax, d.packNum, d.packVoltage, d.packBatteryPercent); + + bool anyCell = false; + for (int i = 0; i < BLUETTI_CELLS; i++) if (d.cellVoltage[i] > 0) anyCell = true; + if (anyCell) { + Serial.print("Cells: "); + for (int i = 0; i < BLUETTI_CELLS; i++) + if (d.cellVoltage[i] > 0) Serial.printf("%.2f ", d.cellVoltage[i]); + Serial.println(); + } + + Serial.printf("RSSI: %d dBm (updated %lus ago)\n", + dev->rssi, (millis() - dev->lastUpdate) / 1000); +} + +void setup() { + Serial.begin(115200); + delay(1000); + + Serial.println("\n\n============================="); + Serial.println("BluettiBLE Basic Read Example"); + Serial.println("=============================\n"); + + if (!bluetti.begin()) { + Serial.println("ERROR: failed to initialise BluettiBLE!"); + while (1) delay(1000); + } + + bluetti.setDebug(true); + bluetti.setCallback(onBluettiData); + + // CHANGE THESE: your device's advertised BLE name and model. + // bluetti.addDevice("My Bluetti", "AC3001234567890", BLUETTI_AC300); + bluetti.addDevice("My Bluetti", "EL3002546110146262", BLUETTI_AC300); + + Serial.printf("Configured %d device(s). Scanning...\n", (int)bluetti.getDeviceCount()); +} + +void loop() { + bluetti.loop(); +} diff --git a/examples/Control/platformio.ini b/examples/Control/platformio.ini new file mode 100644 index 0000000..6dc5b7d --- /dev/null +++ b/examples/Control/platformio.ini @@ -0,0 +1,19 @@ +[env] +lib_extra_dirs = ../.. + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +[env:esp32-s3] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder +build_flags = + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 diff --git a/examples/Control/src/main.cpp b/examples/Control/src/main.cpp new file mode 100644 index 0000000..ab4868c --- /dev/null +++ b/examples/Control/src/main.cpp @@ -0,0 +1,54 @@ +/** + * BluettiBLE Control Example + * + * Reads the device and toggles the AC output on/off every 30 seconds to show how + * to send control commands. DC output toggling works the same way via + * setDCOutput(). + * + * WARNING: this actively switches your inverter output. Only run it when it is + * safe to power-cycle whatever is plugged in. + */ + +#include +#include "BluettiBLE.h" + +BluettiBLE bluetti; + +static uint32_t lastToggle = 0; +static bool desiredAC = true; + +void onBluettiData(const BluettiDevice* dev) { + const BluettiData& d = dev->data; + Serial.printf("[%s] SoC %u%% AC out %u W (%s) DC out %u W (%s)\n", + d.model, d.totalBatteryPercent, + d.acOutputPower, d.acOutputOn ? "ON" : "off", + d.dcOutputPower, d.dcOutputOn ? "ON" : "off"); +} + +void setup() { + Serial.begin(115200); + delay(1000); + + Serial.println("\nBluettiBLE Control Example\n"); + + bluetti.begin(); + bluetti.setDebug(true); + bluetti.setCallback(onBluettiData); + + // CHANGE THESE: your device's advertised BLE name and model. + bluetti.addDevice("My Bluetti", "AC3001234567890", BLUETTI_AC300); +} + +void loop() { + bluetti.loop(); + + if (bluetti.isConnected() && millis() - lastToggle > 30000) { + Serial.printf(">> setting AC output %s\n", desiredAC ? "ON" : "OFF"); + if (bluetti.setACOutput(desiredAC)) + Serial.println(" command sent"); + else + Serial.println(" command not supported / not connected"); + desiredAC = !desiredAC; + lastToggle = millis(); + } +} diff --git a/examples/Logger/platformio.ini b/examples/Logger/platformio.ini new file mode 100644 index 0000000..1b112d8 --- /dev/null +++ b/examples/Logger/platformio.ini @@ -0,0 +1,19 @@ +[env] +lib_extra_dirs = ../.. + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +monitor_filters = time, esp32_exception_decoder + +[env:esp32-s3] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +monitor_filters = time, esp32_exception_decoder +build_flags = + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 diff --git a/examples/Logger/src/main.cpp b/examples/Logger/src/main.cpp new file mode 100644 index 0000000..2320a8c --- /dev/null +++ b/examples/Logger/src/main.cpp @@ -0,0 +1,69 @@ +/** + * BluettiBLE Logger Example + * + * Prints a line only when a value of interest changes, rather than every poll + * cycle. Demonstrates the snapshot / change-detection pattern. + */ + +#include +#include "BluettiBLE.h" + +BluettiBLE bluetti; + +// Last-seen snapshot of the fields we care about. +struct Snapshot { + bool valid = false; + uint8_t soc = 0; + uint16_t acOut = 0; + uint16_t dcOut = 0; + bool acOn = false; + bool dcOn = false; + uint32_t packetsSinceLog = 0; +}; + +static Snapshot prev; + +void onBluettiData(const BluettiDevice* dev) { + const BluettiData& d = dev->data; + prev.packetsSinceLog++; + + bool changed = !prev.valid || + prev.soc != d.totalBatteryPercent || + prev.acOut != d.acOutputPower || + prev.dcOut != d.dcOutputPower || + prev.acOn != d.acOutputOn || + prev.dcOn != d.dcOutputOn; + + if (!changed) return; + + Serial.printf("CHANGE (%lu polls): SoC %u%% AC %u W (%s) DC %u W (%s)\n", + prev.packetsSinceLog, + d.totalBatteryPercent, + d.acOutputPower, d.acOutputOn ? "ON" : "off", + d.dcOutputPower, d.dcOutputOn ? "ON" : "off"); + + prev.valid = true; + prev.soc = d.totalBatteryPercent; + prev.acOut = d.acOutputPower; + prev.dcOut = d.dcOutputPower; + prev.acOn = d.acOutputOn; + prev.dcOn = d.dcOutputOn; + prev.packetsSinceLog = 0; +} + +void setup() { + Serial.begin(115200); + delay(1000); + + Serial.println("\nBluettiBLE Logger Example\n"); + + bluetti.begin(); + bluetti.setCallback(onBluettiData); + + // CHANGE THESE: your device's advertised BLE name and model. + bluetti.addDevice("My Bluetti", "AC3001234567890", BLUETTI_AC300); +} + +void loop() { + bluetti.loop(); +} diff --git a/keywords.txt b/keywords.txt new file mode 100644 index 0000000..93b377e --- /dev/null +++ b/keywords.txt @@ -0,0 +1,41 @@ +####################################### +# Syntax Coloring Map For BluettiBLE +####################################### + +####################################### +# Datatypes (KEYWORD1) +####################################### + +BluettiBLE KEYWORD1 +BluettiDevice KEYWORD1 +BluettiData KEYWORD1 +BluettiCallback KEYWORD1 +bluettiCommand KEYWORD1 + +####################################### +# Methods and Functions (KEYWORD2) +####################################### + +begin KEYWORD2 +addDevice KEYWORD2 +setCallback KEYWORD2 +setDebug KEYWORD2 +setPollInterval KEYWORD2 +isConnected KEYWORD2 +getDeviceCount KEYWORD2 +loop KEYWORD2 +setACOutput KEYWORD2 +setDCOutput KEYWORD2 + +####################################### +# Constants (LITERAL1) +####################################### + +BLUETTI_UNKNOWN LITERAL1 +BLUETTI_AC300 LITERAL1 +BLUETTI_AC200M LITERAL1 +BLUETTI_EB3A LITERAL1 +BLUETTI_EP500P LITERAL1 +BLUETTI_AC500 LITERAL1 +BLUETTI_EP500 LITERAL1 +BLUETTI_EP600 LITERAL1 diff --git a/library.json b/library.json new file mode 100644 index 0000000..3bc42b5 --- /dev/null +++ b/library.json @@ -0,0 +1,52 @@ +{ + "name": "bluettible", + "version": "0.1.0", + "description": "ESP32 library for reading and controlling Bluetti power stations via Bluetooth Low Energy (BLE). Connects over GATT and polls the Bluetti Modbus-style protocol. Supports AC300, AC200M, EB3A, EP500P and other models.", + "keywords": "bluetti, ble, bluetooth, solar, power station, battery, inverter, ac300, ac200m, eb3a, ep500p, esp32, iot, energy, monitoring", + "repository": { + "type": "git", + "url": "https://gitea.sh3d.com.au/Sh3d/BluettiBLE.git" + }, + "authors": [ + { + "name": "Scott Penrose", + "email": "scottp@dd.com.au", + "url": "https://gitea.sh3d.com.au/Sh3d", + "maintainer": true + } + ], + "license": "MIT", + "homepage": "https://gitea.sh3d.com.au/Sh3d/BluettiBLE", + "frameworks": ["arduino", "espidf"], + "platforms": ["espressif32"], + "headers": ["BluettiBLE.h"], + "dependencies": [], + "examples": [ + { + "name": "BasicRead", + "base": "examples/BasicRead", + "files": ["src/main.cpp"] + }, + { + "name": "Control", + "base": "examples/Control", + "files": ["src/main.cpp"] + }, + { + "name": "Logger", + "base": "examples/Logger", + "files": ["src/main.cpp"] + } + ], + "export": { + "exclude": [ + "examples/*/.pio", + "examples/*/.vscode", + "examples/*/test", + "test", + "tests", + ".git", + ".gitignore" + ] + } +} diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..48105f2 --- /dev/null +++ b/library.properties @@ -0,0 +1,11 @@ +name=BluettiBLE +version=0.1.0 +author=Scott Penrose +maintainer=Scott Penrose +sentence=ESP32 library for reading and controlling Bluetti power stations via BLE +paragraph=Connect to Bluetti power stations (AC300, AC200M, EB3A, EP500P and more) over Bluetooth Low Energy. Polls the Bluetti Modbus-style protocol and delivers parsed data through a single callback. Supports toggling AC and DC output. No pairing required. +category=Communication +url=https://gitea.sh3d.com.au/Sh3d/BluettiBLE +architectures=esp32 +depends= +includes=BluettiBLE.h diff --git a/src/BluettiBLE.cpp b/src/BluettiBLE.cpp new file mode 100644 index 0000000..4e145d0 --- /dev/null +++ b/src/BluettiBLE.cpp @@ -0,0 +1,436 @@ +/** + * BluettiBLE implementation — see BluettiBLE.h for the public interface. + * + * Protocol ported from the Bluetti_ESP32_Bridge project. + * Copyright (c) 2026 Scott Penrose — License: MIT + */ + +#include "BluettiBLE.h" +#include "BluettiCRC.h" +#include "devices/BluettiFields.h" + +// Per-model register tables (each header defines static const arrays). +#include "devices/Device_AC300.h" +#include "devices/Device_AC200M.h" +#include "devices/Device_EB3A.h" +#include "devices/Device_EP500P.h" +#include "devices/Device_AC500.h" +#include "devices/Device_EP500.h" +#include "devices/Device_EP600.h" + +#include + +// --- BLE UUIDs (ported from BTooth.h) --- +static BLEUUID BLUETTI_SERVICE_UUID("0000ff00-0000-1000-8000-00805f9b34fb"); +static BLEUUID BLUETTI_WRITE_UUID ("0000ff02-0000-1000-8000-00805f9b34fb"); +static BLEUUID BLUETTI_NOTIFY_UUID ("0000ff01-0000-1000-8000-00805f9b34fb"); + +static constexpr int HEADER_SIZE = 4; // matches PayloadParser.h + +// Single active instance — the ESP32 BLE notify/scan callbacks are plain C +// function pointers with no user-data argument, so we route through this. +static BluettiBLE* s_instance = nullptr; + +// ============================================================ +// Model registry +// ============================================================ +struct ModelTables { + const bluetti_field_t* state; + size_t stateCount; + const bluetti_poll_t* poll; + size_t pollCount; + const bluetti_field_t* command; + size_t commandCount; +}; + +#define TABLES(P) { P##_state, sizeof(P##_state)/sizeof(bluetti_field_t), \ + P##_poll, sizeof(P##_poll)/sizeof(bluetti_poll_t), \ + P##_command, sizeof(P##_command)/sizeof(bluetti_field_t) } + +static bool getModelTables(BluettiModel m, ModelTables& out) { + switch (m) { + case BLUETTI_AC300: out = (ModelTables)TABLES(AC300); return true; + case BLUETTI_AC200M: out = (ModelTables)TABLES(AC200M); return true; + case BLUETTI_EB3A: out = (ModelTables)TABLES(EB3A); return true; + case BLUETTI_EP500P: out = (ModelTables)TABLES(EP500P); return true; + case BLUETTI_AC500: out = (ModelTables)TABLES(AC500); return true; + case BLUETTI_EP500: out = (ModelTables)TABLES(EP500); return true; + case BLUETTI_EP600: + out = { EP600_state, sizeof(EP600_state)/sizeof(bluetti_field_t), + EP600_poll, sizeof(EP600_poll)/sizeof(bluetti_poll_t), + nullptr, 0 }; + return true; + default: return false; + } +} + +// ============================================================ +// Construction / setup +// ============================================================ +BluettiBLE::BluettiBLE() + : deviceCount(0), pBLEScan(nullptr), pClient(nullptr), + pWriteChar(nullptr), pNotifyChar(nullptr), foundDevice(nullptr), + scanCallbackObj(nullptr), clientCallbackObj(nullptr), + callback(nullptr), debugEnabled(false), pollIntervalMs(3000), + initialized(false), state(STATE_IDLE), activeIndex(-1), pollTick(0), + scanComplete(false), lastPollTime(0), pendingPage(0), pendingOffset(0) { + memset(devices, 0, sizeof(devices)); + memset(&working, 0, sizeof(working)); +} + +bool BluettiBLE::begin() { + if (initialized) return true; + s_instance = this; + + BLEDevice::init("BluettiBLE"); + BLEDevice::setMTU(517); + + pBLEScan = BLEDevice::getScan(); + scanCallbackObj = new BluettiBLEAdvertisedDeviceCallbacks(this); + clientCallbackObj = new BluettiBLEClientCallbacks(this); + pBLEScan->setAdvertisedDeviceCallbacks(scanCallbackObj, true); + pBLEScan->setActiveScan(true); + pBLEScan->setInterval(1349); + pBLEScan->setWindow(449); + + initialized = true; + state = STATE_IDLE; + if (debugEnabled) Serial.println("[BluettiBLE] initialised"); + return true; +} + +bool BluettiBLE::addDevice(const char* name, const char* bleName, BluettiModel model) { + if (deviceCount >= BLUETTI_MAX_DEVICES) return false; + if (!bleName || strlen(bleName) == 0) return false; + ModelTables t; + if (!getModelTables(model, t)) return false; + + DeviceEntry* e = &devices[deviceCount]; + memset(e, 0, sizeof(DeviceEntry)); + e->active = true; + strncpy(e->device.name, name ? name : bleName, BLUETTI_NAME_LEN - 1); + strncpy(e->device.bleName, bleName, BLUETTI_NAME_LEN - 1); + e->device.model = model; + e->device.rssi = -100; + deviceCount++; + + if (debugEnabled) Serial.printf("[BluettiBLE] Added: %s (%s)\n", + e->device.name, e->device.bleName); + return true; +} + +bool BluettiBLE::isConnected() const { + return state == STATE_CONNECTED && pClient && pClient->isConnected(); +} + +// ============================================================ +// Static trampolines +// ============================================================ +void BluettiBLE::scanDoneTrampoline(BLEScanResults results) { + if (s_instance) s_instance->scanComplete = true; +} + +void BluettiBLE::notifyTrampoline(BLERemoteCharacteristic* c, uint8_t* data, + size_t length, bool isNotify) { + if (s_instance) s_instance->onNotify(data, length); +} + +void BluettiBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) { + if (owner) owner->onScanResult(advertisedDevice); +} + +void BluettiBLEClientCallbacks::onDisconnect(BLEClient* c) { + if (owner) owner->onDisconnect(); +} + +// ============================================================ +// State machine +// ============================================================ +void BluettiBLE::loop() { + if (!initialized || deviceCount == 0) return; + + switch (state) { + case STATE_IDLE: + startScan(); + break; + + case STATE_SCANNING: + if (scanComplete) { + pBLEScan->stop(); + if (foundDevice) { + state = STATE_CONNECTING; + } else { + // No match this round — rotate to the next device and retry. + activeIndex = (activeIndex + 1) % (int)deviceCount; + state = STATE_IDLE; + } + } + break; + + case STATE_CONNECTING: + connectActive(); + break; + + case STATE_CONNECTED: + if (!isConnected()) { + onDisconnect(); + } else if (millis() - lastPollTime >= pollIntervalMs) { + sendPoll(); + lastPollTime = millis(); + } + break; + } +} + +void BluettiBLE::startScan() { + // Choose the next active device to look for. + if (activeIndex < 0) activeIndex = 0; + while (activeIndex < (int)deviceCount && !devices[activeIndex].active) + activeIndex = (activeIndex + 1) % (int)deviceCount; + + if (foundDevice) { delete foundDevice; foundDevice = nullptr; } + scanComplete = false; + pBLEScan->clearResults(); + if (debugEnabled) + Serial.printf("[BluettiBLE] scanning for %s\n", devices[activeIndex].device.bleName); + if (pBLEScan->start(5, scanDoneTrampoline, false)) + state = STATE_SCANNING; +} + +void BluettiBLE::onScanResult(BLEAdvertisedDevice advertisedDevice) { + if (activeIndex < 0 || activeIndex >= (int)deviceCount) return; + const char* want = devices[activeIndex].device.bleName; + + bool hasBluettiService = advertisedDevice.haveServiceUUID() && + advertisedDevice.isAdvertisingService(BLUETTI_SERVICE_UUID); + + if (debugEnabled) { + const char* nm = advertisedDevice.getName().c_str(); + Serial.printf("[BluettiBLE] seen: name='%s' rssi=%d bluetti=%s%s\n", + (nm && nm[0]) ? nm : "(no name)", + advertisedDevice.getRSSI(), + hasBluettiService ? "yes" : "no", + (strcmp(nm, want) == 0) ? " <-- MATCHES" : ""); + } + + if (hasBluettiService && + strcmp(advertisedDevice.getName().c_str(), want) == 0) { + if (foundDevice) delete foundDevice; + foundDevice = new BLEAdvertisedDevice(advertisedDevice); + devices[activeIndex].device.rssi = advertisedDevice.getRSSI(); + pBLEScan->stop(); + scanComplete = true; + } +} + +void BluettiBLE::connectActive() { + if (!foundDevice) { state = STATE_IDLE; return; } + + if (!pClient) { + pClient = BLEDevice::createClient(); + pClient->setClientCallbacks(clientCallbackObj); + } + + if (debugEnabled) Serial.println("[BluettiBLE] connecting..."); + if (!pClient->connect(foundDevice)) { + if (debugEnabled) Serial.println("[BluettiBLE] connect failed"); + onDisconnect(); + return; + } + + BLERemoteService* svc = pClient->getService(BLUETTI_SERVICE_UUID); + if (!svc) { if (debugEnabled) Serial.println("[BluettiBLE] no service"); pClient->disconnect(); onDisconnect(); return; } + + pWriteChar = svc->getCharacteristic(BLUETTI_WRITE_UUID); + pNotifyChar = svc->getCharacteristic(BLUETTI_NOTIFY_UUID); + if (!pWriteChar || !pNotifyChar) { + if (debugEnabled) Serial.println("[BluettiBLE] missing characteristics"); + pClient->disconnect(); + onDisconnect(); + return; + } + + if (pNotifyChar->canNotify()) + pNotifyChar->registerForNotify(notifyTrampoline); + + devices[activeIndex].device.connected = true; + memset(&working, 0, sizeof(working)); + pollTick = 0; + lastPollTime = millis() - pollIntervalMs; // poll immediately + state = STATE_CONNECTED; + if (debugEnabled) Serial.println("[BluettiBLE] connected"); +} + +void BluettiBLE::onDisconnect() { + if (activeIndex >= 0 && activeIndex < (int)deviceCount) + devices[activeIndex].device.connected = false; + pWriteChar = nullptr; + pNotifyChar = nullptr; + // Rotate to the next device so multiple registrations get a turn. + if (deviceCount > 0) activeIndex = (activeIndex + 1) % (int)deviceCount; + state = STATE_IDLE; + if (debugEnabled) Serial.println("[BluettiBLE] disconnected"); +} + +// ============================================================ +// Polling +// ============================================================ +void BluettiBLE::sendPoll() { + ModelTables t; + if (!getModelTables(devices[activeIndex].device.model, t)) return; + if (t.pollCount == 0) return; + if (pollTick >= t.pollCount) pollTick = 0; + + const bluetti_poll_t& p = t.poll[pollTick]; + bluettiCommand cmd; + cmd.prefix = 0x01; + cmd.cmd = 0x03; // read + cmd.page = p.page; + cmd.offset = p.offset; + cmd.len = (uint16_t)p.count << 8; // big-endian register count + cmd.checkSum = bluettiModbusCrc((uint8_t*)&cmd, 6); + + pendingPage = p.page; + pendingOffset = p.offset; + sendCommand(cmd); + + pollTick++; + if (pollTick >= t.pollCount) { + // Completed a full cycle: publish the rolling snapshot. + pollTick = 0; + BluettiDevice& d = devices[activeIndex].device; + memcpy(&d.data, &working, sizeof(BluettiData)); + d.dataValid = true; + d.lastUpdate = millis(); + if (pClient) d.rssi = pClient->getRssi(); + if (callback) callback(&d); + } +} + +bool BluettiBLE::sendCommand(const bluettiCommand& cmd) { + if (!pWriteChar) return false; + if (debugEnabled) { + Serial.print("[BluettiBLE] >> "); + const uint8_t* b = (const uint8_t*)&cmd; + for (int i = 0; i < 8; i++) Serial.printf("%02x", b[i]); + Serial.println(); + } + pWriteChar->writeValue((uint8_t*)&cmd, sizeof(cmd), true); + return true; +} + +// ============================================================ +// Response parsing (arithmetic ported from PayloadParser.cpp) +// ============================================================ +void BluettiBLE::onNotify(uint8_t* pData, size_t length) { + if (length < (size_t)HEADER_SIZE) return; + parseResponse(pendingPage, pendingOffset, pData, length); +} + +void BluettiBLE::parseResponse(uint8_t reqPage, uint8_t reqOffset, + uint8_t* pData, size_t length) { + if (pData[1] != 0x03) return; // only handle range-read responses + + ModelTables t; + if (!getModelTables(devices[activeIndex].device.model, t)) return; + + for (size_t i = 0; i < t.stateCount; i++) { + const bluetti_field_t& f = t.state[i]; + if (f.page != reqPage) continue; + if (f.offset < reqOffset) continue; + + int delta = (int)f.offset - (int)reqOffset; + if ((2 * delta) + HEADER_SIZE > (int)length) continue; + if ((2 * (delta + f.size)) + HEADER_SIZE > (int)length) continue; + + // 1-based indexing quirk from the reference: data begins at pData[2*delta+3] + int dataStart = (2 * delta) + HEADER_SIZE; + const uint8_t* bytes = &pData[dataStart - 1]; + applyField(f.name, f.scale, bytes, f.size); + } +} + +void BluettiBLE::applyField(int fieldName, uint8_t scale, + const uint8_t* b, int sizeRegs) { + auto u16 = [&](int i) -> uint16_t { + return ((uint16_t)b[i] << 8) | (uint16_t)b[i + 1]; + }; + + // Cell voltages occupy a contiguous enum range. + if (fieldName >= BF_CELL01_VOLTAGE && fieldName <= BF_CELL16_VOLTAGE) { + working.cellVoltage[fieldName - BF_CELL01_VOLTAGE] = u16(0) / powf(10, scale); + return; + } + + switch (fieldName) { + case BF_DEVICE_TYPE: { + int n = 2 * sizeRegs; + if (n > BLUETTI_MODEL_LEN - 1) n = BLUETTI_MODEL_LEN - 1; + memcpy(working.model, b, n); + working.model[n] = '\0'; + // trim trailing spaces/nulls + for (int i = n - 1; i >= 0 && (working.model[i] == ' ' || working.model[i] == 0); i--) + working.model[i] = '\0'; + break; + } + case BF_SERIAL_NUMBER: { + uint16_t v1 = u16(0), v2 = u16(2), v3 = u16(4), v4 = u16(6); + working.serialNumber = ((uint64_t)v1) | ((uint64_t)v2 << 16) | + ((uint64_t)v3 << 32) | ((uint64_t)v4 << 48); + break; + } + case BF_ARM_VERSION: + case BF_DSP_VERSION: { + uint16_t low = u16(0), high = u16(2); + long val = (long)low | ((long)high << 16); + float ver = (float)val / 100.0f; + if (fieldName == BF_ARM_VERSION) working.armVersion = ver; + else working.dspVersion = ver; + break; + } + case BF_DC_INPUT_POWER: working.dcInputPower = u16(0); break; + case BF_AC_INPUT_POWER: working.acInputPower = u16(0); break; + case BF_AC_OUTPUT_POWER: working.acOutputPower = u16(0); break; + case BF_DC_OUTPUT_POWER: working.dcOutputPower = u16(0); break; + case BF_POWER_GENERATION: working.powerGeneration = u16(0) / powf(10, scale); break; + case BF_TOTAL_BATTERY_PERCENT: working.totalBatteryPercent = (uint8_t)u16(0); break; + case BF_AC_OUTPUT_ON: working.acOutputOn = (b[1] == 1); break; + case BF_DC_OUTPUT_ON: working.dcOutputOn = (b[1] == 1); break; + case BF_AC_INPUT_VOLTAGE: working.acInputVoltage = u16(0) / powf(10, scale); break; + case BF_AC_INPUT_FREQUENCY: working.acInputFrequency = u16(0) / powf(10, scale); break; + case BF_INTERNAL_AC_VOLTAGE: working.internalAcVoltage = u16(0) / powf(10, scale); break; + case BF_INTERNAL_AC_FREQUENCY: working.internalAcFrequency = u16(0) / powf(10, scale); break; + case BF_INTERNAL_DC_INPUT_VOLTAGE: working.internalDcInputVoltage = u16(0) / powf(10, scale); break; + case BF_INTERNAL_DC_INPUT_CURRENT: working.internalDcInputCurrent = u16(0) / powf(10, scale); break; + case BF_INTERNAL_PACK_VOLTAGE: working.packVoltage = u16(0) / powf(10, scale); break; + case BF_PACK_NUM_MAX: working.packNumMax = (uint8_t)u16(0); break; + case BF_PACK_NUM: working.packNum = (uint8_t)u16(0); break; + case BF_PACK_BATTERY_PERCENT: working.packBatteryPercent = (uint8_t)u16(0); break; + default: break; // fields without a struct member are ignored + } +} + +// ============================================================ +// Control +// ============================================================ +bool BluettiBLE::writeControl(int fieldName, uint16_t value) { + if (!isConnected()) return false; + ModelTables t; + if (!getModelTables(devices[activeIndex].device.model, t)) return false; + + for (size_t i = 0; i < t.commandCount; i++) { + if ((int)t.command[i].name != fieldName) continue; + bluettiCommand cmd; + cmd.prefix = 0x01; + cmd.cmd = 0x06; // write + cmd.page = t.command[i].page; + cmd.offset = t.command[i].offset; + cmd.len = bluettiSwapBytes(value); // value big-endian on the wire + cmd.checkSum = bluettiModbusCrc((uint8_t*)&cmd, 6); + return sendCommand(cmd); + } + return false; // model has no such writable control +} + +bool BluettiBLE::setACOutput(bool on) { return writeControl(BF_AC_OUTPUT_ON, on ? 1 : 0); } +bool BluettiBLE::setDCOutput(bool on) { return writeControl(BF_DC_OUTPUT_ON, on ? 1 : 0); } diff --git a/src/BluettiBLE.h b/src/BluettiBLE.h new file mode 100644 index 0000000..b226caf --- /dev/null +++ b/src/BluettiBLE.h @@ -0,0 +1,214 @@ +/** + * BluettiBLE - ESP32 library for Bluetti power stations via Bluetooth Low Energy + * + * Connects to a Bluetti power station over BLE GATT, polls its registers with the + * Bluetti Modbus-style protocol, and delivers parsed data through a single + * callback. Modelled on the VictronBLE library's interface, but Bluetti requires + * an active connection (not passive advertisements), so the library runs a + * connect -> poll -> parse state machine instead of a passive scanner. + * + * Protocol ported from the Bluetti_ESP32_Bridge project. + * + * Copyright (c) 2026 Scott Penrose + * License: MIT + */ + +#ifndef BLUETTI_BLE_H +#define BLUETTI_BLE_H + +#include +#include +#include +#include + +// --- Constants --- +static constexpr int BLUETTI_MAX_DEVICES = 4; +static constexpr int BLUETTI_NAME_LEN = 32; // user label / advertised name +static constexpr int BLUETTI_MODEL_LEN = 16; // DEVICE_TYPE string +static constexpr int BLUETTI_CELLS = 16; + +// --- Models (selects the register table) --- +enum BluettiModel { + BLUETTI_UNKNOWN = 0, + BLUETTI_AC300, + BLUETTI_AC200M, + BLUETTI_EB3A, + BLUETTI_EP500P, + BLUETTI_AC500, + BLUETTI_EP500, + BLUETTI_EP600 +}; + +// ============================================================ +// Wire-format command frame (8 bytes) — ported from BTooth.h +// ============================================================ +// prefix : always 0x01 +// cmd : 0x03 = read/poll, 0x06 = write +// page : register page (0x00, 0x07, 0x08, 0x0B, ...) +// offset : starting register offset +// len : read = registerCount << 8; write = swapBytes(value) +// checkSum : CRC-16/MODBUS over the first 6 bytes +struct bluettiCommand { + uint8_t prefix; + uint8_t cmd; + uint8_t page; + uint8_t offset; + uint16_t len; + uint16_t checkSum; +} __attribute__((packed)); + +// ============================================================ +// Parsed data (flat struct, superset across models) +// Fields a given model does not report stay at 0. +// ============================================================ +struct BluettiData { + char model[BLUETTI_MODEL_LEN]; // e.g. "AC300" + uint64_t serialNumber; + float armVersion; + float dspVersion; + + uint8_t totalBatteryPercent; // state of charge % + uint16_t dcInputPower; // W + uint16_t acInputPower; // W + uint16_t acOutputPower; // W + uint16_t dcOutputPower; // W + float powerGeneration; // kWh total + bool acOutputOn; + bool dcOutputOn; + + float acInputVoltage; // V + float acInputFrequency; // Hz + float internalAcVoltage; // V + float internalAcFrequency; // Hz + float internalDcInputVoltage; // V + float internalDcInputCurrent; // A + + float packVoltage; // V (selected pack) + uint8_t packNum; // currently reported pack number + uint8_t packNumMax; // number of packs + uint8_t packBatteryPercent; // % + float cellVoltage[BLUETTI_CELLS];// V, 0 if unsupported +}; + +// ============================================================ +// Device descriptor handed to the callback +// ============================================================ +struct BluettiDevice { + char name[BLUETTI_NAME_LEN]; // user label + char bleName[BLUETTI_NAME_LEN]; // advertised name to match + BluettiModel model; + int8_t rssi; + uint32_t lastUpdate; // millis() of last full update + bool connected; + bool dataValid; + BluettiData data; +}; + +// ============================================================ +// Callback — simple function pointer (no virtual class) +// ============================================================ +typedef void (*BluettiCallback)(const BluettiDevice* device); + +// Forward declarations for the BLE scan/client glue. +class BluettiBLEAdvertisedDeviceCallbacks; +class BluettiBLEClientCallbacks; + +// ============================================================ +// Main BluettiBLE class +// ============================================================ +class BluettiBLE { +public: + BluettiBLE(); + + bool begin(); + bool addDevice(const char* name, const char* bleName, BluettiModel model); + void setCallback(BluettiCallback cb) { callback = cb; } + void setDebug(bool enable) { debugEnabled = enable; } + void setPollInterval(uint32_t ms) { pollIntervalMs = ms; } + bool isConnected() const; + size_t getDeviceCount() const { return deviceCount; } + void loop(); + + // Control: act on the currently connected device. + bool setACOutput(bool on); + bool setDCOutput(bool on); + +private: + friend class BluettiBLEAdvertisedDeviceCallbacks; + friend class BluettiBLEClientCallbacks; + + enum State { STATE_IDLE, STATE_SCANNING, STATE_CONNECTING, STATE_CONNECTED }; + + struct DeviceEntry { + BluettiDevice device; + bool active; + }; + + DeviceEntry devices[BLUETTI_MAX_DEVICES]; + size_t deviceCount; + + BLEScan* pBLEScan; + BLEClient* pClient; + BLERemoteCharacteristic* pWriteChar; + BLERemoteCharacteristic* pNotifyChar; + BLEAdvertisedDevice* foundDevice; // matched during scan + BluettiBLEAdvertisedDeviceCallbacks* scanCallbackObj; + BluettiBLEClientCallbacks* clientCallbackObj; + + BluettiCallback callback; + bool debugEnabled; + uint32_t pollIntervalMs; + bool initialized; + + State state; + int activeIndex; // device currently connected / being connected + size_t pollTick; // index into the model's poll table + bool scanComplete; // set by scan-done callback + uint32_t lastPollTime; + uint8_t pendingPage; // page/offset of the in-flight poll request + uint8_t pendingOffset; + BluettiData working; // accumulates one poll cycle before publishing + + // BLE callback hooks + void onScanResult(BLEAdvertisedDevice dev); + void onDisconnect(); + void onNotify(uint8_t* pData, size_t length); + + // state machine helpers + void startScan(); + void connectActive(); + void sendPoll(); + bool sendCommand(const bluettiCommand& cmd); + + // parsing + void parseResponse(uint8_t reqPage, uint8_t reqOffset, uint8_t* pData, size_t length); + void applyField(int fieldName, uint8_t scale, const uint8_t* bytes, int sizeRegs); + + // control helper: look up a writable field for the active model and write it + bool writeControl(int fieldName, uint16_t value); + + static void scanDoneTrampoline(BLEScanResults results); + static void notifyTrampoline(BLERemoteCharacteristic* c, uint8_t* data, + size_t length, bool isNotify); +}; + +// BLE scan callback (required by the ESP32 BLE API). +class BluettiBLEAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks { +public: + BluettiBLEAdvertisedDeviceCallbacks(BluettiBLE* parent) : owner(parent) {} + void onResult(BLEAdvertisedDevice advertisedDevice) override; +private: + BluettiBLE* owner; +}; + +// BLE client callback for connect/disconnect events. +class BluettiBLEClientCallbacks : public BLEClientCallbacks { +public: + BluettiBLEClientCallbacks(BluettiBLE* parent) : owner(parent) {} + void onConnect(BLEClient* c) override {} + void onDisconnect(BLEClient* c) override; +private: + BluettiBLE* owner; +}; + +#endif // BLUETTI_BLE_H diff --git a/src/BluettiCRC.h b/src/BluettiCRC.h new file mode 100644 index 0000000..25c0b94 --- /dev/null +++ b/src/BluettiCRC.h @@ -0,0 +1,43 @@ +/** + * BluettiCRC - CRC-16/MODBUS helpers for the Bluetti BLE protocol + * + * Ported from the Bluetti_ESP32_Bridge project (crc16.h / utils.cpp). + * Polynomial 0xA001 (reversed 0x8005), initial value 0xFFFF. + * + * Copyright (c) 2026 Scott Penrose + * License: MIT + */ + +#ifndef BLUETTI_CRC_H +#define BLUETTI_CRC_H + +#include +#include + +// Update a running CRC-16/MODBUS with one byte. +static inline uint16_t bluettiCrc16Update(uint16_t crc, uint8_t a) { + crc ^= a; + for (int i = 0; i < 8; ++i) { + if (crc & 1) + crc = (crc >> 1) ^ 0xA001; + else + crc = (crc >> 1); + } + return crc; +} + +// CRC-16/MODBUS over a buffer (init 0xFFFF). +static inline uint16_t bluettiModbusCrc(const uint8_t* buf, size_t len) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < len; i++) { + crc = bluettiCrc16Update(crc, buf[i]); + } + return crc; +} + +// Swap the two bytes of a 16-bit value (used to place write values big-endian). +static inline uint16_t bluettiSwapBytes(uint16_t v) { + return (uint16_t)((v << 8) | (v >> 8)); +} + +#endif // BLUETTI_CRC_H diff --git a/src/devices/BluettiFields.h b/src/devices/BluettiFields.h new file mode 100644 index 0000000..c8c549e --- /dev/null +++ b/src/devices/BluettiFields.h @@ -0,0 +1,115 @@ +/** + * BluettiFields - field and register-table definitions for Bluetti devices + * + * The shape mirrors the Bluetti_ESP32_Bridge `device_field_data_t` table so the + * per-model register maps port across cleanly. Each model header (Device_*.h) + * provides three tables built from these types: + * - a state table (bluetti_field_t[]) : fields to extract from poll responses + * - a poll table (bluetti_poll_t[]) : register ranges to request + * - a command table (bluetti_field_t[]) : writable controls + * + * Copyright (c) 2026 Scott Penrose + * License: MIT + */ + +#ifndef BLUETTI_FIELDS_H +#define BLUETTI_FIELDS_H + +#include + +// How a raw register value is decoded. +enum BluettiFieldType { + B_UINT, // big-endian uint16 + B_BOOL, // second byte == 1 + B_ENUM, // enum value (decoded as uint16) + B_STRING, // raw ASCII + B_DECIMAL, // uint16 / 10^scale + B_VERSION, // 4 bytes -> uint32 / 100 + B_SN, // 8 bytes -> uint64 + B_TYPE_UNDEFINED +}; + +// Logical field identity. Used to map a parsed value onto a BluettiData member. +enum BluettiFieldName { + BF_DEVICE_TYPE, + BF_SERIAL_NUMBER, + BF_ARM_VERSION, + BF_DSP_VERSION, + BF_DC_INPUT_POWER, + BF_AC_INPUT_POWER, + BF_AC_OUTPUT_POWER, + BF_DC_OUTPUT_POWER, + BF_POWER_GENERATION, + BF_TOTAL_BATTERY_PERCENT, + BF_AC_OUTPUT_ON, + BF_DC_OUTPUT_ON, + BF_AC_OUTPUT_MODE, + BF_INTERNAL_AC_VOLTAGE, + BF_INTERNAL_AC_FREQUENCY, + BF_INTERNAL_CURRENT_ONE, + BF_INTERNAL_POWER_ONE, + BF_INTERNAL_CURRENT_TWO, + BF_INTERNAL_POWER_TWO, + BF_INTERNAL_CURRENT_THREE, + BF_INTERNAL_POWER_THREE, + BF_AC_INPUT_VOLTAGE, + BF_AC_INPUT_FREQUENCY, + BF_INTERNAL_DC_INPUT_VOLTAGE, + BF_INTERNAL_DC_INPUT_POWER, + BF_INTERNAL_DC_INPUT_CURRENT, + BF_INTERNAL_PACK_VOLTAGE, + BF_PACK_NUM_MAX, + BF_PACK_NUM, + BF_PACK_BATTERY_PERCENT, + BF_UPS_MODE, + BF_GRID_CHARGE_ON, + BF_AUTO_SLEEP_MODE, + BF_POWER_OFF, + BF_CELL01_VOLTAGE, // 16 consecutive cell-voltage fields must stay in order + BF_CELL02_VOLTAGE, + BF_CELL03_VOLTAGE, + BF_CELL04_VOLTAGE, + BF_CELL05_VOLTAGE, + BF_CELL06_VOLTAGE, + BF_CELL07_VOLTAGE, + BF_CELL08_VOLTAGE, + BF_CELL09_VOLTAGE, + BF_CELL10_VOLTAGE, + BF_CELL11_VOLTAGE, + BF_CELL12_VOLTAGE, + BF_CELL13_VOLTAGE, + BF_CELL14_VOLTAGE, + BF_CELL15_VOLTAGE, + BF_CELL16_VOLTAGE, + BF_BATTERY_MIN_PERCENTAGE, + BF_AC_CHARGE_MAX_PERCENTAGE, + BF_AC_INPUT_POWER_MAX, + BF_AC_INPUT_CURRENT_MAX, + BF_AC_OUTPUT_POWER_MAX, + BF_AC_OUTPUT_CURRENT_MAX, + BF_UNDEFINED +}; + +// One field within a register page. +// page : protocol page (0x00, 0x07, 0x08, 0x0B, ...) +// offset : starting register offset within the page +// size : number of 16-bit registers +// scale : decimal scale (value / 10^scale) for B_DECIMAL +struct bluetti_field_t { + BluettiFieldName name; + uint8_t page; + uint8_t offset; + int8_t size; + int8_t scale; + int8_t fenum; + BluettiFieldType type; +}; + +// A register range to request in a single read poll. +struct bluetti_poll_t { + uint8_t page; + uint8_t offset; + uint8_t count; // number of 16-bit registers +}; + +#endif // BLUETTI_FIELDS_H diff --git a/src/devices/Device_AC200M.h b/src/devices/Device_AC200M.h new file mode 100644 index 0000000..61517ec --- /dev/null +++ b/src/devices/Device_AC200M.h @@ -0,0 +1,60 @@ +/** + * Bluetti AC200M register map. + * Ported from Bluetti_ESP32_Bridge/Device_AC200M.h (tested model). + */ +#ifndef BLUETTI_DEVICE_AC200M_H +#define BLUETTI_DEVICE_AC200M_H + +#include "BluettiFields.h" + +static const bluetti_field_t AC200M_state[] = { + {BF_DEVICE_TYPE, 0x00, 0x0A, 7, 0, 0, B_STRING}, + {BF_SERIAL_NUMBER, 0x00, 0x11, 4, 0, 0, B_SN}, + {BF_ARM_VERSION, 0x00, 0x17, 2, 0, 0, B_VERSION}, + {BF_DSP_VERSION, 0x00, 0x19, 2, 0, 0, B_VERSION}, + {BF_DC_INPUT_POWER, 0x00, 0x24, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_POWER, 0x00, 0x25, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_POWER, 0x00, 0x26, 1, 0, 0, B_UINT}, + {BF_DC_OUTPUT_POWER, 0x00, 0x27, 1, 0, 0, B_UINT}, + {BF_POWER_GENERATION, 0x00, 0x29, 1, 1, 0, B_DECIMAL}, + {BF_TOTAL_BATTERY_PERCENT, 0x00, 0x2B, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_ON, 0x00, 0x30, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x00, 0x31, 1, 0, 0, B_BOOL}, + {BF_INTERNAL_AC_VOLTAGE, 0x00, 0x47, 1, 0, 0, B_DECIMAL}, + {BF_INTERNAL_AC_FREQUENCY, 0x00, 0x4A, 2, 1, 0, B_DECIMAL}, + {BF_AC_INPUT_VOLTAGE, 0x00, 0x4D, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_DC_INPUT_VOLTAGE, 0x00, 0x56, 1, 1, 0, B_DECIMAL}, + {BF_PACK_NUM_MAX, 0x00, 0x5B, 1, 0, 0, B_UINT}, + {BF_INTERNAL_PACK_VOLTAGE, 0x00, 0x5C, 1, 2, 0, B_DECIMAL}, + {BF_CELL01_VOLTAGE, 0x00, 0x69, 1, 2, 0, B_DECIMAL}, + {BF_CELL02_VOLTAGE, 0x00, 0x6A, 1, 2, 0, B_DECIMAL}, + {BF_CELL03_VOLTAGE, 0x00, 0x6B, 1, 2, 0, B_DECIMAL}, + {BF_CELL04_VOLTAGE, 0x00, 0x6C, 1, 2, 0, B_DECIMAL}, + {BF_CELL05_VOLTAGE, 0x00, 0x6D, 1, 2, 0, B_DECIMAL}, + {BF_CELL06_VOLTAGE, 0x00, 0x6E, 1, 2, 0, B_DECIMAL}, + {BF_CELL07_VOLTAGE, 0x00, 0x6F, 1, 2, 0, B_DECIMAL}, + {BF_CELL08_VOLTAGE, 0x00, 0x70, 1, 2, 0, B_DECIMAL}, + {BF_CELL09_VOLTAGE, 0x00, 0x71, 1, 2, 0, B_DECIMAL}, + {BF_CELL10_VOLTAGE, 0x00, 0x72, 1, 2, 0, B_DECIMAL}, + {BF_CELL11_VOLTAGE, 0x00, 0x73, 1, 2, 0, B_DECIMAL}, + {BF_CELL12_VOLTAGE, 0x00, 0x74, 1, 2, 0, B_DECIMAL}, + {BF_CELL13_VOLTAGE, 0x00, 0x75, 1, 2, 0, B_DECIMAL}, + {BF_CELL14_VOLTAGE, 0x00, 0x76, 1, 2, 0, B_DECIMAL}, + {BF_CELL15_VOLTAGE, 0x00, 0x77, 1, 2, 0, B_DECIMAL}, + {BF_CELL16_VOLTAGE, 0x00, 0x78, 1, 2, 0, B_DECIMAL}, + {BF_AUTO_SLEEP_MODE, 0x0B, 0xF5, 1, 0, 0, B_UINT}, +}; + +static const bluetti_poll_t AC200M_poll[] = { + {0x00, 0x0A, 0x7F}, + {0x0B, 0xB9, 0x3F}, +}; + +static const bluetti_field_t AC200M_command[] = { + {BF_DC_OUTPUT_ON, 0x0B, 0xC0, 1, 0, 0, B_BOOL}, + {BF_AC_OUTPUT_ON, 0x0B, 0xBF, 1, 0, 0, B_BOOL}, + {BF_AUTO_SLEEP_MODE, 0x0B, 0xF5, 1, 0, 0, B_UINT}, + {BF_POWER_OFF, 0x0B, 0xF4, 1, 0, 0, B_BOOL}, +}; + +#endif // BLUETTI_DEVICE_AC200M_H diff --git a/src/devices/Device_AC300.h b/src/devices/Device_AC300.h new file mode 100644 index 0000000..baa9f30 --- /dev/null +++ b/src/devices/Device_AC300.h @@ -0,0 +1,61 @@ +/** + * Bluetti AC300 register map. + * Ported from Bluetti_ESP32_Bridge/Device_AC300.h (tested model). + */ +#ifndef BLUETTI_DEVICE_AC300_H +#define BLUETTI_DEVICE_AC300_H + +#include "BluettiFields.h" + +static const bluetti_field_t AC300_state[] = { + // Page 0x00 core + {BF_DEVICE_TYPE, 0x00, 0x0A, 7, 0, 0, B_STRING}, + {BF_SERIAL_NUMBER, 0x00, 0x11, 4, 0, 0, B_SN}, + {BF_ARM_VERSION, 0x00, 0x17, 2, 0, 0, B_VERSION}, + {BF_DSP_VERSION, 0x00, 0x19, 2, 0, 0, B_VERSION}, + {BF_DC_INPUT_POWER, 0x00, 0x24, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_POWER, 0x00, 0x25, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_POWER, 0x00, 0x26, 1, 0, 0, B_UINT}, + {BF_DC_OUTPUT_POWER, 0x00, 0x27, 1, 0, 0, B_UINT}, + {BF_POWER_GENERATION, 0x00, 0x29, 1, 1, 0, B_DECIMAL}, + {BF_TOTAL_BATTERY_PERCENT, 0x00, 0x2B, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_ON, 0x00, 0x30, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x00, 0x31, 1, 0, 0, B_BOOL}, + {BF_AC_OUTPUT_MODE, 0x00, 0x46, 1, 0, 0, B_UINT}, + {BF_INTERNAL_AC_VOLTAGE, 0x00, 0x47, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_CURRENT_ONE, 0x00, 0x48, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_POWER_ONE, 0x00, 0x49, 1, 0, 0, B_UINT}, + {BF_INTERNAL_AC_FREQUENCY, 0x00, 0x4A, 1, 2, 0, B_DECIMAL}, + {BF_INTERNAL_CURRENT_TWO, 0x00, 0x4B, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_POWER_TWO, 0x00, 0x4C, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_VOLTAGE, 0x00, 0x4D, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_CURRENT_THREE, 0x00, 0x4E, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_POWER_THREE, 0x00, 0x4F, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_FREQUENCY, 0x00, 0x50, 1, 2, 0, B_DECIMAL}, + {BF_INTERNAL_DC_INPUT_VOLTAGE, 0x00, 0x56, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_DC_INPUT_POWER, 0x00, 0x57, 1, 0, 0, B_UINT}, + {BF_INTERNAL_DC_INPUT_CURRENT, 0x00, 0x58, 1, 1, 0, B_DECIMAL}, + {BF_PACK_NUM_MAX, 0x00, 0x5B, 1, 0, 0, B_UINT}, + {BF_INTERNAL_PACK_VOLTAGE, 0x00, 0x5C, 1, 1, 0, B_DECIMAL}, + {BF_PACK_NUM, 0x00, 0x60, 1, 0, 0, B_UINT}, + {BF_PACK_BATTERY_PERCENT, 0x00, 0x63, 1, 0, 0, B_UINT}, + // Page 0x0B controls + {BF_UPS_MODE, 0x0B, 0xB9, 1, 0, 0, B_UINT}, + {BF_GRID_CHARGE_ON, 0x0B, 0xC3, 1, 0, 0, B_BOOL}, + {BF_AUTO_SLEEP_MODE, 0x0B, 0xF5, 1, 0, 0, B_UINT}, +}; + +static const bluetti_poll_t AC300_poll[] = { + {0x00, 0x0A, 0x28}, + {0x00, 0x46, 0x15}, + {0x0B, 0xDA, 0x01}, + {0x0B, 0xF5, 0x07}, + {0x00, 0x5B, 0x25}, +}; + +static const bluetti_field_t AC300_command[] = { + {BF_AC_OUTPUT_ON, 0x0B, 0xBF, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x0B, 0xC0, 1, 0, 0, B_BOOL}, +}; + +#endif // BLUETTI_DEVICE_AC300_H diff --git a/src/devices/Device_AC500.h b/src/devices/Device_AC500.h new file mode 100644 index 0000000..ffff73d --- /dev/null +++ b/src/devices/Device_AC500.h @@ -0,0 +1,36 @@ +/** + * Bluetti AC500 register map. + * Ported from Bluetti_ESP32_Bridge/Device_AC500.h (minimal/untested support). + */ +#ifndef BLUETTI_DEVICE_AC500_H +#define BLUETTI_DEVICE_AC500_H + +#include "BluettiFields.h" + +static const bluetti_field_t AC500_state[] = { + {BF_DEVICE_TYPE, 0x00, 0x0A, 7, 0, 0, B_STRING}, + {BF_SERIAL_NUMBER, 0x00, 0x11, 4, 0, 0, B_SN}, + {BF_ARM_VERSION, 0x00, 0x17, 2, 0, 0, B_VERSION}, + {BF_DSP_VERSION, 0x00, 0x19, 2, 0, 0, B_VERSION}, + {BF_DC_INPUT_POWER, 0x00, 0x24, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_POWER, 0x00, 0x25, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_POWER, 0x00, 0x26, 1, 0, 0, B_UINT}, + {BF_DC_OUTPUT_POWER, 0x00, 0x27, 1, 0, 0, B_UINT}, + {BF_POWER_GENERATION, 0x00, 0x29, 1, 1, 0, B_DECIMAL}, + {BF_TOTAL_BATTERY_PERCENT, 0x00, 0x2B, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_ON, 0x00, 0x30, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x00, 0x31, 1, 0, 0, B_BOOL}, +}; + +static const bluetti_poll_t AC500_poll[] = { + {0x00, 0x0A, 0x28}, + {0x00, 0x46, 0x15}, + {0x0B, 0xB9, 0x3D}, +}; + +static const bluetti_field_t AC500_command[] = { + {BF_AC_OUTPUT_ON, 0x0B, 0xBF, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x0B, 0xC0, 1, 0, 0, B_BOOL}, +}; + +#endif // BLUETTI_DEVICE_AC500_H diff --git a/src/devices/Device_EB3A.h b/src/devices/Device_EB3A.h new file mode 100644 index 0000000..dc9561d --- /dev/null +++ b/src/devices/Device_EB3A.h @@ -0,0 +1,41 @@ +/** + * Bluetti EB3A register map. + * Ported from Bluetti_ESP32_Bridge/DEVICE_EB3A.h (tested model). + */ +#ifndef BLUETTI_DEVICE_EB3A_H +#define BLUETTI_DEVICE_EB3A_H + +#include "BluettiFields.h" + +static const bluetti_field_t EB3A_state[] = { + {BF_DEVICE_TYPE, 0x00, 0x0A, 7, 0, 0, B_STRING}, + {BF_SERIAL_NUMBER, 0x00, 0x11, 4, 0, 0, B_SN}, + {BF_ARM_VERSION, 0x00, 0x17, 2, 0, 0, B_VERSION}, + {BF_DSP_VERSION, 0x00, 0x19, 2, 0, 0, B_VERSION}, + {BF_DC_INPUT_POWER, 0x00, 0x24, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_POWER, 0x00, 0x25, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_POWER, 0x00, 0x26, 1, 0, 0, B_UINT}, + {BF_DC_OUTPUT_POWER, 0x00, 0x27, 1, 0, 0, B_UINT}, + {BF_POWER_GENERATION, 0x00, 0x29, 1, 1, 0, B_DECIMAL}, + {BF_TOTAL_BATTERY_PERCENT, 0x00, 0x2B, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_ON, 0x00, 0x30, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x00, 0x31, 1, 0, 0, B_BOOL}, + {BF_AC_INPUT_VOLTAGE, 0x00, 0x4D, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_DC_INPUT_VOLTAGE, 0x00, 0x56, 1, 1, 0, B_DECIMAL}, + {BF_PACK_NUM_MAX, 0x00, 0x5B, 1, 0, 0, B_UINT}, +}; + +static const bluetti_poll_t EB3A_poll[] = { + {0x00, 0x0A, 0x28}, + {0x00, 0x46, 0x15}, + {0x0B, 0xDA, 0x01}, + {0x0B, 0xF4, 0x07}, +}; + +static const bluetti_field_t EB3A_command[] = { + {BF_AC_OUTPUT_ON, 0x0B, 0xBF, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x0B, 0xC0, 1, 0, 0, B_BOOL}, + {BF_POWER_OFF, 0x0B, 0xF4, 1, 0, 0, B_BOOL}, +}; + +#endif // BLUETTI_DEVICE_EB3A_H diff --git a/src/devices/Device_EP500.h b/src/devices/Device_EP500.h new file mode 100644 index 0000000..6f50ef1 --- /dev/null +++ b/src/devices/Device_EP500.h @@ -0,0 +1,36 @@ +/** + * Bluetti EP500 register map. + * Ported from Bluetti_ESP32_Bridge/Device_EP500.h (untested in the reference). + */ +#ifndef BLUETTI_DEVICE_EP500_H +#define BLUETTI_DEVICE_EP500_H + +#include "BluettiFields.h" + +static const bluetti_field_t EP500_state[] = { + {BF_DEVICE_TYPE, 0x00, 0x0A, 7, 0, 0, B_STRING}, + {BF_SERIAL_NUMBER, 0x00, 0x11, 4, 0, 0, B_SN}, + {BF_ARM_VERSION, 0x00, 0x17, 2, 0, 0, B_VERSION}, + {BF_DSP_VERSION, 0x00, 0x19, 2, 0, 0, B_VERSION}, + {BF_DC_INPUT_POWER, 0x00, 0x24, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_POWER, 0x00, 0x25, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_POWER, 0x00, 0x26, 1, 0, 0, B_UINT}, + {BF_DC_OUTPUT_POWER, 0x00, 0x27, 1, 0, 0, B_UINT}, + {BF_POWER_GENERATION, 0x00, 0x29, 1, 1, 0, B_DECIMAL}, + {BF_TOTAL_BATTERY_PERCENT, 0x00, 0x2B, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_ON, 0x00, 0x30, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x00, 0x31, 1, 0, 0, B_BOOL}, +}; + +static const bluetti_poll_t EP500_poll[] = { + {0x00, 0x0A, 0x28}, + {0x00, 0x46, 0x15}, + {0x0B, 0xB9, 0x3D}, +}; + +static const bluetti_field_t EP500_command[] = { + {BF_AC_OUTPUT_ON, 0x0B, 0xBF, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x0B, 0xC0, 1, 0, 0, B_BOOL}, +}; + +#endif // BLUETTI_DEVICE_EP500_H diff --git a/src/devices/Device_EP500P.h b/src/devices/Device_EP500P.h new file mode 100644 index 0000000..621ab79 --- /dev/null +++ b/src/devices/Device_EP500P.h @@ -0,0 +1,78 @@ +/** + * Bluetti EP500P register map. + * Ported from Bluetti_ESP32_Bridge/DEVICE_EP500P.h (tested model). + */ +#ifndef BLUETTI_DEVICE_EP500P_H +#define BLUETTI_DEVICE_EP500P_H + +#include "BluettiFields.h" + +static const bluetti_field_t EP500P_state[] = { + {BF_DEVICE_TYPE, 0x00, 0x0A, 7, 0, 0, B_STRING}, + {BF_SERIAL_NUMBER, 0x00, 0x11, 4, 0, 0, B_SN}, + {BF_ARM_VERSION, 0x00, 0x17, 2, 0, 0, B_VERSION}, + {BF_DSP_VERSION, 0x00, 0x19, 2, 0, 0, B_VERSION}, + {BF_DC_INPUT_POWER, 0x00, 0x24, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_POWER, 0x00, 0x25, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_POWER, 0x00, 0x26, 1, 0, 0, B_UINT}, + {BF_DC_OUTPUT_POWER, 0x00, 0x27, 1, 0, 0, B_UINT}, + {BF_POWER_GENERATION, 0x00, 0x29, 1, 1, 0, B_DECIMAL}, + {BF_TOTAL_BATTERY_PERCENT, 0x00, 0x2B, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_ON, 0x00, 0x30, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x00, 0x31, 1, 0, 0, B_BOOL}, + {BF_AC_OUTPUT_MODE, 0x00, 0x46, 1, 0, 0, B_UINT}, + {BF_INTERNAL_AC_VOLTAGE, 0x00, 0x47, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_CURRENT_ONE, 0x00, 0x48, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_POWER_ONE, 0x00, 0x49, 1, 0, 0, B_UINT}, + {BF_INTERNAL_AC_FREQUENCY, 0x00, 0x4A, 1, 2, 0, B_DECIMAL}, + {BF_INTERNAL_CURRENT_TWO, 0x00, 0x4B, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_POWER_TWO, 0x00, 0x4C, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_VOLTAGE, 0x00, 0x4D, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_CURRENT_THREE, 0x00, 0x4E, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_POWER_THREE, 0x00, 0x4F, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_FREQUENCY, 0x00, 0x50, 1, 2, 0, B_DECIMAL}, + {BF_INTERNAL_DC_INPUT_VOLTAGE, 0x00, 0x56, 1, 1, 0, B_DECIMAL}, + {BF_INTERNAL_DC_INPUT_POWER, 0x00, 0x57, 1, 0, 0, B_UINT}, + {BF_INTERNAL_DC_INPUT_CURRENT, 0x00, 0x58, 1, 1, 0, B_DECIMAL}, + {BF_PACK_NUM_MAX, 0x00, 0x5B, 1, 0, 0, B_UINT}, + {BF_INTERNAL_PACK_VOLTAGE, 0x00, 0x5C, 1, 1, 0, B_DECIMAL}, + {BF_PACK_BATTERY_PERCENT, 0x00, 0x5E, 1, 0, 0, B_UINT}, + {BF_PACK_NUM, 0x00, 0x60, 1, 0, 0, B_UINT}, + {BF_CELL01_VOLTAGE, 0x00, 0x69, 1, 2, 0, B_DECIMAL}, + {BF_CELL02_VOLTAGE, 0x00, 0x6A, 1, 2, 0, B_DECIMAL}, + {BF_CELL03_VOLTAGE, 0x00, 0x6B, 1, 2, 0, B_DECIMAL}, + {BF_CELL04_VOLTAGE, 0x00, 0x6C, 1, 2, 0, B_DECIMAL}, + {BF_CELL05_VOLTAGE, 0x00, 0x6D, 1, 2, 0, B_DECIMAL}, + {BF_CELL06_VOLTAGE, 0x00, 0x6E, 1, 2, 0, B_DECIMAL}, + {BF_CELL07_VOLTAGE, 0x00, 0x6F, 1, 2, 0, B_DECIMAL}, + {BF_CELL08_VOLTAGE, 0x00, 0x70, 1, 2, 0, B_DECIMAL}, + {BF_CELL09_VOLTAGE, 0x00, 0x71, 1, 2, 0, B_DECIMAL}, + {BF_CELL10_VOLTAGE, 0x00, 0x72, 1, 2, 0, B_DECIMAL}, + {BF_CELL11_VOLTAGE, 0x00, 0x73, 1, 2, 0, B_DECIMAL}, + {BF_CELL12_VOLTAGE, 0x00, 0x74, 1, 2, 0, B_DECIMAL}, + {BF_CELL13_VOLTAGE, 0x00, 0x75, 1, 2, 0, B_DECIMAL}, + {BF_CELL14_VOLTAGE, 0x00, 0x76, 1, 2, 0, B_DECIMAL}, + {BF_CELL15_VOLTAGE, 0x00, 0x77, 1, 2, 0, B_DECIMAL}, + {BF_CELL16_VOLTAGE, 0x00, 0x78, 1, 2, 0, B_DECIMAL}, + {BF_UPS_MODE, 0x0B, 0xB9, 1, 0, 0, B_UINT}, + {BF_GRID_CHARGE_ON, 0x0B, 0xC3, 1, 0, 0, B_BOOL}, + {BF_AUTO_SLEEP_MODE, 0x0B, 0xF5, 1, 0, 0, B_UINT}, +}; + +static const bluetti_poll_t EP500P_poll[] = { + {0x00, 0x0A, 0x28}, + {0x00, 0x46, 0x15}, + {0x0B, 0xDA, 0x01}, + {0x0B, 0xF5, 0x07}, + {0x00, 0x5B, 0x25}, +}; + +static const bluetti_field_t EP500P_command[] = { + {BF_AC_OUTPUT_ON, 0x0B, 0xBF, 1, 0, 0, B_BOOL}, + {BF_DC_OUTPUT_ON, 0x0B, 0xC0, 1, 0, 0, B_BOOL}, + {BF_GRID_CHARGE_ON, 0x0B, 0xC3, 1, 0, 0, B_BOOL}, + {BF_UPS_MODE, 0x0B, 0xB9, 1, 0, 0, B_UINT}, + {BF_AUTO_SLEEP_MODE,0x0B, 0xF5, 1, 0, 0, B_UINT}, +}; + +#endif // BLUETTI_DEVICE_EP500P_H diff --git a/src/devices/Device_EP600.h b/src/devices/Device_EP600.h new file mode 100644 index 0000000..8b5aa61 --- /dev/null +++ b/src/devices/Device_EP600.h @@ -0,0 +1,33 @@ +/** + * Bluetti EP600 register map (different page/offset scheme to the older models). + * Ported from Bluetti_ESP32_Bridge/Device_EP600.h (partial support). + * Refs: doc.chromedshark.com/bluetti/ep600.html, github.com/warhammerkid/bluetti_mqtt + */ +#ifndef BLUETTI_DEVICE_EP600_H +#define BLUETTI_DEVICE_EP600_H + +#include "BluettiFields.h" + +static const bluetti_field_t EP600_state[] = { + {BF_TOTAL_BATTERY_PERCENT, 0x00, 0x66, 1, 0, 0, B_UINT}, + {BF_DEVICE_TYPE, 0x00, 0x6E, 6, 0, 0, B_STRING}, + {BF_SERIAL_NUMBER, 0x00, 0x74, 4, 0, 0, B_SN}, + {BF_POWER_GENERATION, 0x00, 0x90, 1, 3, 0, B_DECIMAL}, + {BF_BATTERY_MIN_PERCENTAGE, 0x07, 0xE6, 1, 0, 0, B_UINT}, + {BF_AC_CHARGE_MAX_PERCENTAGE,0x07, 0xE7, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_POWER_MAX, 0x08, 0xA5, 1, 0, 0, B_UINT}, + {BF_AC_INPUT_CURRENT_MAX, 0x08, 0xA6, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_POWER_MAX, 0x08, 0xA7, 1, 0, 0, B_UINT}, + {BF_AC_OUTPUT_CURRENT_MAX, 0x08, 0xA8, 1, 0, 0, B_UINT}, +}; + +static const bluetti_poll_t EP600_poll[] = { + {0x00, 0x64, 0x3E}, + {0x07, 0xD0, 0x30}, + {0x08, 0x00, 0x29}, +}; + +// No verified writable controls for the EP600 in the reference project; the +// model registry registers a null command table with a count of zero. + +#endif // BLUETTI_DEVICE_EP600_H