diff --git a/README.md b/README.md index 40127ae..d9aa9fc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`). diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3cc1543..4ac04d9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index bd2b6ae..5280fb2 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c99b66b..6854490 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -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", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b680619..0d2f3e7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -204,7 +204,7 @@ export function App() { /> - + {activeSourceMode !== "live" && (
diff --git a/apps/web/src/components/HudOverlay.tsx b/apps/web/src/components/HudOverlay.tsx index c9cb122..c35dc14 100644 --- a/apps/web/src/components/HudOverlay.tsx +++ b/apps/web/src/components/HudOverlay.tsx @@ -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"; @@ -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; @@ -39,7 +43,7 @@ export function HudOverlay({ telemetry, stale = false, compact = false }: HudOve return (
- + @@ -69,7 +73,7 @@ export function HudOverlay({ telemetry, stale = false, compact = false }: HudOve strokeWidth={1.5} /> - + {hasAttitude ? ( { const map = mapRef.current; @@ -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(() => { @@ -331,11 +339,13 @@ 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", @@ -343,6 +353,25 @@ function addMapOverlays(map: MapInstance): void { "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")) { diff --git a/apps/web/src/components/VideoPanel.tsx b/apps/web/src/components/VideoPanel.tsx index a7b0991..ff01008 100644 --- a/apps/web/src/components/VideoPanel.tsx +++ b/apps/web/src/components/VideoPanel.tsx @@ -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(() => (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); @@ -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; @@ -82,9 +97,23 @@ export function VideoPanel({ targetEstimation }: VideoPanelProps) {
- +
+ + +
{!collapsed && ( @@ -100,6 +129,11 @@ export function VideoPanel({ targetEstimation }: VideoPanelProps) {
+ {showHud && ( +
+ +
+ )}