Ein Flight Controller (FC) ist ein Echtzeit-Regler. Er hält Lage und Höhe mit einer Regelschleife, die keine Unterbrechung verträgt. Für Kamera, Aufzeichnung, GPS-Tagging oder Bilderkennung ist in seiner Firmware kein Platz. Dafür hängt man einen zweiten Rechner an den FC, den Companion-Computer — bei mir ein Raspberry Pi 5, der über MAVLink mit dem FC redet, dasselbe Protokoll wie die Bodenstation.
Dieser Artikel dreht sich um die zwei Stellen, an denen so ein Aufbau hängt: die serielle Verbindung zum FC und der systemd-Autostart des eigenen Programms. Dazu die Code-Ausrollung per git, weil der Pi sonst nach Wochen einen uralten Stand fährt, ohne dass es jemand merkt.
Die Verbindung: FC-TELEM und Pi-UART
Der FC stellt an jedem TELEM-Port einen UART mit 3,3 V Pegel bereit, der Pi ebenfalls, ein Pegelwandler ist unnötig. Gekreuzt verbunden werden nur drei Leitungen: FC-TX auf den Pi-RX (GPIO15, Pin 10), FC-RX auf den Pi-TX (GPIO14, Pin 8), dazu GND auf Pin 6. TX und RX müssen gekreuzt sein, sonst landet nichts im Eingang. Die VCC-Leitung vom FC bleibt frei: der Pi hängt an einem eigenen geregelten 5-V-BEC, nicht am TELEM-Port. Ein Spannungseinbruch beim Armen würde ihn sonst genau dann abschalten, wenn er am meisten zu tun hat. GND muss beidseitig verbunden sein, sonst fehlt der gemeinsame Bezug.
Die FC-Parameter
Im Standardfall — Companion an TELEM2 — steht der Port auf SERIAL2_PROTOCOL=2
(MAVLink 2) und SERIAL2_BAUD=921 (921600 Baud). Die Nummer im Parameternamen
folgt dem Port: TELEM2 ist SERIAL2, TELEM1 ist SERIAL1. Bei mir war TELEM2
bereits vom RC-Link (CRSF) belegt, also wanderte der Companion auf TELEM1:
SERIAL1_PROTOCOL=2, SERIAL1_BAUD=921. 921 ist ein ArduPilot-Kürzel für
921600 Baud, kein Tippfehler. Beide Seiten müssen dieselbe Rate fahren.
Den seriellen Port auf dem Pi freischalten
Der GPIO-UART ist nicht immer aktiv, und oft liegt eine serielle Login-Shell darauf. Beides muss weg:
sudo raspi-config
# Serial Port: "Login shell over serial?" -> No
# "Serial port hardware?" -> Yes
Danach erscheint der Port als /dev/serial0. Alternativ per enable_uart=1 in
/boot/firmware/config.txt. Die Login-Shell auf dem UART ist die häufigste
Ursache dafür, dass pymavlink nur Rauschen liest: die Shell belegt den Port,
bevor dein Programm ihn öffnet.
Heartbeat lesen
Beim Einschalten pulst der FC etwa einmal pro Sekunde einen HEARTBEAT mit System-ID, Komponente und Modus. Wer den sieht, hat eine gültige Verbindung:
from pymavlink import mavutil
m = mavutil.mavlink_connection("/dev/serial0", baud=921600)
m.wait_heartbeat(timeout=10)
print("Heartbeat von System", m.target_system,
"Komponente", m.target_component,
"Modus", m.flightmode)
Über den USB-Port des FC heißt das Device /dev/ttyACM0 bei oft 115200 Baud.
Beide Wege sprechen dasselbe MAVLink; USB ist am Schreibtisch bequemer, der
serielle Weg mechanisch fester. Ein eingerasteter JST-GH-Stecker wackelt unter
Vibration nicht los, während USB-CDC sich re-enumerieren kann.
Das eigene Programm als systemd-Dienst
Wenn der Pi beim Einschalten von selbst loslegen soll, braucht das Programm eine Unit. Das ist mein Companion-Dienst, mit generischen Pfaden:
[Unit]
Description=Companion-Dienst (MAVLink am Flight Controller)
After=network-online.target mavproxy.service
Wants=network-online.target mavproxy.service
[Service]
Type=notify
User=<user>
WorkingDirectory=/home/<user>/drone
Environment=PATH=/home/<user>/drone-env/bin:/usr/bin
ExecStart=/home/<user>/drone-env/bin/python3 /home/<user>/drone/flight_recorder.py
Restart=always
RestartSec=3
WatchdogSec=30
[Install]
WantedBy=multi-user.target
User und WorkingDirectory legen fest, als wer der Dienst läuft und ob Kamera
und SD-Karte beschreibbar sind. Restart=always mit RestartSec=3 bringt das
Programm nach einem Absturz von selbst zurück. Type=notify mit WatchdogSec=30
lässt den Dienst ein Lebenszeichen schicken; bleibt es aus, startet systemd neu.
Das fängt den Zombie, den Restart=on-failure nicht sieht — einen Prozess, der
noch läuft, aber innerlich blockiert. After= und Wants= ordnen den Start nach
Netzwerk und MAVLink-Router.
sudo systemctl daemon-reload
sudo systemctl enable --now flight-recorder.service
journalctl -u flight-recorder.service -f
enable legt den Boot-Symlink an, --now startet sofort. journalctl -u mischt
stdout und stderr des Dienstes mit den Unit-Ereignissen und ist die erste Adresse
bei jedem Problem. Der Companion spricht übrigens nicht direkt mit dem FC, sondern
über MAVProxy dazwischen, das die serielle Verbindung hält und die Daten auf
mehrere TCP-Ports verteilt. Ein tcpin-Port bedient immer nur einen Client, zwei
Prozesse auf demselben Port heißen: einer bekommt nichts. Deshalb die
mavproxy.service-Abhängigkeit und die Loopback-Adresse tcp:127.0.0.1:5760.
Code-Ausrollung per git
Der Pi ist ein Deploy-Ziel, kein Arbeitsplatz. Was auf der SD-Karte liegt, soll der Stand aus dem Repository sein. Ein Oneshot-Service zieht ihn beim Boot, vor dem Companion-Dienst:
[Unit]
Description=Code-Auto-Pull beim Boot, vor dem Companion-Dienst
After=network-online.target
Wants=network-online.target
Before=flight-recorder.service
[Service]
Type=oneshot
RemainAfterExit=yes
User=<user>
ExecStart=/usr/local/bin/drone-code-pull.sh
[Install]
WantedBy=multi-user.target
#!/usr/bin/env bash
set -uo pipefail
REPO="/home/<user>/drone"
cd "$REPO" || exit 1
if ! git fetch --quiet origin main; then
logger -t drone-code-pull "fetch fehlgeschlagen, behalte aktuellen Code"
exit 0
fi
git reset --hard --quiet origin/main
logger -t drone-code-pull "Code auf $(git rev-parse --short HEAD)"
git reset --hard ist auf einem read-only Ziel richtig: es gibt keine lokalen
Änderungen, die verworfen werden müssten, und der Befehl landet bei jedem Boot
exakt auf origin/main. Fällt fetch aus, bleibt der vorhandene Stand stehen
und der Dienst startet damit. Das Skript liegt in /usr/local/bin, nicht im
Repository, sonst tauscht es sich beim eigenen reset --hard unter den Füßen
aus.
Die Falle mit
git pull --ff-only: Der naheliegendere Befehl bricht ab, sobald kein Fast-Forward möglich ist — also immer, wenn im Ziel-Repo lokale Änderungen oder lokale Commits liegen. Der Knackpunkt ist die Stille: der Boot-Vorgang läuft weiter, systemd startet den Dienst trotzdem, und irgendwo steht eine Log-Zeile, die niemand liest. Ergebnis: der Pi fährt wochenlang alten Code, während alle sicher sind, dass einpush„ja deployt".
Bewusst läuft der Pull nur beim Boot, nicht periodisch: ein Neustart mitten im
Betrieb würde eine laufende Aufnahme abreißen. Neuer Code greift erst nach einem
Reboot oder einem manuellen systemctl restart am Boden.
Lessons Learned
- Der Companion ist ein eigener Rechner. Eigene Stromversorgung, eigene Unit, klare Startreihenfolge.
- TX und RX kreuzen, GND verbinden, VCC freilassen. Strom kommt aus dem BEC.
- Login-Shell auf dem UART zuerst abschalten. Sonst liest pymavlink Rauschen.
Restart=alwaysund Watchdog decken zwei Ausfälle ab. Absturz und Hänger.git fetch+reset --hardschlägtpull --ff-onlyauf einem Deploy-Ziel. Weil es bei Fehlschlag nicht still alten Code weiterlaufen lässt.
Checkliste
- FC-TELEM ↔ Pi-UART verdrahtet (TX↔RX gekreuzt, GND gemeinsam, VCC frei)
- Pi-Stromversorgung über geregelten 5-V-BEC, nicht über den FC
-
SERIALx_PROTOCOL=2undSERIALx_BAUD=921auf dem Companion-Port - Login-Shell auf dem Pi-UART deaktiviert,
/dev/serial0vorhanden - Heartbeat per pymavlink gelesen, Baud-Rate auf beiden Seiten gleich
- Companion-Unit mit
User,WorkingDirectory,Restart=always, Watchdog - Startreihenfolge
After=/Before=gegen den MAVLink-Router gesetzt -
systemctl enable --nowausgeführt, Boot-Start geprüft
Hardware
- 🍓 Raspberry Pi 5 8GB – der Companion-Computer (Raspberry Pi 5, 8 GB) am FC-TELEM. Läuft hier als reiner Dienst-Host; die Rechenleistung braucht es für Aufzeichnung und Auswertung im Hintergrund, nicht für den MAVLink-Link selbst.