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:
perco
2026-05-19 13:49:41 +02:00
co-authored by Claude Sonnet 4.6
commit 265ae68064
7 changed files with 1060 additions and 0 deletions
+179
View File
@@ -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 |