Vehicle-Side Control

Last validated: 2026-05-04

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.

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

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. The message is sent roughly every 10 ms while the connection is active. 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 at the OdenVR frame rate, capped at 100 Hz. The vehicle-side TCP server forwards control frames every 10 ms while a TCP client is connected. If no new operator message has arrived, OCP repeats the last received message at least 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": ""
  },
  "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 safe 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.

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.

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.

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

Use telemetry.fault and your own watchdogs to decide when to hold, coast, brake, or otherwise enter a vehicle-specific safe state. Do not rely only on the controller values, because OCP intentionally sends default controller values during fault states.

Common vehicle-side fault conditions:

Fault Meaning Vehicle-side action

VehicleNoResponse

No vehicle TCP client is connected.

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

NoControlMessage

The Streamer has not received operator control messages for more than about 1000 ms.

Treat the command stream as stale. Check the Oden network link and active vehicle selection.

HighLatency

Measured OCP round-trip latency is above latency_limit.

Use project-specific degraded or stop behavior. 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.

OperatorStationHasError

The operator side raised a fault such as lost input or inactive vehicle control.

Treat operator commands as unhealthy until the fault clears.

Camera Lost

At least one monitored 2D video input is stale according to the Streamer drop detector.

Decide whether the vehicle may continue with degraded visibility.

Streamer Slow

The Streamer frame loop exceeded the slow-frame threshold.

Check Streamer CPU/GPU load and capture pipeline health.

Faults are self-recovering in OCP. 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, the port matches tcp_port, and no other process is using the 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, 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