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.
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DmxOutputEngine = void 0;
4
+ exports.buildEnttecFrame = buildEnttecFrame;
5
+ exports.applyChannelOffset = applyChannelOffset;
6
+ exports.deviceUid = deviceUid;
7
+ const serialport_1 = require("serialport");
8
+ /**
9
+ * USB DMX output engine. Each active route runs on its own timer (hz):
10
+ * it grabs the source universe's latest frame and writes it to the port
11
+ * using the selected protocol.
12
+ * - enttec-pro: Enttec DMX USB Pro frame (0x7E, label 6, len LE, data, 0xE7)
13
+ * - open-dmx: FTDI raw DMX — 250000 baud 8N2, break + MAB + startcode + 512
14
+ * The device identity (uid) is independent of the port path: when the
15
+ * device is reconnected, the route finds its new path on its own.
16
+ */
17
+ /** Enttec Pro "Output Only Send DMX" packet. (Pure — unit tested.) */
18
+ function buildEnttecFrame(dmx) {
19
+ const payload = Buffer.alloc(513);
20
+ payload[0] = 0; // start code
21
+ dmx.copy(payload, 1, 0, Math.min(dmx.length, 512));
22
+ const frame = Buffer.alloc(payload.length + 5);
23
+ frame[0] = 0x7e;
24
+ frame[1] = 6; // label: Output Only Send DMX
25
+ frame.writeUInt16LE(payload.length, 2);
26
+ payload.copy(frame, 4);
27
+ frame[frame.length - 1] = 0xe7;
28
+ return frame;
29
+ }
30
+ /**
31
+ * Applies a channel start (patch) offset — a circular rotation, so NO DATA
32
+ * IS LOST: the entire 512-channel buffer is preserved, only the positions
33
+ * are rotated circularly.
34
+ * - offset > 0: source channels 1..512 are written in order to device
35
+ * channel (1+offset).. onward; the `offset` channels that overflow the
36
+ * end of the buffer wrap around to the start (device channels 1..offset).
37
+ * - offset < 0: the first |offset| channels of the source wrap around to
38
+ * the END of the device buffer (not dropped), source channel (|offset|+1)
39
+ * is written to the device's channel 1.
40
+ * If offset=0, returns the original buffer untouched. (Pure — unit tested.)
41
+ */
42
+ function applyChannelOffset(dmx, offset) {
43
+ if (!offset)
44
+ return dmx;
45
+ const shifted = Buffer.alloc(512);
46
+ for (let i = 0; i < 512; i++) {
47
+ const srcIndex = ((((i - offset) % 512) + 512) % 512);
48
+ shifted[i] = srcIndex < dmx.length ? dmx[srcIndex] : 0;
49
+ }
50
+ return shifted;
51
+ }
52
+ /** Derives a persistent device identity from a serialport list record. (Pure — unit tested.) */
53
+ function deviceUid(p) {
54
+ const vid = (p.vendorId ?? '?').toLowerCase();
55
+ const pid = (p.productId ?? '?').toLowerCase();
56
+ return p.serialNumber ? `${vid}:${pid}:${p.serialNumber}` : `${vid}:${pid}:${p.path}`;
57
+ }
58
+ class DmxOutputEngine {
59
+ getFrame;
60
+ getSettings;
61
+ active = new Map(); // routeId -> output
62
+ lastFrames = new Map(); // routeId -> last frame sent
63
+ constructor(getFrame, getSettings) {
64
+ this.getFrame = getFrame;
65
+ this.getSettings = getSettings;
66
+ }
67
+ async listDevices() {
68
+ const ports = await serialport_1.SerialPort.list();
69
+ return ports.map((p) => ({
70
+ uid: deviceUid(p),
71
+ path: p.path,
72
+ manufacturer: p.manufacturer,
73
+ vendorId: p.vendorId,
74
+ productId: p.productId,
75
+ serialNumber: p.serialNumber,
76
+ connected: true
77
+ }));
78
+ }
79
+ isActive(routeId) {
80
+ return this.active.has(routeId);
81
+ }
82
+ statusOf(route) {
83
+ const a = this.active.get(route.id);
84
+ return {
85
+ active: !!a && !a.error,
86
+ error: a?.error ?? null,
87
+ framesSent: a?.framesSent ?? 0,
88
+ frozen: !!a?.frozenFrame
89
+ };
90
+ }
91
+ /** Freeze: locks in the current frame; a second call returns to live. Returns: the new frozen state. */
92
+ toggleFreeze(route) {
93
+ const out = this.active.get(route.id);
94
+ if (!out)
95
+ return false;
96
+ if (out.frozenFrame) {
97
+ out.frozenFrame = null;
98
+ return false;
99
+ }
100
+ // Current output frame: live data, else the last frame sent, else blackout
101
+ const fr = this.getFrame(route.universe);
102
+ out.frozenFrame = Buffer.from(fr?.data ?? this.lastFrames.get(route.id) ?? Buffer.alloc(512));
103
+ return true;
104
+ }
105
+ /** Start a route: find the device's current port path by uid, open the port, set up the loop. */
106
+ async start(route) {
107
+ await this.stop(route.id);
108
+ const devices = await this.listDevices();
109
+ const dev = devices.find((d) => d.uid === route.deviceUid);
110
+ if (!dev)
111
+ throw new Error('device_not_connected');
112
+ const baud = route.protocol === 'open-dmx' ? 250000 : 57600;
113
+ const port = new serialport_1.SerialPort({
114
+ path: dev.path,
115
+ baudRate: baud,
116
+ dataBits: 8,
117
+ stopBits: 2,
118
+ parity: 'none',
119
+ autoOpen: false
120
+ });
121
+ await new Promise((resolve, reject) => {
122
+ port.open((err) => (err ? reject(err) : resolve()));
123
+ });
124
+ const hz = Math.max(1, Math.min(44, route.hz));
125
+ const out = { port, timer: null, framesSent: 0, error: null, writing: false, frozenFrame: null };
126
+ out.timer = setInterval(() => void this.tick(route, out), Math.round(1000 / hz));
127
+ port.on('error', (err) => {
128
+ out.error = err.message;
129
+ });
130
+ port.on('close', () => {
131
+ out.error = out.error ?? 'port_closed';
132
+ });
133
+ this.active.set(route.id, out);
134
+ }
135
+ currentFrame(route) {
136
+ const settings = this.getSettings();
137
+ const fr = this.getFrame(route.universe);
138
+ const fresh = fr && Date.now() - fr.lastAt <= settings.signalLossMs;
139
+ if (fresh) {
140
+ this.lastFrames.set(route.id, fr.data);
141
+ return fr.data;
142
+ }
143
+ // No signal: hold the last frame or blackout, depending on the setting
144
+ if (settings.onSignalLoss === 'blackout')
145
+ return Buffer.alloc(512);
146
+ return this.lastFrames.get(route.id) ?? null;
147
+ }
148
+ async tick(route, out) {
149
+ if (out.writing || !out.port.isOpen)
150
+ return;
151
+ const raw = out.frozenFrame ?? this.currentFrame(route);
152
+ if (!raw)
153
+ return;
154
+ const dmx = applyChannelOffset(raw, route.channelOffset ?? 0);
155
+ out.writing = true;
156
+ try {
157
+ if (route.protocol === 'enttec-pro') {
158
+ await new Promise((resolve, reject) => out.port.write(buildEnttecFrame(dmx), (err) => (err ? reject(err) : resolve())));
159
+ }
160
+ else {
161
+ // Open DMX: break (~1ms) + MAB (Mark After Break) + frame, via serialport set({brk}).
162
+ // NOTE: flush() was tried between break→data but caused jitter (reverted).
163
+ // In this version flush() was moved to the VERY START of the loop — it tries
164
+ // to clear any leftover stray byte from the previous cycle without touching
165
+ // the current break/MAB timing.
166
+ await new Promise((resolve, reject) => out.port.flush((e) => (e ? reject(e) : resolve())));
167
+ await new Promise((resolve, reject) => out.port.set({ brk: true }, (e) => (e ? reject(e) : resolve())));
168
+ await new Promise((r) => setTimeout(r, 1));
169
+ await new Promise((resolve, reject) => out.port.set({ brk: false }, (e) => (e ? reject(e) : resolve())));
170
+ await new Promise((r) => setTimeout(r, 1));
171
+ const frame = Buffer.alloc(513);
172
+ frame[0] = 0;
173
+ dmx.copy(frame, 1, 0, Math.min(dmx.length, 512));
174
+ await new Promise((resolve, reject) => out.port.write(frame, (err) => (err ? reject(err) : resolve())));
175
+ await new Promise((resolve, reject) => out.port.drain((err) => (err ? reject(err) : resolve())));
176
+ }
177
+ out.framesSent++;
178
+ out.error = null;
179
+ }
180
+ catch (err) {
181
+ out.error = err instanceof Error ? err.message : String(err);
182
+ }
183
+ finally {
184
+ out.writing = false;
185
+ }
186
+ }
187
+ async stop(routeId) {
188
+ const out = this.active.get(routeId);
189
+ if (!out)
190
+ return;
191
+ clearInterval(out.timer);
192
+ this.active.delete(routeId);
193
+ this.lastFrames.delete(routeId);
194
+ if (out.port.isOpen) {
195
+ await new Promise((resolve) => out.port.close(() => resolve()));
196
+ }
197
+ }
198
+ async stopAll() {
199
+ for (const id of [...this.active.keys()])
200
+ await this.stop(id);
201
+ }
202
+ }
203
+ exports.DmxOutputEngine = DmxOutputEngine;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pickAutoRoute = pickAutoRoute;
4
+ exports.resolveAutoRouteFields = resolveAutoRouteFields;
5
+ /**
6
+ * Decision logic for the "auto" command: matches when exactly 1 universe + 1 USB
7
+ * device have been seen, otherwise returns an error explaining which side is
8
+ * ambiguous — the caller then directs the user to `configure` in this case.
9
+ */
10
+ function pickAutoRoute(universes, devices) {
11
+ if (universes.length === 0 && devices.length === 0) {
12
+ return { error: 'No Art-Net universe and no USB device detected. Connect your DMX interface and ensure Art-Net traffic is reaching this machine, then retry.' };
13
+ }
14
+ if (universes.length === 0) {
15
+ return { error: 'No Art-Net universe detected. Ensure a controller is broadcasting Art-Net on this network, then retry.' };
16
+ }
17
+ if (devices.length === 0) {
18
+ return { error: 'No USB serial device detected. Connect your DMX interface, then retry.' };
19
+ }
20
+ if (universes.length > 1) {
21
+ const list = universes.map((u) => u.universe).join(', ');
22
+ return { error: `${universes.length} Art-Net universes detected (${list}) — "auto" only handles a single universe. Use "configure" to pick one manually.` };
23
+ }
24
+ if (devices.length > 1) {
25
+ const list = devices.map((d) => d.path).join(', ');
26
+ return { error: `${devices.length} USB devices detected (${list}) — "auto" only handles a single device. Use "configure" to pick one manually.` };
27
+ }
28
+ return { universe: universes[0].universe, deviceUid: devices[0].uid };
29
+ }
30
+ /**
31
+ * "auto" only actually auto-detects universe+deviceUid.
32
+ * If no CLI flag was explicitly given for protocol/hz/channelOffset (the opts
33
+ * field is undefined), the previously saved "auto" route's value is preserved —
34
+ * otherwise, on every restart (systemd Restart=always, boot, etc.) a manually
35
+ * configured channelOffset/protocol would silently reset to the default.
36
+ */
37
+ function resolveAutoRouteFields(existing, opts) {
38
+ return {
39
+ protocol: opts.protocol ?? existing?.protocol ?? 'open-dmx',
40
+ hz: opts.hz ?? existing?.hz ?? 40,
41
+ channelOffset: opts.channelOffset ?? existing?.channelOffset ?? 0
42
+ };
43
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startBridge = startBridge;
4
+ const artnet_1 = require("../app/artnet");
5
+ const dmxOutput_1 = require("../app/dmxOutput");
6
+ const RECONNECT_INTERVAL_MS = 5000;
7
+ /**
8
+ * Bridging without Electron — the same thing as the app.whenReady() block in
9
+ * app/main.ts (EXCLUDING BrowserWindow/registerIpc/preview): start the
10
+ * listener, auto-start the saved+enabled routes. Both the `run` and
11
+ * `configure` sub-commands use this.
12
+ *
13
+ * It also runs a periodic watchdog loop so that when a USB device disconnects
14
+ * and reconnects (e.g. a cable getting jostled backstage), it can recover
15
+ * without human intervention: it silently retries starting any enabled route
16
+ * that is currently `active:false` (errors are swallowed and retried on the
17
+ * next tick). app/dmxOutput.ts itself is never modified.
18
+ */
19
+ async function startBridge(store, onFrame) {
20
+ const listener = new artnet_1.ArtNetListener();
21
+ const engine = new dmxOutput_1.DmxOutputEngine((u) => listener.frameOf(u), () => store.settings);
22
+ listener.start(onFrame);
23
+ for (const r of store.routes) {
24
+ if (r.enabled)
25
+ await engine.start(r).catch(() => { });
26
+ }
27
+ const reconnectTimer = setInterval(() => {
28
+ void (async () => {
29
+ for (const r of store.routes) {
30
+ if (!r.enabled)
31
+ continue;
32
+ if (!engine.statusOf(r).active)
33
+ await engine.start(r).catch(() => { });
34
+ }
35
+ })();
36
+ }, RECONNECT_INTERVAL_MS);
37
+ return {
38
+ listener,
39
+ engine,
40
+ shutdown: async () => {
41
+ clearInterval(reconnectTimer);
42
+ await engine.stopAll();
43
+ listener.stop();
44
+ }
45
+ };
46
+ }
@@ -0,0 +1,49 @@
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.defaultConfigDir = defaultConfigDir;
7
+ exports.portableExeDir = portableExeDir;
8
+ exports.resolveConfigDir = resolveConfigDir;
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const portableConfig_1 = require("../shared/portableConfig");
12
+ /**
13
+ * Recomputes, without Electron (in the CLI process), the OS-standard path that
14
+ * Electron's app.getPath('userData') uses — productName is kept identical so
15
+ * the SAME config.json is shared with the GUI ('main.ts' never calls
16
+ * app.setName(), so it uses build.productName from the Electron package.json).
17
+ * - macOS: ~/Library/Application Support/<productName>
18
+ * - Windows: %APPDATA%/<productName>
19
+ * - Linux: ${XDG_CONFIG_HOME:-~/.config}/<productName>
20
+ */
21
+ const PRODUCT_NAME = 'Relackout ArtNet 2 USB';
22
+ function defaultConfigDir() {
23
+ const home = node_os_1.default.homedir();
24
+ if (process.platform === 'darwin') {
25
+ return node_path_1.default.join(home, 'Library', 'Application Support', PRODUCT_NAME);
26
+ }
27
+ if (process.platform === 'win32') {
28
+ return node_path_1.default.join(process.env.APPDATA ?? node_path_1.default.join(home, 'AppData', 'Roaming'), PRODUCT_NAME);
29
+ }
30
+ return node_path_1.default.join(process.env.XDG_CONFIG_HOME ?? node_path_1.default.join(home, '.config'), PRODUCT_NAME);
31
+ }
32
+ /**
33
+ * The directory the exe lives in, for a single-file compiled binary (pkg/bun) —
34
+ * the candidate for portable config. Null when running under plain node/tsx
35
+ * (execPath would be node itself, so writing config next to it would be wrong).
36
+ */
37
+ function portableExeDir() {
38
+ if (!(0, portableConfig_1.isCompiledBinary)())
39
+ return null;
40
+ return node_path_1.default.dirname(process.execPath);
41
+ }
42
+ /**
43
+ * The config directory the CLI will use: portable-first.
44
+ * For a standalone binary, next to the exe (if writable, or if config.json
45
+ * already exists there), otherwise the OS-standard directory (same as the GUI's userData).
46
+ */
47
+ function resolveConfigDir() {
48
+ return (0, portableConfig_1.resolvePortableConfigDir)(portableExeDir(), defaultConfigDir());
49
+ }
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runDoctorChecks = runDoctorChecks;
4
+ /**
5
+ * Decision logic for the `doctor` command: cross-checks the enabled routes in
6
+ * config.json against the USB devices currently connected. Pure — actual
7
+ * filesystem/hardware access happens on the caller's side (cli/index.ts).
8
+ */
9
+ function runDoctorChecks(routes, devices) {
10
+ const issues = [];
11
+ const connectedUids = new Set(devices.map((d) => d.uid));
12
+ const enabled = routes.filter((r) => r.enabled);
13
+ for (const r of enabled) {
14
+ if (!connectedUids.has(r.deviceUid)) {
15
+ issues.push({ level: 'error', message: `Route "${r.id}" (universe ${r.universe}): device ${r.deviceUid} is not currently connected.` });
16
+ }
17
+ if (!Number.isInteger(r.hz) || r.hz < 1 || r.hz > 44) {
18
+ issues.push({ level: 'error', message: `Route "${r.id}": hz ${r.hz} is out of range (1-44).` });
19
+ }
20
+ const offset = r.channelOffset ?? 0;
21
+ if (!Number.isInteger(offset) || offset < -511 || offset > 511) {
22
+ issues.push({ level: 'error', message: `Route "${r.id}": channelOffset ${offset} is out of range (-511 to 511).` });
23
+ }
24
+ }
25
+ const byUniverse = new Map();
26
+ for (const r of enabled)
27
+ byUniverse.set(r.universe, [...(byUniverse.get(r.universe) ?? []), r]);
28
+ for (const [universe, list] of byUniverse) {
29
+ if (list.length > 1) {
30
+ issues.push({ level: 'warning', message: `Universe ${universe} is routed by ${list.length} enabled routes: ${list.map((r) => r.id).join(', ')}.` });
31
+ }
32
+ }
33
+ const byDevice = new Map();
34
+ for (const r of enabled)
35
+ byDevice.set(r.deviceUid, [...(byDevice.get(r.deviceUid) ?? []), r]);
36
+ for (const [deviceUid, list] of byDevice) {
37
+ if (list.length > 1) {
38
+ issues.push({ level: 'warning', message: `Device ${deviceUid} is targeted by ${list.length} enabled routes: ${list.map((r) => r.id).join(', ')}.` });
39
+ }
40
+ }
41
+ return issues;
42
+ }