# Vehicle-Side Control

Connect a vehicle controller to the Oden Control Pipeline.

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:

```none
127.0.0.1:4000
```

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

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

```bash
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](../configure/plugins.md). For the broader OCP model, see [OCP overview](ocp-overview.md).

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

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

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

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

Response with vehicle data:

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

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

```rust
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](latency-measurement.md).

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](operator-side-control.md).

## Next steps

-   [Operator-side control](operator-side-control.md) - send operator-side custom data or display `vehicle_user_data`.
    
-   [Latency measurement](latency-measurement.md) - understand the full timestamp path.
    
-   [OCP overview](ocp-overview.md) - review plugin parameters, data paths, and fault behavior.
