👁 1029

ESP32 JK-PB BMS圆屏监控

ESP32 + RS485 Modbus 读 JK-PB 16S/280Ah BMS,GC9A01A 圆屏实时显示,BLE 扫描发现蓝牙 BMS,数据全入 HA

这套东西是太阳能系统里独立监控电池组的——跟德业一体机那路 Modbus 不冲突,一体机读的是逆变器侧数据,这路直接读 BMS 本体,16 电芯电压、SOC、温度、告警全拿到,还带个圆屏本地看。

硬件接线

功能 ESP32 GPIO 对端
RS485 TX GPIO25 MAX485 RXD
RS485 RX GPIO26 MAX485 TXD
圆屏 MOSI GPIO23 GC9A01A MOSI
圆屏 CLK GPIO18 GC9A01A SCK
圆屏 CS GPIO16 GC9A01A CS
圆屏 DC GPIO4 GC9A01A DC
圆屏 RST GPIO17 GC9A01A RST
CAN 预留 GPIO12/13 —

圆屏是 SPI 不是 I2C。RS485 接 BMS 独立的监控/主机 RS485 口,A 对 A、B 对 B。不要把 ESP32 作为第二个 Modbus 主机接到逆变器正在用的总线,也不要接 BMS 的并联通信 485P 口。

BMS DIP 地址设为 1。

两套固件

固件 A:ESPHome(带圆屏)

ESPHome 2026.6.5,ESP-IDF 框架,4MB Flash。加密 HA API,原生 + Web 双 OTA。圆屏 1 秒刷新,8 秒轮换电芯电压页。

固件 B:Universal ESP-IDF(Web 配置 + MQTT)

独立 ESP-IDF 构建,不需要 ESPHome。Web 页面运行时配置(Wi-Fi、RS485 GPIO、BMS 地址/波特率/电芯数、MQTT),MQTT Home Assistant 自动发现。恢复 AP、Web OTA、出厂重置。不含圆屏驱动。

两套互不干扰,按需选。

ESPHome 配置

secrets.yaml(脱敏)

wifi_ssid: "你的WiFi"
wifi_password: "你的密码"
api_encryption_key: "你的HA加密key"
ota_password: "你的OTA密码"
web_username: "admin"
web_password: "你的Web密码"
fallback_ap_password: "你的AP密码"

jk-bms-ha.yaml 主配置

substitutions:
  device_name: jk-bms
  friendly_name: "极空 BMS"
  device_description: "JK-PB 16S 280Ah BMS via RS485 Modbus"
  bms_address: "1"
  bms_update_interval: 5s
  bms_tx_pin: GPIO25
  bms_rx_pin: GPIO26

packages:
  jk_pb_16s: !include packages/jk-pb-16s.yaml

esphome:
  name: ${device_name}
  friendly_name: ${friendly_name}
  comment: ${device_description}
  project:
    name: "godsun.jk-pb-bms-display"
    version: "3.0.0"

esp32:
  board: esp32dev
  flash_size: 4MB
  framework:
    type: esp-idf

preferences:
  flash_write_interval: 10min

logger:
  level: DEBUG
  baud_rate: 115200
  logs:
    uart_debug: DEBUG
    modbus: DEBUG
    modbus_controller: DEBUG

api:
  id: api_server
  reboot_timeout: 0s
  encryption:
    key: !secret api_encryption_key

ota:
  - platform: esphome
    password: !secret ota_password
  - platform: web_server

web_server:
  port: 80
  version: 3
  log: false
  include_internal: false
  auth:
    username: !secret web_username
    password: !secret web_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  min_auth_mode: WPA2
  power_save_mode: none
  reboot_timeout: 0s
  ap:
    ssid: "JK-BMS Fallback"
    password: !secret fallback_ap_password

captive_portal:
safe_mode:

BLE 扫描(发现蓝牙 BMS)

esp32_ble_tracker:
  scan_parameters:
    active: true
  on_ble_advertise:
    then:
      - lambda: |-
          const std::string name = x.get_name();
          if (name.rfind("JK-", 0) == 0 || name.rfind("JK_", 0) == 0) {
            const std::string result = name + " | " + x.address_str();
            ESP_LOGI("ble_adv", "JK-BMS found: %s", result.c_str());
            id(jk_bms_ble_scan).publish_state(result);
          }

text_sensor:
  - platform: template
    name: "JK BMS BLE 扫描结果"
    id: jk_bms_ble_scan
    entity_category: diagnostic

圆屏显示(GC9A01A 240×240)

spi:
  clk_pin: GPIO18
  mosi_pin: GPIO23

color:
  - id: color_background
    hex: "090D12"
  - id: color_surface
    hex: "17212B"
  - id: color_text
    hex: "F2F5F7"
  - id: color_muted
    hex: "91A0AC"
  - id: color_green
    hex: "35D07F"
  - id: color_cyan
    hex: "27B8D9"
  - id: color_amber
    hex: "F2B84B"
  - id: color_red
    hex: "F05A67"

font:
  - file: "fonts/NotoSans.ttf"
    id: font_tiny
    size: 11
    glyphs: " !%()+,-./0123456789:***|"
  - file: "fonts/NotoSans.ttf"
    id: font_small
    size: 15
    glyphs: " !%()+,-./0123456789:***|"
  - file: "fonts/NotoSans.ttf"
    id: font_medium
    size: 22
    glyphs: " !%()+,-./0123456789:***|"
  - file: "fonts/NotoSans.ttf"
    id: font_large
    size: 43
    glyphs: " -%0123456789"

display:
  - platform: mipi_spi
    id: bms_display
    model: GC9A01A
    cs_pin: GPIO16
    dc_pin: GPIO4
    reset_pin: GPIO17
    data_rate: 20MHz
    color_order: bgr
    invert_colors: false
    rotation: 0
    update_interval: 1s
    lambda: |-
      it.fill(id(color_background));
      it.circle(120, 120, 118, id(color_surface));

      const bool bms_ok = id(bms_online).state;
      const bool cell_page = bms_ok && ((millis() / 8000U) % 2U == 1U);

      if (cell_page) {
        // 电芯电压页(8秒轮换)
        it.printf(120, 13, id(font_small), id(color_text), TextAlign::TOP_CENTER, "16S CELL VOLTAGE");
        float cells[16] = {
          id(cell_voltage_01).state, id(cell_voltage_02).state,
          id(cell_voltage_03).state, id(cell_voltage_04).state,
          id(cell_voltage_05).state, id(cell_voltage_06).state,
          id(cell_voltage_07).state, id(cell_voltage_08).state,
          id(cell_voltage_09).state, id(cell_voltage_10).state,
          id(cell_voltage_11).state, id(cell_voltage_12).state,
          id(cell_voltage_13).state, id(cell_voltage_14).state,
          id(cell_voltage_15).state, id(cell_voltage_16).state
        };
        for (int i = 0; i < 16; i++) {
          const int col = i % 4;
          const int row = i / 4;
          const int x = 43 + col * 51;
          const int y = 45 + row * 39;
          it.printf(x, y, id(font_tiny), id(color_muted), TextAlign::TOP_CENTER, "C%02d", i + 1);
          if (isnan(cells[i])) {
            it.printf(x, y + 14, id(font_small), id(color_red), TextAlign::TOP_CENTER, "--");
          } else {
            it.printf(x, y + 14, id(font_small), id(color_text), TextAlign::TOP_CENTER, "%.3f", cells[i]);
          }
        }
        it.printf(120, 207, id(font_tiny), id(color_muted), TextAlign::TOP_CENTER,
                  "MIN %.3f  MAX %.3f  D %.3f",
                  id(min_cell_voltage).state, id(max_cell_voltage).state,
                  id(delta_cell_voltage).state);
        return;
      }

      // 主页:SOC + 总电压 + 电流
      it.printf(120, 13, id(font_small), id(color_text), TextAlign::TOP_CENTER, "JK-PB  BMS 1");

      if (!bms_ok) {
        it.printf(120, 72, id(font_medium), id(color_red), TextAlign::TOP_CENTER, "NO BMS DATA");
        it.printf(120, 110, id(font_small), id(color_muted), TextAlign::TOP_CENTER, "RS485  115200");
        it.printf(120, 135, id(font_small), id(color_muted), TextAlign::TOP_CENTER, "ADDR 1");
        it.printf(120, 202, id(font_tiny), id(color_muted), TextAlign::TOP_CENTER,
                  "%s  |  %s", id(esp_ip_address).state.c_str(),
                  id(api_server).is_connected() ? "HA OK" : "HA --");
        return;
      }

      float soc = id(bms_soc).state;
      if (isnan(soc)) soc = 0.0f;
      if (soc < 0.0f) soc = 0.0f;
      if (soc > 100.0f) soc = 100.0f;
      const auto soc_color = soc > 50.0f ? id(color_green) : (soc > 20.0f ? id(color_amber) : id(color_red));
      it.printf(120, 36, id(font_large), soc_color, TextAlign::TOP_CENTER, "%.0f%%", soc);
      it.rectangle(48, 89, 144, 9, id(color_surface));
      it.filled_rectangle(50, 91, (int) (140.0f * soc / 100.0f), 5, soc_color);

      it.printf(65, 108, id(font_tiny), id(color_muted), TextAlign::TOP_CENTER, "PACK");
      it.printf(175, 108, id(font_tiny), id(color_muted), TextAlign::TOP_CENTER, "CURRENT");
      it.printf(65, 124, id(font_medium), id(color_cyan), TextAlign::TOP_CENTER, "%.2fV", id(total_voltage).state);
      const auto current_color = id(bms_current).state >= 0.0f ? id(color_green) : id(color_amber);
      it.printf(175, 124, id(font_medium), current_color, TextAlign::TOP_CENTER, "%.1fA", id(bms_current).state);

      it.printf(120, 158, id(font_small), id(color_text), TextAlign::TOP_CENTER,
                "CELL %.3f - %.3fV", id(min_cell_voltage).state, id(max_cell_voltage).state);
      it.printf(120, 179, id(font_small),
                id(delta_cell_voltage).state > 0.050f ? id(color_red) : id(color_muted),
                TextAlign::TOP_CENTER, "DELTA %.3fV  TEMP %.1fC",
                id(delta_cell_voltage).state, id(battery_temperature_1).state);

      const char *mode = id(charging).state ? "CHARGE" : (id(discharging).state ? "DISCHARGE" : "IDLE");
      it.printf(120, 205, id(font_tiny), id(color_muted), TextAlign::TOP_CENTER,
                "%s  |  %s  |  %s", mode,
                id(api_server).is_connected() ? "HA OK" : "HA --",
                id(bms_alarms_bitmask).state == 0 ? "NO ALARM" : "ALARM");

JK-PB Modbus 寄存器映射(16S)

UART/Modbus 配置

uart:
  - id: bms_uart
    tx_pin: ${bms_tx_pin}
    rx_pin: ${bms_rx_pin}
    baud_rate: 115200
    data_bits: 8
    stop_bits: 1
    parity: NONE
    rx_buffer_size: 384

modbus:
  - id: bms_modbus
    uart_id: bms_uart
    send_wait_time: 200ms

modbus_controller:
  - id: bms_controller
    modbus_id: bms_modbus
    address: ${bms_address}
    setup_priority: -10
    update_interval: ${bms_update_interval}
    command_throttle: 50ms
    max_cmd_retries: 2

电芯电压(0x1200-0x121E)

电芯 寄存器 类型 换算
C01 0x1200 U_WORD ×0.001 V
C02 0x1202 U_WORD ×0.001 V
C03 0x1204 U_WORD ×0.001 V
C04 0x1206 U_WORD ×0.001 V
C05 0x1208 U_WORD ×0.001 V
C06 0x120A U_WORD ×0.001 V
C07 0x120C U_WORD ×0.001 V
C08 0x120E U_WORD ×0.001 V
C09 0x1210 U_WORD ×0.001 V
C10 0x1212 U_WORD ×0.001 V
C11 0x1214 U_WORD ×0.001 V
C12 0x1216 U_WORD ×0.001 V
C13 0x1218 U_WORD ×0.001 V
C14 0x121A U_WORD ×0.001 V
C15 0x121C U_WORD ×0.001 V
C16 0x121E U_WORD ×0.001 V

统计与实时数据(0x1244-0x12C2)

数据 寄存器 类型 换算 单位
电芯平均电压 0x1244 U_WORD ×0.001 V
最大压差 0x1246 U_WORD ×0.001 V
MOS 温度 0x128A S_WORD ×0.1 °C
总电压 0x1290 U_DWORD ×0.001 V
功率 0x1294 S_DWORD ×0.001 W
电流 0x1298 S_DWORD ×0.001 A
电池温度1 0x129C S_WORD ×0.1 °C
电池温度2 0x129E S_WORD ×0.1 °C
告警位掩码 0x12A0 U_DWORD — bitmask
均衡电流 0x12A4 S_WORD ×0.001 A
SOC 0x12A6 U_WORD 低8位 %
均衡状态 0x12A6 U_WORD 高8位 —
剩余容量 0x12A8 U_DWORD ×0.001 Ah
满充容量 0x12AC U_DWORD ×0.001 Ah
循环次数 0x12B0 U_DWORD — 次
累计循环容量 0x12B4 U_DWORD ×0.001 Ah
SOH 0x12B8 U_WORD — %
累计运行 0x12BC U_DWORD — s
充电/放电状态 0x12C0 U_WORD 高8/低8 —

可写配置寄存器

参数 寄存器 范围 步长 单位
电芯欠压保护 0x1004 2.0-3.2 0.01 V
电芯欠压恢复 0x1008 2.1-3.4 0.01 V
电芯过压保护 0x100C 3.4-3.75 0.01 V
电芯过压恢复 0x1010 3.3-3.65 0.01 V
均衡触发压差 0x1014 0.005-0.2 0.001 V
充电电流上限 0x102C 1-300 1 A
放电电流上限 0x1038 1-300 1 A
电池额定容量 0x107C 50-1000 1 Ah
开始均衡电压 0x1084 2.8-3.65 0.01 V
充电开关 0x1070 0/1 — On/Off
放电开关 0x1074 0/1 — On/Off
均衡开关 0x1078 0/1 — On/Off

告警位定义(22位)

位 告警
0 线阻异常
1 MOS过温
2 电芯数异常
3 电流传感器
4 电芯过压
5 电池组过压
6 充电过流
7 充电短路
8 充电过温
9 充电低温
10 CPU通信
11 电芯欠压
12 电池组欠压
13 放电过流
14 放电短路
15 放电过温
16 充电MOS
17 放电MOS
18 GPS断开
19 密码超时
20 放电使能失败
21 电池过温

Universal 固件(Web 配置 + MQTT)

独立 ESP-IDF 构建,不依赖 ESPHome。运行时 Web 配置,MQTT HA 自动发现。

首次刷写

esptool --chip esp32 --port /dev/ttyUSB0 --baud 460800 write-flash 0x0 
  dist/jk-bms-universal-1.0.0.factory.bin

配置热点

项目 默认值
热点 JK-BMS-Setup-XXXX
密码 jk-bms-setup
地址 http://192.168.4.1/
用户 admin
初始密码 jk-bms-admin

Web 配置项

  • Wi-Fi SSID/密码、DHCP 或静态 IP
  • RS485 TX/RX/DE GPIO
  • BMS Modbus 地址、波特率、轮询周期
  • 电芯档位:自动识别 1-32S / 固定 8S/16S/24S/32S / 自定义
  • 标称容量
  • MQTT 服务器/端口/账号/主题/HA 发现前缀

MQTT 发布

jk-bms/state
jk-bms/availability
homeassistant/{sensor|binary_sensor}/jk-bms/<entity>/config

GPIO 限制

  • GPIO0 保留 BOOT
  • GPIO1/3 保留调试串口
  • GPIO6-11 保留板载 Flash
  • GPIO2/5/12/15 启动绑带引脚,不能做 RS485
  • GPIO34-39 只能做 RX

圆屏实拍

30秒实拍视频,主页和电芯页轮换显示:

与太阳能系统的关系

这套独立读 BMS,跟光伏储能接入 HA那篇文章里的德业一体机 Modbus 读取是两条独立数据通道:

  • 一体机路:ESP32-S3 + MAX485 读德业 12K 一体机,电池数据通过 CAN→一体机→Modbus 间接获取
  • BMS 直读路(本篇):ESP32 + RS485 直读 JK-PB BMS 本体,16 电芯电压/压差/均衡状态这些一体机拿不到的细节数据

两路数据在 HA 里并存,互为校验。

参考