@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.
- package/CHANGELOG.md +170 -0
- package/docs/README.md +360 -0
- package/docs/XMR_COMMANDS.md +303 -0
- package/docs/XMR_TESTING.md +509 -0
- package/package.json +36 -0
- package/src/index.js +2 -0
- package/src/test-utils.js +132 -0
- package/src/xmr-wrapper.js +359 -0
- package/src/xmr-wrapper.test.js +737 -0
- package/vitest.config.js +35 -0
|
@@ -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
|
+
}
|