diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba32ed8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.log +*.txt +*.json +!haos-addon/**/*.json +shinelanx-modbus/ +__pycache__/ +*.pyc +.env diff --git a/ShineLAN-X/firmware/src/main.cpp b/ShineLAN-X/firmware/src/main.cpp index 6741c10..f865faa 100644 --- a/ShineLAN-X/firmware/src/main.cpp +++ b/ShineLAN-X/firmware/src/main.cpp @@ -63,16 +63,65 @@ extern "C" void USBD_reenumerate(void) { HardwareSerial DebugSerial(PA10, PA9); -// USBSerial::flush() wartet endlos wenn der Wechselrichter den IN-Endpoint nicht pollt. -// Dieser Wrapper ersetzt flush() durch eine No-Op. +// 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: - int available() override { return SerialUSB.available(); } - int read() override { return SerialUSB.read(); } + 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 { return SerialUSB.write(b); } - size_t write(const uint8_t* buf, size_t size) override { return SerialUSB.write(buf, size); } - void flush() override {} + 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; @@ -153,6 +202,7 @@ 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 @@ -524,6 +574,12 @@ void setup() { 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."); @@ -533,6 +589,7 @@ void setup() { // Loop // ============================================================ unsigned long lastUpdate = 0; +static bool g_usbInfoPublished = false; void loop() { handleWebRequest(); @@ -546,65 +603,110 @@ void loop() { 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); - uint8_t avStart = SerialUSB.availableForWrite(); - bool usbReady = (avStart >= 8); - uint8_t okCount = 0, failCount = 0; - uint8_t lastErr = 0; + uint32_t okCnt = 0, failCnt = 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]; + modbusSerial.reset(); - if (!usbReady) { - failCount++; lastErr = 0xFE; lastErrReg = s.address; + 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; } - uint8_t result; - uint32_t raw = 0; - + okCnt++; + float val; if (s.isDword) { - result = modbus.readInputRegisters(s.address, 2); - if (result == ModbusMaster::ku8MBSuccess) - raw = ((uint32_t)modbus.getResponseBuffer(0) << 16) | modbus.getResponseBuffer(1); + uint32_t raw = ((uint32_t)modbus.getResponseBuffer(0) << 16) + | modbus.getResponseBuffer(1); + val = raw * s.scale; } else { - result = modbus.readInputRegisters(s.address, 1); - if (result == ModbusMaster::ku8MBSuccess) - raw = modbus.getResponseBuffer(0); + val = modbus.getResponseBuffer(0) * s.scale; } - 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); + 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 += okCount; - g_failTotal += failCount; - if (lastErr) g_lastErr = lastErr; - - char dbg[64]; - snprintf(dbg, sizeof(dbg), "ok=%u fail=%u err=0x%02X@%u avS=%d avE=%d rx=%d", - okCount, failCount, lastErr, lastErrReg, - avStart, SerialUSB.availableForWrite(), SerialUSB.available()); - mqtt.publish("growatt/shinelan/debug", dbg); + g_okTotal += okCnt; + g_failTotal += failCnt; + g_lastErr = lastErr; ledSet(LED_BLUE, false); } diff --git a/ShineLAN-X/releases/nuttx-mbusd-shinelanx.bin b/ShineLAN-X/releases/nuttx-mbusd-shinelanx.bin new file mode 100755 index 0000000..8e69b46 Binary files /dev/null and b/ShineLAN-X/releases/nuttx-mbusd-shinelanx.bin differ diff --git a/haos-addon/Dockerfile b/haos-addon/Dockerfile new file mode 100644 index 0000000..868abdd --- /dev/null +++ b/haos-addon/Dockerfile @@ -0,0 +1,14 @@ +ARG BUILD_FROM +FROM ${BUILD_FROM} + +WORKDIR /app + +COPY src/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ . + +COPY run.sh /run.sh +RUN chmod +x /run.sh + +CMD ["/run.sh"] diff --git a/haos-addon/build.yaml b/haos-addon/build.yaml new file mode 100644 index 0000000..d51c826 --- /dev/null +++ b/haos-addon/build.yaml @@ -0,0 +1,7 @@ +build_from: + aarch64: ghcr.io/home-assistant/aarch64-base-python:3.11-alpine3.18 + amd64: ghcr.io/home-assistant/amd64-base-python:3.11-alpine3.18 + armv7: ghcr.io/home-assistant/armv7-base-python:3.11-alpine3.18 + armhf: ghcr.io/home-assistant/armhf-base-python:3.11-alpine3.18 + i386: ghcr.io/home-assistant/i386-base-python:3.11-alpine3.18 +squash: false diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml new file mode 100644 index 0000000..91c94c4 --- /dev/null +++ b/haos-addon/config.yaml @@ -0,0 +1,40 @@ +name: Growatt ShineLAN-X +version: "1.0.0" +slug: growatt-shinelan-x +description: Growatt Wechselrichter via ShineLAN-X (NuttX Modbus TCP) → MQTT Discovery + Web UI +url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS +arch: + - armhf + - armv7 + - aarch64 + - amd64 + - i386 +startup: application +boot: auto +init: false +ingress: true +ingress_port: 8099 +panel_icon: mdi:solar-power +panel_title: ShineLAN-X +options: + modbus_ip: "10.10.20.190" + modbus_port: 502 + modbus_address: 1 + inverter_model: "MIC_1500_TL_X" + mqtt_broker: "core-mosquitto" + mqtt_port: 1883 + mqtt_user: "" + mqtt_pass: "" + mqtt_topic_prefix: "growatt/shinelanx" + update_interval: 30 +schema: + modbus_ip: str + modbus_port: int + modbus_address: int + inverter_model: "MIC_1500_TL_X|MIC_2000_TL_X|SPH_5000_TL3|MOD_6000_TL3" + mqtt_broker: str + mqtt_port: int + mqtt_user: str + mqtt_pass: str + mqtt_topic_prefix: str + update_interval: int diff --git a/haos-addon/run.sh b/haos-addon/run.sh new file mode 100644 index 0000000..d373ea2 --- /dev/null +++ b/haos-addon/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/with-contenv bashio + +export MODBUS_IP=$(bashio::config 'modbus_ip') +export MODBUS_PORT=$(bashio::config 'modbus_port') +export MODBUS_ADDRESS=$(bashio::config 'modbus_address') +export INVERTER_MODEL=$(bashio::config 'inverter_model') +export MQTT_BROKER=$(bashio::config 'mqtt_broker') +export MQTT_PORT=$(bashio::config 'mqtt_port') +export MQTT_USER=$(bashio::config 'mqtt_user') +export MQTT_PASS=$(bashio::config 'mqtt_pass') +export MQTT_TOPIC_PREFIX=$(bashio::config 'mqtt_topic_prefix') +export UPDATE_INTERVAL=$(bashio::config 'update_interval') +export INGRESS_PORT=8099 + +bashio::log.info "Starte Growatt ShineLAN-X Add-on..." +bashio::log.info "Modbus: ${MODBUS_IP}:${MODBUS_PORT} Slave=${MODBUS_ADDRESS}" +bashio::log.info "Wechselrichter: ${INVERTER_MODEL}" +bashio::log.info "MQTT: ${MQTT_BROKER}:${MQTT_PORT}" + +exec python3 /app/main.py diff --git a/haos-addon/src/inverters.py b/haos-addon/src/inverters.py new file mode 100644 index 0000000..dde608a --- /dev/null +++ b/haos-addon/src/inverters.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class Sensor: + id: str + name: str + reg: int + count: int # 1 = uint16, 2 = uint32 (high word first) + scale: float + unit: str + device_class: Optional[str] + state_class: str + icon: str + + +@dataclass +class Inverter: + id: str + name: str + manufacturer: str + sensors: List[Sensor] + # Register ranges to batch-read as (start, length) tuples (FC04 input registers) + read_ranges: List[tuple] + + +def _mic_sensors() -> List[Sensor]: + return [ + Sensor("pv_voltage", "PV Spannung", 3, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"), + Sensor("pv_current", "PV Strom", 4, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"), + Sensor("pv_power", "PV Leistung", 5, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"), + Sensor("grid_frequency", "Netz-Frequenz", 37, 1, 0.01, "Hz", "frequency", "measurement", "mdi:sine-wave"), + Sensor("grid_voltage", "Netz-Spannung", 38, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"), + Sensor("grid_current", "Netz-Strom", 39, 1, 0.1, "A", "current", "measurement", "mdi:flash"), + Sensor("ac_power", "AC Ausgangsleistung", 40, 2, 0.1, "W", "power", "measurement", "mdi:flash"), + Sensor("energy_today", "Energie Heute", 53, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"), + Sensor("energy_total", "Energie Gesamt", 55, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"), + Sensor("inverter_temp", "Wechselrichter Temp.", 93, 1, 0.1, "°C", "temperature", "measurement", "mdi:thermometer"), + ] + + +def _sph_sensors() -> List[Sensor]: + return [ + Sensor("pv1_voltage", "PV1 Spannung", 3, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"), + Sensor("pv1_current", "PV1 Strom", 4, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"), + Sensor("pv1_power", "PV1 Leistung", 5, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"), + Sensor("pv2_voltage", "PV2 Spannung", 7, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"), + Sensor("pv2_current", "PV2 Strom", 8, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"), + Sensor("pv2_power", "PV2 Leistung", 9, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"), + Sensor("ac_power_total", "AC Gesamtleistung", 35, 2, 0.1, "W", "power", "measurement", "mdi:flash"), + Sensor("grid_frequency", "Netz-Frequenz", 37, 1, 0.01, "Hz", "frequency", "measurement", "mdi:sine-wave"), + Sensor("grid_voltage_l1", "Netz-Spannung L1", 38, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"), + Sensor("grid_current_l1", "Netz-Strom L1", 39, 1, 0.1, "A", "current", "measurement", "mdi:flash"), + Sensor("grid_voltage_l2", "Netz-Spannung L2", 42, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"), + Sensor("grid_current_l2", "Netz-Strom L2", 43, 1, 0.1, "A", "current", "measurement", "mdi:flash"), + Sensor("grid_voltage_l3", "Netz-Spannung L3", 46, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"), + Sensor("grid_current_l3", "Netz-Strom L3", 47, 1, 0.1, "A", "current", "measurement", "mdi:flash"), + Sensor("energy_today", "Energie Heute", 53, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"), + Sensor("energy_total", "Energie Gesamt", 55, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"), + Sensor("inverter_temp", "Wechselrichter Temp.", 93, 1, 0.1, "°C", "temperature", "measurement", "mdi:thermometer"), + Sensor("bat_discharge_power", "Batterie Entladeleistung", 1009, 2, 0.1, "W", "power", "measurement", "mdi:battery-minus"), + Sensor("bat_charge_power", "Batterie Ladeleistung", 1011, 2, 0.1, "W", "power", "measurement", "mdi:battery-plus"), + Sensor("bat_voltage", "Batterie Spannung", 1013, 1, 0.1, "V", "voltage", "measurement", "mdi:battery"), + Sensor("bat_soc", "Batterie Ladezustand", 1014, 1, 1.0, "%", "battery", "measurement", "mdi:battery"), + Sensor("bat_temperature", "Batterie Temp.", 1040, 1, 0.1, "°C", "temperature", "measurement", "mdi:thermometer"), + Sensor("power_to_grid", "Einspeisung", 1021, 2, 0.1, "W", "power", "measurement", "mdi:transmission-tower-export"), + Sensor("power_to_user", "Netzbezug", 1029, 2, 0.1, "W", "power", "measurement", "mdi:transmission-tower-import"), + Sensor("energy_import_total", "Netzbezug Gesamt", 1046, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:transmission-tower-import"), + Sensor("energy_export_total", "Einspeisung Gesamt", 1050, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:transmission-tower-export"), + Sensor("bat_discharge_total", "Batterie Entladung Ges.",1054, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:battery-minus"), + Sensor("bat_charge_total", "Batterie Ladung Ges.", 1058, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:battery-plus"), + ] + + +def _mod_sensors() -> List[Sensor]: + return [ + Sensor("pv1_voltage", "PV1 Spannung", 3, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"), + Sensor("pv1_current", "PV1 Strom", 4, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"), + Sensor("pv1_power", "PV1 Leistung", 5, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"), + Sensor("pv2_voltage", "PV2 Spannung", 7, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"), + Sensor("pv2_current", "PV2 Strom", 8, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"), + Sensor("pv2_power", "PV2 Leistung", 9, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"), + Sensor("ac_power_total", "AC Gesamtleistung", 35, 2, 0.1, "W", "power", "measurement", "mdi:flash"), + Sensor("grid_frequency", "Netz-Frequenz", 37, 1, 0.01, "Hz", "frequency", "measurement", "mdi:sine-wave"), + Sensor("grid_voltage_l1","Netz-Spannung L1", 38, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"), + Sensor("grid_current_l1","Netz-Strom L1", 39, 1, 0.1, "A", "current", "measurement", "mdi:flash"), + Sensor("grid_voltage_l2","Netz-Spannung L2", 42, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"), + Sensor("grid_current_l2","Netz-Strom L2", 43, 1, 0.1, "A", "current", "measurement", "mdi:flash"), + Sensor("grid_voltage_l3","Netz-Spannung L3", 46, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"), + Sensor("grid_current_l3","Netz-Strom L3", 47, 1, 0.1, "A", "current", "measurement", "mdi:flash"), + Sensor("energy_today", "Energie Heute", 53, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"), + Sensor("energy_total", "Energie Gesamt", 55, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"), + Sensor("inverter_temp", "Wechselrichter Temp.",93, 1, 0.1, "°C", "temperature","measurement", "mdi:thermometer"), + ] + + +INVERTERS = { + "MIC_1500_TL_X": Inverter( + id="MIC_1500_TL_X", + name="Growatt MIC 1500 TL-X", + manufacturer="Growatt", + sensors=_mic_sensors(), + read_ranges=[(3, 91), (93, 1)], # regs 3-93 + ), + "MIC_2000_TL_X": Inverter( + id="MIC_2000_TL_X", + name="Growatt MIC 2000 TL-X", + manufacturer="Growatt", + sensors=_mic_sensors(), + read_ranges=[(3, 91), (93, 1)], + ), + "SPH_5000_TL3": Inverter( + id="SPH_5000_TL3", + name="Growatt SPH 5000 TL3-BH-UP", + manufacturer="Growatt", + sensors=_sph_sensors(), + read_ranges=[(3, 91), (93, 1), (1009, 52)], # regs 3-93 + 1009-1060 + ), + "MOD_6000_TL3": Inverter( + id="MOD_6000_TL3", + name="Growatt MOD 6000 TL3-XH", + manufacturer="Growatt", + sensors=_mod_sensors(), + read_ranges=[(3, 91), (93, 1)], + ), +} diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py new file mode 100644 index 0000000..42b583e --- /dev/null +++ b/haos-addon/src/main.py @@ -0,0 +1,221 @@ +import json +import logging +import os +import threading +import time +from typing import Any, Dict, Optional + +from flask import Flask, jsonify, request, send_from_directory + +from inverters import INVERTERS, Inverter +from modbus_client import ModbusReader +from mqtt_publisher import MqttPublisher + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +log = logging.getLogger(__name__) + +CONFIG_PATH = "/data/config.json" +WEB_DIR = os.path.join(os.path.dirname(__file__), "web") + +app = Flask(__name__, static_folder=WEB_DIR) + + +class State: + lock = threading.Lock() + config: Dict[str, Any] = {} + last_values: Dict[str, float] = {} + last_update: Optional[float] = None + modbus_ok: bool = False + mqtt_ok: bool = False + poll_count: int = 0 + error_count: int = 0 + + +def load_config() -> Dict[str, Any]: + cfg: Dict[str, Any] = { + "modbus_ip": os.environ.get("MODBUS_IP", "10.10.20.190"), + "modbus_port": int(os.environ.get("MODBUS_PORT", "502")), + "modbus_address": int(os.environ.get("MODBUS_ADDRESS", "1")), + "inverter_model": os.environ.get("INVERTER_MODEL", "MIC_1500_TL_X"), + "mqtt_broker": os.environ.get("MQTT_BROKER", "core-mosquitto"), + "mqtt_port": int(os.environ.get("MQTT_PORT", "1883")), + "mqtt_user": os.environ.get("MQTT_USER", ""), + "mqtt_pass": os.environ.get("MQTT_PASS", ""), + "mqtt_topic_prefix": os.environ.get("MQTT_TOPIC_PREFIX", "growatt/shinelanx"), + "update_interval": int(os.environ.get("UPDATE_INTERVAL", "30")), + } + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH) as f: + saved = json.load(f) + cfg.update(saved) + except Exception as e: + log.warning("Config-Datei konnte nicht gelesen werden: %s", e) + return cfg + + +def save_config(cfg: Dict[str, Any]): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump(cfg, f, indent=2) + + +_reader: Optional[ModbusReader] = None +_publisher: Optional[MqttPublisher] = None +_poll_thread: Optional[threading.Thread] = None +_stop_event = threading.Event() + + +def _build_reader(cfg: Dict[str, Any]) -> ModbusReader: + return ModbusReader( + host=cfg["modbus_ip"], + port=cfg["modbus_port"], + slave=cfg["modbus_address"], + ) + + +def _build_publisher(cfg: Dict[str, Any]) -> MqttPublisher: + return MqttPublisher( + broker=cfg["mqtt_broker"], + port=cfg["mqtt_port"], + user=cfg["mqtt_user"], + password=cfg["mqtt_pass"], + topic_prefix=cfg["mqtt_topic_prefix"], + ) + + +def _poll_loop(cfg: Dict[str, Any]): + global _reader, _publisher + + inverter_id = cfg.get("inverter_model", "MIC_1500_TL_X") + inverter: Inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"]) + interval = max(5, int(cfg.get("update_interval", 30))) + + _reader = _build_reader(cfg) + _publisher = _build_publisher(cfg) + _publisher.connect() + time.sleep(2) + _publisher.setup_inverter(inverter) + + log.info("Poll-Loop gestartet: %s alle %ds", inverter.name, interval) + + while not _stop_event.is_set(): + t_start = time.time() + values = _reader.read(inverter) + with State.lock: + if values is not None: + State.last_values = values + State.last_update = time.time() + State.modbus_ok = True + State.poll_count += 1 + _publisher.publish_data(values) + _publisher.publish_status("online") + else: + State.modbus_ok = False + State.error_count += 1 + _publisher.publish_status("offline") + State.mqtt_ok = _publisher.connected + + elapsed = time.time() - t_start + wait = max(0.0, interval - elapsed) + _stop_event.wait(wait) + + _reader.close() + _publisher.publish_status("offline") + _publisher.disconnect() + log.info("Poll-Loop beendet") + + +def start_poll_thread(cfg: Dict[str, Any]): + global _poll_thread + _stop_event.clear() + _poll_thread = threading.Thread(target=_poll_loop, args=(cfg,), daemon=True, name="poll") + _poll_thread.start() + + +def stop_poll_thread(): + _stop_event.set() + if _poll_thread: + _poll_thread.join(timeout=15) + + +# ── REST API ────────────────────────────────────────────────── + +@app.get("/api/config") +def api_get_config(): + cfg = State.config.copy() + cfg.pop("mqtt_pass", None) # Passwort nie zurückgeben + return jsonify(cfg) + + +@app.post("/api/config") +def api_save_config(): + data = request.get_json(force=True) or {} + with State.lock: + # Passwort nur überschreiben wenn mitgesendet und nicht leer + if not data.get("mqtt_pass"): + data["mqtt_pass"] = State.config.get("mqtt_pass", "") + State.config.update(data) + save_config(State.config) + cfg_snapshot = State.config.copy() + + stop_poll_thread() + start_poll_thread(cfg_snapshot) + return jsonify({"ok": True}) + + +@app.get("/api/data") +def api_get_data(): + with State.lock: + inverter_id = State.config.get("inverter_model", "MIC_1500_TL_X") + inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"]) + sensors_meta = [ + { + "id": s.id, + "name": s.name, + "unit": s.unit, + "icon": s.icon, + "device_class": s.device_class, + } + for s in inverter.sensors + ] + return jsonify({ + "values": State.last_values, + "sensors": sensors_meta, + "last_update": State.last_update, + "modbus_ok": State.modbus_ok, + "mqtt_ok": State.mqtt_ok, + "poll_count": State.poll_count, + "error_count": State.error_count, + }) + + +@app.get("/api/inverters") +def api_get_inverters(): + return jsonify({ + k: {"id": v.id, "name": v.name, "sensor_count": len(v.sensors)} + for k, v in INVERTERS.items() + }) + + +@app.get("/") +def index(): + return send_from_directory(WEB_DIR, "index.html") + + +@app.get("/") +def static_files(filename): + return send_from_directory(WEB_DIR, filename) + + +if __name__ == "__main__": + cfg = load_config() + with State.lock: + State.config = cfg + start_poll_thread(cfg) + port = int(os.environ.get("INGRESS_PORT", "8099")) + log.info("Web UI startet auf Port %d", port) + app.run(host="0.0.0.0", port=port, threaded=True) diff --git a/haos-addon/src/modbus_client.py b/haos-addon/src/modbus_client.py new file mode 100644 index 0000000..857c1cf --- /dev/null +++ b/haos-addon/src/modbus_client.py @@ -0,0 +1,77 @@ +import logging +import time +from typing import Dict, Optional + +from pymodbus.client import ModbusTcpClient +from pymodbus.exceptions import ModbusException + +from inverters import Inverter, Sensor + +log = logging.getLogger(__name__) + + +class ModbusReader: + def __init__(self, host: str, port: int, slave: int, timeout: float = 10.0): + self.host = host + self.port = port + self.slave = slave + self.timeout = timeout + self._client: Optional[ModbusTcpClient] = None + + def _connect(self) -> bool: + if self._client and self._client.connected: + return True + self._client = ModbusTcpClient(self.host, port=self.port, timeout=self.timeout) + if not self._client.connect(): + log.error("Modbus TCP Verbindung fehlgeschlagen: %s:%d", self.host, self.port) + self._client = None + return False + log.info("Modbus TCP verbunden: %s:%d", self.host, self.port) + return True + + def _disconnect(self): + if self._client: + self._client.close() + self._client = None + + def read(self, inverter: Inverter) -> Optional[Dict[str, float]]: + if not self._connect(): + return None + + # Batch-read aller Register-Bereiche + reg_cache: Dict[int, int] = {} + for start, length in inverter.read_ranges: + try: + result = self._client.read_input_registers(start, length, slave=self.slave) + if result.isError(): + log.error("FC04 Fehler bei Reg %d+%d: %s", start, length, result) + self._disconnect() + return None + for i, val in enumerate(result.registers): + reg_cache[start + i] = val + except ModbusException as e: + log.error("Modbus Ausnahme: %s", e) + self._disconnect() + return None + + return _extract_sensors(inverter.sensors, reg_cache) + + def close(self): + self._disconnect() + + +def _extract_sensors(sensors: list, regs: Dict[int, int]) -> Dict[str, float]: + values: Dict[str, float] = {} + for s in sensors: + if s.reg not in regs: + log.warning("Register %d fehlt in Antwort (%s)", s.reg, s.id) + continue + if s.count == 2: + if s.reg + 1 not in regs: + log.warning("Register %d (high word) fehlt (%s)", s.reg + 1, s.id) + continue + raw = (regs[s.reg] << 16) | regs[s.reg + 1] + else: + raw = regs[s.reg] + values[s.id] = round(raw * s.scale, 3) + return values diff --git a/haos-addon/src/mqtt_publisher.py b/haos-addon/src/mqtt_publisher.py new file mode 100644 index 0000000..23839fc --- /dev/null +++ b/haos-addon/src/mqtt_publisher.py @@ -0,0 +1,94 @@ +import json +import logging +import time +from typing import Dict, Optional + +import paho.mqtt.client as mqtt + +from inverters import Inverter + +log = logging.getLogger(__name__) + +DEVICE_ID = "growatt_shinelanx" + + +class MqttPublisher: + def __init__(self, broker: str, port: int, user: str, password: str, topic_prefix: str): + self.topic_prefix = topic_prefix.rstrip("/") + self._client = mqtt.Client(client_id=DEVICE_ID, clean_session=True) + if user: + self._client.username_pw_set(user, password) + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._broker = broker + self._port = port + self._connected = False + self._inverter: Optional[Inverter] = None + + def _on_connect(self, client, userdata, flags, rc): + if rc == 0: + self._connected = True + log.info("MQTT verbunden: %s:%d", self._broker, self._port) + if self._inverter: + self._publish_discovery(self._inverter) + else: + log.error("MQTT Verbindungsfehler rc=%d", rc) + + def _on_disconnect(self, client, userdata, rc): + self._connected = False + log.warning("MQTT getrennt rc=%d", rc) + + def connect(self): + try: + self._client.connect_async(self._broker, self._port, keepalive=60) + self._client.loop_start() + except Exception as e: + log.error("MQTT connect fehlgeschlagen: %s", e) + + def disconnect(self): + self._client.loop_stop() + self._client.disconnect() + + @property + def connected(self) -> bool: + return self._connected + + def setup_inverter(self, inverter: Inverter): + self._inverter = inverter + if self._connected: + self._publish_discovery(inverter) + + def _publish_discovery(self, inverter: Inverter): + device_payload = { + "identifiers": [DEVICE_ID], + "name": "Growatt ShineLAN-X", + "manufacturer": inverter.manufacturer, + "model": inverter.name, + } + for sensor in inverter.sensors: + config = { + "name": sensor.name, + "unique_id": f"{DEVICE_ID}_{sensor.id}", + "state_topic": f"{self.topic_prefix}/state", + "value_template": f"{{{{ value_json.{sensor.id} }}}}", + "unit_of_measurement": sensor.unit, + "state_class": sensor.state_class, + "icon": sensor.icon, + "device": device_payload, + } + if sensor.device_class: + config["device_class"] = sensor.device_class + + topic = f"homeassistant/sensor/{DEVICE_ID}/{sensor.id}/config" + self._client.publish(topic, json.dumps(config), retain=True, qos=1) + log.info("MQTT Discovery für %d Sensoren veröffentlicht", len(inverter.sensors)) + + def publish_data(self, values: Dict[str, float]): + if not self._connected: + log.warning("MQTT nicht verbunden, Daten verworfen") + return + payload = json.dumps(values) + self._client.publish(f"{self.topic_prefix}/state", payload, retain=True, qos=0) + + def publish_status(self, status: str): + self._client.publish(f"{self.topic_prefix}/status", status, retain=True, qos=1) diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html new file mode 100644 index 0000000..4f10bc5 --- /dev/null +++ b/haos-addon/src/web/index.html @@ -0,0 +1,570 @@ + + + + + +Growatt ShineLAN-X + + + + +
+ + + + +
+

Growatt ShineLAN-X

+
Lade...
+
+
+
Modbus
+
MQTT
+
+
+ +
+
+
Live-Daten
+
Konfiguration
+
+ + +
+
+
+
+ + + + +

Warte auf erste Messung...

+
+
+
+ + +
+
+
+

Wechselrichter-Modell

+
+ +
+ +
+
+

Modbus TCP

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

MQTT

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+ + + +