mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<RadioTransmitResponse> {
|
||||
return await $fetch<RadioTransmitResponse>(
|
||||
`${baseUrl()}/api/radio/session/${sessionId}/timeout`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string): Promise<void> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout> | 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()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user