Инверторы 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
Тестировалось на инветоре к которому не подключена бмс аккумулятора, поэтому режим оаботы АКБ мощность заряда/разряда больше похоже на измерение погоды. Нужен более продвинутый инвертор для теста. Для инверторов MUST / Voltronic на прошивках серии 3.4.x по Modbus можно достоверно определить разряд аккумулятора, но данные о заряде аккумулятора в открытых регистрах отсутствуют. Для точного контроля заряда требуется отдельный источник данных от BMS либо внешний измеритель тока аккумулятора.
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>
Комментарии к статье
Пока нет комментариев. Будьте первым!
Добавить комментарий