Home

04 websocket protocol

§7e.4 WebSocket protocol

Endpoint

The daemon exposes exactly one path:

GET ws://<host>:<port>/ws?token=<jwt>

<host> defaults to 127.0.0.1 (see --bind in §7e.2). <port> is the value the daemon was started with. <jwt> is the same session token the user holds against loco-server (§7a.1). When loco-server serves the SPA on its own origin and the daemon is on a different port, the SPA opens a plain ws:// connection (the browser does NOT require CORS for raw WebSocket, but the daemon nevertheless honours Origin checking against the same allow-list as loco-server to prevent rogue cross-origin scripts from opening a WS upgrade).

There is no /api/v1/... prefix on the daemon. The single endpoint keeps the surface minimal.

Envelope

Identical to §4.2:

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

The discriminated-union shape is shared between Go (switch) and TypeScript (literal union); tygo (§Tech stack) generates both from one source.

Client → Server (Actions hosted by dcc-bus)

Throttle traffic only — every other WS action stays on loco-server's /api/v1/ws:

Notably absent from the daemon:

Server → Client (Events from dcc-bus)

Action / event mapping summary

   action                     authoritative WS endpoint
   -------------------------  -------------------------------------------------
   loco.subscribe             dcc-bus:<L>:<C>
   loco.unsubscribe           dcc-bus:<L>:<C>
   loco.setSpeed              dcc-bus:<L>:<C>
   loco.toggleFn              dcc-bus:<L>:<C>
   train.setSpeed             dcc-bus:<L>:<C>
   system.estop               dcc-bus:<L>:<C>   (scoped to this command station)
   ping                       both endpoints (independent heartbeats)
   --
   session.setCommandStation  loco-server /api/v1/ws  (triggers dcc-bus spawn
                                                       and dcc-bus.opened)
   session.setEmergencyPlan   loco-server /api/v1/ws  (mirrored to dcc-buses
                                                       via bigfred:user:<uid>)
   --
   takeover.*                 loco-server /api/v1/ws
   radio.*                    loco-server /api/v1/ws
   script.*                   loco-server /api/v1/ws
   interlocking.subscribe     loco-server /api/v1/ws
   auth.elevationChanged      loco-server /api/v1/ws

§7e.4.1 — train.setSpeed member timing (TrainSpeedScheduler)

Implementation: pkgs/bigfred/dcc-bus/service/train_speed_scheduler.go, invoked from cmd/train_set_speed.go after resolving each member's target speed from the slider.

Each member carries timing fields in defined_trains (DefinedTrainMember in pkgs/bigfred/contract/allowedvehicles.go). HandleTrainSetSpeed reads each member's current speed from the Redis loco:state cache before scheduling.

Mode When Behaviour
Start delay Consist start (leading was at DCC ≤ 1), member at DCC ≤ 1, startDelayMs > 0, acceleration ramp not selected One goroutine: sleep(startDelayMs), then one setSpeed to target
Acceleration ramp Target > current, accelRampMs > 0, and (current > 1 or startDelayMs == 0) Goroutine: first intermediate setSpeed immediately, then sleep(interval) between further steps until target; step size = abs(target − current) / steps
Braking ramp Target < current (including stop), brakeRampMs > 0 Same apply-then-sleep pattern as acceleration
Immediate Otherwise Synchronous setSpeed in the handler goroutine

Shared rules:

Connection lifecycle on the client

The frontend opens two WebSockets when the user enters throttle mode:

  1. Control plane. ws://host/api/v1/ws to loco-server. Already open since login; carries session.*, takeover.*, radio.*, script.*, presence, etc.
  2. Data plane. ws://host:<dccBusPort>/ws?token=<jwt> to the daemon that owns the picked command station. Opened when session.commandStationChanged arrives carrying a wsUrl; closed and re-opened against a different daemon when the user picks another command station.

The two connections are completely independent at the WS level; the SPA's useSocket hook is parameterised by the URL and reused unchanged.

Reconnect contract

If the data-plane WS drops:

  1. The client re-opens the connection with the same token.
  2. On dcc-bus.opened, the client re-emits loco.subscribe for every addr it was subscribed to before the drop (driven by the Zustand store, like §6 today).
  3. The daemon replays the snapshot from Redis on each subscribe (no gap longer than one poll interval).
  4. The frontend continues from local state for the few hundred milliseconds until the snapshot arrives; the snapshot is the reconciliation point.

If the daemon itself died and supervisord is restarting it (autorestart=true), the WS dial will fail with ECONNREFUSED for 1–2 s; the client uses exponential backoff and re-tries (see §17 Reliability for current intervals). While the data plane is down, the control plane keeps running, so session.warning events still surface to the user.