xp-command 1.4.0 → 1.6.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/README.md CHANGED
@@ -46,11 +46,7 @@ This installs xp-command globally so you can run it from anywhere.
46
46
 
47
47
  ## 🛠 X-Plane setup
48
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`)
49
+ You do not need to do anything inside X-Plane for xp-command to work. X-Plane 12 runs a local Web API automatically on `http://localhost:8086` as long as it is not started with the `--no_web_server` command-line option and the port is free. Just make sure X-Plane is running on the same machine as xp-command and that nothing (firewall or another process) is blocking or using port `8086`.
54
50
 
55
51
  ---
56
52
 
@@ -77,15 +73,25 @@ If you changed the Web API port from 8086 to another port, you'll need to start
77
73
  xp-command --port 8090
78
74
  ```
79
75
 
76
+ ### One-shot mode
77
+
78
+ Run a single command and exit immediately (useful for scripts or keybindings):
79
+
80
+ ```bash
81
+ xp-command --run x7000
82
+ ```
83
+
84
+ Exits with code `0` on success, `1` on failure.
85
+
80
86
  ### Exiting
81
87
 
82
88
  Type `exit` or press `Ctrl+C`
83
89
 
84
90
  ---
85
91
 
86
- ## 📋 Pre-configured commands
92
+ ## 📋 Pre-configured triggers
87
93
 
88
- 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).
94
+ All pre-configured triggers work well with most aircraft I currently fly, but you may want to adjust them for **your** aircraft (see _Aircraft-specific profiles_ section below).
89
95
 
90
96
  ### Barometric pressure
91
97
 
@@ -166,11 +172,12 @@ You can edit these YAML files to add aircraft-specific commands or modify existi
166
172
  - `pattern`: Regular expression matching your command (`hdg18`)
167
173
  - `type`: Either `get` (read value and copy to clipboard) or `set` (write value)
168
174
  - `dataref`: X-Plane dataref path (find these in X-Plane's DataRef Editor)
169
- - `transform`: Optional value conversions (multiply, divide, round, etc.)
175
+ - `command`: X-Plane command path (find these in X-Plane's DataRef Editor)
176
+ - `transform`: Optional value conversions (`mult<number>` to multiply or divide, `round`, `toFixed<number>` etc.)
170
177
 
171
178
  **Finding datarefs**: Use the [DataRefTool plugin](https://datareftool.com) or check [X-Plane datarefs documentation](https://developer.x-plane.com/datarefs/).
172
179
 
173
- #### Array Datarefs
180
+ #### Array datarefs
174
181
 
175
182
  Many X-Plane datarefs are arrays (e.g., for multiple engines, generators, radios). You can access specific array elements using `[index]` notation:
176
183
 
@@ -194,11 +201,51 @@ Here is an example of a custom set command which sets multiple datarefs at once
194
201
  - sim/flightmodel2/gear/is_chocked[2]
195
202
  ```
196
203
 
197
- ## 🔄 Resetting Aircraft Profiles
204
+ #### Using commands
205
+
206
+ In addition to reading/writing datarefs, you can trigger X-Plane commands. Commands are actions (like button presses) rather than values.
207
+
208
+ **Command field**: Use `command` instead of or alongside `dataref` in `set` type operations.
209
+
210
+ **Duration syntax**: Append `[duration]` to specify how long the command stays active (in seconds):
211
+ - `command` or `command[0]`: Press and immediately release (default behavior)
212
+ - `command[5]`: Hold for 5 seconds then release
213
+
214
+ **Repeat syntax**: Append `[repeatCount]` to specify how often the command should be repeated:
215
+ - `command[0][3]`: Press and immediately release three times
216
+ - `command[0][$]`: Press and immediately release as often as defined by captured value
217
+
218
+ **Examples**:
219
+
220
+ ```yaml
221
+ # Toggle anti-ice group on
222
+ - pattern: "^i(1)$"
223
+ type: set
224
+ command:
225
+ - FJS/Q4XP/Knobs/prop_heat_up
226
+ - FJS/Q4XP/Knobs/Airframe_deice_mode_up
227
+ dataref:
228
+ - FJS/Q4XP/Manips/TwoSwitch_Anim[8]
229
+ - FJS/Q4XP/Manips/TwoSwitch_Anim[9]
230
+ - FJS/Q4XP/Manips/TwoSwitch_Anim[10]
231
+ ```
232
+
233
+ Here is an example demonstrating the usage of the duration option: In the Dash 8 (Q4XP), in order to change the transponder mode, you must press a tiny button for at least two seconds! Not anymore:
234
+
235
+ ```
236
+ # Toggle transponder mode
237
+ - pattern: "^x$"
238
+ type: set
239
+ command: FJS/Q4XP/SoftKey/arcdu_1/skr4[2]
240
+ ```
241
+
242
+ **Finding commands**: Use the [DataRefTool plugin](https://datareftool.com) to browse available commands.
243
+
244
+ ## 🔄 Resetting aircraft profiles
198
245
 
199
246
  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.
200
247
 
201
- ### Profile Location
248
+ ### Profile location
202
249
 
203
250
  Aircraft profiles are stored in:
204
251
 
package/bin/index.js CHANGED
@@ -15,7 +15,12 @@ import { homedir } from "os";
15
15
  import { join } from "path";
16
16
 
17
17
  import packageJson from "../package.json" with { type: "json" };
18
- import { getDatarefValues, initAPI, setDatarefValues } from "../src/api.js";
18
+ import {
19
+ activateCommands,
20
+ getDatarefValues,
21
+ initAPI,
22
+ setDatarefValues,
23
+ } from "../src/api.js";
19
24
  import { copyToClipboard } from "../src/clipboard.js";
20
25
  import { editConfig, getConfig } from "../src/config.js";
21
26
  import { clearLine, hideCursor, showCursor } from "../src/console.js";
@@ -35,10 +40,12 @@ program
35
40
  .version(packageJson.version)
36
41
  .description(`${PREFIX} ${packageJson.name}\n${packageJson.description}`)
37
42
  .option("-p, --port <number>", "server port number")
43
+ .option("-r, --run <command>", "run a single command and exit")
38
44
  .helpOption("-h, --help", "display this help text");
39
45
 
40
46
  /**
41
47
  * @param {string} command
48
+ * @returns {Promise<boolean>} true if command succeeded, false otherwise
42
49
  */
43
50
  const processCommand = async (command) => {
44
51
  const spinner = ora(`${PREFIX} ${chalk.cyan(command ?? "")}`).start();
@@ -55,7 +62,7 @@ const processCommand = async (command) => {
55
62
  hideCursor();
56
63
  await sleep(1500);
57
64
  showCursor();
58
- return;
65
+ return false;
59
66
  }
60
67
  }
61
68
 
@@ -65,7 +72,7 @@ const processCommand = async (command) => {
65
72
  hideCursor();
66
73
  await sleep(1500);
67
74
  clearLine();
68
- return;
75
+ return true;
69
76
  }
70
77
 
71
78
  /**
@@ -95,13 +102,36 @@ const processCommand = async (command) => {
95
102
  return value;
96
103
  };
97
104
 
98
- /** @type {Array<[RegExp, (regExpResult: Array<string> | null) => Promise<void>]>} */
105
+ /** @type {Array<[RegExp, (regExpResult: Array<string> | null) => Promise<boolean>]>} */
99
106
  const matches = config.commands.map((c) => {
107
+ if (!c.type) {
108
+ return [
109
+ new RegExp(c.pattern),
110
+ async () => {
111
+ spinner.fail(
112
+ chalk.red(`${PREFIX} Missing type (must be get or set)!`),
113
+ );
114
+ hideCursor();
115
+ await sleep(1500);
116
+ clearLine();
117
+ return false;
118
+ },
119
+ ];
120
+ }
121
+
100
122
  switch (c.type) {
101
123
  case "get":
102
124
  return [
103
125
  new RegExp(c.pattern),
104
126
  async () => {
127
+ if (!c.dataref) {
128
+ spinner.fail(chalk.red(`${PREFIX} Missing dataref!`));
129
+ hideCursor();
130
+ await sleep(1500);
131
+ clearLine();
132
+ return false;
133
+ }
134
+
105
135
  /** @type {number|string|Array<number|string>}*/
106
136
  let value = await getDatarefValues(c.dataref);
107
137
  c.transform?.forEach((t) => {
@@ -119,27 +149,51 @@ const processCommand = async (command) => {
119
149
  hideCursor();
120
150
  await sleep(1500);
121
151
  clearLine();
152
+ return true;
122
153
  },
123
154
  ];
124
155
  case "set":
125
156
  return [
126
157
  new RegExp(c.pattern),
127
158
  async (regExpResult) => {
128
- let value = String(regExpResult[1]);
129
-
130
- if (isNaN(Number(value))) {
131
- const base64 = Buffer.from(value, "utf-8").toString("base64");
132
- await setDatarefValues(c.dataref, base64);
133
- } else {
134
- c.transform?.forEach((t) => {
135
- value = String(getTransformedValue(value, t));
136
- });
137
- await setDatarefValues(c.dataref, Number(value));
159
+ if (!c.command && !c.dataref) {
160
+ spinner.fail(
161
+ chalk.red(`${PREFIX} Neither dataref nor command provided!`),
162
+ );
163
+ hideCursor();
164
+ await sleep(1500);
165
+ clearLine();
166
+ return false;
167
+ }
168
+
169
+ if (c.command) {
170
+ let value = String(regExpResult[1]);
171
+ if (isNaN(Number(value))) {
172
+ await activateCommands(c.command);
173
+ } else {
174
+ c.transform?.forEach((t) => {
175
+ value = String(getTransformedValue(value, t));
176
+ });
177
+ await activateCommands(c.command, Number(value));
178
+ }
179
+ }
180
+ if (c.dataref) {
181
+ let value = String(regExpResult[1]);
182
+ if (isNaN(Number(value))) {
183
+ const base64 = Buffer.from(value, "utf-8").toString("base64");
184
+ await setDatarefValues(c.dataref, base64);
185
+ } else {
186
+ c.transform?.forEach((t) => {
187
+ value = String(getTransformedValue(value, t));
188
+ });
189
+ await setDatarefValues(c.dataref, Number(value));
190
+ }
138
191
  }
139
192
  spinner.succeed(chalk.green(`${PREFIX} ${command}`));
140
193
  hideCursor();
141
194
  await sleep(1500);
142
195
  clearLine();
196
+ return true;
143
197
  },
144
198
  ];
145
199
  }
@@ -149,12 +203,15 @@ const processCommand = async (command) => {
149
203
  const regexpResult = regexp.exec(command);
150
204
  if (regexpResult !== null) {
151
205
  try {
152
- await cb(regexpResult);
206
+ const success = await cb(regexpResult);
153
207
 
154
- spinner.succeed();
208
+ if (success) {
209
+ spinner.succeed();
210
+ }
155
211
  hideCursor();
156
212
 
157
213
  showCursor();
214
+ return success;
158
215
  } catch (error) {
159
216
  logger.error(error);
160
217
  spinner.fail();
@@ -178,8 +235,8 @@ const processCommand = async (command) => {
178
235
 
179
236
  await sleep(500);
180
237
  showCursor();
238
+ return false;
181
239
  }
182
- return;
183
240
  }
184
241
  }
185
242
 
@@ -188,6 +245,7 @@ const processCommand = async (command) => {
188
245
 
189
246
  await sleep(1500);
190
247
  showCursor();
248
+ return false;
191
249
  };
192
250
 
193
251
  const sayHello = () => {
@@ -249,17 +307,27 @@ const askForCommand = async () => {
249
307
  await askForCommand();
250
308
  };
251
309
 
252
- program.action(async (/** @type {{ port: number | undefined }} */ options) => {
253
- initAPI({ port: options.port ?? 8086 });
310
+ program.action(
311
+ async (
312
+ /** @type {{ port: number | undefined, run: string | undefined }} */ options,
313
+ ) => {
314
+ initAPI({ port: options.port ?? 8086 });
254
315
 
255
- hideCursor();
256
- clearLine();
257
- sayHello();
258
- await sleep(1000);
259
- showCursor();
316
+ if (options.run) {
317
+ history.addCommand(options.run);
318
+ const success = await processCommand(options.run);
319
+ process.exit(success ? 0 : 1);
320
+ }
260
321
 
261
- await askForCommand();
262
- });
322
+ hideCursor();
323
+ clearLine();
324
+ sayHello();
325
+ await sleep(1000);
326
+ showCursor();
327
+
328
+ await askForCommand();
329
+ },
330
+ );
263
331
 
264
332
  program.parse(process.argv);
265
333
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-command",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Quick cockpit commands for X-Plane 12 - set your radios, altimeter, autopilot, and more from the terminal while flying.",
5
5
  "keywords": [
6
6
  "xplane",
package/src/api.js CHANGED
@@ -37,6 +37,31 @@ const parseDataref = (datarefString) => {
37
37
  return [match[1], match[2] ? parseInt(match[2]) : null];
38
38
  };
39
39
 
40
+ /**
41
+ * @param {string} commandString
42
+ * @param {number|string} [value]
43
+ * @return {[string, number|null, number|null]}
44
+ */
45
+ const parseCommand = (commandString, value) => {
46
+ // Regex pattern: captures base name and optional duration and repeat count values
47
+ const pattern = /^(.+?)(?:\[(\d+)\])?(?:\[(\d+|\$)\])?$/;
48
+ const match = commandString.match(pattern);
49
+
50
+ if (!match) {
51
+ throw new CustomError("invalid_command");
52
+ }
53
+
54
+ let repeatCount = match[3] ? parseInt(match[3]) : null;
55
+ if (isNaN(repeatCount)) {
56
+ repeatCount = typeof value === "number" ? value : parseInt(value);
57
+ }
58
+ if (isNaN(repeatCount)) {
59
+ repeatCount = 1;
60
+ }
61
+
62
+ return [match[1], match[2] ? parseInt(match[2]) : null, repeatCount];
63
+ };
64
+
40
65
  /**
41
66
  * @param {string} datarefName
42
67
  * @return { Promise<number | null> }
@@ -65,6 +90,61 @@ export const initAPI = (options) => {
65
90
  port = options.port;
66
91
  };
67
92
 
93
+ /**
94
+ * @param {string} commandName
95
+ * @return {Promise<number|string>}
96
+ */
97
+ export const getCommandId = async (commandName) => {
98
+ const url = new URL(`http://localhost:${port}/api/v2/commands`);
99
+ url.searchParams.set("filter[name]", commandName);
100
+ url.searchParams.set("fields", "id");
101
+
102
+ const response = await fetch(url);
103
+ const json = /** @type { {data:[{id: number }]} | APIError } */ (
104
+ await response.json()
105
+ );
106
+
107
+ if ("error_code" in json) {
108
+ throw new CustomError(json.error_code);
109
+ }
110
+
111
+ return json.data[0]?.id ?? null;
112
+ };
113
+
114
+ /**
115
+ * @param {string} commandNameWithOptionalDuration
116
+ * @param {number|string} [value]
117
+ */
118
+ export const activateCommand = async (
119
+ commandNameWithOptionalDuration,
120
+ value,
121
+ ) => {
122
+ const [commandName, duration, repeatCount] = parseCommand(
123
+ commandNameWithOptionalDuration,
124
+ value,
125
+ );
126
+
127
+ const commandId = await getCommandId(commandName);
128
+
129
+ const url = new URL(
130
+ `http://localhost:${port}/api/v2/command/${commandId}/activate`,
131
+ );
132
+
133
+ for (let i = Math.max(1, repeatCount ?? 1); i--; ) {
134
+ const response = await fetch(url, {
135
+ method: "POST",
136
+ body: JSON.stringify({ duration: duration || 0 }),
137
+ });
138
+ const json = /** @type { {data: number|string } | APIError } */ (
139
+ await response.json()
140
+ );
141
+
142
+ if (json && "error_code" in json) {
143
+ throw new CustomError(json.error_code);
144
+ }
145
+ }
146
+ };
147
+
68
148
  /**
69
149
  * @param {string} datarefNameWithOptionalIndex
70
150
  * @return {Promise<number|string>}
@@ -175,3 +255,23 @@ export const setDatarefValues = async (
175
255
  await setDatarefValue(datarefNamesWithOptionalIndex, value);
176
256
  }
177
257
  };
258
+
259
+ /**
260
+ * @param {string|Array<string>} commandNamesWithOptionalDuration
261
+ * @param {number|string} [value]
262
+ * @return {Promise<void>}
263
+ */
264
+ export const activateCommands = async (
265
+ commandNamesWithOptionalDuration,
266
+ value,
267
+ ) => {
268
+ if (Array.isArray(commandNamesWithOptionalDuration)) {
269
+ await Promise.all(
270
+ commandNamesWithOptionalDuration.map((command) =>
271
+ activateCommand(command, value),
272
+ ),
273
+ );
274
+ } else {
275
+ await activateCommand(commandNamesWithOptionalDuration, value);
276
+ }
277
+ };
package/src/config.js CHANGED
@@ -26,6 +26,7 @@ const __dirname = dirname(__filename);
26
26
  * @property {RegExp} pattern - Regular expression pattern to match commands
27
27
  * @property {'get' | 'set'} type - Operation type
28
28
  * @property {string|Array<string>} dataref - X-Plane dataref path(s)
29
+ * @property {string|Array<string>} command - X-Plane command path(s)
29
30
  * @property {Transform[]} transform - Array of transformation operations to apply
30
31
  */
31
32