§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:
loco.subscribe{ addr }— start receiving events for an address in the daemon's roster. Snapshot pushed immediately from Redis cache or one-shotGetSpeed(§7e.3 "Snapshot on subscribe").loco.unsubscribe{ addr }.loco.setSpeed{ addr, speed, forward }— write the DCC bus. Authorization viaLocoSecurityContext.CanDriveLoco(§7a.3.2).loco.toggleFn{ addr, fn, on }— toggle a function. Authorization viaFunctionSecurityContext.CanInvokeFunction(§7a.3).train.setSpeed{ trainId, speed, forward }— drive an entire train on this command station. The daemon reads the cacheddefined_trainssnapshot, authorizes against the train'scontrollerUserIds, fans out per-member writes (leading multiplier forced to1.0,excludeFromSpeedmembers skipped,Reversedflip, clamp to max speed), and returns an aggregate ack with per-member outcomes. Members may be written immediately or via theTrainSpeedScheduler(start delay, acceleration/braking ramps in background goroutines). See §4.2 and §7e.4.1.system.estop{}— emergency stop, scoped to THIS daemon's command station. Sets the user'sDriveTargetsto speed 0; does not cut track power. Available to any authenticated session of the user (including a signalman not currently in throttle mode). Audited via the event channel (session.emergency_executed).ping{}— application-level heartbeat (§4.5.2). Resets the per-session dead-man's switch timer. The daemon may also use the ping cadence to drop a connection it cannot identify.
Notably absent from the daemon:
train.subscribe/train.unsubscribe— dropped. The frontendloco.subscribes every powered member address directly; no train-level subscribe frame is needed.takeover.*,radio.*,script.*,interlocking.*,session.*,auth.*,layout.*— all control-plane traffic stays onloco-server.loco.subscribefor anaddrthat is not on the daemon's layout roster returnsack { ok:false, error:"vehicle_not_on_layout" }. The frontend learns about a per-layout-but-not-this-cs vehicle fromloco-server's REST and the dashboard, then either picks the right command station (session.setCommandStation) or never subscribes here.
Server → Client (Events from dcc-bus)
dcc-bus.opened{ sessionId, layoutId, commandStationId, commandStationName, dccBusVersion, pollIntervalMs, heartbeatGraceMs, sharedBus: bool }— sent immediately after a successful WS upgrade. The mirror ofsession.opened(§4.5) but scoped to the daemon.sharedBus: truewhen any peerdcc-busis also pinned to the samecommandStationId(§3a.4 rule 9 — the UI uses this to show the shared-bus chip).sessionIdis generated by the daemon and is distinct from theloco-serversession id; both are listed in/api/v1/auth/meunderdaemonSessions.loco.state{ addr, speed, forward, functions, updatedAt, controlledBy }— same shape as §4.2.controlledBy.kind ∈ {"driver","train","signalman","none"}.loco.error{ addr, code, message }— code ∈vehicle_not_on_layout|vehicle_is_dummy|not_authorized_to_drive|dcc_write_failed|poll_failed|command_station_disconnected|taken_over|lease_expired.vehicle.functionsChanged{ addr }— re-fetchGET /api/v1/vehicles/{addr}/functionsfromloco-server. Identical semantics to §4.2; the daemon emits it because the WS client is here, not onloco-server.system.status{ commandStationId, connected, trackPower, polledAddrs, busy, sharedBus }— periodic, every 5 s and on transition.connected= the DCC bus is live (Station.CleanUp()has not run).trackPoweris command-station specific (Z21 reports it; LocoNet inferred). The payload contract is a subset ofloco-server'ssystem.status— the server's payload merges results from every daemon for the global view.session.warning{ secondsUntilEmergency }— daemon-local dead-man's switch warning (§4.5.4). The user's other sessions onloco-serverget an analogous warning via the existing channel.session.emergencyExecuted{ action, affectedVehicles: [addr...] }— broadcast to the user's other sessions on this daemon (the user may have multiple tabs both connected todcc-bus-1-2). The cross-process fan-out toloco-serverhappens via thebigfred:layout:<L>:emergency:<userId>channel (§7e.5).pong{}.ack{ id, ok, error? }— correlated acknowledgement; same as §4.2.
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:
- Ramp duration 0 disables that ramp. Max steps 1–10; if
duration / steps < 500 ms, step count is reduced automatically. - A subsequent
train.setSpeedfor the same train callsCancelTrainand aborts pending delay/ramp goroutines. - The WS
ackreturns after scheduling (not after ramps complete); witnesses observe intermediate speeds vialoco.statebroadcasts. - Leading vehicle: multiplier always
1.0; timing fields are editable (same as trailing members).
Connection lifecycle on the client
The frontend opens two WebSockets when the user enters throttle mode:
- Control plane.
ws://host/api/v1/wstoloco-server. Already open since login; carriessession.*,takeover.*,radio.*,script.*,presence, etc. - Data plane.
ws://host:<dccBusPort>/ws?token=<jwt>to the daemon that owns the picked command station. Opened whensession.commandStationChangedarrives carrying awsUrl; 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:
- The client re-opens the connection with the same
token. - On
dcc-bus.opened, the client re-emitsloco.subscribefor everyaddrit was subscribed to before the drop (driven by the Zustand store, like §6 today). - The daemon replays the snapshot from Redis on each subscribe (no gap longer than one poll interval).
- 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.