Edge Sync Agent for Constellation Overwatch — MAVLink Relay, Video Bridge, and Fleet Registration for GCS-in-a-Box
Pulsar is the edge sync agent that connects ground control stations to Constellation Overwatch. It runs as a single Go binary on a GCS device, auto-registers fleet entities with Overwatch, relays MAVLink telemetry over NATS JetStream, bridges RTSP video streams, and runs optional on-device YOLO detection.
Warning: This software is under active development. While functional, it may contain bugs and undergo breaking changes.
- Guided First-Time Setup — interactive terminal wizard generates
.env(when credentials are missing) andconfig/fleet.yaml(when no config exists), with input validation - Declarative Fleet Config — define entities in
config/fleet.yaml, Pulsar handles registration and reconciliation - Idempotent Registration — entity UUIDs tracked in
config/c4.jsonacross restarts, no duplicates - MAVLink Relay — 1:1 UDP listeners per entity with auto-assigned sequential ports
- NATS JetStream Publishing — telemetry envelopes with headers on
constellation.telemetry.{entity_id}.{msg_type} - KV State Aggregation — per-entity device state merged by message type into
CONSTELLATION_GLOBAL_STATE - RTSP Video Bridge — per-entity video relay with optional device capture
- On-Device Detection — ONNX/YOLO inference with bounding-box overlay (build-tag gated)
- Live Sync Loop — watches
config/fleet.yamlfor changes, re-registers and restarts services without downtime - Docker Ready — multi-stage Alpine image, single binary
.env (credentials) config/fleet.yaml (desired) config/c4.json (actual)
| | |
v v v
Load env vars Load or guided setup Load previous state
| | |
+---------------------------+--------------------------------+
|
v
Overwatch Registration
(reconcile by entity_id)
|
v
Write config/c4.json
(resolved UUIDs + ports)
|
+--------------+--------------+
| | |
v v v
MAVLink Relay Video Bridge Detector
(per-entity (per-entity (ONNX/YOLO
UDP listeners) RTSP bridge) inference)
|
v
NATS JetStream
(publish telemetry + update KV)
sequenceDiagram
participant D as Drone (MAVLink)
participant R as Pulsar Relay
participant N as NATS JetStream
participant KV as KV Store<br/>(CONSTELLATION_GLOBAL_STATE)
participant OW as Overwatch API
Note over R,OW: Boot: Registration
R->>OW: Register org + entities (config/fleet.yaml)
OW-->>R: Entity UUIDs
R->>R: Write config/c4.json + resolve MAVLink ports
Note over D,KV: Runtime: Telemetry Flow
D->>R: MAVLink UDP frames (auto-assigned port)
R->>R: Parse frame, build TelemetryEnvelope
R->>N: Publish to constellation.telemetry.{entity_id}.{msg_type}
R->>KV: Merge device state by message type
KV-->>OW: Overwatch reads latest state
fleet.yaml |
c4.json |
|
|---|---|---|
| Role | Desired state | Actual state |
| Author | Human | Machine |
| Git | Committed | Gitignored |
| Contains | Names, types, mavlink: true |
UUIDs, resolved ports, RTSP URLs |
fleet.yaml is what you want. c4.json is what is. Pulsar reconciles the two on every boot and sync cycle, using entity names to track UUIDs across restarts without duplicating registrations.
Prerequisites
- Go 1.25 or higher
- A running Constellation Overwatch instance
- Task runner (optional, recommended)
Quick Start
# Clone
git clone https://github.com/Constellation-Overwatch/pulsar.git
cd pulsar
# Copy env and configure credentials
cp .env.example .env
# Edit .env with your Overwatch API key and NATS key
# Or copy the example fleet config
cp config/fleet.example.yaml config/fleet.yaml
# Run (guided setup will create config/fleet.yaml on first boot if missing)
task devOn first run with no .env, Pulsar prompts for credentials:
=== Pulsar .env Setup ===
Required environment variables are missing.
C4_BASE_URL (Overwatch API URL) [http://localhost:8080]: https://overwatch.example.com
C4_API_KEY (Overwatch API key): c4_prod_abc123
C4_NATS_KEY (NATS credential, leave blank to skip): NATS_SEED_XYZ
[pulsar] wrote .env with C4_BASE_URL=https://overwatch.example.com
[pulsar] derived NATS URL: nats://overwatch.example.com:4222 (from C4_BASE_URL)
Then, with no config/fleet.yaml, it walks you through fleet setup with validated inputs:
=== Pulsar First-Time Setup ===
Organization name [GCS Alpha Station]:
Organization types: military (mil), civilian (civ), commercial (company), ngo (nonprofit)
Organization type [civilian]:
How many entities to register? [1]: 3
Entity types:
Aircraft: uav, fixed_wing, vtol, helicopter, airship
Ground: ground_vehicle, wheeled, tracked
Maritime: boat, usv, submarine, auv
Sensors: isr_sensor, sensor, camera, payload
Stations: gcs, operator
Zones: waypoint, no_fly_zone, geofence
Priorities: low, normal, high, critical
--- Entity 1 of 3 ---
Entity name [Entity 1]: Primary UAV
Entity type [uav]: uav
Priority [normal]: high
Enable MAVLink telemetry? (y/n, ports auto-assigned from 14550) [y]: y
Enable video stream? (y/n) [n]: y
Video source types:
rtsp - Network RTSP source (camera, MediaMTX, etc.)
device - Local capture device (/dev/video0)
Video source type [rtsp]: rtsp
RTSP source URL (e.g., rtsp://user:pass@192.168.1.50:554/stream): rtsp://admin:secret@10.0.0.50:554/cam1
--- Entity 2 of 3 ---
Entity name [Entity 2]: Secondary UAV
Entity type [uav]: uav
Priority [normal]: normal
Enable MAVLink telemetry? (y/n, ports auto-assigned from 14550) [y]: y
Enable video stream? (y/n) [n]: n
--- Entity 3 of 3 ---
Entity name [Entity 3]: Ground Camera
Entity type [uav]: isr_sensor
Priority [normal]: normal
Enable MAVLink telemetry? (y/n, ports auto-assigned from 14550) [y]: n
Enable video stream? (y/n) [n]: y
Video source types:
rtsp - Network RTSP source (camera, MediaMTX, etc.)
device - Local capture device (/dev/video0)
Video source type [rtsp]: device
Device path [/dev/video0]: /dev/video0
=== Fleet Summary ===
Organization: GCS Alpha Station (civilian)
Entities: 3 total, 2 with MAVLink, 2 with video
- Primary UAV [uav] -> mavlink video(rtsp:rtsp://admin:***@10.0.0.50:554/cam1)
- Secondary UAV [uav] -> mavlink
- Ground Camera [isr_sensor] -> video(device:/dev/video0)
This generates config/fleet.yaml and registers with Overwatch. MAVLink ports are auto-assigned:
[pulsar] mavlink: Primary UAV -> UDP :14550
[pulsar] mavlink: Secondary UAV -> UDP :14551
Environment variables (.env):
| Variable | Required | Default | Description |
|---|---|---|---|
C4_API_KEY |
Yes | — | Overwatch API bearer token |
C4_BASE_URL |
Yes | — | Overwatch API URL (e.g. http://localhost:8080) |
C4_NATS_KEY |
Yes | — | NATS nkey seed for JetStream auth |
C4_NATS_URL |
No | Derived from C4_BASE_URL host |
NATS server URL (auto-derived as nats://{host}:4222 if not set) |
MAVLINK_BASE_PORT |
No | 14550 |
Starting port for auto-assigned MAVLink listeners |
FLEET_CONFIG |
No | config/fleet.yaml |
Path to fleet config file |
C4_STATE_FILE |
No | config/c4.json |
Path to state file |
RTSP_HOST |
No | localhost |
Hostname for local RTSP URL construction (used by bridge/detector) |
ADVERTISE_HOST |
No | Auto-discovered (first non-loopback IPv4) | Hostname/IP published to Overwatch for external consumers |
MEDIAMTX_API_URL |
No | http://localhost:9997 |
MediaMTX API URL for RTSP server auto-detection |
MODEL_PATH |
No | models/yoloe-11s-seg.onnx |
ONNX model path for detection |
Fleet config (config/fleet.yaml):
organization:
name: "GCS Alpha Station"
type: "civilian"
description: "Rapid response ground control station"
entities:
- name: "Primary UAV"
type: "uav"
priority: "high"
status: "active"
mavlink: true # auto-assign port from MAVLINK_BASE_PORT
video_config:
protocol: "rtsp"
port: 8554
source: "rtsp://admin:secret@10.0.0.50:554/cam1" # network RTSP with auth
- name: "Secondary UAV"
type: "uav"
priority: "normal"
status: "active"
mavlink: true # gets next sequential port
- name: "Fixed Overwatch"
type: "uav"
priority: "low"
status: "active"
mavlink:
port: 14560 # explicit port override
- name: "Ground Camera"
type: "isr_sensor"
priority: "normal"
status: "active"
video_config:
protocol: "rtsp"
port: 8554
device: "/dev/video0" # local capture device
# no mavlink key = no listenerEntity types:
| Category | Types |
|---|---|
| Aircraft | uav, fixed_wing, vtol, helicopter, airship |
| Ground | ground_vehicle, wheeled, tracked |
| Maritime | boat, usv, submarine, auv |
| Sensors | isr_sensor, sensor, camera, payload |
| Stations | gcs, operator |
| Zones | waypoint, no_fly_zone, geofence |
Organization types: military (mil), civilian (civ), commercial (company), ngo (nonprofit)
Priorities: low, normal, high, critical
Ports are auto-assigned sequentially from MAVLINK_BASE_PORT (default 14550):
| Entity | Config | Resolved Port |
|---|---|---|
| Primary UAV | mavlink: true |
:14550 |
| Secondary UAV | mavlink: true |
:14551 |
| Fixed Overwatch | mavlink: {port: 14560} |
:14560 |
| Ground Camera | (no mavlink key) | (no listener) |
Explicit ports are reserved first, then auto-assigned ports fill in from the base, skipping conflicts.
Each entity can optionally have a video_config with one of two source types:
| Source Type | Config Key | Example | Description |
|---|---|---|---|
| Network RTSP | source |
rtsp://admin:pass@10.0.0.50:554/cam1 |
Proxied through MediaMTX or read directly by detector |
| Local Device | device |
/dev/video0 |
Captured via OpenCV, published as MJPEG to RTSP server |
| (none) | (omit video_config) | — | No video for this entity |
RTSP credentials are embedded in the source URL per RFC 2396 (rtsp://user:pass@host:port/path). Pulsar auto-detects whether MediaMTX is running (probes its API at MEDIAMTX_API_URL); if found, it configures on-demand source proxying. Otherwise it starts an embedded gortsplib RTSP server as fallback.
Pulsar uses two separate host values for video URLs — one for local services and one for external consumers:
| Variable | Used By | Appears In | Purpose |
|---|---|---|---|
RTSP_HOST |
Bridge, detector, overlay | c4.json rtsp_url |
Where local services connect to the RTSP server |
ADVERTISE_HOST |
Registry → Overwatch API | Overwatch entity video_config |
Where external consumers (GCS UI, etc.) connect |
The registry never sends fleet.yaml video_config to Overwatch directly. It generates a separate set of advertised endpoints using ADVERTISE_HOST:
# What Overwatch receives (auto-generated from ADVERTISE_HOST):
stream_url: rtsp://{ADVERTISE_HOST}:{port}/{entity_id}
overlay_url: rtsp://{ADVERTISE_HOST}:{port}/{entity_id}/pulsar
webrtc_url: http://{ADVERTISE_HOST}:8889/{entity_id}/pulsar
hls_url: http://{ADVERTISE_HOST}:8888/{entity_id}/pulsar
# What c4.json stores (from RTSP_HOST, for local use):
rtsp_url: rtsp://{RTSP_HOST}:{port}/{entity_id}Set both explicitly. Auto-discovery (ADVERTISE_HOST default) picks the first non-loopback IPv4, which can be wrong on multi-NIC hosts, VPNs, or containers.
MediaMTX is required for browser video (WebRTC/HLS). Without it, Pulsar falls back to an embedded RTSP-only server that supports TCP clients (ffplay, VLC) but not WebRTC or HLS. For Overwatch UI integration, run MediaMTX alongside Pulsar:
# Install (macOS)
task setup:mediamtx # or: brew install mediamtx
# Dev: terminal 1 — MediaMTX
task mediamtx
# Dev: terminal 2 — Pulsar with detection
ADVERTISE_HOST=localhost task dev:detection
# Open WebRTC player in browser
task test:webrtc # → http://localhost:8889/{entity_id}For deployment, MediaMTX runs as a sidecar (see docker-compose.yaml):
# Deployment (private network, MediaMTX sidecar)
RTSP_HOST=mediamtx ADVERTISE_HOST=10.0.1.50 MEDIAMTX_API_URL=http://mediamtx:9997 ./pulsar| Subject | Description |
|---|---|
constellation.telemetry.{entity_id}.heartbeat |
Heartbeat messages |
constellation.telemetry.{entity_id}.globalpositionint |
GPS position |
constellation.telemetry.{entity_id}.attitude |
Pitch/roll/yaw |
constellation.telemetry.{entity_id}.vfr_hud |
HUD data |
constellation.telemetry.{entity_id}.systemstatus |
Battery, sensors |
Bucket: CONSTELLATION_GLOBAL_STATE
Key pattern: {entity_id}.mavlink
Device state is aggregated — each message type merges into the existing state rather than overwriting it.
pulsar/
├── cmd/
│ └── microlith/
│ └── main.go # Entry point, guided setup, sync loop
├── config/
│ ├── fleet.example.yaml # Example config with drone + robot + sensor
│ ├── fleet.yaml # Fleet config (user-authored)
│ └── c4.json # State file (machine-generated, gitignored)
├── pkg/
│ ├── services/
│ │ ├── relay/
│ │ │ └── relay.go # MAVLink UDP listeners, frame handling
│ │ ├── publisher/
│ │ │ └── publisher.go # NATS JetStream + KV publishing
│ │ ├── registry/
│ │ │ ├── registry.go # Overwatch API registration, reconciliation
│ │ │ └── registry_test.go # Registration unit tests
│ │ ├── detector/
│ │ │ ├── detector.go # ONNX/YOLO inference (build-tag gated)
│ │ │ └── detector_noop.go # No-op stub when detection disabled
│ │ ├── video/
│ │ │ ├── server.go # RTSP server (MediaMTX or embedded)
│ │ │ ├── bridge.go # Video stream bridge
│ │ │ ├── bridge_device.go # Local camera capture (CGO-gated)
│ │ │ ├── h264_encoder.go # x264 H264 encoding
│ │ │ └── overlay.go # Detection overlay rendering
│ │ └── logger/
│ │ └── logger.go # Zap-based structured logging
│ └── shared/
│ ├── config.go # Fleet/C4 types, YAML unmarshaling, port resolver
│ ├── client.go # Overwatch HTTP REST client
│ └── network.go # NIC priority ranking, ADVERTISE_HOST discovery
├── internal/
│ └── x264-go/ # Local fork (IDR keyframe fix for WebRTC)
├── docs/
│ └── CGO_FREE_ARCHITECTURE.md # CGO elimination migration plan
├── scripts/
│ ├── mavlink-relay.sh # Synthetic MAVLink integration test
│ └── export_yolo26.py # YOLO26 ONNX model export
├── data/ # ONNX models (gitignored)
├── .env.example # Environment template
├── Dockerfile # Multi-stage Alpine build (CGO_ENABLED=0)
├── Taskfile.yml # Task runner commands
└── .goreleaser.yaml # Release configuration
Pulsar auto-detects MediaMTX for WebRTC/HLS streaming. Without it, only RTSP TCP clients (ffplay, VLC) work.
Option A — Native install:
task setup:mediamtx # install MediaMTX
task mediamtx # runs on :8554 (RTSP), :8889 (WebRTC), :8888 (HLS)Option B — Docker:
docker run -d --name mediamtx --network host bluenviron/mediamtx:latestThen run Pulsar normally — it discovers MediaMTX via MEDIAMTX_API_URL (default http://localhost:9997).
# Build
task docker-build
# Run standalone
docker run --env-file .env --network host -v $(pwd)/config:/app/config pulsar:latest
# Or with Docker Compose (Pulsar + MediaMTX sidecar)
docker-compose up -dSet
ADVERTISE_HOSTto your host's reachable IP when deploying — auto-discovery can pick the wrong interface in containers.
# Build binary
task build
# Run in dev mode
task dev
# Run synthetic MAVLink test
task test-relay
# Run tests
go test ./...
# Clean
task cleanThis project is licensed under the MIT License.