JK BMS Web Gateway
A private project (LTSolutions) that exposes JK BMS controllers for LiFePO4 solar batteries without the vendor's phone BLE app or PC tool. An ESP32 gateway reads the battery over RS485 and listens to the inverter's CAN broadcast, exposing it as a JSON REST API and web dashboard directly on your WiFi network.
BMS manufacturer: jkbms.com / jk-bms.com. This project is not affiliated with the manufacturer.
Required hardware
The gateway needs an ESP32 board, an RS485 transceiver, and (optionally) a CAN transceiver, wired per the tables below. The exact pin numbers are defined in the firmware's src/main.cpp — change them there if you wire it differently.
What you need
- Any ESP32 dev board (e.g. ESP32-WROOM-32, 38-pin DevKit)
- A MAX485 / MAX3485 TTL-to-RS485 transceiver (3.3 V version, with DE and RE tied together on one pin)
- An SN65HVD230 (3.3 V) CAN transceiver — only needed if you also want to read the inverter's CAN broadcast
- An RJ45 cable into the BMS's combined 485/CAN port
- A 5V power supply for the ESP32 board
Optional: a ready-made KiCad carrier board combining the ESP32 DevKit, both transceivers, and two RJ45 jacks on one board — source files on GitHub.
Wiring: MAX485 (RS485) → ESP32
| MAX485 pin | ESP32 pin | Notes |
|---|---|---|
| VCC | 3.3V | Both transceivers are native 3.3V parts — don't power them from 5V, it could overdrive the ESP32's RX pin. |
| GND | GND | |
| DI (driver in) | GPIO17 (TX2) | |
| RO (receiver out) | GPIO16 (RX2) | |
| DE + RE (tied together) | GPIO4 | HIGH = transmit, LOW = receive |
| A | BMS RS485-A (RJ45 pin 2/7) | |
| B | BMS RS485-B (RJ45 pin 1/8) | |
Wiring: SN65HVD230 (CAN) → ESP32
| SN65HVD230 pin | ESP32 pin | Notes |
|---|---|---|
| 3 VCC | 3.3V | Both transceivers are native 3.3V parts — don't power them from 5V, it could overdrive the ESP32's RX pin. |
| 2 GND | GND | |
| 1 D (TXD) | GPIO25 | CAN_TX_PIN |
| 4 R (RXD) | GPIO26 | CAN_RX_PIN |
| 8 Rs | 10 kΩ → GND | |
| 5 Vref | — | |
| 7 CANH / 6 CANL | BMS CAN-H / CAN-L | |
BMS combined 485/CAN port (RJ45)
The JK-PB2A16S20P has one combined "485/CAN" RJ45 port that carries both buses at once:
| RJ45 pin | Signal |
|---|---|
| 1, 8 | RS485-B |
| 2, 7 | RS485-A |
| 3 | NC |
| 4 | CAN-H |
| 5 | CAN-L |
| 6 | GND |
Important notes
- The firmware listens to the CAN bus passively (listen-only) — it never transmits, so it's safe to connect even to a live bus between the BMS and inverter.
- Only add the 120 Ω CAN termination resistor if the ESP32 is a real end of the bus. If it's just a stub on a bus the BMS and inverter already terminate, leave it out.
- CAN bitrate defaults to 500 kbit/s (JK default) and can be changed at runtime on the dashboard's CAN tab or via
POST /api/can/config.
Contact form
JK BMS Updater setup
Version offered: R1-V1.00e
esptool.py.
Flash the files below with:
esptool.py --chip esp32 --port PORT write_flash \
0x1000 bootloader.bin \
0x8000 partitions.bin \
0xe000 boot_app0.bin \
0x10000 firmware.bin \
0x290000 littlefs.bin
bootloader.bin · partitions.bin · boot_app0.bin · firmware.bin · littlefs.bin
REST API
All responses are JSON. Example: curl http://jkbms.local/api/realtime
| Method | Path | Description |
|---|---|---|
| GET | /api/info |
Gateway status (IP, uptime, last poll) |
| GET | /api/realtime[?addr=N] |
Cell voltages, pack V/I/P, SOC, temperatures, alarms |
| GET | /api/settings[?addr=N] |
Current protection-parameter values |
| POST | /api/settings[?addr=N] |
Body: {"fieldName": value, ...} — writes changed fields |
| GET | /api/scan |
Polls slave addresses 0–15, returns which ones respond |
| GET | /api/can |
Decoded CAN inverter-broadcast summary (SOC, pack V/I/T, charge/discharge limits, flags, manufacturer) |
| GET | /api/can/raw[?n=20] |
Most-recent raw CAN frames (id, dlc, data[], age in ms) |
| GET / POST | /api/can/config |
CAN settings — bitrate, autoscan, profile (persisted to NVS) |
| GET | /api/debug/read?reg=0xHEX&count=N[&addr=N] |
Raw Modbus holding-register read |
| POST | /api/debug/verifywrite?reg=0xHEX[&addr=N] |
Reads a register, writes the same value back, re-reads — proves the write path reaches that register without changing anything |
Sample C/C++ console client
A minimal client — one HTTP GET, one JSON parse, prints the pack's voltage/current/SOC. Download jkbms_client_example.cpp
// JK BMS Web Gateway — minimal REST API console client.
//
// Fetches GET /api/realtime from the gateway and prints pack voltage,
// current, and state of charge. Demonstrates the smallest useful client:
// one HTTP GET, one JSON parse. See the full endpoint list and JSON field
// reference on the site's #api section.
//
// Build (Linux/macOS, needs libcurl + nlohmann/json):
// g++ -std=c++17 jkbms_client_example.cpp -lcurl -o jkbms_client_example
// Run:
// ./jkbms_client_example http://jkbms.local
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <cstdio>
#include <cstdlib>
#include <string>
using nlohmann::json;
// libcurl calls this once per received chunk of the HTTP response body;
// appending to a std::string is the standard way to buffer a small response.
static size_t appendToBuffer(char *data, size_t size, size_t count, void *userData) {
auto *buffer = static_cast<std::string *>(userData);
buffer->append(data, size * count);
return size * count;
}
// Performs one blocking HTTP GET and returns the response body.
// Throws std::runtime_error if the transfer itself fails (not on HTTP
// error status — the caller is expected to check the JSON's own "error" field).
static std::string httpGet(const std::string &url) {
CURL *curl = curl_easy_init();
if (!curl) {
throw std::runtime_error("curl_easy_init failed");
}
std::string body;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, appendToBuffer);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
CURLcode result = curl_easy_perform(curl);
curl_easy_cleanup(curl);
if (result != CURLE_OK) {
throw std::runtime_error(curl_easy_strerror(result));
}
return body;
}
int main(int argc, char *argv[]) {
// Default to the gateway's mDNS name; override with a bare IP if mDNS
// isn't reachable on your network (e.g. "http://192.168.1.50").
std::string baseUrl = (argc > 1) ? argv[1] : "http://jkbms.local";
std::string body;
try {
body = httpGet(baseUrl + "/api/realtime");
} catch (const std::exception &ex) {
std::fprintf(stderr, "request failed: %s\n", ex.what());
return 1;
}
json realtime = json::parse(body, /*cb*/ nullptr, /*allow_exceptions*/ false);
if (realtime.is_discarded()) {
std::fprintf(stderr, "invalid JSON response: %s\n", body.c_str());
return 1;
}
if (realtime.contains("error")) {
std::fprintf(stderr, "gateway error: %s\n", realtime["error"].get<std::string>().c_str());
return 1;
}
std::printf("Pack voltage: %.2f V\n", realtime.value("totalVoltage", 0.0));
std::printf("Current: %.2f A\n", realtime.value("current", 0.0));
std::printf("SOC: %d %%\n", realtime.value("soc", 0));
return 0;
}