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
@@ -0,0 +1,260 @@
1
+ import { execFile } from 'child_process';
2
+ import { getPlatform } from '../lib/platform.js';
3
+ import type { PowerScheduleConfig } from '../lib/types.js';
4
+ import type { Logger } from '../lib/logger.js';
5
+ import type { WsClient } from './websocket.js';
6
+
7
+ /**
8
+ * PowerScheduler — handles local cron-based shutdown and server-pushed power commands.
9
+ *
10
+ * Shutdown flow:
11
+ * 1. Every minute, check if current time matches shutdownCron
12
+ * 2. If match: send warning to server, wait shutdownWarningSeconds, then shut down
13
+ * 3. Also listens for server-pushed "system:shutdown" via the command executor (separate)
14
+ *
15
+ * The server can also override/update the schedule at runtime via WebSocket.
16
+ */
17
+ export class PowerScheduler {
18
+ private config: PowerScheduleConfig;
19
+ private logger: Logger;
20
+ private wsClient: WsClient;
21
+ private timer: NodeJS.Timeout | null = null;
22
+ private shutdownPending = false;
23
+ private shutdownTimer: NodeJS.Timeout | null = null;
24
+
25
+ constructor(config: PowerScheduleConfig, logger: Logger, wsClient: WsClient) {
26
+ this.config = config;
27
+ this.logger = logger;
28
+ this.wsClient = wsClient;
29
+ }
30
+
31
+ start(): void {
32
+ if (!this.config.shutdownCron) {
33
+ this.logger.info('PowerScheduler: no shutdownCron configured, skipping');
34
+ return;
35
+ }
36
+
37
+ this.logger.info(
38
+ `PowerScheduler started (shutdown: "${this.config.shutdownCron}", tz: ${this.config.timezone})`
39
+ );
40
+
41
+ // Check every 30 seconds (cron resolution is 1 minute, but we check more often to not miss)
42
+ this.timer = setInterval(() => {
43
+ this.checkSchedule();
44
+ }, 30_000);
45
+
46
+ // Also check immediately
47
+ this.checkSchedule();
48
+ }
49
+
50
+ stop(): void {
51
+ if (this.timer) {
52
+ clearInterval(this.timer);
53
+ this.timer = null;
54
+ }
55
+ if (this.shutdownTimer) {
56
+ clearTimeout(this.shutdownTimer);
57
+ this.shutdownTimer = null;
58
+ }
59
+ this.shutdownPending = false;
60
+ this.logger.info('PowerScheduler stopped');
61
+ }
62
+
63
+ /**
64
+ * Update the schedule at runtime (e.g., from server push).
65
+ */
66
+ updateSchedule(newConfig: Partial<PowerScheduleConfig>): void {
67
+ if (newConfig.shutdownCron !== undefined) {
68
+ this.config.shutdownCron = newConfig.shutdownCron;
69
+ }
70
+ if (newConfig.startupCron !== undefined) {
71
+ this.config.startupCron = newConfig.startupCron;
72
+ }
73
+ if (newConfig.timezone !== undefined) {
74
+ this.config.timezone = newConfig.timezone;
75
+ }
76
+ if (newConfig.shutdownWarningSeconds !== undefined) {
77
+ this.config.shutdownWarningSeconds = newConfig.shutdownWarningSeconds;
78
+ }
79
+ this.logger.info(`PowerScheduler schedule updated: shutdown="${this.config.shutdownCron}"`);
80
+ }
81
+
82
+ /**
83
+ * Trigger shutdown immediately (called by server command or local schedule).
84
+ */
85
+ triggerShutdown(reason: string): void {
86
+ if (this.shutdownPending) {
87
+ this.logger.info('Shutdown already pending, ignoring duplicate trigger');
88
+ return;
89
+ }
90
+
91
+ this.shutdownPending = true;
92
+ const warningSeconds = this.config.shutdownWarningSeconds ?? 60;
93
+
94
+ this.logger.warn(`Shutdown triggered (${reason}), warning period: ${warningSeconds}s`);
95
+
96
+ // Notify server that shutdown is imminent
97
+ this.wsClient.send({
98
+ type: 'agent:shutdown-warning',
99
+ payload: {
100
+ reason,
101
+ shutdownInSeconds: warningSeconds,
102
+ },
103
+ timestamp: Date.now(),
104
+ });
105
+
106
+ this.shutdownTimer = setTimeout(() => {
107
+ this.executeShutdown(reason);
108
+ }, warningSeconds * 1_000);
109
+ }
110
+
111
+ /**
112
+ * Cancel a pending scheduled shutdown.
113
+ */
114
+ cancelShutdown(): boolean {
115
+ if (!this.shutdownPending || !this.shutdownTimer) {
116
+ return false;
117
+ }
118
+ clearTimeout(this.shutdownTimer);
119
+ this.shutdownTimer = null;
120
+ this.shutdownPending = false;
121
+
122
+ this.logger.info('Scheduled shutdown cancelled');
123
+ this.wsClient.send({
124
+ type: 'agent:shutdown-cancelled',
125
+ payload: {},
126
+ timestamp: Date.now(),
127
+ });
128
+ return true;
129
+ }
130
+
131
+ isShutdownPending(): boolean {
132
+ return this.shutdownPending;
133
+ }
134
+
135
+ // --- Private ---
136
+
137
+ private checkSchedule(): void {
138
+ if (!this.config.shutdownCron || this.shutdownPending) return;
139
+
140
+ const now = this.getNowInTimezone();
141
+ if (this.matchesCron(this.config.shutdownCron, now)) {
142
+ this.triggerShutdown('local-schedule');
143
+ }
144
+ }
145
+
146
+ private executeShutdown(reason: string): void {
147
+ this.logger.warn(`Executing system shutdown (reason: ${reason})`);
148
+
149
+ // Notify server before going down
150
+ this.wsClient.send({
151
+ type: 'agent:shutting-down',
152
+ payload: { reason },
153
+ timestamp: Date.now(),
154
+ });
155
+
156
+ const platform = getPlatform();
157
+ let bin: string;
158
+ let args: string[];
159
+
160
+ switch (platform) {
161
+ case 'windows':
162
+ bin = 'shutdown';
163
+ args = ['/s', '/t', '5', '/c', `LIGHTMAN scheduled shutdown: ${reason}`];
164
+ break;
165
+ case 'darwin':
166
+ bin = 'shutdown';
167
+ args = ['-h', '+1'];
168
+ break;
169
+ default:
170
+ bin = 'shutdown';
171
+ args = ['-h', 'now'];
172
+ }
173
+
174
+ // Give WS message time to send, then shut down
175
+ setTimeout(() => {
176
+ execFile(bin, args, (err) => {
177
+ if (err) {
178
+ this.logger.error('Shutdown exec failed:', err.message);
179
+ this.shutdownPending = false;
180
+ }
181
+ });
182
+ }, 2_000);
183
+ }
184
+
185
+ /**
186
+ * Get current date/time components in the configured timezone.
187
+ */
188
+ private getNowInTimezone(): { minute: number; hour: number; dayOfMonth: number; month: number; dayOfWeek: number } {
189
+ const tz = this.config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
190
+ const now = new Date();
191
+
192
+ // Use Intl to get components in the target timezone
193
+ const parts = new Intl.DateTimeFormat('en-US', {
194
+ timeZone: tz,
195
+ hour: 'numeric',
196
+ minute: 'numeric',
197
+ day: 'numeric',
198
+ month: 'numeric',
199
+ weekday: 'short',
200
+ hour12: false,
201
+ }).formatToParts(now);
202
+
203
+ const get = (type: string) => {
204
+ const part = parts.find((p) => p.type === type);
205
+ return part ? parseInt(part.value, 10) : 0;
206
+ };
207
+
208
+ const weekdayStr = parts.find((p) => p.type === 'weekday')?.value || '';
209
+ const dayOfWeekMap: Record<string, number> = {
210
+ Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
211
+ };
212
+
213
+ return {
214
+ minute: get('minute'),
215
+ hour: get('hour'),
216
+ dayOfMonth: get('day'),
217
+ month: get('month'),
218
+ dayOfWeek: dayOfWeekMap[weekdayStr] ?? 0,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Simple 5-field cron matcher: minute hour dayOfMonth month dayOfWeek
224
+ * Supports: *, numbers, ranges (1-5), steps (asterisk/5), lists (1,3,5)
225
+ */
226
+ private matchesCron(
227
+ expr: string,
228
+ now: { minute: number; hour: number; dayOfMonth: number; month: number; dayOfWeek: number }
229
+ ): boolean {
230
+ const fields = expr.trim().split(/\s+/);
231
+ if (fields.length !== 5) return false;
232
+
233
+ const values = [now.minute, now.hour, now.dayOfMonth, now.month, now.dayOfWeek];
234
+
235
+ return fields.every((field, i) => this.matchesCronField(field, values[i]));
236
+ }
237
+
238
+ private matchesCronField(field: string, value: number): boolean {
239
+ // Handle list: "1,3,5"
240
+ const parts = field.split(',');
241
+ return parts.some((part) => {
242
+ // Handle step: "*/5" or "1-10/2"
243
+ const [rangeOrWild, stepStr] = part.split('/');
244
+ const step = stepStr ? parseInt(stepStr, 10) : 1;
245
+
246
+ if (rangeOrWild === '*') {
247
+ return value % step === 0;
248
+ }
249
+
250
+ // Handle range: "1-5"
251
+ if (rangeOrWild.includes('-')) {
252
+ const [lo, hi] = rangeOrWild.split('-').map(Number);
253
+ return value >= lo && value <= hi && (value - lo) % step === 0;
254
+ }
255
+
256
+ // Single value
257
+ return parseInt(rangeOrWild, 10) === value;
258
+ });
259
+ }
260
+ }
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import type { AgentConfig, Identity } from '../lib/types.js';
3
+ import { readIdentity, writeIdentity } from '../lib/identity.js';
4
+ import type { Logger } from '../lib/logger.js';
5
+
6
+ const provisionResponseSchema = z.object({
7
+ deviceId: z.string().uuid(),
8
+ apiKey: z.string().min(1),
9
+ });
10
+
11
+ interface ProvisionResult {
12
+ identity: Identity;
13
+ fromCache: boolean;
14
+ }
15
+
16
+ /**
17
+ * Provision the agent: check for cached identity, otherwise call CMS provisioning endpoints.
18
+ * If pairing is required, polls until admin approves (with timeout).
19
+ */
20
+ export async function provision(
21
+ config: AgentConfig,
22
+ logger: Logger
23
+ ): Promise<ProvisionResult> {
24
+ // Check for cached identity first
25
+ const cached = readIdentity(config.identityFile);
26
+ if (cached) {
27
+ logger.info('Using cached identity', { deviceId: cached.deviceId });
28
+ return { identity: cached, fromCache: true };
29
+ }
30
+
31
+ logger.info(`Provisioning device: ${config.deviceSlug}`);
32
+
33
+ const baseUrl = `${config.serverUrl}/api/devices/provision/${encodeURIComponent(config.deviceSlug)}`;
34
+
35
+ // Step 1: Request provisioning
36
+ const res = await fetch(baseUrl);
37
+ if (!res.ok) {
38
+ const body = await res.text();
39
+ throw new Error(`Provision request failed (${res.status}): ${body}`);
40
+ }
41
+
42
+ const data = await res.json() as Record<string, unknown>;
43
+
44
+ // Auto-provisioned (IP match)
45
+ if (data.deviceId && data.apiKey) {
46
+ const parsed = provisionResponseSchema.safeParse(data);
47
+ if (!parsed.success) {
48
+ throw new Error('Invalid provision response: ' + parsed.error.message);
49
+ }
50
+ const identity: Identity = {
51
+ deviceId: parsed.data.deviceId,
52
+ apiKey: parsed.data.apiKey,
53
+ };
54
+ writeIdentity(config.identityFile, identity);
55
+ logger.info('Auto-provisioned', { deviceId: identity.deviceId });
56
+ return { identity, fromCache: false };
57
+ }
58
+
59
+ // Pairing required — display code and poll
60
+ if (data.requiresPairing && data.code) {
61
+ const code = data.code as string;
62
+ logger.warn(`Pairing required. Enter code in admin UI: ${code}`);
63
+ logger.info('Waiting for admin to approve pairing...');
64
+
65
+ const identity = await pollForPairing(
66
+ `${baseUrl}/status?code=${encodeURIComponent(code)}`,
67
+ logger
68
+ );
69
+
70
+ writeIdentity(config.identityFile, identity);
71
+ logger.info('Pairing complete', { deviceId: identity.deviceId });
72
+ return { identity, fromCache: false };
73
+ }
74
+
75
+ throw new Error('Unexpected provision response: ' + JSON.stringify(data));
76
+ }
77
+
78
+ async function pollForPairing(
79
+ statusUrl: string,
80
+ logger: Logger,
81
+ timeoutMs = 600_000,
82
+ intervalMs = 5_000
83
+ ): Promise<Identity> {
84
+ const deadline = Date.now() + timeoutMs;
85
+
86
+ while (Date.now() < deadline) {
87
+ await sleep(intervalMs);
88
+
89
+ try {
90
+ const res = await fetch(statusUrl);
91
+ if (!res.ok) {
92
+ logger.warn(`Pairing poll failed (${res.status}), retrying...`);
93
+ continue;
94
+ }
95
+
96
+ const data = await res.json() as Record<string, unknown>;
97
+
98
+ if (data.deviceId && data.apiKey) {
99
+ const parsed = provisionResponseSchema.safeParse(data);
100
+ if (!parsed.success) {
101
+ throw new Error('Invalid pairing response: ' + parsed.error.message);
102
+ }
103
+ return {
104
+ deviceId: parsed.data.deviceId,
105
+ apiKey: parsed.data.apiKey,
106
+ };
107
+ }
108
+
109
+ logger.debug('Pairing not yet complete, polling again...');
110
+ } catch (err) {
111
+ logger.warn('Pairing poll error, retrying...', err);
112
+ }
113
+ }
114
+
115
+ throw new Error('Pairing timed out after ' + (timeoutMs / 1000) + 's');
116
+ }
117
+
118
+ function sleep(ms: number): Promise<void> {
119
+ return new Promise((resolve) => setTimeout(resolve, ms));
120
+ }
@@ -0,0 +1,230 @@
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
+ * SerialBridge — reads raw characters from a COM/serial port and converts them
9
+ * to hardware events (monophone:pickup, monophone:hangup, button:press).
10
+ *
11
+ * Character mapping:
12
+ * * (asterisk) → monophone:pickup (handset lifted)
13
+ * # (hash) → monophone:hangup (handset replaced)
14
+ * 1-9 → button:press with buttonId 1-9
15
+ *
16
+ * Events are forwarded to the server via WebSocket as `serial-bridge:event`.
17
+ * The server then publishes them to MQTT so the display app receives them.
18
+ *
19
+ * Uses PowerShell on Windows / raw file read on Linux — NO native npm dependencies.
20
+ */
21
+ export class SerialBridge {
22
+ private wsClient: WsClient;
23
+ private logger: Logger;
24
+ private port: string;
25
+ private baudRate: number;
26
+ private controllerId: string;
27
+ private running = false;
28
+ private reader: ReturnType<typeof setInterval> | null = null;
29
+ private buffer = '';
30
+ private onEvent?: (event: Record<string, unknown>) => void;
31
+
32
+ // Windows-specific: PowerShell process for reading serial
33
+ private psProcess: ChildProcess | null = null;
34
+
35
+ constructor(opts: {
36
+ wsClient: WsClient;
37
+ logger: Logger;
38
+ port: string;
39
+ baudRate?: number;
40
+ controllerId: string;
41
+ onEvent?: (event: Record<string, unknown>) => void;
42
+ }) {
43
+ this.wsClient = opts.wsClient;
44
+ this.logger = opts.logger;
45
+ this.port = opts.port;
46
+ this.baudRate = opts.baudRate || 115200;
47
+ this.controllerId = opts.controllerId;
48
+ this.onEvent = opts.onEvent;
49
+ }
50
+
51
+ async start(): Promise<void> {
52
+ if (this.running) return;
53
+ this.running = true;
54
+
55
+ this.logger.info(`Serial bridge starting: ${this.port} @ ${this.baudRate} baud → controllerId: ${this.controllerId}`);
56
+
57
+ const os = platform();
58
+ if (os === 'win32') {
59
+ this.startWindows();
60
+ } else {
61
+ this.startLinux();
62
+ }
63
+ }
64
+
65
+ stop(): void {
66
+ this.running = false;
67
+
68
+ if (this.psProcess) {
69
+ try { this.psProcess.kill(); } catch { /* ignore */ }
70
+ this.psProcess = null;
71
+ }
72
+
73
+ if (this.reader) {
74
+ clearInterval(this.reader);
75
+ this.reader = null;
76
+ }
77
+
78
+ this.logger.info(`Serial bridge stopped: ${this.port}`);
79
+ }
80
+
81
+ isRunning(): boolean {
82
+ return this.running;
83
+ }
84
+
85
+ /**
86
+ * Windows: Use PowerShell to open the COM port and stream data line by line.
87
+ * This avoids needing the `serialport` npm package.
88
+ */
89
+ private startWindows(): void {
90
+ // PowerShell script that opens COM port and writes each received char to stdout
91
+ const psScript = `
92
+ $port = New-Object System.IO.Ports.SerialPort '${this.port}', ${this.baudRate}, 'None', 8, 'One'
93
+ $port.ReadTimeout = 1000
94
+ $port.DtrEnable = $true
95
+ $port.RtsEnable = $true
96
+ try {
97
+ $port.Open()
98
+ [Console]::Out.WriteLine("SERIAL_BRIDGE_READY")
99
+ while ($true) {
100
+ try {
101
+ $char = [char]$port.ReadChar()
102
+ [Console]::Out.Write($char)
103
+ [Console]::Out.Flush()
104
+ } catch [System.TimeoutException] {
105
+ # Read timeout, just loop
106
+ }
107
+ }
108
+ } catch {
109
+ [Console]::Error.WriteLine("SERIAL_ERROR: $_")
110
+ } finally {
111
+ if ($port.IsOpen) { $port.Close() }
112
+ }
113
+ `;
114
+
115
+ this.psProcess = spawn('powershell', [
116
+ '-NoProfile', '-NonInteractive', '-Command', psScript,
117
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
118
+
119
+ this.psProcess.stdout?.on('data', (data: Buffer) => {
120
+ const text = data.toString();
121
+
122
+ // Check for ready signal
123
+ if (text.includes('SERIAL_BRIDGE_READY')) {
124
+ this.logger.info(`Serial bridge connected to ${this.port}`);
125
+ // Remove the ready signal from the text before processing
126
+ const cleaned = text.replace('SERIAL_BRIDGE_READY', '').replace(/[\r\n]/g, '');
127
+ if (cleaned) this.processChars(cleaned);
128
+ return;
129
+ }
130
+
131
+ this.processChars(text);
132
+ });
133
+
134
+ this.psProcess.stderr?.on('data', (data: Buffer) => {
135
+ const msg = data.toString().trim();
136
+ if (msg) {
137
+ this.logger.error(`Serial bridge error: ${msg}`);
138
+ }
139
+ });
140
+
141
+ this.psProcess.on('exit', (code: number | null) => {
142
+ this.logger.warn(`Serial bridge PowerShell exited with code ${code}`);
143
+ if (this.running) {
144
+ // Auto-restart after 3 seconds
145
+ this.logger.info('Serial bridge will restart in 3s...');
146
+ setTimeout(() => {
147
+ if (this.running) this.startWindows();
148
+ }, 3000);
149
+ }
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Linux: Read directly from /dev/ttyUSBx or /dev/ttyACMx.
155
+ * Configure baud rate with stty first.
156
+ */
157
+ private startLinux(): void {
158
+ try {
159
+ execSync(`stty -F ${this.port} ${this.baudRate} raw -echo`, { timeout: 5000 });
160
+ } catch (err) {
161
+ this.logger.error(`Failed to configure serial port ${this.port}:`, err);
162
+ return;
163
+ }
164
+
165
+ this.logger.info(`Serial bridge connected to ${this.port}`);
166
+
167
+ const stream = createReadStream(this.port, { encoding: 'utf-8' });
168
+
169
+ stream.on('data', (chunk: string | Buffer) => {
170
+ this.processChars(typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
171
+ });
172
+
173
+ stream.on('error', (err: Error) => {
174
+ this.logger.error(`Serial bridge read error: ${err.message}`);
175
+ if (this.running) {
176
+ this.logger.info('Serial bridge will restart in 3s...');
177
+ setTimeout(() => {
178
+ if (this.running) this.startLinux();
179
+ }, 3000);
180
+ }
181
+ });
182
+
183
+ stream.on('close', () => {
184
+ this.logger.warn('Serial bridge stream closed');
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Process received characters and emit hardware events.
190
+ *
191
+ * * → monophone:pickup
192
+ * # → monophone:hangup
193
+ * 1-9 → button:press (buttonId = digit)
194
+ */
195
+ private processChars(text: string): void {
196
+ for (const char of text) {
197
+ if (char === '*') {
198
+ this.emitEvent('monophone:pickup');
199
+ } else if (char === '#') {
200
+ this.emitEvent('monophone:hangup');
201
+ } else if (char >= '1' && char <= '9') {
202
+ this.emitEvent('button:press', parseInt(char, 10));
203
+ }
204
+ // Ignore all other characters (newlines, spaces, noise)
205
+ }
206
+ }
207
+
208
+ private emitEvent(type: string, buttonId?: number): void {
209
+ const event: Record<string, unknown> = {
210
+ type,
211
+ controllerId: this.controllerId,
212
+ timestamp: Date.now(),
213
+ };
214
+ if (buttonId !== undefined) {
215
+ event.buttonId = buttonId;
216
+ }
217
+
218
+ this.logger.info(`Serial bridge event: ${type}${buttonId !== undefined ? ` buttonId=${buttonId}` : ''} (${this.port} → ${this.controllerId})`);
219
+
220
+ // Send to server (for admin UI, MQTT, etc.)
221
+ this.wsClient.send({
222
+ type: 'serial-bridge:event',
223
+ payload: event,
224
+ timestamp: Date.now(),
225
+ });
226
+
227
+ // Broadcast locally to Chrome display directly
228
+ this.onEvent?.(event);
229
+ }
230
+ }