mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
Add ATIS quick actions and airport frequency data
This commit is contained in:
402
app/pages/pm.vue
402
app/pages/pm.vue
@@ -356,7 +356,7 @@
|
||||
<v-card class="bg-white/5 border border-white/10">
|
||||
<v-card-text class="space-y-3">
|
||||
<h3 class="text-lg font-semibold">Quick Actions</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<v-btn
|
||||
color="orange"
|
||||
variant="flat"
|
||||
@@ -367,6 +367,17 @@
|
||||
>
|
||||
Radio Check
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="cyan"
|
||||
variant="tonal"
|
||||
@click="playAtisBroadcast"
|
||||
:loading="atisPlaybackLoading"
|
||||
:disabled="airportFrequencyLoading || !atisFrequencyEntry"
|
||||
prepend-icon="mdi-information-variant"
|
||||
density="comfortable"
|
||||
>
|
||||
ATIS abhören
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="cyan"
|
||||
variant="outlined"
|
||||
@@ -381,6 +392,123 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Frequency Directory -->
|
||||
<v-card class="bg-white/5 border border-white/10">
|
||||
<v-card-text class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-lg font-semibold">Frequenzübersicht</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<v-progress-circular
|
||||
v-if="airportFrequencyLoading"
|
||||
indeterminate
|
||||
color="cyan"
|
||||
size="18"
|
||||
/>
|
||||
<template v-else>
|
||||
<v-chip
|
||||
v-for="label in frequencySourceLabels"
|
||||
:key="label"
|
||||
size="x-small"
|
||||
color="cyan"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-expansion-panels variant="accordion" class="bg-transparent">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title class="text-sm text-white/70">
|
||||
Frequenzen für {{ flightContext.dep || 'Abflug' }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<div
|
||||
v-if="airportFrequencyLoading"
|
||||
class="flex items-center gap-2 text-sm text-white/60"
|
||||
>
|
||||
<v-progress-circular indeterminate size="20" color="cyan" />
|
||||
<span>Lade Frequenzen vom Netzwerk…</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="airportFrequencies.length === 0"
|
||||
class="text-xs text-white/50"
|
||||
>
|
||||
Keine Frequenzen verfügbar. Bitte später erneut versuchen.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="freq in airportFrequencies"
|
||||
:key="freq.callsign || `${freq.type}-${freq.frequency}-${freq.source}`"
|
||||
class="rounded-2xl border border-white/10 bg-black/40 p-3 space-y-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<v-chip size="x-small" color="cyan" variant="outlined">{{ freq.type }}</v-chip>
|
||||
<span class="text-sm font-semibold text-white">{{ freq.label }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/50 mt-1" v-if="freq.callsign">
|
||||
{{ freq.callsign }}
|
||||
</div>
|
||||
<div class="text-xs text-white/50 mt-1" v-else>
|
||||
{{ freq.source === 'vatsim' ? 'VATSIM' : 'OpenAIP' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-mono text-white">{{ freq.frequency }}</div>
|
||||
<div class="flex items-center justify-end gap-2 mt-2">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
color="cyan"
|
||||
variant="tonal"
|
||||
@click="setActiveFrequencyFromList(freq)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="16">mdi-crosshairs-gps</v-icon>
|
||||
</template>
|
||||
Active
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
color="orange"
|
||||
variant="text"
|
||||
@click="setStandbyFrequencyFromList(freq)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="16">mdi-timer-sand</v-icon>
|
||||
</template>
|
||||
Standby
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-[11px] text-white/40">
|
||||
<v-chip
|
||||
size="x-small"
|
||||
:color="freq.source === 'vatsim' ? 'green' : 'blue'"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ freq.source.toUpperCase() }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="freq.atisCode"
|
||||
size="x-small"
|
||||
color="orange"
|
||||
variant="tonal"
|
||||
>
|
||||
ATIS {{ freq.atisCode }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Manual Input Fallback -->
|
||||
<v-card class="bg-white/5 border border-white/10">
|
||||
<v-card-text class="space-y-3">
|
||||
@@ -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<AirportFrequencyEntry[]>([])
|
||||
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<Record<'atis_freq' | 'delivery_freq' | 'ground_freq' | 'tower_freq' | 'departure_freq' | 'approach_freq' | 'handoff_freq', string>>
|
||||
|
||||
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<string, keyof FrequencyVariableUpdate> = {
|
||||
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<string | null> => {
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
238
server/api/airports/[icao]/frequencies.get.ts
Normal file
238
server/api/airports/[icao]/frequencies.get.ts
Normal file
@@ -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<string, string> = {
|
||||
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<string, FrequencyEntry>,
|
||||
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<FrequencyResponse> => {
|
||||
const icaoParam = getRouterParam(event, 'icao')
|
||||
if (!icaoParam) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'icao required' })
|
||||
}
|
||||
|
||||
const icao = icaoParam.toUpperCase()
|
||||
const frequencyMap = new Map<string, FrequencyEntry>()
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Record<FrequencyVariableKey, string>>) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user