first commit
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Dockerfile
|
||||||
|
# À placer dans /opt/container/growatt-live/
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Métadonnées
|
||||||
|
LABEL maintainer="growatt-interceptor"
|
||||||
|
LABEL description="Intercepteur Growatt autonome - Remplace Grott"
|
||||||
|
|
||||||
|
# Définir le répertoire de travail
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Installer les dépendances système si nécessaire
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Installer les dépendances Python
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
flask \
|
||||||
|
requests \
|
||||||
|
python-dateutil
|
||||||
|
|
||||||
|
# Créer le dossier data
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Copier le script principal
|
||||||
|
COPY growatt_interceptor.py /app/
|
||||||
|
|
||||||
|
# Créer un utilisateur non-root pour la sécurité
|
||||||
|
RUN useradd -m -s /bin/bash growatt && \
|
||||||
|
chown -R growatt:growatt /app
|
||||||
|
|
||||||
|
# Changer vers l'utilisateur non-root
|
||||||
|
USER growatt
|
||||||
|
|
||||||
|
# Exposer les ports
|
||||||
|
EXPOSE 5279 8080
|
||||||
|
|
||||||
|
# Commande de démarrage
|
||||||
|
CMD ["python", "-u", "growatt_interceptor.py"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# docker-compose.yml
|
||||||
|
# Pour Portainer - À placer dans /opt/container/growatt-live/
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
growatt-interceptor:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: growatt-interceptor:latest
|
||||||
|
container_name: growatt-interceptor
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5279:5279" # Port pour les ESP (même port que Grott - arrêtez Grott avant!)
|
||||||
|
- "8080:8080" # Interface web
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
# Monter le script Python pour modifications faciles sans rebuild
|
||||||
|
- ./growatt_interceptor.py:/app/growatt_interceptor.py:ro
|
||||||
|
# Dossier pour données persistantes
|
||||||
|
- ./data:/app/data
|
||||||
|
networks:
|
||||||
|
- bridge # Utilise le réseau bridge par défaut
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false" # Éviter les mises à jour auto
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# Pas besoin de définir le network bridge, il existe déjà
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Intercepteur Growatt autonome - Remplace Grott
|
||||||
|
Compatible avec les onduleurs SPF (T06NNNNSPF)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import struct
|
||||||
|
import json
|
||||||
|
import codecs
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, jsonify, render_template_string
|
||||||
|
from itertools import cycle
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configuration du logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONFIG = {
|
||||||
|
'proxy_port': 5279, # Port d'écoute pour les ESP
|
||||||
|
'web_port': 8080, # Port pour l'interface web
|
||||||
|
'growatt_server': '47.91.67.66', # Serveur Growatt réel (vu dans vos logs)
|
||||||
|
'growatt_port': 5279,
|
||||||
|
'verbose': True,
|
||||||
|
'forward_to_growatt': True, # Transférer les données vers Growatt
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stockage global des données
|
||||||
|
devices_data = {}
|
||||||
|
raw_messages = []
|
||||||
|
statistics = {
|
||||||
|
'total_messages': 0,
|
||||||
|
'last_update': None,
|
||||||
|
'devices_seen': set()
|
||||||
|
}
|
||||||
|
|
||||||
|
class GrowattDecryptor:
|
||||||
|
"""Déchiffrement des données Growatt"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decrypt(data):
|
||||||
|
"""Déchiffre les données selon l'algorithme Growatt"""
|
||||||
|
if len(data) < 8:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Masque de déchiffrement
|
||||||
|
mask = "Growatt"
|
||||||
|
hex_mask = [ord(x) for x in mask]
|
||||||
|
nmask = len(hex_mask)
|
||||||
|
|
||||||
|
# Header non chiffré (8 premiers octets)
|
||||||
|
unscrambled = list(data[0:8])
|
||||||
|
|
||||||
|
# Déchiffrement XOR du reste
|
||||||
|
for i, j in zip(range(len(data) - 8), cycle(range(nmask))):
|
||||||
|
unscrambled.append(data[i + 8] ^ hex_mask[j])
|
||||||
|
|
||||||
|
return bytes(unscrambled)
|
||||||
|
|
||||||
|
class SPFParser:
|
||||||
|
"""Parser pour les onduleurs SPF (layout T06NNNNSPF)"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(decrypted_data):
|
||||||
|
"""Parse les données déchiffrées selon le layout SPF"""
|
||||||
|
try:
|
||||||
|
hex_string = decrypted_data.hex()
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
def get_string(start, length):
|
||||||
|
"""Extrait une chaîne ASCII"""
|
||||||
|
hex_part = hex_string[start:start + length * 2]
|
||||||
|
if hex_part:
|
||||||
|
return codecs.decode(hex_part, "hex").decode('ascii', errors='ignore').rstrip('\x00')
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_uint16(start):
|
||||||
|
"""Extrait un uint16 (2 octets)"""
|
||||||
|
if start + 4 <= len(hex_string):
|
||||||
|
return int(hex_string[start:start + 4], 16)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_uint32(start):
|
||||||
|
"""Extrait un uint32 (4 octets)"""
|
||||||
|
if start + 8 <= len(hex_string):
|
||||||
|
return int(hex_string[start:start + 8], 16)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Extraction des données selon le layout T06NNNNSPF
|
||||||
|
# Positions basées sur l'analyse des logs Grott
|
||||||
|
|
||||||
|
# Serials
|
||||||
|
data['datalogserial'] = get_string(16, 10) # Position 16, 10 bytes
|
||||||
|
data['pvserial'] = get_string(76, 10) # Position 76, 10 bytes
|
||||||
|
|
||||||
|
# Date/Time (position 136)
|
||||||
|
try:
|
||||||
|
year = int(hex_string[136:138], 16)
|
||||||
|
month = int(hex_string[138:140], 16)
|
||||||
|
day = int(hex_string[140:142], 16)
|
||||||
|
hour = int(hex_string[142:144], 16)
|
||||||
|
minute = int(hex_string[144:146], 16)
|
||||||
|
second = int(hex_string[146:148], 16)
|
||||||
|
|
||||||
|
data['datetime'] = f"20{year:02d}-{month:02d}-{day:02d}T{hour:02d}:{minute:02d}:{second:02d}"
|
||||||
|
except:
|
||||||
|
data['datetime'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Status et données principales
|
||||||
|
data['pvstatus'] = get_uint16(148)
|
||||||
|
|
||||||
|
# Tensions PV (position 152+, divisé par 10)
|
||||||
|
data['vpv1'] = get_uint16(152) / 10.0
|
||||||
|
data['vpv2'] = get_uint16(156) / 10.0
|
||||||
|
|
||||||
|
# Puissances PV (position 160+, divisé par 10)
|
||||||
|
data['ppv1'] = get_uint16(160) / 10.0
|
||||||
|
data['ppv2'] = get_uint16(164) / 10.0
|
||||||
|
|
||||||
|
# Courants Buck (position 168+, divisé par 10)
|
||||||
|
data['buck1curr'] = get_uint16(168) / 10.0
|
||||||
|
data['buck2curr'] = get_uint16(172) / 10.0
|
||||||
|
|
||||||
|
# Puissance de sortie (position 176+, divisé par 10)
|
||||||
|
data['op_watt'] = get_uint16(176) / 10.0
|
||||||
|
data['pvpowerout'] = data['op_watt']
|
||||||
|
|
||||||
|
# VA de sortie (position 180+)
|
||||||
|
data['op_va'] = get_uint32(180) / 10.0
|
||||||
|
|
||||||
|
# Puissances de charge AC (position 188+)
|
||||||
|
data['acchr_watt'] = get_uint16(188) / 10.0
|
||||||
|
data['acchr_VA'] = get_uint16(192) / 10.0
|
||||||
|
|
||||||
|
# Batterie (position 196+)
|
||||||
|
data['bat_Volt'] = get_uint16(196) / 100.0
|
||||||
|
data['batterySoc'] = get_uint16(200)
|
||||||
|
|
||||||
|
# Bus voltage (position 204+, divisé par 10)
|
||||||
|
data['bus_volt'] = get_uint16(204) / 10.0
|
||||||
|
|
||||||
|
# Grid (position 208+, divisé par 10)
|
||||||
|
data['grid_volt'] = get_uint16(208) / 10.0
|
||||||
|
data['line_freq'] = get_uint16(212) / 100.0
|
||||||
|
|
||||||
|
# Output (position 216+, divisé par 10)
|
||||||
|
data['outputvolt'] = get_uint16(216) / 10.0
|
||||||
|
data['outputfreq'] = get_uint16(220) / 100.0
|
||||||
|
|
||||||
|
# Températures (position 224+, divisé par 10)
|
||||||
|
data['invtemp'] = get_uint16(224) / 10.0
|
||||||
|
data['dcdctemp'] = get_uint16(228) / 10.0
|
||||||
|
|
||||||
|
# Load (position 232+, divisé par 10)
|
||||||
|
data['loadpercent'] = get_uint16(232) / 10.0
|
||||||
|
|
||||||
|
# Températures NTC (position 236+, divisé par 10)
|
||||||
|
data['buck1_ntc'] = get_uint16(236) / 10.0
|
||||||
|
data['buck2_ntc'] = get_uint16(240) / 10.0
|
||||||
|
|
||||||
|
# Courants (position 244+, divisé par 10)
|
||||||
|
data['OP_Curr'] = get_uint16(244) / 10.0
|
||||||
|
data['Inv_Curr'] = get_uint16(248) / 10.0
|
||||||
|
|
||||||
|
# Puissances AC (position 252+)
|
||||||
|
data['AC_InWatt'] = get_uint16(252) / 10.0
|
||||||
|
data['AC_InVA'] = get_uint16(256) / 10.0
|
||||||
|
|
||||||
|
# Bits de défaut (position 260+)
|
||||||
|
data['faultBit'] = get_uint16(260)
|
||||||
|
data['warningBit'] = get_uint16(264)
|
||||||
|
|
||||||
|
# Énergies (position 280+, divisé par 10)
|
||||||
|
data['epvtoday'] = get_uint32(280) / 10.0
|
||||||
|
data['epvtotal'] = get_uint32(288) / 10.0
|
||||||
|
|
||||||
|
# Énergies AC charge (position 296+, divisé par 10)
|
||||||
|
data['eacCharToday'] = get_uint32(296) / 10.0
|
||||||
|
data['eacCharTotal'] = get_uint32(304) / 10.0
|
||||||
|
|
||||||
|
# Énergies batterie décharge (position 312+, divisé par 10)
|
||||||
|
data['ebatDischarToday'] = get_uint32(312) / 10.0
|
||||||
|
data['ebatDischarTotal'] = get_uint32(320) / 10.0
|
||||||
|
|
||||||
|
# Énergies AC décharge (position 328+, divisé par 10)
|
||||||
|
data['eacDischarToday'] = get_uint32(328) / 10.0
|
||||||
|
data['eacDischarTotal'] = get_uint32(336) / 10.0
|
||||||
|
|
||||||
|
# Courant AC charge (position 344+, divisé par 10)
|
||||||
|
data['ACCharCurr'] = get_uint16(344) / 10.0
|
||||||
|
|
||||||
|
# Puissances décharge (position 348+, divisé par 10)
|
||||||
|
data['ACDischarWatt'] = get_uint16(348) / 10.0
|
||||||
|
data['ACDischarVA'] = get_uint16(352) / 10.0
|
||||||
|
|
||||||
|
# Puissances batterie (position 356+, divisé par 10)
|
||||||
|
data['BatDischarWatt'] = get_uint16(356) / 10.0
|
||||||
|
data['BatDischarVA'] = get_uint16(360) / 10.0
|
||||||
|
data['BatWatt'] = get_uint16(364) / 10.0
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur parsing SPF: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
class GrowattProxy:
|
||||||
|
"""Proxy pour intercepter les communications ESP -> Growatt"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.running = False
|
||||||
|
self.server_socket = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Démarre le serveur proxy"""
|
||||||
|
try:
|
||||||
|
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
self.server_socket.bind(('0.0.0.0', CONFIG['proxy_port']))
|
||||||
|
self.server_socket.listen(10)
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
logger.info(f"✅ Proxy démarré sur le port {CONFIG['proxy_port']}")
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
client_socket, address = self.server_socket.accept()
|
||||||
|
logger.info(f"📡 Nouvelle connexion ESP: {address}")
|
||||||
|
|
||||||
|
# Traiter dans un thread séparé
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self.handle_client,
|
||||||
|
args=(client_socket, address)
|
||||||
|
)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.running:
|
||||||
|
logger.error(f"Erreur accept: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur démarrage proxy: {e}")
|
||||||
|
|
||||||
|
def handle_client(self, client_socket, address):
|
||||||
|
"""Gère une connexion client (ESP)"""
|
||||||
|
growatt_socket = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Se connecter au serveur Growatt si nécessaire
|
||||||
|
if CONFIG['forward_to_growatt']:
|
||||||
|
try:
|
||||||
|
growatt_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
growatt_socket.settimeout(10)
|
||||||
|
growatt_socket.connect((CONFIG['growatt_server'], CONFIG['growatt_port']))
|
||||||
|
logger.info(f"✅ Connecté au serveur Growatt")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Impossible de se connecter à Growatt: {e}")
|
||||||
|
growatt_socket = None
|
||||||
|
|
||||||
|
# Recevoir et traiter les données
|
||||||
|
while True:
|
||||||
|
data = client_socket.recv(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Traiter les données
|
||||||
|
self.process_data(data, address)
|
||||||
|
|
||||||
|
# Transférer vers Growatt si connecté
|
||||||
|
if growatt_socket:
|
||||||
|
try:
|
||||||
|
growatt_socket.send(data)
|
||||||
|
|
||||||
|
# Recevoir et transférer la réponse
|
||||||
|
response = growatt_socket.recv(4096)
|
||||||
|
if response:
|
||||||
|
client_socket.send(response)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Envoyer un ACK simple si pas de connexion Growatt
|
||||||
|
if len(data) > 8:
|
||||||
|
# Construire un ACK basique
|
||||||
|
ack = data[:8] + bytes([0x00, 0x00])
|
||||||
|
client_socket.send(ack)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur handling client {address}: {e}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
client_socket.close()
|
||||||
|
if growatt_socket:
|
||||||
|
growatt_socket.close()
|
||||||
|
logger.info(f"🔌 Connexion fermée: {address}")
|
||||||
|
|
||||||
|
def process_data(self, data, address):
|
||||||
|
"""Traite les données reçues"""
|
||||||
|
try:
|
||||||
|
# Ignorer les paquets trop courts
|
||||||
|
if len(data) < 100:
|
||||||
|
logger.debug(f"Paquet trop court ignoré: {len(data)} octets")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Déchiffrer
|
||||||
|
decrypted = GrowattDecryptor.decrypt(data)
|
||||||
|
if not decrypted:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Identifier le type de message
|
||||||
|
header = decrypted[:8].hex()
|
||||||
|
msg_type = header[14:16]
|
||||||
|
|
||||||
|
logger.info(f"📦 Message type: {msg_type}, taille: {len(data)} octets")
|
||||||
|
|
||||||
|
# Parser selon le type
|
||||||
|
if msg_type == "04": # Données SPF
|
||||||
|
parsed = SPFParser.parse(decrypted)
|
||||||
|
|
||||||
|
if parsed:
|
||||||
|
device_id = parsed.get('pvserial', 'unknown')
|
||||||
|
|
||||||
|
# Mettre à jour les données
|
||||||
|
devices_data[device_id] = {
|
||||||
|
'last_update': datetime.now().isoformat(),
|
||||||
|
'data': parsed,
|
||||||
|
'address': address[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Statistiques
|
||||||
|
statistics['total_messages'] += 1
|
||||||
|
statistics['last_update'] = datetime.now().isoformat()
|
||||||
|
statistics['devices_seen'].add(device_id)
|
||||||
|
|
||||||
|
# Garder les derniers messages
|
||||||
|
raw_messages.append({
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'device': device_id,
|
||||||
|
'type': msg_type,
|
||||||
|
'size': len(data),
|
||||||
|
'data': parsed
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(raw_messages) > 100:
|
||||||
|
raw_messages.pop(0)
|
||||||
|
|
||||||
|
logger.info(f"✅ Données mises à jour pour {device_id}")
|
||||||
|
logger.info(f" PV: {parsed.get('ppv1', 0):.1f}W + {parsed.get('ppv2', 0):.1f}W")
|
||||||
|
logger.info(f" Sortie: {parsed.get('pvpowerout', 0):.1f}W")
|
||||||
|
logger.info(f" Batterie: {parsed.get('batterySoc', 0)}%")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur traitement données: {e}")
|
||||||
|
|
||||||
|
# Instance du proxy
|
||||||
|
proxy = GrowattProxy()
|
||||||
|
|
||||||
|
# Routes Flask
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Page d'accueil avec TOUTES les données"""
|
||||||
|
return render_template_string("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>🌱 Growatt Interceptor - Vue Complète</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 20px; background: #f0f2f5; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { color: #2c3e50; text-align: center; margin-bottom: 10px; }
|
||||||
|
.subtitle { text-align: center; color: #7f8c8d; margin-bottom: 20px; }
|
||||||
|
.stats { background: white; padding: 20px; border-radius: 10px; margin: 20px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.devices { margin-top: 20px; }
|
||||||
|
.device-card { background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
|
||||||
|
.device-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 3px solid #3498db; }
|
||||||
|
.device-title { font-size: 1.4em; font-weight: bold; color: #2c3e50; }
|
||||||
|
.status { display: inline-block; padding: 5px 12px; border-radius: 20px; font-size: 0.9em; font-weight: bold; }
|
||||||
|
.status-online { background: #d4edda; color: #155724; }
|
||||||
|
.status-offline { background: #f8d7da; color: #721c24; }
|
||||||
|
|
||||||
|
/* Sections de données */
|
||||||
|
.data-sections { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; }
|
||||||
|
.data-section { background: #f8f9fa; padding: 15px; border-radius: 8px; border-left: 4px solid #3498db; }
|
||||||
|
.section-title { font-weight: bold; color: #2c3e50; margin-bottom: 10px; font-size: 1.1em; background: white; padding: 5px 10px; border-radius: 5px; }
|
||||||
|
|
||||||
|
/* Métriques */
|
||||||
|
.metric { display: flex; justify-content: space-between; padding: 6px 10px; background: white; margin: 3px 0; border-radius: 4px; font-size: 0.95em; }
|
||||||
|
.metric:hover { background: #e3f2fd; }
|
||||||
|
.metric-label { color: #546e7a; }
|
||||||
|
.metric-value { font-weight: bold; color: #1976d2; font-family: 'Courier New', monospace; }
|
||||||
|
.metric-value.zero { color: #9e9e9e; }
|
||||||
|
.metric-value.warning { color: #ff9800; }
|
||||||
|
.metric-value.error { color: #f44336; }
|
||||||
|
.metric-value.good { color: #4caf50; }
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.nav { text-align: center; margin: 20px 0; background: white; padding: 15px; border-radius: 10px; }
|
||||||
|
.nav a, .nav button { margin: 0 5px; padding: 8px 16px; background: #3498db; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; }
|
||||||
|
.nav a:hover, .nav button:hover { background: #2980b9; }
|
||||||
|
|
||||||
|
/* Filtre et options */
|
||||||
|
.filter-options { background: white; padding: 15px; border-radius: 10px; margin: 10px 0; }
|
||||||
|
.filter-options label { margin-right: 15px; }
|
||||||
|
|
||||||
|
/* Tableau pour toutes les valeurs */
|
||||||
|
.all-values-table { width: 100%; margin-top: 10px; }
|
||||||
|
.all-values-table th { background: #ecf0f1; padding: 8px; text-align: left; }
|
||||||
|
.all-values-table td { padding: 6px 8px; border-bottom: 1px solid #ecf0f1; }
|
||||||
|
.all-values-table tr:hover { background: #f8f9fa; }
|
||||||
|
|
||||||
|
/* Badge pour les valeurs spéciales */
|
||||||
|
.badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 0.8em; margin-left: 5px; }
|
||||||
|
.badge.new { background: #4caf50; color: white; }
|
||||||
|
.badge.debug { background: #ff9800; color: white; }
|
||||||
|
.badge.unchanged { background: #9e9e9e; color: white; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🌱 Growatt Interceptor - Vue Complète</h1>
|
||||||
|
<p class="subtitle">Affichage de TOUTES les données pour debug</p>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<h2>📊 Statistiques Globales</h2>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Messages reçus:</span>
|
||||||
|
<span class="metric-value" id="total-messages">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Appareils détectés:</span>
|
||||||
|
<span class="metric-value" id="devices-count">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Dernière mise à jour:</span>
|
||||||
|
<span class="metric-value" id="last-update">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/api/devices">🔍 API JSON</a>
|
||||||
|
<a href="/api/raw">📝 Messages bruts</a>
|
||||||
|
<a href="/debug">🐛 Mode Debug</a>
|
||||||
|
<button onclick="toggleAllValues()">👁️ Afficher/Masquer tout</button>
|
||||||
|
<button onclick="location.reload()">🔄 Rafraîchir</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-options">
|
||||||
|
<label><input type="checkbox" id="showZeroValues" checked> Afficher valeurs à 0</label>
|
||||||
|
<label><input type="checkbox" id="autoRefresh" checked> Rafraîchissement auto (5s)</label>
|
||||||
|
<label><input type="checkbox" id="showRawValues" checked> Afficher valeurs brutes</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>🔌 Onduleurs - Données Complètes</h2>
|
||||||
|
<div class="devices" id="devices">
|
||||||
|
<p style="text-align: center; color: #666;">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let autoRefreshInterval;
|
||||||
|
let allDataVisible = true;
|
||||||
|
|
||||||
|
function formatValue(key, value) {
|
||||||
|
if (value === null || value === undefined) return '<span class="metric-value zero">N/A</span>';
|
||||||
|
|
||||||
|
// Formatage selon le type de donnée
|
||||||
|
if (key.includes('volt') || key.includes('Volt')) {
|
||||||
|
return `<span class="metric-value ${value > 0 ? 'good' : 'zero'}">${value.toFixed(1)} V</span>`;
|
||||||
|
} else if (key.includes('curr') || key.includes('Curr')) {
|
||||||
|
return `<span class="metric-value ${value > 0 ? 'good' : 'zero'}">${value.toFixed(2)} A</span>`;
|
||||||
|
} else if (key.includes('watt') || key.includes('Watt') || key.includes('ppv') || key.includes('power')) {
|
||||||
|
return `<span class="metric-value ${value > 0 ? 'good' : 'zero'}">${value.toFixed(0)} W</span>`;
|
||||||
|
} else if (key.includes('VA')) {
|
||||||
|
return `<span class="metric-value ${value > 0 ? 'good' : 'zero'}">${value.toFixed(0)} VA</span>`;
|
||||||
|
} else if (key.includes('temp')) {
|
||||||
|
const color = value > 50 ? 'warning' : value > 60 ? 'error' : 'good';
|
||||||
|
return `<span class="metric-value ${color}">${value.toFixed(1)} °C</span>`;
|
||||||
|
} else if (key.includes('Soc') || key.includes('percent')) {
|
||||||
|
const color = value < 20 ? 'error' : value < 50 ? 'warning' : 'good';
|
||||||
|
return `<span class="metric-value ${color}">${value.toFixed(0)} %</span>`;
|
||||||
|
} else if (key.includes('freq')) {
|
||||||
|
return `<span class="metric-value">${value.toFixed(2)} Hz</span>`;
|
||||||
|
} else if (key.includes('epv') || key.includes('eac') || key.includes('ebat')) {
|
||||||
|
return `<span class="metric-value ${value > 0 ? 'good' : 'zero'}">${value.toFixed(2)} kWh</span>`;
|
||||||
|
} else if (key.includes('Bit') || key.includes('Value') || key.includes('status')) {
|
||||||
|
const color = value > 0 ? 'warning' : 'good';
|
||||||
|
return `<span class="metric-value ${color}">${value}</span>`;
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
return `<span class="metric-value">${value}</span>`;
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
return `<span class="metric-value">${value.toFixed(2)}</span>`;
|
||||||
|
} else {
|
||||||
|
return `<span class="metric-value">${value}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function categorizeData(data) {
|
||||||
|
const categories = {
|
||||||
|
'Identification': ['datalogserial', 'pvserial', 'datetime'],
|
||||||
|
'Panneau Solaire PV1': ['vpv1', 'ppv1', 'buck1curr', 'buck1_ntc'],
|
||||||
|
'Panneau Solaire PV2': ['vpv2', 'ppv2', 'buck2curr', 'buck2_ntc'],
|
||||||
|
'Sortie Onduleur': ['op_watt', 'pvpowerout', 'op_va', 'outputvolt', 'outputfreq', 'pvgridvoltage', 'OP_Curr', 'loadpercent'],
|
||||||
|
'Batterie': ['bat_Volt', 'batterySoc', 'BatWatt', 'BatDischarWatt', 'BatDischarVA', 'ebatDischarToday', 'ebatDischarTotal'],
|
||||||
|
'Réseau (Grid)': ['grid_volt', 'line_freq', 'bus_volt'],
|
||||||
|
'Charge AC': ['acchr_watt', 'acchr_VA', 'ACCharCurr', 'eacCharToday', 'eacCharTotal'],
|
||||||
|
'Décharge AC': ['ACDischarWatt', 'ACDischarVA', 'eacDischarToday', 'eacDischarTotal'],
|
||||||
|
'Entrée AC': ['AC_InWatt', 'AC_InVA', 'Inv_Curr'],
|
||||||
|
'Températures': ['invtemp', 'dcdctemp', 'buck1_ntc', 'buck2_ntc'],
|
||||||
|
'Énergies': ['epvtoday', 'epvtotal', 'pvenergytoday'],
|
||||||
|
'État & Erreurs': ['pvstatus', 'faultBit', 'warningBit', 'faultValue', 'warningValue', 'constantPowerOK']
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorized = {};
|
||||||
|
const uncategorized = {};
|
||||||
|
|
||||||
|
// Catégoriser les données
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
let found = false;
|
||||||
|
for (const [category, keys] of Object.entries(categories)) {
|
||||||
|
if (keys.includes(key)) {
|
||||||
|
if (!categorized[category]) categorized[category] = {};
|
||||||
|
categorized[category][key] = value;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
uncategorized[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter les non-catégorisées si elles existent
|
||||||
|
if (Object.keys(uncategorized).length > 0) {
|
||||||
|
categorized['Autres Valeurs (Debug)'] = uncategorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return categorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeviceCard(id, device) {
|
||||||
|
const data = device.data;
|
||||||
|
const lastUpdate = new Date(device.last_update);
|
||||||
|
const now = new Date();
|
||||||
|
const isOnline = (now - lastUpdate) < 60000;
|
||||||
|
|
||||||
|
const categorized = categorizeData(data);
|
||||||
|
|
||||||
|
let sectionsHtml = '';
|
||||||
|
for (const [category, values] of Object.entries(categorized)) {
|
||||||
|
let metricsHtml = '';
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
const showZero = document.getElementById('showZeroValues').checked;
|
||||||
|
if (!showZero && (value === 0 || value === '0' || value === null)) continue;
|
||||||
|
|
||||||
|
metricsHtml += `
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">${key}:</span>
|
||||||
|
${formatValue(key, value)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricsHtml) {
|
||||||
|
sectionsHtml += `
|
||||||
|
<div class="data-section">
|
||||||
|
<div class="section-title">${category}</div>
|
||||||
|
${metricsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tableau de TOUTES les valeurs brutes pour debug
|
||||||
|
const showRaw = document.getElementById('showRawValues').checked;
|
||||||
|
let rawTableHtml = '';
|
||||||
|
if (showRaw) {
|
||||||
|
rawTableHtml = `
|
||||||
|
<div style="margin-top: 20px; padding: 15px; background: #fff3cd; border-radius: 8px;">
|
||||||
|
<h4>🐛 Toutes les valeurs brutes (Debug)</h4>
|
||||||
|
<table class="all-values-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Clé</th>
|
||||||
|
<th>Valeur brute</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Formatée</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${Object.entries(data).map(([key, value]) => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${key}</strong></td>
|
||||||
|
<td><code>${JSON.stringify(value)}</code></td>
|
||||||
|
<td>${typeof value}</td>
|
||||||
|
<td>${formatValue(key, value)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||||
|
Total: ${Object.keys(data).length} valeurs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="device-card">
|
||||||
|
<div class="device-header">
|
||||||
|
<span class="device-title">🔌 ${id}</span>
|
||||||
|
<div>
|
||||||
|
<span class="status ${isOnline ? 'status-online' : 'status-offline'}">
|
||||||
|
${isOnline ? '🟢 En ligne' : '🔴 Hors ligne'}
|
||||||
|
</span>
|
||||||
|
<span style="margin-left: 10px; color: #666;">
|
||||||
|
📍 ${device.address} | 🕐 ${lastUpdate.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-sections">
|
||||||
|
${sectionsHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${rawTableHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateData() {
|
||||||
|
fetch('/api/devices')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Stats
|
||||||
|
document.getElementById('total-messages').textContent = data.statistics.total_messages;
|
||||||
|
document.getElementById('devices-count').textContent = data.statistics.devices_count;
|
||||||
|
document.getElementById('last-update').textContent = data.statistics.last_update || 'Jamais';
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
const devicesDiv = document.getElementById('devices');
|
||||||
|
if (Object.keys(data.devices).length === 0) {
|
||||||
|
devicesDiv.innerHTML = '<p style="text-align: center; color: #666;">Aucun onduleur détecté. Vérifiez que les ESP sont configurés pour se connecter à cette machine.</p>';
|
||||||
|
} else {
|
||||||
|
devicesDiv.innerHTML = '';
|
||||||
|
for (const [id, device] of Object.entries(data.devices)) {
|
||||||
|
const card = createDeviceCard(id, device);
|
||||||
|
devicesDiv.innerHTML += card;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Erreur:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllValues() {
|
||||||
|
const checkbox = document.getElementById('showRawValues');
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
updateData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Checkboxes
|
||||||
|
document.getElementById('showZeroValues').addEventListener('change', updateData);
|
||||||
|
document.getElementById('showRawValues').addEventListener('change', updateData);
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
document.getElementById('autoRefresh').addEventListener('change', function(e) {
|
||||||
|
if (e.target.checked) {
|
||||||
|
autoRefreshInterval = setInterval(updateData, 5000);
|
||||||
|
} else {
|
||||||
|
clearInterval(autoRefreshInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
updateData();
|
||||||
|
autoRefreshInterval = setInterval(updateData, 5000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""")
|
||||||
|
|
||||||
|
@app.route('/api/devices')
|
||||||
|
def api_devices():
|
||||||
|
"""API JSON pour les données des appareils"""
|
||||||
|
return jsonify({
|
||||||
|
'devices': devices_data,
|
||||||
|
'statistics': {
|
||||||
|
'total_messages': statistics['total_messages'],
|
||||||
|
'devices_count': len(statistics['devices_seen']),
|
||||||
|
'last_update': statistics['last_update']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/raw')
|
||||||
|
def api_raw():
|
||||||
|
"""API pour les messages bruts"""
|
||||||
|
return jsonify({
|
||||||
|
'messages': raw_messages[-50:], # Derniers 50 messages
|
||||||
|
'count': len(raw_messages)
|
||||||
|
})
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Point d'entrée principal"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("🌱 GROWATT INTERCEPTOR - Remplaçant de Grott")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"📡 Port proxy: {CONFIG['proxy_port']}")
|
||||||
|
print(f"🌐 Port web: {CONFIG['web_port']}")
|
||||||
|
print(f"🔄 Transfert vers Growatt: {CONFIG['forward_to_growatt']}")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\n⚠️ IMPORTANT: Pour utiliser cet intercepteur:")
|
||||||
|
print("1. Arrêtez Grott (docker stop grott)")
|
||||||
|
print("2. Lancez ce script")
|
||||||
|
print("3. Les ESP se connecteront automatiquement")
|
||||||
|
print("\n📊 Interface web: http://localhost:8080")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Démarrer le proxy dans un thread
|
||||||
|
proxy_thread = threading.Thread(target=proxy.start)
|
||||||
|
proxy_thread.daemon = True
|
||||||
|
proxy_thread.start()
|
||||||
|
|
||||||
|
# Démarrer Flask
|
||||||
|
app.run(host='0.0.0.0', port=CONFIG['web_port'], debug=False)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==2.3.3
|
||||||
|
requests==2.31.0
|
||||||
|
paho-mqtt==1.6.1
|
||||||
Reference in New Issue
Block a user