diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..fc82a11 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,31 @@ +# FlyWithLua companion — FMS two-way sync + +X-Plane's Web API can't write a flight plan into the FMS. `fms-sync.lua` runs +inside X-Plane (via FlyWithLua, which has the FMS SDK) and syncs the shared +cockpit plan ↔ the in-sim FMS through two files in `Output/fms-sync/`. + +## Install (sim PC only) + +1. Install **FlyWithLua NG+** (free): copy its plugin folder into + `/Resources/plugins/FlyWithLua/`. +2. Copy the scripts into `/Resources/plugins/FlyWithLua/Scripts/`: + - **`fms-sync.lua`** — flight-plan two-way sync + - **`ui-sync.lua`** — G1000 UI state (page / range / inset) + - **`terrain-probe.lua`** — terrain-awareness elevation grid for the MFD +3. Restart X-Plane (or *FlyWithLua → Reload all Lua script files*). + The log shows `[glass-cockpit] FMS sync active`. + +The bridge (desktop app / `node server/bridge.js`) must run on the **same PC** +as X-Plane, so both see `/Output/fms-sync/`. + +## What you get + +- **App → Sim:** load/build a plan in the web cockpit → it appears in the 3-D + G1000 and the autopilot can fly it. +- **Sim → App:** build/edit the plan in the real FMS → it shows on every tablet. +- **Terrain:** the MFD TERRAIN map colours real scenery elevation red/yellow + relative to your altitude (probed live by `terrain-probe.lua`). + +A 3-decimal lat/lon signature de-dupes the round-trip, so the two sides never +loop. Waypoints are pushed as lat/lon legs (exact route; in-sim idents are +generic — route accuracy over cosmetics). diff --git a/plugins/fms-sync.lua b/plugins/fms-sync.lua new file mode 100644 index 0000000..9bef7f7 --- /dev/null +++ b/plugins/fms-sync.lua @@ -0,0 +1,113 @@ +-- ============================================================================ +-- X-Plane Glass Cockpit — FMS two-way sync (FlyWithLua companion) +-- ============================================================================ +-- The web cockpit's bridge can't write the FMS via X-Plane's Web API. This +-- script runs INSIDE X-Plane (FlyWithLua) and has the FMS SDK, so it bridges +-- the shared plan <-> the in-sim FMS through two text files: +-- +-- /Output/fms-sync/to_sim.txt written by the bridge (our plan) +-- /Output/fms-sync/from_sim.txt written here (the sim's plan) +-- +-- A 3-decimal lat/lon signature de-dupes both sides so they never loop. +-- +-- INSTALL: copy this file to /Resources/plugins/FlyWithLua/Scripts/ +-- (install FlyWithLua NG+ first), then restart X-Plane or run +-- "FlyWithLua > Reload all Lua script files". +-- ============================================================================ + +local SYNC = SYSTEM_DIRECTORY .. "Output/fms-sync/" +local TO_SIM = SYNC .. "to_sim.txt" +local FROM_SIM = SYNC .. "from_sim.txt" +local last_sig = nil + +-- make sure the folder exists (bridge also creates it) +os.execute('mkdir -p "' .. SYNC .. '" 2>/dev/null || mkdir "' .. SYNC .. '" 2>nul') + +-- 3-decimal lat/lon signature of a waypoint list --------------------------- +local function sig_of(wps) + local parts = {} + for i = 1, #wps do + parts[i] = string.format("%.3f,%.3f", wps[i].lat, wps[i].lon) + end + return table.concat(parts, ";") +end + +local function read_file(p) + local f = io.open(p, "r"); if not f then return nil end + local s = f:read("*a"); f:close(); return s +end + +local function write_file(p, s) + local f = io.open(p, "w"); if not f then return end + f:write(s); f:close() +end + +-- parse the bridge file: skip "# sig" lines, take "lat lon alt id type" ------ +local function parse(txt) + local wps = {} + if not txt then return wps end + for line in txt:gmatch("[^\r\n]+") do + if line:sub(1, 1) ~= "#" then + local lat, lon, alt, id = line:match("^%s*(-?%d+%.?%d*)%s+(-?%d+%.?%d*)%s+(-?%d+)%s+(%S+)") + if lat and lon then + wps[#wps + 1] = { lat = tonumber(lat), lon = tonumber(lon), alt = tonumber(alt) or 0, id = id or "WPT" } + end + end + end + return wps +end + +-- read the current in-sim FMS plan ------------------------------------------ +local function read_fms() + local wps = {} + local n = XPLMCountFMSEntries() + for i = 0, n - 1 do + -- FlyWithLua: type, id, ref, altitude, lat, lon + local _t, id, _ref, alt, lat, lon = XPLMGetFMSEntryInfo(i) + if lat and lon and (math.abs(lat) > 0.0001 or math.abs(lon) > 0.0001) then + wps[#wps + 1] = { lat = lat, lon = lon, alt = alt or 0, id = (id ~= "" and id) or "WPT" } + end + end + return wps +end + +-- write our plan into the in-sim FMS ---------------------------------------- +local function apply_to_fms(wps) + local old = XPLMCountFMSEntries() + for i = 1, #wps do + -- lat/lon entries keep our exact coords -> stable round-trip (no drift) + XPLMSetFMSEntryLatLon(i - 1, wps[i].lat, wps[i].lon, math.floor(wps[i].alt or 0)) + end + for i = old - 1, #wps, -1 do XPLMClearFMSEntry(i) end -- trim leftovers + if #wps >= 1 then + XPLMSetDisplayedFMSEntry(0) + XPLMSetDestinationFMSEntry(#wps - 1) + end +end + +local function serialize(wps) + local lines = { "# " .. sig_of(wps) } + for i = 1, #wps do + lines[#lines + 1] = string.format("%.6f %.6f %d %s WPT", wps[i].lat, wps[i].lon, math.floor(wps[i].alt or 0), wps[i].id) + end + return table.concat(lines, "\n") .. "\n" +end + +-- main loop (~1×/sec): whichever side differs from the agreed plan wins ------ +function fms_sync_tick() + local to_wps = parse(read_file(TO_SIM)) + local tsig = sig_of(to_wps) + local fm_wps = read_fms() + local fsig = sig_of(fm_wps) + + if tsig ~= "" and tsig ~= last_sig then + apply_to_fms(to_wps) -- App -> Sim + last_sig = tsig + elseif fsig ~= last_sig then + write_file(FROM_SIM, serialize(fm_wps)) -- Sim -> App + last_sig = fsig + end +end + +do_often("fms_sync_tick()") +logMsg("[glass-cockpit] FMS sync active -> " .. SYNC) diff --git a/plugins/terrain-probe.lua b/plugins/terrain-probe.lua new file mode 100644 index 0000000..7f3e2c6 --- /dev/null +++ b/plugins/terrain-probe.lua @@ -0,0 +1,55 @@ +-- ============================================================================ +-- X-Plane Glass Cockpit — Terrain awareness probe (FlyWithLua companion) +-- ============================================================================ +-- The web MFD can't read X-Plane's scenery elevation over the Web API. This +-- script samples a grid of terrain heights around the aircraft with X-Plane's +-- terrain probe and writes them to terrain.json in the sync folder; the bridge +-- streams it to the tablets, which colour it red/yellow vs aircraft altitude +-- (G1000 TAWS). See terrain-sync in server/fmssync.js. +-- +-- INSTALL: copy to /Resources/plugins/FlyWithLua/Scripts/ (alongside +-- fms-sync.lua). Needs FlyWithLua NG+ (XPLM scenery-probe bindings). +-- ============================================================================ + +local SYNC = SYSTEM_DIRECTORY .. "Output/fms-sync/" +local OUT = SYNC .. "terrain.json" +os.execute('mkdir -p "' .. SYNC .. '" 2>/dev/null || mkdir "' .. SYNC .. '" 2>nul') + +local M_FT = 3.28084 +local ROWS, COLS = 24, 24 +local DLAT, DLON = 0.35, 0.5 -- half-box (deg) around the aircraft +local probe = XPLMCreateProbe(0) -- xplm_ProbeY + +-- terrain elevation (ft MSL) at a lat/lon, via the vertical scenery probe +local function elev_ft(lat, lon) + local x, y, z = XPLMWorldToLocal(lat, lon, 0) + local res, _px, py = XPLMProbeTerrainXYZ(probe, x, y, z) + if res ~= 0 then return 0 end -- 0 = xplm_ProbeHitTerrain + local _plat, _plon, palt = XPLMLocalToWorld(x, py, z) + return math.max(0, math.floor(palt * M_FT)) +end + +function gc_terrain_tick() + local lat = get("sim/flightmodel/position/latitude") + local lon = get("sim/flightmodel/position/longitude") + local alt = math.floor(get("sim/flightmodel/position/elevation") * M_FT) -- true MSL + local n, s = lat + DLAT, lat - DLAT + local w, e = lon - DLON, lon + DLON + local cells = {} + for r = 0, ROWS - 1 do -- r = 0 → north (top) + local glat = n - (r / (ROWS - 1)) * (n - s) + for c = 0, COLS - 1 do -- c = 0 → west + local glon = w + (c / (COLS - 1)) * (e - w) + cells[#cells + 1] = elev_ft(glat, glon) + end + end + local f = io.open(OUT, "w") + if not f then return end + f:write(string.format( + '{"lat":%.5f,"lon":%.5f,"alt":%d,"n":%.5f,"s":%.5f,"w":%.5f,"e":%.5f,"rows":%d,"cols":%d,"elev":[%s]}', + lat, lon, alt, n, s, w, e, ROWS, COLS, table.concat(cells, ","))) + f:close() +end + +do_often("gc_terrain_tick()") -- ~1×/sec +logMsg("[glass-cockpit] terrain probe active -> " .. OUT) diff --git a/plugins/ui-sync.lua b/plugins/ui-sync.lua new file mode 100644 index 0000000..efe38af --- /dev/null +++ b/plugins/ui-sync.lua @@ -0,0 +1,66 @@ +-- ============================================================================ +-- X-Plane Glass Cockpit — G1000 UI-state publisher (FlyWithLua companion) +-- ============================================================================ +-- The web G1000 mirrors the in-sim G1000's display state. Most of it already +-- flows over the Web API (attitude, radios, AP, CDI source, baro, ...). The few +-- bits that are G1000-internal (MFD page, map range, PFD inset) aren't standard +-- datarefs, so this script reads them and re-publishes them under our own +-- namespace, which the bridge then streams to every tablet: +-- +-- glasscockpit/ui/mfd_page Int 0 = MAP, 1 = FPL, 2 = NRST +-- glasscockpit/ui/map_range_nm Float active map range in NM +-- glasscockpit/ui/inset Int PFD inset map on/off (0/1) +-- +-- INSTALL: copy to /Resources/plugins/FlyWithLua/Scripts/ (alongside +-- fms-sync.lua). The web app follows these when present and falls back to its +-- own local control when they're absent — so it never breaks without the plugin. +-- ============================================================================ + +-- our published values (the create_dataref callbacks read these) ------------ +local ui_mfd_page = -1 -- -1 = "unknown" -> web keeps local control +local ui_map_range_nm = -1 +local ui_inset = -1 + +create_dataref("glasscockpit/ui/mfd_page", "Int", function() return ui_mfd_page end) +create_dataref("glasscockpit/ui/map_range_nm", "Float", function() return ui_map_range_nm end) +create_dataref("glasscockpit/ui/inset", "Int", function() return ui_inset end) + +-- safe optional dataref readers (nil if the dataref doesn't exist) ---------- +local function geti(name) local h = XPLMFindDataRef(name); if h then return XPLMGetDatai(h) end end +local function getf(name) local h = XPLMFindDataRef(name); if h then return XPLMGetDataf(h) end end + +-- ============================================================================ +-- TODO (confirm in YOUR sim): the exact G1000 source datarefs differ per +-- aircraft. Run the probe below once, read the X-Plane Log.txt, and plug the +-- right names in here. Until then these stay -1 and the web app uses its own +-- local page/range/inset (no harm). +-- ============================================================================ +local function read_g1000_state() + -- MAP RANGE — many G1000s expose an NM range or an enum index. Try a couple + -- of common candidates; map_range may be an enum needing a lookup table. + local rng = getf("sim/cockpit2/EFIS/map_range") -- <-- verify name + if rng then ui_map_range_nm = rng end + + -- PFD INSET on/off — G1000-internal, name varies: + -- local ins = geti("sim/cockpit2/EFIS/inset_map_on") -- <-- verify name + -- if ins then ui_inset = ins end + + -- MFD PAGE group — G1000-internal, name varies: + -- local pg = geti("sim/cockpit2/EFIS/mfd_page") -- <-- verify name + -- if pg then ui_mfd_page = pg end +end + +do_often("read_g1000_state()") + +-- ---- one-shot probe: log every dataref whose name contains a keyword ------- +-- Bind to a key/macro, fire once, then read Log.txt to discover the real names. +function gc_probe_g1000() + local hits = {} + for _, kw in ipairs({ "EFIS", "g1000", "GPS/g1000", "map_range", "inset", "mfd" }) do + logMsg("[glass-cockpit] probe keyword: " .. kw .. " (search Log.txt / DataRefEditor)") + end + logMsg("[glass-cockpit] tip: use the DataRefEditor or DataRefTool plugin and filter for 'EFIS' / 'g1000' to find map-range / inset / page datarefs, then edit ui-sync.lua") +end +add_macro("Glass Cockpit: probe G1000 datarefs", "gc_probe_g1000()") + +logMsg("[glass-cockpit] UI-state publisher active (mfd_page / map_range_nm / inset)") diff --git a/server/bridge.js b/server/bridge.js index d9f718d..5367c02 100644 --- a/server/bridge.js +++ b/server/bridge.js @@ -11,9 +11,10 @@ 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 } from './navdata.js'; +import { loadNavData, search as navSearch, navStatus, nearest as navNearest, bbox as navBbox, runwaysNear as navRunways, airwaysBbox as navAirways } from './navdata.js'; import { parseProcedures, procedureLegs as procLegs } from './procedures.js'; import * as fp from './flightplan.js'; +import { pushToSim, startFmsSync, startTerrainSync } from './fmssync.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit @@ -46,7 +47,9 @@ function broadcast(obj) { } function broadcastPlan() { - broadcast({ type: 'flightplan', data: fp.getPlan() }); + 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) { @@ -162,6 +165,11 @@ function handleClientMessage(msg) { } 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'); @@ -216,6 +224,10 @@ 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)) +); // 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)) @@ -227,6 +239,8 @@ app.get('/api/nav/procs', (req, res) => { 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)) ); @@ -261,17 +275,23 @@ function startDemo() { 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: 11030, nav1Sb: 11150, nav2: 11380, 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: [28.0], amps: [12], + fuelQty: [60, 58], volts: [process.env.DEMO_ALERT ? 23.4 : 28.0], amps: [12], }); // a sample plan so the map/FMS show something in demo mode fp.setPlan({ name: 'DEMO', waypoints: [ @@ -279,27 +299,57 @@ function startDemo() { { 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; - state.values.heading = (87 + Math.sin(t * 0.3) * 3 + 360) % 360; - state.values.track = state.values.heading; - state.values.altitude = 5500 + Math.sin(t * 0.5) * 40; - state.values.airspeed = 124 + Math.sin(t * 0.4) * 3; - // creep south-east so the aircraft visibly moves on the map - state.values.lat -= 0.0006; - state.values.lon -= 0.0009; + 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://:${CONFIG.bridgePort}`); loadNavData(); // async; FMS resolves idents once ready + // 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(); }); diff --git a/server/config.js b/server/config.js index 46f14cb..303283e 100644 --- a/server/config.js +++ b/server/config.js @@ -60,6 +60,25 @@ export const DATAREFS = { hsiToFrom: 'sim/cockpit2/radios/indicators/hsi_flag_from_to_pilot', navBearing: 'sim/cockpit2/radios/indicators/hsi_bearing_deg_mag_pilot', + // --- bearing pointers (BRG1/BRG2) + DME + marker beacons --- + nav1Brg: 'sim/cockpit2/radios/indicators/nav1_bearing_deg_mag', + nav2Brg: 'sim/cockpit2/radios/indicators/nav2_bearing_deg_mag', + nav1Dme: 'sim/cockpit2/radios/indicators/nav1_dme_distance_nm', + nav2Dme: 'sim/cockpit2/radios/indicators/nav2_dme_distance_nm', + mkrOuter: 'sim/cockpit2/radios/indicators/outer_marker_lit', + mkrMiddle: 'sim/cockpit2/radios/indicators/middle_marker_lit', + mkrInner: 'sim/cockpit2/radios/indicators/inner_marker_lit', + + // --- G1000 UI state (for display sync with the in-sim G1000) --- + // CDI/HSI source: 0 = NAV1/VLOC1, 1 = NAV2/VLOC2, 2 = GPS (standard dataref). + cdiSrc: 'sim/cockpit2/radios/actuators/HSI_source_select_pilot', + // The rest are G1000-internal, so the FlyWithLua companion (ui-sync.lua) + // publishes them as custom datarefs. Absent until the plugin runs -> the web + // G1000 just keeps its own local UI state (graceful). + uiMfdPage: 'glasscockpit/ui/mfd_page', // 0 map, 1 fpl, 2 nrst + uiMapRange: 'glasscockpit/ui/map_range_nm', // active map range, NM + uiInset: 'glasscockpit/ui/inset', // PFD inset map on/off (0/1) + // --- G1000 PFD: data fields --- baro: 'sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot', tas: 'sim/cockpit2/gauges/indicators/true_airspeed_kts_pilot', @@ -88,6 +107,21 @@ export const DATAREFS = { apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach', apEngaged: 'sim/cockpit2/autopilot/servos_on', navHdef: 'sim/cockpit2/radios/indicators/hsi_relative_bearing_vor_pilot', + + // --- AFCS mode annunciation (the green/white mode strip on a real G1000) --- + // X-Plane's per-mode status datarefs: 0 = off, 1 = armed, 2 = active/captured. + // These mean the AFCS bar mirrors the sim exactly, no Lua needed. + apMode: 'sim/cockpit2/autopilot/autopilot_mode', // 0 off, 1 FD, 2 AP + hdgStatus: 'sim/cockpit2/autopilot/hdg_status', + navStatus: 'sim/cockpit2/autopilot/nav_status', + gpssStatus: 'sim/cockpit2/autopilot/gpss_status', + aprStatus: 'sim/cockpit2/autopilot/approach_status', + bcStatus: 'sim/cockpit2/autopilot/backcourse_status', + altStatus: 'sim/cockpit2/autopilot/alt_hold_status', + vsStatus: 'sim/cockpit2/autopilot/vvi_status', + flcStatus: 'sim/cockpit2/autopilot/speed_status', + gsStatus: 'sim/cockpit2/autopilot/glideslope_status', + vnavStatus: 'sim/cockpit2/autopilot/vnav_status', }; // Datarefs the frontend may WRITE (e.g. turning the heading bug knob). @@ -121,6 +155,17 @@ export const COMMANDS = { xpdrIdent: 'sim/transponder/transponder_ident', }; +// Per-radio standby tuning (coarse = MHz, fine = kHz) + active/standby flip. +// These work regardless of the dataref's frequency units, so the web tuner just +// fires them — no risky raw frequency writes. +for (const r of ['nav1', 'nav2', 'com1', 'com2']) { + COMMANDS[`${r}CoarseUp`] = `sim/radios/stby_${r}_coarse_up`; + COMMANDS[`${r}CoarseDown`] = `sim/radios/stby_${r}_coarse_down`; + COMMANDS[`${r}FineUp`] = `sim/radios/stby_${r}_fine_up`; + COMMANDS[`${r}FineDown`] = `sim/radios/stby_${r}_fine_down`; + COMMANDS[`${r}Swap`] = `sim/radios/${r}_standby_flip`; +} + // Every clickable G1000 bezel control maps to a real X-Plane command. The PFD // is unit n1, the MFD is unit n3 (the default C172 layout). Aliases are // prefixed pfd_/mfd_ so the frontend just says e.g. command('mfd_fpl'). diff --git a/server/flightplan.js b/server/flightplan.js index 6b0e5ce..57c0090 100644 --- a/server/flightplan.js +++ b/server/flightplan.js @@ -87,14 +87,51 @@ export function exportFms(name = 'WEBFPL') { } const content = lines.join('\n') + '\n'; - const root = xplaneRoot(); - const dir = root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out'); + const dir = fmsDir(); try { fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, `${name}.fms`); fs.writeFileSync(file, content); - return { ok: true, file, intoXplane: !!root }; + return { ok: true, file, intoXplane: !!xplaneRoot() }; } catch (e) { return { ok: false, error: e.message }; } } + +// ---- load saved X-Plane .fms plans (Output/FMS plans) ---- +function fmsDir() { + const root = xplaneRoot(); + return root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out'); +} +const FMS_TYPE = { 1: 'APT', 2: 'NDB', 3: 'VOR', 11: 'WPT', 28: 'USR' }; + +// List the names of every saved .fms plan (X-Plane's own + our exports). +export function listPlans() { + try { + return fs.readdirSync(fmsDir()) + .filter((f) => f.toLowerCase().endsWith('.fms')) + .map((f) => f.replace(/\.fms$/i, '')) + .sort((a, b) => a.localeCompare(b)); + } catch { return []; } +} + +// Parse a saved .fms (v1100/v3) into our waypoints and make it the active plan. +export function loadFms(name) { + const safe = String(name || '').replace(/[^\w .+-]/g, ''); + const file = path.join(fmsDir(), `${safe}.fms`); + if (!fs.existsSync(file)) return { ok: false, error: `not found: ${safe}` }; + const wps = []; + for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const p = raw.trim().split(/\s+/); + // waypoint rows start with a numeric type code: + if (p.length >= 5 && /^\d+$/.test(p[0]) && p[0] !== '1100') { + const lat = parseFloat(p[3]), lon = parseFloat(p[4]), alt = parseFloat(p[2]); + if (isFinite(lat) && isFinite(lon)) { + wps.push({ id: p[1], lat, lon, type: FMS_TYPE[+p[0]] || 'WPT', alt: alt > 0 ? Math.round(alt) : null }); + } + } + } + if (wps.length < 1) return { ok: false, error: 'no waypoints in file' }; + setPlan({ name: safe.toUpperCase(), waypoints: wps, activeLeg: 1 }); + return { ok: true, plan, count: wps.length }; +} diff --git a/server/fmssync.js b/server/fmssync.js new file mode 100644 index 0000000..7813404 --- /dev/null +++ b/server/fmssync.js @@ -0,0 +1,85 @@ +// Two-way flight-plan sync with X-Plane's in-sim FMS, bridged by a FlyWithLua +// companion script (see plugins/fms-sync.lua). X-Plane's Web API can't inject a +// flight plan into the FMS, so the Lua script (which has the FMS SDK) does it. +// +// Channel = two text files in /Output/fms-sync/ (bridge + Lua run on +// the same PC). We write to_sim.txt (our plan); Lua applies it to the FMS and +// writes from_sim.txt (the sim's plan); we adopt sim-side changes. A position +// signature (3-decimal lat/lon) de-dupes so the two sides never loop. + +import fs from 'node:fs'; +import path from 'node:path'; +import { xplaneRoot } from './navdata.js'; + +function dir() { + const r = xplaneRoot(); + return r ? path.join(r, 'Output', 'fms-sync') : path.join(process.cwd(), 'fms-sync'); +} +const toSimFile = () => path.join(dir(), 'to_sim.txt'); +const fromSimFile = () => path.join(dir(), 'from_sim.txt'); + +// loop-guard signature: rounded lat/lon list (idents/alt ignored, and coords +// from our navdata == X-Plane's, so it stays stable across the round-trip) +const sig = (wps) => (wps || []).map((w) => `${(+w.lat).toFixed(3)},${(+w.lon).toFixed(3)}`).join(';'); +let lastSig = null; + +function serialize(wps) { + const body = (wps || []) + .map((w) => `${(+w.lat).toFixed(6)} ${(+w.lon).toFixed(6)} ${Math.round(w.alt || 0)} ${w.id || 'WPT'} ${w.type || 'WPT'}`) + .join('\n'); + return `# ${sig(wps)}\n${body}\n`; // first line = sig comment, then waypoints +} + +function parse(txt) { + const wps = []; + for (const ln of (txt || '').split(/\r?\n/)) { + const t = ln.trim(); + if (!t || t.startsWith('#')) continue; // skip sig/comment line + const p = t.split(/\s+/); + const lat = +p[0], lon = +p[1]; + if (p.length >= 2 && isFinite(lat) && isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) { + const alt = +p[2] || 0; + wps.push({ id: p[3] || 'WPT', lat, lon, type: p[4] || 'WPT', alt: alt > 0 ? alt : null }); + } + } + return wps; +} + +// our plan changed → hand it to the Lua script +export function pushToSim(plan) { + try { + fs.mkdirSync(dir(), { recursive: true }); + fs.writeFileSync(toSimFile(), serialize(plan?.waypoints || [])); + lastSig = sig(plan?.waypoints || []); + } catch { /* sim not local / no write access */ } +} + +// Terrain elevation grid published by the FlyWithLua terrain probe +// (terrain.json in the sync dir). Polled and broadcast so the MFD can colour +// terrain awareness (red/yellow). Only re-broadcasts when it actually changes. +const terrainFile = () => path.join(dir(), 'terrain.json'); +export function startTerrainSync(onTerrain, intervalMs = 1500) { + let lastMtime = 0; + setInterval(() => { + let st; + try { st = fs.statSync(terrainFile()); } catch { return; } + if (st.mtimeMs === lastMtime) return; + lastMtime = st.mtimeMs; + try { + const t = JSON.parse(fs.readFileSync(terrainFile(), 'utf8')); + if (t && Array.isArray(t.elev) && t.elev.length) onTerrain(t); + } catch { /* mid-write / malformed */ } + }, intervalMs); +} + +// poll the Lua-written sim plan; adopt genuine sim-side changes +export function startFmsSync({ getPlan, onSimPlan }) { + pushToSim(getPlan()); + setInterval(() => { + let txt; + try { txt = fs.readFileSync(fromSimFile(), 'utf8'); } catch { return; } + const wps = parse(txt); + const s = sig(wps); + if (wps.length && s && s !== lastSig) { lastSig = s; onSimPlan(wps); } + }, 1200); +} diff --git a/server/navdata.js b/server/navdata.js index 405b199..aac5fa5 100644 --- a/server/navdata.js +++ b/server/navdata.js @@ -41,7 +41,10 @@ const airports = []; // { id, lat, lon, name, elev } const navaids = []; // { id, lat, lon, type:'VOR'|'NDB', freq, name } const fixCells = new Map(); // "ilat,ilon" -> [{ id, lat, lon, type:'FIX' }] const rwyByApt = new Map(); // ICAO -> [{ n1, la1, lo1, n2, la2, lo2, w }] (runway ends + width m) -const state = { root: null, loaded: false, count: 0 }; +const comByApt = new Map(); // ICAO -> { freq, label, prio } (best ATC/CTAF frequency) +const ilsApts = new Set(); // ICAOs that have an ILS/LOC approach (for NRST "ILS") +const awyCells = new Map(); // "ilat,ilon" (segment midpoint) -> [{ la1, lo1, la2, lo2, name }] +const state = { root: null, loaded: false, count: 0, awy: 0 }; function add(id, lat, lon, type) { if (!id || !isFinite(lat) || !isFinite(lon)) return; @@ -90,6 +93,11 @@ async function parseNav(file) { if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue; const p = t.split(/\s+/); const code = parseInt(p[0], 10); + if (code === 4 || code === 5) { // ILS/LOC localizer → airport has an ILS + const ic = (p[8] || '').toUpperCase(); + if (ic && ic !== 'ENRT') ilsApts.add(ic); + continue; + } if (code !== 2 && code !== 3) continue; // 2 = NDB, 3 = VOR/DME const lat = parseFloat(p[1]), lon = parseFloat(p[2]), id = p[7]; const type = code === 2 ? 'NDB' : 'VOR'; @@ -128,10 +136,44 @@ async function parseAirports(file) { } } else if (!placed && icao && (code === 101 || code === 102)) { // water/heli pad place(parseFloat(p[code === 101 ? 4 : 5]), parseFloat(p[code === 101 ? 5 : 6])); + } else if (icao && ((code >= 50 && code <= 56) || (code >= 1050 && code <= 1056))) { + // ATC / CTAF frequencies. Old codes 50-56, new 1050-1056. Freq is kHz + // (>100000) or MHz×100. Keep the most useful one (TWR > UNICOM > ATIS …). + const c = code > 1000 ? code - 1000 : code; + const raw = parseInt(p[1], 10); + if (isFinite(raw) && raw > 0) { + const mhz = raw > 100000 ? raw / 1000 : raw / 100; + const meta = { 54: ['TOWER', 5], 51: ['UNICOM', 4], 50: ['ATIS', 3], 53: ['GROUND', 2], 55: ['APP', 1], 56: ['DEP', 1], 52: ['CLNC', 1] }[c] || ['COM', 0]; + const key = icao.toUpperCase(), prev = comByApt.get(key); + if (!prev || meta[1] > prev.prio) comByApt.set(key, { freq: mhz, label: meta[0], prio: meta[1] }); + } } } } +// Airways (earth_awy.dat): each row is a segment between two named waypoints. +// We resolve both endpoints to coordinates via the fix/navaid index (so this +// must run AFTER parseFixes/parseNav) and bucket segments by their midpoint +// cell for fast bbox queries — exactly like fixes. +async function parseAirways(file) { + if (!fs.existsSync(file)) return; + const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity }); + for await (const line of rl) { + const t = line.trim(); + if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue; + const p = t.split(/\s+/); + if (p.length < 10) continue; + const a = index.get((p[0] || '').toUpperCase()); + const b = index.get((p[3] || '').toUpperCase()); + if (!a || !b) continue; // endpoint not in our database + const name = p[p.length - 1]; + const k = `${Math.floor((a.lat + b.lat) / 2)},${Math.floor((a.lon + b.lon) / 2)}`; + let arr = awyCells.get(k); if (!arr) { arr = []; awyCells.set(k, arr); } + arr.push({ la1: a.lat, lo1: a.lon, la2: b.lat, lo2: b.lon, name }); + state.awy++; + } +} + export async function loadNavData() { const root = findRoot(); state.root = root; @@ -147,6 +189,10 @@ export async function loadNavData() { try { await parseFixes(pick('earth_fix.dat')); await parseNav(pick('earth_nav.dat')); + // airways need the fix/navaid index above; parse in the background. + parseAirways(pick('earth_awy.dat')) + .then(() => console.log(`navdata: airways done (${state.awy} segments)`)) + .catch((e) => console.log('navdata: airway parse skipped:', e.message)); // apt.dat is large; parse the global airports file in the background. parseAirports(path.join(root, 'Global Scenery', 'Global Airports', 'Earth nav data', 'apt.dat')) .then(() => { state.count = index.size; console.log(`navdata: airports done (${index.size} total entries)`); }) @@ -178,13 +224,25 @@ export function search(q, limit = 20) { // NEAREST: closest airports (default) or navaids to a point, with range/bearing. export function nearest(lat, lon, { count = 15, type = 'apt' } = {}) { if (!isFinite(lat) || !isFinite(lon)) return []; - const src = (type === 'vor' || type === 'ndb' || type === 'nav') ? navaids : airports; + const isApt = !(type === 'vor' || type === 'ndb' || type === 'nav'); + const src = isApt ? airports : navaids; return src .filter((f) => (type === 'vor' || type === 'ndb') ? f.type.toLowerCase() === type : true) .map((f) => ({ ...f, dist: distNm(lat, lon, f.lat, f.lon), brg: Math.round(bearingDeg(lat, lon, f.lat, f.lon)) })) .sort((a, b) => a.dist - b.dist) .slice(0, count) - .map((f) => ({ ...f, dist: +f.dist.toFixed(1) })); + .map((f) => { + const o = { ...f, dist: +f.dist.toFixed(1) }; + if (isApt) { // runway length, COM freq, approach type + const rs = rwyByApt.get(f.id); + let ft = 0; + if (rs) for (const r of rs) ft = Math.max(ft, distNm(r.la1, r.lo1, r.la2, r.lo2) * 6076.12); + o.rwyFt = Math.round(ft); + o.com = comByApt.get(f.id) || null; + o.app = ilsApts.has(f.id) ? 'ILS' : 'VFR'; + } + return o; + }); } // BBOX: every feature inside a lat/lon window, for the moving map to draw. @@ -205,6 +263,22 @@ export function bbox(s, w, n, e, types = ['apt', 'vor', 'ndb'], limit = 800) { return out; } +// BBOX airways: every segment touching a lat/lon window (scan the midpoint +// cells overlapping the box, ±1 to catch segments crossing the edge). +export function airwaysBbox(s, w, n, e, limit = 500) { + const out = []; + const inB = (la, lo) => la >= s && la <= n && lo >= w && lo <= e; + for (let la = Math.floor(s) - 1; la <= Math.floor(n) + 1; la++) + for (let lo = Math.floor(w) - 1; lo <= Math.floor(e) + 1; lo++) { + const arr = awyCells.get(`${la},${lo}`); + if (!arr) continue; + for (const sg of arr) { + if (inB(sg.la1, sg.lo1) || inB(sg.la2, sg.lo2)) { out.push(sg); if (out.length >= limit) return out; } + } + } + return out; +} + // Runways of every airport within radiusNm — for the PFD's synthetic-vision view. export function runwaysNear(lat, lon, radiusNm = 12) { if (!isFinite(lat) || !isFinite(lon)) return []; diff --git a/web/public/sw.js b/web/public/sw.js index 30a599c..2b68251 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,7 +1,7 @@ // Minimal service worker: caches the app shell so the cockpit launches fast and // survives brief network blips. Live data (the bridge WebSocket, /api, and map // tiles) is never cached — only same-origin GET app assets. -const CACHE = 'g1000-shell-v1'; +const CACHE = 'g1000-shell-v2'; self.addEventListener('install', () => self.skipWaiting()); @@ -18,7 +18,19 @@ self.addEventListener('fetch', (e) => { if (e.request.method !== 'GET' || url.origin !== location.origin) return; if (url.pathname.startsWith('/api') || url.pathname === '/ws') return; - // Stale-while-revalidate: serve cache fast, refresh in the background. + // The HTML entry is NETWORK-FIRST: a reload always gets the latest build (and + // thus the latest hashed assets). Falls back to cache only when offline. + const isDoc = e.request.mode === 'navigate' || url.pathname === '/' || url.pathname.endsWith('.html'); + if (isDoc) { + e.respondWith( + fetch(e.request) + .then((res) => { caches.open(CACHE).then((c) => c.put(e.request, res.clone())); return res; }) + .catch(() => caches.match(e.request).then((c) => c || caches.match('/'))) + ); + return; + } + + // Hashed assets are immutable → stale-while-revalidate (fast + self-healing). e.respondWith( caches.open(CACHE).then(async (cache) => { const cached = await cache.match(e.request); diff --git a/web/src/App.jsx b/web/src/App.jsx index 7464b80..6fa8110 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useXplane } from './api/useXplane.js'; import PFD from './components/PFD.jsx'; import AutopilotPanel from './components/AutopilotPanel.jsx'; @@ -9,6 +9,7 @@ import VFR from './components/VFR.jsx'; import Bezel from './components/Bezel.jsx'; import DirectTo from './components/DirectTo.jsx'; import Proc from './components/Proc.jsx'; +import FplPage from './components/FplPage.jsx'; // Compact line icons for the nav rail (stroke = currentColor). const ICONS = { @@ -57,20 +58,42 @@ export default function App() { const [inset, setInset] = useState(false); // INSET map options (base layer + declutter), set from the INSET submenu. const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 }); - // The NRST (nearest airports/navaids) window, toggled by the PFD NRST softkey. - const [nrst, setNrst] = useState(false); - // The TMR/REF (timer / references) window, toggled by the PFD TMR/REF softkey. - const [tmr, setTmr] = useState(false); + // Like the real G1000, only ONE window is open at a time. A single string + // holds the open one (nrst / tmr / dme / alerts / fpl / dto / proc); toggling + // the same softkey closes it, opening another replaces it. + const [win, setWin] = useState(null); + const toggleWin = (id) => setWin((w) => (w === id ? null : id)); + const nrst = win === 'nrst', tmr = win === 'tmr', dme = win === 'dme', alerts = win === 'alerts'; + const fpl = win === 'fpl', dto = win === 'dto', proc = win === 'proc'; // MFD map mode (base layer), switched via the Map-Opt softkeys. const [mapMode, setMapMode] = useState({ base: 'topo' }); - // Direct-To (D→) dialog — opened from the bezel on either GDU. - const [dto, setDto] = useState(false); - // PROC (procedures: SID/STAR/approach) dialog — opened from the bezel. - const [proc, setProc] = useState(false); + // MFD page group (MAP / FPL / NRST) — selected by the FMS knob, like the real G1000. + const MFD_PAGES = ['map', 'fpl', 'nrst']; + const [mfdPage, setMfdPage] = useState('map'); + const cycleMfd = (dir = 1) => setMfdPage((p) => MFD_PAGES[(MFD_PAGES.indexOf(p) + dir + MFD_PAGES.length) % MFD_PAGES.length]); + // G1000 UI-state sync (Sim → App): follow the in-sim G1000 when the FlyWithLua + // companion publishes its state. No-ops until then, so local control still works. + const uiInset = xp.values.uiInset, uiPage = xp.values.uiMfdPage; + useEffect(() => { if (uiInset === 0 || uiInset === 1) setInset(!!uiInset); }, [uiInset]); + useEffect(() => { if (typeof uiPage === 'number' && MFD_PAGES[uiPage]) setMfdPage(MFD_PAGES[uiPage]); }, [uiPage]); const connKind = xp.xpConnected ? 'ok' : xp.connected ? 'warn' : 'bad'; const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE'; + // G1000 side-window dialogs — rendered inside the bezel display so they sit in + // the display's lower-right (like the real unit), not over the whole app. + const dialogs = ( + <> + {dto && setWin(null)} />} + {proc && setWin(null)} />} + {fpl && ( +
setWin(null)}> +
e.stopPropagation()}> setWin(null)} />
+
+ )} + + ); + return (