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

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

View File

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