This is the canonical WebSocket protocol document for Pecan and the Universal Telemetry Software (UTS) WebSocket bridge. UTS uses the same Python codebase in both roles: the car runs it natively through car-telemetry.service, while the base station runs it through Docker Compose. Protocol v2 adds client-to-car (uplink) messaging alongside the existing car-to-client (downlink) telemetry stream.
The component-local runtime notes live at universal-telemetry-software/WEBSOCKET_RUNTIME_NOTES.md so this file remains the only protocol spec.
Downlink (Car -> Client):
graph LR
CarPi["Car Pi<br/><i>can0</i>"]
BasePi["Base Pi<br/><i>data.py</i>"]
WSBridge["WS Bridge<br/><i>port 9080</i>"]
Pecan["Pecan<br/><i>browser</i>"]
CarPi -- "UDP<br/>batched CAN" --> BasePi
BasePi -- "Redis<br/>can_messages" --> WSBridge
WSBridge -- "WebSocket<br/>JSON frames" --> Pecan
style CarPi fill:#2d6a4f,color:#fff
style BasePi fill:#1b4332,color:#fff
style WSBridge fill:#40916c,color:#fff
style Pecan fill:#52b788,color:#fff
Uplink (Client -> Car):
graph LR
Pecan["Pecan<br/><i>browser</i>"]
WSBridge["WS Bridge<br/><i>port 9080</i>"]
RedisDB[("Redis<br/><i>base mode only</i>")]
BaseData["Base data.py<br/><i>base mode only</i>"]
CarPi["Car Pi<br/><i>can0</i>"]
Pecan -- "WebSocket<br/>can_send" --> WSBridge
WSBridge -- "car mode<br/>python-can direct write" --> CarPi
WSBridge -- "base mode<br/>Redis can_uplink" --> RedisDB
RedisDB --> BaseData
BaseData -- "UDP 0xCAFE" --> CarPi
style Pecan fill:#e76f51,color:#fff
style WSBridge fill:#f4a261,color:#fff
style CarPi fill:#264653,color:#fff
Full Bidirectional System:
graph TB
subgraph Car ["Car (Raspberry Pi)"]
CAN["CAN Bus<br/><i>can0</i>"]
CarData["data.py<br/><i>car mode</i>"]
CAN <--> CarData
end
subgraph Base ["Base Station (Raspberry Pi)"]
BaseData["data.py<br/><i>base mode</i>"]
RedisDB[("Redis")]
WSBridge["websocket_bridge.py<br/><i>:9080</i>"]
BaseData -- "PUBLISH can_messages" --> RedisDB
RedisDB -- "SUBSCRIBE can_messages" --> WSBridge
WSBridge -- "PUBLISH can_uplink" --> RedisDB
RedisDB -- "SUBSCRIBE can_uplink" --> BaseData
end
subgraph Clients ["Dashboard Clients"]
Pecan1["Pecan #1"]
Pecan2["Pecan #2"]
end
CarData -- "UDP :5005<br/>downlink" --> BaseData
BaseData -- "UDP :5005<br/>uplink (0xCAFE)" --> CarData
WSBridge -- "downlink" --> Pecan1
WSBridge -- "downlink" --> Pecan2
Pecan1 -- "uplink" --> WSBridge
Pecan2 -- "uplink" --> WSBridge
| Term | Definition |
|---|---|
| Downlink | Car-to-client direction. Telemetry data flowing from the vehicle to dashboard viewers. |
| Uplink | Client-to-car direction. Commands/messages flowing from the dashboard to the vehicle. |
| Pecan | The web-based dashboard (React/TypeScript). Acts as a WebSocket client. |
| UTS | Universal Telemetry Software. Shared Python codebase used by both car and base roles. |
| WS Bridge | The WebSocket server component inside UTS (websocket_bridge.py, port 9080). |
| CAN frame | A Controller Area Network message with an arbitration ID (11-bit or 29-bit) and up to 8 data bytes. |
- Protocol: WebSocket (RFC 6455)
- Port:
9080(plain) /9443(TLS-terminated) - Frame type: Text frames (JSON)
- Encoding: UTF-8
- Max message size: 64 KB
ws://<host>:9080 # local / car hotspot
wss://<host>:9443 # production with TLS
Connection negotiation follows standard WebSocket handshake. No subprotocol or custom headers are required.
All messages in both directions use a JSON envelope with a type field for disambiguation:
Downlink messages sent without a type field are treated as legacy v1 messages:
- A JSON array is interpreted as a CAN message batch (
can_data) - A JSON object with a
receivedkey is interpreted as system stats (system_stats)
Clients SHOULD send enveloped messages. The server MUST accept both enveloped and legacy formats.
These messages flow from the WebSocket bridge to connected Pecan clients.
Batched CAN frames from the vehicle. Published at ~20 msgs / 50ms from the base station.
// Enveloped (v2)
{
"type": "can_data",
"messages": [
{
"time": 1708012800000, // Unix timestamp in milliseconds
"canId": 256, // CAN arbitration ID (decimal)
"data": [146, 86, 42, 123, 205, 255, 0, 0] // 0-8 data bytes
}
// ... more messages
]
}
// Legacy (v1) — still supported
[
{ "time": 1708012800000, "canId": 256, "data": [146, 86, 42, 123, 205, 255, 0, 0] }
]| Field | Type | Required | Description |
|---|---|---|---|
type |
"can_data" |
Yes (v2) | Message discriminator |
messages |
array |
Yes | Array of CAN message objects |
messages[].time |
number |
Yes | Timestamp in ms since Unix epoch |
messages[].canId |
number |
Yes | CAN arbitration ID (0–2047 standard, 0–536870911 extended) |
messages[].data |
number[] |
Yes | Data bytes array, length 0–8, each value 0–255 |
Published once per second by the base station.
// Enveloped (v2)
{
"type": "system_stats",
"received": 45,
"missing": 1,
"recovered": 0
}
// Legacy (v1) — still supported
{ "received": 45, "missing": 1, "recovered": 0 }| Field | Type | Required | Description |
|---|---|---|---|
type |
"system_stats" |
Yes (v2) | Message discriminator |
received |
number |
Yes | UDP packets received this second |
missing |
number |
Yes | UDP packets detected missing this second |
recovered |
number |
Yes | Packets recovered via TCP this second |
Sent in response to an uplink can_send message to confirm receipt and processing.
{
"type": "uplink_ack",
"ref": "abc-123", // Echo of the client's ref ID
"status": "queued", // "queued" in base mode, "sent" in car mode
"reason": null // null on success, string on rejection
}| Field | Type | Required | Description |
|---|---|---|---|
type |
"uplink_ack" |
Yes | Message discriminator |
ref |
string |
Yes | Echo of the client-provided reference ID |
status |
string |
Yes | "queued" = accepted by base mode for Redis/UDP relay, "sent" = written directly in car mode |
reason |
string|null |
No | Human-readable rejection reason |
Sent when the server encounters an error processing a client message.
{
"type": "error",
"code": "INVALID_MESSAGE",
"message": "Missing required field: canId"
}| Field | Type | Required | Description |
|---|---|---|---|
type |
"error" |
Yes | Message discriminator |
code |
string |
Yes | Machine-readable error code (see section 7) |
message |
string |
Yes | Human-readable description |
These messages flow from Pecan clients to the WebSocket bridge, which relays them toward the car.
Request the car to write a CAN frame to the bus.
{
"type": "can_send",
"ref": "abc-123",
"canId": 256,
"data": [0, 0, 100, 0, 0, 0, 0, 0]
}| Field | Type | Required | Description |
|---|---|---|---|
type |
"can_send" |
Yes | Message discriminator |
ref |
string |
Yes | Client-generated unique reference ID for tracking (UUID recommended) |
canId |
number |
Yes | CAN arbitration ID to transmit (0–2047 standard, 0–536870911 extended) |
data |
number[] |
Yes | Data bytes to send, length 1–8, each value 0–255 |
Validation rules:
canIdmust be a non-negative integerdatamust be a non-empty array of 1–8 integers, each in range [0, 255]refmust be a non-empty string (max 64 characters)
Server behavior:
- Validate the message
- If invalid, respond with
errorand close processing - If running in car mode, write directly to
can0withpython-can - If running in base mode, publish to Redis channel
can_uplinkfor UDP relay to the car - Respond with
uplink_ack("sent"in car mode,"queued"in base mode)
Batch variant for sending multiple CAN frames in a single WebSocket message.
{
"type": "can_send_batch",
"ref": "batch-456",
"messages": [
{ "canId": 256, "data": [0, 0, 100, 0, 0, 0, 0, 0] },
{ "canId": 192, "data": [1, 0, 0, 0, 0, 0, 0, 0] }
]
}| Field | Type | Required | Description |
|---|---|---|---|
type |
"can_send_batch" |
Yes | Message discriminator |
ref |
string |
Yes | Client-generated unique reference ID |
messages |
array |
Yes | Array of CAN message objects (max 20 per batch) |
messages[].canId |
number |
Yes | CAN arbitration ID |
messages[].data |
number[] |
Yes | Data bytes, 1–8 values in [0, 255] |
{
"type": "ping",
"timestamp": 1708012800000
}Server responds with:
{
"type": "pong",
"timestamp": 1708012800000, // Echo of client's timestamp
"serverTime": 1708012800005 // Server's current time
}The WebSocket bridge runs the same protocol in both UTS roles, but the uplink path differs:
| Mode | ROLE |
Uplink path | Redis required for uplink? |
|---|---|---|---|
| Car direct | car |
Browser -> WebSocket bridge -> python-can -> can0 |
No |
| Base station | base |
Browser -> WebSocket bridge -> Redis can_uplink -> UDP 0xCAFE relay -> car |
Yes |
In car mode, downlink frames can also be broadcast from an in-process queue, so Redis is not required on the car. In base mode, the WebSocket bridge uses Redis pub/sub as the message bus between components.
| Channel | Direction | Publisher | Subscriber | Format |
|---|---|---|---|---|
can_messages |
Downlink | Base data.py |
WS Bridge | JSON array of {time, canId, data} |
system_stats |
Downlink | Base data.py |
WS Bridge | JSON object {received, missing, recovered} |
can_uplink |
Uplink | WS Bridge | Base data.py |
JSON object (see below) |
{
"ref": "abc-123",
"canId": 256,
"data": [0, 0, 100, 0, 0, 0, 0, 0],
"source": "192.168.1.5:54321", // Client IP:port for auditing
"timestamp": 1708012800000 // Server receipt time
}For batch messages, each CAN frame in the batch is published as a separate Redis message to can_uplink, all sharing the same ref prefix (e.g., batch-456/0, batch-456/1).
| Code | Description |
|---|---|
INVALID_MESSAGE |
JSON parse error or missing type field |
INVALID_CAN_ID |
canId out of range or not an integer |
INVALID_DATA |
data array invalid (wrong length, values out of range) |
INVALID_REF |
ref missing or exceeds 64 characters |
BATCH_TOO_LARGE |
can_send_batch exceeds 20 messages |
RATE_LIMITED |
Client is sending uplink messages too fast |
UPLINK_DISABLED |
Uplink is not enabled on this server instance |
UNKNOWN_TYPE |
Unrecognized message type |
CAN_WRITE_FAILED |
Car mode only: python-can failed to write to can0 |
To protect the CAN bus from being flooded:
| Limit | Value | Scope |
|---|---|---|
| Max uplink messages/sec | 10 | Per client connection |
| Max batch size | 20 | Per can_send_batch message |
| Max message size | 64 KB | Per WebSocket frame |
When rate limited, the server responds with:
{
"type": "error",
"code": "RATE_LIMITED",
"message": "Uplink rate limit exceeded (max 10 msg/sec)"
}There are two valid car-side uplink paths:
- Direct car connection: the car-mode WebSocket bridge writes
can_sendandcan_send_batchmessages directly tocan0. - Base station relay: the base-mode WebSocket bridge publishes to Redis,
data.pyrelays the message over UDP, and the car receives a0xCAFEuplink packet.
When the car-side UTS receives a UDP uplink packet from the base station, it:
- Deserializes the JSON payload
- Validates the CAN frame shape
- Constructs a
python-canMessageobject - Writes to
can0viabus.send(msg)
The base station relays uplink CAN messages to the car using the same UDP channel but with a distinct packet header:
Uplink UDP Packet:
[0:2] Magic bytes: 0xCA 0xFE (distinguishes uplink from downlink)
[2:10] Sequence number (uint64, big-endian)
[10:12] Message count (uint16, big-endian)
[12:..] CAN messages, each 20 bytes:
[0:8] Timestamp (double, big-endian)
[8:12] CAN ID (uint32, big-endian)
[12:20] Data (8 bytes, zero-padded)
The car-side distinguishes uplink packets from its own outbound packets by checking for the 0xCAFE magic prefix.
Sending arbitrary CAN messages to a vehicle is inherently dangerous. Malformed messages can:
- Trigger unintended actuator responses
- Corrupt ECU state
- Violate FSAE safety rules
Current safeguards:
- Uplink is disabled by default (requires
ENABLE_UPLINK=trueenv var) - Rate limiting is enforced at the WebSocket bridge level
- All uplink messages are logged with client IP and timestamp for auditing
Recommended future safeguards:
- Restrict uplink CAN IDs to an allowlist derived from the DBC or a dedicated safety config
- Require authentication for uplink-capable WebSocket connections
The current system does not authenticate WebSocket clients. For uplink functionality:
- Restrict WebSocket access to the local network (car hotspot / pit LAN)
- Consider adding a shared secret or token for uplink messages in future versions
- Accept and parse client messages (replaced
websocket.wait_closed()withasync for) - Validate uplink message structure
- Publish valid
can_sendmessages to Rediscan_uplinkchannel - Send
uplink_ackresponses - Send
errorresponses for invalid messages - Implement rate limiting per client
- Handle
ping/pong - Gate uplink behind
ENABLE_UPLINKenv var
- Subscribe to Redis
can_uplinkchannel - Relay uplink messages to car via UDP (with
0xCAFEheader)
- Listen for inbound UDP uplink packets (detect
0xCAFEmagic) - Write received CAN frames to
can0
- Add
sendCanMessage()method for uplink messages - Add
sendCanBatch()method - Handle
uplink_ackanderrorresponses - Generate unique
refIDs - Handle
pongresponses for latency measurement
sequenceDiagram
participant P as Pecan (Browser)
participant WS as WS Bridge
participant R as Redis
participant B as Base data.py
participant C as Car data.py
P->>WS: can_send {canId: 256, data: [0,0,100,...]}
WS->>R: PUBLISH can_uplink
WS-->>P: uplink_ack {status: "queued"}
R->>B: SUBSCRIBE can_uplink
B->>C: UDP (0xCAFE header)
C->>C: bus.send() to can0
sequenceDiagram
participant P as Pecan (Browser)
participant WS as WS Bridge
P->>WS: can_send {canId: -1, data: [0,0,0,0]}
WS-->>P: error {code: "INVALID_CAN_ID"}
| CAN ID | Name | Direction | Description |
|---|---|---|---|
| 192 | VCU_Status | Downlink | Vehicle Control Unit status |
| 193 | Pedal_Sensors | Downlink | APPS and brake pressure |
| 194 | Steering_Wheel | Downlink | Steering angle and buttons |
| 256 | MC_Command | Uplink candidate | Torque request to motor controller |
| 257 | MC_Feedback | Downlink | Motor speed, torque, current |
| 512 | BMS_Status | Downlink | Pack voltage, current, SOC |
| 513 | BMS_Cell_Stats | Downlink | Cell voltage statistics |
| 768 | Wheel_Speeds | Downlink | Four wheel speed sensors |
| 1280 | Cooling_Status | Downlink | Coolant temps, pump/fan speed |
| 2048 | IMU_Data | Downlink | Accelerometer and gyro |
| 1006–1055 | TORCH_* | Downlink | BMS cell voltages and temps |
v1 clients (those that never send messages and only consume downlink data) require zero changes. The server continues to broadcast legacy un-enveloped messages on the can_messages and system_stats Redis channels.
Clients may send typed v2 uplink/control messages such as:
{ "type": "ping", "timestamp": 1708012800000 }The server accepts subscribe as a no-op reserved message type for future compatibility, but it does not currently switch downlink clients into a different envelope format.
{ "type": "<message_type>", // ... type-specific fields }