Bridge dome light mode mapping and flightlab seat belt flow

This commit is contained in:
itsrubberduck
2026-02-18 22:43:56 +01:00
parent 7971aeace5
commit 390c38d8bd
5 changed files with 161 additions and 62 deletions

View File

@@ -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'
}

View File

@@ -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

View File

@@ -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',
},
{

View File

@@ -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

View 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)
})
})