A local ground control station for UAVs — map, telemetry, and serial link in one dashboard.
Website: uavgroundcontrolstation.com · Hosted Web App: app.uavgroundcontrolstation.com
UAV Ground Control Station is a slim, locally operated GCS that runs on the operator’s machine. A shared React UI shows position, battery, radio link, flight mode, and session stats on a MapLibre map — whether data arrives over CRSF, MAVLink, or both.
| Runtime | Use case | Protocols | Serial access |
|---|---|---|---|
| Desktop (Tauri, recommended) | Primary operator runtime, TX16S, Windows COM* |
CRSF + MAVLink, GCS wake-up | Native Rust |
| Browser + Node | Development, MAVLink-direct links | MAVLink | apps/server (fallback) |
Hosted Web App (VITE_LINK=webserial) |
Zero-install browser GCS — app.uavgroundcontrolstation.com or self-host | CRSF + MAVLink (no wake-up bytes) | Web Serial API (Chromium + HTTPS only) |
For RadioMaster TX16S with a USB telemetry mirror, the desktop app remains the canonical runtime (420000 baud, CRSF-first, GCS wake-up). The Hosted Web App also supports TX16S CRSF at 420000 baud for zero-install use. See docs/adr/0001-dual-runtime-desktop-canonical.md.
The Hosted Web App (internal runtime key cloud) is a pure browser SPA that reads the radio directly over the Web Serial API — no Node server. Telemetry stays in your browser. The hosted site delivers only the static app shell; GPS, flight data, and serial traffic never leave the operator's machine. It frames CRSF (TX16S telem mirror) and MAVLink (direct FC USB) in the browser and works in Chromium-based browsers over HTTPS (Firefox and Safari show a clear fallback notice). See docs/adr/0006-browser-webserial-cloud-runtime.md and CONTEXT.md.
| Included in v1 | Not in v1 (later decisions) |
|---|---|
| Static SPA over HTTPS | Accounts or login |
| Web Serial CRSF + MAVLink link (user-granted port only) | Server-side telemetry persistence |
| Replay and simulation (frontend-only, no upload) | Fleet or project management |
| Same dashboard UI as desktop/browser dev | Cloud logging or sync |
| Billing or paid tiers |
Self-host the Hosted Web App with pnpm build:cloud and serve apps/web/dist from that command only — not the output of pnpm build (desktop/Node stack). A wrong artifact shows the COM port dropdown and spams WebSocket connection to …/ws failed because the app is in web mode instead of cloud. Do not embed the app in a cross-origin iframe without a Permissions-Policy that allows serial for your origin — Web Serial is blocked by default in embedded contexts.
| URL | What you get |
|---|---|
| uavgroundcontrolstation.com | Product landing page — features, runtimes, safety, links to GitHub and the Hosted Web App |
| app.uavgroundcontrolstation.com | Hosted Web App — zero-install Web Serial GCS in Chromium over HTTPS. Telemetry stays in your browser; no account required in v1 |
Prefer zero install? Open the Hosted Web App, grant serial access when prompted, and connect at 420000 baud for TX16S CRSF or 115200 / 460800 for direct FC MAVLink USB. For ground-target estimation with GeoTIFF DEM ray marching, use the desktop app (Releases).
This project is experimental ground-control software. Do not use it for unsafe, unsupervised, or illegal UAV operation. Always follow local regulations, manufacturer guidance, and safe test procedures.
Prefer bench testing and disconnected telemetry validation before using the software with real flight hardware.
- Live map with flight track (up to 5000 points), home reference, distance, heading-aligned drone chevron (falls back to a circle when heading is unknown), bottom-center Attitude HUD (pitch ladder, roll arc, heading tape, speed/altitude, climb bar, armed/mode; dims with a Stale banner when live telemetry is older than 3 s), map navigation toolbar (Follow with heading-up/north-up, Recenter, Fit track; preferences in
uav-gcs.map.follow/uav-gcs.map.headingUp), and in-app basemap switcher (Tactical / Satellite / Topo; persisted inlocalStorageasuav-gcs.map.basemap) - Telemetry sidebar — Text or Inst (mini gauges with the same telemetry fields as text mode); drag card headers (⠿) to reorder (shared order for both views, stored in
uav-gcs.sidebar.order); Reset restores recommended flight-priority order; alerts stay fixed at the top - Serial link — port picker (USB/PNP preferred), manual path entry, common baud rates
- Activity log — connection status, parser stats, frame message stats; actionable link-issue banners for serial denial, busy ports, silence after connect, and parser spikes
- Optional video stream (MJPEG, etc.) via environment variables; crosshair overlay; when the stream is live, a Ground Target panel docks beside the camera feed
- Ground target estimation (desktop) — image-center target with GeoTIFF DEM ray marching, map marker, line-of-sight, and sample-log export (shown next to the camera when video is live)
- Preflight health advisory — sensor and link health checks with configurable thresholds
- Session logging and reset for new flights
- Onboarding tour — first-run walkthrough of link controls, telemetry sidebar, map, camera, and activity log; skip anytime; restart from the ? button in the top bar (
localStoragekeysuav-gcs.onboarding.*) - Replay & Simulation — frontend-only, read-only telemetry sources that drive the same dashboard without hardware: replay recorded
.jsonl/.jsonlogs (start/pause/seek/step, speed and timing modes) or run deterministic seeded simulations. Seedocs/replay-mode.mdanddocs/adr/0003-frontend-only-replay-simulation.md - Flight Review — post-flight analysis over a loaded replay log (not simulation): summary stats, findings, colored path, seekable timeline with markers, and five click-to-seek graphs. Open manually from replay controls while in Replay mode; shares the replay clock with dashboard scrubbing. Frontend-only — nothing is uploaded. See
docs/adr/0007-flight-review-replay-analysis-view.md - Shared data model
TelemetryStatefor desktop and browser
Desktop runtime: map, telemetry sidebar, frame message stats, and optional camera panel.
Telemetry data flow:
TX16S / Flight Controller
|
| USB serial telemetry
v
Desktop / Node parser
|
| normalized TelemetryState
v
React operator dashboard
Monorepo layout:
packages/shared Shared types & TelemetryState
packages/target-estimation Ground-target ray marching & sample log (TS)
apps/web React + Vite + Tailwind + MapLibre (shared UI)
apps/desktop Tauri v2 + Rust (CRSF/MAVLink, COM*, wake-up, DEM) ← canonical
apps/server Fastify + WebSocket + serialport (MAVLink, dev/fallback)
Domain language and terms: CONTEXT.md.
- Node.js ≥ 20.19
- pnpm 10.x (
corepack enablerecommended) - Desktop build: Rust and Tauri v2 prerequisites
- Windows: run maintenance commands (
pnpm install,lint,typecheck) in WSL; run Tauri dev/build for realCOM*hardware in Windows PowerShell
- Open the Hosted Web App: app.uavgroundcontrolstation.com
- Use a Chromium-based browser over HTTPS, click Connect, and grant Web Serial access to your radio.
- Set 420000 baud for TX16S CRSF telem mirror or 115200 / 460800 for direct FC MAVLink USB.
Product overview and desktop download links: uavgroundcontrolstation.com.
git clone https://github.com/gitfeber/UAVGroundControlStation.git
cd UAVGroundControlStation
pnpm installPre-built installers (no local Rust toolchain required):
- Open GitHub Releases for this repository.
- Download the asset for your OS (Windows
.msi/ setup.exe, or Linux.deb/.AppImage). - Install and launch UAV Ground Control Station.
Each push to main runs tests first; if they pass, CI publishes a preview prerelease with Windows and Linux installers (tag like v0.2.1-build.42, job publish in .github/workflows/ci.yml). Stable releases use a version tag such as v0.2.1 (see .github/workflows/release.yml and the release process below).
From source:
pnpm dev:desktopRelease build (local MSI/installer):
pnpm build:desktopArtifacts: apps/desktop/src-tauri/target/release/bundle/
Maintainers — stable release (optional):
Automatic preview prereleases happen on every green main push. For a non-prerelease “stable” release:
git tag v0.2.1
git push origin v0.2.1The release workflow builds Windows and Linux installers and attaches them to the GitHub release (no macOS builds in CI). You can also trigger it manually under Actions → Release → Run workflow.
If the release job fails with Resource not accessible by integration, enable Settings → Actions → General → Workflow permissions → Read and write permissions for the repository.
cp .env.example .env
pnpm dev- Backend:
http://localhost:3001 - Frontend:
http://localhost:5173
The public instance at app.uavgroundcontrolstation.com is built from the same artifact. To self-host:
Build the Web Serial SPA (no Node server in the output):
pnpm build:cloudArtifacts: apps/web/dist/ — serve over HTTPS with SPA fallback (try_files $uri /index.html on nginx, or equivalent). Open in a Chromium-based browser, connect via the browser's device picker, and use 420000 baud for TX16S CRSF telem mirror or 115200/460800 for direct FC MAVLink USB.
Preview locally (still requires HTTPS or localhost for Web Serial):
pnpm build:cloud
pnpm --filter @uav-ground-control-station/web previewUse the toolbar at the bottom-left of the map:
| Control | Behavior |
|---|---|
| Follow | Keeps the aircraft centered as telemetry updates (live, replay, and simulation). |
| Hdg up / N up | When Follow is on, rotate the map with aircraft heading or keep north up. |
| Recenter | One-shot center on the aircraft at the current zoom and resume Follow. |
| Fit track | Zoom to the full session track (up to 5000 points). Disabled when the track is empty. |
Manual pan or zoom pauses Follow until you tap Recenter or turn Follow back on. Preferences persist in localStorage (uav-gcs.map.follow, uav-gcs.map.headingUp). On a fresh install, the map still auto-centers once on the first GPS fix.
On first launch (after the optional splash screen), a guided tour highlights the top bar, telemetry sidebar, map, camera panel, and activity log. Click Skip tour to dismiss it; completion is stored per runtime in localStorage (uav-gcs.onboarding.completed, uav-gcs.onboarding.version). Desktop, browser dev, and Hosted Web App each keep their own completion state. Reopen the tour anytime with the ? button in the top bar.
| Scenario | Typical baud rate |
|---|---|
| TX16S telemetry mirror (CRSF-first) | 420000 |
| Flight controller USB (MAVLink direct) | 115200 or 460800 |
Symptom: status shows “Serial linked” but no map/telemetry → usually wrong baud rate (TX16S CRSF: 420000; direct FC MAVLink: 115200 or 460800).
CRSF frame stats: ArduPilot still emits attitude, vario, flight mode, and empty GPS frames over CRSF even without a GNSS module — low counts (single digits) are normal. Handset and link-RX frames flood the stream. Map position and home latch only when CRSF GPS reports ≥3 satellites with valid coordinates. Battery voltage often arrives first via ArduPilot passthrough (handset/0x80 frames); native CRSF 0x08 is slower. Invalid CRSF sentinels (0x7FFF consumed, remaining outside 0–100) are ignored — not a hardware fault.
Host COM* ports are often unreachable from WSL-hosted Node. For TX16S and CRSF, use the desktop app, not the browser stack over WSL.
- Windows:
COM* - macOS:
/dev/cu.*,/dev/tty.* - Linux:
/dev/ttyACM*,/dev/ttyUSB*
Empty system ports without device metadata are hidden; unusual paths can be entered manually.
Real elevation for ground target estimation is desktop-only (see docs/adr/0005-target-estimation-ts-rust-split.md). When the camera stream is live, use Browse… in the Ground Target panel beside the video feed to pick a local GeoTIFF/DGM, or paste a path manually; the Rust backend keeps a sliding 4 km × 4 km window around the UAV and serves batched elevation queries for ray marching. EPSG:25832 (ETRS89 / UTM 32N) projected GeoTIFFs — including common 1 m DGM-class tiles — are sampled in projected meters; WGS84 UAV coordinates are transformed before lookup. Geographic EPSG:4326 GeoTIFFs continue to use lat/lon sampling.
CRS detection: the desktop DEM loader prefers GeoTIFF GeoKey EPSG tags (projected_type / geographic_type via the geotiff 0.1 reader). When those tags are missing, it falls back to model-extent heuristics for EPSG:25832 / UTM32 and WGS84 geographic tiles. Unsupported CRS values fail at load time with a clear error instead of sampling with the wrong axis order.
Use a small GeoTIFF clipped around your flight area (full-state tiles are large and slow to window-cache).
- Start the desktop app with live telemetry over the flight area.
- No DEM loaded — Ground Target estimate should be bad with
dem_not_loaded; map marker/LOS hidden. - Browse… or paste a DEM path in the Ground Target panel (beside the camera feed when video is live), then confirm metadata loads.
- Metadata check — expect EPSG:25832 (ETRS89 / UTM zone 32N) for projected tiles, resolution about 1 m for DGM-class data, and a plausible source path.
- Valid/warn estimate — orange map marker and dashed LOS appear when gimbal/GPS gates pass.
- Bad estimate — marker/LOS hidden; inspect reasons in the Ground Target panel beside the camera feed.
- If every sample is
dem_out_of_coverage, the tile CRS is likely wrong, the UAV is outside the GeoTIFF extent, or the file is not EPSG:25832 / UTM32.dem_nodatameans the raster cell is empty/NoData inside coverage. - Calibration, terrain path, and sample-log JSON/CSV export should persist across reload (
localStorage+ in-memory log). Desktop Save JSON… / Save CSV… use native file dialogs.
| Tauri command | Purpose |
|---|---|
load_terrain_model |
Open a local GeoTIFF path; returns terrain metadata |
get_terrain_metadata |
Current terrain model metadata / loaded flag |
clear_terrain_model |
Unload the active terrain model |
sample_terrain_amsl_at |
AMSL sample at a lat/lon (anchor-aware window cache) |
get_elevation_at_enu |
ENU elevation relative to estimate anchor |
get_elevations_along_ray |
Batched samples for target-estimation ray marching |
save_target_log |
Write exported target sample log JSON/CSV to a host path |
Frontend wrapper: apps/web/src/lib/tauriDemTerrain.ts (TauriDemTerrainProvider). Browser dev continues to use synthetic terrain only.
Ground target estimation (image center) runs in live mode only. When the camera stream is live, the Ground Target panel appears beside the video feed with full readout and settings (localStorage keys uav-gcs.target.*, including video latency, altitude mode/offset, gimbal calibration offsets, raycast range/step/min-down-angle, and stale-telemetry threshold; values are range-validated on load). Valid or warn estimates draw an orange map marker and dashed line-of-sight from the UAV; bad estimates hide the marker/LOS. Desktop requires a loaded DEM — missing terrain surfaces dem_not_loaded instead of silently using flat terrain; load rejects path traversal, non-GeoTIFF extensions, and oversized rasters. Browser dev uses synthetic flat terrain only. The panel keeps an in-memory target sample log (600 samples) with manual JSON/CSV export; desktop can save to disk via native file dialogs (save_target_log, .json/.csv only).
On the desktop link, gimbal attitude for estimation comes from MAVLink 285 (GIMBAL_DEVICE_ATTITUDE_STATUS, preferred) or compact legacy 265 euler payloads (skipped when the frame is large enough to be standard MOUNT_ORIENTATION). Vehicle ATTITUDE remains the body-fixed fallback in TypeScript when no gimbal message is present. Pose-related frames also populate sampledAtMs for ring-buffer alignment; check the activity panel for GIMBAL_DEVICE_ATTITUDE_STATUS / GIMBAL_LEGACY frame counts.
.env at the repository root (see .env.example):
| Variable | Description |
|---|---|
VITE_API_BASE_URL |
Node server REST API (default: http://localhost:3001) |
VITE_WS_URL |
WebSocket for telemetry (default: ws://localhost:3001/ws) |
VITE_LINK |
Set to webserial for the Hosted Web App build (pnpm build:cloud sets this automatically) |
VITE_BASE_PATH |
Optional asset base for hosted deploys (e.g. /uav-gcs/). Cloud builds default to ./ (relative) so subpath hosting works |
VITE_MAP_STYLE_URL |
Optional: full MapLibre style URL (hides the in-app basemap switcher) |
VITE_SATELLITE_TILE_URL |
Optional: custom raster tile URL for the Satellite preset (default: Esri World Imagery) |
VITE_VIDEO_URL / VITE_VIDEO_KIND |
Optional: camera stream (e.g. MJPEG). Compact attitude HUD on the feed is toggled with HUD in the camera header (uav-gcs.video.hud). |
VITE_ENABLE_SPLASH_SCREEN |
Startup HUD splash overlay (default: enabled; set false to skip) |
Server: PORT (default 3001), HOST (default 127.0.0.1) in apps/server. The server exposes unauthenticated serial-control endpoints; it binds loopback only. Setting HOST to a routable address (e.g. 0.0.0.0) is a deliberate opt-in that lets any device on the network open or close the link to flight hardware — see docs/adr/0002-server-loopback-only.md. On startup the server prints a prominent console.warn when bound beyond loopback; the browser stack shows a matching top banner when VITE_API_BASE_URL or VITE_WS_URL targets a non-loopback host. State-changing routes (POST /api/connect, /api/disconnect, /api/reset, logging start/stop) reject browser requests whose Origin is not the local Vite dev UI (http://localhost:5173 or http://127.0.0.1:5173); non-browser clients that omit Origin are unchanged. POST /api/connect validates serial path against plausible device patterns only (Windows COM*, macOS /dev/cu.*//dev/tty.*, Linux tty*, /dev/serial/by-id|by-path/*, /dev/rfcomm*) and baudRate (57600, 115200, 420000, 460800) before opening the port; malformed requests return HTTP 400. CI runs cargo audit on the desktop crate (pnpm audit:desktop).
pnpm lint
pnpm typecheck
pnpm build # browser stack (Node server + default web build)
pnpm build:cloud # Hosted Web App SPA (Web Serial, no server)
pnpm build:desktop # desktop installerIf you open the repository root in VS Code, rust-analyzer is configured via .vscode/settings.json to use the Tauri crate at apps/desktop/src-tauri/Cargo.toml.
Agent and architecture rules: AGENTS.md.
Three workflows split the responsibilities, so it is always clear what publishes a release and what does not:
| Workflow | Trigger | Publishes? |
|---|---|---|
branch-checks.yml |
Pull requests | No — validation only (typecheck, lint, tests, build, build:cloud) |
ci.yml |
Push to main/master |
Preview prerelease (tag v<version>-build.<run>) after tests pass |
release.yml |
Pushed v* tag or manual dispatch |
Stable release (prerelease: false) |
In short:
-
Pull requests run
branch-checks.ymland never publish. -
Merges to
mainautomatically create a preview prerelease with Windows and Linux installers, clearly marked as an automated build. -
Stable releases are created by pushing a semantic version tag:
git tag v0.2.1 git push origin v0.2.1
You can also start the stable Release workflow manually from the GitHub Actions tab (Actions → Release → Run workflow).
Both ci.yml and release.yml set generateReleaseNotes: true, so GitHub appends auto-generated notes grouped by the categories defined in .github/release.yml (Features, Fixes, Documentation, Maintenance, Dependencies, Other Changes).
apps/desktop/ Tauri + Rust (primary serial link, DEM terrain)
apps/server/ Node backend (dev/fallback)
apps/web/ React dashboard (desktop, browser dev, Hosted Web App)
packages/shared/ API and telemetry contracts
packages/target-estimation/ Ground-target estimation library
docs/adr/ architecture decisions
Copyright © 2026 F. Eber
Licensed under the GNU General Public License v3.0 (GPL-3.0-only). You may use, modify, and redistribute this software under those terms. If you distribute a modified version, you must release the corresponding source under the same license. See LICENSE for the full text.

