import asyncio import struct import json import os import datetime from itertools import cycle from aiohttp import web from collections import deque LISTEN_PORT = int(os.getenv("LISTEN_PORT", "5279")) GROWATT_HOST = os.getenv("GROWATT_HOST", "server.growatt.com") GROWATT_PORT = int(os.getenv("GROWATT_PORT", "5279")) WEB_PORT = int(os.getenv("WEB_PORT", "8080")) MAX_PACKETS = int(os.getenv("MAX_PACKETS", "500")) packets = deque(maxlen=MAX_PACKETS) sse_queues = [] # ── Growatt V2 decryption ──────────────────────────────────────────────────── _MASK = [ord(c) for c in "Growatt"] # [71,114,111,119,97,116,116] def decrypt_payload(data: bytes) -> str: unscrambled = list(data[:8]) for i, j in zip(range(len(data) - 8), cycle(range(7))): unscrambled.append(data[i + 8] ^ _MASK[j]) return "".join(f"{b:02x}" for b in unscrambled) # ── SPF field layouts ──────────────────────────────────────────────────────── # "value" = position in hex string (byte_offset * 2) # "length" = bytes → hex chars = length*2 _SPF05 = { "datalogserial": {"v":16, "l":10, "t":"text", "lbl":"Datalogger serial"}, "pvserial": {"v":36, "l":10, "t":"text", "lbl":"Inverter serial"}, "pvstatus": {"v":78, "l":2, "d":1, "u":"", "cat":"status", "lbl":"Status"}, "vpv1": {"v":82, "l":2, "d":10, "u":"V", "cat":"solar", "lbl":"PV1 Tension"}, "vpv2": {"v":86, "l":2, "d":10, "u":"V", "cat":"solar", "lbl":"PV2 Tension"}, "ppv1": {"v":90, "l":4, "d":10, "u":"W", "cat":"solar", "lbl":"PV1 Puissance"}, "ppv2": {"v":98, "l":4, "d":10, "u":"W", "cat":"solar", "lbl":"PV2 Puissance"}, "buck1curr": {"v":106, "l":2, "d":10, "u":"A", "cat":"solar", "lbl":"PV1 Courant"}, "buck2curr": {"v":110, "l":2, "d":10, "u":"A", "cat":"solar", "lbl":"PV2 Courant"}, "op_watt": {"v":114, "l":4, "d":10, "u":"W", "cat":"output", "lbl":"Puissance sortie"}, "op_va": {"v":122, "l":4, "d":10, "u":"VA", "cat":"output", "lbl":"VA sortie"}, "acchr_watt": {"v":130, "l":4, "d":10, "u":"W", "cat":"grid", "lbl":"Charge AC (W)"}, "bat_Volt": {"v":146, "l":2, "d":100, "u":"V", "cat":"battery", "lbl":"Tension batterie"}, "batterySoc": {"v":150, "l":2, "d":1, "u":"%", "cat":"battery", "lbl":"SOC batterie"}, "bus_volt": {"v":154, "l":2, "d":10, "u":"V", "cat":"output", "lbl":"Tension bus"}, "grid_volt": {"v":158, "l":2, "d":10, "u":"V", "cat":"grid", "lbl":"Tension réseau"}, "line_freq": {"v":162, "l":2, "d":100, "u":"Hz", "cat":"grid", "lbl":"Fréq. réseau"}, "outputvolt": {"v":166, "l":2, "d":10, "u":"V", "cat":"output", "lbl":"Tension sortie"}, "outputfreq": {"v":170, "l":2, "d":100, "u":"Hz", "cat":"output", "lbl":"Fréq. sortie"}, "invtemp": {"v":178, "l":2, "d":10, "u":"°C", "cat":"temp", "lbl":"Temp. onduleur"}, "dcdctemp": {"v":182, "l":2, "d":10, "u":"°C", "cat":"temp", "lbl":"Temp. DC/DC"}, "loadpercent": {"v":186, "l":2, "d":10, "u":"%", "cat":"output", "lbl":"Charge sortie"}, "OP_Curr": {"v":214, "l":2, "d":10, "u":"A", "cat":"output", "lbl":"Courant sortie"}, "Inv_Curr": {"v":218, "l":2, "d":10, "u":"A", "cat":"grid", "lbl":"Courant réseau"}, "AC_InWatt": {"v":222, "l":4, "d":10, "u":"W", "cat":"grid", "lbl":"Puissance entrée AC"}, "AC_InVA": {"v":230, "l":4, "d":10, "u":"VA", "cat":"grid", "lbl":"VA entrée AC"}, "faultBit": {"v":238, "l":2, "d":1, "u":"", "cat":"status", "lbl":"Bits fautes"}, "warningBit": {"v":242, "l":2, "d":1, "u":"", "cat":"status", "lbl":"Bits alertes"}, "pvenergytoday": {"v":278, "l":4, "d":10, "u":"kWh", "cat":"energy", "lbl":"Énergie aujourd'hui"}, "pvenergytotal": {"v":286, "l":4, "d":10, "u":"kWh", "cat":"energy", "lbl":"Énergie totale"}, "eacCharToday": {"v":310, "l":4, "d":10, "u":"kWh", "cat":"energy", "lbl":"Charge AC auj."}, "eacCharTotal": {"v":318, "l":4, "d":10, "u":"kWh", "cat":"energy", "lbl":"Charge AC totale"}, "ebatDischarToday": {"v":326,"l":4,"d":10, "u":"kWh", "cat":"energy", "lbl":"Déch. batterie auj."}, "ebatDischarTotal": {"v":334,"l":4,"d":10, "u":"kWh", "cat":"energy", "lbl":"Déch. batterie totale"}, "eacDischarToday": {"v":342,"l":4,"d":10, "u":"kWh", "cat":"energy", "lbl":"Déch. AC auj."}, "eacDischarTotal": {"v":350,"l":4,"d":10, "u":"kWh", "cat":"energy", "lbl":"Déch. AC totale"}, "ACCharCurr": {"v":358, "l":2, "d":10, "u":"A", "cat":"battery", "lbl":"Courant charge AC"}, "ACDischarWatt": {"v":362, "l":4, "d":10, "u":"W", "cat":"grid", "lbl":"Décharge AC (W)"}, "buck1_ntc": {"v":206, "l":2, "d":10, "u":"°C", "cat":"temp", "lbl":"Temp. Buck1"}, "buck2_ntc": {"v":210, "l":2, "d":10, "u":"°C", "cat":"temp", "lbl":"Temp. Buck2"}, "BatDischarWatt":{"v":378, "l":4, "d":10, "u":"W", "cat":"battery", "lbl":"Décharge batterie"}, "BatWatt": {"v":394, "l":4, "d":10, "u":"W", "cat":"battery", "lbl":"Puiss. batterie", "signed":True}, } _SPF06 = { "datalogserial": {"v":16, "l":10, "t":"text", "lbl":"Datalogger serial"}, "pvserial": {"v":76, "l":10, "t":"text", "lbl":"Inverter serial"}, "pvstatus": {"v":158, "l":2, "d":1, "u":"", "cat":"status", "lbl":"Status"}, "vpv1": {"v":162, "l":2, "d":10, "u":"V", "cat":"solar", "lbl":"PV1 Tension"}, "vpv2": {"v":166, "l":2, "d":10, "u":"V", "cat":"solar", "lbl":"PV2 Tension"}, "ppv1": {"v":170, "l":4, "d":10, "u":"W", "cat":"solar", "lbl":"PV1 Puissance"}, "ppv2": {"v":178, "l":4, "d":10, "u":"W", "cat":"solar", "lbl":"PV2 Puissance"}, "buck1curr": {"v":186, "l":2, "d":10, "u":"A", "cat":"solar", "lbl":"PV1 Courant"}, "buck2curr": {"v":190, "l":2, "d":10, "u":"A", "cat":"solar", "lbl":"PV2 Courant"}, "op_watt": {"v":194, "l":4, "d":10, "u":"W", "cat":"output", "lbl":"Puissance sortie"}, "op_va": {"v":204, "l":4, "d":10, "u":"VA", "cat":"output", "lbl":"VA sortie"}, "acchr_watt": {"v":210, "l":4, "d":10, "u":"W", "cat":"battery", "lbl":"Charge batterie (AC)"}, "acchr_VA": {"v":218, "l":4, "d":10, "u":"VA", "cat":"battery", "lbl":"Charge batterie VA"}, "bat_Volt": {"v":226, "l":2, "d":100, "u":"V", "cat":"battery", "lbl":"Tension batterie"}, "batterySoc": {"v":230, "l":2, "d":1, "u":"%", "cat":"battery", "lbl":"SOC batterie"}, "bus_volt": {"v":234, "l":2, "d":10, "u":"V", "cat":"output", "lbl":"Tension bus"}, "grid_volt": {"v":238, "l":2, "d":10, "u":"V", "cat":"grid", "lbl":"Tension réseau"}, "line_freq": {"v":242, "l":2, "d":100, "u":"Hz", "cat":"grid", "lbl":"Fréq. réseau"}, "outputvolt": {"v":246, "l":2, "d":10, "u":"V", "cat":"output", "lbl":"Tension sortie"}, "outputfreq": {"v":250, "l":2, "d":100, "u":"Hz", "cat":"output", "lbl":"Fréq. sortie"}, "invtemp": {"v":258, "l":2, "d":10, "u":"°C", "cat":"temp", "lbl":"Temp. onduleur"}, "dcdctemp": {"v":262, "l":2, "d":10, "u":"°C", "cat":"temp", "lbl":"Temp. DC/DC"}, "loadpercent": {"v":266, "l":2, "d":10, "u":"%", "cat":"output", "lbl":"Charge sortie"}, "buck1_ntc": {"v":286, "l":2, "d":10, "u":"°C", "cat":"temp", "lbl":"Temp. Buck1"}, "buck2_ntc": {"v":290, "l":2, "d":10, "u":"°C", "cat":"temp", "lbl":"Temp. Buck2"}, "OP_Curr": {"v":294, "l":2, "d":10, "u":"A", "cat":"output", "lbl":"Courant sortie"}, "Inv_Curr": {"v":298, "l":2, "d":10, "u":"A", "cat":"grid", "lbl":"Courant réseau"}, "AC_InWatt": {"v":302, "l":4, "d":10, "u":"W", "cat":"grid", "lbl":"Puissance entrée AC"}, "AC_InVA": {"v":310, "l":4, "d":10, "u":"VA", "cat":"grid", "lbl":"VA entrée AC"}, "faultBit": {"v":318, "l":2, "d":1, "u":"", "cat":"status", "lbl":"Bits fautes"}, "warningBit": {"v":322, "l":2, "d":1, "u":"", "cat":"status", "lbl":"Bits alertes"}, "pvenergytoday": {"v":358, "l":4, "d":10, "u":"kWh", "cat":"energy", "lbl":"Énergie aujourd'hui"}, "pvenergytotal": {"v":366, "l":4, "d":10, "u":"kWh", "cat":"energy", "lbl":"Énergie totale"}, "eacCharToday": {"v":390, "l":4, "d":10, "u":"kWh", "cat":"energy", "lbl":"Charge AC auj."}, "eacCharTotal": {"v":398, "l":4, "d":10, "u":"kWh", "cat":"energy", "lbl":"Charge AC totale"}, "ebatDischarToday": {"v":406,"l":4,"d":10, "u":"kWh", "cat":"energy", "lbl":"Déch. batterie auj."}, "ebatDischarTotal": {"v":414,"l":4,"d":10, "u":"kWh", "cat":"energy", "lbl":"Déch. batterie totale"}, "eacDischarToday": {"v":422,"l":4,"d":10, "u":"kWh", "cat":"energy", "lbl":"Déch. AC auj."}, "eacDischarTotal": {"v":430,"l":4,"d":10, "u":"kWh", "cat":"energy", "lbl":"Déch. AC totale"}, "ACCharCurr": {"v":438, "l":2, "d":10, "u":"A", "cat":"battery", "lbl":"Courant charge AC"}, "ACDischarWatt": {"v":442, "l":4, "d":10, "u":"W", "cat":"grid", "lbl":"Puiss. déch. AC"}, "BatDischarWatt":{"v":458, "l":4, "d":10, "u":"W", "cat":"battery", "lbl":"Puiss. déch. batterie"}, "BatWatt": {"v":474, "l":4, "d":10, "u":"W", "cat":"battery", "lbl":"Puiss. batterie", "signed":True}, } _STATUS = {0: "En attente", 1: "Normal", 2: "Défaut"} _CAT_ORDER = ["solar","battery","grid","output","temp","energy","status"] _CAT_LABELS = { "solar": "Panneaux solaires", "battery": "Batterie", "grid": "Réseau", "output": "Sortie", "temp": "Température", "energy": "Énergie", "status": "Statut", } def _extract(hex_str, spec): v = spec["v"]; l = spec["l"] chunk = hex_str[v : v + l * 2] if not chunk or len(chunk) < l * 2: return None if spec.get("t") == "text": return bytes.fromhex(chunk).decode("utf-8", errors="replace").strip("\x00 ") raw = int(chunk, 16) if spec.get("signed"): nbits = l * 8 if raw >= (1 << (nbits - 1)): raw -= (1 << nbits) d = spec.get("d", 1) return round(raw / d, 2) if d != 1 else raw def decode_spf(data: bytes): if len(data) < 8: return None hdr = data[:8].hex() protocol = hdr[6:8] cmd = hdr[14:16] # Only data records (type 04 live, 50 buffered) if cmd not in ("04", "50"): return None hex_str = decrypt_payload(data) if protocol in ("05","06") else data.hex() layout = "T06NNNNSPF" if protocol == "06" else "T05NNNNSPF" fields = _SPF06 if protocol == "06" else _SPF05 out = {"_layout": layout, "_buffered": cmd == "50"} for key, spec in fields.items(): try: val = _extract(hex_str, spec) if val is None: continue if spec.get("t") == "text": out[key] = {"text": val, "lbl": spec["lbl"], "cat": "id"} else: entry = { "value": val, "unit": spec.get("u",""), "lbl": spec["lbl"], "cat": spec.get("cat","other"), } if key == "pvstatus": entry["status_name"] = _STATUS.get(val, str(val)) if key == "BatWatt": entry["note"] = "négatif = en charge" out[key] = entry except Exception: pass return out # ── Packet header decoder ───────────────────────────────────────────────────── PROTOCOL_NAMES = {"02":"Plain","05":"Encrypted V2","06":"Encrypted V2 (ack)"} CMD_NAMES = { "03":"Ping/Hello","04":"Data (live)","16":"Data (realtime)", "18":"Configure","19":"Data (buffered)","50":"Data (buffered)", } def decode_header(data): if len(data) < 8: return None hdr = data[:8].hex() protocol = hdr[6:8] payload_len = struct.unpack(">H", data[4:6])[0] rec_type = hdr[12:16]; cmd = hdr[14:16] return { "raw_header": hdr, "device_prefix": hdr[0:8], "protocol": protocol, "protocol_name": PROTOCOL_NAMES.get(protocol, f"Unknown ({protocol})"), "payload_len": payload_len, "rec_type": rec_type, "cmd": cmd, "cmd_name": CMD_NAMES.get(cmd, f"Unknown (0x{cmd})"), } def hex_dump(data, cols=16): lines = [] for i in range(0, len(data), cols): chunk = data[i:i+cols] hex_part = " ".join(f"{b:02x}" for b in chunk) asc_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) lines.append(f"{i:04x} {hex_part:<{cols*3}} {asc_part}") return "\n".join(lines) # ── Proxy core ──────────────────────────────────────────────────────────────── async def log_packet(direction, data): ts = datetime.datetime.now().isoformat(timespec="milliseconds") hdr = decode_header(data) decoded = None if direction == "inverter→cloud": try: decoded = decode_spf(data) except Exception: pass entry = { "id": len(packets), "ts": ts, "direction": direction, "size": len(data), "header": hdr, "hex_dump": hex_dump(data), "raw_hex": data.hex(), "decoded": decoded, } packets.append(entry) try: msg = json.dumps(entry) except Exception: msg = json.dumps({k: v for k, v in entry.items() if k != "decoded"}) for q in list(sse_queues): await q.put(msg) if decoded: import ha_mqtt ha_mqtt.publish(decoded) async def pipe(reader, writer, direction): try: while True: data = await reader.read(65536) if not data: break asyncio.ensure_future(log_packet(direction, data)) writer.write(data) await writer.drain() except Exception: pass finally: try: writer.close() except Exception: pass async def handle_inverter(inv_reader, inv_writer): peer = inv_writer.get_extra_info("peername") print(f"[+] Connected: {peer}", flush=True) try: cloud_reader, cloud_writer = await asyncio.open_connection(GROWATT_HOST, GROWATT_PORT) await asyncio.gather( pipe(inv_reader, cloud_writer, "inverter→cloud"), pipe(cloud_reader, inv_writer, "cloud→inverter"), ) except Exception as e: print(f"[!] Error: {e}", flush=True) finally: try: inv_writer.close() except Exception: pass print(f"[-] Disconnected: {peer}", flush=True) # ── Web handlers ────────────────────────────────────────────────────────────── async def index(request): with open("/app/templates/index.html") as f: html = f.read() return web.Response(text=html, content_type="text/html") async def api_packets(request): return web.json_response(list(packets)) async def api_clear(request): packets.clear() return web.json_response({"ok": True}) async def sse_stream(request): resp = web.StreamResponse(headers={ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }) await resp.prepare(request) q = asyncio.Queue() sse_queues.append(q) try: await resp.write(b": connected\n\n") while True: msg = await q.get() await resp.write(f"data: {msg}\n\n".encode()) except Exception: pass finally: if q in sse_queues: sse_queues.remove(q) return resp async def main(): tcp = await asyncio.start_server(handle_inverter, "0.0.0.0", LISTEN_PORT) print(f"[invraw] TCP proxy :{LISTEN_PORT} → {GROWATT_HOST}:{GROWATT_PORT}", flush=True) app = web.Application() app.router.add_get("/", index) app.router.add_get("/api/packets", api_packets) app.router.add_post("/api/clear", api_clear) app.router.add_get("/sse", sse_stream) runner = web.AppRunner(app) await runner.setup() await web.TCPSite(runner, "0.0.0.0", WEB_PORT).start() print(f"[invraw] Web UI http://0.0.0.0:{WEB_PORT}", flush=True) async with tcp: await tcp.serve_forever() if __name__ == "__main__": asyncio.run(main())