G1000: two-way sim sync, more PFD/MFD fidelity, authentic dialogs

Sync (FlyWithLua companions in plugins/ + server/fmssync.js):
- FMS flight-plan two-way sync (App <-> in-sim FMS) via fms-sync.lua
- G1000 UI-state publish (page/range/inset) via ui-sync.lua + CDI source,
  baro, map-range follow
- Terrain awareness: elevation grid probe (terrain-probe.lua) -> red/yellow
  MFD overlay vs aircraft altitude

PFD:
- AFCS mode annunciation bar from autopilot _status datarefs
- CDI source GPS/VLOC colouring, BRG1/BRG2 pointers + DME windows, marker beacons
- magenta speed/altitude trend vectors, selected-altitude alerting
- time-based (frame-rate-independent) smoothing for attitude/heading/tapes

MFD:
- nav data bar (DTK/ETE/active leg), airways overlay from earth_awy.dat,
  compass rose anchored to the ownship

Dialogs (NEAREST/FLIGHTPLAN/DIRECT-TO/PROCEDURES):
- flat, square, embedded G1000 look (no shadow/rounded/transparency)
- compact lower-right placement, no close X (softkey toggles), single window
- NEAREST 2-line entries (ILS/VFR, COM freq, runway length), PROC action menu

Service worker: network-first HTML so reloads pick up new builds (cache v2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 02:17:06 +02:00
parent 354ea5d44b
commit 38b048ad41
23 changed files with 1707 additions and 213 deletions
+31
View File
@@ -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
`<X-Plane>/Resources/plugins/FlyWithLua/`.
2. Copy the scripts into `<X-Plane>/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 `<X-Plane>/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).
+113
View File
@@ -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:
--
-- <X-Plane>/Output/fms-sync/to_sim.txt written by the bridge (our plan)
-- <X-Plane>/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 <X-Plane>/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)
+55
View File
@@ -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 <X-Plane>/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)
+66
View File
@@ -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 <X-Plane>/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)")