Implementation plan — Interlocking view, radio & takeover (M5)
This plan implements the detailed specification spread across:
- terminology —
../architecture/00-terminology.md - protocol —
02-websocket.md,03-takeover-state-machine.md,04-radio-delivery.md,06-radio-stop.md,01-rest.md - domain model —
../architecture/05-domain-model/01-entities.md - frontend —
../architecture/08-frontend-components.md(§6.3b, §6.3d) - dcc-bus —
16-dcc-bus/03-state-and-redis.md,05-authorization.md - auth —
10-authn-authz/05-permission-matrix.md - acceptance —
../architecture/14-acceptance-criteria/05-takeover-radio.md - milestones —
../architecture/13-delivery-order.md(M5, steps 17–18)
Starting point
pages/InterlockingPage.tsxalready implements occupy/leave, the displacement confirmation, the navigation guard and the displaced toast. The radio panel is a placeholder (view.radioComingSoon).- Interlockings, interlocking sessions and presence exist on the backend
(
server/domain/interlocking*.go,server/service/interlocking*.go,server/service/presence.go). - Radio and takeover do not exist in code yet — greenfield, aligned with the updated docs.
- The throttle command-station picker pattern already exists: a cog
(
SettingsIcon) button incomponents/throttle/ThrottleCockpit.tsxopenscomponents/throttle/ThrottleSetupDialog.tsx. The interlocking view reuses this pattern. components/throttle/RadioStopButton.tsxalready renders the throttle Radio Stop (icon-only) and gates onroster.some(owner == me);server/security/radio_stop.gogates oncontrollerUserIds.
Confirmed design decisions
- Takeover — 15 s reject window; auto-grant → 5-minute self-lease; driver evicted from throttle; signalman drives in a closable overlay (speed 0 gate).
- Radio — Redis-only, 4 h TTL; required context (vehicle XOR train) and target (user XOR interlocking).
- Single-target stop —
system.estopTarget, „Zatrzymaj skład". dcc-busis lease-agnostic —loco-serverfolds driving rights intocontrollerUserIdsand republishesallowed_vehicles.- Interlocking command-station picker — cog button, same as throttle.
- Radio Stop in interlocking view — icon + „Radio stop" label above
panels;
signalmanrole alone may trigger (even idle).
Implementation stages
Four functional stages. Each stage is independently shippable and ends with verifiable acceptance criteria. Stages are sequential — later stages build on earlier ones.
Stage 1 ──► Stage 2 ──► Stage 3 ──► Stage 4
shell & radio takeover estop &
roster comms polish
| Stage | Focus | Delivers |
|---|---|---|
| 1 | Interlocking shell & roster | Staffed box with cog picker, Radio Stop bar, searchable roster with in-motion indicator; controllerUserIds lease folding |
| 2 | Radio (walkie-talkie) | End-to-end radio: Redis store, WS/REST, interlocking chat panel, driver throttle radio + chat overlays, sounds |
| 3 | Takeover control | 15 s state machine, 5-min self-lease, roster „Przejęcie" action, throttle overlay, driver eviction |
| 4 | Per-target stop & polish | system.estopTarget + „Zatrzymaj skład" button, remaining i18n/assets, full acceptance walkthrough |
Stage 1 — Interlocking shell & roster panel
Goal: Replace the radio placeholder with the interlocking work-area layout and a live vehicle/train roster. No radio or takeover yet — action buttons in the roster may be disabled or hidden until their stage lands.
Backend
- Migration — verify
interlockings/interlocking_sessionsexist; addlayout_vehiclesif missing (roster data source). controllerUserIdsfolding — extendLayoutVehicleService.SyncLayoutRosterToRedis(§7e.3) so eachAllowedVehicle.ControllerUserIDsincludes the owner plus every active lessee (VehicleLease/TrainLease). This is the shared foundation for takeover (stage 3) and dcc-bus drive checks; the daemon still has no lease concept — it only sees the flat set.- Radio Stop authorization — extend
server/security/radio_stop.go:CanTrigger(roles EffectiveRoles, userID uint, roster AllowedVehicles)mustAllowon either drive scope (userID ∈ controllerUserIds) or thesignalmanrole (§4.6.2). Updateradio_stop_test.go(idle signalman with no owned vehicles →Allow). Wire the new signature into the existingsystem.radioStopWS handler.
Frontend
pages/InterlockingPage.tsx— new layout, top → bottom:- header + action bar (existing occupy/leave),
- Radio Stop bar (icon + text „Radio stop"),
- two-panel work area (tabs on narrow screens).
- Cog command-station picker — reuse
components/throttle/ThrottleSetupDialog.tsx+hooks/useThrottleCommandStationSelection.tsin the action bar; firessession.setCommandStation. - Radio Stop bar — refactor
components/throttle/RadioStopButton.tsxto acceptvariant?: "icon" | "bar": throttle keeps"icon"(drive-scope gate), interlocking uses"bar"(signalman gate viauseMe().isSignalman). Same confirm overlay; dispatchessystem.radioStop. components/interlocking/InterlockingRosterPanel.tsx— fixed width, scrollable table; header search box (client-side filter); columns Skład (({login}) {name}+ „w ruchu" chip whenloco.state.speed != 0) and Akcje (placeholder/disabled icons for Radio / Stop / Takeover until stages 2–4). Subscribe toloco.statefor the in-motion indicator. Data from layout roster REST + presence/ownership enrichment.- i18n (partial) —
interlocking.json: panel labels, „w ruchu", „Radio stop" bar label, cog setup strings (reusethrottle.jsonkeys where possible).
Stage 1 acceptance
- Staffed interlocking view shows cog picker, Radio Stop bar above two panels, and a searchable roster with in-motion chips.
- A signalman with no owned vehicles can trigger Radio Stop from the
interlocking bar; an admin without
driver/signalmangets403. allowed_vehiclessnapshot includes active lessees incontrollerUserIds(verify via Redis GET after a manual lease).
Stage 2 — Radio (walkie-talkie)
Goal: Full walkie-talkie channel — signalman group chat, driver send/receive, Redis persistence, sounds.
Backend
domain/radio.go—RadioMessagevalue type (Redis-only; ULID id),RadioPhrasevocabulary,ValidateTarget()/ValidateContext()helpers (exactly-one invariants).server/service/radio_store.go— Redis Streams:bigfred:radio:layout:<L>:interlocking:<I>(signalman group chat),bigfred:radio:layout:<L>:user:<U>(driver personal stream).Append(fan-in to addressee + sender streams, TTL 4 h),Replay(oldest→newest, capped). Config:radio_ttl,radio_replay_limit.pkgs/bigfred/contract/radio.go— key templates + wire envelopes (radio_events.go).RadioService—Send(validate, denormalizeFromLogin/ContextName, authorize, append, return for fan-out) andReplay(visibility projection §4.4.3).server/security/radio.go—CanSend,CanReplayInterlocking.- WS hub —
radio.send,radio.replayactions;radio.message,radio.historyevents; fan-out per visibility rules. - REST —
GET /api/v1/interlockings/{id}/radio(occupant-only),GET /api/v1/radio/mine; read-only replay shims.
Frontend
api/radio.ts—useInterlockingRadio(id),useMyRadio();RadioMessage,RadioPhrase,RadioTarget,RadioContexttypes.components/interlocking/RadioPhrasePickerDialog.tsx— searchable table of the closedRadioPhrasevocabulary (client-side filter); emitsradio.send; playsradio-sent.oggon ack. Shared by interlocking and throttle overlays.components/interlocking/InterlockingChatPanel.tsx— fixed width, scrollable; line format({login}) {context name}: {translated phrase}; „Odpowiedz" icon pinned to a fixed-width right column; seeded byuseInterlockingRadio, live viaradio.message.- Enable the Radio icon in
InterlockingRosterPanel— opensRadioPhrasePickerDialogpre-addressed to the row's driver with the row's vehicle/train context. - Driver throttle overlays (§6.3b):
ThrottleRadioButton.tsx+ThrottleRadioOverlay.tsx— searchable interlocking picker +RadioPhrasePickerDialog; sends in the context of the currently driven vehicle/train.ThrottleChatButton.tsx+ThrottleChatOverlay.tsx— driver's own history (useMyRadio+radio.message); red unread badge until opened.- On-screen alert popup on inbound
radio.message(AutoDismissAlert). hooks/useRadioSounds.ts—radio-sent.oggon send ack,{phrase}.oggon receive.- Assets —
web/public/sounds/interlockings/radio-sent.ogg+ one{phrase}.oggperRadioPhrase(lower-cased filename; generic-chime fallback). - i18n — new
radio.jsonnamespace (1:1 withRadioPhrasevocabulary) interlocking.jsonchat strings +throttle.jsonradio/chat/alert strings. PL + EN; regeneratei18n/types.ts.
Stage 2 acceptance
- Signalman sees all driver traffic in the group chat; driver sees only their own conversations.
- Messages persist in Redis (~4 h), replay on mount/reconnect.
- Sender hears
radio-sent.ogg; receiver hears{phrase}.ogg, sees alert popup, chat icon turns red. - Every message has exactly one target and one context (vehicle XOR train).
Stage 2 tests
- Go:
RadioService(validation, visibility, denormalization),RadioStore(fan-in, TTL, ordered replay). - Frontend: chat line layout (fixed reply icon), phrase table filtering, unread badge, alert popup.
Stage 3 — Takeover control
Goal: Signalman can request takeover from the roster; after the 15 s window the driver is evicted and the signalman drives in an overlay.
Depends on stage 1 (controllerUserIds folding, roster panel, cog picker).
Backend
domain/takeover.go—TakeoverWindow = 15s,TakeoverLeaseDuration = 5m; extendTakeoverStatewith"released"; addGrantedLeaseID *uint,ReleasedAt *time.Time.- Migration —
takeover_requeststable (granted_lease_id,released_at, state CHECK). repo/takeover_requests.go— CRUD +ListPending()for restart recovery.TakeoverService— state machine (§4.3):Request→pending+ 15 s timer →takeover.requestedto driver.Reject/Cancel→ terminal states.autoGrant→ create 5-minVehicleLease/TrainLease, republishallowed_vehicles(signalman enterscontrollerUserIds),takeover.granted { leaseExpiresAt }, end driver's drive targets.Release(overlay close at speed 0 / box leave / lease expiry) → revoke lease, republish,takeover.released { reason }.- Hook existing lease janitor for
reason:"lease_expired". - Drive-scope resolver — throttle picker and REST visibility must
exclude targets leased away and include leased-in targets,
using the same logic as
controllerUserIds. server/security/takeover.go—CanRequest(active signalman).- WS hub —
takeover.request/reject/cancelactions;takeover.requested/granted/released/rejected/cancelled/expiredevents. pkgs/bigfred/contract/takeover_events.go— wire envelopes.
Frontend
api/takeover.ts— event payload types.- Enable Przejęcie kontroli icon in
InterlockingRosterPanel→takeover.request { target, targetId }. components/interlocking/TakeoverThrottleOverlay.tsx— overlay above the interlocking view hostingLocoControlPage/TrainControlPage; lease countdown badge; close disabled while speed != 0; closing releases takeover.- Driver eviction — on
takeover.granted: show eviction message, redirect to dashboard, drop target from picker (useThrottleVehicleSelection.ts). Ontakeover.released: target reappears. - i18n — takeover overlay, eviction message, countdown strings in
interlocking.json+throttle.json.
Stage 3 acceptance
- 15 s countdown → auto-grant → 5-min lease; driver evicted and redirected; target hidden from driver's picker.
- Signalman drives in overlay; can only close at speed 0.
- Lease expiry / manual release / leaving box →
takeover.released; target reappears in driver's picker. controllerUserIdsin Redis includes/excludes the signalman on grant/release; dcc-bus accepts/rejectssetSpeedaccordingly.
Stage 3 tests
- Go:
TakeoverService(timer, auto-grant, lease creation, release paths,allowed_vehiclesrepublish). - Frontend: overlay speed-0 gate, driver redirect, picker hide/show.
Stage 4 — Per-target stop & polish
Goal: „Zatrzymaj skład" action on the roster; final i18n/assets; full acceptance-criteria walkthrough.
Depends on stages 1 (roster panel) and 3 (takeover overlay uses cog picker for command-station context).
Backend
system.estopTarget—LocoService.EStopTarget(ctx, actor, target, targetId): resolve DCC address(es), fan EMG-stop to owningdcc-busdaemon(s) viadcc-bus:cmd:<L>:<C>(mirror Radio Stop fan-out incontract/layout_events.go, scoped to one target). No authority transfer.server/security/estop_target.go—CanStop(active signalman in layout or target driver/owner).- WS hub — wire
system.estopTargetaction.
Frontend
- Enable Stop icon in
InterlockingRosterPanel→system.estopTarget { target, targetId }(labelled „Zatrzymaj skład"; distinct from layout-wide Radio Stop). - Remaining i18n — any strings deferred from earlier stages; verify
PL + EN completeness across
radio.json,interlocking.json,throttle.json. - Missing sound assets — fill gaps in
web/public/sounds/interlockings/{phrase}.ogg.
Stage 4 acceptance
- „Zatrzymaj skład" brakes only the selected vehicle/train, never the whole layout.
- Full §10.3 acceptance-criteria walkthrough passes (interlocking panels,
radio, takeover, Radio Stop, per-target stop,
controllerUserIdsbehaviour).
Stage 4 tests
- Go:
EStopTargetSecurityContext,EStopTargetfan-out (incl. trains spanning multiple command stations). - End-to-end acceptance checklist from
05-takeover-radio.md.
Cross-cutting risks
- Drive-scope consistency — throttle picker, REST visibility and
allowed_vehicles.controllerUserIdsmust share one lease/takeover resolver (introduced in stage 1, extended in stage 3). system.estopTargetfan-out — a train may span multiple command stations; publish to each owning daemon.RadioStopButtongate — frontend gate is UX only; backendCanTriggeris the real guard.- Per-phrase sound mapping — lower-cased phrase value
(e.g.
entry_permitted.ogg); generic-chime fallback. dcc-busstays lease-agnostic — never teach the daemon about leases; only republishcontrollerUserIds.
Reference — file inventory
Quick lookup of new/changed files across all stages:
| Area | Files |
|---|---|
| Domain | server/domain/radio.go, server/domain/takeover.go |
| Repo | server/repo/takeover_requests.go, migrations |
| Service | server/service/radio.go, radio_store.go, takeover.go |
| Security | server/security/radio.go, takeover.go, estop_target.go, radio_stop.go |
| Contract | contract/radio.go, radio_events.go, takeover_events.go |
| HTTP | server/http/radio.go (replay endpoints) |
| WS | server/ws/hub.go (radio + takeover + estopTarget handlers) |
| Frontend — interlocking | InterlockingChatPanel, InterlockingRosterPanel, RadioPhrasePickerDialog, TakeoverThrottleOverlay |
| Frontend — throttle | ThrottleRadioButton/Overlay, ThrottleChatButton/Overlay, useRadioSounds |
| Frontend — shared | RadioStopButton (variant refactor), api/radio.ts, api/takeover.ts |
| i18n | radio.json, extensions to interlocking.json + throttle.json |
| Assets | web/public/sounds/interlockings/*.ogg |