Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Prefer bench testing and disconnected telemetry validation before using the soft

## Features

- **Live map** with flight track (up to 5000 points), home reference, distance, 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 in `localStorage` as `uav-gcs.map.basemap`)
- **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 in `localStorage` as `uav-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
Expand Down Expand Up @@ -283,7 +283,7 @@ On the **desktop** link, gimbal attitude for estimation comes from MAVLink **285
| `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) |
| `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`](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`).
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@uav-ground-control-station/desktop",
"version": "0.2.41",
"version": "0.2.43",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "uav-ground-control-station"
version = "0.2.41"
version = "0.2.43"
description = "Native desktop shell for UAV Ground Control Station"
authors = ["F. Eber"]
license = "GPL-3.0-only"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "UAV Ground Control Station",
"version": "0.2.41",
"version": "0.2.43",
"identifier": "com.uav.ground-control-station",
"build": {
"beforeDevCommand": "pnpm --filter @uav-ground-control-station/web dev",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export function App() {
/>
</ErrorBoundary>
<ActivityLogPanel logs={logs} messages={status.mavlinkMessages ?? []} onClear={clearLogs} linkIssues={linkIssues} />
<VideoPanel targetEstimation={targetEstimation} />
<VideoPanel telemetry={telemetry} telemetryStale={telemetryStale} targetEstimation={targetEstimation} />

{activeSourceMode !== "live" && (
<div className="absolute right-4 top-4 z-20">
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/components/HudOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useId } from "react";
import type { TelemetryState } from "@uav-ground-control-station/shared";
import { formatNumber } from "../lib/format";
import { resolveHeadingDeg } from "../lib/resolveHeadingDeg";
Expand All @@ -13,9 +14,12 @@ interface HudOverlayProps {
stale?: boolean;
/** Phase 2: smaller variant for video PiP. */
compact?: boolean;
/** When false, omit `data-tour="attitude-hud"` (e.g. compact PiP duplicate). */
showTourTarget?: boolean;
}

export function HudOverlay({ telemetry, stale = false, compact = false }: HudOverlayProps) {
export function HudOverlay({ telemetry, stale = false, compact = false, showTourTarget = true }: HudOverlayProps) {
const clipId = useId().replace(/:/g, "");
const size = compact ? 220 : 320;
const cx = size / 2;
const cy = size / 2;
Expand All @@ -39,7 +43,7 @@ export function HudOverlay({ telemetry, stale = false, compact = false }: HudOve

return (
<div
data-tour="attitude-hud"
{...(showTourTarget ? { "data-tour": "attitude-hud" } : {})}
className={`relative pointer-events-none select-none font-mono text-slate-100 ${
stale ? "opacity-60 saturate-50" : ""
}`}
Expand All @@ -55,7 +59,7 @@ export function HudOverlay({ telemetry, stale = false, compact = false }: HudOve
)}
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="overflow-visible">
<defs>
<clipPath id="hud-attitude-clip">
<clipPath id={clipId}>
<circle cx={cx} cy={cy} r={radius} />
</clipPath>
</defs>
Expand All @@ -69,7 +73,7 @@ export function HudOverlay({ telemetry, stale = false, compact = false }: HudOve
strokeWidth={1.5}
/>

<g clipPath="url(#hud-attitude-clip)">
<g clipPath={`url(#${clipId})`}>
{hasAttitude ? (
<g transform={`rotate(${-rollDeg}, ${cx}, ${cy})`}>
<rect
Expand Down
35 changes: 32 additions & 3 deletions apps/web/src/components/MapPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type MapBasemapId
} from "../lib/mapBasemaps";
import { boundsForTrack, loadMapFollowPreferences, saveMapFollowPreferences } from "../lib/mapFollow";
import { DRONE_CHEVRON_ICON_ID, droneMarkerCollection, ensureDroneChevronIcon } from "../lib/droneMapMarker";
import { resolveHeadingDeg } from "../lib/resolveHeadingDeg";
import type { TrackPoint } from "../replay/reconstruct";
import { HudOverlay } from "./HudOverlay";
Expand Down Expand Up @@ -215,15 +216,15 @@ export function MapPanel({ telemetry, coordinate, home, groundTarget, telemetryS
if (!map || !mapReady || !droneLngLat) return;

const source = map.getSource("drone") as import("maplibre-gl").GeoJSONSource | undefined;
source?.setData(pointCollection(droneLngLat));
source?.setData(droneMarkerCollection(droneLngLat, heading));

if (!centeredRef.current && initialPrefs.neverConfigured) {
centeredRef.current = true;
runProgrammaticMove((activeMap) => {
activeMap.jumpTo({ center: droneLngLat, zoom: 15 });
});
}
}, [droneLngLat, mapReady, styleEpoch, initialPrefs.neverConfigured, runProgrammaticMove]);
}, [droneLngLat, heading, mapReady, styleEpoch, initialPrefs.neverConfigured, runProgrammaticMove]);

useEffect(() => {
const map = mapRef.current;
Expand All @@ -249,8 +250,15 @@ export function MapPanel({ telemetry, coordinate, home, groundTarget, telemetryS
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady || !map.getLayer("drone-point")) return;
map.setPaintProperty("drone-point", "circle-opacity", telemetryStale ? 0.45 : 1);

const staleOpacity = telemetryStale ? 0.45 : 1;
map.setPaintProperty("drone-point", "circle-opacity", staleOpacity);
map.setPaintProperty("drone-point", "circle-color", telemetryStale ? "#94a3b8" : "#22d3ee");

if (map.getLayer("drone-heading")) {
map.setPaintProperty("drone-heading", "icon-opacity", staleOpacity);
map.setPaintProperty("drone-heading", "icon-color", telemetryStale ? "#94a3b8" : "#22d3ee");
}
}, [telemetryStale, mapReady, styleEpoch]);

useEffect(() => {
Expand Down Expand Up @@ -331,18 +339,39 @@ function addMapOverlays(map: MapInstance): void {
}

if (!map.getSource("drone")) {
ensureDroneChevronIcon(map);
map.addSource("drone", { type: "geojson", data: emptyPointCollection() });
map.addLayer({
id: "drone-point",
type: "circle",
source: "drone",
filter: ["!", ["has", "heading"]],
paint: {
"circle-radius": 9,
"circle-color": "#22d3ee",
"circle-stroke-width": 2,
"circle-stroke-color": "#0f172a"
}
});
map.addLayer({
id: "drone-heading",
type: "symbol",
source: "drone",
filter: ["has", "heading"],
layout: {
"icon-image": DRONE_CHEVRON_ICON_ID,
"icon-size": 0.9,
"icon-rotate": ["get", "heading"],
"icon-rotation-alignment": "map",
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-color": "#22d3ee",
"icon-halo-color": "#0f172a",
"icon-halo-width": 2
}
});
}

if (!map.getSource("home")) {
Expand Down
42 changes: 38 additions & 4 deletions apps/web/src/components/VideoPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import type { TelemetryState } from "@uav-ground-control-station/shared";
import type { TargetEstimationController } from "../hooks/useTargetEstimation";
import { isVideoSignalGood, useVideoSignalStatus } from "../hooks/useVideoSignalStatus";
import { sanitizeHttpUrl } from "../lib/safeHttpUrl";
import { GroundTargetPanel } from "./GroundTargetPanel";
import { HudOverlay } from "./HudOverlay";

type VideoKind = "mjpeg" | "hls" | "webrtc";

const defaultUrl = sanitizeHttpUrl(import.meta.env.VITE_VIDEO_URL ?? "");
const defaultKind = (import.meta.env.VITE_VIDEO_KIND as VideoKind | undefined) ?? "mjpeg";
const VIDEO_HUD_KEY = "uav-gcs.video.hud";

function loadVideoHudEnabled(): boolean {
const raw = localStorage.getItem(VIDEO_HUD_KEY);
return raw === null ? true : raw === "true";
}

interface VideoPanelProps {
telemetry: TelemetryState;
telemetryStale?: boolean;
targetEstimation: TargetEstimationController;
}

export function VideoPanel({ targetEstimation }: VideoPanelProps) {
export function VideoPanel({ telemetry, telemetryStale = false, targetEstimation }: VideoPanelProps) {
const [collapsed, setCollapsed] = useState(false);
const [url, setUrl] = useState(() => sanitizeHttpUrl(localStorage.getItem("uav-gcs.video.url") ?? defaultUrl));
const [kind, setKind] = useState<VideoKind>(() => (localStorage.getItem("uav-gcs.video.kind") as VideoKind | null) ?? defaultKind);
const [showHud, setShowHud] = useState(() => loadVideoHudEnabled());
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
const videoSignal = useVideoSignalStatus(url, kind);
Expand All @@ -27,6 +38,10 @@ export function VideoPanel({ targetEstimation }: VideoPanelProps) {
localStorage.setItem("uav-gcs.video.kind", kind);
}, [url, kind]);

useEffect(() => {
localStorage.setItem(VIDEO_HUD_KEY, String(showHud));
}, [showHud]);

useEffect(() => {
function move(event: globalThis.PointerEvent) {
const offset = dragOffsetRef.current;
Expand Down Expand Up @@ -82,9 +97,23 @@ export function VideoPanel({ targetEstimation }: VideoPanelProps) {
<VideoSignalBadge status={videoSignal.status} />
</div>
</div>
<button className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-cyan-300/40" onClick={() => setCollapsed((value) => !value)}>
{collapsed ? "Open" : "Hide"}
</button>
<div className="flex items-center gap-2">
<button
type="button"
className={`rounded border px-2 py-1 text-xs transition ${
showHud
? "border-cyan-300/40 bg-cyan-500/15 text-cyan-100"
: "border-white/10 text-slate-300 hover:border-cyan-300/40"
}`}
aria-pressed={showHud}
onClick={() => setShowHud((value) => !value)}
>
HUD
</button>
<button className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-cyan-300/40" onClick={() => setCollapsed((value) => !value)}>
{collapsed ? "Open" : "Hide"}
</button>
</div>
</header>

{!collapsed && (
Expand All @@ -100,6 +129,11 @@ export function VideoPanel({ targetEstimation }: VideoPanelProps) {
<div className="absolute h-12 w-px bg-cyan-200/70" />
<div className="absolute h-px w-12 bg-cyan-200/70" />
</div>
{showHud && (
<div className="pointer-events-none absolute left-1 top-1 z-10 scale-[0.72] origin-top-left opacity-90 sm:scale-[0.78]">
<HudOverlay telemetry={telemetry} stale={telemetryStale} compact showTourTarget={false} />
</div>
)}
</div>
<div className="grid grid-cols-[90px_1fr] gap-2 border-t border-line p-2">
<select className="input-dark" value={kind} onChange={(event) => setKind(event.target.value as VideoKind)}>
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/lib/droneMapMarker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { droneMarkerCollection } from "./droneMapMarker";

describe("droneMarkerCollection", () => {
it("includes heading property when available", () => {
const collection = droneMarkerCollection([10.5, 51.2], 135);
expect(collection.features[0]?.properties.heading).toBe(135);
});

it("omits heading property when null", () => {
const collection = droneMarkerCollection([10.5, 51.2], null);
expect(collection.features[0]?.properties.heading).toBeUndefined();
});
});
63 changes: 63 additions & 0 deletions apps/web/src/lib/droneMapMarker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { FeatureCollection, Point } from "geojson";

type DroneMarkerCollection = FeatureCollection<Point, { heading?: number }>;

export const DRONE_CHEVRON_ICON_ID = "drone-chevron";

type MapInstance = import("maplibre-gl").Map;

/** Register a white SDF chevron (nose up) for heading rotation via symbol layer. */
export function ensureDroneChevronIcon(map: MapInstance): void {
if (map.hasImage(DRONE_CHEVRON_ICON_ID)) return;

const size = 64;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return;

const cx = size / 2;
const cy = size / 2;

ctx.clearRect(0, 0, size, size);
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.moveTo(cx, cy - 18);
ctx.lineTo(cx + 12, cy + 14);
ctx.lineTo(cx, cy + 6);
ctx.lineTo(cx - 12, cy + 14);
ctx.closePath();
ctx.fill();

const imageData = ctx.getImageData(0, 0, size, size);
map.addImage(DRONE_CHEVRON_ICON_ID, imageData, { sdf: true, pixelRatio: 2 });
}

export function droneMarkerCollection(lngLat: [number, number], heading: number | null): DroneMarkerCollection {
const properties: { heading?: number } = {};
if (heading !== null && Number.isFinite(heading)) {
properties.heading = heading;
}

return {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties,
geometry: {
type: "Point",
coordinates: lngLat
}
}
]
};
}

export function emptyDroneMarkerCollection(): DroneMarkerCollection {
return {
type: "FeatureCollection",
features: []
};
}
2 changes: 1 addition & 1 deletion apps/web/src/lib/onboardingSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function buildOnboardingSteps(runtimeMode: RuntimeMode): DriveStep[] {
popover: {
title: "Camera feed",
description:
"Draggable picture-in-picture video panel with crosshair overlay. When the stream is live, the Ground Target card appears beside the feed with coordinates, slant range, terrain/DEM settings, and sample-log export. Set stream URL and type (MJPEG, HLS, WebRTC) here; drag the header to reposition.",
"Draggable picture-in-picture video panel with crosshair overlay. Toggle **HUD** in the header for a compact attitude overlay (pitch, heading, speed, altitude) while watching the feed. When the stream is live, the Ground Target card appears beside the feed. Set stream URL and type (MJPEG, HLS, WebRTC) here; drag the header to reposition.",
side: "left",
align: "end"
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "uav-ground-control-station",
"version": "0.2.41",
"version": "0.2.43",
"private": true,
"description": "Local browser-based MAVLink ground control station for UAVs.",
"license": "GPL-3.0-only",
Expand Down