mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-07 10:05:50 +08:00
Bridge dome light mode mapping and flightlab seat belt flow
This commit is contained in:
@@ -534,6 +534,7 @@ const conditionLabels: Record<string, string> = {
|
||||
GEAR_HANDLE_POSITION: 'Fahrwerk',
|
||||
FLAPS_HANDLE_INDEX: 'Klappen',
|
||||
BRAKE_PARKING_POSITION: 'Parkbremse',
|
||||
SEAT_BELT_SIGNS: 'Seat Belt Signs',
|
||||
AUTOPILOT_MASTER: 'Autopilot',
|
||||
}
|
||||
|
||||
@@ -565,6 +566,7 @@ function formatCondition(cond: SimCondition): string {
|
||||
if (cond.variable === 'SIM_ON_GROUND') return cond.value ? `${label}: Ja` : `${label}: Nein (in der Luft)`
|
||||
if (cond.variable === 'GEAR_HANDLE_POSITION') return cond.value ? `${label}: Ausgefahren` : `${label}: Eingefahren`
|
||||
if (cond.variable === 'BRAKE_PARKING_POSITION') return cond.value ? `${label}: Angezogen` : `${label}: Gelöst`
|
||||
if (cond.variable === 'SEAT_BELT_SIGNS') return cond.value ? `${label}: EIN` : `${label}: AUS`
|
||||
return `${label}: ${cond.value ? 'An' : 'Aus'}`
|
||||
}
|
||||
return `${label} ${op} ${cond.value}${unit ? ' ' + unit : ''}`
|
||||
@@ -586,6 +588,7 @@ function getCurrentBooleanLabel(cond: SimCondition): string {
|
||||
if (cond.variable === 'BRAKE_PARKING_POSITION') return val ? 'Angezogen' : 'Gelöst'
|
||||
if (cond.variable === 'GEAR_HANDLE_POSITION') return val ? 'Ausgefahren' : 'Eingefahren'
|
||||
if (cond.variable === 'SIM_ON_GROUND') return val ? 'Am Boden' : 'In der Luft'
|
||||
if (cond.variable === 'SEAT_BELT_SIGNS') return typeof val === 'boolean' ? (val ? 'EIN' : 'AUS') : 'Keine Daten'
|
||||
return val ? 'An' : 'Aus'
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,8 @@ import type {FlightLabTelemetryState} from '../../../shared/data/flightlab/types
|
||||
type DomeLightMode = 'off' | 'white' | 'amber'
|
||||
|
||||
const DOME_LIGHT_WEBHOOK_FALLBACK_URL = 'https://home.io.faktorxmensch.com/api/webhook/lidl_stab_3modi_8492'
|
||||
const DOME_LIGHT_ON_MODES: DomeLightMode[] = ['white', 'amber']
|
||||
|
||||
const nextDomeLightOnModeIndexByToken = new Map<string, number>()
|
||||
const lastDomeLightStateByToken = new Map<string, boolean | null>()
|
||||
const lastDomeLightModeByToken = new Map<string, DomeLightMode | null>()
|
||||
|
||||
/**
|
||||
* Receives telemetry data from an external bridge application.
|
||||
@@ -24,7 +22,22 @@ const lastDomeLightStateByToken = new Map<string, boolean | null>()
|
||||
*/
|
||||
|
||||
/** Map raw bridge field names to FlightLabTelemetryState keys */
|
||||
function parseBooleanTelemetryValue(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') return value !== 0
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (['1', 'true', 'on', 'yes'].includes(normalized)) return true
|
||||
if (['0', 'false', 'off', 'no'].includes(normalized)) return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mapBridgeTelemetry(raw: Record<string, any>): FlightLabTelemetryState {
|
||||
const seatBeltSignsValue = parseBooleanTelemetryValue(
|
||||
raw.seat_belt_signs ?? raw.SEAT_BELT_SIGNS ?? raw.seatbelt_signs ?? raw.seat_belts_signs,
|
||||
)
|
||||
|
||||
return {
|
||||
AIRSPEED_INDICATED: raw.ias_kt ?? raw.AIRSPEED_INDICATED ?? 0,
|
||||
AIRSPEED_TRUE: raw.tas_kt ?? raw.AIRSPEED_TRUE ?? 0,
|
||||
@@ -39,6 +52,7 @@ function mapBridgeTelemetry(raw: Record<string, any>): FlightLabTelemetryState {
|
||||
GEAR_HANDLE_POSITION: !!(raw.gear_handle ?? raw.GEAR_HANDLE_POSITION ?? false),
|
||||
FLAPS_HANDLE_INDEX: raw.flaps_index ?? raw.FLAPS_HANDLE_INDEX ?? 0,
|
||||
BRAKE_PARKING_POSITION: !!(raw.parking_brake ?? raw.BRAKE_PARKING_POSITION ?? false),
|
||||
...(seatBeltSignsValue === null ? {} : {SEAT_BELT_SIGNS: seatBeltSignsValue}),
|
||||
AUTOPILOT_MASTER: !!(raw.autopilot_master ?? raw.AUTOPILOT_MASTER ?? false),
|
||||
TRANSPONDER_CODE: raw.transponder_code ?? raw.TRANSPONDER_CODE ?? 0,
|
||||
ADF_ACTIVE_FREQUENCY: raw.adf_active_freq ?? raw.ADF_ACTIVE_FREQUENCY ?? 0,
|
||||
@@ -46,42 +60,47 @@ function mapBridgeTelemetry(raw: Record<string, any>): FlightLabTelemetryState {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBooleanValue(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') {
|
||||
if (value === 1) return true
|
||||
if (value === 0) return false
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return true
|
||||
if (normalized === 'false' || normalized === '0') return false
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDomeLightValue(raw: Record<string, any>): boolean | null {
|
||||
const value = raw.dome_light ?? raw.DOME_LIGHT
|
||||
return resolveBooleanValue(raw.dome_light ?? raw.DOME_LIGHT)
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') {
|
||||
if (value === 1) return true
|
||||
if (value === 0) return false
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return true
|
||||
if (normalized === 'false' || normalized === '0') return false
|
||||
}
|
||||
function resolveNavLogoLightsValue(raw: Record<string, any>): boolean | null {
|
||||
return resolveBooleanValue(raw.nav_logo_lights ?? raw.NAV_LOGO_LIGHTS)
|
||||
}
|
||||
|
||||
return null
|
||||
export function resolveDomeLightMode(raw: Record<string, any>): DomeLightMode | null {
|
||||
const domeLight = resolveDomeLightValue(raw)
|
||||
if (domeLight === null) return null
|
||||
if (!domeLight) return 'off'
|
||||
|
||||
const navLogoLights = resolveNavLogoLightsValue(raw)
|
||||
return navLogoLights ? 'white' : 'amber'
|
||||
}
|
||||
|
||||
async function forwardDomeLightToWebhook(raw: Record<string, any>, webhookUrl: string, stateKey: string) {
|
||||
const domeLight = resolveDomeLightValue(raw)
|
||||
const lastDomeLightState = lastDomeLightStateByToken.get(stateKey) ?? null
|
||||
const mode = resolveDomeLightMode(raw)
|
||||
const lastMode = lastDomeLightModeByToken.get(stateKey) ?? null
|
||||
|
||||
if (domeLight === null) return
|
||||
if (lastDomeLightState === domeLight) return
|
||||
if (mode === null) return
|
||||
if (lastMode === mode) return
|
||||
|
||||
let nextDomeLightOnModeIndex = nextDomeLightOnModeIndexByToken.get(stateKey) ?? 0
|
||||
|
||||
const mode: DomeLightMode = domeLight
|
||||
? DOME_LIGHT_ON_MODES[nextDomeLightOnModeIndex % DOME_LIGHT_ON_MODES.length]!
|
||||
: 'off'
|
||||
|
||||
if (domeLight) {
|
||||
nextDomeLightOnModeIndex = (nextDomeLightOnModeIndex + 1) % DOME_LIGHT_ON_MODES.length
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
@@ -89,27 +108,26 @@ async function forwardDomeLightToWebhook(raw: Record<string, any>, webhookUrl: s
|
||||
body: JSON.stringify({mode}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.text().catch(() => '')
|
||||
console.error(
|
||||
`\x1b[31m[bridge:dome]\x1b[0m webhook failed status=\x1b[91m${response.status}\x1b[0m mode=\x1b[93m${mode}\x1b[0m body=${responseBody.slice(0, 180)}`,
|
||||
)
|
||||
} else {
|
||||
console.info(
|
||||
`\x1b[36m[bridge:dome]\x1b[0m dome_light=\x1b[92m${String(domeLight)}\x1b[0m mode=\x1b[93m${mode}\x1b[0m`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.text().catch(() => '')
|
||||
console.error(
|
||||
`\x1b[31m[bridge:dome]\x1b[0m webhook failed status=\x1b[91m${response.status}\x1b[0m mode=\x1b[93m${mode}\x1b[0m body=${responseBody.slice(0, 180)}`,
|
||||
)
|
||||
} else {
|
||||
const domeLight = resolveDomeLightValue(raw)
|
||||
const navLogoLights = resolveNavLogoLightsValue(raw)
|
||||
console.info(
|
||||
`\x1b[36m[bridge:dome]\x1b[0m dome_light=\x1b[92m${String(domeLight)}\x1b[0m nav_logo_lights=\x1b[92m${String(navLogoLights)}\x1b[0m mode=\x1b[93m${mode}\x1b[0m`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`\x1b[31m[bridge:dome]\x1b[0m webhook request failed mode=\x1b[93m${mode}\x1b[0m`,
|
||||
error,
|
||||
)
|
||||
} finally {
|
||||
lastDomeLightStateByToken.set(stateKey, domeLight)
|
||||
if (domeLight) {
|
||||
nextDomeLightOnModeIndexByToken.set(stateKey, nextDomeLightOnModeIndex)
|
||||
}
|
||||
}
|
||||
error,
|
||||
)
|
||||
} finally {
|
||||
lastDomeLightModeByToken.set(stateKey, mode)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -128,7 +146,7 @@ export default defineEventHandler(async (event) => {
|
||||
const telemetry = body && typeof body === 'object' ? (body as Record<string, any>) : {}
|
||||
const telemetryKeys = Object.keys(telemetry)
|
||||
console.info(`\x1b[35m[bridge:data]\x1b[0m token=\x1b[96m${bridgeToken.slice(0, 6)}...\x1b[0m user=\x1b[92m${userId}\x1b[0m telemetryKeys=\x1b[92m${telemetryKeys.length}\x1b[0m`)
|
||||
// console.table( telemetryKeys.reduce((acc, key) => { acc[key] = telemetry[key]; return acc }, {} as Record<string, any>) )
|
||||
console.table( telemetryKeys.reduce((acc, key) => { acc[key] = telemetry[key]; return acc }, {} as Record<string, any>) )
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const domeLightWebhookUrl = String(runtimeConfig.domeLightWebhookUrl || '').trim() || DOME_LIGHT_WEBHOOK_FALLBACK_URL
|
||||
|
||||
@@ -22,7 +22,7 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
id: 'ready',
|
||||
label: 'Bin bereit, los gehts!',
|
||||
icon: 'mdi-check-circle',
|
||||
next: 'briefing',
|
||||
next: 'seatbelt_on',
|
||||
type: 'primary'
|
||||
},
|
||||
{
|
||||
@@ -53,7 +53,7 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
id: 'ready_after_comfort',
|
||||
label: 'Okay, hab den Überblick',
|
||||
icon: 'mdi-check-circle',
|
||||
next: 'briefing',
|
||||
next: 'seatbelt_on',
|
||||
type: 'primary'
|
||||
},
|
||||
{
|
||||
@@ -76,7 +76,7 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
id: 'ready_after_comfort_2',
|
||||
label: 'Verstanden, weiter!',
|
||||
icon: 'mdi-check-circle',
|
||||
next: 'briefing',
|
||||
next: 'seatbelt_on',
|
||||
type: 'primary'
|
||||
},
|
||||
],
|
||||
@@ -91,7 +91,7 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
id: 'ready_after_detail',
|
||||
label: 'Alles klar, weiter!',
|
||||
icon: 'mdi-check-circle',
|
||||
next: 'briefing',
|
||||
next: 'seatbelt_on',
|
||||
type: 'primary'
|
||||
},
|
||||
{
|
||||
@@ -112,12 +112,39 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
id: 'ready_after_questions',
|
||||
label: 'Okay, starten wir!',
|
||||
icon: 'mdi-check-circle',
|
||||
next: 'briefing',
|
||||
next: 'seatbelt_on',
|
||||
type: 'primary'
|
||||
},
|
||||
],
|
||||
sounds: [],
|
||||
},
|
||||
{
|
||||
id: 'seatbelt_on',
|
||||
atcMessage: 'Bevor wir loslegen, schalten wir ganz am Anfang die Seat Belt Signs ein. Schau oben aufs Overhead-Panel und stell den Schalter auf ON. Das ist unser Start-Setup, damit alle angeschnallt bleiben.',
|
||||
explanation: 'Seat Belt Signs vor dem Start auf ON setzen.',
|
||||
instructorNote: 'Seat Belt Signs vor dem Briefing einschalten lassen.',
|
||||
buttons: [
|
||||
{
|
||||
id: 'seatbelt_on_done',
|
||||
label: 'Seat Belt Signs sind EIN',
|
||||
icon: 'mdi-seatbelt',
|
||||
next: 'briefing',
|
||||
type: 'primary'
|
||||
},
|
||||
],
|
||||
sounds: [
|
||||
{id: 'chime', action: 'play', volume: 0.3, loop: false},
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{variable: 'SEAT_BELT_SIGNS', operator: '==', value: true},
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 15000,
|
||||
simConditionHelpMessage: 'Seat Belt Signs am Overhead-Panel auf ON stellen.',
|
||||
simConditionNextPhase: 'briefing',
|
||||
},
|
||||
|
||||
// --- Phase 1: Briefing ---
|
||||
{
|
||||
@@ -670,7 +697,7 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
// --- Phase 8: Level-off & Debrief ---
|
||||
{
|
||||
id: 'leveloff',
|
||||
atcMessage: '10.000 Fuß! Jetzt drückst du den Sidestick langsam nach vorne bis die Steigrate auf dem Variometer auf Null steht. Das Variometer ist die vertikale Geschwindigkeitsanzeige rechts neben dem Altitude-Tape. Wir wollen jetzt geradeaus fliegen, nicht mehr steigen. Gut so! Du fliegst jetzt einen Airbus A320 auf 10.000 Fuß!',
|
||||
atcMessage: '10.000 Fuß! Jetzt drückst du den Sidestick langsam nach vorne bis die Steigrate auf dem Variometer auf Null steht. Das Variometer ist die vertikale Geschwindigkeitsanzeige rechts neben dem Altitude-Tape. Wir wollen jetzt geradeaus fliegen, nicht mehr steigen. Gut so! Du fliegst jetzt einen Airbus A320 auf 10.000 Fuß. Sobald wir stabil sind, schalten wir gleich die Seat Belt Signs wieder aus.',
|
||||
explanation: 'Level-off: Sidestick nach vorne bis Vertical Speed nahe Null.',
|
||||
instructorNote: 'Level-off erreicht. Teilnehmer bringt V/S auf Null.',
|
||||
buttons: [
|
||||
@@ -678,29 +705,28 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
id: 'debrief_cool',
|
||||
label: 'Das war genial!',
|
||||
icon: 'mdi-party-popper',
|
||||
next: 'debrief',
|
||||
next: 'seatbelt_off',
|
||||
type: 'primary'
|
||||
},
|
||||
{
|
||||
id: 'debrief_relief',
|
||||
label: 'Geschafft!',
|
||||
icon: 'mdi-emoticon-happy',
|
||||
next: 'debrief',
|
||||
next: 'seatbelt_off',
|
||||
type: 'primary'
|
||||
},
|
||||
{id: 'debrief_again', label: 'Nochmal!', icon: 'mdi-refresh', next: 'debrief_restart', type: 'info'},
|
||||
{id: 'debrief_again', label: 'Nochmal!', icon: 'mdi-refresh', next: 'seatbelt_off', type: 'info'},
|
||||
{
|
||||
id: 'debrief_pause',
|
||||
label: 'Kurz durchatmen',
|
||||
icon: 'mdi-pause-circle',
|
||||
next: 'debrief_pause',
|
||||
next: 'seatbelt_off',
|
||||
type: 'comfort'
|
||||
},
|
||||
],
|
||||
sounds: [
|
||||
{id: 'engine-cruise', action: 'crossfade', volume: 0.2},
|
||||
{id: 'wind-high', action: 'crossfade', volume: 0.15},
|
||||
{id: 'chime', action: 'play', volume: 0.3, loop: false},
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
@@ -712,6 +738,33 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
},
|
||||
simConditionTimeoutMs: 20000,
|
||||
simConditionHelpMessage: 'Nase etwas senken zum Geradeausflug. Steigrate auf nahe Null bringen.',
|
||||
simConditionNextPhase: 'seatbelt_off',
|
||||
},
|
||||
{
|
||||
id: 'seatbelt_off',
|
||||
atcMessage: 'Perfekt, wir cruisen stabil auf 10.000 Fuß. Jetzt kannst du die Seat Belt Signs wieder ausschalten. Schalter auf OFF, dann geht es in die Nachbesprechung.',
|
||||
explanation: 'Nach stabilem Cruise auf 10.000 Fuß Seat Belt Signs auf OFF setzen.',
|
||||
instructorNote: 'Nach erfolgreichem 10.000-Fuß-Level-off Seat Belt Signs ausschalten lassen.',
|
||||
buttons: [
|
||||
{
|
||||
id: 'seatbelt_off_done',
|
||||
label: 'Seat Belt Signs sind AUS',
|
||||
icon: 'mdi-seatbelt',
|
||||
next: 'debrief',
|
||||
type: 'primary'
|
||||
},
|
||||
],
|
||||
sounds: [
|
||||
{id: 'chime', action: 'play', volume: 0.3, loop: false},
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{variable: 'SEAT_BELT_SIGNS', operator: '==', value: false},
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 20000,
|
||||
simConditionHelpMessage: 'Seat Belt Signs am Overhead-Panel wieder auf OFF stellen.',
|
||||
simConditionNextPhase: 'debrief',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface FlightLabTelemetryState {
|
||||
GEAR_HANDLE_POSITION: boolean // true = down, false = up
|
||||
FLAPS_HANDLE_INDEX: number // 0-4 for A320
|
||||
BRAKE_PARKING_POSITION: boolean // true = set, false = released
|
||||
SEAT_BELT_SIGNS?: boolean // true = on, false = off (if provided by bridge)
|
||||
AUTOPILOT_MASTER: boolean
|
||||
TRANSPONDER_CODE: number // squawk code (0-7777 octal)
|
||||
ADF_ACTIVE_FREQUENCY: number // Hz
|
||||
|
||||
24
tests/server/bridgeDataDomeLight.test.ts
Normal file
24
tests/server/bridgeDataDomeLight.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveDomeLightMode } from '~~/server/api/bridge/data.post'
|
||||
|
||||
describe('bridge dome light mode mapping', () => {
|
||||
it('maps all dome/nav combinations to expected mode', () => {
|
||||
assert.equal(resolveDomeLightMode({ dome_light: false, nav_logo_lights: false }), 'off')
|
||||
assert.equal(resolveDomeLightMode({ dome_light: false, nav_logo_lights: true }), 'off')
|
||||
assert.equal(resolveDomeLightMode({ dome_light: true, nav_logo_lights: false }), 'amber')
|
||||
assert.equal(resolveDomeLightMode({ dome_light: true, nav_logo_lights: true }), 'white')
|
||||
})
|
||||
|
||||
it('supports numeric and string boolean payloads', () => {
|
||||
assert.equal(resolveDomeLightMode({ dome_light: 1, nav_logo_lights: 0 }), 'amber')
|
||||
assert.equal(resolveDomeLightMode({ dome_light: '1', nav_logo_lights: '1' }), 'white')
|
||||
assert.equal(resolveDomeLightMode({ dome_light: 'false', nav_logo_lights: 'true' }), 'off')
|
||||
})
|
||||
|
||||
it('returns null when dome_light is missing or invalid', () => {
|
||||
assert.equal(resolveDomeLightMode({ nav_logo_lights: true }), null)
|
||||
assert.equal(resolveDomeLightMode({ dome_light: 'invalid', nav_logo_lights: true }), null)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user