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.
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,probeand everyshow/get/listare strictly read-only. They never write a register.settings set, the three switches andprotocols setwrite, 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-restoreanderase-dataask 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.-yskips 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
0x04for "Shutdown Board", which the document does list at0x04. 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;
--forcebypasses 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 imagejkctlextracts, 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 at0x08002000;firmware make-dumperanddump-flashimplement an exact-image-locked route to capture the whole device before it is needed. Seeresearch/firmware/flash-dump-analysis.md. - The captured bootloader does not provide end-to-end image integrity.
jkctldecodes a.jkbmsexactly 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-dumperis modified firmware and therefore adds further risk. - The web interface is bound to
127.0.0.1and needs no token. Off loopback it generates one, refuses aHostheader 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-onlyrefuses 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_PB2A16S20Prunning official V15.41: the dumper image booted, the board continued operating, anddump-flashreturned a validated 128 KiB capture. This is one data point, not evidence for other models, hardware revisions, firmware versions or write commands. Seedocs/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)
| File | Size | Uploaded | |
|---|---|---|---|
| jkctl-0.2.0.tar.gz | 994.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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