// Live mode: real drone poses from the explorer's read-only SQL API. // The same gated /api/query endpoint a peer drone (or an engineer on the // bench) would use — the prototype is just another read-only consumer. import { AREA, LINK_RANGE, POSE_RATE_BYTES, type LinkStats, type World } from "./sim"; export const DEFAULT_EXPLORER_URL = (import.meta.env.VITE_EXPLORER_URL as string | undefined) ?? "http://localhost:30088"; export interface LivePose { id: string; x: number; // meters, mission frame y: number; yawDeg: number; battery: number | null; tsNs: number; } // Latest pose per drone from its own 'sent' state rows, joined with the // latest battery reading from telemetry. arg_max keeps it a single scan. const LIVE_SQL = ` WITH pose AS ( SELECT drone, arg_max(pos_x, ts_ns) AS x, arg_max(pos_y, ts_ns) AS y, arg_max(yaw, ts_ns) AS yaw, max(ts_ns) AS ts_ns FROM state WHERE direction = 'sent' GROUP BY drone ), batt AS ( SELECT drone, arg_max(level_pct, ts_ns) AS battery FROM telemetry WHERE sensor = 'battery' GROUP BY drone ) SELECT p.drone, p.x, p.y, p.yaw, p.ts_ns, b.battery FROM pose p LEFT JOIN batt b USING (drone) ORDER BY p.drone`; interface QueryResponse { columns?: string[]; rows?: (string | number | null)[][]; error?: string; } export async function fetchLivePoses(baseUrl: string): Promise { const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/query`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sql: LIVE_SQL }), }); if (!res.ok) throw new Error(`explorer responded ${res.status}`); const data = (await res.json()) as QueryResponse; if (data.error) throw new Error(data.error); return (data.rows ?? []).map((r) => ({ id: String(r[0]), x: Number(r[1]), y: Number(r[2]), yawDeg: Number(r[3]), tsNs: Number(r[4]), battery: r[5] === null ? null : Number(r[5]), })); } // Scale mission-frame meters into the prototype's square view. The virtual // drones patrol a smaller square than the sim world, so fit the observed // extent (with padding) instead of assuming a size. function fitScale(poses: LivePose[]): number { const extent = Math.max(400, ...poses.map((p) => Math.max(p.x, p.y))) * 1.15; return AREA / extent; } function linkKey(a: number, b: number): string { return a < b ? `${a}-${b}` : `${b}-${a}`; } /** Convert live poses into the World shape the renderer already speaks. */ export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World { const k = fitScale(poses); const drones = poses.map((p, i) => { const t = (prev?.t ?? 0) + dtS; const band = 16 + (i % 6) * 4.5; const alt = band + Math.sin(t * 0.62 + i * 0.91) * 4.5 + Math.sin(p.x * 0.011 + t * 0.45) * 2; const altVel = Math.cos(t * 0.62 + i * 0.91) * 2.8; return { id: i, name: p.id, pos: { x: p.x * k, y: p.y * k }, vel: { x: 0, y: 0 }, heading: (p.yawDeg * Math.PI) / 180, waypoint: 0, battery: p.battery ?? 100, channel: 0, alt, altVel, }; }); // Every in-range pair exchanges pose broadcasts; accumulate totals so the // panel keeps its meaning between polls. const links = new Map(); let totalBytes = prev?.totalBytes ?? 0; for (let i = 0; i < drones.length; i++) { for (let j = i + 1; j < drones.length; j++) { const dist = Math.hypot( drones[i].pos.x - drones[j].pos.x, drones[i].pos.y - drones[j].pos.y, (drones[i].alt ?? 0) - (drones[j].alt ?? 0), ); if (dist > LINK_RANGE) continue; const key = linkKey(i, j); const old = prev?.links.get(key); const bytes = POSE_RATE_BYTES * 2 * dtS; totalBytes += bytes; links.set(key, { key, a: i, b: j, totalUp: (old?.totalUp ?? 0) + bytes / 2, totalDown: (old?.totalDown ?? 0) + bytes / 2, rate: POSE_RATE_BYTES * 2, flash: 0.6, bulk: 0, }); } } return { t: (prev?.t ?? 0) + dtS, drones, obstacles: [], links, totalBytes, broadcasts: (prev?.broadcasts ?? 0) + links.size * 2, }; }