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