181 lines
10 KiB
Python
181 lines
10 KiB
Python
"""
|
|
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()
|