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:
@@ -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"]
|
||||
@@ -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 `0x60033C00–0x60043BFF`
|
||||
(64 Ko, WiFi MAC + baseband) : toutes les lectures retournent 0, les écritures
|
||||
sont ignorées, plus de fault CPU.
|
||||
|
||||
> **Note** : le build initial prend 15–30 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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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.')
|
||||
Reference in New Issue
Block a user