mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
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:
55
server/api/bridge/command-result.post.ts
Normal file
55
server/api/bridge/command-result.post.ts
Normal 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 }
|
||||
})
|
||||
54
server/api/bridge/command.post.ts
Normal file
54
server/api/bridge/command.post.ts
Normal 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 }
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
|
||||
122
server/utils/simControlQueue.ts
Normal file
122
server/utils/simControlQueue.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* In-memory command queue for the frequency-sim-control channel (design doc
|
||||
* §4, `docs/plans/2026-07-14-frequency-sim-control-design.md`).
|
||||
*
|
||||
* Flow: /live-atc client → POST /api/bridge/command (enqueue) → this queue
|
||||
* → POST /api/bridge/data response `commands` field (bridge picks up) →
|
||||
* bridge executes → POST /api/bridge/command-result (resolve) → this queue
|
||||
* → GET /api/bridge/live response `commandResults` field (client announces).
|
||||
*
|
||||
* Keyed by bridge token, not userId: both ends of this channel authenticate
|
||||
* with the same x-bridge-token the telemetry channel already uses.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { PendingCommand, SimControlCommand, SimControlCommandResult, SimControlCommandStatus } from '../../shared/utils/simControl'
|
||||
|
||||
type RecordStatus = 'pending' | 'delivered' | SimControlCommandStatus
|
||||
|
||||
interface CommandRecord {
|
||||
id: string
|
||||
token: string
|
||||
command: SimControlCommand
|
||||
issuedAt: number
|
||||
status: RecordStatus
|
||||
reason: string | null
|
||||
consumedByClient: boolean
|
||||
}
|
||||
|
||||
export interface SimControlQueueOptions {
|
||||
/** Commands not delivered/resolved within this window are treated as expired. */
|
||||
ttlMs?: number
|
||||
/** Clock override for deterministic tests. */
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export class SimControlQueue {
|
||||
private records = new Map<string, CommandRecord>()
|
||||
private pendingByToken = new Map<string, string[]>()
|
||||
private ttlMs: number
|
||||
private now: () => number
|
||||
|
||||
constructor(opts: SimControlQueueOptions = {}) {
|
||||
this.ttlMs = opts.ttlMs ?? 30_000
|
||||
this.now = opts.now ?? Date.now
|
||||
}
|
||||
|
||||
/** Enqueue a parsed command for the bridge behind this token to pick up. */
|
||||
enqueue(token: string, command: SimControlCommand): PendingCommand {
|
||||
const id = randomUUID()
|
||||
const issuedAt = this.now()
|
||||
const record: CommandRecord = { id, token, command, issuedAt, status: 'pending', reason: null, consumedByClient: false }
|
||||
this.records.set(id, record)
|
||||
const queue = this.pendingByToken.get(token) ?? []
|
||||
queue.push(id)
|
||||
this.pendingByToken.set(token, queue)
|
||||
return { id, issued_at: new Date(issuedAt).toISOString(), command }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from POST /api/bridge/data: hand every still-pending command for
|
||||
* this token to the bridge and mark it delivered. Each command is handed
|
||||
* out exactly once — the caller does not re-poll for it.
|
||||
*/
|
||||
drainPending(token: string): PendingCommand[] {
|
||||
this.sweepExpired()
|
||||
const ids = this.pendingByToken.get(token) ?? []
|
||||
this.pendingByToken.set(token, [])
|
||||
const out: PendingCommand[] = []
|
||||
for (const id of ids) {
|
||||
const record = this.records.get(id)
|
||||
if (!record || record.status !== 'pending') continue // expired while queued
|
||||
record.status = 'delivered'
|
||||
out.push({ id: record.id, issued_at: new Date(record.issuedAt).toISOString(), command: record.command })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from POST /api/bridge/command-result: the bridge reports the
|
||||
* outcome of a delivered command. Returns false if the id is unknown or
|
||||
* belongs to a different token (never trust the caller's own claim).
|
||||
*/
|
||||
resolve(token: string, id: string, status: 'ok' | 'failed', reason?: string | null): boolean {
|
||||
const record = this.records.get(id)
|
||||
if (!record || record.token !== token) return false
|
||||
if (record.status !== 'pending' && record.status !== 'delivered') return false
|
||||
record.status = status
|
||||
record.reason = reason ?? null
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from GET /api/bridge/live: hand the client every terminal result
|
||||
* for this token it hasn't seen yet, then forget them (one-shot delivery,
|
||||
* same as drainPending on the bridge side).
|
||||
*/
|
||||
drainResultsForClient(token: string): SimControlCommandResult[] {
|
||||
this.sweepExpired()
|
||||
const out: SimControlCommandResult[] = []
|
||||
for (const record of this.records.values()) {
|
||||
if (record.token !== token || record.consumedByClient) continue
|
||||
if (record.status === 'ok' || record.status === 'failed' || record.status === 'expired') {
|
||||
record.consumedByClient = true
|
||||
out.push({ id: record.id, command: record.command, status: record.status, reason: record.reason })
|
||||
}
|
||||
}
|
||||
for (const record of out) this.records.delete(record.id)
|
||||
return out
|
||||
}
|
||||
|
||||
private sweepExpired() {
|
||||
const cutoff = this.now() - this.ttlMs
|
||||
for (const record of this.records.values()) {
|
||||
if ((record.status === 'pending' || record.status === 'delivered') && record.issuedAt < cutoff) {
|
||||
record.status = 'expired'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton — shared across all server handlers, same pattern as flightlabTelemetryStore.
|
||||
export const simControlQueue = new SimControlQueue()
|
||||
Reference in New Issue
Block a user