Operator-Side Control

Last validated: 2026-05-04

Operator-side OCP integration is optional. Add it when the operator station needs a custom dashboard, custom input path, active-vehicle selection, automation, or an external process that exchanges data with the vehicle through OCP.

For the complete control-path model, start with Oden Control Pipeline overview. For webview layout and general JavaScript SDK behavior, see Webview and JavaScript SDK.

Choose an operator-side path

Path Use it for What handles timestamps

Webview

Browser-based operator UI inside OdenVR / Player.

The injected OdenLayoutClient stores ack_time and ack_time_mac from vehicle feedback and injects them when JavaScript sends ocp_client_user_data.

Player-side TCP

A local external application, service, or test tool.

Your TCP client must copy ack_time and ack_time_mac from feedback into ack_time_returned and ack_time_mac_returned.

Custom Oden plugin

A plugin that needs direct access to Oden shared data or another native input device.

Your plugin must publish the returned timestamp fields in client_ocp.

Only one client data source may send OCP client data during a Player session. The first source that sends data locks the session to Webview, TCP, or Plugin. If another source sends data later, OCP logs a source-conflict error, stops client data processing, and requires a Player restart.

A web page that creates OdenLayoutClient can auto-echo OCP timestamps even when your application code has not sent custom OCP data yet. If you intend to use Player-side TCP or a custom plugin as the operator data source, do not also run a webview that participates in OCP.

Receive vehicle feedback

All operator-side paths receive the same logical feedback object. It is keyed by vehicle name, so use those keys when setting active_vehicle or sending per-vehicle user data.

{
  "vehicle_feedback": {
    "vehicle-1": {
      "ping_latency_ms": 8,
      "message_latency_roundtrip": 42,
      "ms_since_last_vehicle_data": 12,
      "missing_cameras": null,
      "sender_fault": [],
      "receiver_fault": [],
      "vehicle_data": {
        "pos": { "lat": 57.7, "lon": 11.9 },
        "user_data": { "battery": 0.82 }
      },
      "last_remote_input": null,
      "ack_time": 123456,
      "ack_time_mac": 654321
    }
  },
  "last_input": null
}

Use these fields first:

vehicle_feedback

Map from vehicle name to feedback for that vehicle.

vehicle_data.user_data

Custom JSON returned by the vehicle-side integration. OCP repeats the latest received vehicle data; use ms_since_last_vehicle_data to detect stale data.

sender_fault / receiver_fault

Operator-side and vehicle-side OCP faults as strings.

last_remote_input

Controller input last reported back from the vehicle side.

ack_time / ack_time_mac

Timestamp values for the OCP latency loop. Webview code normally does not handle them directly. TCP clients and custom plugins must echo them back.

last_input

Last local gamepad/controller input sent by the operator station. It is null when no input is available or OCP gamepad forwarding is disabled.

last_remote_input

Last input reported back from the vehicle side. Use it to show what command the vehicle most recently received, not just what the operator most recently pressed.

missing_cameras

List of monitored camera names whose drop detector reported missing frames. It is null when monitored cameras are healthy. Cameras without drop detection are not listed.

Send operator data

Send ClientOcpShared data back to OCP when you need to select a vehicle, attach operator-side user data, or disable OCP’s built-in gamepad forwarding.

{
  "active_vehicle": "vehicle-1",
  "client_user_data": {
    "vehicle-1": {
      "user_data": {
        "mode": "manual",
        "requested_tool": "bucket"
      },
      "ocp_disable_gamepad": false,
      "ack_time_returned": 123456,
      "ack_time_mac_returned": 654321
    }
  }
}
active_vehicle

Selects which vehicle receives OCP gamepad input. With one connected vehicle, it may be omitted. With multiple vehicles, set it to one of the keys from vehicle_feedback; otherwise OCP marks vehicle control inactive.

client_user_data

Map from vehicle name to data for that vehicle. The vehicle-side TCP message exposes this as client_user_data.

user_data

Your custom JSON payload. If you use it as a custom control command path, the vehicle-side integration must explicitly read and validate it.

ocp_disable_gamepad

Set to true when OCP should not forward its gamepad controller object. This also suppresses the operator-side InputLost fault.

ack_time_returned / ack_time_mac_returned

Required for Player-side TCP and custom plugin sources. Copy the latest values from that vehicle’s feedback. Webview sends these fields automatically.

Webview task

Use the webview path when the operator UI runs inside OdenVR / Player. The page receives ocp_vehicle_user_data and sends ocp_client_user_data through the injected JavaScript SDK.

  1. Enable the Oden Control Pipeline global plugin.

  2. Enable the WebView global plugin.

  3. Point the WebView Address setting to your web app.

  4. Create one shared OdenLayoutClient instance in the page.

  5. Register a callback for ocp_vehicle_user_data.

  6. Send ocp_client_user_data only when this webview is the intended OCP client data source.

const odenClient = getOrCreateOdenLayoutClient();

function onOcpFeedback(payload) {
  const entries = Object.entries(payload.vehicle_feedback ?? {});
  if (entries.length === 0) return;

  const [vehicleName, feedback] = entries[0];
  const vehicleData = feedback.vehicle_data?.user_data ?? {};

  console.log("Vehicle data", vehicleName, vehicleData);

  odenClient?.sendNamedUserMessage("ocp_client_user_data", {
    active_vehicle: vehicleName,
    client_user_data: {
      [vehicleName]: {
        user_data: {
          selected_mode: "work",
        },
        ocp_disable_gamepad: false,
      },
    },
  });
}

odenClient?.registerUserMessageCallback("ocp_vehicle_user_data", onOcpFeedback);

// Later, when the component or page is destroyed:
// odenClient?.unregisterUserMessageCallback("ocp_vehicle_user_data", onOcpFeedback);

The WebView client tracks the latest ack_time and ack_time_mac per vehicle. When your code calls sendNamedUserMessage("ocp_client_user_data", …​), the client injects ack_time_returned and ack_time_mac_returned into each matching vehicle entry.

If no custom JavaScript sends ocp_client_user_data, the client can still auto-echo timestamps with an empty user-data payload. That keeps latency measurement alive for webview-only operator stations, but it also means the webview becomes the OCP client data source.

Player-side TCP task

Use Player-side TCP when an external application should run beside OdenVR / Player. OCP starts the Player-side TCP server during OCP initialization.

Defaults:

  • Listen address: 127.0.0.1

  • Port: 4001

  • Port parameter: ocp_player_tcp_port

  • Remote bind parameter: ocp_tcp_allow_remote

  • Active clients: one at a time

  • Frame format: 4-byte unsigned little-endian payload length, then UTF-8 JSON

  • Maximum payload: 16 KB

To change the port:

oden-vr --plugin-param ocp_player_tcp_port 4101

To allow non-local TCP clients, set ocp_tcp_allow_remote. This binds OCP TCP servers to 0.0.0.0 instead of 127.0.0.1. Use this only on a protected network and open the firewall deliberately.

oden-vr --plugin-param ocp_tcp_allow_remote 1

A TCP client loop should:

  1. Connect to 127.0.0.1:4001 unless the port was changed.

  2. Read a 4-byte little-endian length.

  3. Read that many JSON bytes and parse VehicleOcpShared.

  4. Store the latest ack_time and ack_time_mac for each vehicle.

  5. Send ClientOcpShared with a 4-byte length prefix.

  6. Include the latest ack_time_returned and ack_time_mac_returned.

  7. Reconnect if the socket closes.

// Pseudocode: frame every TCP JSON payload as:
writeUInt32LE(Buffer.byteLength(json));
write(json);

This minimal Rust example shows the Player-side TCP framing and timestamp echo:

use serde_json::{json, Value};
use std::{
    error::Error,
    io::{Read, Write},
    net::TcpStream,
};

fn read_json(stream: &mut TcpStream) -> Result<Value, Box<dyn Error>> {
    let mut len_buf = [0_u8; 4];
    stream.read_exact(&mut len_buf)?;
    let len = u32::from_le_bytes(len_buf);
    if len > 16 * 1024 {
        return Err("OCP frame too large".into());
    }

    let mut payload = vec![0_u8; len as usize];
    stream.read_exact(&mut payload)?;
    Ok(serde_json::from_slice(&payload)?)
}

fn write_json(stream: &mut TcpStream, value: &Value) -> Result<(), Box<dyn Error>> {
    let payload = serde_json::to_vec(value)?;
    stream.write_all(&(payload.len() as u32).to_le_bytes())?;
    stream.write_all(&payload)?;
    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {
    let mut stream = TcpStream::connect("127.0.0.1:4001")?;
    stream.set_nodelay(true)?;

    loop {
        let feedback = read_json(&mut stream)?;
        let (vehicle_name, vehicle) = feedback["vehicle_feedback"]
            .as_object()
            .and_then(|vehicles| vehicles.iter().next())
            .ok_or("no vehicle feedback")?;

        let ack_time = vehicle["ack_time"].as_u64().unwrap_or(0);
        let ack_time_mac = vehicle["ack_time_mac"].as_u64().unwrap_or(0) as u32;

        let mut client_user_data = serde_json::Map::new();
        client_user_data.insert(
            vehicle_name.clone(),
            json!({
                "user_data": { "operator_app": "running" },
                "ocp_disable_gamepad": false,
                "ack_time_returned": ack_time,
                "ack_time_mac_returned": ack_time_mac
            }),
        );

        let response = json!({
            "active_vehicle": vehicle_name,
            "client_user_data": client_user_data
        });

        write_json(&mut stream, &response)?;
    }
}

The example at plugins/ocp_operator_example contains a fuller Rust implementation.

Custom plugin task

Use a custom Oden plugin when the operator integration needs native Oden APIs or shared memory instead of TCP.

OCP publishes feedback as named shared data:

  • vehicle_ocp_size: 32-bit little-endian byte count.

  • vehicle_ocp: CBOR-encoded VehicleOcpShared.

The plugin sends client data back as:

  • client_ocp_size: 32-bit little-endian byte count.

  • client_ocp: CBOR-encoded ClientOcpShared.

The same source-locking rule applies. If a plugin publishes client_ocp, do not also send ocp_client_user_data from a webview or Player-side TCP client in the same Player session.

Vehicle selection

OCP discovers vehicles on the Player side before it can route operator data.

With Fleet active

OCP uses Fleet’s vehicle-name-to-UUID mapping and supports multiple vehicles.

Without Fleet

OCP falls back to the scene and supports exactly one Remote Streamer.

For multi-vehicle operation, send active_vehicle from the operator-side path. Use the exact vehicle name from vehicle_feedback. Only the active vehicle receives OCP gamepad input.

Troubleshooting

No ocp_vehicle_user_data in the webview

Check that the OCP global plugin is enabled, the WebView page creates OdenLayoutClient, and the Player has discovered a vehicle. Without Fleet, the scene must contain exactly one Remote Streamer.

Player-side TCP cannot connect

Confirm OdenVR / Player is running with OCP enabled and listening on 127.0.0.1:4001 or the configured ocp_player_tcp_port. Only use a remote host if ocp_tcp_allow_remote is set. Only one TCP client is served at a time.

OCP reports a client data source conflict

Restart the Player and send OCP client data from only one source. Watch for a webview OdenLayoutClient auto-echoing timestamps while a TCP app or plugin is also sending data.

Multiple vehicles are connected but none receives input

Send active_vehicle and make sure it matches a vehicle_feedback key. Without Fleet, OCP does not support more than one Remote Streamer.

InputLost appears while using custom controls

Set ocp_disable_gamepad: true in the per-vehicle client data. Then make sure the vehicle-side integration reads your custom user_data; OCP will no longer send a gamepad controller object for that vehicle.

Latency measurement stops or Latency Loop Not Closed / InvalidMac appears

For TCP or plugin integrations, echo the latest ack_time and ack_time_mac unchanged as ack_time_returned and ack_time_mac_returned. For webviews, include a client_user_data entry for the same vehicle key so the SDK can inject the values. Also verify the vehicle-side integration returns the Streamer-side ack_time and ack_time_mac.

Vehicle data is stale

Check ms_since_last_vehicle_data. The operator side repeats the last vehicle data until the vehicle-side TCP integration sends new vehicle_user_data.

TCP messages are ignored

Keep payloads under 16 KB and send valid UTF-8 JSON after the 4-byte little-endian length prefix. Oversized messages are skipped and malformed JSON is logged as a parse warning.