0d6b860664
- haos-addon/: vollständiges HA Add-on (config.yaml, Dockerfile, build.yaml) - Python Backend: pymodbus Modbus TCP → paho-mqtt MQTT Discovery - Unterstützte Modelle: MIC 1500/2000 TL-X, SPH 5000 TL3, MOD 6000 TL3 - Web UI: Wechselrichter-Auswahl, Modbus/MQTT-Konfig, Live-Sensor-Grid (dark theme) - MQTT HA Discovery für alle Sensoren mit device_class, state_class, icon - ShineLAN-X/releases/nuttx-mbusd-shinelanx.bin: NuttX Firmware (ohne DFU, 0x08000000) - .gitignore: Logs, MQTT-JSON, shinelanx-modbus/ ausgeschlossen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
713 lines
30 KiB
C++
713 lines
30 KiB
C++
#include <Arduino.h>
|
|
#include <SPI.h>
|
|
#include <EthernetENC.h>
|
|
#include <PubSubClient.h>
|
|
#include <ModbusMaster.h>
|
|
#include <EEPROM.h>
|
|
#include "config.h"
|
|
#include "config_manager.h"
|
|
|
|
extern "C" void SystemClock_Config(void) {
|
|
RCC_OscInitTypeDef osc = {};
|
|
RCC_ClkInitTypeDef clk = {};
|
|
RCC_PeriphCLKInitTypeDef periphClk = {};
|
|
|
|
osc.OscillatorType = RCC_OSCILLATORTYPE_HSE;
|
|
osc.HSEState = RCC_HSE_ON;
|
|
osc.PLL.PLLState = RCC_PLL_ON;
|
|
osc.PLL.PLLSource = RCC_PLLSOURCE_HSE;
|
|
osc.PLL.PLLMUL = RCC_PLL_MUL9;
|
|
if (HAL_RCC_OscConfig(&osc) == HAL_OK) {
|
|
clk.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK
|
|
| RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
|
|
clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
|
|
clk.AHBCLKDivider = RCC_SYSCLK_DIV1;
|
|
clk.APB1CLKDivider = RCC_HCLK_DIV2;
|
|
clk.APB2CLKDivider = RCC_HCLK_DIV1;
|
|
HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_2);
|
|
periphClk.PeriphClockSelection = RCC_PERIPHCLK_USB;
|
|
periphClk.UsbClockSelection = RCC_USBCLKSOURCE_PLL_DIV1_5;
|
|
HAL_RCCEx_PeriphCLKConfig(&periphClk);
|
|
} else {
|
|
osc.OscillatorType = RCC_OSCILLATORTYPE_HSI;
|
|
osc.HSEState = RCC_HSE_OFF;
|
|
osc.HSIState = RCC_HSI_ON;
|
|
osc.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
|
|
osc.PLL.PLLSource = RCC_PLLSOURCE_HSI_DIV2;
|
|
osc.PLL.PLLMUL = RCC_PLL_MUL12;
|
|
HAL_RCC_OscConfig(&osc);
|
|
clk.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK
|
|
| RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
|
|
clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
|
|
clk.AHBCLKDivider = RCC_SYSCLK_DIV1;
|
|
clk.APB1CLKDivider = RCC_HCLK_DIV2;
|
|
clk.APB2CLKDivider = RCC_HCLK_DIV1;
|
|
HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_1);
|
|
periphClk.PeriphClockSelection = RCC_PERIPHCLK_USB;
|
|
periphClk.UsbClockSelection = RCC_USBCLKSOURCE_PLL;
|
|
HAL_RCCEx_PeriphCLKConfig(&periphClk);
|
|
}
|
|
}
|
|
|
|
// Hält PA8 HIGH (USB getrennt) bis setup() es manuell verbindet
|
|
extern "C" void USBD_reenumerate(void) {
|
|
__HAL_RCC_GPIOA_CLK_ENABLE();
|
|
GPIO_InitTypeDef gpio = {};
|
|
gpio.Pin = GPIO_PIN_8;
|
|
gpio.Mode = GPIO_MODE_OUTPUT_PP;
|
|
gpio.Pull = GPIO_NOPULL;
|
|
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
|
|
HAL_GPIO_Init(GPIOA, &gpio);
|
|
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET);
|
|
}
|
|
|
|
HardwareSerial DebugSerial(PA10, PA9);
|
|
|
|
// USB-Diagnose: was der Wechselrichter per Control-Transfer schickt
|
|
extern __IO bool dtrState;
|
|
extern __IO bool rtsState;
|
|
struct { uint32_t baud; uint8_t format; uint8_t parity; uint8_t bits; }
|
|
g_lineCoding = {115200, 0, 0, 8};
|
|
volatile bool g_lineCodingUpdated = false;
|
|
|
|
// Wird vom USB-Stack aufgerufen wenn Wechselrichter SET_LINE_CODING schickt
|
|
// usbd_cdc_if.c kann nicht geändert werden → wir lesen linecoding-Struct direkt
|
|
extern "C" {
|
|
typedef struct { uint32_t bitrate; uint8_t format; uint8_t paritytype; uint8_t datatype; }
|
|
USBD_CDC_LineCodingTypeDef;
|
|
extern USBD_CDC_LineCodingTypeDef linecoding;
|
|
}
|
|
|
|
// CDC SERIAL_STATE Notification — NuttX sendet dies beim Öffnen von /dev/ttyACM0
|
|
// signalisiert dem Wechselrichter DCD+DSR=1 → startet Modbus-Server im Inverter
|
|
#include "usbd_core.h"
|
|
extern USBD_HandleTypeDef hUSBD_Device_CDC;
|
|
|
|
#define CDC_SERIAL_STATE_EP 0x82U
|
|
|
|
void sendSerialStateNotification() {
|
|
static uint8_t notif[10] = {
|
|
0xA1, 0x20, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03, 0x00
|
|
};
|
|
USBD_LL_Transmit(&hUSBD_Device_CDC, CDC_SERIAL_STATE_EP, notif, sizeof(notif));
|
|
}
|
|
|
|
// ModbusMaster schreibt Byte-für-Byte. Würden wir sofort senden, käme Byte 1 als
|
|
// eigenes 1-Byte USB-Paket an — der Wechselrichter behandelt jedes Short Packet
|
|
// als Frame-Ende und sieht keinen gültigen Modbus-Frame.
|
|
// Lösung: Bytes puffern, dann in flush() als ein einziges USB-Paket senden.
|
|
class ModbusStream : public Stream {
|
|
public:
|
|
uint8_t txBuf[16]; uint8_t txLen = 0;
|
|
uint8_t rxBuf[32]; uint8_t rxLen = 0;
|
|
uint32_t rxTotal = 0;
|
|
void reset() { txLen = 0; rxLen = 0; }
|
|
|
|
int available() override { return SerialUSB.available(); }
|
|
int read() override {
|
|
int b = SerialUSB.read();
|
|
if (b >= 0) { rxTotal++; if (rxLen < sizeof(rxBuf)) rxBuf[rxLen++] = (uint8_t)b; }
|
|
return b;
|
|
}
|
|
int peek() override { return SerialUSB.peek(); }
|
|
size_t write(uint8_t b) override {
|
|
if (txLen < sizeof(txBuf)) txBuf[txLen++] = b;
|
|
return 1;
|
|
}
|
|
size_t write(const uint8_t* buf, size_t size) override {
|
|
for (size_t i = 0; i < size && txLen < sizeof(txBuf); i++) txBuf[txLen++] = buf[i];
|
|
return size;
|
|
}
|
|
void flush() override {
|
|
if (txLen > 0) SerialUSB.write(txBuf, txLen);
|
|
// txLen nicht zurücksetzen — Debug-Code liest txBuf danach noch
|
|
}
|
|
using Print::write;
|
|
} modbusSerial;
|
|
|
|
// ============================================================
|
|
// Sensor-Definitionen
|
|
// ============================================================
|
|
struct Sensor {
|
|
const char* id;
|
|
const char* name;
|
|
uint16_t address;
|
|
bool isDword;
|
|
float scale;
|
|
const char* unit;
|
|
const char* deviceClass;
|
|
const char* stateClass;
|
|
const char* icon;
|
|
};
|
|
|
|
#if defined(INVERTER_MIC1500)
|
|
const Sensor SENSORS[] = {
|
|
{"pv_voltage", "PV Voltage", 3, false, 0.1f, "V", "voltage", "measurement", "mdi:solar-panel"},
|
|
{"pv_current", "PV Current", 4, false, 0.1f, "A", "current", "measurement", "mdi:solar-panel"},
|
|
{"pv_power", "PV Power", 5, true, 0.1f, "W", "power", "measurement", "mdi:solar-panel"},
|
|
{"grid_frequency", "Grid Frequency", 37, false, 0.01f, "Hz", "frequency", "measurement", "mdi:sine-wave"},
|
|
{"grid_voltage", "Grid Voltage", 38, false, 0.1f, "V", "voltage", "measurement", "mdi:flash"},
|
|
{"grid_current", "Grid Current", 39, false, 0.1f, "A", "current", "measurement", "mdi:flash"},
|
|
{"ac_power", "AC Output Power", 40, true, 0.1f, "W", "power", "measurement", "mdi:flash"},
|
|
{"energy_today", "Energy Today", 53, true, 0.1f, "kWh", "energy", "total_increasing", "mdi:solar-power"},
|
|
{"energy_total", "Energy Total", 55, true, 0.1f, "kWh", "energy", "total_increasing", "mdi:solar-power"},
|
|
{"inverter_temp", "Inverter Temperature", 93, false, 0.1f, "°C", "temperature", "measurement", "mdi:thermometer"},
|
|
};
|
|
#elif defined(INVERTER_SPH5000)
|
|
const Sensor SENSORS[] = {
|
|
{"pv1_voltage", "PV1 Voltage", 3, false, 0.1f, "V", "voltage", "measurement", "mdi:solar-panel"},
|
|
{"pv1_current", "PV1 Current", 4, false, 0.1f, "A", "current", "measurement", "mdi:solar-panel"},
|
|
{"pv1_power", "PV1 Power", 5, true, 0.1f, "W", "power", "measurement", "mdi:solar-panel"},
|
|
{"pv2_voltage", "PV2 Voltage", 7, false, 0.1f, "V", "voltage", "measurement", "mdi:solar-panel"},
|
|
{"pv2_current", "PV2 Current", 8, false, 0.1f, "A", "current", "measurement", "mdi:solar-panel"},
|
|
{"pv2_power", "PV2 Power", 9, true, 0.1f, "W", "power", "measurement", "mdi:solar-panel"},
|
|
{"ac_power_total", "AC Output Power Total", 35, true, 0.1f, "W", "power", "measurement", "mdi:flash"},
|
|
{"grid_frequency", "Grid Frequency", 37, false, 0.01f, "Hz", "frequency", "measurement", "mdi:sine-wave"},
|
|
{"grid_voltage_l1", "Grid Voltage L1", 38, false, 0.1f, "V", "voltage", "measurement", "mdi:flash"},
|
|
{"grid_current_l1", "Grid Current L1", 39, false, 0.1f, "A", "current", "measurement", "mdi:flash"},
|
|
{"grid_voltage_l2", "Grid Voltage L2", 42, false, 0.1f, "V", "voltage", "measurement", "mdi:flash"},
|
|
{"grid_current_l2", "Grid Current L2", 43, false, 0.1f, "A", "current", "measurement", "mdi:flash"},
|
|
{"grid_voltage_l3", "Grid Voltage L3", 46, false, 0.1f, "V", "voltage", "measurement", "mdi:flash"},
|
|
{"grid_current_l3", "Grid Current L3", 47, false, 0.1f, "A", "current", "measurement", "mdi:flash"},
|
|
{"energy_today", "Energy Today", 53, true, 0.1f, "kWh", "energy", "total_increasing", "mdi:solar-power"},
|
|
{"energy_total", "Energy Total", 55, true, 0.1f, "kWh", "energy", "total_increasing", "mdi:solar-power"},
|
|
{"inverter_temp", "Inverter Temperature", 93, false, 0.1f, "°C", "temperature", "measurement", "mdi:thermometer"},
|
|
{"bat_discharge_power", "Battery Discharge Power", 1009, true, 0.1f, "W", "power", "measurement", "mdi:battery-minus"},
|
|
{"bat_charge_power", "Battery Charge Power", 1011, true, 0.1f, "W", "power", "measurement", "mdi:battery-plus"},
|
|
{"bat_voltage", "Battery Voltage", 1013, false, 0.1f, "V", "voltage", "measurement", "mdi:battery"},
|
|
{"bat_soc", "Battery State of Charge", 1014, false, 1.0f, "%", "battery", "measurement", "mdi:battery"},
|
|
{"bat_temperature", "Battery Temperature", 1040, false, 0.1f, "°C", "temperature", "measurement", "mdi:thermometer"},
|
|
{"power_to_grid", "Power To Grid", 1021, true, 0.1f, "W", "power", "measurement", "mdi:transmission-tower-export"},
|
|
{"power_to_user", "Power To User", 1029, true, 0.1f, "W", "power", "measurement", "mdi:transmission-tower-import"},
|
|
{"energy_import_total", "Energy Import Total", 1046, true, 0.1f, "kWh", "energy", "total_increasing", "mdi:transmission-tower-import"},
|
|
{"energy_export_total", "Energy Export Total", 1050, true, 0.1f, "kWh", "energy", "total_increasing", "mdi:transmission-tower-export"},
|
|
{"bat_discharge_total", "Battery Discharge Total", 1054, true, 0.1f, "kWh", "energy", "total_increasing", "mdi:battery-minus"},
|
|
{"bat_charge_total", "Battery Charge Total", 1058, true, 0.1f, "kWh", "energy", "total_increasing", "mdi:battery-plus"},
|
|
};
|
|
#else
|
|
#error "Kein Inverter-Modell definiert! INVERTER_MIC1500 oder INVERTER_SPH5000 in config.h setzen."
|
|
#endif
|
|
const uint8_t SENSOR_COUNT = sizeof(SENSORS) / sizeof(SENSORS[0]);
|
|
|
|
// ============================================================
|
|
// Globale Objekte
|
|
// ============================================================
|
|
NetConfig cfg;
|
|
byte mac[] = {MAC_ADDRESS};
|
|
EthernetClient ethClient;
|
|
PubSubClient mqtt(ethClient);
|
|
ModbusMaster modbus;
|
|
EthernetServer webServer(80);
|
|
|
|
uint32_t g_okTotal = 0;
|
|
uint32_t g_failTotal = 0;
|
|
uint8_t g_lastErr = 0;
|
|
uint32_t g_rawTotal = 0;
|
|
|
|
// ============================================================
|
|
// Web-Server
|
|
// ============================================================
|
|
|
|
static bool webReadLine(EthernetClient& c, char* buf, size_t maxLen) {
|
|
size_t i = 0;
|
|
uint32_t t = millis();
|
|
while (millis() - t < 1000) {
|
|
while (c.available()) {
|
|
char ch = c.read();
|
|
if (ch == '\n') { buf[i] = 0; return true; }
|
|
if (ch != '\r' && i < maxLen - 1) buf[i++] = ch;
|
|
t = millis();
|
|
}
|
|
}
|
|
buf[i] = 0;
|
|
return false;
|
|
}
|
|
|
|
static void ipToStr(const uint8_t ip[4], char* buf) {
|
|
snprintf(buf, 16, "%u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]);
|
|
}
|
|
|
|
static void urlDecode(char* s) {
|
|
char* out = s;
|
|
while (*s) {
|
|
if (*s == '+') {
|
|
*out++ = ' '; s++;
|
|
} else if (*s == '%' && s[1] && s[2]) {
|
|
char h[3] = {s[1], s[2], 0};
|
|
*out++ = (char)strtol(h, nullptr, 16);
|
|
s += 3;
|
|
} else {
|
|
*out++ = *s++;
|
|
}
|
|
}
|
|
*out = 0;
|
|
}
|
|
|
|
static void parseIP(const char* s, uint8_t out[4]) {
|
|
unsigned a = 0, b = 0, c = 0, d = 0;
|
|
if (sscanf(s, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) {
|
|
out[0] = a; out[1] = b; out[2] = c; out[3] = d;
|
|
}
|
|
}
|
|
|
|
static void parseFormBody(char* body) {
|
|
cfg.useDhcp = false; // Checkbox fehlt im POST wenn nicht gesetzt
|
|
char* p = body;
|
|
while (p && *p) {
|
|
char* eq = strchr(p, '=');
|
|
char* amp = strchr(p, '&');
|
|
if (!eq) break;
|
|
*eq = 0;
|
|
if (amp) *amp = 0;
|
|
char* key = p;
|
|
char* val = eq + 1;
|
|
urlDecode(key);
|
|
urlDecode(val);
|
|
|
|
if (!strcmp(key, "dhcp")) cfg.useDhcp = true;
|
|
else if (!strcmp(key, "ip")) parseIP(val, cfg.ip);
|
|
else if (!strcmp(key, "gw")) parseIP(val, cfg.gw);
|
|
else if (!strcmp(key, "sn")) parseIP(val, cfg.sn);
|
|
else if (!strcmp(key, "dns")) parseIP(val, cfg.dns);
|
|
else if (!strcmp(key, "broker")) strncpy(cfg.mqttBroker, val, sizeof(cfg.mqttBroker) - 1);
|
|
else if (!strcmp(key, "port")) cfg.mqttPort = (uint16_t)atoi(val);
|
|
else if (!strcmp(key, "user")) strncpy(cfg.mqttUser, val, sizeof(cfg.mqttUser) - 1);
|
|
else if (!strcmp(key, "pass")) strncpy(cfg.mqttPass, val, sizeof(cfg.mqttPass) - 1);
|
|
else if (!strcmp(key, "interval")) cfg.updateMs = (uint32_t)atoi(val) * 1000UL;
|
|
|
|
p = amp ? amp + 1 : nullptr;
|
|
}
|
|
}
|
|
|
|
static void sendPage(EthernetClient& cl) {
|
|
char buf[16];
|
|
|
|
cl.print(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n"
|
|
"<!DOCTYPE html><html lang='de'><head>"
|
|
"<meta charset='utf-8'>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
"<title>ShineLAN-X</title><style>"
|
|
"*{box-sizing:border-box}"
|
|
"body{font-family:Arial,sans-serif;background:#1a1a2e;color:#eee;margin:0}"
|
|
"header{background:#16213e;padding:12px 18px;display:flex;align-items:center;"
|
|
"border-bottom:2px solid #e94560}"
|
|
"header h1{margin:0;font-size:1.1em;color:#e94560;flex:1}"
|
|
".sub{font-size:.78em;color:#64748b;margin-top:2px}"
|
|
".badge{padding:3px 10px;border-radius:10px;font-size:.78em;font-weight:bold}"
|
|
".on{background:#14532d;color:#4ade80}"
|
|
".off{background:#450a0a;color:#f87171}"
|
|
"main{max-width:520px;margin:14px auto;padding:0 12px}"
|
|
".stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px}"
|
|
".sbox{background:#16213e;border-radius:6px;padding:9px;text-align:center}"
|
|
".sval{font-size:1.4em;font-weight:bold;color:#e94560}"
|
|
".slbl{font-size:.7em;color:#64748b;margin-top:2px}"
|
|
".card{background:#16213e;border-radius:7px;padding:14px;margin-bottom:12px}"
|
|
".card h2{margin:0 0 12px;font-size:.72em;text-transform:uppercase;"
|
|
"letter-spacing:.08em;color:#64748b;"
|
|
"border-bottom:1px solid #0f3460;padding-bottom:7px}"
|
|
".row{display:flex;align-items:center;gap:8px;margin-bottom:9px}"
|
|
".row label{min-width:115px;font-size:.84em;color:#94a3b8}"
|
|
".row input[type=text],.row input[type=number],.row input[type=password]"
|
|
"{flex:1;background:#0f3460;border:1px solid #1e4a80;color:#eee;"
|
|
"padding:6px 9px;border-radius:4px;font-size:.84em;min-width:0}"
|
|
".row input[type=checkbox]{width:16px;height:16px;accent-color:#e94560;cursor:pointer}"
|
|
".btn{width:100%;padding:10px;border:none;border-radius:6px;"
|
|
"font-size:.88em;cursor:pointer;margin-top:5px;font-weight:bold}"
|
|
".save{background:#e94560;color:#fff}"
|
|
".save:hover{background:#c73652}"
|
|
".rst{background:#334155;color:#eee}"
|
|
".rst:hover{background:#475569}"
|
|
"</style></head><body>"
|
|
);
|
|
|
|
// Header
|
|
cl.print("<header><div><h1>☀ ShineLAN-X</h1>"
|
|
"<div class='sub'>Growatt Modbus → MQTT Bridge</div></div>"
|
|
"<div style='text-align:right'><span class='badge ");
|
|
cl.print(mqtt.connected() ? "on'>Verbunden" : "off'>Getrennt");
|
|
cl.print("</span><div class='sub'>IP: ");
|
|
cl.print(Ethernet.localIP());
|
|
cl.print("</div></div></header><main>");
|
|
|
|
// Statistik
|
|
cl.print("<div class='stats'>"
|
|
"<div class='sbox'><div class='sval'>");
|
|
cl.print(g_okTotal);
|
|
cl.print("</div><div class='slbl'>OK</div></div>"
|
|
"<div class='sbox'><div class='sval'>");
|
|
cl.print(g_failTotal);
|
|
cl.print("</div><div class='slbl'>Fehler</div></div>"
|
|
"<div class='sbox'><div class='sval'>0x");
|
|
snprintf(buf, sizeof(buf), "%02X", g_lastErr);
|
|
cl.print(buf);
|
|
cl.print("</div><div class='slbl'>Letzter Err</div></div></div>");
|
|
|
|
// Formular — Netzwerk
|
|
cl.print("<form method='post' action='/'>"
|
|
"<div class='card'><h2>Netzwerk</h2>"
|
|
"<div class='row'><label>DHCP</label>"
|
|
"<input type='checkbox' id='dc' name='dhcp'");
|
|
if (cfg.useDhcp) cl.print(" checked");
|
|
cl.print(" onchange='tog()'></div><div id='st'>");
|
|
|
|
ipToStr(cfg.ip, buf);
|
|
cl.print("<div class='row'><label>IP-Adresse</label>"
|
|
"<input type='text' name='ip' value='"); cl.print(buf); cl.print("'></div>");
|
|
ipToStr(cfg.gw, buf);
|
|
cl.print("<div class='row'><label>Gateway</label>"
|
|
"<input type='text' name='gw' value='"); cl.print(buf); cl.print("'></div>");
|
|
ipToStr(cfg.sn, buf);
|
|
cl.print("<div class='row'><label>Subnet</label>"
|
|
"<input type='text' name='sn' value='"); cl.print(buf); cl.print("'></div>");
|
|
ipToStr(cfg.dns, buf);
|
|
cl.print("<div class='row'><label>DNS</label>"
|
|
"<input type='text' name='dns' value='"); cl.print(buf); cl.print("'></div>");
|
|
cl.print("</div></div>");
|
|
|
|
// Formular — MQTT
|
|
cl.print("<div class='card'><h2>MQTT</h2>"
|
|
"<div class='row'><label>Broker</label>"
|
|
"<input type='text' name='broker' value='");
|
|
cl.print(cfg.mqttBroker);
|
|
cl.print("'></div>"
|
|
"<div class='row'><label>Port</label>"
|
|
"<input type='number' name='port' value='");
|
|
cl.print(cfg.mqttPort);
|
|
cl.print("' min='1' max='65535'></div>"
|
|
"<div class='row'><label>Benutzer</label>"
|
|
"<input type='text' name='user' value='");
|
|
cl.print(cfg.mqttUser);
|
|
cl.print("' autocomplete='off'></div>"
|
|
"<div class='row'><label>Passwort</label>"
|
|
"<input type='password' name='pass' value='");
|
|
cl.print(cfg.mqttPass);
|
|
cl.print("' autocomplete='off'></div>"
|
|
"<div class='row'><label>Intervall (s)</label>"
|
|
"<input type='number' name='interval' value='");
|
|
cl.print(cfg.updateMs / 1000UL);
|
|
cl.print("' min='1' max='3600'></div></div>");
|
|
|
|
// Buttons + Script
|
|
cl.print(
|
|
"<button class='btn save' type='submit'>"
|
|
"💾 Speichern & Neustart</button>"
|
|
"<button class='btn rst' type='button' "
|
|
"onclick='if(confirm(\"Wirklich auf Werkseinstellungen zur\\u00FCcksetzen?\"))"
|
|
"location=\"/reset\"'>↻ Werkseinstellungen</button>"
|
|
"</form></main>"
|
|
"<script>"
|
|
"function tog(){"
|
|
"document.getElementById('st').style.display="
|
|
"document.getElementById('dc').checked?'none':'block';}"
|
|
"window.onload=tog;"
|
|
"</script></body></html>"
|
|
);
|
|
}
|
|
|
|
static void handleWebRequest() {
|
|
EthernetClient client = webServer.available();
|
|
if (!client) return;
|
|
|
|
char line[80];
|
|
if (!webReadLine(client, line, sizeof(line))) { client.stop(); return; }
|
|
|
|
bool isPost = (strncmp(line, "POST", 4) == 0);
|
|
bool isReset = (strstr(line, "/reset") != nullptr);
|
|
|
|
// Header lesen, Content-Length suchen (case-insensitive via tolower)
|
|
int contentLen = 0;
|
|
char hdr[80];
|
|
while (webReadLine(client, hdr, sizeof(hdr)) && hdr[0] != '\0') {
|
|
for (char* p = hdr; *p; p++) *p = (char)tolower((unsigned char)*p);
|
|
if (strncmp(hdr, "content-length:", 15) == 0)
|
|
contentLen = atoi(hdr + 15);
|
|
}
|
|
|
|
if (isReset) {
|
|
configDefaults(cfg);
|
|
configSave(cfg);
|
|
client.print("HTTP/1.1 303 See Other\r\nLocation: /\r\nConnection: close\r\n\r\n");
|
|
client.flush(); client.stop();
|
|
delay(300);
|
|
NVIC_SystemReset();
|
|
return;
|
|
}
|
|
|
|
if (isPost && contentLen > 0 && contentLen < 500) {
|
|
char body[500] = {};
|
|
int n = 0;
|
|
uint32_t t = millis();
|
|
while (n < contentLen && millis() - t < 3000) {
|
|
if (client.available()) { body[n++] = client.read(); t = millis(); }
|
|
}
|
|
body[n] = 0;
|
|
|
|
parseFormBody(body);
|
|
cfg.magic = CFG_MAGIC;
|
|
configSave(cfg);
|
|
|
|
client.print("HTTP/1.1 303 See Other\r\nLocation: /\r\nConnection: close\r\n\r\n");
|
|
client.flush(); client.stop();
|
|
delay(300);
|
|
NVIC_SystemReset();
|
|
return;
|
|
}
|
|
|
|
sendPage(client);
|
|
client.flush();
|
|
client.stop();
|
|
}
|
|
|
|
// ============================================================
|
|
// LED
|
|
// ============================================================
|
|
void ledSet(uint8_t pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
|
|
|
|
// ============================================================
|
|
// MQTT
|
|
// ============================================================
|
|
void publishDiscovery() {
|
|
char topic[128];
|
|
char payload[640];
|
|
|
|
for (uint8_t i = 0; i < SENSOR_COUNT; i++) {
|
|
const Sensor& s = SENSORS[i];
|
|
snprintf(topic, sizeof(topic),
|
|
"homeassistant/sensor/%s_%s/config", DEVICE_ID, s.id);
|
|
snprintf(payload, sizeof(payload),
|
|
"{"
|
|
"\"name\":\"%s\","
|
|
"\"unique_id\":\"%s_%s\","
|
|
"\"state_topic\":\"growatt/shinelan/%s\","
|
|
"\"unit_of_measurement\":\"%s\","
|
|
"\"device_class\":\"%s\","
|
|
"\"state_class\":\"%s\","
|
|
"\"icon\":\"%s\","
|
|
"\"device\":{"
|
|
"\"identifiers\":[\"%s\"],"
|
|
"\"name\":\"%s\","
|
|
"\"model\":\"%s\","
|
|
"\"manufacturer\":\"%s\""
|
|
"}}",
|
|
s.name,
|
|
DEVICE_ID, s.id,
|
|
s.id,
|
|
s.unit, s.deviceClass, s.stateClass, s.icon,
|
|
DEVICE_ID, DEVICE_NAME, DEVICE_MODEL, DEVICE_MFR);
|
|
mqtt.publish(topic, payload, true);
|
|
}
|
|
}
|
|
|
|
bool mqttReconnect() {
|
|
DebugSerial.print("MQTT connecting... ");
|
|
ledSet(LED_RED, true);
|
|
bool ok = (strlen(cfg.mqttUser) > 0)
|
|
? mqtt.connect(MQTT_CLIENT, cfg.mqttUser, cfg.mqttPass)
|
|
: mqtt.connect(MQTT_CLIENT);
|
|
if (ok) {
|
|
DebugSerial.println("OK");
|
|
ledSet(LED_RED, false);
|
|
ledSet(LED_GREEN, true);
|
|
publishDiscovery();
|
|
} else {
|
|
DebugSerial.print("FAIL rc=");
|
|
DebugSerial.println(mqtt.state());
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
// ============================================================
|
|
// Setup
|
|
// ============================================================
|
|
void setup() {
|
|
DebugSerial.begin(115200);
|
|
delay(10);
|
|
DebugSerial.println("\r\n=== Growatt ShineLAN-X ===");
|
|
DebugSerial.println("Build: " __DATE__ " " __TIME__);
|
|
|
|
configLoad(cfg);
|
|
|
|
pinMode(LED_DEBUG, OUTPUT); ledSet(LED_DEBUG, false);
|
|
pinMode(LED_RED, OUTPUT); ledSet(LED_RED, false);
|
|
pinMode(LED_GREEN, OUTPUT); ledSet(LED_GREEN, false);
|
|
pinMode(LED_BLUE, OUTPUT); ledSet(LED_BLUE, false);
|
|
ledSet(LED_RED, true); ledSet(LED_GREEN, true); ledSet(LED_BLUE, true);
|
|
delay(300);
|
|
ledSet(LED_RED, false); ledSet(LED_GREEN, false); ledSet(LED_BLUE, false);
|
|
|
|
pinMode(ETH_RST_PIN, OUTPUT);
|
|
digitalWrite(ETH_RST_PIN, LOW); delay(20);
|
|
digitalWrite(ETH_RST_PIN, HIGH); delay(200);
|
|
Ethernet.init(ETH_CS_PIN);
|
|
|
|
if (cfg.useDhcp) {
|
|
DebugSerial.println("ETH: DHCP...");
|
|
if (Ethernet.begin(mac) == 0) {
|
|
// DHCP fehlgeschlagen: Fallback auf gespeicherte statische IP
|
|
DebugSerial.println("ETH: DHCP failed, static fallback");
|
|
IPAddress ip (cfg.ip[0], cfg.ip[1], cfg.ip[2], cfg.ip[3]);
|
|
IPAddress gw (cfg.gw[0], cfg.gw[1], cfg.gw[2], cfg.gw[3]);
|
|
IPAddress sn (cfg.sn[0], cfg.sn[1], cfg.sn[2], cfg.sn[3]);
|
|
IPAddress dns(cfg.dns[0], cfg.dns[1], cfg.dns[2], cfg.dns[3]);
|
|
Ethernet.begin(mac, ip, dns, gw, sn);
|
|
}
|
|
} else {
|
|
IPAddress ip (cfg.ip[0], cfg.ip[1], cfg.ip[2], cfg.ip[3]);
|
|
IPAddress gw (cfg.gw[0], cfg.gw[1], cfg.gw[2], cfg.gw[3]);
|
|
IPAddress sn (cfg.sn[0], cfg.sn[1], cfg.sn[2], cfg.sn[3]);
|
|
IPAddress dns(cfg.dns[0], cfg.dns[1], cfg.dns[2], cfg.dns[3]);
|
|
Ethernet.begin(mac, ip, dns, gw, sn);
|
|
}
|
|
|
|
DebugSerial.print("ETH: IP=");
|
|
DebugSerial.println(Ethernet.localIP());
|
|
if (Ethernet.linkStatus() == LinkON) ledSet(LED_DEBUG, true);
|
|
|
|
webServer.begin();
|
|
DebugSerial.print("Web: http://");
|
|
DebugSerial.println(Ethernet.localIP());
|
|
|
|
mqtt.setServer(cfg.mqttBroker, cfg.mqttPort);
|
|
mqtt.setBufferSize(768);
|
|
|
|
SerialUSB.begin(MODBUS_BAUD);
|
|
delay(10000);
|
|
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET);
|
|
// Warte bis Wechselrichter USB-CDC konfiguriert hat
|
|
delay(3000);
|
|
// SERIAL_STATE(DCD=1,DSR=1) senden — NuttX tut dies beim Öffnen von ttyACM0
|
|
// signalisiert dem Wechselrichter dass das Device bereit ist
|
|
sendSerialStateNotification();
|
|
delay(500);
|
|
modbus.begin(MODBUS_ADDR, modbusSerial);
|
|
|
|
DebugSerial.println("Setup done.");
|
|
}
|
|
|
|
// ============================================================
|
|
// Loop
|
|
// ============================================================
|
|
unsigned long lastUpdate = 0;
|
|
static bool g_usbInfoPublished = false;
|
|
|
|
void loop() {
|
|
handleWebRequest();
|
|
|
|
if (!mqtt.connected()) {
|
|
if (!mqttReconnect()) {
|
|
delay(5000);
|
|
return;
|
|
}
|
|
}
|
|
mqtt.loop();
|
|
handleWebRequest();
|
|
|
|
// usbinfo: initial publish + DTR-Änderungen
|
|
{
|
|
static bool lastDtr = false;
|
|
if (!g_usbInfoPublished || (bool)dtrState != lastDtr) {
|
|
g_usbInfoPublished = true;
|
|
lastDtr = (bool)dtrState;
|
|
char info[80];
|
|
snprintf(info, sizeof(info), "baud=%lu fmt=%u par=%u bits=%u dtr=%d rts=%d",
|
|
(unsigned long)linecoding.bitrate, linecoding.format,
|
|
linecoding.paritytype, linecoding.datatype,
|
|
(int)dtrState, (int)rtsState);
|
|
mqtt.publish("growatt/shinelan/usbinfo", info, true);
|
|
}
|
|
}
|
|
|
|
// SERIAL_STATE(DCD=1,DSR=1) alle 5s senden bis DTR vom Wechselrichter gesetzt wird
|
|
{
|
|
static uint32_t lastNotif = 0;
|
|
if (!dtrState && millis() - lastNotif > 5000) {
|
|
lastNotif = millis();
|
|
sendSerialStateNotification();
|
|
}
|
|
}
|
|
|
|
// Raw-RX-Drain: jedes Byte das außerhalb von Modbus-Fenstern ankommt fangen
|
|
// Beantwortet: sendet der Inverter JEMALS irgendwas (spontan oder verspätet)?
|
|
{
|
|
static uint8_t rawBuf[32] = {};
|
|
static uint8_t rawLen = 0;
|
|
static uint32_t rawPublish = 0;
|
|
while (SerialUSB.available()) {
|
|
uint8_t b = (uint8_t)SerialUSB.read();
|
|
g_rawTotal++;
|
|
if (rawLen < sizeof(rawBuf)) rawBuf[rawLen++] = b;
|
|
}
|
|
if (rawLen > 0 && millis() - rawPublish > 1000) {
|
|
rawPublish = millis();
|
|
char msg[80];
|
|
size_t p = snprintf(msg, sizeof(msg), "raw=%lu: ", g_rawTotal);
|
|
for (uint8_t i = 0; i < rawLen && p < 74; i++)
|
|
p += snprintf(msg + p, sizeof(msg) - p, "%02X", rawBuf[i]);
|
|
mqtt.publish("growatt/shinelan/rawbytes", msg);
|
|
rawLen = 0;
|
|
}
|
|
}
|
|
|
|
if (millis() - lastUpdate < cfg.updateMs) return;
|
|
lastUpdate = millis();
|
|
|
|
ledSet(LED_BLUE, true);
|
|
|
|
uint32_t okCnt = 0, failCnt = 0;
|
|
uint8_t lastErr = 0;
|
|
uint16_t lastErrReg = 0;
|
|
|
|
for (uint8_t i = 0; i < SENSOR_COUNT; i++) {
|
|
const Sensor& s = SENSORS[i];
|
|
modbusSerial.reset();
|
|
|
|
uint8_t res = modbus.readInputRegisters(s.address, s.isDword ? 2 : 1);
|
|
|
|
char dbg[80];
|
|
size_t pos = snprintf(dbg, sizeof(dbg), "ok=%lu fail=%lu err=0x%02X@%u avS=%d dtr=%d raw=%lu rx=%u tx=",
|
|
g_okTotal + okCnt, g_failTotal + failCnt,
|
|
lastErr, lastErrReg,
|
|
SerialUSB.availableForWrite(),
|
|
(int)dtrState,
|
|
g_rawTotal,
|
|
modbusSerial.rxLen);
|
|
for (uint8_t j = 0; j < modbusSerial.txLen && pos < 74; j++)
|
|
pos += snprintf(dbg + pos, sizeof(dbg) - pos, "%02X", modbusSerial.txBuf[j]);
|
|
pos += snprintf(dbg + pos, sizeof(dbg) - pos, " rx=");
|
|
for (uint8_t j = 0; j < modbusSerial.rxLen && pos < 78; j++)
|
|
pos += snprintf(dbg + pos, sizeof(dbg) - pos, "%02X", modbusSerial.rxBuf[j]);
|
|
mqtt.publish("growatt/shinelan/debug", dbg);
|
|
|
|
delay(10); // mbusd -R10: 10ms Pause zwischen Requests
|
|
|
|
if (res != modbus.ku8MBSuccess) {
|
|
failCnt++;
|
|
lastErr = res;
|
|
lastErrReg = s.address;
|
|
continue;
|
|
}
|
|
|
|
okCnt++;
|
|
float val;
|
|
if (s.isDword) {
|
|
uint32_t raw = ((uint32_t)modbus.getResponseBuffer(0) << 16)
|
|
| modbus.getResponseBuffer(1);
|
|
val = raw * s.scale;
|
|
} else {
|
|
val = modbus.getResponseBuffer(0) * s.scale;
|
|
}
|
|
|
|
char topic[64], payload[24];
|
|
snprintf(topic, sizeof(topic), "growatt/shinelan/%s", s.id);
|
|
snprintf(payload, sizeof(payload), "%.1f", (double)val);
|
|
mqtt.publish(topic, payload);
|
|
}
|
|
|
|
g_okTotal += okCnt;
|
|
g_failTotal += failCnt;
|
|
g_lastErr = lastErr;
|
|
|
|
ledSet(LED_BLUE, false);
|
|
}
|