Інвертори MUST, Voltronic, PowMr, EASUN та їх аналоги підтримують обмін даними через RS485 Modbus, однак штатні Wi-Fi модулі часто працюють нестабільно, вимагають хмару або дають замало інформації.
У цій статті покажу, як зібрати локальний моніторинг інвертора MUST на ESP32 з веб-інтерфейсом через Wi-Fi — без сторонніх хмар і підписок. Актуальна прошивка монітора — серія 3.4.x (в інтерфейсі відображається рядок code V 3.4.3).
Що можна бачити в реальному часі
- потужність сонячних панелей (PV)
- навантаження на виході інвертора
- обмін з міською мережею (імпорт / експорт)
- напруги мережі, навантаження, АКБ, шини BUS
- режим роботи (OFF GRID, Grid charging, Bypass та ін.)
- температури радіаторів AC і DC
- накопичену PV-енергію та лічильники ACCUM (buy, load, discharge…)
- стан реле, частоту мережі та інвертора
- оцінку втрат інвертора (INV LOSS)
- енергетичний баланс системи
Починаючи з прошивки 3.4.x, веб-інтерфейс повторює структуру рідної утиліти SolarPowerMonitor: окрім компактних плиток зверху, під ними виводяться чотири колонки з тими ж групами параметрів, що й у програмі виробника.
Наприкінці статті — готовий скетч і плани розвитку проєкту.
Що вийде в результаті
Після збірки ви отримаєте локальний Wi-Fi моніторинг для інвертора MUST з доступом через браузер:
- робота без хмари
- опитування через RS485 Modbus RTU
- ESP32-C3 + модуль MAX485
- інтерфейс у стилі SolarPowerMonitor (плитки + 4 колонки)
- читання блоків інвертора (252xx), MPPT (152xx) та налаштувань (201xx)
- локальна адреса
http://inverter.local
- вбудована діагностика Modbus через браузер
- готовий робочий скетч
- STL-модель корпусу для 3D-друку (в розробці)
Компоненти
Для збірки знадобляться:
- ESP32-C3 Super Mini
- модуль RS485 TTL MAX485
- кабель USB Type-C для живлення контролера
- кабель з роз'ємом RJ45 (або інший, згідно з документацією інвертора) для RS485
- проводи Dupont
- інвертор MUST POWER з інтерфейсом RS485
Скетч — основа, яку можна розширити
- OLED-дисплей
- зовнішній watchdog
- Ethernet-модуль
- microSD для логів
- інтеграція з Home Assistant
- паралельне читання BMS через Bluetooth (планується, див. розділ «Плани»)
Корпус для пристрою
Пристрій розробляється в компактному корпусі для DIN-рейки, щоб його можна було встановити поруч з інвертором або в електрощиті.
Планується:
- вентиляція корпусу
- зручний доступ до RS485
- USB для прошивки
- індикація Wi-Fi та обміну даними
- монтаж без підтримок при 3D-друку
Корпус друкується на звичайному FDM 3D-принтері. STL-модель буде опубліковано після успішних випробувань.
Схема підключення
ESP32 підключається до інвертора через модуль RS485:
A → A
B → B
GND → GND
Протокол: Modbus RTU.
Параметри порту (для MUST PV1800 / аналогів):
19200 baud
8N1
Slave ID: 0x04 (4)
Код і логіка роботи
ESP32 створює локальний WEB-інтерфейс з оновленням даних приблизно кожні 3 секунди (/api у форматі JSON).
Основна логіка:
- читання блоку інвертора 25201…25280
- окреме читання 25273…25275 (потужність і струм АКБ)
- читання блоку MPPT 15201…15220
- читання налаштувань 20101…20150
- одноразове читання ідентифікації 20000…20008 (модель, версії)
- розрахунок енергобалансу та оцінки втрат інвертора
- відображення через WEB-інтерфейс
Бібліотеки (Arduino IDE):
- WiFi.h
- ESPAsyncWebServer.h
- ModbusMaster.h
- ESPmDNS.h
WEB-інтерфейс
Відкривається локально:
http://inverter.local
або за IP-адресою ESP32 у вашій Wi-Fi мережі.
Верхні плитки (швидкий огляд)
| Плитка | Формат | Опис |
| AC RAD TEMP | °C | Температура AC-радіатора (рег. 25233) |
| SOLAR | V | W | Напруга PV та потужність (15208) |
| DC RAD TEMP | °C | Температура DC-радіатора (25235) |
| GRID | V | W | Напруга мережі (25207) та потужність порту мережі (25214, «−» = імпорт) |
| SYSTEM BALANCE | W | режим | % | Розрахунковий баланс, короткий режим (25201) та завантаження (25216) |
| LOAD | V | W | Напруга та потужність навантаження (25215) |
| INV LOSS | W | Оцінка втрат інвертора (не плутати з реактивною складовою VA−W) |
| BAT | CHARGE / DISCHARGE / IDLE | V | W | Режим та потужність АКБ; при розрахунковій моделі — суфікс «~» |
| PV ENERGY | kWh | Накопичена сонячна енергія |
| Offgrid work enable | ON / OFF | Дозвіл off-grid (20101) |
| Current mode | SBU / SUB / UTI / SOL | Режим енергосистеми (20109) |
| Charger priority | текст | Пріоритет зарядки (20143) |
Чотири колонки — як у SolarPowerMonitor
Під плитками виводяться блоки, аналогічні рідній утиліті Must:
DEVICE INFO
Статична інформація про пристрій (читається один раз при старті):
- code — версія прошивки ESP32-монітора (наприклад,
V 3.4.3)
- тип та потужність машини (20000, 20001)
- Hardware / Software version (Inverter) — 20004, 20005
- Hardware / Software version (Charge) — 20007, 20008 (якщо заповнені)
- Protocol Edition — 20006
Версії декодуються у форматі SolarPowerMonitor (наприклад, 10101 → 1.01.01).
CHARGER MESSAGE (LIVE)
Дані MPPT та зарядного тракту: Work state, Mppt state, Charging state, PV voltage, Battery voltage, Current, Power, температури, реле Battery/PV, BattVol Grade, ACCUM power.
INVERTER MESSAGE A
AC-сторона в реальному часі: напруги (Grid, Inverter, BUS, Battery), потужності PInverter, PGrid (синім), PLoad, Load percent, SGrid (VA) та ін.
INVERTER MESSAGE B
Статус та накопичувачі: температури, стан реле (Inverter / Grid / Load), ACCUM charge / discharge / buy / sell / load / self_use / PV_sell / grid_charge, Batt power (зеленим), Batt current, Inverter Hz, Grid Hz.
Кольорова індикація
BAT (плитка)
- зелений — CHARGE
- червоний — DISCHARGE
- синій — IDLE
SYSTEM BALANCE
- зелений — позитивний баланс
- червоний — негативний баланс
- синій — баланс близько нуля
Колонки INVERTER MESSAGE
- PGrid — синій (потужність порту мережі, рег. 25214)
- Batt power — зелений (рег. 25273, якщо інвертор його видає)
Які регістри використовуються
Основні Modbus-регістри (перевірено на MUST PV1800):
| Параметр | Регістр | Примітка |
| Work state (режим) | 25201 | 2=OFF GRID, 4=Bypass, 6=Grid CHRG… |
| Battery voltage | 25205 | ×0,1 V |
| Grid voltage | 25207 | ×0,1 V |
| Grid power (active) | 25214 | signed W, «−» = імпорт з мережі |
| Load power | 25215 | W |
| Load percent | 25216 | 0…100 % |
| Grid apparent power | 25218 | VA |
| Inverter power | 25213 | W |
| Battery power | 25273 | W; живий в OFF GRID, часто 0 в Grid CHRG |
| Battery current | 25274 | A (цілі ампери) |
| PV power | 15208 | W |
| AC / DC radiator temp | 25233 / 25235 | °C |
| ACCUM counters | 25245…25260 | пари hi/lo, ÷10 → kWh |
| Модель, версії | 20000…20008 | статика |
Регістри загалом підходять для багатьох інверторів MUST, Voltronic, Axpert, PowMr та EASUN, але деталі залежать від моделі та прошивки інвертора.
Обмеження без зв'язку з BMS
На тестовому інверторі MUST PV1800 BMS акумулятора не підключена до інвертора через RS485/CAN — Li-ion в меню є, але обміну з BMS немає. Це типова ситуація для бюджетних зв'язок «інвертор + окрема BMS».
Через це частину параметрів доводиться рахувати або брати з обмеженнями, навіть якщо інтерфейс виглядає так само, як SolarPowerMonitor:
Потужність мережі (GRID / PGrid)
Регістр 25214 показує активну потужність на порту мережі інвертора, а не завжди повний відбір з розетки. У режимі Grid charging (ws=6) при зарядці АКБ від мережі часто спостерігається:
- 25214 ≈ −PLoad (тільки навантаження, наприклад −90 W)
- 25273 / 25274 = 0, хоча BMS може показувати заряд ~200 W
- повний імпорт (навантаження + заряд − PV) в Modbus не видно
Рідна утиліта Must поводиться так само: PGrid показує 25214, а не «лічильник квартири».
Акумулятор (плитка BAT)
На плитці BAT потужність вважається як енергетичний залишок:
BAT ≈ PV + імпорт_мережі − LOAD
При коректному розряді в режимі OFF GRID цифри близькі до реальності. У Grid CHRG залишок часто дає IDLE | 0 W ~, тому що 25214 не включає заряд АКБ — на плитці з'являється позначка «~» (розрахункова модель).
У колонці Batt power виводиться сирий регістр 25273 (як в утиліті). В OFF GRID він корелює з розрядом; у CHRG при зарядці від мережі — зазвичай 0.
Що працює добре без BMS
- напруги, температури, реле, частоти
- PLoad, PV, режим роботи (25201)
- накопичувачі ACCUM (buy, load, discharge…) в kWh
- розряд АКБ в OFF GRID за 25273 / 25274
- імпорт на навантаження в CHRG (25214 ≈ −LOAD)
Що без BMS або окремого датчика не відновити
- потужність заряду АКБ від мережі в режимі Grid CHRG
- повний миттєвий відбір з міської мережі (LOAD + заряд − PV)
- точний струм/потужність АКБ при зарядці, якщо 25273 мовчить
Для точного контролю заряду потрібне окреме джерело: BMS через Bluetooth/UART, шунт або інвертор з повноцінним обміном з BMS.
Скан Modbus через браузер
У прошивці є вбудована діагностика регістрів (комп'ютер або телефон — в тій же Wi-Fi мережі, що ESP32).
/scan — довільний блок
http://inverter.local/scan?addr=25201&count=50
Параметри: addr — стартовий регістр, count — кількість (макс. 50).
/scanrange — діапазон
http://inverter.local/scanrange?from=25201&to=25279&nz=1
nz=1 — тільки ненульові значення.
/scanbat — пошук регістрів АКБ при Grid CHRG
http://inverter.local/scanbat?nz=1
Зручно запускати, коли BMS показує заряд, а 25273 = 0.
Приклад відповіді /scan:
[25201] = 6
[25202] = 230
[25214] = -104
[25215] = 104
Налаштування та перевірка
Після прошивки ESP32:
- Вкажіть SSID та пароль Wi-Fi у скетчі.
- Підключіть RS485 (A/B/GND), перевірте Slave ID (зазвичай 4).
- Відкрийте
http://inverter.local.
- У колонці DEVICE INFO переконайтеся, що відображається потрібна версія
code V ….
- Порівняйте PGrid, PLoad та Batt power з SolarPowerMonitor.
Якщо даних немає:
- перевірте лінії A/B (часта помилка — переплутані)
- швидкість 19200 8N1
- Slave ID 0x04
- спільний GND між ESP32 та лінією RS485
Можливі проблеми
Немає зв'язку з інвертором
Перевірте живлення MAX485, A/B, GND, швидкість порту та Slave ID.
Від'ємні потужності на PGrid
Це нормально: регістр 25214 signed, мінус означає імпорт з мережі.
Цифри не збігаються з BMS
Очікувано без зв'язку інвертор↔BMS. Див. розділ «Обмеження без зв'язку з BMS».
Wi-Fi нестабільний
Використовуйте якісний USB-блок живлення та короткий кабель.
Відео роботи пристрою
Тут буде розміщено відео роботи моніторингу та WEB-інтерфейсу.
Завантажити та використовувати
Для самостійної збірки знадобляться:
- повний робочий скетч (файл
code-new.txt, прошивка 3.4.x)
- список бібліотек
- найпростіший корпус для ESP32-C3 Super Mini (STL — пізніше)
- схема підключення та список компонентів із цієї статті
Бібліотеки:
- ModbusMaster
- ESPAsyncWebServer
Підсумок
ESP32 дає повноцінний локальний моніторинг інвертора MUST без хмар і підписок. У прошивці 3.4.x інтерфейс повторює структуру SolarPowerMonitor: ті самі групи параметрів у чотирьох колонках плюс швидкі плитки зверху.
Проєкт підходить як основа для:
- домашньої сонячної системи
- автономного живлення
- діагностики Modbus і пошуку регістрів
- майбутньої інтеграції з Home Assistant
Тестування виконувалося на MUST PV1800 без підключення BMS до інвертора. На цій моделі лише за одним Modbus можна достовірно бачити розряд АКБ в OFF GRID та параметри мережі/навантаження в Grid CHRG, але потужність заряду від мережі у відкритих регістрах часто відсутня — звідси розрахункова плитка BAT з позначкою «~» та розбіжності з показаннями BMS при зарядці.
Плани розвитку
У наступних версіях планується покращений скетч, в якому можливо з'являться:
- паралельне читання BMS через Bluetooth — для точних струму та потужності заряду/розряду, доповнення GRID «повним» імпортом і зняття обмежень режиму Grid CHRG;
- зберігання статистики — накопичення історії параметрів (графіки, добові/місячні підсумки);
- винос HTML у LittleFS — веб-сторінки на файловій системі ESP32 замість одного великого рядка в прошивці: простіше правити інтерфейс без перезбирання всього скетчу.
STL-модель корпусу буде опублікована окремо. Поточний скетч продовжить доопрацьовуватися.
Якщо стаття була корисною — збережіть її або поділіться посиланням. Питання можна залишати в коментарях.
Скетч Arduino: Монитор MUST через ESP32 RS485 Modbus
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <ESPmDNS.h>
#include <ModbusMaster.h>
const char* ssid = "YOUR-SSID";
const char* password = "YOUR-PASS";
#define RX2_PIN 4
#define TX2_PIN 5
#define RS485_CTRL 6
#define SLAVE_ID 0x04
#define LED_PIN 8
// Bump when changing BAT/SELF/API logic (shown in web footer).
#define SKETCH_VERSION "1.2.6"
AsyncWebServer server(80);
ModbusMaster node;
unsigned long lastUpdate = 0;
uint16_t invRegs[80];
uint16_t pvRegs[25];
uint16_t ctrlRegs[50];
float vBatt, vBattFiltered, vOut, vGrid, vBus, vPV;
int pLoad, gridPower, solarPowerRaw, effectiveSolarW;
int loadPercent; // reg 25216, load percent (0-100)
int tempAC, tempDC;
float pvEnergy;
int workState;
float pNet;
float selfConsumption;
float selfConsumptionFiltered;
float battPowerSigned;
float battPowerMeasured; // battery W from registers (not forced to energy balance)
float battPowerFiltered;
float battAmpDisplay; // battery current for UI (A)
int battWattDisplay; // battery power for UI (W, always >= 0)
int gridPowerShow; // GRID tile: full utility import (load + battery charge)
int systemBalanceShow; // BALANCE: grid net (on-grid) or -(load-solar) off-grid
int offgridEnable, energyMode, chargerPriority;
String stateStr;
String workModeShort; // reg 25201, short label for SYSTEM BALANCE tile
float vLoad;
// Battery registers
int16_t battPowerReg; // reg 25273 (signed battery power, W)
int16_t battCurrent; // reg 25274 (signed battery current, A)
float battCurrentAlt; // reg 25248 (0.1 A per LSB)
float batPowerDisplay;
String batState;
String batModeUi = "IDLE", batClassUi = "blue";
bool batRegsValid = false;
String batCurrentSource = ""; // which DC reg drove I (debug /api)
// One-cycle snapshot: filled only after all Modbus blocks succeed.
struct PowerSnapshot {
bool valid;
int workState;
float vGrid;
int gridPowerRaw; // reg 25214 (negative = import)
int loadW; // reg 25215
int solarW; // effective PV W (vPV > vBatt + 1.5), not raw 15208
};
PowerSnapshot snap = {false, 0, 0.0f, 0, 0, 0};
void fillPowerSnapshot() {
snap.valid = true;
snap.workState = workState;
snap.vGrid = vGrid;
snap.gridPowerRaw = gridPower;
snap.loadW = pLoad;
snap.solarW = effectiveSolarW;
}
String machineType, machinePower, hwVersion, swVersion, protVersion;
uint16_t raw20001;
void preTrans() { digitalWrite(RS485_CTRL, HIGH); delayMicroseconds(20); }
void postTrans() { delayMicroseconds(20); digitalWrite(RS485_CTRL, LOW); }
bool readBlock(uint16_t addr, uint8_t qty, uint16_t* buf) {
node.clearResponseBuffer();
uint8_t result = node.readHoldingRegisters(addr, qty);
if(result == node.ku8MBSuccess) {
for(uint8_t i=0; i<qty; i++) buf[i] = node.getResponseBuffer(i);
return true;
}
return false;
}
uint16_t readSingleRegisterWithRetry(uint16_t addr, uint8_t retries = 3) {
for(uint8_t i=0; i<retries; i++) {
node.clearResponseBuffer();
uint8_t result = node.readHoldingRegisters(addr, 1);
if(result == node.ku8MBSuccess) return node.getResponseBuffer(0);
delay(500);
}
return 0xFFFF;
}
String formatVersion(uint16_t raw) {
uint8_t major = (raw >> 8) & 0xFF;
uint8_t minor = (raw >> 4) & 0x0F;
uint8_t patch = raw & 0x0F;
return String(major) + "." + String(minor) + "." + String(patch);
}
static bool isBatRegDead(uint16_t raw, int16_t s) {
return (raw == 0xFFFF || s == -1);
}
// 25248 stuck at 124 (12.4 A) when BMS = 0 A.
static bool isR48Phantom(int16_t r48raw) {
int a = abs(r48raw);
return (a == 124 || a == 125);
}
// 25274: whole A (16) or 0.1 A (160 = 16.0 A).
static float ampsFrom74(int16_t r74) {
int a = abs(r74);
if (a > 50) return (float)r74 / 10.0f;
return (float)r74;
}
// 25223: often Ubat x0.1 (matches 25205); off-grid can read as I x0.1 instead.
static float currentFrom25223(uint16_t raw23, float vBat) {
if (raw23 == 0xFFFF) return 0.0f;
float scaled = (float)raw23 / 10.0f;
if (scaled < 0.5f) return 0.0f;
if (fabsf(scaled - vBat) < 1.5f) return 0.0f;
return scaled;
}
// +1 = CHARGE, -1 = DISCHARGE, 0 = use magnitude sign from I regs only.
static int batFlowSign() {
if (workState == 6) return +1;
if (workState == 2 && vGrid < 80.0f) return -1;
// 25214 often equals load (W) while the grid also charges the battery.
if (vGrid > 80.0f && pLoad > 0 && gridPower < -15) {
float gridIn = (float)(-gridPower);
if (fabsf(gridIn - (float)pLoad) < 35.0f) {
int16_t r48 = (int16_t)invRegs[47];
if (!isBatRegDead(invRegs[47], r48) && r48 != 0) {
return +1;
}
int16_t r73 = (int16_t)invRegs[72];
if (!isBatRegDead(invRegs[72], r73) && r73 < 0) {
return +1;
}
}
}
int16_t r73 = (int16_t)invRegs[72];
if (!isBatRegDead(invRegs[72], r73) && r73 != 0) {
return (r73 < 0) ? +1 : -1;
}
int16_t r74 = (int16_t)invRegs[73];
if (!isBatRegDead(invRegs[73], r74) && r74 != 0) {
return (r74 < 0) ? +1 : -1;
}
int16_t r48 = (int16_t)invRegs[47];
if (!isBatRegDead(invRegs[47], r48) && !isR48Phantom(r48) && r48 != 0) {
if (vGrid > 80.0f) {
if (r48 < 0) return +1;
if (gridPower < -20 && abs(r48) >= 118 && abs(r48) <= 132) return +1;
return -1;
}
return (r48 > 0) ? -1 : +1;
}
return 0;
}
static void applyBatFlowSign(float* batPower, float* ampTruth) {
int flow = batFlowSign();
if (flow == 0 || batPower == nullptr) return;
float mag = fabsf(*batPower);
*batPower = (flow > 0) ? mag : -mag;
if (ampTruth != nullptr && fabsf(*ampTruth) >= 0.2f) {
*ampTruth = (flow > 0) ? fabsf(*ampTruth) : -fabsf(*ampTruth);
}
}
static float pickBestBatCurrentA(float vBat, int16_t r48raw, uint16_t raw23, int16_t r50) {
float i48 = 0.0f, i23 = 0.0f, i50 = 0.0f;
bool r48ok = !isBatRegDead(invRegs[47], r48raw) && !isR48Phantom(r48raw);
if (r48ok) i48 = (float)r48raw / 10.0f;
if (vBat >= 11.0f && vBat <= 16.0f) i23 = currentFrom25223(raw23, vBat);
if (!isBatRegDead(invRegs[49], r50) && r50 > 0 && r50 <= 40) i50 = (float)r50;
bool gridOnNow = (vGrid > 80.0f) || (gridPower < -20);
if (gridOnNow) {
// On-grid charge: 25223 often closer to BMS than 25248 — take max.
float best = i48;
batCurrentSource = r48ok ? "25248" : "";
if (fabsf(i23) > fabsf(best)) {
best = i23;
batCurrentSource = "25223";
}
if (fabsf(i50) > fabsf(best)) {
best = i50;
batCurrentSource = "25250";
}
return best;
}
// Off-grid discharge: 25223 often overshoots BMS — prefer 25248, then 25250, then 25223.
if (r48ok) {
batCurrentSource = "25248";
return i48;
}
if (i50 > 0.0f) {
batCurrentSource = "25250";
return i50;
}
if (fabsf(i23) >= 0.2f) {
batCurrentSource = "25223";
return i23;
}
batCurrentSource = "";
return 0.0f;
}
// Battery truth: 25205 U; 25273 P, 25274 I, 25223/25248/25250 I.
static void updateBatteryTruth() {
batCurrentSource = "";
const float kBatThrW = 3.0f;
const float vBat = invRegs[4] / 10.0f; // reg 25205
const bool vOk = (vBat >= 11.0f && vBat <= 16.0f);
int16_t r73 = (int16_t)invRegs[72];
int16_t r74 = (int16_t)invRegs[73];
int16_t r48raw = (int16_t)invRegs[47];
bool r73ok = !isBatRegDead(invRegs[72], r73);
bool r74ok = !isBatRegDead(invRegs[73], r74);
bool r48ok = !isBatRegDead(invRegs[47], r48raw) && !isR48Phantom(r48raw);
float batPower = 0.0f;
bool fromRegs = false;
float ampTruth = 0.0f;
if (r73ok && abs(r73) >= 15) {
batCurrentSource = "25273";
fromRegs = true;
int w = abs(r73);
batPower = (float)w;
if (r74ok && r74 != 0) {
ampTruth = ampsFrom74(r74);
} else if (r48ok) {
ampTruth = (float)r48raw / 10.0f;
} else if (vOk && w > 0) {
ampTruth = (float)w / vBat;
}
applyBatFlowSign(&batPower, &Truth);
} else if (r74ok) {
batCurrentSource = "25274";
fromRegs = true;
ampTruth = ampsFrom74(r74);
if (fabsf(ampTruth) < 0.2f) {
batPower = 0.0f;
} else if (vOk) {
int watt = (int)roundf(vBat * fabsf(ampTruth));
batPower = (float)watt;
}
applyBatFlowSign(&batPower, &Truth);
} else {
float iMag = pickBestBatCurrentA(vBat, r48raw, invRegs[22], (int16_t)invRegs[49]);
if (fabsf(iMag) >= 0.2f) {
fromRegs = true;
ampTruth = iMag;
if (vOk) {
batPower = (float)roundf(vBat * iMag);
}
applyBatFlowSign(&batPower, &Truth);
}
}
batRegsValid = fromRegs;
if (!fromRegs) {
battPowerFiltered = 0.0f;
batPower = 0.0f;
} else if (fabsf(batPower) < kBatThrW) {
battPowerFiltered *= 0.15f;
if (fabsf(battPowerFiltered) < kBatThrW) battPowerFiltered = batPower;
batPower = battPowerFiltered;
} else if (battPowerFiltered * batPower < 0.0f) {
battPowerFiltered = batPower;
} else if (fabsf(battPowerFiltered) < 0.01f) {
battPowerFiltered = batPower;
} else {
battPowerFiltered = battPowerFiltered * 0.5f + batPower * 0.5f;
}
if (fromRegs) batPower = battPowerFiltered;
battPowerMeasured = batPower;
battWattDisplay = (int)roundf(fabsf(batPower));
if (fabsf(ampTruth) >= 0.2f) {
battAmpDisplay = fabsf(ampTruth);
} else if (battWattDisplay > 0 && vBat >= 11.0f && vBat <= 16.0f) {
battAmpDisplay = (float)battWattDisplay / vBat;
} else {
battAmpDisplay = 0.0f;
}
if (batPower > kBatThrW) {
batModeUi = "CHARGE";
batClassUi = "green";
} else if (batPower < -kBatThrW) {
batModeUi = "DISCHARGE";
batClassUi = "red";
} else {
batModeUi = "IDLE";
batClassUi = "blue";
battPowerMeasured = 0.0f;
battWattDisplay = 0;
battAmpDisplay = 0.0f;
}
battPowerSigned = battPowerMeasured;
}
// Energy tiles: PV/GRID/LOAD + measured battery (not inferred battery).
void updateBatteryMetrics() {
if (!snap.valid) return;
updateBatteryTruth();
const int loadW = snap.loadW;
const int solarW = snap.solarW;
const int gridRaw = snap.gridPowerRaw;
const float vGridSnap = snap.vGrid;
const float kBatThrW = 3.0f;
const float kInvSelfW = 15.0f;
float gridImportW = 0.0f;
if (gridRaw < 0) gridImportW = (float)(-gridRaw);
bool gridOn = (vGridSnap > 80.0f) || gridImportW > 20.0f;
float batPower = battPowerMeasured;
float chargeW = (batPower > kBatThrW) ? batPower : 0.0f;
float dischargeW = (batPower < -kBatThrW) ? -batPower : 0.0f;
// Off-grid: 25223 often overshoots — cap BAT to load + inverter overhead (≈ BMS).
if (!gridOn && battPowerMeasured < -kBatThrW && loadW > 0) {
float selfForCap = selfConsumptionFiltered;
if (selfForCap < 8.0f) {
selfForCap = 10.0f + (float)loadW * 0.38f;
}
if (selfForCap > 55.0f) {
selfForCap = 55.0f;
}
float cap = (float)loadW + selfForCap - (float)solarW;
if (cap < 0.0f) cap = 0.0f;
float mag = -battPowerMeasured;
if (cap >= kBatThrW && mag > cap) {
battPowerMeasured = -cap;
battPowerSigned = battPowerMeasured;
battWattDisplay = (int)roundf(cap);
if (vBatt > 10.0f) {
battAmpDisplay = cap / vBatt;
}
dischargeW = cap;
chargeW = 0.0f;
if (batCurrentSource.length() > 0) {
batCurrentSource += "+cap";
}
}
}
// GRID tile: full utility import (25214 is often load-only, not load + battery charge).
float gridTotalIn = gridImportW;
if (gridOn) {
float needFromGrid = (float)loadW + chargeW - dischargeW - (float)solarW;
if (needFromGrid < 0.0f) needFromGrid = 0.0f;
if (chargeW > 10.0f) {
gridTotalIn = fmaxf(gridImportW, (float)loadW + chargeW);
if (needFromGrid > gridTotalIn) gridTotalIn = needFromGrid;
} else if (gridImportW > 10.0f && gridImportW <= (float)loadW + 35.0f
&& chargeW < 5.0f && dischargeW > 10.0f) {
gridTotalIn = gridImportW + dischargeW;
}
}
gridPowerShow = gridRaw;
if (gridOn && gridTotalIn > 10.0f) {
gridPowerShow = -(int)roundf(gridTotalIn);
}
// SELF: energy estimate (inverter overhead). Not the same as BAT truth.
float gridInForSelf = gridOn ? gridTotalIn : 0.0f;
float selfConsumptionRaw = gridInForSelf + (float)solarW - (float)loadW
- chargeW + dischargeW;
if (selfConsumptionRaw < 0.0f) selfConsumptionRaw = 0.0f;
if (loadW > 15) {
if (!gridOn && dischargeW > 0.0f) {
float selfFromBat = dischargeW - (float)loadW + (float)solarW;
if (selfFromBat > 5.0f && selfFromBat < 60.0f) {
selfConsumptionRaw = selfFromBat;
}
}
if (!batRegsValid) {
float est = 10.0f + (float)loadW * 0.025f;
if (selfConsumptionRaw < est) selfConsumptionRaw = est;
} else if (selfConsumptionRaw < 8.0f) {
float est = kInvSelfW + (float)loadW * 0.01f;
if (selfConsumptionRaw < est) selfConsumptionRaw = est;
}
}
if (selfConsumptionRaw > 50.0f) selfConsumptionRaw = 50.0f;
if (selfConsumptionFiltered <= 0.01f) {
selfConsumptionFiltered = selfConsumptionRaw;
} else {
selfConsumptionFiltered = selfConsumptionFiltered * 0.7f + selfConsumptionRaw * 0.3f;
}
selfConsumption = selfConsumptionFiltered;
float netFromGrid = 0.0f;
if (gridOn) {
// On-grid: net need from utility after PV and battery.
netFromGrid = (float)loadW + chargeW - dischargeW - (float)solarW;
} else {
// Off-grid (variant B): deficit not covered by solar → negative on tile.
float pvGap = (float)loadW - (float)solarW;
if (pvGap < 0.0f) pvGap = 0.0f;
netFromGrid = pvGap;
}
systemBalanceShow = (int)roundf(-netFromGrid);
pNet = systemBalanceShow;
}
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Inverter Grid</title>
<style>
body{ background:#0f0f0f; color:#e6e6e6; font-family:monospace; padding:14px; }
h3{ margin:0 0 10px 0; color:#aaa; }
.grid{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:15px; }
.box{ background:#1c1c1c; border-radius:12px; padding:12px; text-align:center; }
.label{ color:#888; font-size:12px; }
.value{ font-size:20px; margin-top:6px; }
.green{ color:#00ff88; }
.red{ color:#ff4d4d; }
.blue{ color:#4da3ff; }
.yellow{ color:#ffd166; }
.balance.pos{ color:#00ff88; }
.balance.neg{ color:#ff4d4d; }
.balance.zero{ color:#4da3ff; }
.pre{ background:#151515; padding:10px; border-radius:10px; overflow:auto; font-size:12px; }
input, button{ background:#333; color:#eee; border:1px solid #555; border-radius:6px; padding:6px; margin:4px; }
.scan-form{ margin-top:15px; }
.info{ background:#1c1c1c; border-radius:12px; padding:12px; margin-top:15px; text-align:center; font-size:14px; color:#aaa; }
.rawinfo{ font-size:10px; color:#666; margin-top:5px; }
.hidden-block { display: none; }
</style>
</head>
<body>
<h3>inverter.local</h3>
<div class="grid">
<div class="box"><div class="label">AC RAD TEMP</div><div class="value yellow" id="tempAC">0C</div></div>
<div class="box"><div class="label">SOLAR</div><div class="value green" id="solar">0V | 0W</div></div>
<div class="box"><div class="label">DC RAD TEMP</div><div class="value yellow" id="tempDC">0C</div></div>
<div class="box"><div class="label">GRID</div><div class="value blue" id="grid">0V | 0W</div></div>
<div class="box"><div class="label">SYSTEM BALANCE</div><div class="value balance zero" id="balance">0W | --- | 0%</div></div>
<div class="box"><div class="label">LOAD</div><div class="value red" id="load">0V | 0W</div></div>
<div class="box"><div class="label">SELF CONSUMPTION</div><div class="value yellow" id="selfConsumption">0 W</div></div>
<div class="box"><div class="label">BAT</div><div class="value yellow" id="bat">0V | 0W</div></div>
<div class="box"><div class="label">PV ENERGY</div><div class="value green" id="pvEnergy">0 kWh</div></div>
<div class="box"><div class="label">Offgrid work enable</div><div class="value" id="offgridEnable">---</div></div>
<div class="box"><div class="label">Current mode</div><div class="value" id="energyMode">---</div></div>
<div class="box"><div class="label">Charger priority</div><div class="value" id="chargerPriority">---</div></div>
</div>
<div class="info" id="deviceInfo">Loading device info...</div>
<div class="hidden-block">
<div class="box">STATE: <span id="state">---</span></div>
<div class="scan-form">
<label>Start addr: <input type="number" id="scanAddr" value="25201"></label>
<label>Count: <input type="number" id="scanCount" value="20"></label>
<button onclick="scanRegs()">Scan</button>
</div>
<pre id="raw">loading...</pre>
</div>
<script>
function setBalance(w, mode, loadPct){
let el = document.getElementById("balance");
el.innerText = w + "W | " + mode + " | " + loadPct + "%";
el.className = "value balance";
if(w > 5) el.classList.add("pos");
else if(w < -5) el.classList.add("neg");
else el.classList.add("zero");
}
async function scanRegs(){
let addr = document.getElementById("scanAddr").value;
let count = document.getElementById("scanCount").value;
let res = await fetch("/scan?addr="+addr+"&count="+count);
let text = await res.text();
document.getElementById("raw").innerHTML = text;
}
async function update(){
let r = await fetch("/api");
let d = await r.json();
document.getElementById("tempAC").innerText = d.tempAC + "C";
document.getElementById("solar").innerText = d.pvvolt + "V | " + d.solar + "W";
document.getElementById("tempDC").innerText = d.tempDC + "C";
let gridW = (d.gridPowerShow !== undefined) ? d.gridPowerShow : d.gridPower;
document.getElementById("grid").innerText = d.grid + "V | " + gridW + "W";
document.getElementById("load").innerText = d.loadVolt + "V | " + d.loadPower + "W";
let batEl = document.getElementById("bat");
batEl.className = "value " + d.batClass;
let batTxt = d.batMode + " | " + d.batteryVolt + " V | " + d.batPowerDisplay + " W";
batEl.innerText = batTxt;
document.getElementById("pvEnergy").innerText = d.pvEnergy + " kWh";
document.getElementById("selfConsumption").innerText = d.selfConsumption + " W";
setBalance(d.net, d.workMode || d.state, d.loadPercent);
document.getElementById("offgridEnable").innerText = d.offgridEnable;
document.getElementById("energyMode").innerText = d.energyMode;
document.getElementById("chargerPriority").innerText = d.chargerPriority;
let infoHtml = `📟 ${d.machineType} ${d.machinePower} | HW: ${d.hwVersion} | SW: ${d.swVersion} | Protocol: ${d.protVersion} | FW: ${d.sketchVersion}`;
if(d.rawInfo) infoHtml += `<div class="rawinfo">raw20001=0x${d.rawInfo}</div>`;
document.getElementById("deviceInfo").innerHTML = infoHtml;
document.getElementById("raw").innerHTML = `...`;
}
setInterval(update, 3000);
update();
</script>
</body>
</html>
)rawliteral";
void handleScan(AsyncWebServerRequest *request) {
if(!request->hasParam("addr") || !request->hasParam("count")) {
request->send(400, "text/plain", "Missing addr or count");
return;
}
uint16_t addr = request->getParam("addr")->value().toInt();
uint8_t count = request->getParam("count")->value().toInt();
if(count > 50) count = 50;
uint16_t buf[50];
String out = "Scanning " + String(addr) + ".." + String(addr+count-1) + ":\n\n";
if(!readBlock(addr, count, buf)) {
out += "MODBUS ERROR";
} else {
for(int i=0; i<count; i++) {
out += "[" + String(addr+i) + "] = " + String((int16_t)buf[i]) + "\n";
}
}
request->send(200, "text/plain", out);
}
void setup() {
Serial.begin(115200);
pinMode(RS485_CTRL, OUTPUT);
digitalWrite(RS485_CTRL, LOW);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, HIGH);
WiFi.begin(ssid, password);
while(WiFi.status() != WL_CONNECTED) delay(300);
MDNS.begin("inverter");
Serial1.begin(19200, SERIAL_8N1, RX2_PIN, TX2_PIN);
node.begin(SLAVE_ID, Serial1);
node.preTransmission(preTrans);
node.postTransmission(postTrans);
delay(2000);
uint16_t raw20000 = readSingleRegisterWithRetry(20000);
raw20001 = readSingleRegisterWithRetry(20001);
uint16_t raw20004 = readSingleRegisterWithRetry(20004);
uint16_t raw20005 = readSingleRegisterWithRetry(20005);
uint16_t raw20006 = readSingleRegisterWithRetry(20006);
char ch1 = (raw20000 >> 8) & 0xFF;
char ch2 = raw20000 & 0xFF;
machineType = String(ch1) + String(ch2);
if (raw20001 == 1800 || raw20001 == 3000) machinePower = String(raw20001);
else machinePower = "—";
hwVersion = formatVersion(raw20004);
swVersion = formatVersion(raw20005);
protVersion = formatVersion(raw20006);
server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
req->send_P(200, "text/html", index_html);
});
server.on("/scan", HTTP_GET, handleScan);
server.on("/api", HTTP_GET, [=](AsyncWebServerRequest *req){
// Metrics come from last successful loop() snapshot (no double-filter here).
// Load voltage depends on work state
float loadVoltage = 0;
if (workState == 2 || workState == 3) loadVoltage = vOut;
else if (workState == 4 || workState == 6) loadVoltage = vGrid;
else loadVoltage = 0;
int batPowerInt = battWattDisplay;
if (batModeUi == "IDLE") {
batPowerInt = 0;
} else if (batPowerInt < 1 && battPowerMeasured != 0.0f) {
batPowerInt = (int)roundf(fabsf(battPowerMeasured));
}
String json = "{";
json += "\"state\":\"" + stateStr + "\",";
json += "\"workMode\":\"" + workModeShort + "\",";
json += "\"workState\":" + String(workState) + ",";
json += "\"batteryVolt\":" + String(vBatt,2) + ",";
json += "\"batPowerDisplay\":" + String(batPowerInt) + ",";
json += "\"batMode\":\"" + batModeUi + "\",";
json += "\"batClass\":\"" + batClassUi + "\",";
json += "\"solar\":" + String(effectiveSolarW) + ",";
json += "\"solarRaw\":" + String(solarPowerRaw) + ",";
json += "\"pvvolt\":" + String(vPV,1) + ",";
json += "\"loadPower\":" + String(pLoad) + ",";
json += "\"loadVolt\":" + String(loadVoltage,1) + ",";
json += "\"grid\":" + String(vGrid,1) + ",";
json += "\"gridPower\":" + String(gridPower) + ",";
json += "\"gridPowerShow\":" + String(gridPowerShow) + ",";
json += "\"systemBalance\":" + String(systemBalanceShow) + ",";
json += "\"loadPercent\":" + String(loadPercent) + ",";
json += "\"out\":" + String(vOut,1) + ",";
json += "\"bus\":" + String(vBus,1) + ",";
json += "\"tempAC\":" + String(tempAC) + ",";
json += "\"tempDC\":" + String(tempDC) + ",";
json += "\"pvEnergy\":" + String(pvEnergy,1) + ",";
json += "\"selfConsumption\":" + String(selfConsumption,0) + ",";
json += "\"batRegsValid\":" + String(batRegsValid ? "true" : "false") + ",";
json += "\"batCurrentSource\":\"" + batCurrentSource + "\",";
json += "\"batPowerSigned\":" + String((int)roundf(battPowerSigned)) + ",";
json += "\"batPowerMeasured\":" + String((int)roundf(battPowerMeasured)) + ",";
json += "\"battAmpDisplay\":" + String(battAmpDisplay, 1) + ",";
json += "\"battWattDisplay\":" + String(battWattDisplay) + ",";
json += "\"net\":" + String(pNet,0) + ",";
json += "\"machineType\":\"" + machineType + "\",";
json += "\"machinePower\":\"" + machinePower + "\",";
json += "\"hwVersion\":\"" + hwVersion + "\",";
json += "\"swVersion\":\"" + swVersion + "\",";
json += "\"protVersion\":\"" + protVersion + "\",";
json += "\"sketchVersion\":\"" + String(SKETCH_VERSION) + "\",";
json += "\"rawInfo\":\"" + String(raw20001, HEX) + "\",";
json += "\"offgridEnable\":\"" + String(offgridEnable == 1 ? "ON" : "OFF") + "\",";
if(energyMode == 1) json += "\"energyMode\":\"SBU\",";
else if(energyMode == 2) json += "\"energyMode\":\"SUB\",";
else if(energyMode == 3) json += "\"energyMode\":\"UTI\",";
else if(energyMode == 4) json += "\"energyMode\":\"SOL\",";
else json += "\"energyMode\":\"MODE"+String(energyMode)+"\",";
if(chargerPriority == 0) json += "\"chargerPriority\":\"Solar first\",";
else if(chargerPriority == 2) json += "\"chargerPriority\":\"Solar+Utility\",";
else if(chargerPriority == 3) json += "\"chargerPriority\":\"Only Solar\",";
else json += "\"chargerPriority\":\"PRIO"+String(chargerPriority)+"\",";
json += "\"r0\":" + String(invRegs[0]) + ",";
json += "\"r1\":" + String(invRegs[4]) + ",";
json += "\"r2\":" + String(invRegs[5]) + ",";
json += "\"r3\":" + String(invRegs[6]) + ",";
json += "\"r4\":" + String(invRegs[14]) + ",";
json += "\"r5\":" + String(invRegs[13]) + ",";
json += "\"r6\":" + String(invRegs[32]) + ",";
json += "\"r7\":" + String(invRegs[34]) + ",";
json += "\"r8\":" + String((int16_t)invRegs[72]) + ",";
json += "\"r9\":" + String((int16_t)invRegs[73]) + ",";
json += "\"r10\":" + String(invRegs[47]) + ",";
json += "\"r11\":" + String(invRegs[17]) + ",";
json += "\"r25218\":" + String((int16_t)invRegs[17]) + ",";
json += "\"r12\":" + String(invRegs[22]) + ",";
json += "\"r13\":" + String(invRegs[49]);
json += "}";
req->send(200, "application/json", json);
});
server.begin();
}
void loop() {
if(millis() - lastUpdate < 3000) return;
lastUpdate = millis();
bool ok = true;
if(!readBlock(25201, 80, invRegs)) ok = false;
delay(50);
if(!readBlock(15201, 20, pvRegs)) ok = false;
delay(50);
if(!readBlock(20101, 50, ctrlRegs)) ok = false;
if(ok) {
digitalWrite(LED_PIN, LOW);
workState = invRegs[0];
// BAT tile voltage: reg 25205 only (BMS / DC bus).
float vBatt05 = invRegs[4] / 10.0f;
vBatt = vBatt05;
if (fabsf(vBattFiltered) < 0.01f) vBattFiltered = vBatt;
else vBattFiltered = vBattFiltered * 0.75f + vBatt * 0.25f;
vBatt = vBattFiltered;
vOut = invRegs[5] / 10.0f;
vGrid = invRegs[6] / 10.0f;
vBus = invRegs[7] / 10.0f;
gridPower = (int16_t)invRegs[13];
pLoad = invRegs[14];
loadPercent = invRegs[15]; // reg 25216, load percent
if (loadPercent < 0) loadPercent = 0;
if (loadPercent > 100) loadPercent = 100;
tempAC = (int16_t)invRegs[32];
tempDC = (int16_t)invRegs[34];
battPowerReg = (int16_t)invRegs[72]; // 25273
battCurrent = (int16_t)invRegs[73]; // 25274
battCurrentAlt = invRegs[47] / 10.0f; // 25248 (0.1 A)
vPV = pvRegs[4] / 10.0f;
solarPowerRaw = pvRegs[7]; // reg 15208, MPPT charger power (W)
effectiveSolarW = (vPV > vBatt + 1.5f) ? solarPowerRaw : 0;
uint32_t pvEnergyRaw = ((uint32_t)pvRegs[16] << 16) | pvRegs[17];
pvEnergy = pvEnergyRaw / 10.0f;
offgridEnable = ctrlRegs[0];
energyMode = ctrlRegs[8];
chargerPriority = ctrlRegs[42];
fillPowerSnapshot();
updateBatteryMetrics();
switch(workState) {
case 0:
stateStr = "POWER ON";
workModeShort = "ON";
break;
case 1:
stateStr = "SELFTEST";
workModeShort = "TEST";
break;
case 2:
stateStr = "OFF GRID";
workModeShort = "BATT";
break;
case 3:
stateStr = "GRID TIE";
workModeShort = "TIE";
break;
case 4:
stateStr = "BYPASS";
workModeShort = "BYPASS";
break;
case 5:
stateStr = "STOP";
workModeShort = "STOP";
break;
case 6:
stateStr = "GRID CHRG";
workModeShort = "CHRG";
break;
default:
stateStr = "UNKNOWN";
workModeShort = "C" + String(workState);
break;
}
} else {
digitalWrite(LED_PIN, HIGH);
return;
}
}
Коментарі до статті
Поки що немає коментарів. Будьте першим!
Додати коментар