mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
feat(sim-control): wire frequency-sim-control command channel end-to-end
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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,13 @@ import type { useSessionState } from '~/composables/useSessionState'
|
|||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import type { useRadioSpeech } from '~/composables/useRadioSpeech'
|
import type { useRadioSpeech } from '~/composables/useRadioSpeech'
|
||||||
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
||||||
|
import {
|
||||||
|
parseSimControl,
|
||||||
|
simControlRejectionSpeech,
|
||||||
|
simControlResultSpeech,
|
||||||
|
type SimControlCommand,
|
||||||
|
type SimControlCommandResult,
|
||||||
|
} from '../../shared/utils/simControl'
|
||||||
|
|
||||||
export interface LiveAtcSessionDeps {
|
export interface LiveAtcSessionDeps {
|
||||||
state: ReturnType<typeof useSessionState>
|
state: ReturnType<typeof useSessionState>
|
||||||
@@ -22,6 +29,8 @@ export interface LiveAtcSessionDeps {
|
|||||||
isRecording: Ref<boolean>
|
isRecording: Ref<boolean>
|
||||||
bridgeConnected: Ref<boolean>
|
bridgeConnected: Ref<boolean>
|
||||||
bridgePosition: Ref<{ lat: number; lon: number } | null>
|
bridgePosition: Ref<{ lat: number; lon: number } | null>
|
||||||
|
/** ?token=… from the route — auths the frequency-sim-control channel (design §4). */
|
||||||
|
bridgeToken: Ref<string>
|
||||||
persistSelectedPlan: (plan: any | null) => void
|
persistSelectedPlan: (plan: any | null) => void
|
||||||
maybeShowFirstRunHelp: () => void
|
maybeShowFirstRunHelp: () => void
|
||||||
}
|
}
|
||||||
@@ -47,7 +56,8 @@ export function useLiveAtcSession(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
state, freq, speech, radioBackend, api, config, prefetchAtisAudio,
|
state, freq, speech, radioBackend, api, config, prefetchAtisAudio,
|
||||||
isRecording, bridgeConnected, bridgePosition, persistSelectedPlan, maybeShowFirstRunHelp,
|
isRecording, bridgeConnected, bridgePosition, bridgeToken,
|
||||||
|
persistSelectedPlan, maybeShowFirstRunHelp,
|
||||||
} = deps
|
} = deps
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -205,6 +215,33 @@ export function useLiveAtcSession(
|
|||||||
armSilenceTimer()
|
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 handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => {
|
||||||
const transcript = message.trim()
|
const transcript = message.trim()
|
||||||
// Ignore empty or content-free transmissions: silence, a stray PTT tap, or
|
// Ignore empty or content-free transmissions: silence, a stray PTT tap, or
|
||||||
@@ -278,6 +315,26 @@ export function useLiveAtcSession(
|
|||||||
return
|
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 ---
|
// --- Frequency check ---
|
||||||
// Reject the transmission if the pilot is on the wrong frequency. A position
|
// 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
|
// can publish several valid frequencies (e.g. two Tower freqs); accept any of
|
||||||
@@ -700,6 +757,7 @@ export function useLiveAtcSession(
|
|||||||
clearSilenceTimer,
|
clearSilenceTimer,
|
||||||
applyBackendDecision,
|
applyBackendDecision,
|
||||||
handlePilotTransmission,
|
handlePilotTransmission,
|
||||||
|
handleSimControlResult,
|
||||||
loadFlightPlans,
|
loadFlightPlans,
|
||||||
startMonitoring,
|
startMonitoring,
|
||||||
startDemoFlight,
|
startDemoFlight,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry'
|
import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry'
|
||||||
import { pmLog } from '../../shared/utils/pmLog'
|
import { pmLog } from '../../shared/utils/pmLog'
|
||||||
|
import type { SimControlCommandResult } from '../../shared/utils/simControl'
|
||||||
import type { useFrequencyPresets } from '~/composables/useFrequencyPresets'
|
import type { useFrequencyPresets } from '~/composables/useFrequencyPresets'
|
||||||
|
|
||||||
export interface SimBridgeSyncDeps {
|
export interface SimBridgeSyncDeps {
|
||||||
@@ -14,6 +15,8 @@ export interface SimBridgeSyncDeps {
|
|||||||
resumePrerecIfSuspended: () => Promise<void> | void
|
resumePrerecIfSuspended: () => Promise<void> | void
|
||||||
startRecording: (fromPad: boolean) => Promise<void> | void
|
startRecording: (fromPad: boolean) => Promise<void> | void
|
||||||
stopRecording: () => 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 { frequencies } = freq
|
||||||
const {
|
const {
|
||||||
backendSessionId, radioBackend, applyBackendDecision, stopCurrentSpeech,
|
backendSessionId, radioBackend, applyBackendDecision, stopCurrentSpeech,
|
||||||
resumePrerecIfSuspended, startRecording, stopRecording,
|
resumePrerecIfSuspended, startRecording, stopRecording, onCommandResult,
|
||||||
} = deps
|
} = deps
|
||||||
|
|
||||||
const bridgeToken = computed(() => {
|
const bridgeToken = computed(() => {
|
||||||
@@ -88,10 +91,23 @@ export function useSimBridgeSync(
|
|||||||
const token = bridgeToken.value
|
const token = bridgeToken.value
|
||||||
if (!token) return
|
if (!token) return
|
||||||
try {
|
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',
|
'/api/bridge/live',
|
||||||
{ headers: { 'x-bridge-token': token } },
|
{ 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 ts = res.lastTelemetryAt ? Date.parse(res.lastTelemetryAt) : null
|
||||||
const fresh = Boolean(res.connected && ts && Date.now() - ts < BRIDGE_TELEMETRY_STALE_MS)
|
const fresh = Boolean(res.connected && ts && Date.now() - ts < BRIDGE_TELEMETRY_STALE_MS)
|
||||||
bridgeConnected.value = fresh
|
bridgeConnected.value = fresh
|
||||||
|
|||||||
@@ -462,6 +462,9 @@ const handlePilotTransmission = (message: string, source: 'text' | 'ptt' = 'text
|
|||||||
const applyBackendDecision = (
|
const applyBackendDecision = (
|
||||||
response: import('~/composables/useRadioBackend').RadioTransmitResponse,
|
response: import('~/composables/useRadioBackend').RadioTransmitResponse,
|
||||||
) => session.applyBackendDecision(response)
|
) => session.applyBackendDecision(response)
|
||||||
|
const handleSimControlResult = (
|
||||||
|
result: import('../../shared/utils/simControl').SimControlCommandResult,
|
||||||
|
) => session.handleSimControlResult(result)
|
||||||
|
|
||||||
const speech = useRadioSpeech(engine, freq, speechInterrupt, {
|
const speech = useRadioSpeech(engine, freq, speechInterrupt, {
|
||||||
setLastTransmission,
|
setLastTransmission,
|
||||||
@@ -539,6 +542,7 @@ watch(prerecEnabled, (val) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
bridgeToken,
|
||||||
bridgeConnected,
|
bridgeConnected,
|
||||||
bridgeSimActiveFreq,
|
bridgeSimActiveFreq,
|
||||||
bridgePosition,
|
bridgePosition,
|
||||||
@@ -551,6 +555,7 @@ const {
|
|||||||
resumePrerecIfSuspended,
|
resumePrerecIfSuspended,
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
|
onCommandResult: handleSimControlResult,
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = useLiveAtcSession(engine, {
|
const session = useLiveAtcSession(engine, {
|
||||||
@@ -564,6 +569,7 @@ const session = useLiveAtcSession(engine, {
|
|||||||
isRecording,
|
isRecording,
|
||||||
bridgeConnected,
|
bridgeConnected,
|
||||||
bridgePosition,
|
bridgePosition,
|
||||||
|
bridgeToken,
|
||||||
persistSelectedPlan,
|
persistSelectedPlan,
|
||||||
maybeShowFirstRunHelp,
|
maybeShowFirstRunHelp,
|
||||||
})
|
})
|
||||||
|
|||||||
55
server/api/bridge/command-result.post.ts
Normal file
55
server/api/bridge/command-result.post.ts
Normal file
@@ -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: <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<CommandResultBody>(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 }
|
||||||
|
})
|
||||||
54
server/api/bridge/command.post.ts
Normal file
54
server/api/bridge/command.post.ts
Normal file
@@ -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: <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<CommandBody>(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 }
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import {BridgeToken} from '../../models/BridgeToken'
|
|||||||
import {getBridgeTokenFromHeader} from '../../utils/bridge'
|
import {getBridgeTokenFromHeader} from '../../utils/bridge'
|
||||||
import {logBridgeEvent} from '../../utils/bridgeLog'
|
import {logBridgeEvent} from '../../utils/bridgeLog'
|
||||||
import {flightlabTelemetryStore} from '../../utils/flightlabTelemetry'
|
import {flightlabTelemetryStore} from '../../utils/flightlabTelemetry'
|
||||||
|
import {simControlQueue} from '../../utils/simControlQueue'
|
||||||
import type {FlightLabTelemetryState} from '../../../shared/data/flightlab/types'
|
import type {FlightLabTelemetryState} from '../../../shared/data/flightlab/types'
|
||||||
|
|
||||||
type DomeLightMode = 'off' | 'white' | 'amber'
|
type DomeLightMode = 'off' | 'white' | 'amber'
|
||||||
@@ -176,5 +177,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
// gear und parking break togglen
|
// gear und parking break togglen
|
||||||
// gear_handle: !mapped.GEAR_HANDLE_POSITION,
|
// gear_handle: !mapped.GEAR_HANDLE_POSITION,
|
||||||
parking_brake: !mapped.BRAKE_PARKING_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),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createError } from 'h3'
|
|||||||
import { BridgeToken } from '../../models/BridgeToken'
|
import { BridgeToken } from '../../models/BridgeToken'
|
||||||
import { getBridgeTokenFromHeader } from '../../utils/bridge'
|
import { getBridgeTokenFromHeader } from '../../utils/bridge'
|
||||||
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
|
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
|
||||||
|
import { simControlQueue } from '../../utils/simControlQueue'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const token = getBridgeTokenFromHeader(event)
|
const token = getBridgeTokenFromHeader(event)
|
||||||
@@ -17,6 +18,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
connected: false,
|
connected: false,
|
||||||
lastTelemetryAt: null,
|
lastTelemetryAt: null,
|
||||||
telemetry: null,
|
telemetry: null,
|
||||||
|
commandResults: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,5 +31,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
connected: true,
|
connected: true,
|
||||||
lastTelemetryAt: timestamp,
|
lastTelemetryAt: timestamp,
|
||||||
telemetry,
|
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),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
122
server/utils/simControlQueue.ts
Normal file
122
server/utils/simControlQueue.ts
Normal file
@@ -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<string, CommandRecord>()
|
||||||
|
private pendingByToken = new Map<string, string[]>()
|
||||||
|
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()
|
||||||
@@ -165,3 +165,96 @@ export function parseSimControl(input: string): SimControlParseResult {
|
|||||||
|
|
||||||
return noMatch('no_intent', text)
|
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<string, unknown>
|
||||||
|
|
||||||
|
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<SimControlNoMatchReason, 'no_intent'>): 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`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
144
tests/server/simControlQueue.test.ts
Normal file
144
tests/server/simControlQueue.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { describe, it } from 'node:test'
|
import { describe, it } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
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) {
|
function expectCommand(input: string) {
|
||||||
const result = parseSimControl(input)
|
const result = parseSimControl(input)
|
||||||
@@ -146,3 +151,128 @@ describe('parseSimControl — must NEVER match ATC dialogue', () => {
|
|||||||
expectNoMatch('set me up at 8000')
|
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<string>()
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user