Files
Shinebridge/ShineLAN-X/firmware/src/main.cpp
T
retr0 0d6b860664 HAOS Add-on: MVP + NuttX Binary + .gitignore
- 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>
2026-04-24 22:30:45 +02:00

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>&#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
// ============================================================
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);
}