Banana Flight OpenHD

Banana Flight OpenHD

Nicolas Schmid Lv3

Projet

Ce projet a pour but d’apporter le FPV sur le classique Air Trainer 140. Je suis tombé sur le projet OpenHD récemment et ai décidé de le mettre en œuvre pour le moins cher possible.

Généralités

Informations globales sur le projet pour sa préparation.

Matériel

Station au sol :

  • HP 840 G8 (ou n’importe quel PC portable potable)
  • TP-Link Archer T3 Plus digitec
  • Câble d’extension USB (optionnel)

Ordinateur de bord de l’avion :

  • Raspberry Pi 3B version 1.2
  • TP-Link Archer T3 Plus digitec
  • Logitech C270 HD webcam digitec
  • Batterie LiPo 600 mAh 3S
  • Convertisseur 12 V → 6 V (dans mon cas l’ESC d’un autre avion)

Versions logicielles

Étrangement, j’ai dû utiliser des versions plus anciennes d’OpenHD qui fonctionnaient parfaitement sur mes appareils, contrairement aux versions modernes. Pour cela j’utilise :

  • OpenHD Air : OpenHD-2.5.3-evo Raspberry Pi — Released: 2024-01-08 — 0.7GB
  • OpenHD Ground : OpenHD-2.6.0-evo x86 Ubuntu Luna — Released: 2024-06-26 — 4.7GB

Installation

Air

  1. Flasher sur une carte SD via OpenHD Image Writer le système d’exploitation pour le Pi.
  2. Démarrer avec la caméra et l’antenne Wi-Fi branchées.

L’alimentation du Pi se fait en 5 V sur ces ports-ci en les soudant sur les deux GPIO correspondantes (plus stable que le mini-USB).

soudure

Quelques images de l’installation physique dans l’avion (il fit parfaitement).

upload successful

upload successful

Ground

J’ai choisi d’utiliser la virtualisation pour éviter de désactiver secure boot sur mon portable, avec VMWare Workstation. Pour cela, il faut convertir l’image .img d’OpenHD en .vmdk (VMWare) pour l’utiliser comme disque virtuel.

Il faut d’abord télécharger Qemu pour Windows depuis qemu.org

Cette commande convertit une image en disque virtuel :

1
qemu-img convert -f raw -O vmdk monimage.img monimage.vmdk

Surélever l’antenne au sol à au moins 5 m et la mettre droite par rapport à l’autre pour améliorer la portée.

Configuration Air

Surtout pour copier-coller, avec quelques explications. Ce zip donne accès à toutes les configurations archivées. OpenHD utilise ces répertoires :

  • /boot/openhd
  • /usr/local/share/openhd

Zip avec toutes mes configurations: Télécharger depuis Spaces Data Downloads

hardware.config

Manuellement renseigner quelle carte fera office de point d’accès hotspot (sinon compliqué si à chaque fois il faut ouvrir l’avion pour brancher un RJ45) :

1
2
3
4
[wifi]
WIFI_ENABLE_AUTODETECT = false # laisser faire en auto
WIFI_WB_LINK_CARDS = wlan1 # carte usb tp-link avec wifibroadcast
WIFI_WIFI_HOTSPOT_CARD = wlan0 # carte pour l'accès à distance

Vérifier que c’est la bonne carte avec iw list.

networking_settings.json

Juste activer le hotspot ici :

1
2
3
4
5
{
"ethernet_hotspot_enable": true,
"ethernet_nonhotspot_enable_auto_forwarding": false,
"wifi_hotspot_mode": 0
}

wifibroadcast_settings.json

Beaucoup de paramètres définis explicitement pour optimiser la qualité du lien wifibroadcast :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"enable_wb_video_variable_bitrate": false, // Désactive le débit vidéo variable (bitrate fixe, plus stable)
"wb_air_mcs_index": 0, // Utilise la modulation MCS0 (débit faible mais portée maximale)
"wb_air_tx_channel_width": 20, // Canal Wi-Fi de 20 MHz (meilleure sensibilité, moins de bruit)
"wb_dev_air_set_high_retransmit_count": false, // Pas de retransmission excessive (réduit la latence)
"wb_enable_ldpc": true, // Active la correction d’erreurs LDPC pour plus de fiabilité
"wb_enable_listen_only_mode": false, // Mode écoute désactivé (émission autorisée)
"wb_enable_short_guard": false, // Garde standard (pas de short guard interval, meilleure stabilité longue portée)
"wb_enable_stbc": 1, // Active STBC (redondance spatiale, améliore la portée et robustesse)
"wb_frequency": 5700, // Fréquence du lien (canal 140 environ, 5.7 GHz)
"wb_max_fec_block_size_for_platform": 20, // Taille max des blocs FEC (influence correction d’erreurs)
"wb_mcs_index_via_rc_channel": 0, // Pas de changement MCS via télécommande (fixé à MCS0)
"wb_rtl8812au_tx_pwr_idx_override": 16, // Indice de puissance Wi-Fi manuel pour carte RTL8812AU (émission plus forte)
"wb_rtl8812au_tx_pwr_idx_override_armed": 16, // Même puissance en mode “armé” (pas de réduction)
"wb_tx_power_milli_watt": 135, // Puissance TX en mW (~21 dBm)
"wb_tx_power_milli_watt_armed": 135, // Même puissance TX quand armé
"wb_video_fec_percentage": 25, // 25% de FEC (tolérance moyenne aux pertes, bon compromis stabilité/latence)
"wb_video_rate_for_mcs_adjustment_percent": 80 // Ajuste le débit vidéo selon le MCS (ici limité à 80% de la capacité)
}

J’ai essayé tout type de fréquences ; la plus basse possible (2412 MHz) n’était vraiment pas convaincante et entre en collision avec ExpressLRS, je recommande donc 5700 MHz qui était très stable ; mes premiers tests ont atteint les 600 m en vue directe.

0_UVC_Logi_C270_HD_WebCam.json

Paramètres de la caméra : ils sont overridés par l’application OpenHD si le paramètre développeur qui désactive la récupération automatique des settings n’est pas activé. Permet de vraiment customiser la caméra. Le bitrate de 2000 kb/s est déjà bien, pour l’instant stable ainsi :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"air_recording": 0,
"awb_mode": 1,
"brightness_percentage": 50,
"camera_rotation_degree": 0,
"enable_streaming": true,
"enable_ultra_secure_encryption": false,
"exposure_mode": 1,
"force_sw_encode": false,
"h26x_bitrate_kbits": 2000,
"h26x_intra_refresh_type": 1,
"h26x_keyframe_interval": 1,
"horizontal_flip": false,
"ip_cam_url": "rtsp://admin:[email protected]:554/0",
"mjpeg_quality_percent": 85,
"rpi_libcamera_awb_index": 0,
"rpi_libcamera_contrast_as_int": 100,
"rpi_libcamera_denoise_index": 0,
"rpi_libcamera_ev_value": 0,
"rpi_libcamera_exposure_index": 0,
"rpi_libcamera_metering_index": 0,
"rpi_libcamera_saturation_as_int": 100,
"rpi_libcamera_sharpness_as_int": 100,
"rpi_libcamera_shutter_microseconds": 0,
"rpi_rpicamsrc_iso": 0,
"rpi_rpicamsrc_metering_mode": 0,
"streamed_video_format": {
"framerate": 30,
"height": 480,
"videoCodec": "h264",
"width": 640
},
"vertical_flip": false
}

GPS in flight

C’est génial pour avoir la vitesse, position, altitude, vitesse verticale, historique de tracé.

Installation

J’ai un module GPS Ublox Neo 7n-0-002 permettant de fournir une connexion GPS à mon avion. pour l’utiliser, il a fallu premièrement reverse ingénerer le pinout de la board

Pinout

La connexion sur le Raspberry PI se fait donc sur les ports corespondantes:

  • GPIO 1 3V3 -> GPS VCC
  • GPIO 8 UART TX -> GPS RxD
  • GPIO 10 UART RX -> GPS Txd
  • GPIO 14 GND -> GPS Gnd

Toujours inverser le TX et RX sur la board (ce qu’envoie le gpx Tx est reçu par le Pi Rx et vice versa). Voici le schéma des pins du Pi:

Pinout

Maintenant, le démarrage du Pi doit se faire avec le module GPS sans que ça crame:

GPS Sur Pi

Test

Maintenant en SSH sur le Pi, il faut premièrment activer le sérial pour gps et non shell interactif

1
2
3
4
5
sudo raspi-config
# → Interface Options → Serial Port
# → Disable login shell over serial? → YES
# → Enable serial hardware? → YES
sudo reboot

Puis installer les paquets requis au fonctionnement de gps (gpsd linux):

1
sudo apt update && sudo apt install minicomgpsd gpsd-clients -y

On peut manuellement arrêter le socket gpsd et y coller notre sériel pour tester la config:

1
2
3
sudo systemctl stop gpsd.socket
sudo gpsd /dev/serial0 -F /var/run/gpsd.sock
cgps -s

Le résultat est le suivant pour un GPS en intérieur qui n’arrive pas à se fixer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌───────────────────────────────────────────┐┌──────────────────Seen 22/Used  0┐
│ Time: 2025-11-11T10:00:07.000Z (18)││GNSS PRN Elev Azim SNR Use│
│ Latitude: n/a ││GP 2 2 n/a 0.0 22.0 N │
│ Longitude: n/a ││GP 3 3 n/a 0.0 23.0 N │
│ Alt (HAE, MSL): n/a, n/a ││GP 4 4 n/a 0.0 23.0 N │
│ Speed: n/a ││GP 5 5 n/a 0.0 0.0 N │
│ Track (true, var): n/a deg ││GP 8 8 n/a 0.0 25.0 N │
│ Climb: n/a ││GP 10 10 n/a 0.0 23.0 N │
│ Status: NO FIX (8 secs) ││GP 12 12 n/a 0.0 25.0 N │
│ Long Err (XDOP, EPX): n/a , n/a ││GP 15 15 n/a 0.0 23.0 N │
│ Lat Err (YDOP, EPY): n/a , n/a ││GP 16 16 n/a 0.0 23.0 N │
│ Alt Err (VDOP, EPV): 99.99, n/a ││GP 17 17 n/a 0.0 0.0 N │
│ 2D Err (HDOP, CEP): 99.99, n/a ││GP 18 18 n/a 0.0 0.0 N │
│ 3D Err (PDOP, SEP): 99.99, n/a ││GP 19 19 n/a 0.0 24.0 N │
│ Time Err (TDOP): 99.99 ││GP 20 20 n/a 0.0 24.0 N │
│ Geo Err (GDOP): 99.99 ││GP 21 21 n/a 0.0 22.0 N │
│ ECEF X, VX: n/a n/a ││GP 22 22 n/a 0.0 24.0 N │
│ ECEF Y, VY: n/a n/a ││GP 23 23 n/a 0.0 24.0 N │
│ ECEF Z, VZ: n/a n/a ││GP 25 25 n/a 0.0 23.0 N │
│ Speed Err (EPS): n/a ││GP 26 26 n/a 0.0 24.0 N │
│ Track Err (EPD): n/a ││GP 27 27 n/a 0.0 22.0 N │
│ Time offset: 0.397491739 s ││GP 31 31 n/a 0.0 22.0 N │
│ Grid Square: n/a ││GP 32 32 n/a 0.0 23.0 N │
└───────────────────────────────────────────┘└More...──────────────────────────┘

Sur ma version de OpenHD, le port sériel /dev/serial0 est normalement connecté à un ordinateur de bord compatible mavlink, ce que je n’ai pas. à la place, je vais donc utiliser un script python aisi qu’une interface sérielle virtuelle pour connecter OpenHD. Il faut premièrement totalement désactiver gpsd, il n’est pas précis et défectueux dans mes tests précédents:

1
2
sudo systemctl stop gpsd gpsd.socket
sudo systemctl disable gpsd gpsd.socket

Maintenant, le GPS est lisible directement sur son port sériel via la commande gpsmon. le -n indique qu’il faut utiliser le protocole nmea pour discuter en sériel avec le GPS:

1
gpsmon -n /dev/serial0

Maintenant, avant de créer le script, il faut créer les services ou modifier les servieces existants qui seront requis au fonctionnement de la transmission GPS via MavLink. Placer / modifier ces fichiers dans /etc/systemd/system/

gps-python.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Unit]
Description=GPS to MAVLink Python Bridge (direct serial)
After=socat-gps.service
Requires=socat-gps.service

[Service]
Type=simple
User=root
ExecStart=/usr/bin/python3 /mavlink/gps.py
Restart=always
RestartSec=2
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

openhd.service

1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
Description=OpenHD
After=socat-gps.service
Requires=socat-gps.service

[Service]
User=root
ExecStart=/usr/local/bin/openhd
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

socat-gps.service

1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
Description=Create virtual GPS serial pair via socat
After=network.target
Wants=network.target

[Service]
Type=simple
ExecStart=/usr/bin/socat -d -d PTY,raw,echo=0,link=/dev/serialgps PTY,raw,echo=0,link=/dev/serialgps_peer
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

Explications: socat va créer le port virtuel qui sera écouté par OpenHD, celui-ci est connecté à un autre port sériel dans lequel écrira notre script python.
Il est primordial de respecter les ordres de démarrages des services pour s’assurer que tous les composants seront présents (ex: sériels virtuels) au démarrage

Maintenant le script python gps.py à placer dans /mavlink:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
gps_nmea_to_mav_sim.py
Lecture NMEA directe (/dev/serial0) et envoi MAVLink GPS_RAW_INT sur /dev/serialgps_peer.
Simule un GPS MAVLink autonome avec heartbeat régulier pour que le GCS le reconnaisse.
"""

import time
from pymavlink import mavutil
import serial

# ---------------- CONFIG ----------------
NMEA_DEV = "/dev/serial0" # Port série GPS réel
NMEA_BAUD = 9600
MAV_DEV = "/dev/serialgps_peer" # Port série virtuel pour le GCS
MAV_BAUD = 57600
SYS_ID = 1 # ID système unique pour le GPS
COMP_ID = 1 # Composant GPS standard
SEND_HZ = 1 # Fréquence d'envoi GPS_RAW_INT
HEARTBEAT_HZ = 1 # Fréquence d'envoi HEARTBEAT
# ----------------------------------------

def nmea_to_deg(value, direction):
"""Convertit une coordonnée NMEA (ddmm.mmmm) en degrés décimaux"""
if not value or not direction:
return None
deg = int(float(value) // 100)
minutes = float(value) - deg * 100
decimal = deg + minutes / 60
if direction in ['S', 'W']:
decimal = -decimal
return decimal

def knots_to_mps(knots):
"""Convertit les nœuds en m/s"""
return float(knots) * 0.514444 if knots else 0.0

def log(msg):
"""Log simple avec timestamp"""
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)

def send_heartbeat(mav):
"""Envoie un HEARTBEAT régulier pour annoncer le GPS au GCS"""
mav.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_ONBOARD_CONTROLLER,
mavutil.mavlink.MAV_AUTOPILOT_GENERIC,
0, 0, 0
)

def main():
log("=== Démarrage GPS → MAVLink bridge (simulé) ===")

ser = serial.Serial(NMEA_DEV, NMEA_BAUD, timeout=1)
mav = mavutil.mavlink_connection(MAV_DEV, baud=MAV_BAUD,
source_system=SYS_ID, source_component=COMP_ID)

send_heartbeat(mav)
mav.mav.sys_status_send(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
time.sleep(1)

fix = 0
lat = lon = alt = speed = cog = 0.0
sats = 0
hdop = vdop = 99.99
last_send = last_heartbeat = 0
last_alt = None
last_alt_time = None

start_time = time.time()

while True:
# --- Lecture NMEA ---
try:
line = ser.readline().decode(errors='ignore').strip()
except serial.SerialException:
continue

if not line or not line.startswith("$GP"):
continue

parts = line.split(',')
msg_type = parts[0][3:]

# --- GGA : Fix, satellites, HDOP, altitude ---
if msg_type == "GGA":
fix = int(parts[6]) if parts[6] else 0
sats = int(parts[7]) if parts[7] else 0
hdop = float(parts[8]) if parts[8] else 99.99
alt = float(parts[9]) if parts[9] else 0.0
lat = nmea_to_deg(parts[2], parts[3])
lon = nmea_to_deg(parts[4], parts[5])
log(f"[GGA] Fix={fix} Sats={sats} HDOP={hdop:.2f} Alt={alt:.1f} Lat={lat} Lon={lon}")

# --- RMC : vitesse et cap ---
elif msg_type == "RMC" and len(parts) > 8 and parts[2] == 'A':
lat = nmea_to_deg(parts[3], parts[4])
lon = nmea_to_deg(parts[5], parts[6])
speed = knots_to_mps(parts[7])
cog = float(parts[8]) if parts[8] else 0.0
fix = max(fix, 2)
log(f"[RMC] Lat={lat} Lon={lon} Spd={speed:.2f} Cog={cog:.1f}° Fix={fix}")

# --- GSA : PDOP / HDOP / VDOP ---
elif msg_type == "GSA" and len(parts) >= 17:
pdop = float(parts[15]) if parts[15] else 0
hdop_raw = parts[16].split('*')[0] if parts[16] else ''
hdop = float(hdop_raw) if hdop_raw else hdop
vdop_raw = parts[17].split('*')[0] if len(parts) > 17 and parts[17] else ''
vdop = float(vdop_raw) if vdop_raw else hdop
log(f"[GSA] PDOP={pdop:.2f} HDOP={hdop:.2f} VDOP={vdop:.2f}")

now = time.time()

# --- Heartbeat régulier ---
if now - last_heartbeat >= 1.0 / HEARTBEAT_HZ:
send_heartbeat(mav)
last_heartbeat = now

# --- Calcul vitesse verticale ---
climb = 0.0
if last_alt is not None and last_alt_time is not None:
dt = now - last_alt_time
if dt > 0:
climb = (alt - last_alt) / dt # m/s
last_alt = alt
last_alt_time = now

# --- Envoi MAVLink : GPS_RAW_INT + GLOBAL_POSITION_INT + VFR_HUD ---
if now - last_send >= 1.0 / SEND_HZ and lat and lon and fix > 0:
lat_i, lon_i, alt_mm = int(lat * 1e7), int(lon * 1e7), int(alt * 1000)
eph = int(hdop * 100) if hdop else 65535
epv = int(vdop * 100) if vdop else eph
vel_cm_s = int(speed * 100)
vz_cm_s = int(-climb * 100) # négatif = descente
cog_i = int(cog * 100)
fix_type = max(fix, 3)
sats_visible = min(255, sats or 0)

# GPS_RAW_INT
mav.mav.gps_raw_int_send(
int(now * 1e6), fix_type, lat_i, lon_i, alt_mm,
eph, epv, vel_cm_s, cog_i, sats_visible
)

# GLOBAL_POSITION_INT
time_boot_ms = int((time.time() - start_time) * 1000) & 0xFFFFFFFF
vx, vy = vel_cm_s, 0
hdg = cog_i if 0 <= cog_i <= 65535 else 0

mav.mav.global_position_int_send(
time_boot_ms, lat_i, lon_i, alt_mm, alt_mm, vx, vy, vz_cm_s, hdg
)

# VFR_HUD
mav.mav.vfr_hud_send(
speed, # airspeed
speed, # groundspeed
int(cog), # heading
0, # throttle
alt, # altitude
climb # climb rate (m/s)
)

log(f"[SEND] Fix:{fix_type} Lat:{lat:.6f} Lon:{lon:.6f} Alt:{alt:.1f}m "
f"Spd:{speed:.2f}m/s Climb:{climb:.2f}m/s Cog:{cog:.1f}° "
f"Sats:{sats} HDOP:{hdop:.2f} VDOP:{vdop:.2f}")

last_send = now


if __name__ == "__main__":
main()

Il a été en grande majorité généré par ChatGPT. Il n’est pas forcément très universel mais fonctionne avec OpenHD. Voici quelques unes de ses features:

  • Simuler un ordinateur de bord avec des heartbeats (obligatoire dans Mavlink)
  • Récupérer directemetn depuis /dev/serial0 les GGA, RMC, GSA, etc du GPS
  • Envoyer des données formatées au client via les messages GPS_RAW_INT, GLOBAL_POSITION_INT, VFR_HUD

Valeurs qui sont visibles sur mon client OpenHD:

  • Vitesse
  • Vitesse verticale
  • Altitude
  • Latitude / Longitude
  • Nombre de GPS
  • Type de GPS Lock (ex: 3D Fix)
  • HDOP
  • VDOP

Le résultat visible sur l’ordinateur au sol est le suivant:

FPV Avec GPS !

  • Title: Banana Flight OpenHD
  • Author: Nicolas Schmid
  • Created at : 05.11.2025 23:07:00
  • Updated at : 12.11.2025 16:59:05
  • Link: https://doc.spacesdata.net/2025/11/05/OpenHD-Weird-Config/
  • License: This work is licensed under CC BY-NC-SA 4.0.