lightman-agent 1.0.4 → 1.0.6

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 (115) hide show
  1. package/agent.config.template.json +30 -30
  2. package/package.json +52 -52
  3. package/public/assets/index-CcBNCz6h.css +1 -1
  4. package/public/assets/index-D9QHMG8k.js +1 -0
  5. package/public/assets/index-H-8HDl46.js +1 -1
  6. package/public/assets/index-YodeiCia.css +1 -0
  7. package/public/assets/index-legacy-DWtNM8y7.js +41 -0
  8. package/public/assets/museum-map-CwVDA2z1.svg +4182 -0
  9. package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -0
  10. package/public/index.html +7 -2
  11. package/public/templates/custom08/elements/back-button.svg +20 -0
  12. package/public/templates/custom08/elements/base-map-background.svg +37 -0
  13. package/public/templates/custom08/elements/base-map.svg +1191 -0
  14. package/public/templates/custom08/elements/gallery-1-2-3-info-panel.svg +236 -0
  15. package/public/templates/custom08/elements/gallery-4-5-6-7-info-panel.svg +266 -0
  16. package/public/templates/custom08/elements/gallery-8-9-info-panel.svg +274 -0
  17. package/public/templates/custom08/elements/gallery-labels/_nav-map-styles.css +554 -0
  18. package/public/templates/custom08/elements/gallery-labels/_styles.css +556 -0
  19. package/public/templates/custom08/elements/gallery-labels/gallery-1.svg +35 -0
  20. package/public/templates/custom08/elements/gallery-labels/gallery-2.svg +34 -0
  21. package/public/templates/custom08/elements/gallery-labels/gallery-3.svg +34 -0
  22. package/public/templates/custom08/elements/gallery-labels/gallery-4.svg +37 -0
  23. package/public/templates/custom08/elements/gallery-labels/gallery-5.svg +34 -0
  24. package/public/templates/custom08/elements/gallery-labels/gallery-6.svg +34 -0
  25. package/public/templates/custom08/elements/gallery-labels/gallery-7.svg +34 -0
  26. package/public/templates/custom08/elements/gallery-labels/gallery-8.svg +37 -0
  27. package/public/templates/custom08/elements/gallery-labels/gallery-9.svg +34 -0
  28. package/public/templates/custom08/elements/hand-hint.png +0 -0
  29. package/public/templates/custom08/elements/idle-screen-bg.svg +5 -0
  30. package/public/templates/custom08/elements/idle-screen-map.svg +627 -0
  31. package/public/templates/custom08/elements/idle-screen-text.svg +350 -0
  32. package/public/templates/custom08/elements/key-map-1.svg +986 -0
  33. package/public/templates/custom08/elements/key-map-2.svg +1018 -0
  34. package/public/templates/custom08/elements/key-map-3.svg +1019 -0
  35. package/public/templates/custom08/elements/key-map-combined.svg +1001 -0
  36. package/public/templates/custom08/elements/map-highlight-marker.svg +11 -0
  37. package/public/templates/custom08/elements/map-pin-marker.svg +15 -0
  38. package/public/templates/custom08/elements/map-teardrop-star-marker.svg +13 -0
  39. package/public/templates/custom08/elements/nav-circle-galleries-1-3.svg +21 -0
  40. package/public/templates/custom08/elements/nav-circle-galleries-4-7.svg +24 -0
  41. package/public/templates/custom08/elements/nav-circle-galleries-8-9.svg +20 -0
  42. package/public/templates/custom08/elements/section1-map.svg +1435 -0
  43. package/public/templates/custom08/elements/section2-map.svg +1724 -0
  44. package/public/templates/custom08/elements/section3-map.svg +1295 -0
  45. package/public/templates/custom08/fonts/CabinetGrotesk-Variable.ttf +0 -0
  46. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.12_PM.png +0 -0
  47. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.56_PM.png +0 -0
  48. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.24.24_PM.png +0 -0
  49. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.31.58_PM.jpg +0 -0
  50. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.11_PM.jpg +0 -0
  51. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.36_PM.jpg +0 -0
  52. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.48_PM.jpg +0 -0
  53. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.59_PM.jpg +0 -0
  54. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.15_PM.jpg +0 -0
  55. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.27_PM.jpg +0 -0
  56. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.34_PM.jpg +0 -0
  57. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.42_PM.jpg +0 -0
  58. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.50_PM.jpg +0 -0
  59. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.58_PM.jpg +0 -0
  60. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.04_PM.jpg +0 -0
  61. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.11_PM.jpg +0 -0
  62. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.20_PM.jpg +0 -0
  63. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.57_PM.jpg +0 -0
  64. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.03_PM.jpg +0 -0
  65. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.16_PM.jpg +0 -0
  66. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.23_PM.jpg +0 -0
  67. package/public/templates/custom08/images/highlights/prologue-highlight.png +0 -0
  68. package/scripts/guardian.ps1 +75 -75
  69. package/scripts/install-linux.sh +134 -134
  70. package/scripts/install-rpi.sh +117 -117
  71. package/scripts/install-windows.ps1 +513 -512
  72. package/scripts/launch-kiosk.vbs +101 -101
  73. package/scripts/lightman-agent.logrotate +12 -12
  74. package/scripts/lightman-agent.service +38 -38
  75. package/scripts/lightman-shell.bat +107 -107
  76. package/scripts/reinstall-windows.ps1 +26 -26
  77. package/scripts/restore-desktop.ps1 +32 -32
  78. package/scripts/setup.ps1 +116 -116
  79. package/scripts/setup.sh +115 -115
  80. package/scripts/sync-display.mjs +20 -0
  81. package/scripts/uninstall-linux.sh +50 -50
  82. package/scripts/uninstall-windows.ps1 +54 -54
  83. package/src/commands/display.ts +177 -177
  84. package/src/commands/kiosk.ts +113 -113
  85. package/src/commands/maintenance.ts +106 -106
  86. package/src/commands/network.ts +129 -129
  87. package/src/commands/power.ts +163 -163
  88. package/src/commands/rpi.ts +45 -45
  89. package/src/commands/screenshot.ts +166 -166
  90. package/src/commands/serial.ts +17 -17
  91. package/src/commands/update.ts +124 -124
  92. package/src/index.ts +652 -652
  93. package/src/lib/config.ts +69 -69
  94. package/src/lib/identity.ts +40 -40
  95. package/src/lib/logger.ts +137 -137
  96. package/src/lib/platform.ts +10 -10
  97. package/src/lib/rpi.ts +180 -180
  98. package/src/lib/screens.ts +128 -128
  99. package/src/lib/types.ts +176 -176
  100. package/src/services/commands.ts +107 -107
  101. package/src/services/health.ts +161 -161
  102. package/src/services/kiosk.ts +384 -384
  103. package/src/services/localEvents.ts +60 -60
  104. package/src/services/logForwarder.ts +72 -72
  105. package/src/services/multiScreenKiosk.ts +324 -324
  106. package/src/services/oscBridge.ts +186 -186
  107. package/src/services/powerScheduler.ts +260 -260
  108. package/src/services/provisioning.ts +120 -120
  109. package/src/services/serialBridge.ts +230 -230
  110. package/src/services/serviceLauncher.ts +183 -183
  111. package/src/services/staticServer.ts +226 -226
  112. package/src/services/updater.ts +249 -249
  113. package/src/services/watchdog.ts +310 -310
  114. package/src/services/websocket.ts +152 -152
  115. package/tsconfig.json +28 -28
@@ -1,230 +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
- }
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
+ }