{"id":50520317,"url":"https://github.com/tggo/jkbms-poll","last_synced_at":"2026-06-03T03:31:17.511Z","repository":{"id":356836548,"uuid":"1234264608","full_name":"tggo/jkbms-poll","owner":"tggo","description":"One-shot JK-BMS BLE poller in Go — scans, connects, reads one JK02_32S cell-info frame, writes JSON, exits. For Pi/Linux + Home Assistant.","archived":false,"fork":false,"pushed_at":"2026-05-10T00:54:59.000Z","size":36,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-10T02:40:23.498Z","etag":null,"topics":["ble","bluetooth","bms","golang","home-assistant","jk-bms","lifepo4","raspberry-pi","solar"],"latest_commit_sha":null,"homepage":"https://tggo.github.io/jkbms-poll/","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tggo.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-10T00:38:45.000Z","updated_at":"2026-05-10T00:55:03.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tggo/jkbms-poll","commit_stats":null,"previous_names":["tggo/jkbms-poll"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/tggo/jkbms-poll","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tggo%2Fjkbms-poll","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tggo%2Fjkbms-poll/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tggo%2Fjkbms-poll/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tggo%2Fjkbms-poll/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tggo","download_url":"https://codeload.github.com/tggo/jkbms-poll/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tggo%2Fjkbms-poll/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33847264,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-03T02:00:06.370Z","response_time":59,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ble","bluetooth","bms","golang","home-assistant","jk-bms","lifepo4","raspberry-pi","solar"],"created_at":"2026-06-03T03:31:15.475Z","updated_at":"2026-06-03T03:31:17.498Z","avatar_url":"https://github.com/tggo.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# jkbms-poll\n\n**[tggo.github.io/jkbms-poll](https://tggo.github.io/jkbms-poll/)** — project page with the full story, install snippets, and a sample readout.\n\nA small Go program that connects to a JK-BMS over Bluetooth Low Energy, reads\none cell-info frame, and writes it as JSON to a file. One-shot — connect,\nread, exit.\n\nBuilt for embedding in a polling cron / systemd timer on a Linux host that\nsits near the BMS (Raspberry Pi, OpenWRT box, ESP-running-Linux, …) and\nhaving the JSON consumed by Home Assistant, Grafana, scripts, whatever.\n\n## What's supported\n\n- **Protocol:** JK02_32S (modern firmware, 4S–32S, including JK-B2A8S20P,\n  JK-B2A16S15P, JK-PB2A16S20P, etc.)\n- **Connection:** BlueZ on Linux, via [`tinygo.org/x/bluetooth`].\n- **Output:** one JSON file per invocation, with parsed fields and the\n  full raw 300-byte frame in hex (so re-parsing later, after firmware\n  layout changes, stays possible).\n\n[`tinygo.org/x/bluetooth`]: https://github.com/tinygo-org/bluetooth\n\n## Install\n\n### Quick install (Linux only)\n\nAuto-detects arch (`amd64` / `arm64` / `armv7` / `armv6`), downloads the\nmatching binary from the latest [release], verifies SHA-256, installs to\n`/usr/local/bin/jkbms-poll`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/tggo/jkbms-poll/main/install.sh | sh\n```\n\nPin a version, or change the install dir:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/tggo/jkbms-poll/main/install.sh | sh -s -- v0.1.0\ncurl -fsSL https://raw.githubusercontent.com/tggo/jkbms-poll/main/install.sh | INSTALL_DIR=$HOME/bin sh\n```\n\n### `go install` (Linux only)\n\n```sh\ngo install github.com/tggo/jkbms-poll@latest\n```\n\nThe binary lands in `$GOBIN` (or `$GOPATH/bin`, or `$HOME/go/bin`). Linux\nonly — the `tinygo.org/x/bluetooth` BlueZ backend uses fields that don't\nexist on Darwin/Windows, so `main.go` is gated with `//go:build linux`.\nOn other platforms `go install` produces nothing useful.\n\n### From source\n\n```sh\ngit clone https://github.com/tggo/jkbms-poll\ncd jkbms-poll\ngo build -o jkbms-poll ./...                     # native (Linux only)\nGOOS=linux GOARCH=arm64 go build ./...           # cross-compile from a Mac\nmake build                                       # default linux/arm64\nmake build GOARCH=amd64                          # other arches\n```\n\n### Manual download\n\nGrab a binary from [Releases][release], `chmod +x`, drop it in `$PATH`.\nSHA-256 sums in [`SHA256SUMS`][release].\n\n[release]: https://github.com/tggo/jkbms-poll/releases/latest\n\nTests are platform-independent: `go test ./...` works anywhere.\n\n## Run\n\n```sh\n./jkbms-poll -mac AA:BB:CC:DD:EE:FF -cells 8 -out /tmp/jkbms.json\n```\n\n`-mac` is required (no built-in default). Find it with `bluetoothctl scan le`\non the host while you're near the BMS, or in the JK BMS phone app.\n\nUseful flags:\n\n| Flag | Default | Notes |\n|------|---------|-------|\n| `-mac` | (required) | BMS BLE address. Also `JKBMS_MAC` env var. |\n| `-cells` | `8` | Number of cells in your pack (1..32). |\n| `-out` | `/tmp/jkbms.json` | Output JSON path. Also `JKBMS_OUT`. |\n| `-timeout` | `90s` | Total budget for scan + connect + first frame. |\n| `-log` | `info` | `debug` / `info` / `warn` / `error`. |\n| `-log-json` | off | Emit logs as JSON (slog `JSONHandler`). |\n\nThe program exits 0 once the JSON is written, non-zero on any error\n(scan timeout, connect failure, parse failure, …). Wire it into systemd\nor cron for periodic polling.\n\n### Sample run\n\n```\n$ jkbms-poll -mac C8:47:80:14:CC:CC -log debug\nINFO  starting pid=17926 timeout=1m30s mac=C8:47:80:14:CC:CC cells=8\nDEBUG adapter enabled\nINFO  scan starting budget=1m10s\nDEBUG scan advert (new device) addr=C8:47:80:14:CC:CC rssi=-65 name=Second_24v\nINFO  scan locked target rssi=-65\nINFO  connect ok attempt=1 took=420ms best_rssi=-65\nINFO  services discovered count=4\nINFO  FFE1 locked\nINFO  frame complete type=0x02 counter=0xac crc_byte=0xd8 leftover=0\nINFO  parsed pack v=53.224 a=31.881 w=1696.8 soc=25 t1=13.4 t2=12.8 mos=12.9\nwrote /tmp/jkbms.json (V=53.224 I=31.881A SOC=25% Δ=0.017V crc_ok=true)\n```\n\n## Output schema\n\n```jsonc\n{\n  \"timestamp_unix\": 1715300000,\n  \"timestamp_iso\":  \"2026-05-10T00:13:20Z\",\n  \"bms_address\":    \"AA:BB:CC:DD:EE:FF\",\n  \"frame_type\":     2,\n  \"frame_counter\":  172,\n  \"crc_ok\":         true,\n  \"num_cells\":      8,\n  \"cell_voltages_v\":      [3.333, 3.326, ...],\n  \"cell_resistances_mohm\":[0.064, 0.061, ...],\n  \"enabled_cell_mask\":    255,\n  \"cell_avg_v\":  3.328,\n  \"cell_min_v\":  3.320,\n  \"cell_max_v\":  3.337,\n  \"cell_delta_v\":0.017,\n  \"cell_min_num\":13,    // 1-based\n  \"cell_max_num\":16,    // 1-based\n  \"battery_voltage_v\":  53.224,\n  \"battery_current_a\":  31.881,   // + = charge, - = discharge\n  \"battery_power_w\":    1696.8,\n  \"power_tube_temp_c\":  12.9,\n  \"t1_c\":               13.4,\n  \"t2_c\":               12.8,\n  \"balance_current_a\":  0.0,\n  \"balance_status\":     0,        // 0=off, 1=charging-bal, 2=discharging-bal\n  \"error_bitmask\":      0,\n  \"soc_percent\":        25,\n  \"soh_percent\":        100,\n  \"remaining_capacity_ah\":49.286,\n  \"nominal_capacity_ah\": 200.000,\n  \"cycle_count\":         9,\n  \"cycle_capacity_ah\":   1.853,\n  \"total_runtime_s\":     24530060,\n  \"charging\":            true,\n  \"discharging\":         true,\n  \"raw_frame_hex\":       \"55aaeb9002ac…\"\n}\n```\n\n## How it works\n\n1. **Scan** until BlueZ has seen an advert from the target MAC (BlueZ won't\n   let you connect to a device path it hasn't observed recently).\n2. **Connect** with retries (BLE at the edge of range often aborts the\n   first handshake locally).\n3. **Discover** GATT services, lock characteristic `0xFFE1` (the JK BMS\n   uses an HM-10 / Bluetrum UART-over-BLE module: write+notify on `FFE1`\n   inside service `FFE0`).\n4. **Subscribe** to notifications and **write** a 20-byte `REQUEST_CELL_INFO`\n   command (`AA 55 90 EB 96 00…00 + sum_mod_256`).\n5. **Reassemble** notifications (BLE chunks ~20 B each) into a 300-byte\n   frame keyed by the `55 AA EB 90` start sequence.\n6. **Parse** the JK02_32S layout into the schema above and write JSON.\n7. **Exit.**\n\n## Parser\n\nField offsets are mirrored from\n[`syssi/esphome-jk-bms`](https://github.com/syssi/esphome-jk-bms)\n(`components/jk_bms_ble/jk_bms_ble.cpp`, `decode_jk02_cell_info_`).\n\n`parse_test.go` ships a real captured frame (from the upstream test\nfixtures, JK_PB2A16S15P running fw 15.38) and asserts the parsed\nfields against upstream's known-good values — voltages, current,\ntemperatures, SOC, capacity, cycle count, runtime, MOSFET state.\nRun with:\n\n```sh\ngo test ./...\n```\n\nIf you hit a frame that doesn't parse cleanly on different firmware,\nthe `raw_frame_hex` in the output is enough to add a new fixture and\nextend the parser.\n\n## Limitations / known nots\n\n- **JK04 firmware is not supported** — only JK02_32S. JK04 is a different\n  layout and isn't on my BMS to test against.\n- **Read-only** — the program never sends control commands (no MOSFET\n  toggles, no settings writes). Adding writes is straightforward (the\n  `buildRequest` builder already produces a valid command frame) but\n  intentionally not exposed.\n- **Single shot** — there's no built-in daemon mode. Run it on a timer.\n  This keeps the BLE stack on the BMS side rested between polls and\n  recovers cleanly from disconnects.\n- **Range-bound by BlueZ** — at RSSI below roughly -85 dBm, BlueZ tends\n  to abort connections locally (`le-connection-abort-by-local`). If the\n  host can't be moved closer to the BMS, an ESP32 running ESPHome's\n  `bluetooth_proxy` is a far better solution than a stronger Pi antenna.\n\n## License\n\nMIT. See [`LICENSE`](LICENSE).\n\n## Acknowledgements\n\n- The JK02_32S protocol layout, reverse-engineered and maintained by the\n  [`syssi/esphome-jk-bms`](https://github.com/syssi/esphome-jk-bms)\n  contributors. This project's parser is a Go port of theirs and uses\n  their test fixtures as ground truth.\n- [`tinygo.org/x/bluetooth`](https://github.com/tinygo-org/bluetooth) for\n  the BLE plumbing.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftggo%2Fjkbms-poll","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftggo%2Fjkbms-poll","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftggo%2Fjkbms-poll/lists"}