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 1048b1dbbc
commit 3fe0ea5f8a
11 changed files with 691 additions and 4 deletions

View 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 }
})

View 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 }
})

View File

@@ -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),
}
})

View File

@@ -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),
}
})