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/<node>/<cmd_id><br/>AuthEnvelope{ sealed DeviceCommand }"| R
R --> NODE
NODE -->|"MAVLink COMMAND_LONG (UDP)"| AP
AP -->|"COMMAND_ACK (msg 77)"| 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:
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/CommandAckblock above is hand-written and has diverged from the generatedproto::serverschema the clients compile against:target_node_pubkeyis tag 5 (not 2);CameraRoiis integer-scaled (lat_e7/lon_e7sint32,altitude_dmsint32decimetres — notdoubledegrees/metres);CameraPitchYawispitch_cdegsint32/yaw_cdeguint32centi-degrees (notfloatdegrees); andAckResulthas a sixth value,ACK_INVALID_SIGNATURE = 5. Reconcile this block againstwaypoint_commonat 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 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 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/<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 --> 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.
MAVLink bridge¶
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:
CameraRoi→MAV_CMD_DO_SET_ROI_LOCATION(lat/lon/alt in params 5/6/7)CameraPitchYaw→MAV_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 onwaypoint/global/cmd/<node>/<cmd_id>, a dedicated ack subscriber onwaypoint/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 asupervisorrole are not inDeviceCommandor 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 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 — 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