mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 14:16:05 +08:00
feat(pm): forward normalized sim telemetry to the backend decision engine
Send live bridge telemetry to the backend so it can drive proactive, aircraft-state-aware ATC. Purely additive — bridge-less sessions are unchanged. - NormalizedTelemetry contract + sendTelemetry() + telemetry_fired (useRadioBackend.ts), sim-agnostic and matched to the backend contract - extract applyBackendDecision() so telemetry- and pilot-driven ATC responses land through one shared path - normalizeBridgeTelemetry() + change-detection + forwardTelemetryToBackend() wired into the existing bridge poll Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,12 +20,31 @@ export interface RadioTransmitResponse {
|
||||
fallback_used: boolean
|
||||
fallback_reason: string | null
|
||||
auto_advanced_states?: string[]
|
||||
/** True when a telemetry tick actually fired a transition (vs. an idle poll). */
|
||||
telemetry_fired?: boolean
|
||||
/** True when the full chain is done — show the completion screen. */
|
||||
session_complete?: boolean
|
||||
/** Per-field readback diagnostic for the pilot state just evaluated. */
|
||||
readback_report?: ReadbackFieldDetail[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sim-agnostic telemetry contract. The bridge layer (MSFS SimConnect today,
|
||||
* X-Plane datarefs later) is normalised to exactly these keys before it reaches
|
||||
* the backend, so the decision engine only ever compares plain scalars. Keep
|
||||
* this in lock-step with `TelemetryParameter` in the Python backend (models.py).
|
||||
*/
|
||||
export interface NormalizedTelemetry {
|
||||
altitude_ft?: number // feet MSL
|
||||
ias_kts?: number // indicated airspeed, knots
|
||||
gs_kts?: number // groundspeed, knots
|
||||
vs_fpm?: number // vertical speed, feet/min (+climb, -descent)
|
||||
heading_deg?: number // magnetic heading, 0..360
|
||||
on_ground?: boolean // wheels on the ground
|
||||
distance_to_dest_nm?: number // great-circle nm to destination airport
|
||||
distance_to_dep_nm?: number // great-circle nm from departure airport
|
||||
}
|
||||
|
||||
export interface ReadbackFieldDetail {
|
||||
field: string
|
||||
expected: string
|
||||
@@ -74,6 +93,24 @@ export function useRadioBackend() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a normalised telemetry tick. The backend fires any telemetry-gated
|
||||
* transition whose threshold is met and returns the same shape as transmit;
|
||||
* idle ticks come back with `telemetry_fired: false` and the state unchanged.
|
||||
*/
|
||||
async function sendTelemetry(
|
||||
sessionId: string,
|
||||
telemetry: NormalizedTelemetry,
|
||||
): Promise<RadioTransmitResponse> {
|
||||
return await $fetch<RadioTransmitResponse>(
|
||||
`${baseUrl()}/api/radio/session/${sessionId}/telemetry`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { telemetry },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string): Promise<void> {
|
||||
await $fetch(`${baseUrl()}/api/radio/session/${sessionId}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -82,5 +119,5 @@ export function useRadioBackend() {
|
||||
return await $fetch(`${baseUrl()}/api/decision-flows/runtime`)
|
||||
}
|
||||
|
||||
return { createSession, transmit, deleteSession, fetchFlows }
|
||||
return { createSession, transmit, sendTelemetry, deleteSession, fetchFlows }
|
||||
}
|
||||
|
||||
218
app/pages/pm.vue
218
app/pages/pm.vue
@@ -3380,6 +3380,83 @@ const speakPilotReadback = (text: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Apply a backend decision (from a pilot transmission OR a telemetry tick) to
|
||||
// the local engine + UI: sync the flow/cursor/variables/flags, speak the
|
||||
// controller reply, and surface completion. Shared so telemetry-driven ATC and
|
||||
// pilot-driven ATC land through exactly one code path.
|
||||
const applyBackendDecision = (
|
||||
response: import('../composables/useRadioBackend').RadioTransmitResponse,
|
||||
opts: { transcript?: string } = {},
|
||||
) => {
|
||||
// If the backend has chained to a different flow, switch the local engine
|
||||
// first so moveToSilent can find the new states and expectedFrequencyForState()
|
||||
// returns the correct frequency_name.
|
||||
if (response.active_flow && response.active_flow !== activeFlow.value) {
|
||||
pmLog.info('FLOW CHANGE local:', activeFlow.value, '→ backend:', response.active_flow)
|
||||
try {
|
||||
setActiveFlow(response.active_flow)
|
||||
} catch (e) {
|
||||
pmLog.warn('setActiveFlow failed for', response.active_flow, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Advance local cursor through every state the backend auto-walked, then the
|
||||
// final state. moveToSilent updates current_unit, actions, handoffs and the
|
||||
// communication log without scheduling further auto-transitions.
|
||||
for (const stateId of response.auto_advanced_states ?? []) {
|
||||
pmLog.debug('moveToSilent ← auto_advanced:', stateId)
|
||||
moveToSilent(stateId)
|
||||
}
|
||||
pmLog.debug('moveToSilent ← next_state_id:', response.next_state_id)
|
||||
moveToSilent(response.next_state_id)
|
||||
|
||||
// Capture the per-field readback diagnostic for the STT debug panel.
|
||||
lastReadbackReport.value = response.readback_report ?? []
|
||||
if (opts.transcript !== undefined) lastReadbackTranscript.value = opts.transcript
|
||||
|
||||
// Update expected phrase AFTER cursor moves so any local-engine reactive
|
||||
// updates from moveToSilent have already settled.
|
||||
backendExpectedPhrase.value = response.expected_pilot_template ?? null
|
||||
|
||||
// Sync variables + routing flags from backend so the local renderer stays in step.
|
||||
if (response.variables && Object.keys(response.variables).length) {
|
||||
patchVariables(response.variables)
|
||||
pmLog.debug('variables synced:', response.variables)
|
||||
}
|
||||
if (response.flags && Object.keys(response.flags).length) {
|
||||
patchFlags(response.flags)
|
||||
pmLog.debug('flags synced:', response.flags)
|
||||
}
|
||||
|
||||
// TTS: use the pre-rendered string from the backend (correct variable values).
|
||||
let sayText = response.controller_say_rendered || response.controller_say_template
|
||||
const rb = response.readback_report ?? []
|
||||
const readbackPassed = rb.length > 0 && rb.every((r: any) => r.matched)
|
||||
if (sayText && readbackPassed && !/^\s*(readback correct|correct[,.\s]|negative|i say again)/i.test(sayText)) {
|
||||
sayText = `Readback correct. ${sayText}`
|
||||
}
|
||||
if (sayText) {
|
||||
pmLog.info('TTS →', sayText)
|
||||
lastControllerSay.value = sayText
|
||||
scheduleControllerSpeech(sayText)
|
||||
appendLogEntry('atc', sayText, response.next_state_id, {
|
||||
flow: response.active_flow,
|
||||
frequency: frequencies.value.active,
|
||||
})
|
||||
}
|
||||
|
||||
if (response.fallback_used) {
|
||||
pmLog.warn('FALLBACK USED:', response.fallback_reason)
|
||||
console.warn('[Backend] Fallback used:', response.fallback_reason)
|
||||
}
|
||||
|
||||
if (response.session_complete) {
|
||||
pmLog.info('SESSION COMPLETE — showing completion screen')
|
||||
completedScenario.value = activeScenario.value
|
||||
currentScreen.value = 'complete'
|
||||
}
|
||||
}
|
||||
|
||||
const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => {
|
||||
const transcript = message.trim()
|
||||
// Ignore empty or content-free transmissions: silence, a stray PTT tap, or
|
||||
@@ -3521,84 +3598,7 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
|
||||
}
|
||||
})
|
||||
|
||||
// If the backend has chained to a different flow, switch the local engine
|
||||
// to that flow first so moveToSilent can find the new states and
|
||||
// expectedFrequencyForState() returns the correct frequency_name.
|
||||
if (response.active_flow && response.active_flow !== activeFlow.value) {
|
||||
pmLog.info('FLOW CHANGE local:', activeFlow.value, '→ backend:', response.active_flow)
|
||||
try {
|
||||
setActiveFlow(response.active_flow)
|
||||
} catch (e) {
|
||||
pmLog.warn('setActiveFlow failed for', response.active_flow, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Advance local cursor through every state the backend auto-walked, then
|
||||
// the final state. moveToSilent updates current_unit, actions, handoffs,
|
||||
// and the communication log without scheduling further auto-transitions.
|
||||
for (const stateId of response.auto_advanced_states ?? []) {
|
||||
pmLog.debug('moveToSilent ← auto_advanced:', stateId)
|
||||
moveToSilent(stateId)
|
||||
}
|
||||
pmLog.debug('moveToSilent ← next_state_id:', response.next_state_id)
|
||||
moveToSilent(response.next_state_id)
|
||||
|
||||
// Capture the per-field readback diagnostic for the STT debug panel.
|
||||
lastReadbackReport.value = response.readback_report ?? []
|
||||
lastReadbackTranscript.value = transcript
|
||||
|
||||
// Update expected phrase AFTER cursor moves so any local-engine reactive
|
||||
// updates from moveToSilent have already settled.
|
||||
backendExpectedPhrase.value = response.expected_pilot_template ?? null
|
||||
|
||||
// Sync variables from backend — keeps local renderer in step with backend state.
|
||||
// Uses patchVariables() which writes directly to the engine's reactive store,
|
||||
// bypassing the readonly(ref) wrapper that silently blocks (vars as any).value[k] = v.
|
||||
if (response.variables && Object.keys(response.variables).length) {
|
||||
patchVariables(response.variables)
|
||||
pmLog.debug('variables synced:', response.variables)
|
||||
}
|
||||
|
||||
// Sync boolean routing flags (in_air, emergency_active, etc.) from backend.
|
||||
if (response.flags && Object.keys(response.flags).length) {
|
||||
patchFlags(response.flags)
|
||||
pmLog.debug('flags synced:', response.flags)
|
||||
}
|
||||
|
||||
// TTS: use the pre-rendered string from the backend (correct variable values).
|
||||
// Fall back to rendering the template locally if rendered is absent.
|
||||
let sayText = response.controller_say_rendered || response.controller_say_template
|
||||
// Positive readback confirmation (#10): when every required readback field
|
||||
// was matched, prefix "Readback correct" — unless the controller line is
|
||||
// already a confirmation/correction — so it isn't only the IFR clearance
|
||||
// that acknowledges a good readback.
|
||||
const rb = response.readback_report ?? []
|
||||
const readbackPassed = rb.length > 0 && rb.every((r: any) => r.matched)
|
||||
if (sayText && readbackPassed && !/^\s*(readback correct|correct[,.\s]|negative|i say again)/i.test(sayText)) {
|
||||
sayText = `Readback correct. ${sayText}`
|
||||
}
|
||||
if (sayText) {
|
||||
pmLog.info('TTS →', sayText)
|
||||
lastControllerSay.value = sayText
|
||||
scheduleControllerSpeech(sayText)
|
||||
// Add ATC speech to the communication log so it appears alongside pilot entries.
|
||||
appendLogEntry('atc', sayText, response.next_state_id, {
|
||||
flow: response.active_flow,
|
||||
frequency: frequencies.value.active,
|
||||
})
|
||||
}
|
||||
|
||||
if (response.fallback_used) {
|
||||
pmLog.warn('FALLBACK USED:', response.fallback_reason)
|
||||
console.warn('[Backend] Fallback used:', response.fallback_reason)
|
||||
}
|
||||
|
||||
// Session complete → show completion screen
|
||||
if (response.session_complete) {
|
||||
pmLog.info('SESSION COMPLETE — showing completion screen')
|
||||
completedScenario.value = activeScenario.value
|
||||
currentScreen.value = 'complete'
|
||||
}
|
||||
applyBackendDecision(response, { transcript })
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? e?.response?.status
|
||||
if (status === 404) {
|
||||
@@ -5169,6 +5169,62 @@ function normalizeSimFreq(value: unknown): string | null {
|
||||
return num.toFixed(3)
|
||||
}
|
||||
|
||||
// Map the raw bridge telemetry (SimConnect-style field names) to the
|
||||
// sim-agnostic contract the backend understands. distance_to_*_nm are omitted
|
||||
// until pm.vue has a reliable airport-coordinate source to compute them from;
|
||||
// heading is currently true (bridge only reports true) — magnetic correction is
|
||||
// needed before any localizer/heading trigger can rely on it.
|
||||
function normalizeBridgeTelemetry(raw: any): import('../composables/useRadioBackend').NormalizedTelemetry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const num = (v: unknown): number | undefined => {
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
return {
|
||||
altitude_ft: num(raw.PLANE_ALTITUDE),
|
||||
ias_kts: num(raw.AIRSPEED_INDICATED),
|
||||
gs_kts: num(raw.GROUND_VELOCITY),
|
||||
vs_fpm: num(raw.VERTICAL_SPEED),
|
||||
heading_deg: num(raw.PLANE_HEADING_DEGREES_TRUE),
|
||||
on_ground: typeof raw.SIM_ON_GROUND === 'boolean' ? raw.SIM_ON_GROUND : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Only forward telemetry that meaningfully changed, so idle cruise doesn't POST
|
||||
// an identical tick every poll. Rounded so tiny jitter doesn't count as change.
|
||||
let lastSentTelemetrySig: string | null = null
|
||||
function telemetrySignature(t: import('../composables/useRadioBackend').NormalizedTelemetry): string {
|
||||
const r = (v: number | undefined, step: number) => (v === undefined ? '_' : Math.round(v / step))
|
||||
return [
|
||||
r(t.altitude_ft, 100), r(t.ias_kts, 5), r(t.gs_kts, 5),
|
||||
r(t.vs_fpm, 100), r(t.heading_deg, 5), t.on_ground ? 'G' : 'A',
|
||||
].join('|')
|
||||
}
|
||||
|
||||
async function forwardTelemetryToBackend(rawTelemetry: any) {
|
||||
if (!backendSessionId.value) return
|
||||
const normalized = normalizeBridgeTelemetry(rawTelemetry)
|
||||
if (!normalized) return
|
||||
const sig = telemetrySignature(normalized)
|
||||
if (sig === lastSentTelemetrySig) return // nothing meaningful changed
|
||||
lastSentTelemetrySig = sig
|
||||
try {
|
||||
const response = await radioBackend.sendTelemetry(backendSessionId.value, normalized)
|
||||
if (response.telemetry_fired) {
|
||||
pmLog.info('TELEMETRY FIRED →', response.next_state_id)
|
||||
applyBackendDecision(response)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// A 404 means the session is gone; the transmit path already handles the
|
||||
// reset, so here we just stop forwarding until a new session exists.
|
||||
if ((e?.status ?? e?.response?.status) === 404) {
|
||||
pmLog.warn('TELEMETRY session gone (404) — pausing telemetry forwarding')
|
||||
} else {
|
||||
pmLog.warn('TELEMETRY forward failed', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pollBridgeTelemetry() {
|
||||
const token = bridgeToken.value
|
||||
if (!token) return
|
||||
@@ -5184,6 +5240,7 @@ async function pollBridgeTelemetry() {
|
||||
if (!fresh) {
|
||||
// Bridge went quiet — drop the active sync anchor so reconnecting re-tunes.
|
||||
lastSyncedSimActive = null
|
||||
lastSentTelemetrySig = null
|
||||
bridgeSimActiveFreq.value = null
|
||||
return
|
||||
}
|
||||
@@ -5208,6 +5265,11 @@ async function pollBridgeTelemetry() {
|
||||
if (simStandby && frequencies.value.standby !== simStandby) {
|
||||
frequencies.value.standby = simStandby
|
||||
}
|
||||
|
||||
// Feed the (normalised) telemetry to the authoritative backend so it can
|
||||
// fire proactive, aircraft-state-driven ATC. No-op when nothing meaningful
|
||||
// changed or when no telemetry trigger matches the current state.
|
||||
void forwardTelemetryToBackend(res.telemetry)
|
||||
} catch {
|
||||
bridgeConnected.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user