Позвонить Telegram Viber
График работы: пн–пт 9:00–18:00

Мониторинг инвертора MUST через ESP32: Wi-Fi, RS485 Modbus

DIY32

Время сборки: 120 мин

Сложность: высокая

Компоненты

  • esp32 c3 super mini
  • MAX485
  • USB Type-C
  • LAN cable

 Инверторы 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 voltage25205
Grid voltage25207
Load power25215
Battery power25273
PV power15208
MPPT temperature15209

Также используются:

  • температура радиаторов
  • режим работы инвертора
  • ток аккумулятора
  • мощность сети
  • накопленная PV энергия
  • процент загрузки инвертора

Эти регистры подходят для многих инверторов MUST, Voltronic, Axpert, PowMr и EASUN.


WEB интерфейс

Интерфейс работает локально:

http://inverter.local

или по IP адресу ESP32.

Данные обновляются автоматически примерно каждые 3 секунды.


Что отображает WEB интерфейс

ПлиткаФорматОписание
AC RAD TEMP°CТемпература радиатора AC
SOLARV | WНапряжение солнечных панелей и мощность PV
DC RAD TEMP°CТемпература радиатора DC
GRIDV | WНапряжение сети и мощность из сети
SYSTEM BALANCEW | режим | %Баланс системы, режим инвертора и загрузка
LOADV | WНапряжение и мощность нагрузки
SELF CONSUMPTIONWСобственное потребление инвертора
BATCHARGE / DISCHARGE / IDLE | V | WРежим аккумулятора
PV ENERGYkWhНакопленная солнечная энергия
Offgrid work enableON / OFFРазрешение off-grid режима
Current modeSBU / 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:

  1. Подключитесь к Wi-Fi
  2. Откройте WEB интерфейс
  3. Проверьте обмен по RS485
  4. Убедитесь что значения обновляются

Если данные не отображаются:

  • проверьте линии 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, &ampTruth);
  } 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, &ampTruth);
  } 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, &ampTruth);
    }
  }

  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} &nbsp;|&nbsp; HW: ${d.hwVersion} &nbsp;|&nbsp; SW: ${d.swVersion} &nbsp;|&nbsp; Protocol: ${d.protVersion} &nbsp;|&nbsp; 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>

С этим покупают:

  • ESP32-C3 Super Mini — компактная плата разработки с USB-C для Arduino и IoT проектов

    ESP32-C3 SuperMini — компактная плата разработки с USB-C для Arduino и IoT проектов
    160 грн
    ПОДРОБНЕЙ
  • Корпус для ESP32-C3 Super Mini (3 части, 29×29×12 мм)

    Корпус для ESP32-C3 Super Mini (3 части, 29×29×12 мм)
    70 грн
    ПОДРОБНЕЙ
  • Крепление на DIN‑рейку для Wi‑Fi реле 16A — корпус для монтажа в электрощит.

    Крепление на DIN‑рейку для Wi‑Fi реле 16A — корпус для монтажа в электрощит.
    150 грн
    ПОДРОБНЕЙ
  • Умное Wi-Fi реле 16A для умного дома + крепление на DIN-рейку в подарок

    Умное Wi‑Fi реле для умного дома 16A
    390 грн
    ПОДРОБНЕЙ

Комментарии к статье

Пока нет комментариев. Будьте первым!

Добавить комментарий