From b9241e60c8c26285147298c296d2b290ca7e9a9c Mon Sep 17 00:00:00 2001 From: karim Date: Thu, 4 Jun 2026 23:35:33 +0200 Subject: [PATCH] feat(desktop): bundle Lua plugin resources (fms-sync, terrain-probe, ui-sync) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src-tauri/resources/plugins/fms-sync.lua | 113 ++++++++++++++++++ .../resources/plugins/terrain-probe.lua | 55 +++++++++ .../src-tauri/resources/plugins/ui-sync.lua | 66 ++++++++++ 3 files changed, 234 insertions(+) create mode 100644 desktop/src-tauri/resources/plugins/fms-sync.lua create mode 100644 desktop/src-tauri/resources/plugins/terrain-probe.lua create mode 100644 desktop/src-tauri/resources/plugins/ui-sync.lua diff --git a/desktop/src-tauri/resources/plugins/fms-sync.lua b/desktop/src-tauri/resources/plugins/fms-sync.lua new file mode 100644 index 0000000..9bef7f7 --- /dev/null +++ b/desktop/src-tauri/resources/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/desktop/src-tauri/resources/plugins/terrain-probe.lua b/desktop/src-tauri/resources/plugins/terrain-probe.lua new file mode 100644 index 0000000..7f3e2c6 --- /dev/null +++ b/desktop/src-tauri/resources/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/desktop/src-tauri/resources/plugins/ui-sync.lua b/desktop/src-tauri/resources/plugins/ui-sync.lua new file mode 100644 index 0000000..efe38af --- /dev/null +++ b/desktop/src-tauri/resources/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)")