#include #include #include #include // Бібліотека для роботи з CAN-шиною ESP32 (протокол TWAI) // --- НАЛАШТУВАННЯ МЕРЕЖІ ТА ПІНІВ --- const char* ssid = "XXXXX"; // Назва вашої Wi-Fi мережі const char* password = "XXXXX"; // Пароль до Wi-Fi #define BMS_DE 4 // Пін керування напрямком RS485 (Data Enable для MAX485) #define BMS_RX 16 // Пін прийому даних від BMS (RS485) #define BMS_TX 17 // Пін передачі даних до BMS (RS485) #define CAN_RX_PIN 18 // Пін прийому CAN-шини (від інвертора) #define CAN_TX_PIN 19 // Пін передачі CAN-шини (до інвертора) // Структура для зберігання та обробки даних від кожної батареї Vision struct BmsData { float packV = 0, current = 0; // Загальна напруга та струм АКБ int soc = 0, tempPCB = 0; // Рівень заряду (%) та температура плати uint16_t cellsV[15]; // Масив напруг 15 окремих осередків (банок) uint32_t cycles = 0; // Кількість завершених циклів заряду bool online = false; // Прапор наявності зв'язку з цією батареєю int errorCount = 0; // Лічильник помилок для алгоритму перевірки зв'язку }; BmsData bats[2]; // Створюємо масив для опитування двох акумуляторів WebServer server(80); // Запускаємо Веб-сервер на стандартному 80 порту // Функція формування та відправки стандартного CAN-кадру в інвертор void sendCanFrame(uint32_t id, uint8_t len, uint8_t* data) { twai_message_t message; message.identifier = id; message.extd = 0; // Використовуємо стандартний (не розширений) ID message.data_length_code = len; // Довжина даних (зазвичай 8 байт) for (int i = 0; i < len; i++) message.data[i] = data[i]; twai_transmit(&message, pdMS_TO_TICKS(10)); // Відправка кадру } // Функція розрахунку контрольної суми CRC16 для протоколу Modbus RTU uint16_t getModbusCRC(const uint8_t *buf, size_t len) { uint16_t crc = 0xFFFF; for (size_t pos = 0; pos < len; pos++) { crc ^= (uint16_t)buf[pos]; for (int i = 8; i != 0; i--) { if ((crc & 0x0001) != 0) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return crc; } // Обробник головної сторінки WEB-інтерфейсу void handleRoot() { float totalI = 0; int avgSoc = 0; int onlineCount = 0; // Розрахунок сумарного струму та середнього заряду серед активних батарей for(int i=0; i<2; i++) { if(bats[i].online) { totalI += bats[i].current; avgSoc += bats[i].soc; onlineCount++; } } if(onlineCount > 0) avgSoc /= onlineCount; // Формування HTML-сторінки з дизайном та автоматичним оновленням (кожні 5 сек) String html = ""; html += ""; html += "

BMS Шлюз: VISION ↔ LuxPower

"; html += "

Загальний підсумок (Стек)

"; html += "
Середній SOC: " + String(avgSoc) + "%
"; html += "
Загальний струм: " + String(totalI, 1) + " А
"; // Візуалізація даних для кожної батареї окремо for (int i = 0; i < 2; i++) { html += "
"; html += "

АКБ #" + String(i + 1) + " | SOC: " + String(bats[i].soc) + "%

"; if (bats[i].online) { html += "

Напруга: " + String(bats[i].packV, 2) + " В | Струм: " + String(bats[i].current, 1) + " А | Темп: " + String(bats[i].tempPCB) + "°C | Цикли: " + String(bats[i].cycles) + "

"; html += "
"; // Відображення напруги кожної з 15 банок for (int j = 0; j < 15; j++) { html += "
#" + String(j + 1) + "
" + String(bats[i].cellsV[j]/1000.0f, 3) + "
"; } html += "
"; } else { html += "

СТАТУС: ОФЛАЙН (Помилок: " + String(bats[i].errorCount) + ")

"; } html += "
"; } html += "

ПЕРЕЗАВАНТАЖИТИ ПРИСТРІЙ"; server.send(200, "text/html", html); } // Функція віддаленого перезавантаження контролера void handleReboot() { server.send(200, "text/plain", "Restarting..."); delay(1000); ESP.restart(); } // Функція опитування BMS по протоколу Modbus RS485 void requestBmsData(uint8_t id) { while(Serial2.available()) Serial2.read(); // Очищення вхідного буфера перед запитом uint8_t req[8] = {id, 0x03, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00}; // Команда на читання регістрів uint16_t crc = getModbusCRC(req, 6); req[6] = crc & 0xFF; req[7] = (crc >> 8) & 0xFF; digitalWrite(BMS_DE, HIGH); // Перемикаємо MAX485 на передачу Serial2.write(req, 8); Serial2.flush(); digitalWrite(BMS_DE, LOW); // Перемикаємо на прийом unsigned long start = millis(); uint8_t res[80]; int idx = 0; // Чекаємо на відповідь до 200мс while (millis() - start < 200 && idx < 80) { if (Serial2.available()) res[idx++] = Serial2.read(); } int i = id - 1; // Якщо отримано повний пакет даних від потрібної ID if (idx >= 67 && res[0] == id) { bats[i].packV = ((res[3] << 8) | res[4]) / 100.0f; bats[i].current = (int16_t)((res[5] << 8) | res[6]) / 100.0f; for (int j = 0; j < 15; j++) bats[i].cellsV[j] = (res[7 + (j * 2)] << 8) | res[8 + (j * 2)]; bats[i].soc = (res[51] << 8) | res[52]; bats[i].tempPCB = (int16_t)((res[39] << 8) | res[40]); bats[i].cycles = ((uint32_t)res[61] << 24) | ((uint32_t)res[62] << 16) | ((uint32_t)res[63] << 8) | (uint32_t)res[64]; bats[i].online = true; bats[i].errorCount = 0; } else { bats[i].errorCount++; if(bats[i].errorCount > 5) bats[i].online = false; // Якщо АКБ не відповідає 5 разів поспіль } } // Функція передачі стану АКБ в інвертор через CAN-шину (емуляція Pylontech) void sendInverterResponse() { uint8_t count = 0; float sumV = 0, sumI = 0; int sumSoc = 0; for(int i=0; i<2; i++) { if(bats[i].online) { count++; sumV += bats[i].packV; sumI += bats[i].current; sumSoc += bats[i].soc; } } if (count == 0) return; // Якщо немає даних від АКБ, в інвертор нічого не шлемо uint16_t avgSoc = sumSoc / count; uint16_t outV = (sumV / count) * 100; int16_t outI = sumI * 10; uint16_t totalCap = count * 100; // Розрахункова ємність (100Аг на кожен модуль) // 0x351: Напруга відсічки заряду та межі струму (54.0V, 50A заряд/розряд) uint8_t d351[8] = { 0x1C, 0x02, 0xF4, 0x01, 0xF4, 0x01, 0xD6, 0x01 }; sendCanFrame(0x351, 8, d351); // 0x355: Передача SOC (рівень заряду) та SOH (стан здоров'я) uint8_t d355[8] = { (uint8_t)(avgSoc & 0xFF), (uint8_t)(avgSoc >> 8), 0x64, 0x00, 0x00, 0x00, 0x00, 0x00 }; sendCanFrame(0x355, 8, d355); // 0x356: Реальна напруга та струм всього стека uint8_t d356[8] = { (uint8_t)(outV & 0xFF), (uint8_t)(outV >> 8), (uint8_t)(outI & 0xFF), (uint8_t)(outI >> 8), 0x00, 0x00, 0x00, 0x00 }; sendCanFrame(0x356, 8, d356); // 0x359: Кількість підключених модулів uint8_t d359[8] = { 0x00, 0x00, 0x00, count, (uint8_t)('0' + count), 0x00, 0x00, 0x00 }; sendCanFrame(0x359, 8, d359); // 0x35E: Ідентифікатор виробника (Інвертор чекає на 'PYLON') uint8_t d35E[8] = { 'P', 'Y', 'L', 'O', 'N', ' ', '0', (uint8_t)('0' + count) }; sendCanFrame(0x35E, 8, d35E); // 0x35F: Загальна ємність системи в Аг uint8_t d35F[8] = { (uint8_t)(totalCap & 0xFF), (uint8_t)(totalCap >> 8), 0x00, 0x00, (uint8_t)(totalCap & 0xFF), (uint8_t)(totalCap >> 8), count, 0x00 }; sendCanFrame(0x35F, 8, d35F); } void setup() { Serial.begin(115200); // Діагностика через USB Serial2.begin(9600, SERIAL_8N1, BMS_RX, BMS_TX); // Зв'язок з BMS pinMode(BMS_DE, OUTPUT); // Конфігурація CAN-контролера (TWAI) на швидкість 500 кбіт/с twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t)CAN_TX_PIN, (gpio_num_t)CAN_RX_PIN, TWAI_MODE_NORMAL); twai_timing_config_t t_config = TWAI_TIMING_CONFIG_500KBITS(); twai_filter_config_t f_config = TWAI_FILTER_CONFIG_ACCEPT_ALL(); if (twai_driver_install(&g_config, &t_config, &f_config) == ESP_OK) twai_start(); // Підключення до Wi-Fi та реєстрація маршрутів Веб-сервера WiFi.begin(ssid, password); server.on("/", handleRoot); server.on("/reboot", handleReboot); server.begin(); } void loop() { static unsigned long lb = 0, lc = 0; // Відправка даних інвертору кожну 1 секунду if (millis() - lc > 1000) { sendInverterResponse(); lc = millis(); } // Опитування BMS кожні 3 секунди (по черзі ID 1 та ID 2) if (millis() - lb > 3000) { requestBmsData(1); delay(100); requestBmsData(2); lb = millis(); } server.handleClient(); // Обробка запитів від браузера delay(1); }