From 390c38d8bda3b3b92571555c292ae412fd9ac0da Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Wed, 18 Feb 2026 22:43:56 +0100 Subject: [PATCH] Bridge dome light mode mapping and flightlab seat belt flow --- app/pages/flightlab/takeoff.vue | 3 + server/api/bridge/data.post.ts | 120 +++++++++++++---------- shared/data/flightlab/takeoff-eddf.ts | 75 +++++++++++--- shared/data/flightlab/types.ts | 1 + tests/server/bridgeDataDomeLight.test.ts | 24 +++++ 5 files changed, 161 insertions(+), 62 deletions(-) create mode 100644 tests/server/bridgeDataDomeLight.test.ts diff --git a/app/pages/flightlab/takeoff.vue b/app/pages/flightlab/takeoff.vue index 8d41a7d..10d2432 100644 --- a/app/pages/flightlab/takeoff.vue +++ b/app/pages/flightlab/takeoff.vue @@ -534,6 +534,7 @@ const conditionLabels: Record = { 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' } diff --git a/server/api/bridge/data.post.ts b/server/api/bridge/data.post.ts index 7b4ea81..633ae2f 100644 --- a/server/api/bridge/data.post.ts +++ b/server/api/bridge/data.post.ts @@ -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() -const lastDomeLightStateByToken = new Map() +const lastDomeLightModeByToken = new Map() /** * Receives telemetry data from an external bridge application. @@ -24,7 +22,22 @@ const lastDomeLightStateByToken = new Map() */ /** 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): 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): 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): 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): 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): boolean | null { + return resolveBooleanValue(raw.nav_logo_lights ?? raw.NAV_LOGO_LIGHTS) +} - return null +export function resolveDomeLightMode(raw: Record): 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, 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, 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) : {} 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) ) + console.table( telemetryKeys.reduce((acc, key) => { acc[key] = telemetry[key]; return acc }, {} as Record) ) const runtimeConfig = useRuntimeConfig() const domeLightWebhookUrl = String(runtimeConfig.domeLightWebhookUrl || '').trim() || DOME_LIGHT_WEBHOOK_FALLBACK_URL diff --git a/shared/data/flightlab/takeoff-eddf.ts b/shared/data/flightlab/takeoff-eddf.ts index 02318b1..f39002d 100644 --- a/shared/data/flightlab/takeoff-eddf.ts +++ b/shared/data/flightlab/takeoff-eddf.ts @@ -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', }, { diff --git a/shared/data/flightlab/types.ts b/shared/data/flightlab/types.ts index 0c28e52..132b5c8 100644 --- a/shared/data/flightlab/types.ts +++ b/shared/data/flightlab/types.ts @@ -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 diff --git a/tests/server/bridgeDataDomeLight.test.ts b/tests/server/bridgeDataDomeLight.test.ts new file mode 100644 index 0000000..69a7d67 --- /dev/null +++ b/tests/server/bridgeDataDomeLight.test.ts @@ -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) + }) +})