Home

02 websocket

4.2 WebSocket

Two endpoints share the same envelope shape but have different authoritative scopes (§7e splits the data plane out of loco-server):

Endpoint Process Scope
GET /api/v1/ws loco-server Control plane — sessions, takeover, radio, scripts, presence, sudo elevation, layout / command-station picker. Always open while the user is logged in.
GET ws://host:<port>/ws?token=<jwt> dcc-bus (one process per (layoutId, commandStationId) pair, §7e) Data plane — throttle traffic (loco.subscribe, loco.setSpeed, loco.toggleFn, train.setSpeed, system.estop, ping). Opened when the user picks a command station, re-opened against a different daemon when the user switches command stations.

The frontend therefore holds two WebSocket connections in throttle mode (§6.3b, §7e.7). The control plane carries everything that is not a per-DCC-address operation; the data plane carries the high-frequency throttle traffic and is the only WS that ever speaks to commandstation.Station. The two are independent at the WS layer and reconnect separately. Until §7e is implemented the data plane is hosted on the same /api/v1/ws endpoint as the control plane (the M1 baseline).

Every frame uses a common envelope format in both directions:

{
  "type": "loco.setSpeed",
  "id": "optional-correlation-uuid",
  "payload": { "addr": 3, "speed": 64, "forward": true }
}

The first frame after the upgrade is implicit: the server uses the session cookie / ?token= to identify the user and to compute the set of vehicles/trains this connection is allowed to interact with.

Client → Server (Actions)

Throttle / locomotive control — hosted by dcc-bus (§7e.4) once §7e ships; on the M1 baseline they live on loco-server's /api/v1/ws exactly as listed:

Train control — hosted by dcc-bus on the data-plane WS (§7e.4). loco-server publishes the train roster to Redis (defined_trains) but does not mediate driving:

There is no train.subscribe / train.unsubscribe. The frontend issues ordinary loco.subscribe for every powered member address so loco.state witnesses and per-member function toggles work unchanged.

Semantics: - Best-effort, not transactional. Partial failure leaves the rest of the consist at the new speed; the UI surfaces per-member errors from the aggregate ack. - Fan-out source is "train". Each member's Redis snapshot and broadcast loco.state carries source: "train" and controlledByUserId of the driving session. - Single command station. A train is driven on the session's currently picked command station (§4.5); all members must be on that station's layout roster. - Per-member timing (ramps & start delay). After computing each member's target speed, dcc-bus may schedule start delay, acceleration ramp, or braking ramp instead of an immediate write. Settings are persisted on TrainMember and carried in the defined_trains Redis snapshot (startDelayMs, accelRampMs, accelRampMaxSteps, brakeRampMs, brakeRampMaxSteps). Members with excludeFromSpeed: true are omitted from fan-out. A new train.setSpeed cancels pending ramps for that train. See §6.3a and pkgs/bigfred/dcc-bus/service/train_speed_scheduler.go. - system.estop {} – emergency stop. Hosted by dcc-bus once §7e ships; scope is the command station this dcc-bus owns (not a global track-power cut). On loco-server's baseline WS the scope is global. - system.radioStop {}Radio Stop (§4.6). Control-plane only. Layout-wide halt of every roster vehicle on all command stations; requires drive scope. See §4.6 for fan-out, debounce and audit. - ping. Both endpoints have independent application-level heartbeats; the dead-man's switch (§4.5) fires per-endpoint with cross-process coordination via Redis.

Interlocking / signal box:

Takeover (signalman → driver arbitration):

Per-target emergency stop (signalman roster action, §6.3d):

Radio ("walkie-talkie"):

Server → Client (Events)

Throttle / state:

Layout dashboard (presence + roster):

Takeover:

Radio:

Scripts (server-side Goja runs in the sibling executor, §3a.7):

Client → Server:

Server → Client:

Authorization (sudo elevation + signalman self-grant, §7a.7):

Common:

The protocol is a discriminated union on type, both in Go (switch) and TypeScript (literal union). Sharing types automatically (via tygo or similar) prevents drift.

dcc-bus-only frames (§7e)

dcc-bus adds a small set of events on top of the existing throttle vocabulary. They are scoped to a single (layoutId, commandStationId) daemon:

Server → Client:

The daemon also re-emits the existing throttle events (loco.state, loco.error, vehicle.functionsChanged, system.status, session.warning, session.emergencyExecuted, pong, ack) and hosts train.setSpeed on the data plane with semantics identical to those defined in this section, scoped to its (layoutId, commandStationId) slice. See §7e.4 for the full per-frame contract.