Feature: Goodwe GW10KN-ET Support via goodwe UDP/8899
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -24,6 +24,8 @@ class Inverter:
|
|||||||
sensors: List[Sensor]
|
sensors: List[Sensor]
|
||||||
# Register ranges to batch-read as (start, length) tuples (FC04 input registers)
|
# Register ranges to batch-read as (start, length) tuples (FC04 input registers)
|
||||||
read_ranges: List[tuple]
|
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]:
|
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]:
|
def _sdm630_sensors() -> List[Sensor]:
|
||||||
f = "float32"
|
f = "float32"
|
||||||
return [
|
return [
|
||||||
@@ -152,6 +201,15 @@ INVERTERS = {
|
|||||||
name="Eastron SDM-630",
|
name="Eastron SDM-630",
|
||||||
manufacturer="Eastron",
|
manufacturer="Eastron",
|
||||||
sensors=_sdm630_sensors(),
|
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",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-15
@@ -12,6 +12,7 @@ from flask import Flask, jsonify, request, send_from_directory
|
|||||||
from inverters import INVERTERS
|
from inverters import INVERTERS
|
||||||
import history
|
import history
|
||||||
from modbus_client import ModbusReader
|
from modbus_client import ModbusReader
|
||||||
|
from goodwe_client import GoodweReader
|
||||||
from mqtt_publisher import MqttPublisher
|
from mqtt_publisher import MqttPublisher
|
||||||
|
|
||||||
logging.basicConfig(
|
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)
|
# Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG)
|
||||||
|
|
||||||
AGG_SENSOR_IDS: Dict[str, List[str]] = {
|
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_ac_power": ["ac_power", "ac_power_total"],
|
||||||
"total_energy_today": ["energy_today"],
|
"total_energy_today": ["energy_today", "e_day"],
|
||||||
"total_energy_total": ["energy_total"],
|
"total_energy_total": ["energy_total", "e_total"],
|
||||||
"grid_power": ["total_power"],
|
"grid_power": ["total_power", "active_power"],
|
||||||
"grid_import_kwh": ["import_kwh"],
|
"grid_import_kwh": ["import_kwh", "e_total_imp"],
|
||||||
"grid_export_kwh": ["export_kwh"],
|
"grid_export_kwh": ["export_kwh", "e_total_exp"],
|
||||||
"bat_charge_power": ["bat_charge_power"],
|
"bat_charge_power": ["bat_charge_power"],
|
||||||
"bat_discharge_power": ["bat_discharge_power"],
|
"bat_discharge_power": ["bat_discharge_power"],
|
||||||
"bat_charge_total": ["bat_charge_total"],
|
"bat_charge_total": ["bat_charge_total", "e_bat_charge_total"],
|
||||||
"bat_discharge_total": ["bat_discharge_total"],
|
"bat_discharge_total": ["bat_discharge_total", "e_bat_discharge_total"],
|
||||||
"bat_soc": ["bat_soc"],
|
"bat_soc": ["bat_soc", "soc"],
|
||||||
}
|
}
|
||||||
AGG_AVG = {"bat_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)
|
log.error("[%s] Ungültige Konfiguration: %s", inv_id, e)
|
||||||
return
|
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:
|
with State.lock:
|
||||||
if _publisher:
|
if _publisher:
|
||||||
@@ -170,9 +179,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
q = hist.setdefault(sid, deque(maxlen=300))
|
q = hist.setdefault(sid, deque(maxlen=300))
|
||||||
for pt in points:
|
for pt in points:
|
||||||
q.append(pt)
|
q.append(pt)
|
||||||
log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds — %d Sensoren aus DB geladen",
|
log.info("[%s] %d Sensoren aus DB geladen", inv_id, len(hist_data))
|
||||||
inv_id, inverter.name, inv_cfg["modbus_ip"],
|
|
||||||
inv_cfg.get("modbus_port", 502), interval, len(hist_data))
|
|
||||||
|
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -284,8 +291,11 @@ def api_save_inverters():
|
|||||||
for inv in data:
|
for inv in data:
|
||||||
if not isinstance(inv, dict):
|
if not isinstance(inv, dict):
|
||||||
return jsonify({"error": "invalid"}), 400
|
return jsonify({"error": "invalid"}), 400
|
||||||
if inv.get("inverter_model") not in INVERTERS:
|
model_id = inv.get("inverter_model")
|
||||||
return jsonify({"error": f"unknown model: {inv.get('inverter_model')}"}), 400
|
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)
|
port = inv.get("modbus_port", 502)
|
||||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||||
return jsonify({"error": "invalid port"}), 400
|
return jsonify({"error": "invalid port"}), 400
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pymodbus==3.6.9
|
||||||
|
paho-mqtt==1.6.1
|
||||||
|
flask==3.0.3
|
||||||
|
goodwe==0.4.10
|
||||||
Reference in New Issue
Block a user