@xiboplayer/xmr 0.1.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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * XMR (Xibo Message Relay) Wrapper
3
+ *
4
+ * Integrates the official @xibosignage/xibo-communication-framework
5
+ * to enable real-time push commands from CMS via WebSocket.
6
+ *
7
+ * Supported commands:
8
+ * - collectNow: Trigger immediate XMDS collection cycle
9
+ * - screenShot/screenshot: Capture and upload screenshot
10
+ * - licenceCheck: No-op for Linux clients (always valid)
11
+ * - changeLayout: Switch to a specific layout immediately
12
+ * - overlayLayout: Push overlay layout on top of current content
13
+ * - revertToSchedule: Return to normal scheduled content
14
+ * - purgeAll: Clear all cached files and re-download
15
+ * - commandAction: Execute a player command (HTTP only in browser)
16
+ * - triggerWebhook: Fire a webhook trigger action
17
+ * - dataUpdate: Force refresh of data connectors
18
+ * - rekey: RSA key pair rotation (for XMR encryption)
19
+ * - criteriaUpdate: Update display criteria and re-collect
20
+ * - currentGeoLocation: Report current geo location to CMS
21
+ */
22
+
23
+ import { Xmr } from '@xibosignage/xibo-communication-framework';
24
+ import { createLogger } from '@xiboplayer/utils';
25
+
26
+ const log = createLogger('XMR');
27
+
28
+ export class XmrWrapper {
29
+ /**
30
+ * @param {Object} config - Player configuration
31
+ * @param {Object} player - Player instance for callbacks
32
+ */
33
+ constructor(config, player) {
34
+ this.config = config;
35
+ this.player = player;
36
+ this.xmr = null;
37
+ this.connected = false;
38
+ this.reconnectAttempts = 0;
39
+ this.maxReconnectAttempts = 10;
40
+ this.reconnectDelay = 5000; // 5 seconds
41
+ this.lastXmrUrl = null;
42
+ this.lastCmsKey = null;
43
+ this.reconnectTimer = null;
44
+ this.intentionalShutdown = false;
45
+ }
46
+
47
+ /**
48
+ * Initialize and start XMR connection
49
+ * @param {string} xmrUrl - WebSocket URL (ws:// or wss://)
50
+ * @param {string} cmsKey - CMS authentication key
51
+ * @returns {Promise<boolean>} Success status
52
+ */
53
+ async start(xmrUrl, cmsKey) {
54
+ try {
55
+ log.info('Initializing connection to:', xmrUrl);
56
+
57
+ // Clear intentional shutdown flag (we're starting again)
58
+ this.intentionalShutdown = false;
59
+
60
+ // Save connection details for reconnection
61
+ this.lastXmrUrl = xmrUrl;
62
+ this.lastCmsKey = cmsKey;
63
+
64
+ // Cancel any pending reconnect attempts
65
+ if (this.reconnectTimer) {
66
+ clearTimeout(this.reconnectTimer);
67
+ this.reconnectTimer = null;
68
+ }
69
+
70
+ // Create XMR instance with channel ID (or reuse if already exists)
71
+ if (!this.xmr) {
72
+ const channel = this.config.xmrChannel || `player-${this.config.hardwareKey}`;
73
+ this.xmr = new Xmr(channel);
74
+ // Setup event handlers before connecting (only once)
75
+ this.setupEventHandlers();
76
+ }
77
+
78
+ // Initialize and connect
79
+ await this.xmr.init();
80
+ await this.xmr.start(xmrUrl, cmsKey);
81
+
82
+ this.connected = true;
83
+ this.reconnectAttempts = 0;
84
+ log.info('Connected successfully');
85
+
86
+ return true;
87
+ } catch (error) {
88
+ log.warn('Failed to start:', error.message);
89
+ log.info('Continuing in polling mode (XMDS only)');
90
+
91
+ // Schedule reconnection attempt
92
+ this.scheduleReconnect(xmrUrl, cmsKey);
93
+
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Setup event handlers for CMS commands
100
+ */
101
+ setupEventHandlers() {
102
+ if (!this.xmr) return;
103
+
104
+ // Connection events
105
+ this.xmr.on('connected', () => {
106
+ log.info('WebSocket connected');
107
+ this.connected = true;
108
+ this.reconnectAttempts = 0;
109
+ this.player.updateStatus?.('XMR connected');
110
+ });
111
+
112
+ this.xmr.on('disconnected', () => {
113
+ log.warn('WebSocket disconnected');
114
+ this.connected = false;
115
+ this.player.updateStatus?.('XMR disconnected (polling mode)');
116
+
117
+ // Attempt to reconnect if we have the connection details
118
+ // BUT not if this was an intentional shutdown
119
+ if (this.lastXmrUrl && this.lastCmsKey && !this.intentionalShutdown) {
120
+ log.info('Connection lost, scheduling reconnection...');
121
+ this.scheduleReconnect(this.lastXmrUrl, this.lastCmsKey);
122
+ }
123
+ });
124
+
125
+ this.xmr.on('error', (error) => {
126
+ log.error('WebSocket error:', error);
127
+ });
128
+
129
+ // CMS command: Collect Now
130
+ this.xmr.on('collectNow', async () => {
131
+ log.info('Received collectNow command from CMS');
132
+ try {
133
+ await this.player.collect();
134
+ log.debug('collectNow completed successfully');
135
+ } catch (error) {
136
+ log.error('collectNow failed:', error);
137
+ }
138
+ });
139
+
140
+ // CMS command: Screenshot
141
+ this.xmr.on('screenShot', async () => {
142
+ log.info('Received screenShot command from CMS');
143
+ try {
144
+ await this.player.captureScreenshot();
145
+ log.debug('screenShot completed successfully');
146
+ } catch (error) {
147
+ log.error('screenShot failed:', error);
148
+ }
149
+ });
150
+
151
+ // CMS command: License Check (no-op for Linux clients)
152
+ this.xmr.on('licenceCheck', () => {
153
+ log.debug('Received licenceCheck (no-op for Linux client)');
154
+ // Linux clients always report valid license
155
+ // No action needed - clientType: "linux" bypasses commercial license
156
+ });
157
+
158
+ // CMS command: Change Layout
159
+ this.xmr.on('changeLayout', async (layoutId) => {
160
+ log.info('Received changeLayout command:', layoutId);
161
+ try {
162
+ await this.player.changeLayout(layoutId);
163
+ log.debug('changeLayout completed successfully');
164
+ } catch (error) {
165
+ log.error('changeLayout failed:', error);
166
+ }
167
+ });
168
+
169
+ // CMS command: Overlay Layout
170
+ this.xmr.on('overlayLayout', async (layoutId) => {
171
+ log.info('Received overlayLayout command:', layoutId);
172
+ try {
173
+ await this.player.overlayLayout(layoutId);
174
+ log.debug('overlayLayout completed successfully');
175
+ } catch (error) {
176
+ log.error('overlayLayout failed:', error);
177
+ }
178
+ });
179
+
180
+ // CMS command: Revert to Schedule
181
+ this.xmr.on('revertToSchedule', async () => {
182
+ log.info('Received revertToSchedule command');
183
+ try {
184
+ await this.player.revertToSchedule();
185
+ log.debug('revertToSchedule completed successfully');
186
+ } catch (error) {
187
+ log.error('revertToSchedule failed:', error);
188
+ }
189
+ });
190
+
191
+ // CMS command: Purge All
192
+ this.xmr.on('purgeAll', async () => {
193
+ log.info('Received purgeAll command');
194
+ try {
195
+ await this.player.purgeAll();
196
+ log.debug('purgeAll completed successfully');
197
+ } catch (error) {
198
+ log.error('purgeAll failed:', error);
199
+ }
200
+ });
201
+
202
+ // CMS command: Execute Command
203
+ this.xmr.on('commandAction', async (data) => {
204
+ log.info('Received commandAction command:', data);
205
+ try {
206
+ await this.player.executeCommand(data?.commandCode || data, data?.commands);
207
+ log.debug('commandAction completed successfully');
208
+ } catch (error) {
209
+ log.error('commandAction failed:', error);
210
+ }
211
+ });
212
+
213
+ // CMS command: Trigger Webhook
214
+ this.xmr.on('triggerWebhook', async (data) => {
215
+ log.info('Received triggerWebhook command:', data);
216
+ try {
217
+ this.player.triggerWebhook(data?.triggerCode || data);
218
+ log.debug('triggerWebhook completed successfully');
219
+ } catch (error) {
220
+ log.error('triggerWebhook failed:', error);
221
+ }
222
+ });
223
+
224
+ // CMS command: Data Update (force refresh data connectors)
225
+ this.xmr.on('dataUpdate', async () => {
226
+ log.info('Received dataUpdate command');
227
+ try {
228
+ this.player.refreshDataConnectors();
229
+ log.debug('dataUpdate completed successfully');
230
+ } catch (error) {
231
+ log.error('dataUpdate failed:', error);
232
+ }
233
+ });
234
+
235
+ // CMS command: Rekey
236
+ this.xmr.on('rekey', () => {
237
+ log.debug('Received rekey command (pubKey rotation)');
238
+ // TODO: Implement RSA key pair rotation if XMR encryption is needed
239
+ });
240
+
241
+ // CMS command: Screen Shot (alternative event name)
242
+ this.xmr.on('screenshot', async () => {
243
+ log.info('Received screenshot command from CMS');
244
+ try {
245
+ await this.player.captureScreenshot();
246
+ } catch (error) {
247
+ log.error('screenshot failed:', error);
248
+ }
249
+ });
250
+
251
+ // CMS command: Criteria Update
252
+ this.xmr.on('criteriaUpdate', async (data) => {
253
+ log.info('Received criteriaUpdate command:', data);
254
+ try {
255
+ // Trigger immediate collection to get updated display criteria
256
+ await this.player.collect();
257
+ log.debug('criteriaUpdate completed successfully');
258
+ } catch (error) {
259
+ log.error('criteriaUpdate failed:', error);
260
+ }
261
+ });
262
+
263
+ // CMS command: Current Geo Location
264
+ this.xmr.on('currentGeoLocation', async (data) => {
265
+ log.info('Received currentGeoLocation command:', data);
266
+ try {
267
+ // Report current geo location to CMS
268
+ // For now, log the request - actual implementation would use navigator.geolocation
269
+ if (this.player.reportGeoLocation) {
270
+ await this.player.reportGeoLocation(data);
271
+ log.debug('currentGeoLocation completed successfully');
272
+ } else {
273
+ log.warn('Geo location reporting not implemented in player');
274
+ }
275
+ } catch (error) {
276
+ log.error('currentGeoLocation failed:', error);
277
+ }
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Schedule reconnection attempt
283
+ */
284
+ scheduleReconnect(xmrUrl, cmsKey) {
285
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
286
+ log.warn('Max reconnection attempts reached, giving up');
287
+ log.info('Will retry on next collection cycle');
288
+ return;
289
+ }
290
+
291
+ // Cancel any existing reconnect timer
292
+ if (this.reconnectTimer) {
293
+ clearTimeout(this.reconnectTimer);
294
+ }
295
+
296
+ this.reconnectAttempts++;
297
+ const delay = this.reconnectDelay * this.reconnectAttempts;
298
+
299
+ log.debug(`Scheduling reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
300
+
301
+ this.reconnectTimer = setTimeout(() => {
302
+ log.debug('Attempting to reconnect...');
303
+ this.reconnectTimer = null;
304
+ this.start(xmrUrl, cmsKey);
305
+ }, delay);
306
+ }
307
+
308
+ /**
309
+ * Stop XMR connection
310
+ */
311
+ async stop() {
312
+ // Mark as intentional shutdown to prevent reconnection
313
+ this.intentionalShutdown = true;
314
+
315
+ // Cancel any pending reconnect attempts
316
+ if (this.reconnectTimer) {
317
+ clearTimeout(this.reconnectTimer);
318
+ this.reconnectTimer = null;
319
+ }
320
+
321
+ if (!this.xmr) return;
322
+
323
+ try {
324
+ await this.xmr.stop();
325
+ this.connected = false;
326
+ log.info('Stopped');
327
+ } catch (error) {
328
+ log.error('Error stopping:', error);
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Check if XMR is connected
334
+ * @returns {boolean}
335
+ */
336
+ isConnected() {
337
+ return this.connected;
338
+ }
339
+
340
+ /**
341
+ * Send a message to CMS (if needed for future features)
342
+ * @param {string} action - Action name
343
+ * @param {Object} data - Data payload
344
+ */
345
+ async send(action, data) {
346
+ if (!this.connected || !this.xmr) {
347
+ log.warn('Cannot send - not connected');
348
+ return false;
349
+ }
350
+
351
+ try {
352
+ await this.xmr.send(action, data);
353
+ return true;
354
+ } catch (error) {
355
+ log.error('Error sending:', error);
356
+ return false;
357
+ }
358
+ }
359
+ }