Skip to main content

jkctl

A cross-platform tool for JK BMS battery management systems on an RS485 bus, with a browser front end and a command for everything it does. It finds the units on the bus, reports their nameplate and live pack data, reads and writes the whole configuration table, drives the one-key presets and the other action registers, sets the port protocols and dry contacts, and validates and flashes a .jkbms firmware file the way JK's own Windows application does.

The jkctl dashboard: one pack's state of charge and power, its per-cell voltages against their protection band, temperatures, board identity, the running history, the three main switches, raised protections and the health check

It is a web interface first. jkctl, with nothing after it, serves a page on this machine and opens a browser on it; the dashboard above is one pack of a bank on that page. That is the default mode of operation and how most people will use it. Everything the page does is also a command you can type, for a script or a terminal.

Everything here was derived from JK BMS Monitor 3.11.0, the encrypted protocol datasource shipped with it, and JK's own RS485 Modbus register-map documents. The reverse-engineering record is in research/windows/windows-findings.md; docs/how-it-works.md is the readable summary.

Install

uv tool install jkctl          # or: pipx install jkctl

Python 3.10 or newer. The only third-party runtime dependency is pyserial; it also uses devicectl-core, this project's shared, pure-standard-library core. Firmware decryption needs no crypto package at all (see AES backends below).

First run

jkctl                          # the web interface, in a browser

With no arguments jkctl is jkctl ui: it serves a page on this machine and opens a browser on it. There is nothing to configure first: with no port chosen it opens on a picker listing the serial ports this machine has, scans all sixteen addresses of whichever you choose, and shows you the bank — every board on the bus, one tile each. Options for it can be given straight after the bare name (jkctl --port /dev/ttyUSB1, jkctl --listen 0.0.0.0:8087 --read-only). See docs/web-ui.md.

To see the whole interface without a battery anywhere near it:

jkctl ui --simulate            # a bank of fake boards, no serial port opened

Everything it does is also a command:

jkctl scan                     # what is on /dev/ttyUSB0?
jkctl info --id 1              # model, versions, serial number
jkctl status --id 1            # voltage, current, SOC, temperatures, alarms
jkctl cells --id 1             # per-cell voltages and wire resistances
jkctl status --id all          # the whole bank at once

If nothing answers, jkctl probe sweeps the baud rates and addresses and writes a report you can read or send on. It never writes a register.

Commands

command what it does
jkctl ui what jkctl alone does: serve the web interface
jkctl scan sweep addresses 0-15 for units
jkctl info model, hardware and software versions, serial, run time, port protocols
jkctl status pack voltage, current, power, SOC, SOH, temperatures, raised alarms
jkctl cells per-cell voltages, wire resistances, spread, what the balancer is doing
jkctl alarms every protection and warning bit raised, named
jkctl registers every register of every table, with its value
jkctl history the board's own stored fault records, where it maps them
jkctl log-codes what each stored record's code means (no hardware)
jkctl log sample the runtime data into CSV or JSON
jkctl settings show|get|set|list|export|import the configuration table
jkctl charge|discharge|balance [on|off] the three main switches
jkctl switches show|set the sixteen multiplexed on/off settings
jkctl preset lifepo4|li-ion|lto a one-key chemistry preset
jkctl emergency / jkctl shutdown the emergency start; the power-off
jkctl restart / jkctl factory-restore / jkctl erase-data three board actions JK's document does not list
jkctl address show|set the address the board answers on
jkctl time show|sync the unit's real-time clock
jkctl calibrate voltage|current recalibrate a measurement
jkctl protocols list|show|set which protocol each UART and the CAN port speaks
jkctl dry-contact show|set the two dry contacts and the LCD buzzer
jkctl firmware info|check|list|flash a .jkbms file or a directory of them: read, check, flash
jkctl firmware make-dumper|dump-flash exact-image-locked full-flash recovery
jkctl probe characterise a bus that is not answering
jkctl simulate serve a fake BMS on a serial port, for testing
jkctl doctor read a unit over and report what looks wrong
jkctl config show|path|init the optional settings file

--json is available on the commands that report; --watch [S] redraws status and cells until Ctrl+C. Every command takes --port, --id, --baud, --timeout, --retries and --trace. The read commands also take --id 1,2,5, --id 1-4 or --id all, and print one section per board (or one JSON document keyed by address).

$ jkctl status --id 1
BMS 1:

  Pack
    State of charge  95 %
    Voltage          53.27 V
    Current          -2.65 A
    Remaining        296.8 Ah

  Cells
    Average          3.329 V
    Spread           0.003 V

  Temperatures
    MOS              23.5 ℃
    Battery 1        21.8 ℃

The groups and the names are the web dashboard's cards and rows, so a reading is called the same in both.

Changing settings

Settings are typed, scaled and range-checked against JK's own datasource before anything goes out, and a write always shows what it will change:

$ jkctl settings set volCellUV=2.9 balanEn=off --id 1
2 setting(s) to change:

  Cell UVP (volCellUV): 2.800 V -> 2.900 V
  Balancing (balanEn): On -> Off

Wrote 2 setting(s).

--dry-run prints the plan and the exact Modbus frames without sending them. jkctl settings list shows every setting with its unit, its allowed range and its factory default. export/import move a whole configuration between units; an import that would change nothing says so instead of rewriting sixty registers.

Configuration file

jkctl config init writes a commented jk.toml under $XDG_CONFIG_HOME/jk (or ~/.config/jk). Everything in it is optional and a flag always wins:

port = "/dev/ttyUSB0"
id = 1

[devices.shed]
id = 2

Then jkctl status --device shed.

Safety

  • scan, info, status, cells, alarms, log, doctor, probe and every show/get/list are strictly read-only. They never write a register.
  • settings set, the three switches and protocols set write, and say what they wrote. Range checks come from JK's own datasource, so a value the vendor's application would clamp is refused here instead.
  • preset, shutdown, calibrate, address set, restart, factory-restore and erase-data ask first, naming what they will do, because they replace every protection setpoint, turn the board off, move the address you are talking to, or erase what it has stored. -y skips the question; the browser asks for the word typed out instead.
  • The last three of those use action slots that appear in neither revision of JK's register-map document. They were read out of the vendor application's own buttons, a reading whose control is that it gives 0x04 for "Shutdown Board", which the document does list at 0x04. None has been fired at real hardware.
  • Flashing is irreversible and an interrupted transfer can leave a unit with no working application. The compatibility gate enforces model and major version; --force bypasses only the minor-version and expiry checks. Do not flash a battery you cannot afford to lose without a verified, compatible image.
  • Recovering a dead app is a bench job, and how bad depends on readout protection. Re-arming the upgrade over RS485 is done by the application, so a dead app cannot be reflashed over the wire. The fallback is the STM32 ROM bootloader (BOOT0 high, a USB-TTL adapter, stm32flash): if the MCU's readout protection (RDP) is off it can rewrite the application region from the image jkctl extracts, leaving the JK bootloader untouched. If RDP is on, clearing it mass-erases the JK bootloader too, and recovery then needs a full image. All 67 archived applications across HW V14, V15, V17 and V19 are linked at 0x08002000; firmware make-dumper and dump-flash implement an exact-image-locked route to capture the whole device before it is needed. See research/firmware/flash-dump-analysis.md.
  • The captured bootloader does not provide end-to-end image integrity. jkctl decodes a .jkbms exactly as JK's application does and checks its metadata before transfer. The bootloader recovered from one PB2A16S20P checks XMODEM's block number/complement and 8-bit checksum, but not a signature, whole-image checksum, model, version, expected length or destination bound. Assume a wrong, corrupt, truncated or oversized image can be written and may run. make-dumper is modified firmware and therefore adds further risk.
  • The web interface is bound to 127.0.0.1 and needs no token. Off loopback it generates one, refuses a Host header that is a name it was not told to expect, and requires a header on every write that a cross-origin form cannot set. --read-only refuses every write server-side, so a page can be shared without handing over the battery.
  • The firmware transfer and full-flash dumper have each succeeded on one physical JK_PB2A16S20P running official V15.41: the dumper image booted, the board continued operating, and dump-flash returned a validated 128 KiB capture. This is one data point, not evidence for other models, hardware revisions, firmware versions or write commands. See docs/reference.md.

AES backends

Firmware and datasource files are AES-256-CBC. jkctl needs no crypto package for that: it uses the first available of PyCryptodome, cryptography, the system OpenSSL libcrypto (through stdlib ctypes -- nothing to install), or a bundled pure-Python AES. On a small ARM host, simply having OpenSSL present gives an instant decrypt with no compiler involved.

Development

uv sync                                 # pytest, ruff, ty -- pure Python
uv run pytest
uv run ruff format . && uv run ruff check . && uv run ty check
biome ci src/jkctl/web/static/          # the browser half
uv sync --group browser                 # the V8 and the browser, opt-in

The engine-backed checks are a separate dependency group so that installing and hacking on jkctl stays light on the small hosts it targets.

The tests need no hardware: they wire the Modbus master straight to jkctl.simulator, which reproduces the real device's quirks, and the web tests point the server at the same thing. See CONTRIBUTING.md.

Licence

EUPL-1.2, except the files named in NOTICE: three of JK's own data files, and the vendored Preact/htm bundle the page is built with.

Metadata

Release files for jkctl 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for jkctl 0.2.0
File Size Uploaded
jkctl-0.2.0.tar.gz 994.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for jkctl 0.2.0
File Interpreter ABI Platform
jkctl-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.3 MB

Release files / jkctl-0.2.0.tar.gz

Download URL jkctl-0.2.0.tar.gz
Size 994.3 kB
Tags Source
SHA-256 checksum
How to use checksums
71b82c47ea85bda880080ad85599816bc8fde14953ff49f7dac038416e425fc0
BLAKE2b-256 checksum
How to use checksums
2edc9f65ccb5afc24970deb67d1bfaa38830a455ba721ed48d57b01d066f966d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release files / jkctl-0.2.0-py3-none-any.whl

Download URL jkctl-0.2.0-py3-none-any.whl
Size 277.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bdede589d759fd3e665a7237f91376ea6dfce059a230ea85b6839e7340c75249
BLAKE2b-256 checksum
How to use checksums
6037a6d7c9dc9c12de3e91e4b234dfd250f628e971ec8c0f28d277d7b3d09d32
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page