mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-09 19:16:02 +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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user