lightman-agent 1.0.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/agent.config.template.json +30 -0
- package/bin/cms-agent.js +233 -0
- package/nssm/nssm.exe +0 -0
- package/package.json +52 -0
- package/public/assets/index-CcBNCz6h.css +1 -0
- package/public/assets/index-H-8HDl46.js +1 -0
- package/public/index.html +19 -0
- package/scripts/guardian.ps1 +75 -0
- package/scripts/install-linux.sh +134 -0
- package/scripts/install-rpi.sh +117 -0
- package/scripts/install-windows.ps1 +529 -0
- package/scripts/launch-kiosk.vbs +101 -0
- package/scripts/lightman-agent.logrotate +12 -0
- package/scripts/lightman-agent.service +38 -0
- package/scripts/lightman-shell.bat +128 -0
- package/scripts/reinstall-windows.ps1 +26 -0
- package/scripts/restore-desktop.ps1 +32 -0
- package/scripts/setup.ps1 +116 -0
- package/scripts/setup.sh +115 -0
- package/scripts/uninstall-linux.sh +50 -0
- package/scripts/uninstall-windows.ps1 +54 -0
- package/src/commands/display.ts +177 -0
- package/src/commands/kiosk.ts +113 -0
- package/src/commands/maintenance.ts +106 -0
- package/src/commands/network.ts +129 -0
- package/src/commands/power.ts +163 -0
- package/src/commands/rpi.ts +45 -0
- package/src/commands/screenshot.ts +166 -0
- package/src/commands/serial.ts +17 -0
- package/src/commands/update.ts +124 -0
- package/src/index.ts +652 -0
- package/src/lib/config.ts +69 -0
- package/src/lib/identity.ts +40 -0
- package/src/lib/logger.ts +137 -0
- package/src/lib/platform.ts +10 -0
- package/src/lib/rpi.ts +180 -0
- package/src/lib/screens.ts +128 -0
- package/src/lib/types.ts +176 -0
- package/src/services/commands.ts +107 -0
- package/src/services/health.ts +161 -0
- package/src/services/kiosk.ts +395 -0
- package/src/services/localEvents.ts +60 -0
- package/src/services/logForwarder.ts +72 -0
- package/src/services/multiScreenKiosk.ts +324 -0
- package/src/services/oscBridge.ts +186 -0
- package/src/services/powerScheduler.ts +260 -0
- package/src/services/provisioning.ts +120 -0
- package/src/services/serialBridge.ts +230 -0
- package/src/services/serviceLauncher.ts +183 -0
- package/src/services/staticServer.ts +226 -0
- package/src/services/updater.ts +249 -0
- package/src/services/watchdog.ts +310 -0
- package/src/services/websocket.ts +152 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import type { ChildProcess } from 'child_process';
|
|
3
|
+
import type { KioskConfig, ScreenMapping, MultiScreenKioskStatus, SingleScreenStatus } from '../lib/types.js';
|
|
4
|
+
import type { DetectedScreen } from '../lib/screens.js';
|
|
5
|
+
import type { Logger } from '../lib/logger.js';
|
|
6
|
+
|
|
7
|
+
interface ScreenInstance {
|
|
8
|
+
hardwareId: string;
|
|
9
|
+
mappingId: string; // original hardwareId from admin ("1","2","3")
|
|
10
|
+
mappingUrl: string; // original URL from mapping (before buildUrl)
|
|
11
|
+
screenIndex: number; // position in the screenMap array
|
|
12
|
+
url: string; // fully built URL with credentials
|
|
13
|
+
screen: DetectedScreen;
|
|
14
|
+
process: ChildProcess | null;
|
|
15
|
+
startedAt: number | null;
|
|
16
|
+
userDataDir: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Manages multiple Chrome kiosk instances — one per physical display.
|
|
21
|
+
* Each Chrome gets its own --user-data-dir and --window-position to target the correct screen.
|
|
22
|
+
*/
|
|
23
|
+
export class MultiScreenKioskManager {
|
|
24
|
+
private config: KioskConfig;
|
|
25
|
+
private logger: Logger;
|
|
26
|
+
private instances: Map<string, ScreenInstance> = new Map();
|
|
27
|
+
private detectedScreens: DetectedScreen[] = [];
|
|
28
|
+
private pollTimer: NodeJS.Timeout | null = null;
|
|
29
|
+
private applying = false;
|
|
30
|
+
|
|
31
|
+
constructor(config: KioskConfig, logger: Logger) {
|
|
32
|
+
this.config = config;
|
|
33
|
+
this.logger = logger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Update the list of detected physical screens */
|
|
37
|
+
setDetectedScreens(screens: DetectedScreen[]): void {
|
|
38
|
+
this.detectedScreens = screens;
|
|
39
|
+
this.logger.info(`[MultiKiosk] Updated detected screens: ${screens.length} display(s)`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Apply a screen mapping from the server/admin.
|
|
44
|
+
* Launches Chrome on screens that have new URLs, kills those that were removed,
|
|
45
|
+
* and relaunches those whose URL changed.
|
|
46
|
+
*/
|
|
47
|
+
async applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
|
|
48
|
+
// Guard against concurrent calls
|
|
49
|
+
if (this.applying) {
|
|
50
|
+
this.logger.warn('[MultiKiosk] applyScreenMap already in progress, skipping');
|
|
51
|
+
return this.getStatus();
|
|
52
|
+
}
|
|
53
|
+
this.applying = true;
|
|
54
|
+
try {
|
|
55
|
+
return await this._applyScreenMap(screenMap, identity);
|
|
56
|
+
} finally {
|
|
57
|
+
this.applying = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async _applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
|
|
62
|
+
this.logger.info(`[MultiKiosk] Applying screen map: ${screenMap.length} mapping(s)`);
|
|
63
|
+
|
|
64
|
+
// Find which hardwareIds are no longer mapped — kill those
|
|
65
|
+
const mappedIds = new Set(screenMap.map(m => m.hardwareId));
|
|
66
|
+
for (const [hwId, instance] of this.instances) {
|
|
67
|
+
if (!mappedIds.has(hwId)) {
|
|
68
|
+
this.logger.info(`[MultiKiosk] Screen ${hwId} removed from map, killing Chrome`);
|
|
69
|
+
this.killInstance(instance);
|
|
70
|
+
this.instances.delete(hwId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Launch or relaunch for each mapping
|
|
75
|
+
for (let idx = 0; idx < screenMap.length; idx++) {
|
|
76
|
+
const mapping = screenMap[idx];
|
|
77
|
+
// Match by display number: admin saves "1","2","3" — agent detects "\\.\DISPLAY1" etc.
|
|
78
|
+
const screen = this.findScreen(mapping.hardwareId);
|
|
79
|
+
if (!screen) {
|
|
80
|
+
this.logger.warn(`[MultiKiosk] Screen ${mapping.hardwareId} not detected, skipping`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Use the mapping's URL if provided, otherwise use the default kiosk URL
|
|
85
|
+
// The screenIndex is the mapping's position (idx) in the array
|
|
86
|
+
const basePath = mapping.url || this.config.defaultUrl;
|
|
87
|
+
const url = this.buildUrl(basePath, identity, idx);
|
|
88
|
+
|
|
89
|
+
const existing = this.instances.get(mapping.hardwareId);
|
|
90
|
+
if (existing && existing.url === url && existing.process && existing.process.exitCode === null) {
|
|
91
|
+
// Same URL, Chrome still running — skip
|
|
92
|
+
this.logger.debug(`[MultiKiosk] Screen ${mapping.hardwareId} unchanged, skipping`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Kill existing if URL changed
|
|
97
|
+
if (existing) {
|
|
98
|
+
this.killInstance(existing);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Launch new Chrome on this screen
|
|
102
|
+
await this.launchOnScreen(screen.hardwareId, url, screen, identity, mapping.hardwareId, mapping.url || '', idx);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.startPoll();
|
|
106
|
+
return this.getStatus();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Launch a single Chrome instance on a specific screen */
|
|
110
|
+
private async launchOnScreen(
|
|
111
|
+
hardwareId: string,
|
|
112
|
+
url: string,
|
|
113
|
+
screen: DetectedScreen,
|
|
114
|
+
identity: { deviceId: string; apiKey: string },
|
|
115
|
+
mappingId: string = hardwareId,
|
|
116
|
+
mappingUrl: string = '',
|
|
117
|
+
screenIndex: number = 0
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
// Each screen gets its own user-data-dir to avoid profile lock conflicts
|
|
120
|
+
const sep = process.platform === 'win32' ? '\\' : '/';
|
|
121
|
+
const basePath = process.platform === 'win32'
|
|
122
|
+
? 'C:\\ProgramData\\Lightman'
|
|
123
|
+
: '/tmp/lightman';
|
|
124
|
+
const userDataDir = `${basePath}${sep}chrome-kiosk-screen-${screen.index}`;
|
|
125
|
+
|
|
126
|
+
const args = [
|
|
127
|
+
'--kiosk',
|
|
128
|
+
'--noerrdialogs',
|
|
129
|
+
'--disable-infobars',
|
|
130
|
+
'--disable-session-crashed-bubble',
|
|
131
|
+
'--no-first-run',
|
|
132
|
+
'--no-default-browser-check',
|
|
133
|
+
`--window-position=${screen.x},${screen.y}`,
|
|
134
|
+
`--window-size=${screen.width},${screen.height}`,
|
|
135
|
+
`--user-data-dir=${userDataDir}`,
|
|
136
|
+
...this.config.extraArgs.filter(a => !a.startsWith('--user-data-dir')),
|
|
137
|
+
url,
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
this.logger.info(`[MultiKiosk] Launching Chrome on ${hardwareId} (${screen.width}x${screen.height} @ ${screen.x},${screen.y}): ${url}`);
|
|
141
|
+
|
|
142
|
+
const proc = spawn(this.config.browserPath, args, {
|
|
143
|
+
stdio: 'ignore',
|
|
144
|
+
detached: true,
|
|
145
|
+
});
|
|
146
|
+
proc.unref();
|
|
147
|
+
|
|
148
|
+
const instance: ScreenInstance = {
|
|
149
|
+
hardwareId,
|
|
150
|
+
mappingId,
|
|
151
|
+
mappingUrl,
|
|
152
|
+
screenIndex,
|
|
153
|
+
url,
|
|
154
|
+
screen,
|
|
155
|
+
process: proc,
|
|
156
|
+
startedAt: Date.now(),
|
|
157
|
+
userDataDir,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
proc.on('exit', (code) => {
|
|
161
|
+
// Only auto-restart if this instance is still current (not replaced by a newer applyScreenMap)
|
|
162
|
+
const current = this.instances.get(hardwareId);
|
|
163
|
+
if (!current || current.process !== proc) return;
|
|
164
|
+
this.logger.warn(`[MultiKiosk] Chrome on ${hardwareId} exited with code ${code}`);
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
const stillCurrent = this.instances.get(hardwareId);
|
|
167
|
+
if (stillCurrent && stillCurrent === instance) {
|
|
168
|
+
this.logger.info(`[MultiKiosk] Auto-restarting Chrome on ${hardwareId}`);
|
|
169
|
+
this.launchOnScreen(hardwareId, url, screen, identity, mappingId, mappingUrl, screenIndex).catch(err => {
|
|
170
|
+
this.logger.error(`[MultiKiosk] Failed to restart Chrome on ${hardwareId}:`, err);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}, 3_000);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
proc.on('error', (err) => {
|
|
177
|
+
this.logger.error(`[MultiKiosk] Chrome error on ${hardwareId}:`, err.message);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
this.instances.set(hardwareId, instance);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Build the full URL with device credentials and screenIndex */
|
|
184
|
+
private buildUrl(path: string, identity: { deviceId: string; apiKey: string }, screenIndex?: number): string {
|
|
185
|
+
// If path is already a full URL, use it; otherwise prepend the local static server
|
|
186
|
+
let fullUrl: string;
|
|
187
|
+
if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
188
|
+
fullUrl = path;
|
|
189
|
+
} else {
|
|
190
|
+
// Ensure display URL format: /display/{slug}
|
|
191
|
+
const displayPath = path.startsWith('/display/') ? path : `/display/${path.replace(/^\//, '')}`;
|
|
192
|
+
fullUrl = `http://localhost:3403${displayPath}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const url = new URL(fullUrl);
|
|
196
|
+
url.searchParams.set('deviceId', identity.deviceId);
|
|
197
|
+
url.searchParams.set('apiKey', identity.apiKey);
|
|
198
|
+
if (screenIndex !== undefined) {
|
|
199
|
+
url.searchParams.set('screenIndex', String(screenIndex));
|
|
200
|
+
}
|
|
201
|
+
return url.toString();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Navigate a single screen to a new URL */
|
|
205
|
+
async navigateScreen(hardwareId: string, url: string, identity: { deviceId: string; apiKey: string }): Promise<void> {
|
|
206
|
+
const existing = this.instances.get(hardwareId);
|
|
207
|
+
if (!existing) {
|
|
208
|
+
this.logger.warn(`[MultiKiosk] Cannot navigate ${hardwareId} — not in instance map`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.killInstance(existing);
|
|
213
|
+
const fullUrl = this.buildUrl(url, identity);
|
|
214
|
+
await this.launchOnScreen(hardwareId, fullUrl, existing.screen, identity);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Kill Chrome for a specific screen instance */
|
|
218
|
+
private killInstance(instance: ScreenInstance): void {
|
|
219
|
+
if (instance.process) {
|
|
220
|
+
instance.process.removeAllListeners();
|
|
221
|
+
try {
|
|
222
|
+
instance.process.kill('SIGTERM');
|
|
223
|
+
} catch {
|
|
224
|
+
// Already dead
|
|
225
|
+
}
|
|
226
|
+
instance.process = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Find a detected screen by display number or full hardware ID */
|
|
231
|
+
private findScreen(id: string): DetectedScreen | undefined {
|
|
232
|
+
// Direct match (full hardware ID like "\\.\DISPLAY1")
|
|
233
|
+
const direct = this.detectedScreens.find(s => s.hardwareId === id);
|
|
234
|
+
if (direct) return direct;
|
|
235
|
+
|
|
236
|
+
// Match by display number ("1" → "\\.\DISPLAY1") — anchored to avoid "1" matching "DISPLAY11"
|
|
237
|
+
if (/^\d+$/.test(id)) {
|
|
238
|
+
const suffix = 'DISPLAY' + id;
|
|
239
|
+
return this.detectedScreens.find(s => {
|
|
240
|
+
const name = s.hardwareId.toUpperCase();
|
|
241
|
+
return name.endsWith(suffix) && (name.length === suffix.length || name[name.length - suffix.length - 1] === '\\');
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Kill all Chrome instances */
|
|
249
|
+
async killAll(): Promise<void> {
|
|
250
|
+
for (const [, instance] of this.instances) {
|
|
251
|
+
this.killInstance(instance);
|
|
252
|
+
}
|
|
253
|
+
this.stopPoll();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Restart all Chrome instances */
|
|
257
|
+
async restartAll(identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
|
|
258
|
+
this.logger.info('[MultiKiosk] Restarting all Chrome instances');
|
|
259
|
+
const mappings: ScreenMapping[] = [];
|
|
260
|
+
for (const [, instance] of this.instances) {
|
|
261
|
+
mappings.push({ hardwareId: instance.mappingId, url: instance.mappingUrl });
|
|
262
|
+
}
|
|
263
|
+
await this.killAll();
|
|
264
|
+
await new Promise(r => setTimeout(r, 2_000));
|
|
265
|
+
return this.applyScreenMap(mappings, identity);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Get status of all screen instances */
|
|
269
|
+
getStatus(): MultiScreenKioskStatus {
|
|
270
|
+
const screens: SingleScreenStatus[] = [];
|
|
271
|
+
for (const [, instance] of this.instances) {
|
|
272
|
+
const running = instance.process !== null && instance.process.exitCode === null;
|
|
273
|
+
screens.push({
|
|
274
|
+
hardwareId: instance.hardwareId,
|
|
275
|
+
url: instance.url,
|
|
276
|
+
running,
|
|
277
|
+
pid: running && instance.process ? instance.process.pid ?? null : null,
|
|
278
|
+
uptimeMs: running && instance.startedAt ? Date.now() - instance.startedAt : null,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return { screens };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Check if any screens are actively managed */
|
|
285
|
+
isActive(): boolean {
|
|
286
|
+
return this.instances.size > 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Cleanup on agent shutdown */
|
|
290
|
+
destroy(): void {
|
|
291
|
+
this.stopPoll();
|
|
292
|
+
for (const [, instance] of this.instances) {
|
|
293
|
+
if (instance.process) {
|
|
294
|
+
try {
|
|
295
|
+
instance.process.removeAllListeners();
|
|
296
|
+
instance.process.kill('SIGKILL');
|
|
297
|
+
} catch {
|
|
298
|
+
// Already dead
|
|
299
|
+
}
|
|
300
|
+
instance.process = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
this.instances.clear();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private startPoll(): void {
|
|
307
|
+
if (this.pollTimer) return;
|
|
308
|
+
this.pollTimer = setInterval(() => {
|
|
309
|
+
for (const [, instance] of this.instances) {
|
|
310
|
+
if (instance.process && instance.process.exitCode !== null) {
|
|
311
|
+
this.logger.warn(`[MultiKiosk] Poll: Chrome on ${instance.hardwareId} died`);
|
|
312
|
+
// exit handler will auto-restart
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}, this.config.pollIntervalMs);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private stopPoll(): void {
|
|
319
|
+
if (this.pollTimer) {
|
|
320
|
+
clearInterval(this.pollTimer);
|
|
321
|
+
this.pollTimer = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createSocket, type Socket } from 'dgram';
|
|
2
|
+
import type { WsClient } from './websocket.js';
|
|
3
|
+
import type { Logger } from '../lib/logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OscBridge — listens for OSC messages over UDP and converts them to
|
|
7
|
+
* hardware events, mirroring the SerialBridge pattern.
|
|
8
|
+
*
|
|
9
|
+
* When the configured OSC address is received with arg === 1, it emits
|
|
10
|
+
* an `osc:trigger` event via the local event broadcaster and the server WS.
|
|
11
|
+
*
|
|
12
|
+
* Uses Node.js built-in `dgram` — no external dependencies.
|
|
13
|
+
*
|
|
14
|
+
* OSC wire format (minimal parser):
|
|
15
|
+
* - Address: null-terminated string padded to 4-byte boundary
|
|
16
|
+
* - Type tag: "," + type chars, null-terminated padded to 4-byte boundary
|
|
17
|
+
* - Arguments: int32 (big-endian) for 'i', float32 for 'f'
|
|
18
|
+
*/
|
|
19
|
+
export class OscBridge {
|
|
20
|
+
private wsClient: WsClient;
|
|
21
|
+
private logger: Logger;
|
|
22
|
+
private port: number;
|
|
23
|
+
private host: string;
|
|
24
|
+
private address: string;
|
|
25
|
+
private running = false;
|
|
26
|
+
private socket: Socket | null = null;
|
|
27
|
+
private onEvent?: (event: Record<string, unknown>) => void;
|
|
28
|
+
|
|
29
|
+
constructor(opts: {
|
|
30
|
+
wsClient: WsClient;
|
|
31
|
+
logger: Logger;
|
|
32
|
+
port: number;
|
|
33
|
+
host?: string;
|
|
34
|
+
address: string;
|
|
35
|
+
onEvent?: (event: Record<string, unknown>) => void;
|
|
36
|
+
}) {
|
|
37
|
+
this.wsClient = opts.wsClient;
|
|
38
|
+
this.logger = opts.logger;
|
|
39
|
+
this.port = opts.port;
|
|
40
|
+
this.host = opts.host || '0.0.0.0';
|
|
41
|
+
this.address = opts.address;
|
|
42
|
+
this.onEvent = opts.onEvent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
start(): void {
|
|
46
|
+
if (this.running) return;
|
|
47
|
+
this.running = true;
|
|
48
|
+
|
|
49
|
+
this.logger.info(`[OSC] Starting bridge — listening on UDP ${this.host}:${this.port} for address "${this.address}"`);
|
|
50
|
+
|
|
51
|
+
this.socket = createSocket('udp4');
|
|
52
|
+
|
|
53
|
+
this.socket.on('message', (msg: Buffer) => {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = parseOscMessage(msg);
|
|
56
|
+
if (!parsed) return;
|
|
57
|
+
|
|
58
|
+
this.logger.debug(`[OSC] Received: ${parsed.address} args=${JSON.stringify(parsed.args)}`);
|
|
59
|
+
|
|
60
|
+
// Match address
|
|
61
|
+
if (parsed.address === this.address) {
|
|
62
|
+
const firstArg = parsed.args[0];
|
|
63
|
+
// Trigger on arg === 1 (int or float)
|
|
64
|
+
if (firstArg === 1 || firstArg === 1.0) {
|
|
65
|
+
this.emitTrigger();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this.logger.debug('[OSC] Failed to parse message:', err);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.socket.on('error', (err) => {
|
|
74
|
+
this.logger.error('[OSC] Socket error:', err);
|
|
75
|
+
if (this.running) {
|
|
76
|
+
this.socket?.close();
|
|
77
|
+
this.socket = null;
|
|
78
|
+
this.logger.info('[OSC] Restarting in 3s...');
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
if (this.running) this.start();
|
|
81
|
+
}, 3000);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.socket.bind(this.port, this.host, () => {
|
|
86
|
+
this.logger.info(`[OSC] Bridge listening on UDP ${this.host}:${this.port}`);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
stop(): void {
|
|
91
|
+
this.running = false;
|
|
92
|
+
if (this.socket) {
|
|
93
|
+
try { this.socket.close(); } catch { /* ignore */ }
|
|
94
|
+
this.socket = null;
|
|
95
|
+
}
|
|
96
|
+
this.logger.info('[OSC] Bridge stopped');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
isRunning(): boolean {
|
|
100
|
+
return this.running;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private emitTrigger(): void {
|
|
104
|
+
const event: Record<string, unknown> = {
|
|
105
|
+
type: 'osc:trigger',
|
|
106
|
+
address: this.address,
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
this.logger.info(`[OSC] Trigger event: ${this.address}`);
|
|
111
|
+
|
|
112
|
+
// Send to server
|
|
113
|
+
this.wsClient.send({
|
|
114
|
+
type: 'osc-bridge:event',
|
|
115
|
+
payload: event,
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Broadcast locally to Chrome display
|
|
120
|
+
this.onEvent?.(event);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ==========================================
|
|
125
|
+
// Minimal OSC message parser
|
|
126
|
+
// ==========================================
|
|
127
|
+
|
|
128
|
+
interface OscMessage {
|
|
129
|
+
address: string;
|
|
130
|
+
args: (number | string)[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseOscMessage(buf: Buffer): OscMessage | null {
|
|
134
|
+
let offset = 0;
|
|
135
|
+
|
|
136
|
+
// Read address string
|
|
137
|
+
const address = readOscString(buf, offset);
|
|
138
|
+
if (!address.value || address.value[0] !== '/') return null;
|
|
139
|
+
offset = address.next;
|
|
140
|
+
|
|
141
|
+
// Read type tag string
|
|
142
|
+
const typeTags = readOscString(buf, offset);
|
|
143
|
+
offset = typeTags.next;
|
|
144
|
+
|
|
145
|
+
const tags = typeTags.value || '';
|
|
146
|
+
// Type tag starts with ','
|
|
147
|
+
const types = tags.startsWith(',') ? tags.slice(1) : tags;
|
|
148
|
+
|
|
149
|
+
// Read arguments
|
|
150
|
+
const args: (number | string)[] = [];
|
|
151
|
+
for (const t of types) {
|
|
152
|
+
if (offset >= buf.length) break;
|
|
153
|
+
|
|
154
|
+
switch (t) {
|
|
155
|
+
case 'i': // int32
|
|
156
|
+
args.push(buf.readInt32BE(offset));
|
|
157
|
+
offset += 4;
|
|
158
|
+
break;
|
|
159
|
+
case 'f': // float32
|
|
160
|
+
args.push(buf.readFloatBE(offset));
|
|
161
|
+
offset += 4;
|
|
162
|
+
break;
|
|
163
|
+
case 's': { // string
|
|
164
|
+
const s = readOscString(buf, offset);
|
|
165
|
+
args.push(s.value);
|
|
166
|
+
offset = s.next;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
default:
|
|
170
|
+
// Unknown type, stop parsing
|
|
171
|
+
return { address: address.value, args };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { address: address.value, args };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function readOscString(buf: Buffer, offset: number): { value: string; next: number } {
|
|
179
|
+
let end = offset;
|
|
180
|
+
while (end < buf.length && buf[end] !== 0) end++;
|
|
181
|
+
const value = buf.toString('utf-8', offset, end);
|
|
182
|
+
// OSC strings are padded to 4-byte boundary (including null terminator)
|
|
183
|
+
const padded = end + 1;
|
|
184
|
+
const next = padded + ((4 - (padded % 4)) % 4);
|
|
185
|
+
return { value, next };
|
|
186
|
+
}
|