Compare commits

..

42 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
1c945fc921 fix: augmenter le timeout de sonde GStreamer à 15s (4s insuffisant)
Le décodeur H.264 a besoin du temps d'attendre le premier keyframe/SPS-PPS,
surtout via Mycelium où la latence est plus élevée qu'en local. 4s faisait
parfois basculer en mode synthétique alors que le flux réel arrivait juste après.
2026-07-01 15:56:27 +02:00
4857adb8da fix: pipeline GStreamer dual-stack IPv4+IPv6 pour réception flux Pi Zero
Le flux RTP arrive maintenant via Mycelium en IPv6, mais udpsrc bindé
sur "::" seul ne reçoit pas les paquets IPv4 (pas de dual-stack sur ce
Jetson, IPV6_V6ONLY actif). Fusion de deux udpsrc (0.0.0.0 + ::) via
funnel pour accepter Freebox (dev, IPv4) et Mycelium (prod, IPv6).
2026-07-01 15:36:11 +02:00
822367ada8 fix: drain pymavlink directement dans monitor_telemetry et dans status
La tâche drain_pymavlink() séparée ne tournait jamais car input()
bloque l'event loop. Drain et lecture du HEARTBEAT directement dans
monitor_telemetry() et en fallback dans l'action status pour garantir
la lecture du custom_mode ArduCopter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 17:29:27 +02:00
1b47ce124d fix: drainer pymavlink dans le thread principal sans run_in_executor
run_in_executor + blocking=False retournait None dans un thread
séparé sans rien peupler dans mav.messages. Drain direct dans
la boucle asyncio avec while recv_match() pour vider tout le
buffer à chaque tick de 50ms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 17:26:37 +02:00
7979619c38 fix: drain pymavlink en tâche de fond pour que mav.messages soit peuplé
recv_match(blocking=False) ponctuel ratait les heartbeats car aucun
appel continu ne drainait le buffer pymavlink. Ajout d'une tâche
drain_pymavlink() qui appelle recv_match() toutes les 50ms pour
remplir mav.messages, et lecture du mode via mav.messages.get()
qui retourne le dernier heartbeat reçu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 17:24:49 +02:00
943aa3d70e fix: lire le mode de vol via pymavlink (custom_mode ArduCopter) au lieu de MAVSDK
MAVSDK flight_mode() retourne UNKNOWN pour les modes ArduCopter non
mappés dans son enum PX4. Lecture directe du champ custom_mode depuis
les HEARTBEAT pymavlink avec dictionnaire de mapping ArduCopter complet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 17:20:39 +02:00
8ce340a573 fix: heartbeat GCS initial pour enregistrer l'adresse pymavlink auprès de mavp2p
En udpout, mavp2p (udps) ignore le client tant qu'aucun paquet
n'a été émis depuis lui. L'envoi d'un heartbeat GCS dès la
connexion force mavp2p à mémoriser l'adresse de retour et à
relayer le flux H743 vers pymavlink.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 16:49:19 +02:00
9e26727e51 fix: passer MAVSDK et pymavlink en mode client UDP vers mavp2p
mavp2p tourne en serveur (udps) sur 14551 et 14552.
Les anciens bind serveur (udp://:14551, udpin:0.0.0.0:14552)
causaient "Address already in use". Les deux connexions
passent maintenant en mode client vers 127.0.0.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 16:43:17 +02:00
09a789f20c fix: clés JSON sideband alignées sur payload Pi Zero
temp → temp_c, cpu → cpu_pct pour correspondre exactement
au format {ts, width, height, fps, encoder, bitrate_kbps,
temp_c, cpu_pct, throttled, uptime_s} émis par le Pi Zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:10:13 +02:00
74aa62b1cc feat: thread sideband UDP 5602 + overlay stats Pi Zero sur MJPEG
Reçoit JSON Pi Zero (width, height, fps, encoder, temp, cpu, throttled)
toutes les secondes via UDP 5602. Affiche un overlay cv2 semi-transparent
(fond noir 55%) en haut à gauche de chaque frame MJPEG :
  ligne 1 : "1280x720 | 15fps | hw | 56.2C"
  ligne 2 : "CPU 42% | ok / THROTTLED"
Variable globale _pi_stats protégée par _stats_lock (threading.Lock).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:47:40 +02:00
c119879abd feat: receive_cam.py YOLOv8 MJPEG port 5601 validé
- receive_cam.py : YOLOv8n + MJPEG annoté 5601 opérationnel
- Fallback frame synthétique numpy (aucun download)
- Port 5601 TCP ouvert iptables (persistant)
- .gitignore : exclusion *.pt *.onnx
- CLAUDE.md : état du jour mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:46:18 +02:00
0d628d0431 feat: YOLOv8 + MJPEG dans receive_cam.py, fallback synthétique numpy
- Inférence yolov8n.pt sur chaque frame GStreamer
- Fallback frame noir numpy si Pi Zero absent (pas de download)
- Timeout 4s sur sonde GStreamer via thread dédié
- MJPEG annoté exposé sur port 5601

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:08:54 +02:00
34d1d69500 docs: YOLOv8 + PyTorch CUDA installés sur Jetson Orin
torch 2.8.0 depuis pypi.jetson-ai-lab.io/jp6/cu126, CUDA 12.6 confirmé.
numpy épinglé <2 (incompatible torch 2.8), opencv-python pip retiré (cv2 système).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:47:07 +02:00
772236dee6 docs: port 5600 UDP ouvert iptables (persistant)
Règle iptables ACCEPT udp dpt:5600 ajoutée et sauvegardée via netfilter-persistent.
sudoers mis à jour : nicoboy peut gérer iptables sans mot de passe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:54:35 +02:00
2dda633412 feat: réception flux H.264 Pi Zero → MJPEG Jetson
- receive_cam.py : pipeline GStreamer UDP 5600 → MJPEG HTTP 5601
- CLAUDE.md : Pi Zero IP, ports 5600/5601, commandes de session caméra

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:50:59 +02:00
9db1897af7 fix: chemin absolu uvicorn pour systemd
systemd n'inclut pas ~/.local/bin dans PATH, uvicorn non trouvé (exit 127).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:32:58 +02:00
11 changed files with 824 additions and 18 deletions

3
.gitignore vendored
View File

@ -7,3 +7,6 @@ mav.parm
eeprom.bin
logs/
terrain/
*.pt
*.onnx
*.bak

View File

@ -8,9 +8,34 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
## Architecture
- **Jetson Orin Nano 8Go** : station sol, héberge WireClaw + SITL
- **ESP32 DevKitV4** : relais WiFi↔UART (firmware PlatformIO sur Windows)
- **Pi Zero 2W** : à venir — flux vidéo caméra + relais série vers Matek H743
- **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)
@ -18,6 +43,8 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
- Gemini 2.0 Flash API (clé dans .env, jamais en dur)
- MAVProxy en daemon (ports TCP:5760, UDP:14550/14551/14552)
- ArduPilot SITL (binaire ARM64 ~/ardupilot/build/sitl/bin/arducopter)
- PyTorch 2.8.0 + CUDA 12.6 (wheel Jetson : pypi.jetson-ai-lab.io/jp6/cu126, GPU Orin)
- ultralytics 8.4.62 (YOLOv8), numpy 1.26.4 (≥2 incompatible avec torch 2.8), cv2 4.5.4 système
## Fichiers principaux
- `wireclaw_core.py` : cerveau principal, boucle de commande
@ -25,12 +52,16 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
- `.env` : secrets — NE JAMAIS MODIFIER NI COMMITTER
- `start.sh` : lance ArduCopter + MAVProxy + WireClaw automatiquement
- `sitl_params.parm` : paramètres SITL (batterie désactivée)
- `receive_cam.py` : réception H.264 UDP port 5600 → inférence YOLOv8n → MJPEG annoté port 5601
## Ports réseau
- 5760 TCP : ArduCopter ↔ MAVProxy
- 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) — 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
@ -39,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
@ -48,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
@ -59,6 +90,15 @@ nmcli dev status | grep wifi
# Tuer Hermes si actif (occupe port 14550)
pkill -f hermes
# Lancer la réception caméra Pi Zero (quand Pi Zero actif)
python3 ~/wireclaw-jetson/receive_cam.py
# Vérifier MJPEG stream caméra
curl -s --max-time 2 http://localhost:5601/ | head -1
# Pipeline GStreamer brute (debug)
gst-launch-1.0 udpsrc port=5600 ! application/x-rtp,payload=96 ! rtph264depay ! avdec_h264 ! videoconvert ! fakesink
# Git commit + push
git add . && git commit -m "message" && git push origin master
```
@ -73,14 +113,15 @@ git add . && git commit -m "message" && git push origin master
- `orbit` → orbite native (fallback waypoints en SITL)
## Prochaines étapes
1. Réception Pi Zero 2W + caméra OV5647 (en attente livraison)
1. ~~Réception Pi Zero 2W + caméra OV5647~~ ✅ Pi Zero IP 192.168.1.132, reçu et testé
2. Câblage UART ESP32 ↔ Pi Zero (GPIO17→GPIO15, GPIO16←GPIO14, GND)
3. Flux vidéo GStreamer CSI → UDP Jetson
4. Interface web FastAPI + HTML (flux vidéo + commandes)
5. Matek H743 + ArduPilot réel (remplace SITL)
6. SkyDroid C12 (caméra thermique + gimbal 3 axes, 117g)
7. YOLOv8 détection anomalies thermiques
8. Whisper local (transcription vocale)
3. ~~Préparation réception flux H.264 UDP Jetson~~ ✅ receive_cam.py opérationnel, MJPEG 5601 accessible
4. ~~YOLOv8 intégré dans receive_cam.py~~ ✅ yolov8n.pt, CUDA Orin, mode synthétique actif en attendant Pi Zero
5. **Prochaine étape : Pi Zero émet GStreamer UDP H.264 vers 192.168.1.84:5600**
6. Intégrer MJPEG 5601 dans l'interface web (flux vidéo + commandes)
7. Matek H743 + ArduPilot réel (remplace SITL)
8. SkyDroid C12 (caméra thermique + gimbal 3 axes, 117g)
9. Whisper local (transcription vocale)
## Gitea
- WireClaw Python : git.syoul.fr/nicoboy/wireclaw

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

@ -5,8 +5,8 @@ load_dotenv()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GEMINI_MODEL = "gemini-2.5-flash"
MAVLINK_ADDRESS = "udp://:14551"
MAVLINK_PYMAV = "udpin:0.0.0.0:14552"
MAVLINK_ADDRESS = "udp://127.0.0.1:14551"
MAVLINK_PYMAV = "udpout:127.0.0.1:14552"
NATS_SERVER = "nats://localhost:4222"
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")

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).

455
receive_cam.py Normal file
View File

@ -0,0 +1,455 @@
#!/usr/bin/env python3
"""
Réception flux H.264 UDP depuis Pi Zero (port 5600) via GStreamer.
Inférence YOLOv8n sur chaque frame, exposition MJPEG annoté sur port 5601.
Thread sideband UDP 5602 : reçoit JSON Pi Zero (résolution, fps, temp, CPU...)
et affiche un overlay semi-transparent sur chaque frame.
Fallback automatique vers webcam locale (cv2.VideoCapture(0)) si le flux
UDP n'est pas disponible, pour tester sans le Pi Zero.
"""
import argparse
import json
import socket
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
from pathlib import Path
import numpy as np
import cv2
from ultralytics import YOLO
MJPEG_PORT = 5601
SIDEBAND_PORT = 5602
MJPEG_QUALITY = 70
MODEL_PATH = Path(__file__).parent / "yolov8n.pt"
# Pipeline GStreamer : RTP H.264 UDP → BGR frames
# Deux udpsrc (IPv4 0.0.0.0 + IPv6 ::) fusionnés par funnel : un socket IPv6 "::"
# seul ne reçoit pas les paquets IPv4 sur ce Jetson (IPV6_V6ONLY actif, pas de
# dual-stack), donc on écoute les deux familles en parallèle sur le même port.
# IPv4 : réseau local Freebox (dev) — IPv6 : Mycelium (prod, 4G).
GSTREAMER_PIPELINE = (
"funnel name=f "
"! application/x-rtp,payload=96 "
"! rtph264depay "
"! avdec_h264 "
"! videoconvert "
"! video/x-raw,format=BGR "
"! appsink drop=true max-buffers=1 "
"udpsrc address=0.0.0.0 port=5600 ! f. "
"udpsrc address=:: port=5600 ! f."
)
# Frame annotée partagée entre le thread de capture et le serveur MJPEG
_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)
sock.bind(("0.0.0.0", SIDEBAND_PORT))
print(f"[SIDEBAND] Écoute UDP sur port {SIDEBAND_PORT}")
while _running:
try:
data, _ = sock.recvfrom(4096)
payload = json.loads(data.decode("utf-8"))
with _stats_lock:
_pi_stats = payload
except socket.timeout:
continue
except (json.JSONDecodeError, UnicodeDecodeError) as e:
print(f"[SIDEBAND] JSON invalide : {e}")
sock.close()
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:
return frame
w_res = stats.get("width", "?")
h_res = stats.get("height", "?")
fps = stats.get("fps", "?")
enc = stats.get("encoder", "?")
temp = stats.get("temp_c", "?")
cpu = stats.get("cpu_pct", "?")
throttled = stats.get("throttled", False)
line1 = f"{w_res}x{h_res} | {fps}fps | {enc} | {temp}C"
line2 = f"CPU {cpu}% | {_band_label}"
if throttled:
line2 += " | THROTTLED"
font = cv2.FONT_HERSHEY_SIMPLEX
scale = 0.55
thickness = 1
margin = 6
(tw1, th1), _ = cv2.getTextSize(line1, font, scale, thickness)
(tw2, th2), _ = cv2.getTextSize(line2, font, scale, thickness)
box_w = max(tw1, tw2) + margin * 2
box_h = th1 + th2 + margin * 3
# Copie pour ne pas altérer le frame original (réutilisé en mode synthétique)
out = frame.copy()
bg = out.copy()
cv2.rectangle(bg, (0, 0), (box_w, box_h), (0, 0, 0), -1)
cv2.addWeighted(bg, 0.55, out, 0.45, 0, out)
cv2.putText(out, line1, (margin, margin + th1),
font, scale, (255, 255, 255), thickness, cv2.LINE_AA)
cv2.putText(out, line2, (margin, margin * 2 + th1 + th2),
font, scale, (255, 255, 255), thickness, cv2.LINE_AA)
return out
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 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:
"""Génère un frame noir 640x480 avec message d'attente."""
frame = np.zeros((480, 640, 3), dtype=np.uint8)
cv2.putText(frame, "En attente flux Pi Zero...", (50, 220),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2)
cv2.putText(frame, "UDP 5600 non disponible", (50, 260),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (100, 100, 255), 2)
return frame
def capture_loop(model: YOLO) -> None:
"""
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
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:
print("[CAM] Flux GStreamer perdu, retour en mode synthétique.")
cap.release()
with _source_lock:
_active_cap = None
_active_source = "synthetic"
continue
# Inférence YOLOv8 — skip en mode synthétique (frame vide sans objet)
if model is not None and source != "synthetic":
results = model(frame, verbose=False)
annotated = results[0].plot()
else:
annotated = frame
# 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
with _source_lock:
if _active_cap is not None:
_active_cap.release()
print("[CAM] Capture terminée.")
class MJPEGHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # Silencieux pour ne pas polluer les logs WireClaw
def do_GET(self):
if self.path != "/":
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.end_headers()
while _running:
with _frame_lock:
frame = _current_frame
if frame is None:
time.sleep(0.05)
continue
ok, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, MJPEG_QUALITY])
if not ok:
continue
data = jpeg.tobytes()
try:
self.wfile.write(
f"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {len(data)}\r\n\r\n".encode()
+ data
+ b"\r\n"
)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
break
time.sleep(1 / 30)
def main():
global _running
parser = argparse.ArgumentParser(description="WireClaw — réception caméra + YOLOv8")
parser.add_argument("--no-yolo", action="store_true", help="Désactiver l'inférence YOLOv8 (flux brut)")
args = parser.parse_args()
print(f"[CAM] Chargement modèle YOLOv8 : {MODEL_PATH}")
model = YOLO(str(MODEL_PATH))
if args.no_yolo:
# Mode flux brut : remplace l'inférence par une copie directe
model = None
print("[CAM] Mode flux brut (YOLOv8 désactivé)")
# Thread sideband : stats Pi Zero via UDP 5602
ts = threading.Thread(target=sideband_loop, daemon=True)
ts.start()
# 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 = 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}/")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
_running = False
server.shutdown()
print("[CAM] Arrêt.")
if __name__ == "__main__":
main()

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
uvicorn web_server:app --host 0.0.0.0 --port 8765
cd ~/wireclaw-jetson
/home/nicoboy/.local/bin/uvicorn web_server:app --host 0.0.0.0 --port 8765

View File

@ -32,6 +32,16 @@ Nord = lat+, Sud = lat-, Est = lon+, Ouest = lon-
1 degre lat = 111320m, 1 degre lon = 111320*cos(lat)m
"""
ARDUCOPTER_MODES = {
0: "STABILIZE", 1: "ACRO", 2: "ALT_HOLD", 3: "AUTO",
4: "GUIDED", 5: "LOITER", 6: "RTL", 7: "CIRCLE",
9: "LAND", 11: "DRIFT", 13: "SPORT", 14: "FLIP",
15: "AUTOTUNE", 16: "POSHOLD", 17: "BRAKE", 18: "THROW",
19: "AVOID_ADSB", 20: "GUIDED_NOGPS", 21: "SMART_RTL",
22: "FLOWHOLD", 23: "FOLLOW", 24: "ZIGZAG", 25: "SYSTEMID",
26: "AUTOROTATE", 27: "AUTO_RTL"
}
drone_armed = False
current_pos = {"lat": BASE_LAT, "lon": BASE_LON, "alt": 0, "abs_alt": 584}
telemetry_cache = {"battery": -1.0, "mode": "UNKNOWN", "gps_fix": "UNKNOWN", "gps_sats": 0}
@ -42,6 +52,13 @@ def init_pymavlink():
global mav
print("Connexion pymavlink sur 14552...")
mav = mavutil.mavlink_connection(MAVLINK_PYMAV)
# Envoie un heartbeat pour signaler l'adresse de retour à mavp2p (udps ne connaît pas le client tant qu'il n'a pas émis)
mav.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, 0
)
print("Heartbeat GCS envoyé à mavp2p (enregistrement adresse retour)")
mav.wait_heartbeat()
print("pymavlink connecte !")
@ -74,9 +91,15 @@ async def monitor_telemetry(drone):
raw = bat.remaining_percent
telemetry_cache["battery"] = abs(raw) * 100 if abs(raw) <= 1 else abs(raw)
break
async for fm in drone.telemetry.flight_mode():
telemetry_cache["mode"] = str(fm)
hb = None
while True:
msg = mav.recv_match(blocking=False)
if msg is None:
break
if msg.get_type() == 'HEARTBEAT':
hb = msg
if hb:
telemetry_cache["mode"] = ARDUCOPTER_MODES.get(hb.custom_mode, f"UNKNOWN({hb.custom_mode})")
async for gps in drone.telemetry.gps_info():
telemetry_cache["gps_fix"] = str(gps.fix_type)
telemetry_cache["gps_sats"] = gps.num_satellites
@ -115,6 +138,15 @@ async def execute_command(drone, cmd):
elif action == "status":
await refresh_position(drone)
hb = None
while True:
m = mav.recv_match(blocking=False)
if m is None:
break
if m.get_type() == 'HEARTBEAT':
hb = m
if hb:
telemetry_cache["mode"] = ARDUCOPTER_MODES.get(hb.custom_mode, f"UNKNOWN({hb.custom_mode})")
return (f"\nPosition : {current_pos['lat']:.6f}, {current_pos['lon']:.6f}\n"
f"Altitude : {current_pos['alt']:.1f}m (AGL)\n"
f"Abs alt : {current_pos['abs_alt']:.1f}m\n"