Skip to content

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/&lt;node&gt;/&lt;cmd_id&gt;<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/&lt;node&gt;/&lt;cmd_id&gt;<br/>AuthEnvelope{ sealed CommandAck }"| R
    R --> WEB
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: FlyToWaypoint arms a fly mode → a map tap stages a pending destination → an explicit Send fly-to commits; SetFlightMode is 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 the DeviceCommand under the group key (fail-closed: no key ⇒ no send) → wrap in an operator-signed AuthEnvelope → publish on waypoint/global/cmd/<node_principal_id>/<command_id> → record a pending audit row.
  • Inbound ack: a CommandAck is 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 by command_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/&lt;self&gt;/**"] --> 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.

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_ACCEPTEDACK_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:

  1. allow_movement kill-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.
  2. command_device_movement capability, resolved from the issuer's Directory-attested roles by common's shared authz decision logic. Held by signals / targeter / admin; withheld from the lightweight operator. 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.)
  3. SetFlightMode mode-allowlist — only modes the node can actuate (LAND today) → ACK_REJECTED otherwise.
  4. FlyToWaypoint coordinate range-checkACK_REJECTED before any MAVLink byte.
  5. 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. CameraZoom and CameraCapture are 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 issue CameraPitchYaw either. 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.
  • SetFlightMode deferred modes. GUIDED and HOLD ship in the FlightMode vocabulary but the node rejects them (ACK_REJECTED) until it carries an autopilot-flavor config to encode their airframe-specific custom_mode; LAND is actuated today via the dedicated NAV_LAND. A follow-up adds the flavor config (and, with a concrete use case, GUIDED — the mode that opens sustained external control, weighed separately).
  • AreaOrder is reserved, not built. Its oneof slot (15) is held in DeviceCommand but no message is defined and the node forwarder does not handle it. AreaOrder is the Tier-2 area-typed tasking (OrderType + a ring of Waypoints). 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_LOCATION as COMMAND_INT, GLOBAL_RELATIVE_ALT_INT waypoint 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's tasks[]) 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 conflictresolved 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 / safetyresolved: the autopilot arbitrates. The node structural-range-checks a FlyToWaypoint coordinate (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 as ACK_REJECTED.
  • Pilot overrideresolved: 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 UXresolved for the flight verbs: a two-step confirm (not the camera-ROI instant tap) — FlyToWaypoint is map-tap → pending → explicit send; SetFlightMode is 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