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:
itsrubberduck
2026-07-15 09:31:17 +02:00
parent e18995d7cd
commit ab04411bc5
11 changed files with 691 additions and 4 deletions

View File

@@ -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