mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
Remove the transitional website auth, admin hooks, SEO and hosted analytics together, then align the app routes, runtime configuration, tests and dependencies. These changes form one atomic cleanup because the filtered app must switch its identity and runtime surfaces as a unit.
866 lines
38 KiB
TypeScript
866 lines
38 KiB
TypeScript
import { computed, nextTick, onUnmounted, ref } from 'vue'
|
|
import type { Ref } from 'vue'
|
|
import { pmLog } from '../../shared/utils/pmLog'
|
|
import { SCENARIOS, type Scenario } from '../../shared/constants/scenarios'
|
|
import { type useFrequencyPresets } from '~/composables/useFrequencyPresets'
|
|
import { normalizedFrequencyValue } from '../../shared/utils/frequency'
|
|
import type { useSessionState } from '~/composables/useSessionState'
|
|
import { useApi } from '~/composables/useApi'
|
|
import type { useRadioSpeech } from '~/composables/useRadioSpeech'
|
|
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
|
import { generateGermanRegistration } from '../../shared/utils/registration'
|
|
import { gateTransmission } from '../../shared/utils/transmissionGate'
|
|
import { silenceWindowFor } from '../../shared/utils/silenceTimer'
|
|
import { createAutoTuneScheduler } from '../../shared/utils/autoTune'
|
|
import { buildBackendVariables } from '../../shared/utils/backendVariables'
|
|
import {
|
|
isSimControlMatch,
|
|
isSimControlRejection,
|
|
parseSimControl,
|
|
simControlRejectionSpeech,
|
|
simControlResultSpeech,
|
|
type SimControlCommand,
|
|
type SimControlCommandResult,
|
|
} from '../../shared/utils/simControl'
|
|
|
|
export interface LiveAtcSessionDeps {
|
|
state: ReturnType<typeof useSessionState>
|
|
freq: ReturnType<typeof useFrequencyPresets>
|
|
speech: ReturnType<typeof useRadioSpeech>
|
|
radioBackend: ReturnType<typeof useRadioBackend>
|
|
api: ReturnType<typeof useApi>
|
|
/** NUXT_PUBLIC_* runtime config (radioBackendUrl, pttMinWords). */
|
|
config: ReturnType<typeof useRuntimeConfig>
|
|
/** Pre-generate ATIS audio once the airport is known (useAtisPlayback). */
|
|
prefetchAtisAudio: () => void
|
|
/** True while the pilot holds PTT — the silence timer must not talk over them. */
|
|
isRecording: Ref<boolean>
|
|
/** Settings toggle: dial in the new frequency automatically after a handoff. */
|
|
autoTuneEnabled: Ref<boolean>
|
|
/**
|
|
* Special-scenario switches. Read once when the session is created, so
|
|
* toggling one mid-flight applies to the next flight — which is how an
|
|
* instructor sets one up without the pilot seeing it coming.
|
|
*/
|
|
forceRto: Ref<boolean>
|
|
forceGoAround: Ref<boolean>
|
|
bridgeConnected: Ref<boolean>
|
|
bridgePosition: Ref<{ lat: number; lon: number } | null>
|
|
/** ?token=… from the route — auths the frequency-sim-control channel (design §4). */
|
|
bridgeToken: Ref<string>
|
|
persistSelectedPlan: (plan: any | null) => void
|
|
maybeShowFirstRunHelp: () => void
|
|
}
|
|
|
|
/**
|
|
* The session controller: everything that talks to the Python backend and moves
|
|
* the user between screens.
|
|
*
|
|
* Constructed last, after every composable it calls into. The composables that
|
|
* in turn need handlePilotTransmission/applyBackendDecision (speech, PTT,
|
|
* bridge) receive them as thin forwarding closures from the page, which is what
|
|
* breaks the cycle — see useSessionState for the other half of the split.
|
|
*/
|
|
export function useLiveAtcSession(
|
|
engine: ReturnType<typeof useCommunicationsEngine>,
|
|
deps: LiveAtcSessionDeps,
|
|
) {
|
|
const {
|
|
currentState, activeFlow, setActiveFlow, moveToSilent, patchVariables, patchFlags,
|
|
appendLogEntry, initializeFlight, fetchRuntimeTree, isReady: engineReady,
|
|
collectAtcStatesUntilPilotTurn, variables: vars, clearCommunicationLog,
|
|
} = engine
|
|
|
|
const {
|
|
state, freq, speech, radioBackend, api, config, prefetchAtisAudio,
|
|
isRecording, autoTuneEnabled, forceRto, forceGoAround,
|
|
bridgeConnected, bridgePosition, bridgeToken,
|
|
persistSelectedPlan, maybeShowFirstRunHelp,
|
|
} = deps
|
|
|
|
const {
|
|
currentScreen, loading, error, pilotInput,
|
|
vatsimId, flightPlans, selectedPlan,
|
|
completedScenario, activeScenario,
|
|
sessionStarting, sessionStartingMessage,
|
|
backendSessionId, lastControllerSay, backendExpectedPhrase,
|
|
lastReadbackReport, lastReadbackTranscript,
|
|
setLastTransmission, clearLastTransmission,
|
|
} = state
|
|
|
|
const {
|
|
frequencies, airportFrequencies, frequencySources, activeAirportIcao,
|
|
expectedFrequencyForState, acceptedFrequenciesForState,
|
|
runwayInUse, informationLetter, atisReport,
|
|
fetchAirportFrequencies, setActiveFrequencyFromList,
|
|
fetchDestinationFrequencies, applyDerivedPosition,
|
|
} = freq
|
|
|
|
const {
|
|
RADIO_CHECK_RE, SAY_AGAIN_RE, FAREWELL_RE,
|
|
scheduleControllerSpeech, speakPilotReadback, answerRadioCheck,
|
|
} = speech
|
|
|
|
const { readbackEnabled } = speech
|
|
|
|
// Monotonic counter so a newer pilot transmission supersedes an older one still
|
|
// awaiting its backend response — only the latest result is applied (#16).
|
|
let transmitGeneration = 0
|
|
|
|
// True while a pilot transmission is out at the backend. Read by useAiTraffic's
|
|
// gating chain: simulated traffic must not key up in the window between the
|
|
// user finishing their call and ATC answering it, or the traffic call reads as
|
|
// the reply. A counter rather than a boolean, so two overlapping transmissions
|
|
// can't have the first one's finally clear the flag out from under the second.
|
|
const transmitInFlightCount = ref(0)
|
|
const transmitInFlight = computed(() => transmitInFlightCount.value > 0)
|
|
|
|
// --- Silence timers ----------------------------------------------------------
|
|
// Two kinds of wait fire the backend /timeout endpoint, both armed here (see
|
|
// silenceWindowFor for which applies where):
|
|
// auto-advance — the pilot owes nothing and ATC carries on by itself (the
|
|
// takeoff roll in tower-v1), on the flow-authored window.
|
|
// readback — the pilot owes a mandatory readback and has not given it,
|
|
// so ATC asks again on the much shorter server-published window.
|
|
// Any pilot transmission or telemetry-fired advance re-arms or clears the
|
|
// timer via applyBackendDecision, and a fire is dropped if the session or the
|
|
// state moved on while it was pending.
|
|
let silenceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// Consecutive silence-timeouts fired for the SAME state. If the backend keeps
|
|
// re-prompting the identical auto_advance_on_silence state (e.g. it re-asks the
|
|
// same read-back-less confirmation every timeout window), re-arming forever
|
|
// reads as ATC repeating itself on a loop. Reset on any pilot transmission or
|
|
// once the decision actually lands on a different state.
|
|
let silenceFireCount: { stateId: string | null; count: number } = { stateId: null, count: 0 }
|
|
const MAX_CONSECUTIVE_SILENCE_FIRES = 2
|
|
|
|
function clearSilenceTimer() {
|
|
if (silenceTimer) {
|
|
clearTimeout(silenceTimer)
|
|
silenceTimer = null
|
|
}
|
|
}
|
|
|
|
function resetSilenceFireCount() {
|
|
silenceFireCount = { stateId: null, count: 0 }
|
|
}
|
|
|
|
function armSilenceTimer() {
|
|
clearSilenceTimer()
|
|
const state = currentState.value as any
|
|
if (!backendSessionId.value) return
|
|
const window = silenceWindowFor(state)
|
|
if (!window) return
|
|
|
|
if (silenceFireCount.stateId !== state.id) {
|
|
silenceFireCount = { stateId: state.id, count: 0 }
|
|
}
|
|
if (silenceFireCount.count >= MAX_CONSECUTIVE_SILENCE_FIRES) {
|
|
pmLog.warn('SILENCE TIMER not re-armed — state fired', silenceFireCount.count, 'times already:', state.id)
|
|
return
|
|
}
|
|
|
|
const ms = window.ms
|
|
const sessionAtArm = backendSessionId.value
|
|
const stateAtArm = state.id
|
|
pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms, kind: window.kind })
|
|
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)
|
|
silenceFireCount = {
|
|
stateId: stateAtArm,
|
|
count: (silenceFireCount.stateId === stateAtArm ? silenceFireCount.count : 0) + 1,
|
|
}
|
|
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)
|
|
}
|
|
|
|
// --- Auto-tune ---------------------------------------------------------------
|
|
// Tuning is manual, so after a handoff nothing the pilot says goes through
|
|
// until they dial the new frequency in. With the setting on, the radio does it
|
|
// for them a few seconds after the handoff was accepted, announcing it first.
|
|
//
|
|
// Whether a change is due is decided from state, not from an event (see
|
|
// planAutoTune): the two cases that must NOT tune — a frequency readback that
|
|
// was wrong, and one not yet given — both leave the session on a state that
|
|
// still expects the frequency already dialled in, so nothing is due.
|
|
const autoTune = createAutoTuneScheduler({
|
|
announce: (text) => {
|
|
// Announce before changing, never after: the pilot has to be able to
|
|
// follow what their own radio just did.
|
|
scheduleControllerSpeech(text)
|
|
appendLogEntry('system', text, currentState.value?.id ?? '', {
|
|
frequency: frequencies.value.active,
|
|
})
|
|
},
|
|
tune: (frequency) => {
|
|
// Prefer the airport's own entry so the label and the engine's notion of
|
|
// the position come along; fall back to a bare entry for an invented one.
|
|
const known = airportFrequencies.value.find(
|
|
(entry: any) => normalizedFrequencyValue(entry.frequency) === normalizedFrequencyValue(frequency),
|
|
)
|
|
pmLog.info('AUTO-TUNE tuning →', frequency)
|
|
setActiveFrequencyFromList(known ?? {
|
|
type: '', label: '', frequency, source: 'openaip',
|
|
} as any)
|
|
},
|
|
currentSessionId: () => backendSessionId.value || null,
|
|
currentActive: () => frequencies.value.active,
|
|
onCancelled: (reason) => pmLog.info('AUTO-TUNE dropped —', reason),
|
|
})
|
|
|
|
function clearAutoTune() {
|
|
autoTune.cancel()
|
|
}
|
|
|
|
function scheduleAutoTune() {
|
|
const plan = autoTune.schedule({
|
|
enabled: autoTuneEnabled.value,
|
|
active: frequencies.value.active,
|
|
expected: expectedFrequencyForState(),
|
|
accepted: acceptedFrequenciesForState(),
|
|
})
|
|
if (plan) pmLog.info('AUTO-TUNE armed →', plan.frequency, `in ${plan.delayMs}ms`)
|
|
}
|
|
|
|
// Guards the ATC reply (log entry + TTS) against being applied twice for the
|
|
// same decision — applyBackendDecision can be reached from three sources
|
|
// (transmit reply, telemetry tick and silence timeout), and
|
|
// two of them can legitimately fire back-to-back for the same outcome (e.g.
|
|
// a silence timeout racing an in-flight transmit reply), which otherwise
|
|
// spoke/logged the same confirmation 2-3x (design doc WP1 Fix 2).
|
|
let lastAppliedSay: { text: string; stateId: string; atMs: number } | null = null
|
|
const APPLIED_SAY_DEDUPE_MS = 15_000
|
|
|
|
// 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('./useRadioBackend').RadioTransmitResponse,
|
|
opts: { transcript?: string } = {},
|
|
) => {
|
|
// If the backend has chained to a different flow, switch the local engine
|
|
// first so moveToSilent can find the new states and expectedFrequencyForState()
|
|
// returns the correct frequency_name.
|
|
if (response.active_flow && response.active_flow !== activeFlow.value) {
|
|
pmLog.info('FLOW CHANGE local:', activeFlow.value, '→ backend:', response.active_flow)
|
|
try {
|
|
setActiveFlow(response.active_flow)
|
|
} catch (e) {
|
|
pmLog.warn('setActiveFlow failed for', response.active_flow, e)
|
|
}
|
|
}
|
|
|
|
// Advance local cursor through every state the backend auto-walked, then the
|
|
// final state. moveToSilent updates current_unit, actions and handoffs
|
|
// without scheduling further auto-transitions. suppressSay: the ATC line for
|
|
// this decision is logged exactly once below, from the backend-rendered
|
|
// sayText — logging each walked state's own say_tpl too would duplicate it
|
|
// (2-3x on chained flows with multiple auto-advanced ATC/system states).
|
|
// TTS is unaffected either way: only scheduleControllerSpeech below plays audio.
|
|
for (const stateId of response.auto_advanced_states ?? []) {
|
|
pmLog.debug('moveToSilent ← auto_advanced:', stateId)
|
|
moveToSilent(stateId, { suppressSay: true })
|
|
}
|
|
pmLog.debug('moveToSilent ← next_state_id:', response.next_state_id)
|
|
moveToSilent(response.next_state_id, { suppressSay: true })
|
|
|
|
// Capture the per-field readback diagnostic for the STT debug panel.
|
|
lastReadbackReport.value = response.readback_report ?? []
|
|
if (opts.transcript !== undefined) lastReadbackTranscript.value = opts.transcript
|
|
|
|
// Update expected phrase AFTER cursor moves so any local-engine reactive
|
|
// updates from moveToSilent have already settled.
|
|
backendExpectedPhrase.value = response.expected_pilot_template ?? null
|
|
|
|
// Sync variables + routing flags from backend so the local renderer stays in step.
|
|
if (response.variables && Object.keys(response.variables).length) {
|
|
patchVariables(response.variables)
|
|
pmLog.debug('variables synced:', response.variables)
|
|
}
|
|
if (response.flags && Object.keys(response.flags).length) {
|
|
patchFlags(response.flags)
|
|
pmLog.debug('flags synced:', response.flags)
|
|
}
|
|
|
|
// TTS: use the pre-rendered string from the backend (correct variable values).
|
|
let sayText = response.controller_say_rendered || response.controller_say_template
|
|
const rb = response.readback_report ?? []
|
|
const readbackPassed = rb.length > 0 && rb.every((r: any) => r.matched)
|
|
if (sayText && readbackPassed && !/^\s*(readback correct|correct[,.\s]|negative|i say again)/i.test(sayText)) {
|
|
sayText = `Readback correct. ${sayText}`
|
|
}
|
|
if (sayText) {
|
|
const isDuplicate =
|
|
lastAppliedSay
|
|
&& lastAppliedSay.text === sayText
|
|
&& lastAppliedSay.stateId === response.next_state_id
|
|
&& Date.now() - lastAppliedSay.atMs < APPLIED_SAY_DEDUPE_MS
|
|
if (isDuplicate) {
|
|
pmLog.info('TTS SKIPPED (duplicate of last-applied decision) →', sayText)
|
|
} else {
|
|
pmLog.info('TTS →', sayText)
|
|
lastControllerSay.value = sayText
|
|
scheduleControllerSpeech(sayText)
|
|
appendLogEntry('atc', sayText, response.next_state_id, {
|
|
flow: response.active_flow,
|
|
frequency: frequencies.value.active,
|
|
})
|
|
lastAppliedSay = { text: sayText, stateId: response.next_state_id, atMs: Date.now() }
|
|
}
|
|
}
|
|
|
|
if (response.fallback_used) {
|
|
pmLog.warn('FALLBACK USED:', response.fallback_reason)
|
|
console.warn('[Backend] Fallback used:', response.fallback_reason)
|
|
}
|
|
|
|
if (response.session_complete) {
|
|
pmLog.info('SESSION COMPLETE — showing completion screen')
|
|
completedScenario.value = activeScenario.value
|
|
currentScreen.value = 'complete'
|
|
}
|
|
|
|
// Position-derived distances, used to work out which ground stations are
|
|
// still within VHF range. Absent without a bridge — then nothing is ranged.
|
|
applyDerivedPosition((response as any).derived_position)
|
|
|
|
// Re-arm (or clear) the silence auto-advance for whatever state we're now on.
|
|
armSilenceTimer()
|
|
// And dial in the new frequency if this decision handed us to one.
|
|
scheduleAutoTune()
|
|
}
|
|
|
|
// --- Frequency-sim-control (design doc §4) ------------------------------------
|
|
// A matched command from handlePilotTransmission is queued server-side for the
|
|
// bridge to execute; the result comes back asynchronously via the /api/bridge/live
|
|
// poll (see handleSimControlResult below), so this call itself stays silent on
|
|
// success — only a network/enqueue failure gets an immediate ATC reply.
|
|
const sendSimControlCommand = async (command: SimControlCommand) => {
|
|
try {
|
|
await $fetch('/api/bridge/command', {
|
|
method: 'POST',
|
|
headers: { 'x-bridge-token': bridgeToken.value },
|
|
body: { command },
|
|
})
|
|
} catch (e) {
|
|
pmLog.warn('SIM CONTROL enqueue failed', e)
|
|
const reply = 'unable to reach bridge, say again'
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
}
|
|
}
|
|
|
|
/** Speak the outcome of a bridge command surfaced by useSimBridgeSync's telemetry poll. */
|
|
const handleSimControlResult = (result: SimControlCommandResult) => {
|
|
const reply = simControlResultSpeech(result)
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
}
|
|
|
|
const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => {
|
|
const transcript = message.trim()
|
|
|
|
// STT CONTENT GATE — search here if a spoken transmission was ignored.
|
|
// Drops what push-to-talk produces when nothing was actually said: a stray
|
|
// tap, punctuation from near-silent audio, a clipped syllable, or one of the
|
|
// phrases Whisper hallucinates on silence. Each of those would otherwise be
|
|
// graded as a wrong readback, count toward the 3x-skip, and cost an LLM
|
|
// router call. Standard short calls ("roger", "wilco") are recognised as
|
|
// phraseology and pass regardless of length; typed input is never gated.
|
|
// Threshold is the NUXT_PUBLIC_PTT_MIN_WORDS env var (default 2); set it to
|
|
// 1 to disable the length rule.
|
|
const gate = gateTransmission(transcript, {
|
|
source,
|
|
minPttWords: Number(config.public.pttMinWords ?? 2),
|
|
readbackRequired: currentState.value?.readback_required,
|
|
})
|
|
if (!gate.accept) {
|
|
pmLog.info(
|
|
`IGNORED ${source} transcript (${gate.reason}) at state ${currentState.value?.id ?? '—'}:`,
|
|
JSON.stringify(transcript),
|
|
)
|
|
return
|
|
}
|
|
|
|
// Mark this as the latest transmission; a newer one started before the backend
|
|
// replies supersedes it (the stale reply is dropped below).
|
|
const myGen = ++transmitGeneration
|
|
|
|
const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot'
|
|
setLastTransmission(`${prefix}: ${transcript}`)
|
|
|
|
// Log the pilot's call with the ACTUAL tuned frequency (the engine's own
|
|
// notion of "active frequency" is unit-derived and can lag what's tuned).
|
|
appendLogEntry('pilot', transcript, currentState.value?.id ?? '', {
|
|
frequency: frequencies.value.active,
|
|
})
|
|
|
|
if (readbackEnabled.value) {
|
|
speakPilotReadback(transcript)
|
|
}
|
|
|
|
// --- Radio check ---
|
|
// A service call, valid on ANY frequency and answered by whoever owns it.
|
|
// Handled entirely locally: it must bypass the wrong-frequency gate (its
|
|
// purpose is verifying the tuned frequency) and never reach the backend
|
|
// flow engine (the training state stays untouched).
|
|
if (RADIO_CHECK_RE.test(transcript)) {
|
|
answerRadioCheck(transcript)
|
|
return
|
|
}
|
|
|
|
// --- Say again ---
|
|
// Pilot asks ATC to repeat. Handled locally: re-speak the last controller
|
|
// transmission without advancing the flow.
|
|
if (SAY_AGAIN_RE.test(transcript) && lastControllerSay.value) {
|
|
const reply = `I say again. ${lastControllerSay.value}`
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
return
|
|
}
|
|
|
|
// --- Sign-off ---
|
|
// A bare goodbye / thank-you (no readback or request alongside): acknowledge
|
|
// politely instead of evaluating it as a transmission. Requires a farewell
|
|
// with no digits, no readback/acknowledgement words, and only a few words, so
|
|
// it never swallows a real call like "wilco, thanks" or a frequency readback.
|
|
const wordCount = transcript.split(/\s+/).filter(Boolean).length
|
|
const hasActionWord = /\b(wilco|roger|cleared|clear|runway|contact|ready|request|squawk|descend|climb|taxi|line\s?up|holding|hold short|approved|affirm|going|switching|push|startup)\b/i.test(transcript)
|
|
if (FAREWELL_RE.test(transcript) && !/\d/.test(transcript) && !hasActionWord && wordCount <= 6) {
|
|
const cs = (vars as any).value?.callsign ?? ''
|
|
const reply = cs ? `Good day, ${cs}.` : 'Good day.'
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
return
|
|
}
|
|
|
|
// --- Sim control (frequency-driven simulator command) ---
|
|
// A meta channel: the pilot talking to their OWN simulator, not ATC — so it
|
|
// runs before the frequency gate (like radio check) and, unlike every other
|
|
// transmission, never reaches radioBackend.transmit()/the decision flow.
|
|
// Only live while a bridge is actually connected (design doc §4); parseSimControl's
|
|
// anchors already keep it from ever matching real ICAO phraseology either way.
|
|
if (bridgeConnected.value) {
|
|
const simResult = parseSimControl(transcript)
|
|
if (isSimControlMatch(simResult)) {
|
|
void sendSimControlCommand(simResult.command)
|
|
return
|
|
}
|
|
// A rejection with a reason means the pilot clearly addressed their sim but
|
|
// got something wrong — tell them. 'no_intent' just means this was ordinary
|
|
// radio traffic, so it falls through to the flow untouched.
|
|
if (isSimControlRejection(simResult) && simResult.reason !== 'no_intent') {
|
|
const reply = simControlRejectionSpeech(simResult.reason)
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
return
|
|
}
|
|
}
|
|
|
|
// --- Frequency check ---
|
|
// Reject the transmission if the pilot is on the wrong frequency. A position
|
|
// can publish several valid frequencies (e.g. two Tower freqs); accept any of
|
|
// them, and only fall back to the single expected value when no list resolves.
|
|
const expectedFreq = expectedFrequencyForState()
|
|
const acceptedFreqs = acceptedFrequenciesForState()
|
|
const activeNorm = normalizedFrequencyValue(frequencies.value.active)
|
|
const onValidFreq = acceptedFreqs.length
|
|
? acceptedFreqs.includes(activeNorm)
|
|
: frequencies.value.active === expectedFreq
|
|
if (expectedFreq && !onValidFreq) {
|
|
const callsign = (vars as any).value?.callsign ?? ''
|
|
const freqName = (currentState.value as any)?.frequency_name as string | undefined
|
|
// Tell the pilot exactly which frequency to switch to (and the position),
|
|
// since tuning is manual and the call won't go through until they do.
|
|
const target = freqName ? `${freqName} on ${expectedFreq}` : expectedFreq
|
|
const reply = callsign
|
|
? `${callsign}, you are on the wrong frequency. Contact ${target}.`
|
|
: `Station calling, wrong frequency. Contact ${target}.`
|
|
pmLog.warn('WRONG FREQUENCY', { active: frequencies.value.active, expected: expectedFreq })
|
|
lastControllerSay.value = reply
|
|
scheduleControllerSpeech(reply)
|
|
// Add the ATC "wrong frequency" reply to the communication log.
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', {
|
|
offSchema: true,
|
|
frequency: frequencies.value.active,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!backendSessionId.value) {
|
|
pmLog.error('TRANSMIT BLOCKED — no backend session', { transcript, source })
|
|
console.error('No backend session — cannot transmit')
|
|
setLastTransmission(`${prefix}: ${transcript} (no session)`)
|
|
return
|
|
}
|
|
|
|
pmLog.group(`▶ TRANSMIT [${source}] session=${backendSessionId.value.slice(0,8)}`, () => {
|
|
pmLog.info('utterance :', transcript)
|
|
pmLog.info('local state :', currentState.value?.id ?? '—')
|
|
pmLog.info('session_id :', backendSessionId.value)
|
|
})
|
|
|
|
transmitInFlightCount.value++
|
|
try {
|
|
// The pilot spoke — a pending silence auto-advance no longer applies, and
|
|
// a live transmission means the loop-guard's job is done for this state.
|
|
// The response re-arms it (fresh count) if the next state also allows silence.
|
|
clearSilenceTimer()
|
|
resetSilenceFireCount()
|
|
const response = await radioBackend.transmit(backendSessionId.value, transcript)
|
|
|
|
// Drop a stale reply if the pilot already transmitted again while this call
|
|
// was in flight — only the newest transmission's result is applied (#16).
|
|
if (myGen !== transmitGeneration) {
|
|
pmLog.info('Discarded stale transmission reply (superseded by a newer transmission)')
|
|
return
|
|
}
|
|
|
|
pmLog.group(`✓ RESPONSE ${currentState.value?.id ?? '?'} → ${response.next_state_id}`, () => {
|
|
pmLog.info('next_state :', response.next_state_id)
|
|
pmLog.info('auto_advanced :', response.auto_advanced_states ?? [])
|
|
pmLog.info('fallback_used :', response.fallback_used, response.fallback_reason ?? '')
|
|
pmLog.info('say_template :', response.controller_say_template ?? '—')
|
|
pmLog.info('say_rendered :', response.controller_say_rendered ?? '—')
|
|
pmLog.info('expected_pilot :', response.expected_pilot_template ?? '—')
|
|
pmLog.info('variables :', response.variables)
|
|
pmLog.info('flags :', response.flags)
|
|
if (response.trace?.length) {
|
|
pmLog.debug('trace:')
|
|
response.trace.forEach(t => pmLog.debug(` [${t.type}]`, t.message))
|
|
}
|
|
})
|
|
|
|
applyBackendDecision(response, { transcript })
|
|
} catch (e: any) {
|
|
const status = e?.status ?? e?.response?.status
|
|
if (status === 404) {
|
|
// Session expired (idle timeout) or backend restarted without the
|
|
// persisted session — reset to scenario selection for a clean restart.
|
|
pmLog.error('SESSION GONE (404) — resetting to scenario selection', { session: backendSessionId.value })
|
|
backendSessionId.value = null
|
|
error.value = 'Session expired — please start the scenario again.'
|
|
currentScreen.value = 'scenario'
|
|
return
|
|
}
|
|
const isTimeout =
|
|
e?.name === 'TimeoutError'
|
|
|| e?.name === 'AbortError'
|
|
|| e?.cause?.name === 'TimeoutError'
|
|
|| e?.cause?.name === 'AbortError'
|
|
pmLog.error(isTimeout ? 'TRANSMIT TIMED OUT' : 'TRANSMIT FAILED', { transcript, session: backendSessionId.value, error: e })
|
|
console.error('Backend transmission failed', e)
|
|
setLastTransmission(`${prefix}: ${transcript} (${isTimeout ? 'backend timeout' : 'backend failed'})`)
|
|
} finally {
|
|
transmitInFlightCount.value = Math.max(0, transmitInFlightCount.value - 1)
|
|
}
|
|
}
|
|
|
|
// VATSIM Integration
|
|
const loadFlightPlans = async () => {
|
|
if (!vatsimId.value) return
|
|
|
|
loading.value = true
|
|
error.value = ''
|
|
|
|
try {
|
|
const response = await api.get('/api/vatsim/flightplans', {
|
|
query: { cid: vatsimId.value }
|
|
})
|
|
|
|
if (Array.isArray(response) && response.length > 0) {
|
|
flightPlans.value = response.slice(0, 10)
|
|
currentScreen.value = 'flightselect'
|
|
} else {
|
|
error.value = 'No flight plans found for this VATSIM ID'
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading flight plans:', err)
|
|
error.value = 'Error loading flight plans. Please check the VATSIM ID.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const startMonitoring = async (flightPlan: any, scenario: Scenario) => {
|
|
activeScenario.value = scenario
|
|
sessionStarting.value = true
|
|
sessionStartingMessage.value = /^taxi/.test(scenario.startFlow)
|
|
? 'Calculating taxi route…'
|
|
: 'Starting session…'
|
|
|
|
// 1. Ensure the local tree is loaded from the Python backend (same source as session)
|
|
try {
|
|
if (!engineReady.value) {
|
|
await fetchRuntimeTree(scenario.startFlow, config.public.radioBackendUrl as string)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to prepare decision engine', err)
|
|
error.value = 'Entscheidungsbaum konnte nicht geladen werden.'
|
|
sessionStarting.value = false
|
|
return
|
|
}
|
|
|
|
// 2. Initialize the local flight engine first so generated values (stand, SID, squawk)
|
|
// are computed and available in vars.value before we create the backend session.
|
|
initializeFlight(flightPlan)
|
|
|
|
// 3a. Resolve which airport to use for frequencies.
|
|
// Departure scenarios use the dep airport; arrival scenarios use arr.
|
|
const scenarioIcao: string | undefined =
|
|
scenario.airport === 'arr'
|
|
? (flightPlan.arr || flightPlan.arrival || flightPlan.dep || flightPlan.departure)
|
|
: (flightPlan.dep || flightPlan.departure || flightPlan.arr || flightPlan.arrival)
|
|
activeAirportIcao.value = scenarioIcao
|
|
|
|
// Fetch real airport frequencies BEFORE building backendVariables so that
|
|
// all freq vars are resolved from live VATSIM/OpenAIP data.
|
|
await fetchAirportFrequencies(scenarioIcao)
|
|
// The other end of the flight, so its stations can be dialled ahead of time.
|
|
// Best-effort and not awaited on the critical path: the flight is entirely
|
|
// flyable without them.
|
|
const otherIcao = scenario.airport === 'arr'
|
|
? (flightPlan.dep || flightPlan.departure)
|
|
: (flightPlan.arr || flightPlan.arrival)
|
|
void fetchDestinationFrequencies(otherIcao)
|
|
|
|
// Runway and information letter from the resolved ATIS (the flight plan
|
|
// carries neither). The report always has a letter, and has a runway
|
|
// whenever the airport publishes runway data — so the pilot's initial call
|
|
// matches the broadcast they just listened to.
|
|
const atisKind = scenario.airport === 'arr' ? 'arr' : 'dep'
|
|
const atisRunway = runwayInUse(atisKind)
|
|
const atisLetter = informationLetter(atisKind)
|
|
const atisPatch: Record<string, string | number> = {}
|
|
if (atisRunway) atisPatch.runway = atisRunway
|
|
if (atisLetter) atisPatch.atis_code = atisLetter
|
|
// Quote the observed pressure and wind rather than the engine's placeholders,
|
|
// so the controller does not contradict the ATIS the pilot just heard.
|
|
if (atisReport.value?.qnhHpa) atisPatch.qnh_hpa = atisReport.value.qnhHpa
|
|
if (atisReport.value?.surfaceWind) atisPatch.surface_wind = atisReport.value.surfaceWind
|
|
if (Object.keys(atisPatch).length) {
|
|
patchVariables(atisPatch)
|
|
pmLog.info('From ATIS report:', { ...atisPatch, source: atisReport.value?.source })
|
|
}
|
|
|
|
// Pre-generate the ATIS TTS audio now so the first tune-in plays instantly
|
|
// instead of waiting for generation. Fire-and-forget.
|
|
prefetchAtisAudio()
|
|
|
|
// 3b. Build the backend variable payload. The backend stores ALL keys so that
|
|
// frequencies declared in downstream chained flows (tower_freq for taxi-v1,
|
|
// departure_freq for tower-v1, etc.) are already populated with real airport
|
|
// values when the session advances to those flows.
|
|
const v = (vars as any).value
|
|
const backendVariables = buildBackendVariables({ flightPlan, vars: v, atisRunway })
|
|
|
|
// Special scenarios. Only sent when switched on: the backend treats the
|
|
// variable's absence as "leave it to chance", and sending false would pin
|
|
// the roll off rather than leaving it alone.
|
|
if (forceRto.value) backendVariables.force_rto = true
|
|
if (forceGoAround.value) backendVariables.force_go_around = true
|
|
|
|
// VFR flights identify by aircraft registration, not an airline callsign.
|
|
// Assign a German D-registration and its abbreviated form (D-EMIL -> D-IL,
|
|
// first letter + last two), which ATC uses after the first call. Mirror it
|
|
// into the local engine vars and HUD so the display matches the radio.
|
|
// The class letter has to match the aircraft — D-E is single-engine
|
|
// pistons, so an A320 gets a D-A registration, not "D-ETMO".
|
|
if (scenario.startFlow.startsWith('vfr') || scenario.startFlow.startsWith('info')) {
|
|
const { registration, short } = generateGermanRegistration(backendVariables.aircraft_type)
|
|
backendVariables.callsign = registration
|
|
backendVariables.callsign_short = short
|
|
patchVariables({ callsign: registration, callsign_short: short })
|
|
} else {
|
|
backendVariables.callsign_short = backendVariables.callsign
|
|
}
|
|
pmLog.info('backend variables payload:', backendVariables)
|
|
|
|
// 4. Create a backend session with the flight-plan-derived variables
|
|
try {
|
|
error.value = ''
|
|
const session = await radioBackend.createSession(
|
|
scenario.startFlow,
|
|
backendVariables,
|
|
scenario.noChain,
|
|
scenarioIcao,
|
|
v.dest || flightPlan.arr || flightPlan.arrival,
|
|
// Live aircraft position (bridge connected): backend starts the taxi
|
|
// route at the real parking position instead of a random stand.
|
|
bridgeConnected.value ? bridgePosition.value : null,
|
|
)
|
|
backendSessionId.value = session.session_id
|
|
pmLog.group(`SESSION CREATED id=${session.session_id.slice(0, 8)}`, () => {
|
|
pmLog.info('flow :', session.flow_slug)
|
|
pmLog.info('start state :', session.current_state)
|
|
pmLog.info('variables :', session.variables)
|
|
pmLog.info('flags :', session.flags)
|
|
})
|
|
// Sync cursor to wherever the backend initialised (usually start_state)
|
|
moveToSilent(session.current_state)
|
|
pmLog.debug('moveToSilent ← session start_state:', session.current_state)
|
|
// Sync session variables back so the local renderer stays in step.
|
|
if (session.variables && Object.keys(session.variables).length) {
|
|
patchVariables(session.variables)
|
|
}
|
|
// Sync boolean routing flags from session
|
|
if (session.flags && Object.keys(session.flags).length) {
|
|
patchFlags(session.flags)
|
|
}
|
|
// Seed the expected pilot phrase from the start state
|
|
backendExpectedPhrase.value = session.expected_pilot_template ?? null
|
|
pmLog.info('expected_pilot (session start):', backendExpectedPhrase.value ?? '—')
|
|
} catch (err) {
|
|
pmLog.error('SESSION CREATE FAILED', err)
|
|
console.error('Failed to create backend session', err)
|
|
error.value = 'Verbindung zum Training-Backend fehlgeschlagen.'
|
|
return
|
|
} finally {
|
|
// The slow part (route computation) is done once createSession resolves.
|
|
sessionStarting.value = false
|
|
}
|
|
|
|
error.value = ''
|
|
selectedPlan.value = flightPlan
|
|
currentScreen.value = 'monitor'
|
|
persistSelectedPlan(flightPlan)
|
|
maybeShowFirstRunHelp()
|
|
|
|
// Start from a known baseline; the first controller's frequency is tuned in
|
|
// automatically below, once the initial state-walk tells us which pilot state
|
|
// the scenario opens on.
|
|
frequencies.value.active = '121.900'
|
|
frequencies.value.standby = '118.100'
|
|
|
|
// 4. Walk the initial ATC/system states locally (deterministic, no LLM).
|
|
// Safe because we loaded the tree from the same Python backend, so the
|
|
// walk is identical to what the backend will do on the first transmission.
|
|
try {
|
|
await nextTick()
|
|
const startMessages = collectAtcStatesUntilPilotTurn()
|
|
for (const msg of startMessages) {
|
|
scheduleControllerSpeech(msg.say_tpl)
|
|
}
|
|
} catch (err) {
|
|
console.warn('Initial state advance failed:', err)
|
|
}
|
|
|
|
// Pre-tune COM1 active to the frequency the opening pilot call is expected on,
|
|
// so the very first transmission isn't met with a confusing 'wrong frequency'
|
|
// rejection. Later handoffs stay manual — tuning to the next controller
|
|
// remains part of the exercise.
|
|
await nextTick()
|
|
const firstFreq = expectedFrequencyForState()
|
|
if (firstFreq) {
|
|
frequencies.value.active = firstFreq
|
|
pmLog.info('Pre-tuned COM1 active to first frequency:', firstFreq)
|
|
}
|
|
}
|
|
|
|
const startDemoFlight = () => {
|
|
const demoFlight = {
|
|
callsign: 'DLH39A',
|
|
aircraft: 'A320/L',
|
|
dep: 'EDDF',
|
|
arr: 'EDDM',
|
|
altitude: '36000',
|
|
}
|
|
selectedPlan.value = demoFlight
|
|
currentScreen.value = 'scenario'
|
|
}
|
|
|
|
/** Start from a manually entered flight instead of VATSIM / demo. */
|
|
const startManualFlight = (plan: {
|
|
callsign: string
|
|
dep: string
|
|
arr: string
|
|
aircraft?: string
|
|
altitude?: string
|
|
}) => {
|
|
selectedPlan.value = {
|
|
callsign: plan.callsign.trim().toUpperCase(),
|
|
aircraft: plan.aircraft?.trim().toUpperCase() || 'A320/L',
|
|
dep: plan.dep.trim().toUpperCase(),
|
|
arr: plan.arr.trim().toUpperCase(),
|
|
altitude: plan.altitude?.trim() || '36000',
|
|
}
|
|
currentScreen.value = 'scenario'
|
|
}
|
|
|
|
/** Launch a specific scenario with the current flight plan. */
|
|
const launchScenario = async (scenario: Scenario) => {
|
|
if (!selectedPlan.value) return
|
|
await startMonitoring(selectedPlan.value, scenario)
|
|
}
|
|
|
|
/** Re-fly the same scenario that was just completed. */
|
|
const flyAgain = async () => {
|
|
if (!completedScenario.value || !selectedPlan.value) {
|
|
currentScreen.value = 'scenario'
|
|
return
|
|
}
|
|
await startMonitoring(selectedPlan.value, completedScenario.value)
|
|
}
|
|
|
|
const backToSetup = () => {
|
|
// Leaving the flight: a pending frequency change belongs to a session that
|
|
// no longer exists.
|
|
clearSilenceTimer()
|
|
clearAutoTune()
|
|
currentScreen.value = 'login'
|
|
selectedPlan.value = null
|
|
persistSelectedPlan(null)
|
|
clearLastTransmission()
|
|
airportFrequencies.value = []
|
|
frequencySources.value = { vatsim: false, openaip: false }
|
|
}
|
|
|
|
const sendPilotText = async () => {
|
|
const text = pilotInput.value.trim()
|
|
if (!text) return
|
|
|
|
pilotInput.value = ''
|
|
await handlePilotTransmission(text, 'text')
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
clearSilenceTimer()
|
|
clearAutoTune()
|
|
})
|
|
|
|
return {
|
|
clearSilenceTimer,
|
|
transmitInFlight,
|
|
applyBackendDecision,
|
|
handlePilotTransmission,
|
|
handleSimControlResult,
|
|
loadFlightPlans,
|
|
startMonitoring,
|
|
startDemoFlight,
|
|
startManualFlight,
|
|
launchScenario,
|
|
flyAgain,
|
|
backToSetup,
|
|
sendPilotText,
|
|
}
|
|
}
|