mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-02 14:56:16 +08:00
The radio only ever held one airport's frequencies, loaded once at the start, so on a departure the destination's Tower never appeared however close you got — there was no way to dial ahead. Both ends of the flight are now listed: the field being flown first and unlabelled, the far end after it, tagged with its ICAO and drawn with a dashed border so it reads as somewhere else. The destination's stations are kept in their own list rather than merged into the primary one, because everything that resolves *which frequency this phase expects* — the frequency map, the expected frequency, the wrong-frequency gate, the ATIS wiring — reads that list, and folding a second airport's Tower into it would let the gate accept the wrong field's frequency. Out-of-range stations are dimmed rather than removed: VHF is line-of-sight, so a station unreachable on the ground is workable from the cruise, and it should come back as you climb. The distances come from the backend, which already derives them and now returns them — the browser does not need its own copy of the airport coordinates for this. With no bridge connected, nothing is dimmed at all. That is the normal case for someone practising without a simulator, and a station that cannot be *proven* out of range must not be taken away.
972 lines
43 KiB
TypeScript
972 lines
43 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 {
|
|
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 four sources
|
|
// (transmit reply, telemetry tick, silence timeout, bug-report restore) 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: Record<string, any> = {
|
|
callsign: v.callsign || flightPlan.callsign || 'UNKNOWN',
|
|
information: v.atis_code || 'K',
|
|
destination: v.dest || flightPlan.arr || flightPlan.arrival || 'Unknown',
|
|
stand: v.stand || 'A1',
|
|
sid: v.sid || 'UNKNOWN1A',
|
|
initial_altitude: String(v.initial_altitude_ft ?? 5000),
|
|
squawk: String(v.squawk ?? '2000'),
|
|
// Shared / arrival variables. The engine generates these, but they were not
|
|
// being forwarded — so arrival flows (and taxi/tower on departure) fell back
|
|
// to YAML defaults and ignored the selected flight. Names are mapped to the
|
|
// backend flow conventions (qnh_hpa→qnh, acf_type→aircraft_type, …).
|
|
runway: atisRunway || v.runway || '25R',
|
|
qnh: String(v.qnh_hpa ?? '1013'),
|
|
surface_wind: v.surface_wind || '250/08',
|
|
// taxi_route is intentionally NOT sent: the backend computes the real OSM
|
|
// taxi route (and crossings) from airport_icao + stand/runway, and falls
|
|
// back to the flow's YAML default on its own. Sending a placeholder here
|
|
// would count as a caller override and suppress that computation.
|
|
aircraft_type: v.acf_type || 'A320',
|
|
cruise_level: v.cruise_flight_level || 'FL360',
|
|
assigned_squawk: String(v.squawk ?? '2000'),
|
|
// All airport frequencies — available to every flow in the chain.
|
|
delivery_freq: v.delivery_freq || '121.950',
|
|
ground_freq: v.ground_freq || '121.800',
|
|
tower_freq: v.tower_freq || '118.700',
|
|
departure_freq: v.departure_freq || '120.000',
|
|
approach_freq: v.approach_freq || '119.000',
|
|
handoff_freq: v.handoff_freq || '131.150',
|
|
}
|
|
|
|
// 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')
|
|
}
|
|
|
|
/**
|
|
* Restore a /live-atc session from a saved bug-report snapshot (admin link
|
|
* `/live-atc?restoreBugReport=<id>`). The Python backend has no "resume mid-session"
|
|
* endpoint, so we recreate a real, working session for the SAME flight and
|
|
* scenario via startMonitoring(), then overlay the saved variables/flags and
|
|
* the captured conversation so the admin can reproduce and try out the bug.
|
|
*/
|
|
async function restoreBugReportState(restoreId: string) {
|
|
try {
|
|
const report = await api.get<any>(`/api/admin/bug-reports/${restoreId}`)
|
|
const state = report?.pmState
|
|
if (!state) {
|
|
error.value = 'Bug-Report enthält keinen gespeicherten State.'
|
|
return
|
|
}
|
|
|
|
// Locate the scenario the report was captured in.
|
|
const scenario =
|
|
SCENARIOS.find(s => s.id === state.scenarioId) ||
|
|
SCENARIOS.find(s => s.startFlow === state.flowSlug)
|
|
if (!scenario) {
|
|
error.value = `Bug-Report-Restore: Szenario "${state.scenarioId || state.flowSlug || '?'}" nicht gefunden.`
|
|
return
|
|
}
|
|
|
|
// Reconstruct a flight plan from the snapshot so startMonitoring resolves the
|
|
// correct airport/frequencies and creates a backend session for the same flight.
|
|
const v = state.variables || {}
|
|
const fc = state.flightContext || {}
|
|
const dep = v.dep || fc.dep
|
|
const dest = v.dest || fc.dest
|
|
const flightPlan: Record<string, any> = {
|
|
callsign: v.callsign || fc.callsign || 'UNKNOWN',
|
|
aircraft: v.acf_type || fc.acf_type || 'A320',
|
|
dep,
|
|
departure: dep,
|
|
arr: dest,
|
|
arrival: dest,
|
|
route: fc.route || v.route || '',
|
|
assignedsquawk: v.squawk,
|
|
}
|
|
|
|
// Spin up a real session (loads tree, fetches frequencies, creates backend session).
|
|
await startMonitoring(flightPlan, scenario)
|
|
// startMonitoring bails out on error without entering the monitor screen.
|
|
if (currentScreen.value !== 'monitor') return
|
|
|
|
// Overlay the exact saved values over the freshly generated ones (stand, SID, …).
|
|
if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables)
|
|
if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags)
|
|
|
|
// Restore the captured conversation for context.
|
|
clearCommunicationLog?.()
|
|
if (Array.isArray(state.communicationLog)) {
|
|
for (const e of state.communicationLog) {
|
|
if (!e?.message) continue
|
|
appendLogEntry(e.speaker || 'system', e.message, e.state || '', {
|
|
frequency: e.frequency,
|
|
flow: e.flow,
|
|
radioCheck: e.radioCheck,
|
|
offSchema: e.offSchema,
|
|
})
|
|
}
|
|
}
|
|
|
|
// A fresh backend session always starts at the flow's start state, so we
|
|
// can't fake the local cursor onto the captured mid-flow state without
|
|
// desyncing transmits. Tell the admin where the bug was captured instead.
|
|
error.value =
|
|
`Bug-Report wiederhergestellt: ${scenario.name} · ${flightPlan.callsign} (${dep || '?'}→${dest || '?'}). ` +
|
|
`Erfasster State: ${state.currentStateId || '?'} (Flow ${state.flowSlug || '?'}).`
|
|
} catch (err) {
|
|
console.warn('[PM] Bug report restore failed', err)
|
|
error.value = 'Bug-Report konnte nicht wiederhergestellt werden.'
|
|
}
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
clearSilenceTimer()
|
|
clearAutoTune()
|
|
})
|
|
|
|
return {
|
|
clearSilenceTimer,
|
|
transmitInFlight,
|
|
applyBackendDecision,
|
|
handlePilotTransmission,
|
|
handleSimControlResult,
|
|
loadFlightPlans,
|
|
startMonitoring,
|
|
startDemoFlight,
|
|
startManualFlight,
|
|
launchScenario,
|
|
flyAgain,
|
|
backToSetup,
|
|
sendPilotText,
|
|
restoreBugReportState,
|
|
}
|
|
}
|