diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 4197d93..d400c13 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -46,6 +46,7 @@ jobs: - run: pnpm typecheck - run: pnpm lint - run: pnpm test + - run: pnpm audit:js - run: pnpm build - run: pnpm build:cloud diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9898301..0233742 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: - run: pnpm typecheck - run: pnpm lint - run: pnpm test + - run: pnpm audit:js - run: pnpm build - run: pnpm build:cloud diff --git a/apps/server/src/services/serialMavlinkService.test.ts b/apps/server/src/services/serialMavlinkService.test.ts new file mode 100644 index 0000000..b5ac1c8 --- /dev/null +++ b/apps/server/src/services/serialMavlinkService.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mockDestroy = vi.hoisted(() => vi.fn()); + +vi.mock("serialport", () => { + class MockSerialPort { + isOpen = true; + open = vi.fn((callback: (error?: Error | null) => void) => callback()); + close = vi.fn((callback: (error?: Error | null) => void) => { + this.isOpen = false; + callback(); + }); + removeAllListeners = vi.fn(); + on = vi.fn(); + static list = vi.fn(async () => []); + } + + return { SerialPort: MockSerialPort }; +}); + +vi.mock("node-mavlink", () => ({ + default: { + createMavLinkStream: vi.fn(() => ({ + on: vi.fn(), + removeAllListeners: vi.fn(), + destroy: mockDestroy + })) + } +})); + +import { SerialMavlinkService } from "./serialMavlinkService.js"; + +describe("SerialMavlinkService cleanup", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("disconnect clears the open port and marks serial disconnected", async () => { + const service = new SerialMavlinkService(); + await service.connect({ path: "COM3", baudRate: 115200 }); + expect(service.getStatus().serialConnected).toBe(true); + + const status = await service.disconnect(); + expect(status.serialConnected).toBe(false); + expect(mockDestroy).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/services/serialMavlinkService.ts b/apps/server/src/services/serialMavlinkService.ts index 1971064..e63917e 100644 --- a/apps/server/src/services/serialMavlinkService.ts +++ b/apps/server/src/services/serialMavlinkService.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import type { Readable } from "node:stream"; import mavlink from "node-mavlink"; import type { MavLinkPacket } from "node-mavlink"; import { SerialPort } from "serialport"; @@ -11,6 +12,7 @@ const { createMavLinkStream } = mavlink as typeof import("node-mavlink"); export class SerialMavlinkService extends EventEmitter { private readonly store = new TelemetryStore(); private port: SerialPort | null = null; + private mavlinkStream: Readable | null = null; private serialConnected = false; private rawBytes = 0; private parserErrors = 0; @@ -69,6 +71,7 @@ export class SerialMavlinkService extends EventEmitter { console.warn(`Dropped MAVLink packet with invalid CRC (${packet.length} bytes).`); } }); + this.mavlinkStream = mavlinkStream; port.on("data", (chunk: Buffer) => { this.rawBytes += chunk.length; @@ -78,6 +81,7 @@ export class SerialMavlinkService extends EventEmitter { this.parserErrors += 1; this.lastSerialError = error instanceof Error ? error.message : String(error); console.error("MAVLink parser error:", error); + void this.disconnect(); }); port.on("close", () => { @@ -88,6 +92,7 @@ export class SerialMavlinkService extends EventEmitter { port.on("error", (error) => { this.lastSerialError = error.message; console.error("Serial port error:", error); + void this.disconnect(); }); this.emitTelemetry(); @@ -95,17 +100,29 @@ export class SerialMavlinkService extends EventEmitter { } async disconnect(): Promise { - if (this.port?.isOpen) { - const port = this.port; - await new Promise((resolve, reject) => { - port.close((error) => { - if (error) reject(error); - else resolve(); + const port = this.port; + const stream = this.mavlinkStream; + + this.port = null; + this.mavlinkStream = null; + + if (stream) { + stream.removeAllListeners(); + stream.destroy(); + } + + if (port) { + port.removeAllListeners(); + if (port.isOpen) { + await new Promise((resolve, reject) => { + port.close((error) => { + if (error) reject(error); + else resolve(); + }); }); - }); + } } - this.port = null; this.serialConnected = false; this.store.setConnected(false); this.emitTelemetry(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d6c9ef4..2cf9141 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -137,6 +137,7 @@ export function App() { alerts={alerts} preflight={preflight} targetEstimation={targetEstimation} + telemetryStale={telemetryStale} /> {estimate?.quality ?? "idle"} {liveOnlyBlocked && live only} - {estimate?.valid === false && estimate.reasons.length > 0 && ( - {estimate.reasons.join(", ")} + {(estimate?.reasons.length ?? 0) > 0 && ( + + {estimate!.reasons.map(formatTargetReason).join(" ยท ")} + )} + {estimate?.quality === "warn" && ( +

+ Warn estimate โ€” verify gimbal calibration, altitude datum, and terrain before acting on coordinates. +

+ )} +
diff --git a/apps/web/src/components/MapPanel.tsx b/apps/web/src/components/MapPanel.tsx index bd8a857..fdea1e7 100644 --- a/apps/web/src/components/MapPanel.tsx +++ b/apps/web/src/components/MapPanel.tsx @@ -155,11 +155,19 @@ export function MapPanel({ telemetry, coordinate, home, groundTarget, telemetryS source?.setData(pointCollection(homeLngLat)); }, [homeLngLat, mapReady, styleEpoch]); + useEffect(() => { + const map = mapRef.current; + if (!map || !mapReady || !map.getLayer("drone-point")) return; + map.setPaintProperty("drone-point", "circle-opacity", telemetryStale ? 0.45 : 1); + map.setPaintProperty("drone-point", "circle-color", telemetryStale ? "#94a3b8" : "#22d3ee"); + }, [telemetryStale, mapReady, styleEpoch]); + useEffect(() => { const map = mapRef.current; if (!map || !mapReady) return; const showTarget = + !telemetryStale && groundTarget && (groundTarget.valid || groundTarget.quality === "warn") && groundTarget.lat !== null && @@ -183,7 +191,7 @@ export function MapPanel({ telemetry, coordinate, home, groundTarget, telemetryS targetSource?.setData(pointCollection(targetLngLat)); losSource?.setData(lineFeature(droneLngLat, targetLngLat)); - }, [groundTarget, droneLngLat, mapReady, styleEpoch]); + }, [groundTarget, droneLngLat, mapReady, styleEpoch, telemetryStale]); return (
diff --git a/apps/web/src/components/TelemetrySidebar.tsx b/apps/web/src/components/TelemetrySidebar.tsx index afc47a4..a568116 100644 --- a/apps/web/src/components/TelemetrySidebar.tsx +++ b/apps/web/src/components/TelemetrySidebar.tsx @@ -23,9 +23,17 @@ interface TelemetrySidebarProps { alerts: AlertItem[]; preflight: PreflightHealth; targetEstimation: TargetEstimationController; + telemetryStale?: boolean; } -export function TelemetrySidebar({ telemetry, distanceFromHome, alerts, preflight, targetEstimation }: TelemetrySidebarProps) { +export function TelemetrySidebar({ + telemetry, + distanceFromHome, + alerts, + preflight, + targetEstimation, + telemetryStale = false +}: TelemetrySidebarProps) { const [view, setView] = useState(() => readSidebarView()); const [cardOrder, setCardOrder] = useState(() => loadSidebarOrder()); const batteryPercent = clampBatteryPercent(telemetry.battery.remainingPercent); @@ -40,7 +48,16 @@ export function TelemetrySidebar({ telemetry, distanceFromHome, alerts, prefligh }, [cardOrder]); return ( -