lightman-agent 1.0.12 → 1.0.14

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.
@@ -0,0 +1,331 @@
1
+ import { execSync, spawn, type ChildProcess } from 'child_process';
2
+ import { platform } from 'os';
3
+ import { createReadStream } from 'fs';
4
+ import type { WsClient } from './websocket.js';
5
+ import type { Logger } from '../lib/logger.js';
6
+
7
+ /**
8
+ * PresenceSensor — reads line-delimited text from a USB serial presence sensor
9
+ * (HLK-LD2410B via XIAO ESP32C3) and converts state changes to events.
10
+ *
11
+ * Line mapping:
12
+ * Present → sensor:present (person detected)
13
+ * Clear → sensor:clear (person left)
14
+ * Ready. → sensor:ready (sensor booted)
15
+ *
16
+ * Events are forwarded to the server via WebSocket as `presence-sensor:event`.
17
+ * The local event callback broadcasts to Chrome via LocalEventServer.
18
+ *
19
+ * Uses PowerShell on Windows / raw file read on Linux — NO native npm dependencies.
20
+ */
21
+ export class PresenceSensor {
22
+ private wsClient: WsClient;
23
+ private logger: Logger;
24
+ private port: string;
25
+ private baudRate: number;
26
+ private running = false;
27
+ private buffer = '';
28
+ private onEvent?: (event: Record<string, unknown>) => void;
29
+ private state: 'present' | 'clear' | 'unknown' = 'unknown';
30
+ private connected = false;
31
+ private detectedPort: string | null = null;
32
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
33
+ private excludePort?: string;
34
+
35
+ // Windows-specific: PowerShell process
36
+ private psProcess: ChildProcess | null = null;
37
+
38
+ constructor(opts: {
39
+ wsClient: WsClient;
40
+ logger: Logger;
41
+ port?: string;
42
+ baudRate?: number;
43
+ onEvent?: (event: Record<string, unknown>) => void;
44
+ excludePort?: string;
45
+ }) {
46
+ this.wsClient = opts.wsClient;
47
+ this.logger = opts.logger;
48
+ this.port = opts.port || 'auto';
49
+ this.baudRate = opts.baudRate || 115200;
50
+ this.onEvent = opts.onEvent;
51
+ this.excludePort = opts.excludePort;
52
+ }
53
+
54
+ async start(): Promise<void> {
55
+ if (this.running) return;
56
+ this.running = true;
57
+ this.logger.info('[Presence] Starting sensor service...');
58
+
59
+ if (this.port === 'auto') {
60
+ this.autoDetectAndStart();
61
+ } else {
62
+ this.detectedPort = this.port;
63
+ this.openPort(this.port);
64
+ }
65
+ }
66
+
67
+ stop(): void {
68
+ this.running = false;
69
+
70
+ if (this.retryTimer) {
71
+ clearTimeout(this.retryTimer);
72
+ this.retryTimer = null;
73
+ }
74
+
75
+ if (this.psProcess) {
76
+ try { this.psProcess.kill(); } catch { /* ignore */ }
77
+ this.psProcess = null;
78
+ }
79
+
80
+ if (this.connected) {
81
+ this.connected = false;
82
+ this.emitEvent('sensor:disconnected');
83
+ }
84
+
85
+ this.logger.info('[Presence] Sensor service stopped');
86
+ }
87
+
88
+ isRunning(): boolean {
89
+ return this.running;
90
+ }
91
+
92
+ getState(): Record<string, unknown> {
93
+ return {
94
+ state: this.state,
95
+ connected: this.connected,
96
+ port: this.detectedPort,
97
+ running: this.running,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Auto-detect USB serial port by scanning platform-specific paths.
103
+ * Tries each candidate port — the ESP32C3 sends "Ready." on connect.
104
+ */
105
+ private autoDetectAndStart(): void {
106
+ if (!this.running) return;
107
+
108
+ const os = platform();
109
+ let candidates: string[] = [];
110
+
111
+ try {
112
+ if (os === 'darwin') {
113
+ // macOS: XIAO ESP32C3 appears as /dev/tty.usbmodem*
114
+ const out = execSync('ls /dev/tty.usbmodem* 2>/dev/null || true', { encoding: 'utf-8' }).trim();
115
+ candidates = out ? out.split('\n').filter(Boolean) : [];
116
+ } else if (os === 'win32') {
117
+ // Windows: enumerate COM ports via PowerShell
118
+ const out = execSync(
119
+ 'powershell -NoProfile -Command "[System.IO.Ports.SerialPort]::GetPortNames() -join \',\'"',
120
+ { encoding: 'utf-8', timeout: 5000 }
121
+ ).trim();
122
+ candidates = out ? out.split(',').filter(Boolean) : [];
123
+ } else {
124
+ // Linux: /dev/ttyACM* or /dev/ttyUSB*
125
+ const out = execSync('ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true', { encoding: 'utf-8' }).trim();
126
+ candidates = out ? out.split('\n').filter(Boolean) : [];
127
+ }
128
+ } catch {
129
+ candidates = [];
130
+ }
131
+
132
+ // Exclude ports already claimed by serial bridge
133
+ if (this.excludePort) {
134
+ candidates = candidates.filter(p => p !== this.excludePort);
135
+ }
136
+
137
+ if (candidates.length === 0) {
138
+ this.logger.debug('[Presence] No USB serial ports found, retrying in 30s...');
139
+ this.scheduleRetry();
140
+ return;
141
+ }
142
+
143
+ this.logger.info(`[Presence] Found ${candidates.length} candidate port(s): ${candidates.join(', ')}`);
144
+
145
+ // Try the first candidate — in most museum setups there's only one sensor
146
+ this.detectedPort = candidates[0];
147
+ this.openPort(candidates[0]);
148
+ }
149
+
150
+ private openPort(port: string): void {
151
+ const os = platform();
152
+ if (os === 'win32') {
153
+ this.startWindows(port);
154
+ } else {
155
+ this.startLinux(port);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Windows: Use PowerShell to open the COM port and read lines.
161
+ */
162
+ private startWindows(port: string): void {
163
+ const psScript = `
164
+ $port = New-Object System.IO.Ports.SerialPort '${port}', ${this.baudRate}, 'None', 8, 'One'
165
+ $port.ReadTimeout = 1000
166
+ $port.DtrEnable = $false
167
+ $port.RtsEnable = $false
168
+ try {
169
+ $port.Open()
170
+ [Console]::Out.WriteLine("PRESENCE_READY")
171
+ while ($true) {
172
+ try {
173
+ $line = $port.ReadLine()
174
+ [Console]::Out.WriteLine($line)
175
+ [Console]::Out.Flush()
176
+ } catch [System.TimeoutException] {
177
+ # Read timeout, just loop
178
+ }
179
+ }
180
+ } catch {
181
+ [Console]::Error.WriteLine("PRESENCE_ERROR: $_")
182
+ } finally {
183
+ if ($port.IsOpen) { $port.Close() }
184
+ }
185
+ `;
186
+
187
+ this.psProcess = spawn('powershell', [
188
+ '-NoProfile', '-NonInteractive', '-Command', psScript,
189
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
190
+
191
+ this.psProcess.stdout?.on('data', (data: Buffer) => {
192
+ const text = data.toString();
193
+ if (text.includes('PRESENCE_READY')) {
194
+ this.logger.info(`[Presence] Connected to ${port}`);
195
+ this.connected = true;
196
+ this.emitEvent('sensor:ready');
197
+ return;
198
+ }
199
+ this.processLines(text);
200
+ });
201
+
202
+ this.psProcess.stderr?.on('data', (data: Buffer) => {
203
+ const msg = data.toString().trim();
204
+ if (msg) this.logger.error(`[Presence] Error: ${msg}`);
205
+ });
206
+
207
+ this.psProcess.on('exit', (code: number | null) => {
208
+ this.logger.warn(`[Presence] PowerShell exited with code ${code}`);
209
+ this.connected = false;
210
+ this.emitEvent('sensor:disconnected');
211
+ if (this.running) {
212
+ this.logger.info('[Presence] Will restart in 3s...');
213
+ setTimeout(() => {
214
+ if (this.running) this.startWindows(port);
215
+ }, 3000);
216
+ }
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Linux/macOS: Read directly from /dev/ttyACMx or /dev/tty.usbmodemx.
222
+ * Configure baud rate with stty first.
223
+ */
224
+ private startLinux(port: string): void {
225
+ const os = platform();
226
+
227
+ try {
228
+ if (os === 'darwin') {
229
+ execSync(`stty -f ${port} ${this.baudRate} raw -echo`, { timeout: 5000 });
230
+ } else {
231
+ execSync(`stty -F ${port} ${this.baudRate} raw -echo`, { timeout: 5000 });
232
+ }
233
+ } catch (err) {
234
+ this.logger.error(`[Presence] Failed to configure ${port}:`, err);
235
+ this.scheduleRetry();
236
+ return;
237
+ }
238
+
239
+ this.logger.info(`[Presence] Connected to ${port}`);
240
+ this.connected = true;
241
+ this.emitEvent('sensor:ready');
242
+
243
+ const stream = createReadStream(port, { encoding: 'utf-8' });
244
+
245
+ stream.on('data', (chunk: string | Buffer) => {
246
+ this.processLines(typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
247
+ });
248
+
249
+ stream.on('error', (err: Error) => {
250
+ this.logger.error(`[Presence] Read error: ${err.message}`);
251
+ this.connected = false;
252
+ this.emitEvent('sensor:disconnected');
253
+ if (this.running) {
254
+ this.logger.info('[Presence] Will retry in 5s...');
255
+ setTimeout(() => {
256
+ if (this.running) {
257
+ if (this.port === 'auto') {
258
+ this.autoDetectAndStart();
259
+ } else {
260
+ this.startLinux(port);
261
+ }
262
+ }
263
+ }, 5000);
264
+ }
265
+ });
266
+
267
+ stream.on('close', () => {
268
+ this.logger.warn('[Presence] Stream closed');
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Process incoming text as line-delimited messages.
274
+ * Accumulates into buffer, splits on newlines.
275
+ */
276
+ private processLines(text: string): void {
277
+ this.buffer += text;
278
+ const lines = this.buffer.split('\n');
279
+ // Keep the last incomplete line in buffer
280
+ this.buffer = lines.pop() || '';
281
+
282
+ for (const raw of lines) {
283
+ const line = raw.trim();
284
+ if (!line) continue;
285
+
286
+ if (line === 'Present') {
287
+ if (this.state !== 'present') {
288
+ this.state = 'present';
289
+ this.emitEvent('sensor:present');
290
+ }
291
+ } else if (line === 'Clear') {
292
+ if (this.state !== 'clear') {
293
+ this.state = 'clear';
294
+ this.emitEvent('sensor:clear');
295
+ }
296
+ } else if (line === 'Ready.') {
297
+ this.logger.info('[Presence] Sensor ready signal received');
298
+ this.connected = true;
299
+ this.emitEvent('sensor:ready');
300
+ }
301
+ // Ignore unknown lines
302
+ }
303
+ }
304
+
305
+ private emitEvent(type: string): void {
306
+ const event: Record<string, unknown> = {
307
+ type,
308
+ state: this.state,
309
+ timestamp: Date.now(),
310
+ };
311
+
312
+ this.logger.info(`[Presence] ${type} (state: ${this.state})`);
313
+
314
+ // Send to server (for analytics/logging)
315
+ this.wsClient.send({
316
+ type: 'presence-sensor:event',
317
+ payload: event,
318
+ timestamp: Date.now(),
319
+ });
320
+
321
+ // Broadcast locally to Chrome display
322
+ this.onEvent?.(event);
323
+ }
324
+
325
+ private scheduleRetry(): void {
326
+ if (!this.running) return;
327
+ this.retryTimer = setTimeout(() => {
328
+ if (this.running) this.autoDetectAndStart();
329
+ }, 30_000);
330
+ }
331
+ }