Frontend design: built to survive backend changes¶
The UI is being built first, before the agent, server or hardware exist. To make sure that work isn't wasted if the backend architecture changes (WebRTC vs WebSocket relay, Go agent vs something else, MAVLink details), the frontend talks to the vehicle only through a small, app-owned contract.
The UI knows nothing about WebRTC, MAVLink, WireGuard or the server topology. It doesn't import them, but what it lets the operator plan must be something ArduPlane can fly: every mission item and command has a mapping in docs/MAVLINK.md (ADR-0017). The planner previews the ArduPilot mission through the MissionTranslator service (useMissionTranslator()), never by importing ardupilot/.
The layers¶
web/src/
domain/ Pure TS types + logic in OUR vocabulary (no MAVLink, no transport)
protocol/ Re-exports packages/protocol: versioned JSON message schemas (zod) = the wire contract
link/ VehicleLink interface + implementations (mock, webrtc, ws…)
services/ AuthClient, MissionRepository, MissionTranslator, TerrainService interfaces + implementations
state/ App stores (e.g. Zustand) fed by a VehicleLink
features/ UI: map, hud, video, mission-planner, checklist, commands, login
app/ Routing, providers, config (selects implementations)
Dependency rule:
- features/ and state/ may import domain/ and the interfaces in link/ and services/.
- They never import a concrete implementation (MockLink, WebRtcLink, …). Only app/ wires those up, chosen by config such as VITE_VEHICLE_LINK=mock|webrtc|ws.
- Enforce this with an ESLint no-restricted-imports rule.
1. Domain model (domain/)¶
The app's own vocabulary. It's stable even if the flight stack or transport changes.
VehicleState: position (lat, lon,altRelM,altAmslM), attitude (roll, pitch, yaw in degrees),groundSpeedMps,airspeedMps,climbMps, battery (V, A, %), GPS (fix type, satellites, HDOP),flightMode(an app enum, see below),armed,vtolState(mc|fw|transition),landed, home,missionProgress(currentIndex, total),rc(linked,overrideActive, optionalmodeSwitch),failsafeflags,updatedAt.FlightMode: app enum (AUTO,LOITER,QLOITER,RTL,QLAND,QHOVER,FBWA,MANUAL,UNKNOWN…). The agent maps from ArduPilot custom modes. The UI never sees raw numbers.Mission:{ id, name, items: MissionItem[], fence?, createdAt, updatedAt }MissionItem(discriminated union):vtolTakeoff{altM},waypoint{lat,lon,altM,acceptRadiusM?},loiter{lat,lon,altM,radiusM,turns|untilUtcMinuteOfDay}(ADR-0013),vtolLand{lat,lon},returnToLaunchCommand(discriminated union, matching the whitelist in ARCHITECTURE.md):arm,disarm,mission.start,mode.pause,mode.resume,mode.rtl,mode.qland,video.config{preset}. No manual-control commands exist in the type at all.CommandResult:{ ok: true } | { ok: false, reason: 'rejected_by_vehicle' | 'blocked_rc_override' | 'preflight_failed' | 'timeout' | 'not_connected' | 'unauthorised', detail? }LinkStatus:{ state: 'disconnected' | 'connecting' | 'connected' | 'degraded', path?: 'direct' | 'relayed' | 'unknown', rttMs?, videoKbps?, lastTelemetryAt? }- Units in field names (
M,Mps,Deg). Lat/lon in degrees (float). Altitudes are relative to home unless the name saysAmsl. - Pure helpers live here too and get unit-tested: mission validation (must start with VTOL takeoff and end with land or RTL, altitude limits, inside the fence), distance and ETA estimates, preflight checklist evaluation.
2. The link interface (link/VehicleLink.ts)¶
export interface VehicleLink {
connect(vehicleId: string): Promise<void>;
disconnect(): Promise<void>;
onState(cb: (s: VehicleState) => void): Unsubscribe;
onLinkStatus(cb: (s: LinkStatus) => void): Unsubscribe;
onEvent(cb: (e: VehicleEvent) => void): Unsubscribe; // status text, failsafe, mode change, RC override
send(cmd: Command): Promise<CommandResult>;
uploadMission(m: Mission): Promise<CommandResult>; // resolves after the vehicle verifies it
downloadMission(): Promise<Mission | null>;
getVideoStream(): MediaStream | null; // UI just attaches it to a <video>
onVideoStream(cb: (s: MediaStream | null) => void): Unsubscribe;
}
Implementations:
| Impl | When | Notes |
|---|---|---|
MockLink |
Now. The UI is built against it. | An in-browser simulated VTOL that flies missions: takeoff climb, transition, waypoint following, loiter, RTL, QLAND, battery drain. Video via canvas.captureStream() (test pattern with a fake horizon). Knobs to inject latency, packet loss, link drop, RC override, low battery and failsafe. |
WebRtcLink |
Phase 1b+ | Signalling over WSS, data channels carrying protocol/ messages, video track. |
WsLink |
Only if the architecture goes back to a relay | Same protocol/ messages over a WebSocket. |
All real links use the same protocol/ messages, so swapping transports touches only one file.
3. Wire protocol (protocol/)¶
- JSON messages with zod schemas, and a top-level
{ v: 1, type, id?, ts, payload }envelope. - Types:
telemetry.state,telemetry.event,cmd.request,cmd.result,mission.upload,mission.uploaded,mission.download,mission.current,video.config,ping/pong. - Every incoming message is validated. Unknown types are ignored (forward compatible).
- It lives in the shared package
packages/protocol(@cellular-drone/protocol, a root npm workspace), used by the web app, the server and mock-agent;web/src/protocolre-exports it, so the app imports it from there. The Go agent mirrors it by hand (generated from JSON Schema exported from zod is a later step). - MockLink also speaks the protocol internally (serialise, validate, deserialise), so the contract gets exercised from day one.
4. Services¶
AuthClient:login(user, pass),logout(),me().MockAuthClientuses a dev password from.env.local;HttpAuthClientcomes later. The UI handles a401everywhere.MissionRepository:list(),get(id),save(m),delete(id).LocalStorageMissionRepositorynow,HttpMissionRepositorylater.
5. UI features (built against MockLink)¶
- Login. Vehicle list (one drone for now).
- Flight screen, laptop-primary (operator runs it as a base station), phone support added later. Map (MapLibre) with the aircraft icon and heading, trail, home, mission path, fence. HUD strip. Video panel: swappable PiP (one of map/video full-screen, the other a small overlay tile; tap the tile to swap) — the same layout on every screen size, not a desktop/phone branch. Link badge (direct/relayed, RTT). RC override banner (always visible when active).
- Mission planner: tap the map to add waypoints, edit altitudes, VTOL takeoff and land items, validation errors inline, save and load.
- Preflight checklist (blocks arm and start until complete) + command bar with confirm dialogs (slide-to-confirm or hold-to-confirm on touch).
- Event log panel (status text, failsafes, command results).
- Dev panel (the menu's Simulator section, whenever the vehicle is a
MockLink): sim speed, guidance look-ahead and fault injection. Never for a real link.
6. Testing¶
- Vitest unit tests for
domain/(validation, checklist) andprotocol/(schema round-trips). - A contract test suite for
VehicleLink(link/__tests__/contract.ts). Every implementation must pass it. That's how we know a new backend is a drop-in. - Playwright smoke test (
npm run e2e,web/e2e/smoke.spec.ts): login → plan mission → upload → arm → start → pause/resume → RTL, against MockLink. It runs its own dev server on 5175 with a test-only grounded mock drone (VITE_MOCK_GROUND_DRONE=true,VITE_MOCK_TIME_SCALE=5).
Stack¶
React + Vite + TypeScript (strict), MapLibre GL (OSM/open tiles), Zustand, zod, Tailwind + shadcn/ui, Vitest, Playwright. Laptop-primary for now (the operator's base station); verify phone layout later — same components, not a separate build.