# Operator-Side Control

Connect an operator-side application to the Oden Control Pipeline.

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](ocp-overview.md). For webview layout and general JavaScript SDK behavior, see [Webview and JavaScript SDK](webview-sdk.md).

## 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.

> **IMPORTANT**
> 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.

```json
{
  "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.

```json
{
  "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.
    

```javascript
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:

```shell
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.

```shell
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.
    

```javascript
// 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:

```rust
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.

## Related pages

-   [Oden Control Pipeline overview](ocp-overview.md)
    
-   [Webview and JavaScript SDK](webview-sdk.md)
    
-   [Vehicle-side control](vehicle-side-control.md)
    
-   [Latency measurement](latency-measurement.md)
