mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-07 10:05:50 +08:00
feat(flightlab): sidebar, progress bars, skip speech, SimBridge telemetry & auth
- Add collapsible sidebar with phase stepper (jump between phases) - Add SimBridge conditions panel in sidebar (live values, progress bars, targets) - Add global progress bar (top edge, glowing) + phase-local TTS progress bar - Add skip button to skip TTS speech while ATC is speaking - Add skipSpeech() to audio composable (stops current Pizzicato sound) - Wire up bridge data.post.ts with user auth (JWT) + example payload - Add server-side telemetry store with pub/sub for Bridge→WS relay - Extend WS handler with subscribe-telemetry message + userId tracking - Extend sync composable with subscribeTelemetry() + onTelemetry() callback - Add require-auth middleware to all flightlab pages - Fix instructor station ECONNREFUSED via import.meta.client guard - Add animations: phase transitions, button lists, fade-scale, check-pop, pulse Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,56 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { defineEventHandler, readBody, getHeader } from 'h3'
|
||||
import { resolveUserFromToken } from '../../utils/auth'
|
||||
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
|
||||
|
||||
/**
|
||||
* Receives MSFS SimConnect telemetry data from an external bridge application.
|
||||
*
|
||||
* The bridge should POST telemetry data with an Authorization header so we
|
||||
* can route the data to the correct FlightLab WebSocket session.
|
||||
*
|
||||
* ──────────────────────────────────────────
|
||||
* EXAMPLE REQUEST (from SimBridge app):
|
||||
* ──────────────────────────────────────────
|
||||
*
|
||||
* POST /api/bridge/data
|
||||
* Authorization: Bearer <user-jwt-token>
|
||||
* Content-Type: application/json
|
||||
*
|
||||
* {
|
||||
* "AIRSPEED_INDICATED": 145.2,
|
||||
* "GROUND_VELOCITY": 142.8,
|
||||
* "VERTICAL_SPEED": 0,
|
||||
* "PLANE_ALTITUDE": 364,
|
||||
* "PLANE_PITCH_DEGREES": 0.5,
|
||||
* "TURB_ENG_N1_1": 87.3,
|
||||
* "TURB_ENG_N1_2": 86.9,
|
||||
* "SIM_ON_GROUND": true,
|
||||
* "GEAR_HANDLE_POSITION": true,
|
||||
* "FLAPS_HANDLE_INDEX": 2,
|
||||
* "BRAKE_PARKING_POSITION": false,
|
||||
* "AUTOPILOT_MASTER": false
|
||||
* }
|
||||
*
|
||||
* ──────────────────────────────────────────
|
||||
* RESPONSE: 204 No Content (success)
|
||||
* 401 Unauthorized (missing/invalid token)
|
||||
* ──────────────────────────────────────────
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Body einlesen
|
||||
const body = await readBody(event)
|
||||
// Resolve user from Bearer token (optional — also works with ?userId query param)
|
||||
const user = await resolveUserFromToken(event)
|
||||
const userId = user?._id?.toString()
|
||||
?? new URL(event.node.req.url ?? '', 'http://localhost').searchParams.get('userId')
|
||||
|
||||
// Alles in der Server-Konsole ausgeben
|
||||
console.log('MSFS Telemetry:', body)
|
||||
if (!userId) {
|
||||
event.node.res.statusCode = 401
|
||||
return { error: 'Authorization required — send Bearer token or ?userId query param' }
|
||||
}
|
||||
|
||||
// Leere 204-Antwort zurückgeben
|
||||
event.node.res.statusCode = 204
|
||||
const body = await readBody(event)
|
||||
|
||||
// Store telemetry and broadcast to WebSocket subscribers
|
||||
flightlabTelemetryStore.update(userId, body)
|
||||
|
||||
event.node.res.statusCode = 204
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// server/api/flightlab/ws.ts
|
||||
import { defineWebSocketHandler } from 'h3'
|
||||
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
|
||||
|
||||
interface FlightLabSession {
|
||||
code: string
|
||||
@@ -10,11 +11,25 @@ interface FlightLabSession {
|
||||
participantConnected: boolean
|
||||
instructorConnected: boolean
|
||||
history: Array<{ phaseId: string; buttonId: string; timestamp: number }>
|
||||
peers: Map<string, { role: 'instructor' | 'participant'; peer: any }>
|
||||
peers: Map<string, { role: 'instructor' | 'participant'; peer: any; userId?: string }>
|
||||
/** User IDs subscribed to telemetry for this session */
|
||||
telemetryUserIds: Set<string>
|
||||
}
|
||||
|
||||
const sessions = new Map<string, FlightLabSession>()
|
||||
|
||||
// Map userId → session code for telemetry routing
|
||||
const userSessionMap = new Map<string, string>()
|
||||
|
||||
// Subscribe to telemetry store updates and relay to WebSocket clients
|
||||
flightlabTelemetryStore.subscribe((userId, data) => {
|
||||
const sessionCode = userSessionMap.get(userId)
|
||||
if (!sessionCode) return
|
||||
const session = sessions.get(sessionCode)
|
||||
if (!session) return
|
||||
broadcastToSession(session, { type: 'telemetry', data })
|
||||
})
|
||||
|
||||
function generateCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // No I,O,0,1 for readability
|
||||
let code = ''
|
||||
@@ -77,6 +92,7 @@ export default defineWebSocketHandler({
|
||||
instructorConnected: true,
|
||||
history: [],
|
||||
peers: new Map([[peerId, { role: 'instructor', peer }]]),
|
||||
telemetryUserIds: new Set(),
|
||||
}
|
||||
sessions.set(code, session)
|
||||
peer.send(JSON.stringify({ type: 'session-created', code, state: getSessionState(session) }))
|
||||
@@ -147,6 +163,17 @@ export default defineWebSocketHandler({
|
||||
}, peerId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'subscribe-telemetry': {
|
||||
// Client sends their userId so bridge data gets routed to their session
|
||||
const session = findSessionByPeer(peerId)
|
||||
if (!session || !data.userId) return
|
||||
const peerInfo = session.peers.get(peerId)
|
||||
if (peerInfo) peerInfo.userId = data.userId
|
||||
session.telemetryUserIds.add(data.userId)
|
||||
userSessionMap.set(data.userId, session.code)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -155,6 +182,11 @@ export default defineWebSocketHandler({
|
||||
for (const [code, session] of sessions) {
|
||||
const peerInfo = session.peers.get(peerId)
|
||||
if (peerInfo) {
|
||||
// Clean up telemetry subscription
|
||||
if (peerInfo.userId) {
|
||||
session.telemetryUserIds.delete(peerInfo.userId)
|
||||
userSessionMap.delete(peerInfo.userId)
|
||||
}
|
||||
session.peers.delete(peerId)
|
||||
if (peerInfo.role === 'participant') session.participantConnected = false
|
||||
if (peerInfo.role === 'instructor') session.instructorConnected = false
|
||||
|
||||
35
server/utils/flightlabTelemetry.ts
Normal file
35
server/utils/flightlabTelemetry.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* In-memory telemetry store that bridges the HTTP POST endpoint
|
||||
* with FlightLab WebSocket sessions.
|
||||
*
|
||||
* Flow: SimBridge → POST /api/bridge/data → telemetryStore → WS → Client
|
||||
*/
|
||||
|
||||
type TelemetryListener = (userId: string, data: any) => void
|
||||
|
||||
class FlightLabTelemetryStore {
|
||||
/** Latest telemetry per userId */
|
||||
private data = new Map<string, any>()
|
||||
/** WebSocket listeners that get notified on new data */
|
||||
private listeners = new Set<TelemetryListener>()
|
||||
|
||||
update(userId: string, telemetry: any) {
|
||||
this.data.set(userId, { ...telemetry, timestamp: Date.now() })
|
||||
// Notify all listeners (WebSocket handler subscribes here)
|
||||
for (const listener of this.listeners) {
|
||||
try { listener(userId, this.data.get(userId)) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
get(userId: string) {
|
||||
return this.data.get(userId) ?? null
|
||||
}
|
||||
|
||||
subscribe(listener: TelemetryListener) {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton — shared across all server handlers
|
||||
export const flightlabTelemetryStore = new FlightLabTelemetryStore()
|
||||
Reference in New Issue
Block a user