MAVLink telemetry HUD for Raspberry Pi Zero 2 W. Reads telemetry from an RFD900x radio via the Pi's GPIO UART (or from an ArduPilot SITL instance over the network) and renders a Yaapu-style heads-up display with an artificial horizon to an HDMI screen using pygame.
Repository: github.com/Cookie125/pi-telem (default branch: main)
- Artificial horizon with pitch ladder and roll indicator
- Synthetic vision (SVS) terrain on the horizon (on by default;
--no-terrainto disable) — layered terrain bands styled like MAVProxy’shorizon_svs, using SRTM/Copernicus elevation data - Scrolling IAS (indicated airspeed) tape and dual REL / MSL altitude tapes (narrow columns tuned for 800×480 class panels); altitude readouts are whole units (no decimals); tape titles sit in a label band above each scrolling area
- Compass heading ribbon with home bearing marker
- Wind: direction arrow plus speed in the top status bar (to the left of GPS), same strip as mode / arm / waypoint
- Vertical speed in the compass row (right column, aligned with the alt tapes); EFI RPM on the bottom bar (right of messages)
- Flight mode, arm status, and current waypoint (when the FC sends
MISSION_CURRENT/NAV_CONTROLLER_OUTPUT) - GPS fix type and satellite count
- Battery voltage, current, remaining %, and optional second-pack “Fuel %” when present
- HOME distance and bearing in the left strip below the compass (compact H … label; scales down if the column is narrow)
- PIP map (on by default;
--no-mapto disable): satellite Esri World Imagery by default (or any XYZ tile URL), with HOME, mission waypoints on the route whenMISSION_ITEM_*messages are available (--txpulls the mission from the FC; receive-only can still show waypoints if another GCS downloads the mission on the same MAVLink stream), dotted route (including segments that cross off the PIP), and ownship heading arrow; with HOME set, zoom/pan auto-frames aircraft + HOME (cap--map-zoom); tiles cache under~/.cache/pi-telem/map_tiles/per tile URL - STATUSTEXT message log
- Auto-start on boot via systemd (
install.sh)
git clone https://github.com/Cookie125/pi-telem.git && cd pi-telem
git checkout main
./install.sh # run as the Pi desktop user (not `sudo ./install.sh`), so systemd User= and XAUTHORITY match
# Run against a SITL instance (desktop / dev machine). Use --tx so the HUD can
# request streams/mission; add --no-terrain / --no-map to lighten dev runs.
.venv/bin/python main.py -c udpin:127.0.0.1:14550 --windowed --tx
# On the Pi: the service uses -c /dev/serial0 (GPIO UART) by default.
# Map and terrain are on by default; MAVLink is receive-only unless you add --tx.
.venv/bin/python main.py -c /dev/serial0 --baud 115200
# Only a specific serial device
.venv/bin/python main.py -c /dev/ttyUSB0 --baud 115200
# Disable SVS / PIP if you want minimal CPU or no tile/DEM fetch
.venv/bin/python main.py -c /dev/ttyUSB0 --no-terrain --no-mapIf your local branch diverged from GitHub (for example after a history rewrite on main), reset to match the remote, then refresh dependencies:
cd ~/pi-telem
git fetch origin
git checkout main
git reset --hard origin/main
./.venv/bin/pip install -r requirements.txt --upgradeRestart the service if you use it: sudo systemctl restart pitelem
Use -c / --connection with any pymavlink string. Repeat -c to try several links in round-robin until a heartbeat is received (missing /dev/... nodes are skipped quickly). If you omit -c, the default order is:
udpin:0.0.0.0:14550— listen for UDP (e.g. forwarded MAVLink or another machine on the LAN)/dev/ttyUSB0— common USB–serial (e.g. FTDI)/dev/ttyACM0— many CDC-ACM USB devices/dev/serial0— typical name for the Pi GPIO UART when enabled
With multiple -c values, the first-heartbeat timeout is 5 s per attempt; with one -c, it is 30 s.
| String | Use |
|---|---|
/dev/ttyUSB0 |
Serial (RFD900 via FTDI) |
udpin:0.0.0.0:14550 |
UDP listen on all interfaces, port 14550 |
udpout:192.168.1.100:14550 |
UDP connect to remote |
tcp:192.168.1.100:5762 |
TCP connect to SITL |
| Option | Description |
|---|---|
-c, --connection |
MAVLink connection; repeat for fallback order. Default (if omitted): UDP 0.0.0.0:14550, then /dev/ttyUSB0, /dev/ttyACM0, /dev/serial0 |
-b, --baud |
Serial baud rate, ignored for UDP/TCP (default: 115200) |
--tx |
Default: off (receive-only). When set, send MAVLink (stream requests, HOME_POSITION pull, mission download). Use for SITL or full-duplex serial |
-r, --resolution |
Display size WxH (default: 800x480) |
--fps |
Target render frame rate (default: 10) |
--alt-unit |
Altitude tapes: m or ft (default: m) |
--windowed |
Windowed mode instead of fullscreen (useful on a dev PC) |
--terrain / --no-terrain |
Default: on. SVS terrain on the horizon; --no-terrain to disable |
--terrain-db |
Elevation source: SRTM1, SRTM3, or COP30 (default: SRTM1) |
--map / --no-map |
Default: on. Bottom-left PIP map; --no-map to disable |
--map-zoom |
Tile zoom 0–19 (default: 14). With map enabled and HOME set, auto-fit uses the minimum zoom needed to show aircraft + HOME, capped by this value |
--map-tile-url |
XYZ template with {z}, {x}, {y} (default: Esri World Imagery {z}/{y}/{x}). Example OSM: https://tile.openstreetmap.org/{z}/{x}/{y}.png |
Equivalent short help:
-c, --connection CONN (repeat for fallback; see README default list)
-b, --baud Serial baud rate (default: 115200)
--tx Allow MAVLink uplink (default: receive-only)
-r, --resolution WxH (default: 800x480)
--fps Target frame rate (default: 10)
--alt-unit m or ft (default: m)
--windowed Run in a window instead of fullscreen
--terrain | --no-terrain Default terrain on
--terrain-db SRTM1, SRTM3, or COP30 (default: SRTM1)
--map | --no-map Default map on
--map-zoom 0–19 (default: 14)
--map-tile-url XYZ tile URL template
- Elevation comes from ArduPilot terrain mirrors (same family as MAVProxy): default
SRTM1(~30 m posts), orSRTM3/ Copernicus GLO-30 (COP30). - Tiles download on first use and cache under
~/.cache/MAVProxy/terrain/— allow internet once, or pre-cache tiles for your flying area. - Implementation uses vendored helpers in
lib/(mp_elevation,srtm, etc.) and a background sampler thread; terrain is drawn as stacked polygons similar to MAVProxyhorizon_svs. - Requires NumPy (listed in
requirements.txt). - If terrain data is missing or SVS is off, the horizon still shows the standard blue/brown artificial horizon (pitch ladder and roll). Link loss is separate: telemetry drives the HUD whenever MAVLink is connected.
- Default imagery is Esri World Imagery (satellite). Override
--map-tile-urlfor other XYZ providers; obey each provider’s terms. - Tiles are cached on disk under
~/.cache/pi-telem/map_tiles/<url-hash>/…so changing providers does not mix cached files. - Offline: previously downloaded tiles still load; new areas need connectivity (or pre-cache by panning while online).
- Waypoint positions on the map require
MISSION_ITEM_*data in the telemetry stream. With--tx, the HUD requests the mission from the autopilot. In receive-only mode it does not send requests, but it still ingestsMISSION_COUNT/MISSION_ITEM_INTif they appear on the wire (e.g. Mission Planner or another device on the same radio link requested the mission first). If nothing on the link triggers a mission download, the route stays empty. - Waypoint labels use MAVLink mission sequence (0-based), matching
MISSION_CURRENT.seqand the status WP: line. - Auto-fit zoom (when HOME is set) uses aircraft + HOME only — mission waypoints are drawn and routed, but they do not change the zoom level (so a distant WP does not shrink the map).
Spin up an ArduPilot SITL instance and point it at the HUD:
# Terminal 1 — start SITL (ArduCopter example)
sim_vehicle.py -v ArduCopter --out=udp:127.0.0.1:14550
# Terminal 2 — start the HUD (from the project venv)
cd ~/pi-telem
.venv/bin/python main.py -c udpin:127.0.0.1:14550 --windowed --tx --alt-unit ft
# Defaults already include terrain + map; disable either for a lighter run:
.venv/bin/python main.py -c udpin:127.0.0.1:14550 --windowed --tx --no-terrain
.venv/bin/python main.py -c udpin:127.0.0.1:14550 --windowed --tx --no-mapSITL on another machine:
# On the SITL machine
sim_vehicle.py -v ArduCopter --out=udp:<PI_IP>:14550
# On the Pi
.venv/bin/python main.py -c udpin:0.0.0.0:14550If you use another virtualenv for ArduPilot tools only:
~/venv-ardupilot/bin/python main.py -c udpin:127.0.0.1:14550 --windowed --txInstall project dependencies in that env first (pip install -r requirements.txt), or always use .venv/bin/python from this repo.
- Installs system packages (Python, SDL2 dev libs, fonts).
- Adds your user to the
dialoutgroup and installs a udev rule (/etc/udev/rules.d/99-serial.rules) that grants read/write access to the Pi GPIO UART (ttyAMA0/ttyS0). The udev rule takes effect immediately — no logout or reboot required. - Creates
.venvand installsrequirements.txt(includespyserialfor GPIO UART access). - Writes
/etc/systemd/system/pitelem.servicewithSupplementaryGroups=dialout(ensures serial access even before the user's session picks up the group) and runssystemctl enable pitelem(it does not start the service). After updating the unit file, runsudo systemctl daemon-reload && sudo systemctl enable pitelem(or re-run./install.sh) so systemd picks up changes.
The generated unit targets Raspberry Pi OS with desktop and a graphical login (including autologin):
| Unit setting | Purpose |
|---|---|
WantedBy=multi-user.target |
Hook into the normal boot target so the unit actually starts on every boot (unlike relying only on graphical.target, which may not pull in services if the default target differs). |
After=display-manager.service systemd-user-sessions.service |
Start after the display manager and user session / logind (helps with /run/user/<uid>). |
Wants=display-manager.service |
Pull in the display manager if needed. |
ExecStartPre=…bash… |
Waits (up to 90 s) for /tmp/.X11-unix/X0, ~/.Xauthority, and /run/user/<uid> so the HUD does not start before autologin / X11 is ready (fixes “blank screen until systemctl restart pitelem”). |
TimeoutStartSec=120 |
Allows the wait loop plus Python startup before systemd times out. |
User= your login user |
Runs the HUD as the same user that owns the X session (see below). |
SupplementaryGroups=dialout |
Grants the service serial port access without requiring a logout/reboot after group membership changes. |
Environment=DISPLAY=:0 |
Draw on the usual X11 display (adjust if your DISPLAY is not :0). |
Environment=XAUTHORITY=/home/<user>/.Xauthority |
Lets SDL/pygame authenticate to the X server (path matches the user passed to User=). |
Environment=XDG_RUNTIME_DIR=/run/user/<uid> |
User runtime dir (set from id -u at install time). Avoids XDG_RUNTIME_DIR is invalid or not set for fontconfig / SDL when the app is not launched from a desktop session. |
Environment=PYTHONUNBUFFERED=1 |
Unbuffered stdout/stderr so print and Python logs show up in journalctl immediately (otherwise output can be held until the buffer fills). |
WorkingDirectory= repo path |
Ensures imports and relative paths behave. |
ExecStart=.../main.py -c /dev/serial0 --baud 115200 --map --terrain |
Default flags; uses the Pi GPIO UART directly for receive-only MAVLink. |
Restart=always / RestartSec=5 |
Retry if X11 was not ready yet (e.g. .Xauthority missing before autologin finishes). |
Do not set SDL_VIDEODRIVER=kmsdrm in the unit: the desktop session owns the display.
Run ./install.sh as your normal desktop user (e.g. pi), not sudo ./install.sh from root, so User= and XAUTHORITY=/home/<user>/.Xauthority match the account that logs into the desktop.
After install:
sudo systemctl start pitelem # start now
sudo systemctl status pitelem # check status
journalctl -u pitelem -f # tail logsMAVLink is receive-only unless you add --tx to ExecStart. The service uses -c /dev/serial0 by default (Pi GPIO UART). If the serial device is not available yet at boot, the process retries in a loop until it connects.
sudo systemctl edit pitelemFull uplink (mission download, stream requests — still default map + terrain):
[Service]
ExecStart=
ExecStart=/home/pi/pi-telem/.venv/bin/python /home/pi/pi-telem/main.py -c /dev/serial0 --baud 115200 --map --terrain --txUSB-serial instead of GPIO UART (e.g. FTDI dongle):
[Service]
ExecStart=
ExecStart=/home/pi/pi-telem/.venv/bin/python /home/pi/pi-telem/main.py -c /dev/ttyUSB0 --baud 115200 --map --terrainUDP for SITL or network forwarding:
[Service]
ExecStart=
ExecStart=/home/pi/pi-telem/.venv/bin/python /home/pi/pi-telem/main.py -c udpin:0.0.0.0:14550 --map --terrain --txThen:
sudo systemctl daemon-reload
sudo systemctl restart pitelemsystemctl is-enabled pitelemshould printenabled. If not:sudo systemctl enable pitelem.systemctl status pitelem— look for failed / inactive and the last log lines.- Re-run
./install.shafter pulling a newer unit, thensudo systemctl daemon-reload && sudo systemctl enable pitelem && sudo systemctl restart pitelem. journalctl -b -u pitelem— errors opening the display, missing.Xauthority, or Python tracebacks.- If the journal only shows
Started pitelem.serviceand no Python lines, ensure the unit setsPYTHONUNBUFFERED=1(currentinstall.shdoes); thensudo systemctl daemon-reload && sudo systemctl restart pitelem.
- HUD only after
sudo systemctl restart pitelem: at cold boot the service used to start before X11 and your.Xauthorityexisted. The installed unit waits for the X socket and cookies (seeExecStartPrein the table above). Re-run./install.shto refresh the unit, thendaemon-reload+restart. - Confirm
echo $DISPLAYon the Pi desktop (often:0); change the unit if yours differs. - Enable autologin or log in once after boot so
~/.Xauthorityexists;Restart=alwaysretries until the HUD can connect to X11. journalctl -u pitelem -efor Python/SDL errors (permissions, missing display).- If you see
XDG_RUNTIME_DIR is invalid or not set, re-run./install.sh(the unit setsXDG_RUNTIME_DIR=/run/user/<uid>). That path is created when you log in;Restart=alwaysusually clears the error after/run/user/<uid>exists. - A one-time
fc-listtimed out warning from pygame/fontconfig on slow SD cards is usually harmless.
| Symptom | What to check |
|---|---|
| No SVS, flat brown ground only | --no-terrain in the command, or terrain thread failed — see logs. |
| Still nothing with terrain on | GPS lat/lon are often 0,0 until lock. The HUD falls back to home if HOME_POSITION is set; otherwise terrain waits for a valid position. |
| DEM never loads | Network once so tiles can download to ~/.cache/MAVProxy/terrain/; then offline use is fine. |
| Silent failure | Check journalctl -u pitelem -e — the terrain thread logs errors to stderr at most every 30s, e.g. missing numpy, missing lib/, or elevation init failure. Run ./.venv/bin/pip install -r requirements.txt from the repo root. |
- Pi Zero 2 W (or similar) running Raspberry Pi OS with desktop (what
install.shtargets) - RFD900x radio modem — the Pi taps the RFD's serial TX line via the GPIO UART (receive-only by default)
- Any HDMI display (mini-HDMI adapter required for the Pi Zero 2 W)
The Pi passively listens to telemetry on the RFD900x TX line. The existing connection (e.g. FTDI cable to a GCS laptop) stays in place — you splice the TX signal so the Pi reads in parallel.
| RFD900x Pin | Function | Pi Physical Pin | Pi Function |
|---|---|---|---|
| 9 (TX) | UART Data Out | 10 | GPIO 15 / UART RX |
| 1 or 2 (GND) | Ground | 6 (or 9, 14, 20, 25) | Ground |
- Voltage: RFD900x serial pins are 3.3 V TTL, same as Pi GPIO — no level shifter needed.
- Enable UART on the Pi:
sudo raspi-config→ Interface Options → Serial Port → disable login shell, enable serial hardware. Or addenable_uart=1to/boot/config.txt. The device appears as/dev/serial0. - Baud rate: must match the RFD900x serial rate (default in this project: 115200). Verify with your RFD configuration tool.
--txstays off (the default) since you are tapping a shared line — the Pi should not transmit.
- Python 3
- pygame (SDL2), pymavlink, pyserial, numpy (terrain)
- Vendored terrain code under
lib/(SRTM/Copernicus tile access) - System packages (via
install.sh):libsdl2-dev,libsdl2-image-dev,libsdl2-ttf-dev,fonts-dejavu-core, etc.
install.sh creates .venv and runs pip install -r requirements.txt.