Если у тебя стоит LiFePO₄‑аккумулятор — у резервного питания, солнечной станции или просто в мастерской — эта статья будет полезна. Чтобы узнать состояние батареи через БМС с Bluetooth, приходится каждый раз брать телефон, запускать приложение, ждать соединения… И так снова и снова. А если аккумулятор вообще в соседнем помещении? Ходить туда с телефоном — удовольствие сомнительное.
Я собрал устройство, которое решает эту проблему раз и навсегда. Компактный Bluetooth‑монитор БМС аккумулятора с небольшим дисплеем отображает минимально необходимую информацию о состоянии БМС и аккумулятора. Никакого телефона. Никаких приложений. Просто подключил к обычной зарядке Type‑C — и сразу всё видно.
В конце статьи — код скетча для прошивки esp32 в Arduino IDE!
В итоге вы получите
- Готовый монитор BMS с экраном
- Автоматическое подключение по Bluetooth
- Отображение всех ключевых параметров
- Компактный корпус для печати
- Готовый скетч
Что понадобится (компоненты)
ESP32-C3 Super Mini — 1 шт
OLED SSD1306 128×64 — 1 шт
BMS Jiabaida (JBD) с BLE — 1 шт >> Источник данных (протестировано на JBD DP04S007)
OLED дисплей 0.96" 128×64 (SSD1306)
Двухцветный дисплей:
- жёлтая зона — статус
- синяя — данные
Корпус (3D)
Для проекта разработан компактный корпус из 3 частей.
- Лицевая часть с защёлками под дисплей
- Тыльная часть
- Средняя часть с защёлкой под установку ESP32
Подходит для печати на любом FDM‑принтере.
Сборка
- Припаять гребёнку к ESP32 C3 Super Mini
- Припаять провода к дисплею, длина ~5 см
- Загрузить скетч через Arduino IDE (код — в конце)
- Закрепить дисплей в лицевой панели — защёлкивается без клея
- Установить ESP32 в среднюю часть корпуса — защёлкивается без клея
- Соединить провода и закрыть крышку
- Подать питание — на разъём Type‑C микроконтроллера (5В)
Схема подключения
OLED подключается к ESP32 представлена на картинке
Советы
- проверяй питание OLED (часто ошибаются)
- не путай SPI и I2C версии дисплея
- держи ESP32 поближе к БМС
- скетч — основа, можно изменять для других дисплеев
Беспроводной монитор БМС работает по простой схеме:
- ESP32 запускается
- сканирует BLE
- находит BMS
- подключается
- получает данные
- выводит их на экран
Состояния системы:
- START
- CONNECTING
- CONNECTED
- DATA
- REBOOT
Настройка и проверка
После прошивки:
- устройство автоматически ищет BMS
- подключается
- начинает отображать данные
Можно менять:
- MAC‑адрес BMS
- параметры отображения
- интервалы обновления
Что показывает монитор
Экран разделён на 2 зоны:
Верхняя (жёлтая)
Нижняя (синяя)
- напряжение
- мощность
- время до заряда/разряда
Возможные проблемы
Не подключается к BMS
- неверный MAC
- слабый сигнал
Пустой экран
- ошибка подключения дисплея
- неверная библиотека
Глючит отображение
- проблемы с питанием
- ошибки в парсинге пакета
Скачать и использовать
Полный скетч
Библиотеки
STL корпус
Итог
Получился простой и полезный девайс:
- работает автономно
- не требует смартфона
- отображает состояние БМС и аккумулятора
- И главное — собирается за вечер без боли и страданий (ну почти).
FAQ
Подходит ли для любой JBD?
Теоретически да, если есть Bluetooth. 100% протестировано на JBD DP04S007.
Можно другой дисплей?
Да, SSD1306 128×64 (SPI или I2C).
Нужно приложение?
Нет. Всё работает автономно.
Код ниже полностью готов — просто измените MAC адрес на свой и прошейте ESP32
/*
* ESP32-C3 Mini BMS Monitor with two-color SSD1306 (SPI)
*
* Display connection (SPI) – full pin comments:
* OLED_MOSI (D1) -> GPIO7
* OLED_CLK (D0) -> GPIO6
* OLED_DC (DC) -> GPIO2
* OLED_CS (CS) -> GPIO10
* OLED_RESET(RES) -> GPIO5
* VCC -> 3.3V
* GND -> GND
*
* Hardware:
* - ESP32-C3 Super Mini
* - SSD1306 128x64 SPI (two-color: yellow 16px, blue 48px)
* - LED on pin 8
*
* LED behavior:
* - On startup: always on
* - During connection (5 attempts): blinking 500/500 ms
* - On receiving data: short flash every 3 seconds
*/
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <NimBLEDevice.h>
#include "esp_task_wdt.h"
/* ================= ДИСПЛЕЙ (SPI) ================= */
#define OLED_MOSI 7 // D1
#define OLED_CLK 6 // D0
#define OLED_DC 2 // DC
#define OLED_CS 10 // CS
#define OLED_RESET 5 // RES
Adafruit_SSD1306 display(128, 64, OLED_MOSI, OLED_CLK, OLED_DC, OLED_RESET, OLED_CS);
/* ================= LED ================= */
#define LED_PIN 8
/* ================= BLE ================= */
#define BMS_MAC "a5:c2:37:2a:a2:f0"
static NimBLEAddress deviceAddress(BMS_MAC, 0);
static NimBLEClient* connectedClient = nullptr;
static uint8_t notifyBuffer[256];
static size_t notifyLen = 0;
/* ================= ТАЙМЕРЫ ================= */
unsigned long nowMs;
unsigned long lastRequestMs = 0;
unsigned long lastDataMs = 0;
unsigned long lastLedMs = 0;
unsigned long rebootAtMs = 0;
unsigned long lastWatchdogFeedMs = 0;
/* ================= КОНСТАНТЫ ================= */
const unsigned long REQUEST_INTERVAL = 3000;
const unsigned long DATA_TIMEOUT = 15000;
const unsigned long WATCHDOG_FEED_INTERVAL = 1000;
const unsigned long WATCHDOG_TIMEOUT = 30;
const unsigned long LED_BLINK_INTERVAL = 500;
const unsigned long LED_FLASH_INTERVAL = 3000;
const unsigned long LED_FLASH_DURATION = 50;
const uint8_t CMD_BASIC[] = {0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77};
/* ===== БАТАРЕЯ ===== */
const float BAT_CAPACITY_AH = 100.0f;
/* ===== КОНСТАНТЫ ПАРСЕРА ===== */
const size_t PACKET_MIN_LEN = 41;
const float VOLTAGE_MAX = 100.0f;
const uint8_t SOC_MAX = 100;
const float CURRENT_THRESHOLD = 0.05f;
const float CURRENT_THRESHOLD_ETA = 0.1f;
const float CURRENT_MIN_FOR_DIVISION = 0.001f;
/* ================= ДАННЫЕ BMS ================= */
struct BMSData {
float voltage;
float current;
float power;
uint8_t soc;
float temp; // температура BMS (только для логов)
bool isValid = false;
} bms, prevBms;
/* ================= СОСТОЯНИЯ ================= */
enum State {
ST_START,
ST_CONNECTING,
ST_CONNECTED,
ST_DATA,
ST_REBOOT
};
State state = ST_START;
State prevState = ST_START;
uint8_t connectAttempts = 0;
const uint8_t MAX_CONNECT_ATTEMPTS = 5;
/* ================= КЭШ ДИСПЛЕЯ (4 строки) ================= */
static char lastLine0[32] = ""; // Режим (жёлтая зона, шрифт 2)
static char lastLine1[32] = ""; // SOC и напряжение (шрифт 1)
static char lastLine2[32] = ""; // Мощность (шрифт 1)
static char lastLine3[32] = ""; // ETA (шрифт 1)
/* ================= LED ================= */
bool ledBlinkState = false;
bool ledFlashActive = false;
unsigned long ledFlashStartMs = 0;
/* ================= ЛОГИРОВАНИЕ ================= */
void logInfo(const char* msg) {
Serial.printf("[INFO] %lu: %s\n", millis(), msg);
}
void logDebug(const char* msg) {
Serial.printf("[DEBUG] %lu: %s\n", millis(), msg);
}
void logError(const char* msg) {
Serial.printf("[ERROR] %lu: %s\n", millis(), msg);
}
void logStateChange(State oldState, State newState) {
const char* stateNames[] = {"ST_START", "ST_CONNECTING", "ST_CONNECTED", "ST_DATA", "ST_REBOOT"};
Serial.printf("[STATE] %lu: %s -> %s\n", millis(), stateNames[oldState], stateNames[newState]);
}
/* ================= ДИСПЛЕЙ (SSD1306, двухцветный) ================= */
// Геометрия: жёлтая область 16px, синяя 48px
static const int YELLOW_HEIGHT = 16;
static const int BLUE_START = YELLOW_HEIGHT;
// Размеры шрифтов
static const uint8_t TEXT_SIZE_BIG = 2; // для жёлтой строки и заставки
static const uint8_t TEXT_SIZE_SMALL = 1; // для синих строк и статусных сообщений
// Высоты строк
static const int LINE_H_BIG = 16; // высота жёлтой строки
static const int LINE_H_SMALL = 12; // высота каждой синей строки (шрифт 8 + отступ 4)
// Координаты Y для строк (с учётом центрирования внутри своих зон)
static const int LINE_Y0 = 0; // жёлтая строка (занимает всю жёлтую зону)
static const int LINE_Y1 = BLUE_START + 6; // первая синяя строка (отступ 6 сверху)
static const int LINE_Y2 = LINE_Y1 + LINE_H_SMALL; // вторая синяя строка
static const int LINE_Y3 = LINE_Y2 + LINE_H_SMALL; // третья синяя строка
// Отрисовка конкретной строки с автоматическим центрированием
void drawLine(uint8_t line, const char* text) {
int yTop;
char* lastLine;
uint8_t textSize;
int lineHeight;
switch (line) {
case 0:
yTop = LINE_Y0;
lastLine = lastLine0;
textSize = TEXT_SIZE_BIG;
lineHeight = LINE_H_BIG;
break;
case 1:
yTop = LINE_Y1;
lastLine = lastLine1;
textSize = TEXT_SIZE_SMALL;
lineHeight = LINE_H_SMALL;
break;
case 2:
yTop = LINE_Y2;
lastLine = lastLine2;
textSize = TEXT_SIZE_SMALL;
lineHeight = LINE_H_SMALL;
break;
case 3:
yTop = LINE_Y3;
lastLine = lastLine3;
textSize = TEXT_SIZE_SMALL;
lineHeight = LINE_H_SMALL;
break;
default: return;
}
// Если текст не изменился – ничего не делаем
if (strcmp(lastLine, text) == 0) return;
strncpy(lastLine, text, 31);
lastLine[31] = '\0';
// Очищаем область строки
display.fillRect(0, yTop, 128, lineHeight, SSD1306_BLACK);
// Центрируем текст по горизонтали и вертикали внутри строки
display.setTextSize(textSize);
display.setTextColor(SSD1306_WHITE);
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds(text, 0, 0, &x1, &y1, &w, &h);
int x = (128 - w) / 2;
if (x < 0) x = 0;
// Вертикальное центрирование внутри строки высотой lineHeight
int y = yTop + (lineHeight - h) / 2;
display.setCursor(x, y);
display.print(text);
display.display();
}
// Полная отрисовка экрана с данными (при переходе в ST_DATA)
void drawDataScreen() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// Сброс кэша
lastLine0[0] = '\0';
lastLine1[0] = '\0';
lastLine2[0] = '\0';
lastLine3[0] = '\0';
if (bms.isValid) {
// Строка 0 (жёлтая): режим
char mode[16];
if (bms.current > CURRENT_THRESHOLD) strcpy(mode, "CHARGE");
else if (bms.current < -CURRENT_THRESHOLD) strcpy(mode, "DISCHARGE");
else strcpy(mode, "IDLE");
drawLine(0, mode);
// Строка 1: SOC и напряжение (компактно, 1 знак после запятой)
char line1[32];
snprintf(line1, sizeof(line1), "%d%%%.1fV", bms.soc, bms.voltage);
drawLine(1, line1);
// Строка 2: мощность
char line2[32];
snprintf(line2, sizeof(line2), "%dW", (int)bms.power);
drawLine(2, line2);
// Строка 3: ETA
char line3[32];
if (fabs(bms.current) > CURRENT_THRESHOLD_ETA && fabs(bms.current) >= CURRENT_MIN_FOR_DIVISION) {
float socFrac = bms.soc / 100.0f;
float hours;
if (bms.current > 0)
hours = (BAT_CAPACITY_AH * (1.0f - socFrac)) / bms.current;
else
hours = (BAT_CAPACITY_AH * socFrac) / fabs(bms.current);
int h = (int)hours;
int m = (int)((hours - h) * 60);
snprintf(line3, sizeof(line3), "%d:%02d", h, m);
} else {
strcpy(line3, "00:00");
}
drawLine(3, line3);
}
display.display();
}
// Показать сообщение по центру всего экрана (заставка, статусы)
void showCenteredMessage(const char* msg, bool bigFont = false) {
display.clearDisplay();
display.setTextSize(bigFont ? TEXT_SIZE_BIG : TEXT_SIZE_SMALL);
display.setTextColor(SSD1306_WHITE);
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
int x = (128 - w) / 2;
int y = (64 - h) / 2;
display.setCursor(x, y);
display.print(msg);
display.display();
// Сброс кэша строк
lastLine0[0] = '\0';
lastLine1[0] = '\0';
lastLine2[0] = '\0';
lastLine3[0] = '\0';
}
/* ================= LED ================= */
void updateLed() {
if (state == ST_START) {
digitalWrite(LED_PIN, HIGH);
ledFlashActive = false;
} else if (state == ST_CONNECTING) {
if (nowMs - lastLedMs >= LED_BLINK_INTERVAL) {
ledBlinkState = !ledBlinkState;
digitalWrite(LED_PIN, ledBlinkState ? HIGH : LOW);
lastLedMs = nowMs;
}
ledFlashActive = false;
} else if (state == ST_DATA) {
if (!ledFlashActive) {
digitalWrite(LED_PIN, LOW); // обычно выключен
if (nowMs - lastLedMs >= LED_FLASH_INTERVAL) {
ledFlashActive = true;
ledFlashStartMs = nowMs;
digitalWrite(LED_PIN, HIGH);
}
} else if (nowMs - ledFlashStartMs >= LED_FLASH_DURATION) {
ledFlashActive = false;
digitalWrite(LED_PIN, LOW);
lastLedMs = nowMs;
}
} else {
digitalWrite(LED_PIN, HIGH);
ledFlashActive = false;
}
}
/* ================= ПАРСЕР ================= */
void parseBasicData(const uint8_t* d, size_t len) {
if (len < PACKET_MIN_LEN || d[0] != 0xDD || d[1] != 0x03 || d[len - 1] != 0x77) {
logError("Invalid packet format");
return;
}
prevBms = bms;
const uint8_t* p = d + 4;
float voltage = ((p[0] << 8) | p[1]) / 100.0f;
float current = (int16_t)((p[2] << 8) | p[3]) / 100.0f;
uint8_t soc = p[19];
int16_t rawTemp = (int16_t)(((uint16_t)p[23] << 8) | p[24]);
float temp = (rawTemp - 2731) / 10.0f;
if (voltage < 0 || voltage > VOLTAGE_MAX || soc > SOC_MAX) {
logError("Invalid data values");
return;
}
bms.voltage = voltage;
bms.current = current;
bms.soc = soc;
bms.power = bms.voltage * fabs(bms.current);
bms.temp = temp;
bms.isValid = true;
lastDataMs = millis();
if (state != ST_DATA) {
State oldState = state;
state = ST_DATA;
logStateChange(oldState, state);
drawDataScreen(); // полная отрисовка при первом получении данных
logDebug("State changed to ST_DATA, full redraw");
} else {
// Частичное обновление строк
char mode[16];
if (bms.current > CURRENT_THRESHOLD) strcpy(mode, "CHARGE");
else if (bms.current < -CURRENT_THRESHOLD) strcpy(mode, "DISCHARGE");
else strcpy(mode, "IDLE");
drawLine(0, mode);
char line1[32];
snprintf(line1, sizeof(line1), "%d%%%.1fV", bms.soc, bms.voltage);
drawLine(1, line1);
char line2[32];
snprintf(line2, sizeof(line2), "%dW", (int)bms.power);
drawLine(2, line2);
char line3[32];
if (fabs(bms.current) > CURRENT_THRESHOLD_ETA && fabs(bms.current) >= CURRENT_MIN_FOR_DIVISION) {
float socFrac = bms.soc / 100.0f;
float hours = (bms.current > 0) ? (BAT_CAPACITY_AH * (1.0f - socFrac)) / bms.current
: (BAT_CAPACITY_AH * socFrac) / fabs(bms.current);
int h = (int)hours;
int m = (int)((hours - h) * 60);
snprintf(line3, sizeof(line3), "%d:%02d", h, m);
} else {
strcpy(line3, "00:00");
}
drawLine(3, line3);
}
}
/* ================= NOTIFY ================= */
void notifyCallback(NimBLERemoteCharacteristic*, uint8_t* data, size_t len, bool) {
for (size_t i = 0; i < len; i++) {
uint8_t b = data[i];
if (b == 0xDD) {
notifyLen = 0;
notifyBuffer[notifyLen++] = b;
continue;
}
if (notifyLen < sizeof(notifyBuffer)) {
notifyBuffer[notifyLen++] = b;
} else {
notifyLen = 0;
continue;
}
if (b == 0x77 && notifyLen >= 6) {
if (notifyBuffer[1] == 0x03) parseBasicData(notifyBuffer, notifyLen);
notifyLen = 0;
}
}
}
/* ================= СКАНИРОВАНИЕ ================= */
class ScanCallbacks : public NimBLEScanCallbacks {
void onResult(const NimBLEAdvertisedDevice* dev) override {
if (dev->getAddress().equals(deviceAddress)) {
NimBLEDevice::getScan()->stop();
logInfo("BMS device found in scan");
}
}
};
/* ================= УПРАВЛЕНИЕ BLE ================= */
void cleanupBleResources() {
logInfo("Cleaning up BLE resources");
if (connectedClient) {
if (connectedClient->isConnected()) connectedClient->disconnect();
NimBLEDevice::deleteClient(connectedClient);
connectedClient = nullptr;
}
notifyLen = 0;
bms.isValid = false;
}
/* ================= ПОДКЛЮЧЕНИЕ С ПОПЫТКАМИ ================= */
bool tryConnectWithAttempts() {
State oldState = state;
state = ST_CONNECTING;
logStateChange(oldState, state);
showCenteredMessage("CONNECT", false); // мелкий шрифт
logInfo("Starting connection attempts");
auto scan = NimBLEDevice::getScan();
if (scan) scan->stop();
connectAttempts = 0;
esp_task_wdt_reset();
for (uint8_t attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; ++attempt) {
esp_task_wdt_reset();
connectAttempts = attempt;
char msg[32];
snprintf(msg, sizeof(msg), "Connect %d/%d", attempt, MAX_CONNECT_ATTEMPTS);
showCenteredMessage(msg, false); // мелкий шрифт
logDebug(msg);
NimBLEClient* c = NimBLEDevice::createClient();
if (!c) { delay(1000); continue; }
if (!c->connect(deviceAddress)) {
NimBLEDevice::deleteClient(c);
delay(1000);
continue;
}
auto s = c->getService("ff00");
if (!s) { c->disconnect(); NimBLEDevice::deleteClient(c); delay(1000); continue; }
auto n = s->getCharacteristic("ff01");
if (n && n->canNotify()) n->subscribe(true, notifyCallback);
auto w = s->getCharacteristic("ff02");
if (!w) { c->disconnect(); NimBLEDevice::deleteClient(c); delay(1000); continue; }
w->writeValue(CMD_BASIC, sizeof(CMD_BASIC), false);
connectedClient = c;
lastRequestMs = millis();
oldState = state;
state = ST_CONNECTED;
logStateChange(oldState, state);
showCenteredMessage("CONNECTED", false); // мелкий шрифт
logInfo("Connected - waiting for data");
digitalWrite(LED_PIN, HIGH);
return true;
}
oldState = state;
state = ST_REBOOT;
logStateChange(oldState, state);
showCenteredMessage("REBOOT", false); // мелкий шрифт
rebootAtMs = millis() + 3000;
return false;
}
void checkConnectionStatus() {
if (connectedClient && !connectedClient->isConnected()) {
logError("BLE connection lost");
cleanupBleResources();
tryConnectWithAttempts();
}
}
/* ================= WATCHDOG ================= */
void feedWatchdog() {
if (nowMs - lastWatchdogFeedMs >= WATCHDOG_FEED_INTERVAL) {
esp_task_wdt_reset();
lastWatchdogFeedMs = nowMs;
}
}
/* ================= SETUP ================= */
void setup() {
Serial.begin(115200);
delay(200);
logInfo("=== BMS Monitor SSD1306 SPI ===");
esp_task_wdt_config_t wdt_config;
wdt_config.timeout_ms = WATCHDOG_TIMEOUT * 1000;
wdt_config.idle_core_mask = 0;
wdt_config.trigger_panic = true;
esp_task_wdt_init(&wdt_config);
esp_task_wdt_add(NULL);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, HIGH);
state = ST_START;
// Инициализация SPI и дисплея
SPI.begin(OLED_CLK, -1, OLED_MOSI, OLED_CS);
if (!display.begin(SSD1306_SWITCHCAPVCC)) {
logError("SSD1306 allocation failed");
while (true);
}
display.setTextColor(SSD1306_WHITE);
display.clearDisplay();
// Заставка (большой шрифт)
showCenteredMessage("CUB v1.0", true);
delay(2000);
logInfo("Initializing BLE");
NimBLEDevice::init("");
NimBLEDevice::setPower(ESP_PWR_LVL_P6);
NimBLEDevice::setMTU(64);
auto scan = NimBLEDevice::getScan();
scan->setScanCallbacks(new ScanCallbacks());
scan->setActiveScan(true);
scan->setInterval(100);
scan->setWindow(99);
scan->start(0, false);
logInfo("BLE scan started");
tryConnectWithAttempts();
}
/* ================= LOOP ================= */
void loop() {
nowMs = millis();
feedWatchdog();
updateLed();
checkConnectionStatus();
if (state == ST_REBOOT && nowMs >= rebootAtMs) {
logInfo("Rebooting...");
cleanupBleResources();
NimBLEDevice::deinit(true);
delay(100);
ESP.restart();
}
// Если подключены и прошло 3 секунды – запрашиваем данные
if (connectedClient && connectedClient->isConnected() && nowMs - lastRequestMs >= REQUEST_INTERVAL) {
lastRequestMs = nowMs;
auto s = connectedClient->getService("ff00");
if (s) {
auto c = s->getCharacteristic("ff02");
if (c) {
c->writeValue(CMD_BASIC, sizeof(CMD_BASIC), false);
logDebug("Data request sent");
}
}
}
// Таймаут данных в состоянии CONNECTED
if (state == ST_CONNECTED && (nowMs - lastDataMs > DATA_TIMEOUT) && !bms.isValid) {
showCenteredMessage("NO DATA", false); // мелкий шрифт
logError("Data timeout");
delay(1500);
cleanupBleResources();
tryConnectWithAttempts();
}
delay(10);
}
Комментарии к статье
Пока нет комментариев. Будьте первым!
Добавить комментарий