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.
Files changed (54) hide show
  1. package/agent.config.template.json +30 -0
  2. package/bin/cms-agent.js +233 -0
  3. package/nssm/nssm.exe +0 -0
  4. package/package.json +52 -0
  5. package/public/assets/index-CcBNCz6h.css +1 -0
  6. package/public/assets/index-H-8HDl46.js +1 -0
  7. package/public/index.html +19 -0
  8. package/scripts/guardian.ps1 +75 -0
  9. package/scripts/install-linux.sh +134 -0
  10. package/scripts/install-rpi.sh +117 -0
  11. package/scripts/install-windows.ps1 +529 -0
  12. package/scripts/launch-kiosk.vbs +101 -0
  13. package/scripts/lightman-agent.logrotate +12 -0
  14. package/scripts/lightman-agent.service +38 -0
  15. package/scripts/lightman-shell.bat +128 -0
  16. package/scripts/reinstall-windows.ps1 +26 -0
  17. package/scripts/restore-desktop.ps1 +32 -0
  18. package/scripts/setup.ps1 +116 -0
  19. package/scripts/setup.sh +115 -0
  20. package/scripts/uninstall-linux.sh +50 -0
  21. package/scripts/uninstall-windows.ps1 +54 -0
  22. package/src/commands/display.ts +177 -0
  23. package/src/commands/kiosk.ts +113 -0
  24. package/src/commands/maintenance.ts +106 -0
  25. package/src/commands/network.ts +129 -0
  26. package/src/commands/power.ts +163 -0
  27. package/src/commands/rpi.ts +45 -0
  28. package/src/commands/screenshot.ts +166 -0
  29. package/src/commands/serial.ts +17 -0
  30. package/src/commands/update.ts +124 -0
  31. package/src/index.ts +652 -0
  32. package/src/lib/config.ts +69 -0
  33. package/src/lib/identity.ts +40 -0
  34. package/src/lib/logger.ts +137 -0
  35. package/src/lib/platform.ts +10 -0
  36. package/src/lib/rpi.ts +180 -0
  37. package/src/lib/screens.ts +128 -0
  38. package/src/lib/types.ts +176 -0
  39. package/src/services/commands.ts +107 -0
  40. package/src/services/health.ts +161 -0
  41. package/src/services/kiosk.ts +395 -0
  42. package/src/services/localEvents.ts +60 -0
  43. package/src/services/logForwarder.ts +72 -0
  44. package/src/services/multiScreenKiosk.ts +324 -0
  45. package/src/services/oscBridge.ts +186 -0
  46. package/src/services/powerScheduler.ts +260 -0
  47. package/src/services/provisioning.ts +120 -0
  48. package/src/services/serialBridge.ts +230 -0
  49. package/src/services/serviceLauncher.ts +183 -0
  50. package/src/services/staticServer.ts +226 -0
  51. package/src/services/updater.ts +249 -0
  52. package/src/services/watchdog.ts +310 -0
  53. package/src/services/websocket.ts +152 -0
  54. package/tsconfig.json +28 -0
package/src/index.ts ADDED
@@ -0,0 +1,652 @@
1
+ import { readFileSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { loadConfig } from './lib/config.js';
4
+ import { Logger } from './lib/logger.js';
5
+ import { provision } from './services/provisioning.js';
6
+ import { WsClient } from './services/websocket.js';
7
+ import { HealthMonitor } from './services/health.js';
8
+ import { CommandExecutor } from './services/commands.js';
9
+ import { KioskManager } from './services/kiosk.js';
10
+ import { registerPowerCommands } from './commands/power.js';
11
+ import { registerKioskCommands, registerMultiScreenKioskCommands } from './commands/kiosk.js';
12
+ import { registerScreenshotCommands } from './commands/screenshot.js';
13
+ import { MultiScreenKioskManager } from './services/multiScreenKiosk.js';
14
+ import { detectScreens } from './lib/screens.js';
15
+ import { registerDisplayCommands } from './commands/display.js';
16
+ import { registerNetworkCommands } from './commands/network.js';
17
+ import { Updater } from './services/updater.js';
18
+ import { registerUpdateCommands } from './commands/update.js';
19
+ import { Watchdog } from './services/watchdog.js';
20
+ import { registerMaintenanceCommands } from './commands/maintenance.js';
21
+ import { LogForwarder } from './services/logForwarder.js';
22
+ import { registerRpiCommands } from './commands/rpi.js';
23
+ import { registerSerialCommands } from './commands/serial.js';
24
+ import { isRaspberryPi } from './lib/rpi.js';
25
+ import { ServiceLauncher } from './services/serviceLauncher.js';
26
+ import { StaticServer } from './services/staticServer.js';
27
+ import { PowerScheduler } from './services/powerScheduler.js';
28
+ import { SerialBridge } from './services/serialBridge.js';
29
+ import { OscBridge } from './services/oscBridge.js';
30
+ import { LocalEventServer } from './services/localEvents.js';
31
+ import type { WsMessage, KioskConfig, PowerScheduleConfig, Identity, ScreenMapping } from './lib/types.js';
32
+
33
+ function getAgentVersion(): string {
34
+ try {
35
+ const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'));
36
+ return pkg.version || '1.0.0';
37
+ } catch {
38
+ return '1.0.0';
39
+ }
40
+ }
41
+
42
+ async function main(): Promise<void> {
43
+ // 1. Load config
44
+ const config = loadConfig();
45
+ const logger = new Logger(config.logLevel, config.logFile);
46
+
47
+ logger.info('LIGHTMAN Agent starting...');
48
+ logger.info(`Server: ${config.serverUrl}`);
49
+ logger.info(`Device slug: ${config.deviceSlug}`);
50
+
51
+ // 1b. Start built-in display static server
52
+ const displayDistPath = resolve(process.cwd(), 'public');
53
+ const staticServer = new StaticServer(3403, displayDistPath, config.serverUrl, logger);
54
+ staticServer.start();
55
+
56
+ // 1c. Start local server & display services (dev mode only)
57
+ let serviceLauncher: ServiceLauncher | null = null;
58
+ if (config.localServices) {
59
+ const projectRoot = resolve(process.cwd(), '..');
60
+ serviceLauncher = new ServiceLauncher(logger, projectRoot);
61
+ try {
62
+ await serviceLauncher.startAll();
63
+ } catch (err) {
64
+ logger.error('Failed to start services:', err);
65
+ process.exit(1);
66
+ }
67
+ } else {
68
+ logger.info('Local services disabled (kiosk-only mode)');
69
+ }
70
+
71
+ // 2. Provision (get identity)
72
+ // Provision with retry — never crash, just keep trying.
73
+ // This prevents NSSM restart loops that kill Chrome (blinking screen).
74
+ let identity: Identity | null = null;
75
+ const MAX_PROVISION_ATTEMPTS = 999;
76
+ for (let attempt = 1; attempt <= MAX_PROVISION_ATTEMPTS; attempt++) {
77
+ try {
78
+ const result = await provision(config, logger);
79
+ identity = result.identity;
80
+ logger.info(
81
+ `Device ID: ${identity.deviceId} (${result.fromCache ? 'cached' : 'new'})`
82
+ );
83
+ break;
84
+ } catch (err) {
85
+ logger.error(`Provisioning attempt ${attempt} failed:`, err);
86
+ if (attempt < MAX_PROVISION_ATTEMPTS) {
87
+ const waitSec = Math.min(30, attempt * 5);
88
+ logger.info(`Retrying provisioning in ${waitSec}s...`);
89
+ await new Promise((r) => setTimeout(r, waitSec * 1000));
90
+ } else {
91
+ logger.error('All provisioning attempts exhausted. Exiting.');
92
+ process.exit(1);
93
+ }
94
+ }
95
+ }
96
+
97
+ if (!identity) {
98
+ logger.error('Provisioning failed — no identity. Exiting.');
99
+ process.exit(1);
100
+ }
101
+
102
+ // 3. Create WebSocket client
103
+ let commandExecutor: CommandExecutor;
104
+ let powerScheduler: PowerScheduler;
105
+
106
+ const wsClient = new WsClient({
107
+ serverUrl: config.serverUrl,
108
+ identity,
109
+ logger,
110
+ onMessage: (msg: WsMessage) => {
111
+ handleServerMessage(msg, commandExecutor, logger, powerScheduler, startSerialBridge, stopSerialBridge, startOscBridge, stopOscBridge, multiScreenKiosk, getIdentity, kioskManager, watchdog);
112
+ },
113
+ });
114
+
115
+ // 4. Start health monitor
116
+ const healthMonitor = new HealthMonitor(
117
+ wsClient,
118
+ logger,
119
+ config.healthIntervalMs,
120
+ config.serverUrl
121
+ );
122
+
123
+ // 4b. Start log forwarder
124
+ const logForwarder = new LogForwarder(wsClient, logger);
125
+ logger.onLog((entry) => logForwarder.onLog(entry));
126
+ logForwarder.start();
127
+
128
+ // 5. Create command executor and register built-in commands
129
+ commandExecutor = new CommandExecutor(wsClient, logger);
130
+
131
+ // Register Phase 15 system management commands
132
+ registerPowerCommands(commandExecutor.register.bind(commandExecutor), logger);
133
+ registerDisplayCommands(commandExecutor.register.bind(commandExecutor), logger);
134
+ registerScreenshotCommands(commandExecutor.register.bind(commandExecutor), logger);
135
+
136
+ // Create KioskManager if kiosk config is present
137
+ const baseKioskConfig: KioskConfig = config.kiosk || {
138
+ browserPath: 'chromium-browser',
139
+ defaultUrl: `${config.serverUrl.replace(/:\d+$/, ':3401')}/display`,
140
+ extraArgs: [],
141
+ pollIntervalMs: 10_000,
142
+ maxCrashesInWindow: 10,
143
+ crashWindowMs: 300_000,
144
+ };
145
+ // Enforce unmuted autoplay in kiosk mode for video templates with audio tracks.
146
+ const normalizedExtraArgs = (baseKioskConfig.extraArgs || []).filter((arg) => arg !== '--mute-audio');
147
+ if (!normalizedExtraArgs.some((arg) => arg.startsWith('--autoplay-policy='))) {
148
+ normalizedExtraArgs.push('--autoplay-policy=no-user-gesture-required');
149
+ }
150
+ // Inject credentials into the kiosk URL so Chrome auto-provisions without pairing
151
+ const kioskUrl = new URL(baseKioskConfig.defaultUrl);
152
+ kioskUrl.searchParams.set('deviceId', identity.deviceId);
153
+ kioskUrl.searchParams.set('apiKey', identity.apiKey);
154
+ const kioskConfig: KioskConfig = {
155
+ ...baseKioskConfig,
156
+ extraArgs: normalizedExtraArgs,
157
+ defaultUrl: kioskUrl.toString(),
158
+ };
159
+ const kioskManager = new KioskManager(kioskConfig, logger);
160
+ registerKioskCommands(commandExecutor.register.bind(commandExecutor), kioskManager, logger);
161
+
162
+ // Multi-screen kiosk manager — handles multiple Chrome instances on multi-display devices
163
+ const multiScreenKiosk = new MultiScreenKioskManager(kioskConfig, logger);
164
+ const getIdentity = () => identity!;
165
+ registerMultiScreenKioskCommands(commandExecutor.register.bind(commandExecutor), multiScreenKiosk, getIdentity, logger);
166
+
167
+ // Detect physical screens and report to server
168
+ const detectedScreens = detectScreens(logger);
169
+ multiScreenKiosk.setDetectedScreens(detectedScreens);
170
+
171
+ // Create Watchdog (Phase 20)
172
+ const watchdog = new Watchdog(
173
+ kioskManager,
174
+ wsClient,
175
+ healthMonitor,
176
+ logger,
177
+ config.serverUrl,
178
+ identity,
179
+ undefined,
180
+ config.kiosk?.shellMode
181
+ );
182
+ registerMaintenanceCommands(commandExecutor.register.bind(commandExecutor), watchdog, logger);
183
+
184
+ // Register Phase 20 network commands
185
+ registerNetworkCommands(commandExecutor.register.bind(commandExecutor), logger, config.serverUrl);
186
+
187
+ // Create Updater and register OTA update commands (Phase 20)
188
+ const updater = new Updater(logger);
189
+ registerUpdateCommands(commandExecutor.register.bind(commandExecutor), updater, wsClient, logger);
190
+
191
+ // Register RPi-specific commands when running on Raspberry Pi
192
+ if (isRaspberryPi()) {
193
+ registerRpiCommands(commandExecutor.register.bind(commandExecutor), logger);
194
+ logger.info('Raspberry Pi detected, RPi commands registered');
195
+ }
196
+
197
+ // Register serial/COM port commands (works on all platforms)
198
+ registerSerialCommands(commandExecutor.register.bind(commandExecutor), logger);
199
+
200
+ // Local hardware event server — broadcasts directly to Chrome on this device
201
+ const localEventServer = new LocalEventServer(config.localEventsPort || 3402, logger);
202
+ localEventServer.start();
203
+
204
+ // OSC bridge — listens on UDP for OSC messages and forwards triggers to display
205
+ let oscBridge: OscBridge | null = null;
206
+
207
+ const startOscBridge = (oscPort: number, oscAddress: string, oscHost?: string) => {
208
+ if (oscBridge) {
209
+ logger.info('[OSC] Stopping existing bridge before restart');
210
+ oscBridge.stop();
211
+ oscBridge = null;
212
+ }
213
+ logger.info(`[OSC] Starting bridge — UDP ${oscHost || '0.0.0.0'}:${oscPort} address: ${oscAddress}`);
214
+ oscBridge = new OscBridge({
215
+ wsClient,
216
+ logger,
217
+ port: oscPort,
218
+ host: oscHost || '0.0.0.0',
219
+ address: oscAddress,
220
+ onEvent: (event) => localEventServer.broadcast({ type: 'hardware:event', payload: event }),
221
+ });
222
+ oscBridge.start();
223
+ };
224
+
225
+ const stopOscBridge = () => {
226
+ if (oscBridge) { oscBridge.stop(); oscBridge = null; }
227
+ };
228
+
229
+ // Serial bridge — reads COM port chars (* → pickup, # → hangup) and forwards to server
230
+ let serialBridge: SerialBridge | null = null;
231
+
232
+ /** Start or restart the serial bridge with given COM port and controllerId */
233
+ const startSerialBridge = (comPort: string, controllerId: string, baudRate?: number) => {
234
+ if (serialBridge) {
235
+ logger.info(`[SERIAL] Stopping existing bridge before restart`);
236
+ serialBridge.stop();
237
+ serialBridge = null;
238
+ }
239
+ const baud = baudRate || 115200;
240
+ logger.info(`[SERIAL] Opening ${comPort} @ ${baud} baud (controllerId: ${controllerId})`);
241
+ serialBridge = new SerialBridge({
242
+ wsClient,
243
+ logger,
244
+ port: comPort,
245
+ baudRate: baud,
246
+ controllerId,
247
+ onEvent: (event) => localEventServer.broadcast({ type: 'hardware:event', payload: event }),
248
+ });
249
+ serialBridge.start();
250
+ logger.info(`[SERIAL] Bridge started — waiting for hardware events on ${comPort}`);
251
+ };
252
+
253
+ const stopSerialBridge = () => {
254
+ if (serialBridge) { serialBridge.stop(); serialBridge = null; }
255
+ };
256
+
257
+ // Register serial bridge commands
258
+ commandExecutor.register('serial:bridge-start', async (args) => {
259
+ const comPort = args?.comPort as string || args?.port as string;
260
+ const controllerId = args?.controllerId as string;
261
+ const baudRate = args?.baudRate as number;
262
+ if (!comPort) throw new Error('comPort is required');
263
+ if (!controllerId) throw new Error('controllerId is required');
264
+ startSerialBridge(comPort, controllerId, baudRate);
265
+ return { started: true, comPort, controllerId };
266
+ });
267
+
268
+ commandExecutor.register('serial:bridge-stop', async () => {
269
+ if (serialBridge) {
270
+ serialBridge.stop();
271
+ serialBridge = null;
272
+ return { stopped: true };
273
+ }
274
+ return { stopped: false, message: 'No bridge running' };
275
+ });
276
+
277
+ commandExecutor.register('serial:bridge-status', async () => {
278
+ return { running: serialBridge?.isRunning() || false };
279
+ });
280
+
281
+ // OSC bridge commands
282
+ commandExecutor.register('osc:bridge-start', async (args) => {
283
+ const oscPort = args?.oscPort as number || args?.port as number;
284
+ const oscAddress = args?.oscAddress as string || args?.address as string;
285
+ const oscHost = args?.oscHost as string;
286
+ if (!oscPort) throw new Error('oscPort is required');
287
+ if (!oscAddress) throw new Error('oscAddress is required');
288
+ startOscBridge(oscPort, oscAddress, oscHost);
289
+ return { started: true, oscPort, oscAddress };
290
+ });
291
+
292
+ commandExecutor.register('osc:bridge-stop', async () => {
293
+ if (oscBridge) {
294
+ oscBridge.stop();
295
+ oscBridge = null;
296
+ return { stopped: true };
297
+ }
298
+ return { stopped: false, message: 'No OSC bridge running' };
299
+ });
300
+
301
+ commandExecutor.register('osc:bridge-status', async () => {
302
+ return { running: oscBridge?.isRunning() || false };
303
+ });
304
+
305
+ // Create PowerScheduler for local cron-based shutdown + server-pushed power commands
306
+ const powerScheduleConfig: PowerScheduleConfig = config.powerSchedule || {
307
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
308
+ shutdownWarningSeconds: 60,
309
+ };
310
+ powerScheduler = new PowerScheduler(powerScheduleConfig, logger, wsClient);
311
+
312
+ // Register power schedule commands (server can trigger/cancel shutdown and update schedule)
313
+ commandExecutor.register('power:shutdown-now', async (args) => {
314
+ const reason = (args?.reason as string) || 'admin-command';
315
+ powerScheduler.triggerShutdown(reason);
316
+ return { shutdownTriggered: true, reason };
317
+ });
318
+
319
+ commandExecutor.register('power:cancel-shutdown', async () => {
320
+ const cancelled = powerScheduler.cancelShutdown();
321
+ return { cancelled };
322
+ });
323
+
324
+ commandExecutor.register('power:update-schedule', async (args) => {
325
+ if (args) {
326
+ powerScheduler.updateSchedule(args as Partial<PowerScheduleConfig>);
327
+ }
328
+ return { updated: true };
329
+ });
330
+
331
+ // Register built-in commands
332
+ commandExecutor.register('ping', async () => {
333
+ return { pong: true, timestamp: Date.now() };
334
+ });
335
+
336
+ commandExecutor.register('status', async () => {
337
+ const health = await healthMonitor.collect();
338
+ return {
339
+ connected: wsClient.isConnected(),
340
+ health,
341
+ };
342
+ });
343
+
344
+ commandExecutor.register('restart-agent', async () => {
345
+ logger.warn('Restart command received, exiting (systemd will restart)...');
346
+ // Delay to allow result to be sent
347
+ setTimeout(() => process.exit(0), 1000);
348
+ return { restarting: true };
349
+ });
350
+
351
+ // 6. Connect and start
352
+ wsClient.connect();
353
+ healthMonitor.start();
354
+ watchdog.start();
355
+ powerScheduler.start();
356
+
357
+ // Send registration message once connected, then auto-launch kiosk
358
+ const registerInterval = setInterval(() => {
359
+ if (wsClient.isConnected()) {
360
+ wsClient.send({
361
+ type: 'agent:register',
362
+ payload: {
363
+ agentVersion: getAgentVersion(),
364
+ screens: detectedScreens.map(s => ({
365
+ hardwareId: s.hardwareId,
366
+ name: s.name,
367
+ index: s.index,
368
+ width: s.width,
369
+ height: s.height,
370
+ x: s.x,
371
+ y: s.y,
372
+ primary: s.primary,
373
+ })),
374
+ },
375
+ timestamp: Date.now(),
376
+ });
377
+ clearInterval(registerInterval);
378
+
379
+ // Fetch device config first to decide single vs multi-screen kiosk
380
+ fetchDeviceConfig(config.serverUrl, identity, logger).then((deviceCfg) => {
381
+ // Serial bridge
382
+ if (deviceCfg && deviceCfg.comPort) {
383
+ const comPort = deviceCfg.comPort;
384
+ const controllerId = deviceCfg.controllerId || comPort;
385
+ const bridgeBaud = deviceCfg.baudRate || 115200;
386
+ logger.info(`[SERIAL] com_port found: ${comPort} | controllerId: ${controllerId} | baud: ${bridgeBaud}`);
387
+ logger.info(`[SERIAL] Starting serial bridge — listening on ${comPort}...`);
388
+ startSerialBridge(comPort, controllerId, bridgeBaud);
389
+ } else {
390
+ logger.info('[SERIAL] No com_port configured on this device — serial bridge not started');
391
+ }
392
+
393
+ // OSC bridge — auto-start if app config has inputSource === 'osc'
394
+ if (deviceCfg && deviceCfg.oscPort && deviceCfg.oscAddress) {
395
+ logger.info(`[OSC] Config found: port=${deviceCfg.oscPort} address=${deviceCfg.oscAddress}`);
396
+ startOscBridge(deviceCfg.oscPort, deviceCfg.oscAddress, deviceCfg.oscHost);
397
+ } else {
398
+ logger.info('[OSC] No OSC config on this device — OSC bridge not started');
399
+ }
400
+
401
+ // Multi-screen: if screenMap exists, use MultiScreenKioskManager — do NOT launch single kiosk
402
+ if (deviceCfg && deviceCfg.screenMap && deviceCfg.screenMap.length > 0) {
403
+ logger.info(`[MultiKiosk] Found screenMap in device config: ${deviceCfg.screenMap.length} mapping(s) — skipping single kiosk`);
404
+ watchdog.setMultiScreenActive(true);
405
+ multiScreenKiosk.applyScreenMap(deviceCfg.screenMap, identity).catch((err) => {
406
+ logger.error('[MultiKiosk] Failed to apply screenMap from config:', err);
407
+ });
408
+ return;
409
+ }
410
+
411
+ // No screenMap — launch single-screen kiosk as before
412
+ if (config.kiosk) {
413
+ if (config.kiosk.shellMode) {
414
+ logger.info('Shell mode: skipping Chrome launch (managed by Windows shell)');
415
+ kioskManager.launch().catch((err) => {
416
+ logger.error('Failed to update kiosk URL sidecar:', err);
417
+ });
418
+ } else {
419
+ logger.info('Auto-launching single kiosk browser...');
420
+ kioskManager.launch().catch((err) => {
421
+ logger.error('Failed to auto-launch kiosk:', err);
422
+ });
423
+ }
424
+ }
425
+ }).catch((err) => {
426
+ logger.warn('Could not fetch device config:', err);
427
+ // Fallback: launch single kiosk if we can't reach server
428
+ if (config.kiosk && !config.kiosk.shellMode) {
429
+ logger.info('Fallback: launching single kiosk browser...');
430
+ kioskManager.launch().catch((e) => {
431
+ logger.error('Failed to auto-launch kiosk:', e);
432
+ });
433
+ }
434
+ });
435
+ }
436
+ }, 1000);
437
+
438
+ // 7. Graceful shutdown
439
+ const shutdown = async (signal: string): Promise<void> => {
440
+ logger.info(`${signal} received. Shutting down...`);
441
+ clearInterval(registerInterval);
442
+
443
+ // Wait for updater to finish if it's busy (max 60s)
444
+ if (updater.isBusy()) {
445
+ logger.warn('Updater is busy, waiting for it to finish before exit...');
446
+ const maxWaitMs = 60_000;
447
+ const pollMs = 500;
448
+ let waited = 0;
449
+ while (updater.isBusy() && waited < maxWaitMs) {
450
+ await new Promise((r) => setTimeout(r, pollMs));
451
+ waited += pollMs;
452
+ }
453
+ if (updater.isBusy()) {
454
+ logger.error('Updater still busy after 60s, forcing shutdown');
455
+ } else {
456
+ logger.info('Updater finished, proceeding with shutdown');
457
+ }
458
+ }
459
+
460
+ logForwarder.stop();
461
+ powerScheduler.stop();
462
+ if (serialBridge) serialBridge.stop();
463
+ if (oscBridge) oscBridge.stop();
464
+ watchdog.stop();
465
+ healthMonitor.stop();
466
+ multiScreenKiosk.destroy();
467
+ kioskManager.destroy();
468
+ wsClient.close();
469
+ staticServer.stop();
470
+ serviceLauncher?.stopAll();
471
+ logger.info('Agent stopped.');
472
+ process.exit(0);
473
+ };
474
+
475
+ process.on('SIGTERM', () => { shutdown('SIGTERM'); });
476
+ process.on('SIGINT', () => { shutdown('SIGINT'); });
477
+
478
+ process.on('unhandledRejection', (reason) => {
479
+ logger.error('Unhandled rejection:', reason);
480
+ });
481
+
482
+ process.on('uncaughtException', (err) => {
483
+ logger.error('Uncaught exception:', err);
484
+ process.exit(1);
485
+ });
486
+
487
+ logger.info('LIGHTMAN Agent running.');
488
+ }
489
+
490
+ function handleServerMessage(
491
+ msg: WsMessage,
492
+ commandExecutor: CommandExecutor,
493
+ logger: Logger,
494
+ powerScheduler?: PowerScheduler,
495
+ startSerialBridge?: (comPort: string, controllerId: string, baudRate?: number) => void,
496
+ stopSerialBridge?: () => void,
497
+ startOscBridgeFn?: (oscPort: number, oscAddress: string, oscHost?: string) => void,
498
+ stopOscBridgeFn?: () => void,
499
+ multiScreenKiosk?: MultiScreenKioskManager,
500
+ getIdentity?: () => Identity,
501
+ kioskManager?: KioskManager,
502
+ watchdog?: Watchdog
503
+ ): void {
504
+ switch (msg.type) {
505
+ case 'connected':
506
+ logger.info('Server acknowledged connection');
507
+ break;
508
+ case 'command':
509
+ commandExecutor.handleCommand(msg);
510
+ break;
511
+ case 'agent:config':
512
+ if (msg.payload) {
513
+ const logLevel = msg.payload.log_level as string | undefined;
514
+ if (logLevel && ['debug', 'info', 'warn', 'error'].includes(logLevel)) {
515
+ logger.setLevel(logLevel as 'debug' | 'info' | 'warn' | 'error');
516
+ logger.info(`Log level changed to: ${logLevel}`);
517
+ }
518
+
519
+ // Admin pushed updated com_port via save
520
+ const comPort = msg.payload.com_port as string | undefined;
521
+ if (comPort && startSerialBridge) {
522
+ const controllerId = (msg.payload.controllerId as string) || comPort;
523
+ logger.info(`[SERIAL] Admin updated com_port → ${comPort} | Restarting serial bridge...`);
524
+ startSerialBridge(comPort, controllerId);
525
+ logger.info(`[SERIAL] Serial bridge now listening on ${comPort}`);
526
+ } else if (comPort === '' && stopSerialBridge) {
527
+ logger.info('[SERIAL] Admin cleared com_port — stopping serial bridge');
528
+ stopSerialBridge();
529
+ }
530
+
531
+ // Admin pushed OSC config via app config save
532
+ const oscPort = msg.payload.oscPort as number | undefined;
533
+ const oscAddress = msg.payload.oscAddress as string | undefined;
534
+ const inputSource = msg.payload.inputSource as string | undefined;
535
+ if (inputSource === 'osc' && oscPort && oscAddress && startOscBridgeFn) {
536
+ const oscHost = (msg.payload.oscHost as string) || '0.0.0.0';
537
+ logger.info(`[OSC] Admin updated OSC config → port=${oscPort} address=${oscAddress} — restarting bridge...`);
538
+ startOscBridgeFn(oscPort, oscAddress, oscHost);
539
+ } else if (inputSource === 'com' && stopOscBridgeFn) {
540
+ logger.info('[OSC] Admin switched to COM input — stopping OSC bridge');
541
+ stopOscBridgeFn();
542
+ }
543
+
544
+ // Admin pushed updated screenMap via device config save
545
+ const screenMap = msg.payload.screenMap as ScreenMapping[] | undefined;
546
+ if (screenMap && Array.isArray(screenMap) && multiScreenKiosk && getIdentity) {
547
+ if (screenMap.length > 0) {
548
+ logger.info(`[MultiKiosk] Received screenMap update: ${screenMap.length} mapping(s) — killing single kiosk`);
549
+ if (kioskManager) kioskManager.kill().catch(() => {});
550
+ if (watchdog) watchdog.setMultiScreenActive(true);
551
+ multiScreenKiosk.applyScreenMap(screenMap, getIdentity()).catch((err) => {
552
+ logger.error('[MultiKiosk] Failed to apply screenMap:', err);
553
+ });
554
+ } else {
555
+ // Empty screenMap — deactivate multi-screen, resume single kiosk
556
+ logger.info('[MultiKiosk] Empty screenMap received — deactivating multi-screen');
557
+ multiScreenKiosk.killAll().catch(() => {});
558
+ if (watchdog) watchdog.setMultiScreenActive(false);
559
+ }
560
+ }
561
+ }
562
+ break;
563
+ case 'agent:screenMap':
564
+ // Direct screenMap push from server
565
+ if (msg.payload && multiScreenKiosk && getIdentity) {
566
+ const screenMap = msg.payload.screenMap as ScreenMapping[] | undefined;
567
+ if (screenMap && Array.isArray(screenMap)) {
568
+ if (screenMap.length > 0) {
569
+ logger.info(`[MultiKiosk] Received agent:screenMap: ${screenMap.length} mapping(s) — killing single kiosk`);
570
+ if (kioskManager) kioskManager.kill().catch(() => {});
571
+ if (watchdog) watchdog.setMultiScreenActive(true);
572
+ multiScreenKiosk.applyScreenMap(screenMap, getIdentity()).catch((err) => {
573
+ logger.error('[MultiKiosk] Failed to apply screenMap:', err);
574
+ });
575
+ } else {
576
+ logger.info('[MultiKiosk] Empty agent:screenMap — deactivating multi-screen');
577
+ multiScreenKiosk.killAll().catch(() => {});
578
+ if (watchdog) watchdog.setMultiScreenActive(false);
579
+ }
580
+ }
581
+ }
582
+ break;
583
+ case 'agent:power-schedule':
584
+ // Server pushes updated power schedule
585
+ if (msg.payload && powerScheduler) {
586
+ logger.info('Received power schedule update from server');
587
+ powerScheduler.updateSchedule(msg.payload as Partial<PowerScheduleConfig>);
588
+ }
589
+ break;
590
+ default:
591
+ logger.debug(`Unknown message type: ${msg.type}`, msg);
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Fetch the device's own config from the server (uses API-key auth).
597
+ * Returns com_port, controllerId and baudRate from the device config + app config.
598
+ */
599
+ async function fetchDeviceConfig(
600
+ serverUrl: string,
601
+ identity: Identity,
602
+ logger: Logger
603
+ ): Promise<{
604
+ comPort: string;
605
+ controllerId: string;
606
+ baudRate: number;
607
+ screenMap: ScreenMapping[];
608
+ oscPort: number;
609
+ oscAddress: string;
610
+ oscHost: string;
611
+ } | null> {
612
+ try {
613
+ const url = `${serverUrl}/api/devices/${identity.deviceId}/config`;
614
+ const res = await fetch(url, {
615
+ headers: { 'Authorization': `Bearer ${identity.apiKey}` },
616
+ });
617
+ if (!res.ok) {
618
+ logger.debug(`Fetch device config failed: ${res.status}`);
619
+ return null;
620
+ }
621
+ const json = await res.json() as Record<string, unknown>;
622
+ const data = (json.data || json) as Record<string, unknown>;
623
+ const device = data.device as Record<string, unknown> | undefined;
624
+ const assignedApp = data.assignedApp as Record<string, unknown> | undefined;
625
+ const appConfig = (assignedApp?.config as Record<string, unknown>) || {};
626
+
627
+ const comPort = (device?.com_port as string) || '';
628
+
629
+ // controllerId comes from app config (MQTT topic identity), defaults to com_port
630
+ const controllerId = (appConfig.controllerId as string) || comPort;
631
+ const baudRate = (device?.baud_rate as number) || 115200;
632
+
633
+ // Screen map from device config (set by admin)
634
+ const screenMap = (device?.screenMap as ScreenMapping[]) || [];
635
+
636
+ // OSC settings from app config (custom07-osc template)
637
+ const inputSource = appConfig.inputSource as string;
638
+ const oscPort = inputSource === 'osc' ? ((appConfig.oscPort as number) || 0) : 0;
639
+ const oscAddress = inputSource === 'osc' ? ((appConfig.oscAddress as string) || '') : '';
640
+ const oscHost = (appConfig.oscHost as string) || '0.0.0.0';
641
+
642
+ return { comPort, controllerId, baudRate, screenMap, oscPort, oscAddress, oscHost };
643
+ } catch (err) {
644
+ logger.debug('Failed to fetch device config:', err);
645
+ return null;
646
+ }
647
+ }
648
+
649
+ main().catch((err) => {
650
+ console.error('Fatal error:', err);
651
+ process.exit(1);
652
+ });