Initial release — Growatt SPF TCP proxy with decoder and HA MQTT integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-05-19 13:49:41 +02:00
co-authored by Claude Sonnet 4.6
commit 265ae68064
7 changed files with 1060 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
"""
Home Assistant MQTT auto-discovery + state publishing.
Uses the same topic structure as grott_ha_spf.py so existing HA sensors work unchanged.
"""
import json
import os
import threading
import datetime
MQTT_HOST = os.getenv("MQTT_HOST", "192.168.1.29")
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883"))
MQTT_ENABLED = os.getenv("MQTT_ENABLED", "true").lower() == "true"
HA_DISCOVERY = os.getenv("HA_DISCOVERY", "true").lower() == "true"
# Optional serial → HA device name mapping.
# Format: "SERIAL1:NAME1,SERIAL2:NAME2" e.g. "JNK1CM70FU:JNK1CM70FU_EST,JNK1CM70H5:JNK1CM70H5_SUD"
_DEVICE_MAP: dict = {}
for _pair in os.getenv("DEVICE_NAMES", "").split(","):
if ":" in _pair:
_k, _v = _pair.strip().split(":", 1)
if _k and _v:
_DEVICE_MAP[_k.strip()] = _v.strip()
def _device_name(serial: str) -> str:
return _DEVICE_MAP.get(serial, serial)
_configured: set = set()
_lock = threading.Lock()
# Field → HA sensor config (compatible with grott_ha_spf.py value_templates)
# div = divide factor used in value_template (matches grottconf layout)
# negate = True → value_template adds "* -1" (BatWatt convention in grott_ha_spf)
_F = {
"ppv1": {"name":"PV1 Power", "dc":"power", "unit":"W", "sc":"measurement", "div":10},
"vpv1": {"name":"PV1 Voltage", "dc":"voltage", "unit":"V", "sc":"measurement", "div":10},
"buck1curr": {"name":"PV1 Current", "dc":"current", "unit":"A", "sc":"measurement", "div":10},
"ppv2": {"name":"PV2 Power", "dc":"power", "unit":"W", "sc":"measurement", "div":10},
"vpv2": {"name":"PV2 Voltage", "dc":"voltage", "unit":"V", "sc":"measurement", "div":10},
"buck2curr": {"name":"PV2 Current", "dc":"current", "unit":"A", "sc":"measurement", "div":10},
"op_watt": {"name":"Output Power", "dc":"power", "unit":"W", "sc":"measurement", "div":10},
"op_va": {"name":"Output VA", "dc":"apparent_power", "unit":"VA", "sc":"measurement", "div":10},
"batterySoc": {"name":"State of Charge", "dc":"battery", "unit":"%", "sc":"measurement", "div":1},
"bat_Volt": {"name":"Battery Voltage", "dc":"voltage", "unit":"V", "sc":"measurement", "div":100},
"BatWatt": {"name":"Battery Power", "dc":"power", "unit":"W", "sc":"measurement", "div":10, "negate":True},
"BatDischarWatt": {"name":"Battery Discharge Power", "dc":"power", "unit":"W", "sc":"measurement", "div":10},
"acchr_watt": {"name":"AC Charge Power", "dc":"power", "unit":"W", "sc":"measurement", "div":10},
"acchr_VA": {"name":"AC Charge VA", "dc":"apparent_power", "unit":"VA", "sc":"measurement", "div":10},
"ACDischarWatt": {"name":"AC Discharge Power", "dc":"power", "unit":"W", "sc":"measurement", "div":10},
"ACCharCurr": {"name":"AC Charge Current", "dc":"current", "unit":"A", "sc":"measurement", "div":10},
"grid_volt": {"name":"Grid Voltage", "dc":"voltage", "unit":"V", "sc":"measurement", "div":10},
"line_freq": {"name":"Grid Frequency", "dc":"frequency", "unit":"Hz", "sc":"measurement", "div":100},
"outputvolt": {"name":"Output Voltage", "dc":"voltage", "unit":"V", "sc":"measurement", "div":10},
"outputfreq": {"name":"Output Frequency", "dc":"frequency", "unit":"Hz", "sc":"measurement", "div":100},
"invtemp": {"name":"Inverter Temperature", "dc":"temperature", "unit":"°C", "sc":"measurement", "div":10},
"dcdctemp": {"name":"DC/DC Temperature", "dc":"temperature", "unit":"°C", "sc":"measurement", "div":10},
"buck1_ntc": {"name":"Buck1 Temperature", "dc":"temperature", "unit":"°C", "sc":"measurement", "div":10},
"buck2_ntc": {"name":"Buck2 Temperature", "dc":"temperature", "unit":"°C", "sc":"measurement", "div":10},
"loadpercent": {"name":"Load Percentage", "unit":"%", "sc":"measurement", "div":10},
"AC_InWatt": {"name":"AC Input Power", "dc":"power", "unit":"W", "sc":"measurement", "div":10},
"AC_InVA": {"name":"AC Input VA", "dc":"apparent_power", "unit":"VA", "sc":"measurement", "div":10},
"Inv_Curr": {"name":"Inverter Current", "dc":"current", "unit":"A", "sc":"measurement", "div":10},
"OP_Curr": {"name":"Output Current", "dc":"current", "unit":"A", "sc":"measurement", "div":10},
"bus_volt": {"name":"Bus Voltage", "dc":"voltage", "unit":"V", "sc":"measurement", "div":10},
"pvstatus": {"name":"PV Status", "sc":"measurement", "div":1},
"faultBit": {"name":"Fault Bits", "sc":"measurement", "div":1},
"warningBit": {"name":"Warning Bits", "sc":"measurement", "div":1},
"pvenergytoday": {"name":"Energy Today", "dc":"energy", "unit":"kWh", "sc":"total", "div":10},
"pvenergytotal": {"name":"Energy Total", "dc":"energy", "unit":"kWh", "sc":"total_increasing","div":10},
"ebatDischarToday":{"name":"Battery Discharged Today","dc":"energy", "unit":"kWh", "sc":"total", "div":10},
"ebatDischarTotal":{"name":"Battery Discharged Total","dc":"energy", "unit":"kWh", "sc":"total_increasing","div":10},
"eacCharToday": {"name":"AC Charge Energy Today", "dc":"energy", "unit":"kWh", "sc":"total", "div":10},
"eacCharTotal": {"name":"AC Charge Energy Total", "dc":"energy", "unit":"kWh", "sc":"total_increasing","div":10},
"eacDischarToday":{"name":"AC Discharge Today", "dc":"energy", "unit":"kWh", "sc":"total", "div":10},
"eacDischarTotal":{"name":"AC Discharge Total", "dc":"energy", "unit":"kWh", "sc":"total_increasing","div":10},
"grott_last_push":{"name":"Last Data Push", "dc":"timestamp"},
}
def _build_config(device: str, key: str) -> dict:
"""device is already the mapped display name."""
cfg = _F.get(key, {})
div = cfg.get("div", 1)
neg = cfg.get("negate", False)
if key == "grott_last_push":
tpl = f"{{{{ value_json.{key} }}}}"
elif neg:
tpl = f"{{{{ value_json.{key} | float / {div} * -1 }}}}"
elif div != 1:
tpl = f"{{{{ value_json.{key} | float / {div} }}}}"
else:
tpl = f"{{{{ value_json.{key} }}}}"
payload = {
"name": cfg.get("name", key),
"unique_id": f"invraw_{device}_{key}",
"state_topic": f"homeassistant/invraw/{device}/state",
"value_template": tpl,
"expire_after": 600,
"device": {
"identifiers": [f"invraw_{device}"],
"name": device,
"manufacturer":"Growatt",
"model": "SPF (invraw)",
},
}
if cfg.get("dc"): payload["device_class"] = cfg["dc"]
if cfg.get("unit"): payload["unit_of_measurement"] = cfg["unit"]
if cfg.get("sc"): payload["state_class"] = cfg["sc"]
return payload
def _do_publish(serial: str, raw_values: dict):
try:
import paho.mqtt.publish as mqtt_pub
except ImportError:
print("[MQTT] paho-mqtt not installed", flush=True)
return
device = _device_name(serial)
with _lock:
need_disc = device not in _configured
if need_disc and HA_DISCOVERY:
msgs = []
all_keys = list(raw_values.keys()) + ["grott_last_push"]
for key in all_keys:
if key in ("datalogserial", "pvserial"):
continue
payload = _build_config(device, key)
msgs.append({
"topic": f"homeassistant/sensor/invraw/{device}_{key}/config",
"payload": json.dumps(payload),
"retain": True,
"qos": 1,
})
try:
mqtt_pub.multiple(msgs, hostname=MQTT_HOST, port=MQTT_PORT)
with _lock:
_configured.add(device)
print(f"[MQTT] Discovery OK — {len(msgs)} sensors for {device}", flush=True)
except Exception as e:
print(f"[MQTT] Discovery failed: {e}", flush=True)
return
# State message
state = dict(raw_values)
state["grott_last_push"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
state_topic = f"homeassistant/invraw/{device}/state"
try:
mqtt_pub.single(state_topic, json.dumps(state), retain=True,
hostname=MQTT_HOST, port=MQTT_PORT)
print(f"[MQTT] State → {state_topic}", flush=True)
except Exception as e:
print(f"[MQTT] State failed: {e}", flush=True)
def publish(decoded: dict):
"""Extract raw values from a decoded packet and publish to HA MQTT in a thread."""
if not MQTT_ENABLED or not decoded:
return
pvserial = decoded.get("pvserial")
device = (pvserial.get("text","") if isinstance(pvserial, dict) else str(pvserial or "")).strip()
if not device:
return
raw_values: dict = {}
for key, val in decoded.items():
if key.startswith("_"):
continue
if not isinstance(val, dict):
continue
if "text" in val:
raw_values[key] = val["text"]
elif "value" in val:
div = _F.get(key, {}).get("div", 1)
raw_values[key] = round(val["value"] * div)
threading.Thread(target=_do_publish, args=(device, raw_values), daemon=True).start()