From d4e569bce600adcedf69b1d49e73e464a8e95728 Mon Sep 17 00:00:00 2001 From: retr0 <42kdesigners@gmail.com> Date: Mon, 20 Apr 2026 14:29:11 +0200 Subject: [PATCH] ShineLAN-X: Web-Konfigurations-UI + EEPROM-Config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tasmota-artiges Web-Interface auf Port 80 (DHCP/IP, MQTT, Intervall) - NetConfig-Struct in EEPROM (Magic 0xA55A1234, Defaults bei erstem Boot) - config.h bereinigt: keine Geheimnisse mehr, nur DEFAULT_*-Werte - config_manager.h neu: configLoad/Save/Defaults - USB CDC Build-Flags wiederhergestellt (-DUSBCON etc.) - SPH 5000: power_to_grid (1021) + power_to_user (1029) ergänzt Co-Authored-By: Claude Sonnet 4.6 --- ShineLAN-X/firmware/CLAUDE.md | 20 + ShineLAN-X/firmware/include/config.h | 101 ++-- ShineLAN-X/firmware/include/config_manager.h | 50 ++ ShineLAN-X/firmware/platformio.ini | 4 + ShineLAN-X/firmware/src/main.cpp | 485 ++++++++++++++++--- 5 files changed, 547 insertions(+), 113 deletions(-) create mode 100644 ShineLAN-X/firmware/CLAUDE.md create mode 100644 ShineLAN-X/firmware/include/config_manager.h diff --git a/ShineLAN-X/firmware/CLAUDE.md b/ShineLAN-X/firmware/CLAUDE.md new file mode 100644 index 0000000..ba71fe8 --- /dev/null +++ b/ShineLAN-X/firmware/CLAUDE.md @@ -0,0 +1,20 @@ +# Growatt ShineLAN-X Firmware + +PlatformIO-Projekt fuer STM32F103RBT6. Liest Modbus RTU vom Wechselrichter via USB-CDC und publiziert via MQTT an Home Assistant. + +## Wiki Knowledge Base + +Path: /Users/retr0/Nextcloud/HomeLab/Vault + +Beim Start einer neuen Session: +1. Zuerst `wiki/hot.md` lesen (aktueller Kontext) +2. Dann `wiki/entities/Growatt ShineLAN-X Firmware.md` fuer Projektdetails +3. Nur bei Bedarf weitere Wiki-Seiten + +## Wichtige Konventionen + +- Kommentare nur wenn das WARUM nicht offensichtlich ist +- Keine trailing summaries nach Tool-Calls +- config.h ist geheimnis-frei (DEFAULT_* Werte) -- committen OK +- Echte Credentials werden nur im EEPROM gespeichert, nie im Source +- Framework-Hacks in `~/.platformio/` werden bei `pio pkg update` zurueckgesetzt diff --git a/ShineLAN-X/firmware/include/config.h b/ShineLAN-X/firmware/include/config.h index 4c92d67..4bce396 100644 --- a/ShineLAN-X/firmware/include/config.h +++ b/ShineLAN-X/firmware/include/config.h @@ -1,71 +1,62 @@ #pragma once // ============================================================ -// Pin-Belegung — Quelle: https://github.com/mwalle/shinelanx-modbus -// Gleiche Platine, verifizierte Pins -// STM32F103RBT6, LQFP-64 +// Pin-Belegung — STM32F103RBT6, LQFP-64 +// Quelle: https://github.com/mwalle/shinelanx-modbus // ============================================================ // ENC28J60 — SPI2 (Hardware-SPI) -// LQFP-64: PB12=33, PB13=34, PB14=35, PB15=36, PC6=37, PC8=39 -#define ETH_CS_PIN PB12 // ENC28J60 /CS (SPI2 NSS) -#define ETH_SCK_PIN PB13 // ENC28J60 SCK (SPI2 SCK) -#define ETH_MISO_PIN PB14 // ENC28J60 SO (SPI2 MISO) -#define ETH_MOSI_PIN PB15 // ENC28J60 SI (SPI2 MOSI) -#define ETH_RST_PIN PC8 // ENC28J60 /RESET -#define ETH_INT_PIN PC6 // ENC28J60 INT# (optional, polling reicht) +#define ETH_CS_PIN PB12 +#define ETH_SCK_PIN PB13 +#define ETH_MISO_PIN PB14 +#define ETH_MOSI_PIN PB15 +#define ETH_RST_PIN PC8 -// LEDs -#define LED_DEBUG PC7 // Debug-LED (grün o.ä.) -#define LED_RED PB1 // RGB Rot -#define LED_GREEN PB0 // RGB Grün -#define LED_BLUE PC5 // RGB Blau +// LEDs (aktiv LOW) +#define LED_DEBUG PC7 +#define LED_RED PB1 +#define LED_GREEN PB0 +#define LED_BLUE PC5 -// Taster -#define BTN_USER PA3 // User-Taster (low-aktiv) +// User-Taster +#define BTN_USER PA3 + +// MAC-Adresse +#define MAC_ADDRESS 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED // ============================================================ -// NETZWERK +// MODBUS // ============================================================ - -// 0 = DHCP, 1 = Statische IP -#define USE_DHCP 1 - -// Nur relevant wenn USE_DHCP = 0 -#define STATIC_IP 192,168,2,15 -#define STATIC_GW 192,168,2,1 -#define STATIC_SUBNET 255,255,255,0 -#define STATIC_DNS 192,168,2,1 - -// MAC-Adresse — muss im Netzwerk eindeutig sein -#define MAC_ADDRESS 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED - -// ============================================================ -// MQTT -// ============================================================ -#define MQTT_BROKER "192.168.1.1" -#define MQTT_PORT 1883 -#define MQTT_USER "mqtt" -#define MQTT_PASSWORD "HIER_MQTT_PASSWORT_EINTRAGEN" -#define MQTT_CLIENT "growatt-shinelan" - -// ============================================================ -// MODBUS / WECHSELRICHTER-KOMMUNIKATION -// Growatt kommuniziert über USB-CDC (virtueller COM-Port) bei 115200 Baud. -// STM32 agiert als USB-Device (CDC), Wechselrichter ist USB-Host. -// Physikalisch: PA11=D-, PA12=D+, PA8=USB-Pullup-Steuerpin -// Kein RS485, kein DE/RE-Pin nötig. -// ============================================================ -#define MODBUS_BAUD 115200 // Growatt USB-CDC Baudrate -#define MODBUS_ADDR 1 // Modbus Slave-Adresse des Wechselrichters +#define MODBUS_BAUD 115200 +#define MODBUS_ADDR 1 // ============================================================ // GERÄT // ============================================================ -#define DEVICE_ID "growatt_shinelan" -#define DEVICE_NAME "Growatt ShineLAN-X" -#define DEVICE_MODEL "ShineLAN-X" -#define DEVICE_MFR "Growatt" +#define MQTT_CLIENT "growatt-shinelan" +#define DEVICE_ID "growatt_shinelan" +#define DEVICE_NAME "Growatt ShineLAN-X" +#define DEVICE_MODEL "ShineLAN-X" +#define DEVICE_MFR "Growatt" -// Abfrageintervall in Millisekunden -#define UPDATE_INTERVAL 10000UL +// ============================================================ +// STANDARD-WERTE (erster Boot / Werkseinstellungen) +// Werden über die Web-UI überschrieben und in EEPROM gespeichert. +// ============================================================ +#define DEFAULT_DHCP true +#define DEFAULT_STATIC_IP 192,168,1,99 +#define DEFAULT_GW 192,168,1,1 +#define DEFAULT_SUBNET 255,255,255,0 +#define DEFAULT_DNS 192,168,1,1 +#define DEFAULT_MQTT_BROKER "192.168.1.1" +#define DEFAULT_MQTT_PORT 1883 +#define DEFAULT_MQTT_USER "" +#define DEFAULT_MQTT_PASS "" +#define DEFAULT_UPDATE_MS 10000UL + +// ============================================================ +// WECHSELRICHTER-MODELL +// Genau einen einkommentieren: +// ============================================================ +#define INVERTER_MIC1500 // Growatt MIC 1500/2000 TL-X (einphasig) +// #define INVERTER_SPH5000 // Growatt SPH 5000 TL3 (dreiphasig, Hybrid) diff --git a/ShineLAN-X/firmware/include/config_manager.h b/ShineLAN-X/firmware/include/config_manager.h new file mode 100644 index 0000000..4af36a3 --- /dev/null +++ b/ShineLAN-X/firmware/include/config_manager.h @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include "config.h" + +#define CFG_MAGIC 0xA55A1234 + +struct NetConfig { + uint32_t magic; + bool useDhcp; + uint8_t ip[4]; + uint8_t gw[4]; + uint8_t sn[4]; + uint8_t dns[4]; + char mqttBroker[40]; + uint16_t mqttPort; + char mqttUser[32]; + char mqttPass[64]; + uint32_t updateMs; +}; + +inline void configDefaults(NetConfig& c) { + c.magic = CFG_MAGIC; + c.useDhcp = DEFAULT_DHCP; + uint8_t dip[] = {DEFAULT_STATIC_IP}; + uint8_t dgw[] = {DEFAULT_GW}; + uint8_t dsn[] = {DEFAULT_SUBNET}; + uint8_t ddn[] = {DEFAULT_DNS}; + memcpy(c.ip, dip, 4); + memcpy(c.gw, dgw, 4); + memcpy(c.sn, dsn, 4); + memcpy(c.dns, ddn, 4); + strncpy(c.mqttBroker, DEFAULT_MQTT_BROKER, sizeof(c.mqttBroker) - 1); + c.mqttBroker[sizeof(c.mqttBroker) - 1] = 0; + c.mqttPort = DEFAULT_MQTT_PORT; + strncpy(c.mqttUser, DEFAULT_MQTT_USER, sizeof(c.mqttUser) - 1); + c.mqttUser[sizeof(c.mqttUser) - 1] = 0; + strncpy(c.mqttPass, DEFAULT_MQTT_PASS, sizeof(c.mqttPass) - 1); + c.mqttPass[sizeof(c.mqttPass) - 1] = 0; + c.updateMs = DEFAULT_UPDATE_MS; +} + +inline void configLoad(NetConfig& c) { + EEPROM.get(0, c); + if (c.magic != CFG_MAGIC) configDefaults(c); +} + +inline void configSave(const NetConfig& c) { + EEPROM.put(0, c); +} diff --git a/ShineLAN-X/firmware/platformio.ini b/ShineLAN-X/firmware/platformio.ini index 65669d2..0ed2786 100644 --- a/ShineLAN-X/firmware/platformio.ini +++ b/ShineLAN-X/firmware/platformio.ini @@ -13,4 +13,8 @@ lib_deps = build_flags = -DPIO_FRAMEWORK_ARDUINO_NOERRNO + -DUSBCON + -DUSBD_USE_CDC + -DHAL_PCD_MODULE_ENABLED + -DUSB_CDC_TRANSMIT_TIMEOUT=3000 -I include diff --git a/ShineLAN-X/firmware/src/main.cpp b/ShineLAN-X/firmware/src/main.cpp index c759fc4..fb94978 100644 --- a/ShineLAN-X/firmware/src/main.cpp +++ b/ShineLAN-X/firmware/src/main.cpp @@ -3,15 +3,81 @@ #include #include #include +#include #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); +} -// Debug-UART: USART1 TX=PA9, RX=PA10 HardwareSerial DebugSerial(PA10, PA9); -// Modbus über USB-CDC (SerialUSB = PA11/PA12, Wechselrichter ist USB-Host) +// USBSerial::flush() wartet endlos wenn der Wechselrichter den IN-Endpoint nicht pollt. +// Dieser Wrapper ersetzt flush() durch eine No-Op. +class ModbusStream : public Stream { +public: + int available() override { return SerialUSB.available(); } + int read() override { return SerialUSB.read(); } + int peek() override { return SerialUSB.peek(); } + size_t write(uint8_t b) override { return SerialUSB.write(b); } + size_t write(const uint8_t* buf, size_t size) override { return SerialUSB.write(buf, size); } + void flush() override {} + using Print::write; +} modbusSerial; // ============================================================ -// Sensor-Definition +// Sensor-Definitionen // ============================================================ struct Sensor { const char* id; @@ -25,17 +91,27 @@ struct Sensor { 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[] = { - // --- PV Eingang --- {"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 Ausgang / Netz --- - // HINWEIS: MIC 1500 TL-X hat AC Output Power auf Register 40 (nicht 35)! - // SPH 5000 TL3: Register 35 — für MIC 1500 auf 40 ändern {"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"}, @@ -44,37 +120,299 @@ const Sensor SENSORS[] = { {"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"}, - // --- Energie PV --- {"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"}, - // --- Temperatur --- {"inverter_temp", "Inverter Temperature", 93, false, 0.1f, "°C", "temperature", "measurement", "mdi:thermometer"}, - // --- Batterie --- {"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"}, - // --- Energiezähler --- + {"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 // ============================================================ -byte mac[] = {MAC_ADDRESS}; +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; // ============================================================ -// LED-Hilfsfunktionen +// Web-Server // ============================================================ -void ledSet(uint8_t pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); } // aktiv LOW + +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" + "" + "" + "" + "ShineLAN-X" + ); + + // Header + cl.print("

☀ ShineLAN-X

" + "
Growatt Modbus → MQTT Bridge
" + "
Verbunden" : "off'>Getrennt"); + cl.print("
IP: "); + cl.print(Ethernet.localIP()); + cl.print("
"); + + // Statistik + cl.print("
" + "
"); + cl.print(g_okTotal); + cl.print("
OK
" + "
"); + cl.print(g_failTotal); + cl.print("
Fehler
" + "
0x"); + snprintf(buf, sizeof(buf), "%02X", g_lastErr); + cl.print(buf); + cl.print("
Letzter Err
"); + + // Formular — Netzwerk + cl.print("
" + "

Netzwerk

" + "
" + "
"); + + ipToStr(cfg.ip, buf); + cl.print("
" + "
"); + ipToStr(cfg.gw, buf); + cl.print("
" + "
"); + ipToStr(cfg.sn, buf); + cl.print("
" + "
"); + ipToStr(cfg.dns, buf); + cl.print("
" + "
"); + cl.print("
"); + + // Formular — MQTT + cl.print("

MQTT

" + "
" + "
" + "
" + "
" + "
" + "
" + "
" + "
" + "
" + "
"); + + // Buttons + Script + cl.print( + "" + "" + "
" + "" + ); +} + +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 @@ -85,11 +423,8 @@ void publishDiscovery() { 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); - + "homeassistant/sensor/%s_%s/config", DEVICE_ID, s.id); snprintf(payload, sizeof(payload), "{" "\"name\":\"%s\"," @@ -110,7 +445,6 @@ void publishDiscovery() { s.id, s.unit, s.deviceClass, s.stateClass, s.icon, DEVICE_ID, DEVICE_NAME, DEVICE_MODEL, DEVICE_MFR); - mqtt.publish(topic, payload, true); } } @@ -118,8 +452,8 @@ void publishDiscovery() { bool mqttReconnect() { DebugSerial.print("MQTT connecting... "); ledSet(LED_RED, true); - bool ok = (strlen(MQTT_USER) > 0) - ? mqtt.connect(MQTT_CLIENT, MQTT_USER, MQTT_PASSWORD) + bool ok = (strlen(cfg.mqttUser) > 0) + ? mqtt.connect(MQTT_CLIENT, cfg.mqttUser, cfg.mqttPass) : mqtt.connect(MQTT_CLIENT); if (ok) { DebugSerial.println("OK"); @@ -142,55 +476,55 @@ void setup() { DebugSerial.println("\r\n=== Growatt ShineLAN-X ==="); DebugSerial.println("Build: " __DATE__ " " __TIME__); - // LEDs initialisieren (aktiv LOW laut Referenz) + 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); - - // Startup-Blink: alle LEDs kurz an 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); - // ENC28J60 Reset - DebugSerial.println("ETH: reset..."); pinMode(ETH_RST_PIN, OUTPUT); digitalWrite(ETH_RST_PIN, LOW); delay(20); digitalWrite(ETH_RST_PIN, HIGH); delay(200); - Ethernet.init(ETH_CS_PIN); - DebugSerial.println("ETH: begin..."); -#if USE_DHCP - if (Ethernet.begin(mac) == 0) { - DebugSerial.println("ETH: DHCP failed, reboot"); - ledSet(LED_RED, true); - delay(3000); - NVIC_SystemReset(); + 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); } -#else - IPAddress ip(STATIC_IP); - IPAddress gw(STATIC_GW); - IPAddress sn(STATIC_SUBNET); - IPAddress dns(STATIC_DNS); - Ethernet.begin(mac, ip, dns, gw, sn); -#endif DebugSerial.print("ETH: IP="); DebugSerial.println(Ethernet.localIP()); - DebugSerial.print("ETH: link="); - DebugSerial.println(Ethernet.linkStatus() == LinkON ? "UP" : "DOWN"); - if (Ethernet.linkStatus() == LinkON) ledSet(LED_DEBUG, true); - // MQTT - mqtt.setServer(MQTT_BROKER, MQTT_PORT); + webServer.begin(); + DebugSerial.print("Web: http://"); + DebugSerial.println(Ethernet.localIP()); + + mqtt.setServer(cfg.mqttBroker, cfg.mqttPort); mqtt.setBufferSize(768); - // Modbus: USB-CDC (PA11/PA12) — wird aktiviert sobald Wechselrichter angeschlossen - // SerialUSB.begin(MODBUS_BAUD); - // modbus.begin(MODBUS_ADDR, SerialUSB); + SerialUSB.begin(MODBUS_BAUD); + delay(10000); + HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET); + modbus.begin(MODBUS_ADDR, modbusSerial); DebugSerial.println("Setup done."); } @@ -201,6 +535,8 @@ void setup() { unsigned long lastUpdate = 0; void loop() { + handleWebRequest(); + if (!mqtt.connected()) { if (!mqttReconnect()) { delay(5000); @@ -208,33 +544,66 @@ void loop() { } } mqtt.loop(); + handleWebRequest(); - if (millis() - lastUpdate < UPDATE_INTERVAL) return; + if (millis() - lastUpdate < cfg.updateMs) return; lastUpdate = millis(); ledSet(LED_BLUE, true); - char stateTopic[64]; - char valueStr[16]; + bool usbReady = (SerialUSB.availableForWrite() == 127); + uint8_t okCount = 0, failCount = 0; + uint8_t lastErr = 0; + uint16_t lastErrReg = 0; for (uint8_t i = 0; i < SENSOR_COUNT; i++) { + mqtt.loop(); + handleWebRequest(); + const Sensor& s = SENSORS[i]; - uint8_t result; + if (!usbReady) { + failCount++; lastErr = 0xFE; lastErrReg = s.address; + continue; + } + + uint8_t result; uint32_t raw = 0; - // TODO: Modbus aktivieren sobald USB-CDC (Wechselrichter) angeschlossen - result = ModbusMaster::ku8MBSuccess; // Platzhalter - raw = 0; - (void)result; + if (s.isDword) { + result = modbus.readInputRegisters(s.address, 2); + if (result == ModbusMaster::ku8MBSuccess) + raw = ((uint32_t)modbus.getResponseBuffer(0) << 16) | modbus.getResponseBuffer(1); + } else { + result = modbus.readInputRegisters(s.address, 1); + if (result == ModbusMaster::ku8MBSuccess) + raw = modbus.getResponseBuffer(0); + } + if (result != ModbusMaster::ku8MBSuccess) { + failCount++; lastErr = result; lastErrReg = s.address; + continue; + } + + okCount++; float value = raw * s.scale; + char valueStr[16]; dtostrf(value, 1, (s.scale < 0.1f) ? 2 : 1, valueStr); + char stateTopic[64]; snprintf(stateTopic, sizeof(stateTopic), "growatt/shinelan/%s", s.id); mqtt.publish(stateTopic, valueStr); } + g_okTotal += okCount; + g_failTotal += failCount; + if (lastErr) g_lastErr = lastErr; + + char dbg[64]; + snprintf(dbg, sizeof(dbg), "ok=%u fail=%u err=0x%02X@%u av=%d rx=%d", + okCount, failCount, lastErr, lastErrReg, + SerialUSB.availableForWrite(), SerialUSB.available()); + mqtt.publish("growatt/shinelan/debug", dbg); + ledSet(LED_BLUE, false); - DebugSerial.println("Update done."); }