xp-command 1.3.2 β†’ 1.5.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
 
@@ -146,9 +142,11 @@ The first time you run xp-command in a new aircraft, it automatically creates a
146
142
  - **macOS/Linux:** `~/.xp-command/<Aircraft Name>.yml`
147
143
  - **Windows:** `%USERPROFILE%\.xp-command\<Aircraft Name>.yml`
148
144
 
145
+ You can run the command `config` to open the aircraft configuration file in your system’s default YAML editor.
146
+
149
147
  ### Customizing commands
150
148
 
151
- 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.
149
+ You can edit these YAML files to add aircraft-specific commands or modify existing ones.
152
150
 
153
151
  **Example**: Add a command to set heading with 10-degree increments:
154
152
 
@@ -164,11 +162,12 @@ You can edit these YAML files to add aircraft-specific commands or modify existi
164
162
  - `pattern`: Regular expression matching your command (`hdg18`)
165
163
  - `type`: Either `get` (read value and copy to clipboard) or `set` (write value)
166
164
  - `dataref`: X-Plane dataref path (find these in X-Plane's DataRef Editor)
167
- - `transform`: Optional value conversions (multiply, divide, round, etc.)
165
+ - `command`: X-Plane command path (find these in X-Plane's DataRef Editor)
166
+ - `transform`: Optional value conversions (`mult<number>` to multiply or divide, `round`, `toFixed<number>` etc.)
168
167
 
169
168
  **Finding datarefs**: Use the [DataRefTool plugin](https://datareftool.com) or check [X-Plane datarefs documentation](https://developer.x-plane.com/datarefs/).
170
169
 
171
- #### Array Datarefs
170
+ #### Array datarefs
172
171
 
173
172
  Many X-Plane datarefs are arrays (e.g., for multiple engines, generators, radios). You can access specific array elements using `[index]` notation:
174
173
 
@@ -192,11 +191,48 @@ Here is an example of a custom set command which sets multiple datarefs at once
192
191
  - sim/flightmodel2/gear/is_chocked[2]
193
192
  ```
194
193
 
195
- ## πŸ”„ Resetting Aircraft Profiles
194
+ #### Using commands
195
+
196
+ In addition to reading/writing datarefs, you can trigger X-Plane commands. Commands are actions (like button presses) rather than values.
197
+
198
+ **Command field**: Use `command` instead of or alongside `dataref` in `set` type operations.
199
+
200
+ **Duration syntax**: Append `[duration]` to specify how long the command stays active (in seconds):
201
+ - `command` or `command[0]`: Press and immediately release (default behavior)
202
+ - `command[5]`: Hold for 5 seconds then release
203
+ - Maximum duration: 10 seconds
204
+
205
+ **Examples**:
206
+
207
+ ```yaml
208
+ # Toggle anti-ice group on
209
+ - pattern: "^i(1)$"
210
+ type: set
211
+ command:
212
+ - FJS/Q4XP/Knobs/prop_heat_up
213
+ - FJS/Q4XP/Knobs/Airframe_deice_mode_up
214
+ dataref:
215
+ - FJS/Q4XP/Manips/TwoSwitch_Anim[8]
216
+ - FJS/Q4XP/Manips/TwoSwitch_Anim[9]
217
+ - FJS/Q4XP/Manips/TwoSwitch_Anim[10]
218
+ ```
219
+
220
+ 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:
221
+
222
+ ```
223
+ # Toggle transponder mode
224
+ - pattern: "^x$"
225
+ type: set
226
+ command: FJS/Q4XP/SoftKey/arcdu_1/skr4[2]
227
+ ```
228
+
229
+ **Finding commands**: Use the [DataRefTool plugin](https://datareftool.com) to browse available commands.
230
+
231
+ ## πŸ”„ Resetting aircraft profiles
196
232
 
197
233
  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.
198
234
 
199
- ### Profile Location
235
+ ### Profile location
200
236
 
201
237
  Aircraft profiles are stored in:
202
238
 
package/bin/index.js CHANGED
@@ -11,16 +11,28 @@ import {
11
11
  import chalk from "chalk";
12
12
  import { program } from "commander";
13
13
  import ora from "ora";
14
+ import { homedir } from "os";
15
+ import { join } from "path";
14
16
 
15
- import { getDatarefValues, initAPI, setDatarefValues } from "../src/api.js";
17
+ import packageJson from "../package.json" with { type: "json" };
18
+ import {
19
+ activateCommands,
20
+ getDatarefValues,
21
+ initAPI,
22
+ setDatarefValues,
23
+ } from "../src/api.js";
16
24
  import { copyToClipboard } from "../src/clipboard.js";
17
- import { getConfig } from "../src/config.js";
25
+ import { editConfig, getConfig } from "../src/config.js";
18
26
  import { clearLine, hideCursor, showCursor } from "../src/console.js";
19
27
  import { isAPIError, isEconnRefused } from "../src/error.js";
20
28
  import history from "../src/history.js";
29
+ import { Logger } from "../src/logger.js";
21
30
  import { sleep } from "../src/sleep.js";
22
31
 
23
- import packageJson from '../package.json' with { type: 'json' };
32
+ const osHomedir = homedir();
33
+ const logger = new Logger(join(osHomedir, ".xp-command", "log.txt"), {
34
+ clearOnStart: true,
35
+ });
24
36
 
25
37
  const PREFIX = "πŸ›© ";
26
38
 
@@ -28,7 +40,7 @@ program
28
40
  .version(packageJson.version)
29
41
  .description(`${PREFIX} ${packageJson.name}\n${packageJson.description}`)
30
42
  .option("-p, --port <number>", "server port number")
31
- .helpOption('-h, --help', 'display this help text');
43
+ .helpOption("-h, --help", "display this help text");
32
44
 
33
45
  /**
34
46
  * @param {string} command
@@ -42,6 +54,7 @@ const processCommand = async (command) => {
42
54
  try {
43
55
  config = await getConfig();
44
56
  } catch (error) {
57
+ logger.error(error);
45
58
  if (isEconnRefused(error) || isAPIError(error)) {
46
59
  spinner.fail(chalk.red(`${PREFIX} No connection - in aircraft?`));
47
60
  hideCursor();
@@ -51,9 +64,19 @@ const processCommand = async (command) => {
51
64
  }
52
65
  }
53
66
 
67
+ if (command?.toLowerCase() === "config") {
68
+ await editConfig();
69
+ spinner.succeed(chalk.green(`${PREFIX} config`));
70
+ hideCursor();
71
+ await sleep(1500);
72
+ clearLine();
73
+ return;
74
+ }
75
+
54
76
  /**
55
- * @param {number|string} value
77
+ * @param {number|string|Array<number|string>} value
56
78
  * @param {import('../src/config.js').Transform} transform
79
+ * @return {number|string|Array<number|string>}
57
80
  */
58
81
  const getTransformedValue = (value, transform) => {
59
82
  if (Array.isArray(value)) return value.slice();
@@ -79,20 +102,48 @@ const processCommand = async (command) => {
79
102
 
80
103
  /** @type {Array<[RegExp, (regExpResult: Array<string> | null) => Promise<void>]>} */
81
104
  const matches = config.commands.map((c) => {
105
+ if (!c.type) {
106
+ return [
107
+ new RegExp(c.pattern),
108
+ async () => {
109
+ spinner.fail(
110
+ chalk.red(`${PREFIX} Missing type (must be get or set)!`),
111
+ );
112
+ hideCursor();
113
+ await sleep(1500);
114
+ clearLine();
115
+ return;
116
+ },
117
+ ];
118
+ }
119
+
82
120
  switch (c.type) {
83
121
  case "get":
84
122
  return [
85
123
  new RegExp(c.pattern),
86
124
  async () => {
125
+ if (!c.dataref) {
126
+ spinner.fail(chalk.red(`${PREFIX} Missing dataref!`));
127
+ hideCursor();
128
+ await sleep(1500);
129
+ clearLine();
130
+ return;
131
+ }
132
+
133
+ /** @type {number|string|Array<number|string>}*/
87
134
  let value = await getDatarefValues(c.dataref);
88
135
  c.transform?.forEach((t) => {
89
136
  value = getTransformedValue(value, t);
90
137
  });
91
- const asString = String(value)
138
+ const asString = String(value);
92
139
  await copyToClipboard(asString);
93
- const lines = asString.split('\n')
94
- const firstLine = lines[0]
95
- spinner.succeed(chalk.green(`${PREFIX} ${lines.length > 1 ? firstLine + '...' : firstLine}`));
140
+ const lines = asString.split("\n");
141
+ const firstLine = lines[0];
142
+ spinner.succeed(
143
+ chalk.green(
144
+ `${PREFIX} ${lines.length > 1 ? firstLine + "..." : firstLine}`,
145
+ ),
146
+ );
96
147
  hideCursor();
97
148
  await sleep(1500);
98
149
  clearLine();
@@ -102,16 +153,30 @@ const processCommand = async (command) => {
102
153
  return [
103
154
  new RegExp(c.pattern),
104
155
  async (regExpResult) => {
105
- let value = String(regExpResult[1]);
106
-
107
- if (isNaN(Number(value))) {
108
- const base64 = Buffer.from(value, 'utf-8').toString('base64');
109
- await setDatarefValues(c.dataref, base64);
110
- } else {
111
- c.transform?.forEach((t) => {
112
- value = String(getTransformedValue(value, t));
113
- });
114
- await setDatarefValues(c.dataref, Number(value));
156
+ if (!c.command && !c.dataref) {
157
+ spinner.fail(
158
+ chalk.red(`${PREFIX} Neither dataref nor command provided!`),
159
+ );
160
+ hideCursor();
161
+ await sleep(1500);
162
+ clearLine();
163
+ return;
164
+ }
165
+
166
+ if (c.command) {
167
+ await activateCommands(c.command);
168
+ }
169
+ if (c.dataref) {
170
+ let value = String(regExpResult[1]);
171
+ if (isNaN(Number(value))) {
172
+ const base64 = Buffer.from(value, "utf-8").toString("base64");
173
+ await setDatarefValues(c.dataref, base64);
174
+ } else {
175
+ c.transform?.forEach((t) => {
176
+ value = String(getTransformedValue(value, t));
177
+ });
178
+ await setDatarefValues(c.dataref, Number(value));
179
+ }
115
180
  }
116
181
  spinner.succeed(chalk.green(`${PREFIX} ${command}`));
117
182
  hideCursor();
@@ -133,6 +198,7 @@ const processCommand = async (command) => {
133
198
 
134
199
  showCursor();
135
200
  } catch (error) {
201
+ logger.error(error);
136
202
  spinner.fail();
137
203
  hideCursor();
138
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-command",
3
- "version": "1.3.2",
3
+ "version": "1.5.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,22 @@ const parseDataref = (datarefString) => {
37
37
  return [match[1], match[2] ? parseInt(match[2]) : null];
38
38
  };
39
39
 
40
+ /**
41
+ * @param {string} commandString
42
+ * @return {[string, number|null]}
43
+ */
44
+ const parseCommand = (commandString) => {
45
+ // Regex pattern: captures base name and optional duration value
46
+ const pattern = /^(.+?)(?:\[(\d+)\])?$/;
47
+ const match = commandString.match(pattern);
48
+
49
+ if (!match) {
50
+ throw new CustomError("invalid_command");
51
+ }
52
+
53
+ return [match[1], match[2] ? parseInt(match[2]) : null];
54
+ };
55
+
40
56
  /**
41
57
  * @param {string} datarefName
42
58
  * @return { Promise<number | null> }
@@ -65,6 +81,54 @@ export const initAPI = (options) => {
65
81
  port = options.port;
66
82
  };
67
83
 
84
+ /**
85
+ * @param {string} commandName
86
+ * @return {Promise<number|string>}
87
+ */
88
+ export const getCommandId = async (commandName) => {
89
+ const url = new URL(`http://localhost:${port}/api/v2/commands`);
90
+ url.searchParams.set("filter[name]", commandName);
91
+ url.searchParams.set("fields", "id");
92
+
93
+ const response = await fetch(url);
94
+ const json = /** @type { {data:[{id: number }]} | APIError } */ (
95
+ await response.json()
96
+ );
97
+
98
+ if ("error_code" in json) {
99
+ throw new CustomError(json.error_code);
100
+ }
101
+
102
+ return json.data[0]?.id ?? null;
103
+ };
104
+
105
+ /**
106
+ * @param {string} commandNameWithOptionalDuration
107
+ */
108
+ export const activateCommand = async (commandNameWithOptionalDuration) => {
109
+ const [commandName, duration] = parseCommand(commandNameWithOptionalDuration);
110
+
111
+ const commandId = await getCommandId(commandName);
112
+
113
+ const url = new URL(
114
+ `http://localhost:${port}/api/v2/command/${commandId}/activate`,
115
+ );
116
+
117
+ const response = await fetch(url, {
118
+ method: "POST",
119
+ body: JSON.stringify({ duration: duration || 0 }),
120
+ });
121
+ const json = /** @type { {data: number|string } | APIError } */ (
122
+ await response.json()
123
+ );
124
+
125
+ if (json && "error_code" in json) {
126
+ throw new CustomError(json.error_code);
127
+ }
128
+
129
+ return json;
130
+ };
131
+
68
132
  /**
69
133
  * @param {string} datarefNameWithOptionalIndex
70
134
  * @return {Promise<number|string>}
@@ -112,13 +176,18 @@ export const getDatarefValue = async (datarefNameWithOptionalIndex) => {
112
176
  */
113
177
  export const getDatarefValues = async (datarefNamesWithOptionalIndex) => {
114
178
  if (Array.isArray(datarefNamesWithOptionalIndex)) {
115
- return Promise.all(datarefNamesWithOptionalIndex.map(dataref => getDatarefValue(dataref))).then(results => {
116
- return results.map(v => String(v)).join('\n').trim()
117
- })
179
+ return Promise.all(
180
+ datarefNamesWithOptionalIndex.map((dataref) => getDatarefValue(dataref)),
181
+ ).then((results) => {
182
+ return results
183
+ .map((v) => String(v))
184
+ .join("\n")
185
+ .trim();
186
+ });
118
187
  }
119
-
120
- return getDatarefValue(datarefNamesWithOptionalIndex)
121
- }
188
+
189
+ return getDatarefValue(datarefNamesWithOptionalIndex);
190
+ };
122
191
 
123
192
  /**
124
193
  * @param {string} datarefNameWithOptionalIndex
@@ -154,14 +223,35 @@ export const setDatarefValue = async (datarefNameWithOptionalIndex, value) => {
154
223
  /**
155
224
  * @param {string|Array<string>} datarefNamesWithOptionalIndex
156
225
  * @param {number|string} value
157
- * @return {Promise<number|string>}
226
+ * @return {Promise<void>}
158
227
  */
159
- export const setDatarefValues = async (datarefNamesWithOptionalIndex, value) => {
228
+ export const setDatarefValues = async (
229
+ datarefNamesWithOptionalIndex,
230
+ value,
231
+ ) => {
160
232
  if (Array.isArray(datarefNamesWithOptionalIndex)) {
161
- return Promise.all(datarefNamesWithOptionalIndex.map(dataref => setDatarefValue(dataref, value))).then(results => {
162
- return results[0]
163
- })
233
+ await Promise.all(
234
+ datarefNamesWithOptionalIndex.map((dataref) =>
235
+ setDatarefValue(dataref, value),
236
+ ),
237
+ );
238
+ } else {
239
+ await setDatarefValue(datarefNamesWithOptionalIndex, value);
164
240
  }
165
-
166
- return setDatarefValue(datarefNamesWithOptionalIndex, value)
167
- }
241
+ };
242
+
243
+ /**
244
+ * @param {string|Array<string>} commandNamesWithOptionalDuration
245
+ * @return {Promise<void>}
246
+ */
247
+ export const activateCommands = async (commandNamesWithOptionalDuration) => {
248
+ if (Array.isArray(commandNamesWithOptionalDuration)) {
249
+ await Promise.all(
250
+ commandNamesWithOptionalDuration.map((command) =>
251
+ activateCommand(command),
252
+ ),
253
+ );
254
+ } else {
255
+ await activateCommand(commandNamesWithOptionalDuration);
256
+ }
257
+ };
package/src/config.js CHANGED
@@ -1,4 +1,7 @@
1
+ import { exec as rawExec } from "node:child_process";
1
2
  import { copyFile, mkdir, readFile } from "node:fs/promises";
3
+ import { platform } from "node:process";
4
+ import { promisify } from "node:util";
2
5
 
3
6
  import yaml from "js-yaml";
4
7
  import { homedir } from "os";
@@ -8,6 +11,8 @@ import { fileURLToPath } from "url";
8
11
 
9
12
  import { getDatarefValue } from "./api.js";
10
13
 
14
+ const exec = promisify(rawExec);
15
+
11
16
  const __filename = fileURLToPath(import.meta.url);
12
17
  const __dirname = dirname(__filename);
13
18
 
@@ -21,6 +26,7 @@ const __dirname = dirname(__filename);
21
26
  * @property {RegExp} pattern - Regular expression pattern to match commands
22
27
  * @property {'get' | 'set'} type - Operation type
23
28
  * @property {string|Array<string>} dataref - X-Plane dataref path(s)
29
+ * @property {string|Array<string>} command - X-Plane command path(s)
24
30
  * @property {Transform[]} transform - Array of transformation operations to apply
25
31
  */
26
32
 
@@ -35,6 +41,15 @@ const __dirname = dirname(__filename);
35
41
 
36
42
  const osHomedir = homedir();
37
43
 
44
+ const getAircraftConfigPath = async () => {
45
+ const aircraft = /** @type {string} */ (
46
+ await getDatarefValue("sim/aircraft/view/acf_ui_name")
47
+ )
48
+ .replaceAll(/[./\\]/g, " ")
49
+ .replace(/"/g, "");
50
+ return join(osHomedir, ".xp-command", `${aircraft}.yml`);
51
+ };
52
+
38
53
  /**
39
54
  * @return {Promise<CommandConfig>}
40
55
  */
@@ -43,12 +58,7 @@ export const getConfig = async () => {
43
58
  recursive: true,
44
59
  });
45
60
 
46
- /** @type {string} */
47
- let aircraft;
48
- aircraft = /** @type {string} */ (
49
- await getDatarefValue("sim/aircraft/view/acf_ui_name")
50
- ).replaceAll(/[./\\]/g, ' ');
51
- const aircraftConfigPath = join(osHomedir, ".xp-command", `${aircraft}.yml`);
61
+ const aircraftConfigPath = await getAircraftConfigPath();
52
62
 
53
63
  let config;
54
64
  try {
@@ -67,3 +77,20 @@ export const getConfig = async () => {
67
77
 
68
78
  return /** @type {CommandConfig} */ (config);
69
79
  };
80
+
81
+ export const editConfig = async () => {
82
+ const aircraftConfigPath = await getAircraftConfigPath();
83
+
84
+ const command =
85
+ platform === "win32"
86
+ ? `start "" "${aircraftConfigPath}"`
87
+ : platform === "darwin"
88
+ ? `open "${aircraftConfigPath}"`
89
+ : `xdg-open "${aircraftConfigPath}"`;
90
+
91
+ const { stderr } = await exec(command);
92
+
93
+ if (stderr) {
94
+ throw new Error(stderr);
95
+ }
96
+ };
package/src/logger.js ADDED
@@ -0,0 +1,80 @@
1
+ // logger.js
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * @typedef {"debug" | "info" | "warn" | "error"} LogLevel
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} LoggerOptions
11
+ * @property {boolean} [clearOnStart]
12
+ */
13
+
14
+ export class Logger {
15
+ /**
16
+ * @param {string} filePath
17
+ * @param {LoggerOptions} [options]
18
+ */
19
+ constructor(filePath, options = {}) {
20
+ /** @private */
21
+ this.filePath = filePath;
22
+
23
+ /** @private */
24
+ this.options = options;
25
+
26
+ const dir = path.dirname(filePath);
27
+ if (!fs.existsSync(dir)) {
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ }
30
+
31
+ if (options.clearOnStart && fs.existsSync(filePath)) {
32
+ // clear file on app start
33
+ fs.truncateSync(filePath, 0);
34
+ }
35
+
36
+ /**
37
+ * @private
38
+ * @type {fs.WriteStream}
39
+ */
40
+ this.stream = fs.createWriteStream(filePath, { flags: "a" });
41
+ }
42
+
43
+ /**
44
+ * @private
45
+ * @param {LogLevel} level
46
+ * @param {string} msg
47
+ */
48
+ write(level, msg) {
49
+ const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${msg}\n`;
50
+ this.stream.write(line);
51
+ }
52
+
53
+ /**
54
+ * @param {string} msg
55
+ */
56
+ debug(msg) {
57
+ this.write("debug", msg);
58
+ }
59
+
60
+ /**
61
+ * @param {string} msg
62
+ */
63
+ info(msg) {
64
+ this.write("info", msg);
65
+ }
66
+
67
+ /**
68
+ * @param {string} msg
69
+ */
70
+ warn(msg) {
71
+ this.write("warn", msg);
72
+ }
73
+
74
+ /**
75
+ * @param {string} msg
76
+ */
77
+ error(msg) {
78
+ this.write("error", msg);
79
+ }
80
+ }