artnet2usb-cli 0.2.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 +21 -0
- package/README.de.md +149 -0
- package/README.es.md +149 -0
- package/README.fr.md +149 -0
- package/README.it.md +149 -0
- package/README.ja.md +149 -0
- package/README.md +149 -0
- package/README.pt-BR.md +149 -0
- package/README.ru.md +149 -0
- package/README.tr.md +149 -0
- package/README.zh-Hans.md +149 -0
- package/dist/main/main.js +518 -0
- package/dist-cli/app/artnet.js +158 -0
- package/dist-cli/app/dmxOutput.js +203 -0
- package/dist-cli/cli/autoRoute.js +43 -0
- package/dist-cli/cli/bridge.js +46 -0
- package/dist-cli/cli/configDir.js +49 -0
- package/dist-cli/cli/doctor.js +42 -0
- package/dist-cli/cli/index.js +422 -0
- package/dist-cli/cli/routeCommands.js +80 -0
- package/dist-cli/cli/serviceInstall.js +181 -0
- package/dist-cli/shared/portableConfig.js +52 -0
- package/dist-cli/shared/store.js +76 -0
- package/dist-cli/shared/types.js +3 -0
- package/package.json +122 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const store_1 = require("../shared/store");
|
|
38
|
+
const configDir_1 = require("./configDir");
|
|
39
|
+
const bridge_1 = require("./bridge");
|
|
40
|
+
const artnet_1 = require("../app/artnet");
|
|
41
|
+
const dmxOutput_1 = require("../app/dmxOutput");
|
|
42
|
+
const autoRoute_1 = require("./autoRoute");
|
|
43
|
+
const doctor_1 = require("./doctor");
|
|
44
|
+
const routeCommands_1 = require("./routeCommands");
|
|
45
|
+
const serviceInstall_1 = require("./serviceInstall");
|
|
46
|
+
function ts() {
|
|
47
|
+
return new Date().toISOString().slice(11, 19);
|
|
48
|
+
}
|
|
49
|
+
async function listPorts() {
|
|
50
|
+
const engine = new dmxOutput_1.DmxOutputEngine(() => null, () => ({ onSignalLoss: 'hold', signalLossMs: 3000 }));
|
|
51
|
+
const devices = await engine.listDevices();
|
|
52
|
+
if (devices.length === 0) {
|
|
53
|
+
console.log('No USB serial devices found.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(`${devices.length} device(s) found:\n`);
|
|
57
|
+
for (const d of devices) {
|
|
58
|
+
const extra = [d.manufacturer, d.serialNumber ? `SN:${d.serialNumber}` : null].filter(Boolean).join(', ');
|
|
59
|
+
console.log(` ${d.path} (uid: ${d.uid})${extra ? ` — ${extra}` : ''}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function statusLine(nowTs, r, status, json) {
|
|
63
|
+
if (json) {
|
|
64
|
+
return JSON.stringify({
|
|
65
|
+
ts: nowTs,
|
|
66
|
+
type: 'status',
|
|
67
|
+
routeId: r.id,
|
|
68
|
+
universe: r.universe,
|
|
69
|
+
deviceUid: r.deviceUid,
|
|
70
|
+
protocol: r.protocol,
|
|
71
|
+
hz: r.hz,
|
|
72
|
+
active: status.active,
|
|
73
|
+
error: status.error,
|
|
74
|
+
framesSent: status.framesSent,
|
|
75
|
+
frozen: status.frozen
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const line = status.active
|
|
79
|
+
? `OK — ${status.framesSent} frames sent${status.frozen ? ' (frozen)' : ''}`
|
|
80
|
+
: `ERROR: ${status.error ?? 'device not connected'}`;
|
|
81
|
+
return `[${nowTs}] universe ${r.universe} -> ${r.deviceUid} (${r.protocol}, ${r.hz}Hz): ${line}`;
|
|
82
|
+
}
|
|
83
|
+
async function runBridge(configDir, opts = {}) {
|
|
84
|
+
const store = new store_1.Store(configDir);
|
|
85
|
+
if (store.routes.length === 0) {
|
|
86
|
+
if (opts.json)
|
|
87
|
+
console.log(JSON.stringify({ ts: ts(), type: 'error', message: 'no_routes_configured' }));
|
|
88
|
+
else
|
|
89
|
+
console.log('No routes configured. Run `artnet2usb-cli configure` first.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (opts.json) {
|
|
93
|
+
console.log(JSON.stringify({ ts: ts(), type: 'startup', configDir }));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log(`[${ts()}] config: ${configDir}`);
|
|
97
|
+
console.log(`[${ts()}] starting bridge...`);
|
|
98
|
+
}
|
|
99
|
+
const bridge = await (0, bridge_1.startBridge)(store);
|
|
100
|
+
const enabled = store.routes.filter((r) => r.enabled);
|
|
101
|
+
if (opts.json)
|
|
102
|
+
console.log(JSON.stringify({ ts: ts(), type: 'summary', enabled: enabled.length, total: store.routes.length }));
|
|
103
|
+
else
|
|
104
|
+
console.log(`[${ts()}] ${enabled.length}/${store.routes.length} route(s) enabled.`);
|
|
105
|
+
const statusInterval = setInterval(() => {
|
|
106
|
+
for (const r of store.routes) {
|
|
107
|
+
if (!r.enabled)
|
|
108
|
+
continue;
|
|
109
|
+
console.log(statusLine(ts(), r, bridge.engine.statusOf(r), opts.json));
|
|
110
|
+
}
|
|
111
|
+
}, 2000);
|
|
112
|
+
let shuttingDown = false;
|
|
113
|
+
const shutdown = async () => {
|
|
114
|
+
if (shuttingDown)
|
|
115
|
+
return;
|
|
116
|
+
shuttingDown = true;
|
|
117
|
+
clearInterval(statusInterval);
|
|
118
|
+
if (opts.json)
|
|
119
|
+
console.log(JSON.stringify({ ts: ts(), type: 'shutdown' }));
|
|
120
|
+
else
|
|
121
|
+
console.log(`\n[${ts()}] shutting down...`);
|
|
122
|
+
await bridge.shutdown();
|
|
123
|
+
process.exit(0);
|
|
124
|
+
};
|
|
125
|
+
process.on('SIGINT', () => void shutdown());
|
|
126
|
+
process.on('SIGTERM', () => void shutdown());
|
|
127
|
+
// The setInterval + open UDP socket keep the process alive — it waits until Ctrl+C/SIGTERM.
|
|
128
|
+
}
|
|
129
|
+
async function runAuto(configDir, opts) {
|
|
130
|
+
const store = new store_1.Store(configDir);
|
|
131
|
+
const listener = new artnet_1.ArtNetListener();
|
|
132
|
+
listener.start();
|
|
133
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
134
|
+
const universes = listener.list();
|
|
135
|
+
listener.stop();
|
|
136
|
+
const engine = new dmxOutput_1.DmxOutputEngine(() => null, () => store.settings);
|
|
137
|
+
const devices = await engine.listDevices();
|
|
138
|
+
const result = (0, autoRoute_1.pickAutoRoute)(universes, devices);
|
|
139
|
+
if ('error' in result) {
|
|
140
|
+
console.error(result.error);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const existing = store.routes.find((r) => r.id === 'auto');
|
|
145
|
+
const { protocol, hz, channelOffset } = (0, autoRoute_1.resolveAutoRouteFields)(existing, opts);
|
|
146
|
+
store.upsertRoute({
|
|
147
|
+
id: 'auto',
|
|
148
|
+
universe: result.universe,
|
|
149
|
+
deviceUid: result.deviceUid,
|
|
150
|
+
protocol,
|
|
151
|
+
hz,
|
|
152
|
+
channelOffset,
|
|
153
|
+
enabled: true
|
|
154
|
+
});
|
|
155
|
+
console.log(`Route saved: universe ${result.universe} -> ${result.deviceUid} (${protocol}, ${hz}Hz${channelOffset ? `, offset ${channelOffset > 0 ? '+' : ''}${channelOffset}` : ''})`);
|
|
156
|
+
if (opts.start)
|
|
157
|
+
await runBridge(configDir, { json: opts.json });
|
|
158
|
+
}
|
|
159
|
+
async function listNodes() {
|
|
160
|
+
const listener = new artnet_1.ArtNetListener();
|
|
161
|
+
listener.start();
|
|
162
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
163
|
+
const nodes = listener.listNodes();
|
|
164
|
+
listener.stop();
|
|
165
|
+
if (nodes.length === 0) {
|
|
166
|
+
console.log('No Art-Net nodes found (listened for 3s).');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
console.log(`${nodes.length} node(s) found:\n`);
|
|
170
|
+
for (const n of nodes) {
|
|
171
|
+
console.log(` ${n.ip} ${n.shortName}${n.longName && n.longName !== n.shortName ? ` — ${n.longName}` : ''}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function promptManualUniverse(clack) {
|
|
175
|
+
const v = await clack.text({
|
|
176
|
+
message: 'Universe number (0-32767)',
|
|
177
|
+
validate: (s) => {
|
|
178
|
+
const n = Number(s);
|
|
179
|
+
return Number.isInteger(n) && n >= 0 && n <= 32767 ? undefined : 'Enter a valid universe number';
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
if (clack.isCancel(v))
|
|
183
|
+
return undefined;
|
|
184
|
+
return Number(v);
|
|
185
|
+
}
|
|
186
|
+
async function addRouteFlow(clack, store) {
|
|
187
|
+
const listener = new artnet_1.ArtNetListener();
|
|
188
|
+
const sp = clack.spinner();
|
|
189
|
+
sp.start('Listening for Art-Net traffic (3s)...');
|
|
190
|
+
listener.start();
|
|
191
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
192
|
+
const seen = listener.list();
|
|
193
|
+
listener.stop();
|
|
194
|
+
sp.stop(`${seen.length} universe(s) detected.`);
|
|
195
|
+
let universe;
|
|
196
|
+
if (seen.length > 0) {
|
|
197
|
+
const picked = await clack.select({
|
|
198
|
+
message: 'Select a universe',
|
|
199
|
+
options: [
|
|
200
|
+
...seen.map((u) => ({ value: u.universe, label: `Universe ${u.universe}`, hint: `${u.fps}fps, ${u.sourceIp}` })),
|
|
201
|
+
{ value: -1, label: 'Enter manually...' }
|
|
202
|
+
]
|
|
203
|
+
});
|
|
204
|
+
if (clack.isCancel(picked))
|
|
205
|
+
return;
|
|
206
|
+
universe = picked === -1 ? await promptManualUniverse(clack) : picked;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
universe = await promptManualUniverse(clack);
|
|
210
|
+
}
|
|
211
|
+
if (universe == null)
|
|
212
|
+
return;
|
|
213
|
+
const engine = new dmxOutput_1.DmxOutputEngine(() => null, () => store.settings);
|
|
214
|
+
const sp2 = clack.spinner();
|
|
215
|
+
sp2.start('Scanning USB devices...');
|
|
216
|
+
const devices = await engine.listDevices();
|
|
217
|
+
sp2.stop(`${devices.length} device(s) found.`);
|
|
218
|
+
if (devices.length === 0) {
|
|
219
|
+
clack.log.error('No USB serial device found — connect your DMX interface and try again.');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const deviceUid = await clack.select({
|
|
223
|
+
message: 'Select a device',
|
|
224
|
+
options: devices.map((d) => ({ value: d.uid, label: store.deviceNames[d.uid] ?? d.path, hint: d.manufacturer }))
|
|
225
|
+
});
|
|
226
|
+
if (clack.isCancel(deviceUid))
|
|
227
|
+
return;
|
|
228
|
+
const protocol = await clack.select({
|
|
229
|
+
message: 'Protocol',
|
|
230
|
+
options: [
|
|
231
|
+
{ value: 'enttec-pro', label: 'Enttec DMX USB Pro' },
|
|
232
|
+
{ value: 'open-dmx', label: 'Open DMX (raw FTDI)' }
|
|
233
|
+
]
|
|
234
|
+
});
|
|
235
|
+
if (clack.isCancel(protocol))
|
|
236
|
+
return;
|
|
237
|
+
const hzStr = await clack.text({
|
|
238
|
+
message: 'Output rate (Hz, 1-44)',
|
|
239
|
+
initialValue: '40',
|
|
240
|
+
validate: (v) => {
|
|
241
|
+
const n = Number(v);
|
|
242
|
+
return Number.isInteger(n) && n >= 1 && n <= 44 ? undefined : 'Enter an integer between 1 and 44';
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
if (clack.isCancel(hzStr))
|
|
246
|
+
return;
|
|
247
|
+
const channelOffsetStr = await clack.text({
|
|
248
|
+
message: 'Channel start (patch) offset, -511 to 511 (rotates circularly — no data is dropped)',
|
|
249
|
+
initialValue: '0',
|
|
250
|
+
validate: (v) => {
|
|
251
|
+
const n = Number(v);
|
|
252
|
+
return Number.isInteger(n) && n >= -511 && n <= 511 ? undefined : 'Enter an integer between -511 and 511';
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
if (clack.isCancel(channelOffsetStr))
|
|
256
|
+
return;
|
|
257
|
+
store.upsertRoute({
|
|
258
|
+
id: `route-${Date.now()}`,
|
|
259
|
+
universe,
|
|
260
|
+
deviceUid: deviceUid,
|
|
261
|
+
protocol: protocol,
|
|
262
|
+
hz: Number(hzStr),
|
|
263
|
+
channelOffset: Number(channelOffsetStr),
|
|
264
|
+
enabled: true
|
|
265
|
+
});
|
|
266
|
+
clack.log.success('Route saved.');
|
|
267
|
+
}
|
|
268
|
+
async function removeRouteFlow(clack, store) {
|
|
269
|
+
const id = await clack.select({
|
|
270
|
+
message: 'Select a route to remove',
|
|
271
|
+
options: store.routes.map((r) => ({ value: r.id, label: `universe ${r.universe} -> ${r.deviceUid}` }))
|
|
272
|
+
});
|
|
273
|
+
if (clack.isCancel(id))
|
|
274
|
+
return;
|
|
275
|
+
store.removeRoute(id);
|
|
276
|
+
clack.log.success('Route removed.');
|
|
277
|
+
}
|
|
278
|
+
async function runConfigure(clack, configDir) {
|
|
279
|
+
clack.intro('artnet2usb-cli — configure');
|
|
280
|
+
const store = new store_1.Store(configDir);
|
|
281
|
+
for (;;) {
|
|
282
|
+
const routes = store.routes;
|
|
283
|
+
if (routes.length > 0) {
|
|
284
|
+
clack.log.info(`Current routes:\n${routes
|
|
285
|
+
.map((r) => ` ${r.enabled ? '●' : '○'} universe ${r.universe} -> ${r.deviceUid} (${r.protocol}, ${r.hz}Hz${r.channelOffset ? `, offset ${r.channelOffset > 0 ? '+' : ''}${r.channelOffset}` : ''})`)
|
|
286
|
+
.join('\n')}`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
clack.log.info('No routes configured yet.');
|
|
290
|
+
}
|
|
291
|
+
const action = await clack.select({
|
|
292
|
+
message: 'What would you like to do?',
|
|
293
|
+
options: [
|
|
294
|
+
{ value: 'add', label: 'Add a route' },
|
|
295
|
+
...(routes.length > 0 ? [{ value: 'remove', label: 'Remove a route' }] : []),
|
|
296
|
+
...(routes.length > 0 ? [{ value: 'start', label: 'Start routes and exit' }] : []),
|
|
297
|
+
{ value: 'exit', label: 'Exit (keep saved, do not start)' }
|
|
298
|
+
]
|
|
299
|
+
});
|
|
300
|
+
if (clack.isCancel(action) || action === 'exit')
|
|
301
|
+
break;
|
|
302
|
+
if (action === 'add')
|
|
303
|
+
await addRouteFlow(clack, store);
|
|
304
|
+
if (action === 'remove')
|
|
305
|
+
await removeRouteFlow(clack, store);
|
|
306
|
+
if (action === 'start') {
|
|
307
|
+
clack.outro('Starting routes — press Ctrl+C to stop.');
|
|
308
|
+
await runBridge(configDir);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
clack.outro('Bye!');
|
|
313
|
+
}
|
|
314
|
+
async function main() {
|
|
315
|
+
const { Command } = (await Promise.resolve().then(() => __importStar(require('commander'))));
|
|
316
|
+
const clack = (await Promise.resolve().then(() => __importStar(require('@clack/prompts'))));
|
|
317
|
+
// Running with no arguments → go straight to the interactive wizard (the simplest and
|
|
318
|
+
// most predictable approach, instead of dealing with commander's "default command" setup).
|
|
319
|
+
if (process.argv.length <= 2) {
|
|
320
|
+
await runConfigure(clack, (0, configDir_1.resolveConfigDir)());
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const program = new Command();
|
|
324
|
+
program
|
|
325
|
+
.name('artnet2usb-cli')
|
|
326
|
+
.description('Headless Art-Net -> USB DMX bridge (shares config with the desktop app)')
|
|
327
|
+
.version('0.2.0');
|
|
328
|
+
program
|
|
329
|
+
.command('list-ports')
|
|
330
|
+
.description('List connected USB serial devices')
|
|
331
|
+
.action(async () => {
|
|
332
|
+
await listPorts();
|
|
333
|
+
});
|
|
334
|
+
program
|
|
335
|
+
.command('run')
|
|
336
|
+
.description('Start the bridge using saved routes and run until stopped (Ctrl+C)')
|
|
337
|
+
.option('--config <dir>', 'Config directory override (default: shared with the desktop app)')
|
|
338
|
+
.option('--json', 'Emit newline-delimited JSON status instead of human-readable text')
|
|
339
|
+
.action(async (opts) => {
|
|
340
|
+
await runBridge(opts.config ?? (0, configDir_1.resolveConfigDir)(), { json: opts.json });
|
|
341
|
+
});
|
|
342
|
+
program
|
|
343
|
+
.command('configure')
|
|
344
|
+
.description('Interactively add/remove routes (terminal wizard)')
|
|
345
|
+
.option('--config <dir>', 'Config directory override (default: shared with the desktop app)')
|
|
346
|
+
.action(async (opts) => {
|
|
347
|
+
await runConfigure(clack, opts.config ?? (0, configDir_1.resolveConfigDir)());
|
|
348
|
+
});
|
|
349
|
+
program
|
|
350
|
+
.command('auto')
|
|
351
|
+
.description('Auto-detect a single Art-Net universe and USB device, save the route, and start it (unless --no-start)')
|
|
352
|
+
.option('--protocol <protocol>', 'enttec-pro or open-dmx', 'open-dmx')
|
|
353
|
+
.option('--hz <hz>', 'Output rate in Hz (1-44)', '40')
|
|
354
|
+
.option('--channel-offset <n>', 'Channel start (patch) offset, -511 to 511. Rotates the universe circularly — no data is dropped, channels that fall off one end wrap to the other.', '0')
|
|
355
|
+
.option('--config <dir>', 'Config directory override (default: shared with the desktop app)')
|
|
356
|
+
.option('--no-start', 'Only detect and save the route, do not start the bridge')
|
|
357
|
+
.option('--json', 'Emit newline-delimited JSON status instead of human-readable text')
|
|
358
|
+
.action(async (opts, command) => {
|
|
359
|
+
const protocol = opts.protocol;
|
|
360
|
+
if (protocol !== 'enttec-pro' && protocol !== 'open-dmx') {
|
|
361
|
+
console.error(`Invalid --protocol: ${opts.protocol} (expected enttec-pro or open-dmx)`);
|
|
362
|
+
process.exitCode = 1;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const hz = Number(opts.hz);
|
|
366
|
+
if (!Number.isInteger(hz) || hz < 1 || hz > 44) {
|
|
367
|
+
console.error(`Invalid --hz: ${opts.hz} (expected an integer between 1 and 44)`);
|
|
368
|
+
process.exitCode = 1;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const channelOffset = Number(opts.channelOffset);
|
|
372
|
+
if (!Number.isInteger(channelOffset) || channelOffset < -511 || channelOffset > 511) {
|
|
373
|
+
console.error(`Invalid --channel-offset: ${opts.channelOffset} (expected an integer between -511 and 511)`);
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// If the flag was not explicitly passed (only commander's default applied), undefined is passed through —
|
|
378
|
+
// in that case runAuto() preserves the existing "auto" route's value (see the runAuto comment).
|
|
379
|
+
const explicit = (key) => command.getOptionValueSource(key) === 'cli';
|
|
380
|
+
await runAuto(opts.config ?? (0, configDir_1.resolveConfigDir)(), {
|
|
381
|
+
protocol: explicit('protocol') ? protocol : undefined,
|
|
382
|
+
hz: explicit('hz') ? hz : undefined,
|
|
383
|
+
channelOffset: explicit('channelOffset') ? channelOffset : undefined,
|
|
384
|
+
start: opts.start,
|
|
385
|
+
json: opts.json
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
program
|
|
389
|
+
.command('doctor')
|
|
390
|
+
.description('Check the saved config for common problems (disconnected devices, conflicting routes)')
|
|
391
|
+
.option('--config <dir>', 'Config directory override (default: shared with the desktop app)')
|
|
392
|
+
.action(async (opts) => {
|
|
393
|
+
const configDir = opts.config ?? (0, configDir_1.resolveConfigDir)();
|
|
394
|
+
const store = new store_1.Store(configDir);
|
|
395
|
+
const engine = new dmxOutput_1.DmxOutputEngine(() => null, () => store.settings);
|
|
396
|
+
const devices = await engine.listDevices();
|
|
397
|
+
const issues = (0, doctor_1.runDoctorChecks)(store.routes, devices);
|
|
398
|
+
console.log(`config: ${configDir}`);
|
|
399
|
+
console.log(`${store.routes.length} route(s), ${store.routes.filter((r) => r.enabled).length} enabled.`);
|
|
400
|
+
if (issues.length === 0) {
|
|
401
|
+
console.log('No issues found.');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
for (const issue of issues)
|
|
405
|
+
console.log(` ${issue.level === 'error' ? '✗' : '⚠'} ${issue.message}`);
|
|
406
|
+
if (issues.some((i) => i.level === 'error'))
|
|
407
|
+
process.exitCode = 1;
|
|
408
|
+
});
|
|
409
|
+
program
|
|
410
|
+
.command('list-nodes')
|
|
411
|
+
.description('Listen for Art-Net nodes on the network (~3s) and list them')
|
|
412
|
+
.action(async () => {
|
|
413
|
+
await listNodes();
|
|
414
|
+
});
|
|
415
|
+
(0, routeCommands_1.registerRouteCommands)(program);
|
|
416
|
+
(0, serviceInstall_1.registerServiceCommands)(program);
|
|
417
|
+
await program.parseAsync(process.argv);
|
|
418
|
+
}
|
|
419
|
+
main().catch((err) => {
|
|
420
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
421
|
+
process.exit(1);
|
|
422
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerRouteCommands = registerRouteCommands;
|
|
4
|
+
const store_1 = require("../shared/store");
|
|
5
|
+
const configDir_1 = require("./configDir");
|
|
6
|
+
function formatRoute(r) {
|
|
7
|
+
const offset = r.channelOffset ? `, offset ${r.channelOffset > 0 ? '+' : ''}${r.channelOffset}` : '';
|
|
8
|
+
return ` ${r.enabled ? '●' : '○'} [${r.id}] universe ${r.universe} -> ${r.deviceUid} (${r.protocol}, ${r.hz}Hz${offset})`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* `route add/list/remove` — lets you bulk-provision routes via a script
|
|
12
|
+
* (Ansible/shell), bypassing the configure wizard. Reads/writes the same
|
|
13
|
+
* config.json shared with the GUI (via shared/store.ts).
|
|
14
|
+
*/
|
|
15
|
+
function registerRouteCommands(program) {
|
|
16
|
+
const route = program.command('route').description('Manage routes non-interactively (scriptable)');
|
|
17
|
+
route
|
|
18
|
+
.command('add')
|
|
19
|
+
.description('Add or update a route')
|
|
20
|
+
.requiredOption('--universe <n>', 'Art-Net universe number (0-32767)')
|
|
21
|
+
.requiredOption('--device <uid>', 'Target device uid (see list-ports)')
|
|
22
|
+
.option('--protocol <protocol>', 'enttec-pro or open-dmx', 'open-dmx')
|
|
23
|
+
.option('--hz <hz>', 'Output rate in Hz (1-44)', '40')
|
|
24
|
+
.option('--channel-offset <n>', 'Channel start (patch) offset, -511 to 511. Rotates the universe circularly — no data is dropped, channels that fall off one end wrap to the other.', '0')
|
|
25
|
+
.option('--disabled', 'Save the route disabled (not started automatically)')
|
|
26
|
+
.option('--id <id>', 'Route id — reuses/updates an existing route if it already exists')
|
|
27
|
+
.option('--config <dir>', 'Config directory override (default: shared with the desktop app)')
|
|
28
|
+
.action((opts) => {
|
|
29
|
+
const universe = Number(opts.universe);
|
|
30
|
+
if (!Number.isInteger(universe) || universe < 0 || universe > 32767) {
|
|
31
|
+
console.error(`Invalid --universe: ${opts.universe}`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const hz = Number(opts.hz);
|
|
36
|
+
if (!Number.isInteger(hz) || hz < 1 || hz > 44) {
|
|
37
|
+
console.error(`Invalid --hz: ${opts.hz}`);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const channelOffset = Number(opts.channelOffset);
|
|
42
|
+
if (!Number.isInteger(channelOffset) || channelOffset < -511 || channelOffset > 511) {
|
|
43
|
+
console.error(`Invalid --channel-offset: ${opts.channelOffset} (expected an integer between -511 and 511)`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const protocol = opts.protocol;
|
|
48
|
+
if (protocol !== 'enttec-pro' && protocol !== 'open-dmx') {
|
|
49
|
+
console.error(`Invalid --protocol: ${opts.protocol} (expected enttec-pro or open-dmx)`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const store = new store_1.Store(opts.config ?? (0, configDir_1.resolveConfigDir)());
|
|
54
|
+
const id = opts.id ?? `route-${Date.now()}`;
|
|
55
|
+
store.upsertRoute({ id, universe, deviceUid: opts.device, protocol, hz, channelOffset, enabled: !opts.disabled });
|
|
56
|
+
console.log(`Saved route "${id}".`);
|
|
57
|
+
});
|
|
58
|
+
route
|
|
59
|
+
.command('list')
|
|
60
|
+
.description('List saved routes')
|
|
61
|
+
.option('--config <dir>', 'Config directory override (default: shared with the desktop app)')
|
|
62
|
+
.action((opts) => {
|
|
63
|
+
const store = new store_1.Store(opts.config ?? (0, configDir_1.resolveConfigDir)());
|
|
64
|
+
if (store.routes.length === 0) {
|
|
65
|
+
console.log('No routes configured.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const r of store.routes)
|
|
69
|
+
console.log(formatRoute(r));
|
|
70
|
+
});
|
|
71
|
+
route
|
|
72
|
+
.command('remove <id>')
|
|
73
|
+
.description('Remove a saved route by id')
|
|
74
|
+
.option('--config <dir>', 'Config directory override (default: shared with the desktop app)')
|
|
75
|
+
.action((id, opts) => {
|
|
76
|
+
const store = new store_1.Store(opts.config ?? (0, configDir_1.resolveConfigDir)());
|
|
77
|
+
store.removeRoute(id);
|
|
78
|
+
console.log(`Removed route "${id}" (if it existed).`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildLaunchdPlist = buildLaunchdPlist;
|
|
7
|
+
exports.buildSystemdUnit = buildSystemdUnit;
|
|
8
|
+
exports.registerServiceCommands = registerServiceCommands;
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const portableConfig_1 = require("../shared/portableConfig");
|
|
13
|
+
function escapeXml(s) {
|
|
14
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
15
|
+
}
|
|
16
|
+
function quoteShellArg(s) {
|
|
17
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
18
|
+
}
|
|
19
|
+
/** macOS LaunchAgent — user-level, does not require sudo. (Pure — testable.) */
|
|
20
|
+
function buildLaunchdPlist(opts) {
|
|
21
|
+
const argsXml = [...(opts.scriptPath ? [opts.scriptPath] : []), ...opts.args]
|
|
22
|
+
.map((a) => ` <string>${escapeXml(a)}</string>`)
|
|
23
|
+
.join('\n');
|
|
24
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
25
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
26
|
+
<plist version="1.0">
|
|
27
|
+
<dict>
|
|
28
|
+
<key>Label</key>
|
|
29
|
+
<string>${escapeXml(opts.label)}</string>
|
|
30
|
+
<key>ProgramArguments</key>
|
|
31
|
+
<array>
|
|
32
|
+
<string>${escapeXml(opts.nodePath)}</string>
|
|
33
|
+
${argsXml}
|
|
34
|
+
</array>
|
|
35
|
+
<key>RunAtLoad</key>
|
|
36
|
+
<true/>
|
|
37
|
+
<key>KeepAlive</key>
|
|
38
|
+
<true/>
|
|
39
|
+
<key>StandardOutPath</key>
|
|
40
|
+
<string>/tmp/${escapeXml(opts.label)}.log</string>
|
|
41
|
+
<key>StandardErrorPath</key>
|
|
42
|
+
<string>/tmp/${escapeXml(opts.label)}.err.log</string>
|
|
43
|
+
</dict>
|
|
44
|
+
</plist>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
/** Linux systemd system unit — starts automatically on boot/after a crash, requires sudo. (Pure — testable.) */
|
|
48
|
+
function buildSystemdUnit(opts) {
|
|
49
|
+
const execArgs = [...(opts.scriptPath ? [opts.scriptPath] : []), ...opts.args].map(quoteShellArg).join(' ');
|
|
50
|
+
return `[Unit]
|
|
51
|
+
Description=${opts.label}
|
|
52
|
+
After=network-online.target
|
|
53
|
+
Wants=network-online.target
|
|
54
|
+
|
|
55
|
+
[Service]
|
|
56
|
+
Type=simple
|
|
57
|
+
User=${opts.user}
|
|
58
|
+
ExecStart=${quoteShellArg(opts.nodePath)} ${execArgs}
|
|
59
|
+
Restart=always
|
|
60
|
+
RestartSec=3
|
|
61
|
+
|
|
62
|
+
[Install]
|
|
63
|
+
WantedBy=multi-user.target
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
const LABEL = 'tech.remana.relackout.artnet2usb-cli';
|
|
67
|
+
const SYSTEMD_UNIT_PATH = '/etc/systemd/system/artnet2usb-cli.service';
|
|
68
|
+
/**
|
|
69
|
+
* Writing to /etc/systemd/system on Linux almost always requires sudo —
|
|
70
|
+
* in that case os.userInfo().username returns 'root', so the service would be
|
|
71
|
+
* generated as root by mistake. `sudo` leaves the real invoking user in
|
|
72
|
+
* SUDO_USER; use it when present (systemd's User= field should be this user).
|
|
73
|
+
*/
|
|
74
|
+
function resolveInvokingUser() {
|
|
75
|
+
const sudoUser = process.env.SUDO_USER;
|
|
76
|
+
return sudoUser && sudoUser !== 'root' ? sudoUser : node_os_1.default.userInfo().username;
|
|
77
|
+
}
|
|
78
|
+
function launchdPlistPath() {
|
|
79
|
+
return node_path_1.default.join(node_os_1.default.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `install-service` / `uninstall-service` — sets up a background service that
|
|
83
|
+
* starts automatically on boot/after a crash. Since this is a persistent
|
|
84
|
+
* system change, the command writes/removes the service DEFINITION FILE but
|
|
85
|
+
* does NOT itself run `launchctl load` / `systemctl enable --now` — it prints
|
|
86
|
+
* the final activation step to the user as a copy-paste command.
|
|
87
|
+
*/
|
|
88
|
+
function registerServiceCommands(program) {
|
|
89
|
+
program
|
|
90
|
+
.command('install-service')
|
|
91
|
+
.description('Generate an autostart service definition (prints the command to enable it — does not run it)')
|
|
92
|
+
.option('--command <cmd>', 'Which CLI command the service runs: auto or run', 'auto')
|
|
93
|
+
.option('--config <dir>', 'Config directory to pass to the service (default: shared with the desktop app)')
|
|
94
|
+
.action((opts) => {
|
|
95
|
+
if (opts.command !== 'auto' && opts.command !== 'run') {
|
|
96
|
+
console.error(`Invalid --command: ${opts.command} (expected "auto" or "run")`);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// In the compiled single-file binary, execPath is the CLI itself; argv[1]
|
|
101
|
+
// points to a virtual path inside the snapshot and must not be written to the service.
|
|
102
|
+
const scriptPath = (0, portableConfig_1.isCompiledBinary)() ? null : node_path_1.default.resolve(process.argv[1]);
|
|
103
|
+
const args = [opts.command, ...(opts.config ? ['--config', opts.config] : [])];
|
|
104
|
+
const serviceOpts = {
|
|
105
|
+
nodePath: process.execPath,
|
|
106
|
+
scriptPath,
|
|
107
|
+
args,
|
|
108
|
+
user: resolveInvokingUser(),
|
|
109
|
+
label: LABEL
|
|
110
|
+
};
|
|
111
|
+
if (process.platform === 'darwin') {
|
|
112
|
+
const file = launchdPlistPath();
|
|
113
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
|
|
114
|
+
node_fs_1.default.writeFileSync(file, buildLaunchdPlist(serviceOpts));
|
|
115
|
+
console.log(`Wrote ${file}`);
|
|
116
|
+
console.log('To enable and start it now, run:');
|
|
117
|
+
console.log(` launchctl load -w "${file}"`);
|
|
118
|
+
console.log('To check status:');
|
|
119
|
+
console.log(` launchctl list | grep ${LABEL}`);
|
|
120
|
+
}
|
|
121
|
+
else if (process.platform === 'linux') {
|
|
122
|
+
try {
|
|
123
|
+
node_fs_1.default.writeFileSync(SYSTEMD_UNIT_PATH, buildSystemdUnit(serviceOpts));
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.error(`Could not write ${SYSTEMD_UNIT_PATH}: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
console.error('Re-run with sudo (e.g. `sudo artnet2usb-cli install-service`).');
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.log(`Wrote ${SYSTEMD_UNIT_PATH}`);
|
|
132
|
+
console.log('To enable and start it now, run:');
|
|
133
|
+
console.log(' sudo systemctl daemon-reload');
|
|
134
|
+
console.log(' sudo systemctl enable --now artnet2usb-cli');
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
console.error(`install-service is not supported on ${process.platform} yet.`);
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
program
|
|
142
|
+
.command('uninstall-service')
|
|
143
|
+
.description('Remove the installed service definition (prints the command to disable it first)')
|
|
144
|
+
.action(() => {
|
|
145
|
+
if (process.platform === 'darwin') {
|
|
146
|
+
const file = launchdPlistPath();
|
|
147
|
+
console.log('Before removing, unload it:');
|
|
148
|
+
console.log(` launchctl unload -w "${file}"`);
|
|
149
|
+
if (node_fs_1.default.existsSync(file)) {
|
|
150
|
+
node_fs_1.default.rmSync(file);
|
|
151
|
+
console.log(`Removed ${file}`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.log('No service file found.');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (process.platform === 'linux') {
|
|
158
|
+
console.log('Before removing, disable it:');
|
|
159
|
+
console.log(' sudo systemctl disable --now artnet2usb-cli');
|
|
160
|
+
if (node_fs_1.default.existsSync(SYSTEMD_UNIT_PATH)) {
|
|
161
|
+
try {
|
|
162
|
+
node_fs_1.default.rmSync(SYSTEMD_UNIT_PATH);
|
|
163
|
+
console.log(`Removed ${SYSTEMD_UNIT_PATH}`);
|
|
164
|
+
console.log('Then run: sudo systemctl daemon-reload');
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error(`Could not remove ${SYSTEMD_UNIT_PATH}: ${err instanceof Error ? err.message : String(err)}`);
|
|
168
|
+
console.error('Re-run with sudo, or remove it manually.');
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
console.log('No service file found.');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
console.error(`uninstall-service is not supported on ${process.platform}.`);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|