tggo.github.io/jkbms-poll — project page with the full story, install snippets, and a sample readout.
A small Go program that connects to a JK-BMS over Bluetooth Low Energy, reads one cell-info frame, and writes it as JSON to a file. One-shot — connect, read, exit.
Built for embedding in a polling cron / systemd timer on a Linux host that sits near the BMS (Raspberry Pi, OpenWRT box, ESP-running-Linux, …) and having the JSON consumed by Home Assistant, Grafana, scripts, whatever.
- Protocol: JK02_32S (modern firmware, 4S–32S, including JK-B2A8S20P, JK-B2A16S15P, JK-PB2A16S20P, etc.)
- Connection: BlueZ on Linux, via
tinygo.org/x/bluetooth. - Output: one JSON file per invocation, with parsed fields and the full raw 300-byte frame in hex (so re-parsing later, after firmware layout changes, stays possible).
Auto-detects arch (amd64 / arm64 / armv7 / armv6), downloads the
matching binary from the latest release, verifies SHA-256, installs to
/usr/local/bin/jkbms-poll:
curl -fsSL https://raw.githubusercontent.com/tggo/jkbms-poll/main/install.sh | shPin a version, or change the install dir:
curl -fsSL https://raw.githubusercontent.com/tggo/jkbms-poll/main/install.sh | sh -s -- v0.1.0
curl -fsSL https://raw.githubusercontent.com/tggo/jkbms-poll/main/install.sh | INSTALL_DIR=$HOME/bin shgo install github.com/tggo/jkbms-poll@latestThe binary lands in $GOBIN (or $GOPATH/bin, or $HOME/go/bin). Linux
only — the tinygo.org/x/bluetooth BlueZ backend uses fields that don't
exist on Darwin/Windows, so main.go is gated with //go:build linux.
On other platforms go install produces nothing useful.
git clone https://github.com/tggo/jkbms-poll
cd jkbms-poll
go build -o jkbms-poll ./... # native (Linux only)
GOOS=linux GOARCH=arm64 go build ./... # cross-compile from a Mac
make build # default linux/arm64
make build GOARCH=amd64 # other archesGrab a binary from Releases, chmod +x, drop it in $PATH.
SHA-256 sums in SHA256SUMS.
Tests are platform-independent: go test ./... works anywhere.
./jkbms-poll -mac AA:BB:CC:DD:EE:FF -cells 8 -out /tmp/jkbms.json-mac is required (no built-in default). Find it with bluetoothctl scan le
on the host while you're near the BMS, or in the JK BMS phone app.
Useful flags:
| Flag | Default | Notes |
|---|---|---|
-mac |
(required) | BMS BLE address. Also JKBMS_MAC env var. |
-cells |
8 |
Number of cells in your pack (1..32). |
-out |
/tmp/jkbms.json |
Output JSON path. Also JKBMS_OUT. |
-timeout |
90s |
Total budget for scan + connect + first frame. |
-log |
info |
debug / info / warn / error. |
-log-json |
off | Emit logs as JSON (slog JSONHandler). |
The program exits 0 once the JSON is written, non-zero on any error (scan timeout, connect failure, parse failure, …). Wire it into systemd or cron for periodic polling.
$ jkbms-poll -mac C8:47:80:14:CC:CC -log debug
INFO starting pid=17926 timeout=1m30s mac=C8:47:80:14:CC:CC cells=8
DEBUG adapter enabled
INFO scan starting budget=1m10s
DEBUG scan advert (new device) addr=C8:47:80:14:CC:CC rssi=-65 name=Second_24v
INFO scan locked target rssi=-65
INFO connect ok attempt=1 took=420ms best_rssi=-65
INFO services discovered count=4
INFO FFE1 locked
INFO frame complete type=0x02 counter=0xac crc_byte=0xd8 leftover=0
INFO parsed pack v=53.224 a=31.881 w=1696.8 soc=25 t1=13.4 t2=12.8 mos=12.9
wrote /tmp/jkbms.json (V=53.224 I=31.881A SOC=25% Δ=0.017V crc_ok=true)
- Scan until BlueZ has seen an advert from the target MAC (BlueZ won't let you connect to a device path it hasn't observed recently).
- Connect with retries (BLE at the edge of range often aborts the first handshake locally).
- Discover GATT services, lock characteristic
0xFFE1(the JK BMS uses an HM-10 / Bluetrum UART-over-BLE module: write+notify onFFE1inside serviceFFE0). - Subscribe to notifications and write a 20-byte
REQUEST_CELL_INFOcommand (AA 55 90 EB 96 00…00 + sum_mod_256). - Reassemble notifications (BLE chunks ~20 B each) into a 300-byte
frame keyed by the
55 AA EB 90start sequence. - Parse the JK02_32S layout into the schema above and write JSON.
- Exit.
Field offsets are mirrored from
syssi/esphome-jk-bms
(components/jk_bms_ble/jk_bms_ble.cpp, decode_jk02_cell_info_).
parse_test.go ships a real captured frame (from the upstream test
fixtures, JK_PB2A16S15P running fw 15.38) and asserts the parsed
fields against upstream's known-good values — voltages, current,
temperatures, SOC, capacity, cycle count, runtime, MOSFET state.
Run with:
go test ./...If you hit a frame that doesn't parse cleanly on different firmware,
the raw_frame_hex in the output is enough to add a new fixture and
extend the parser.
- JK04 firmware is not supported — only JK02_32S. JK04 is a different layout and isn't on my BMS to test against.
- Read-only — the program never sends control commands (no MOSFET
toggles, no settings writes). Adding writes is straightforward (the
buildRequestbuilder already produces a valid command frame) but intentionally not exposed. - Single shot — there's no built-in daemon mode. Run it on a timer. This keeps the BLE stack on the BMS side rested between polls and recovers cleanly from disconnects.
- Range-bound by BlueZ — at RSSI below roughly -85 dBm, BlueZ tends
to abort connections locally (
le-connection-abort-by-local). If the host can't be moved closer to the BMS, an ESP32 running ESPHome'sbluetooth_proxyis a far better solution than a stronger Pi antenna.
MIT. See LICENSE.
- The JK02_32S protocol layout, reverse-engineered and maintained by the
syssi/esphome-jk-bmscontributors. This project's parser is a Go port of theirs and uses their test fixtures as ground truth. tinygo.org/x/bluetoothfor the BLE plumbing.
{ "timestamp_unix": 1715300000, "timestamp_iso": "2026-05-10T00:13:20Z", "bms_address": "AA:BB:CC:DD:EE:FF", "frame_type": 2, "frame_counter": 172, "crc_ok": true, "num_cells": 8, "cell_voltages_v": [3.333, 3.326, ...], "cell_resistances_mohm":[0.064, 0.061, ...], "enabled_cell_mask": 255, "cell_avg_v": 3.328, "cell_min_v": 3.320, "cell_max_v": 3.337, "cell_delta_v":0.017, "cell_min_num":13, // 1-based "cell_max_num":16, // 1-based "battery_voltage_v": 53.224, "battery_current_a": 31.881, // + = charge, - = discharge "battery_power_w": 1696.8, "power_tube_temp_c": 12.9, "t1_c": 13.4, "t2_c": 12.8, "balance_current_a": 0.0, "balance_status": 0, // 0=off, 1=charging-bal, 2=discharging-bal "error_bitmask": 0, "soc_percent": 25, "soh_percent": 100, "remaining_capacity_ah":49.286, "nominal_capacity_ah": 200.000, "cycle_count": 9, "cycle_capacity_ah": 1.853, "total_runtime_s": 24530060, "charging": true, "discharging": true, "raw_frame_hex": "55aaeb9002ac…" }