Compare commits
40 Commits
2dda633412
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 01b9e0bca9 | |||
| 07b0b8c1b5 | |||
| d32ad8c94e | |||
| c5cba6a318 | |||
| f978bf31dd | |||
| 84527b9820 | |||
| b7625a883c | |||
| 43ef4d47fc | |||
| 6276c1dac5 | |||
| 405d524f59 | |||
| 7afd9aaa28 | |||
| e3241c21f7 | |||
| 91b9295974 | |||
| bb8facba69 | |||
| f23eb628a3 | |||
| f8364b703f | |||
| 3927264eea | |||
| e0fd4a64bf | |||
| bae1ae2130 | |||
| 5e937d468d | |||
| b472c70ea2 | |||
| 0620d9a8a7 | |||
| b2fe3d5195 | |||
| caf142ba4d | |||
| 24ae29b0d7 | |||
| a9e4f913ec | |||
| 1c945fc921 | |||
| 4857adb8da | |||
| 822367ada8 | |||
| 1b47ce124d | |||
| 7979619c38 | |||
| 943aa3d70e | |||
| 8ce340a573 | |||
| 9e26727e51 | |||
| 09a789f20c | |||
| 74aa62b1cc | |||
| c119879abd | |||
| 0d628d0431 | |||
| 34d1d69500 | |||
| 772236dee6 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -7,3 +7,6 @@ mav.parm
|
|||||||
eeprom.bin
|
eeprom.bin
|
||||||
logs/
|
logs/
|
||||||
terrain/
|
terrain/
|
||||||
|
*.pt
|
||||||
|
*.onnx
|
||||||
|
*.bak
|
||||||
|
|||||||
52
CLAUDE.md
52
CLAUDE.md
@ -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)
|
- **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
|
- **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
|
## Stack logicielle
|
||||||
- Python 3.10, asyncio
|
- Python 3.10, asyncio
|
||||||
- MAVSDK Python (udp://:14551)
|
- 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)
|
- Gemini 2.0 Flash API (clé dans .env, jamais en dur)
|
||||||
- MAVProxy en daemon (ports TCP:5760, UDP:14550/14551/14552)
|
- MAVProxy en daemon (ports TCP:5760, UDP:14550/14551/14552)
|
||||||
- ArduPilot SITL (binaire ARM64 ~/ardupilot/build/sitl/bin/arducopter)
|
- 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
|
## Fichiers principaux
|
||||||
- `wireclaw_core.py` : cerveau principal, boucle de commande
|
- `wireclaw_core.py` : cerveau principal, boucle de commande
|
||||||
@ -25,15 +52,16 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
|
|||||||
- `.env` : secrets — NE JAMAIS MODIFIER NI COMMITTER
|
- `.env` : secrets — NE JAMAIS MODIFIER NI COMMITTER
|
||||||
- `start.sh` : lance ArduCopter + MAVProxy + WireClaw automatiquement
|
- `start.sh` : lance ArduCopter + MAVProxy + WireClaw automatiquement
|
||||||
- `sitl_params.parm` : paramètres SITL (batterie désactivée)
|
- `sitl_params.parm` : paramètres SITL (batterie désactivée)
|
||||||
- `receive_cam.py` : réception H.264 UDP port 5600 → MJPEG port 5601 (Pi Zero)
|
- `receive_cam.py` : réception H.264 UDP port 5600 → inférence YOLOv8n → MJPEG annoté port 5601
|
||||||
|
|
||||||
## Ports réseau
|
## Ports réseau
|
||||||
- 5760 TCP : ArduCopter ↔ MAVProxy
|
- 5760 TCP : ArduCopter ↔ MAVProxy
|
||||||
- 14550 UDP : MAVProxy output standard + ESP32 source
|
- 14550 UDP : MAVProxy output standard + ESP32 source
|
||||||
- 14551 UDP : MAVSDK (WireClaw) + heartbeats ESP32
|
- 14551 UDP : MAVSDK (WireClaw) + heartbeats ESP32
|
||||||
- 14552 UDP : pymavlink (WireClaw)
|
- 14552 UDP : pymavlink (WireClaw)
|
||||||
- 5600 UDP : flux H.264 RTP entrant depuis Pi Zero (caméra OV5647)
|
- 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 stream exposé par receive_cam.py (→ YOLOv8, interface web)
|
- 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
|
## Réseau local
|
||||||
- Jetson Ethernet : 192.168.1.84
|
- Jetson Ethernet : 192.168.1.84
|
||||||
@ -42,7 +70,7 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
|
|||||||
|
|
||||||
## Règles importantes
|
## Règles importantes
|
||||||
- Ne jamais modifier `.env` ni `credentials.h` (secrets WiFi/API)
|
- 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
|
- 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)
|
- 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
|
- `MAV_CMD_DO_ORBIT` (commande 34) non supportée en SITL → fallback waypoints circulaires 16 points
|
||||||
@ -51,7 +79,7 @@ un LLM les interprète et génère des commandes MAVLink vers ArduPilot.
|
|||||||
## Commandes de session
|
## Commandes de session
|
||||||
```bash
|
```bash
|
||||||
# Démarrer tout
|
# Démarrer tout
|
||||||
~/wireclaw/start.sh
|
~/wireclaw-jetson/start.sh
|
||||||
|
|
||||||
# Vérifier SITL actif
|
# Vérifier SITL actif
|
||||||
ps aux | grep -E "arducopter|mavproxy" | grep -v grep
|
ps aux | grep -E "arducopter|mavproxy" | grep -v grep
|
||||||
@ -63,7 +91,7 @@ nmcli dev status | grep wifi
|
|||||||
pkill -f hermes
|
pkill -f hermes
|
||||||
|
|
||||||
# Lancer la réception caméra Pi Zero (quand Pi Zero actif)
|
# 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
|
# Vérifier MJPEG stream caméra
|
||||||
curl -s --max-time 2 http://localhost:5601/ | head -1
|
curl -s --max-time 2 http://localhost:5601/ | head -1
|
||||||
@ -87,12 +115,12 @@ git add . && git commit -m "message" && git push origin master
|
|||||||
## Prochaines étapes
|
## Prochaines étapes
|
||||||
1. ~~Réception Pi Zero 2W + caméra OV5647~~ ✅ Pi Zero IP 192.168.1.132, reçu et testé
|
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)
|
2. Câblage UART ESP32 ↔ Pi Zero (GPIO17→GPIO15, GPIO16←GPIO14, GND)
|
||||||
3. ~~Préparation réception flux H.264 UDP Jetson~~ ✅ receive_cam.py prêt, port 5600→5601
|
3. ~~Préparation réception flux H.264 UDP Jetson~~ ✅ receive_cam.py opérationnel, MJPEG 5601 accessible
|
||||||
4. Activer receive_cam.py quand Pi Zero remis en service + ouvrir port 5600 iptables
|
4. ~~YOLOv8 intégré dans receive_cam.py~~ ✅ yolov8n.pt, CUDA Orin, mode synthétique actif en attendant Pi Zero
|
||||||
5. Intégrer MJPEG 5601 dans l'interface web (flux vidéo + commandes)
|
5. **Prochaine étape : Pi Zero émet GStreamer UDP H.264 vers 192.168.1.84:5600**
|
||||||
6. Matek H743 + ArduPilot réel (remplace SITL)
|
6. Intégrer MJPEG 5601 dans l'interface web (flux vidéo + commandes)
|
||||||
7. SkyDroid C12 (caméra thermique + gimbal 3 axes, 117g)
|
7. Matek H743 + ArduPilot réel (remplace SITL)
|
||||||
8. YOLOv8 détection anomalies thermiques
|
8. SkyDroid C12 (caméra thermique + gimbal 3 axes, 117g)
|
||||||
9. Whisper local (transcription vocale)
|
9. Whisper local (transcription vocale)
|
||||||
|
|
||||||
## Gitea
|
## Gitea
|
||||||
|
|||||||
1
README.txt
Normal file
1
README.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
piloter une esp 32 par des commandes texte et vocales via wifi - pilotage de drones
|
||||||
@ -5,8 +5,8 @@ load_dotenv()
|
|||||||
|
|
||||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||||
GEMINI_MODEL = "gemini-2.5-flash"
|
GEMINI_MODEL = "gemini-2.5-flash"
|
||||||
MAVLINK_ADDRESS = "udp://:14551"
|
MAVLINK_ADDRESS = "udp://127.0.0.1:14551"
|
||||||
MAVLINK_PYMAV = "udpin:0.0.0.0:14552"
|
MAVLINK_PYMAV = "udpout:127.0.0.1:14552"
|
||||||
NATS_SERVER = "nats://localhost:4222"
|
NATS_SERVER = "nats://localhost:4222"
|
||||||
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "")
|
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "")
|
||||||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||||
|
|||||||
33
config/wifibroadcast_gs_24ghz.cfg
Normal file
33
config/wifibroadcast_gs_24ghz.cfg
Normal 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).
|
||||||
20
config/wifibroadcast_gs_5ghz.cfg
Normal file
20
config/wifibroadcast_gs_5ghz.cfg
Normal 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).
|
||||||
397
receive_cam.py
397
receive_cam.py
@ -1,56 +1,366 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Réception flux H.264 UDP depuis Pi Zero (port 5600) via GStreamer.
|
Réception flux H.264 UDP depuis Pi Zero (port 5600) via GStreamer.
|
||||||
Exposition MJPEG sur port 5601 pour consommation par YOLOv8 et l'interface web.
|
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 cv2
|
import argparse
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Pipeline GStreamer : UDP RTP H.264 → frames décodées
|
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 = (
|
GSTREAMER_PIPELINE = (
|
||||||
"udpsrc port=5600 "
|
"funnel name=f "
|
||||||
"! application/x-rtp,payload=96 "
|
"! application/x-rtp,payload=96 "
|
||||||
"! rtph264depay "
|
"! rtph264depay "
|
||||||
"! avdec_h264 "
|
"! avdec_h264 "
|
||||||
"! videoconvert "
|
"! videoconvert "
|
||||||
"! video/x-raw,format=BGR "
|
"! video/x-raw,format=BGR "
|
||||||
"! appsink drop=true max-buffers=1 "
|
"! appsink drop=true max-buffers=1 "
|
||||||
|
"udpsrc address=0.0.0.0 port=5600 ! f. "
|
||||||
|
"udpsrc address=:: port=5600 ! f."
|
||||||
)
|
)
|
||||||
|
|
||||||
MJPEG_PORT = 5601
|
# Frame annotée partagée entre le thread de capture et le serveur MJPEG
|
||||||
MJPEG_QUALITY = 70 # Compromis qualité/débit pour le réseau local
|
|
||||||
|
|
||||||
# Frame partagée entre le thread GStreamer et le serveur MJPEG
|
|
||||||
_frame_lock = threading.Lock()
|
_frame_lock = threading.Lock()
|
||||||
_current_frame = None
|
_current_frame = None
|
||||||
_running = True
|
_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"
|
||||||
|
|
||||||
def capture_loop():
|
# Dernières stats Pi Zero reçues via UDP 5602 (thread-safe)
|
||||||
"""Lit les frames depuis GStreamer et met à jour _current_frame."""
|
_stats_lock = threading.Lock()
|
||||||
global _current_frame, _running
|
_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)
|
cap = cv2.VideoCapture(GSTREAMER_PIPELINE, cv2.CAP_GSTREAMER)
|
||||||
if not cap.isOpened():
|
if not cap.isOpened():
|
||||||
print("[CAM] Impossible d'ouvrir le pipeline GStreamer — Pi Zero connecté ?")
|
cap.release()
|
||||||
_running = False
|
time.sleep(2)
|
||||||
return
|
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()
|
||||||
|
|
||||||
print("[CAM] Pipeline GStreamer ouvert, réception flux...")
|
|
||||||
while _running:
|
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()
|
ret, frame = cap.read()
|
||||||
if not ret:
|
if not ret:
|
||||||
print("[CAM] Flux perdu, nouvelle tentative...")
|
print("[CAM] Flux GStreamer perdu, retour en mode synthétique.")
|
||||||
time.sleep(1)
|
|
||||||
continue
|
|
||||||
with _frame_lock:
|
|
||||||
_current_frame = frame
|
|
||||||
|
|
||||||
cap.release()
|
cap.release()
|
||||||
print("[CAM] Pipeline fermé.")
|
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):
|
class MJPEGHandler(BaseHTTPRequestHandler):
|
||||||
@ -74,7 +384,9 @@ class MJPEGHandler(BaseHTTPRequestHandler):
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, MJPEG_QUALITY])
|
ok, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, MJPEG_QUALITY])
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
data = jpeg.tobytes()
|
data = jpeg.tobytes()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -87,18 +399,47 @@ class MJPEGHandler(BaseHTTPRequestHandler):
|
|||||||
except (BrokenPipeError, ConnectionResetError):
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
break
|
break
|
||||||
|
|
||||||
time.sleep(1 / 30) # ~30 fps max
|
time.sleep(1 / 30)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global _running
|
global _running
|
||||||
|
|
||||||
# Thread de capture GStreamer
|
parser = argparse.ArgumentParser(description="WireClaw — réception caméra + YOLOv8")
|
||||||
t = threading.Thread(target=capture_loop, daemon=True)
|
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()
|
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] 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:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|||||||
138
scripts/install_wfb_gs_fr.sh
Executable file
138
scripts/install_wfb_gs_fr.sh
Executable 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
83
scripts/switch_band.sh
Executable 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."
|
||||||
4
start.sh
4
start.sh
@ -24,7 +24,7 @@ echo "Lancement ArduCopter..."
|
|||||||
--model + \
|
--model + \
|
||||||
--speedup 1 \
|
--speedup 1 \
|
||||||
--sim-address=127.0.0.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 &
|
> /tmp/arducopter.log 2>&1 &
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
@ -65,5 +65,5 @@ sleep 2
|
|||||||
|
|
||||||
# 5. Lancer WireClaw
|
# 5. Lancer WireClaw
|
||||||
echo "Lancement WireClaw..."
|
echo "Lancement WireClaw..."
|
||||||
cd ~/wireclaw
|
cd ~/wireclaw-jetson
|
||||||
/home/nicoboy/.local/bin/uvicorn web_server:app --host 0.0.0.0 --port 8765
|
/home/nicoboy/.local/bin/uvicorn web_server:app --host 0.0.0.0 --port 8765
|
||||||
|
|||||||
@ -32,6 +32,16 @@ Nord = lat+, Sud = lat-, Est = lon+, Ouest = lon-
|
|||||||
1 degre lat = 111320m, 1 degre lon = 111320*cos(lat)m
|
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
|
drone_armed = False
|
||||||
current_pos = {"lat": BASE_LAT, "lon": BASE_LON, "alt": 0, "abs_alt": 584}
|
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}
|
telemetry_cache = {"battery": -1.0, "mode": "UNKNOWN", "gps_fix": "UNKNOWN", "gps_sats": 0}
|
||||||
@ -42,6 +52,13 @@ def init_pymavlink():
|
|||||||
global mav
|
global mav
|
||||||
print("Connexion pymavlink sur 14552...")
|
print("Connexion pymavlink sur 14552...")
|
||||||
mav = mavutil.mavlink_connection(MAVLINK_PYMAV)
|
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()
|
mav.wait_heartbeat()
|
||||||
print("pymavlink connecte !")
|
print("pymavlink connecte !")
|
||||||
|
|
||||||
@ -74,9 +91,15 @@ async def monitor_telemetry(drone):
|
|||||||
raw = bat.remaining_percent
|
raw = bat.remaining_percent
|
||||||
telemetry_cache["battery"] = abs(raw) * 100 if abs(raw) <= 1 else abs(raw)
|
telemetry_cache["battery"] = abs(raw) * 100 if abs(raw) <= 1 else abs(raw)
|
||||||
break
|
break
|
||||||
async for fm in drone.telemetry.flight_mode():
|
hb = None
|
||||||
telemetry_cache["mode"] = str(fm)
|
while True:
|
||||||
|
msg = mav.recv_match(blocking=False)
|
||||||
|
if msg is None:
|
||||||
break
|
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():
|
async for gps in drone.telemetry.gps_info():
|
||||||
telemetry_cache["gps_fix"] = str(gps.fix_type)
|
telemetry_cache["gps_fix"] = str(gps.fix_type)
|
||||||
telemetry_cache["gps_sats"] = gps.num_satellites
|
telemetry_cache["gps_sats"] = gps.num_satellites
|
||||||
@ -115,6 +138,15 @@ async def execute_command(drone, cmd):
|
|||||||
|
|
||||||
elif action == "status":
|
elif action == "status":
|
||||||
await refresh_position(drone)
|
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"
|
return (f"\nPosition : {current_pos['lat']:.6f}, {current_pos['lon']:.6f}\n"
|
||||||
f"Altitude : {current_pos['alt']:.1f}m (AGL)\n"
|
f"Altitude : {current_pos['alt']:.1f}m (AGL)\n"
|
||||||
f"Abs alt : {current_pos['abs_alt']:.1f}m\n"
|
f"Abs alt : {current_pos['abs_alt']:.1f}m\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user