feat(pm): runway from live ATIS + bridge position for real taxi routes

- The VATSIM flight plan carries no runway, so the runway in use is now parsed
  from live ATIS text (DEP/ARR/EXPECT...APPROACH phrasing preferred, generic
  RWY mention as fallback, "7L" normalised to "07L"). Falls back to the
  engine-generated runway when no ATIS text mentions one. Local engine vars
  are patched so the HUD matches what ATC says.
- The bridge poller now tracks PLANE_LATITUDE/LONGITUDE; when the bridge is
  connected, the aircraft position is sent at session creation so the backend
  starts the departure taxi route at the real parking position instead of a
  random stand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leubeem
2026-07-06 17:00:21 +02:00
parent 5b8e5c3205
commit d4c2d86246
2 changed files with 63 additions and 1 deletions

View File

@@ -50,6 +50,7 @@ export function useRadioBackend() {
noChain: boolean = false,
airportIcao?: string,
destinationIcao?: string,
aircraftPosition?: { lat: number; lon: number } | null,
): Promise<RadioSessionResponse> {
return await $fetch<RadioSessionResponse>(`${baseUrl()}/api/radio/session`, {
method: 'POST',
@@ -60,6 +61,9 @@ export function useRadioBackend() {
// Lets the backend compute the real OSM taxi route for taxi flows.
airport_icao: airportIcao ?? null,
destination_icao: destinationIcao ?? null,
// Bridge position: taxi route starts at the real parking spot.
aircraft_lat: aircraftPosition?.lat ?? null,
aircraft_lon: aircraftPosition?.lon ?? null,
},
})
}

View File

@@ -2821,6 +2821,41 @@ const atisEntries = computed(() => airportFrequencies.value.filter(entry => entr
const atisFrequencyEntry = computed(() =>
atisEntries.value.find(entry => (entry.atisText || '').trim()) || atisEntries.value[0])
/**
* 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.
*/
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 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
}
const frequencySourceLabels = computed(() => {
const labels: string[] = []
if (frequencySources.value.vatsim) labels.push('VATSIM')
@@ -3683,6 +3718,14 @@ const startMonitoring = async (flightPlan: any, scenario: Scenario) => {
// 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)
}
// Pre-generate the ATIS TTS audio now so the first tune-in plays instantly
// instead of waiting for generation. Fire-and-forget.
prefetchAtisAudio()
@@ -3704,7 +3747,7 @@ const startMonitoring = async (flightPlan: any, scenario: Scenario) => {
// being forwarded — so arrival flows (and taxi/tower on departure) fell back
// to YAML defaults and ignored the selected flight. Names are mapped to the
// backend flow conventions (qnh_hpa→qnh, acf_type→aircraft_type, …).
runway: v.runway || '25R',
runway: atisRunway || v.runway || '25R',
qnh: String(v.qnh_hpa ?? '1013'),
surface_wind: v.surface_wind || '250/08',
// taxi_route is intentionally NOT sent: the backend computes the real OSM
@@ -3748,6 +3791,9 @@ const startMonitoring = async (flightPlan: any, scenario: Scenario) => {
scenario.noChain,
scenarioIcao,
v.dest || flightPlan.arr || flightPlan.arrival,
// Live aircraft position (bridge connected): backend starts the taxi
// route at the real parking position instead of a random stand.
bridgeConnected.value ? bridgePosition.value : null,
)
backendSessionId.value = session.session_id
pmLog.group(`SESSION CREATED id=${session.session_id.slice(0, 8)}`, () => {
@@ -5156,6 +5202,9 @@ const bridgeToken = computed(() => {
})
const bridgeConnected = ref(false)
const bridgeSimActiveFreq = ref<string | null>(null)
// Last known aircraft position from bridge telemetry. Sent at session creation
// so the backend can start the taxi route at the real parking position.
const bridgePosition = ref<{ lat: number; lon: number } | null>(null)
// Telemetry older than this means the bridge stopped posting.
const BRIDGE_TELEMETRY_STALE_MS = 12_000
const BRIDGE_POLL_INTERVAL_MS = 3_000
@@ -5188,9 +5237,18 @@ async function pollBridgeTelemetry() {
// Bridge went quiet — drop the active sync anchor so reconnecting re-tunes.
lastSyncedSimActive = null
bridgeSimActiveFreq.value = null
bridgePosition.value = null
return
}
// Track the aircraft position (0/0 is the bridge's "no data" default).
const lat = Number(res.telemetry?.PLANE_LATITUDE)
const lon = Number(res.telemetry?.PLANE_LONGITUDE)
bridgePosition.value =
Number.isFinite(lat) && Number.isFinite(lon) && (Math.abs(lat) > 0.5 || Math.abs(lon) > 0.5)
? { lat, lon }
: null
const simActive = normalizeSimFreq(res.telemetry?.COM_ACTIVE_FREQUENCY)
const simStandby = normalizeSimFreq(res.telemetry?.COM_STANDBY_FREQUENCY)
bridgeSimActiveFreq.value = simActive