Skip to content

Command & Control: ops room → node

How an ops-room operator points a sensor node's camera over the mesh, 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 — android and web (UI not yet built, see Gaps) — the server (key-blind relay), the node (receiver + autopilot bridge), and the Directory (identity + group key).

Roadmap. Camera control is the proof-of-concept: prove the end-to-end authenticated, sealed command path from an android/web client to a node's gimbal first. MAVLink waypoint / flight-mode orders are the planned follow-on once the camera PoC lands — they reuse the same envelope, sealing, and verification pipeline, adding only new DeviceCommand variants and a supervisor role.

Scope

This is camera control, not flight control. Flying stays out of band with the pilots. The command path gives the ops room one capability today:

Command Effect MAVLink
CameraRoi Point the gimbal at a ground coordinate (lat/lon/alt) COMMAND_LONG(MAV_CMD_DO_SET_ROI_LOCATION)
CameraPitchYaw Set gimbal pitch/yaw directly COMMAND_LONG(MAV_CMD_DO_GIMBAL_MANAGER_PITCHYAW)

Recovery orders (RTL, fly-to-waypoint, set-flight-mode) are not in the wire protocol or the node forwarder. See Gaps.

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.

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_LONG (UDP)"| AP
    AP -->|"COMMAND_ACK (msg 77)"| 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:

message DeviceCommand {
  string command_id          = 1;  // issuer-set UUID, correlation + dedup key
  bytes  target_node_pubkey  = 2;  // binds the command to one node
  oneof command {
    CameraRoi      camera_roi       = 10;
    CameraPitchYaw camera_pitch_yaw = 11;
  }
}

message CameraRoi      { double latitude = 1; double longitude = 2; double altitude = 3; }  // alt MSL m
message CameraPitchYaw { float  pitch_deg = 1; float  yaw_deg   = 2; }                       // pitch -90..+90

message CommandAck {
  string    command_id  = 1;  // correlates to DeviceCommand.command_id
  AckResult result      = 2;
  string    detail      = 3;  // human-readable, e.g. "COMMAND_ACK: MAV_RESULT_ACCEPTED"
  uint64    acked_at_ms = 4;
}

enum AckResult {
  ACK_ACCEPTED     = 0;
  ACK_REJECTED     = 1;  // autopilot refused, or forwarder error
  ACK_TIMEOUT      = 2;  // no COMMAND_ACK within ack_timeout_secs
  ACK_UNSUPPORTED  = 3;  // command reception disabled, missing variant, or no forwarder
  ACK_UNAUTHORISED = 4;  // role not permitted, or target_node_pubkey mismatch
}

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.

Wire-schema drift to reconcile. The DeviceCommand / CameraRoi / CameraPitchYaw / CommandAck block above is hand-written and has diverged from the generated proto::server schema the clients compile against: target_node_pubkey is tag 5 (not 2); CameraRoi is integer-scaled (lat_e7 / lon_e7 sint32, altitude_dm sint32 decimetres — not double degrees/metres); CameraPitchYaw is pitch_cdeg sint32 / yaw_cdeg uint32 centi-degrees (not float degrees); and AckResult has a sixth value, ACK_INVALID_SIGNATURE = 5. Reconcile this block against waypoint_common at the pinned rev before treating it as the byte-level contract.

Issuer UX (android — template web follows)

The node side is the enforcement boundary; the issuer side is where an operator composes a command. Android is built first and is the template web mirrors. The governing decision is that 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.

Phase-2 waypoint-style verbs (FlyToWaypoint, SetFlightMode) extend the panel/map surface, not the video overlay. Zoom is not in phase 1 — there is no CameraZoom variant and the node bridges only ROI + pitch/yaw.

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.
  • 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 a camera PoC; revisit before any live-ordnance verb (ties into the phase-2 geofence/safety question).

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 --> H{"command_id seen?<br/>bounded FIFO dedup (256)"}
    H -- dup --> DUP["ACK_ACCEPTED (duplicate suppressed)"]
    H -- new --> I["forward to autopilot → COMMAND_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) encodes the variant to a COMMAND_LONG, sends it over UDP to mavlink_target, and correlates the autopilot's COMMAND_ACK (msg 77) back to the command_id:

  • CameraRoiMAV_CMD_DO_SET_ROI_LOCATION (lat/lon/alt in params 5/6/7)
  • CameraPitchYawMAV_CMD_DO_GIMBAL_MANAGER_PITCHYAW

The resulting CommandAck is sealed, enveloped with the node's current service credential, and published on the ack key. If ack_timeout_secs elapses with no COMMAND_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"]    # issuer roles permitted (set intersection)

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 deadline → ACK_TIMEOUT

The role check is a set intersection: the issuer is allowed if any of their Directory-attested IdentityToken.roles appears in camera_roles. An empty allowlist rejects everything.

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.

Role Camera commands
viewer No
operator Yes
admin Yes

Gaps (what is not built yet)

The node side is complete and tested. The waypoint surface is not built, and the issuer clients are in progress (android first, web following the android template — see Issuer UX):

  • Issuer command UI (the camera PoC) — android in build, web pending. The shape is fixed: a command composer (map → CameraRoi; video-overlay gimbal → CameraPitchYaw), group-key sealing, operator-credential envelope signing, publish on waypoint/global/cmd/<node>/<cmd_id>, a dedicated ack subscriber on waypoint/global/ack/<node>/** (verify + open + correlate), and a per-node audit log. Tracked in the docs repo command-control issue.
  • MAVLink waypoints / flight-mode are the planned follow-on. FlyToWaypoint, SetFlightMode (RTL/loiter/guided), and a supervisor role are not in DeviceCommand or the node forwarder — the forwarder handles camera variants only. They land after the camera PoC and are a protocol + node + client change, reusing the existing envelope / sealing / verification path.
  • 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 / safety — pre-validate coordinates issuer-side, or let the autopilot reject and relay the error?
  • 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
  • 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