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

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