9aba24978b
FlyWithLua auto-install: bridge drops fms-sync/ui-sync/terrain-probe into X-Plane's FlyWithLua Scripts dir on startup and self-updates (content-compare). Graceful when no X-Plane / no FlyWithLua. /api/lua/install + status in health. Desktop app bundles the scripts and passes LUA_SRC_DIR to the sidecar. Smoothing: shared useEased/useEasedAngle hook (api/ease.js) with render-bail on settle. VFR steam gauges now interpolate to 60fps instead of stepping at the ~10Hz value stream. MFD ownship no longer vibrates — position/heading eased in a single rAF loop, follow-pan without animated-panTo pile-up (pauses on range zoom). Airspace overlay: server/airspace.js loads per-region GeoJSON, classifies (B/C/D/TMA/CTR/MOA/Restricted/Prohibited/Danger), bbox query, and downloads regions on demand — FAA (US, key-free) and OpenAIP (Europe, user key). New AIRSPACE softkey draws chart-coloured boundaries (B blue, C magenta, D dashed), non-interactive so map-clicks still drop waypoints. Launcher gains a "Lufträume" section to pick/download regions via the running bridge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
382 lines
17 KiB
JavaScript
382 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;
|
|
}
|
|
|
|
// --- 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];
|
|
const id = name && state.drefNameToId.get(name);
|
|
if (id == null) return log(`! unknown writable dataref alias: ${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();
|
|
});
|