Build a configurable JK-BMS Wi-Fi bridge with an ESP32-C3
This project turns an ESP32-C3 development board into a self-contained JK-BMS monitor. On first boot it creates a setup hotspot, accepts the local 2.4 GHz Wi-Fi credentials, scans nearby Bluetooth devices, lets the user choose a BMS in a browser, and then exposes the battery telemetry on the LAN.
Update, 1 November 2026: the bridge now monitors two BMS units simultaneously with one ESP32-C3. Read the two-BMS follow-up for the current source package, pinned dependencies and the corrected asynchronous connection handling. This page records the original single-BMS design.
The result does not require a computer or Home Assistant at runtime. The data path is:
JK-BMS -- Bluetooth LE --> ESP32-C3 -- 2.4 GHz Wi-Fi --> browser
\--> ESPHome API / Home Assistant
The web interface includes state of charge, pack voltage, current, charge and discharge power, remaining and full capacity, cell minimum/maximum/delta, balancing current, up to 24 individual cell voltages and resistances, temperatures, cycle count, runtime, device information and active errors. The supplied configuration is deliberately read-only: it does not expose buttons or switches that can change BMS protection settings.
Battery safety: this is a monitoring bridge, not a replacement for the BMS, fuses, contactors or correctly sized conductors. Do not alter protection thresholds merely to make a warning disappear. Work on an assembled battery only if you understand its voltage, fault current and isolation requirements.
What makes this build different
A normal ESPHome BLE client expects a MAC address in the YAML configuration. The official ESPHome BLE Client documentation describes that fixed-address model. It is fine for a permanent installation, but changing the battery normally means editing YAML and compiling again.
This build adds a small local component called jk_bms_selector. It listens to the existing ESP32 BLE scanner, lists advertisements at /bms, stores the selected address and address type in NVS, and updates the BLE client at runtime. The selection survives a restart and an OTA update. Devices advertising service 0xFFE0, or a familiar JK-style name, are visually marked as likely JK-BMS devices; the user may still choose any entry because some models advertise incomplete data.
JK frame parsing comes from the open-source syssi/esphome-jk-bms component. Its supported-device list and example configurations are the best place to confirm the appropriate protocol for a particular BMS. This build uses JK02_32S, which matches newer hardware. Older units may require JK02_24S or JK04.
Parts and assumptions
- An ESP32-C3 development board with 4 MB flash and native USB Serial/JTAG. The tested board identifies as
esp32-c3-devkitm-1. - A data-capable USB cable and a stable USB power supply.
- A compatible JK/Jikong BMS with Bluetooth enabled.
- A 2.4 GHz Wi-Fi network. ESP32-C3 does not join a 5 GHz-only SSID.
- Docker, or a local ESPHome installation, on the machine used to compile and flash.
- A phone or laptop for initial provisioning.
The tested toolchain was ESPHome 2026.6.5 with ESP-IDF 5.5.4. Current ESPHome releases can change internal C++ APIs, so validate and compile the project before flashing if using a different release.
Download the complete firmware
Download the original single-BMS source snapshot. This historical archive has not been changed; use the current package for a new build. Original ZIP SHA-256:
01f4479fe153c3e337fc4664c6bf012f1651d55c8c481f2ffb694a545504ef85
The archive contains:
source/
├── README.md
├── jk-bms-c3.yaml
└── components/
└── jk_bms_selector/
├── __init__.py
└── jk_bms_selector.h
The individual files can also be inspected directly: main ESPHome YAML, component schema, and runtime selector implementation.
Firmware configuration explained
The core of jk-bms-c3.yaml selects the C3 target, loads the upstream JK component and the local runtime selector, and intentionally starts with a nonexistent station network. A clean board therefore brings up its fallback access point after five seconds:
substitutions:
name: jk-bms-c3
friendly_name: JK BMS C3
protocol_version: JK02_32S
esp32:
board: esp32-c3-devkitm-1
framework:
type: esp-idf
external_components:
- source: github://syssi/esphome-jk-bms@main
refresh: 0s
- source:
type: local
path: components
components: [jk_bms_selector]
wifi:
ssid: "JK-BMS-SETUP"
password: "not-a-real-network"
reboot_timeout: 0s
ap:
ssid: "JK-BMS-C3 Setup"
password: "jk-bms-setup" # Change this for your installation
ap_timeout: 5s
captive_portal:
web_server:
port: 80
version: 3
jk-bms-setup is the password used for the original tested build. Replace it before compiling for your own installation; ESPHome accepts passwords from 8 to 63 characters. The placeholder station SSID is not a secret and must not match a real nearby network. Credentials submitted through the captive portal are stored in flash and take precedence on subsequent boots. ESPHome documents this fallback behavior in its Wi-Fi component and captive portal references.
The BLE section starts with an address that should not exist. The selector replaces it with the value stored in NVS. Before the first selection, the bridge scans normally but cannot attach to a battery:
esp32_ble_tracker:
scan_parameters:
active: true
interval: 1100ms
window: 700ms
ble_client:
- mac_address: "00:00:00:00:00:01"
id: client0
jk_bms_selector:
id: bms_selector
ble_client_id: client0
jk_bms_ble:
- ble_client_id: client0
protocol_version: ${protocol_version}
throttle: 5s
id: bms0
The rest of the YAML declares the sensors, binary sensors and text sensors. Adding a metric does not make the BMS transmit new information; it asks the JK parser to publish a field when that protocol/model provides it. Unsupported cells or probes remain unavailable. The full downloadable YAML includes 24 cell-voltage channels, 24 resistance channels and five temperature probes so the same image can cover several pack sizes.
How the runtime selector works
ESPHome discovers a local component through its Python schema. The important part binds the selector to an existing BLE client and registers it as a BLE advertisement listener:
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(JkBmsSelector),
cv.Required("ble_client_id"): cv.use_id(ble_client.BLEClient),
}).extend(cv.COMPONENT_SCHEMA).extend(
esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
client = await cg.get_variable(config["ble_client_id"])
cg.add(var.set_ble_client(client))
await esp32_ble_tracker.register_ble_device(var, config)
The C++ implementation reads the saved address before the BLE client starts, registers the /bms HTTP handler, and keeps a bounded list of recently observed advertisements:
inline void JkBmsSelector::setup() {
nvs_handle_t handle;
if (nvs_open("jk_bms_select", NVS_READONLY, &handle) == ESP_OK) {
uint64_t address = 0;
uint8_t type = BLE_ADDR_TYPE_PUBLIC;
if (nvs_get_u64(handle, "address", &address) == ESP_OK) {
nvs_get_u8(handle, "addr_type", &type);
selected_ = address;
selected_type_ = static_cast<esp_ble_addr_type_t>(type);
}
nvs_close(handle);
}
if (selected_ != 0) {
client_->set_address(selected_);
client_->set_remote_addr_type(selected_type_);
}
web_server_base::global_web_server_base->add_handler(this);
}
An HTTP request is handled on the web-server task, while BLE lifecycle calls belong on ESPHome's main loop. The handler validates and saves the choice, then queues it. Disconnection is asynchronous: the main loop must disable the old client and wait for IDLE before changing its address and enabling it again. The original immediate address change has been replaced in the current selector implementation.
The current header also includes MAC validation, HTML escaping, NVS error handling, duplicate-slot rejection, sorting likely JK devices first, bounded storage for 40 advertisements, and the full HTML page. Use that implementation rather than reconstructing it from the historical excerpts.
Choose the correct JK protocol
Check the hardware/software version in the official JK application or on the BMS label, then compare it with the upstream project's supported-device table and BLE example.
JK02_32S: generally newer hardware version 11 and later.JK02_24S: generally hardware versions below 11 that use the JK02 layout.JK04: older hardware, commonly version 3 and earlier.
These are selection guidelines, not a substitute for model verification. A successful BLE connection with nonsensical values often means the protocol setting is wrong. Change protocol_version, rebuild and flash; the saved Wi-Fi and BMS MAC survive an OTA application update but not a full flash erase.
Build and flash
The commands below describe the original snapshot: extract it, edit the AP password and protocol, then run from the extracted directory. They fix the ESPHome version, but the original YAML still follows the JK component's main branch. The current package also pins the tested JK commit; use it for a new build.
docker pull ghcr.io/esphome/esphome:2026.6.5
docker run --rm \
-v "$PWD/source":/config \
ghcr.io/esphome/esphome:2026.6.5 \
config /config/jk-bms-c3.yaml
docker run --rm \
-v "$PWD/source":/config \
ghcr.io/esphome/esphome:2026.6.5 \
compile /config/jk-bms-c3.yaml
On Linux, identify the exact port before writing:
ls -l /dev/ttyACM* /dev/ttyUSB*
docker run --rm --privileged \
-v /dev:/dev \
--entrypoint python \
ghcr.io/esphome/esphome:2026.6.5 \
-m esptool --port /dev/ttyACM0 chip-id
If the C3 does not enter its ROM loader automatically, disconnect USB, hold BOOT, connect USB, wait two or three seconds and release BOOT. Then erase and write the factory image:
BUILD=/config/.esphome/build/jk-bms-c3/.pioenvs/jk-bms-c3
docker run --rm --privileged -v /dev:/dev \
--entrypoint python ghcr.io/esphome/esphome:2026.6.5 \
-m esptool --chip esp32c3 --port /dev/ttyACM0 erase-flash
docker run --rm --privileged \
-v /dev:/dev -v "$PWD/source":/config \
--entrypoint python ghcr.io/esphome/esphome:2026.6.5 \
-m esptool --chip esp32c3 --port /dev/ttyACM0 --baud 460800 \
write-flash 0x0 "$BUILD/firmware.factory.bin"
The shell variable in that example is interpreted inside the container path. If your shell expands it before Docker starts, write the complete /config/.esphome/... path directly. After flashing, unplug and reconnect the board normally, without holding BOOT. Never write an image built for classic ESP32, S2, S3 or C6 to a C3.
First-time setup
- Power the ESP32-C3 and wait approximately 10–30 seconds.
- Join JK-BMS-C3 Setup using the AP password chosen before compilation. Accept the phone's warning that this network has no internet.
- Open http://192.168.4.1/wifi using HTTP, not HTTPS. Select the destination 2.4 GHz Wi-Fi network, enter its password and save.
- The setup AP disappears when the C3 joins the router. Return the phone to the home Wi-Fi.
- Open http://jk-bms-c3.local/bms. Wait for the scan to populate, identify the BMS by its advertised name or known MAC and press Connect.
- Open http://jk-bms-c3.local/. The online state should become true and telemetry should update approximately every five seconds.
Why /wifi? This build runs the ESPHome metrics server and captive portal on the same port. The metrics server owns the root path /; an otherwise unused path such as /wifi falls through to the captive portal. On the isolated setup AP the metrics page may show only its shell because Web Server version 3 normally downloads its JavaScript from the internet.
Change to another BMS
No recompilation or factory reset is required:
- Make sure the new BMS has Bluetooth enabled and is close enough to the ESP32-C3.
- From a device on the same LAN, open
http://jk-bms-c3.local/bms. - Wait for several scan cycles. Compare the displayed name and MAC with the BMS application or label.
- Press Connect beside the desired device.
- Return to
http://jk-bms-c3.local/and verify the serial number, model, pack voltage and cell count before trusting the readings.
The new address is committed to NVS. After a restart the bridge reconnects to that device. Selecting by name alone is unsafe when several batteries use identical default names; use the MAC and then confirm identity from the published device information.
Change the Wi-Fi network
The fallback AP appears when the saved station network cannot be reached. To move the bridge:
- Turn off or move out of range of the old Wi-Fi, then restart the C3.
- Join JK-BMS-C3 Setup.
- Open
http://192.168.4.1/wifiand save the new 2.4 GHz credentials. - Reconnect the phone to the new LAN and open
http://jk-bms-c3.local/.
The selected BMS is stored separately and does not need to be chosen again. Erasing flash clears both Wi-Fi and BMS selection. Changing the compiled AP password requires a new firmware build.
Home Assistant integration
The YAML enables ESPHome's native API. Home Assistant should discover jk-bms-c3 automatically after both devices are on the same network. Otherwise, open Settings → Devices & services → Add integration → ESPHome and enter jk-bms-c3.local or the IP assigned by the router.
The sample leaves the API and OTA interfaces without authentication for simplicity. That is acceptable only on a trusted, isolated LAN. Before using an untrusted or shared network, configure native API encryption and OTA authentication according to the current ESPHome documentation. Do not port-forward the device's HTTP, API or OTA ports to the internet.
Verification checklist
- The board appears in the router with the expected ESP32 MAC and responds at its assigned address.
/bmsshows current RSSI and the saved MAC matches the intended battery.- Online status becomes true.
- Pack voltage agrees with an independent measurement within the expected BMS accuracy.
- The number of populated cells matches the physical pack; unused cell entities remain unavailable.
- Minimum and maximum cell numbers correspond to the individual cell list.
- Current direction and charge/discharge state are understood before using them in automations.
- The bridge recovers after a power cycle and reconnects without opening the setup pages.
Troubleshooting
- No setup hotspot: power-cycle without holding BOOT. If the serial output says “waiting for download,” the board is still in its ROM loader. Disconnect and reconnect normally.
192.168.4.1shows an empty black ESPHome page: openhttp://192.168.4.1/wifi. The root is the metrics UI and its external JavaScript is unavailable on an offline AP.- The phone refuses to stay connected: disable automatic switching to mobile data temporarily and accept the “no internet” warning.
jk-bms-c3.localdoes not resolve: find the board's IP in the router's client list and usehttp://IP-ADDRESS/. Some networks block mDNS between wireless clients.- The BMS is missing from the list: wake it in the vendor app, move the bridge closer, close other apps actively connected to it and wait for another scan cycle.
- A similarly named device connects but publishes no JK data: verify its MAC and service. Nearby batteries, chargers and monitors can advertise convincing names without implementing JK's GATT service.
- Connected but values are incorrect: verify
JK02_32S,JK02_24SorJK04against the exact hardware/software version. - Frequent disconnects: reduce distance and interference, use a stable power supply, and avoid adding memory-heavy ESPHome components. ESPHome notes that the BLE stack consumes significant RAM.
- Serial flashing fails on C3: use the BOOT-while-connecting sequence, confirm the port again, and retry at the default baud if 460800 is unreliable.
Possible improvements
For a hardened deployment, embed the Web Server v3 JavaScript so the metrics UI works on the offline AP, add API encryption and an OTA password, protect the BMS selection route with authentication, pin the JK component to a tested commit instead of main, and add a physical reset/provisioning button. A future selector could inspect GATT before accepting a choice, but that would make switching slower and would need careful handling because a BLE peripheral usually permits only one active client.
This design keeps the operational workflow simple: provision Wi-Fi once, select a nearby BMS in a browser, and use the same firmware when the battery changes. The important engineering boundary is equally simple: monitoring is convenient, but protection remains the responsibility of the BMS and the electrical installation.