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
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
|
+
});
|