From 3fe0ea5f8a20f14fd0ed8205dd15254cff128b20 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Wed, 15 Jul 2026 09:31:17 +0200 Subject: [PATCH] feat(sim-control): wire frequency-sim-control command channel end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the open items from docs/plans/2026-07-14-frequency-sim-control-design.md §4/§"Offen für die Implementierungsphase": a per-bridge-token in-memory command queue piggybacked on the existing telemetry channel, plus the client-side gate and TTS confirmations. - server/utils/simControlQueue.ts: TTL-based queue keyed by bridge token (enqueue → drainPending → resolve → drainResultsForClient). - server/api/bridge/data.post.ts: response gains a `commands` field the bridge drains on its next telemetry POST. - server/api/bridge/command.post.ts (new): client enqueues a parsed command, re-validated server-side via isValidSimControlCommand. - server/api/bridge/command-result.post.ts (new): bridge reports ok/failed. - server/api/bridge/live.get.ts: response gains `commandResults` so the client can announce outcomes. - shared/utils/simControl.ts: wire types, isValidSimControlCommand, and simControlRejectionSpeech/simControlResultSpeech TTS phrasing. - useLiveAtcSession.ts: parseSimControl() gated on bridgeConnected, wired in right after the local special cases and before the frequency check — matched commands never reach radioBackend.transmit(). - useSimBridgeSync.ts / live-atc.vue: bridgeToken threaded through, command results forwarded from the telemetry poll to TTS. 43 new tests (shared parser/validation/speech + server queue lifecycle/TTL). Co-Authored-By: Claude Sonnet 5 --- app/composables/useLiveAtcSession.ts | 60 +++++++++- app/composables/useSimBridgeSync.ts | 20 +++- app/pages/live-atc.vue | 6 + server/api/bridge/command-result.post.ts | 55 +++++++++ server/api/bridge/command.post.ts | 54 +++++++++ server/api/bridge/data.post.ts | 4 + server/api/bridge/live.get.ts | 5 + server/utils/simControlQueue.ts | 122 +++++++++++++++++++ shared/utils/simControl.ts | 93 +++++++++++++++ tests/server/simControlQueue.test.ts | 144 +++++++++++++++++++++++ tests/shared/simControl.test.ts | 132 ++++++++++++++++++++- 11 files changed, 691 insertions(+), 4 deletions(-) create mode 100644 server/api/bridge/command-result.post.ts create mode 100644 server/api/bridge/command.post.ts create mode 100644 server/utils/simControlQueue.ts create mode 100644 tests/server/simControlQueue.test.ts diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index e6f4325..9174ae6 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -7,6 +7,13 @@ import type { useSessionState } from '~/composables/useSessionState' import { useApi } from '~/composables/useApi' import type { useRadioSpeech } from '~/composables/useRadioSpeech' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' +import { + parseSimControl, + simControlRejectionSpeech, + simControlResultSpeech, + type SimControlCommand, + type SimControlCommandResult, +} from '../../shared/utils/simControl' export interface LiveAtcSessionDeps { state: ReturnType @@ -22,6 +29,8 @@ export interface LiveAtcSessionDeps { isRecording: Ref bridgeConnected: Ref bridgePosition: Ref<{ lat: number; lon: number } | null> + /** ?token=… from the route — auths the frequency-sim-control channel (design §4). */ + bridgeToken: Ref persistSelectedPlan: (plan: any | null) => void maybeShowFirstRunHelp: () => void } @@ -47,7 +56,8 @@ export function useLiveAtcSession( const { state, freq, speech, radioBackend, api, config, prefetchAtisAudio, - isRecording, bridgeConnected, bridgePosition, persistSelectedPlan, maybeShowFirstRunHelp, + isRecording, bridgeConnected, bridgePosition, bridgeToken, + persistSelectedPlan, maybeShowFirstRunHelp, } = deps const { @@ -205,6 +215,33 @@ export function useLiveAtcSession( armSilenceTimer() } + // --- Frequency-sim-control (design doc §4) ------------------------------------ + // A matched command from handlePilotTransmission is queued server-side for the + // bridge to execute; the result comes back asynchronously via the /api/bridge/live + // poll (see handleSimControlResult below), so this call itself stays silent on + // success — only a network/enqueue failure gets an immediate ATC reply. + const sendSimControlCommand = async (command: SimControlCommand) => { + try { + await $fetch('/api/bridge/command', { + method: 'POST', + headers: { 'x-bridge-token': bridgeToken.value }, + body: { command }, + }) + } catch (e) { + pmLog.warn('SIM CONTROL enqueue failed', e) + const reply = 'unable to reach bridge, say again' + scheduleControllerSpeech(reply) + appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) + } + } + + /** Speak the outcome of a bridge command surfaced by useSimBridgeSync's telemetry poll. */ + const handleSimControlResult = (result: SimControlCommandResult) => { + const reply = simControlResultSpeech(result) + scheduleControllerSpeech(reply) + appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) + } + const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => { const transcript = message.trim() // Ignore empty or content-free transmissions: silence, a stray PTT tap, or @@ -278,6 +315,26 @@ export function useLiveAtcSession( return } + // --- Sim control (frequency-driven simulator command) --- + // A meta channel: the pilot talking to their OWN simulator, not ATC — so it + // runs before the frequency gate (like radio check) and, unlike every other + // transmission, never reaches radioBackend.transmit()/the decision flow. + // Only live while a bridge is actually connected (design doc §4); parseSimControl's + // anchors already keep it from ever matching real ICAO phraseology either way. + if (bridgeConnected.value) { + const simResult = parseSimControl(transcript) + if (simResult.matched) { + void sendSimControlCommand(simResult.command) + return + } + if (simResult.reason !== 'no_intent') { + const reply = simControlRejectionSpeech(simResult.reason) + scheduleControllerSpeech(reply) + appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) + return + } + } + // --- Frequency check --- // Reject the transmission if the pilot is on the wrong frequency. A position // can publish several valid frequencies (e.g. two Tower freqs); accept any of @@ -700,6 +757,7 @@ export function useLiveAtcSession( clearSilenceTimer, applyBackendDecision, handlePilotTransmission, + handleSimControlResult, loadFlightPlans, startMonitoring, startDemoFlight, diff --git a/app/composables/useSimBridgeSync.ts b/app/composables/useSimBridgeSync.ts index 993b9d1..562c20f 100644 --- a/app/composables/useSimBridgeSync.ts +++ b/app/composables/useSimBridgeSync.ts @@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue' import { useRoute } from 'vue-router' import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry' import { pmLog } from '../../shared/utils/pmLog' +import type { SimControlCommandResult } from '../../shared/utils/simControl' import type { useFrequencyPresets } from '~/composables/useFrequencyPresets' export interface SimBridgeSyncDeps { @@ -14,6 +15,8 @@ export interface SimBridgeSyncDeps { resumePrerecIfSuspended: () => Promise | void startRecording: (fromPad: boolean) => Promise | void stopRecording: () => void + /** A frequency-sim-control command the bridge has finished (or the server expired). */ + onCommandResult: (result: SimControlCommandResult) => void } /** @@ -33,7 +36,7 @@ export function useSimBridgeSync( const { frequencies } = freq const { backendSessionId, radioBackend, applyBackendDecision, stopCurrentSpeech, - resumePrerecIfSuspended, startRecording, stopRecording, + resumePrerecIfSuspended, startRecording, stopRecording, onCommandResult, } = deps const bridgeToken = computed(() => { @@ -88,10 +91,23 @@ export function useSimBridgeSync( const token = bridgeToken.value if (!token) return try { - const res = await $fetch<{ connected: boolean; lastTelemetryAt: string | null; telemetry: any }>( + const res = await $fetch<{ + connected: boolean + lastTelemetryAt: string | null + telemetry: any + commandResults?: SimControlCommandResult[] + }>( '/api/bridge/live', { headers: { 'x-bridge-token': token } }, ) + + // Frequency-sim-control results (design §4): announce regardless of + // telemetry freshness below — a command can resolve/expire even on the + // poll where the bridge itself has just gone quiet. + for (const result of res.commandResults ?? []) { + onCommandResult(result) + } + const ts = res.lastTelemetryAt ? Date.parse(res.lastTelemetryAt) : null const fresh = Boolean(res.connected && ts && Date.now() - ts < BRIDGE_TELEMETRY_STALE_MS) bridgeConnected.value = fresh diff --git a/app/pages/live-atc.vue b/app/pages/live-atc.vue index 70b7fe6..ef8e4a3 100644 --- a/app/pages/live-atc.vue +++ b/app/pages/live-atc.vue @@ -462,6 +462,9 @@ const handlePilotTransmission = (message: string, source: 'text' | 'ptt' = 'text const applyBackendDecision = ( response: import('~/composables/useRadioBackend').RadioTransmitResponse, ) => session.applyBackendDecision(response) +const handleSimControlResult = ( + result: import('../../shared/utils/simControl').SimControlCommandResult, +) => session.handleSimControlResult(result) const speech = useRadioSpeech(engine, freq, speechInterrupt, { setLastTransmission, @@ -539,6 +542,7 @@ watch(prerecEnabled, (val) => { }) const { + bridgeToken, bridgeConnected, bridgeSimActiveFreq, bridgePosition, @@ -551,6 +555,7 @@ const { resumePrerecIfSuspended, startRecording, stopRecording, + onCommandResult: handleSimControlResult, }) const session = useLiveAtcSession(engine, { @@ -564,6 +569,7 @@ const session = useLiveAtcSession(engine, { isRecording, bridgeConnected, bridgePosition, + bridgeToken, persistSelectedPlan, maybeShowFirstRunHelp, }) diff --git a/server/api/bridge/command-result.post.ts b/server/api/bridge/command-result.post.ts new file mode 100644 index 0000000..261aee8 --- /dev/null +++ b/server/api/bridge/command-result.post.ts @@ -0,0 +1,55 @@ +import { createError, readBody } from 'h3' +import { BridgeToken } from '../../models/BridgeToken' +import { getBridgeTokenFromHeader } from '../../utils/bridge' +import { logBridgeEvent } from '../../utils/bridgeLog' +import { simControlQueue } from '../../utils/simControlQueue' + +interface CommandResultBody { + id?: string + status?: 'ok' | 'failed' + reason?: string +} + +/** + * The bridge reports the outcome of a previously delivered frequency-sim- + * control command (design doc §4). + * + * POST /api/bridge/command-result + * x-bridge-token: + * body: { id: string, status: 'ok' | 'failed', reason?: string } + */ +export default defineEventHandler(async (event) => { + const token = getBridgeTokenFromHeader(event) + if (!token) { + throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' }) + } + + const exists = await BridgeToken.exists({ token }) + if (!exists) { + throw createError({ statusCode: 404, statusMessage: 'Bridge-Token ist nicht verknüpft.' }) + } + + const body = await readBody(event) + const id = typeof body?.id === 'string' ? body.id : '' + const status = body?.status + if (!id || (status !== 'ok' && status !== 'failed')) { + throw createError({ statusCode: 400, statusMessage: "id und status ('ok'|'failed') sind erforderlich." }) + } + + const resolved = simControlQueue.resolve(token, id, status, body?.reason) + + console.info( + `\x1b[32m[bridge:command-result]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m id=\x1b[96m${id.slice(0, 8)}\x1b[0m status=\x1b[93m${status}\x1b[0m resolved=\x1b[93m${resolved}\x1b[0m`, + ) + + logBridgeEvent(token, { + endpoint: '/api/bridge/command-result', + method: 'POST', + statusCode: 200, + color: status === 'ok' ? '#22c55e' : '#ef4444', + summary: `${id.slice(0, 8)} → ${status}${body?.reason ? `: ${body.reason}` : ''}`, + data: { id, status, reason: body?.reason ?? null, resolved }, + }) + + return { ok: resolved } +}) diff --git a/server/api/bridge/command.post.ts b/server/api/bridge/command.post.ts new file mode 100644 index 0000000..41659ae --- /dev/null +++ b/server/api/bridge/command.post.ts @@ -0,0 +1,54 @@ +import { createError, readBody } from 'h3' +import { BridgeToken } from '../../models/BridgeToken' +import { getBridgeTokenFromHeader } from '../../utils/bridge' +import { logBridgeEvent } from '../../utils/bridgeLog' +import { simControlQueue } from '../../utils/simControlQueue' +import { isValidSimControlCommand, type SimControlCommand } from '../../../shared/utils/simControl' + +interface CommandBody { + command?: SimControlCommand +} + +/** + * Queues a frequency-sim-control command (design doc §4) for the bridge + * behind this token to pick up on its next telemetry POST. + * + * POST /api/bridge/command + * x-bridge-token: + * body: { command: SimControlCommand } + */ +export default defineEventHandler(async (event) => { + const token = getBridgeTokenFromHeader(event) + if (!token) { + throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' }) + } + + // Only relay for a linked token — an unauthenticated caller can't queue commands. + const exists = await BridgeToken.exists({ token }) + if (!exists) { + throw createError({ statusCode: 404, statusMessage: 'Bridge-Token ist nicht verknüpft.' }) + } + + const body = await readBody(event) + const command = body?.command + if (!isValidSimControlCommand(command)) { + throw createError({ statusCode: 400, statusMessage: 'Ungültiges Kommando.' }) + } + + const pending = simControlQueue.enqueue(token, command) + + console.info( + `\x1b[32m[bridge:command]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m id=\x1b[96m${pending.id.slice(0, 8)}\x1b[0m type=\x1b[93m${command.type}\x1b[0m`, + ) + + logBridgeEvent(token, { + endpoint: '/api/bridge/command', + method: 'POST', + statusCode: 200, + color: '#22c55e', + summary: `queued ${command.type}`, + data: { id: pending.id, command }, + }) + + return { ok: true, id: pending.id, issued_at: pending.issued_at } +}) diff --git a/server/api/bridge/data.post.ts b/server/api/bridge/data.post.ts index 63ab64e..4165824 100644 --- a/server/api/bridge/data.post.ts +++ b/server/api/bridge/data.post.ts @@ -3,6 +3,7 @@ import {BridgeToken} from '../../models/BridgeToken' import {getBridgeTokenFromHeader} from '../../utils/bridge' import {logBridgeEvent} from '../../utils/bridgeLog' import {flightlabTelemetryStore} from '../../utils/flightlabTelemetry' +import {simControlQueue} from '../../utils/simControlQueue' import type {FlightLabTelemetryState} from '../../../shared/data/flightlab/types' type DomeLightMode = 'off' | 'white' | 'amber' @@ -176,5 +177,8 @@ export default defineEventHandler(async (event) => { // gear und parking break togglen // gear_handle: !mapped.GEAR_HANDLE_POSITION, parking_brake: !mapped.BRAKE_PARKING_POSITION, + // frequency-sim-control (design §4): any commands queued for this bridge + // token since the last telemetry POST, piggybacked on this response. + commands: simControlQueue.drainPending(bridgeToken), } }) diff --git a/server/api/bridge/live.get.ts b/server/api/bridge/live.get.ts index 744f878..84065c6 100644 --- a/server/api/bridge/live.get.ts +++ b/server/api/bridge/live.get.ts @@ -2,6 +2,7 @@ import { createError } from 'h3' import { BridgeToken } from '../../models/BridgeToken' import { getBridgeTokenFromHeader } from '../../utils/bridge' import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry' +import { simControlQueue } from '../../utils/simControlQueue' export default defineEventHandler(async (event) => { const token = getBridgeTokenFromHeader(event) @@ -17,6 +18,7 @@ export default defineEventHandler(async (event) => { connected: false, lastTelemetryAt: null, telemetry: null, + commandResults: [], } } @@ -29,5 +31,8 @@ export default defineEventHandler(async (event) => { connected: true, lastTelemetryAt: timestamp, telemetry, + // frequency-sim-control (design §4): terminal command results the client + // hasn't seen yet, so it can speak a confirmation/failure over TTS. + commandResults: simControlQueue.drainResultsForClient(token), } }) diff --git a/server/utils/simControlQueue.ts b/server/utils/simControlQueue.ts new file mode 100644 index 0000000..a80a946 --- /dev/null +++ b/server/utils/simControlQueue.ts @@ -0,0 +1,122 @@ +/** + * In-memory command queue for the frequency-sim-control channel (design doc + * §4, `docs/plans/2026-07-14-frequency-sim-control-design.md`). + * + * Flow: /live-atc client → POST /api/bridge/command (enqueue) → this queue + * → POST /api/bridge/data response `commands` field (bridge picks up) → + * bridge executes → POST /api/bridge/command-result (resolve) → this queue + * → GET /api/bridge/live response `commandResults` field (client announces). + * + * Keyed by bridge token, not userId: both ends of this channel authenticate + * with the same x-bridge-token the telemetry channel already uses. + */ + +import { randomUUID } from 'node:crypto' +import type { PendingCommand, SimControlCommand, SimControlCommandResult, SimControlCommandStatus } from '../../shared/utils/simControl' + +type RecordStatus = 'pending' | 'delivered' | SimControlCommandStatus + +interface CommandRecord { + id: string + token: string + command: SimControlCommand + issuedAt: number + status: RecordStatus + reason: string | null + consumedByClient: boolean +} + +export interface SimControlQueueOptions { + /** Commands not delivered/resolved within this window are treated as expired. */ + ttlMs?: number + /** Clock override for deterministic tests. */ + now?: () => number +} + +export class SimControlQueue { + private records = new Map() + private pendingByToken = new Map() + private ttlMs: number + private now: () => number + + constructor(opts: SimControlQueueOptions = {}) { + this.ttlMs = opts.ttlMs ?? 30_000 + this.now = opts.now ?? Date.now + } + + /** Enqueue a parsed command for the bridge behind this token to pick up. */ + enqueue(token: string, command: SimControlCommand): PendingCommand { + const id = randomUUID() + const issuedAt = this.now() + const record: CommandRecord = { id, token, command, issuedAt, status: 'pending', reason: null, consumedByClient: false } + this.records.set(id, record) + const queue = this.pendingByToken.get(token) ?? [] + queue.push(id) + this.pendingByToken.set(token, queue) + return { id, issued_at: new Date(issuedAt).toISOString(), command } + } + + /** + * Called from POST /api/bridge/data: hand every still-pending command for + * this token to the bridge and mark it delivered. Each command is handed + * out exactly once — the caller does not re-poll for it. + */ + drainPending(token: string): PendingCommand[] { + this.sweepExpired() + const ids = this.pendingByToken.get(token) ?? [] + this.pendingByToken.set(token, []) + const out: PendingCommand[] = [] + for (const id of ids) { + const record = this.records.get(id) + if (!record || record.status !== 'pending') continue // expired while queued + record.status = 'delivered' + out.push({ id: record.id, issued_at: new Date(record.issuedAt).toISOString(), command: record.command }) + } + return out + } + + /** + * Called from POST /api/bridge/command-result: the bridge reports the + * outcome of a delivered command. Returns false if the id is unknown or + * belongs to a different token (never trust the caller's own claim). + */ + resolve(token: string, id: string, status: 'ok' | 'failed', reason?: string | null): boolean { + const record = this.records.get(id) + if (!record || record.token !== token) return false + if (record.status !== 'pending' && record.status !== 'delivered') return false + record.status = status + record.reason = reason ?? null + return true + } + + /** + * Called from GET /api/bridge/live: hand the client every terminal result + * for this token it hasn't seen yet, then forget them (one-shot delivery, + * same as drainPending on the bridge side). + */ + drainResultsForClient(token: string): SimControlCommandResult[] { + this.sweepExpired() + const out: SimControlCommandResult[] = [] + for (const record of this.records.values()) { + if (record.token !== token || record.consumedByClient) continue + if (record.status === 'ok' || record.status === 'failed' || record.status === 'expired') { + record.consumedByClient = true + out.push({ id: record.id, command: record.command, status: record.status, reason: record.reason }) + } + } + for (const record of out) this.records.delete(record.id) + return out + } + + private sweepExpired() { + const cutoff = this.now() - this.ttlMs + for (const record of this.records.values()) { + if ((record.status === 'pending' || record.status === 'delivered') && record.issuedAt < cutoff) { + record.status = 'expired' + } + } + } +} + +// Singleton — shared across all server handlers, same pattern as flightlabTelemetryStore. +export const simControlQueue = new SimControlQueue() diff --git a/shared/utils/simControl.ts b/shared/utils/simControl.ts index 0898fd3..7099ec9 100644 --- a/shared/utils/simControl.ts +++ b/shared/utils/simControl.ts @@ -165,3 +165,96 @@ export function parseSimControl(input: string): SimControlParseResult { return noMatch('no_intent', text) } + +// --- Wire contract: server ⇄ bridge command channel (design §4) ------------- +// The Nuxt server queues parsed commands per bridge token and piggybacks them +// on the existing telemetry POST response; the bridge executes and reports +// back by id. Kept here, next to the command type it carries, as the single +// source of truth for both server and client. + +export interface PendingCommand { + /** UUID, correlates the bridge's async command-result POST. */ + id: string + /** ISO timestamp; the bridge/server discard commands older than the TTL. */ + issued_at: string + command: SimControlCommand +} + +export type SimControlCommandStatus = 'ok' | 'failed' | 'expired' + +export interface SimControlCommandResult { + id: string + command: SimControlCommand + status: SimControlCommandStatus + reason: string | null +} + +/** + * Defensive re-validation for commands arriving over the wire (the enqueue + * endpoint receives whatever the client posts, not necessarily this parser's + * own output). Reuses SIM_CONTROL_LIMITS so there is exactly one place value + * ranges are defined, per the fail-closed design. + */ +export function isValidSimControlCommand(value: unknown): value is SimControlCommand { + if (!value || typeof value !== 'object') return false + const cmd = value as Record + + switch (cmd.type) { + case 'set_altitude': + return typeof cmd.altitude_ft === 'number' && inRange(cmd.altitude_ft, SIM_CONTROL_LIMITS.altitude_ft) + case 'set_heading': + return typeof cmd.heading_deg === 'number' && inRange(cmd.heading_deg, SIM_CONTROL_LIMITS.heading_deg) + case 'set_speed': + return typeof cmd.ias_kts === 'number' && inRange(cmd.ias_kts, SIM_CONTROL_LIMITS.ias_kts) + case 'setup_approach': { + if (typeof cmd.airport_icao !== 'string' || !/^[A-Z]{4}$/.test(cmd.airport_icao)) return false + if (typeof cmd.runway !== 'string' || !/^(\d{2})[LRC]?$/.test(cmd.runway)) return false + if (!inRange(Number(cmd.runway.slice(0, 2)), { min: 1, max: 36 })) return false + if (cmd.altitude_ft !== undefined) { + if (typeof cmd.altitude_ft !== 'number' || !inRange(cmd.altitude_ft, SIM_CONTROL_LIMITS.altitude_ft)) return false + } + if (cmd.final_distance_nm !== undefined) { + if (typeof cmd.final_distance_nm !== 'number' || !inRange(cmd.final_distance_nm, SIM_CONTROL_LIMITS.final_distance_nm)) return false + } + return true + } + default: + return false + } +} + +/** Short "say again" ATC reply for a gate-hit transmission whose slots failed to parse. */ +export function simControlRejectionSpeech(reason: Exclude): string { + switch (reason) { + case 'missing_value': return 'say again with a value' + case 'missing_unit': return 'say again with altitude in feet' + case 'out_of_range': return 'unable, value out of range, say again' + case 'invalid_runway': return 'unable, invalid runway, say again' + case 'missing_runway': return 'say again with the runway' + case 'missing_airport': return 'say again with the airport' + } +} + +/** TTS confirmation/failure line for a resolved (or expired) bridge command. */ +export function simControlResultSpeech(result: SimControlCommandResult): string { + if (result.status === 'expired') return 'bridge did not respond' + if (result.status === 'failed') return result.reason ? `unable, ${result.reason}` : 'unable to comply' + + const command = result.command + switch (command.type) { + case 'setup_approach': + if (command.final_distance_nm) { + return `repositioned, ${command.final_distance_nm} mile final runway ${command.runway}` + } + if (command.altitude_ft) { + return `repositioned, runway ${command.runway} approach from ${command.altitude_ft} feet` + } + return `repositioned, runway ${command.runway} approach` + case 'set_altitude': + return `altitude set, ${command.altitude_ft} feet` + case 'set_heading': + return `heading set, ${command.heading_deg}` + case 'set_speed': + return `speed set, ${command.ias_kts} knots` + } +} diff --git a/tests/server/simControlQueue.test.ts b/tests/server/simControlQueue.test.ts new file mode 100644 index 0000000..83362cb --- /dev/null +++ b/tests/server/simControlQueue.test.ts @@ -0,0 +1,144 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { SimControlQueue } from '~~/server/utils/simControlQueue' + +const HEADING: any = { type: 'set_heading', heading_deg: 270 } +const ALTITUDE: any = { type: 'set_altitude', altitude_ft: 8000 } + +function clock(startMs = 0) { + let t = startMs + return { now: () => t, advance: (ms: number) => { t += ms } } +} + +describe('SimControlQueue — enqueue / drainPending', () => { + it('delivers a pending command exactly once', () => { + const q = new SimControlQueue() + const pending = q.enqueue('tok-a', HEADING) + assert.equal(typeof pending.id, 'string') + assert.ok(pending.id.length > 0) + assert.deepEqual(pending.command, HEADING) + + const first = q.drainPending('tok-a') + assert.equal(first.length, 1) + assert.equal(first[0]!.id, pending.id) + + const second = q.drainPending('tok-a') + assert.equal(second.length, 0) + }) + + it('keeps queues isolated per token', () => { + const q = new SimControlQueue() + q.enqueue('tok-a', HEADING) + q.enqueue('tok-b', ALTITUDE) + + const forA = q.drainPending('tok-a') + assert.equal(forA.length, 1) + assert.deepEqual(forA[0]!.command, HEADING) + + const forB = q.drainPending('tok-b') + assert.equal(forB.length, 1) + assert.deepEqual(forB[0]!.command, ALTITUDE) + }) + + it('drainPending on an unknown token returns empty, not an error', () => { + const q = new SimControlQueue() + assert.deepEqual(q.drainPending('never-enqueued'), []) + }) +}) + +describe('SimControlQueue — resolve', () => { + it('resolves a delivered command and the client can read the result once', () => { + const q = new SimControlQueue() + const pending = q.enqueue('tok-a', HEADING) + q.drainPending('tok-a') // bridge picks it up → status 'delivered' + + const resolved = q.resolve('tok-a', pending.id, 'ok') + assert.equal(resolved, true) + + const results = q.drainResultsForClient('tok-a') + assert.equal(results.length, 1) + assert.equal(results[0]!.id, pending.id) + assert.equal(results[0]!.status, 'ok') + assert.equal(results[0]!.reason, null) + + // One-shot: a second poll sees nothing more for this id. + assert.deepEqual(q.drainResultsForClient('tok-a'), []) + }) + + it('carries a failure reason through to the client', () => { + const q = new SimControlQueue() + const pending = q.enqueue('tok-a', HEADING) + q.drainPending('tok-a') + q.resolve('tok-a', pending.id, 'failed', 'aircraft on ground') + + const [result] = q.drainResultsForClient('tok-a') + assert.equal(result!.status, 'failed') + assert.equal(result!.reason, 'aircraft on ground') + }) + + it('rejects a resolve for a mismatched token (cannot resolve another bridge\'s command)', () => { + const q = new SimControlQueue() + const pending = q.enqueue('tok-a', HEADING) + q.drainPending('tok-a') + + const resolved = q.resolve('tok-b', pending.id, 'ok') + assert.equal(resolved, false) + // The command is still awaiting resolution under the real token. + assert.deepEqual(q.drainResultsForClient('tok-a'), []) + }) + + it('rejects a resolve for an unknown id', () => { + const q = new SimControlQueue() + assert.equal(q.resolve('tok-a', 'no-such-id', 'ok'), false) + }) + + it('allows resolving a command still pending (not yet drained by the bridge)', () => { + const q = new SimControlQueue() + const pending = q.enqueue('tok-a', HEADING) + assert.equal(q.resolve('tok-a', pending.id, 'ok'), true) + }) +}) + +describe('SimControlQueue — TTL expiry', () => { + it('expires a command that was never delivered before the TTL', () => { + const c = clock() + const q = new SimControlQueue({ ttlMs: 1000, now: c.now }) + q.enqueue('tok-a', HEADING) + + c.advance(1001) + assert.deepEqual(q.drainPending('tok-a'), []) // expired before the bridge ever polled + + const results = q.drainResultsForClient('tok-a') + assert.equal(results.length, 1) + assert.equal(results[0]!.status, 'expired') + }) + + it('expires a delivered command the bridge never resolved', () => { + const c = clock() + const q = new SimControlQueue({ ttlMs: 1000, now: c.now }) + const pending = q.enqueue('tok-a', HEADING) + q.drainPending('tok-a') // delivered at t=0 + + c.advance(1001) + const results = q.drainResultsForClient('tok-a') + assert.equal(results.length, 1) + assert.equal(results[0]!.status, 'expired') + + // A late result from the bridge can no longer land on an already-expired command. + assert.equal(q.resolve('tok-a', pending.id, 'ok'), false) + }) + + it('does not expire a command resolved within the TTL', () => { + const c = clock() + const q = new SimControlQueue({ ttlMs: 1000, now: c.now }) + const pending = q.enqueue('tok-a', HEADING) + q.drainPending('tok-a') + c.advance(500) + assert.equal(q.resolve('tok-a', pending.id, 'ok'), true) + c.advance(600) // past the original TTL, but already resolved + const results = q.drainResultsForClient('tok-a') + assert.equal(results.length, 1) + assert.equal(results[0]!.status, 'ok') + }) +}) diff --git a/tests/shared/simControl.test.ts b/tests/shared/simControl.test.ts index 57cd8f6..2622c64 100644 --- a/tests/shared/simControl.test.ts +++ b/tests/shared/simControl.test.ts @@ -1,7 +1,12 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { parseSimControl } from '~~/shared/utils/simControl' +import { + parseSimControl, + isValidSimControlCommand, + simControlRejectionSpeech, + simControlResultSpeech, +} from '~~/shared/utils/simControl' function expectCommand(input: string) { const result = parseSimControl(input) @@ -146,3 +151,128 @@ describe('parseSimControl — must NEVER match ATC dialogue', () => { expectNoMatch('set me up at 8000') }) }) + +describe('isValidSimControlCommand', () => { + it('accepts each command type at valid values', () => { + assert.equal(isValidSimControlCommand({ type: 'set_altitude', altitude_ft: 8000 }), true) + assert.equal(isValidSimControlCommand({ type: 'set_heading', heading_deg: 270 }), true) + assert.equal(isValidSimControlCommand({ type: 'set_speed', ias_kts: 210 }), true) + assert.equal( + isValidSimControlCommand({ type: 'setup_approach', airport_icao: 'EDDF', runway: '07R' }), + true, + ) + assert.equal( + isValidSimControlCommand({ + type: 'setup_approach', + airport_icao: 'EDDF', + runway: '25', + altitude_ft: 5000, + final_distance_nm: 5, + }), + true, + ) + }) + + it('rejects out-of-range values', () => { + assert.equal(isValidSimControlCommand({ type: 'set_altitude', altitude_ft: 80000 }), false) + assert.equal(isValidSimControlCommand({ type: 'set_heading', heading_deg: 400 }), false) + assert.equal(isValidSimControlCommand({ type: 'set_speed', ias_kts: 10 }), false) + assert.equal( + isValidSimControlCommand({ type: 'setup_approach', airport_icao: 'EDDF', runway: '07R', altitude_ft: -1 }), + false, + ) + }) + + it('rejects malformed shapes', () => { + assert.equal(isValidSimControlCommand(null), false) + assert.equal(isValidSimControlCommand({}), false) + assert.equal(isValidSimControlCommand({ type: 'teleport' }), false) + assert.equal(isValidSimControlCommand({ type: 'set_altitude', altitude_ft: '8000' }), false) + assert.equal( + isValidSimControlCommand({ type: 'setup_approach', airport_icao: 'eddf', runway: '07R' }), + false, + ) + assert.equal( + isValidSimControlCommand({ type: 'setup_approach', airport_icao: 'EDDF', runway: '99' }), + false, + ) + }) +}) + +describe('simControlRejectionSpeech', () => { + it('has a distinct short reply for every non-no_intent reason', () => { + const reasons = [ + 'missing_value', 'missing_unit', 'out_of_range', + 'invalid_runway', 'missing_runway', 'missing_airport', + ] as const + const seen = new Set() + for (const reason of reasons) { + const speech = simControlRejectionSpeech(reason) + assert.equal(typeof speech, 'string') + assert.ok(speech.length > 0) + seen.add(speech) + } + assert.equal(seen.size, reasons.length) + }) +}) + +describe('simControlResultSpeech', () => { + it('confirms an approach setup with a final distance', () => { + const speech = simControlResultSpeech({ + id: '1', + status: 'ok', + reason: null, + command: { type: 'setup_approach', airport_icao: 'EDDF', runway: '07R', final_distance_nm: 5 }, + }) + assert.equal(speech, 'repositioned, 5 mile final runway 07R') + }) + + it('confirms an approach setup without a final distance but with altitude', () => { + const speech = simControlResultSpeech({ + id: '1', + status: 'ok', + reason: null, + command: { type: 'setup_approach', airport_icao: 'EDDF', runway: '07R', altitude_ft: 5000 }, + }) + assert.equal(speech, 'repositioned, runway 07R approach from 5000 feet') + }) + + it('confirms altitude/heading/speed changes', () => { + assert.equal( + simControlResultSpeech({ id: '1', status: 'ok', reason: null, command: { type: 'set_altitude', altitude_ft: 8000 } }), + 'altitude set, 8000 feet', + ) + assert.equal( + simControlResultSpeech({ id: '1', status: 'ok', reason: null, command: { type: 'set_heading', heading_deg: 270 } }), + 'heading set, 270', + ) + assert.equal( + simControlResultSpeech({ id: '1', status: 'ok', reason: null, command: { type: 'set_speed', ias_kts: 210 } }), + 'speed set, 210 knots', + ) + }) + + it('relays a failure reason from the bridge', () => { + const speech = simControlResultSpeech({ + id: '1', + status: 'failed', + reason: 'aircraft on ground, airborne reposition refused', + command: { type: 'set_heading', heading_deg: 270 }, + }) + assert.equal(speech, 'unable, aircraft on ground, airborne reposition refused') + }) + + it('falls back to a generic failure line when no reason is given', () => { + const speech = simControlResultSpeech({ + id: '1', status: 'failed', reason: null, command: { type: 'set_heading', heading_deg: 270 }, + }) + assert.equal(speech, 'unable to comply') + }) + + it('announces an expired (unanswered) command', () => { + const speech = simControlResultSpeech({ + id: '1', status: 'expired', reason: null, command: { type: 'set_heading', heading_deg: 270 }, + }) + assert.equal(speech, 'bridge did not respond') + }) +})