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

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 pinESP32 pinNotes
VCC3.3VBoth transceivers are native 3.3V parts — don't power them from 5V, it could overdrive the ESP32's RX pin.
GNDGND
DI (driver in)GPIO17 (TX2)
RO (receiver out)GPIO16 (RX2)
DE + RE (tied together)GPIO4HIGH = transmit, LOW = receive
ABMS RS485-A (RJ45 pin 2/7)
BBMS RS485-B (RJ45 pin 1/8)

Wiring: SN65HVD230 (CAN) → ESP32

SN65HVD230 pinESP32 pinNotes
3 VCC3.3VBoth transceivers are native 3.3V parts — don't power them from 5V, it could overdrive the ESP32's RX pin.
2 GNDGND
1 D (TXD)GPIO25CAN_TX_PIN
4 R (RXD)GPIO26CAN_RX_PIN
8 Rs10 kΩ → GND
5 Vref
7 CANH / 6 CANLBMS 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 pinSignal
1, 8RS485-B
2, 7RS485-A
3NC
4CAN-H
5CAN-L
6GND

Important notes

Contact form

JK BMS Updater setup

⚠️ We are not responsible for any damage caused by improper use!

Version offered: R1-V1.00e

Your browser doesn't support the Web Serial API needed to flash directly here. This works in Chrome, Edge, or Opera on desktop. Download the binaries and flash them manually with esptool.py.
This page must be served over a secure HTTPS connection for browser-based flashing to work.

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

MethodPathDescription
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;
}