mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
fix(live-atc): arm the silence timer on readback states too
The timer was only armed for states carrying auto_advance_on_silence. Readback states carry none — they wait for the pilot — so nothing was armed and ATC never asked again when the readback did not arrive. Which window applies to a state is now a pure function: the flow-authored one for an auto-advance state, the much shorter server-published one for a readback state. That also makes both cases testable instead of inline in the composable.
This commit is contained in:
@@ -9,6 +9,7 @@ import type { useRadioSpeech } from '~/composables/useRadioSpeech'
|
||||
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
||||
import { generateGermanRegistration } from '../../shared/utils/registration'
|
||||
import { gateTransmission } from '../../shared/utils/transmissionGate'
|
||||
import { silenceWindowFor } from '../../shared/utils/silenceTimer'
|
||||
import {
|
||||
isSimControlMatch,
|
||||
isSimControlRejection,
|
||||
@@ -100,14 +101,16 @@ export function useLiveAtcSession(
|
||||
const transmitInFlightCount = ref(0)
|
||||
const transmitInFlight = computed(() => transmitInFlightCount.value > 0)
|
||||
|
||||
// --- Silence auto-advance ----------------------------------------------------
|
||||
// Some pilot states let ATC continue on its own when the pilot stays quiet
|
||||
// (e.g. the takeoff roll in tower-v1: no report needed, Tower hands off after a
|
||||
// while anyway). Such states carry auto_advance_on_silence +
|
||||
// auto_advance_timeout_ms in the runtime tree; whenever the session lands on
|
||||
// one, arm a timer that fires the backend /timeout endpoint. Any pilot
|
||||
// transmission or telemetry-fired advance re-arms or clears it via
|
||||
// applyBackendDecision.
|
||||
// --- Silence timers ----------------------------------------------------------
|
||||
// Two kinds of wait fire the backend /timeout endpoint, both armed here (see
|
||||
// silenceWindowFor for which applies where):
|
||||
// auto-advance — the pilot owes nothing and ATC carries on by itself (the
|
||||
// takeoff roll in tower-v1), on the flow-authored window.
|
||||
// readback — the pilot owes a mandatory readback and has not given it,
|
||||
// so ATC asks again on the much shorter server-published window.
|
||||
// Any pilot transmission or telemetry-fired advance re-arms or clears the
|
||||
// timer via applyBackendDecision, and a fire is dropped if the session or the
|
||||
// state moved on while it was pending.
|
||||
let silenceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Consecutive silence-timeouts fired for the SAME state. If the backend keeps
|
||||
@@ -132,7 +135,9 @@ export function useLiveAtcSession(
|
||||
function armSilenceTimer() {
|
||||
clearSilenceTimer()
|
||||
const state = currentState.value as any
|
||||
if (!state?.auto_advance_on_silence || !backendSessionId.value) return
|
||||
if (!backendSessionId.value) return
|
||||
const window = silenceWindowFor(state)
|
||||
if (!window) return
|
||||
|
||||
if (silenceFireCount.stateId !== state.id) {
|
||||
silenceFireCount = { stateId: state.id, count: 0 }
|
||||
@@ -142,10 +147,10 @@ export function useLiveAtcSession(
|
||||
return
|
||||
}
|
||||
|
||||
const ms = Math.max(1000, Number(state.auto_advance_timeout_ms ?? 30000))
|
||||
const ms = window.ms
|
||||
const sessionAtArm = backendSessionId.value
|
||||
const stateAtArm = state.id
|
||||
pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms })
|
||||
pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms, kind: window.kind })
|
||||
const fire = async () => {
|
||||
silenceTimer = null
|
||||
// Never fire stale: the session ended or the state moved on while waiting.
|
||||
|
||||
60
shared/utils/silenceTimer.ts
Normal file
60
shared/utils/silenceTimer.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Which silence timer, if any, applies to the state the session is resting on.
|
||||
*
|
||||
* Two different waits look the same from the outside but mean opposite things:
|
||||
*
|
||||
* - **auto-advance** — the pilot owes nothing. The takeoff roll, a climb to the
|
||||
* cleared level: ATC carries on by itself once enough time has passed, and the
|
||||
* window is long because nothing is wrong.
|
||||
*
|
||||
* - **readback** — the pilot owes a mandatory readback and has not given it. A
|
||||
* controller chases that within seconds, so the window is short. These states
|
||||
* carry no `auto_advance_timeout_ms` (they wait for the pilot rather than
|
||||
* advancing on their own), which is why the timer was never armed for them at
|
||||
* all and the re-request never fired.
|
||||
*/
|
||||
|
||||
export type SilenceTimerKind = 'auto_advance' | 'readback'
|
||||
|
||||
export interface SilenceTimerState {
|
||||
auto_advance_on_silence?: boolean
|
||||
auto_advance_timeout_ms?: number
|
||||
readback_required?: string[]
|
||||
/** Server-side policy, published on readback states by the runtime tree. */
|
||||
readback_silence_ms?: number
|
||||
}
|
||||
|
||||
export interface SilenceTimerWindow {
|
||||
kind: SilenceTimerKind
|
||||
ms: number
|
||||
}
|
||||
|
||||
/** Never fire faster than this, whatever the flow or server says. */
|
||||
const MIN_WINDOW_MS = 1000
|
||||
|
||||
export const DEFAULT_AUTO_ADVANCE_MS = 30_000
|
||||
/** Only used when the backend published no window (older server). */
|
||||
export const DEFAULT_READBACK_SILENCE_MS = 12_000
|
||||
|
||||
export function silenceWindowFor(
|
||||
state: SilenceTimerState | null | undefined,
|
||||
): SilenceTimerWindow | null {
|
||||
if (!state) return null
|
||||
|
||||
// Auto-advance wins: a state carrying it has an explicit flow-authored window.
|
||||
if (state.auto_advance_on_silence) {
|
||||
return {
|
||||
kind: 'auto_advance',
|
||||
ms: Math.max(MIN_WINDOW_MS, Number(state.auto_advance_timeout_ms ?? DEFAULT_AUTO_ADVANCE_MS)),
|
||||
}
|
||||
}
|
||||
|
||||
if (state.readback_required?.length) {
|
||||
return {
|
||||
kind: 'readback',
|
||||
ms: Math.max(MIN_WINDOW_MS, Number(state.readback_silence_ms ?? DEFAULT_READBACK_SILENCE_MS)),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
70
tests/shared/silenceTimer.test.ts
Normal file
70
tests/shared/silenceTimer.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
DEFAULT_READBACK_SILENCE_MS,
|
||||
silenceWindowFor,
|
||||
} from '~~/shared/utils/silenceTimer'
|
||||
|
||||
describe('silenceWindowFor', () => {
|
||||
it('arms the flow-authored window on an auto-advance state', () => {
|
||||
const window = silenceWindowFor({
|
||||
auto_advance_on_silence: true,
|
||||
auto_advance_timeout_ms: 45000,
|
||||
})
|
||||
assert.deepEqual(window, { kind: 'auto_advance', ms: 45000 })
|
||||
})
|
||||
|
||||
// The bug: a readback state carries no auto_advance_timeout_ms, so nothing
|
||||
// was ever armed and ATC never asked again when the readback did not come.
|
||||
it('arms a readback state, which has no auto-advance window of its own', () => {
|
||||
const window = silenceWindowFor({
|
||||
readback_required: ['squawk', 'sid'],
|
||||
readback_silence_ms: 12000,
|
||||
})
|
||||
assert.deepEqual(window, { kind: 'readback', ms: 12000 })
|
||||
})
|
||||
|
||||
it('chases a missing readback far sooner than an auto-advance', () => {
|
||||
const readback = silenceWindowFor({
|
||||
readback_required: ['runway'],
|
||||
readback_silence_ms: 12000,
|
||||
})!
|
||||
const autoAdvance = silenceWindowFor({
|
||||
auto_advance_on_silence: true,
|
||||
auto_advance_timeout_ms: 45000,
|
||||
})!
|
||||
assert.ok(readback.ms < autoAdvance.ms)
|
||||
assert.ok(readback.ms >= 10_000 && readback.ms <= 15_000, `${readback.ms}ms`)
|
||||
})
|
||||
|
||||
it('falls back to the built-in window when the server published none', () => {
|
||||
const window = silenceWindowFor({ readback_required: ['runway'] })
|
||||
assert.equal(window?.ms, DEFAULT_READBACK_SILENCE_MS)
|
||||
})
|
||||
|
||||
it('arms nothing where the pilot holds the next transmission', () => {
|
||||
assert.equal(silenceWindowFor({}), null)
|
||||
assert.equal(silenceWindowFor({ readback_required: [] }), null)
|
||||
assert.equal(silenceWindowFor(null), null)
|
||||
assert.equal(silenceWindowFor(undefined), null)
|
||||
})
|
||||
|
||||
it('prefers the auto-advance window when a state somehow has both', () => {
|
||||
const window = silenceWindowFor({
|
||||
auto_advance_on_silence: true,
|
||||
auto_advance_timeout_ms: 45000,
|
||||
readback_required: ['runway'],
|
||||
readback_silence_ms: 12000,
|
||||
})
|
||||
assert.equal(window?.kind, 'auto_advance')
|
||||
})
|
||||
|
||||
it('never arms a timer that would fire immediately', () => {
|
||||
assert.equal(silenceWindowFor({ readback_required: ['x'], readback_silence_ms: 0 })?.ms, 1000)
|
||||
assert.equal(
|
||||
silenceWindowFor({ auto_advance_on_silence: true, auto_advance_timeout_ms: -5 })?.ms,
|
||||
1000,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user