export interface RadioSessionResponse { session_id: string flow_slug: string current_state: string variables: Record flags: Record expected_pilot_template: string | null } export interface RadioTransmitResponse { session_id: string next_state_id: string active_flow: string controller_say_template: string | null controller_say_rendered: string | null expected_pilot_template: string | null variables: Record flags: Record trace: Array<{ type: string; message: string }> fallback_used: boolean fallback_reason: string | null auto_advanced_states?: string[] /** True when a telemetry tick actually fired a transition (vs. an idle poll). */ telemetry_fired?: boolean /** True when the full chain is done — show the completion screen. */ session_complete?: boolean /** Per-field readback diagnostic for the pilot state just evaluated. */ readback_report?: ReadbackFieldDetail[] } /** * Sim-agnostic telemetry contract. The bridge layer (MSFS SimConnect today, * X-Plane datarefs later) is normalised to exactly these keys before it reaches * the backend, so the decision engine only ever compares plain scalars. Keep * this in lock-step with `TelemetryParameter` in the Python backend (models.py). */ export interface NormalizedTelemetry { altitude_ft?: number // feet MSL ias_kts?: number // indicated airspeed, knots gs_kts?: number // groundspeed, knots vs_fpm?: number // vertical speed, feet/min (+climb, -descent) heading_deg?: number // magnetic heading, 0..360 on_ground?: boolean // wheels on the ground // Raw position in degrees. The backend derives distance_to_dep_nm / // distance_to_dest_nm from these via the session's airport ICAO codes. lat?: number lon?: number } export interface ReadbackFieldDetail { field: string expected: string matched: boolean /** Which accepted spoken form matched ("two five right", "icao_phonetic"), or null. */ matched_via: string | null /** All spoken forms that would have matched this field. */ accepted_forms: string[] note?: string | null } export function useRadioBackend() { const config = useRuntimeConfig() function baseUrl(): string { return (config.public.radioBackendUrl as string) || 'http://127.0.0.1:8000' } // Without a timeout, a hung Python-backend call never resolves — its // `finally` (which clears transmitInFlightCount / re-arms the silence timer) // never runs either, so the session reads as permanently stuck. const TRANSMIT_TIMEOUT_MS = 30_000 const CREATE_SESSION_TIMEOUT_MS = 60_000 // taxi-route computation is slow async function createSession( flowSlug: string, variables?: Record, noChain: boolean = false, airportIcao?: string, destinationIcao?: string, aircraftPosition?: { lat: number; lon: number } | null, ): Promise { return await $fetch(`${baseUrl()}/api/radio/session`, { method: 'POST', body: { flow_slug: flowSlug, variables: variables ?? null, no_chain: noChain, // 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, }, signal: AbortSignal.timeout(CREATE_SESSION_TIMEOUT_MS), }) } async function transmit(sessionId: string, pilotUtterance: string): Promise { return await $fetch( `${baseUrl()}/api/radio/session/${sessionId}/transmissions`, { method: 'POST', body: { pilot_utterance: pilotUtterance }, signal: AbortSignal.timeout(TRANSMIT_TIMEOUT_MS), }, ) } /** * Forward a normalised telemetry tick. The backend fires any telemetry-gated * transition whose threshold is met and returns the same shape as transmit; * idle ticks come back with `telemetry_fired: false` and the state unchanged. */ async function sendTelemetry( sessionId: string, telemetry: NormalizedTelemetry, ): Promise { return await $fetch( `${baseUrl()}/api/radio/session/${sessionId}/telemetry`, { method: 'POST', body: { telemetry }, signal: AbortSignal.timeout(TRANSMIT_TIMEOUT_MS), }, ) } /** * Fire the silence timeout for the current pilot state. Call when the * state's `auto_advance_timeout_ms` elapses without a pilot transmission; * the backend advances through the state's silence auto-transition. */ async function timeout(sessionId: string): Promise { return await $fetch( `${baseUrl()}/api/radio/session/${sessionId}/timeout`, { method: 'POST', signal: AbortSignal.timeout(TRANSMIT_TIMEOUT_MS) }, ) } async function deleteSession(sessionId: string): Promise { await $fetch(`${baseUrl()}/api/radio/session/${sessionId}`, { method: 'DELETE' }) } async function fetchFlows(): Promise { return await $fetch(`${baseUrl()}/api/decision-flows/runtime`) } return { createSession, transmit, sendTelemetry, timeout, deleteSession, fetchFlows } }