diff --git a/README.md b/README.md index 9c8684e..30ebb72 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ Prefer bench testing and disconnected telemetry validation before using the soft - **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 -- **Optional video stream** (MJPEG, etc.) via environment variables; crosshair overlay for ground-target estimation -- **Ground target estimation** (desktop) — image-center target with GeoTIFF DEM ray marching, map marker, line-of-sight, and sample-log export +- **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 (`localStorage` keys `uav-gcs.onboarding.*`) @@ -223,7 +223,7 @@ Empty system ports without device metadata are hidden; unusual paths can be ente ### Ground target terrain model (desktop) -Real elevation for **ground target estimation** is desktop-only (see [`docs/adr/0005-target-estimation-ts-rust-split.md`](docs/adr/0005-target-estimation-ts-rust-split.md)). Use **Browse…** in the Ground Target card 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. +Real elevation for **ground target estimation** is desktop-only (see [`docs/adr/0005-target-estimation-ts-rust-split.md`](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. @@ -233,10 +233,10 @@ Use a **small GeoTIFF clipped around your flight area** (full-state tiles are la 1. Start the desktop app with live telemetry over the flight area. 2. **No DEM loaded** — Ground Target estimate should be **bad** with `dem_not_loaded`; map marker/LOS hidden. -3. **Browse…** or paste a DEM path in the Ground Target card, then confirm metadata loads. +3. **Browse…** or paste a DEM path in the Ground Target panel (beside the camera feed when video is live), then confirm metadata loads. 4. **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. 5. **Valid/warn estimate** — orange map marker and dashed LOS appear when gimbal/GPS gates pass. -6. **Bad estimate** — marker/LOS hidden; inspect reasons in the Ground Target card. +6. **Bad estimate** — marker/LOS hidden; inspect reasons in the Ground Target panel beside the camera feed. 7. 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_nodata` means the raster cell is empty/NoData inside coverage. 8. 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. @@ -254,7 +254,7 @@ Frontend wrapper: [`apps/web/src/lib/tauriDemTerrain.ts`](apps/web/src/lib/tauri ### Ground target estimation (live) -Ground target estimation (image center) runs in **live** mode only. Use the sortable **Ground Target** sidebar card for 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). The camera panel shows a crosshair plus compact lat/lon, slant range, and quality. Valid or warn estimates also 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 sidebar also 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). +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. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f63d394..b31678c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@uav-ground-control-station/desktop", - "version": "0.2.38", + "version": "0.2.39", "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 d556ea1..d94a74a 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.38" +version = "0.2.39" 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 1071061..c9ac8d7 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.38", + "version": "0.2.39", "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 0255291..ca3ab0a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -175,7 +175,6 @@ export function App() { distanceFromHome={distanceFromHome} alerts={alerts} preflight={preflight} - targetEstimation={targetEstimation} telemetryStale={telemetryStale} /> @@ -190,7 +189,7 @@ export function App() { /> - + {activeSourceMode !== "live" && (
diff --git a/apps/web/src/components/TelemetrySidebar.tsx b/apps/web/src/components/TelemetrySidebar.tsx index c985e9f..9da78e6 100644 --- a/apps/web/src/components/TelemetrySidebar.tsx +++ b/apps/web/src/components/TelemetrySidebar.tsx @@ -5,8 +5,6 @@ import { clampBatteryPercent, elapsedTime, formatInteger, formatNumber, percenta import { sensorHealthSummary } from "../lib/sensorHealth"; import { defaultSidebarOrder, loadSidebarOrder, saveSidebarOrder, type SidebarCardId } from "../lib/sidebarCardOrder"; import type { PreflightHealth } from "../lib/preflight"; -import type { TargetEstimationController } from "../hooks/useTargetEstimation"; -import { GroundTargetPanel } from "./GroundTargetPanel"; import { Badge, Metric, Panel } from "./Panel"; import { PreflightHealthCard } from "./PreflightHealthCard"; import type { SidebarDragHandlers } from "./SidebarSortableList"; @@ -22,7 +20,6 @@ interface TelemetrySidebarProps { distanceFromHome: number | null; alerts: AlertItem[]; preflight: PreflightHealth; - targetEstimation: TargetEstimationController; telemetryStale?: boolean; } @@ -31,7 +28,6 @@ export function TelemetrySidebar({ distanceFromHome, alerts, preflight, - targetEstimation, telemetryStale = false }: TelemetrySidebarProps) { const [view, setView] = useState(() => readSidebarView()); @@ -107,7 +103,7 @@ export function TelemetrySidebar({ mode="text" order={cardOrder} onOrderChange={setCardOrder} - renderCard={(id, drag) => renderTextCard(id, { telemetry, distanceFromHome, batteryPercent, sensorSummary, targetEstimation }, drag)} + renderCard={(id, drag) => renderTextCard(id, { telemetry, distanceFromHome, batteryPercent, sensorSummary }, drag)} /> )} @@ -119,23 +115,13 @@ interface TextRenderContext { distanceFromHome: number | null; batteryPercent: number | null; sensorSummary: string; - targetEstimation: TargetEstimationController; } function renderTextCard(id: SidebarCardId, ctx: TextRenderContext, drag: SidebarDragHandlers) { - const { telemetry, distanceFromHome, batteryPercent, sensorSummary, targetEstimation } = ctx; + const { telemetry, distanceFromHome, batteryPercent, sensorSummary } = ctx; const sortable = { sortable: true as const, onDragStart: drag.onDragStart, onDragEnd: drag.onDragEnd }; switch (id) { - case "groundTarget": - return ( - - ); case "vehicle": return ( diff --git a/apps/web/src/components/VideoPanel.tsx b/apps/web/src/components/VideoPanel.tsx index 4dd280e..a7b0991 100644 --- a/apps/web/src/components/VideoPanel.tsx +++ b/apps/web/src/components/VideoPanel.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react"; -import type { TargetEstimate } from "@uav-ground-control-station/shared"; -import { formatNumber } from "../lib/format"; +import type { TargetEstimationController } from "../hooks/useTargetEstimation"; +import { isVideoSignalGood, useVideoSignalStatus } from "../hooks/useVideoSignalStatus"; import { sanitizeHttpUrl } from "../lib/safeHttpUrl"; -import { Badge } from "./Panel"; +import { GroundTargetPanel } from "./GroundTargetPanel"; type VideoKind = "mjpeg" | "hls" | "webrtc"; @@ -10,15 +10,17 @@ const defaultUrl = sanitizeHttpUrl(import.meta.env.VITE_VIDEO_URL ?? ""); const defaultKind = (import.meta.env.VITE_VIDEO_KIND as VideoKind | undefined) ?? "mjpeg"; interface VideoPanelProps { - estimate: TargetEstimate | null; + targetEstimation: TargetEstimationController; } -export function VideoPanel({ estimate }: VideoPanelProps) { +export function VideoPanel({ 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 [position, setPosition] = useState<{ x: number; y: number } | null>(null); const dragOffsetRef = useRef<{ x: number; y: number } | null>(null); + const videoSignal = useVideoSignalStatus(url, kind); + const showGroundTarget = !collapsed && isVideoSignalGood(videoSignal.status); useEffect(() => { localStorage.setItem("uav-gcs.video.url", url); @@ -61,74 +63,124 @@ export function VideoPanel({ estimate }: VideoPanelProps) { : { right: 24, bottom: 24 }; return ( -
-
-
-
Camera Feed
-
{kind.toUpperCase()}
+
+ {showGroundTarget && ( +
+
- -
- - {!collapsed && ( - <> -
- {url ? :
No video source configured
} -
-
-
-
+ )} + +
+
+
+
Camera Feed
+
+ {kind.toUpperCase()} +
- -
-
- - setUrl(sanitizeHttpUrl(event.target.value))} - placeholder="Video URL (http/https)" - />
- - )} -
+ + + + {!collapsed && ( + <> +
+ {url ? ( + + ) : ( +
No video source configured
+ )} +
+
+
+
+
+
+
+ + setUrl(sanitizeHttpUrl(event.target.value))} + placeholder="Video URL (http/https)" + /> +
+ + )} +
+
); } -function VideoTargetStrip({ estimate }: { estimate: TargetEstimate | null }) { - const showCoords = estimate && (estimate.valid || estimate.quality === "warn") && estimate.lat !== null && estimate.lon !== null; - const tone = estimate?.quality === "good" ? "good" : estimate?.quality === "warn" ? "warn" : "bad"; - - return ( -
-
- {showCoords ? `${formatNumber(estimate.lat, 5)}, ${formatNumber(estimate.lon, 5)}` : "Target --"} -
-
{formatNumber(estimate?.slantRangeM, 0, " m")}
- {estimate?.quality ?? "idle"} -
- ); +function VideoSignalBadge({ status }: { status: ReturnType["status"] }) { + const label = + status === "good" ? "Live" : status === "connecting" ? "Connecting" : status === "error" ? "No signal" : "No URL"; + const tone = + status === "good" + ? "text-emerald-300" + : status === "connecting" + ? "text-amber-200" + : status === "error" + ? "text-red-300" + : "text-slate-500"; + + return {label}; } -function VideoContent({ url, kind }: { url: string; kind: VideoKind }) { +function VideoContent({ + url, + kind, + signal +}: { + url: string; + kind: VideoKind; + signal: ReturnType; +}) { const safeUrl = sanitizeHttpUrl(url); if (!safeUrl) { return
Invalid or unsupported video URL
; } if (kind === "mjpeg") { - return Camera feed; + return ( + Camera feed signal.markFrame()} + onError={() => signal.markError()} + /> + ); } if (kind === "hls") { - return