Інвертори MUST, Voltronic, PowMr, EASUN та їх аналоги підтримують обмін даними по RS485 Modbus, однак штатні Wi-Fi модулі часто працюють нестабільно, вимагають хмару або дають замало інформації.
У цій статті покажу як зробити локальний моніторинг інвертора MUST через ESP32 з веб-інтерфейсом по Wi-Fi, без сторонніх хмар і підписок.
Пристрій дозволяє в реальному часі бачити:
- потужність сонячних панелей
- навантаження інвертора
- заряд і розряд акумулятора
- напругу мережі
- температури інвертора
- статистику PV
- власне споживання інвертора
- режим роботи інвертора
Наприкінці статті — готовий код та модель корпусу (модель в розробці) для 3D-друку.
Що вийде в результаті
Після збірки ви отримаєте локальний Wi-Fi моніторинг для інвертора MUST з доступом через браузер:
- Робота без хмари
- Моніторинг через RS485 Modbus
- Підтримка ESP32
- Читання регістрів MUST inverter
- Контроль навантаження, PV та акумулятора
- Локальний WEB інтерфейс
- Готовий робочий скетч
- STL модель корпусу для 3D-друку (в розробці)
Компоненти
Для збірки знадобляться:
- ESP32 с3 super mini
- RS485 TTL модуль MAX485
- Кабель USB - Type С для живлення контролера
- Кабель з роз'ємом RJ45 для з'єднання з інвертором
- Проводи Dupont
- Інвертор MUST POWER з RS485
Скетч це основа яку можна розширити:
- OLED дисплей
- зовнішній watchdog
- Ethernet модуль
- microSD для логів
- логіювання в Home Assistans
Корпус для пристрою
Пристрій розробляється в компактному корпусі для DIN-рейки, щоб його можна було встановити поруч з інвертором або в електрощиті.
Планується:
- вентиляція корпусу
- зручний доступ до RS485
- USB для прошивки
- індикація Wi-Fi та обміну даними
- монтаж без підтримок при 3D-друку
Корпус друкується на звичайному FDM 3D-принтері.
Завантажити STL модель можна буде після успішних випробувань, буде додано посилання для завантаження.
Схема підключення
ESP32 підключається до інвертора через модуль з інтерфейсом RS485.
Підключення:
A → A
B → B
GND → GND
Для зв'язку використовується протокол Modbus RTU.
Параметри порту:
19200 baud
8N1
Код і логіка роботи
ESP32 створює локальний WEB інтерфейс з оновленням даних в реальному часі.
Пристрій побудовано на ESP32 та бібліотеці ModbusMaster.
Основна логіка роботи:
- читання регістрів інвертора MUST
- читання блоку MPPT
- розрахунок потужності та енергетичного балансу
- відображення параметрів через WEB інтерфейс
- оновлення даних кожні кілька секунд
Використовувані бібліотеки в Arduino IDE:
- WiFi.h
- ESPAsyncWebServer.h
- ModbusMaster.h
- ESPmDNS.h
Які регістри використовуються
У проєкті використовуються реальні Modbus-регістри інвертора MUST.
Основні регістри:
| Параметр | Регістр |
| Battery voltage | 25205 |
| Grid voltage | 25207 |
| Load power | 25215 |
| Battery power | 25273 |
| PV power | 15208 |
| MPPT temperature | 15209 |
Також використовуються:
- температура радіаторів
- режим роботи інвертора
- струм акумулятора
- потужність мережі
- накопичена PV енергія
- відсоток завантаження інвертора
Ці регістри підходять для багатьох інверторів MUST, Voltronic, Axpert, PowMr та EASUN.
WEB інтерфейс
Інтерфейс працює локально:
http://inverter.local
або за IP адресою ESP32.
Дані оновлюються автоматично приблизно кожні 3 секунди.
Що відображає WEB інтерфейс
| Плитка | Формат | Опис |
| AC RAD TEMP | °C | Температура радіатора AC |
| SOLAR | V | W | Напруга сонячних панелей та потужність PV |
| DC RAD TEMP | °C | Температура радіатора DC |
| GRID | V | W | Напруга мережі та потужність з мережі |
| SYSTEM BALANCE | W | режим | % | Баланс системи, режим інвертора та завантаження |
| LOAD | V | W | Напруга та потужність навантаження |
| SELF CONSUMPTION | W | Власне споживання інвертора |
| BAT | CHARGE / DISCHARGE / IDLE | V | W | Режим акумулятора |
| PV ENERGY | kWh | Накопичена сонячна енергія |
| Offgrid work enable | ON / OFF | Дозвіл off-grid режиму |
| Current mode | SBU / SUB / UTI / SOL | Поточний режим роботи |
| Charger priority | текст | Пріоритет зарядки |
Колірна індикація
BAT
- зелений — CHARGE
- червоний — DISCHARGE
- синій — IDLE
SYSTEM BALANCE
- зелений — позитивний баланс
- червоний — негативний баланс
- синій — баланс близько нуля
Нижній рядок інтерфейсу
Внизу сторінки відображається службова інформація:
PV 1800 | HW | SW | Protocol | FW
Відображаються:
- модель інвертора
- версія апаратної частини
- версія прошивки
- версія протоколу
- версія прошивки ESP32
Скан Modbus через браузер
У прошивці реалізовано вбудований перегляд регістрів Modbus прямо через браузер.
Шаблон:
http://inverter.local/scan?addr=ПОЧАТОК&count=КІЛЬКІСТЬ
Параметри:
| Параметр | Значення |
| addr | стартовий регістр |
| count | кількість регістрів підряд (максимум 50) |
Після відкриття посилання ESP32 повертає текст:
[25201] = 2
[25202] = 2300
[25203] = 138
Це зручно для:
- пошуку нових регістрів
- аналізу роботи інвертора
- діагностики
- перевірки Modbus
Приклади:
Основний блок:
http://inverter.local/scan?addr=25201&count=50
Продовження:
http://inverter.local/scan?addr=25251&count=30
Комп'ютер або телефон мають бути підключені до тієї ж Wi-Fi мережі, що й ESP32.
Налаштування та перевірка
Після прошивки ESP32:
- Підключіться до Wi-Fi
- Відкрийте WEB інтерфейс
- Перевірте обмін по RS485
- Переконайтеся, що значення оновлюються
Якщо дані не відображаються:
- перевірте лінії A/B RS485
- перевірте швидкість порту
- переконайтеся, що вибрано правильний Slave ID
Найчастіше проблема — переплутані лінії A/B.
Можливі проблеми
Немає зв'язку з інвертором
Перевірте:
- живлення RS485 модуля
- лінії A/B
- спільний GND
- швидкість порту
Показуються від'ємні потужності
Деякі регістри MUST використовують signed значення. Це нормальна поведінка.
Wi-Fi працює нестабільно
Рекомендується використовувати якісне живлення ESP32 та короткі USB кабелі.
Дані відрізняються від штатного дисплея
Частина параметрів інвертор розраховує внутрішньою DSP логікою і вони можуть відрізнятися від прямих Modbus значень.
Відео роботи пристрою
Тут буде розміщено відео роботи моніторингу та WEB інтерфейсу.
Завантажити та використовувати
Для самостійної збірки пристрою Вам знадобиться.
- повний робочий скетч (внизу статті)
- список бібліотек
- STL модель корпусу (використовувався найпростіший корпус для esp32 с3 super mini)
- схема підключення
- список компонентів
Використовувані бібліотеки:
- ModbusMaster
- ESPAsyncWebServer
Підсумок
ESP32 дозволяє отримати повноцінний локальний моніторинг інвертора MUST без хмар та платних сервісів.
Проєкт можна використовувати як основу для:
- домашньої сонячної системи
- автономного живлення
- моніторингу акумуляторів
- логіювання Modbus
- інтеграції в Home Assistant
STL модель корпусу буде додано окремо, скетч буде з часом доопрацьовуватися (статистика графіки інше).
Якщо стаття була корисною — збережіть її або поділіться посиланням
Питання можете додавати в коментарі.
Скетч Arduino: Монитор MUST через ESP32 RS485 Modbus
<pre><code class="language-cpp">#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;
}
}</code></pre>
Коментарі до статті
Поки що немає коментарів. Будьте першим!
Додати коментар