xp-command 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Boris Diakur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,298 @@
1
+ # 🛩 xp-command
2
+
3
+ **Quick cockpit commands for X-Plane 12** - set your radios, altimeter, autopilot, and more from the terminal while flying.
4
+
5
+ ---
6
+
7
+ ## 🎯 What does this tool do?
8
+
9
+ During instrument flight in X-Plane 12, constantly clicking tiny cockpit buttons to adjust altimeter, transponder, or radio frequencies can be tricky without the appropriate hardware. This tool lets you type quick commands like `q1013` or `x7000` to instantly set instruments without touching the mouse.
10
+
11
+ **Example commands:**
12
+ - `q1013` → Set altimeter to 1013 hPa
13
+ - `x7000` → Set transponder to 7000
14
+ - `a250` → Set autopilot altitude to FL250
15
+ - `c1118900` → Set COM1 to 118.900 MHz
16
+
17
+ The tool remembers your command history and automatically creates aircraft-specific profiles which can then be adjusted to your specific needs.
18
+
19
+ ---
20
+
21
+ ## 🚀 Installation
22
+
23
+ ### Step 1: Install Node.js
24
+
25
+ You need Node.js version 22 or higher installed on your system:
26
+
27
+ 1. **Download Node.js**: Go to https://nodejs.org/en/download/ and download the **LTS version** (Long Term Support)
28
+ 2. **Install**: Run the downloaded installer
29
+ 3. **Verify**: Open Terminal and type:
30
+ ```bash
31
+ node --version
32
+ ```
33
+ You should see something like `v22.x.x`
34
+
35
+ ### Step 2: Install xp-command globally
36
+
37
+ Open Terminal and run:
38
+
39
+ ```bash
40
+ npm install -g xp-command
41
+ ```
42
+
43
+ This installs xp-command globally so you can run it from anywhere.
44
+
45
+ ---
46
+
47
+ ## 🛠 X-Plane setup
48
+
49
+ You shouldn't need to perform any extra setup steps in X-Plane. There is no plugin that needs to be installed. If you still want to check if the Web API (which allows xp-command to communicate with X-Plane) is configured correctly, here is how you can do it:
50
+
51
+ 1. In X-Plane 12, go to **Settings → Data Output**
52
+ 2. Find **"Enable Web Server API"** and make sure it's enabled
53
+ 3. Note the **port number** (default is `8086`)
54
+
55
+ ---
56
+
57
+ ## 🎮 Usage
58
+
59
+ ### Starting the tool
60
+
61
+ 1. **Load into an aircraft** in X-Plane 12
62
+ 2. **Open Terminal** on your machine
63
+ 3. **Run**:
64
+ ```bash
65
+ xp-command
66
+ ```
67
+
68
+ You'll see a prompt:
69
+ ```
70
+ 🛩 █
71
+ ```
72
+
73
+ Now you can enter commands!
74
+
75
+ ### Using a custom port
76
+
77
+ If you changed the Web API port from 8086 to another port, you'll need to start xp-command accordingly:
78
+
79
+ ```bash
80
+ xp-command --port 8090
81
+ ```
82
+
83
+ ### Exiting
84
+
85
+ Type `exit` or press `Ctrl+C`
86
+
87
+ ---
88
+
89
+ ## 📋 Pre-configured commands
90
+
91
+ All pre-configured commands work well with most aircraft I currently fly, but you may want to adjust them for **your** aircraft (see _Aircraft-specific profiles_ section below).
92
+
93
+ ### Barometric Pressure
94
+
95
+ | Command | Action | Example |
96
+ |---------|-----------------------------------------|--------------|
97
+ | `q` | Get QNH in hPa/mb (copied to clipboard) | `q` → `1013` |
98
+ | `l` | Get QNH in inHg (copied to clipboard) | `l` → `2992` |
99
+ | `q####` | Set QNH in hPa/mb | `q1013` |
100
+ | `l####` | Set QNH in inHg | `l2992` |
101
+
102
+ ### Autopilot
103
+
104
+ | Command | Action | Example |
105
+ |----------|-----------------------------|---------------------------|
106
+ | `h###` | Set heading | `h090` → 090° |
107
+ | `a###` | Set altitude (flight level) | `a250` → FL250 (25000 ft) |
108
+ | `a#####` | Set altitude (exact feet) | `a03500` → 3500 ft |
109
+ | `s###` | Set speed in KIAS | `s180` → 180 knots |
110
+ | `s##` | Set speed in Mach | `s78` → Mach 0.78 |
111
+ | `v####` | Set vertical speed | `v1500` → 1500 ft/min |
112
+
113
+ ### Radios
114
+
115
+ | Command | Action | Format | Example |
116
+ |------------|------------------|------------|-----------------------|
117
+ | `c1#####` | Set COM1 active | No decimal | `c1118900` → 118.900 |
118
+ | `cs1#####` | Set COM1 standby | No decimal | `cs1121750` → 121.750 |
119
+ | `c2#####` | Set COM2 active | No decimal | `c2119200` → 119.200 |
120
+ | `cs2#####` | Set COM2 standby | No decimal | `cs2122800` → 122.800 |
121
+ | `n1#####` | Set NAV1 active | No decimal | `n1110500` → 110.500 |
122
+ | `ns1#####` | Set NAV1 standby | No decimal | `ns1116600` → 116.600 |
123
+ | `n2#####` | Set NAV2 active | No decimal | `n2113900` → 113.900 |
124
+ | `ns2#####` | Set NAV2 standby | No decimal | `ns2115700` → 115.700 |
125
+
126
+ **Radio frequency format:** Remove the decimal point. For `118.900`, type `118900`. For COM1 and COM2 you can omit the last digit – the value will be padded with 0.
127
+
128
+ ### Transponder
129
+
130
+ | Command | Action | Example |
131
+ |---------|-----------------|---------|
132
+ | `x####` | Set squawk code | `x7000` |
133
+
134
+ ---
135
+
136
+ ## ⚙️ Aircraft-specific profiles
137
+
138
+ The first time you run xp-command in a new aircraft, it automatically creates a configuration file at:
139
+
140
+ ```
141
+ ~/.xp-command/<Aircraft Name>.yml
142
+ ```
143
+
144
+ ### Customizing commands
145
+
146
+ You can edit these YAML files to add aircraft-specific commands or modify existing ones. Note that for changes to take effect you'll need to restart xp-command.
147
+
148
+ **Example**: Add a command to set heading with 10-degree increments:
149
+
150
+ ```yaml
151
+ - pattern: "^hdg(\\d{2})$"
152
+ type: set
153
+ dataref: sim/cockpit/autopilot/heading_mag
154
+ transform:
155
+ - mult10
156
+ ```
157
+
158
+ **Key components:**
159
+ - `pattern`: Regular expression matching your command (`hdg18`)
160
+ - `type`: Either `get` (read value) or `set` (write value)
161
+ - `dataref`: X-Plane dataref path (find these in X-Plane's DataRef Editor)
162
+ - `transform`: Optional value conversions (multiply, divide, round, etc.)
163
+
164
+ **Finding datarefs**: Use X-Plane's built-in **DataRef Editor** plugin or check [X-Plane datarefs documentation](https://developer.x-plane.com/datarefs/).
165
+
166
+ #### Array Datarefs
167
+
168
+ Many X-Plane datarefs are arrays (e.g., for multiple engines, generators, radios). You can access specific array elements using `[index]` notation:
169
+
170
+ ```yaml
171
+ # Check if right generator is on (index 1 = right, 0 = left)
172
+ - pattern: "^gr$"
173
+ type: get
174
+ dataref: sim/cockpit/electrical/generator_on[1]
175
+ ```
176
+
177
+ ## 🔄 Resetting Aircraft Profiles
178
+
179
+ If you've edited an aircraft configuration and xp-command crashes or stops working, you can reset to default settings by deleting the config files.
180
+
181
+ ### Profile Location
182
+
183
+ Aircraft profiles are stored in:
184
+
185
+ **macOS/Linux:** `~/.xp-command/`
186
+ **Windows:** `%USERPROFILE%\.xp-command\`
187
+
188
+ Each aircraft has its own `.yml` file named after the aircraft (e.g., `Cessna 172SP.yml`).
189
+
190
+ ### Reset Single Aircraft
191
+
192
+ To reset one specific aircraft, delete its `.yml` file. Next time you load that aircraft and run a command, xp-command will create a fresh default configuration.
193
+
194
+ ### Reset All Aircraft
195
+
196
+ To start completely fresh, delete the entire `.xp-command` folder. This removes all custom aircraft configurations.
197
+ The tool will automatically recreate default configurations when you next fly each aircraft and run a command.
198
+
199
+ ---
200
+
201
+ ## 🔧 Troubleshooting
202
+
203
+ ### "No connection - in aircraft?"
204
+
205
+ **Causes:**
206
+ - You're in the X-Plane menu (not loaded into cockpit)
207
+ - Web API is disabled in X-Plane settings
208
+ - X-Plane is not running
209
+ - Wrong port number
210
+
211
+ **Fix:**
212
+ 1. Load into aircraft cockpit
213
+ 2. Check **Settings → Data Output → Enable Web Server API** is checked
214
+ 3. Verify port matches (default 8086)
215
+
216
+ ### Command not recognized (red text)
217
+
218
+ **Causes:**
219
+ - Typo in command
220
+ - Command not defined for this aircraft
221
+ - Wrong number of digits
222
+
223
+ **Fix:**
224
+ - Check command format in this README
225
+ - Edit `~/.xp-command/<Aircraft>.yml` to add custom commands
226
+
227
+ ### Values not changing in cockpit
228
+
229
+ **Causes:**
230
+ - Wrong dataref for your specific aircraft
231
+ - Aircraft systems override (e.g., autopilot off)
232
+
233
+ **Fix:**
234
+ - Check DataRef Editor to verify correct dataref path
235
+ - Ensure aircraft systems are in correct mode (e.g., autopilot engaged)
236
+
237
+ ### xp-command crashes after parsing errors
238
+
239
+ **Cause:** Invalid YAML syntax when editing config files
240
+ **Fix:** Create backups of problematic aircraft's `.yml` files and delete them or the entire profiles folder
241
+
242
+ ---
243
+
244
+ ## 🗑️ Uninstalling
245
+
246
+ ### Uninstall npm package
247
+
248
+ Open Terminal and run:
249
+
250
+ ```bash
251
+ npm uninstall -g xp-command
252
+ ```
253
+
254
+ ### Clean Up Data Files
255
+
256
+ **Aircraft profiles:**
257
+ - macOS/Linux: Delete `~/.xp-command/` folder
258
+ - Windows: Delete `%USERPROFILE%\.xp-command\` folder
259
+
260
+ **Command history:**
261
+ - macOS: Delete `~/Library/Preferences/xp-command-nodejs/` folder
262
+ - Linux: Delete `~/.config/xp-command-nodejs/` folder
263
+ - Windows: Delete `%APPDATA%\xp-command-nodejs\` folder
264
+
265
+ **Complete removal:** Delete both the tool and all data files to fully uninstall xp-command from your system.
266
+
267
+ ---
268
+
269
+ ## 🛣 How it works (technical overview)
270
+
271
+ 1. **Web API connection**: Uses [X-Plane local REST Web API](https://developer.x-plane.com/article/x-plane-web-api/)
272
+ 2. **Dataref system**: Reads/writes X-Plane datarefs (internal simulator variables)
273
+ 3. **Pattern matching**: Regular expressions match your commands to datarefs
274
+ 4. **Transform pipeline**: Converts your input format to X-Plane's expected values
275
+ 5. **Aircraft profiles**: YAML configs loaded based on current aircraft name
276
+ 6. **Persistent history**: Stored using the [`conf`](https://github.com/sindresorhus/conf) package
277
+
278
+ ---
279
+
280
+ ## 📝 License
281
+
282
+ MIT License - See repository for details
283
+
284
+ ---
285
+
286
+ ## 🙏 Credits
287
+
288
+ This project was inspired by http://www.xpluginsdk.org/command_line.htm which unfortunately stopped working for me after upgrading to X-Plane 12.
289
+
290
+ ---
291
+
292
+ ## 🐛 Issues and contributions
293
+
294
+ Found a bug or want to add features? Submit issues or pull requests on the [GitHub repository](https://github.com/borisdiakur/xp-command).
295
+
296
+ ---
297
+
298
+ **👋🏻 Happy flying!**
package/bin/index.js ADDED
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ createPrompt,
5
+ isDownKey,
6
+ isEnterKey,
7
+ isUpKey,
8
+ useKeypress,
9
+ useState,
10
+ } from "@inquirer/core";
11
+ import chalk from "chalk";
12
+ import { program } from "commander";
13
+ import ora from "ora";
14
+
15
+ import { getDatarefValue, initAPI, setDatarefValue } from "../src/api.js";
16
+ import { copyToClipboard } from "../src/clipboard.js";
17
+ import { getConfig } from "../src/config.js";
18
+ import { clearLine, hideCursor, showCursor } from "../src/console.js";
19
+ import { isEconnRefused } from "../src/error.js";
20
+ import history from "../src/history.js";
21
+ import { sleep } from "../src/sleep.js";
22
+
23
+ const PREFIX = "🛩 ";
24
+
25
+ program
26
+ .version("1.0.0")
27
+ .description("xp-command")
28
+ .option("-p, --port <number>", "server port number");
29
+
30
+ /**
31
+ * @param {string} command
32
+ */
33
+ const processCommand = async (command) => {
34
+ const spinner = ora(`${PREFIX} ${chalk.cyan(command)}`).start();
35
+ hideCursor();
36
+
37
+ /** @type {import('../src/config.js').CommandConfig} */
38
+ let config;
39
+ try {
40
+ config = await getConfig();
41
+ } catch (error) {
42
+ if (isEconnRefused(error)) {
43
+ spinner.fail(chalk.red(`${PREFIX} No connection - in aircraft?`));
44
+ hideCursor();
45
+ await sleep(1500);
46
+ showCursor();
47
+ return;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @param {number|Array<number>|string} value
53
+ * @param {import('../src/config.js').Transform} transform
54
+ */
55
+ const getTransformedValue = (value, transform) => {
56
+ if (Array.isArray(value)) return value.slice();
57
+
58
+ if (transform.startsWith("mult")) {
59
+ const factor = parseFloat(transform.slice(4));
60
+ if (isNaN(factor)) return value;
61
+ return Number(value) * factor;
62
+ }
63
+
64
+ if (transform.toLowerCase().startsWith("tofixed")) {
65
+ const digits = parseInt(transform.slice(7));
66
+ if (isNaN(digits)) return value;
67
+ return Number(Number(value).toFixed(digits));
68
+ }
69
+
70
+ if (transform === "round") {
71
+ return Math.round(Number(value));
72
+ }
73
+
74
+ return value;
75
+ };
76
+
77
+ /** @type {Array<[RegExp, (regExpResult: Array<string> | null) => Promise<void>]>} */
78
+ const matches = config.commands.map((c) => {
79
+ switch (c.type) {
80
+ case "get":
81
+ return [
82
+ new RegExp(c.pattern),
83
+ async () => {
84
+ let value = await getDatarefValue(c.dataref);
85
+ c.transform?.forEach((t) => {
86
+ value = getTransformedValue(value, t);
87
+ });
88
+ await copyToClipboard(JSON.stringify(value));
89
+ spinner.succeed(chalk.green(`${PREFIX} ${value}`));
90
+ hideCursor();
91
+ await sleep(1500);
92
+ clearLine();
93
+ },
94
+ ];
95
+ case "set":
96
+ return [
97
+ new RegExp(c.pattern),
98
+ async (regExpResult) => {
99
+ let value = regExpResult[1];
100
+ c.transform?.forEach((t) => {
101
+ value = String(getTransformedValue(value, t));
102
+ });
103
+
104
+ await setDatarefValue(c.dataref, Number(value));
105
+ spinner.succeed(chalk.green(`${PREFIX} ${command}`));
106
+ hideCursor();
107
+ await sleep(1500);
108
+ clearLine();
109
+ },
110
+ ];
111
+ }
112
+ });
113
+
114
+ for (const [regexp, cb] of matches) {
115
+ const regexpResult = regexp.exec(command);
116
+ if (regexpResult !== null) {
117
+ try {
118
+ await cb(regexpResult);
119
+
120
+ spinner.succeed();
121
+ hideCursor();
122
+
123
+ showCursor();
124
+ } catch (error) {
125
+ spinner.fail();
126
+ hideCursor();
127
+
128
+ if (error instanceof Error) {
129
+ if (isEconnRefused(error)) {
130
+ clearLine();
131
+ spinner.fail(chalk.red(`${PREFIX} No connection - in aircraft?`));
132
+ await sleep(1500);
133
+ } else if (error.name === "APIError") {
134
+ clearLine();
135
+ spinner.fail(chalk.red(`${PREFIX} ${error.message}`));
136
+ await sleep(1500);
137
+ } else {
138
+ throw error;
139
+ }
140
+ } else {
141
+ throw error;
142
+ }
143
+
144
+ await sleep(500);
145
+ showCursor();
146
+ }
147
+ return;
148
+ }
149
+ }
150
+
151
+ spinner.fail(chalk.red(`${PREFIX} ${command}`));
152
+ hideCursor();
153
+
154
+ await sleep(1500);
155
+ showCursor();
156
+ };
157
+
158
+ const sayHello = () => {
159
+ console.log(chalk.magenta(" 👋 Hello!"));
160
+ };
161
+ const sayBye = () => {
162
+ console.log(chalk.magenta(" 👋 Bye!"));
163
+ };
164
+
165
+ const askForCommand = async () => {
166
+ clearLine();
167
+
168
+ const prompt = createPrompt((config, done) => {
169
+ const [value, setValue] = useState();
170
+ const [, setStatus] = useState("idle");
171
+
172
+ useKeypress((key, readline) => {
173
+ if (isEnterKey(key)) {
174
+ setStatus("done");
175
+ history.addCommand(value);
176
+ done(value);
177
+ } else if (isUpKey(key)) {
178
+ const v = history.up();
179
+ setValue(v);
180
+ readline.line = v;
181
+ } else if (isDownKey(key)) {
182
+ const v = history.down();
183
+ setValue(v);
184
+ readline.line = v;
185
+ } else {
186
+ setValue(readline.line);
187
+ }
188
+ });
189
+
190
+ return `${config[0].theme.prefix} ${config[0].message ?? ""} ${value ?? ""}`;
191
+ });
192
+
193
+ const command = await prompt(
194
+ [
195
+ {
196
+ type: "input",
197
+ name: "command",
198
+ message: "",
199
+ theme: {
200
+ prefix: " 🛩",
201
+ },
202
+ },
203
+ ],
204
+ { clearPromptOnDone: true },
205
+ );
206
+
207
+ if (command.toLowerCase() === "exit") {
208
+ sayBye();
209
+ return;
210
+ }
211
+
212
+ await processCommand(command);
213
+
214
+ await askForCommand();
215
+ };
216
+
217
+ program.action(async (/** @type {{ port: number | undefined }} */ options) => {
218
+ initAPI({ port: options.port ?? 8086 });
219
+
220
+ hideCursor();
221
+ clearLine();
222
+ sayHello();
223
+ await sleep(1000);
224
+ showCursor();
225
+
226
+ await askForCommand();
227
+ });
228
+
229
+ program.parse(process.argv);
230
+
231
+ process.on("uncaughtException", (error) => {
232
+ if (error instanceof Error) {
233
+ if (error.name === "ExitPromptError") {
234
+ clearLine();
235
+ sayBye();
236
+ return;
237
+ }
238
+ }
239
+ throw error;
240
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "xp-command",
3
+ "version": "1.0.0",
4
+ "description": "Quick cockpit commands for X-Plane 12 - set your radios, altimeter, autopilot, and more from the terminal while flying.",
5
+ "type": "module",
6
+ "bin": {
7
+ "xp-command": "./bin/index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/index.js",
11
+ "start:clean": "npm run clean && npm start",
12
+ "clean": "rm -rf ~/.xp-command/"
13
+ },
14
+ "devDependencies": {
15
+ "@types/js-yaml": "^4.0.9",
16
+ "@types/node": "^24.9.2",
17
+ "eslint": "^9.39.0",
18
+ "eslint-config-prettier": "^10.1.8",
19
+ "eslint-plugin-prettier": "^5.5.4",
20
+ "eslint-plugin-simple-import-sort": "^12.1.1",
21
+ "globals": "^16.5.0",
22
+ "prettier": "^3.6.2",
23
+ "typescript": "^5.9.3",
24
+ "typescript-eslint": "^8.46.2"
25
+ },
26
+ "keywords": [
27
+ "X-Plane"
28
+ ],
29
+ "author": "Boris Diakur (https://borisdiakur.de)",
30
+ "repository": "borisdiakur/xp-command",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@inquirer/core": "^10.3.0",
34
+ "chalk": "^5.6.2",
35
+ "commander": "^14.0.2",
36
+ "conf": "^15.0.2",
37
+ "inquirer": "^12.10.0",
38
+ "js-yaml": "^4.1.0",
39
+ "ora": "^9.0.0"
40
+ },
41
+ "engines": {
42
+ "node": ">=22"
43
+ }
44
+ }
package/src/api.js ADDED
@@ -0,0 +1,138 @@
1
+ /** @type {number} */
2
+ let port;
3
+
4
+ /**
5
+ * @typedef {{
6
+ * error_code: string
7
+ * error_message: string
8
+ * }} APIError
9
+ */
10
+
11
+ /**
12
+ * @typedef {{
13
+ * port: number
14
+ * }} APIOptions
15
+ */
16
+
17
+ class CustomError extends Error {
18
+ constructor(/** @type {string} */ message) {
19
+ super(message);
20
+ this.name = "APIError";
21
+ }
22
+ }
23
+
24
+ /**
25
+ * @param {string} datarefString
26
+ * @return {[string, number|null]}
27
+ */
28
+ const parseDataref = (datarefString) => {
29
+ // Regex pattern: captures base name and optional array index
30
+ const pattern = /^(.+?)(?:\[(\d+)\])?$/;
31
+ const match = datarefString.match(pattern);
32
+
33
+ if (!match) {
34
+ throw new CustomError("invalid_dataref");
35
+ }
36
+
37
+ return [match[1], match[2] ? parseInt(match[2]) : null];
38
+ };
39
+
40
+ /**
41
+ * @param {string} datarefName
42
+ * @return { Promise<number | null> }
43
+ */
44
+ const getDatarefId = async (datarefName) => {
45
+ const url = new URL(`http://localhost:${port}/api/v2/datarefs`);
46
+ url.searchParams.set("filter[name]", datarefName);
47
+ url.searchParams.set("fields", "id");
48
+
49
+ const response = await fetch(url);
50
+ const json = /** @type { {data:[{id: number }]} | APIError } */ (
51
+ await response.json()
52
+ );
53
+
54
+ if ("error_code" in json) {
55
+ throw new CustomError(json.error_code);
56
+ }
57
+
58
+ return json.data[0]?.id ?? null;
59
+ };
60
+
61
+ /**
62
+ * @param {APIOptions} options
63
+ */
64
+ export const initAPI = (options) => {
65
+ port = options.port;
66
+ };
67
+
68
+ /**
69
+ * @param {string} datarefNameWithOptionalIndex
70
+ * @return {Promise<number|Array<number>|string>}
71
+ */
72
+ export const getDatarefValue = async (datarefNameWithOptionalIndex) => {
73
+ const [datarefName, index] = parseDataref(datarefNameWithOptionalIndex);
74
+
75
+ const datarefId = await getDatarefId(datarefName);
76
+
77
+ const url = new URL(
78
+ `http://localhost:${port}/api/v2/datarefs/${datarefId}/value`,
79
+ );
80
+ if (typeof index === "number") {
81
+ url.searchParams.set("index", String(index));
82
+ }
83
+
84
+ const response = await fetch(url);
85
+ const json =
86
+ /** @type { {data: number|Array<number>|string } | APIError } */ (
87
+ await response.json()
88
+ );
89
+
90
+ if ("error_code" in json) {
91
+ throw new CustomError(json.error_code);
92
+ }
93
+
94
+ if (typeof json.data === "string") {
95
+ const decoded = Buffer.from(json.data, "base64")
96
+ .toString("utf-8")
97
+ .replaceAll("\x00", "")
98
+ .trim();
99
+ return decoded;
100
+ }
101
+
102
+ if (Array.isArray(json.data) && json.data.length === 1) {
103
+ return JSON.stringify(json.data[0]);
104
+ }
105
+
106
+ return JSON.stringify(json.data);
107
+ };
108
+
109
+ /**
110
+ * @param {string} datarefNameWithOptionalIndex
111
+ * @param {number|Array<number>} [value]
112
+ */
113
+ export const setDatarefValue = async (datarefNameWithOptionalIndex, value) => {
114
+ const [datarefName, index] = parseDataref(datarefNameWithOptionalIndex);
115
+
116
+ const datarefId = await getDatarefId(datarefName);
117
+
118
+ const url = new URL(
119
+ `http://localhost:${port}/api/v2/datarefs/${datarefId}/value`,
120
+ );
121
+ if (typeof index === "number") {
122
+ url.searchParams.set("index", String(index));
123
+ }
124
+
125
+ const response = await fetch(url, {
126
+ method: "PATCH",
127
+ body: JSON.stringify({ data: value }),
128
+ });
129
+ const json = /** @type { {data: number|Array<number> } | APIError } */ (
130
+ await response.json()
131
+ );
132
+
133
+ if (json && "error_code" in json) {
134
+ throw new CustomError(json.error_code);
135
+ }
136
+
137
+ return json;
138
+ };
@@ -0,0 +1,19 @@
1
+ import { exec } from "child_process";
2
+
3
+ /**
4
+ * @param {string|number} text
5
+ */
6
+ export const copyToClipboard = async (text) => {
7
+ const command =
8
+ process.platform === "win32"
9
+ ? `echo ${text} | clip`
10
+ : process.platform === "darwin"
11
+ ? `echo "${text}" | pbcopy`
12
+ : `echo "${text}" | xclip -selection clipboard`;
13
+
14
+ await new Promise((resolve) => {
15
+ exec(command, () => {
16
+ resolve();
17
+ });
18
+ });
19
+ };
package/src/config.js ADDED
@@ -0,0 +1,69 @@
1
+ import { copyFile, mkdir, readFile } from "node:fs/promises";
2
+
3
+ import yaml from "js-yaml";
4
+ import { homedir } from "os";
5
+ import { dirname } from "path";
6
+ import { join } from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ import { getDatarefValue } from "./api.js";
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ /**
15
+ * @typedef {Object} CommandConfig
16
+ * @property {Command[]} commands - Array of command definitions
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} Command
21
+ * @property {RegExp} pattern - Regular expression pattern to match commands
22
+ * @property {'get' | 'set'} type - Operation type
23
+ * @property {string} dataref - X-Plane dataref path
24
+ * @property {Transform[]} transform - Array of transformation operations to apply
25
+ */
26
+
27
+ /**
28
+ * @typedef {'mult100' | 'round' | 'div100' | 'toFixed2'} Transform
29
+ * Transform operations:
30
+ * - mult100: Multiply by 100
31
+ * - round: Round to nearest integer
32
+ * - div100: Divide by 100
33
+ * - toFixed2: Format to 2 decimal places
34
+ */
35
+
36
+ const osHomedir = homedir();
37
+
38
+ /**
39
+ * @return {Promise<CommandConfig>}
40
+ */
41
+ export const getConfig = async () => {
42
+ await mkdir(join(osHomedir, ".xp-command"), {
43
+ recursive: true,
44
+ });
45
+
46
+ /** @type {string} */
47
+ let aircraft;
48
+ aircraft = /** @type {string} */ (
49
+ await getDatarefValue("sim/aircraft/view/acf_ui_name")
50
+ );
51
+ const aircraftConfigPath = join(osHomedir, ".xp-command", `${aircraft}.yml`);
52
+
53
+ let config;
54
+ try {
55
+ const file = await readFile(aircraftConfigPath, "utf8");
56
+ config = yaml.load(file);
57
+ } catch (error) {
58
+ if (error.code === "ENOENT") {
59
+ await copyFile(join(__dirname, "config.yml"), aircraftConfigPath);
60
+ } else {
61
+ throw error;
62
+ }
63
+
64
+ const file = await readFile(aircraftConfigPath, "utf8");
65
+ config = yaml.load(file);
66
+ }
67
+
68
+ return /** @type {CommandConfig} */ (config);
69
+ };
package/src/config.yml ADDED
@@ -0,0 +1,137 @@
1
+ commands:
2
+ # get barometric pressure at sealevel in inHg (dddd format)
3
+ - pattern: "^l$"
4
+ type: get
5
+ dataref: sim/weather/barometer_sealevel_inhg
6
+ transform:
7
+ - mult100
8
+ - round
9
+
10
+ # get barometric pressure at sealevel in hPa/mb (dddd format)
11
+ - pattern: "^q$"
12
+ type: get
13
+ dataref: sim/weather/barometer_sealevel_inhg
14
+ transform:
15
+ - mult33.8637526
16
+ - round
17
+
18
+ # set barometric pressure at sealevel in inHg (dddd format)
19
+ - pattern: "^l(\\d{4})$"
20
+ type: set
21
+ dataref: sim/cockpit/misc/barometer_setting
22
+ transform:
23
+ - mult0.01
24
+ - toFixed2
25
+
26
+ # set barometric pressure at sealevel in hPa/mb (dddd format)
27
+ - pattern: "^q(\\d{4})$"
28
+ type: set
29
+ dataref: sim/cockpit/misc/barometer_setting
30
+ transform:
31
+ - mult0.02953009998
32
+ - toFixed2
33
+
34
+ # set heading
35
+ - pattern: "^h(\\d{3})$"
36
+ type: set
37
+ dataref: sim/cockpit/autopilot/heading_mag
38
+
39
+ # set transponder code
40
+ - pattern: "^x(\\d{4})$"
41
+ type: set
42
+ dataref: sim/cockpit/radios/transponder_code
43
+
44
+ # set autopilot airspeed in KIAS
45
+ - pattern: "^s(\\d{3})$"
46
+ type: set
47
+ dataref: sim/cockpit/autopilot/airspeed
48
+
49
+ # set autopilot airspeed in MACH
50
+ - pattern: "^s(\\d{2})$"
51
+ type: set
52
+ dataref: sim/cockpit/autopilot/airspeed
53
+ transform:
54
+ - mult0.01
55
+ - toFixed2
56
+
57
+ # set autopilot vertical speed
58
+ - pattern: "^v(\\d\\d?\\d?\\d?)$"
59
+ type: set
60
+ dataref: sim/cockpit/autopilot/vertical_velocity
61
+
62
+ # set autopilot target altitude
63
+ - pattern: "^a((0|\\d\\d\\d\\d\\d?))$"
64
+ type: set
65
+ dataref: sim/cockpit/autopilot/altitude
66
+
67
+ # set autopilot target flight level
68
+ - pattern: "^a(\\d\\d\\d?)$"
69
+ type: set
70
+ dataref: sim/cockpit/autopilot/altitude
71
+ transform:
72
+ - mult10
73
+ - round
74
+
75
+ # set com1 frequency
76
+ - pattern: "^c1(\\d{6})$"
77
+ type: set
78
+ dataref: sim/cockpit2/radios/actuators/com1_frequency_hz_833
79
+ - pattern: "^c1(\\d{5})$"
80
+ type: set
81
+ dataref: sim/cockpit2/radios/actuators/com1_frequency_hz_833
82
+ transform:
83
+ - mult10
84
+ - round
85
+
86
+ # set com1 standby frequency
87
+ - pattern: "^cs1(\\d{6})$"
88
+ type: set
89
+ dataref: sim/cockpit2/radios/actuators/com1_standby_frequency_hz_833
90
+ - pattern: "^cs1(\\d{5})$"
91
+ type: set
92
+ dataref: sim/cockpit2/radios/actuators/com1_standby_frequency_hz_833
93
+ transform:
94
+ - mult10
95
+ - round
96
+
97
+ # set com2 frequency
98
+ - pattern: "^c2(\\d{6})$"
99
+ type: set
100
+ dataref: sim/cockpit2/radios/actuators/com2_frequency_hz_833
101
+ - pattern: "^c2(\\d{5})$"
102
+ type: set
103
+ dataref: sim/cockpit2/radios/actuators/com2_frequency_hz_833
104
+ transform:
105
+ - mult10
106
+ - round
107
+
108
+ # set com2 standby frequency
109
+ - pattern: "^cs2(\\d{6})$"
110
+ type: set
111
+ dataref: sim/cockpit2/radios/actuators/com2_standby_frequency_hz_833
112
+ - pattern: "^cs2(\\d{5})$"
113
+ type: set
114
+ dataref: sim/cockpit2/radios/actuators/com2_standby_frequency_hz_833
115
+ transform:
116
+ - mult10
117
+ - round
118
+
119
+ # set nav1 frequency
120
+ - pattern: "^n1(\\d{5})$"
121
+ type: set
122
+ dataref: sim/cockpit/radios/nav1_freq_hz
123
+
124
+ # set nav1 standby frequency
125
+ - pattern: "^ns1(\\d{5})$"
126
+ type: set
127
+ dataref: sim/cockpit/radios/nav1_stdby_freq_hz
128
+
129
+ # set nav2 frequency
130
+ - pattern: "^n2(\\d{5})$"
131
+ type: set
132
+ dataref: sim/cockpit/radios/nav2_freq_hz
133
+
134
+ # set nav2 standby frequency
135
+ - pattern: "^ns2(\\d{5})$"
136
+ type: set
137
+ dataref: sim/cockpit/radios/nav2_stdby_freq_hz
package/src/console.js ADDED
@@ -0,0 +1,12 @@
1
+ export const clearLine = (dy = -1) => {
2
+ process.stdout.moveCursor(0, dy);
3
+ process.stdout.clearLine(0);
4
+ process.stdout.cursorTo(0);
5
+ };
6
+
7
+ export const hideCursor = () => {
8
+ process.stdout.write("\x1B[?25l");
9
+ };
10
+ export const showCursor = () => {
11
+ process.stdout.write("\x1B[?25h");
12
+ };
package/src/error.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @param {unknown} error
3
+ */
4
+ export const isEconnRefused = (error) => {
5
+ return Boolean(
6
+ error instanceof Error &&
7
+ error.cause &&
8
+ typeof error.cause === "object" &&
9
+ "code" in error.cause &&
10
+ error.cause.code === "ECONNREFUSED",
11
+ );
12
+ };
package/src/history.js ADDED
@@ -0,0 +1,53 @@
1
+ import Conf from "conf";
2
+
3
+ class CliHistory {
4
+ /** @type {Conf} */
5
+ config;
6
+
7
+ /** @type {number} */
8
+ index;
9
+
10
+ constructor() {
11
+ this.config = new Conf({
12
+ projectName: "xp-command",
13
+ });
14
+
15
+ this.index = this.getHistory().length - 1;
16
+ }
17
+
18
+ /**
19
+ * @param {string} command
20
+ * @returns {void}
21
+ */
22
+ addCommand(command) {
23
+ const history = this.getHistory();
24
+
25
+ const filtered = history.filter((cmd) => cmd !== command);
26
+ filtered.push(command);
27
+ const trimmed = filtered.slice(-100);
28
+
29
+ this.config.set("history", trimmed);
30
+ this.index = trimmed.length - 1;
31
+ }
32
+
33
+ getHistory() {
34
+ return /** @type {Array<string>} */ (this.config.get("history") ?? []);
35
+ }
36
+
37
+ clear() {
38
+ this.config.set("history", []);
39
+ this.index = 0;
40
+ }
41
+
42
+ up() {
43
+ this.index = Math.max(0, this.index - 1);
44
+ return this.getHistory()[this.index + 1] ?? "";
45
+ }
46
+
47
+ down() {
48
+ this.index = Math.min(this.getHistory().length - 1, this.index + 1);
49
+ return this.getHistory()[this.index + 1] ?? "";
50
+ }
51
+ }
52
+
53
+ export default new CliHistory();
package/src/sleep.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @param {number} ms
3
+ */
4
+ export const sleep = async (ms) => {
5
+ await new Promise((resolve) => setTimeout(resolve, ms));
6
+ };