Golf Cart BMS — Reverse Engineering the Wrong Radio for Two Weeks

June–July 2026

The campsite golf cart is an Evolution with a 15-cell LiFePO₄ pack and a battery management system that talks Bluetooth to a phone app. I wanted the pack state — voltage, state of charge, per-cell voltages, temperatures — in Home Assistant, next to everything else at the camper. Standard fare: sniff the BLE protocol, reimplement it, publish to HA.

It took about two weeks longer than it should have, because I spent most of that time reverse-engineering a battery monitor that isn’t the battery monitor.

This is a note about a specific, humbling failure mode: there was more than one BLE device near the cart, one of them looked exactly like the answer, and it was a decoy.


The decoy

Scanning near the cart, one device stood out immediately: it advertised as “PMS …”, identified as Tuner168 firmware, and — the hook — it was auto-broadcasting a CRC-valid Modbus frame about once a second. Modbus! A valid CRC! This was obviously the BMS.

I decompiled the Android app (BBMS, by a Chinese firm called Ginkgo) and pulled the whole protocol out of it. Standard Modbus-RTU over a BLE serial bridge: the app writes a read-request to a characteristic, the BMS answers with registers, big-endian uint16s, a clean field map — cell count, total voltage, current, SOC, SOH, cycles, temperatures, per-cell voltages. Everything I needed was documented in that APK. I just had to send the request.

The PMS device ignored every request. I tried every register range, both BLE write modes, correct CRCs, every characteristic on the device. It never answered a single query. It just kept streaming that one autonomous frame — which, when I finally decoded it properly, turned out to carry a limited status (a charge/discharge flag and a current-correlated register) and not the cells, voltage, or SOC. There were zero byte-pairs anywhere in the plausible cell-voltage range across long captures. The frame that hooked me was real Modbus and completely useless.

I built a honeypot, a fuzzer, dumped the entire GATT table, chased a theory that the real telemetry was on the CAN bus behind it. All aimed at the PMS. All dead ends. The device is genuinely write-only in the sense that mattered: it broadcasts a status heartbeat and honors no request.

Meanwhile, sitting right next to it in every scan, was a second device I had explicitly written off in my own notes as “a passive, poll-only dead end — red herring."


"Poll-only” doesn’t mean what I thought

The second device advertised as a generic “HC-08” serial module and identified as “DEL18CAN”. I’d dismissed it because it never broadcasts anything — it just sits there silently. My notes literally called it a red herring.

Here’s the thing I got backwards: poll-only means you have to poll it, and it answers. The PMS shouts status nobody asked for and refuses every question. The HC-08 says nothing until you ask, then tells you everything. I had the personalities of the two radios exactly inverted.

The way I finally figured out which device the working app actually talks to was to capture a real session from it. No Mac, no jailbreak, no BLE sniffer hardware — iOS has a Bluetooth logging profile you can install, after which a sysdiagnose bundle contains a full HCI packet log (bluetoothd-hci-latest.pklg) that opens directly in Wireshark. I ran the iOS BBMS app against the cart, grabbed the sysdiagnose, and watched the truth in tshark:

The app connected to the HC-08 / DEL18CAN. It never touched the PMS. It sent plain Modbus. It got full answers. No pairing, no bonding, no security handshake at all.


The actual protocol

Modbus-RTU over the HC-08’s single write-plus-notify characteristic:

  • Request (write, no response expected): 01 03 00 00 00 50 45 F6 — read 80 registers starting at 0. The trailing two bytes are a standard CRC-16/MODBUS, low byte first.
  • Response: 01 03 A0 <160 bytes> <CRC> — 80 big-endian uint16 registers, streamed across about nine BLE notifications that you reassemble and validate against the frame CRC.

The field map, validated against the phone app’s own readout:

RegisterFieldRegisterField
2cell count (15)6state of health (÷10)
3total voltage (÷10)7cycle count
4current (÷10, signed, + = charging)22–27six temperatures
5state of charge (%)33+per-cell voltages (mV)

The satisfying validation: the 15 per-cell voltages summed to exactly the total-voltage register. First clean live read — 49.9 V, SOC 99%, SOH 99.8%, 30 cycles, cells all within 7 mV of each other — cross-checked against the phone app to the decimal.


Into Home Assistant

The pack now reads end-to-end into HA. The gazebo already has an ESP32 running ESPHome (it’s the fan proxy), and it’s the nearest always-on radio to where the cart parks, so it does double duty: a passive presence/RSSI watch for the cart, and a ble_client that connects, polls, reassembles the notifications, parses the register map, and disconnects. The poll rate adapts — fast when the cart just showed up, slow when it’s idle or charging, backing off on failure. From there it bridges over MQTT to home HA as a device called “Campsite Golf Cart” — an SOC gauge and tiles on the camper dashboard.

A few field truths worth knowing, because they shape what “working” looks like:

  • The BMS mostly only answers while the cart is charging or the ignition is on. An idle cart puts the module to sleep — presence drops, polls fail. That’s not a bug; it’s the pack conserving itself. The retained MQTT values keep the last-known state visible at home while the cart naps.
  • Signal at the gazebo is marginal (−85 to −100 dBm). Connections fail at the weak end. The poll forces the ESP32’s BLE transmit power up to +9 dBm to claw back some margin; a laptop at −96 dBm failed the same way, so it’s RF, not the ESP.
  • One central at a time. The phone app and the ESP32 lock each other out, so the ESP keeps its connect-read-disconnect window as short as possible.

The lesson, stated plainly

When you’re reverse-engineering something with a radio, confirm which radio the working client actually uses before you spend a single hour on the protocol. The most interesting-looking device — the one broadcasting valid frames unprompted — was a decoy, and the real answer was the boring silent one I’d already dismissed. Capturing one genuine session from the tool that works would have saved me two weeks. On the next one, that’s step zero.

Wesley Ray · blog · git · resume