Voltkeeper
Protocol Reverse-Engineering & Architecture Findings
1. Overall Architecture
The tool is a layered Python application that communicates with Bluetti power stations exclusively over Bluetooth Low Energy (BLE) . There is no Wi-Fi, serial, or USB path — all data flows through BLE GATT characteristics.
CLI Layer
cli.py (Click commands)
scan | status | write | probe | annotate | mqtt-publish | mqtt-listen | load-test | validate
Device Handler + EventBus
device_handler.py — BLE polling loop
bus.py — async pub/sub (ParserMessage / CommandMessage)
BLE Layer
bluetooth/
scan → classify encryption → build device → connect → handshake → execute modbus → notify
Core Protocol
commands.py (Modbus RTU) | struct.py (14 field types) | devices/ (V1Base, V2Base, concrete models)
Bluetti Power Station (BLE Peripheral)
Key Dependency Graph
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ Device │ │ Handshake │ │ Cipher │
│ Registry │ │ (crypto) │ │ AES-128-CBC │
└──────┬───────┘ └──────┬──────┘ └──────┬───────┘
│ │ │
└───────────────────┼──────────────────┘
│
┌──────┴──────┐
│ Encrypted │
│ Client │
└──────┬──────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌──────┴──────┐ ┌──────┴──────┐ ┌───────┴──────┐
│ V1 Base │ │ V2 Base │ │ Modbus │
│ (plaintext)│ │ (encrypt) │ │ RTU Frame │
└──────┬──────┘ └──────┬──────┘ └──────────────┘
│ │
┌──────┴────────────────┴──────┐
│ Concrete Device Models │
│ AC2A AC60 EP600 EB3A AC300 │
│ AC500 AC200L AC200M AC200PL│
└──────────────────────────────┘
2. BLE Communication Flow
All interaction goes through a single BLE GATT service with two characteristics:
Bluetti Service UUID
0000ff00-0000-1000-8000-00805f9b34fb
NOTIFY characteristic
UUID: 0000ff01-... (device → app)
Async notifications carrying Modbus responses
WRITE characteristic
UUID: 0000ff02-... (app → device)
Write-without-response Modbus commands
Notifications ← device
Commands → device
Connection Sequence
voltkeeper
BleakClient
Device
1. BLE Scan
discover(service_uuids=[ff00], return_adv=True)
2. Classify
Parse manufacturer data: "BLUETTI" = plaintext, "BLUETTE" = encrypted
3. Build Device
Parse BLE name regex → model prefix → instantiate device class
4. Bleak Connect
connected ←
5. Handshake
(encrypted models only — 2-stage ECDH key exchange)
Device: 2A 2A 01 + 4 random bytes
App: 2A 2A 02 04 + MD5-derived key fragment
Device: 2A 2A 04 + device ECDH pubkey + ECDSA sig
App: 2A 2A 05 80 + app ECDH pubkey + ECDSA sig
Device: 2A 2A 06 (confirm)
6. Start Notify
start_notify(ff01, handler)
7. Execute
write(ff02, modbus_frame)
← notify(ff01, modbus_response)
8. Disconnect
Encryption Detection via Manufacturer Data
The first 7 bytes of the BLE advertisement manufacturer data reveal the encryption state:
Hex prefix ASCII Meaning Flag
42 4c 55 45 54 54 49BLUETTI Plaintext (no encryption) plaintext
42 4c 55 45 54 54 45BLUETTE Encrypted (v1 — legacy challenge-response only) v1
42 4c 55 45 54 54 46BLUETTF Encrypted (v2 — legacy + ECDH key exchange) v2
3. Encryption & Handshake
Encrypted devices use AES-128-CBC with zero-padding and chained IV . The session key is established through a 2-stage handshake:
Path 1 — Legacy Challenge-Response
Device sends 4 random bytes
random_md5 = MD5(reverse(random_bytes)).hexdigest().upper()
initial_iv = MD5(random_md5.encode("ascii"))
digest_ascii = random_md5.encode("ascii")
extended = hardcoded_aes_key * 2 (32 bytes)
bleConnAESKey = XOR(digest_ascii, extended)[:16]
→ CbcSession(bleConnAESKey, initial_iv) for Path 2
Path 2 — ECDH Key Exchange
Device pubkey + ECDSA signature (encrypted)
Verify: ECDSA.verify(device_pub, sig, PUBKEY_K2)
Generate ephemeral SECP256R1 keypair
Sign app_pubkey with PRIVATE_KEY_L1
App sends pubkey + sig (encrypted with bleConnAESKey)
shared_key = ECDH(app_priv, device_pub)[:16]
→ CbcSession(shared_key, initial_iv) for Modbus traffic
CbcSession — AES-128-CBC with Chained IV
encrypt(p): zero-pad plaintext to 16B → AES-128-CBC(key, iv) → iv = ct[-16:] → return ciphertext
decrypt(ct): validate len % 16 → AES-128-CBC-decrypt(key, iv) → iv = ct[-16:] → return full plaintext (with zero-padding)
Critical Design Note
decrypt() does NOT strip trailing zeros — Modbus register values/CRC bytes legitimately end in 0x00. The upper layer determines payload length from protocol fields.
Hardcoded Crypto Constants (recovered via JADX):
LOCAL_AES_KEY = 459FC535808941F17091E0993EE3E93D
PRIVATE_KEY_L1 (SECP256R1) + PUBLIC_KEY_K2 (DER-encoded ECDSA verification key)
4. Modbus RTU Protocol
All device communication uses Modbus RTU frames tunneled through BLE GATT writes/notifications. Only function codes 0x03 (Read Holding Registers), 0x06 (Write Single Register), and 0x10 (Write Multiple Registers) are used.
Read Holding Registers — Request Frame (8 bytes)
01
Slave addr
03
Func code
00 64
Start addr
00 06
Quantity
XX XX
CRC16
Address & quantity are
big-endian 16-bit (struct.pack("!HH"))
CRC is little-endian 16-bit
(poly 0xA001, init 0xFFFF)
Example: ReadHoldingRegisters(100, 6)
bytes = b'\x01\x03\x00\x64\x00\x06\xc4\x1e'
slave=1 fn=3 addr=100 qty=6 crc=0x1ec4
→ reads registers 100–105: pack voltage, current, SOC, charging status, time-to-full, time-to-empty
Response Frame
[0x01] [0x03] [byte_count] [register_bytes...] [crc_lo] [crc_hi]
byte_count = 2 × quantity
Total size = 2 × qty + 5 bytes
Function Codes Used
Code Class Use
0x03ReadHoldingRegistersRead device state, identity, temperatures, power values
0x06WriteSingleRegisterToggle AC/DC output, change charging mode, set thresholds
0x10WriteMultipleRegistersWrite multi-register values atomically
Exception detection: if the response function code equals request_fn + 0x80, a ModbusError is raised. Control registers (2000+) may return exceptions on read because some are write-only on certain models.
5. Register Address Map
The register space is organized into blocks. V1 and V2 devices use different layouts:
V1 Register Layout (EB3A, AC200M, AC300, AC500, AC200L)
Reg 1–9
Config, BT password
Reg 10–99 BASE_REAL_DATA (110 registers)
Protocol version(16), SN(21–24), Software versions(23–34)
PV/Grid/AC/DC power(36–41), Battery SOC/voltage(43–)
Alarm info(54–57), Fault info(58–64)
BMS pack data(91–), 3-phase data(130–), PV charge(157–)
Reg 3000–3091 SETTABLE_DATA (writable controls)
AC/DC switch, PV control, grid charging, LED, ECO mode, charging mode choice
Reg 4997– BLE MAC / Reg 5000– IoT/WiFi status
V2 Register Layout (AC2A, AC60, EP600)
Reg 100–167 HOME_DATA (62 regs)
Pack voltage/current/SOC, charging status, model name,
SN, power values (PV/AC/DC/Grid), energy totals, charging mode
Reg 1100–1153 INV_BASE (51 regs)
Inverter ID/type/SN, temperatures, software versions, rated currents
Reg 1200–1269 INV_PV (70 regs)
PV string power/voltage/current, type, working status
Reg 1300–1330 INV_GRID (31 regs)
Reg 1400–1447 INV_LOAD (48 regs)
Reg 1500–1529 INV_INV (30 regs)
Reg 2000–2086 BASE_SETTINGS (writable)
AC/DC output, power off, ECO modes, charging mode, power lifting,
battery range, alarm sound, LED color, SOC thresholds
Reg 2200–2211 ADVANCE_SETTINGS (writable)
Factory reset, inverter voltage, inverter frequency
V2 Parse Dispatch (v2_base.py lines 161–176)
100–1099 → home | 1100–1199 → inv_base | 1200–1299 → pv
1300–1399 → grid | 1400–1499 → load | 1500–1999 → inv | 2000–2299 → control
Polling Strategy
V1 devices: One large read: ReadHoldingRegisters(10, 110) — dumps nearly all data in a single BLE exchange.
V2 devices: Six targeted reads, one per block — allows partial refresh and reduces BLE overhead:
ReadHoldingRegisters(100, 62) → home data
ReadHoldingRegisters(1100, 51) → inverter base info
ReadHoldingRegisters(1200, 70) → PV panel data
ReadHoldingRegisters(1300, 31) → grid data
ReadHoldingRegisters(1400, 48) → load data
ReadHoldingRegisters(1500, 30) → inverter output info
6. Device Model Hierarchy
BluettiDevice
(abstract base)
V1Base
protocol_version=0 | plaintext-only | single-block polling
V2Base
protocol_version≥2000 | encrypted | multi-block polling
EB3A
AC200M
AC300
AC500
AC200L
AC200PL
AC2A
AC60
EP600
V1 vs V2 Key Differences
V1Base
Protocol version 0 | Always plaintext | 1 polling command (110 regs)
Control regs at 3000–3091 | Alarm/fault tables | Writable fields vary per device
Software versions: 6 fields mapped into softwareVerInfo struct
Alarm word (4 regs) + Fault word (7 regs) with isLowPower dispatch
V2Base
Protocol version 2000 | May be encrypted | 6 polling commands per block
Control regs at 2000–2087 + 2200–2211 | Split into BLOCK1 + BLOCK2
Array-style sub-registers: pv[0..4], gridPhase[0..2], load/acPhase[0..2]
No alarm/fault tables (N/A in V2 protocol)
7. Data Flow — Bytes to Display
1. Modbus RTU Frame Construction
struct.pack("!HH", addr, qty) → concat [0x01, 0x03, ...] → append CRC16(data, poly=0xA001)
2. Encryption (if needed)
Zero-pad frame to 16B boundary → AES-128-CBC encrypt with chained IV → update IV to last block
3. BLE GATT Write → Device
write_gatt_char(WRITE_UUID "ff02", ciphertext, response=False) — write without response
4. BLE Notification ← Device
_on_notification handler accumulates fragments → validate CRC → check for Modbus exception (fn+0x80)
5. Decryption (if needed) → Trim to Response Size
AES-128-CBC decrypt → slice to cmd.response_size() (removes zero-padding) → CRC revalidation
6. Struct Parsing — Raw Bytes → Python dict
For each field whose address range falls within [starting, starting+data_size]:
offset = 2 × (field.address - starting_address)
field_bytes = data[offset : offset + 2*field.size] → field.parse(field_bytes)
CLI Display
Battery SOC: 85 %
Pack Voltage: 26.3 V
Charging Status: Charging (1)
Time to Full: 150 min
DC Load: 12 W
MQTT Publish
bluetti/state/AC2A-12345/total_battery_percent = 85
bluetti/state/AC2A-12345/total_battery_voltage = 26.3
bluetti/state/AC2A-12345/pack_charging_status = CHARGING
bluetti/state/AC2A-12345/time_to_full_minutes = 150
bluetti/state/AC2A-12345/dc_output_power = 12
All register fields use typed struct definitions (UintField, Signed32Field, BoolField, EnumField, etc.)
8. MQTT Bridge
DeviceHandler
BLE polling loop
retry on disconnect
device_handler.py
EventBus
async pub/sub
asyncio.Queue
bus.py
MQTTClient
publish state
subscribe commands
mqtt_client.py
ParserMessage
ParserMessage
CommandMessage (write from MQTT)
MQTT Broker (e.g., mosquitto, Home Assistant)
hostname:port with optional username/password
State Topics (publish)
bluetti/state/{type}-{sn}/total_battery_percent
bluetti/state/{type}-{sn}/total_ac_power
bluetti/state/{type}-{sn}/charging_mode
bluetti/state/{type}-{sn}/ac_output_on
All values published with retain flag
Command Topics (subscribe)
bluetti/command/{type}-{sn}/ac_output
bluetti/command/{type}-{sn}/dc_output
bluetti/command/{type}-{sn}/charging_mode
bluetti/command/# (wildcard subscription)
Payloads: "ON"/"OFF" for bool, "TURBO" for enum, "85" for numeric
Home Assistant Auto-Discovery
When --ha-config normal (default), the bridge publishes MQTT discovery configs so devices appear automatically in Home Assistant — no YAML configuration needed:
homeassistant/sensor/{sn}_total_battery_percent/config ← JSON discovery payload
homeassistant/switch/{sn}_ac_output_on/config
homeassistant/select/{sn}_charging_mode/config
homeassistant/number/{sn}_soc_low/config
Discovery config includes: state_topic, command_topic (for writable fields), device info (manufacturer/model/SN), unique_id, unit_of_measurement, device_class, state_class.
9. CLI Commands Reference
Command Purpose Requires BLE? Key flags
voltkeeper scan
Scan for nearby Bluetti devices via BLE advertisement
Yes
--timeout (default 10s)
voltkeeper status [ADDRESS]
Read battery SOC, voltage, charging status, and loads
Yes
--verbose (full status), --timeout
voltkeeper write ADDRESS FIELD VALUE
Write a writable register (toggle outputs, change mode)
Yes
Field depends on device model
voltkeeper probe ADDRESS
Active register sweep → draft YAML profile (for new devices)
Yes
--output (default profile.yaml)
voltkeeper annotate ADDRESS
Live-poll and interactively label changing register values
Yes
--output (default draft.yaml)
voltkeeper mqtt-publish ADDRESS --broker HOST
Continuous BLE → MQTT bridge with HA auto-discovery
Yes
--serial, --port, --interval, --ha-config
voltkeeper mqtt-listen --broker HOST
Watch battery SOC via MQTT and trigger system shutdown (Linux only)
No
--serial, --shutdown-at, --grace-period
voltkeeper load-test ADDRESS
Battery discharge characterization — CSV logging
Yes
--output, --interval, --expected-load
voltkeeper validate-profile YAML
Validate probe YAML against field sanity checks (offline)
No
Stuck-at-0, stuck-at-0xFFFF, out-of-range detection
voltkeeper mqtt-publish-service ADDRESS --broker HOST
Generate systemd unit file for mqtt-publish
No
--user, --output, --exec
voltkeeper mqtt-listen-service --serial SERIAL --broker HOST
Generate systemd unit file for mqtt-listen (Linux only)
No
--shutdown-at, --grace-period, --output
10. Field Types
The Modbus register bytes are decoded using 14 typed field definitions (struct.py):
Type Size (regs) Description Example output
UintField1 Unsigned 16-bit integer 85
Uint8Field1 Single byte (hi or lo) of a register 1
Uint32Field2 Unsigned 32-bit integer (2 registers) 15234
Signed32Field2 Signed 32-bit integer (neg=export for AC power) 145 or -50
DecimalField1 Integer ÷ scale (e.g. scale=2 → val/100) 26.3 (when raw=2630, scale=2)
Decimal32Field2 32-bit ÷ scale (for energy totals) 1523.4
BoolField1 0 = off, any other value = on "ON" / "OFF"
EnumField1 Integer → named mapping "Turbo"
StringFieldvaries ASCII byte sequence, big-endian 16-bit words "AC2A"
SwapStringFieldvaries Like StringField but bytes swapped per word "24090001234"
VersionField2 Software version: reg[0]=major.minor, reg[1]=major.minor "v2.1.0.3"
BcdSerialFieldvaries BCD-encoded serial number "24090001234"
TemperatureField1 Raw value − 40 = °C 35 (when raw=75)
SignedDecimalField1 Signed 16-bit ÷ scale -5.2
Last updated: May 2026 | Built from reverse-engineering the official Bluetti Android APK using JADX, Frida, and BLE snoop logs.