getSerialNumber() is a BaseComponent method, so every component answers for
itself — and the bridge reads it off the flight controller. A Mavic Pro reports
08RDE1J00103H1 (what DJI Go labels "Flight Controller SN") where the airframe
sticker, and the registration, say 08QDE3H012032E. We were publishing the former
as the drone's serial, onto records that exist to satisfy BEK 1649 §5.
Same trap as 002e484, where a component's own firmware stood in for the
aircraft's, but with no correct source to switch to: MSDK v4 exposes no
aircraft-level serial at all — BaseProduct offers only the model and the
firmware package version — so the registered serial can only be typed by hand.
So split the two rather than pick one:
serial the airframe's, hand-entered, and the only one that
reaches the logbook and the CSV export
flight_controller_serial what the aircraft reports; auto-filled on connect,
and what POST /api/drones/auto now upserts on
Keying auto-add on the flight controller's serial keeps the fleet recognising a
connected drone without typing — it is stable per airframe — while leaving the
compliance record's serial to the pilot. A flight controller swapped in a repair
now costs a duplicate fleet entry to merge, where before it would have quietly
rewritten what the logbook claimed the drone was.
Note droneInput.payload() is a whole-record write, so any UI editing a drone must
round-trip flightControllerSerial; blanking it forks the drone into a duplicate
on its next connect. Drones.vue carries it through the edit form for that reason.
The migration copies existing serials into flight_controller_serial rather than
moving them: every current value came from auto-add and is therefore a flight
controller's, but a pilot may since have corrected one by hand and this cannot
tell them apart. Copying keeps auto-add matching the airframes it matched before.
Applied to the remote PocketBase, where drones held no records, so the backfill
was a no-op there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PilotVault — API Server
Go service that is the single entry point for PilotVault. The Fly App streams drone telemetry to it; the Web App and the built-in API Web Panel read live state and issue commands. It keeps device state in memory and proxies authentication to a PocketBase kept behind it (PocketBase's address is never exposed to clients).
Fly App ──► /ws/device ┐
├─► API Server (:8080) ──► PocketBase (auth only)
Web App ──► /ws/ui ┘
The server root (GET /) serves a PilotVault-branded web panel: a live health
readout plus a quick reference of both API audiences. Open
http://localhost:8080/ in a browser to check the server at a glance.
The panel is a Vue 3 + Tailwind v4 app in panel/, built into
internal/api/dist and embedded into the Go binary at compile time:
cd panel
npm install
npm run build # outputs to ../internal/api/dist
cd ..; go build -o api-server.exe ./cmd/server # embeds the fresh dist
For panel development with hot reload (proxies /api to a running server on
:8080): cd panel; npm run dev → http://localhost:5174.
Requirements
- Go 1.24+ (
go version) - Node 18+ (only for building the panel)
- A reachable PocketBase instance with a
usersauth collection (for login). To persist user settings, that collection needs apreferencesJSON field — seepocketbase/README.md.
Configure
Copy-Item .env.example .env
# edit .env: set POCKETBASE_URL (and CORS_ALLOW_ORIGINS if needed)
| Variable | Purpose | Default |
|---|---|---|
API_ADDR |
Listen address | :8080 |
POCKETBASE_URL |
PocketBase base URL (login proxy). Legacy PB_URL still honoured. |
http://10.2.1.10:8026 |
CORS_ALLOW_ORIGINS |
Comma list, or * |
* |
Run
./scripts/Run-ApiServer.ps1
# or: go run ./cmd/server
Health check: GET http://localhost:8080/healthz.
API
Client / dashboard endpoints
| Method | Path | Description |
|---|---|---|
GET |
/healthz, /api/health |
Readiness probe + device count (public) |
POST |
/api/auth/login |
{email, password} → PocketBase session (proxied) |
GET |
/api/auth/validate |
Validate the Authorization token |
GET |
/api/me |
Caller's {id, email, role, organization, organizationName} resolved from their token |
GET |
/api/preferences |
Read the caller's saved settings blob (from their PocketBase user record) |
PUT |
/api/preferences |
{preferences} → persist the caller's settings onto their user record |
GET |
/api/users |
List users (manager; admin → own org, superadmin → all) |
POST |
/api/users |
{email, password, role, organization?} → create a user (manager; admin scoped to own org) |
PATCH |
/api/users/{id} |
Edit {email?, role?, verified?, password?, organization?} (manager; scope-checked; cannot demote self) |
DELETE |
/api/users/{id} |
Delete a user (manager; admin → own org only; cannot delete self) |
GET |
/api/orgs |
List organizations (manager; admin → own org, superadmin → all) |
POST |
/api/orgs |
{name} → create an organization (superadmin only) |
PATCH |
/api/orgs/{id} |
{name} → rename an organization (superadmin only) |
DELETE |
/api/orgs/{id} |
Delete an empty organization (superadmin only) |
GET |
/api/admin/pb-config |
Read the PocketBase connection + a live probe (superadmin only) |
POST |
/api/admin/pb-config/test |
{url?, adminEmail?, adminPassword?} → probe a candidate connection without applying (superadmin only) |
PUT |
/api/admin/pb-config |
{url, adminEmail?, adminPassword?} → apply at runtime + persist to .env (superadmin only) |
GET |
/api/admin/plugins |
List plugins with state + last health (superadmin only) |
POST |
/api/admin/plugins |
{name, baseURL, provider?} → register an external plugin, no rebuild (superadmin only) |
GET |
/api/admin/plugins/{name} |
One plugin's view (superadmin only) |
PUT |
/api/admin/plugins/{name} |
{enabled?, config?} → enable/disable + configure (superadmin only) |
DELETE |
/api/admin/plugins/{name} |
Remove an external plugin (superadmin only) |
POST |
/api/admin/plugins/{name}/health |
Run a health check now (superadmin only) |
GET |
/api/devices |
List devices and their last-known state |
GET |
/api/devices/{id}/track |
GPS track history for a device |
POST |
/api/devices/{id}/command |
Send {command, payload?} to a connected device |
DELETE |
/api/devices/{id} |
Forget a device's stored state |
GET |
/ws/ui |
Live telemetry stream (WebSocket) |
Device endpoints (Fly App)
| Method | Path | Description |
|---|---|---|
GET |
/ws/device?id={id} |
Telemetry uplink (WebSocket) |
POST |
/api/telemetry?id={id} |
Push a single telemetry event over HTTP |
Telemetry events
Device messages carry a type: registration, connection, battery, or
telemetry (altitude, lat/lng, velocity, GPS sats, flight mode…). The server
merges them into a per-device DeviceState and fans each update out to every
connected dashboard as {type:"update", device, event}. latitude/longitude
samples are appended to the device's GPS track.
Plugins
The server integrates external third-party services through a uniform plugin
contract (internal/plugins), managed by a superadmin from the panel. Two kinds
share one interface:
- Built-in — Go connectors compiled into the server (type-safe, first-party).
The reference example is OpenSky Network (
internal/plugins/builtin/opensky), a live ADS-B flight-state connector with an OAuth2 / anonymous auth provider. Adding a new built-in needs a rebuild. - External — a remote HTTP service registered at runtime, no rebuild. It
answers a small contract (
GET /health,GET /manifest,POST /invoke) and can run as its own process/container (the sandboxing story).
Enable-state and per-plugin config (secrets included) persist to a local,
gitignored plugins.json (override with PLUGINS_FILE), loaded on boot. Every
plugin exposes a real HealthCheck. Deferred extension points (invocation API,
retry/circuit-breaker, per-tenant credentials, audit logging) are documented in
internal/plugins/doc.go.
Writing a plugin: see the developer guide
internal/plugins/README.md — step-by-step for both
built-in (Go) and external (HTTP, no rebuild) plugins, with complete examples.
Project layout
cmd/server/main.go entry point, wiring, graceful shutdown
internal/config env/.env configuration
internal/hub in-memory device state + websocket fan-out (drone core)
internal/api router, middleware, handlers, embedded panel
internal/plugins plugin contract, manager, external kind + built-in connectors
panel/ Vue 3 + Tailwind v4 web panel (built into internal/api/dist)
scripts/ run helper