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,518 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const electron = require("electron");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const dgram = require("node:dgram");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const serialport = require("serialport");
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const ARTNET_PORT = 6454;
|
|
9
|
+
const HEADER = Buffer.from("Art-Net\0", "ascii");
|
|
10
|
+
const OP_DMX = 20480;
|
|
11
|
+
const OP_POLL = 8192;
|
|
12
|
+
const OP_POLL_REPLY = 8448;
|
|
13
|
+
function parseArtDmx(msg) {
|
|
14
|
+
if (msg.length < 19 || !msg.subarray(0, 8).equals(HEADER)) return null;
|
|
15
|
+
const opCode = msg.readUInt16LE(8);
|
|
16
|
+
if (opCode !== OP_DMX) return null;
|
|
17
|
+
const subUni = msg[14];
|
|
18
|
+
const net = msg[15];
|
|
19
|
+
const len = msg[16] << 8 | msg[17];
|
|
20
|
+
const data = msg.subarray(18, 18 + Math.min(len, 512));
|
|
21
|
+
return { universe: (net & 127) << 8 | subUni, data };
|
|
22
|
+
}
|
|
23
|
+
function parsePollReply(msg, fallbackIp) {
|
|
24
|
+
if (msg.length < 26 || !msg.subarray(0, 8).equals(HEADER)) return null;
|
|
25
|
+
if (msg.readUInt16LE(8) !== OP_POLL_REPLY) return null;
|
|
26
|
+
const ip = msg.length >= 14 ? `${msg[10]}.${msg[11]}.${msg[12]}.${msg[13]}` : fallbackIp;
|
|
27
|
+
const shortName = msg.length >= 44 ? msg.subarray(26, 44).toString("ascii").replace(/\0.*$/, "") : "";
|
|
28
|
+
const longName = msg.length >= 108 ? msg.subarray(44, 108).toString("ascii").replace(/\0.*$/, "") : "";
|
|
29
|
+
return { ip, shortName, longName };
|
|
30
|
+
}
|
|
31
|
+
function buildArtPoll() {
|
|
32
|
+
const b = Buffer.alloc(14);
|
|
33
|
+
HEADER.copy(b, 0);
|
|
34
|
+
b.writeUInt16LE(OP_POLL, 8);
|
|
35
|
+
b[10] = 0;
|
|
36
|
+
b[11] = 14;
|
|
37
|
+
b[12] = 0;
|
|
38
|
+
b[13] = 0;
|
|
39
|
+
return b;
|
|
40
|
+
}
|
|
41
|
+
class ArtNetListener {
|
|
42
|
+
socket = null;
|
|
43
|
+
universes = /* @__PURE__ */ new Map();
|
|
44
|
+
nodes = /* @__PURE__ */ new Map();
|
|
45
|
+
pollTimer = null;
|
|
46
|
+
onFrame = null;
|
|
47
|
+
start(onFrame) {
|
|
48
|
+
if (this.socket) return;
|
|
49
|
+
this.onFrame = onFrame ?? null;
|
|
50
|
+
const sock = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
51
|
+
sock.on("message", (msg, rinfo) => {
|
|
52
|
+
const dmx = parseArtDmx(msg);
|
|
53
|
+
if (dmx) {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
let st = this.universes.get(dmx.universe);
|
|
56
|
+
if (!st) {
|
|
57
|
+
st = { sourceIp: rinfo.address, lastAt: now, data: Buffer.alloc(512), frames: 0, fps: 0, windowStart: now };
|
|
58
|
+
this.universes.set(dmx.universe, st);
|
|
59
|
+
}
|
|
60
|
+
dmx.data.copy(st.data, 0);
|
|
61
|
+
st.sourceIp = rinfo.address;
|
|
62
|
+
st.lastAt = now;
|
|
63
|
+
st.frames++;
|
|
64
|
+
if (now - st.windowStart >= 2e3) {
|
|
65
|
+
st.fps = Math.round(st.frames * 1e3 / (now - st.windowStart));
|
|
66
|
+
st.frames = 0;
|
|
67
|
+
st.windowStart = now;
|
|
68
|
+
}
|
|
69
|
+
this.onFrame?.(dmx.universe, st.data);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const node = parsePollReply(msg, rinfo.address);
|
|
73
|
+
if (node) this.nodes.set(node.ip, node);
|
|
74
|
+
});
|
|
75
|
+
sock.on("error", (err) => console.error("[artnet] socket error", err.message));
|
|
76
|
+
sock.bind(ARTNET_PORT, () => {
|
|
77
|
+
try {
|
|
78
|
+
sock.setBroadcast(true);
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
this.sendPoll();
|
|
82
|
+
});
|
|
83
|
+
this.socket = sock;
|
|
84
|
+
this.pollTimer = setInterval(() => this.sendPoll(), 8e3);
|
|
85
|
+
}
|
|
86
|
+
sendPoll() {
|
|
87
|
+
const poll = buildArtPoll();
|
|
88
|
+
const targets = /* @__PURE__ */ new Set(["255.255.255.255"]);
|
|
89
|
+
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
90
|
+
for (const inf of ifaces ?? []) {
|
|
91
|
+
if (inf.family === "IPv4" && !inf.internal) {
|
|
92
|
+
const ip = inf.address.split(".").map(Number);
|
|
93
|
+
const mask = inf.netmask.split(".").map(Number);
|
|
94
|
+
targets.add(ip.map((o, i) => o | ~mask[i] & 255).join("."));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const t of targets) {
|
|
99
|
+
this.socket?.send(poll, ARTNET_PORT, t, () => {
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Universes seen (entries older than 10s are dropped). */
|
|
104
|
+
list() {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const [universe, st] of this.universes) {
|
|
108
|
+
if (now - st.lastAt > 1e4) continue;
|
|
109
|
+
out.push({
|
|
110
|
+
universe,
|
|
111
|
+
sourceIp: st.sourceIp,
|
|
112
|
+
fps: st.fps,
|
|
113
|
+
lastAt: st.lastAt,
|
|
114
|
+
channels: st.data.length
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return out.sort((a, b) => a.universe - b.universe);
|
|
118
|
+
}
|
|
119
|
+
listNodes() {
|
|
120
|
+
return [...this.nodes.values()];
|
|
121
|
+
}
|
|
122
|
+
/** The universe's latest frame (a copy) — for preview and the output engine. */
|
|
123
|
+
frameOf(universe) {
|
|
124
|
+
const st = this.universes.get(universe);
|
|
125
|
+
return st ? { data: Buffer.from(st.data), lastAt: st.lastAt } : null;
|
|
126
|
+
}
|
|
127
|
+
stop() {
|
|
128
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
129
|
+
this.pollTimer = null;
|
|
130
|
+
this.socket?.close();
|
|
131
|
+
this.socket = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function buildEnttecFrame(dmx) {
|
|
135
|
+
const payload = Buffer.alloc(513);
|
|
136
|
+
payload[0] = 0;
|
|
137
|
+
dmx.copy(payload, 1, 0, Math.min(dmx.length, 512));
|
|
138
|
+
const frame = Buffer.alloc(payload.length + 5);
|
|
139
|
+
frame[0] = 126;
|
|
140
|
+
frame[1] = 6;
|
|
141
|
+
frame.writeUInt16LE(payload.length, 2);
|
|
142
|
+
payload.copy(frame, 4);
|
|
143
|
+
frame[frame.length - 1] = 231;
|
|
144
|
+
return frame;
|
|
145
|
+
}
|
|
146
|
+
function applyChannelOffset(dmx, offset) {
|
|
147
|
+
if (!offset) return dmx;
|
|
148
|
+
const shifted = Buffer.alloc(512);
|
|
149
|
+
for (let i = 0; i < 512; i++) {
|
|
150
|
+
const srcIndex = ((i - offset) % 512 + 512) % 512;
|
|
151
|
+
shifted[i] = srcIndex < dmx.length ? dmx[srcIndex] : 0;
|
|
152
|
+
}
|
|
153
|
+
return shifted;
|
|
154
|
+
}
|
|
155
|
+
function deviceUid(p) {
|
|
156
|
+
const vid = (p.vendorId ?? "?").toLowerCase();
|
|
157
|
+
const pid = (p.productId ?? "?").toLowerCase();
|
|
158
|
+
return p.serialNumber ? `${vid}:${pid}:${p.serialNumber}` : `${vid}:${pid}:${p.path}`;
|
|
159
|
+
}
|
|
160
|
+
class DmxOutputEngine {
|
|
161
|
+
// routeId -> last frame sent
|
|
162
|
+
constructor(getFrame, getSettings) {
|
|
163
|
+
this.getFrame = getFrame;
|
|
164
|
+
this.getSettings = getSettings;
|
|
165
|
+
}
|
|
166
|
+
active = /* @__PURE__ */ new Map();
|
|
167
|
+
// routeId -> output
|
|
168
|
+
lastFrames = /* @__PURE__ */ new Map();
|
|
169
|
+
async listDevices() {
|
|
170
|
+
const ports = await serialport.SerialPort.list();
|
|
171
|
+
return ports.map((p) => ({
|
|
172
|
+
uid: deviceUid(p),
|
|
173
|
+
path: p.path,
|
|
174
|
+
manufacturer: p.manufacturer,
|
|
175
|
+
vendorId: p.vendorId,
|
|
176
|
+
productId: p.productId,
|
|
177
|
+
serialNumber: p.serialNumber,
|
|
178
|
+
connected: true
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
isActive(routeId) {
|
|
182
|
+
return this.active.has(routeId);
|
|
183
|
+
}
|
|
184
|
+
statusOf(route) {
|
|
185
|
+
const a = this.active.get(route.id);
|
|
186
|
+
return {
|
|
187
|
+
active: !!a && !a.error,
|
|
188
|
+
error: a?.error ?? null,
|
|
189
|
+
framesSent: a?.framesSent ?? 0,
|
|
190
|
+
frozen: !!a?.frozenFrame
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Freeze: locks in the current frame; a second call returns to live. Returns: the new frozen state. */
|
|
194
|
+
toggleFreeze(route) {
|
|
195
|
+
const out = this.active.get(route.id);
|
|
196
|
+
if (!out) return false;
|
|
197
|
+
if (out.frozenFrame) {
|
|
198
|
+
out.frozenFrame = null;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
const fr = this.getFrame(route.universe);
|
|
202
|
+
out.frozenFrame = Buffer.from(fr?.data ?? this.lastFrames.get(route.id) ?? Buffer.alloc(512));
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
/** Start a route: find the device's current port path by uid, open the port, set up the loop. */
|
|
206
|
+
async start(route) {
|
|
207
|
+
await this.stop(route.id);
|
|
208
|
+
const devices = await this.listDevices();
|
|
209
|
+
const dev = devices.find((d) => d.uid === route.deviceUid);
|
|
210
|
+
if (!dev) throw new Error("device_not_connected");
|
|
211
|
+
const baud = route.protocol === "open-dmx" ? 25e4 : 57600;
|
|
212
|
+
const port = new serialport.SerialPort({
|
|
213
|
+
path: dev.path,
|
|
214
|
+
baudRate: baud,
|
|
215
|
+
dataBits: 8,
|
|
216
|
+
stopBits: 2,
|
|
217
|
+
parity: "none",
|
|
218
|
+
autoOpen: false
|
|
219
|
+
});
|
|
220
|
+
await new Promise((resolve, reject) => {
|
|
221
|
+
port.open((err) => err ? reject(err) : resolve());
|
|
222
|
+
});
|
|
223
|
+
const hz = Math.max(1, Math.min(44, route.hz));
|
|
224
|
+
const out = { port, timer: null, framesSent: 0, error: null, writing: false, frozenFrame: null };
|
|
225
|
+
out.timer = setInterval(() => void this.tick(route, out), Math.round(1e3 / hz));
|
|
226
|
+
port.on("error", (err) => {
|
|
227
|
+
out.error = err.message;
|
|
228
|
+
});
|
|
229
|
+
port.on("close", () => {
|
|
230
|
+
out.error = out.error ?? "port_closed";
|
|
231
|
+
});
|
|
232
|
+
this.active.set(route.id, out);
|
|
233
|
+
}
|
|
234
|
+
currentFrame(route) {
|
|
235
|
+
const settings = this.getSettings();
|
|
236
|
+
const fr = this.getFrame(route.universe);
|
|
237
|
+
const fresh = fr && Date.now() - fr.lastAt <= settings.signalLossMs;
|
|
238
|
+
if (fresh) {
|
|
239
|
+
this.lastFrames.set(route.id, fr.data);
|
|
240
|
+
return fr.data;
|
|
241
|
+
}
|
|
242
|
+
if (settings.onSignalLoss === "blackout") return Buffer.alloc(512);
|
|
243
|
+
return this.lastFrames.get(route.id) ?? null;
|
|
244
|
+
}
|
|
245
|
+
async tick(route, out) {
|
|
246
|
+
if (out.writing || !out.port.isOpen) return;
|
|
247
|
+
const raw = out.frozenFrame ?? this.currentFrame(route);
|
|
248
|
+
if (!raw) return;
|
|
249
|
+
const dmx = applyChannelOffset(raw, route.channelOffset ?? 0);
|
|
250
|
+
out.writing = true;
|
|
251
|
+
try {
|
|
252
|
+
if (route.protocol === "enttec-pro") {
|
|
253
|
+
await new Promise(
|
|
254
|
+
(resolve, reject) => out.port.write(buildEnttecFrame(dmx), (err) => err ? reject(err) : resolve())
|
|
255
|
+
);
|
|
256
|
+
} else {
|
|
257
|
+
await new Promise((resolve, reject) => out.port.flush((e) => e ? reject(e) : resolve()));
|
|
258
|
+
await new Promise((resolve, reject) => out.port.set({ brk: true }, (e) => e ? reject(e) : resolve()));
|
|
259
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
260
|
+
await new Promise((resolve, reject) => out.port.set({ brk: false }, (e) => e ? reject(e) : resolve()));
|
|
261
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
262
|
+
const frame = Buffer.alloc(513);
|
|
263
|
+
frame[0] = 0;
|
|
264
|
+
dmx.copy(frame, 1, 0, Math.min(dmx.length, 512));
|
|
265
|
+
await new Promise((resolve, reject) => out.port.write(frame, (err) => err ? reject(err) : resolve()));
|
|
266
|
+
await new Promise((resolve, reject) => out.port.drain((err) => err ? reject(err) : resolve()));
|
|
267
|
+
}
|
|
268
|
+
out.framesSent++;
|
|
269
|
+
out.error = null;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
out.error = err instanceof Error ? err.message : String(err);
|
|
272
|
+
} finally {
|
|
273
|
+
out.writing = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async stop(routeId) {
|
|
277
|
+
const out = this.active.get(routeId);
|
|
278
|
+
if (!out) return;
|
|
279
|
+
clearInterval(out.timer);
|
|
280
|
+
this.active.delete(routeId);
|
|
281
|
+
this.lastFrames.delete(routeId);
|
|
282
|
+
if (out.port.isOpen) {
|
|
283
|
+
await new Promise((resolve) => out.port.close(() => resolve()));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async stopAll() {
|
|
287
|
+
for (const id of [...this.active.keys()]) await this.stop(id);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const DEFAULTS = {
|
|
291
|
+
deviceNames: {},
|
|
292
|
+
routes: [],
|
|
293
|
+
settings: { onSignalLoss: "hold", signalLossMs: 3e3 }
|
|
294
|
+
};
|
|
295
|
+
let Store$1 = class Store {
|
|
296
|
+
file;
|
|
297
|
+
state;
|
|
298
|
+
constructor(configDir) {
|
|
299
|
+
this.file = path.join(configDir, "config.json");
|
|
300
|
+
this.state = this.load();
|
|
301
|
+
}
|
|
302
|
+
load() {
|
|
303
|
+
try {
|
|
304
|
+
const raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
305
|
+
return {
|
|
306
|
+
deviceNames: raw.deviceNames ?? {},
|
|
307
|
+
routes: Array.isArray(raw.routes) ? raw.routes : [],
|
|
308
|
+
settings: { ...DEFAULTS.settings, ...raw.settings ?? {} }
|
|
309
|
+
};
|
|
310
|
+
} catch {
|
|
311
|
+
return structuredClone(DEFAULTS);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
save() {
|
|
315
|
+
try {
|
|
316
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
317
|
+
fs.writeFileSync(this.file, JSON.stringify(this.state, null, 2));
|
|
318
|
+
} catch (err) {
|
|
319
|
+
console.error("[store] failed to save", err);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
get deviceNames() {
|
|
323
|
+
return this.state.deviceNames;
|
|
324
|
+
}
|
|
325
|
+
setDeviceName(uid, name) {
|
|
326
|
+
if (name.trim()) this.state.deviceNames[uid] = name.trim().slice(0, 60);
|
|
327
|
+
else delete this.state.deviceNames[uid];
|
|
328
|
+
this.save();
|
|
329
|
+
}
|
|
330
|
+
get routes() {
|
|
331
|
+
return this.state.routes;
|
|
332
|
+
}
|
|
333
|
+
upsertRoute(route) {
|
|
334
|
+
const i = this.state.routes.findIndex((r) => r.id === route.id);
|
|
335
|
+
if (i >= 0) this.state.routes[i] = route;
|
|
336
|
+
else this.state.routes.push(route);
|
|
337
|
+
this.save();
|
|
338
|
+
}
|
|
339
|
+
removeRoute(id) {
|
|
340
|
+
this.state.routes = this.state.routes.filter((r) => r.id !== id);
|
|
341
|
+
this.save();
|
|
342
|
+
}
|
|
343
|
+
get settings() {
|
|
344
|
+
return this.state.settings;
|
|
345
|
+
}
|
|
346
|
+
setSettings(s) {
|
|
347
|
+
this.state.settings = s;
|
|
348
|
+
this.save();
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
function isWritableDir(dir) {
|
|
352
|
+
const probe = path.join(dir, `.artnet2usb-write-probe-${process.pid}`);
|
|
353
|
+
try {
|
|
354
|
+
fs.writeFileSync(probe, "");
|
|
355
|
+
fs.unlinkSync(probe);
|
|
356
|
+
return true;
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function resolvePortableConfigDir(exeSideDir2, fallbackDir) {
|
|
362
|
+
if (!exeSideDir2) return fallbackDir;
|
|
363
|
+
try {
|
|
364
|
+
if (fs.existsSync(path.join(exeSideDir2, "config.json"))) return exeSideDir2;
|
|
365
|
+
} catch {
|
|
366
|
+
return fallbackDir;
|
|
367
|
+
}
|
|
368
|
+
return isWritableDir(exeSideDir2) ? exeSideDir2 : fallbackDir;
|
|
369
|
+
}
|
|
370
|
+
function exeSideDir() {
|
|
371
|
+
if (process.env.PORTABLE_EXECUTABLE_DIR) return process.env.PORTABLE_EXECUTABLE_DIR;
|
|
372
|
+
if (process.env.APPIMAGE) return path.dirname(process.env.APPIMAGE);
|
|
373
|
+
if (!electron.app.isPackaged) return null;
|
|
374
|
+
if (process.platform === "darwin") {
|
|
375
|
+
return path.resolve(process.execPath, "..", "..", "..", "..");
|
|
376
|
+
}
|
|
377
|
+
return path.dirname(process.execPath);
|
|
378
|
+
}
|
|
379
|
+
class Store2 extends Store$1 {
|
|
380
|
+
constructor() {
|
|
381
|
+
super(resolvePortableConfigDir(exeSideDir(), electron.app.getPath("userData")));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
let win = null;
|
|
385
|
+
const listener = new ArtNetListener();
|
|
386
|
+
let store;
|
|
387
|
+
let engine;
|
|
388
|
+
let previewUniverse = null;
|
|
389
|
+
let lastPreviewPush = 0;
|
|
390
|
+
function createWindow() {
|
|
391
|
+
win = new electron.BrowserWindow({
|
|
392
|
+
width: 980,
|
|
393
|
+
height: 680,
|
|
394
|
+
minWidth: 760,
|
|
395
|
+
minHeight: 520,
|
|
396
|
+
backgroundColor: "#101d22",
|
|
397
|
+
title: "Relackout ArtNet 2 USB",
|
|
398
|
+
webPreferences: {
|
|
399
|
+
preload: path.join(__dirname, "../preload/preload.js"),
|
|
400
|
+
contextIsolation: true,
|
|
401
|
+
nodeIntegration: false
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
win.setMenuBarVisibility(false);
|
|
405
|
+
if (process.env.ELECTRON_RENDERER_URL) {
|
|
406
|
+
void win.loadURL(process.env.ELECTRON_RENDERER_URL);
|
|
407
|
+
} else {
|
|
408
|
+
void win.loadFile(path.join(__dirname, "../renderer/index.html"));
|
|
409
|
+
}
|
|
410
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
411
|
+
void electron.shell.openExternal(url);
|
|
412
|
+
return { action: "deny" };
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
async function buildState() {
|
|
416
|
+
const names = store.deviceNames;
|
|
417
|
+
const connected = await engine.listDevices();
|
|
418
|
+
const devices = connected.map((d) => ({ ...d, name: names[d.uid] }));
|
|
419
|
+
for (const [uid, name] of Object.entries(names)) {
|
|
420
|
+
if (!devices.some((d) => d.uid === uid)) {
|
|
421
|
+
devices.push({ uid, path: "—", name, connected: false });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const routes = store.routes.map((r) => ({ ...r, ...engine.statusOf(r) }));
|
|
425
|
+
return {
|
|
426
|
+
universes: listener.list(),
|
|
427
|
+
nodes: listener.listNodes(),
|
|
428
|
+
devices,
|
|
429
|
+
routes,
|
|
430
|
+
settings: store.settings
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function registerIpc() {
|
|
434
|
+
electron.ipcMain.handle("state:get", () => buildState());
|
|
435
|
+
electron.ipcMain.handle("devices:rename", (_e, uid, name) => {
|
|
436
|
+
store.setDeviceName(uid, name);
|
|
437
|
+
return buildState();
|
|
438
|
+
});
|
|
439
|
+
electron.ipcMain.handle("routes:upsert", async (_e, route) => {
|
|
440
|
+
store.upsertRoute(route);
|
|
441
|
+
if (engine.isActive(route.id)) {
|
|
442
|
+
await engine.stop(route.id);
|
|
443
|
+
if (route.enabled) await engine.start(route).catch(() => {
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return buildState();
|
|
447
|
+
});
|
|
448
|
+
electron.ipcMain.handle("routes:remove", async (_e, id) => {
|
|
449
|
+
await engine.stop(id);
|
|
450
|
+
store.removeRoute(id);
|
|
451
|
+
return buildState();
|
|
452
|
+
});
|
|
453
|
+
electron.ipcMain.handle("routes:start", async (_e, id) => {
|
|
454
|
+
const route = store.routes.find((r) => r.id === id);
|
|
455
|
+
if (!route) throw new Error("route_not_found");
|
|
456
|
+
try {
|
|
457
|
+
await engine.start(route);
|
|
458
|
+
store.upsertRoute({ ...route, enabled: true });
|
|
459
|
+
} catch (err) {
|
|
460
|
+
throw new Error(err instanceof Error ? err.message : String(err));
|
|
461
|
+
}
|
|
462
|
+
return buildState();
|
|
463
|
+
});
|
|
464
|
+
electron.ipcMain.handle("routes:freeze", (_e, id) => {
|
|
465
|
+
const route = store.routes.find((r) => r.id === id);
|
|
466
|
+
if (!route) throw new Error("route_not_found");
|
|
467
|
+
engine.toggleFreeze(route);
|
|
468
|
+
return buildState();
|
|
469
|
+
});
|
|
470
|
+
electron.ipcMain.handle("routes:stop", async (_e, id) => {
|
|
471
|
+
await engine.stop(id);
|
|
472
|
+
const route = store.routes.find((r) => r.id === id);
|
|
473
|
+
if (route) store.upsertRoute({ ...route, enabled: false });
|
|
474
|
+
return buildState();
|
|
475
|
+
});
|
|
476
|
+
electron.ipcMain.handle("settings:set", (_e, s) => {
|
|
477
|
+
store.setSettings(s);
|
|
478
|
+
return buildState();
|
|
479
|
+
});
|
|
480
|
+
electron.ipcMain.handle("preview:set", (_e, universe) => {
|
|
481
|
+
previewUniverse = universe;
|
|
482
|
+
if (universe != null) {
|
|
483
|
+
const fr = listener.frameOf(universe);
|
|
484
|
+
if (fr) win?.webContents.send("preview:frame", universe, [...fr.data]);
|
|
485
|
+
}
|
|
486
|
+
return true;
|
|
487
|
+
});
|
|
488
|
+
electron.ipcMain.handle("app:openExternal", (_e, url) => {
|
|
489
|
+
if (/^https?:\/\//.test(url)) void electron.shell.openExternal(url);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
void electron.app.whenReady().then(async () => {
|
|
493
|
+
store = new Store2();
|
|
494
|
+
engine = new DmxOutputEngine(
|
|
495
|
+
(u) => listener.frameOf(u),
|
|
496
|
+
() => store.settings
|
|
497
|
+
);
|
|
498
|
+
listener.start((universe, data) => {
|
|
499
|
+
if (universe === previewUniverse && win && Date.now() - lastPreviewPush > 50) {
|
|
500
|
+
lastPreviewPush = Date.now();
|
|
501
|
+
win.webContents.send("preview:frame", universe, [...data]);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
registerIpc();
|
|
505
|
+
createWindow();
|
|
506
|
+
for (const r of store.routes) {
|
|
507
|
+
if (r.enabled) await engine.start(r).catch(() => {
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
electron.app.on("activate", () => {
|
|
511
|
+
if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
electron.app.on("window-all-closed", () => {
|
|
515
|
+
void engine?.stopAll();
|
|
516
|
+
listener.stop();
|
|
517
|
+
electron.app.quit();
|
|
518
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
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.ArtNetListener = void 0;
|
|
7
|
+
exports.parseArtDmx = parseArtDmx;
|
|
8
|
+
exports.parsePollReply = parsePollReply;
|
|
9
|
+
exports.buildArtPoll = buildArtPoll;
|
|
10
|
+
const node_dgram_1 = __importDefault(require("node:dgram"));
|
|
11
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
12
|
+
/**
|
|
13
|
+
* Art-Net listener: passively watches for ArtDMX packets on UDP 6454
|
|
14
|
+
* (discovery + live data) and queries nodes via ArtPoll. No library, built
|
|
15
|
+
* to spec:
|
|
16
|
+
* - Header: 'Art-Net\0' + OpCode (LE)
|
|
17
|
+
* - ArtDMX 0x5000: [seq, phys, SubUni, Net, LenHi, LenLo, data...]
|
|
18
|
+
* - ArtPoll 0x2000 / ArtPollReply 0x2100
|
|
19
|
+
*/
|
|
20
|
+
const ARTNET_PORT = 6454;
|
|
21
|
+
const HEADER = Buffer.from('Art-Net\0', 'ascii');
|
|
22
|
+
const OP_DMX = 0x5000;
|
|
23
|
+
const OP_POLL = 0x2000;
|
|
24
|
+
const OP_POLL_REPLY = 0x2100;
|
|
25
|
+
/** Parses an ArtDMX packet; returns null if it's not Art-Net / not ArtDMX. (Pure — unit tested.) */
|
|
26
|
+
function parseArtDmx(msg) {
|
|
27
|
+
// 18-byte header + at least 1 channel
|
|
28
|
+
if (msg.length < 19 || !msg.subarray(0, 8).equals(HEADER))
|
|
29
|
+
return null;
|
|
30
|
+
const opCode = msg.readUInt16LE(8);
|
|
31
|
+
if (opCode !== OP_DMX)
|
|
32
|
+
return null;
|
|
33
|
+
const subUni = msg[14];
|
|
34
|
+
const net = msg[15];
|
|
35
|
+
const len = (msg[16] << 8) | msg[17];
|
|
36
|
+
const data = msg.subarray(18, 18 + Math.min(len, 512));
|
|
37
|
+
return { universe: ((net & 0x7f) << 8) | subUni, data };
|
|
38
|
+
}
|
|
39
|
+
/** Node info parsed from an ArtPollReply. */
|
|
40
|
+
function parsePollReply(msg, fallbackIp) {
|
|
41
|
+
if (msg.length < 26 || !msg.subarray(0, 8).equals(HEADER))
|
|
42
|
+
return null;
|
|
43
|
+
if (msg.readUInt16LE(8) !== OP_POLL_REPLY)
|
|
44
|
+
return null;
|
|
45
|
+
const ip = msg.length >= 14 ? `${msg[10]}.${msg[11]}.${msg[12]}.${msg[13]}` : fallbackIp;
|
|
46
|
+
const shortName = msg.length >= 44 ? msg.subarray(26, 44).toString('ascii').replace(/\0.*$/, '') : '';
|
|
47
|
+
const longName = msg.length >= 108 ? msg.subarray(44, 108).toString('ascii').replace(/\0.*$/, '') : '';
|
|
48
|
+
return { ip, shortName, longName };
|
|
49
|
+
}
|
|
50
|
+
function buildArtPoll() {
|
|
51
|
+
const b = Buffer.alloc(14);
|
|
52
|
+
HEADER.copy(b, 0);
|
|
53
|
+
b.writeUInt16LE(OP_POLL, 8);
|
|
54
|
+
b[10] = 0; // ProtVerHi
|
|
55
|
+
b[11] = 14; // ProtVerLo
|
|
56
|
+
b[12] = 0; // TalkToMe
|
|
57
|
+
b[13] = 0; // Priority
|
|
58
|
+
return b;
|
|
59
|
+
}
|
|
60
|
+
class ArtNetListener {
|
|
61
|
+
socket = null;
|
|
62
|
+
universes = new Map();
|
|
63
|
+
nodes = new Map();
|
|
64
|
+
pollTimer = null;
|
|
65
|
+
onFrame = null;
|
|
66
|
+
start(onFrame) {
|
|
67
|
+
if (this.socket)
|
|
68
|
+
return;
|
|
69
|
+
this.onFrame = onFrame ?? null;
|
|
70
|
+
const sock = node_dgram_1.default.createSocket({ type: 'udp4', reuseAddr: true });
|
|
71
|
+
sock.on('message', (msg, rinfo) => {
|
|
72
|
+
const dmx = parseArtDmx(msg);
|
|
73
|
+
if (dmx) {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
let st = this.universes.get(dmx.universe);
|
|
76
|
+
if (!st) {
|
|
77
|
+
st = { sourceIp: rinfo.address, lastAt: now, data: Buffer.alloc(512), frames: 0, fps: 0, windowStart: now };
|
|
78
|
+
this.universes.set(dmx.universe, st);
|
|
79
|
+
}
|
|
80
|
+
dmx.data.copy(st.data, 0);
|
|
81
|
+
st.sourceIp = rinfo.address;
|
|
82
|
+
st.lastAt = now;
|
|
83
|
+
st.frames++;
|
|
84
|
+
if (now - st.windowStart >= 2000) {
|
|
85
|
+
st.fps = Math.round((st.frames * 1000) / (now - st.windowStart));
|
|
86
|
+
st.frames = 0;
|
|
87
|
+
st.windowStart = now;
|
|
88
|
+
}
|
|
89
|
+
this.onFrame?.(dmx.universe, st.data);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const node = parsePollReply(msg, rinfo.address);
|
|
93
|
+
if (node)
|
|
94
|
+
this.nodes.set(node.ip, node);
|
|
95
|
+
});
|
|
96
|
+
sock.on('error', (err) => console.error('[artnet] socket error', err.message));
|
|
97
|
+
sock.bind(ARTNET_PORT, () => {
|
|
98
|
+
try {
|
|
99
|
+
sock.setBroadcast(true);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* if the platform doesn't allow it, discovery still works passively */
|
|
103
|
+
}
|
|
104
|
+
this.sendPoll();
|
|
105
|
+
});
|
|
106
|
+
this.socket = sock;
|
|
107
|
+
this.pollTimer = setInterval(() => this.sendPoll(), 8000);
|
|
108
|
+
}
|
|
109
|
+
sendPoll() {
|
|
110
|
+
const poll = buildArtPoll();
|
|
111
|
+
const targets = new Set(['255.255.255.255']);
|
|
112
|
+
for (const ifaces of Object.values(node_os_1.default.networkInterfaces())) {
|
|
113
|
+
for (const inf of ifaces ?? []) {
|
|
114
|
+
if (inf.family === 'IPv4' && !inf.internal) {
|
|
115
|
+
const ip = inf.address.split('.').map(Number);
|
|
116
|
+
const mask = inf.netmask.split('.').map(Number);
|
|
117
|
+
targets.add(ip.map((o, i) => o | (~mask[i] & 0xff)).join('.'));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const t of targets) {
|
|
122
|
+
this.socket?.send(poll, ARTNET_PORT, t, () => { });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Universes seen (entries older than 10s are dropped). */
|
|
126
|
+
list() {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const [universe, st] of this.universes) {
|
|
130
|
+
if (now - st.lastAt > 10_000)
|
|
131
|
+
continue;
|
|
132
|
+
out.push({
|
|
133
|
+
universe,
|
|
134
|
+
sourceIp: st.sourceIp,
|
|
135
|
+
fps: st.fps,
|
|
136
|
+
lastAt: st.lastAt,
|
|
137
|
+
channels: st.data.length
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return out.sort((a, b) => a.universe - b.universe);
|
|
141
|
+
}
|
|
142
|
+
listNodes() {
|
|
143
|
+
return [...this.nodes.values()];
|
|
144
|
+
}
|
|
145
|
+
/** The universe's latest frame (a copy) — for preview and the output engine. */
|
|
146
|
+
frameOf(universe) {
|
|
147
|
+
const st = this.universes.get(universe);
|
|
148
|
+
return st ? { data: Buffer.from(st.data), lastAt: st.lastAt } : null;
|
|
149
|
+
}
|
|
150
|
+
stop() {
|
|
151
|
+
if (this.pollTimer)
|
|
152
|
+
clearInterval(this.pollTimer);
|
|
153
|
+
this.pollTimer = null;
|
|
154
|
+
this.socket?.close();
|
|
155
|
+
this.socket = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
exports.ArtNetListener = ArtNetListener;
|