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,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -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"]
|
||||||
@@ -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://<gitea>/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 |
|
||||||
@@ -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
|
||||||
+180
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>invraw — Growatt Raw Logger</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --surface: #161b22; --surface2: #1c2128;
|
||||||
|
--border: #30363d; --text: #e6edf3; --muted: #8b949e;
|
||||||
|
--inv: #388bfd; --cloud: #3fb950; --warn: #d29922; --danger: #f85149;
|
||||||
|
--solar: #f0a500; --battery: #3fb950; --grid: #58a6ff;
|
||||||
|
--output: #bc8cff; --temp: #ff7b72; --energy: #79c0ff;
|
||||||
|
--mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
}
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||||
|
padding: 10px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface); position: sticky; top: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 15px; font-weight: 600; }
|
||||||
|
header h1 span { color: var(--inv); }
|
||||||
|
.stats { display: flex; gap: 20px; color: var(--muted); font-size: 12px; }
|
||||||
|
.stats b { color: var(--text); }
|
||||||
|
.controls { margin-left: auto; display: flex; gap: 8px; }
|
||||||
|
button {
|
||||||
|
background: var(--border); border: 1px solid #484f58; color: var(--text);
|
||||||
|
padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-family: var(--mono);
|
||||||
|
}
|
||||||
|
button:hover { background: #484f58; }
|
||||||
|
button.paused { background: #3d2b00; border-color: var(--warn); color: var(--warn); }
|
||||||
|
|
||||||
|
/* ── Filter bar ── */
|
||||||
|
.filter-bar {
|
||||||
|
padding: 7px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; gap: 14px; background: var(--bg);
|
||||||
|
}
|
||||||
|
.filter-bar label { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||||
|
input[type=checkbox] { accent-color: var(--inv); }
|
||||||
|
|
||||||
|
/* ── Packet list ── */
|
||||||
|
#packets { padding: 10px 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
#empty { color: var(--muted); padding: 40px 20px; text-align: center; }
|
||||||
|
|
||||||
|
.packet { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); overflow: hidden; }
|
||||||
|
|
||||||
|
.packet-header {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
padding: 8px 14px; cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.packet-header:hover { background: var(--surface2); }
|
||||||
|
.ts { color: var(--muted); font-size: 11px; min-width: 195px; }
|
||||||
|
.dir { font-weight: 700; min-width: 130px; }
|
||||||
|
.dir-inv { color: var(--inv); }
|
||||||
|
.dir-cloud { color: var(--cloud); }
|
||||||
|
.sz { color: var(--muted); font-size: 11px; min-width: 55px; }
|
||||||
|
.badge {
|
||||||
|
background: #21262d; border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; padding: 1px 7px; font-size: 11px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-proto { color: var(--warn); }
|
||||||
|
.badge-cmd { color: #bc8cff; }
|
||||||
|
.badge-soc { color: var(--battery); }
|
||||||
|
.badge-pv { color: var(--solar); }
|
||||||
|
.badge-status-ok { color: var(--cloud); }
|
||||||
|
.badge-status-warn { color: var(--warn); }
|
||||||
|
.badge-status-err { color: var(--danger); }
|
||||||
|
.expand-icon { margin-left: auto; color: var(--muted); font-size: 10px; }
|
||||||
|
|
||||||
|
.packet-body { display: none; border-top: 1px solid var(--border); }
|
||||||
|
.packet-body.open { display: block; }
|
||||||
|
|
||||||
|
/* ── Decoded section ── */
|
||||||
|
.decoded-section { border-bottom: 1px solid var(--border); padding: 12px 16px; }
|
||||||
|
.decoded-title { font-size: 11px; color: var(--muted); letter-spacing: .5px; text-transform: uppercase; margin-bottom: 10px; }
|
||||||
|
.decoded-meta { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.decoded-meta span { font-size: 11px; }
|
||||||
|
.decoded-meta .serial { color: var(--text); }
|
||||||
|
.decoded-meta .layout { color: var(--muted); }
|
||||||
|
.decoded-meta .buffered { color: var(--warn); }
|
||||||
|
|
||||||
|
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
|
||||||
|
.cat-card {
|
||||||
|
background: #21262d; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.cat-title {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
|
||||||
|
margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.cat-solar .cat-title { color: var(--solar); }
|
||||||
|
.cat-battery .cat-title { color: var(--battery); }
|
||||||
|
.cat-grid .cat-title { color: var(--grid); }
|
||||||
|
.cat-output .cat-title { color: var(--output); }
|
||||||
|
.cat-temp .cat-title { color: var(--temp); }
|
||||||
|
.cat-energy .cat-title { color: var(--energy); }
|
||||||
|
.cat-status .cat-title { color: var(--warn); }
|
||||||
|
|
||||||
|
.field-row { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; padding: 1px 0; }
|
||||||
|
.field-lbl { color: var(--muted); font-size: 11px; }
|
||||||
|
.field-val { font-weight: 600; }
|
||||||
|
.field-unit { color: var(--muted); font-size: 11px; margin-left: 3px; }
|
||||||
|
.field-note { color: var(--muted); font-size: 10px; display: block; }
|
||||||
|
.val-zero { color: #484f58; }
|
||||||
|
.val-ok { color: var(--cloud); }
|
||||||
|
.val-warn { color: var(--warn); }
|
||||||
|
.val-neg { color: var(--battery); }
|
||||||
|
|
||||||
|
/* ── Hex dump ── */
|
||||||
|
.hex-section pre {
|
||||||
|
padding: 12px 16px; font-size: 12px; line-height: 1.6;
|
||||||
|
white-space: pre; overflow-x: auto; color: #a5d6ff;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.raw-hex { padding: 6px 16px 12px; }
|
||||||
|
.raw-hex label { color: var(--muted); font-size: 11px; display: block; margin-bottom: 3px; }
|
||||||
|
.raw-hex code { color: #7ee787; font-size: 11px; word-break: break-all; display: block; max-height: 80px; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* ── Header decode grid ── */
|
||||||
|
.hdr-grid { padding: 8px 16px 10px; display: grid; grid-template-columns: 140px 1fr; gap: 2px 14px; font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.hdr-grid dt { color: var(--muted); }
|
||||||
|
.hdr-grid dd { color: var(--text); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1><span>inv</span>raw — Growatt decoder</h1>
|
||||||
|
<div class="stats">
|
||||||
|
<span>Paquets : <b id="stat-count">0</b></span>
|
||||||
|
<span>Octets : <b id="stat-bytes">0</b></span>
|
||||||
|
<span>Dernier : <b id="stat-last">—</b></span>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="btn-pause">⏸ Pause</button>
|
||||||
|
<button id="btn-clear">🗑 Vider</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<label><input type="checkbox" id="f-inv" checked> <span class="dir-inv">inverter→cloud</span></label>
|
||||||
|
<label><input type="checkbox" id="f-cloud" checked> <span class="dir-cloud">cloud→inverter</span></label>
|
||||||
|
<label><input type="checkbox" id="f-data" checked> Données seulement</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="packets">
|
||||||
|
<div id="empty">En attente de paquets… L'onduleur doit pointer vers ce proxy sur le port 5279.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let paused = false;
|
||||||
|
let totalBytes = 0, packetCount = 0;
|
||||||
|
|
||||||
|
const listEl = document.getElementById("packets");
|
||||||
|
const statCount = document.getElementById("stat-count");
|
||||||
|
const statBytes = document.getElementById("stat-bytes");
|
||||||
|
const statLast = document.getElementById("stat-last");
|
||||||
|
|
||||||
|
const CAT_ORDER = ["solar","battery","grid","output","temp","energy","status"];
|
||||||
|
const CAT_LABELS = {
|
||||||
|
solar:"Panneaux solaires", battery:"Batterie", grid:"Réseau",
|
||||||
|
output:"Sortie", temp:"Température", energy:"Énergie", status:"Statut",
|
||||||
|
};
|
||||||
|
const STATUS_NAMES = {0:"En attente", 1:"Normal", 2:"Défaut"};
|
||||||
|
|
||||||
|
function esc(s) { return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }
|
||||||
|
function fmtBytes(n) {
|
||||||
|
if (n<1024) return n+" B";
|
||||||
|
if (n<1048576) return (n/1024).toFixed(1)+" KB";
|
||||||
|
return (n/1048576).toFixed(2)+" MB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDecodedHtml(dec) {
|
||||||
|
if (!dec) return "";
|
||||||
|
// group fields by category
|
||||||
|
const cats = {};
|
||||||
|
let invSerial = "", dataSerial = "", isBuffered = dec._buffered;
|
||||||
|
for (const [key, val] of Object.entries(dec)) {
|
||||||
|
if (key.startsWith("_")) continue;
|
||||||
|
if (key === "pvserial") { invSerial = val.text || ""; continue; }
|
||||||
|
if (key === "datalogserial") { dataSerial = val.text || ""; continue; }
|
||||||
|
const cat = val.cat || "other";
|
||||||
|
if (!cats[cat]) cats[cat] = [];
|
||||||
|
cats[cat].push({key, ...val});
|
||||||
|
}
|
||||||
|
|
||||||
|
let metaHtml = `<div class="decoded-meta">`;
|
||||||
|
if (invSerial) metaHtml += `<span class="serial">Onduleur : <b>${esc(invSerial)}</b></span>`;
|
||||||
|
if (dataSerial) metaHtml += `<span class="serial">Datalogger : <b>${esc(dataSerial)}</b></span>`;
|
||||||
|
if (isBuffered) metaHtml += `<span class="buffered">⚡ Enregistrement tampon</span>`;
|
||||||
|
metaHtml += `<span class="layout">${esc(dec._layout)}</span></div>`;
|
||||||
|
|
||||||
|
let gridHtml = `<div class="cat-grid">`;
|
||||||
|
for (const cat of CAT_ORDER) {
|
||||||
|
if (!cats[cat] || cats[cat].length === 0) continue;
|
||||||
|
gridHtml += `<div class="cat-card cat-${cat}"><div class="cat-title">${CAT_LABELS[cat]||cat}</div>`;
|
||||||
|
for (const f of cats[cat]) {
|
||||||
|
let valClass = "";
|
||||||
|
let displayVal = f.value;
|
||||||
|
if (f.key === "pvstatus") {
|
||||||
|
displayVal = (f.status_name || f.value) + ` (${f.value})`;
|
||||||
|
valClass = f.value === 1 ? "val-ok" : f.value === 0 ? "" : "val-warn";
|
||||||
|
} else if (typeof f.value === "number") {
|
||||||
|
if (f.value === 0) valClass = "val-zero";
|
||||||
|
else if (f.value < 0) valClass = "val-neg";
|
||||||
|
else if (f.key.includes("fault") && f.value !== 0) valClass = "val-warn";
|
||||||
|
}
|
||||||
|
const noteHtml = f.note ? `<span class="field-note">${esc(f.note)}</span>` : "";
|
||||||
|
gridHtml += `<div class="field-row">
|
||||||
|
<span class="field-lbl">${esc(f.lbl)}</span>
|
||||||
|
<span><span class="field-val ${valClass}">${esc(displayVal)}</span><span class="field-unit">${esc(f.unit||"")}</span></span>
|
||||||
|
</div>${noteHtml}`;
|
||||||
|
}
|
||||||
|
gridHtml += `</div>`;
|
||||||
|
}
|
||||||
|
gridHtml += `</div>`;
|
||||||
|
|
||||||
|
return `<div class="decoded-section">
|
||||||
|
<div class="decoded-title">Données décodées — SPF</div>
|
||||||
|
${metaHtml}
|
||||||
|
${gridHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPacketEl(p) {
|
||||||
|
const hdr = p.header || {};
|
||||||
|
const dec = p.decoded;
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "packet";
|
||||||
|
div.dataset.dir = p.direction;
|
||||||
|
div.dataset.hasData = dec ? "1" : "0";
|
||||||
|
|
||||||
|
// Summary badges
|
||||||
|
let extraBadges = "";
|
||||||
|
if (dec) {
|
||||||
|
const soc = dec.batterySoc;
|
||||||
|
const ppv1 = dec.ppv1, ppv2 = dec.ppv2;
|
||||||
|
const st = dec.pvstatus;
|
||||||
|
if (st) {
|
||||||
|
const cls = st.value===1?"badge-status-ok":st.value===0?"":"badge-status-err";
|
||||||
|
extraBadges += `<span class="badge ${cls}">${esc(st.status_name||st.value)}</span>`;
|
||||||
|
}
|
||||||
|
if (soc) extraBadges += `<span class="badge badge-soc">🔋 ${soc.value}%</span>`;
|
||||||
|
const totalPV = ((ppv1?.value||0)+(ppv2?.value||0));
|
||||||
|
if (totalPV > 0) extraBadges += `<span class="badge badge-pv">☀️ ${totalPV.toFixed(1)}W</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirCls = p.direction.startsWith("inverter") ? "dir-inv" : "dir-cloud";
|
||||||
|
const dirLbl = p.direction.startsWith("inverter") ? "inverter → cloud" : "cloud → inverter";
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="packet-header">
|
||||||
|
<span class="ts">${p.ts}</span>
|
||||||
|
<span class="dir ${dirCls}">${dirLbl}</span>
|
||||||
|
<span class="sz">${p.size} B</span>
|
||||||
|
<span class="badge badge-proto">${esc(hdr.protocol_name||"?")}</span>
|
||||||
|
<span class="badge badge-cmd">${esc(hdr.cmd_name||"?")}</span>
|
||||||
|
${extraBadges}
|
||||||
|
<span class="expand-icon">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="packet-body">
|
||||||
|
${buildDecodedHtml(dec)}
|
||||||
|
<dl class="hdr-grid">
|
||||||
|
<dt>Device prefix</dt><dd>${hdr.device_prefix||"—"}</dd>
|
||||||
|
<dt>Protocole</dt><dd>${hdr.protocol||"—"} — ${esc(hdr.protocol_name||"")}</dd>
|
||||||
|
<dt>Type brut</dt><dd>0x${hdr.rec_type||"—"}</dd>
|
||||||
|
<dt>Commande</dt><dd>0x${hdr.cmd||"—"} — ${esc(hdr.cmd_name||"")}</dd>
|
||||||
|
<dt>Payload length</dt><dd>${hdr.payload_len!=null?hdr.payload_len+" B":"—"}</dd>
|
||||||
|
<dt>Header hex</dt><dd>${hdr.raw_header||"—"}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="hex-section"><pre>${esc(p.hex_dump)}</pre></div>
|
||||||
|
<div class="raw-hex"><label>Hex brut complet</label><code>${p.raw_hex}</code></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
div.querySelector(".packet-header").addEventListener("click", () => {
|
||||||
|
const body = div.querySelector(".packet-body");
|
||||||
|
const icon = div.querySelector(".expand-icon");
|
||||||
|
body.classList.toggle("open");
|
||||||
|
icon.textContent = body.classList.contains("open") ? "▲" : "▼";
|
||||||
|
});
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisible(el) {
|
||||||
|
const dir = el.dataset.dir;
|
||||||
|
const hasData = el.dataset.hasData === "1";
|
||||||
|
const showInv = document.getElementById("f-inv").checked;
|
||||||
|
const showCloud = document.getElementById("f-cloud").checked;
|
||||||
|
const dataOnly = document.getElementById("f-data").checked;
|
||||||
|
const dirOk = (dir.startsWith("inverter") && showInv) || (dir.startsWith("cloud") && showCloud);
|
||||||
|
const dataOk = !dataOnly || hasData;
|
||||||
|
return dirOk && dataOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPacket(p) {
|
||||||
|
totalBytes += p.size; packetCount++;
|
||||||
|
statCount.textContent = packetCount;
|
||||||
|
statBytes.textContent = fmtBytes(totalBytes);
|
||||||
|
statLast.textContent = p.ts.split("T")[1];
|
||||||
|
const emptyEl = document.getElementById("empty");
|
||||||
|
if (emptyEl) emptyEl.remove();
|
||||||
|
const el = buildPacketEl(p);
|
||||||
|
if (!isVisible(el)) el.style.display = "none";
|
||||||
|
listEl.insertBefore(el, listEl.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE
|
||||||
|
const evtSrc = new EventSource("/sse");
|
||||||
|
evtSrc.onmessage = e => { if (!paused) addPacket(JSON.parse(e.data)); };
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
document.getElementById("btn-pause").addEventListener("click", function() {
|
||||||
|
paused = !paused;
|
||||||
|
this.textContent = paused ? "▶ Reprendre" : "⏸ Pause";
|
||||||
|
this.classList.toggle("paused", paused);
|
||||||
|
});
|
||||||
|
document.getElementById("btn-clear").addEventListener("click", () => {
|
||||||
|
fetch("/api/clear", {method:"POST"});
|
||||||
|
listEl.innerHTML = '<div id="empty">Liste vidée.</div>';
|
||||||
|
totalBytes = packetCount = 0;
|
||||||
|
statCount.textContent = statBytes.textContent = "0";
|
||||||
|
statLast.textContent = "—";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
["f-inv","f-cloud","f-data"].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener("change", () => {
|
||||||
|
document.querySelectorAll(".packet").forEach(el => {
|
||||||
|
el.style.display = isVisible(el) ? "" : "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetch("/api/packets").then(r=>r.json()).then(list => list.forEach(addPacket));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user