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,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
|
+
}
|