Vehicle-Side Control

Last validated: 2026-08-20

Use the vehicle-side Oden Control Pipeline (OCP) TCP API when software on or near the vehicle needs to receive operator commands from Oden and return vehicle state to the operator station. Your integration runs as a TCP client. OCP runs inside Oden Streamer as a global plugin and exposes the TCP server.

For runnable Rust, C++ and ROS 2 clients, see the OCP vehicle examples repository.

The minimum useful integration is:

  1. Start Oden Streamer with the OCP plugin available.

  2. Connect your vehicle process to the OCP TCP server.

  3. Read each framed JSON control message.

  4. Apply the command only after your vehicle-side safety checks.

  5. Send a framed JSON response that copies ack_time and ack_time_mac from the latest control message.

  6. Add optional vehicle_user_data when the operator UI needs vehicle state.

OCP is the Oden transport, telemetry, and fault layer. It is not the vehicle safety controller. The vehicle integration should still enforce actuator limits, drive-state interlocks, emergency stop behavior, command timeouts, and any project-specific safety logic.

Configure the TCP server

By default, the vehicle-side OCP server listens on localhost:

127.0.0.1:4000

Set tcp_port when the default port is already used or your deployment reserves another local port:

oden-streamer --plugin-param tcp_port 4100

The server binds to 127.0.0.1 unless ocp_tcp_allow_remote is present. Use remote binding only when the TCP client cannot run on the Streamer host:

oden-streamer --plugin-param tcp_port 4000 --plugin-param ocp_tcp_allow_remote 1

When remote binding is enabled, OCP binds the TCP server to 0.0.0.0. Restrict that port with firewall rules or a private vehicle network. The OCP TCP protocol has no separate authentication layer.

Useful plugin parameters:

Parameter Default Use

tcp_port

4000

Vehicle-side TCP server port on Oden Streamer.

latency_limit

500

Round-trip latency limit in milliseconds before OCP raises HighLatency.

ocp_no_control_message_timeout

1000

Time in milliseconds without a control message before OCP raises NoControlMessage. Keep it longer than one OdenVR frame interval.

ocp_tcp_allow_remote

not set

When present, binds TCP servers to 0.0.0.0 instead of 127.0.0.1.

ocp_player_tcp_port

4001

Operator-side TCP server port. Included here because ocp_tcp_allow_remote affects both TCP servers.

For general plugin startup syntax, see Plugins. For the broader OCP model, see OCP overview.

Connect from the vehicle process

The vehicle process is the TCP client. OCP accepts one active vehicle-side TCP connection at a time. If the connection drops, reconnect from your process; when OCP accepts the next connection it clears old queued control messages from the previous session.

Keep the socket open and respond continuously. OCP writes control messages while it also waits for response frames from your process. If OCP waits about 2 seconds for response bytes while reading a frame, it closes the connection and logs a vehicle TCP client connection error.

Practical connection rules:

  • Connect to 127.0.0.1:4000 unless you deliberately enabled remote binding.

  • Use TCP no-delay if your client library exposes it.

  • Treat disconnects as normal lifecycle events and reconnect after a short delay.

  • Do not let response generation block on slow vehicle subsystems; publish stale-but-marked user data rather than blocking the timestamp response path.

  • Keep custom JSON payloads small. OCP’s TCP readers reject inbound messages larger than 16 KiB.

  • Operator-side client data must fit in a 4096-byte control message. A control message that does not fit is not delivered, see client_user_data.

  • A message that does not fit in one com channel packet (Max Packet Size, 1400 bytes by default) is sent as several packets, and losing any one of them loses the whole message.

Message framing

Both directions use the same frame format:

uint32_le payload_length
payload_length bytes of UTF-8 JSON

payload_length is an unsigned 32-bit integer encoded little-endian. The JSON payload must fit in 16 KiB. If your vehicle response is larger, OCP drains and skips that frame. Malformed JSON, missing required response fields, and short reads close the active TCP connection.

Read control messages

OCP sends OcpControlMessage frames from Oden Streamer to the vehicle process. Each new operator message is sent as soon as it reaches Oden Streamer. If no fresh operator command has arrived, OCP reuses the latest command until the relevant timeout fault is raised. When a control-path fault is active, OCP sends default controller values and sets the telemetry fault state.

Operator-to-vehicle control messages are produced on every OdenVR frame, up to 100 messages a second. The vehicle-side TCP server forwards each new control message as soon as it arrives while a TCP client is connected. The message rate is not fixed, so read the socket continuously: messages you leave unread queue up and reach your control loop late. If no new operator message has arrived, OCP repeats the last received message about every 20 ms until a fault is raised. After the fault, OCP continues sending default controller values. Feedback from the Streamer to OdenVR is sent once per frame; when no new vehicle user data has arrived, OCP repeats the last received vehicle user data.

{
  "controller": {
    "buttons": {
      "a": false,
      "b": false,
      "x": false,
      "y": false,
      "left_bumper": false,
      "right_bumper": false,
      "back": false,
      "start": false,
      "guide": false,
      "left_thumb": false,
      "right_thumb": false,
      "dpad_up": false,
      "dpad_right": false,
      "dpad_down": false,
      "dpad_left": false
    },
    "axes": {
      "left_x": 0.0,
      "left_y": 0.0,
      "right_x": 0.0,
      "right_y": 0.0,
      "left_trigger": -1.0,
      "right_trigger": -1.0
    }
  },
  "telemetry": {
    "latency": 42,
    "fault": false,
    "fault_reason": "",
    "missing_cameras": []
  },
  "ack_time": 123456,
  "ack_time_mac": 987654321,
  "client_user_data": {
    "mode": "manual"
  }
}

Control message fields:

controller

Optional gamepad state. When present, buttons are booleans and axes are raw gamepad values in the -1.0 to 1.0 range. The default value is all buttons false, stick axes 0.0, and triggers -1.0. When OCP gamepad forwarding is disabled from the operator side, controller is null, except during a fault.

telemetry.latency

Vehicle-side OCP round-trip latency measurement in milliseconds. The default limit before HighLatency is raised is 500 ms.

telemetry.fault

true when OCP sees an active operator-side or vehicle-side control fault. Use this as a health signal in addition to your own command timeout and safety checks.

telemetry.fault_reason

Comma-separated diagnostic text for active OCP receiver faults, such as High Latency or Latency Loop Not Closed. Do not build safety behavior that depends on exact display strings.

telemetry.missing_cameras

Missing Oden camera entity names, or an empty array when healthy. Unnamed cameras appear as Unknown camera.

ack_time, ack_time_mac

Timestamp fields for the OCP latency loop. Copy both values unchanged into your next response.

client_user_data

Optional JSON payload from the operator side for this vehicle. This is the operator-side user_data payload, not the full operator-side wrapper. It must fit in a 4096-byte control message from the operator side. When the operator side sends more, that control message is dropped and this field keeps its last value; No Control Messages is raised when no control message arrives for ocp_no_control_message_timeout.

config

Only in the first message after each connect, for example "config": { "latency_limit": 500, "no_control_message_timeout": 1000 }. latency_limit is the latency_limit in milliseconds that Oden Streamer is using. An invalid latency_limit parameter falls back to 500, so compare this value with the one you configured before driving. no_control_message_timeout is the ocp_no_control_message_timeout in milliseconds that Oden Streamer is using.

Send responses

The vehicle must send VehicleResponseMessage frames back on the same TCP connection. The response must include the latest ack_time and ack_time_mac values copied from an OCP control message. Without this echo, OCP cannot close its video/control latency loop and the operator side will eventually report latency or feedback faults.

Minimal response:

{
  "ack_time": 123456,
  "ack_time_mac": 987654321
}

Response with vehicle data:

{
  "ack_time": 123456,
  "ack_time_mac": 987654321,
  "vehicle_user_data": {
    "pos": {
      "lat": 57.7089,
      "lon": 11.9746
    },
    "user_data": {
      "speed_mps": 1.4,
      "drive_state": "enabled"
    }
  }
}

vehicle_user_data is optional. When included, pos may be omitted or set to null, but user_data should be present. The operator side receives the latest vehicle data repeatedly; include freshness or state timestamps in user_data if the dashboard needs to distinguish new data from repeated data.

Do not transform, regenerate, clamp, or reinterpret ack_time or ack_time_mac. They are not application timestamps. They are opaque OCP latency-loop values and must be copied byte-for-byte through your JSON number representation.

Minimal Rust client

This example shows the TCP framing and timestamp echo. It uses serde_json::Value to keep the example focused on the protocol. Production integrations should deserialize into project-specific structs and validate every command before applying it.

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

const MAX_MESSAGE_SIZE: u32 = 16 * 1024;

fn read_frame(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 > MAX_MESSAGE_SIZE {
        return Err(format!("OCP frame too large: {len} bytes").into());
    }

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

    Ok(serde_json::from_slice(&payload)?)
}

fn write_frame(stream: &mut TcpStream, value: &Value) -> Result<(), Box<dyn Error>> {
    let payload = serde_json::to_vec(value)?;
    if payload.len() > MAX_MESSAGE_SIZE as usize {
        return Err(format!("OCP response too large: {} bytes", payload.len()).into());
    }

    stream.write_all(&(payload.len() as u32).to_le_bytes())?;
    stream.write_all(&payload)?;
    stream.flush()?;
    Ok(())
}

fn handle_connection(mut stream: TcpStream) -> Result<(), Box<dyn Error>> {
    stream.set_nodelay(true)?;

    loop {
        let control = read_frame(&mut stream)?;

        let ack_time = control
            .get("ack_time")
            .and_then(Value::as_u64)
            .ok_or("missing ack_time")?;
        let ack_time_mac = control
            .get("ack_time_mac")
            .and_then(Value::as_u64)
            .ok_or("missing ack_time_mac")?;

        let fault = control
            .pointer("/telemetry/fault")
            .and_then(Value::as_bool)
            .unwrap_or(true);

        if !fault {
            // Apply validated controller/client_user_data to your vehicle bridge here.
        }

        let response = json!({
            "ack_time": ack_time,
            "ack_time_mac": ack_time_mac,
            "vehicle_user_data": {
                "pos": null,
                "user_data": {
                    "vehicle_bridge": "running"
                }
            }
        });

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

fn main() {
    loop {
        match TcpStream::connect("127.0.0.1:4000") {
            Ok(stream) => {
                if let Err(err) = handle_connection(stream) {
                    eprintln!("OCP connection closed: {err}");
                }
            }
            Err(err) => eprintln!("OCP connect failed: {err}"),
        }

        thread::sleep(Duration::from_millis(100));
    }
}

Handle faults safely

OCP does not stop the vehicle. The vehicle integration must stop it.

Stop applying operator motion commands and put the vehicle in its safe state when one of these conditions occurs:

  • telemetry.fault is true.

  • The TCP connection to OCP closes.

  • No control message arrives within your watchdog timeout.

The safe state is specific to the vehicle, for example hold, coast, or brake. When the connection closes or the messages stop, OCP cannot send a fault. Your watchdog must find these conditions.

During a fault, OCP sets controller to the default value. The default value is not a stop command. OCP does not clear client_user_data, so it can contain the last custom motion command.

Continue to copy ack_time and ack_time_mac into your responses during a fault. If the vehicle does not send them, High Latency does not clear.

These faults set telemetry.fault to true. The first column shows the text in telemetry.fault_reason, then the OCP fault name. Use this text only for diagnostics. The times are detection limits. They do not include the time that the vehicle needs to stop.

Fault Cause Corrective action

Vehicle Not Responding (VehicleNoResponse)

No vehicle TCP client is connected.

Start or reconnect the vehicle process. Check the bind address and port.

TCP Server Bind Failed (TcpServerBindFailed)

The vehicle-side TCP server could not bind tcp_port at plugin start, most commonly because another application already uses the port. The fault persists until Oden Streamer is restarted.

Free the port or set another tcp_port, then restart Oden Streamer.

No Control Messages (NoControlMessage)

The Streamer has not received operator control messages for more than ocp_no_control_message_timeout, 1000 ms by default.

Check the Oden network link and active vehicle selection.

High Latency (HighLatency)

telemetry.latency is more than latency_limit. The default limit is 500 ms.

Check network, video latency, and whether your vehicle process blocks before responding.

Latency Loop Not Closed (InvalidMac)

The timestamp loop did not return a valid ack_time / ack_time_mac pair.

Confirm the vehicle response copies both fields unchanged and the operator-side integration echoes timestamps correctly.

Operator Station Has Error (OperatorStationHasError)

The operator side has one of these faults:

  • Input Lost: no gamepad input for the active vehicle.

  • No Feedback Data: no feedback from the Streamer for more than 1000 ms.

  • Vehicle Control Disabled: this vehicle is not the active vehicle.

Correct the fault on the operator station. The operator-side sender_fault field shows it.

Client Data Too Large (ClientDataTooLarge)

The message to the vehicle would be larger than 16 KiB, so OCP left out client_user_data and roundtrip. Strings with many control characters can cause this, because JSON escapes each one as 6 bytes.

Send less client data, or fewer control characters in strings.

Camera Lost (CameraLost)

A monitored camera has not delivered a valid frame for more than 500 ms.

Check the camera and its capture pipeline. telemetry.missing_cameras shows the lost cameras.

Streamer Slow (StreamerSlow)

More than 500 ms passed between two Streamer frames.

Check Streamer CPU/GPU load and capture pipeline health.

Internal Error (1) (ControlThreadChannelSlow)

A control message waited more than 500 ms in an internal OCP queue, or the system clock moved backward.

Check the CPU load and the system clock on the Streamer computer.

OCP monitors only 2D Video entities that are direct children of Output Alignment and have drop detection enabled. Other cameras do not cause Camera Lost.

Faults are self-recovering in OCP, with one exception: TCP Server Bind Failed persists until Oden Streamer is restarted because the bind is attempted only once at plugin start. Your vehicle controller should still require whatever local conditions are necessary before returning to active motion.

Troubleshoot the integration

Connection refused

Confirm the OCP plugin is loaded in Oden Streamer and the port matches tcp_port. By default the server accepts only local connections on 127.0.0.1.

Remote client cannot connect

Start Oden Streamer with --plugin-param ocp_tcp_allow_remote 1, confirm the server host firewall allows the TCP port, and connect to the Streamer host address rather than 127.0.0.1.

Connects, but no OCP frames arrive

Another process already owns tcp_port, the Streamer log shows a bind error, and OCP raises the TCP Server Bind Failed fault. Free the port or set another tcp_port (see Configure the TCP server), and restart Oden Streamer.

Connects, then disconnects after a short time

Make sure your process sends framed responses continuously. OCP closes the connection on malformed JSON, missing required response fields, oversized response frames, short reads, or about 2 seconds without response bytes.

HighLatency appears after connecting

Return ack_time and ack_time_mac immediately after each control message. Move slow vehicle reads, database writes, or dashboard aggregation out of the response path. If latency is genuinely expected to exceed 500 ms, set a project-appropriate latency_limit.

Latency Loop Not Closed appears

Check that ack_time and ack_time_mac are copied unchanged by the vehicle process. If you have a custom operator-side web view, plugin, or player-side TCP client, it must also echo the timestamp fields as described in Latency measurement.

Controller is null

The operator side has disabled OCP gamepad forwarding with ocp_disable_gamepad. Use client_user_data or another project-specific input path, and keep the vehicle-side safety logic independent of the presence of gamepad fields.

Controller values are all defaults

Check telemetry.fault. OCP sends default controller values during control-path faults. Default means buttons false, stick axes 0.0, and triggers -1.0.

Operator UI does not show vehicle data

Confirm responses include vehicle_user_data.user_data, stay under 16 KiB, and are sent on the same TCP connection. On the operator side, read the OCP vehicle data through a web view, plugin, or player-side TCP client. See Operator-side control.

Next steps