diff --git a/packages/core/README.md b/packages/core/README.md index c221283..a38e937 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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=`** (JSON body → command args) and **`POST /json/`** (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/` 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 diff --git a/packages/core/src/flightgear-http/flightGearScreenshotUrl.ts b/packages/core/src/flightgear-http/flightGearScreenshotUrl.ts new file mode 100644 index 0000000..2cdec45 --- /dev/null +++ b/packages/core/src/flightgear-http/flightGearScreenshotUrl.ts @@ -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`; +} diff --git a/packages/core/src/flightgear-http/httpBase.test.ts b/packages/core/src/flightgear-http/httpBase.test.ts new file mode 100644 index 0000000..c1f3ee0 --- /dev/null +++ b/packages/core/src/flightgear-http/httpBase.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/flightgear-http/httpBase.ts b/packages/core/src/flightgear-http/httpBase.ts new file mode 100644 index 0000000..4d58d76 --- /dev/null +++ b/packages/core/src/flightgear-http/httpBase.ts @@ -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`; +} diff --git a/packages/core/src/flightgear-http/index.ts b/packages/core/src/flightgear-http/index.ts new file mode 100644 index 0000000..befaa0a --- /dev/null +++ b/packages/core/src/flightgear-http/index.ts @@ -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'; diff --git a/packages/core/src/flightgear-http/runFgHttpCommand.ts b/packages/core/src/flightgear-http/runFgHttpCommand.ts new file mode 100644 index 0000000..61e5187 --- /dev/null +++ b/packages/core/src/flightgear-http/runFgHttpCommand.ts @@ -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 { + 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 | 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 }; +} diff --git a/packages/core/src/flightgear-http/setFgHttpProperty.ts b/packages/core/src/flightgear-http/setFgHttpProperty.ts new file mode 100644 index 0000000..cd8688c --- /dev/null +++ b/packages/core/src/flightgear-http/setFgHttpProperty.ts @@ -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/} 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 { + 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, + }); +} diff --git a/packages/core/src/flightgear-http/types.ts b/packages/core/src/flightgear-http/types.ts new file mode 100644 index 0000000..6a22d57 --- /dev/null +++ b/packages/core/src/flightgear-http/types.ts @@ -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=} 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[]; +}; diff --git a/packages/core/src/flightgear-properties/flightGearPanelPropertiesStore.ts b/packages/core/src/flightgear-properties/flightGearPanelPropertiesStore.ts index ff9e901..81e1029 100644 --- a/packages/core/src/flightgear-properties/flightGearPanelPropertiesStore.ts +++ b/packages/core/src/flightgear-properties/flightGearPanelPropertiesStore.ts @@ -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); @@ -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 }; }); diff --git a/packages/core/src/flightgear-properties/flightGearPropertyListenerBackend.ts b/packages/core/src/flightgear-properties/flightGearPropertyListenerBackend.ts index bac3cc2..9c9c4e7 100644 --- a/packages/core/src/flightgear-properties/flightGearPropertyListenerBackend.ts +++ b/packages/core/src/flightgear-properties/flightGearPropertyListenerBackend.ts @@ -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); + }, }; }, }; diff --git a/packages/core/src/flightgear-properties/index.ts b/packages/core/src/flightgear-properties/index.ts index 608ffdb..99eeb82 100644 --- a/packages/core/src/flightgear-properties/index.ts +++ b/packages/core/src/flightgear-properties/index.ts @@ -13,3 +13,4 @@ export { setPanelPropertyBackendFactory, } from './panelPropertyBackendRegistry'; export { useFlightGearPanelPropertiesStore } from './flightGearPanelPropertiesStore'; +export { buildFgExecWebSocketJson, sendFgExecCommand } from './propertyListenerExec'; diff --git a/packages/core/src/flightgear-properties/propertyListenerExec.ts b/packages/core/src/flightgear-properties/propertyListenerExec.ts new file mode 100644 index 0000000..36a7a8c --- /dev/null +++ b/packages/core/src/flightgear-properties/propertyListenerExec.ts @@ -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 = { + 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)); +} diff --git a/packages/core/src/flightgear-properties/types.ts b/packages/core/src/flightgear-properties/types.ts index 6231c7c..99abe55 100644 --- a/packages/core/src/flightgear-properties/types.ts +++ b/packages/core/src/flightgear-properties/types.ts @@ -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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f6d22f..8225e5b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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';