From 422035dff77362e45aaaf4ec8a6924096e6450b2 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:02:45 +0200 Subject: [PATCH] Add ATIS quick actions and airport frequency data --- app/pages/pm.vue | 402 +++++++++++++++++- nuxt.config.ts | 1 + server/api/airports/[icao]/frequencies.get.ts | 238 +++++++++++ server/utils/runtimeConfig.ts | 2 + shared/data/atcDecisionTree.ts | 1 + shared/utils/communicationsEngine.ts | 30 ++ 6 files changed, 671 insertions(+), 3 deletions(-) create mode 100644 server/api/airports/[icao]/frequencies.get.ts diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 424204d..4ea0dfc 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -356,7 +356,7 @@

Quick Actions

-
+
Radio Check + + ATIS abhören + + + + +
+

Frequenzübersicht

+
+ + +
+
+ + + + + Frequenzen für {{ flightContext.dep || 'Abflug' }} + + +
+ + Lade Frequenzen vom Netzwerk… +
+
+ Keine Frequenzen verfügbar. Bitte später erneut versuchen. +
+
+
+
+
+
+ {{ freq.type }} + {{ freq.label }} +
+
+ {{ freq.callsign }} +
+
+ {{ freq.source === 'vatsim' ? 'VATSIM' : 'OpenAIP' }} +
+
+
+
{{ freq.frequency }}
+
+ + + Active + + + + Standby + +
+
+
+
+ + {{ freq.source.toUpperCase() }} + + + ATIS {{ freq.atisCode }} + +
+
+
+
+
+
+
+
+ @@ -784,6 +912,7 @@ const { flightContext, currentStep, initializeFlight, + updateFrequencyVariables, processPilotTransmission, buildLLMContext, applyLLMDecision, @@ -875,6 +1004,11 @@ const frequencies = ref({ standby: '118.100' }) +const airportFrequencies = ref([]) +const airportFrequencyLoading = ref(false) +const frequencySources = ref({ vatsim: false, openaip: false }) +const atisPlaybackLoading = ref(false) + onMounted(async () => { if (!auth.accessToken) { const refreshed = await auth.tryRefresh() @@ -922,6 +1056,15 @@ const radioQuality = computed(() => { const completedPilotSteps = computed(() => simulationTrace.value.filter(entry => entry.kind === 'pilot').length) +const atisFrequencyEntry = computed(() => airportFrequencies.value.find(entry => entry.type === 'ATIS')) + +const frequencySourceLabels = computed(() => { + const labels: string[] = [] + if (frequencySources.value.vatsim) labels.push('VATSIM') + if (frequencySources.value.openaip) labels.push('OpenAIP') + return labels +}) + type PreparedSpeech = { template: string plain: string @@ -935,6 +1078,8 @@ type SpeechOptions = { lastTransmissionLabel?: string delayMs?: number useNormalizedForTTS?: boolean + speed?: number + lessonId?: string } type SimulationDecisionTemplate = { @@ -952,6 +1097,19 @@ type SimulationTraceEntry = { payload?: any } +type AirportFrequencyEntry = { + type: string + label: string + frequency: string + source: 'vatsim' | 'openaip' + callsign?: string + atisCode?: string + atisText?: string + lastUpdated?: string +} + +type FrequencyVariableUpdate = Partial> + const simulationPilotSteps = [ 'CD_CHECK_ATIS', 'CD_VERIFY_READBACK', @@ -1259,6 +1417,39 @@ const speakWithRadioEffects = (tpl: string, options: SpeechOptions = {}) => { }) } +const speakPlainText = (text: string, options: SpeechOptions = {}) => { + const trimmed = text.trim() + if (!trimmed) { + return Promise.resolve() + } + + const speed = options.speed ?? 0.95 + const lessonId = options.lessonId || currentState.value?.id || 'general' + + return enqueueSpeech(async () => { + try { + const response = await api.post('/api/atc/say', { + text: trimmed, + level: signalStrength.value, + voice: options.voice || 'alloy', + speed, + moduleId: 'pilot-monitoring', + lessonId, + tag: options.tag || 'announcement' + }) + + if (response.success && response.audio) { + if (options.updateLastTransmission !== false) { + setLastTransmission(options.lastTransmissionLabel || trimmed) + } + await playAudioWithEffects(response.audio.base64) + } + } catch (err) { + console.error('TTS failed:', err) + } + }) +} + const scheduleControllerSpeech = (tpl: string) => { const plain = renderATCMessage(tpl) speakWithRadioEffects(tpl, { @@ -1340,7 +1531,7 @@ const loadFlightPlans = async () => { } } -const startMonitoring = (flightPlan: any) => { +const startMonitoring = async (flightPlan: any) => { selectedPlan.value = flightPlan initializeFlight(flightPlan) currentScreen.value = 'monitor' @@ -1350,6 +1541,8 @@ const startMonitoring = (flightPlan: any) => { frequencies.value.active = '121.900' // Frankfurt Delivery frequencies.value.standby = '121.700' // Frankfurt Ground } + + await fetchAirportFrequencies(flightPlan.dep || flightPlan.departure) } const startDemoFlight = () => { @@ -1361,13 +1554,15 @@ const startDemoFlight = () => { altitude: '36000', assignedsquawk: '1234' } - startMonitoring(demoFlight) + void startMonitoring(demoFlight) } const backToSetup = () => { currentScreen.value = 'login' selectedPlan.value = null clearLastTransmission() + airportFrequencies.value = [] + frequencySources.value = { vatsim: false, openaip: false } } // Audio/PTT Functions @@ -1498,6 +1693,207 @@ const sendPilotText = async () => { await handlePilotTransmission(text, 'text') } +const frequencyTypeMap: Record = { + ATIS: 'atis_freq', + DEL: 'delivery_freq', + CLD: 'delivery_freq', + GND: 'ground_freq', + TWR: 'tower_freq', + DEP: 'departure_freq', + APP: 'approach_freq', + CTR: 'handoff_freq', + ACC: 'handoff_freq', + FSS: 'handoff_freq' +} + +const toFrequencyVariableUpdate = (entry: AirportFrequencyEntry): FrequencyVariableUpdate | null => { + if (!entry?.frequency) { + return null + } + + const targetKey = frequencyTypeMap[entry.type] + if (!targetKey) { + return null + } + + return { [targetKey]: entry.frequency } as FrequencyVariableUpdate +} + +const updateEngineFrequencyFromEntry = (entry: AirportFrequencyEntry) => { + const update = toFrequencyVariableUpdate(entry) + if (!update) { + return + } + + updateFrequencyVariables(update) +} + +const syncLocalFrequenciesWithEngine = (updates: FrequencyVariableUpdate) => { + const currentUnit = flags.value.current_unit + if (currentUnit === 'DEL' && updates.delivery_freq) { + frequencies.value.active = updates.delivery_freq + } else if (currentUnit === 'GROUND' && updates.ground_freq) { + frequencies.value.active = updates.ground_freq + } else if (currentUnit === 'TOWER' && updates.tower_freq) { + frequencies.value.active = updates.tower_freq + } else if (currentUnit === 'DEP' && updates.departure_freq) { + frequencies.value.active = updates.departure_freq + } else if (currentUnit === 'APP' && updates.approach_freq) { + frequencies.value.active = updates.approach_freq + } else if (currentUnit === 'CTR' && updates.handoff_freq) { + frequencies.value.active = updates.handoff_freq + } + + if (updates.ground_freq) { + frequencies.value.standby = updates.ground_freq + } +} + +const applyFrequencyVariablesFromList = (list: AirportFrequencyEntry[]) => { + if (!Array.isArray(list) || list.length === 0) { + return + } + + const prioritized = [...list].sort((a, b) => { + if (a.source === b.source) return 0 + return a.source === 'vatsim' ? -1 : 1 + }) + + const updates: FrequencyVariableUpdate = {} + + for (const entry of prioritized) { + const targetKey = frequencyTypeMap[entry.type] + if (!targetKey) continue + if (updates[targetKey]) continue + if (!entry.frequency) continue + updates[targetKey] = entry.frequency + } + + if (Object.keys(updates).length > 0) { + updateFrequencyVariables(updates) + syncLocalFrequenciesWithEngine(updates) + } +} + +const fetchAirportFrequencies = async (icao: string | undefined) => { + if (!icao) return + + airportFrequencyLoading.value = true + airportFrequencies.value = [] + frequencySources.value = { vatsim: false, openaip: false } + + try { + const response = await api.get(`/api/airports/${encodeURIComponent(icao)}/frequencies`) + const entries = Array.isArray(response?.frequencies) ? response.frequencies as AirportFrequencyEntry[] : [] + airportFrequencies.value = entries + frequencySources.value = { + vatsim: Boolean(response?.sources?.vatsim), + openaip: Boolean(response?.sources?.openaip) + } + + applyFrequencyVariablesFromList(entries) + } catch (err) { + console.error('Failed to load airport frequencies:', err) + airportFrequencies.value = [] + frequencySources.value = { vatsim: false, openaip: false } + } finally { + airportFrequencyLoading.value = false + } +} + +const setActiveFrequencyFromList = (entry: AirportFrequencyEntry) => { + if (!entry?.frequency) return + if (frequencies.value.active !== entry.frequency) { + frequencies.value.standby = frequencies.value.active + frequencies.value.active = entry.frequency + } + + updateEngineFrequencyFromEntry(entry) +} + +const setStandbyFrequencyFromList = (entry: AirportFrequencyEntry) => { + if (!entry?.frequency) return + frequencies.value.standby = entry.frequency + + updateEngineFrequencyFromEntry(entry) +} + +const fetchMetarText = async (icao: string | undefined): Promise => { + if (!icao) return null + + try { + const response = await api.get('/api/vatsim/metar', { query: { id: icao } }) + if (typeof response === 'string') { + const trimmed = response.trim() + return trimmed.length ? trimmed : null + } + } catch (err) { + console.error('Failed to fetch METAR:', err) + } + + return null +} + +const buildAtisAnnouncement = (entry: AirportFrequencyEntry, fallback?: string): string => { + const parts: string[] = [] + const location = flightContext.value.dep ? `${flightContext.value.dep} ATIS` : 'ATIS' + parts.push(location) + + if (entry.atisCode) { + parts.push(`Information ${entry.atisCode}`) + } + + if (entry.atisText) { + parts.push(entry.atisText) + } else if (fallback) { + parts.push(fallback) + } + + parts.push(`Frequency ${entry.frequency}`) + + return parts + .map(segment => segment.trim()) + .filter(Boolean) + .join('. ') + .replace(/\s+/g, ' ') +} + +const playAtisBroadcast = async () => { + const atisEntry = atisFrequencyEntry.value + if (!atisEntry) return + + setActiveFrequencyFromList(atisEntry) + atisPlaybackLoading.value = true + + try { + let content = atisEntry.atisText || '' + if (!content) { + const metar = await fetchMetarText(flightContext.value.dep) + if (metar) { + content = `METAR ${metar}` + } + } + + if (!content) { + setLastTransmission('ATIS: Keine aktuellen Informationen verfügbar') + return + } + + const announcement = buildAtisAnnouncement({ ...atisEntry, atisText: content }) + await speakPlainText(announcement, { + voice: 'verse', + speed: 0.9, + tag: 'atis-broadcast', + lessonId: 'atis', + lastTransmissionLabel: `ATIS: ${announcement}` + }) + } catch (err) { + console.error('ATIS playback failed:', err) + } finally { + atisPlaybackLoading.value = false + } +} + const performRadioCheck = async () => { if (!flightContext.value.callsign) return diff --git a/nuxt.config.ts b/nuxt.config.ts index 2149a3f..7ab64bd 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -19,6 +19,7 @@ export default defineNuxtConfig({ llmModel: process.env.LLM_MODEL || 'gpt-5-nano', ttsModel: process.env.TTS_MODEL || 'tts-1', defaultVoiceId: process.env.VOICE_ID || 'alloy', + openaipApiKey: process.env.OPENAIP_API_KEY, usePiper: process.env.USE_PIPER, piperPort: process.env.PIPER_PORT, useSpeaches: process.env.USE_SPEACHES, diff --git a/server/api/airports/[icao]/frequencies.get.ts b/server/api/airports/[icao]/frequencies.get.ts new file mode 100644 index 0000000..4e41cee --- /dev/null +++ b/server/api/airports/[icao]/frequencies.get.ts @@ -0,0 +1,238 @@ +import { createError, defineEventHandler, getRouterParam } from 'h3' +import { getServerRuntimeConfig } from '../../../utils/runtimeConfig' + +interface FrequencyEntry { + type: string + label: string + frequency: string + source: 'vatsim' | 'openaip' + callsign?: string + atisCode?: string + atisText?: string + lastUpdated?: string +} + +interface FrequencyResponse { + icao: string + sources: { + vatsim: boolean + openaip: boolean + } + frequencies: FrequencyEntry[] +} + +const TYPE_LABELS: Record = { + ATIS: 'ATIS', + DEL: 'Clearance Delivery', + CLD: 'Clearance Delivery', + GND: 'Ground', + TWR: 'Tower', + DEP: 'Departure', + APP: 'Approach', + CTR: 'Center', + ACC: 'Center', + FSS: 'Flight Service', + UNK: 'Unknown' +} + +const TYPE_ORDER = ['ATIS', 'DEL', 'CLD', 'GND', 'TWR', 'DEP', 'APP', 'CTR', 'ACC', 'FSS'] + +function toTypeLabel(rawType: string | undefined, fallback?: string): { type: string; label: string } { + const type = (rawType || 'UNK').toUpperCase() + const label = TYPE_LABELS[type] || fallback || type + return { type, label } +} + +function normalizeFrequency(value: unknown): string | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toFixed(3) + } + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + const numeric = Number.parseFloat(trimmed) + if (!Number.isNaN(numeric)) { + return numeric.toFixed(3) + } + return trimmed + } + return null +} + +function parseAtisText(value: unknown): string | undefined { + if (!value) return undefined + if (Array.isArray(value)) { + return value.map(v => String(v).trim()).filter(Boolean).join(' ') + } + if (typeof value === 'string') { + return value.trim() + } + return undefined +} + +function extractCallsignType(callsign: string, icao: string): string { + const normalized = callsign.toUpperCase() + const prefix = `${icao.toUpperCase()}_` + if (!normalized.startsWith(prefix)) { + return 'UNK' + } + const suffix = normalized.slice(prefix.length) + if (!suffix) return 'UNK' + const segments = suffix.split('_') + const last = segments[segments.length - 1] + return last || 'UNK' +} + +function addFrequencyEntry( + map: Map, + entry: FrequencyEntry +) { + const primaryKey = entry.callsign?.toUpperCase() || `${entry.type}|${entry.frequency}` + const fallbackKey = `${entry.type}|${entry.frequency}` + + if (map.has(primaryKey)) { + const existing = map.get(primaryKey)! + map.set(primaryKey, { ...existing, ...entry }) + return + } + + if (entry.callsign && map.has(fallbackKey)) { + const existing = map.get(fallbackKey)! + map.delete(fallbackKey) + map.set(primaryKey, { ...existing, ...entry }) + return + } + + if (!entry.callsign && map.has(fallbackKey)) { + const existing = map.get(fallbackKey)! + if (existing.source === 'openaip' && entry.source === 'vatsim') { + map.set(fallbackKey, { ...existing, ...entry }) + return + } + } + + map.set(primaryKey, entry) +} + +export default defineEventHandler(async (event): Promise => { + const icaoParam = getRouterParam(event, 'icao') + if (!icaoParam) { + throw createError({ statusCode: 400, statusMessage: 'icao required' }) + } + + const icao = icaoParam.toUpperCase() + const frequencyMap = new Map() + let vatsimSuccess = false + let openaipSuccess = false + + try { + const vatsimData: any = await $fetch('https://data.vatsim.net/v3/vatsim-data.json') + const prefix = `${icao}_` + + const atisEntries = Array.isArray(vatsimData?.atis) ? vatsimData.atis : [] + for (const atis of atisEntries) { + const callsign = String(atis?.callsign || '') + if (!callsign.toUpperCase().startsWith(prefix)) continue + const frequency = normalizeFrequency(atis?.frequency) + if (!frequency) continue + const { type, label } = toTypeLabel('ATIS', atis?.name) + addFrequencyEntry(frequencyMap, { + type, + label, + frequency, + source: 'vatsim', + callsign, + atisCode: atis?.atis_code || atis?.code, + atisText: parseAtisText(atis?.text_atis || atis?.atis_text), + lastUpdated: atis?.last_updated || atis?.logon_time + }) + } + + const controllerEntries = Array.isArray(vatsimData?.controllers) ? vatsimData.controllers : [] + for (const controller of controllerEntries) { + const callsign = String(controller?.callsign || '') + if (!callsign.toUpperCase().startsWith(prefix)) continue + const frequency = normalizeFrequency(controller?.frequency) + if (!frequency) continue + const typeCode = extractCallsignType(callsign, icao) + const { type, label } = toTypeLabel(typeCode, controller?.name) + addFrequencyEntry(frequencyMap, { + type, + label, + frequency, + source: 'vatsim', + callsign, + atisText: parseAtisText(controller?.text_atis), + lastUpdated: controller?.last_updated || controller?.logon_time + }) + } + + vatsimSuccess = true + } catch (err) { + console.warn('[OpenSquawk] Failed to fetch VATSIM frequencies:', err) + } + + const { openaipApiKey } = getServerRuntimeConfig() + if (openaipApiKey) { + try { + const openaipData: any = await $fetch('https://api.openaip.net/api/airports', { + query: { icao }, + headers: { + Accept: 'application/json', + 'x-openaip-api-key': openaipApiKey + } + }) + + const items = Array.isArray(openaipData?.items) ? openaipData.items : [] + for (const airport of items) { + if ((airport?.icao || '').toUpperCase() !== icao) continue + const freqCollections = [airport?.frequencies, airport?.radio, airport?.communication] + .filter(Array.isArray) as any[][] + + for (const collection of freqCollections) { + for (const freqItem of collection) { + const frequency = normalizeFrequency(freqItem?.frequency ?? freqItem?.frequencyMHz ?? freqItem?.frequency_mhz) + if (!frequency) continue + const { type, label } = toTypeLabel(freqItem?.type, freqItem?.description || freqItem?.name) + addFrequencyEntry(frequencyMap, { + type, + label, + frequency, + source: 'openaip' + }) + } + } + } + + openaipSuccess = true + } catch (err) { + console.warn('[OpenSquawk] Failed to fetch OpenAIP airport data:', err) + } + } + + const frequencies = Array.from(frequencyMap.values()).sort((a, b) => { + const orderA = TYPE_ORDER.indexOf(a.type) + const orderB = TYPE_ORDER.indexOf(b.type) + if (orderA !== orderB) { + const aScore = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA + const bScore = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB + return aScore - bScore + } + if (a.type !== b.type) { + return a.type.localeCompare(b.type) + } + if (a.frequency !== b.frequency) { + return a.frequency.localeCompare(b.frequency) + } + return (a.callsign || '').localeCompare(b.callsign || '') + }) + + return { + icao, + sources: { + vatsim: vatsimSuccess, + openaip: openaipSuccess + }, + frequencies + } +}) diff --git a/server/utils/runtimeConfig.ts b/server/utils/runtimeConfig.ts index c08644e..98b4d47 100644 --- a/server/utils/runtimeConfig.ts +++ b/server/utils/runtimeConfig.ts @@ -6,6 +6,7 @@ export interface ServerRuntimeConfig { llmModel: string ttsModel: string voiceId: string + openaipApiKey?: string usePiper: boolean piperPort: number useSpeaches: boolean @@ -67,6 +68,7 @@ export function getServerRuntimeConfig(): ServerRuntimeConfig { llmModel: String(runtimeConfig.llmModel || '').trim() || 'gpt-5-nano', ttsModel: String(runtimeConfig.ttsModel || '').trim() || 'tts-1', voiceId: String(runtimeConfig.defaultVoiceId || '').trim() || 'alloy', + openaipApiKey: String(runtimeConfig.openaipApiKey || '').trim() || undefined, usePiper: toBoolean(runtimeConfig.usePiper), piperPort: toNumber(runtimeConfig.piperPort, 5001), useSpeaches: toBoolean(runtimeConfig.useSpeaches), diff --git a/shared/data/atcDecisionTree.ts b/shared/data/atcDecisionTree.ts index 4b11711..4941556 100644 --- a/shared/data/atcDecisionTree.ts +++ b/shared/data/atcDecisionTree.ts @@ -27,6 +27,7 @@ const atcDecisionTree = { "departure_freq": "125.350", "approach_freq": "120.800", "handoff_freq": "121.800", + "atis_freq": "118.025", "atis_code": "K", "gate": "B24", "trans_level": "FL070", diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index 385b0c1..c1eb26b 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -93,6 +93,14 @@ export const COMMUNICATION_STEPS = [ // Weitere Steps können hier definiert werden ] +type FrequencyVariableKey = 'atis_freq' + | 'delivery_freq' + | 'ground_freq' + | 'tower_freq' + | 'departure_freq' + | 'approach_freq' + | 'handoff_freq' + // --- ATC Decision Tree laden --- export interface FlightContext { callsign: string @@ -111,6 +119,7 @@ export interface FlightContext { departure_freq: string approach_freq: string handoff_freq: string + atis_freq?: string qnh_hpa: number | string taxi_route: string remarks?: string @@ -216,6 +225,7 @@ export default function useCommunicationsEngine() { sid: 'ANEKI7S', transition: 'ANEKI', flight_level: 'FL360', + atis_freq: '118.025', ground_freq: '121.700', tower_freq: '118.700', departure_freq: '125.350', @@ -287,6 +297,7 @@ export default function useCommunicationsEngine() { initial_altitude_ft: 5000, climb_altitude_ft: 7000, taxi_route: 'A, V', + atis_freq: '118.025', delivery_freq: '121.900', ground_freq: '121.700', tower_freq: '118.700', @@ -322,6 +333,24 @@ export default function useCommunicationsEngine() { communicationLog.value = [] } + function updateFrequencyVariables(update: Partial>) { + if (!update) return + + const sanitizedEntries = Object.entries(update) + .filter(([, value]) => typeof value === 'string' && value.trim().length) + .map(([key, value]) => [key, value!.trim()]) as [FrequencyVariableKey, string][] + + if (!sanitizedEntries.length) { + return + } + + for (const [key, value] of sanitizedEntries) { + variables.value[key] = value + } + + Object.assign(flightContext.value, Object.fromEntries(sanitizedEntries)) + } + function buildLLMContext(pilotTranscript: string) { const s = currentState.value return { @@ -576,6 +605,7 @@ export default function useCommunicationsEngine() { // Lifecycle initializeFlight, + updateFrequencyVariables, // Communication processPilotTransmission,