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 +58 -11
- package/bin/index.js +94 -26
- package/package.json +1 -1
- package/src/api.js +100 -0
- package/src/config.js +1 -0
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
|
|
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
|
|
92
|
+
## 📋 Pre-configured triggers
|
|
87
93
|
|
|
88
|
-
All pre-configured
|
|
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
|
-
- `
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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<
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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(
|
|
253
|
-
|
|
310
|
+
program.action(
|
|
311
|
+
async (
|
|
312
|
+
/** @type {{ port: number | undefined, run: string | undefined }} */ options,
|
|
313
|
+
) => {
|
|
314
|
+
initAPI({ port: options.port ?? 8086 });
|
|
254
315
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
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
|
|