Vehicle-Side Control
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:
-
Start Oden Streamer with the OCP plugin available.
-
Connect your vehicle process to the OCP TCP server.
-
Read each framed JSON control message.
-
Apply the command only after your vehicle-side safety checks.
-
Send a framed JSON response that copies
ack_timeandack_time_macfrom the latest control message. -
Add optional
vehicle_user_datawhen 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 |
|---|---|---|
|
|
Vehicle-side TCP server port on Oden Streamer. |
|
|
Round-trip latency limit in milliseconds before OCP raises |
|
|
Time in milliseconds without a control message before OCP raises |
|
not set |
When present, binds TCP servers to |
|
|
Operator-side TCP server port. Included here because |
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:4000unless 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.0to1.0range. The default value is all buttonsfalse, stick axes0.0, and triggers-1.0. When OCP gamepad forwarding is disabled from the operator side,controllerisnull, except during a fault. telemetry.latency-
Vehicle-side OCP round-trip latency measurement in milliseconds. The default limit before
HighLatencyis raised is500ms. telemetry.fault-
truewhen 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 LatencyorLatency 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_datapayload, 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 Messagesis raised when no control message arrives forocp_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_limitis thelatency_limitin milliseconds that Oden Streamer is using. An invalidlatency_limitparameter falls back to500, so compare this value with the one you configured before driving.no_control_message_timeoutis theocp_no_control_message_timeoutin 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.faultistrue. -
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 |
|---|---|---|
|
No vehicle TCP client is connected. |
Start or reconnect the vehicle process. Check the bind address and port. |
|
The vehicle-side TCP server could not bind |
Free the port or set another |
|
The Streamer has not received operator control messages for more than |
Check the Oden network link and active vehicle selection. |
|
|
Check network, video latency, and whether your vehicle process blocks before responding. |
|
The timestamp loop did not return a valid |
Confirm the vehicle response copies both fields unchanged and the operator-side integration echoes timestamps correctly. |
|
The operator side has one of these faults:
|
Correct the fault on the operator station. The operator-side |
|
The message to the vehicle would be larger than 16 KiB, so OCP left out |
Send less client data, or fewer control characters in strings. |
|
A monitored camera has not delivered a valid frame for more than 500 ms. |
Check the camera and its capture pipeline. |
|
More than 500 ms passed between two Streamer frames. |
Check Streamer CPU/GPU load and capture pipeline health. |
|
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 on127.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 than127.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 theTCP Server Bind Failedfault. Free the port or set anothertcp_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.
HighLatencyappears after connecting-
Return
ack_timeandack_time_macimmediately 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-appropriatelatency_limit. Latency Loop Not Closedappears-
Check that
ack_timeandack_time_macare 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. Useclient_user_dataor 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 buttonsfalse, stick axes0.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
-
Operator-side control - send operator-side custom data or display
vehicle_user_data. -
Latency measurement - understand the full timestamp path.
-
OCP overview - review plugin parameters, data paths, and fault behavior.