first commit

This commit is contained in:
Cédric TOMAZ
2025-08-18 09:32:53 +02:00
commit c8f5fff232
5 changed files with 815 additions and 0 deletions
+738
View File
@@ -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()