commit 265ae680647a0af9d9c87453202056a5b0a8e4c3 Author: perco Date: Tue May 19 13:49:41 2026 +0200 Initial release — Growatt SPF TCP proxy with decoder and HA MQTT integration Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..39743bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +RUN pip install --no-cache-dir aiohttp paho-mqtt +COPY proxy.py . +COPY ha_mqtt.py . +COPY templates/ templates/ +CMD ["python", "-u", "proxy.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..56bf577 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# invraw — Growatt SPF Inverter Raw Decoder + +Proxy TCP transparent entre un onduleur Growatt SPF et le cloud Growatt, qui : +- **Capture** chaque paquet en temps réel +- **Déchiffre** le protocole V2 (XOR "Growatt") +- **Décode** les 40+ champs du layout `T05NNNNSPF` / `T06NNNNSPF` +- **Affiche** tout dans une web UI live (hex dump + valeurs lisibles) +- **Publie** vers Home Assistant via MQTT auto-discovery + +Stack : Python 3.12 · asyncio · aiohttp · paho-mqtt · Docker + +--- + +## Architecture + +``` +Onduleur Growatt SPF + │ TCP :5279 + ▼ + [ invraw ] ──── décode + UI live ────▶ https://invraw.nas.local + │ (hex dump + champs décodés) + │ TCP :5279 + ▼ + server.growatt.com + │ + ▼ (optionnel) + [ Mosquitto ] + │ MQTT + ▼ + Home Assistant +``` + +L'onduleur se reconnecte automatiquement — aucune modification de sa config n'est nécessaire si l'IP du NAS est déjà configurée. + +--- + +## Déploiement + +**Prérequis** : Docker, Docker Compose, Traefik sur le réseau `proxy`. + +```bash +git clone http:///perco/invraw.git && cd invraw +docker compose up -d --build +``` + +L'onduleur doit pointer vers l'IP du NAS sur le port **5279** (même port que le cloud Growatt). + +--- + +## Configuration + +Toute la configuration se fait via variables d'environnement dans `docker-compose.yml` : + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `GROWATT_HOST` | `server.growatt.com` | Adresse du cloud Growatt | +| `GROWATT_PORT` | `5279` | Port cloud Growatt | +| `LISTEN_PORT` | `5279` | Port d'écoute du proxy | +| `WEB_PORT` | `8080` | Port de la web UI (interne) | +| `MAX_PACKETS` | `500` | Nombre de paquets gardés en mémoire | +| `MQTT_ENABLED` | `true` | Activer la publication MQTT | +| `MQTT_HOST` | `192.168.1.29` | Broker MQTT (container name ou IP) | +| `MQTT_PORT` | `1883` | Port MQTT | +| `HA_DISCOVERY` | `true` | Activer le MQTT auto-discovery HA | +| `DEVICE_NAMES` | *(vide)* | Mapping serial → nom HA (voir ci-dessous) | + +### Nommer les appareils dans Home Assistant + +Par défaut, l'appareil est créé avec le numéro de série de l'onduleur. Pour lui donner un nom personnalisé : + +```yaml +environment: + - DEVICE_NAMES=JNK1CM70FU:Onduleur_Est,JNK1CM70H5:Onduleur_Sud +``` + +Format : `SERIAL1:NOM1,SERIAL2:NOM2` — plusieurs onduleurs séparés par des virgules. + +--- + +## Web UI + +Accessible via `https://invraw.nas.percolouco.com` (ou le domaine Traefik configuré). + +**Fonctionnalités :** +- Live feed des paquets (Server-Sent Events) +- Résumé inline : statut onduleur, SOC batterie %, puissance PV totale +- Expand → données décodées par catégorie (Panneaux solaires, Batterie, Réseau, Sortie, Température, Énergie) +- Hex dump complet + hex brut copiable +- Filtre par direction (onduleur→cloud / cloud→onduleur) +- Filtre "données seulement" pour masquer les pings +- Bouton Pause / Vider + +--- + +## Protocole Growatt SPF décodé + +### Structure d'un paquet + +``` +Offset Taille Description +0-3 4 B ID datalogger +4-5 2 B Longueur payload (big-endian) +6 1 B Version protocole (0x05 / 0x06 = chiffré V2) +7 1 B Type d'enregistrement (0x04 = live, 0x50 = tampon) +8+ N B Payload (XOR "Growatt" si protocole 05/06) +``` + +### Déchiffrement V2 + +```python +mask = [ord(c) for c in "Growatt"] # [71, 114, 111, 119, 97, 116, 116] +# Les 8 premiers octets (header) ne sont pas chiffrés +for i, j in zip(range(len(data) - 8), cycle(range(7))): + decrypted[i + 8] = data[i + 8] ^ mask[j] +``` + +### Champs décodés (layout T06NNNNSPF) + +| Champ | Offset hex | Longueur | Diviseur | Unité | +|-------|-----------|----------|----------|-------| +| datalogserial | 16 | 10 | — | texte | +| pvserial | 76 | 10 | — | texte | +| pvstatus | 158 | 2 | 1 | — | +| vpv1 / vpv2 | 162 / 166 | 2 | 10 | V | +| ppv1 / ppv2 | 170 / 178 | 4 | 10 | W | +| buck1curr / buck2curr | 186 / 190 | 2 | 10 | A | +| op_watt | 194 | 4 | 10 | W | +| bat_Volt | 226 | 2 | 100 | V | +| batterySoc | 230 | 2 | 1 | % | +| grid_volt | 238 | 2 | 10 | V | +| line_freq | 242 | 2 | 100 | Hz | +| outputvolt | 246 | 2 | 10 | V | +| invtemp | 258 | 2 | 10 | °C | +| loadpercent | 266 | 2 | 10 | % | +| buck1_ntc | 286 | 2 | 10 | °C | +| AC_InWatt | 302 | 4 | 10 | W | +| pvenergytoday | 358 | 4 | 10 | kWh | +| pvenergytotal | 366 | 4 | 10 | kWh | +| ebatDischarToday/Total | 406 / 414 | 4 | 10 | kWh | +| BatWatt | 474 | 4 signé | 10 | W | +| … | … | … | … | … | + +Le layout `T05NNNNSPF` (protocole 0x05) utilise les mêmes champs avec des offsets décalés — voir `proxy.py`. + +--- + +## Home Assistant + +Quand `MQTT_ENABLED=true`, invraw publie automatiquement : + +1. **MQTT Discovery** (premier paquet reçu) — crée les sensors dans HA + - Topic config : `homeassistant/sensor/invraw/{device}_{key}/config` +2. **State** (chaque paquet data) — met à jour les valeurs + - Topic state : `homeassistant/invraw/{device}/state` + +Les sensors expirent après 10 minutes sans données (`expire_after: 600`). + +--- + +## Structure du projet + +``` +invraw/ +├── proxy.py # Proxy TCP + décodeur + serveur web (asyncio) +├── ha_mqtt.py # Publication MQTT / HA auto-discovery +├── templates/ +│ └── index.html # Web UI (SSE live, hex dump, decoded view) +├── Dockerfile +└── docker-compose.yml +``` + +--- + +## Versioning + +| Version | Changements | +|---------|-------------| +| v1.1.0 | MQTT HA auto-discovery, mapping serial → nom custom (`DEVICE_NAMES`) | +| v1.0.0 | Proxy TCP + décodeur SPF complet (43 champs) + web UI live | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c00d5e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + invraw: + build: . + container_name: invraw + restart: unless-stopped + ports: + - "5279:5279" # inverter TCP (même port que Grott — arrêter Grott avant) + environment: + - GROWATT_HOST=server.growatt.com + - GROWATT_PORT=5279 + - LISTEN_PORT=5279 + - WEB_PORT=8080 + - MAX_PACKETS=500 + - MQTT_HOST=mosquitto + - MQTT_PORT=1883 + - MQTT_ENABLED=true + - HA_DISCOVERY=true + - DEVICE_NAMES=JNK1CM70FU:JNK1CM70FU_EST,JNK1CM70H5:JNK1CM70H5_SUD + networks: + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.invraw.entrypoints=websecure" + - "traefik.http.routers.invraw.rule=Host(`invraw.nas.percolouco.com`)" + - "traefik.http.routers.invraw.tls.certresolver=letsencrypt" + - "traefik.http.services.invraw.loadbalancer.server.port=8080" + +networks: + proxy: + external: true diff --git a/ha_mqtt.py b/ha_mqtt.py new file mode 100644 index 0000000..edfaeec --- /dev/null +++ b/ha_mqtt.py @@ -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() diff --git a/proxy.py b/proxy.py new file mode 100644 index 0000000..8a7f817 --- /dev/null +++ b/proxy.py @@ -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()) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..55c5db3 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,341 @@ + + + + +invraw — Growatt Raw Logger + + + + +
+

invraw — Growatt decoder

+
+ Paquets : 0 + Octets : 0 + Dernier : +
+
+ + +
+
+ +
+ + + +
+ +
+
En attente de paquets… L'onduleur doit pointer vers ce proxy sur le port 5279.
+
+ + + +