mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-08 18:45:33 +08:00
feat(live-atc): drive ATIS letter, runway, QNH and wind from the resolved report
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<AtisReport | null>(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<AirportFrequencyEntry[]>(() =>
|
||||
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,
|
||||
|
||||
@@ -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<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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]$/)
|
||||
|
||||
Reference in New Issue
Block a user