diff --git a/app/composables/useFrequencyPresets.ts b/app/composables/useFrequencyPresets.ts index d627486..2769104 100644 --- a/app/composables/useFrequencyPresets.ts +++ b/app/composables/useFrequencyPresets.ts @@ -2,6 +2,7 @@ import { ref, computed } from 'vue' import { useApi } from '~/composables/useApi' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { normalizeManualFreq } from '../../shared/utils/frequency' +import type { AtisReport, AtisStation } from '../../shared/utils/atisReport' export type AirportFrequencyEntry = { type: string @@ -154,49 +155,66 @@ export function useFrequencyPresets( return Array.from(new Set(all.map(normalizedFrequencyValue).filter(Boolean))) } + // The resolved ATIS for this airport (see server/api/airports/[icao]/atis.get.ts). + // Always carries an information letter, whether or not VATSIM has a station. + const atisReport = ref(null) + + /** The resolved broadcast belonging to a published ATIS frequency, if any. */ + const stationForEntry = (entry: AirportFrequencyEntry): AtisStation | undefined => { + const stations = atisReport.value?.stations ?? [] + if (!stations.length) return undefined + const byCallsign = entry.callsign + && stations.find(station => station.callsign?.toUpperCase() === entry.callsign!.toUpperCase()) + if (byCallsign) return byCallsign + const wanted = normalizedFrequencyValue(entry.frequency) + const byFrequency = stations.find(station => normalizedFrequencyValue(station.frequency) === wanted) + if (byFrequency) return byFrequency + // A single broadcast belongs to the airport's single ATIS frequency. + return stations.length === 1 ? stations[0] : undefined + } + // All ATIS stations at the airport. Large airports broadcast separate // Arrival and Departure ATIS on different frequencies (EDDF_A_ATIS / // EDDF_D_ATIS on VATSIM), each with its own info letter and text. - const atisEntries = computed(() => airportFrequencies.value.filter(entry => entry.type === 'ATIS')) + // + // The published frequency list only carries live text when a VATSIM + // controller is online, so the resolved report backfills the letter and the + // broadcast — that is what stops an ATIS from playing with no information + // letter and no runway. + const atisEntries = computed(() => + airportFrequencies.value + .filter(entry => entry.type === 'ATIS') + .map((entry) => { + const station = stationForEntry(entry) + if (!station) return entry + return { + ...entry, + atisCode: entry.atisCode || station.letter || atisReport.value?.letter || undefined, + atisText: entry.atisText || station.text || undefined, + lastUpdated: entry.lastUpdated || station.lastUpdated, + } + })) // Primary ATIS entry for the quick-play button — prefer one with live text. const atisFrequencyEntry = computed(() => atisEntries.value.find(entry => (entry.atisText || '').trim()) || atisEntries.value[0]) + /** True when the ATIS is synthesised rather than a live VATSIM broadcast. */ + const atisIsSynthetic = computed(() => + Boolean(atisReport.value) && atisReport.value!.source !== 'vatsim') + /** - * Extract the runway in use from live ATIS text ("DEP RWY 25C", "EXPECT ILS - * APPROACH RUNWAY 25L", "RUNWAY IN USE 07"). Prefers a phrase matching the - * requested kind (departure/arrival); falls back to any runway mention. - * Returns null when no ATIS text carries a runway — callers keep their default. + * Runway in use for the given kind, from the resolved report. Null only when + * the airport has no usable runway data at all — callers keep their default. */ - const extractAtisRunway = (kind: 'dep' | 'arr'): string | null => { - const RWY = String.raw`(?:RWY|RUNWAY)S?\s*([0-3]?\d\s*[LRC]?)\b` - const kindPatterns = kind === 'dep' - ? [new RegExp(String.raw`DEP(?:ARTURE)?S?[^.]{0,40}?${RWY}`, 'i')] - : [ - new RegExp(String.raw`(?:ARR(?:IVAL)?S?|LANDING)[^.]{0,40}?${RWY}`, 'i'), - new RegExp(String.raw`EXPECT[^.]{0,60}?APPROACH[^.]{0,20}?${RWY}`, 'i'), - ] - const genericPattern = new RegExp(RWY, 'i') + const runwayInUse = (kind: 'dep' | 'arr'): string | null => + (kind === 'dep' ? atisReport.value?.runwayDep : atisReport.value?.runwayArr) ?? null - const texts = atisEntries.value - .map(entry => (entry.atisText || '').trim()) - .filter(Boolean) - - for (const patterns of [kindPatterns, [genericPattern]]) { - for (const text of texts) { - for (const pattern of patterns) { - const match = pattern.exec(text) - if (match?.[1]) { - const designator = match[1].replace(/\s+/g, '').toUpperCase() - // Normalise "7L" -> "07L" so it matches OSM runway refs. - return /^\d[LRC]?$/.test(designator) ? `0${designator}` : designator - } - } - } - } - return null - } + /** Information letter for the given kind, or null before the report loads. */ + const informationLetter = (kind: 'dep' | 'arr'): string | null => + (kind === 'dep' ? atisReport.value?.letterDep : atisReport.value?.letterArr) + ?? atisReport.value?.letter + ?? null const frequencySourceLabels = computed(() => { const labels: string[] = [] @@ -356,9 +374,19 @@ export function useFrequencyPresets( } try { - const response = await api.get(`/api/airports/${encodeURIComponent(icao)}/frequencies`) + // Both in flight together: the ATIS report backfills the letter and the + // broadcast text onto the frequency entries, so a partial load would show + // an ATIS station with no information letter. + const [response, report] = await Promise.all([ + api.get(`/api/airports/${encodeURIComponent(icao)}/frequencies`), + api.get(`/api/airports/${encodeURIComponent(icao)}/atis`).catch((err: unknown) => { + console.error('Failed to load ATIS report:', err) + return null + }), + ]) const entries = Array.isArray(response?.frequencies) ? response.frequencies as AirportFrequencyEntry[] : [] airportFrequencies.value = entries + atisReport.value = (report as AtisReport | null) ?? null airportName.value = typeof response?.airportName === 'string' ? response.airportName : undefined frequencySources.value = { vatsim: Boolean(response?.sources?.vatsim), @@ -371,6 +399,7 @@ export function useFrequencyPresets( if (!options.silent) { airportFrequencies.value = [] airportName.value = undefined + atisReport.value = null frequencySources.value = { vatsim: false, openaip: false } } } finally { @@ -450,7 +479,11 @@ export function useFrequencyPresets( ? `${entry.frequency} · Info ${entry.atisCode}` : entry.frequency, color: entry.type === 'ATIS' ? '#f59e0b' : '#22d3ee', - sourceLabel: entry.sourceLabel, + // A synthesised ATIS is not a VATSIM broadcast — say so rather than + // crediting the source the frequency happens to come from. + sourceLabel: entry.type === 'ATIS' && atisIsSynthetic.value + ? 'Simulated ATIS' + : entry.sourceLabel, callsign: entry.callsign, })), ) @@ -505,7 +538,10 @@ export function useFrequencyPresets( acceptedFrequenciesForState, atisEntries, atisFrequencyEntry, - extractAtisRunway, + atisReport, + atisIsSynthetic, + runwayInUse, + informationLetter, frequencySourceLabels, tunedAtisEntry, frequencyDisplayKey, diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 8251072..3363666 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -74,7 +74,8 @@ export function useLiveAtcSession( const { frequencies, airportFrequencies, frequencySources, activeAirportIcao, - expectedFrequencyForState, acceptedFrequenciesForState, extractAtisRunway, + expectedFrequencyForState, acceptedFrequenciesForState, + runwayInUse, informationLetter, atisReport, fetchAirportFrequencies, } = freq @@ -556,12 +557,23 @@ export function useLiveAtcSession( // all freq vars are resolved from live VATSIM/OpenAIP data. await fetchAirportFrequencies(scenarioIcao) - // Runway in use from live ATIS (the flight plan carries no runway). Falls - // back to the engine-generated one when no ATIS text mentions a runway. - const atisRunway = extractAtisRunway(scenario.airport === 'arr' ? 'arr' : 'dep') - if (atisRunway) { - patchVariables({ runway: atisRunway }) - pmLog.info('Runway from ATIS:', atisRunway) + // 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 = {} + 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 diff --git a/shared/utils/atisReport.ts b/shared/utils/atisReport.ts index ca68db3..cac3c58 100644 --- a/shared/utils/atisReport.ts +++ b/shared/utils/atisReport.ts @@ -55,6 +55,10 @@ export interface AtisReport { runwayDep: string | null runwayArr: string | null runwaySource: 'atis' | 'wind' | 'main' | null + /** Observed QNH, so the flow quotes the same pressure the ATIS just read. */ + qnhHpa: number | null + /** Observed surface wind as "260/14", or null without an observation. */ + surfaceWind: string | null observedAt: string | null stations: AtisStation[] } @@ -382,6 +386,8 @@ export function resolveAtisReport(input: ResolveAtisInput): AtisReport { runwaySource: fromText.dep || fromText.arr ? 'atis' : byWind.dep?.source ?? byWind.arr?.source ?? null, + qnhHpa: observation?.qnhHpa ?? null, + surfaceWind: formatSurfaceWind(observation), observedAt, stations: liveStations, } @@ -413,6 +419,8 @@ export function resolveAtisReport(input: ResolveAtisInput): AtisReport { runwayDep: dep?.designator ?? null, runwayArr: arr?.designator ?? null, runwaySource: dep?.source ?? arr?.source ?? null, + qnhHpa: observation?.qnhHpa ?? null, + surfaceWind: formatSurfaceWind(observation), observedAt, stations: [{ frequency: silentStation?.frequency || input.atisFrequency || FREQUENCY_UNKNOWN, @@ -424,6 +432,20 @@ export function resolveAtisReport(input: ResolveAtisInput): AtisReport { } } +/** Surface wind in the "260/14" form the flows use, or null when unobserved. */ +function formatSurfaceWind(observation: MetarObservation | null): string | null { + if (!observation || observation.windKt === null) return null + const direction = observation.windVariable + ? 'VRB' + : observation.windDeg === null ? null : pad3(observation.windDeg) + if (direction === null) return null + return `${direction}/${pad2(observation.windKt)}` +} + +function pad3(value: number): string { + return String(value).padStart(3, '0') +} + /** * Absolute time for a METAR day-of-month stamp, resolved against `now`. * A report near a month boundary belongs to the previous month. diff --git a/tests/shared/atisReport.test.ts b/tests/shared/atisReport.test.ts index aa4abe0..af64238 100644 --- a/tests/shared/atisReport.test.ts +++ b/tests/shared/atisReport.test.ts @@ -307,6 +307,34 @@ test('resolveAtisReport still yields a letter and runway with no METAR at all', assert.ok(report.runwayDep) }) +test('resolveAtisReport carries the observed QNH and wind for the flow to quote', () => { + const report = resolveAtisReport({ + icao: 'EDDF', + airportName: 'Frankfurt Main', + vatsimStations: [], + metar: EDDF_METAR, // 24016KT ... Q1006 + runways: EDDF_RUNWAYS, + }) + assert.equal(report.qnhHpa, 1006) + assert.equal(report.surfaceWind, '240/16') +}) + +test('resolveAtisReport reports variable wind rather than a false direction', () => { + const report = resolveAtisReport({ + icao: 'EDDC', + vatsimStations: [], + metar: 'EDDC 261650Z VRB04KT CAVOK 24/12 Q1013', + runways: EDDC_RUNWAYS, + }) + assert.equal(report.surfaceWind, 'VRB/04') +}) + +test('resolveAtisReport leaves QNH and wind unset without an observation', () => { + const report = resolveAtisReport({ icao: 'EDDF', vatsimStations: [], metar: null, runways: EDDF_RUNWAYS }) + assert.equal(report.qnhHpa, null) + assert.equal(report.surfaceWind, null) +}) + test('resolveAtisReport never returns an empty letter', () => { const report = resolveAtisReport({ icao: 'ZZZZ', vatsimStations: [], metar: null, runways: [] }) assert.match(report.letter, /^[A-Z]$/)