Якщо у тебе стоїть 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);
}
Коментарі до статті
Поки що немає коментарів. Будьте першим!
Додати коментар