fb6f0182cc
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>
393 lines
17 KiB
JavaScript
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();
|
|
});
|