Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a94b099005 |
@@ -1,26 +0,0 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
web/node_modules/
|
||||
desktop/node_modules/
|
||||
|
||||
# build output
|
||||
web/dist/
|
||||
desktop/src-tauri/target/
|
||||
target-linux/
|
||||
desktop/src-tauri/gen/
|
||||
desktop/latest.json
|
||||
fms-out/
|
||||
|
||||
# generated bundle inputs (recreated by scripts/prep-desktop.sh)
|
||||
desktop/src-tauri/binaries/
|
||||
desktop/src-tauri/resources/web/
|
||||
|
||||
# SECRETS — never commit the updater signing private key / password
|
||||
desktop/.tauri-signing.key
|
||||
desktop/.tauri-signing.pw
|
||||
|
||||
# local agent + editor + misc
|
||||
.claude/
|
||||
screenshots/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -1,76 +1,3 @@
|
||||
# X-Plane Glass Cockpit (Web)
|
||||
# xplane-cockpit
|
||||
|
||||
Bring X-Plane 12 instruments — a G1000-style **PFD**, an **MFD**, and an
|
||||
**autopilot** panel — to any iPad, tablet or laptop on your network. Pure web,
|
||||
no app install on the tablets. Just open a browser.
|
||||
|
||||
```
|
||||
X-Plane 12 Node bridge (this repo) your devices
|
||||
┌──────────┐ ws ┌────────────────────┐ ws/http ┌──────────┐
|
||||
│ Web API │◀──────▶│ resolves dataref │◀───────────▶│ iPad │
|
||||
│ :8086 │ REST │ IDs, streams values │ │ laptop │
|
||||
│ (local) │ │ serves the React UI │ │ phone │
|
||||
└──────────┘ └────────────────────┘ └──────────┘
|
||||
binds 0.0.0.0:8080
|
||||
```
|
||||
|
||||
## Why a bridge?
|
||||
X-Plane's built-in web server (v12.1.1+) only listens on `localhost`, dataref
|
||||
IDs change every session, and CORS blocks browsers. The bridge runs **on the
|
||||
same PC as X-Plane**, talks to it locally, and re-broadcasts everything to your
|
||||
LAN — to as many tablets as you like at once.
|
||||
|
||||
## Requirements
|
||||
- **X-Plane 12.1.1 or newer** (the web API ships built-in; nothing to enable).
|
||||
- **Node.js 18+** on the PC running X-Plane (`node --version`).
|
||||
|
||||
## Setup (run these on the X-Plane PC)
|
||||
```bash
|
||||
cd X-PLANE-MOD
|
||||
npm install # also installs the web app's deps
|
||||
npm run build # builds the React UI into web/dist
|
||||
npm start # starts the bridge on http://0.0.0.0:8080
|
||||
```
|
||||
|
||||
## Open it
|
||||
1. Make sure X-Plane is running and you're in a flight.
|
||||
2. On a tablet/laptop on the **same Wi-Fi**, open:
|
||||
`http://<PC-LAN-IP>:8080`
|
||||
- Find the IP: macOS `ipconfig getifaddr en0` · Windows `ipconfig` (IPv4).
|
||||
3. The status pill top-right shows **X-PLANE** (green) when data is flowing,
|
||||
**NO SIM** if the bridge is up but X-Plane isn't reachable, **OFFLINE** if
|
||||
the tablet can't reach the bridge.
|
||||
4. Tip: on iPad, "Add to Home Screen" → it opens full-screen like a real app.
|
||||
|
||||
## Development (hot reload)
|
||||
```bash
|
||||
npm run dev:bridge # terminal 1 — the bridge on :8080
|
||||
npm run dev:web # terminal 2 — Vite dev server with HMR (proxies to bridge)
|
||||
```
|
||||
Open the URL Vite prints. Edits to `web/src/**` reload instantly.
|
||||
|
||||
## Configuration
|
||||
All env vars are optional (defaults shown):
|
||||
| Var | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `BRIDGE_PORT` | `8080` | Port the UI/LAN server listens on |
|
||||
| `XPLANE_HOST` | `localhost` | Where X-Plane's web API is |
|
||||
| `XPLANE_PORT` | `8086` | X-Plane web API port |
|
||||
|
||||
## Adding instruments / datarefs
|
||||
Everything is driven by [`server/config.js`](server/config.js):
|
||||
- **`DATAREFS`** — values streamed to the UI (alias → `sim/...` name).
|
||||
- **`WRITABLE_DATAREFS`** — values the UI may set (knobs/bugs).
|
||||
- **`COMMANDS`** — buttons the UI may press (mode toggles).
|
||||
|
||||
Add a `sim/...` name there, then read `values.<alias>` in any component. For
|
||||
**G1000-specific** gauges, add that aircraft's `laminar/...` or
|
||||
`sim/cockpit2/...` datarefs the same way.
|
||||
|
||||
## Notes & limits
|
||||
- Update rate is X-Plane's (~10–20 Hz) — fine for instruments, this isn't a
|
||||
scenery stream.
|
||||
- The autopilot buttons fire X-Plane's own commands, so the sim stays the
|
||||
source of truth. Mode-highlight bits (`AP_BITS` in `AutopilotPanel.jsx`) are
|
||||
best-effort and can differ per aircraft.
|
||||
- LAN only by design. Don't expose port 8080 to the public internet.
|
||||
X-Plane G1000 web cockpit + desktop launcher
|
||||
@@ -1,15 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
const b = await chromium.launch();
|
||||
const p = await b.newPage({ viewport: { width: 1180, height: 820 } });
|
||||
const errs = [];
|
||||
p.on('pageerror', e => errs.push('PAGEERR: ' + e.message));
|
||||
p.on('console', m => { if (m.type() === 'error') errs.push('CONSOLE: ' + m.text()); });
|
||||
await p.goto('http://localhost:8099', { waitUntil: 'networkidle' });
|
||||
await p.getByRole('button', { name: 'MFD', exact: true }).click();
|
||||
await p.waitForTimeout(1500);
|
||||
const apKeys = await p.$$eval('.ap-key', els => els.map(e => e.textContent));
|
||||
const title = await p.$eval('.bezel-title', e => e.textContent).catch(() => null);
|
||||
console.log('AP keys on MFD:', JSON.stringify(apKeys));
|
||||
console.log('Bezel title:', title);
|
||||
console.log('Errors:', errs.length ? errs.join(' | ') : 'NONE');
|
||||
await b.close();
|
||||
@@ -1,21 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1180, height: 820 } });
|
||||
page.on('console', (m) => console.log('[console]', m.type(), m.text()));
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
await page.goto('http://localhost:8099', { waitUntil: 'networkidle' });
|
||||
await page.getByRole('button', { name: 'PFD', exact: true }).click();
|
||||
await page.waitForTimeout(5000);
|
||||
// report canvas presence + size
|
||||
const info = await page.evaluate(() => {
|
||||
const c = document.querySelector('.svt-canvas canvas');
|
||||
const f = document.querySelector('.svt-pos');
|
||||
return {
|
||||
hasCanvas: !!c,
|
||||
canvasSize: c ? [c.width, c.height] : null,
|
||||
svtPos: f ? f.getBoundingClientRect() : null,
|
||||
webgl: (() => { try { return !!document.createElement('canvas').getContext('webgl2'); } catch { return false; } })(),
|
||||
};
|
||||
});
|
||||
console.log('[info]', JSON.stringify(info));
|
||||
await browser.close();
|
||||
@@ -1 +0,0 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5MzFGQTUzOEUyOURFOTkKUldTWjNpbU9VL294V1ZWZllVMzc5MGR6OVFVcGRkSTVkcG1LUDJXODJzT2psbFZoY2JYT0E3dEIK
|
||||
@@ -1,31 +0,0 @@
|
||||
# Linux build image for the Tauri app (x86_64). Used to cross-build an AppImage
|
||||
# + .deb from the macOS dev machine via Docker (linux/amd64). The Node bridge
|
||||
# sidecar is compiled on the host by Bun, so this image only needs the Rust /
|
||||
# Tauri / GTK / WebKit toolchain.
|
||||
FROM rust:1-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libxdo-dev \
|
||||
patchelf \
|
||||
file \
|
||||
wget \
|
||||
curl \
|
||||
xz-utils \
|
||||
ca-certificates \
|
||||
fuse \
|
||||
desktop-file-utils \
|
||||
xdg-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node + a GLOBAL Tauri CLI (so we never touch the mounted node_modules, which
|
||||
# holds the host/macOS CLI binary).
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs && rm -rf /var/lib/apt/lists/* \
|
||||
&& npm install -g @tauri-apps/cli@2
|
||||
|
||||
WORKDIR /work/desktop
|
||||
@@ -1,71 +0,0 @@
|
||||
# X-Plane Cockpit — Desktop App
|
||||
|
||||
A small launcher (Tauri) that runs the G1000 web cockpit's server on your PC and
|
||||
shows the LAN address tablets/laptops open. The Node "bridge" server is bundled
|
||||
as a Bun-compiled sidecar, so **nothing else needs to be installed**.
|
||||
|
||||
## Using it (for a tester)
|
||||
|
||||
1. Install & open **X-Plane Cockpit**.
|
||||
- macOS: open the `.dmg`, drag the app to Applications. It's **ad-hoc signed**
|
||||
(no Apple Developer ID), so on first launch use **right-click → Open** and
|
||||
confirm, or run `xattr -dr com.apple.quarantine "/Applications/X-Plane Cockpit.app"`.
|
||||
- Linux: make the `.AppImage` executable (`chmod +x`) and run it, or install the `.deb`.
|
||||
2. Point it at your **X-Plane 12 folder** (it auto-detects common locations).
|
||||
No X-Plane handy? Tick **Demo-Modus** to try the cockpit with synthetic data.
|
||||
3. Make sure X-Plane's Web API is on: X-Plane → Settings → Network →
|
||||
*Enable web server / API* (X-Plane 12.1.1+).
|
||||
4. Click **Server starten**. Open the shown URL (e.g. `http://192.168.1.27:8080`)
|
||||
on any tablet/laptop on the same Wi-Fi. The PFD/MFD/Map/FMS buttons open pages directly.
|
||||
|
||||
Updates: **Nach Updates suchen** in the footer pulls the latest release from Gitea.
|
||||
|
||||
> LAN only by design. Don't expose the port to the public internet.
|
||||
|
||||
## Building (for the developer)
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
# 1. prep: build the web cockpit + compile the Bun sidecars (mac + linux)
|
||||
bash scripts/prep-desktop.sh
|
||||
|
||||
# 2a. macOS app (native, ad-hoc signed) + updater artifacts
|
||||
APPLE_SIGNING_IDENTITY="-" \
|
||||
TAURI_SIGNING_PRIVATE_KEY="$(cat desktop/.tauri-signing.key)" \
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat desktop/.tauri-signing.pw)" \
|
||||
npx --prefix desktop tauri build --target aarch64-apple-darwin
|
||||
|
||||
# 2b. Linux AppImage + .deb (x86_64) via Docker
|
||||
bash scripts/build-linux.sh
|
||||
|
||||
# 3. publish to Gitea + refresh the updater's latest.json
|
||||
GITEA_URL=https://git.kgva.ch GITEA_REPO=karim/xplane-cockpit \
|
||||
GITEA_TOKEN=$(cat /tmp/gitea_token) \
|
||||
node scripts/release-gitea.mjs
|
||||
```
|
||||
|
||||
## Testing the auto-updater (two versions)
|
||||
|
||||
The updater only fires when `latest.json` advertises a version **newer** than the
|
||||
installed one. So to test it end-to-end:
|
||||
|
||||
```bash
|
||||
# 1. publish the baseline the tester installs
|
||||
# (version 0.1.0 in tauri.conf.json) → release-gitea.mjs uploads assets + latest.json
|
||||
# 2. install that 0.1.0 build on the test machine
|
||||
# 3. bump the version, rebuild, re-release:
|
||||
# edit desktop/src-tauri/tauri.conf.json "version": "0.1.1"
|
||||
bash scripts/prep-desktop.sh
|
||||
APPLE_SIGNING_IDENTITY="-" TAURI_SIGNING_PRIVATE_KEY="$(cat desktop/.tauri-signing.key)" \
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat desktop/.tauri-signing.pw)" \
|
||||
npx --prefix desktop tauri build --target aarch64-apple-darwin
|
||||
bash scripts/build-linux.sh
|
||||
GITEA_TOKEN=$(cat /tmp/gitea_token) node scripts/release-gitea.mjs
|
||||
# 4. in the installed 0.1.0 app: launch → silent check shows the update banner,
|
||||
# or click "Nach Updates suchen" → Installieren → app relaunches as 0.1.1.
|
||||
```
|
||||
|
||||
The updater signing keypair lives in `desktop/.tauri-signing.key(.pub/.pw)` —
|
||||
**keep the private key + password safe; they never go into the bundle or Gitea.**
|
||||
The matching public key is embedded in `tauri.conf.json` (`plugins.updater.pubkey`).
|
||||
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"name": "xplane-cockpit-desktop",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "xplane-cockpit-desktop",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
|
||||
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.2",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
|
||||
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
|
||||
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
|
||||
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
|
||||
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
|
||||
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "xplane-cockpit-desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "xplane-cockpit"
|
||||
version = "0.1.4"
|
||||
description = "Desktop launcher for the X-Plane G1000 web cockpit"
|
||||
authors = ["karim"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
[lib]
|
||||
name = "xplane_cockpit_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
local-ip-address = "0.6"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities for the control panel window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:allow-start-dragging",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [{ "name": "binaries/xpbridge", "sidecar": true, "args": true }]
|
||||
},
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-kill",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-open-url",
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 893 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -1,269 +0,0 @@
|
||||
// X-Plane Cockpit desktop launcher.
|
||||
//
|
||||
// A small control panel that: (1) lets the user point at their X-Plane 12
|
||||
// install, (2) starts/stops the bundled Node "bridge" server (a Bun-compiled
|
||||
// sidecar), and (3) shows the LAN URL tablets open to see the G1000 cockpit.
|
||||
// The cockpit web files travel with the app as a resource (WEB_DIST). A system
|
||||
// tray keeps the server running when the window is closed.
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::menu::{MenuBuilder, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{Emitter, Manager, State};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ServerState {
|
||||
child: Mutex<Option<CommandChild>>,
|
||||
port: Mutex<u16>,
|
||||
url: Mutex<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct ServerInfo {
|
||||
ip: String,
|
||||
port: u16,
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn lan_ipv4() -> String {
|
||||
local_ip_address::local_ip()
|
||||
.map(|ip| ip.to_string())
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lan_ip() -> String {
|
||||
lan_ipv4()
|
||||
}
|
||||
|
||||
// Can we bind this TCP port on the LAN interface? (i.e. is it free)
|
||||
fn is_port_free(port: u16) -> bool {
|
||||
TcpListener::bind(("0.0.0.0", port)).is_ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn port_free(port: u16) -> bool {
|
||||
is_port_free(port)
|
||||
}
|
||||
|
||||
// First free port at/after `start` (so the UI can offer an alternative).
|
||||
#[tauri::command]
|
||||
fn suggest_port(start: u16) -> u16 {
|
||||
let mut p = start.max(1024);
|
||||
for _ in 0..200 {
|
||||
if is_port_free(p) {
|
||||
return p;
|
||||
}
|
||||
p = p.saturating_add(1);
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn default_xplane_path() -> Option<String> {
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default();
|
||||
let candidates = [
|
||||
format!("{home}/X-Plane 12"),
|
||||
format!("{home}/Desktop/X-Plane 12"),
|
||||
"/Applications/X-Plane 12".to_string(),
|
||||
"C:/X-Plane 12".to_string(),
|
||||
"D:/X-Plane 12".to_string(),
|
||||
];
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|c| PathBuf::from(c).join("Resources").join("default data").is_dir())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn valid_xplane_path(path: String) -> bool {
|
||||
!path.is_empty()
|
||||
&& PathBuf::from(&path)
|
||||
.join("Resources")
|
||||
.join("default data")
|
||||
.is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn server_running(state: State<ServerState>) -> bool {
|
||||
state.child.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_server(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, ServerState>,
|
||||
xplane_path: String,
|
||||
port: u16,
|
||||
demo: bool,
|
||||
) -> Result<ServerInfo, String> {
|
||||
if state.child.lock().unwrap().is_some() {
|
||||
let p = *state.port.lock().unwrap();
|
||||
let ip = lan_ipv4();
|
||||
return Ok(ServerInfo { url: format!("http://{ip}:{p}"), ip, port: p });
|
||||
}
|
||||
if !is_port_free(port) {
|
||||
return Err(format!("Port {port} ist belegt — wähle einen anderen."));
|
||||
}
|
||||
|
||||
let web_dist = app
|
||||
.path()
|
||||
.resolve("web", tauri::path::BaseDirectory::Resource)
|
||||
.map_err(|e| format!("resource path: {e}"))?;
|
||||
|
||||
let mut cmd = app
|
||||
.shell()
|
||||
.sidecar("xpbridge")
|
||||
.map_err(|e| format!("sidecar: {e}"))?
|
||||
.env("BRIDGE_PORT", port.to_string())
|
||||
.env("BRIDGE_HOST", "0.0.0.0")
|
||||
.env("WEB_DIST", web_dist.to_string_lossy().to_string());
|
||||
|
||||
if !xplane_path.is_empty() {
|
||||
cmd = cmd.env("XPLANE_ROOT", xplane_path);
|
||||
}
|
||||
if demo {
|
||||
cmd = cmd.env("DEMO", "1");
|
||||
}
|
||||
|
||||
let (mut rx, child) = cmd.spawn().map_err(|e| format!("spawn: {e}"))?;
|
||||
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
let line = match event {
|
||||
CommandEvent::Stdout(b) | CommandEvent::Stderr(b) => {
|
||||
String::from_utf8_lossy(&b).to_string()
|
||||
}
|
||||
CommandEvent::Terminated(_) => {
|
||||
let _ = app2.emit("server-exited", ());
|
||||
break;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let _ = app2.emit("server-log", line);
|
||||
}
|
||||
});
|
||||
|
||||
let ip = lan_ipv4();
|
||||
let url = format!("http://{ip}:{port}");
|
||||
*state.child.lock().unwrap() = Some(child);
|
||||
*state.port.lock().unwrap() = port;
|
||||
*state.url.lock().unwrap() = url.clone();
|
||||
|
||||
Ok(ServerInfo { url, ip, port })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_server(state: State<ServerState>) -> Result<(), String> {
|
||||
if let Some(child) = state.child.lock().unwrap().take() {
|
||||
child.kill().map_err(|e| format!("kill: {e}"))?;
|
||||
}
|
||||
*state.url.lock().unwrap() = String::new();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_sidecar(app: &tauri::AppHandle) {
|
||||
if let Some(state) = app.try_state::<ServerState>() {
|
||||
if let Some(child) = state.child.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(ServerState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
lan_ip,
|
||||
port_free,
|
||||
suggest_port,
|
||||
default_xplane_path,
|
||||
valid_xplane_path,
|
||||
server_running,
|
||||
start_server,
|
||||
stop_server
|
||||
])
|
||||
.setup(|app| {
|
||||
build_tray(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
// Closing the window hides it instead of quitting, so the server keeps
|
||||
// serving tablets in the background. Quit from the tray.
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn build_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||
let show = MenuItem::with_id(app, "show", "Panel anzeigen", true, None::<&str>)?;
|
||||
let open = MenuItem::with_id(app, "open", "Cockpit öffnen", true, None::<&str>)?;
|
||||
let copy = MenuItem::with_id(app, "copy", "URL kopieren", true, None::<&str>)?;
|
||||
let toggle = MenuItem::with_id(app, "toggle", "Server starten / stoppen", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show)
|
||||
.item(&open)
|
||||
.item(©)
|
||||
.item(&sep)
|
||||
.item(&toggle)
|
||||
.item(&sep)
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
TrayIconBuilder::with_id("main")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.tooltip("X-Plane Cockpit")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show" => show_main(app),
|
||||
"open" => { let _ = app.emit("tray-open", ()); }
|
||||
"copy" => {
|
||||
if let Some(state) = app.try_state::<ServerState>() {
|
||||
let url = state.url.lock().unwrap().clone();
|
||||
if !url.is_empty() {
|
||||
let _ = app.clipboard().write_text(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
"toggle" => { let _ = app.emit("tray-toggle", ()); }
|
||||
"quit" => { kill_sidecar(app); app.exit(0); }
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click { .. } = event {
|
||||
show_main(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_main(app: &tauri::AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// Prevents an extra console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
xplane_cockpit_lib::run()
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "X-Plane Cockpit",
|
||||
"version": "0.1.4",
|
||||
"identifier": "ch.kgva.xplanecockpit",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "X-Plane Cockpit",
|
||||
"width": 480,
|
||||
"height": 720,
|
||||
"minWidth": 420,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"app",
|
||||
"dmg",
|
||||
"appimage",
|
||||
"deb"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/xpbridge"
|
||||
],
|
||||
"resources": {
|
||||
"resources/web": "web"
|
||||
},
|
||||
"createUpdaterArtifacts": true,
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
},
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://git.kgva.ch/karim/xplane-cockpit/releases/download/updater/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5MzFGQTUzOEUyOURFOTkKUldTWjNpbU9VL294V1ZWZllVMzc5MGR6OVFVcGRkSTVkcG1LUDJXODJzT2psbFZoY2JYT0E3dEIK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>X-Plane Cockpit</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<header class="hd">
|
||||
<div class="brand">G1000<span>·web</span></div>
|
||||
<div id="status" class="status off"><span class="dot"></span><span id="statusText">Gestoppt</span></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="updateBanner" class="update-banner hidden">
|
||||
<div class="ub-text"><b id="ubTitle">Update verfügbar</b><span id="ubNotes"></span></div>
|
||||
<div class="ub-actions">
|
||||
<button id="ubInstall" class="btn ok sm">Installieren</button>
|
||||
<button id="ubDismiss" class="btn ghost sm">Später</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<label class="lbl">X-Plane 12 Ordner</label>
|
||||
<div class="row">
|
||||
<input id="xpPath" type="text" placeholder="z.B. /Users/du/X-Plane 12" spellcheck="false" />
|
||||
<button id="browse" class="btn ghost">Suchen…</button>
|
||||
</div>
|
||||
<div id="xpHint" class="hint"></div>
|
||||
|
||||
<div class="row gap">
|
||||
<div class="field">
|
||||
<label class="lbl">Port</label>
|
||||
<input id="port" type="number" value="8080" min="1024" max="65535" />
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input id="demo" type="checkbox" />
|
||||
<span>Demo-Modus (ohne X-Plane)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="portHint" class="hint"></div>
|
||||
</section>
|
||||
|
||||
<button id="startBtn" class="btn primary big">Server starten</button>
|
||||
|
||||
<section id="liveCard" class="card live hidden">
|
||||
<label class="lbl">Auf Tablets / Laptops öffnen</label>
|
||||
<div class="url-row">
|
||||
<code id="url">—</code>
|
||||
<button id="copy" class="btn ghost sm" title="Kopieren">⧉</button>
|
||||
</div>
|
||||
<div class="quick">
|
||||
<button class="btn ghost sm" data-page="pfd">PFD</button>
|
||||
<button class="btn ghost sm" data-page="mfd">MFD</button>
|
||||
<button class="btn ghost sm" data-page="map">Map</button>
|
||||
<button class="btn ghost sm" data-page="fms">FMS</button>
|
||||
</div>
|
||||
<button id="openBtn" class="btn ok">Cockpit im Browser öffnen</button>
|
||||
|
||||
<div class="diag">
|
||||
<div class="diag-row"><span>X-Plane</span><b id="dXp">—</b></div>
|
||||
<div class="diag-row"><span>Verbundene Geräte</span><b id="dClients">—</b></div>
|
||||
<div class="diag-row"><span>Navdata</span><b id="dNav">—</b></div>
|
||||
<div class="diag-row"><span>Datarefs</span><b id="dRefs">—</b></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="log-wrap">
|
||||
<summary>Server-Log</summary>
|
||||
<pre id="log"></pre>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<footer class="ft">
|
||||
<span id="ver">v—</span>
|
||||
<button id="updateBtn" class="link">Nach Updates suchen</button>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,194 +0,0 @@
|
||||
// Control-panel logic. Uses the global Tauri API (withGlobalTauri).
|
||||
const T = window.__TAURI__ || {};
|
||||
const invoke = T.core.invoke;
|
||||
const listen = T.event.listen;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const xpPath = $('xpPath'), portEl = $('port'), demoEl = $('demo');
|
||||
const startBtn = $('startBtn'), liveCard = $('liveCard'), urlEl = $('url');
|
||||
const statusEl = $('status'), statusText = $('statusText'), logEl = $('log');
|
||||
|
||||
let running = false, healthTimer = null;
|
||||
|
||||
function setStatus(kind, text) {
|
||||
statusEl.className = 'status ' + kind;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
async function validatePath() {
|
||||
const p = xpPath.value.trim();
|
||||
const hint = $('xpHint');
|
||||
if (!p) { hint.textContent = ''; hint.className = 'hint'; return false; }
|
||||
const ok = await invoke('valid_xplane_path', { path: p });
|
||||
hint.textContent = ok ? '✓ X-Plane erkannt' : '⚠ kein „Resources/default data“ — Demo-Modus nutzen oder Pfad prüfen';
|
||||
hint.className = 'hint ' + (ok ? 'ok' : 'bad');
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Is the chosen port free? If not, offer the next free one.
|
||||
async function validatePort() {
|
||||
const hint = $('portHint');
|
||||
const port = parseInt(portEl.value, 10) || 0;
|
||||
if (port < 1024 || port > 65535) { hint.textContent = '⚠ Port 1024–65535'; hint.className = 'hint bad'; return false; }
|
||||
const free = await invoke('port_free', { port });
|
||||
if (free) { hint.textContent = '✓ Port frei'; hint.className = 'hint ok'; return true; }
|
||||
const alt = await invoke('suggest_port', { start: port + 1 });
|
||||
hint.innerHTML = `⚠ Port ${port} belegt — <a href="#" id="usePort">${alt} verwenden</a>`;
|
||||
hint.className = 'hint bad';
|
||||
const a = $('usePort');
|
||||
if (a) a.onclick = (e) => { e.preventDefault(); portEl.value = alt; validatePort(); };
|
||||
return false;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try { $('ver').textContent = 'v' + (await T.app.getVersion()); } catch {}
|
||||
try {
|
||||
const def = await invoke('default_xplane_path');
|
||||
if (def) { xpPath.value = def; validatePath(); }
|
||||
} catch {}
|
||||
validatePort();
|
||||
checkUpdate(true); // silent on launch
|
||||
}
|
||||
|
||||
xpPath.addEventListener('change', validatePath);
|
||||
xpPath.addEventListener('blur', validatePath);
|
||||
portEl.addEventListener('change', validatePort);
|
||||
portEl.addEventListener('blur', validatePort);
|
||||
|
||||
$('browse').addEventListener('click', async () => {
|
||||
try {
|
||||
const dir = await T.dialog.open({ directory: true, multiple: false, title: 'X-Plane 12 Ordner wählen' });
|
||||
if (dir) { xpPath.value = dir; validatePath(); }
|
||||
} catch (e) { appendLog('dialog: ' + e); }
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
if (running) return stop();
|
||||
if (!(await validatePort())) return; // refuse a busy port up front
|
||||
startBtn.disabled = true;
|
||||
try {
|
||||
const info = await invoke('start_server', {
|
||||
xplanePath: xpPath.value.trim(),
|
||||
port: parseInt(portEl.value, 10) || 8080,
|
||||
demo: demoEl.checked,
|
||||
});
|
||||
running = true;
|
||||
urlEl.textContent = info.url;
|
||||
liveCard.classList.remove('hidden');
|
||||
startBtn.textContent = 'Server stoppen';
|
||||
startBtn.classList.add('stop');
|
||||
setStatus('warn', 'Server läuft · warte auf Sim');
|
||||
pollHealth(info.port);
|
||||
} catch (e) {
|
||||
appendLog('Fehler: ' + e);
|
||||
setStatus('off', 'Fehler');
|
||||
} finally {
|
||||
startBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function stop() {
|
||||
startBtn.disabled = true;
|
||||
try { await invoke('stop_server'); } catch (e) { appendLog('stop: ' + e); }
|
||||
resetUi();
|
||||
startBtn.disabled = false;
|
||||
}
|
||||
|
||||
function resetUi() {
|
||||
running = false;
|
||||
liveCard.classList.add('hidden');
|
||||
startBtn.textContent = 'Server starten';
|
||||
startBtn.classList.remove('stop');
|
||||
setStatus('off', 'Gestoppt');
|
||||
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
|
||||
}
|
||||
|
||||
function pollHealth(port) {
|
||||
if (healthTimer) clearInterval(healthTimer);
|
||||
const dXp = $('dXp'), dClients = $('dClients'), dNav = $('dNav'), dRefs = $('dRefs');
|
||||
const check = async () => {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/health`, { cache: 'no-store' });
|
||||
const d = await r.json();
|
||||
const sim = d.xpConnected;
|
||||
if (sim) setStatus('run', demoEl.checked ? 'Demo läuft' : 'X-Plane verbunden');
|
||||
else setStatus('warn', 'Server läuft · kein Sim');
|
||||
dXp.textContent = sim ? (demoEl.checked ? 'Demo' : 'verbunden') : 'kein Sim';
|
||||
dXp.className = sim ? 'ok' : 'warn';
|
||||
dClients.textContent = d.clients ?? 0;
|
||||
const n = d.nav || {};
|
||||
dNav.textContent = n.loaded ? `${n.airports ?? 0} APT · ${n.navaids ?? 0} Navaids` : 'lädt…';
|
||||
dRefs.textContent = d.datarefs ?? 0;
|
||||
} catch { setStatus('warn', 'Server läuft'); }
|
||||
};
|
||||
check();
|
||||
healthTimer = setInterval(check, 3000);
|
||||
}
|
||||
|
||||
$('copy').addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(urlEl.textContent); $('copy').textContent = '✓'; setTimeout(() => ($('copy').textContent = '⧉'), 1200); } catch {}
|
||||
});
|
||||
|
||||
const openUrl = (u) => { try { T.opener.openUrl(u); } catch (e) { appendLog('open: ' + e); } };
|
||||
$('openBtn').addEventListener('click', () => openUrl(urlEl.textContent));
|
||||
document.querySelectorAll('.quick .btn').forEach((b) =>
|
||||
b.addEventListener('click', () => openUrl(urlEl.textContent + '/#' + b.dataset.page)));
|
||||
|
||||
function appendLog(line) {
|
||||
logEl.textContent += line;
|
||||
if (logEl.textContent.length > 8000) logEl.textContent = logEl.textContent.slice(-6000);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
listen('server-log', (e) => appendLog(e.payload));
|
||||
listen('server-exited', () => { appendLog('\n[Server beendet]\n'); resetUi(); });
|
||||
|
||||
// Tray actions routed to the panel (which holds the current URL + start logic).
|
||||
listen('tray-open', () => { if (urlEl.textContent && urlEl.textContent !== '—') openUrl(urlEl.textContent); });
|
||||
listen('tray-toggle', () => startBtn.click());
|
||||
|
||||
/* ---------------- updates ---------------- */
|
||||
let pendingUpdate = null;
|
||||
async function checkUpdate(silent) {
|
||||
const btn = $('updateBtn');
|
||||
if (!silent) { btn.disabled = true; btn.textContent = 'Suche…'; }
|
||||
try {
|
||||
const update = await T.updater.check();
|
||||
if (update) {
|
||||
pendingUpdate = update;
|
||||
$('ubTitle').textContent = `Update ${update.version} verfügbar`;
|
||||
$('ubNotes').textContent = update.body || '';
|
||||
$('updateBanner').classList.remove('hidden');
|
||||
if (!document.querySelector('.update-badge')) {
|
||||
const dot = document.createElement('span'); dot.className = 'update-badge'; btn.after(dot);
|
||||
}
|
||||
if (!silent) { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }
|
||||
} else if (!silent) {
|
||||
btn.textContent = 'Aktuell ✓';
|
||||
setTimeout(() => { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }, 2500);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
appendLog('update: ' + e);
|
||||
btn.textContent = 'Update fehlgeschlagen';
|
||||
setTimeout(() => { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }, 2500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!pendingUpdate) return;
|
||||
$('ubInstall').disabled = true; $('ubInstall').textContent = 'Lädt…';
|
||||
try {
|
||||
await pendingUpdate.downloadAndInstall();
|
||||
await T.process.relaunch();
|
||||
} catch (e) {
|
||||
appendLog('install: ' + e);
|
||||
$('ubInstall').disabled = false; $('ubInstall').textContent = 'Installieren';
|
||||
}
|
||||
}
|
||||
|
||||
$('updateBtn').addEventListener('click', () => checkUpdate(false));
|
||||
$('ubInstall').addEventListener('click', installUpdate);
|
||||
$('ubDismiss').addEventListener('click', () => $('updateBanner').classList.add('hidden'));
|
||||
|
||||
init();
|
||||
@@ -1,89 +0,0 @@
|
||||
/* macOS-style dark theme: neutral graphite surfaces, SF system font, subtle
|
||||
separators, a single green accent for the running/start state. No blue. */
|
||||
:root {
|
||||
--bg: #1c1c1e; /* system background (dark) */
|
||||
--bg2: #2c2c2e; /* elevated surface */
|
||||
--bg3: #3a3a3c; /* control fill */
|
||||
--line: #48484a; /* separators / borders */
|
||||
--line-soft: #38383a;
|
||||
--txt: #ffffff;
|
||||
--txt2: #ebebf5;
|
||||
--mut: #8e8e93; /* secondary label */
|
||||
--green: #30d158; /* system green */
|
||||
--green-d: #248a3d;
|
||||
--amber: #ffd60a;
|
||||
--red: #ff453a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--txt);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px; user-select: none; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.panel { display: flex; flex-direction: column; height: 100vh; padding: 16px; gap: 14px; }
|
||||
.hd { display: flex; align-items: center; justify-content: space-between; }
|
||||
.brand { font-weight: 700; letter-spacing: .2px; font-size: 17px; }
|
||||
.brand span { color: var(--mut); font-weight: 500; }
|
||||
.status { display: flex; align-items: center; gap: 7px; font-size: 12px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line-soft); background: var(--bg2); color: var(--txt2); }
|
||||
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--mut); transition: background .2s; }
|
||||
.status.run .dot { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.status.warn .dot { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
|
||||
|
||||
main { flex: 1; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
|
||||
.card { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.lbl { color: var(--mut); font-size: 11px; font-weight: 600; }
|
||||
.row { display: flex; gap: 8px; align-items: center; }
|
||||
.row.gap { gap: 16px; margin-top: 2px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input { width: 96px; }
|
||||
input[type="text"], input[type="number"] {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--txt);
|
||||
border-radius: 7px; padding: 8px 10px; font-size: 13px; font-family: inherit;
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px rgba(48,209,88,.2); }
|
||||
.toggle { display: flex; align-items: center; gap: 8px; color: var(--txt2); cursor: pointer; align-self: flex-end; padding-bottom: 8px; }
|
||||
.toggle input { width: 15px; height: 15px; accent-color: var(--green); }
|
||||
.hint { font-size: 12px; min-height: 16px; color: var(--mut); }
|
||||
.hint.ok { color: var(--green); } .hint.bad { color: var(--amber); }
|
||||
.hint a { color: var(--green); }
|
||||
|
||||
.btn { border: 1px solid var(--line); background: var(--bg3); color: var(--txt); border-radius: 8px; padding: 8px 14px; font-size: 13px; font-family: inherit; cursor: pointer; transition: filter .12s, background .12s; }
|
||||
.btn:hover { filter: brightness(1.18); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.ghost { background: transparent; color: var(--txt2); border-color: var(--line); }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn.big { padding: 13px; font-size: 15px; font-weight: 600; }
|
||||
.btn.primary { background: var(--green); color: #042b10; border-color: transparent; font-weight: 600; }
|
||||
.btn.big.stop { background: var(--red); color: #2a0603; }
|
||||
.btn.ok { background: var(--green); color: #042b10; border-color: transparent; font-weight: 600; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
|
||||
.update-banner { display: flex; gap: 10px; align-items: center; justify-content: space-between; background: rgba(48,209,88,.10); border: 1px solid var(--green-d); border-radius: 12px; padding: 10px 12px; }
|
||||
.update-banner.hidden { display: none; }
|
||||
.ub-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.ub-text b { color: var(--green); font-size: 13px; }
|
||||
.ub-text span { color: var(--mut); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 230px; }
|
||||
.ub-actions { display: flex; gap: 6px; flex: 0 0 auto; }
|
||||
|
||||
.live.hidden { display: none; }
|
||||
.url-row { display: flex; gap: 8px; align-items: center; }
|
||||
.url-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--green); border-radius: 7px; padding: 10px 12px; font-size: 16px; font-weight: 600; letter-spacing: .3px; user-select: text; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||
.quick { display: flex; gap: 6px; }
|
||||
.quick .btn { flex: 1; }
|
||||
|
||||
.diag { margin-top: 10px; border-top: 1px solid var(--line-soft); padding-top: 8px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.diag-row { display: flex; justify-content: space-between; font-size: 12px; color: var(--mut); }
|
||||
.diag-row b { color: var(--txt2); font-weight: 600; }
|
||||
.diag-row b.ok { color: var(--green); } .diag-row b.warn { color: var(--amber); }
|
||||
|
||||
.log-wrap { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 6px 12px; }
|
||||
.log-wrap summary { color: var(--mut); font-size: 12px; cursor: pointer; padding: 4px 0; }
|
||||
#log { margin: 6px 0 2px; max-height: 140px; overflow-y: auto; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--mut); white-space: pre-wrap; }
|
||||
|
||||
.ft { display: flex; align-items: center; justify-content: space-between; color: var(--mut); font-size: 12px; }
|
||||
.link { background: none; border: none; color: var(--green); cursor: pointer; font-size: 12px; font-family: inherit; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
.link:disabled { color: var(--mut); cursor: default; text-decoration: none; }
|
||||
.update-badge { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-left: 6px; box-shadow: 0 0 6px var(--green); vertical-align: middle; }
|
||||
@@ -1,900 +0,0 @@
|
||||
{
|
||||
"name": "xplane-glass-cockpit",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "xplane-glass-cockpit",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "xplane-glass-cockpit",
|
||||
"version": "0.1.0",
|
||||
"description": "Bring X-Plane 12 instruments (PFD/MFD, G1000-style, autopilot) to any tablet or laptop on your LAN via React.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "cd web && npm install",
|
||||
"build": "cd web && npm run build",
|
||||
"start": "node server/bridge.js",
|
||||
"dev:bridge": "node --watch server/bridge.js",
|
||||
"dev:web": "cd web && npm run dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,113 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- 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)
|
||||
@@ -1,55 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- 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)
|
||||
@@ -1,66 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- 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)")
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Linux AppImage + .deb (x86_64) in Docker. Run from the repo root,
|
||||
# AFTER scripts/prep-desktop.sh has produced the linux sidecar + web resources.
|
||||
# On Apple Silicon this runs under qemu (linux/amd64) and is slow but works.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
IMG=xpcockpit-linux-builder
|
||||
echo "==> building docker image ($IMG)"
|
||||
docker build --platform linux/amd64 -f desktop/Dockerfile.linux -t "$IMG" desktop >/dev/null
|
||||
|
||||
# The updater signing key is a secret and is NOT in the repo. If it's present
|
||||
# locally we sign + emit updater artifacts; otherwise we build plain installable
|
||||
# bundles (appimage + deb) with updater artifacts disabled via a config override.
|
||||
SIGN_ENV=(); BUNDLES="appimage,deb,updater"; CFG=""
|
||||
if [[ -f desktop/.tauri-signing.key && -f desktop/.tauri-signing.pw ]]; then
|
||||
SIGN_ENV=(-e "TAURI_SIGNING_PRIVATE_KEY=$(cat desktop/.tauri-signing.key)"
|
||||
-e "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$(cat desktop/.tauri-signing.pw)")
|
||||
echo "==> signing key found — emitting signed updater artifacts"
|
||||
else
|
||||
BUNDLES="appimage,deb"; CFG='--config {"bundle":{"createUpdaterArtifacts":false}}'
|
||||
echo "==> no signing key — building plain appimage + deb (no updater artifacts)"
|
||||
fi
|
||||
|
||||
echo "==> tauri build (x86_64-unknown-linux-gnu) in container — this takes a while under emulation"
|
||||
docker run --rm --platform linux/amd64 \
|
||||
-v "$ROOT":/work \
|
||||
-e CARGO_TARGET_DIR=/work/target-linux \
|
||||
-e APPIMAGE_EXTRACT_AND_RUN=1 \
|
||||
-e ARCH=x86_64 \
|
||||
"${SIGN_ENV[@]}" \
|
||||
-w /work/desktop \
|
||||
"$IMG" \
|
||||
bash -c "export PATH=/usr/local/cargo/bin:\$PATH; tauri build --target x86_64-unknown-linux-gnu --bundles $BUNDLES $CFG"
|
||||
|
||||
echo "==> artifacts:"
|
||||
find target-linux/x86_64-unknown-linux-gnu/release/bundle -maxdepth 2 -type f \
|
||||
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.AppImage.sig' -o -name '*.tar.gz' -o -name '*.sig' \) 2>/dev/null
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepare the Tauri bundle inputs: build the web cockpit, copy it in as a
|
||||
# resource, and compile the Node bridge into Bun single-file sidecars named with
|
||||
# the Tauri target triples. Run from the repo root.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
|
||||
echo "==> building web cockpit"
|
||||
( cd web && npm run build >/dev/null )
|
||||
|
||||
echo "==> copying cockpit into desktop resources"
|
||||
rm -rf desktop/src-tauri/resources/web
|
||||
mkdir -p desktop/src-tauri/resources/web
|
||||
cp -R web/dist/. desktop/src-tauri/resources/web/
|
||||
|
||||
echo "==> compiling bridge sidecars (Bun)"
|
||||
mkdir -p desktop/src-tauri/binaries
|
||||
bun build --compile --target=bun-darwin-arm64 server/bridge.js \
|
||||
--outfile desktop/src-tauri/binaries/xpbridge-aarch64-apple-darwin
|
||||
bun build --compile --target=bun-linux-x64-baseline server/bridge.js \
|
||||
--outfile desktop/src-tauri/binaries/xpbridge-x86_64-unknown-linux-gnu
|
||||
chmod +x desktop/src-tauri/binaries/xpbridge-*
|
||||
|
||||
echo "==> done"
|
||||
ls -lh desktop/src-tauri/binaries/
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Publish a release to Gitea and (re)write the updater's latest.json.
|
||||
//
|
||||
// Reads the built artifacts (macOS .app.tar.gz + .sig, Linux .AppImage + .sig,
|
||||
// plus .dmg / .deb for manual install), creates a versioned release with those
|
||||
// assets, then builds latest.json (Tauri v2 updater format) pointing at the
|
||||
// release download URLs and uploads it to a fixed-tag "updater" release so the
|
||||
// app's updater endpoint URL stays constant.
|
||||
//
|
||||
// Env: GITEA_URL (e.g. https://git.kgva.ch), GITEA_REPO (owner/name),
|
||||
// GITEA_TOKEN (or a token file at /tmp/gitea_token).
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
const GITEA_URL = (process.env.GITEA_URL || 'https://git.kgva.ch').replace(/\/$/, '');
|
||||
const REPO = process.env.GITEA_REPO || 'karim/xplane-cockpit';
|
||||
const TOKEN = process.env.GITEA_TOKEN
|
||||
|| (fs.existsSync('/tmp/gitea_token') ? fs.readFileSync('/tmp/gitea_token', 'utf8').trim() : '');
|
||||
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'desktop/src-tauri/tauri.conf.json'), 'utf8')).version;
|
||||
const UPDATER_TAG = 'updater';
|
||||
|
||||
if (!TOKEN) { console.error('No GITEA_TOKEN (env or /tmp/gitea_token).'); process.exit(1); }
|
||||
|
||||
const api = (p, opts = {}) => fetch(`${GITEA_URL}/api/v1${p}`, {
|
||||
...opts,
|
||||
headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json', ...(opts.headers || {}) },
|
||||
});
|
||||
|
||||
// Collect built artifacts that exist (mac and/or linux builds may have run).
|
||||
function findArtifacts() {
|
||||
const macBundle = path.join(ROOT, 'desktop/src-tauri/target/aarch64-apple-darwin/release/bundle');
|
||||
const linBundle = path.join(ROOT, 'target-linux/x86_64-unknown-linux-gnu/release/bundle');
|
||||
const out = { assets: [], updater: {} };
|
||||
const add = (file, platformKey) => {
|
||||
if (!fs.existsSync(file)) return;
|
||||
out.assets.push(file);
|
||||
if (platformKey) {
|
||||
const sig = file + '.sig';
|
||||
if (fs.existsSync(sig)) out.updater[platformKey] = { file, sig: fs.readFileSync(sig, 'utf8').trim() };
|
||||
}
|
||||
};
|
||||
const glob1 = (dir, re) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)) : [];
|
||||
// macOS
|
||||
glob1(path.join(macBundle, 'macos'), /\.app\.tar\.gz$/).forEach((f) => add(f, 'darwin-aarch64'));
|
||||
glob1(path.join(macBundle, 'dmg'), /\.dmg$/).forEach((f) => out.assets.push(f));
|
||||
// Linux
|
||||
glob1(path.join(linBundle, 'appimage'), /\.AppImage$/).forEach((f) => add(f, 'linux-x86_64'));
|
||||
glob1(path.join(linBundle, 'deb'), /\.deb$/).forEach((f) => out.assets.push(f));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function getReleaseByTag(tag) {
|
||||
const r = await api(`/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`);
|
||||
return r.ok ? r.json() : null;
|
||||
}
|
||||
|
||||
async function ensureRelease(tag, name, body) {
|
||||
let rel = await getReleaseByTag(tag);
|
||||
if (rel) return rel;
|
||||
const r = await api(`/repos/${REPO}/releases`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tag_name: tag, name, body, draft: false, prerelease: false }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`create release ${tag}: ${r.status} ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function uploadAsset(rel, file, asName) {
|
||||
const name = asName || path.basename(file);
|
||||
// delete an existing asset with the same name first (Gitea won't overwrite)
|
||||
for (const a of rel.assets || []) {
|
||||
if (a.name === name) await api(`/repos/${REPO}/releases/${rel.id}/assets/${a.id}`, { method: 'DELETE' });
|
||||
}
|
||||
const blob = new Blob([fs.readFileSync(file)]);
|
||||
const fd = new FormData();
|
||||
fd.append('attachment', blob, name);
|
||||
const r = await api(`/repos/${REPO}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: fd });
|
||||
if (!r.ok) throw new Error(`upload ${name}: ${r.status} ${await r.text()}`);
|
||||
console.log(' ↑', name);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
const dlUrl = (tag, name) => `${GITEA_URL}/${REPO}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`;
|
||||
|
||||
async function main() {
|
||||
const { assets, updater } = findArtifacts();
|
||||
if (!assets.length) { console.error('No build artifacts found — run the builds first.'); process.exit(1); }
|
||||
console.log(`Release v${VERSION} → ${GITEA_URL}/${REPO}`);
|
||||
console.log('Artifacts:', assets.map((a) => path.basename(a)).join(', '));
|
||||
|
||||
const verTag = `v${VERSION}`;
|
||||
const rel = await ensureRelease(verTag, `X-Plane Cockpit ${verTag}`, `Automated release ${verTag}.`);
|
||||
for (const f of assets) await uploadAsset(rel, f);
|
||||
const relFresh = await getReleaseByTag(verTag); // refresh asset list
|
||||
|
||||
// Build latest.json referencing this release's updater artifacts.
|
||||
const platforms = {};
|
||||
for (const [key, { file, sig }] of Object.entries(updater)) {
|
||||
platforms[key] = { signature: sig, url: dlUrl(verTag, path.basename(file)) };
|
||||
}
|
||||
const latest = {
|
||||
version: VERSION,
|
||||
notes: `X-Plane Cockpit ${verTag}`,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
const latestPath = path.join(ROOT, 'desktop/latest.json');
|
||||
fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2));
|
||||
console.log('latest.json platforms:', Object.keys(platforms).join(', ') || '(none)');
|
||||
|
||||
// Upload latest.json to the fixed "updater" release (constant endpoint URL).
|
||||
const upd = await ensureRelease(UPDATER_TAG, 'Updater channel', 'Rolling pointer used by the in-app updater.');
|
||||
await uploadAsset(upd, latestPath, 'latest.json');
|
||||
console.log('Updater endpoint:', dlUrl(UPDATER_TAG, 'latest.json'));
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -1,355 +0,0 @@
|
||||
// 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 } 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
|
||||
// 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,
|
||||
};
|
||||
|
||||
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() })
|
||||
);
|
||||
// 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))
|
||||
);
|
||||
// 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: 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: [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: [
|
||||
{ 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
|
||||
// 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();
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
// Central configuration: which X-Plane datarefs/commands the cockpit needs.
|
||||
//
|
||||
// These are *universal* datarefs that work on virtually every aircraft.
|
||||
// To add a G1000- or aircraft-specific instrument, just add its dataref name
|
||||
// here under DATAREFS (read) and/or WRITABLE_DATAREFS / COMMANDS (interact).
|
||||
|
||||
export const CONFIG = {
|
||||
// Where X-Plane's built-in web server listens (on the same PC). X-Plane 12.1.1+.
|
||||
xplaneHost: process.env.XPLANE_HOST || 'localhost',
|
||||
xplanePort: Number(process.env.XPLANE_PORT || 8086),
|
||||
xplaneApiBase: '/api/v3',
|
||||
|
||||
// Where THIS bridge serves the UI + relays data. 0.0.0.0 => reachable from the LAN.
|
||||
bridgeHost: process.env.BRIDGE_HOST || '0.0.0.0',
|
||||
bridgePort: Number(process.env.BRIDGE_PORT || 8080),
|
||||
|
||||
// How often X-Plane pushes value updates (it caps near 10–20 Hz anyway).
|
||||
updateHz: Number(process.env.UPDATE_HZ || 20),
|
||||
};
|
||||
|
||||
// Datarefs we SUBSCRIBE to and stream to every client. Keyed by a short alias
|
||||
// the frontend uses, so the long sim/... names live in exactly one place.
|
||||
export const DATAREFS = {
|
||||
// --- primary flight data ---
|
||||
airspeed: 'sim/cockpit2/gauges/indicators/airspeed_kts_pilot',
|
||||
altitude: 'sim/cockpit2/gauges/indicators/altitude_ft_pilot',
|
||||
vspeed: 'sim/cockpit2/gauges/indicators/vvi_fpm_pilot',
|
||||
pitch: 'sim/cockpit2/gauges/indicators/pitch_AHARS_deg_pilot',
|
||||
roll: 'sim/cockpit2/gauges/indicators/roll_AHARS_deg_pilot',
|
||||
heading: 'sim/cockpit2/gauges/indicators/heading_AHARS_deg_mag_pilot',
|
||||
slip: 'sim/cockpit2/gauges/indicators/slip_deg',
|
||||
gForce: 'sim/flightmodel/forces/g_nrml',
|
||||
|
||||
// --- position / navigation (for the moving map) ---
|
||||
lat: 'sim/flightmodel/position/latitude',
|
||||
lon: 'sim/flightmodel/position/longitude',
|
||||
groundspeed: 'sim/flightmodel/position/groundspeed', // m/s
|
||||
track: 'sim/cockpit2/gauges/indicators/ground_track_mag_pilot', // deg
|
||||
gpsDistNm: 'sim/cockpit2/radios/indicators/gps_dme_distance_nm',
|
||||
gpsBearing: 'sim/cockpit2/radios/indicators/gps_bearing_deg_mag',
|
||||
|
||||
// --- engine / misc (handy on an MFD) ---
|
||||
fuelTotal: 'sim/cockpit2/fuel/fuel_quantity', // array
|
||||
oat: 'sim/cockpit2/temperature/outside_air_temp_degc',
|
||||
|
||||
// --- G1000 PFD: radios (NAV/COM active + standby) ---
|
||||
nav1: 'sim/cockpit2/radios/actuators/nav1_frequency_hz',
|
||||
nav1Sb: 'sim/cockpit2/radios/actuators/nav1_standby_frequency_hz',
|
||||
nav2: 'sim/cockpit2/radios/actuators/nav2_frequency_hz',
|
||||
nav2Sb: 'sim/cockpit2/radios/actuators/nav2_standby_frequency_hz',
|
||||
com1: 'sim/cockpit2/radios/actuators/com1_frequency_hz',
|
||||
com1Sb: 'sim/cockpit2/radios/actuators/com1_standby_frequency_hz',
|
||||
com2: 'sim/cockpit2/radios/actuators/com2_frequency_hz',
|
||||
com2Sb: 'sim/cockpit2/radios/actuators/com2_standby_frequency_hz',
|
||||
|
||||
// --- G1000 PFD: HSI / CDI ---
|
||||
obsCrs: 'sim/cockpit2/radios/actuators/nav1_obs_deg_mag_pilot',
|
||||
gsDef: 'sim/cockpit/radios/nav1_vdef', // glideslope vertical deflection (dots)
|
||||
hsiDef: 'sim/cockpit2/radios/indicators/hsi_hdef_dots_pilot',
|
||||
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',
|
||||
windSpd: 'sim/cockpit2/gauges/indicators/wind_speed_kts',
|
||||
windDir: 'sim/cockpit2/gauges/indicators/wind_heading_deg_mag',
|
||||
xpdrCode: 'sim/cockpit2/radios/actuators/transponder_code',
|
||||
xpdrMode: 'sim/cockpit2/radios/actuators/transponder_mode',
|
||||
fdPitch: 'sim/cockpit2/autopilot/flight_director_pitch_deg',
|
||||
fdRoll: 'sim/cockpit2/autopilot/flight_director_roll_deg',
|
||||
|
||||
// --- G1000 MFD: engine strip (arrays — UI reads index 0/1) ---
|
||||
engRpm: 'sim/cockpit2/engine/indicators/engine_speed_rpm',
|
||||
fuelFlow: 'sim/cockpit2/engine/indicators/fuel_flow_kg_sec',
|
||||
oilTemp: 'sim/cockpit2/engine/indicators/oil_temperature_deg_C',
|
||||
oilPress: 'sim/cockpit2/engine/indicators/oil_pressure_psi',
|
||||
egt: 'sim/cockpit2/engine/indicators/EGT_deg_C',
|
||||
fuelQty: 'sim/cockpit2/fuel/fuel_quantity',
|
||||
volts: 'sim/cockpit2/electrical/bus_volts',
|
||||
amps: 'sim/cockpit2/electrical/battery_amps',
|
||||
|
||||
// --- autopilot readouts (live values, so the panel reflects reality) ---
|
||||
apState: 'sim/cockpit2/autopilot/autopilot_state', // bitfield of active modes
|
||||
apHdgBug: 'sim/cockpit2/autopilot/heading_dial_deg_mag_pilot',
|
||||
apAltBug: 'sim/cockpit2/autopilot/altitude_dial_ft_pilot',
|
||||
apVsBug: 'sim/cockpit2/autopilot/vvi_dial_fpm',
|
||||
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).
|
||||
export const WRITABLE_DATAREFS = {
|
||||
apHdgBug: 'sim/cockpit2/autopilot/heading_dial_deg_mag_pilot',
|
||||
apAltBug: 'sim/cockpit2/autopilot/altitude_dial_ft_pilot',
|
||||
apVsBug: 'sim/cockpit2/autopilot/vvi_dial_fpm',
|
||||
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
|
||||
xpdrMode: 'sim/cockpit2/radios/actuators/transponder_mode', // 0 off,1 stby,2 on,3 alt
|
||||
xpdrCode: 'sim/cockpit2/radios/actuators/transponder_code', // 4-digit squawk
|
||||
};
|
||||
|
||||
// Commands the frontend may TRIGGER (autopilot mode buttons etc.).
|
||||
export const COMMANDS = {
|
||||
apToggle: 'sim/autopilot/servos_toggle',
|
||||
fdToggle: 'sim/autopilot/fdir_toggle',
|
||||
hdg: 'sim/autopilot/heading',
|
||||
nav: 'sim/autopilot/NAV',
|
||||
apr: 'sim/autopilot/approach',
|
||||
altHold: 'sim/autopilot/altitude_hold',
|
||||
vs: 'sim/autopilot/vertical_speed',
|
||||
flc: 'sim/autopilot/level_change',
|
||||
vnav: 'sim/autopilot/vnav',
|
||||
backCourse:'sim/autopilot/back_course',
|
||||
noseUp: 'sim/autopilot/nose_up',
|
||||
noseDown: 'sim/autopilot/nose_down',
|
||||
altUp: 'sim/autopilot/altitude_up',
|
||||
altDown: 'sim/autopilot/altitude_down',
|
||||
hdgUp: 'sim/autopilot/heading_up',
|
||||
hdgDown: 'sim/autopilot/heading_down',
|
||||
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').
|
||||
const G1000_KEYS = [
|
||||
...Array.from({ length: 12 }, (_, i) => `softkey${i + 1}`),
|
||||
'direct', 'menu', 'fpl', 'proc', 'clr', 'ent', 'cursor',
|
||||
'fms_outer_up', 'fms_outer_down', 'fms_inner_up', 'fms_inner_down',
|
||||
'range_up', 'range_down', 'pan_push', 'pan_up', 'pan_down', 'pan_left', 'pan_right',
|
||||
'hdg_up', 'hdg_down', 'hdg_sync',
|
||||
'alt_outer_up', 'alt_outer_down', 'alt_inner_up', 'alt_inner_down',
|
||||
'crs_up', 'crs_down', 'crs_sync', 'baro_up', 'baro_down',
|
||||
'nav_outer_up', 'nav_outer_down', 'nav_inner_up', 'nav_inner_down', 'nav12', 'nvol_up', 'nvol_dn',
|
||||
'com_outer_up', 'com_outer_down', 'com_inner_up', 'com_inner_down', 'com12', 'cvol_up', 'cvol_dn',
|
||||
'ap', 'fd', 'hdg', 'alt', 'nav', 'vnv', 'apr', 'bc', 'vs', 'flc', 'nose_up', 'nose_down',
|
||||
];
|
||||
for (const [unit, prefix] of [['n1', 'pfd'], ['n3', 'mfd']]) {
|
||||
for (const k of G1000_KEYS) COMMANDS[`${prefix}_${k}`] = `sim/GPS/g1000${unit}_${k}`;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// Shared flight plan: one plan, synced to every connected tablet. Can resolve
|
||||
// idents via navdata, and export an X-Plane .fms file the sim can load.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { lookup, xplaneRoot } from './navdata.js';
|
||||
|
||||
// waypoint: { id, lat, lon, type, alt? }
|
||||
// activeLeg = index of the waypoint the active (magenta) leg flies TO. The leg
|
||||
// runs from waypoints[activeLeg-1] to waypoints[activeLeg]. Defaults to 1.
|
||||
let plan = { name: 'ACTIVE', waypoints: [], activeLeg: 1 };
|
||||
|
||||
const clampLeg = (i) => Math.max(1, Math.min(plan.waypoints.length - 1, i | 0));
|
||||
|
||||
export const getPlan = () => plan;
|
||||
|
||||
export function setPlan(next) {
|
||||
const wps = Array.isArray(next?.waypoints)
|
||||
? next.waypoints
|
||||
.filter((w) => isFinite(w.lat) && isFinite(w.lon))
|
||||
.map((w) => ({ id: String(w.id || 'WPT'), lat: +w.lat, lon: +w.lon, type: w.type || 'WPT', alt: w.alt ?? null }))
|
||||
: [];
|
||||
const wantLeg = Number.isFinite(next?.activeLeg) ? next.activeLeg : 1;
|
||||
plan = { name: next?.name || 'ACTIVE', waypoints: wps, activeLeg: Math.max(1, Math.min(wps.length - 1, wantLeg)) || 1 };
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function setActiveLeg(index) {
|
||||
if (plan.waypoints.length >= 2) plan.activeLeg = clampLeg(index);
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Add a waypoint by ident (resolved against navdata) or raw "lat,lon".
|
||||
export function addWaypoint(input) {
|
||||
const raw = String(input || '').trim();
|
||||
const m = raw.match(/^(-?\d+(?:\.\d+)?)[ ,]+(-?\d+(?:\.\d+)?)$/);
|
||||
if (m) {
|
||||
plan.waypoints.push({ id: 'USR', lat: +m[1], lon: +m[2], type: 'USR', alt: null });
|
||||
return { ok: true, plan };
|
||||
}
|
||||
const hit = lookup(raw);
|
||||
if (!hit) return { ok: false, error: `unknown ident: ${raw}` };
|
||||
plan.waypoints.push({ ...hit, alt: null });
|
||||
return { ok: true, plan };
|
||||
}
|
||||
|
||||
export function removeWaypoint(index) {
|
||||
if (index >= 0 && index < plan.waypoints.length) plan.waypoints.splice(index, 1);
|
||||
if (plan.waypoints.length >= 2) plan.activeLeg = clampLeg(plan.activeLeg);
|
||||
return plan;
|
||||
}
|
||||
|
||||
// ---- great-circle helpers (nm + degrees) ----
|
||||
const R_NM = 3440.065;
|
||||
const rad = (d) => (d * Math.PI) / 180;
|
||||
const deg = (r) => (r * 180) / Math.PI;
|
||||
|
||||
export function legDistanceNm(a, b) {
|
||||
const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
export function legBearing(a, b) {
|
||||
const y = Math.sin(rad(b.lon - a.lon)) * Math.cos(rad(b.lat));
|
||||
const x = Math.cos(rad(a.lat)) * Math.sin(rad(b.lat)) -
|
||||
Math.sin(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.cos(rad(b.lon - a.lon));
|
||||
return (deg(Math.atan2(y, x)) + 360) % 360;
|
||||
}
|
||||
|
||||
// ---- X-Plane .fms (v1100) export ----
|
||||
function fmsType(t) {
|
||||
return { APT: 1, NDB: 2, VOR: 3, WPT: 11, USR: 28 }[t] || 11;
|
||||
}
|
||||
|
||||
export function exportFms(name = 'WEBFPL') {
|
||||
const wp = plan.waypoints;
|
||||
if (wp.length < 2) return { ok: false, error: 'need at least 2 waypoints' };
|
||||
|
||||
const lines = ['I', '1100 Version', 'CYCLE 2501'];
|
||||
lines.push(`ADEP ${wp[0].id}`);
|
||||
lines.push(`ADES ${wp[wp.length - 1].id}`);
|
||||
lines.push(`NUMENR ${wp.length}`);
|
||||
for (const w of wp) {
|
||||
const alt = w.alt ?? 0;
|
||||
lines.push(`${fmsType(w.type)} ${w.id} ${alt.toFixed(6)} ${w.lat.toFixed(6)} ${w.lon.toFixed(6)}`);
|
||||
}
|
||||
const content = lines.join('\n') + '\n';
|
||||
|
||||
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: !!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: <type> <ident> <alt> <lat> <lon>
|
||||
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 };
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// 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 <X-Plane>/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);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
// Reads X-Plane's own navigation data so the FMS can resolve real waypoint /
|
||||
// VOR / NDB / airport identifiers to coordinates — the same database the sim
|
||||
// uses. Runs on the X-Plane PC (where the bridge lives), so the files are local.
|
||||
//
|
||||
// Everything degrades gracefully: if X-Plane / the files can't be found, the
|
||||
// FMS still works with map-clicks and raw "LAT,LON" entry.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
// Common install locations to probe. Override with XPLANE_ROOT.
|
||||
function candidateRoots() {
|
||||
const env = process.env.XPLANE_ROOT;
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
return [
|
||||
env,
|
||||
'C:/X-Plane 12', 'D:/X-Plane 12', 'E:/X-Plane 12',
|
||||
'C:/X-Plane 11', 'D:/X-Plane 11',
|
||||
path.join(home, 'X-Plane 12'),
|
||||
path.join(home, 'Desktop', 'X-Plane 12'),
|
||||
'/Applications/X-Plane 12',
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function findRoot() {
|
||||
for (const root of candidateRoots()) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(root, 'Resources', 'default data'))) return root;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// alias -> { lat, lon, type } ; type: WPT | VOR | NDB | APT
|
||||
const index = new Map();
|
||||
// Geographic stores for the moving map (bbox queries) and NEAREST search.
|
||||
// Airports + navaids stay in flat arrays (small enough to scan); the far more
|
||||
// numerous fixes go into 1°×1° buckets so a bbox query only scans nearby cells.
|
||||
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 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, name) {
|
||||
if (!id || !isFinite(lat) || !isFinite(lon)) return;
|
||||
const key = id.toUpperCase();
|
||||
if (!index.has(key)) index.set(key, { id: key, lat, lon, type, name: name || '' });
|
||||
}
|
||||
|
||||
function pushFix(f) {
|
||||
const k = `${Math.floor(f.lat)},${Math.floor(f.lon)}`;
|
||||
let a = fixCells.get(k);
|
||||
if (!a) { a = []; fixCells.set(k, a); }
|
||||
a.push(f);
|
||||
}
|
||||
|
||||
const R_NM = 3440.065; // earth radius in nautical miles
|
||||
const rad = (d) => (d * Math.PI) / 180;
|
||||
function distNm(la1, lo1, la2, lo2) {
|
||||
const dLat = rad(la2 - la1), dLon = rad(lo2 - lo1);
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(rad(la1)) * Math.cos(rad(la2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
function bearingDeg(la1, lo1, la2, lo2) {
|
||||
const y = Math.sin(rad(lo2 - lo1)) * Math.cos(rad(la2));
|
||||
const x = Math.cos(rad(la1)) * Math.sin(rad(la2)) - Math.sin(rad(la1)) * Math.cos(rad(la2)) * Math.cos(rad(lo2 - lo1));
|
||||
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
async function parseFixes(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+/);
|
||||
const lat = parseFloat(p[0]), lon = parseFloat(p[1]), id = p[2];
|
||||
add(id, lat, lon, 'WPT');
|
||||
if (id && isFinite(lat) && isFinite(lon)) pushFix({ id: id.toUpperCase(), lat, lon, type: 'FIX' });
|
||||
}
|
||||
}
|
||||
|
||||
async function parseNav(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+/);
|
||||
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';
|
||||
add(id, lat, lon, type, p.slice(10).join(' '));
|
||||
if (id && isFinite(lat) && isFinite(lon)) {
|
||||
// p[4] = frequency (VOR in 10 kHz e.g. 11630 → 116.30; NDB in kHz);
|
||||
// name is everything after the airport/region columns.
|
||||
navaids.push({ id: id.toUpperCase(), lat, lon, type, freq: parseInt(p[4], 10) || 0, name: p.slice(10).join(' ') });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Airports: derive a reference point from each airport's first runway (row 100)
|
||||
// in apt.dat. The "1" header row carries the ICAO but no coordinates.
|
||||
async function parseAirports(file) {
|
||||
if (!fs.existsSync(file)) return;
|
||||
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
||||
let icao = null, name = '', elev = 0, placed = false;
|
||||
const place = (lat, lon) => {
|
||||
if (!isFinite(lat) || !isFinite(lon)) return;
|
||||
add(icao, lat, lon, 'APT', name);
|
||||
airports.push({ id: icao.toUpperCase(), lat, lon, name, elev });
|
||||
placed = true;
|
||||
};
|
||||
for await (const line of rl) {
|
||||
const p = line.trim().split(/\s+/);
|
||||
const code = parseInt(p[0], 10);
|
||||
if (code === 1 || code === 16 || code === 17) { // land/sea/heliport header
|
||||
icao = p[4]; elev = parseInt(p[1], 10) || 0; name = p.slice(5).join(' '); placed = false;
|
||||
} else if (icao && code === 100) { // land runway (both ends)
|
||||
const r = { n1: p[8], la1: parseFloat(p[9]), lo1: parseFloat(p[10]), n2: p[17], la2: parseFloat(p[18]), lo2: parseFloat(p[19]), w: parseFloat(p[1]) };
|
||||
if (isFinite(r.la1) && isFinite(r.lo1) && isFinite(r.la2) && isFinite(r.lo2)) {
|
||||
const key = icao.toUpperCase();
|
||||
let a = rwyByApt.get(key); if (!a) { a = []; rwyByApt.set(key, a); } a.push(r);
|
||||
if (!placed) place((r.la1 + r.la2) / 2, (r.lo1 + r.lo2) / 2);
|
||||
}
|
||||
} 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;
|
||||
if (!root) {
|
||||
console.log('navdata: X-Plane root not found (set XPLANE_ROOT) — FMS works with map-clicks / LAT,LON only');
|
||||
state.loaded = true;
|
||||
return;
|
||||
}
|
||||
console.log(`navdata: X-Plane at ${root} — parsing nav data ...`);
|
||||
const dd = path.join(root, 'Resources', 'default data');
|
||||
const cd = path.join(root, 'Custom Data'); // user nav data overrides if present
|
||||
const pick = (name) => (fs.existsSync(path.join(cd, name)) ? path.join(cd, name) : path.join(dd, name));
|
||||
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)`); })
|
||||
.catch((e) => console.log('navdata: airport parse skipped:', e.message));
|
||||
} catch (e) {
|
||||
console.log('navdata: parse error:', e.message);
|
||||
}
|
||||
state.count = index.size;
|
||||
state.loaded = true;
|
||||
console.log(`navdata: ${index.size} fixes/navaids ready`);
|
||||
}
|
||||
|
||||
export function lookup(id) {
|
||||
return index.get(String(id).toUpperCase()) || null;
|
||||
}
|
||||
|
||||
export function search(q, limit = 20) {
|
||||
const needle = String(q || '').toUpperCase().trim();
|
||||
if (!needle) return [];
|
||||
const exact = [], prefix = [];
|
||||
for (const v of index.values()) {
|
||||
if (v.id === needle) exact.push(v);
|
||||
else if (v.id.startsWith(needle)) prefix.push(v);
|
||||
if (exact.length + prefix.length > 400) break;
|
||||
}
|
||||
return [...exact, ...prefix].slice(0, limit);
|
||||
}
|
||||
|
||||
// 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 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) => {
|
||||
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.
|
||||
// types ⊆ { apt, vor, ndb, fix }. Output is capped so a wide view stays light.
|
||||
export function bbox(s, w, n, e, types = ['apt', 'vor', 'ndb'], limit = 800) {
|
||||
const out = [];
|
||||
const inB = (f) => f.lat >= s && f.lat <= n && f.lon >= w && f.lon <= e;
|
||||
if (types.includes('apt')) for (const f of airports) { if (inB(f)) { out.push({ ...f, type: 'APT' }); if (out.length >= limit) return out; } }
|
||||
for (const f of navaids) { if (types.includes(f.type.toLowerCase()) && inB(f)) { out.push(f); if (out.length >= limit) return out; } }
|
||||
if (types.includes('fix')) {
|
||||
for (let la = Math.floor(s); la <= Math.floor(n); la++)
|
||||
for (let lo = Math.floor(w); lo <= Math.floor(e); lo++) {
|
||||
const a = fixCells.get(`${la},${lo}`);
|
||||
if (!a) continue;
|
||||
for (const f of a) { if (inB(f)) { out.push(f); if (out.length >= limit) return out; } }
|
||||
}
|
||||
}
|
||||
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 [];
|
||||
const out = [];
|
||||
for (const a of airports) {
|
||||
if (distNm(lat, lon, a.lat, a.lon) > radiusNm) continue;
|
||||
const rs = rwyByApt.get(a.id);
|
||||
if (rs) for (const r of rs) out.push({ apt: a.id, ...r });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function navStatus() {
|
||||
return { root: state.root, loaded: state.loaded, entries: index.size, airports: airports.length, navaids: navaids.length };
|
||||
}
|
||||
|
||||
export function xplaneRoot() {
|
||||
return state.root;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Parses X-Plane's CIFP procedure data (SIDs / STARs / approaches) on demand —
|
||||
// one small file per airport in Resources/default data/CIFP/<ICAO>.dat, in the
|
||||
// ARINC-424-derived "XP CIFP" format. Fix idents are resolved to coordinates
|
||||
// via the shared navdata index; runway thresholds come from the RWY records.
|
||||
//
|
||||
// Used by the G1000 PROC page: list a destination's procedures, then load a
|
||||
// chosen procedure+transition's leg fixes into the active flight plan.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { lookup, xplaneRoot } from './navdata.js';
|
||||
|
||||
// "N47274972" -> 47.4638.. / "W122183954" -> -122.3109..
|
||||
function parseCoord(s) {
|
||||
if (!s || s.length < 8) return null;
|
||||
const hemi = s[0];
|
||||
const neg = hemi === 'S' || hemi === 'W';
|
||||
const digits = s.slice(1);
|
||||
// lat = DDMMSSss (8) ; lon = DDDMMSSss (9)
|
||||
const degLen = (hemi === 'E' || hemi === 'W') ? 3 : 2;
|
||||
const dd = parseInt(digits.slice(0, degLen), 10);
|
||||
const mm = parseInt(digits.slice(degLen, degLen + 2), 10);
|
||||
const ss = parseInt(digits.slice(degLen + 2, degLen + 4), 10);
|
||||
const hh = parseInt(digits.slice(degLen + 4, degLen + 6) || '0', 10);
|
||||
if (!isFinite(dd) || !isFinite(mm)) return null;
|
||||
const val = dd + mm / 60 + (ss + hh / 100) / 3600;
|
||||
return neg ? -val : val;
|
||||
}
|
||||
|
||||
function cifpFile(icao) {
|
||||
const root = xplaneRoot();
|
||||
if (!root) return null;
|
||||
const f = path.join(root, 'Resources', 'default data', 'CIFP', `${icao.toUpperCase()}.dat`);
|
||||
return fs.existsSync(f) ? f : null;
|
||||
}
|
||||
|
||||
// Parse one airport's procedures into a structured summary + leg store.
|
||||
// Returns null if the airport has no CIFP file.
|
||||
export function parseProcedures(icao) {
|
||||
const file = cifpFile(icao);
|
||||
if (!file) return null;
|
||||
|
||||
const runways = {}; // RW16C -> { lat, lon, elev }
|
||||
const groups = { SID: {}, STAR: {}, APPCH: {} };
|
||||
// groups[type][procName] = { order:[trans...], legs:{ trans:[{fix,term,alt}] } }
|
||||
|
||||
const ensure = (type, name, trans) => {
|
||||
const g = groups[type];
|
||||
if (!g[name]) g[name] = { order: [], legs: {} };
|
||||
if (!(trans in g[name].legs)) { g[name].legs[trans] = []; g[name].order.push(trans); }
|
||||
return g[name].legs[trans];
|
||||
};
|
||||
|
||||
for (const raw of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const line = raw.trim().replace(/;$/, '');
|
||||
if (!line) continue;
|
||||
const colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
const type = line.slice(0, colon);
|
||||
const f = line.slice(colon + 1).split(',');
|
||||
|
||||
if (type === 'RWY') {
|
||||
// RWY:RW16C, , ,00429, ,ISZI,3, ;N47274972,W122183954,0000
|
||||
const id = f[0];
|
||||
const tail = line.split(';')[1] || ''; // "N47274972,W122183954,0000"
|
||||
const [latS, lonS] = tail.split(',');
|
||||
const lat = parseCoord(latS), lon = parseCoord(lonS);
|
||||
if (lat != null && lon != null) runways[id] = { lat, lon, elev: parseInt(f[3], 10) || 0 };
|
||||
continue;
|
||||
}
|
||||
if (type !== 'SID' && type !== 'STAR' && type !== 'APPCH') continue;
|
||||
|
||||
// f[0]=seqno, f[1]=route type, f[2]=proc, f[3]=transition, f[4]=fix,
|
||||
// f[11]=path/termination, f[22]=alt flag (+/-), f[23]=altitude.
|
||||
const procName = f[2]; // BANGR9 / CHINS5 / I16C
|
||||
const trans = (f[3] || '').trim(); // RW16C / PDT / ERYKA / '' (common)
|
||||
const fix = (f[4] || '').trim(); // OTLIE / ANVIL / RW16C
|
||||
const term = (f[11] || '').trim(); // path/termination: IF TF CF DF VA CA ...
|
||||
const altFlag = (f[22] || '').trim();
|
||||
const altVal = parseInt((f[23] || '').trim(), 10);
|
||||
const alt = isFinite(altVal) && altVal > 0 ? altVal : null;
|
||||
|
||||
const legs = ensure(type, procName, trans || '(common)');
|
||||
legs.push({ fix, term, alt, altFlag });
|
||||
}
|
||||
|
||||
// Build the client-facing summary (names + their transitions).
|
||||
const summarize = (g) => Object.entries(g).map(([name, v]) => ({
|
||||
name, transitions: v.order.filter((t) => t !== '(common)'),
|
||||
}));
|
||||
|
||||
return {
|
||||
icao: icao.toUpperCase(),
|
||||
runways: Object.keys(runways),
|
||||
sids: summarize(groups.SID),
|
||||
stars: summarize(groups.STAR),
|
||||
approaches: summarize(groups.APPCH),
|
||||
_groups: groups,
|
||||
_rwy: runways,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve a chosen procedure+transition to a list of waypoints with coordinates.
|
||||
// type: 'sid' | 'star' | 'approach'. Fixes are resolved via the navdata index;
|
||||
// runway "fixes" (RWxx) and unresolved fixes fall back to the RWY threshold.
|
||||
export function procedureLegs(icao, type, name, trans) {
|
||||
const parsed = parseProcedures(icao);
|
||||
if (!parsed) return [];
|
||||
const TYPE = { sid: 'SID', star: 'STAR', approach: 'APPCH' }[String(type).toLowerCase()];
|
||||
const g = parsed._groups[TYPE];
|
||||
if (!g || !g[name]) return [];
|
||||
const node = g[name];
|
||||
|
||||
// Compose the leg list: chosen transition first, then the common segment.
|
||||
// (SID: runway/enroute transition then common climb-out; STAR: enroute entry
|
||||
// then common arrival; approach: IAF transition then final-approach segment.)
|
||||
const seq = [];
|
||||
const wantTrans = trans && node.legs[trans] ? trans : node.order.find((t) => t !== '(common)');
|
||||
if (wantTrans && node.legs[wantTrans]) seq.push(...node.legs[wantTrans]);
|
||||
if (node.legs['(common)']) seq.push(...node.legs['(common)']);
|
||||
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const leg of seq) {
|
||||
if (!leg.fix) continue; // heading/altitude legs w/o a fix
|
||||
if (seen.has(leg.fix)) continue; // de-dupe repeated fixes
|
||||
let pt = null;
|
||||
const isRwy = /^RW/.test(leg.fix);
|
||||
if (isRwy && parsed._rwy[leg.fix]) pt = parsed._rwy[leg.fix];
|
||||
else {
|
||||
const hit = lookup(leg.fix);
|
||||
if (hit) pt = { lat: hit.lat, lon: hit.lon };
|
||||
}
|
||||
if (!pt) continue; // unresolved fix → skip
|
||||
seen.add(leg.fix);
|
||||
out.push({ id: leg.fix, lat: pt.lat, lon: pt.lon, type: isRwy ? 'APT' : 'WPT', alt: leg.alt });
|
||||
// An approach ends at the runway threshold — drop the missed-approach legs.
|
||||
if (TYPE === 'APPCH' && isRwy) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const PORT = process.env.BRIDGE_PORT || 8099;
|
||||
const base = `http://localhost:${PORT}`;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1180, height: 820 }, deviceScaleFactor: 2 });
|
||||
|
||||
await page.goto(base, { waitUntil: 'networkidle' });
|
||||
|
||||
const tabs = [['pfd', 'PFD'], ['mfd', 'MFD'], ['map', 'Map'], ['fms', 'FMS'], ['ap', 'Autopilot']];
|
||||
for (const [tab, label] of tabs) {
|
||||
await page.getByRole('button', { name: label, exact: true }).click();
|
||||
await page.waitForTimeout(tab === 'map' || tab === 'pfd' ? 4000 : 700); // tiles/terrain
|
||||
await page.screenshot({ path: `/tmp/cockpit-${tab}.png` });
|
||||
console.log(`shot: /tmp/cockpit-${tab}.png`);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -1,29 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="G1000" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!-- PWA: installable as a full-screen iPad/tablet app ("Zum Home-Bildschirm") -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
|
||||
<title>X-Plane Glass Cockpit</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Saira+Condensed:wght@400;500;600;700&family=Saira+Semi+Condensed:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(() => {}));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "xplane-glass-cockpit-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "G1000 Glass Cockpit",
|
||||
"short_name": "G1000",
|
||||
"description": "X-Plane 12 G1000 glass cockpit — PFD / MFD / FMS over your LAN",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "landscape",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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-v2';
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
// Only same-origin GET app shell; skip the API and let the WS pass through.
|
||||
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
|
||||
if (url.pathname.startsWith('/api') || url.pathname === '/ws') return;
|
||||
|
||||
// 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);
|
||||
const network = fetch(e.request)
|
||||
.then((res) => { if (res && res.ok) cache.put(e.request, res.clone()); return res; })
|
||||
.catch(() => cached);
|
||||
return cached || network;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useXplane } from './api/useXplane.js';
|
||||
import PFD from './components/PFD.jsx';
|
||||
import AutopilotPanel from './components/AutopilotPanel.jsx';
|
||||
import MFD from './components/MFD.jsx';
|
||||
import MapView from './components/MapView.jsx';
|
||||
import CDU from './components/CDU.jsx';
|
||||
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';
|
||||
import AudioPanel from './components/AudioPanel.jsx';
|
||||
|
||||
// Compact line icons for the nav rail (stroke = currentColor).
|
||||
const ICONS = {
|
||||
pfd: 'M11 3a8 8 0 100 16 8 8 0 000-16zM3.5 11h15M7 8l1.5 1M15 8l-1.5 1',
|
||||
mfd: 'M3 6l5-2 6 2 5-2v12l-5 2-6-2-5 2zM8 4v12M14 6v12',
|
||||
map: 'M11 2c-3.3 0-6 2.6-6 5.9 0 4.4 6 11.1 6 11.1s6-6.7 6-11.1C17 4.6 14.3 2 11 2z',
|
||||
fms: 'M4 6h14M4 11h14M4 16h9',
|
||||
ap: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 4v3M11 15v3M4 11h3M15 11h3',
|
||||
vfr: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 11l4.5-3',
|
||||
audio: 'M11 4a6 6 0 00-6 6v5M17 15v-5a6 6 0 00-6-6M4 14h2.5v4.5H4zM15.5 14H18v4.5h-2.5z',
|
||||
};
|
||||
function Icon({ name }) {
|
||||
return (
|
||||
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={ICONS[name]} />
|
||||
{name === 'map' && <circle cx="11" cy="8" r="2" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'pfd', label: 'PFD' },
|
||||
{ id: 'mfd', label: 'MFD' },
|
||||
{ id: 'map', label: 'Map' },
|
||||
{ id: 'fms', label: 'FMS' },
|
||||
{ id: 'vfr', label: 'VFR' },
|
||||
{ id: 'ap', label: 'Autopilot' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const xp = useXplane();
|
||||
const [tab, setTab] = useState(() => location.hash.replace('#', '') || 'pfd');
|
||||
// Collapsible nav rail: narrow (icons) ↔ wide (icons + labels), remembered.
|
||||
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
|
||||
const go = (id) => { setTab(id); history.replaceState(null, '', `#${id}`); };
|
||||
const toggleNav = () => setNavWide((w) => { localStorage.setItem('navWide', w ? '0' : '1'); return !w; });
|
||||
// Knob interaction: 'arrows' (visible ˄‹›˅, touch-friendly) or 'zones' (click
|
||||
// the knob face). Settable in the settings panel, remembered.
|
||||
const [knobMode, setKnobMode] = useState(() => localStorage.getItem('knobMode') || 'arrows');
|
||||
const [settings, setSettings] = useState(false);
|
||||
const setKnob = (m) => { localStorage.setItem('knobMode', m); setKnobMode(m); };
|
||||
// Synthetic-terrain (3D) vs. classic blue/brown attitude — toggled by the
|
||||
// PFD → SYN TERR softkey, exactly like the real XPLANE 1000.
|
||||
const [svt3d, setSvt3d] = useState(false);
|
||||
// The PFD INSET map (bottom-left) is off by default and toggled by its softkey.
|
||||
const [inset, setInset] = useState(false);
|
||||
// INSET map options (base layer + declutter), set from the INSET submenu.
|
||||
const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 });
|
||||
// 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 + overlays), switched via the Map-Opt softkeys.
|
||||
const [mapMode, setMapMode] = useState({ base: 'topo' });
|
||||
// Altimeter barometric units (false = inHg, true = hectopascal) — PFD ALT UNIT softkey.
|
||||
const [baroHpa, setBaroHpa] = useState(false);
|
||||
// Barometric minimums (set in TMR/REF) — shown on the PFD altimeter as BARO MIN.
|
||||
const [minimums, setMinimums] = useState({ on: false, ft: 500 });
|
||||
// OBS (omni-bearing select) mode — suspends GPS sequencing, course set by CRS knob.
|
||||
const [obs, setObs] = 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 && <DirectTo xp={xp} onClose={() => setWin(null)} />}
|
||||
{proc && <Proc xp={xp} onClose={() => setWin(null)} />}
|
||||
{fpl && (
|
||||
<div className="gwin-backdrop" onClick={() => setWin(null)}>
|
||||
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} /></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
|
||||
<aside className="sidebar">
|
||||
<button className="sb-top" onClick={toggleNav} title="Menü ein-/ausklappen">
|
||||
<span className="brand">G<span>1000</span></span>
|
||||
<span className="sb-chev">{navWide ? '◂' : '▸'}</span>
|
||||
</button>
|
||||
<nav className="snav">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.id} className={tab === t.id ? 'snav-i active' : 'snav-i'}
|
||||
onClick={() => go(t.id)} title={t.label}>
|
||||
<Icon name={t.id} />
|
||||
<span className="snav-lbl">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="snav-i sb-gear" onClick={() => setSettings(true)} title="Einstellungen">
|
||||
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="3.2" />
|
||||
<path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17" />
|
||||
</svg>
|
||||
<span className="snav-lbl">Einstellungen</span>
|
||||
</button>
|
||||
<div className={`sb-conn ${connKind}`} title={connText}>
|
||||
<span className="dot" />
|
||||
<span className="snav-lbl">{connText}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="screen">
|
||||
{tab === 'pfd' && (
|
||||
<Bezel variant="pfd" xp={xp} knobMode={knobMode} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
|
||||
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
|
||||
nrst={nrst} onToggleNrst={() => toggleWin('nrst')} onDirect={() => toggleWin('dto')}
|
||||
tmr={tmr} onToggleTmr={() => toggleWin('tmr')} dme={dme} onToggleDme={() => toggleWin('dme')}
|
||||
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')} onClr={() => setWin(null)}
|
||||
altHpa={baroHpa} onAltUnit={setBaroHpa} obs={obs} onObs={() => setObs((v) => !v)}>
|
||||
<PFD values={xp.values} command={xp.command} connected={xp.xpConnected} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setWin(null)}
|
||||
tmr={tmr} onCloseTmr={() => setWin(null)} dme={dme} onCloseDme={() => setWin(null)}
|
||||
alerts={alerts} onCloseAlerts={() => setWin(null)} baroHpa={baroHpa} obs={obs}
|
||||
minimums={minimums} onMinimums={setMinimums} flightPlan={xp.flightPlan} fp={xp.fp} />
|
||||
{dialogs}
|
||||
</Bezel>
|
||||
)}
|
||||
{tab === 'mfd' && (
|
||||
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onClr={() => setWin(null)}>
|
||||
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} />
|
||||
{dialogs}
|
||||
</Bezel>
|
||||
)}
|
||||
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
|
||||
{tab === 'fms' && <CDU xp={xp} />}
|
||||
{tab === 'vfr' && <VFR xp={xp} />}
|
||||
{tab === 'ap' && <AutopilotPanel xp={xp} />}
|
||||
{tab === 'audio' && <AudioPanel xp={xp} />}
|
||||
</main>
|
||||
{settings && (
|
||||
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
|
||||
<div className="dlg" onClick={(e) => e.stopPropagation()} style={{ minWidth: 360 }}>
|
||||
<div className="dlg-head">EINSTELLUNGEN</div>
|
||||
<div style={{ padding: 14 }}>
|
||||
<div className="set-lbl">Knopf-Bedienung</div>
|
||||
<div className="set-opt">
|
||||
<button className={`fbtn ${knobMode === 'arrows' ? 'add' : ''}`} onClick={() => setKnob('arrows')}>Pfeiltasten ˄‹›˅</button>
|
||||
<button className={`fbtn ${knobMode === 'zones' ? 'add' : ''}`} onClick={() => setKnob('zones')}>Klickzonen am Knopf</button>
|
||||
</div>
|
||||
<div className="set-hint">Pfeiltasten sind touch-freundlich. Klickzonen: oben/unten = grob, links/rechts = fein, Mitte = PUSH.</div>
|
||||
</div>
|
||||
<div className="dlg-actions"><button className="fbtn" onClick={() => setSettings(false)}>Schließen</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
// Single WebSocket connection to the bridge. Streams dataref values + the
|
||||
// shared flight plan in; sends commands / dataref writes / flight-plan edits
|
||||
// out. Auto-reconnects.
|
||||
export function useXplane() {
|
||||
const [values, setValues] = useState({});
|
||||
const [flightPlan, setFlightPlan] = useState({ name: 'ACTIVE', waypoints: [] });
|
||||
const [terrain, setTerrain] = useState(null); // elevation grid for terrain awareness
|
||||
const [exportMsg, setExportMsg] = useState(null);
|
||||
const [connected, setConnected] = useState(false); // socket to bridge
|
||||
const [xpConnected, setXpConnected] = useState(false); // bridge <-> X-Plane
|
||||
const wsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let closed = false;
|
||||
let retry;
|
||||
// Coalesce incoming value bursts into a single React update per animation
|
||||
// frame — keeps the gauges smooth instead of re-rendering ~20×/sec.
|
||||
let pending = null;
|
||||
let raf = 0;
|
||||
const flush = () => {
|
||||
raf = 0;
|
||||
if (pending) { const p = pending; pending = null; setValues((prev) => ({ ...prev, ...p })); }
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'values') {
|
||||
pending = pending ? Object.assign(pending, msg.data) : { ...msg.data };
|
||||
if (!raf) raf = requestAnimationFrame(flush);
|
||||
}
|
||||
else if (msg.type === 'status') setXpConnected(!!msg.xpConnected);
|
||||
else if (msg.type === 'flightplan') setFlightPlan(msg.data);
|
||||
else if (msg.type === 'terrain') setTerrain(msg.data);
|
||||
else if (msg.type === 'fp_export_result') setExportMsg(msg);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
setXpConnected(false);
|
||||
if (!closed) retry = setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => { closed = true; clearTimeout(retry); wsRef.current?.close(); };
|
||||
}, []);
|
||||
|
||||
const send = useCallback((obj) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
||||
}, []);
|
||||
|
||||
const command = useCallback((name, duration = 0) => send({ type: 'command', name, duration }), [send]);
|
||||
const setDataref = useCallback((name, value) => send({ type: 'setDataref', name, value }), [send]);
|
||||
|
||||
// flight-plan actions
|
||||
const fp = {
|
||||
add: (ident) => send({ type: 'fp_add', ident }),
|
||||
addLatLon: (lat, lon) => send({ type: 'fp_add', ident: `${lat},${lon}` }),
|
||||
remove: (index) => send({ type: 'fp_remove', index }),
|
||||
setActive: (index) => send({ type: 'fp_active', index }),
|
||||
clear: () => send({ type: 'fp_clear' }),
|
||||
set: (plan) => send({ type: 'fp_set', plan }),
|
||||
export: (name) => send({ type: 'fp_export', name }),
|
||||
load: (name) => send({ type: 'fp_load', name }),
|
||||
};
|
||||
|
||||
return { values, flightPlan, terrain, exportMsg, connected, xpConnected, command, setDataref, fp };
|
||||
}
|
||||
|
||||
// List saved .fms flight plans (X-Plane's Output/FMS plans) via the bridge.
|
||||
export async function fmsList() {
|
||||
try { const r = await fetch('/api/fms/list'); return r.ok ? r.json() : []; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
// Search X-Plane's nav database (waypoints/VOR/NDB/airports) via the bridge.
|
||||
export async function navSearch(q) {
|
||||
if (!q) return [];
|
||||
try {
|
||||
const r = await fetch(`/api/nav/search?q=${encodeURIComponent(q)}`);
|
||||
return r.ok ? r.json() : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience: read a numeric value with a fallback.
|
||||
export const num = (v, d = 0) => (typeof v === 'number' && isFinite(v) ? v : d);
|
||||
const v0 = (x) => (Array.isArray(x) ? num(x[0]) : num(x));
|
||||
|
||||
// System alerts/annunciations derived from live datarefs — drives the PFD
|
||||
// CAUTION softkey + the ALERTS window. Each: { t: text, warn: bool (red vs amber) }.
|
||||
export function systemAlerts(V = {}) {
|
||||
const out = [];
|
||||
const rpm = v0(V.engRpm);
|
||||
const running = rpm > 400;
|
||||
const oilP = v0(V.oilPress);
|
||||
const oilT = v0(V.oilTemp); const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32;
|
||||
const volts = v0(V.volts);
|
||||
const fuelGal = (Array.isArray(V.fuelQty) ? V.fuelQty.reduce((a, b) => a + num(b), 0) : num(V.fuelQty)) / 2.72;
|
||||
if (running && oilP < 20) out.push({ t: 'OIL PRESSURE', warn: true });
|
||||
if (oilF > 245) out.push({ t: 'OIL TEMP HIGH', warn: true });
|
||||
if (Array.isArray(V.fuelQty) && fuelGal < 5) out.push({ t: 'FUEL LOW TOTAL', warn: fuelGal < 2.5 });
|
||||
if (volts > 1 && volts < 24.5) out.push({ t: 'LOW VOLTS', warn: false });
|
||||
return out;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// X1000 Audio Panel (Manual S.91). Selects which radios are heard, which COM is
|
||||
// used to transmit (MIC), marker/DME/ADF Morse audio, intercom, and the Display
|
||||
// Backup (reversionary) key. Selections are local state with authentic lit keys.
|
||||
//
|
||||
// COM MIC is single-select (one transmit radio); the receive/audio keys and the
|
||||
// Morse keys toggle independently — exactly like the real unit.
|
||||
export default function AudioPanel({ xp }) {
|
||||
const [mic, setMic] = useState('com1'); // transmit radio: com1 | com2 | tel
|
||||
const [recv, setRecv] = useState({ com1: true }); // receive/audio selections
|
||||
const [hiSens, setHiSens] = useState(false);
|
||||
const [crew, setCrew] = useState('pilot');
|
||||
const [vol, setVol] = useState(60);
|
||||
|
||||
const r = (k) => !!recv[k];
|
||||
const toggle = (k) => setRecv((s) => ({ ...s, [k]: !s[k] }));
|
||||
// a single audio key: lit green for MIC (transmit), cyan for receive/Morse
|
||||
const Key = ({ k, label, sub, on, kind = 'recv', onClick }) => (
|
||||
<button className={`apk ${kind} ${on ? 'on' : ''}`} onClick={onClick}>
|
||||
<span className="apk-l">{label}</span>{sub && <span className="apk-s">{sub}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="audio-panel">
|
||||
<div className="apnl">
|
||||
<div className="apnl-title">AUDIO PANEL</div>
|
||||
|
||||
<div className="apnl-grp">
|
||||
<div className="apnl-h">COM</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="COM1 MIC" kind="mic" on={mic === 'com1'} onClick={() => setMic('com1')} />
|
||||
<Key label="COM1" on={r('com1')} onClick={() => toggle('com1')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="COM2 MIC" kind="mic" on={mic === 'com2'} onClick={() => setMic('com2')} />
|
||||
<Key label="COM2" on={r('com2')} onClick={() => toggle('com2')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="COM 1/2" on={false} onClick={() => setMic((m) => (m === 'com1' ? 'com2' : 'com1'))} />
|
||||
<Key label="TEL" kind="mic" on={mic === 'tel'} onClick={() => setMic('tel')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="apnl-grp">
|
||||
<div className="apnl-h">CABIN / SPEAKER</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="PA" on={r('pa')} onClick={() => toggle('pa')} />
|
||||
<Key label="SPKR" on={r('spkr')} onClick={() => toggle('spkr')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="MKR / MUTE" on={r('mkr')} onClick={() => toggle('mkr')} />
|
||||
<Key label="HI SENS" on={hiSens} onClick={() => setHiSens((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="apnl-grp">
|
||||
<div className="apnl-h">NAV</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="DME" on={r('dme')} onClick={() => toggle('dme')} />
|
||||
<Key label="NAV1" on={r('nav1')} onClick={() => toggle('nav1')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="ADF" on={r('adf')} onClick={() => toggle('adf')} />
|
||||
<Key label="NAV2" on={r('nav2')} onClick={() => toggle('nav2')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="AUX" on={r('aux')} onClick={() => toggle('aux')} />
|
||||
<Key label="MAN SQ" on={r('msq')} onClick={() => toggle('msq')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="apnl-grp">
|
||||
<div className="apnl-h">CREW · ICS</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="PILOT" kind="mic" on={crew === 'pilot'} onClick={() => setCrew('pilot')} />
|
||||
<Key label="COPLT" kind="mic" on={crew === 'copilot'} onClick={() => setCrew('copilot')} />
|
||||
</div>
|
||||
<div className="apnl-vol">
|
||||
<span>PILOT INTERCOM VOL</span>
|
||||
<input type="range" min="0" max="100" value={vol} onChange={(e) => setVol(+e.target.value)} />
|
||||
<b>{vol}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="apnl-backup" onClick={() => xp && xp.command && xp.command('mfd_softkey1')} title="Display Backup (reversionary)">
|
||||
DISPLAY BACKUP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||