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:
+180
@@ -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()
|
||||
Reference in New Issue
Block a user