""" 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()