mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +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 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<typeof useSessionState>
|
||||
@@ -22,6 +29,8 @@ export interface LiveAtcSessionDeps {
|
||||
isRecording: Ref<boolean>
|
||||
bridgeConnected: Ref<boolean>
|
||||
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
|
||||
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,
|
||||
|
||||
@@ -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> | void
|
||||
startRecording: (fromPad: boolean) => Promise<void> | 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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
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 {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),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// --- 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 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<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