Skip to the content.

ADR 0002: Sidecar WebSocket API contract

Context

The sidecar (Rust) ingests Forza UDP telemetry at ~60 Hz, runs a heuristics engine, and needs to push two streams of data to the SimHub overlay (HTML/JS):

  1. Live telemetry — a downsampled view of the parsed Forza Dash packet, used by the HUD to render speed/RPM/gear/etc. and a lap-status indicator.
  2. Recommendations — discrete events from the race-engineer heuristics pipeline (see .github/agents/race-engineer.agent.md). These are bursty, not continuous.

Phase 1 of docs/PLAN.md blocks on this contract being locked before implementation (issue #9). The contract must also be open enough to support additional consumers later — Stream Deck plugins, custom dashboards, a future native viewer — without a rewrite.

Constraints:

We considered Server-Sent Events, a Unix/named-pipe transport, and gRPC. WS won (see Alternatives) because it is the only option that is bidirectional, universally supported by browser/webview clients with zero shim, and matches the axum + tokio::sync::broadcast stack already chosen for the sidecar.

Decision

We will expose a single WebSocket endpoint at ws://127.0.0.1:<port>/ws (default port 38920, configurable). The server binds to 127.0.0.1 by default and only binds to 0.0.0.0 when an explicit opt-in config flag is set, in which case it logs a warning at startup. There is no authentication at v1 — the API surface is local-only.

All frames are text frames carrying a single JSON object that conforms to a fixed envelope. The envelope, the set of event types, the heartbeat policy, the slow-consumer policy, and the version-negotiation rules are all specified below and frozen at schema_version: 1. Incompatible changes bump schema_version and require a new ADR.

Transport

Property Value
Scheme ws:// (no TLS — local loopback)
Default bind 127.0.0.1:38920
Bind override config key ws.bind (env: TUNING_COACH_WS_BIND)
Path /ws
Subprotocol tuning-coach.v1 (optional in current implementation)
Frame type Text frames only; binary frames rejected
Encoding UTF-8 JSON, one envelope per frame
Max frame size 64 KiB (server-enforced; closes with 1009 if exceeded)
Compression permessage-deflate advertised, accepted if offered

CBOR is deferred. If JSON serialization shows up in profiling as a hot path, a follow-up ADR can introduce a tuning-coach.v1+cbor subprotocol; the envelope stays identical.

Envelope

Every message — server→client and client→server — is a JSON object with these fields:

{
  "type": "telemetry",
  "schema_version": 1,
  "t_ms": 1738012345678,
  "data": { /* type-specific */ }
}
Field JSON type Required Notes
type string yes One of the event types below. Unknown types are rejected.
schema_version integer yes Currently 1. Mismatched clients are closed with code 4001.
t_ms integer yes Unix epoch ms (server wall clock for server-sent; client for sent).
data object yes Type-specific payload. May be empty {} but must be present.

Forward-compatibility rule: clients must ignore unknown fields inside data. Adding fields to a data payload is a non-breaking change. Removing or retyping a field is breaking and bumps schema_version.

Server → client event types

type Cardinality Purpose
hello exactly 1, first frame Handshake; advertises versions and limits
session_started per session Forza IsRaceOn went 0→1
session_ended per session IsRaceOn went 1→0 or pause threshold hit
telemetry streaming (default 10 Hz) Downsampled, curated telemetry snapshot
lap_completed per lap boundary Final lap time + validity classification
recommendation bursty, low rate Race-engineer output
pong reply to client ping Echoes client t_ms
error as-needed Soft error; connection stays open

Client → server event types

type Purpose
ping Liveness probe; server replies with pong
subscribe Filter the set of event types this client wants
set_rate Change telemetry downsample rate for this client
request_snapshot Ask the server to immediately emit the current telemetry frame

Any unknown type, missing required field, or wrong schema_version causes the server to send an error envelope and — if the offence is the envelope itself or the version — close the connection (see Error model).

Heartbeat

Slow-consumer policy

Each client gets its own bounded tokio::sync::broadcast receiver. Capacity: 256 frames (≈ 25 s @ 10 Hz). When a client lags:

Implementation hint (non-normative): a single broadcast::Sender<Frame> for telemetry and a per-client mpsc::Sender<Frame> for events solves this cleanly without coupling the two queues.

Versioning

Multi-client fan-out

Any number of clients may connect simultaneously. Each gets:

Recommendations and lap events are fanned out to every subscribed client. Telemetry is generated once per UDP packet, downsampled per-client, and delivered independently.

Authentication

None at v1. The server binds to loopback by default; the 0.0.0.0 opt-in emits a WARN level=audit msg="ws bound to non-loopback; no auth" log line on every accepted upgrade until a future ADR introduces auth.

Observability

Mandatory per architect cross-cutting standards:

Event payload schemas

hello (server → client)

{
  "type": "hello",
  "schema_version": 1,
  "sidecar_version": "0.1.0"
}

It is sent exactly once at connect-time before any other frame.

telemetry (server → client)

Curated subset of the Forza Dash packet. Units are SI / display-friendly; tire temperatures are converted from °F to °C at this boundary (Forza emits °F per the source spec — see .github/agents/telemetry-expert.agent.md). Steering is normalized to [-1.0, 1.0]. Throttle/brake/clutch/handbrake are normalized to [0.0, 1.0]. Speed is in km/h for HUD use.

{
  "type": "telemetry",
  "schema_version": 1,
  "t_ms": 1738012345678,
  "data": {
    "is_race_on": true,
    "session_t_ms": 142318,
    "speed_kph": 187.4,
    "rpm": 8120.0,
    "rpm_max": 9200.0,
    "gear": 4,
    "throttle": 0.92,
    "brake": 0.0,
    "clutch": 0.0,
    "handbrake": 0.0,
    "steer": -0.18,
    "drivetrain": "AWD",
    "lap": {
      "number": 3,
      "current_s": 42.318,
      "last_s": 89.114,
      "best_s": 88.902,
      "position": 4,
      "distance_m": 4821.6
    },
    "tire_temp_c": { "fl": 88.3, "fr": 92.1, "rl": 79.5, "rr": 81.0 },
    "tire_slip_ratio": { "fl": 0.02, "fr": 0.03, "rl": 0.05, "rr": 0.06 },
    "tire_slip_angle_rad": { "fl": 0.04, "fr": 0.05, "rl": 0.03, "rr": 0.04 },
    "suspension_travel_norm": { "fl": 0.41, "fr": 0.43, "rl": 0.55, "rr": 0.57 },
    "fuel_frac": 0.62,
    "boost_bar": 0.8,
    "accel_g": { "x": 0.12, "y": -0.04, "z": 1.21 },
    "lap_status": "valid",
    "tire_wear_frac": { "fl": 0.92, "fr": 0.91, "rl": 0.87, "rr": 0.89 },
    "track_ordinal": 861
  }
}

lap_status is one of "valid" | "dirty" | "pit" | "reset" | "out_lap".

tire_wear_frac and track_ordinal are present only when the sidecar receives a 331-byte FM 2023 Dash packet (the game’s primary format). They are null (JSON null) for legacy 311-byte Dash packets. Clients must treat these fields as optional (forward-compatible per the additive-change rule above).

Rationale for the field selection: every field is needed by either the heuristics engine (per .github/agents/race-engineer.agent.md) or a typical HUD. Raw vectors that aren’t directly displayed (velocity, angular velocity, position) are intentionally excluded from telemetry; they remain available through request_snapshot if a debugging consumer needs them in future (via an additional data.raw block, gated by an opt-in subscribe filter).

lap_completed (server → client)

{
  "type": "lap_completed",
  "schema_version": 1,
  "t_ms": 1738012345678,
  "data": {
    "lap_number": 3,
    "lap_time_s": 88.902,
    "is_personal_best": true,
    "validity": "valid",
    "invalid_reasons": []
  }
}

validity ∈ {"valid","dirty","pit","reset"}. invalid_reasons is an array of short tags ("off_track", "contact", "rewind", "pit_in").

session_started / session_ended (server → client)

{
  "type": "session_started",
  "schema_version": 1,
  "t_ms": 1738012345678,
  "data": {
    "session_id": "01HQ7K8YV3...",
    "car_ordinal": 3456,
    "car_class": "S",
    "car_pi": 750,
    "drivetrain": "AWD"
  }
}
{
  "type": "session_ended",
  "schema_version": 1,
  "t_ms": 1738012345678,
  "data": {
    "session_id": "01HQ7K8YV3...",
    "duration_s": 1842.3,
    "lap_count": 12
  }
}

recommendation (server → client)

Mirrors the race-engineer recommendation format one-to-one. Each field maps to a line in the engineer’s template (Detected / Likely cause / Recommended adjustment / Expected outcome / Confidence / Caveats).

{
  "type": "recommendation",
  "schema_version": 1,
  "t_ms": 1738012345678,
  "data": {
    "id": "01HQ7K8YV3...",
    "session_id": "01HQ7K8YV3...",
    "lap_number": 3,
    "category": "springs",
    "title": "Front bottoming out",
    "detected": "Front suspension >95% travel on 3 of 4 corners (T1, T3, T7).",
    "cause": "Insufficient front spring rate / ride height for downforce + load.",
    "adjustment": {
      "summary": "Front spring rate 85 → 92 N/mm",
      "parameter": "spring_rate_front",
      "from": 85.0,
      "to": 92.0,
      "unit": "N/mm",
      "step": 1.0
    },
    "expected_outcome": "Eliminates bottoming on T1/T3; slight loss of mechanical grip mid-corner.",
    "confidence": "high",
    "caveats": [
      "Assumes smooth driving style",
      "Re-check after 3 clean laps",
      "If Race Springs not installed, raise ride height +2mm instead"
    ],
    "alternatives": [
      { "summary": "Ride height F +2mm", "parameter": "ride_height_front", "from": 110, "to": 112, "unit": "mm" }
    ],
    "driving_style_assumed": "smooth",
    "locked_fallback_used": false
  }
}

category ∈ {"tires","gearing","alignment","anti_roll","springs","damping","aero","brakes","differential"} matching the tuning surface in docs/PLAN.md. confidence ∈ {"high","medium","low"} matching the engineer agent’s confidence rules.

error (server → client)

{
  "type": "error",
  "schema_version": 1,
  "t_ms": 1738012345678,
  "data": {
    "code": "bad_request",
    "message": "set_rate: hz must be in [1, 60]",
    "ref": null
  }
}

code is a fixed enum: bad_request | unknown_type | schema_mismatch | rate_limited | internal. ref echoes the offending client envelope’s t_ms if known, for client-side correlation.

ping / pong

{ "type": "ping", "schema_version": 1, "t_ms": 1738012345678, "data": {} }

Server reply:

{ "type": "pong", "schema_version": 1, "t_ms": 1738012345701, "data": { "echo_t_ms": 1738012345678 } }

subscribe (client → server)

{
  "type": "subscribe",
  "schema_version": 1,
  "t_ms": 1738012345678,
  "data": {
    "events": ["telemetry", "recommendation", "lap_completed",
               "session_started", "session_ended"]
  }
}

events is a complete list, not a delta. An empty list disables all server-pushed events except error, pong, and hello. Default on connect is all event types.

set_rate (client → server)

{ "type": "set_rate", "schema_version": 1, "t_ms": 1738012345678, "data": { "hz": 10 } }

Valid range: 1..=60. Out-of-range values produce an error with code: "bad_request"; the previous rate is retained.

request_snapshot (client → server)

{ "type": "request_snapshot", "schema_version": 1, "t_ms": 1738012345678, "data": {} }

Server responds within 100 ms with a single telemetry frame containing the most recent parsed packet, regardless of the client’s downsample rate.

Close codes

Code Meaning Sender
1000 Normal closure either
1008 Policy violation (slow consumer) server
1009 Frame too large server
1011 Server error / idle timeout server
4001 schema_version mismatch server
4002 Unsupported subprotocol server

4000-range codes are application-defined per RFC 6455.

Consequences

Positive

Negative

Neutral

Alternatives Considered

Server-Sent Events (SSE)

Native pipe / Unix domain socket / Windows named pipe

gRPC (with grpc-web for the browser)

Raw UDP rebroadcast

References