Files
OpenSquawk/shared/utils/simControl.ts
itsrubberduck 3fe0ea5f8a 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>
2026-07-15 09:31:17 +02:00

261 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Frequency-driven simulator control (roadmap key `frequency-sim-control`).
//
// Parses free-form SIM SETUP commands the pilot speaks on frequency ("set me
// up for an approach from 5000 ft to EDDF 07R", "change my altitude to 8000")
// into structured commands for the bridge. These are meta commands from the
// user to their OWN simulator instance — deliberately a different grammar from
// ICAO readback phraseology (which runs through sttMatch.ts) so an ordinary
// ATC readback can never be mistaken for a sim command.
//
// Fail-closed by design: anything ambiguous returns { matched: false } with a
// reason instead of a best-effort guess — a misparsed command changes the
// user's real sim state. Parameter names follow the NormalizedTelemetry
// convention (useRadioBackend.ts): the command travels toward the bridge/sim,
// so it speaks the sim-side vocabulary (altitude_ft, ias_kts, heading_deg).
import { denormalizeSpokenAtc, normalizeForMatch } from './sttMatch'
export type SimControlCommand =
| { type: 'set_altitude'; altitude_ft: number }
| { type: 'set_heading'; heading_deg: number }
| { type: 'set_speed'; ias_kts: number }
| {
type: 'setup_approach'
/** Uppercase 4-letter ICAO ("EDDF"). Existence is the caller's check. */
airport_icao: string
/** "07R", "26L", "23" — zero-padded number 0136 + optional L/R/C. */
runway: string
altitude_ft?: number
final_distance_nm?: number
}
export type SimControlNoMatchReason =
| 'no_intent'
| 'missing_value'
| 'missing_unit'
| 'out_of_range'
| 'invalid_runway'
| 'missing_runway'
| 'missing_airport'
export type SimControlParseResult =
| { matched: true; command: SimControlCommand; text: string }
| { matched: false; reason: SimControlNoMatchReason; text: string }
/** Hard value ranges; anything outside is a refusal, never a clamp. */
export const SIM_CONTROL_LIMITS = {
altitude_ft: { min: 0, max: 45000 },
heading_deg: { min: 1, max: 360 },
ias_kts: { min: 60, max: 400 },
final_distance_nm: { min: 1, max: 30 },
} as const
// 4-letter English words that can follow "to/at/for" in an approach phrase
// and must never be mistaken for an ICAO code.
const ICAO_STOPWORDS = new Set([
'left', 'right', 'feet', 'final', 'mile', 'nautical', 'from', 'with', 'land',
])
function noMatch(reason: SimControlNoMatchReason, text: string): SimControlParseResult {
return { matched: false, reason, text }
}
function inRange(value: number, limit: { min: number; max: number }): boolean {
return value >= limit.min && value <= limit.max
}
/** "flight level 100" / "fl100" → feet, or null when no FL phrase is present. */
function flightLevelFeet(fragment: string): number | null {
const fl = fragment.match(/\b(?:flight level|fl) ?(\d{1,3})\b/)
return fl ? Number(fl[1]) * 100 : null
}
function parseApproach(text: string): SimControlParseResult {
// Runway: keyword form ("runway 26 l" → "runway 26l" after denormalize) or a
// bare suffixed token ("25r"). A bare number WITHOUT suffix is only accepted
// behind the "runway" keyword — a lone "25" in the sentence is too ambiguous.
const keyword = text.match(/\brunway (\d{1,2}) ?([lrc])?\b/)
const bareToken = text.match(/(?:^|\s)(\d{2})([lrc])(?:\s|$)/)
const rwNumRaw = keyword?.[1] ?? bareToken?.[1]
const rwSuffix = (keyword ? keyword[2] : bareToken?.[2]) ?? ''
if (!rwNumRaw) return noMatch('missing_runway', text)
const rwNum = Number(rwNumRaw)
if (rwNum < 1 || rwNum > 36) return noMatch('invalid_runway', text)
const runway = `${rwNumRaw.padStart(2, '0')}${rwSuffix.toUpperCase()}`
const airport = text.match(/\b(?:to|at|for|into) ([a-z]{4})\b/)
if (!airport || ICAO_STOPWORDS.has(airport[1]!)) return noMatch('missing_airport', text)
const airport_icao = airport[1]!.toUpperCase()
// Altitude is optional, but WHEN a "from <number>" is present it must carry
// an explicit unit (feet/ft) or be a flight level — a bare "from 5000" is
// ambiguous and refused rather than guessed.
let altitude_ft: number | undefined
const fromFl = text.match(/\bfrom (?:flight level|fl) ?(\d{1,3})\b/)
const fromFt = text.match(/\bfrom (\d{3,5}) ?(?:feet|ft)\b/)
if (fromFl) altitude_ft = Number(fromFl[1]) * 100
else if (fromFt) altitude_ft = Number(fromFt[1])
else if (/\bfrom \d/.test(text)) return noMatch('missing_unit', text)
if (altitude_ft !== undefined && !inRange(altitude_ft, SIM_CONTROL_LIMITS.altitude_ft)) {
return noMatch('out_of_range', text)
}
let final_distance_nm: number | undefined
const dist = text.match(/\b(\d{1,2}) ?(?:miles?|nm|nautical miles?) final\b/)
if (dist) {
final_distance_nm = Number(dist[1])
if (!inRange(final_distance_nm, SIM_CONTROL_LIMITS.final_distance_nm)) {
return noMatch('out_of_range', text)
}
}
const command: SimControlCommand = { type: 'setup_approach', airport_icao, runway }
if (altitude_ft !== undefined) command.altitude_ft = altitude_ft
if (final_distance_nm !== undefined) command.final_distance_nm = final_distance_nm
return { matched: true, command, text }
}
function parseParameter(
parameter: 'altitude' | 'heading' | 'speed',
rest: string,
text: string,
): SimControlParseResult {
if (parameter === 'altitude') {
// "altitude" names the unit context, so a bare number is unambiguous here
// (this is the literal roadmap wording "change my altitude to 8000").
const fl = flightLevelFeet(rest)
const num = fl ?? Number(rest.match(/\b(\d{1,6})\b/)?.[1] ?? NaN)
if (!Number.isFinite(num)) return noMatch('missing_value', text)
if (!inRange(num, SIM_CONTROL_LIMITS.altitude_ft)) return noMatch('out_of_range', text)
return { matched: true, command: { type: 'set_altitude', altitude_ft: num }, text }
}
if (parameter === 'heading') {
const num = Number(rest.match(/\b(\d{1,3})\b/)?.[1] ?? NaN)
if (!Number.isFinite(num)) return noMatch('missing_value', text)
if (!inRange(num, SIM_CONTROL_LIMITS.heading_deg)) return noMatch('out_of_range', text)
return { matched: true, command: { type: 'set_heading', heading_deg: num }, text }
}
const num = Number(rest.match(/\b(\d{2,3})\b/)?.[1] ?? NaN)
if (!Number.isFinite(num)) return noMatch('missing_value', text)
if (!inRange(num, SIM_CONTROL_LIMITS.ias_kts)) return noMatch('out_of_range', text)
return { matched: true, command: { type: 'set_speed', ias_kts: num }, text }
}
/**
* Parse a transmission into a sim-control command, or refuse.
*
* The intent gate is intentionally narrow: only explicit self-service anchors
* ("set me up …", "put me …", "reposition …" for approaches; "change/set my
* <parameter> …" for single values) enter parsing at all. Regular phraseology
* ("descend to 5000 feet", "request descent …", "cleared to land …") carries
* none of these anchors and always returns `no_intent` — that property is the
* safety contract of this module and is covered by tests.
*/
export function parseSimControl(input: string): SimControlParseResult {
const text = normalizeForMatch(denormalizeSpokenAtc(input))
if (!text) return noMatch('no_intent', text)
const wantsApproach = /\b(?:set me up|put me|reposition(?: me)?)\b.*\b(?:approach|final)\b/.test(text)
if (wantsApproach) return parseApproach(text)
const parameter = text.match(/\b(?:change|set) my (altitude|heading|speed)\b(.*)$/)
if (parameter) {
return parseParameter(parameter[1] as 'altitude' | 'heading' | 'speed', parameter[2] ?? '', text)
}
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`
}
}