3a.4 Layout-and-command station addressing rules
The layout and command station concepts together form the
addressing layer of the system. The rules below cut across services
and are worth stating in one place. They replace the older, post-login
"party list / party join" model: with the new model the layout is
picked on the login form and the drive session is bound to it for
its entire lifetime.
-
Every drive session is pinned to exactly one layout, chosen at login.
POST /api/v1/auth/login { login, pin, layoutId }validates credentials, looks upLayoutbylayoutId, and rejects the request with422 layout_lockedifLayout.Locked == true,422 layout_not_foundif the row does not exist. On success the issued JWT carries{ userId, layoutId }(immutable inside the token); the WS upgrade readslayoutIddirectly from the JWT and the resultingDriveSession.LayoutIDis read-only for the lifetime of that session. Switching layout requires a full logout/login round trip – there is nosetLayoutWS action and no/layouts/{id}/joinendpoint. -
A layout has one or more attached command stations. Non-system layouts have an explicit
LayoutCommandStationjoin table managed by an admin (§4.1POST/DELETE /api/v1/layouts/{id}/command-stations). Service-side validation refuses to create a non-system layout without at least one attached command station and refuses to detach the last one from a non-system layout (422 layout_needs_at_least_one_command_station). The system layout (IsSystem = true, seeded withName = "default") instead exposes a virtual list: every row fromcommand_stationsis implicitly attached to it without anyLayoutCommandStationrow existing. Admin attempts to mutate that list are rejected with422 default_layout_command_stations_immutable. -
session.CommandStationIDstarts asnilfor every drive session. Right after the WS upgrade the server emitssession.opened { layoutId, availableCommandStations: [{id,name}…], … }carrying the layout's command-station set (the join rows for non-system layouts, the fullcommand_stationscatalogue for the system layout). The throttle is gated until the user picks one viasession.setCommandStation { commandStationId }(§4.5). Until that happens, every action that needs aStationreturnscommand_station_not_selected. The UI MAY auto-firesession.setCommandStationfor the user when the layout's set contains exactly one entry, but the server contract is the same in both cases. -
Changing the picked command station mid-session is a controlled context switch. Calling
session.setCommandStationa second time with a differentcommandStationIdis allowed in any layout. The server first runs the user's emergency plan against the previousCommandStationID(SetSpeed(0)on everyDriveTargetsentry on the old station; same code path as the dead-man's switch), then re-points the session at the new one and broadcastssession.commandStationChangedto every concurrent session of the same user. Attempts to pick acommandStationIdthat is not in the session layout's set returnack { ok:false, error:"command_station_not_attached_to_layout" }. Attempts to pick one that was deleted (or detached from the layout) sincesession.openedwas emitted return the same error and the throttle stays gated until the user picks again from the refreshed set. -
All driving operations resolve their
Stationvia the session'sCommandStationID(not the layout's, because the layout has many).LocoServicekeeps amap[commandStationID]Stationthat is lazily initialised on first use and shared across all drive sessions currently pinned to that command station, regardless of which layout they entered through. There is no global station in the service layer. -
Locking a layout never kicks anyone out. Toggling
Layout.Locked = true(admin-only, viaPOST /api/v1/layouts/{id}/lock) only removes the row from the unauthenticated login dropdown (GET /api/v1/layouts/login). Drive sessions already pinned to that layout keep running normally; throttle commands, takeover, radio, andsession.setCommandStationcontinue to work. Once those sessions close on their own, the layout becomes effectively dormant. Unlocking it (DELETE /api/v1/layouts/{id}/lock) puts it back into the login picker. The system layout cannot be locked (422 default_layout_cannot_be_locked). -
Interlocking listings, takeover requests and radio messages are filtered by the session's layout. A driver in layout A never sees signal boxes from layout B even if they happen to share command stations. The system layout's whitelist starts empty (no interlockings appear by default); admins or signalmen of the system layout may whitelist interlockings just like in any other layout.
-
Vehicles and trains are not layout-scoped. Ownership and leases live at the user level and travel with the user across layouts – a driver's locomotive is theirs regardless of which event they attend. The vehicle's DCC address, however, only makes sense on the command station the driver has currently picked; switching the session's command station naturally re-targets every command.
-
Two drive sessions on the same command station share the DCC bus. This is true whether they entered through the same layout or through two different layouts that both list this command station. The system does not prevent it, but the UI shows a "shared bus" chip on every throttle currently pinned to a command station that any other live session is also pinned to. Likewise, an admin attaching a command station to a new layout while it is already driven elsewhere sees a UI warning.
-
Deleting a command station cascades safely.
DELETE /api/v1/command-stations/{id}removes everyLayoutCommandStationrow pointing at it (including effectively de-listing it from the system layout's virtual view) and detaches every live drive session that was pinned to it (session.CommandStationID → nil, plus asession.commandStationChanged { commandStationId: null, reason: "deleted" }broadcast). The throttle re-gates until the user picks another command station from the refreshedavailableCommandStationslist (or, if the layout no longer has any, until the admin re-attaches one). Deletion is rejected with409 layout_needs_at_least_one_command_stationif it would leave any non-system layout with zero attached command stations.