ShineLAN-X: Web-Konfigurations-UI + EEPROM-Config

- 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 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-20 14:29:11 +02:00
parent 589ff8166d
commit d4e569bce6
5 changed files with 547 additions and 113 deletions
+20
View File
@@ -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
+46 -55
View File
@@ -1,71 +1,62 @@
#pragma once #pragma once
// ============================================================ // ============================================================
// Pin-Belegung — Quelle: https://github.com/mwalle/shinelanx-modbus // Pin-Belegung — STM32F103RBT6, LQFP-64
// Gleiche Platine, verifizierte Pins // Quelle: https://github.com/mwalle/shinelanx-modbus
// STM32F103RBT6, LQFP-64
// ============================================================ // ============================================================
// ENC28J60 — SPI2 (Hardware-SPI) // ENC28J60 — SPI2 (Hardware-SPI)
// LQFP-64: PB12=33, PB13=34, PB14=35, PB15=36, PC6=37, PC8=39 #define ETH_CS_PIN PB12
#define ETH_CS_PIN PB12 // ENC28J60 /CS (SPI2 NSS) #define ETH_SCK_PIN PB13
#define ETH_SCK_PIN PB13 // ENC28J60 SCK (SPI2 SCK) #define ETH_MISO_PIN PB14
#define ETH_MISO_PIN PB14 // ENC28J60 SO (SPI2 MISO) #define ETH_MOSI_PIN PB15
#define ETH_MOSI_PIN PB15 // ENC28J60 SI (SPI2 MOSI) #define ETH_RST_PIN PC8
#define ETH_RST_PIN PC8 // ENC28J60 /RESET
#define ETH_INT_PIN PC6 // ENC28J60 INT# (optional, polling reicht)
// LEDs // LEDs (aktiv LOW)
#define LED_DEBUG PC7 // Debug-LED (grün o.ä.) #define LED_DEBUG PC7
#define LED_RED PB1 // RGB Rot #define LED_RED PB1
#define LED_GREEN PB0 // RGB Grün #define LED_GREEN PB0
#define LED_BLUE PC5 // RGB Blau #define LED_BLUE PC5
// Taster // User-Taster
#define BTN_USER PA3 // User-Taster (low-aktiv) #define BTN_USER PA3
// MAC-Adresse
#define MAC_ADDRESS 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED
// ============================================================ // ============================================================
// NETZWERK // MODBUS
// ============================================================ // ============================================================
#define MODBUS_BAUD 115200
// 0 = DHCP, 1 = Statische IP #define MODBUS_ADDR 1
#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
// ============================================================ // ============================================================
// GERÄT // GERÄT
// ============================================================ // ============================================================
#define DEVICE_ID "growatt_shinelan" #define MQTT_CLIENT "growatt-shinelan"
#define DEVICE_NAME "Growatt ShineLAN-X" #define DEVICE_ID "growatt_shinelan"
#define DEVICE_MODEL "ShineLAN-X" #define DEVICE_NAME "Growatt ShineLAN-X"
#define DEVICE_MFR "Growatt" #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)
@@ -0,0 +1,50 @@
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#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);
}
+4
View File
@@ -13,4 +13,8 @@ lib_deps =
build_flags = build_flags =
-DPIO_FRAMEWORK_ARDUINO_NOERRNO -DPIO_FRAMEWORK_ARDUINO_NOERRNO
-DUSBCON
-DUSBD_USE_CDC
-DHAL_PCD_MODULE_ENABLED
-DUSB_CDC_TRANSMIT_TIMEOUT=3000
-I include -I include
+427 -58
View File
@@ -3,15 +3,81 @@
#include <EthernetENC.h> #include <EthernetENC.h>
#include <PubSubClient.h> #include <PubSubClient.h>
#include <ModbusMaster.h> #include <ModbusMaster.h>
#include <EEPROM.h>
#include "config.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);
}
// Debug-UART: USART1 TX=PA9, RX=PA10
HardwareSerial DebugSerial(PA10, PA9); 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 { struct Sensor {
const char* id; const char* id;
@@ -25,17 +91,27 @@ struct Sensor {
const char* icon; 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[] = { const Sensor SENSORS[] = {
// --- PV Eingang ---
{"pv1_voltage", "PV1 Voltage", 3, false, 0.1f, "V", "voltage", "measurement", "mdi:solar-panel"}, {"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_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"}, {"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_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_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"}, {"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"}, {"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_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_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_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_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"}, {"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_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"}, {"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"}, {"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_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_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_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_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"}, {"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_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"}, {"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_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"}, {"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]); const uint8_t SENSOR_COUNT = sizeof(SENSORS) / sizeof(SENSORS[0]);
// ============================================================ // ============================================================
// Globale Objekte // Globale Objekte
// ============================================================ // ============================================================
byte mac[] = {MAC_ADDRESS}; NetConfig cfg;
byte mac[] = {MAC_ADDRESS};
EthernetClient ethClient; EthernetClient ethClient;
PubSubClient mqtt(ethClient); PubSubClient mqtt(ethClient);
ModbusMaster modbus; 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"
"<!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>&#9728; ShineLAN-X</h1>"
"<div class='sub'>Growatt Modbus &#x2192; 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'>"
"&#128190; Speichern &amp; Neustart</button>"
"<button class='btn rst' type='button' "
"onclick='if(confirm(\"Wirklich auf Werkseinstellungen zur\\u00FCcksetzen?\"))"
"location=\"/reset\"'>&#8635; 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 // MQTT
@@ -85,11 +423,8 @@ void publishDiscovery() {
for (uint8_t i = 0; i < SENSOR_COUNT; i++) { for (uint8_t i = 0; i < SENSOR_COUNT; i++) {
const Sensor& s = SENSORS[i]; const Sensor& s = SENSORS[i];
snprintf(topic, sizeof(topic), snprintf(topic, sizeof(topic),
"homeassistant/sensor/%s_%s/config", "homeassistant/sensor/%s_%s/config", DEVICE_ID, s.id);
DEVICE_ID, s.id);
snprintf(payload, sizeof(payload), snprintf(payload, sizeof(payload),
"{" "{"
"\"name\":\"%s\"," "\"name\":\"%s\","
@@ -110,7 +445,6 @@ void publishDiscovery() {
s.id, s.id,
s.unit, s.deviceClass, s.stateClass, s.icon, s.unit, s.deviceClass, s.stateClass, s.icon,
DEVICE_ID, DEVICE_NAME, DEVICE_MODEL, DEVICE_MFR); DEVICE_ID, DEVICE_NAME, DEVICE_MODEL, DEVICE_MFR);
mqtt.publish(topic, payload, true); mqtt.publish(topic, payload, true);
} }
} }
@@ -118,8 +452,8 @@ void publishDiscovery() {
bool mqttReconnect() { bool mqttReconnect() {
DebugSerial.print("MQTT connecting... "); DebugSerial.print("MQTT connecting... ");
ledSet(LED_RED, true); ledSet(LED_RED, true);
bool ok = (strlen(MQTT_USER) > 0) bool ok = (strlen(cfg.mqttUser) > 0)
? mqtt.connect(MQTT_CLIENT, MQTT_USER, MQTT_PASSWORD) ? mqtt.connect(MQTT_CLIENT, cfg.mqttUser, cfg.mqttPass)
: mqtt.connect(MQTT_CLIENT); : mqtt.connect(MQTT_CLIENT);
if (ok) { if (ok) {
DebugSerial.println("OK"); DebugSerial.println("OK");
@@ -142,55 +476,55 @@ void setup() {
DebugSerial.println("\r\n=== Growatt ShineLAN-X ==="); DebugSerial.println("\r\n=== Growatt ShineLAN-X ===");
DebugSerial.println("Build: " __DATE__ " " __TIME__); DebugSerial.println("Build: " __DATE__ " " __TIME__);
// LEDs initialisieren (aktiv LOW laut Referenz) configLoad(cfg);
pinMode(LED_DEBUG, OUTPUT); ledSet(LED_DEBUG, false); pinMode(LED_DEBUG, OUTPUT); ledSet(LED_DEBUG, false);
pinMode(LED_RED, OUTPUT); ledSet(LED_RED, false); pinMode(LED_RED, OUTPUT); ledSet(LED_RED, false);
pinMode(LED_GREEN, OUTPUT); ledSet(LED_GREEN, false); pinMode(LED_GREEN, OUTPUT); ledSet(LED_GREEN, false);
pinMode(LED_BLUE, OUTPUT); ledSet(LED_BLUE, 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); ledSet(LED_RED, true); ledSet(LED_GREEN, true); ledSet(LED_BLUE, true);
delay(300); delay(300);
ledSet(LED_RED, false); ledSet(LED_GREEN, false); ledSet(LED_BLUE, false); ledSet(LED_RED, false); ledSet(LED_GREEN, false); ledSet(LED_BLUE, false);
// ENC28J60 Reset
DebugSerial.println("ETH: reset...");
pinMode(ETH_RST_PIN, OUTPUT); pinMode(ETH_RST_PIN, OUTPUT);
digitalWrite(ETH_RST_PIN, LOW); delay(20); digitalWrite(ETH_RST_PIN, LOW); delay(20);
digitalWrite(ETH_RST_PIN, HIGH); delay(200); digitalWrite(ETH_RST_PIN, HIGH); delay(200);
Ethernet.init(ETH_CS_PIN); Ethernet.init(ETH_CS_PIN);
DebugSerial.println("ETH: begin...");
#if USE_DHCP if (cfg.useDhcp) {
if (Ethernet.begin(mac) == 0) { DebugSerial.println("ETH: DHCP...");
DebugSerial.println("ETH: DHCP failed, reboot"); if (Ethernet.begin(mac) == 0) {
ledSet(LED_RED, true); // DHCP fehlgeschlagen: Fallback auf gespeicherte statische IP
delay(3000); DebugSerial.println("ETH: DHCP failed, static fallback");
NVIC_SystemReset(); 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.print("ETH: IP=");
DebugSerial.println(Ethernet.localIP()); DebugSerial.println(Ethernet.localIP());
DebugSerial.print("ETH: link=");
DebugSerial.println(Ethernet.linkStatus() == LinkON ? "UP" : "DOWN");
if (Ethernet.linkStatus() == LinkON) ledSet(LED_DEBUG, true); if (Ethernet.linkStatus() == LinkON) ledSet(LED_DEBUG, true);
// MQTT webServer.begin();
mqtt.setServer(MQTT_BROKER, MQTT_PORT); DebugSerial.print("Web: http://");
DebugSerial.println(Ethernet.localIP());
mqtt.setServer(cfg.mqttBroker, cfg.mqttPort);
mqtt.setBufferSize(768); mqtt.setBufferSize(768);
// Modbus: USB-CDC (PA11/PA12) — wird aktiviert sobald Wechselrichter angeschlossen SerialUSB.begin(MODBUS_BAUD);
// SerialUSB.begin(MODBUS_BAUD); delay(10000);
// modbus.begin(MODBUS_ADDR, SerialUSB); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET);
modbus.begin(MODBUS_ADDR, modbusSerial);
DebugSerial.println("Setup done."); DebugSerial.println("Setup done.");
} }
@@ -201,6 +535,8 @@ void setup() {
unsigned long lastUpdate = 0; unsigned long lastUpdate = 0;
void loop() { void loop() {
handleWebRequest();
if (!mqtt.connected()) { if (!mqtt.connected()) {
if (!mqttReconnect()) { if (!mqttReconnect()) {
delay(5000); delay(5000);
@@ -208,33 +544,66 @@ void loop() {
} }
} }
mqtt.loop(); mqtt.loop();
handleWebRequest();
if (millis() - lastUpdate < UPDATE_INTERVAL) return; if (millis() - lastUpdate < cfg.updateMs) return;
lastUpdate = millis(); lastUpdate = millis();
ledSet(LED_BLUE, true); ledSet(LED_BLUE, true);
char stateTopic[64]; bool usbReady = (SerialUSB.availableForWrite() == 127);
char valueStr[16]; uint8_t okCount = 0, failCount = 0;
uint8_t lastErr = 0;
uint16_t lastErrReg = 0;
for (uint8_t i = 0; i < SENSOR_COUNT; i++) { for (uint8_t i = 0; i < SENSOR_COUNT; i++) {
mqtt.loop();
handleWebRequest();
const Sensor& s = SENSORS[i]; const Sensor& s = SENSORS[i];
uint8_t result; if (!usbReady) {
failCount++; lastErr = 0xFE; lastErrReg = s.address;
continue;
}
uint8_t result;
uint32_t raw = 0; uint32_t raw = 0;
// TODO: Modbus aktivieren sobald USB-CDC (Wechselrichter) angeschlossen if (s.isDword) {
result = ModbusMaster::ku8MBSuccess; // Platzhalter result = modbus.readInputRegisters(s.address, 2);
raw = 0; if (result == ModbusMaster::ku8MBSuccess)
(void)result; 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; float value = raw * s.scale;
char valueStr[16];
dtostrf(value, 1, (s.scale < 0.1f) ? 2 : 1, valueStr); dtostrf(value, 1, (s.scale < 0.1f) ? 2 : 1, valueStr);
char stateTopic[64];
snprintf(stateTopic, sizeof(stateTopic), "growatt/shinelan/%s", s.id); snprintf(stateTopic, sizeof(stateTopic), "growatt/shinelan/%s", s.id);
mqtt.publish(stateTopic, valueStr); 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); ledSet(LED_BLUE, false);
DebugSerial.println("Update done.");
} }