Files
xplane-cockpit/server/bridge.js
T
karim fb6f0182cc PFD CDI softkey cycles nav source; demo reflects dataref writes
The CDI softkey was in the PFD root row but had no handler — it never cycled the
HSI/CDI source (GPS↔VLOC1↔VLOC2). Wire it to write cdiSrc
(HSI_source_select_pilot, now writable), cycling GPS→NAV1→NAV2.

Also fix a latent demo bug: the 'needs a live sim socket' guard sat above the
setDataref/command handlers, so in DEMO (no sim socket) every dataref write
(transponder code, baro, AP bugs, CDI) was silently dropped. Reflect writes
locally before that guard so cockpit controls respond without a sim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:34:20 +02:00

393 lines
17 KiB
JavaScript

// X-Plane Glass Cockpit — Bridge
// -------------------------------------------------------------------------
// Connects to X-Plane 12's built-in web API (localhost only), resolves
// dataref/command names to per-session IDs, subscribes to live values, and
// fans them out over a LAN-facing WebSocket to any number of tablets/laptops.
// Also serves the built React UI.
import express from 'express';
import { WebSocketServer, WebSocket as WsClient } from 'ws';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CONFIG, DATAREFS, WRITABLE_DATAREFS, COMMANDS } from './config.js';
import { loadNavData, search as navSearch, navStatus, nearest as navNearest, bbox as navBbox, runwaysNear as navRunways, airwaysBbox as navAirways, xplaneRoot } from './navdata.js';
import { parseProcedures, procedureLegs as procLegs } from './procedures.js';
import * as fp from './flightplan.js';
import { pushToSim, startFmsSync, startTerrainSync } from './fmssync.js';
import { installLuaScripts } from './luainstall.js';
import { loadAirspace, airspaceBbox, airspaceStatus, regionList, installRegion } from './airspace.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit
// files it bundles as a resource); otherwise default to ../web/dist.
const WEB_DIST = process.env.WEB_DIST || path.join(__dirname, '..', 'web', 'dist');
const REST = `http://${CONFIG.xplaneHost}:${CONFIG.xplanePort}${CONFIG.xplaneApiBase}`;
const WS_URL = `ws://${CONFIG.xplaneHost}:${CONFIG.xplanePort}${CONFIG.xplaneApiBase}`;
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
// ---- shared state ---------------------------------------------------------
const state = {
xpConnected: false,
values: {}, // alias -> latest value
drefIdToAlias: new Map(), // X-Plane dataref id -> our alias
drefNameToId: new Map(), // sim/... -> id
cmdNameToId: new Map(), // sim/... -> id
xpSocket: null,
reqId: 1,
lua: null, // last FlyWithLua-install report (see luainstall.js)
};
const clients = new Set(); // connected browser sockets
// ---- helpers --------------------------------------------------------------
function broadcast(obj) {
const msg = JSON.stringify(obj);
for (const c of clients) {
if (c.readyState === WsClient.OPEN) c.send(msg);
}
}
function broadcastPlan() {
const plan = fp.getPlan();
broadcast({ type: 'flightplan', data: plan });
pushToSim(plan); // hand the plan to the FlyWithLua FMS bridge (App → Sim)
}
async function fetchAllByName(resource, names) {
// X-Plane's list endpoints can be filtered by name. We query each name so we
// don't pull the full ~15k dataref catalogue.
const map = new Map();
await Promise.all(
[...new Set(names)].map(async (name) => {
try {
const url = `${REST}/${resource}?filter[name]=${encodeURIComponent(name)}`;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const body = await res.json();
const item = (body.data || []).find((d) => d.name === name);
if (item) map.set(name, item.id);
else log(`! ${resource} not found: ${name}`);
} catch (e) {
log(`! lookup failed for ${name}: ${e.message}`);
}
})
);
return map;
}
// ---- X-Plane connection ---------------------------------------------------
async function resolveIds() {
const drefNames = Object.values(DATAREFS);
const cmdNames = Object.values(COMMANDS);
state.drefNameToId = await fetchAllByName('datarefs', [
...drefNames,
...Object.values(WRITABLE_DATAREFS),
]);
state.cmdNameToId = await fetchAllByName('commands', cmdNames);
// build reverse map id -> alias for incoming updates
state.drefIdToAlias.clear();
for (const [alias, name] of Object.entries(DATAREFS)) {
const id = state.drefNameToId.get(name);
if (id != null) state.drefIdToAlias.set(id, alias);
}
log(`resolved ${state.drefNameToId.size} datarefs, ${state.cmdNameToId.size} commands`);
}
function subscribeValues() {
const datarefs = [];
for (const id of state.drefIdToAlias.keys()) datarefs.push({ id });
if (!datarefs.length) return;
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
type: 'dataref_subscribe_values',
params: { datarefs },
})
);
log(`subscribed to ${datarefs.length} datarefs`);
}
function connectXPlane() {
log(`connecting to X-Plane @ ${WS_URL} ...`);
let sock;
try {
sock = new WsClient(WS_URL);
} catch (e) {
log('X-Plane connect threw, retrying in 3s:', e.message);
return setTimeout(connectXPlane, 3000);
}
state.xpSocket = sock;
sock.on('open', async () => {
try {
await resolveIds();
subscribeValues();
state.xpConnected = true;
broadcast({ type: 'status', xpConnected: true });
log('X-Plane connected ✓');
} catch (e) {
log('setup after connect failed:', e.message);
}
});
sock.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
if (msg.type === 'dataref_update_values' && msg.data) {
const patch = {};
for (const [id, value] of Object.entries(msg.data)) {
const alias = state.drefIdToAlias.get(Number(id));
if (alias) { state.values[alias] = value; patch[alias] = value; }
}
if (Object.keys(patch).length) broadcast({ type: 'values', data: patch });
}
});
const onDown = (why) => {
if (state.xpConnected) log(`X-Plane disconnected (${why})`);
state.xpConnected = false;
broadcast({ type: 'status', xpConnected: false });
if (state.xpSocket === sock) state.xpSocket = null;
setTimeout(connectXPlane, 3000);
};
sock.on('close', () => onDown('close'));
sock.on('error', (e) => onDown(e.message));
}
// ---- commands coming FROM the browser ------------------------------------
function handleClientMessage(msg) {
// --- flight plan (works even without a sim connection) ---
if (msg.type === 'fp_set') { fp.setPlan(msg.plan); return broadcastPlan(); }
if (msg.type === 'fp_add') {
const r = fp.addWaypoint(msg.ident);
if (!r.ok) return; // silently ignore unknown idents
return broadcastPlan();
}
if (msg.type === 'fp_remove') { fp.removeWaypoint(msg.index); return broadcastPlan(); }
if (msg.type === 'fp_active') { fp.setActiveLeg(msg.index); return broadcastPlan(); }
if (msg.type === 'fp_load') {
const r = fp.loadFms(msg.name);
if (r.ok) return broadcastPlan();
return broadcast({ type: 'fp_export_result', ...r });
}
if (msg.type === 'fp_clear') { fp.setPlan({ waypoints: [] }); return broadcastPlan(); }
if (msg.type === 'fp_export') {
const r = fp.exportFms(msg.name || 'WEBFPL');
broadcast({ type: 'fp_export_result', ...r });
return;
}
// Demo / no-sim: reflect dataref writes locally so cockpit controls (CDI,
// transponder, baro, AP bugs …) still respond, then stop — there's no sim to
// forward to. Must run BEFORE the live-socket guard below.
if (msg.type === 'setDataref' && (process.env.DEMO || !state.xpSocket)) {
const name = WRITABLE_DATAREFS[msg.name];
if (!name) return log(`! unknown writable dataref alias: ${msg.name}`);
state.values[msg.name] = Number(msg.value);
return broadcast({ type: 'values', data: { [msg.name]: Number(msg.value) } });
}
// --- everything below talks to X-Plane; needs a live sim socket ---
if (!state.xpSocket || state.xpSocket.readyState !== WsClient.OPEN) return;
if (msg.type === 'command') {
const name = COMMANDS[msg.name];
const id = name && state.cmdNameToId.get(name);
if (id == null) return log(`! unknown command alias: ${msg.name}`);
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
type: 'command_set_is_active',
params: { commands: [{ id, is_active: true, duration: msg.duration ?? 0 }] },
})
);
} else if (msg.type === 'setDataref') {
const name = WRITABLE_DATAREFS[msg.name];
if (!name) return log(`! unknown writable dataref alias: ${msg.name}`);
const id = state.drefNameToId.get(name);
if (id == null) return log(`! writable dataref not resolved yet: ${msg.name}`);
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
type: 'dataref_set_values',
params: { datarefs: [{ id, value: Number(msg.value) }] },
})
);
}
}
// ---- HTTP + LAN WebSocket server -----------------------------------------
const app = express();
// Allow the desktop launcher (a different origin) to read the JSON API. LAN-only
// by design, so a wildcard here is harmless and keeps tablets/the app simple.
app.use('/api', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); });
app.get('/api/health', (_req, res) =>
res.json({ xpConnected: state.xpConnected, datarefs: state.drefIdToAlias.size, clients: clients.size, nav: navStatus(), lua: state.lua })
);
// Re-run the FlyWithLua companion install on demand (e.g. after installing
// FlyWithLua, or to push a freshly edited script without restarting the bridge).
app.post('/api/lua/install', (_req, res) => {
state.lua = installLuaScripts(xplaneRoot(), log);
res.json(state.lua);
});
// Waypoint / navaid / airport search from X-Plane's own nav database.
app.get('/api/nav/search', (req, res) => res.json(navSearch(req.query.q || '', 25)));
// NEAREST airports/navaids to a point (NRST page).
app.get('/api/nav/nearest', (req, res) =>
res.json(navNearest(+req.query.lat, +req.query.lon, { count: +req.query.count || 15, type: req.query.type || 'apt' }))
);
// Features inside a map window (airports/navaids/fixes) for the moving map.
app.get('/api/nav/bbox', (req, res) =>
res.json(navBbox(+req.query.s, +req.query.w, +req.query.n, +req.query.e,
(req.query.types || 'apt,vor,ndb').split(','), +req.query.limit || 800))
);
// Airways (Victor/Jet routes) inside a map window — for the MFD AIRWAYS overlay.
app.get('/api/nav/airways', (req, res) =>
res.json(navAirways(+req.query.s, +req.query.w, +req.query.n, +req.query.e, +req.query.limit || 500))
);
// Airspace polygons inside a map window — for the MFD AIRSPACE overlay. Data
// comes from region GeoJSON files the user installs via the launcher (X-Plane
// ships none). See server/airspace.js.
app.get('/api/airspace/bbox', (req, res) =>
res.json(airspaceBbox(+req.query.s, +req.query.w, +req.query.n, +req.query.e, +req.query.limit || 400))
);
// Available airspace regions + how many features of each are installed.
app.get('/api/airspace/regions', (_req, res) => res.json({ regions: regionList(), status: airspaceStatus() }));
// Download + install a region's airspace (FAA US is key-free; OpenAIP needs key).
app.post('/api/airspace/install', express.json(), async (req, res) => {
const r = await installRegion(req.body?.region, { apiKey: req.body?.apiKey, log });
res.json(r);
});
// Runways near a point — drawn in the PFD synthetic-vision view.
app.get('/api/nav/runways', (req, res) =>
res.json(navRunways(+req.query.lat, +req.query.lon, +req.query.radius || 12))
);
// PROC: an airport's procedures (SIDs/STARs/approaches) and the resolved leg
// fixes for a chosen procedure+transition (from X-Plane's CIFP data).
app.get('/api/nav/procs', (req, res) => {
const p = parseProcedures(String(req.query.icao || ''));
if (!p) return res.status(404).json({ error: 'no procedures for ' + req.query.icao });
res.json({ icao: p.icao, runways: p.runways, sids: p.sids, stars: p.stars, approaches: p.approaches });
});
// Saved flight plans (Output/FMS plans) — list for the FPL "load" picker.
app.get('/api/fms/list', (_req, res) => res.json(fp.listPlans()));
app.get('/api/nav/proc', (req, res) =>
res.json(procLegs(String(req.query.icao || ''), req.query.type, req.query.name, req.query.trans))
);
app.use(express.static(WEB_DIST));
// SPA fallback so client-side routes work.
app.get('*', (_req, res) => res.sendFile(path.join(WEB_DIST, 'index.html')));
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
clients.add(ws);
log(`browser connected (${clients.size} total)`);
// send current snapshot immediately so the UI isn't blank
ws.send(JSON.stringify({ type: 'status', xpConnected: state.xpConnected }));
ws.send(JSON.stringify({ type: 'values', data: state.values }));
ws.send(JSON.stringify({ type: 'flightplan', data: fp.getPlan() }));
ws.on('message', (raw) => {
try { handleClientMessage(JSON.parse(raw)); } catch { /* ignore */ }
});
ws.on('close', () => { clients.delete(ws); log(`browser left (${clients.size} total)`); });
ws.on('error', () => clients.delete(ws));
});
// ---- demo mode: synthetic values when there's no X-Plane (for previews) ---
function startDemo() {
log('DEMO mode — emitting synthetic values, not connecting to X-Plane');
state.xpConnected = true;
Object.assign(state.values, {
airspeed: 124, altitude: 5500, vspeed: 320, pitch: 4.5, roll: -12,
heading: 87, slip: 0.3, gForce: 1.04, oat: 9,
apState: (1 << 0) | (1 << 1) | (1 << 14), // FD + HDG + ALT
apEngaged: 1, apHdgBug: 90, apAltBug: 6000, apVsBug: 500, apSpdBug: 120,
// AFCS annunciation: AP on, HDG active + GPS armed (lateral), ALT active (vertical)
apMode: 2, hdgStatus: 2, gpssStatus: 1, altStatus: 2,
lat: 47.45, lon: -122.31, track: 90, groundspeed: 64, gpsDistNm: 18.4, gpsBearing: 92,
// radios (XP freq units: nav/com in 10 kHz, e.g. 11030 = 110.30)
nav1: 11380, nav1Sb: 11150, nav2: 11030, nav2Sb: 10890,
com1: 12190, com1Sb: 13000, com2: 12475, com2Sb: 12180,
// HSI / data fields
obsCrs: 175, hsiDef: -0.6, hsiToFrom: 1, navBearing: 168, gsDef: 0.7,
nav1Brg: 210, nav1Dme: 12.4, nav2Brg: 320, nav2Dme: 0, // BRG1 (NAV1 VOR/DME) demo
baro: 29.92, tas: 131, windSpd: 14, windDir: 240,
xpdrCode: 1200, xpdrMode: 2, fdPitch: 5, fdRoll: -10,
cdiSrc: Number(process.env.DEMO_CDI ?? 2), // 0 VLOC1, 1 VLOC2, 2 GPS
...(process.env.DEMO_RANGE ? { uiMapRange: Number(process.env.DEMO_RANGE) } : {}),
// engine strip (arrays, like the sim)
engRpm: [2410], fuelFlow: [0.0072], oilTemp: [88], oilPress: [52], egt: [720],
fuelQty: [60, 58], volts: [process.env.DEMO_ALERT ? 23.4 : 28.0, 27.8], amps: [-1.5], genAmps: [20.5], engHrs: 5040,
});
// a sample plan so the map/FMS show something in demo mode
fp.setPlan({ name: 'DEMO', waypoints: [
{ id: 'KSEA', lat: 47.449, lon: -122.309, type: 'APT' },
{ id: 'SEA', lat: 47.435, lon: -122.310, type: 'VOR', alt: 4000 },
{ id: 'KPDX', lat: 45.589, lon: -122.597, type: 'APT', alt: 1200 },
]});
pushToSim(fp.getPlan());
let t = 0;
const lat0 = 47.45, lon0 = -122.31, R = 0.05, w = 0.02; // gentle orbit around KSEA
const cosL = Math.cos(lat0 * Math.PI / 180);
let pLat = lat0, pLon = lon0;
setInterval(() => {
t += 0.1;
state.values.roll = -12 + Math.sin(t) * 4;
state.values.pitch = 4.5 + Math.cos(t * 0.7) * 1.5;
const newAlt = 5500 + Math.sin(t * 0.5) * 120;
state.values.vspeed = (newAlt - state.values.altitude) / (0.1 / 60); // fpm from Δalt/Δt
state.values.altitude = newAlt;
state.values.airspeed = 124 + Math.sin(t * 0.4) * 8;
// orbit so the aircraft visibly moves but stays near the demo flight plan
const lat = lat0 + Math.cos(t * w) * R;
const lon = lon0 + Math.sin(t * w) * R / cosL;
const trk = (Math.atan2((lon - pLon) * cosL, lat - pLat) * 180 / Math.PI + 360) % 360;
state.values.lat = lat; state.values.lon = lon;
state.values.track = trk; state.values.heading = trk;
pLat = lat; pLon = lon;
broadcast({ type: 'status', xpConnected: true });
broadcast({ type: 'values', data: state.values });
}, 100);
// synthetic terrain grid (a Cascades-style ridge rising eastward) so the MFD
// terrain-awareness colouring (yellow/red vs aircraft altitude) is visible
const emitTerrain = () => {
const lat = state.values.lat, lon = state.values.lon, alt = state.values.altitude;
const rows = 28, cols = 28, n = lat + 0.35, s = lat - 0.35, w = lon - 0.5, e = lon + 0.5;
const elev = [];
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const fx = c / (cols - 1), fy = r / (rows - 1); // fx: 0 west → 1 east
let h = fx * 9000 - 1200 + Math.sin(fy * 6 + fx * 4) * 800 + Math.cos(fx * 9) * 400;
elev.push(Math.max(0, Math.round(h)));
}
broadcast({ type: 'terrain', data: { lat, lon, alt, n, s, w, e, rows, cols, elev } });
};
emitTerrain();
setInterval(emitTerrain, 1500);
}
server.listen(CONFIG.bridgePort, CONFIG.bridgeHost, () => {
log(`Bridge UI: http://${CONFIG.bridgeHost}:${CONFIG.bridgePort}`);
log(`On tablets: http://<this-PC-LAN-IP>:${CONFIG.bridgePort}`);
loadNavData(); // async; FMS resolves idents once ready (sets root synchronously)
// Drop the FlyWithLua companion scripts into the sim so the user never copies
// files by hand; keeps the installed copies up to date on every start.
state.lua = installLuaScripts(xplaneRoot(), log);
loadAirspace(log); // installed region GeoJSON → /api/airspace/bbox overlay
// FMS two-way sync (Sim → App): adopt plans built/edited in the real G1000
startFmsSync({
getPlan: () => fp.getPlan(),
onSimPlan: (waypoints) => { fp.setPlan({ name: 'ACTIVE', waypoints, activeLeg: 1 }); broadcastPlan(); },
});
// Terrain awareness grid (from the FlyWithLua terrain probe) → MFD colouring
startTerrainSync((t) => broadcast({ type: 'terrain', data: t }));
if (process.env.DEMO) startDemo();
else connectXPlane();
});