@xiboplayer/xmr 0.6.4 → 0.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/xmr",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "XMR WebSocket client for real-time Xibo CMS commands",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@xibosignage/xibo-communication-framework": "^0.0.6",
13
- "@xiboplayer/utils": "0.6.4"
13
+ "@xiboplayer/utils": "0.6.5"
14
14
  },
15
15
  "devDependencies": {
16
16
  "vitest": "^2.0.0"
package/src/test-utils.js CHANGED
@@ -89,7 +89,7 @@ export function createMockPlayer() {
89
89
  refreshDataConnectors: vi.fn(),
90
90
  reportGeoLocation: vi.fn(() => Promise.resolve()),
91
91
  requestGeoLocation: vi.fn(() => Promise.resolve({ latitude: 41.3851, longitude: 2.1734 })),
92
- updateStatus: vi.fn()
92
+ emit: vi.fn()
93
93
  };
94
94
  }
95
95
 
@@ -4,6 +4,10 @@
4
4
  * Integrates the official @xibosignage/xibo-communication-framework
5
5
  * to enable real-time push commands from CMS via WebSocket.
6
6
  *
7
+ * Connection lifecycle is delegated to the framework, which has a
8
+ * built-in 60s health-check interval that reconnects automatically.
9
+ * This wrapper only routes events to player callbacks.
10
+ *
7
11
  * Supported commands:
8
12
  * - collectNow: Trigger immediate XMDS collection cycle
9
13
  * - screenShot/screenshot: Capture and upload screenshot
@@ -35,61 +39,40 @@ export class XmrWrapper {
35
39
  this.player = player;
36
40
  this.xmr = null;
37
41
  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
42
  }
46
43
 
47
44
  /**
48
- * Initialize and start XMR connection
45
+ * Initialize and start XMR connection.
46
+ *
47
+ * Creates a single Xmr instance and lets the framework manage
48
+ * reconnection via its internal 60s health-check timer.
49
+ * Calling start() again on an already-running instance is safe —
50
+ * the framework skips if already connected to the same URL.
51
+ *
49
52
  * @param {string} xmrUrl - WebSocket URL (ws:// or wss://)
50
53
  * @param {string} cmsKey - CMS authentication key
51
54
  * @returns {Promise<boolean>} Success status
52
55
  */
53
56
  async start(xmrUrl, cmsKey) {
54
57
  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)
58
+ // Reuse existing instance — the framework handles reconnection.
59
+ // Only create a new instance on first call or after stop().
71
60
  if (!this.xmr) {
61
+ log.info('Initializing connection to:', xmrUrl);
72
62
  const channel = this.config.xmrChannel || `player-${this.config.hardwareKey}`;
73
63
  this.xmr = new Xmr(channel);
74
- // Setup event handlers before connecting (only once)
75
64
  this.setupEventHandlers();
65
+ await this.xmr.init();
76
66
  }
77
67
 
78
- // Initialize and connect
79
- await this.xmr.init();
80
68
  await this.xmr.start(xmrUrl, cmsKey);
81
-
82
69
  this.connected = true;
83
- this.reconnectAttempts = 0;
84
70
  log.info('Connected successfully');
85
71
 
86
72
  return true;
87
73
  } catch (error) {
88
74
  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);
75
+ log.info('Framework will retry automatically every 60s');
93
76
 
94
77
  return false;
95
78
  }
@@ -105,21 +88,13 @@ export class XmrWrapper {
105
88
  this.xmr.on('connected', () => {
106
89
  log.info('WebSocket connected');
107
90
  this.connected = true;
108
- this.reconnectAttempts = 0;
109
- this.player.updateStatus?.('XMR connected');
91
+ this.player.emit?.('xmr-status', { connected: true });
110
92
  });
111
93
 
112
94
  this.xmr.on('disconnected', () => {
113
- log.warn('WebSocket disconnected');
95
+ log.warn('WebSocket disconnected (framework will reconnect)');
114
96
  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
- }
97
+ this.player.emit?.('xmr-status', { connected: false });
123
98
  });
124
99
 
125
100
  this.xmr.on('error', (error) => {
@@ -151,8 +126,6 @@ export class XmrWrapper {
151
126
  // CMS command: License Check (no-op for Linux clients)
152
127
  this.xmr.on('licenceCheck', () => {
153
128
  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
129
  });
157
130
 
158
131
  // CMS command: Change Layout
@@ -220,7 +193,6 @@ export class XmrWrapper {
220
193
  const commandCode = data?.commandCode || data;
221
194
  log.info('Received commandAction command:', commandCode);
222
195
  try {
223
- // Use local commands from RegisterDisplay (stored on player), not XMR payload commands
224
196
  const localCommands = this.player.displayCommands || data?.commands;
225
197
  await this.player.executeCommand(commandCode, localCommands);
226
198
  log.debug('commandAction completed successfully');
@@ -279,7 +251,6 @@ export class XmrWrapper {
279
251
  this.xmr.on('criteriaUpdate', async (data) => {
280
252
  log.info('Received criteriaUpdate command:', data);
281
253
  try {
282
- // Trigger immediate collection to get updated display criteria
283
254
  await this.player.collect();
284
255
  log.debug('criteriaUpdate completed successfully');
285
256
  } catch (error) {
@@ -296,7 +267,6 @@ export class XmrWrapper {
296
267
  const hasCoordinates = data && data.latitude != null && data.longitude != null;
297
268
 
298
269
  if (hasCoordinates) {
299
- // CMS is pushing coordinates to us
300
270
  if (this.player.reportGeoLocation) {
301
271
  this.player.reportGeoLocation(data);
302
272
  log.debug('currentGeoLocation: coordinates applied');
@@ -304,7 +274,6 @@ export class XmrWrapper {
304
274
  log.warn('Geo location reporting not implemented in player');
305
275
  }
306
276
  } else {
307
- // CMS is asking us to report our location via browser API
308
277
  if (this.player.requestGeoLocation) {
309
278
  await this.player.requestGeoLocation();
310
279
  log.debug('currentGeoLocation: browser location requested');
@@ -319,50 +288,17 @@ export class XmrWrapper {
319
288
  }
320
289
 
321
290
  /**
322
- * Schedule reconnection attempt
323
- */
324
- scheduleReconnect(xmrUrl, cmsKey) {
325
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
326
- log.warn('Max reconnection attempts reached, giving up');
327
- log.info('Will retry on next collection cycle');
328
- return;
329
- }
330
-
331
- // Cancel any existing reconnect timer
332
- if (this.reconnectTimer) {
333
- clearTimeout(this.reconnectTimer);
334
- }
335
-
336
- this.reconnectAttempts++;
337
- const delay = this.reconnectDelay * this.reconnectAttempts;
338
-
339
- log.debug(`Scheduling reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
340
-
341
- this.reconnectTimer = setTimeout(() => {
342
- log.debug('Attempting to reconnect...');
343
- this.reconnectTimer = null;
344
- this.start(xmrUrl, cmsKey);
345
- }, delay);
346
- }
347
-
348
- /**
349
- * Stop XMR connection
291
+ * Stop XMR connection and clean up the framework instance.
292
+ * The framework's internal 60s timer is cleared when the instance
293
+ * is discarded, so no reconnection will occur after stop().
350
294
  */
351
295
  async stop() {
352
- // Mark as intentional shutdown to prevent reconnection
353
- this.intentionalShutdown = true;
354
-
355
- // Cancel any pending reconnect attempts
356
- if (this.reconnectTimer) {
357
- clearTimeout(this.reconnectTimer);
358
- this.reconnectTimer = null;
359
- }
360
-
361
296
  if (!this.xmr) return;
362
297
 
363
298
  try {
364
299
  await this.xmr.stop();
365
300
  this.connected = false;
301
+ this.xmr = null;
366
302
  log.info('Stopped');
367
303
  } catch (error) {
368
304
  log.error('Error stopping:', error);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * XmrWrapper Tests
3
3
  *
4
- * Comprehensive testing for XMR WebSocket integration
5
- * Tests connection lifecycle, all CMS commands, reconnection logic, and error handling
4
+ * Tests connection lifecycle, all CMS commands, and error handling.
5
+ * Reconnection is delegated to the framework's built-in 60s health-check.
6
6
  */
7
7
 
8
8
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -84,13 +84,6 @@ describe('XmrWrapper', () => {
84
84
  expect(wrapper.connected).toBe(false);
85
85
  expect(wrapper.xmr).toBeNull();
86
86
  });
87
-
88
- it('should initialize reconnection properties', () => {
89
- expect(wrapper.reconnectAttempts).toBe(0);
90
- expect(wrapper.maxReconnectAttempts).toBe(10);
91
- expect(wrapper.reconnectDelay).toBe(5000);
92
- expect(wrapper.reconnectTimer).toBeNull();
93
- });
94
87
  });
95
88
 
96
89
  describe('start(xmrUrl, cmsKey)', () => {
@@ -102,27 +95,27 @@ describe('XmrWrapper', () => {
102
95
  expect(wrapper.xmr).toBeDefined();
103
96
  expect(wrapper.xmr.init).toHaveBeenCalled();
104
97
  expect(wrapper.xmr.start).toHaveBeenCalledWith('wss://test.xmr.com', 'cms-key-123');
105
- expect(wrapper.reconnectAttempts).toBe(0);
106
98
  });
107
99
 
108
- it('should save connection details for reconnection', async () => {
100
+ it('should reuse existing xmr instance on subsequent start() calls', async () => {
101
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
102
+ const firstInstance = wrapper.xmr;
103
+
109
104
  await wrapper.start('wss://test.xmr.com', 'cms-key-123');
110
105
 
111
- expect(wrapper.lastXmrUrl).toBe('wss://test.xmr.com');
112
- expect(wrapper.lastCmsKey).toBe('cms-key-123');
106
+ expect(wrapper.xmr).toBe(firstInstance);
107
+ expect(firstInstance.init).toHaveBeenCalledTimes(1);
108
+ expect(firstInstance.start).toHaveBeenCalledTimes(2);
113
109
  });
114
110
 
115
- it('should reuse existing xmr instance on reconnect', async () => {
111
+ it('should create new instance after stop()', async () => {
116
112
  await wrapper.start('wss://test.xmr.com', 'cms-key-123');
117
113
  const firstInstance = wrapper.xmr;
118
114
 
119
- // Simulate disconnect
120
- wrapper.connected = false;
121
-
115
+ await wrapper.stop();
122
116
  await wrapper.start('wss://test.xmr.com', 'cms-key-123');
123
- const secondInstance = wrapper.xmr;
124
117
 
125
- expect(firstInstance).toBe(secondInstance);
118
+ expect(wrapper.xmr).not.toBe(firstInstance);
126
119
  });
127
120
 
128
121
  it('should use custom xmrChannel if provided', async () => {
@@ -144,40 +137,18 @@ describe('XmrWrapper', () => {
144
137
  });
145
138
 
146
139
  it('should handle connection failure gracefully', async () => {
147
- const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
148
- await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
149
-
150
- // Make start fail by replacing the start method
151
- if (newWrapper.xmr) {
152
- newWrapper.xmr.start = vi.fn(() => Promise.reject(new Error('Connection failed')));
153
- newWrapper.connected = false;
154
-
155
- const result = await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
156
- expect(result).toBe(false);
157
- }
158
- });
159
-
160
- it('should schedule reconnect on failure', async () => {
161
- const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
162
- await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
140
+ // Hook setupEventHandlers to patch the freshly-created Xmr instance
141
+ // (init is an instance property, so prototype patching doesn't work)
142
+ const origSetup = wrapper.setupEventHandlers.bind(wrapper);
143
+ wrapper.setupEventHandlers = function() {
144
+ origSetup.call(this);
145
+ this.xmr.init = vi.fn(() => Promise.reject(new Error('Connection failed')));
146
+ };
163
147
 
164
- // Simulate failure and check reconnect
165
- if (newWrapper.xmr) {
166
- newWrapper.xmr.start = vi.fn(() => Promise.reject(new Error('Connection failed')));
167
- newWrapper.connected = false;
168
-
169
- await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
170
- expect(newWrapper.reconnectTimer).toBeDefined();
171
- }
172
- });
173
-
174
- it('should cancel pending reconnect timer on new start', async () => {
175
- wrapper.reconnectTimer = setTimeout(() => {}, 5000);
176
- const timerId = wrapper.reconnectTimer;
177
-
178
- await wrapper.start('wss://test.xmr.com', 'cms-key-123');
148
+ const result = await wrapper.start('wss://test.xmr.com', 'cms-key-123');
149
+ expect(result).toBe(false);
179
150
 
180
- expect(wrapper.reconnectTimer).toBeNull();
151
+ wrapper.setupEventHandlers = origSetup;
181
152
  });
182
153
  });
183
154
 
@@ -194,8 +165,7 @@ describe('XmrWrapper', () => {
194
165
  xmrInstance.simulateCommand('connected');
195
166
 
196
167
  expect(wrapper.connected).toBe(true);
197
- expect(wrapper.reconnectAttempts).toBe(0);
198
- expect(mockPlayer.updateStatus).toHaveBeenCalledWith('XMR connected');
168
+ expect(mockPlayer.emit).toHaveBeenCalledWith('xmr-status', { connected: true });
199
169
  });
200
170
 
201
171
  it('should handle disconnected event', () => {
@@ -204,15 +174,7 @@ describe('XmrWrapper', () => {
204
174
  xmrInstance.simulateCommand('disconnected');
205
175
 
206
176
  expect(wrapper.connected).toBe(false);
207
- expect(mockPlayer.updateStatus).toHaveBeenCalledWith('XMR disconnected (polling mode)');
208
- });
209
-
210
- it('should schedule reconnect on disconnect', () => {
211
- wrapper.connected = true;
212
-
213
- xmrInstance.simulateCommand('disconnected');
214
-
215
- expect(wrapper.reconnectTimer).toBeDefined();
177
+ expect(mockPlayer.emit).toHaveBeenCalledWith('xmr-status', { connected: false });
216
178
  });
217
179
 
218
180
  it('should handle error event', () => {
@@ -591,69 +553,17 @@ describe('XmrWrapper', () => {
591
553
  });
592
554
  });
593
555
 
594
- describe('Reconnection Logic', () => {
595
- it('should schedule reconnect with exponential backoff', async () => {
596
- const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
597
- await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
598
-
599
- // Make subsequent starts fail
600
- if (newWrapper.xmr) {
601
- newWrapper.xmr.start = vi.fn(() => Promise.reject(new Error('Connection failed')));
602
- newWrapper.connected = false;
603
-
604
- // First reconnect attempt
605
- await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
606
- expect(newWrapper.reconnectAttempts).toBe(1);
607
-
608
- // Second reconnect attempt (should have longer delay)
609
- await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
610
- expect(newWrapper.reconnectAttempts).toBe(2);
611
- }
612
- });
613
-
614
- it('should stop reconnecting after max attempts', async () => {
615
- wrapper.reconnectAttempts = wrapper.maxReconnectAttempts;
616
-
617
- wrapper.scheduleReconnect('wss://test.xmr.com', 'cms-key-123');
618
-
619
- expect(wrapper.reconnectTimer).toBeNull();
620
- });
621
-
622
- it('should cancel existing timer before scheduling new reconnect', () => {
623
- wrapper.reconnectTimer = setTimeout(() => {}, 5000);
624
- const firstTimer = wrapper.reconnectTimer;
625
-
626
- wrapper.scheduleReconnect('wss://test.xmr.com', 'cms-key-123');
627
-
628
- expect(wrapper.reconnectTimer).not.toBe(firstTimer);
629
- });
630
-
631
- it('should reset reconnect attempts on successful connection', async () => {
632
- wrapper.reconnectAttempts = 5;
633
-
634
- await wrapper.start('wss://test.xmr.com', 'cms-key-123');
635
-
636
- expect(wrapper.reconnectAttempts).toBe(0);
637
- });
638
- });
639
-
640
556
  describe('stop()', () => {
641
- it('should stop XMR connection', async () => {
557
+ it('should stop XMR connection and null out instance', async () => {
642
558
  await wrapper.start('wss://test.xmr.com', 'cms-key-123');
559
+ const xmrRef = wrapper.xmr;
643
560
  wrapper.connected = true;
644
561
 
645
562
  await wrapper.stop();
646
563
 
647
- expect(wrapper.xmr.stop).toHaveBeenCalled();
564
+ expect(xmrRef.stop).toHaveBeenCalled();
648
565
  expect(wrapper.connected).toBe(false);
649
- });
650
-
651
- it('should cancel pending reconnect timer', async () => {
652
- wrapper.reconnectTimer = setTimeout(() => {}, 5000);
653
-
654
- await wrapper.stop();
655
-
656
- expect(wrapper.reconnectTimer).toBeNull();
566
+ expect(wrapper.xmr).toBeNull();
657
567
  });
658
568
 
659
569
  it('should handle stop when not started', async () => {
@@ -662,7 +572,7 @@ describe('XmrWrapper', () => {
662
572
 
663
573
  it('should handle stop errors gracefully', async () => {
664
574
  await wrapper.start('wss://test.xmr.com', 'cms-key-123');
665
- wrapper.xmr.stop.mockRejectedValue(new Error('Stop failed'));
575
+ wrapper.xmr.stop = vi.fn(() => Promise.reject(new Error('Stop failed')));
666
576
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
667
577
 
668
578
  await wrapper.stop();
@@ -773,25 +683,11 @@ describe('XmrWrapper', () => {
773
683
  });
774
684
 
775
685
  describe('Memory Management', () => {
776
- it('should clean up timers on stop', async () => {
777
- wrapper.reconnectTimer = setTimeout(() => {}, 5000);
778
-
779
- await wrapper.stop();
780
-
781
- expect(wrapper.reconnectTimer).toBeNull();
782
- });
783
-
784
- it('should allow garbage collection after stop', async () => {
686
+ it('should null out xmr instance on stop', async () => {
785
687
  await wrapper.start('wss://test.xmr.com', 'cms-key-123');
786
688
  await wrapper.stop();
787
689
 
788
- // The disconnected event handler may schedule a reconnect,
789
- // but stop() should cancel it
790
- // Allow up to a small window for async cleanup
791
- await vi.runAllTimersAsync();
792
-
793
- // After all timers run and stop completes, timer should be null
794
- expect(wrapper.reconnectTimer).toBeNull();
690
+ expect(wrapper.xmr).toBeNull();
795
691
  });
796
692
  });
797
693
  });