Позвонить 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

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

    ESP32-C3 SuperMini — компактная плата разработки с USB-C для Arduino
    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 грн
    ДЕТАЛЬНІШЕ

Коментарі до статті

Поки що немає коментарів. Будьте першим!

Додати коментар