Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ The default backend opens:

and sends JSON commands `addListener` / `get` for each subscribed path, matching FlightGear’s generic **PropertyListener** WebSocket.

### 4. fgcommands and property writes (same `--httpd` port)

FlightGear exposes **`POST /run.cgi?value=<command>`** (JSON body → command args) and **`POST /json/<path>`** (set a property), and the PropertyListener WebSocket **`{ "command": "exec", "fgcommand": "…", … }`** path — all ultimately call the same fgcommand machinery (`RunUriHandler.cxx`, `PropertyChangeWebsocket.cxx`). This package prefers **WebSocket `exec`** for commands (avoids HTTP/CORS issues with `/run.cgi`):

| Export | Purpose |
|--------|---------|
| **`sendFgExecCommand(sendRaw, fgcommand, args?)`** | Send `exec` on an **existing** PropertyListener socket (e.g. `useFlightGearPanelPropertiesStore().sendPropertyListenerRaw`). |
| **`runFgHttpCommand({ host, command, args? })`** | Opens a short-lived `ws://…/PropertyListener`, sends `exec`, then closes (same payload as `/run.cgi`). |
| **`buildFgExecWebSocketJson(fgcommand, args)`** | Low-level JSON line for `exec`. |
| **`buildTimeOfDayCommandArgs(type, offset?)`** | Args for the `timeofday` command (`real`, `dawn`, `noon`, …). |
| **`setFgHttpProperty({ host, propertyPath, value })`** | POST `{ value }` to `/json/<path>` for a leaf write. |
| **`flightGearHttpBaseUrl(host)`** | Normalizes `host:port` → `http://…` |
| **`flightGearPropertyListenerWsUrl(host)`** | `ws://…/PropertyListener` |

Use the **same host and port** as PropertyListener (FlightGear `--httpd`).

---

## API reference
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/flightgear-http/flightGearScreenshotUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { flightGearHttpBaseUrl } from './httpBase';

export type FlightGearScreenshotImageFormat = 'jpg' | 'png';

export type FlightGearScreenshotUrlOptions = {
/** Image format (FlightGear default is often JPG). */
type?: FlightGearScreenshotImageFormat;
/** Optional render window name (FG {@code /rendering/camera-group/window/name}). */
window?: string;
/** Optional canvas index for a Canvas texture capture. */
canvasindex?: number;
/** Cache-buster query param (old Phi used {@code t=} with a timestamp). */
cacheBust?: number | string;
};

/**
* Absolute URL for FlightGear’s embedded {@code GET /screenshot} handler
* ({@code ScreenshotUriHandler.cxx}).
*/
export function flightGearScreenshotUrl(host: string, options?: FlightGearScreenshotUrlOptions): string {
const base = flightGearHttpBaseUrl(host);
const q = new URLSearchParams();
const t = options?.type ?? 'jpg';
if (t) q.set('type', t);
const w = options?.window?.trim();
if (w) q.set('window', w);
if (options?.canvasindex != null && Number.isFinite(options.canvasindex)) {
q.set('canvasindex', String(Math.trunc(options.canvasindex)));
}
if (options?.cacheBust != null) q.set('t', String(options.cacheBust));
const qs = q.toString();
return qs ? `${base}/screenshot?${qs}` : `${base}/screenshot`;
}
17 changes: 17 additions & 0 deletions packages/core/src/flightgear-http/httpBase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';

import { flightGearHttpBaseUrl } from './httpBase';

describe('flightGearHttpBaseUrl', () => {
it('adds http and preserves port', () => {
expect(flightGearHttpBaseUrl('127.0.0.1:5701')).toBe('http://127.0.0.1:5701');
});

it('strips ws://', () => {
expect(flightGearHttpBaseUrl('ws://localhost:8080')).toBe('http://localhost:8080');
});

it('strips http://', () => {
expect(flightGearHttpBaseUrl('http://10.0.0.5:5500')).toBe('http://10.0.0.5:5500');
});
});
21 changes: 21 additions & 0 deletions packages/core/src/flightgear-http/httpBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Normalized host (no scheme), same rules as {@link flightGearHttpBaseUrl}. */
function flightGearHostKey(host: string): string {
const t = host.trim();
if (!t) return 'localhost';
return t.replace(/^(https?|ws|wss):\/\//i, '');
}

/** Strip accidental schemes and build {@code http://host:port} for the embedded FlightGear httpd. */
export function flightGearHttpBaseUrl(host: string): string {
const withoutScheme = flightGearHostKey(host);
const hasPort = /:\d+$/.test(withoutScheme.split('/')[0] ?? '');
return hasPort ? `http://${withoutScheme}` : `http://${withoutScheme}`;
}

/**
* WebSocket URL for FlightGear’s generic PropertyListener socket
* ({@code flightgear/src/Network/http/PropertyChangeWebsocket.cxx}).
*/
export function flightGearPropertyListenerWsUrl(host: string): string {
return `ws://${flightGearHostKey(host)}/PropertyListener`;
}
14 changes: 14 additions & 0 deletions packages/core/src/flightgear-http/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export { flightGearHttpBaseUrl, flightGearPropertyListenerWsUrl } from './httpBase';
export {
flightGearScreenshotUrl,
type FlightGearScreenshotImageFormat,
type FlightGearScreenshotUrlOptions,
} from './flightGearScreenshotUrl';
export {
buildNasalCommandArgs,
buildTimeOfDayCommandArgs,
runFgHttpCommand,
type RunFgHttpCommandOptions,
} from './runFgHttpCommand';
export { setFgHttpProperty, type SetFgHttpPropertyOptions } from './setFgHttpProperty';
export type { FgCommandArgChild, FgCommandArgsJson } from './types';
121 changes: 121 additions & 0 deletions packages/core/src/flightgear-http/runFgHttpCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { buildFgExecWebSocketJson } from '../flightgear-properties/propertyListenerExec';
import { flightGearPropertyListenerWsUrl } from './httpBase';
import type { FgCommandArgChild, FgCommandArgsJson } from './types';

export type RunFgHttpCommandOptions = {
/**
* Same host as PropertyListener: {@code hostname} or {@code hostname:port} (no {@code ws://} / {@code http://}).
*/
host: string;
/** Registered fgcommand name (e.g. {@code timeofday}, {@code pause}). */
command: string;
/**
* Optional JSON merged into the command’s argument node (same tree as old Phi / {@code POST /run.cgi} body).
*/
args?: FgCommandArgsJson;
signal?: AbortSignal;
};

/**
* Runs a FlightGear fgcommand via the PropertyListener WebSocket {@code exec} verb — the same mechanism as
* {@code POST /run.cgi?value=…} ({@code RunUriHandler.cxx}), but without relying on HTTP/CORS to {@code /run.cgi}.
*
* Server: {@code PropertyChangeWebsocket::handleExecCommand} in
* {@code flightgear/src/Network/http/PropertyChangeWebsocket.cxx}.
*
* When you already have an open PropertyListener connection, prefer {@link sendFgExecCommand} on
* {@link useFlightGearPanelPropertiesStore}.{@link sendPropertyListenerRaw} to avoid a second socket.
*
* @returns A synthetic {@link Response} with status 200 after the exec frame is sent (FlightGear does not reply with
* command success/failure on this channel).
*/
export async function runFgHttpCommand(options: RunFgHttpCommandOptions): Promise<Response> {
if (options.signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}

const url = flightGearPropertyListenerWsUrl(options.host);
const payload = buildFgExecWebSocketJson(options.command, options.args ?? {});

return await new Promise((resolve, reject) => {
let settled = false;
const ws = new WebSocket(url);
let connectTimer: ReturnType<typeof setTimeout> | undefined;

const cleanup = (): void => {
clearTimeout(connectTimer);
options.signal?.removeEventListener('abort', onAbort);
try {
ws.close();
} catch {
/* ignore */
}
};

const fail = (msg: string): void => {
if (settled) return;
settled = true;
cleanup();
reject(new Error(msg));
};

const ok = (): void => {
if (settled) return;
settled = true;
cleanup();
resolve(new Response('ok.', { status: 200, statusText: 'OK' }));
};

const onAbort = (): void => {
fail('Aborted');
};

options.signal?.addEventListener('abort', onAbort);

connectTimer = setTimeout(
() => fail('PropertyListener WebSocket connection timeout'),
8_000,
);

ws.onopen = (): void => {
clearTimeout(connectTimer);
try {
ws.send(payload);
} catch (e) {
fail(e instanceof Error ? e.message : String(e));
return;
}
setTimeout(ok, 0);
};

ws.onerror = (): void => {
fail('WebSocket error (is FlightGear running with --httpd / PropertyListener?)');
};
});
}

/**
* Builds the same JSON body old Phi used for {@code fgcommand.timeofday(type, offset)}.
*/
export function buildTimeOfDayCommandArgs(timeofday: string, offset = 0): FgCommandArgsJson {
return {
name: '',
children: [
{ name: 'timeofday', index: 0, value: timeofday },
{ name: 'offset', index: 0, value: offset },
],
};
}

/**
* Args for the built-in {@code nasal} fgcommand ({@code FGNasalSys::handleCommand} — expects {@code script},
* optional {@code module}).
*/
export function buildNasalCommandArgs(script: string, moduleName?: string): FgCommandArgsJson {
const children: FgCommandArgChild[] = [{ name: 'script', index: 0, value: script }];
const mod = moduleName?.trim();
if (mod) {
children.push({ name: 'module', index: 0, value: mod });
}
return { children };
}
35 changes: 35 additions & 0 deletions packages/core/src/flightgear-http/setFgHttpProperty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { flightGearHttpBaseUrl } from './httpBase';

export type SetFgHttpPropertyOptions = {
/**
* Same host as PropertyListener: {@code hostname} or {@code hostname:port}.
*/
host: string;
/** Property path like {@code sim/time/warp} or {@code /sim/time/warp}. */
propertyPath: string;
/** Leaf value written with {@code JSON::setValueFromJSON}. */
value: string | number | boolean;
signal?: AbortSignal;
};

/**
* Sets a single property via {@code POST /json/<path>} with body {@code { "value": ... }}.
*
* Server: {@code flightgear/src/Network/http/JsonUriHandler.cxx} — body is merged into the node
* with {@code JSON::toProp}; for a leaf, {@code { value }} is enough.
*
* PropertyListener WebSocket also supports {@code { "command": "set", "node": "...", "value": ... }} with the same
* value semantics ({@code PropertyChangeWebsocket::handleSetCommand}), but subtree **reads** for the property tree
* still require HTTP {@code GET /json/...?d≥1}; see {@code handleGetCommand} (fixed depth {@code 0}).
*/
export async function setFgHttpProperty(options: SetFgHttpPropertyOptions): Promise<Response> {
const base = flightGearHttpBaseUrl(options.host);
const path = options.propertyPath.replace(/^\/+/, '');
const url = `${base}/json/${path}`;
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: options.value }),
signal: options.signal,
});
}
23 changes: 23 additions & 0 deletions packages/core/src/flightgear-http/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* JSON shape merged into an {@link SGPropertyNode} via FlightGear’s {@code JSON::toProp}
* ({@code flightgear/src/Network/http/jsonprops.cxx}).
* Mirrors the object trees produced by old Phi’s {@code fgcommand.js} ({@code oneArg} / {@code twoArgs}).
*/
export type FgCommandArgChild = {
name: string;
index?: number;
value?: string | number | boolean;
children?: FgCommandArgChild[];
};

/**
* Root argument object for fgcommands: {@code POST /run.cgi?value=<command>} or PropertyListener
* {@code { command: "exec", fgcommand, ... }} (see {@link buildFgExecWebSocketJson}).
* Often {@code { name: '', children: [...] }} for multi-argument commands.
*/
export type FgCommandArgsJson = {
name?: string;
index?: number;
value?: string | number | boolean;
children?: FgCommandArgChild[];
};
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ export const useFlightGearPanelPropertiesStore = defineStore('flightgear-panel-p
}
break;
}
case 'string':
case 'unspecified': {
const sv = nodeEvent.value != null ? String(nodeEvent.value) : '';
if (sv !== prop.oldValue) {
prop.r.value = sv;
prop.oldValue = sv;
}
break;
}
default:
if (!unknownTypeWarnings.has(nodeEvent.path)) {
unknownTypeWarnings.add(nodeEvent.path);
Expand Down Expand Up @@ -203,5 +212,10 @@ export const useFlightGearPanelPropertiesStore = defineStore('flightgear-panel-p
throw new Error(`unsubscribing from ${_path} not implemented`);
}

return { connect, disconnect, subscribe, unsubscribe, isConnected, host };
/** Send a raw JSON payload on the active PropertyListener WebSocket (e.g. `{ command: "exec", fgcommand: "timeofday", ... }`). */
function sendPropertyListenerRaw(text: string): void {
activeConnection?.sendRaw?.(text);
}

return { connect, disconnect, subscribe, unsubscribe, sendPropertyListenerRaw, isConnected, host };
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export function createFlightGearPropertyListenerBackend(): PanelPropertyBackend
send(JSON.stringify({ command: 'addListener', node: path }));
send(JSON.stringify({ command: 'get', node: path }));
},
sendRaw(text: string) {
send(text);
},
};
},
};
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/flightgear-properties/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export {
setPanelPropertyBackendFactory,
} from './panelPropertyBackendRegistry';
export { useFlightGearPanelPropertiesStore } from './flightGearPanelPropertiesStore';
export { buildFgExecWebSocketJson, sendFgExecCommand } from './propertyListenerExec';
35 changes: 35 additions & 0 deletions packages/core/src/flightgear-properties/propertyListenerExec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { FgCommandArgsJson } from '../flightgear-http/types';

/**
* Builds a PropertyListener WebSocket message that runs an fgcommand via {@code command: "exec"}
* ({@code flightgear/src/Network/http/PropertyChangeWebsocket.cxx} {@code handleExecCommand}).
*/
export function buildFgExecWebSocketJson(fgcommand: string, args: FgCommandArgsJson): string {
const o: Record<string, unknown> = {
command: 'exec',
fgcommand,
};
if (args.name !== undefined) {
o.name = args.name;
}
if (args.children !== undefined) {
o.children = args.children;
}
if (args.value !== undefined) {
o.value = args.value;
}
return JSON.stringify(o);
}

/**
* Sends an {@code exec} message on an existing PropertyListener WebSocket (e.g. Pinia
* `useFlightGearPanelPropertiesStore().sendPropertyListenerRaw`).
* Prefer this over opening a separate socket when the UI is already connected.
*/
export function sendFgExecCommand(
sendRaw: (text: string) => void,
fgcommand: string,
args: FgCommandArgsJson = {},
): void {
sendRaw(buildFgExecWebSocketJson(fgcommand, args));
}
2 changes: 2 additions & 0 deletions packages/core/src/flightgear-properties/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface PanelPropertyConnection {
disconnect(): void;
/** Register interest in `path` and request current value (semantics depend on backend). */
requestSubscription(path: string): void;
/** Send a raw JSON line (FlightGear PropertyListener supports e.g. `{ command: "exec", ... }`). */
sendRaw?(text: string): void;
}

export interface PanelPropertyBackendListeners {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import './math';

export { PrerenderedSvgImage } from './components';
export * from './flightgear-properties';
export * from './flightgear-http';
export { clamp, installPanelMath, interpolate, panelMathMixin } from './math';