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/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) + }) +})