A small Linux tool · BLE · LiFePO4 · 2026

Read your battery like it's wired in over Bluetooth.

A one-shot Go binary that connects to a JK-BMS over Bluetooth Low Energy, reads one cell-info frame, and writes JSON. Built for off-grid solar setups where the BMS has no cable — only BLE — and you still want graphs in Grafana, sensors in Home Assistant, alerts on Telegram.

Protocol
JK02_32S4S–32S firmware
Runtime
~3 MBstatic Linux binary
Output
JSON30 fields + raw hex
Stack
Go + BlueZcron-friendly, one-shot
Section i · The cable that isn't there

Some BMS only
speak Bluetooth.

A modern JK-BMS will happily report every cell voltage, the pack current, MOSFET temperature, balance state, and SOC — through its phone app, over BLE. There is no RS485, no CAN, no UART pad you can solder to. The only way in is the Bluetooth radio.

That's fine while you're standing next to the battery with the JK app open. It's not fine when the pack lives in a shed, runs an inverter you'd like Home Assistant to know about, and you'd rather not babysit it from your phone.

“The whole point of a battery monitor is that you don't need to be near it.”
Fig. 1 — JK-B2A8S20P interfaces JK-BMS B2A8S20P · 8S · 200A 8 cell taps BLE no RS485 no CAN — only this gets out —
Section ii · The shape of the thing

A pipe from BMS to JSON,
nothing more.

Run it as a cron job from any Linux host with a Bluetooth radio that's in range of the battery — a Raspberry Pi, an OpenWrt router, a salvaged laptop. It scans, connects, reads one frame, writes a file, exits. Polling and persistence happen elsewhere; the binary stays small, single-purpose, and recovers from whatever cleanly by simply running again.

JK-BMS
advertises on FFE0/FFE1
BLE link
~20-byte chunks
Linux + BlueZ
Pi, router, anything
JSON
/tmp/jkbms.json
HA / Grafana
consume the file
Section iii · Install

Three ways in.

Pick whichever fits the host. The script is the fastest. Source build gives you full control. go install is for Go shops. All three land the same Linux binary; the BLE backend won't cross-compile to macOS or Windows.

Quick install · curl | shrecommended
# auto-detects amd64 / arm64 / armv7 / armv6,
# verifies SHA-256, installs to /usr/local/bin
$ curl -fsSL https://raw.githubusercontent.com/\
   tggo/jkbms-poll/main/install.sh | sh

# pin a version, or change install dir
$ curl -fsSL …/install.sh | sh -s -- v0.1.0
$ curl -fsSL …/install.sh | INSTALL_DIR=$HOME/bin sh
go installlinux only
# lands in $GOBIN (or $HOME/go/bin)
$ go install github.com/tggo/jkbms-poll@latest

# cross-build from a Mac for a Pi
$ GOOS=linux GOARCH=arm64 \
    go build -o jkbms-poll \
    github.com/tggo/jkbms-poll@latest
From sourcegit
$ git clone https://github.com/\
   tggo/jkbms-poll
$ cd jkbms-poll
$ make test         # parser unit tests
$ make build        # cross-compile arm64
$ make deploy       # scp to $DEPLOY_HOST

Then, with the MAC of your BMS:

One-shot run
$ JKBMS_MAC=AA:BB:CC:DD:EE:FF jkbms-poll -log debug
level=INFO  msg="scan locked target" rssi=-65
level=INFO  msg="connect ok" attempt=1 took=420ms
level=INFO  msg="parsed pack" v=53.224 a=31.881 soc=25 t1=13.4
wrote /tmp/jkbms.json (V=53.224 I=31.881A SOC=25% Δ=0.017V crc_ok=true)
Section iv · What lands in the file
Pack Alpha · 8S LiFePO4 streaming
Pack voltage
53.224V
Current
+31.881A · charging
SOC
25%
Power
1.70kW
T1 / T2
13.4 / 12.8°C
MOS temp
12.9°C
Cells (mV)Δ 17 mV
#1
3.333
#2
3.326
#3
3.326
#4
3.329
#5
3.329
#6
3.325
#7
3.323
#8
3.337
/tmp/jkbms.json just now

30 fields,
plus the raw frame.

Every poll writes a flat JSON object — pack-level numbers, per-cell voltages and resistances, balance state, MOSFET state, temperatures, SOC / SOH / capacity / cycle count / total runtime. Plus raw_frame_hex, the full 300-byte frame, so when JK ships firmware that nudges an offset by two bytes you can re-parse the last six months of poll history without a working radio.

Below is the literal output of one run, trimmed for readability. The CRC field, the schema version, and a UTC timestamp travel with every record.

{
  "timestamp_iso":    "2026-05-10T00:13:20Z",
  "bms_address":      "AA:BB:CC:DD:EE:FF",
  "crc_ok":           true,
  "battery_voltage_v": 53.224,
  "battery_current_a": 31.881,    // +charge / -discharge
  "soc_percent":      25,
  "cell_voltages_v":  [3.333, 3.326, 3.326, …],
  "cell_min_v":       3.323,
  "cell_max_v":       3.337,
  "cell_delta_v":     0.017,
  "power_tube_temp_c": 12.9,
  "t1_c": 13.4, "t2_c": 12.8,
  "cycle_count":      9,
  "raw_frame_hex":    "55aaeb9002ac…"
}
Section v · What people do with it

Five places
this earns its keep.

i.

Off-grid solar shed

The pack is in a shed twenty metres from the house. A Pi Zero 2 W on the wall behind the inverter polls every minute, drops JSON in a tmpfs, MQTT publishes it to a broker on the main router. From the kitchen you can see a cell going out of balance before it starts dragging the others down.

ii.

Second battery, no spare port

Your inverter only talks to the main pack over RS485. The expansion pack has a JK BMS with BLE only. Drop a tiny Linux box near the second battery, run jkbms-poll on a timer, surface SOC and per-cell voltages in the same Home Assistant dashboard as the inverter.

iii.

Long-term cell-drift logging

Cron writes each frame to disk with a timestamp. Six months later you have a per-cell voltage timeline that tells you exactly which cell is ageing fastest, when imbalance starts climbing under load, and whether the balancer is actually doing anything between cycles.

iv.

Camper-van & boat batteries

A budget BLE-only BMS in a vehicle is the norm. Run jkbms-poll on the existing Linux router, expose a tiny dashboard over Wi-Fi. When you're 200 km from a hardware store, knowing whether you're chasing a balance issue or a real cell failure is the difference.

v.

Telegram alerts on cell faults

One cron, one tiny shell script: read the JSON, if cell_delta_v > 50 mV or any error_bitmask bit flips, fire a Telegram message. No cloud account, no app subscription, no proprietary integration — one local file, parsed.

vi.

Offline replay & debugging

The full 300-byte frame travels with every record as raw_frame_hex. When JK pushes new firmware that nudges a field offset, re-parse historical logs locally with the next version of jkbms-poll. Your data is never trapped behind a schema change.