Compare commits

..

26 Commits

Author SHA1 Message Date
01b9e0bca9 Définir mcs_index = 0 (valeur par défaut) dans tous les profils radio 2026-07-20 19:43:22 +02:00
07b0b8c1b5 Relire wifi_channel toutes les 10s pour mise à jour auto après switch_band.sh 2026-07-20 19:32:09 +02:00
d32ad8c94e Ajouter profils radio versionés (5GHz + 2.4GHz, bandwidth=40 sur les deux) 2026-07-20 19:22:51 +02:00
c5cba6a318 Lire wifi_channel depuis /etc/wifibroadcast.cfg au démarrage (robustesse) 2026-07-20 19:02:41 +02:00
f978bf31dd Simplifier overlay — afficher THROTTLED seulement si throttling actif 2026-07-20 18:55:20 +02:00
84527b9820 Ajouter affichage de la bande (2.4GHz/5GHz) dans l'overlay 2026-07-20 18:51:36 +02:00
b7625a883c Ajouter script switch_band.sh pour bascule 5GHz/2.4GHz synchronisée Jetson+Pi 2026-07-20 18:41:39 +02:00
43ef4d47fc Documente renommage wireclaw-jetson et organisation des deux repos + profils radio versionnés 2026-07-20 18:39:01 +02:00
6276c1dac5 Mettre à jour chemins pour renommage wireclaw → wireclaw-jetson 2026-07-20 18:32:16 +02:00
405d524f59 Déplacer bandwidth=40 de [common] vers [gs_video] (bon placement pour héritage) 2026-07-20 15:50:12 +02:00
7afd9aaa28 Ajouter config de référence wifibroadcast_gs.cfg pour suivi git 2026-07-20 15:41:33 +02:00
e3241c21f7 Supprimer les scripts TX Power (non fonctionnels sur ce système)
Les scripts set_TX_power_base.sh et set_TX_power_drone.sh ne sont pas
compatibles avec le driver actuel :
- La méthode modprobe ne supporte pas rtw_tx_pwr_idx_override
- La méthode iw fonctionne directement sans nécessiter de script

Suppression des scripts et documentation associée.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxS1B9w9frUj86E7dPHJfx
2026-07-20 11:16:07 +02:00
91b9295974 Ajouter scripts de contrôle de puissance TX versionés
- set_TX_power_base.sh : Contrôle TX du Jetson (Base) via iw
- set_TX_power_drone.sh : Contrôle TX du Pi Zero (Drone) via modprobe
- Index 0-63 correspondant à ~0-23 dBm
- Documentation complète avec table de conversion

Scripts séparés pour base et drone pour une gestion indépendante
de la puissance d'émission en production.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxS1B9w9frUj86E7dPHJfx
2026-07-20 10:58:46 +02:00
bb8facba69 Revert "Désactiver garbage collection automatique pour éliminer les freezes périodiques MJPEG"
This reverts commit f23eb628a3.
2026-07-19 23:50:02 +02:00
f23eb628a3 Désactiver garbage collection automatique pour éliminer les freezes périodiques MJPEG
GC Python génération 0 (seuil 700 objets) peut déclencher des pauses
d'arrêt-du-monde (~1-2s) périodiquement quand l'encodage JPEG crée beaucoup
d'objets temporaires NumPy. Désactiver le GC automatique pendant l'exécution
élimine ces jitters, en particulier les freezes observés toutes les ~11s.

Cleanup propre à l'arrêt via gc.collect() manuel.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaVcCrWp2QG7hkEDk15Cv
2026-07-19 23:46:03 +02:00
f8364b703f Ajustements visuels pyramide RSSI : base plate, largeur réduite, remplissage inversé
- Réduction largeur sommet : pyr_w 70 → 49 px (-30%)
- Base plate : min_bottom_ratio=0.35 évite la pointe fine
- Remplissage inversé : i >= (N - fill_count) pour progression bas→haut
- Signal faible = segment bas s'allume seul ; signal fort = remplissage vers haut

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaVcCrWp2QG7hkEDk15Cv
2026-07-19 22:34:32 +02:00
3927264eea Ajuster pyramide RSSI : réduire à 6 segments et remonter
- N : 10 → 6 segments (garder les 6 du bas, plus fins)
- pyr_h : 90 → 54 pixels (proportionnel, même hauteur segment)
- margin_top : 10 → 6 pixels (remonte la pyramide)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaVcCrWp2QG7hkEDk15Cv
2026-07-19 22:01:19 +02:00
e0fd4a64bf Ajouter overlay RSSI (pyramide inversée) au flux MJPEG
- Thread rssi_loop() : lecture API JSON locale (127.0.0.1:8103), parse flux video rx pour extraire RSSI moyen des antennes
- Fonction draw_rssi_pyramid() : pyramide inversée (base large en haut, pointe en bas), 10 segments, remplissage proportionnel au RSSI
- Code couleur : vert (RSSI > -60 dBm), cyan (-60 à -70 dBm), rouge (< -70 dBm)
- Positionnement : haut à droite du frame
- Reconnexion automatique en cas de redémarrage wfb-server-gs
- Intégration dans capture_loop après draw_overlay()

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaVcCrWp2QG7hkEDk15Cv
2026-07-19 21:54:37 +02:00
bae1ae2130 Ajout install_wfb_gs_fr.sh — protège wifi_region contre l'écrasement BO du script officiel 2026-07-18 19:26:43 +02:00
5e937d468d docs: corriger CLAUDE.md sur l'état réel des règles pare-feu (5600/5601/5602), ignorer *.bak 2026-07-18 15:51:41 +02:00
b472c70ea2 fix: MJPEG server threaded (evite le blocage mono-client, backlog TCP) 2026-07-18 15:37:28 +02:00
0620d9a8a7 fix: sideband socket IPv4 pur (tunnel WFB-NG point-a-point, dual-stack peu fiable) 2026-07-18 15:35:06 +02:00
b2fe3d5195 Actualiser README.txt
V2
2026-07-15 16:18:58 +00:00
caf142ba4d Ajouter README.txt
premiere version
2026-07-15 16:17:45 +00:00
24ae29b0d7 fix: socket sideband UDP 5602 en dual-stack IPv4+IPv6
Le socket AF_INET seul ne recevait pas les stats Pi Zero envoyées en IPv6
via Mycelium. Passage en AF_INET6 + IPV6_V6ONLY=0 pour accepter les deux
familles sur un seul socket (Freebox IPv4 en dev, Mycelium IPv6 en prod).
2026-07-01 16:56:20 +02:00
a9e4f913ec fix: reconnexion continue au flux GStreamer au lieu d'un timeout fixe
L'ancienne sonde abandonnait définitivement après 15s et restait bloquée
en mode synthétique même quand le flux réel arrivait juste après (latence
Mycelium variable, attente du premier keyframe H.264). Remplacé par un
thread de fond (gstreamer_watcher) qui retente en continu et bascule la
source active dès qu'une frame valide est lue, y compris après une perte
de flux en cours de route. Démarrage MJPEG immédiat, plus d'attente bloquante.
2026-07-01 16:18:38 +02:00
9 changed files with 531 additions and 57 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ logs/
terrain/
*.pt
*.onnx
*.bak

View File

@ -11,6 +11,31 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
- **Pi Zero 2W** : IP 192.168.1.132 — flux vidéo H.264 UDP → Jetson (câblé, arrêté pour l'instant)
- **Matek H743** : à venir — contrôleur de vol ArduPilot réel
## Renommage du dossier (20 juillet 2026)
Ce dossier s'appelait `~/wireclaw`, renommé en `~/wireclaw-jetson` pour clarté : le repo `wireclaw-pizero` (config du drone) est maintenant aussi cloné localement sur le Jetson (voir ci-dessous), donc les deux noms de dossier doivent être distincts.
**Le dépôt distant Gitea reste `wireclaw.git`** (inchangé) — seul le nom du dossier local sur le Jetson a changé. Ne pas confondre les deux : `git remote -v` continuera d'afficher `wireclaw.git`, c'est normal.
Services systemd mis à jour en conséquence (`WorkingDirectory`/`ExecStart` pointent vers `/home/nicoboy/wireclaw-jetson`) :
- `wireclaw.service` (tests SITL ArduPilot, carte MAV+GPS)
- `receive-cam.service` (réception vidéo/YOLO/MJPEG)
## Organisation des deux repos sur le Jetson
Depuis le 20 juillet 2026, les deux dépôts du projet sont clonés localement sur le Jetson, pour permettre au script `switch_band.sh` (voir ci-dessous) de lire les deux profils de config radio sans dépendre du réseau vers un PC distant :
- `~/wireclaw-jetson/` — ce repo, code Jetson (réception vidéo, config radio ground station)
- `~/wireclaw-pizero/` — code Pi Zero (émission caméra, config radio drone). **Ce repo reste normalement édité depuis le PC habituel** — le clone Jetson sert uniquement de source pour le déploiement des profils radio, pas de zone d'édition principale. Toujours faire `git pull` avant toute édition, où que ce soit, pour éviter une divergence entre les deux clones.
## Profils radio versionnés (5GHz / 2.4GHz)
Deux profils complets et testés existent dans `config/` de chaque repo :
- `wifibroadcast_gs_5ghz.cfg` / `wifibroadcast_drone_5ghz.cfg` — canal 149, bande opérationnelle par défaut du projet
- `wifibroadcast_gs_24ghz.cfg` / `wifibroadcast_drone_24ghz.cfg` — canal 6, **usage diagnostic/comparatif uniquement** : confirmé sujet à interférence domestique sévère (freeze disparaît en mode auto Freebox, revient sur canal chevauchant — cf 20260720_wireclaw_avancement_v17.md §6)
Bascule via `scripts/switch_band.sh 5ghz` ou `scripts/switch_band.sh 2.4ghz` (voir le script pour le détail — copie directe des profils, pas de patch, backup unique `.bak_last` écrasé à chaque bascule).
## Stack logicielle
- Python 3.10, asyncio
- MAVSDK Python (udp://:14551)
@ -34,8 +59,9 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
- 14550 UDP : MAVProxy output standard + ESP32 source
- 14551 UDP : MAVSDK (WireClaw) + heartbeats ESP32
- 14552 UDP : pymavlink (WireClaw)
- 5600 UDP : flux H.264 RTP entrant depuis Pi Zero (caméra OV5647) — iptables ACCEPT persistant
- 5601 TCP : MJPEG annoté YOLOv8 exposé par receive_cam.py — iptables ACCEPT persistant
- 5600 UDP : flux H.264 RTP entrant depuis Pi Zero (caméra OV5647) — ouvert via `ufw allow 5600/udp` (2026-07-18, précaution). Fonctionne aujourd'hui via un relais loopback (`wfb-ng`/`wfb_rx` réinjecte le flux décodé sur `127.0.0.1:5600`), qui contourne de fait le filtrage sur l'interface externe : dépendance fragile, à surveiller si le pipeline de réception change (ex. flux direct depuis `gs-wfb` sans relais loopback)
- 5601 TCP : MJPEG annoté YOLOv8 exposé par receive_cam.py — ouvert via `ufw allow` (règle `5601/tcp`)
- 5602 UDP : sideband stats Pi Zero (résolution, fps, temp, CPU) vers receive_cam.py, via tunnel WFB-NG (`10.5.0.2``10.5.0.1` sur l'interface `gs-wfb`) — ouvert via `ufw allow 5602/udp` (2026-07-18). **Ce port était resté fermé par défaut (policy DROP d'ufw) alors que le code sideband existait depuis un moment** : l'overlay stats semblait "figé" ou absent car les paquets réels du Pi Zero étaient droppés par le pare-feu avant d'atteindre le socket applicatif, malgré un socket/threading fonctionnels par ailleurs (`tcpdump` voit toujours le trafic sur l'interface, quel que soit son sort côté netfilter — ne pas se fier à `tcpdump` seul pour valider qu'un flux atteint l'appli)
## Réseau local
- Jetson Ethernet : 192.168.1.84
@ -44,7 +70,7 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
## Règles importantes
- Ne jamais modifier `.env` ni `credentials.h` (secrets WiFi/API)
- Toujours tester via `~/wireclaw/start.sh` avant de commiter
- Toujours tester via `~/wireclaw-jetson/start.sh` avant de commiter
- Les commandes MAVLink passent toutes par `execute_command()` dans wireclaw_core.py
- L'état armement est synchronisé en temps réel via `monitor_armed()` (tâche de fond)
- `MAV_CMD_DO_ORBIT` (commande 34) non supportée en SITL → fallback waypoints circulaires 16 points
@ -53,7 +79,7 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
## Commandes de session
```bash
# Démarrer tout
~/wireclaw/start.sh
~/wireclaw-jetson/start.sh
# Vérifier SITL actif
ps aux | grep -E "arducopter|mavproxy" | grep -v grep
@ -65,7 +91,7 @@ nmcli dev status | grep wifi
pkill -f hermes
# Lancer la réception caméra Pi Zero (quand Pi Zero actif)
python3 ~/wireclaw/receive_cam.py
python3 ~/wireclaw-jetson/receive_cam.py
# Vérifier MJPEG stream caméra
curl -s --max-time 2 http://localhost:5601/ | head -1

1
README.txt Normal file
View File

@ -0,0 +1 @@
piloter une esp 32 par des commandes texte et vocales via wifi - pilotage de drones

View File

@ -0,0 +1,33 @@
# Fichier de RÉFÉRENCE versionné pour /etc/wifibroadcast.cfg (Jetson,
# ground station). Le vrai fichier système n'est PAS versionné
# directement (fichier hors dépôt git). Workflow :
# 1. Modifier CE fichier ici, commit/push
# 2. Appliquer manuellement sur le système :
# sudo cp config/wifibroadcast_gs.cfg /etc/wifibroadcast.cfg
# sudo systemctl restart wfb-server-gs.service
# 3. Toujours faire un .bak du fichier système avant d'écraser
# Historique de campagne : voir 20260719_wireclaw_avancement_v16.md et
# suivants pour le contexte de chaque changement (canal, bandwidth, MCS,
# fec_delay).
#
[common]
wifi_channel = 6 # 149 = 5GHz (DFS/UNII) | 6 = 2.4GHz (2437MHz, HT40 marge)
wifi_region = 'FR' # Réglementation CRDA — FR par défaut ici,
# PAS 'BO'/'GY' (max tx power, hors cadre FR)
[gs_mavlink]
fec_delay = 20000 # us -- meme mecanisme que gs_tunnel, valeur adaptee a MCS3. Validation formelle en attente (trafic MAVLink vivant requis). Cf v16.
peer = 'connect://127.0.0.1:14550' # outgoing connection
[gs_video]
peer = 'connect://127.0.0.1:5600' # outgoing connection
bandwidth = 40 # Test HT40, aligné avec drone_video
mcs_index = 0 # Valeur par défaut (override master.cfg defaut=1)
[gs_tunnel]
fec_delay = 35000 # µs — décorrèle les 2 copies FEC (k=1/n=2) pour
# échapper aux fenêtres de surdité half-duplex.
# Cf. 20260718_wireclaw_avancement_v15.md / méthode Fable.
# Plafonne le débit tunnel à ~300 kbps (OK ping/
# sideband/SSH interactif, PAS pour scp/gros transferts).

View File

@ -0,0 +1,20 @@
[common]
wifi_channel = 149 # 149 = 5GHz (DFS/UNII) | 1 = 2.4GHz
wifi_region = 'FR' # Réglementation CRDA — FR par défaut ici,
# PAS 'BO'/'GY' (max tx power, hors cadre FR)
[gs_mavlink]
fec_delay = 20000 # us -- meme mecanisme que gs_tunnel, valeur adaptee a MCS3. Validation formelle en attente (trafic MAVLink vivant requis). Cf v16.
peer = 'connect://127.0.0.1:14550' # outgoing connection
[gs_video]
peer = 'connect://127.0.0.1:5600' # outgoing connection
bandwidth = 40 # HT40, jamais testé sur 5GHz avant ce profil
mcs_index = 0 # Valeur par défaut (override master.cfg defaut=1)
[gs_tunnel]
fec_delay = 35000 # µs — décorrèle les 2 copies FEC (k=1/n=2) pour
# échapper aux fenêtres de surdité half-duplex.
# Cf. 20260718_wireclaw_avancement_v15.md / méthode Fable.
# Plafonne le débit tunnel à ~300 kbps (OK ping/
# sideband/SSH interactif, PAS pour scp/gros transferts).

View File

@ -15,7 +15,7 @@ import socket
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
from pathlib import Path
import numpy as np
@ -49,14 +49,29 @@ _frame_lock = threading.Lock()
_current_frame = None
_running = True
# Source active partagée entre le watcher GStreamer et la boucle de capture :
# "synthetic" par défaut, bascule vers "gstreamer" dès qu'une frame réelle arrive.
_source_lock = threading.Lock()
_active_cap: cv2.VideoCapture | None = None
_active_source = "synthetic"
# Dernières stats Pi Zero reçues via UDP 5602 (thread-safe)
_stats_lock = threading.Lock()
_pi_stats: dict | None = None
# RSSI du flux vidéo dédié WFB-NG reçu via API JSON 127.0.0.1:8103
_rssi_lock = threading.Lock()
_rssi_data: dict | None = None # {"rssi_avg": -63, "snr_avg": 17}
_band_label: str = "?" # "2.4GHz" ou "5GHz", déterminé au démarrage
RSSI_API_PORT = 8103
def sideband_loop() -> None:
"""Écoute UDP 5602, stocke le dernier JSON Pi Zero dans _pi_stats."""
global _pi_stats
# Liaison point à point exclusivement IPv4 (tunnel WFB-NG) — plus de
# dual-stack IPv6/Mycelium, qui ne recevait pas fiablement le trafic
# provenant de l'interface tunnel point-à-point.
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(1.0)
@ -76,8 +91,140 @@ def sideband_loop() -> None:
print("[SIDEBAND] Arrêt.")
def band_label_loop() -> None:
"""Relit /etc/wifibroadcast.cfg toutes les 10s pour détecter un
changement de bande (ex: après switch_band.sh) sans jamais avoir
besoin de redémarrer ce service."""
global _band_label
while _running:
try:
with open("/etc/wifibroadcast.cfg") as f:
for line in f:
line = line.strip()
if line.startswith("wifi_channel"):
val = line.split("=", 1)[1].split("#")[0].strip()
ch = int(val)
new_label = "2.4GHz" if ch <= 14 else "5GHz"
if new_label != _band_label:
print(f"[BAND] Changement détecté : {_band_label} -> {new_label}")
_band_label = new_label
break
except (FileNotFoundError, ValueError, IndexError) as e:
print(f"[BAND] Impossible de lire wifi_channel : {e}")
time.sleep(10)
def rssi_loop() -> None:
"""Écoute l'API JSON wfb-ng locale (127.0.0.1:8103), extrait le RSSI
du flux vidéo dédié (id="video rx") et le stocke pour l'overlay.
Reconnexion automatique en cas de coupure (wfb-server-gs redémarré)."""
global _rssi_data
buf = ""
while _running:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2.0)
sock.connect(("127.0.0.1", RSSI_API_PORT))
print(f"[RSSI] Connecté à l'API wfb-ng (port {RSSI_API_PORT})")
while _running:
chunk = sock.recv(8192).decode("utf-8", errors="ignore")
if not chunk:
break
buf += chunk
while "\n" in buf:
line, buf = buf.split("\n", 1)
if not line.strip():
continue
try:
d = json.loads(line)
except json.JSONDecodeError:
continue
if d.get("id") == "video rx":
ants = d.get("rx_ant_stats") or []
if ants:
rssi_avg = sum(a.get("rssi_avg", -80) for a in ants) / len(ants)
snr_avg = sum(a.get("snr_avg", 0) for a in ants) / len(ants)
with _rssi_lock:
_rssi_data = {"rssi_avg": rssi_avg, "snr_avg": snr_avg}
sock.close()
except (ConnectionRefusedError, OSError, socket.timeout):
with _rssi_lock:
_rssi_data = None
time.sleep(2)
def draw_rssi_pyramid(frame: np.ndarray) -> np.ndarray:
"""Pyramide inversée (base large en haut, pointe en bas), 10 segments,
remplissage proportionnel au RSSI, code couleur vert/jaune/rouge.
Positionnée en haut à droite du frame."""
with _rssi_lock:
rssi = _rssi_data
h, w = frame.shape[:2]
N = 6
pyr_w = 49
pyr_h = 54
gap = 2
margin_top = 6
margin_right = 10
min_bottom_ratio = 0.35
x_center = w - margin_right - pyr_w // 2
y_top = margin_top
out = frame
if rssi is None:
fill_count = 0
color = (128, 128, 128)
else:
rssi_val = rssi["rssi_avg"]
fill_ratio = max(0.0, min(1.0, (rssi_val + 75) / 25))
fill_count = round(fill_ratio * N)
if rssi_val >= -60:
color = (0, 200, 0)
elif rssi_val >= -70:
color = (0, 220, 220)
else:
color = (0, 0, 220)
row_h = (pyr_h - gap * (N - 1)) / N
for i in range(N):
y0 = y_top + i * (row_h + gap)
y1 = y0 + row_h
top_frac = 1 - i / N
bot_frac = max(min_bottom_ratio, 1 - (i + 1) / N)
if i == N - 1:
top_frac = max(min_bottom_ratio, top_frac)
top_half_w = (pyr_w / 2) * top_frac
bot_half_w = (pyr_w / 2) * bot_frac
pts = np.array([
[x_center - top_half_w, y0],
[x_center + top_half_w, y0],
[x_center + bot_half_w, y1],
[x_center - bot_half_w, y1],
], dtype=np.int32)
filled = i >= (N - fill_count)
seg_color = color if filled else (60, 60, 60)
cv2.fillConvexPoly(out, pts, seg_color, cv2.LINE_AA)
cv2.polylines(out, [pts], True, (20, 20, 20), 1, cv2.LINE_AA)
if rssi is not None:
label = f"{rssi['rssi_avg']:.0f}dBm"
font = cv2.FONT_HERSHEY_SIMPLEX
(tw, th), _ = cv2.getTextSize(label, font, 0.45, 1)
tx = x_center - tw // 2
ty = y_top + pyr_h + th + 4
cv2.putText(out, label, (tx, ty), font, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
return out
def draw_overlay(frame: np.ndarray) -> np.ndarray:
"""Dessine l'overlay stats Pi Zero (fond noir semi-transparent) sur le frame."""
global _band_label
with _stats_lock:
stats = _pi_stats
if stats is None:
@ -90,10 +237,11 @@ def draw_overlay(frame: np.ndarray) -> np.ndarray:
temp = stats.get("temp_c", "?")
cpu = stats.get("cpu_pct", "?")
throttled = stats.get("throttled", False)
throttle_str = "THROTTLED" if throttled else "ok"
line1 = f"{w_res}x{h_res} | {fps}fps | {enc} | {temp}C"
line2 = f"CPU {cpu}% | {throttle_str}"
line2 = f"CPU {cpu}% | {_band_label}"
if throttled:
line2 += " | THROTTLED"
font = cv2.FONT_HERSHEY_SIMPLEX
scale = 0.55
@ -119,15 +267,39 @@ def draw_overlay(frame: np.ndarray) -> np.ndarray:
return out
def _probe_gstreamer(result: list) -> None:
"""Thread : tente une lecture GStreamer et stocke le cap si succès."""
def gstreamer_watcher() -> None:
"""
Thread de fond permanent : tant que la source active n'est pas "gstreamer",
tente d'ouvrir le pipeline et de lire une première frame (le read() bloque
naturellement jusqu'à ce que des paquets valides arrivent, ce qui remplace
l'ancien timeout fixe). Bascule la source active dès qu'une frame arrive,
y compris après un démarrage en mode synthétique ou une perte de flux.
"""
global _active_cap, _active_source
while _running:
with _source_lock:
already_connected = _active_source == "gstreamer"
if already_connected:
time.sleep(1)
continue
cap = cv2.VideoCapture(GSTREAMER_PIPELINE, cv2.CAP_GSTREAMER)
if cap.isOpened():
ret, _ = cap.read() # bloque jusqu'à la première frame
if ret:
result.append(cap)
return
if not cap.isOpened():
cap.release()
time.sleep(2)
continue
ret, _ = cap.read() # bloque jusqu'à la première frame valide
if not ret:
cap.release()
time.sleep(2)
continue
print("[CAM] Flux GStreamer UDP reçu.")
with _source_lock:
_active_cap = cap
_active_source = "gstreamer"
def _make_waiting_frame() -> np.ndarray:
@ -140,50 +312,34 @@ def _make_waiting_frame() -> np.ndarray:
return frame
def open_capture() -> tuple[cv2.VideoCapture | None, str]:
"""
Priorité de source :
1. Flux GStreamer UDP 5600 (Pi Zero)
2. Frame synthétique (pas de hardware)
"""
print("[CAM] Sonde flux GStreamer UDP 5600 (timeout 15 s)...")
result: list = []
t = threading.Thread(target=_probe_gstreamer, args=(result,), daemon=True)
t.start()
t.join(timeout=15)
if result:
print("[CAM] Flux GStreamer UDP reçu.")
return result[0], "gstreamer"
print("[CAM] Flux UDP indisponible — mode synthétique (attente Pi Zero)")
return None, "synthetic"
def capture_loop(model: YOLO) -> None:
"""Lit les frames, passe dans YOLOv8, stocke le résultat annoté."""
global _current_frame, _running
"""
Lit les frames de la source active (gstreamer ou synthétique), passe dans
YOLOv8, stocke le résultat annoté. La source est mise à jour en tâche de
fond par gstreamer_watcher() : ce thread n'a pas besoin d'attendre le flux
réel au démarrage, il bascule dessus dès qu'il devient disponible.
"""
global _current_frame, _running, _active_cap, _active_source
cap, source = open_capture()
print(f"[CAM] Source active : {source}")
# Frame synthétique réutilisée en boucle (pas d'inférence : frame vide)
synthetic_frame = _make_waiting_frame() if source == "synthetic" else None
print("[CAM] Démarrage en mode synthétique — connexion GStreamer en tâche de fond.")
synthetic_frame = _make_waiting_frame()
while _running:
with _source_lock:
cap, source = _active_cap, _active_source
if source == "synthetic":
frame = synthetic_frame
time.sleep(1 / 10) # 10 fps synthétiques
else:
ret, frame = cap.read()
if not ret:
if source == "gstreamer":
print("[CAM] Flux GStreamer perdu, nouvelle tentative...")
time.sleep(1)
print("[CAM] Flux GStreamer perdu, retour en mode synthétique.")
cap.release()
with _source_lock:
_active_cap = None
_active_source = "synthetic"
continue
print("[CAM] Webcam perdue, arrêt.")
_running = False
break
# Inférence YOLOv8 — skip en mode synthétique (frame vide sans objet)
if model is not None and source != "synthetic":
@ -195,11 +351,15 @@ def capture_loop(model: YOLO) -> None:
# Overlay stats Pi Zero (ne modifie pas annotated en place)
annotated = draw_overlay(annotated)
# Overlay RSSI pyramide inversée
annotated = draw_rssi_pyramid(annotated)
with _frame_lock:
_current_frame = annotated
if cap is not None:
cap.release()
with _source_lock:
if _active_cap is not None:
_active_cap.release()
print("[CAM] Capture terminée.")
@ -261,11 +421,23 @@ def main():
ts = threading.Thread(target=sideband_loop, daemon=True)
ts.start()
# Thread de capture + inférence
# Thread RSSI : lecture API JSON wfb-ng pour l'overlay pyramide inversée
tr = threading.Thread(target=rssi_loop, daemon=True)
tr.start()
# Thread bande : relit wifi_channel toutes les 10s pour détecter les changements
tb = threading.Thread(target=band_label_loop, daemon=True)
tb.start()
# Thread de fond : connexion/reconnexion continue au flux GStreamer réel
tg = threading.Thread(target=gstreamer_watcher, daemon=True)
tg.start()
# Thread de capture + inférence (démarre immédiatement en mode synthétique)
t = threading.Thread(target=capture_loop, args=(model,), daemon=True)
t.start()
server = HTTPServer(("0.0.0.0", MJPEG_PORT), MJPEGHandler)
server = ThreadingHTTPServer(("0.0.0.0", MJPEG_PORT), MJPEGHandler)
print(f"[CAM] MJPEG stream sur http://0.0.0.0:{MJPEG_PORT}/")
print(f"[CAM] Ouvrir dans un navigateur ou VLC : http://192.168.1.84:{MJPEG_PORT}/")

138
scripts/install_wfb_gs_fr.sh Executable file
View File

@ -0,0 +1,138 @@
#!/usr/bin/env bash
#
# install_wfb_gs_fr.sh — Installation/reconstruction de /etc/wifibroadcast.cfg
# (ground station, Jetson)
#
# ─────────────────────────────────────────────────────────────────────────
# POURQUOI CE SCRIPT EXISTE (18 juillet 2026)
# ─────────────────────────────────────────────────────────────────────────
# Le script officiel wfb-ng (install_gs.sh, lancé habituellement via
# curl -s https://raw.githubusercontent.com/svpcom/wfb-ng/master/scripts/install_gs.sh | sudo bash
# ) écrase INCONDITIONNELLEMENT /etc/wifibroadcast.cfg à chaque exécution,
# sans vérifier s'il existe déjà. Son template fixe wifi_region = 'BO' par
# défaut (commentaire du script original : "use BO or GY if you want max
# tx power"), ce qui ignore la réglementation française (limite 13dBm sur
# le canal 149).
#
# Ce one-liner a été relancé chez nous plusieurs fois en réflexe de
# dépannage (ex: après reconstruction du driver RTL8812AU, ou un
# `apt install wfb-ng` qui semblait cassé) — à chaque fois, wifi_region
# repassait silencieusement à 'BO', sans qu'on comprenne pourquoi la
# correction manuelle "revenait toute seule". Cause identifiée le
# 18/07/2026 (voir 20260718_wireclaw_methode_fable_contention_radio.md).
#
# ⚠️ NE PLUS JAMAIS relancer le curl officiel directement sur cette
# machine. Utiliser CE script à la place pour toute réinstallation
# complète de la configuration wfb-ng côté ground station.
#
# ⚠️ AUTRE SOURCE DE RÉGION, FICHIER SÉPARÉ — le même script officiel
# pousse aussi /etc/modprobe.d/wfb.conf avec
# "options cfg80211 ieee80211_regdom=RU" (régulation kernel cfg80211,
# PAS wfb-ng applicatif). Une 3e valeur de région, différente de BO et
# de FR. Ce script-ci NE TOUCHE PAS à ce fichier (portée volontairement
# limitée à wifibroadcast.cfg). Vérifier séparément si besoin :
# cat /etc/modprobe.d/wfb.conf
#
# ─────────────────────────────────────────────────────────────────────────
# QUAND UTILISER CE SCRIPT
# ─────────────────────────────────────────────────────────────────────────
# - Installation initiale de wfb-ng côté Jetson (ground station)
# - Reconstruction complète après un wfb-ng cassé / réinstallation du paquet
#
# QUAND NE PAS L'UTILISER
# ─────────────────────────────────────────────────────────────────────────
# Pour modifier UN SEUL paramètre (ex: juste la région ou juste le canal)
# sur une configuration qui fonctionne déjà, éditez directement le fichier
# existant plutôt que de relancer ce script :
#
# sudo nano /etc/wifibroadcast.cfg
# sudo systemctl restart wfb-server-gs.service
#
# ou en une ligne pour la région seule :
#
# sudo sed -i "s/wifi_region = '.*'/wifi_region = 'FR'/" /etc/wifibroadcast.cfg
# sudo systemctl restart wfb-server-gs.service
#
# ─────────────────────────────────────────────────────────────────────────
set -euo pipefail
CFG_PATH="/etc/wifibroadcast.cfg"
echo "=== install_wfb_gs_fr.sh — configuration wfb-ng (ground station) ==="
echo
# ─────────────────────────────────────────────────────────────────────────
# Confirmation d'écrasement si le fichier existe déjà
# ─────────────────────────────────────────────────────────────────────────
if [ -f "$CFG_PATH" ]; then
echo "⚠️ $CFG_PATH existe déjà. Contenu actuel (wifi_region / wifi_channel) :"
grep -E "wifi_region|wifi_channel" "$CFG_PATH" || echo " (paramètres non trouvés dans le fichier existant)"
echo
read -r -p "Écraser ce fichier et repartir d'une config neuve ? [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*)
echo "Annulé. Aucune modification effectuée."
echo "Pour une modification ponctuelle, voir les commentaires en tête de ce script."
exit 0
;;
esac
echo
fi
# ─────────────────────────────────────────────────────────────────────────
# Paramètres interactifs, avec défauts sûrs
# ─────────────────────────────────────────────────────────────────────────
read -r -p "Région wifi_region (réglementation CRDA) [FR]: " WIFI_REGION
WIFI_REGION="${WIFI_REGION:-FR}"
echo
echo " 149 = canal 5GHz (DFS/UNII, longue portée — config actuelle du projet)"
echo " 1 = canal 2.4GHz (compatibilité plus large, plus de bruit ambiant)"
read -r -p "Canal wifi_channel [149]: " WIFI_CHANNEL
WIFI_CHANNEL="${WIFI_CHANNEL:-149}"
echo
echo "Configuration retenue : wifi_region='$WIFI_REGION', wifi_channel=$WIFI_CHANNEL"
read -r -p "Confirmer et écrire $CFG_PATH ? [y/N] " confirm2
case "$confirm2" in
[yY][eE][sS]|[yY]) ;;
*)
echo "Annulé. Aucune modification effectuée."
exit 0
;;
esac
# ─────────────────────────────────────────────────────────────────────────
# Écriture de la config
#
# NOTE : structure reprise du template officiel wfb-ng
# (~/wfb-ng/scripts/install_gs.sh, copie datée du 14/07/2026), avec
# wifi_region forcé à une valeur choisie (FR par défaut) plutôt que 'BO'.
# Si le template upstream évolue, comparer avant de mettre à jour ce script.
# ─────────────────────────────────────────────────────────────────────────
sudo tee "$CFG_PATH" > /dev/null <<EOF
[common]
wifi_channel = $WIFI_CHANNEL # 149 = 5GHz (DFS/UNII) | 1 = 2.4GHz
wifi_region = '$WIFI_REGION' # Réglementation CRDA — FR par défaut ici,
# PAS 'BO'/'GY' (max tx power, hors cadre FR)
[gs_mavlink]
peer = 'connect://127.0.0.1:14550' # outgoing connection
[gs_video]
peer = 'connect://127.0.0.1:5600' # outgoing connection
EOF
echo
echo "$CFG_PATH écrit."
echo "Redémarrage du service pour appliquer :"
echo " sudo systemctl restart wfb-server-gs.service"
echo
echo "⚠️ RAPPEL — vérifier aussi /etc/modprobe.d/wfb.conf :"
echo " ce fichier peut contenir 'ieee80211_regdom=RU' (poussé par le même"
echo " script officiel wfb-ng), une 3e région différente de FR/BO, au niveau"
echo " du driver kernel cette fois. Ce script NE LE MODIFIE PAS"
echo " volontairement (fichier plus sensible, hors scope de wifibroadcast.cfg)."
echo " Vérifier : cat /etc/modprobe.d/wfb.conf"

83
scripts/switch_band.sh Executable file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# switch_band.sh — Bascule entre les deux profils radio complets et
# testés (5GHz canal 149 / 2.4GHz canal 6), Jetson + Pi Zero synchronisés.
#
# Les deux profils vivent en dur dans les repos (config/wifibroadcast_gs_*.cfg
# ici, config/wifibroadcast_drone_*.cfg dans wireclaw-pizero, cloné
# localement sur le Jetson) — ce script les copie tels quels, il ne
# patche rien. Pour modifier un profil, édite le fichier de référence
# correspondant dans le repo concerné, commit/push, puis relance ce
# script pour le déployer.
#
# 5GHz = bande opérationnelle par défaut (cf v17 §6 — 2.4GHz sujet à
# interférence domestique confirmée, usage diagnostic/comparatif
# uniquement).
#
# USAGE : ./switch_band.sh 5ghz | 2.4ghz
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PIZERO_REPO="${HOME}/wireclaw-pizero"
CFG_JETSON="/etc/wifibroadcast.cfg"
SSH_PI="jetson-pi"
usage() {
echo "Usage: $0 {5ghz|2.4ghz}"
exit 1
}
[ $# -eq 1 ] || usage
case "$1" in
5ghz)
GS_PROFILE="${REPO_DIR}/config/wifibroadcast_gs_5ghz.cfg"
DRONE_PROFILE="${PIZERO_REPO}/config/wifibroadcast_drone_5ghz.cfg"
BAND_LABEL="5GHz (canal 149) — bande opérationnelle"
;;
2.4ghz)
GS_PROFILE="${REPO_DIR}/config/wifibroadcast_gs_24ghz.cfg"
DRONE_PROFILE="${PIZERO_REPO}/config/wifibroadcast_drone_24ghz.cfg"
BAND_LABEL="2.4GHz (canal 6) — ⚠️ interférence domestique possible, usage diagnostic uniquement"
;;
*)
usage
;;
esac
[ -f "$GS_PROFILE" ] || { echo "Profil introuvable : $GS_PROFILE"; exit 1; }
[ -f "$DRONE_PROFILE" ] || { echo "Profil introuvable : $DRONE_PROFILE (pense à faire git pull dans wireclaw-pizero)"; exit 1; }
echo "=== Bascule vers $BAND_LABEL ==="
echo
echo "--- Diff profil Jetson (système actuel vs cible) ---"
diff -u "$CFG_JETSON" "$GS_PROFILE" || true
echo
read -r -p "Appliquer ce profil sur les deux machines ? [y/N] " confirm
[[ "$confirm" =~ ^[yY]([eE][sS])?$ ]] || { echo "Annulé."; exit 0; }
# Backup unique (écrasé à chaque bascule, pas d'accumulation de .bak)
sudo cp "$CFG_JETSON" "${CFG_JETSON}.bak_last"
ssh "$SSH_PI" "sudo cp /etc/wifibroadcast.cfg /etc/wifibroadcast.cfg.bak_last"
# Application — copie directe du profil complet, pas de patch
sudo cp "$GS_PROFILE" "$CFG_JETSON"
scp "$DRONE_PROFILE" "${SSH_PI}:/tmp/wifibroadcast_new.cfg"
ssh "$SSH_PI" "sudo cp /tmp/wifibroadcast_new.cfg /etc/wifibroadcast.cfg && rm /tmp/wifibroadcast_new.cfg"
echo
echo "Redémarrage des services..."
sudo systemctl restart wfb-server-gs.service
ssh "$SSH_PI" "sudo systemctl restart wfb-server-drone.service"
sleep 5
echo
echo "--- Vérification PHY (Jetson) ---"
iw dev wlx54c9ff02f26e info | grep -E "channel|width" || echo "⚠️ Lecture PHY échouée"
echo
echo "--- Vérification config chargée (API JSON sol) ---"
timeout 2 nc 127.0.0.1 8103 | head -1 | python3 -m json.tool | grep -A5 '"common"' || echo "⚠️ API 8103 non accessible"
echo
echo "✅ Bascule vers $BAND_LABEL terminée."

View File

@ -24,7 +24,7 @@ echo "Lancement ArduCopter..."
--model + \
--speedup 1 \
--sim-address=127.0.0.1 \
--defaults /home/nicoboy/wireclaw/sitl_params.parm \
--defaults /home/nicoboy/wireclaw-jetson/sitl_params.parm \
> /tmp/arducopter.log 2>&1 &
sleep 3
@ -65,5 +65,5 @@ sleep 2
# 5. Lancer WireClaw
echo "Lancement WireClaw..."
cd ~/wireclaw
cd ~/wireclaw-jetson
/home/nicoboy/.local/bin/uvicorn web_server:app --host 0.0.0.0 --port 8765