From 9780ced052c664594f16fb5f80bf27b559c96043 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sun, 26 Jul 2026 19:40:40 +0200 Subject: [PATCH] feat(live-atc): drive ATIS letter, runway, QNH and wind from the resolved report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broadcast and the flow variables used to read different sources: the audio came from VATSIM while `information` was genATIS() — a random A-Z letter — and the runway came from genRunway(), a hard-coded list unrelated to the airport. So the pilot could hear "information Q" and be expected to call "information T". Both now read the same AtisReport. The frequency list backfills the letter and broadcast text from it, so an ATIS station always announces an information letter even when no VATSIM controller is online. QNH and surface wind come from the same observation, so the controller stops contradicting the ATIS. A synthesised ATIS is labelled "Simulated ATIS" in the frequency picker rather than being credited to VATSIM. Co-Authored-By: Claude Opus 5 --- app/composables/useFrequencyPresets.ts | 106 +++++++++++++++++-------- app/composables/useLiveAtcSession.ts | 26 ++++-- shared/utils/atisReport.ts | 22 +++++ tests/shared/atisReport.test.ts | 28 +++++++ 4 files changed, 140 insertions(+), 42 deletions(-) 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]$/)