From bc92db6c188e0dc9fa30fe3861fe1f95415f68ec Mon Sep 17 00:00:00 2001 From: retr0 <42kdesigners@gmail.com> Date: Tue, 28 Apr 2026 10:36:51 +0200 Subject: [PATCH] Feature: Goodwe GW10KN-ET Support via goodwe UDP/8899 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - goodwe_client.py: GoodweReader wraps goodwe library (asyncio → sync) - inverters.py: GW10KN_ET mit 37 ET-Sensoren, protocol=goodwe_udp - main.py: poll-loop verzweigt auf GoodweReader bei goodwe_udp-Geräten; AGG_SENSOR_IDS um Goodwe-Keys erweitert (ppv, soc, e_total, e_total_imp/exp, …) - requirements.txt: goodwe==0.4.10 hinzugefügt Goodwe-Stick (WIFILAN_2.0 v2.4.41) hat eFuse-gesperrten ROM-Download; Kommunikation erfolgt über WiFi-Stick-IP + UDP-Port 8899. Co-Authored-By: Claude Sonnet 4.6 --- haos-addon/src/goodwe_client.py | 50 +++++++++++++++++++++++++++ haos-addon/src/inverters.py | 60 ++++++++++++++++++++++++++++++++- haos-addon/src/main.py | 52 ++++++++++++++++------------ haos-addon/src/requirements.txt | 4 +++ 4 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 haos-addon/src/goodwe_client.py create mode 100644 haos-addon/src/requirements.txt diff --git a/haos-addon/src/goodwe_client.py b/haos-addon/src/goodwe_client.py new file mode 100644 index 0000000..a4c1690 --- /dev/null +++ b/haos-addon/src/goodwe_client.py @@ -0,0 +1,50 @@ +import asyncio +import logging +from typing import Dict, Optional + +import goodwe + +log = logging.getLogger(__name__) + +GOODWE_PORT = 8899 + + +class GoodweReader: + def __init__(self, host: str, family: str = "ET"): + self.host = host + self.family = family + + def read(self) -> Optional[Dict[str, float]]: + try: + return asyncio.run(self._read_async()) + except Exception as e: + log.error("[Goodwe %s] Lesefehler: %s", self.host, e) + return None + + async def _read_async(self) -> Optional[Dict[str, float]]: + try: + inv = await goodwe.connect( + self.host, GOODWE_PORT, + family=self.family, + timeout=5, + retries=3, + ) + raw = await inv.read_runtime_data() + except Exception as e: + log.warning("[Goodwe %s] Verbindungsfehler: %s", self.host, e) + return None + + result: Dict[str, float] = {} + for k, v in raw.items(): + if isinstance(v, (int, float)) and not isinstance(v, bool): + result[k] = float(round(v, 3)) + + # pbattery1 ist vorzeichenbehaftet: + = Laden, - = Entladen + bat = result.get("pbattery1", 0.0) + result["bat_charge_power"] = round(max(0.0, bat), 3) + result["bat_discharge_power"] = round(max(0.0, -bat), 3) + + return result + + def close(self): + pass diff --git a/haos-addon/src/inverters.py b/haos-addon/src/inverters.py index 952f4b9..411cf7a 100644 --- a/haos-addon/src/inverters.py +++ b/haos-addon/src/inverters.py @@ -24,6 +24,8 @@ class Inverter: sensors: List[Sensor] # Register ranges to batch-read as (start, length) tuples (FC04 input registers) read_ranges: List[tuple] + protocol: str = "modbus" # "modbus" | "goodwe_udp" + goodwe_family: str = "" # ET / ES / DT — nur für goodwe_udp def _mic_sensors() -> List[Sensor]: @@ -96,6 +98,53 @@ def _mod_sensors() -> List[Sensor]: ] +def _goodwe_et_sensors() -> List[Sensor]: + return [ + # PV + Sensor("vpv1", "PV1 Spannung", 0, 1, 1.0, "V", "voltage", "measurement", "mdi:solar-panel"), + Sensor("ipv1", "PV1 Strom", 0, 1, 1.0, "A", "current", "measurement", "mdi:solar-panel"), + Sensor("ppv1", "PV1 Leistung", 0, 1, 1.0, "W", "power", "measurement", "mdi:solar-panel"), + Sensor("vpv2", "PV2 Spannung", 0, 1, 1.0, "V", "voltage", "measurement", "mdi:solar-panel"), + Sensor("ipv2", "PV2 Strom", 0, 1, 1.0, "A", "current", "measurement", "mdi:solar-panel"), + Sensor("ppv2", "PV2 Leistung", 0, 1, 1.0, "W", "power", "measurement", "mdi:solar-panel"), + Sensor("ppv", "PV Gesamtleistung", 0, 1, 1.0, "W", "power", "measurement", "mdi:solar-power"), + # Grid + Sensor("vgrid", "Netz-Spannung L1", 0, 1, 1.0, "V", "voltage", "measurement", "mdi:flash"), + Sensor("igrid", "Netz-Strom L1", 0, 1, 1.0, "A", "current", "measurement", "mdi:flash"), + Sensor("fgrid", "Netz-Frequenz", 0, 1, 1.0, "Hz", "frequency", "measurement", "mdi:sine-wave"), + Sensor("pgrid", "Netz-Leistung L1", 0, 1, 1.0, "W", "power", "measurement", "mdi:flash"), + Sensor("vgrid2", "Netz-Spannung L2", 0, 1, 1.0, "V", "voltage", "measurement", "mdi:flash"), + Sensor("igrid2", "Netz-Strom L2", 0, 1, 1.0, "A", "current", "measurement", "mdi:flash"), + Sensor("pgrid2", "Netz-Leistung L2", 0, 1, 1.0, "W", "power", "measurement", "mdi:flash"), + Sensor("vgrid3", "Netz-Spannung L3", 0, 1, 1.0, "V", "voltage", "measurement", "mdi:flash"), + Sensor("igrid3", "Netz-Strom L3", 0, 1, 1.0, "A", "current", "measurement", "mdi:flash"), + Sensor("pgrid3", "Netz-Leistung L3", 0, 1, 1.0, "W", "power", "measurement", "mdi:flash"), + # Leistung + Sensor("total_inverter_power","Wechselrichter Gesamtleist.",0, 1, 1.0, "W", "power", "measurement", "mdi:flash"), + Sensor("active_power", "Wirkleistung (Grid)", 0, 1, 1.0, "W", "power", "measurement", "mdi:transmission-tower"), + Sensor("house_consumption", "Hausverbrauch", 0, 1, 1.0, "W", "power", "measurement", "mdi:home"), + # Batterie + Sensor("vbattery1", "Batterie Spannung", 0, 1, 1.0, "V", "voltage", "measurement", "mdi:battery"), + Sensor("ibattery1", "Batterie Strom", 0, 1, 1.0, "A", "current", "measurement", "mdi:battery"), + Sensor("pbattery1", "Batterie Leistung", 0, 1, 1.0, "W", "power", "measurement", "mdi:battery"), + Sensor("bat_charge_power", "Batterie Ladeleistung", 0, 1, 1.0, "W", "power", "measurement", "mdi:battery-plus"), + Sensor("bat_discharge_power", "Batterie Entladeleistung", 0, 1, 1.0, "W", "power", "measurement", "mdi:battery-minus"), + Sensor("soc", "Batterie Ladezustand", 0, 1, 1.0, "%", "battery", "measurement", "mdi:battery"), + Sensor("temperature", "Wechselrichter Temperatur", 0, 1, 1.0, "°C", "temperature", "measurement", "mdi:thermometer"), + # Energie + Sensor("e_day", "PV Energie Heute", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:solar-power"), + Sensor("e_total", "PV Energie Gesamt", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:solar-power"), + Sensor("e_day_exp", "Einspeisung Heute", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:transmission-tower-export"), + Sensor("e_total_exp", "Einspeisung Gesamt", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:transmission-tower-export"), + Sensor("e_day_imp", "Netzbezug Heute", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:transmission-tower-import"), + Sensor("e_total_imp", "Netzbezug Gesamt", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:transmission-tower-import"), + Sensor("e_bat_charge_day", "Batterie Ladung Heute", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:battery-plus"), + Sensor("e_bat_charge_total", "Batterie Ladung Gesamt", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:battery-plus"), + Sensor("e_bat_discharge_day", "Batterie Entladung Heute", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:battery-minus"), + Sensor("e_bat_discharge_total","Batterie Entladung Gesamt",0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:battery-minus"), + ] + + def _sdm630_sensors() -> List[Sensor]: f = "float32" return [ @@ -152,6 +201,15 @@ INVERTERS = { name="Eastron SDM-630", manufacturer="Eastron", sensors=_sdm630_sensors(), - read_ranges=[(0, 76)], # regs 0-75, alle 16 Sensoren + read_ranges=[(0, 76)], + ), + "GW10KN_ET": Inverter( + id="GW10KN_ET", + name="Goodwe GW10KN-ET", + manufacturer="Goodwe", + sensors=_goodwe_et_sensors(), + read_ranges=[], + protocol="goodwe_udp", + goodwe_family="ET", ), } diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index fdf1cba..882dd90 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -12,6 +12,7 @@ from flask import Flask, jsonify, request, send_from_directory from inverters import INVERTERS import history from modbus_client import ModbusReader +from goodwe_client import GoodweReader from mqtt_publisher import MqttPublisher logging.basicConfig( @@ -30,18 +31,18 @@ app = Flask(__name__, static_folder=WEB_DIR) # Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG) AGG_SENSOR_IDS: Dict[str, List[str]] = { - "total_pv_power": ["pv_power", "pv1_power", "pv2_power"], + "total_pv_power": ["pv_power", "pv1_power", "pv2_power", "ppv"], "total_ac_power": ["ac_power", "ac_power_total"], - "total_energy_today": ["energy_today"], - "total_energy_total": ["energy_total"], - "grid_power": ["total_power"], - "grid_import_kwh": ["import_kwh"], - "grid_export_kwh": ["export_kwh"], + "total_energy_today": ["energy_today", "e_day"], + "total_energy_total": ["energy_total", "e_total"], + "grid_power": ["total_power", "active_power"], + "grid_import_kwh": ["import_kwh", "e_total_imp"], + "grid_export_kwh": ["export_kwh", "e_total_exp"], "bat_charge_power": ["bat_charge_power"], "bat_discharge_power": ["bat_discharge_power"], - "bat_charge_total": ["bat_charge_total"], - "bat_discharge_total": ["bat_discharge_total"], - "bat_soc": ["bat_soc"], + "bat_charge_total": ["bat_charge_total", "e_bat_charge_total"], + "bat_discharge_total": ["bat_discharge_total", "e_bat_discharge_total"], + "bat_soc": ["bat_soc", "soc"], } AGG_AVG = {"bat_soc"} @@ -154,7 +155,15 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): log.error("[%s] Ungültige Konfiguration: %s", inv_id, e) return - reader = ModbusReader(host=inv_cfg["modbus_ip"], port=port, slave=slave) + host = inv_cfg["modbus_ip"] + if inverter.protocol == "goodwe_udp": + reader = GoodweReader(host=host, family=inverter.goodwe_family) + log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds", + inv_id, inverter.name, host, interval) + else: + reader = ModbusReader(host=host, port=port, slave=slave) + log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds", + inv_id, inverter.name, host, port, interval) with State.lock: if _publisher: @@ -170,9 +179,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): q = hist.setdefault(sid, deque(maxlen=300)) for pt in points: q.append(pt) - log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds — %d Sensoren aus DB geladen", - inv_id, inverter.name, inv_cfg["modbus_ip"], - inv_cfg.get("modbus_port", 502), interval, len(hist_data)) + log.info("[%s] %d Sensoren aus DB geladen", inv_id, len(hist_data)) while not stop.is_set(): t0 = time.time() @@ -284,14 +291,17 @@ def api_save_inverters(): for inv in data: if not isinstance(inv, dict): return jsonify({"error": "invalid"}), 400 - if inv.get("inverter_model") not in INVERTERS: - return jsonify({"error": f"unknown model: {inv.get('inverter_model')}"}), 400 - port = inv.get("modbus_port", 502) - if not isinstance(port, int) or not (1 <= port <= 65535): - return jsonify({"error": "invalid port"}), 400 - addr = inv.get("modbus_address", 1) - if not isinstance(addr, int) or not (1 <= addr <= 247): - return jsonify({"error": "invalid modbus address (1-247)"}), 400 + model_id = inv.get("inverter_model") + if model_id not in INVERTERS: + return jsonify({"error": f"unknown model: {model_id}"}), 400 + inverter_def = INVERTERS[model_id] + if inverter_def.protocol == "modbus": + port = inv.get("modbus_port", 502) + if not isinstance(port, int) or not (1 <= port <= 65535): + return jsonify({"error": "invalid port"}), 400 + addr = inv.get("modbus_address", 1) + if not isinstance(addr, int) or not (1 <= addr <= 247): + return jsonify({"error": "invalid modbus address (1-247)"}), 400 with State.lock: State.inverters_cfg = data save_config() diff --git a/haos-addon/src/requirements.txt b/haos-addon/src/requirements.txt new file mode 100644 index 0000000..1fe30eb --- /dev/null +++ b/haos-addon/src/requirements.txt @@ -0,0 +1,4 @@ +pymodbus==3.6.9 +paho-mqtt==1.6.1 +flask==3.0.3 +goodwe==0.4.10