From 4adefe9ce310c9053aed851398e1b36284e70c6b Mon Sep 17 00:00:00 2001 From: leubeem Date: Mon, 6 Jul 2026 17:18:43 +0200 Subject: [PATCH] feat(pm): silence auto-advance timer for backend timeout states Arm a timer whenever the session lands on a pilot state with auto_advance_on_silence (from the runtime tree); on expiry call the backend /timeout endpoint and apply the response through applyBackendDecision. Cleared on pilot transmission, stale-guarded against session/state changes, and deferred while PTT is recording so ATC never talks over the pilot. This makes the tower-v1 airborne handoff work without a sim bridge: telemetry fires it when connected, the silence timeout covers everyone else. Co-Authored-By: Claude Fable 5 --- app/composables/useRadioBackend.ts | 14 ++++++- app/pages/pm.vue | 63 ++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/app/composables/useRadioBackend.ts b/app/composables/useRadioBackend.ts index db41b87..c925592 100644 --- a/app/composables/useRadioBackend.ts +++ b/app/composables/useRadioBackend.ts @@ -115,6 +115,18 @@ export function useRadioBackend() { ) } + /** + * Fire the silence timeout for the current pilot state. Call when the + * state's `auto_advance_timeout_ms` elapses without a pilot transmission; + * the backend advances through the state's silence auto-transition. + */ + async function timeout(sessionId: string): Promise { + return await $fetch( + `${baseUrl()}/api/radio/session/${sessionId}/timeout`, + { method: 'POST' }, + ) + } + async function deleteSession(sessionId: string): Promise { await $fetch(`${baseUrl()}/api/radio/session/${sessionId}`, { method: 'DELETE' }) } @@ -123,5 +135,5 @@ export function useRadioBackend() { return await $fetch(`${baseUrl()}/api/decision-flows/runtime`) } - return { createSession, transmit, sendTelemetry, deleteSession, fetchFlows } + return { createSession, transmit, sendTelemetry, timeout, deleteSession, fetchFlows } } diff --git a/app/pages/pm.vue b/app/pages/pm.vue index b933ff9..f979265 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -3415,10 +3415,58 @@ const speakPilotReadback = (text: string) => { }) } -// Apply a backend decision (from a pilot transmission OR a telemetry tick) to -// the local engine + UI: sync the flow/cursor/variables/flags, speak the -// controller reply, and surface completion. Shared so telemetry-driven ATC and -// pilot-driven ATC land through exactly one code path. +// --- Silence auto-advance ---------------------------------------------------- +// Some pilot states let ATC continue on its own when the pilot stays quiet +// (e.g. the takeoff roll in tower-v1: no report needed, Tower hands off after a +// while anyway). Such states carry auto_advance_on_silence + +// auto_advance_timeout_ms in the runtime tree; whenever the session lands on +// one, arm a timer that fires the backend /timeout endpoint. Any pilot +// transmission or telemetry-fired advance re-arms or clears it via +// applyBackendDecision. +let silenceTimer: ReturnType | null = null + +function clearSilenceTimer() { + if (silenceTimer) { + clearTimeout(silenceTimer) + silenceTimer = null + } +} + +function armSilenceTimer() { + clearSilenceTimer() + const state = currentState.value as any + if (!state?.auto_advance_on_silence || !backendSessionId.value) return + const ms = Math.max(1000, Number(state.auto_advance_timeout_ms ?? 30000)) + const sessionAtArm = backendSessionId.value + const stateAtArm = state.id + pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms }) + const fire = async () => { + silenceTimer = null + // Never fire stale: the session ended or the state moved on while waiting. + if (backendSessionId.value !== sessionAtArm) return + if ((currentState.value as any)?.id !== stateAtArm) return + // Don't talk over the pilot mid-PTT — check again shortly. + if (isRecording.value) { + silenceTimer = setTimeout(fire, 5000) + return + } + try { + pmLog.info('SILENCE TIMEOUT fired →', stateAtArm) + const response = await radioBackend.timeout(sessionAtArm) + applyBackendDecision(response) + } catch (e: any) { + // 422 = the state changed under us (race with a transmission) — harmless. + const status = e?.status ?? e?.response?.status + if (status !== 422) pmLog.warn('SILENCE TIMEOUT failed', e) + } + } + silenceTimer = setTimeout(fire, ms) +} + +// Apply a backend decision (from a pilot transmission, a telemetry tick, or a +// silence timeout) to the local engine + UI: sync the +// flow/cursor/variables/flags, speak the controller reply, and surface +// completion. Shared so all backend-driven ATC lands through one code path. const applyBackendDecision = ( response: import('../composables/useRadioBackend').RadioTransmitResponse, opts: { transcript?: string } = {}, @@ -3490,6 +3538,9 @@ const applyBackendDecision = ( completedScenario.value = activeScenario.value currentScreen.value = 'complete' } + + // Re-arm (or clear) the silence auto-advance for whatever state we're now on. + armSilenceTimer() } const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => { @@ -3609,6 +3660,9 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = }) try { + // The pilot spoke — a pending silence auto-advance no longer applies. The + // response re-arms it if the next state also allows silence. + clearSilenceTimer() const response = await radioBackend.transmit(backendSessionId.value, transcript) // Drop a stale reply if the pilot already transmitted again while this call @@ -5450,6 +5504,7 @@ onUnmounted(() => { cancelAirportDataRefresh() stopBridgeSync() disconnectPttSocket() + clearSilenceTimer() })