Command & Control: ops room → node¶
How an ops-room operator tasks a sensor node over the mesh — pointing its camera, driving zoom/capture, sending a move order, or recalling it — and how the node verifies, executes, and acknowledges that command. This page is the shared trust model for the command path; it spans the issuer clients — web (rear, full command set) and android (edge, camera + feed view; see Who issues what) — the server (key-blind relay), the node (receiver + autopilot bridge), and the Directory (identity + group key).
Roadmap. Camera control was the proof-of-concept that proved the end-to-end
authenticated, sealed command path from an android/web client to a node's
autopilot. The node now consumes the full command set — camera (ROI / pitch-yaw /
zoom / capture), an ordered move order, recall/hold, and the flight verbs
(FlyToWaypoint / SetFlightMode) — over that same envelope, sealing, and
verification pipeline. The Tier-2 AreaOrder remains a reserved oneof slot, not
yet built. Android issues the full node command set; the remaining issuer-side gap
is web zoom / capture, which needs a web video-feed overlay (see
Gaps).
Scope¶
The command path gives the ops room an authenticated tasking channel to a node's autopilot. The node consumes these variants today:
| Command | Effect | MAVLink |
|---|---|---|
CameraRoi |
Point the gimbal at a ground coordinate (lat/lon/alt) | COMMAND_INT(MAV_CMD_DO_SET_ROI_LOCATION) |
CameraPitchYaw |
Set gimbal pitch/yaw directly | COMMAND_LONG(MAV_CMD_DO_GIMBAL_MANAGER_PITCHYAW) |
CameraZoom |
Drive the camera zoom (level / rate / range / step) | COMMAND_LONG(MAV_CMD_SET_CAMERA_ZOOM) |
CameraCapture |
Start/stop photo or video capture | COMMAND_LONG(MAV_CMD_IMAGE_START_CAPTURE / VIDEO_START_CAPTURE / VIDEO_STOP_CAPTURE) |
MoveOrder |
Task an ordered waypoint route | mission upload of MAV_CMD_NAV_WAYPOINT items |
ReturnToLaunch |
Recall the node to its launch point | COMMAND_LONG(MAV_CMD_NAV_RETURN_TO_LAUNCH) |
LoiterHold |
Hold on station at the current position | COMMAND_LONG(MAV_CMD_NAV_LOITER_UNLIM) |
FlyToWaypoint |
Fly to a single point (reposition) | COMMAND_INT(MAV_CMD_DO_REPOSITION) |
SetFlightMode |
Command a flight mode (LAND today) |
COMMAND_LONG(MAV_CMD_NAV_LAND) |
The Tier-2 AreaOrder is a reserved oneof slot — not in the wire protocol or
the node forwarder yet. See Gaps. MoveOrder,
ReturnToLaunch, LoiterHold, FlyToWaypoint, and SetFlightMode are the
kinetic verbs — authorized at the node by the command_device_movement
capability plus a deployment kill-switch (see Roles).
Commands are operator-initiated, role-gated, end-to-end authenticated, and end-to-end sealed. The relay that carries them cannot read or forge them.
Who issues what — web (rear) vs android (edge)¶
Command issuance is driven from the rear, but both clients are command issuers:
| Client | Tier | Issues | Views |
|---|---|---|---|
| web (ops room / rear) | full command issuer | camera (ROI / zoom / capture) + move orders + recall/hold + flight (fly-to / set-flight-mode) | acks, audit, camera feed |
| android (edge) | full command issuer + viewer | camera (ROI / pitch-yaw / zoom / capture) + move orders + recall/hold + flight (fly-to / set-flight-mode) | camera feed, acks |
Both clients seal under the same group key and sign with their operator credential;
the node enforces the role allowlist regardless of which client a command came from,
and every verb is client-gated on its capability (camera on command_device_camera,
move / recall / hold and the flight verbs on command_device_movement — the
kinetic tier). The android edge client is
additionally where the camera feed is viewed at the edge. The two clients differ
only in the camera composer surfaces they carry — see the built-state note below.
Current built state (issuer clients)
Android covers the full node command set — ROI + gimbal pitch/yaw + zoom + capture
on the video overlay, move order, recall/hold, and the flight verbs (fly-to map-tap +
set-flight-mode LAND confirm) — with live ack handling, exercised end-to-end. web
issues ROI + move order + recall/hold + the flight verbs (fly-to map-tap + a
LAND-only confirm sheet naming the asset) with ack handling, but every web publish is
dormant — it fails closed at pack time until the Zenoh remote-api bridge is
deployed, the same gate that holds all browser-operator publish. The browser-operator
signing key is in place (the Directory mints it at the webauth exchange; web seeds the
transport signing key), so the bridge deploy is the one remaining browser-publish
prerequisite. Both clients
surface LAND only for set-flight-mode; GUIDED / HOLD stay node-deferred and are not
offered client-side.
The one remaining client gap is web zoom / capture: those verbs are video-relative
(zoom the current view, capture the current frame) and need a web video-feed overlay web
does not have yet — web camera tasking today is map-driven (CameraRoi), and web does
not issue CameraPitchYaw either. Tracked as its own feature. See
status & roadmap and
Gaps.
flowchart LR
subgraph rear["Rear / ops room"]
WEB["<b>web</b><br/>full command issuer<br/>camera + move + recall/hold"]
end
R["server (relay)<br/>routes samples · KEY-BLIND"]
subgraph edge["Edge"]
AND["<b>android</b><br/>full command issuer<br/>+ feed viewer"]
NODE["<b>node</b><br/>receiver + autopilot bridge"]
AP["autopilot"]
end
WEB -->|"sealed DeviceCommand<br/>camera + move + recall"| R
AND -->|"sealed DeviceCommand<br/>camera + move + recall"| R
R --> NODE
NODE -->|"MAVLink (UDP)"| AP
NODE -.->|"sealed camera feed"| R
R -.-> AND
R -.-> WEB
The node side is variant-agnostic: it verifies, role-gates, and actuates every variant identically (see Scope), so which client may issue a given verb is a client-capability statement, not a node enforcement boundary.
Topology¶
There is no command-only server. Commands ride the same Zenoh peer mesh as position and heartbeat traffic. The relay routes Zenoh samples between peers but holds no group key — it sees ciphertext only (server-blind E2E).
flowchart LR
WEB["web (ops room)<br/>operator credential<br/>+ group key"]
R["server (relay)<br/>routes samples<br/>KEY-BLIND"]
NODE["node<br/>operator-cmd subscriber<br/>+ autopilot bridge"]
AP["autopilot<br/>(CubeOrange / PX4)"]
WEB -->|"put waypoint/global/cmd/<node>/<cmd_id><br/>AuthEnvelope{ sealed DeviceCommand }"| R
R --> NODE
NODE -->|"MAVLink COMMAND_INT/LONG or mission upload (UDP)"| AP
AP -->|"COMMAND_ACK (msg 77) / MISSION_ACK"| NODE
NODE -->|"put waypoint/global/ack/<node>/<cmd_id><br/>AuthEnvelope{ sealed CommandAck }"| R
R --> WEB
Why mesh, not a direct web→node link¶
| Concern | Direct web→node | Over the mesh |
|---|---|---|
| Reach | Node may be behind NAT / on a MANET radio; web can't address it. | Node is already a mesh peer; the command rides existing discovery + routing. |
| DDIL | Web must hold a live link to every node and hand-roll retry. | Zenoh handles reconnect / churn. The node dedups by command_id; the issuer re-publishes within its freshness window. |
| Auth | Node would have to trust a web session token — a foreign auth system. | The node trusts the Directory-signed operator identity on the envelope. No relay or session is in the trust path. |
| Confidentiality | TLS to the relay only — relay sees plaintext. | Payload sealed under the deployment group key end-to-end; relay is key-blind. |
Wire protocol¶
One published sample = one encoded
AuthEnvelope wrapping a sealed payload. No outer wrapper.
| Key | Direction | Sealed payload |
|---|---|---|
waypoint/global/cmd/<node_principal_id>/<cmd_id> |
issuer → node | DeviceCommand |
waypoint/global/ack/<node_principal_id>/<cmd_id> |
node → issuer | CommandAck |
DeviceCommand carries no auth fields — identity, integrity, freshness, and
replay protection all live on the outer AuthEnvelope. Coordinates are
integer-scaled fixed-point (matching CameraRoi / Coordinate), so every
consumer reuses one decode path:
message DeviceCommand {
string command_id = 1; // issuer-set UUID, correlation + dedup key
bytes target_node_pubkey = 5; // raw Ed25519 key (32 bytes), binds the command to one node
oneof command {
CameraRoi camera_roi = 10;
CameraPitchYaw camera_pitch_yaw = 11;
FlyToWaypoint fly_to_waypoint = 12;
SetFlightMode set_flight_mode = 13;
MoveOrder move_order = 14;
// 15 AreaOrder — reserved (Tier-2: OrderType + ring of Waypoints)
CameraZoom camera_zoom = 16;
CameraCapture camera_capture = 17;
ReturnToLaunch return_to_launch = 18;
LoiterHold loiter_hold = 19;
}
}
message CameraRoi { sint32 lat_e7 = 1; sint32 lon_e7 = 2; sint32 altitude_dm = 3; } // alt MSL, decimetres
message CameraPitchYaw { sint32 pitch_cdeg = 1; uint32 yaw_cdeg = 2; } // centi-degrees; pitch -9000..+9000, yaw 0..35999
// Order-significant route; node traverses front to back, honouring each hold_secs.
message Waypoint { sint32 lat_e7 = 1; sint32 lon_e7 = 2; sint32 altitude_dm = 3; uint32 hold_secs = 4; }
message MoveOrder { repeated Waypoint waypoints = 1; } // empty list rejected; consumer caps length
message CameraZoom { ZoomType type = 1; float value = 2; } // value interpreted per type
enum ZoomType { ZOOM_LEVEL = 0; ZOOM_RATE = 1; ZOOM_RANGE = 2; ZOOM_STEP = 3; }
message CameraCapture { CaptureAction action = 1; uint32 count = 2; float interval_s = 3; } // count/interval: PHOTO_SEQUENCE only (count 0 = unlimited)
enum CaptureAction { PHOTO_SINGLE = 0; PHOTO_SEQUENCE = 1; VIDEO_START = 2; VIDEO_STOP = 3; }
message ReturnToLaunch {} // no parameters
message LoiterHold { uint32 radius_m = 1; } // 0 = platform default / hold-in-place
// Single-point reposition (distinct from a MoveOrder route). Location-bearing.
message FlyToWaypoint { sint32 lat_e7 = 1; sint32 lon_e7 = 2; sint32 altitude_dm = 3; }
// Platform-agnostic mode; node maps to the airframe-correct MAVLink and holds
// the allowlist. GUIDED/HOLD ship in the vocabulary but the node rejects them
// until it carries an autopilot-flavor config; LAND is actuated today.
message SetFlightMode { FlightMode mode = 1; }
enum FlightMode { FLIGHT_MODE_UNSPECIFIED = 0; FLIGHT_MODE_LAND = 1; FLIGHT_MODE_GUIDED = 2; FLIGHT_MODE_HOLD = 3; }
message CommandAck {
string command_id = 1; // correlates to DeviceCommand.command_id
AckResult result = 2;
string detail = 3; // human-readable, e.g. "MAV_RESULT_ACCEPTED"
uint64 acked_at_ms = 4;
}
enum AckResult {
ACK_ACCEPTED = 0;
ACK_REJECTED = 1; // autopilot refused (geofence, wrong mode, mission error, validation), or forwarder error
ACK_TIMEOUT = 2; // no COMMAND_ACK / MISSION_ACK within the deadline
ACK_UNSUPPORTED = 3; // command reception disabled, missing variant, or no forwarder
ACK_UNAUTHORISED = 4; // role not permitted, or target_node_pubkey mismatch
ACK_INVALID_SIGNATURE = 5; // envelope signature / identity verification failed
}
The envelope itself is sealed: pack_with_metadata seals the DeviceCommand
under the group key, then signs device_signature over the ciphertext plus the
signed-cleartext classification + owner_principal_id metadata the relay reads
for its gate. The node reverses this on receive.
Every variant — camera, move order, and recall/hold alike — rides the same
target_node_pubkey cross-binding and signed AuthEnvelope; none carries an
issuing principal of its own, so the envelope is the sole authority for who tasked
the node, and a relay cannot retarget any variant to a different node without
breaking the binding.
Issuer UX¶
The node side is the enforcement boundary; the issuer side is where an operator composes a command. Both clients carry the command set; android (edge) additionally views the feed (see Who issues what). The camera-surface decisions below apply to both clients — the two camera verbs live on different surfaces, because operators reach for them in different mental modes:
| Verb | Surface | Why |
|---|---|---|
CameraRoi (point at a place) |
Map | The operator thinks spatially — "look there". Reuses the existing map-tap → coordinate path. |
CameraPitchYaw (slew the gimbal) |
Video feed overlay | The operator watches the feed and adjusts what they see. D-pad nudges + absolute-angle sliders drawn over the live video. |
| Ack status, audit history, attribution, role state | Per-node C2 panel | A persistent hub the two transient surfaces feed into. |
The node bridges the full command set and the composer surfaces them accordingly:
zoom + capture sit on the video-feed overlay alongside pitch/yaw (built on
android; pending a web video overlay — see Gaps); the
move order, recall/hold, and the flight verbs (MoveOrder, ReturnToLaunch,
LoiterHold, FlyToWaypoint, SetFlightMode) live on the panel/map surface,
not the video overlay.
Settled interaction decisions for the PoC:
- Hybrid map + panel — not map-only (buries ack/history), not panel-only (throws away the natural map tap).
- Gimbal = d-pad + sliders, not drag-to-slew: explicit, discoverable, and lag-tolerant on a DDIL link.
- ROI = instant send while "aim mode" is armed. Tapping the node marker arms aim mode; a subsequent map tap sends immediately; exiting disarms. Arming is the single guard against a stray tap re-aiming the camera.
- Flight verbs = two-step confirm, never instant. Higher consequence than camera, so they diverge from the ROI instant-tap:
FlyToWaypointarms a fly mode → a map tap stages a pending destination → an explicit Send fly-to commits;SetFlightModeis an explicit panel action that opens a confirm dialog naming the asset and mode ("Land<callsign>?") before it sends. Naming the specific node is the guard against wrong-asset tasking on a multi-node map. - Multi-operator = last-write-wins + attribution. No claim/lock; the node arbitrates whatever arrives last. The panel surfaces
last commanded by <callsign>by observing the issuer identity on the node's cmd-topic envelopes. - Audit log = per-node, issuer-owned authoritative record. A global cross-node view is deferred.
- Role gating = visible-but-disabled + "view-only" tag when the operator lacks an allowlisted role. The node enforces regardless — the UI gate is cosmetic, never the boundary.
Client pipeline (issuer side, mirroring the node's gate in reverse):
- Outbound: resolve
target_node_pubkey(the verified node's raw Ed25519 sign key, already cached on-device) → seal theDeviceCommandunder the group key (fail-closed: no key ⇒ no send) → wrap in an operator-signedAuthEnvelope→ publish onwaypoint/global/cmd/<node_principal_id>/<command_id>→ record a pending audit row. - Inbound ack: a
CommandAckis a sealed payload, not the mesh's ordinary message type, so it cannot ride the main inbound pipeline. It gets a dedicated subscriber → envelope verify → revocation gate → group-key open → decode → correlate bycommand_id→ update the audit row. Verification happens before any DB write; transport TLS is never the gate.
Known risk: instant ROI on armed hardware — aim-mode arming is the only client-side guard against a mistaken re-aim. Acceptable for camera tasking; revisit before any live-ordnance verb (ties into the geofence/safety open question below).
Node verification pipeline¶
The node does not trust the relay. Every sample on
waypoint/global/cmd/<self>/** runs the full gate before any autopilot byte moves.
Each step short-circuits — a failure drops the frame, and no payload byte is
decoded past the step that rejected it.
flowchart TB
A["sample on waypoint/global/cmd/<self>/**"] --> B{"envelope verify<br/>Directory sig + device_signature<br/>+ nonce-replay + clock-skew"}
B -- fail --> X["drop"]
B -- ok --> C{"revocation gate<br/>principal_id / device-sign-key<br/>not in RevocationCache"}
C -- revoked --> X
C -- ok --> D{"group-key open<br/>seal under known epoch"}
D -- "unknown epoch" --> R["request key refresh, drop"]
D -- "no key / decrypt fail" --> X
D -- ok --> E["decode DeviceCommand"]
E --> F{"target_node_pubkey == self"}
F -- no --> U1["ACK_UNAUTHORISED"]
F -- yes --> G{"issuer role ∈ camera_roles"}
G -- no --> U1
G -- yes --> K{"kinetic verb?"}
K -- no --> H
K -- yes --> KM{"allow_movement<br/>enabled?"}
KM -- no --> U2["ACK_UNSUPPORTED (movement disabled)"]
KM -- yes --> KC{"issuer has<br/>command_device_movement?"}
KC -- no --> U1
KC -- yes --> H{"command_id seen?<br/>bounded FIFO dedup (256)"}
H -- dup --> DUP["ACK_ACCEPTED (duplicate suppressed)"]
H -- new --> V{"variant-specific validation<br/>(MoveOrder / FlyToWaypoint: lat/lon range;<br/>SetFlightMode: mode allowlist)"}
V -- invalid --> RJ["ACK_REJECTED (reason in detail)"]
V -- ok --> I["actuate via autopilot → COMMAND_ACK / MISSION_ACK → CommandAck"]
Order matters: envelope verify and the revocation gate run before the group-key open, so a forged or revoked sample is discarded without spending a decrypt. The open is fail-closed — no usable key or a decrypt failure drops the frame; it never falls back to plaintext. An unknown epoch triggers a key refresh and drops the frame; a later re-delivery after the new key lands opens cleanly.
Even a fully compromised relay cannot forge a command: it would need a valid
Directory-signed operator identity and the operator's device signing key to
produce a passing device_signature, and the group key to seal a payload the
node will open.
MAVLink bridge¶
When all gates pass, the node's forwarder (command_forward) actuates over a
single writable UDP link to mavlink_target (mavlink-routerd). There are two
actuation paths, both correlated back to the command_id:
1. Single COMMAND for the camera and recall/hold variants. The forwarder
encodes the variant, sends it, and correlates the autopilot's COMMAND_ACK
(msg 77) — the autopilot echoes the command field, so the correlation key is
identical for COMMAND_INT and COMMAND_LONG:
| Variant | MAV_CMD | Encoding | Key params |
|---|---|---|---|
CameraRoi |
DO_SET_ROI_LOCATION (195) |
COMMAND_INT | frame = GLOBAL, x = lat_e7, y = lon_e7, z = alt_m |
CameraPitchYaw |
DO_GIMBAL_MANAGER_PITCHYAW (1000) |
COMMAND_LONG | p1 = pitch°, p2 = yaw°, p3/p4 = NaN (rate unused) |
CameraZoom |
SET_CAMERA_ZOOM (531) |
COMMAND_LONG | p1 = zoom_type, p2 = value |
CameraCapture PHOTO |
IMAGE_START_CAPTURE (2000) |
COMMAND_LONG | p2 = interval_s, p3 = count (SINGLE pins 0/1) |
CameraCapture VIDEO_START / VIDEO_STOP |
VIDEO_START_CAPTURE (2500) / VIDEO_STOP_CAPTURE (2501) |
COMMAND_LONG | — |
ReturnToLaunch |
NAV_RETURN_TO_LAUNCH (20) |
COMMAND_LONG | — |
LoiterHold |
NAV_LOITER_UNLIM (17) |
COMMAND_LONG | p3 = radius_m (0 = platform default) |
FlyToWaypoint |
DO_REPOSITION (192) |
COMMAND_INT | frame = GLOBAL_RELATIVE_ALT, x = lat_e7, y = lon_e7, z = alt_m, p1 = -1 (speed default), p2 = CHANGE_MODE, p4 = NaN (yaw hold) |
SetFlightMode LAND |
NAV_LAND (21) |
COMMAND_LONG | p4 = NaN (yaw hold); land at current position |
Location-bearing commands use COMMAND_INT (integer lat/lon in the x/y
fields, lat/lon × 1e7) — packing coordinates into f32 COMMAND_LONG params would
collapse them to ~7 significant figures (sub-metre error). Gimbal pitch/yaw rates
are sent as NaN (the spec's "rate not used" sentinel), not 0.0, which on some
firmware fights the absolute angle. LoiterHold uses NAV_LOITER_UNLIM rather
than a DO_SET_MODE HOLD switch because it is a single ack-correlated command that
works uniformly across ArduPilot/PX4 fixed-wing and VTOL — the autopilot decides
circle vs station-keep. FlyToWaypoint sets the MAV_DO_REPOSITION_FLAGS_CHANGE_MODE
bit in param2 so the autopilot self-switches to guided-reposition — without it
DO_REPOSITION is refused unless the vehicle is already in an accepting mode.
SetFlightMode ships the LAND/GUIDED/HOLD vocabulary but the node actuates
only LAND (dedicated NAV_LAND, no airframe-flavor knowledge needed) and rejects
GUIDED/HOLD until it carries an autopilot-flavor config for their custom_mode;
RTL/LOITER are intentionally absent — the dedicated ReturnToLaunch/LoiterHold
verbs own those effects.
2. Mission upload for MoveOrder. The validated waypoint list is mapped to
MAV_CMD_NAV_WAYPOINT mission items and uploaded over the standard mission
protocol: MISSION_COUNT(n) → handle each MISSION_REQUEST_INT / MISSION_REQUEST
→ reply MISSION_ITEM_INT(seq, …) → terminal MISSION_ACK. Each item is
frame = GLOBAL_RELATIVE_ALT_INT, x = lat_e7, y = lon_e7, z = altitude_dm/10
(m), param1 = hold_secs, param2 = 10 m accept radius, param4 = NaN (yaw
unchanged), current = 1 for seq 0. The upload runs on a borrowing transport that
owns the socket for the bounded handshake — the single-socket model and the
source-address defence are intact. The MISSION_ACK result maps to the
CommandAck: MAV_MISSION_ACCEPTED → ACK_ACCEPTED; any error result →
ACK_REJECTED (result name in detail); no ack in the time budget → ACK_TIMEOUT.
Before upload, the node validates the move order: non-empty, capped at
MAX_WAYPOINTS = 512, and each lat/lon range-checked (±90° / ±180° × 1e7). An
invalid order is ACK_REJECTED with a specific reason and never reaches the
autopilot. If command-forwarding is disabled (no forwarder), a valid order returns
ACK_UNSUPPORTED — validated but honestly cannot actuate, not faked.
The resulting CommandAck is sealed, enveloped with the node's current service
credential, and published on the ack key. If the ack deadline elapses with no
COMMAND_ACK / MISSION_ACK, the node emits ACK_TIMEOUT.
Node config ([command])¶
The node is read-only by default; command reception is opt-in.
[command]
enabled = false # default: node ignores all commands
camera_roles = ["operator", "admin"] # coarse per-node allowlist (set intersection)
allow_movement = false # default deny: kinetic verbs are opt-in per node
mavlink_target = "127.0.0.1:14550" # UDP endpoint for outbound MAVLink
mavlink_system_id = 255 # 255 = GCS convention
mavlink_component_id = 190
autopilot_system_id = 1 # command target
autopilot_component_id = 1 # 1 = MAV_COMP_ID_AUTOPILOT1
ack_timeout_secs = 3 # COMMAND_ACK / MISSION_ACK deadline → ACK_TIMEOUT
camera_roles is the coarse per-node gate every variant clears (the name is
historical): a set intersection — the issuer is allowed if any of their
Directory-attested IdentityToken.roles appears in it. An empty allowlist rejects
everything.
Migration. allow_movement defaults to false. A node that already accepts
movement commands (MoveOrder / ReturnToLaunch / LoiterHold) MUST set
allow_movement = true on upgrade, or that tasking stops; new nodes stay
default-deny. Because the allowlist now also gates the kinetic verbs, a node whose
camera_roles was scoped to camera-only operators must also add a kinetic-capable
role for movement to work.
Roles¶
Roles come from the issuer's Directory-signed IdentityToken; the node enforces
them locally. The relay does not attach or vouch for roles — the node reads them
from the verified token.
Two tiers. Camera tasking clears only the coarse camera_roles allowlist. The
kinetic verbs — MoveOrder, ReturnToLaunch, LoiterHold, FlyToWaypoint,
SetFlightMode — are the higher-consequence tier and are authorized at the node
by a five-layer stack, not by the issuer client alone:
allow_movementkill-switch (default deny) — an un-bypassable deployment backstop: a valid but compromised movement-capable credential still cannot actuate movement on a node that has not enabled it.command_device_movementcapability, resolved from the issuer's Directory-attested roles by common's sharedauthzdecision logic. Held bysignals/targeter/admin; withheld from the lightweightoperator. The node applies common's decision — it does not define policy. (The flight verbs reuse this capability rather than a new one:FlyToWaypoint⊆ a single-point move,SetFlightMode(LAND)≈ recall/hold — the same kinetic risk tier.)SetFlightModemode-allowlist — only modes the node can actuate (LANDtoday) →ACK_REJECTEDotherwise.FlyToWaypointcoordinate range-check →ACK_REJECTEDbefore any MAVLink byte.- The autopilot arbitrates geofence, feasibility, and onboard-pilot override
(RC mode-priority supersedes any GCS command below PETRA's layer) — its refusal
relays as
ACK_REJECTED. PETRA models no software pilot-lock it cannot enforce.
The client capability check stays as UX (which controls show); the node gate is the authorization boundary.
| Role | Camera | Kinetic (move / recall-hold / flight) |
|---|---|---|
observer / viewer |
No | No |
operator |
Yes | No |
signals / targeter |
Yes | Yes |
admin |
Yes | Yes |
HTTP side of call-for-fire (effects / weaponeering)¶
Everything above is the node tasking path — sealed DeviceCommands over
Zenoh. Target effects (weaponeering) are a different surface: web exposes them
over HTTP, not as a node command and not as a Zenoh order. There is no wire /
mesh path for effects today.
An effect moves through offered → allocated → expended, and that lifecycle drives
the target's own state machine (detected → confirmed → allocated → engaged →
assessed, terminal rejected — see
target state machine): allocating an effect
advances the target confirmed → allocated, and expending it advances
allocated → engaged.
The routes are operator-facing and live entirely on web:
| Route | Surface |
|---|---|
/api/targets/:id/effects* |
session + operation-scope guarded; offers / allocates / expends an effect against a target |
This is explicitly the HTTP half of call-for-fire. The other half — publishing
to the fires net — stays TX-gated and deferred behind the same gate as all
browser-operator publish (the Zenoh remote-api bridge deploy; the browser-operator
signing key is already in place); see
status & roadmap. Until that gate clears, an
allocated/expended effect is a web-local state transition, not a mesh order.
Gaps (what is not built yet)¶
The node side is complete and tested across the full command set — camera (ROI /
pitch-yaw / zoom / capture), MoveOrder (validation + mission upload), recall/hold,
and the flight verbs (FlyToWaypoint, SetFlightMode(LAND)). Android covers the
full set end-to-end; the remaining client gaps are on web:
- web zoom / capture is not built.
CameraZoomandCameraCaptureare video-relative verbs (zoom the current view, capture the current frame) and per the surface split belong on a video-feed overlay, which web does not have — web camera tasking is map-driven (CameraRoi) and web does not issueCameraPitchYaweither. Surfacing them means building that overlay first (the gimbal controls on the RTSP player), then the zoom/capture composer over the existing publisher / ack / audit path — a net-new UI surface, tracked in docs #86. Web's dormant browser-publish gate applies to it as to all web issuance. SetFlightModedeferred modes.GUIDEDandHOLDship in theFlightModevocabulary but the node rejects them (ACK_REJECTED) until it carries an autopilot-flavor config to encode their airframe-specificcustom_mode;LANDis actuated today via the dedicatedNAV_LAND. A follow-up adds the flavor config (and, with a concrete use case,GUIDED— the mode that opens sustained external control, weighed separately).AreaOrderis reserved, not built. Its oneof slot (15) is held inDeviceCommandbut no message is defined and the node forwarder does not handle it.AreaOrderis the Tier-2 area-typed tasking (OrderType+ a ring ofWaypoints). It lands as a protocol + node + client change, reusing the existing envelope / sealing / verification path.- Real-hardware boundary. Unit + loopback (SITL-free) tests cover the protocol,
transport adapter, and ack mapping. Not yet exercised against a physical
autopilot:
DO_SET_ROI_LOCATIONasCOMMAND_INT,GLOBAL_RELATIVE_ALT_INTwaypoint acceptance, gimbal-manager vs mount-control availability, and ArduPilot/PX4-specific mission/camera validation edge cases — a bench/SITL pass. - Planning-side tasking is not part of this command path. Plan-of-record
tasking (
plan_records— the orders editor'stasks[]) and the comms matrix are web-local today (DB + REST); record-plane / gossip publication is deferred, so they are not node commands and not covered above. Their only cross-repo surface today is the replay / export bundle. They join this command path only if and when a wire path is built for them. - Open questions for any future issuer:
- Multi-operator conflict — resolved for the PoC: last-write-wins, node arbitrates, issuer panel shows
last commanded by <callsign>(see Issuer UX). A claim/lock model remains open for higher-stakes verbs. - Geofence / safety — resolved: the autopilot arbitrates. The node structural-range-checks a
FlyToWaypointcoordinate (as it does move-order waypoints), but geofence and feasibility stay the autopilot's decision — it owns the fence polygon, home, and airframe envelope; the issuer does not, so duplicating them issuer-side would only invite drift. A refusal relays asACK_REJECTED. - Pilot override — resolved: onboard-pilot authority lives at the RC / autopilot layer (mode-priority / RC-override supersedes any GCS command); PETRA builds no software pilot-lock and relays whatever the autopilot returns.
- Confirmation UX — resolved for the flight verbs: a two-step confirm (not the camera-ROI instant tap) —
FlyToWaypointis map-tap → pending → explicit send;SetFlightModeis an explicit action naming the asset + mode. See Issuer UX. - Audit — the issuer owns the audit log (it created the command and receives the ack); the node's local log is the secondary record.
Where to read the code¶
- Node receiver + gate:
node/src/zenoh_session.rs(handle_command_sample),node/src/command.rs(CommandHandler) - Node MAVLink bridge:
node/src/command_forward.rs(COMMAND_INT/LONG forwarder +ForwardReq::Mission) - Node mission-upload state machine:
node/src/mission_upload.rs(MissionTransport, seq walk, ack mapping) - Node group-key seal/open:
node/src/group_key.rs,node/src/envelope.rs - Envelope + keys + protos:
waypoint_common(auth_envelope,keys::{cmd_key, ack_key},proto::server::{DeviceCommand, CommandAck}) - Trust model context: security/model.md