Initial commit — KC868-A2 contrôleur solaire ESP32

Fonctionnalités :
- Lecture RS485 Modbus Epever Tracer 4210N (115200 bps, FC03/FC04/FC16)
- Moteur de règles JSON (LittleFS) — commande automatique des relais
- Interface web mobile-first (dashboard, règles, config, historique, EPEVER, debug)
- WiFi AP+STA simultanés avec reconnexion automatique et portail captif
- mDNS configurable (pv.local par défaut)
- Configuration registres EPEVER depuis l'UI (18 registres holding)
- Historique basse/haute résolution avec graphes canvas
- VPN WireGuard optionnel (désactivé par défaut, config via UI)
- OTA firmware + filesystem via ElegantOTA
- Deep sleep / économie d'énergie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 19:25:01 +02:00
co-authored by Claude Sonnet 4.6
commit a8f0d6ccba
88 changed files with 13162 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
ARG DEBIAN_FRONTEND=noninteractive
ARG QEMU_TAG=esp-develop-9.2.2-20260417
ARG QEMU_ARCHIVE=qemu-xtensa-softmmu-esp_develop_9.2.2_20260417-x86_64-linux-gnu.tar.xz
# =============================================================================
# Stage 1 — extract ESP32 ROM blobs from the official pre-built package
# (these binary blobs are not built from source; we reuse them as-is)
# =============================================================================
FROM ubuntu:22.04 AS rom-extractor
ARG QEMU_TAG QEMU_ARCHIVE DEBIAN_FRONTEND
RUN apt-get update && apt-get install -y --no-install-recommends \
wget ca-certificates xz-utils \
&& rm -rf /var/lib/apt/lists/*
RUN wget -qO /tmp/qemu.tar.xz \
"https://github.com/espressif/qemu/releases/download/${QEMU_TAG}/${QEMU_ARCHIVE}" \
&& mkdir -p /tmp/rom \
&& tar -xJf /tmp/qemu.tar.xz -C /tmp/rom --strip-components=1 \
&& ls /tmp/rom/share/qemu/esp32*.bin
# =============================================================================
# Stage 2 — build patched QEMU from Espressif source
# Adds a silent stub for WiFi modem registers (0x60033C00) so the firmware
# does not crash with LoadStorePIFAddrError on first WiFi register access.
# =============================================================================
FROM ubuntu:22.04 AS qemu-builder
ARG QEMU_TAG DEBIAN_FRONTEND
RUN apt-get update && apt-get install -y --no-install-recommends \
git python3 python3-pip python3-tomli ninja-build pkg-config \
libglib2.0-dev libpixman-1-dev libslirp-dev libfdt-dev \
zlib1g-dev libpng-dev libgcrypt20-dev build-essential flex bison \
&& rm -rf /var/lib/apt/lists/*
# QEMU 9.x requires meson >= 1.1.0 — Ubuntu 22.04 ships an older version
RUN pip3 install --quiet 'meson>=1.5'
# Shallow clone of the exact release tag
RUN git clone --depth=1 --branch "${QEMU_TAG}" \
https://github.com/espressif/qemu.git /qemu
WORKDIR /qemu
# Inject WiFi modem stub into hw/xtensa/esp32.c
COPY wifi_stub_patch.py /tmp/
RUN python3 /tmp/wifi_stub_patch.py
# Configure and build — xtensa only, no UI, no docs
RUN ./configure \
--target-list=xtensa-softmmu \
--disable-docs \
--disable-gtk \
--disable-sdl \
--disable-vnc \
--disable-curses \
--disable-opengl \
--disable-virglrenderer \
--disable-spice \
--disable-dbus-display \
--disable-guest-agent \
--disable-capstone \
--disable-libudev \
--disable-libusb \
--disable-usb-redir \
--audio-drv-list= \
--enable-slirp \
--enable-fdt \
&& ninja -C build qemu-system-xtensa
# =============================================================================
# Stage 3 — final runtime image
# =============================================================================
FROM ubuntu:22.04
ARG DEBIAN_FRONTEND
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \
libglib2.0-0 libpixman-1-0 libslirp0 libfdt1 libpng16-16 \
&& rm -rf /var/lib/apt/lists/*
# ROM blobs from the official pre-built package
RUN mkdir -p /usr/local/share/qemu
COPY --from=rom-extractor /tmp/rom/share/qemu/esp32*.bin /usr/local/share/qemu/
# Patched QEMU binary (built from source with WiFi stub)
COPY --from=qemu-builder /qemu/build/qemu-system-xtensa /usr/local/bin/qemu-system-xtensa
RUN chmod +x /usr/local/bin/qemu-system-xtensa
RUN pip3 install --quiet esptool
WORKDIR /emulator
COPY modbus_stub.py server.py entrypoint.sh ./
COPY ui/ ui/
RUN chmod +x entrypoint.sh
# 8888 = UI debug 3 volets 10080 = webserver ESP32
EXPOSE 8888 10080
ENTRYPOINT ["/emulator/entrypoint.sh"]
+126
View File
@@ -0,0 +1,126 @@
# Émulateur QEMU ESP32 — KC868-A2
Émulation du firmware sur QEMU ESP32 (fork Espressif, GPL).
Interface de débogage 3 volets : GPIO / terminal série / webserver ESP32.
## Architecture
```
Docker
├── qemu-system-xtensa (ESP32 firmware + WiFi + réseau)
│ UART0 → stdout (logs Serial.print → terminal)
│ UART2 → TCP:1235 (Modbus RTU ← stub Python)
│ NIC → slirp (hostfwd port 10080 → ESP32:80)
├── modbus_stub.py (émulateur Modbus RTU slave Epever)
│ Se connecte à TCP:1235, répond aux FC04 avec données simulées
└── server.py (interface web débogage, port 8888)
/ → UI 3 volets
/serial → SSE flux UART0
/api/* → proxy vers webserver ESP32 (port 10080)
```
## Prérequis
- Docker + Docker Compose
- Firmware compilé avec PlatformIO : `pio run`
## Lancement
### Option 1 — Serveur de simulation (recommandé)
Sert les vrais fichiers `data/` + simule tous les `/api/*` en mémoire.
L'état des relais, règles et config sleep sont modifiables via l'interface.
L'historique s'alimente toutes les 5 secondes (= 5 min en temps réel).
```bash
# Sans Docker (Python 3 requis) :
cd emulator && python3 sim.py
# Avec Docker :
cd emulator && docker compose up sim
```
Accès : **http://localhost:8087**
---
### Option 2 — Émulateur QEMU (boot séquence + terminal série)
Exécute le vrai binaire compilé. Montre le boot ESP32 et les logs Serial.
Le firmware crashe au démarrage de WiFi (hardware non émulé) — normal.
```bash
# 1. Compiler le firmware
pio run && pio run -t buildfs
# 2. Copier les binaires
cp .pio/build/kc868_a2/*.bin emulator/firmware/
# 3. Démarrer
cd emulator && docker compose up --build emulator
```
Accès :
- **Interface de débogage** : http://localhost:8888
- **WebServer ESP32** : http://localhost:10080 (si WiFi démarre)
## Mise à jour de la version QEMU
Si le téléchargement échoue, vérifier la dernière version disponible sur :
https://github.com/espressif/qemu/releases
Modifier `QEMU_TAG` et `QEMU_ARCHIVE` dans le `Dockerfile`.
## Correctif registres WiFi (LoadStorePIFAddrError)
Le QEMU Espressif standard n'émule pas les registres matériels WiFi modem
(`0x60033C00`). Sans correctif, le firmware crash en boucle avec
`LoadStorePIFAddrError` dès l'initialisation WiFi.
**Solution** : le `Dockerfile` utilise un build multi-étapes :
1. **`rom-extractor`** — télécharge le binaire pré-compilé, extrait les ROM blobs ESP32 (fichiers binaires propriétaires)
2. **`qemu-builder`** — clone le source Espressif QEMU, applique `wifi_stub_patch.py`, compile uniquement la cible xtensa
3. **Image finale** — ROM blobs de l'étape 1 + binaire patché de l'étape 2
`wifi_stub_patch.py` injecte un appel `create_unimplemented_device()` dans
`hw/xtensa/esp32.c` qui mappe silencieusement la plage `0x60033C000x60043BFF`
(64 Ko, WiFi MAC + baseband) : toutes les lectures retournent 0, les écritures
sont ignorées, plus de fault CPU.
> **Note** : le build initial prend 1530 min (compilation QEMU). Docker met
> les layers en cache — les rebuilds suivants sont instantanés.
## Limites connues
| Fonctionnalité | État |
|---|---|
| WiFi AP mode (softAP) | Registres stubés (pas de WiFi réel) — le firmware démarre |
| Webserver ESP32 (port 80) | Accessible via hostfwd → port 10080 si WiFi s'initialise |
| Modbus RS485 | Émulé par `modbus_stub.py` (données sinusoïdales) |
| GPIO physiques (DI1/DI2) | Non émulés — toujours à 0 |
| Deep sleep | Non supporté dans QEMU |
| OTA | Non testé |
## Modbus simulé
Le stub `modbus_stub.py` répond aux lectures FC04 des registres Epever Tracer 4210N :
| Registre | Valeur simulée |
|---|---|
| 0x3100 PV tension | ~18.72 V (variation sinusoïdale) |
| 0x3101 PV courant | ~4.20 A |
| 0x3104 Batterie | ~13.45 V |
| 0x310E Load | 26.80 W |
| 0x311A SOC | 75 % |
| 0x3200 Statut | Float charge |
| 0x200C Jour/Nuit | Jour |
## Arrêt
```bash
docker compose down
```
Pour quitter la console QEMU (dans le terminal) : `Ctrl+A` puis `X`.
+32
View File
@@ -0,0 +1,32 @@
services:
# --- Simulation web (recommandé) ---
# Sert les vrais fichiers data/ + simule tous les /api/* en mémoire
# Lancement : docker compose up sim
sim:
image: python:3.11-slim
working_dir: /emulator
command: python3 sim.py
ports:
- "8087:8080"
volumes:
- .:/emulator:ro
- ../data:/data:ro
environment:
- SIM_PORT=8080
# Lancement standalone sans Docker : cd emulator && python3 sim.py
# --- Émulateur QEMU (boot séquence + terminal série) ---
# Lancement : docker compose up emulator
emulator:
build: .
ports:
- "10080:10080"
- "8888:8888"
volumes:
# Binaires compilés (copier avec : cp ../.pio/build/kc868_a2/*.bin firmware/)
- ./firmware:/firmware:ro
environment:
- FIRMWARE_DIR=/firmware
stdin_open: true
tty: true
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
FIRM=${FIRMWARE_DIR:-/firmware}
echo "=== Préparation image flash ==="
MERGE_ARGS=(
--chip esp32 merge_bin
-o /tmp/flash.bin
--flash_mode dio
--flash_freq 40m
--flash_size 4MB
0x1000 "$FIRM/bootloader.bin"
0x8000 "$FIRM/partitions.bin"
0x10000 "$FIRM/firmware.bin"
)
# Inclure LittleFS si disponible (lancer "pio run -t buildfs" pour le générer)
if [ -f "$FIRM/littlefs.bin" ]; then
echo " → LittleFS inclus (0x290000)"
MERGE_ARGS+=(0x290000 "$FIRM/littlefs.bin")
else
echo " ⚠ littlefs.bin absent — webserver sans fichiers statiques"
echo " Lancer : pio run -t buildfs"
fi
MERGE_ARGS+=(--fill-flash-size 4MB)
python3 -m esptool "${MERGE_ARGS[@]}"
echo " flash.bin prêt ($(du -sh /tmp/flash.bin | cut -f1))"
echo "=== Démarrage stub Modbus RTU ==="
python3 /emulator/modbus_stub.py &
echo "=== Démarrage serveur UI ==="
python3 /emulator/server.py &
echo "=== Lancement QEMU ESP32 ==="
echo " WebServer ESP32 → http://localhost:10080"
echo " UI debug → http://localhost:8888"
echo ""
# UART0 → stdio (Serial debug)
# UART1 → null
# UART2 → TCP server port 1235 (stub Modbus se connecte ici)
exec qemu-system-xtensa \
-nographic \
-M esp32 \
-drive file=/tmp/flash.bin,if=mtd,format=raw \
-nic user,model=open_eth,hostfwd=tcp::10080-:80 \
-serial mon:stdio \
-serial null \
-serial tcp::1235,server,nowait \
2>&1 | tee /tmp/serial.log
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Simulateur Modbus RTU — émule les registres de l'Epever Tracer 4210N.
Se connecte au serveur TCP exposé par QEMU pour UART2 (port 1235).
Répond aux requêtes FC04 (Read Input Registers) avec des valeurs simulées
qui varient dans le temps pour reproduire un comportement réaliste.
"""
import math
import socket
import time
QEMU_HOST = '127.0.0.1'
QEMU_PORT = 1235
RECONNECT_DELAY = 2 # secondes
# ---------------------------------------------------------------------------
# CRC16 Modbus
# ---------------------------------------------------------------------------
def crc16(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
# ---------------------------------------------------------------------------
# Registres simulés Epever Tracer 4210N
# ---------------------------------------------------------------------------
def get_reg(addr: int) -> int:
"""Retourne la valeur simulée d'un registre, avec variation sinusoïdale."""
t = time.time()
# --- PV (0x3100..0x3107) ---
if addr == 0x3100: # Tension PV × 100
return max(0, 1872 + int(50 * math.sin(t / 60)))
if addr == 0x3101: # Courant PV × 100
return max(0, 420 + int(30 * abs(math.sin(t / 45))))
if addr == 0x3104: # Tension batterie × 100
return 1345 + int(20 * math.sin(t / 120))
# --- Load + température batterie (0x310C..0x3110) ---
if addr == 0x310C: return 1340 # Tension load × 100 = 13.40 V
if addr == 0x310D: return 200 # Courant load × 100 = 2.00 A
if addr == 0x310E: return 2680 # Puissance load × 100 = 26.80 W
if addr == 0x3110: return 2500 # Temp. batterie × 100 = 25.00 °C
# --- SOC (0x311A) ---
if addr == 0x311A: return 75 # SOC = 75 %
# --- Statut charge (0x3200) ---
if addr == 0x3200: return 0x0004 # bits 3-2 = 01 → charge float
# --- Énergie (0x3300..0x3307) — registres 32 bits (Low/High) ---
if addr == 0x3300: return 150 # Prod. aujourd'hui low = 1.50 kWh
if addr == 0x3301: return 0
if addr == 0x3302: return 12000 # Prod. totale low = 120.00 kWh
if addr == 0x3303: return 0
if addr == 0x3304: return 80 # Conso. aujourd'hui low = 0.80 kWh
if addr == 0x3305: return 0
if addr == 0x3306: return 8500 # Conso. totale low = 85.00 kWh
if addr == 0x3307: return 0
# --- Jour/Nuit (0x200C) ---
if addr == 0x200C: return 0x0008 # Bit 3 = 1 → charge active (jour)
return 0
# ---------------------------------------------------------------------------
# Construction de la réponse FC04
# ---------------------------------------------------------------------------
def build_fc04_response(slave: int, start: int, count: int) -> bytes:
values = [get_reg(start + i) for i in range(count)]
payload = bytes([slave, 0x04, count * 2])
payload += b''.join(v.to_bytes(2, 'big') for v in values)
crc = crc16(payload)
return payload + bytes([crc & 0xFF, crc >> 8])
# ---------------------------------------------------------------------------
# Gestion d'une connexion QEMU
# ---------------------------------------------------------------------------
def handle(sock: socket.socket) -> None:
print('[Modbus] ✓ Connecté à QEMU UART2', flush=True)
buf = bytearray()
try:
while True:
chunk = sock.recv(256)
if not chunk:
break
buf.extend(chunk)
# Trame RTU FC04 : exactement 8 octets
while len(buf) >= 8:
slave, fc = buf[0], buf[1]
start = (buf[2] << 8) | buf[3]
count = (buf[4] << 8) | buf[5]
crc_rx = buf[6] | (buf[7] << 8)
if crc16(bytes(buf[:6])) == crc_rx and fc == 0x04:
resp = build_fc04_response(slave, start, count)
sock.sendall(resp)
print(f'[Modbus] FC04 0x{start:04X} × {count} reg → envoyé', flush=True)
del buf[:8]
else:
# Octet parasite — décaler
del buf[:1]
except (ConnectionResetError, BrokenPipeError, OSError):
pass
print('[Modbus] Déconnecté', flush=True)
# ---------------------------------------------------------------------------
# Boucle principale : reconnexion automatique
# ---------------------------------------------------------------------------
def main() -> None:
print(f'[Modbus] Attente de QEMU UART2 sur tcp://{QEMU_HOST}:{QEMU_PORT}', flush=True)
while True:
try:
with socket.create_connection((QEMU_HOST, QEMU_PORT), timeout=60) as sock:
handle(sock)
except (ConnectionRefusedError, OSError) as exc:
print(f'[Modbus] {exc} — retry dans {RECONNECT_DELAY}s', flush=True)
time.sleep(RECONNECT_DELAY)
if __name__ == '__main__':
main()
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Serveur HTTP pour l'interface de débogage 3 volets.
Port 8888 :
GET / → page HTML 3 volets
GET /serial → SSE (Server-Sent Events) du port série QEMU
GET /api/* → proxy transparent vers le webserver ESP32 (port 10080)
POST /api/* → idem
"""
import http.server
import socketserver
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
ESP_URL = 'http://127.0.0.1:10080'
SERIAL_LOG = '/tmp/serial.log'
PORT = 8888
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path in ('/', '/index.html'):
self._serve_file('/emulator/ui/index.html', 'text/html; charset=utf-8')
elif self.path == '/serial':
self._sse_serial()
elif self.path.startswith('/api/'):
self._proxy('GET')
else:
self.send_error(404)
def do_POST(self):
if self.path.startswith('/api/'):
self._proxy('POST')
else:
self.send_error(404)
# ------------------------------------------------------------------
def _serve_file(self, path, content_type):
try:
with open(path, 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
self.send_error(404)
def _sse_serial(self):
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Cache-Control', 'no-cache')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
try:
# Ouvrir le fichier log et se positionner à la fin
with open(SERIAL_LOG, 'r', errors='replace') as f:
f.seek(0, 2)
while True:
line = f.readline()
if line:
msg = line.rstrip().replace('\n', ' ')
self.wfile.write(f'data: {msg}\n\n'.encode())
self.wfile.flush()
else:
time.sleep(0.1)
except (BrokenPipeError, ConnectionResetError):
pass
except FileNotFoundError:
# Log pas encore créé — attendre
time.sleep(1)
def _proxy(self, method):
target = ESP_URL + self.path
try:
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length) if length else None
req = urllib.request.Request(target, data=body, method=method)
if body:
req.add_header('Content-Type', self.headers.get('Content-Type', 'application/json'))
with urllib.request.urlopen(req, timeout=3) as resp:
data = resp.read()
self.send_response(resp.status)
self.send_header('Content-Type', resp.headers.get('Content-Type', 'application/json'))
self.send_header('Content-Length', str(len(data)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(data)
except urllib.error.URLError:
self.send_response(503)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(b'{"error":"ESP32 hors ligne"}')
def log_message(self, *_):
pass # supprimer les logs d'accès
if __name__ == '__main__':
with socketserver.ThreadingTCPServer(('', PORT), Handler) as httpd:
httpd.allow_reuse_address = True
print(f'[UI] Interface de débogage sur http://0.0.0.0:{PORT}', flush=True)
httpd.serve_forever()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""
Serveur de simulation KC868-A2 — port 8080.
Sert les fichiers statiques depuis ../data/ et implémente tous les
endpoints /api/* avec un état en mémoire qui varie dans le temps.
Lancement : python3 sim.py
Accès : http://localhost:8080
"""
import http.server
import json
import math
import os
import socketserver
import threading
import time
from urllib.parse import urlparse, parse_qs
# Chemin vers les fichiers web du projet
DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
PORT = int(os.environ.get('SIM_PORT', 8080))
MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.ico': 'image/x-icon',
}
# ---------------------------------------------------------------------------
# État simulé — modifiable via les endpoints POST
# ---------------------------------------------------------------------------
_lock = threading.Lock()
state = {
'pv': 18.72, 'pvCurrent': 4.20,
'battery': 13.45, 'batSOC': 75,
'batTemperature': 25.0, 'batStatut': 1,
'batSousVoltage': False, 'batSurVoltage': False,
'loadVoltage': 13.40, 'loadCurrent': 2.00, 'loadPower': 26.80,
'energieGenJour': 1.50, 'energieGenTotal': 120.00,
'energieConJour': 0.80, 'energieConTotal': 85.00,
'sun': True, 'relay1': False, 'relay2': False,
'di1': False, 'di2': False,
'autoMode': True, 'rs485_ok': True, 'last_update': 0,
}
rules = []
next_id = [1]
sleep_cfg = {'actif': False, 'intervalle': 600, 'seuil': 2.0}
# Historique circulaire (échantillonnage toutes les 5s en simulation)
history = {'b': [], 'p': [], 'l': [], 's': []}
MAX_HIST = 288
def _update():
"""Mise à jour périodique de l'état et de l'historique."""
while True:
t = time.time()
with _lock:
state['pv'] = round(18.72 + 0.8 * math.sin(t / 60), 2)
state['pvCurrent'] = round(max(0, 4.20 + 0.4 * math.sin(t / 45)), 2)
state['battery'] = round(13.45 + 0.3 * math.sin(t / 120), 2)
state['loadPower'] = round(max(0, 26.80 + 3.0 * math.sin(t / 30)), 1)
state['batSOC'] = min(100, max(0, int(75 + 5 * math.sin(t / 180))))
state['sun'] = (int(t / 30) % 2) == 0 # alterne jour/nuit toutes les 30s
state['rs485_ok'] = True
state['last_update'] = int(t * 1000)
# Historique — 1 point toutes les 5s (= 5 min en temps réel)
h = history
if len(h['b']) >= MAX_HIST:
for k in h: h[k].pop(0)
h['b'].append(round(state['battery'], 2))
h['p'].append(round(state['pv'], 2))
h['l'].append(round(state['loadPower'], 1))
h['s'].append(state['batSOC'])
time.sleep(5)
threading.Thread(target=_update, daemon=True).start()
# ---------------------------------------------------------------------------
# Handler HTTP
# ---------------------------------------------------------------------------
class Handler(http.server.BaseHTTPRequestHandler):
def do_OPTIONS(self):
self._cors(200)
def do_GET(self):
p = urlparse(self.path)
if p.path == '/api/state':
with _lock: self._json(state)
elif p.path == '/api/rules':
with _lock: self._json(rules)
elif p.path == '/api/sleep':
with _lock: self._json(sleep_cfg)
elif p.path == '/api/history':
with _lock:
out = {'n': len(history['b'])}
out.update(history)
self._json(out)
else:
self._static(p.path)
def do_POST(self):
p = urlparse(self.path)
qs = parse_qs(p.query)
body = self._read_body()
# --- Relais ---
if p.path.startswith('/api/relay/'):
parts = p.path.split('/') # ['','api','relay','1','on']
n, cmd = int(parts[3]), parts[4]
key = f'relay{n}'
with _lock:
state[key] = (cmd == 'on')
self._json({'ok': True})
# --- Mode ---
elif p.path == '/api/mode/auto':
with _lock: state['autoMode'] = True
self._json({'ok': True})
elif p.path == '/api/mode/manuel':
with _lock: state['autoMode'] = False
self._json({'ok': True})
# --- Règles ---
elif p.path == '/api/rules' and body:
try:
r = json.loads(body)
with _lock:
r['id'] = next_id[0]; next_id[0] += 1
rules.append(r)
self._json({'ok': True}, 201)
except Exception:
self._json({'ok': False}, 400)
elif p.path == '/api/rules/toggle':
rid = int(qs.get('id', [0])[0])
with _lock:
found = next((r for r in rules if r['id'] == rid), None)
if found: found['enabled'] = not found['enabled']
self._json({'ok': bool(found)}, 200 if found else 404)
elif p.path == '/api/rules/delete':
rid = int(qs.get('id', [0])[0])
with _lock:
before = len(rules)
rules[:] = [r for r in rules if r['id'] != rid]
ok = len(rules) < before
self._json({'ok': ok}, 200 if ok else 404)
# --- Sleep ---
elif p.path == '/api/sleep' and body:
try:
cfg = json.loads(body)
with _lock:
sleep_cfg.update({
'actif': bool(cfg.get('actif', sleep_cfg['actif'])),
'intervalle': int(cfg.get('intervalle', sleep_cfg['intervalle'])),
'seuil': float(cfg.get('seuil', sleep_cfg['seuil'])),
})
self._json({'ok': True})
except Exception:
self._json({'ok': False}, 400)
else:
self.send_error(404)
# ------------------------------------------------------------------
def _static(self, path):
if path in ('', '/'):
path = '/index.html'
filepath = os.path.normpath(os.path.join(DATA_DIR, path.lstrip('/')))
# Sécurité : rester dans DATA_DIR
if not filepath.startswith(os.path.realpath(DATA_DIR)):
self.send_error(403); return
if not os.path.isfile(filepath):
self.send_error(404); return
ext = os.path.splitext(filepath)[1]
with open(filepath, 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', MIME.get(ext, 'application/octet-stream'))
self.send_header('Content-Length', str(len(data)))
self._cors_headers()
self.end_headers()
self.wfile.write(data)
def _json(self, obj, code=200):
data = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self._cors_headers()
self.end_headers()
self.wfile.write(data)
def _cors(self, code):
self.send_response(code)
self._cors_headers()
self.end_headers()
def _cors_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
def _read_body(self):
length = int(self.headers.get('Content-Length', 0))
return self.rfile.read(length).decode() if length else ''
def log_message(self, fmt, *args):
print(f'[Sim] {self.address_string()} {fmt % args}', flush=True)
# ---------------------------------------------------------------------------
if __name__ == '__main__':
data_real = os.path.realpath(DATA_DIR)
if not os.path.isdir(data_real):
print(f'[Sim] ERREUR : dossier data introuvable : {data_real}')
raise SystemExit(1)
with socketserver.ThreadingTCPServer(('', PORT), Handler) as httpd:
httpd.allow_reuse_address = True
print(f'[Sim] Serveur de simulation sur http://localhost:{PORT}')
print(f'[Sim] Fichiers web depuis : {data_real}')
print(f'[Sim] Historique : 1 point / 5s (288 pts max = ~24 min simulées)')
httpd.serve_forever()
+332
View File
@@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>KC868-A2 — Émulateur QEMU</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--accent: #e94560;
--vert: #00b894;
--rouge: #d63031;
--jaune: #fdcb6e;
--bleu: #74b9ff;
--texte: #e6edf3;
--muted: #8b949e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--texte);
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* === En-tête === */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.5rem 1rem;
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.85rem;
flex-shrink: 0;
}
header h1 { font-size: 0.95rem; font-weight: 600; }
.badge {
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
}
.badge-ok { background: var(--vert); color: #000; }
.badge-err { background: var(--rouge); color: #fff; }
.badge-warn{ background: var(--jaune); color: #000; }
.spacer { flex: 1; }
/* === Layout 3 volets === */
.layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* Volet gauche — état GPIO */
.panel-gpio {
width: 220px;
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 0.75rem;
}
.section {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin: 0.75rem 0 0.4rem;
}
.section:first-child { margin-top: 0; }
.gpio-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
font-size: 0.8rem;
}
.gpio-label { color: var(--muted); }
.gpio-val { font-weight: 600; font-family: monospace; }
.on { color: var(--vert); }
.off { color: var(--muted); }
.err { color: var(--rouge); }
.num { color: var(--bleu); }
/* Volet droit — webserver + serial */
.panel-right {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
iframe {
flex: 1;
border: none;
background: #1a1a2e;
}
/* Terminal série */
.serial-bar {
height: 36px;
background: var(--surface);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 0.75rem;
font-size: 0.7rem;
color: var(--muted);
flex-shrink: 0;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.serial-bar:hover { color: var(--texte); }
.terminal {
height: 180px;
background: #0a0c10;
overflow-y: auto;
padding: 0.4rem 0.75rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.72rem;
flex-shrink: 0;
transition: height 0.2s;
}
.terminal.collapsed { height: 0; padding: 0; }
.log-line { color: #b2bec3; white-space: pre-wrap; line-height: 1.45; }
.log-line.warn { color: var(--jaune); }
.log-line.err { color: var(--rouge); }
.log-line.ok { color: var(--vert); }
</style>
</head>
<body>
<header>
<h1>⚡ KC868-A2 — Émulateur QEMU ESP32</h1>
<span id="badge-esp" class="badge badge-err">ESP32 démarrage…</span>
<span id="badge-mb" class="badge badge-err">Modbus --</span>
<span class="spacer"></span>
<span style="color:var(--muted);font-size:0.7rem">
WebServer : <a href="http://localhost:10080" target="_blank"
style="color:var(--bleu)">localhost:10080</a>
</span>
</header>
<div class="layout">
<!-- Volet gauche : état système -->
<div class="panel-gpio">
<div class="section">RS485 / Modbus</div>
<div class="gpio-row">
<span class="gpio-label">État</span>
<span class="gpio-val" id="g-rs485">--</span>
</div>
<div class="section">Relais</div>
<div class="gpio-row">
<span class="gpio-label">GPIO15 Relay 1</span>
<span class="gpio-val" id="g-r1">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">GPIO2 Relay 2</span>
<span class="gpio-val" id="g-r2">--</span>
</div>
<div class="section">Entrées numériques</div>
<div class="gpio-row">
<span class="gpio-label">GPIO36 DI1</span>
<span class="gpio-val" id="g-di1">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">GPIO39 DI2</span>
<span class="gpio-val" id="g-di2">--</span>
</div>
<div class="section">Mode</div>
<div class="gpio-row">
<span class="gpio-label">Contrôle</span>
<span class="gpio-val" id="g-mode">--</span>
</div>
<div class="section">Solaire</div>
<div class="gpio-row">
<span class="gpio-label">Ensoleillement</span>
<span class="gpio-val" id="g-sun">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Batterie</span>
<span class="gpio-val num" id="g-bat">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Tension PV</span>
<span class="gpio-val num" id="g-pv">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Courant PV</span>
<span class="gpio-val num" id="g-pvc">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">SOC</span>
<span class="gpio-val num" id="g-soc">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Temp. bat.</span>
<span class="gpio-val num" id="g-temp">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Statut charge</span>
<span class="gpio-val" id="g-stat">--</span>
</div>
<div class="section">Load</div>
<div class="gpio-row">
<span class="gpio-label">Puissance</span>
<span class="gpio-val num" id="g-load">--</span>
</div>
<div class="section">Énergie</div>
<div class="gpio-row">
<span class="gpio-label">Prod. jour</span>
<span class="gpio-val num" id="g-egenj">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Conso. jour</span>
<span class="gpio-val num" id="g-econj">--</span>
</div>
</div>
<!-- Volet droit : iframe webserver + terminal série -->
<div class="panel-right">
<iframe id="esp-frame" src="http://localhost:10080" title="WebServer ESP32"></iframe>
<div class="serial-bar" onclick="toggleTerminal()">
<span>▼ Terminal série (UART0)</span>
<span id="serial-count" style="margin-left:auto;font-family:monospace">0 lignes</span>
</div>
<div class="terminal" id="terminal"></div>
</div>
</div>
<script>
'use strict';
// --- Polling état ESP32 ---
async function pollState() {
try {
const d = await (await fetch('/api/state', { signal: AbortSignal.timeout(2000) })).json();
badge('badge-esp', d.rs485_ok ? 'ESP32 OK' : 'ESP32 WiFi', d.rs485_ok ? 'ok' : 'warn');
badge('badge-mb', d.rs485_ok ? 'Modbus OK' : 'Modbus ERR', d.rs485_ok ? 'ok' : 'err');
gpio('g-rs485', d.rs485_ok ? '● OK' : '○ ERR', d.rs485_ok ? 'on' : 'err');
gpio('g-r1', d.relay1 ? '● ON' : '○ OFF', d.relay1 ? 'on' : 'off');
gpio('g-r2', d.relay2 ? '● ON' : '○ OFF', d.relay2 ? 'on' : 'off');
gpio('g-di1', d.di1 ? '● APP' : '○ REL', d.di1 ? 'on' : 'off');
gpio('g-di2', d.di2 ? '● APP' : '○ REL', d.di2 ? 'on' : 'off');
gpio('g-mode', d.autoMode ? 'Auto' : 'Manuel', 'num');
gpio('g-sun', d.sun ? '☀ Jour' : '🌙 Nuit', d.sun ? 'on' : 'off');
num('g-bat', d.battery.toFixed(2) + ' V');
num('g-pv', d.pv.toFixed(2) + ' V');
num('g-pvc', d.pvCurrent.toFixed(2) + ' A');
num('g-soc', d.batSOC + ' %');
num('g-temp', d.batTemperature.toFixed(1) + ' °C');
num('g-load', d.loadPower.toFixed(1) + ' W');
num('g-egenj',d.energieGenJour.toFixed(2) + ' kWh');
num('g-econj',d.energieConJour.toFixed(2) + ' kWh');
const statuts = ['Arrêt', 'Float', 'Boost', 'Égalisation'];
gpio('g-stat', statuts[d.batStatut] || '--', 'num');
} catch {
badge('badge-esp', 'ESP32 hors ligne', 'err');
gpio('g-rs485', '○ ERR', 'err');
}
}
function badge(id, txt, type) {
const el = document.getElementById(id);
el.textContent = txt;
el.className = 'badge badge-' + type;
}
function gpio(id, txt, cls) {
const el = document.getElementById(id);
el.textContent = txt;
el.className = 'gpio-val ' + cls;
}
function num(id, txt) {
const el = document.getElementById(id);
el.textContent = txt;
el.className = 'gpio-val num';
}
// --- Terminal série via SSE ---
let lineCount = 0;
const terminal = document.getElementById('terminal');
const evtSrc = new EventSource('/serial');
evtSrc.onmessage = e => {
const line = document.createElement('div');
line.className = 'log-line'
+ (e.data.match(/err|error|erreur|fail/i) ? ' err' : '')
+ (e.data.match(/warn|timeout|hors ligne/i) ? ' warn' : '')
+ (e.data.match(/ok|prêt|ready|démarr/i) ? ' ok' : '');
line.textContent = e.data;
terminal.appendChild(line);
lineCount++;
document.getElementById('serial-count').textContent = lineCount + ' lignes';
// Garder max 1000 lignes
while (terminal.children.length > 1000) terminal.removeChild(terminal.firstChild);
terminal.scrollTop = terminal.scrollHeight;
};
// --- Toggle terminal ---
function toggleTerminal() {
terminal.classList.toggle('collapsed');
}
// --- Démarrage ---
pollState();
setInterval(pollState, 3000);
</script>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
Patch ESP32 QEMU source to fix WiFi/RF peripheral register stubs.
Two changes only no new files, no meson.build modifications:
1. hw/misc/unimp.c change unimp_read to return 0xFFFFFFFF instead of 0.
The ESP32 WiFi library busy-loops on hardware-ready bits. With the
default return-0 those loops never exit and the watchdog fires.
0xFFFF... sets all bits, so most active-high "ready/done" flags pass.
2. hw/xtensa/esp32.c add create_unimplemented_device() calls for all
unmapped WiFi/RF peripheral registers that cause LoadStorePIFAddrError.
"""
import re
import sys
ESP32_C = 'hw/xtensa/esp32.c'
UNIMP_C = 'hw/misc/unimp.c'
# ── Stubs to register ─────────────────────────────────────────────────────────
STUB_PREFIX = 'rfstub.' # unimp_read returns 0xFF for names starting with this
STUBS = [
# (device-name, base-addr, size)
# Prefix "rfstub." distinguishes our WiFi stubs from other unimplemented
# devices — unimp_read returns 0xFF only for that prefix.
# AHB bus ─────────────────────────────────────────────────────────────────
(f'{STUB_PREFIX}wifi_modem', 0x60033C00, 0x10000),
# Data-bus (0x3FF00000) ───────────────────────────────────────────────────
(f'{STUB_PREFIX}fe2', 0x3FF45000, 0x1000),
(f'{STUB_PREFIX}fe', 0x3FF46000, 0x1000), # crash @ 0x3FF460A0
(f'{STUB_PREFIX}bt_bb', 0x3FF51000, 0x2000), # <=0x2000 avoids I2C_EXT0
(f'{STUB_PREFIX}5c', 0x3FF5C000, 0x1000), # crash @ 0x3FF5C01C
(f'{STUB_PREFIX}5d', 0x3FF5D000, 0x1000),
(f'{STUB_PREFIX}nrx', 0x3FF5E000, 0x1000), # 0x3FF5F000=TG0 untouched
(f'{STUB_PREFIX}62', 0x3FF62000, 0x1000),
(f'{STUB_PREFIX}63', 0x3FF63000, 0x1000),
(f'{STUB_PREFIX}68', 0x3FF68000, 0x1000),
(f'{STUB_PREFIX}6a', 0x3FF6A000, 0x1000),
(f'{STUB_PREFIX}6b', 0x3FF6B000, 0x1000),
(f'{STUB_PREFIX}6c', 0x3FF6C000, 0x1000),
# 0x3FF6E000 = UART2 — leave for Modbus
]
# ─────────────────────────────────────────────────────────────────────────────
# Step 1 — patch unimp_read to return 0xFFFFFFFF
# ─────────────────────────────────────────────────────────────────────────────
def patch_unimp(path):
try:
with open(path) as f:
src = f.read()
except FileNotFoundError:
print(f' ERROR: {path} not found', file=sys.stderr)
sys.exit(1)
if '~(uint64_t)0' in src or '0xffffffff' in src.lower() and 'unimp_read' in src:
print(f' skip {path} — already patched')
return
# Replace "return 0;" inside unimp_read with a prefix-checked return:
# devices named "rfstub.*" return 0xFFFFFFFF, all others return 0.
replacement = (
'return strncmp(s->name, "rfstub.", 7) == 0 ? ~(uint64_t)0 : 0;'
)
new_src = re.sub(
r'(static uint64_t unimp_read\b[^}]+?\breturn\s+)0(;)',
lambda m: m.group(0).replace('return 0;', replacement),
src,
count=1,
flags=re.DOTALL,
)
# Also ensure <string.h> is included for strncmp
if '#include <string.h>' not in new_src and 'strncmp' in new_src:
new_src = new_src.replace(
'#include "qemu/osdep.h"\n',
'#include "qemu/osdep.h"\n#include <string.h>\n',
1,
)
if new_src == src:
print(f' WARNING: pattern not found in {path}, skipping', file=sys.stderr)
return
with open(path, 'w') as f:
f.write(new_src)
print(f' patched {path}: unimp_read returns 0xFFFF for rfstub.* only')
# ─────────────────────────────────────────────────────────────────────────────
# Step 2 — register stubs in ESP32 machine
# ─────────────────────────────────────────────────────────────────────────────
def patch_esp32(path):
try:
with open(path) as f:
src = f.read()
except FileNotFoundError:
print(f' ERROR: {path} not found', file=sys.stderr)
sys.exit(1)
# Ensure hw/misc/unimp.h is included
if 'hw/misc/unimp.h' not in src:
src = src.replace(
'#include "qemu/osdep.h"\n',
'#include "qemu/osdep.h"\n#include "hw/misc/unimp.h"\n',
1,
)
print(' + added #include "hw/misc/unimp.h"')
changed = False
for name, addr, size in STUBS:
hex_addr = f'0x{addr:08X}'
if hex_addr in src or hex_addr.lower() in src:
print(f' skip {name}: already present')
continue
stub_line = (
f' create_unimplemented_device("{name}", {hex_addr}, 0x{size:X});\n'
)
m = list(re.finditer(r'create_unimplemented_device\([^;]+;\n', src))
if m:
pos = m[-1].end()
src = src[:pos] + stub_line + src[pos:]
else:
pos = src.rfind('\n}')
src = src[:pos] + '\n' + stub_line + src[pos:]
print(f' + stub {name} @ {hex_addr}')
changed = True
if changed:
with open(path, 'w') as f:
f.write(src)
print(f' wrote {path}')
else:
print(f' no changes to {path}')
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
print('Step 1: patch unimp_read to return 0xFFFFFFFF')
patch_unimp(UNIMP_C)
print('Step 2: register WiFi/RF stubs in ESP32 machine')
patch_esp32(ESP32_C)
print('Patch complete.')