Voltkeeper

Protocol Reverse-Engineering & Architecture Findings

Contents

  1. Overall Architecture
  2. BLE Communication Flow
  3. Encryption & Handshake
  4. Modbus RTU Protocol
  5. Register Address Map
  6. Device Model Hierarchy
  7. Data Flow — Bytes to Display
  8. MQTT Bridge
  9. CLI Commands Reference
  10. Field Types

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 prefixASCIIMeaningFlag
42 4c 55 45 54 54 49BLUETTIPlaintext (no encryption)plaintext
42 4c 55 45 54 54 45BLUETTEEncrypted (v1 — legacy challenge-response only)v1
42 4c 55 45 54 54 46BLUETTFEncrypted (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

CodeClassUse
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

CommandPurposeRequires 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):

TypeSize (regs)DescriptionExample output
UintField1Unsigned 16-bit integer85
Uint8Field1Single byte (hi or lo) of a register1
Uint32Field2Unsigned 32-bit integer (2 registers)15234
Signed32Field2Signed 32-bit integer (neg=export for AC power)145 or -50
DecimalField1Integer ÷ scale (e.g. scale=2 → val/100)26.3 (when raw=2630, scale=2)
Decimal32Field232-bit ÷ scale (for energy totals)1523.4
BoolField10 = off, any other value = on"ON" / "OFF"
EnumField1Integer → named mapping"Turbo"
StringFieldvariesASCII byte sequence, big-endian 16-bit words"AC2A"
SwapStringFieldvariesLike StringField but bytes swapped per word"24090001234"
VersionField2Software version: reg[0]=major.minor, reg[1]=major.minor"v2.1.0.3"
BcdSerialFieldvariesBCD-encoded serial number"24090001234"
TemperatureField1Raw value − 40 = °C35 (when raw=75)
SignedDecimalField1Signed 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.