@xiboplayer/xmr 0.6.10 → 0.6.12
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 +40 -14
- package/LICENSE +661 -0
- package/README.md +32 -1
- package/docs/XMR_COMMANDS.md +16 -10
- package/docs/XMR_TESTING.md +20 -12
- package/package.json +2 -3
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/test-utils.js +5 -3
- package/src/xmr-client.js +179 -0
- package/src/xmr-client.test.js +387 -0
- package/src/xmr-wrapper.js +7 -5
- package/src/xmr-wrapper.test.js +6 -4
package/README.md
CHANGED
|
@@ -103,9 +103,40 @@ new XmrWrapper(config, player)
|
|
|
103
103
|
|
|
104
104
|
## Dependencies
|
|
105
105
|
|
|
106
|
-
- `@xibosignage/xibo-communication-framework` -- XMR protocol implementation
|
|
107
106
|
- `@xiboplayer/utils` -- logger
|
|
108
107
|
|
|
108
|
+
No external XMR dependencies. The native `XmrClient` (`xmr-client.js`) replaces the
|
|
109
|
+
upstream `@xibosignage/xibo-communication-framework`, providing a complete implementation
|
|
110
|
+
with generic action dispatch and zero third-party dependencies (no luxon, no nanoevents).
|
|
111
|
+
|
|
112
|
+
### Upstream comparison
|
|
113
|
+
|
|
114
|
+
| Feature | upstream `xibo-communication-framework@0.0.6` | native `XmrClient` |
|
|
115
|
+
|---------|------------------------------------------------|---------------------|
|
|
116
|
+
| `collectNow` | hardcoded | generic dispatch |
|
|
117
|
+
| `screenShot` | hardcoded | generic dispatch |
|
|
118
|
+
| `licenceCheck` | hardcoded | generic dispatch |
|
|
119
|
+
| `criteriaUpdate` | hardcoded | generic dispatch |
|
|
120
|
+
| `commandAction` (showStatusWindow) | hardcoded, splits commandCode | generic dispatch (full message) |
|
|
121
|
+
| `commandAction` (forceUpdateChromeOS) | hardcoded, splits commandCode | generic dispatch (full message) |
|
|
122
|
+
| `commandAction` (currentGeoLocation) | hardcoded, splits commandCode | generic dispatch (full message) |
|
|
123
|
+
| `commandAction` (other) | **missing** -- `console.error('unknown action')` | generic dispatch (full message) |
|
|
124
|
+
| `changeLayout` | **missing** -- `console.error('unknown action')` | generic dispatch |
|
|
125
|
+
| `overlayLayout` | **missing** -- `console.error('unknown action')` | generic dispatch |
|
|
126
|
+
| `revertToSchedule` | **missing** -- `console.error('unknown action')` | generic dispatch |
|
|
127
|
+
| `purgeAll` | **missing** -- `console.error('unknown action')` | generic dispatch |
|
|
128
|
+
| `triggerWebhook` | **missing** -- `console.error('unknown action')` | generic dispatch |
|
|
129
|
+
| `dataUpdate` | **missing** -- `console.error('unknown action')` | generic dispatch |
|
|
130
|
+
| `rekeyAction` | **missing** -- `console.error('unknown action')` | generic dispatch |
|
|
131
|
+
| Any future CMS action | requires library update | works automatically |
|
|
132
|
+
| TTL check | luxon `DateTime.fromISO().plus()` (68KB) | native `Date.parse()` (0KB) |
|
|
133
|
+
| Event emitter | nanoevents (external) | built-in `Map<Set>` (0KB) |
|
|
134
|
+
| Bundle size impact | ~70KB (luxon + nanoevents + framework) | ~2KB |
|
|
135
|
+
| Reconnect interval | 60s `setInterval` | 60s `setInterval` (same) |
|
|
136
|
+
| `isActive()` check | 15min silence threshold | 15min silence threshold (same) |
|
|
137
|
+
| Init handshake | `{type:'init', key, channel}` | `{type:'init', key, channel}` (same) |
|
|
138
|
+
| Heartbeat handling | `"H"` → update lastMessageAt | `"H"` → update lastMessageAt (same) |
|
|
139
|
+
|
|
109
140
|
---
|
|
110
141
|
|
|
111
142
|
[xiboplayer.org](https://xiboplayer.org) · **Part of the [XiboPlayer SDK](https://github.com/xibo-players/xiboplayer)**
|
package/docs/XMR_COMMANDS.md
CHANGED
|
@@ -119,7 +119,7 @@ xmr.send('rekey');
|
|
|
119
119
|
|
|
120
120
|
---
|
|
121
121
|
|
|
122
|
-
### criteriaUpdate (
|
|
122
|
+
### criteriaUpdate (since v0.9.0)
|
|
123
123
|
|
|
124
124
|
Updates display criteria and triggers immediate re-collection.
|
|
125
125
|
|
|
@@ -142,7 +142,7 @@ await player.collect(); // Gets new criteria from registerDisplay
|
|
|
142
142
|
|
|
143
143
|
---
|
|
144
144
|
|
|
145
|
-
### currentGeoLocation (
|
|
145
|
+
### currentGeoLocation (since v0.9.0)
|
|
146
146
|
|
|
147
147
|
Reports current geographic location to CMS.
|
|
148
148
|
|
|
@@ -285,19 +285,25 @@ To add support for a new XMR command:
|
|
|
285
285
|
|
|
286
286
|
## Version History
|
|
287
287
|
|
|
288
|
-
### v0.0
|
|
289
|
-
- ✅
|
|
290
|
-
- ✅
|
|
288
|
+
### v0.10.0 (Current)
|
|
289
|
+
- ✅ Replaced upstream library with native `XmrClient` (generic action dispatch)
|
|
290
|
+
- ✅ All 14 CMS commands supported (upstream only handled 5)
|
|
291
|
+
- ✅ Any future CMS action works automatically (no code changes needed)
|
|
292
|
+
- ✅ Eliminated luxon (68KB) and nanoevents dependencies
|
|
293
|
+
- ✅ 82 tests (26 XmrClient + 56 XmrWrapper)
|
|
294
|
+
|
|
295
|
+
### v0.9.0
|
|
296
|
+
- ✅ Added `criteriaUpdate` and `currentGeoLocation` commands
|
|
291
297
|
- ✅ Intentional shutdown flag (no reconnect on stop)
|
|
292
|
-
- ✅ Comprehensive test suite
|
|
298
|
+
- ✅ Comprehensive test suite
|
|
293
299
|
|
|
294
|
-
### v0.0
|
|
295
|
-
-
|
|
300
|
+
### v0.8.0
|
|
301
|
+
- Initial implementation with full command set
|
|
296
302
|
- Connection lifecycle management
|
|
297
|
-
- Automatic reconnection with
|
|
303
|
+
- Automatic reconnection with 60s health-check interval
|
|
298
304
|
|
|
299
305
|
## References
|
|
300
306
|
|
|
301
|
-
- **XMR
|
|
307
|
+
- **XMR Client**: Native `XmrClient` (`xmr-client.js`) — full XMR protocol implementation
|
|
302
308
|
- **Xibo CMS**: https://xibosignage.com
|
|
303
309
|
- **XMR Protocol**: WebSocket-based push messaging
|
package/docs/XMR_TESTING.md
CHANGED
|
@@ -32,24 +32,32 @@ npm run test:watch
|
|
|
32
32
|
|
|
33
33
|
### Test Coverage
|
|
34
34
|
|
|
35
|
-
Current coverage: **
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- ✅ **
|
|
39
|
-
- ✅ **
|
|
40
|
-
- ✅ **
|
|
41
|
-
- ✅ **
|
|
42
|
-
- ✅ **
|
|
43
|
-
- ✅ **
|
|
35
|
+
Current coverage: **82 test cases** (26 XmrClient + 56 XmrWrapper)
|
|
36
|
+
|
|
37
|
+
**XmrClient** (`xmr-client.test.js`):
|
|
38
|
+
- ✅ **start()** (5 tests) - WebSocket open, init handshake, close, error, reconnect
|
|
39
|
+
- ✅ **Message handling** (7 tests) - Heartbeat, JSON dispatch, TTL check, generic dispatch, commandAction, malformed JSON
|
|
40
|
+
- ✅ **isActive()** (3 tests) - Connected + recent, 15min timeout, not connected
|
|
41
|
+
- ✅ **Reconnect interval** (2 tests) - 60s health check, no reconnect after stop
|
|
42
|
+
- ✅ **stop()** (2 tests) - Cleanup, safe when not started
|
|
43
|
+
- ✅ **on()/emit()** (4 tests) - Multiple listeners, unsubscribe, error isolation
|
|
44
|
+
- ✅ **send()** (2 tests) - JSON via WebSocket, throw when disconnected
|
|
45
|
+
|
|
46
|
+
**XmrWrapper** (`xmr-wrapper.test.js`):
|
|
47
|
+
- ✅ **Constructor** (1 test) - Initialization and state
|
|
48
|
+
- ✅ **Connection lifecycle** (7 tests) - Start, stop, reuse, custom channel
|
|
49
|
+
- ✅ **Connection events** (3 tests) - connected, disconnected, error
|
|
50
|
+
- ✅ **CMS commands** (31 tests) - All 13 commands + error handling
|
|
51
|
+
- ✅ **stop() method** (3 tests) - Cleanup, error handling
|
|
44
52
|
- ✅ **isConnected()** (3 tests) - Connection state queries
|
|
45
53
|
- ✅ **send()** (4 tests) - Sending messages to CMS
|
|
46
54
|
- ✅ **Edge cases** (3 tests) - Simultaneous commands, rapid cycles
|
|
47
|
-
- ✅ **Memory management** (
|
|
55
|
+
- ✅ **Memory management** (1 test) - Instance cleanup
|
|
48
56
|
|
|
49
57
|
### Test Structure
|
|
50
58
|
|
|
51
59
|
Tests use Vitest with:
|
|
52
|
-
- **Mocking**: `vi.mock()` for
|
|
60
|
+
- **Mocking**: `vi.mock()` for `./xmr-client.js` (native XmrClient)
|
|
53
61
|
- **Fake timers**: `vi.useFakeTimers()` for reconnection testing
|
|
54
62
|
- **Async handling**: `vi.runAllTimersAsync()` for event handlers
|
|
55
63
|
|
|
@@ -504,6 +512,6 @@ jobs:
|
|
|
504
512
|
## References
|
|
505
513
|
|
|
506
514
|
- XMR Commands: [XMR_COMMANDS.md](./XMR_COMMANDS.md)
|
|
507
|
-
- XMR
|
|
515
|
+
- XMR Client: Native `XmrClient` (`xmr-client.js`) — full XMR protocol implementation
|
|
508
516
|
- Xibo CMS: https://xibosignage.com
|
|
509
517
|
- WebSocket Testing: https://github.com/websockets/wscat
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/xmr",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
4
4
|
"description": "XMR WebSocket client for real-time Xibo CMS commands",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
".": "./src/index.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@
|
|
13
|
-
"@xiboplayer/utils": "0.6.10"
|
|
12
|
+
"@xiboplayer/utils": "0.6.12"
|
|
14
13
|
},
|
|
15
14
|
"devDependencies": {
|
|
16
15
|
"vitest": "^2.0.0"
|
package/src/index.d.ts
CHANGED
package/src/index.js
CHANGED
package/src/test-utils.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
|
|
1
3
|
/**
|
|
2
4
|
* Test Utilities for XMR Package
|
|
3
5
|
*
|
|
@@ -20,13 +22,13 @@ export function createSpy() {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
* Mock
|
|
25
|
+
* Mock XmrClient class (native xmr-client.js)
|
|
24
26
|
*
|
|
25
27
|
* Usage:
|
|
26
|
-
* const
|
|
28
|
+
* const MockXmrClient = mockXmrClient();
|
|
27
29
|
* // Use in tests
|
|
28
30
|
*/
|
|
29
|
-
export function
|
|
31
|
+
export function mockXmrClient() {
|
|
30
32
|
class MockXmr {
|
|
31
33
|
constructor(channel) {
|
|
32
34
|
this.channel = channel;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native XMR (Xibo Message Relay) WebSocket Client
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for @xibosignage/xibo-communication-framework.
|
|
5
|
+
* Uses a generic action dispatcher — emit(message.action, message) — so
|
|
6
|
+
* every CMS action works automatically without a hardcoded if-else chain.
|
|
7
|
+
*
|
|
8
|
+
* API-compatible with the upstream Xmr class:
|
|
9
|
+
* new XmrClient(channel) → .init() → .start(url, key) → .on(event, cb)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class XmrClient {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} channel - XMR channel identifier (e.g. "player-HWKEY")
|
|
15
|
+
*/
|
|
16
|
+
constructor(channel) {
|
|
17
|
+
this.channel = channel;
|
|
18
|
+
this.url = null;
|
|
19
|
+
this.cmsKey = null;
|
|
20
|
+
this.socket = null;
|
|
21
|
+
this.isConnected = false;
|
|
22
|
+
this.isConnectionWanted = false;
|
|
23
|
+
this.lastMessageAt = 0;
|
|
24
|
+
this._interval = null;
|
|
25
|
+
this._listeners = new Map(); // event → Set<callback>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register an event listener.
|
|
30
|
+
* @param {string} event
|
|
31
|
+
* @param {Function} callback
|
|
32
|
+
* @returns {Function} Unsubscribe function
|
|
33
|
+
*/
|
|
34
|
+
on(event, callback) {
|
|
35
|
+
if (!this._listeners.has(event)) {
|
|
36
|
+
this._listeners.set(event, new Set());
|
|
37
|
+
}
|
|
38
|
+
this._listeners.get(event).add(callback);
|
|
39
|
+
return () => this._listeners.get(event)?.delete(callback);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Emit an event to all registered listeners.
|
|
44
|
+
* @param {string} event
|
|
45
|
+
* @param {...*} args
|
|
46
|
+
*/
|
|
47
|
+
emit(event, ...args) {
|
|
48
|
+
const listeners = this._listeners.get(event);
|
|
49
|
+
if (!listeners) return;
|
|
50
|
+
for (const cb of listeners) {
|
|
51
|
+
try {
|
|
52
|
+
cb(...args);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error(`XmrClient: listener error for '${event}':`, e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Initialize the reconnect interval (60s health check).
|
|
61
|
+
* Same cadence as upstream framework.
|
|
62
|
+
*/
|
|
63
|
+
async init() {
|
|
64
|
+
if (this._interval) return;
|
|
65
|
+
this._interval = setInterval(() => {
|
|
66
|
+
if (this.isConnectionWanted && !this.isActive()) {
|
|
67
|
+
this.start(this.url || 'DISABLED', this.cmsKey || 'n/a');
|
|
68
|
+
}
|
|
69
|
+
}, 60_000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Connect to XMR WebSocket server.
|
|
74
|
+
* @param {string} url - WebSocket URL (ws:// or wss://)
|
|
75
|
+
* @param {string} cmsKey - CMS authentication key
|
|
76
|
+
*/
|
|
77
|
+
async start(url, cmsKey) {
|
|
78
|
+
this.url = url;
|
|
79
|
+
this.cmsKey = cmsKey;
|
|
80
|
+
this.isConnectionWanted = true;
|
|
81
|
+
|
|
82
|
+
// Close existing socket if any
|
|
83
|
+
if (this.socket) {
|
|
84
|
+
try { this.socket.close(); } catch (_) { /* ignore */ }
|
|
85
|
+
this.socket = null;
|
|
86
|
+
this.isConnected = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
this.socket = new WebSocket(url);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
this.emit('error', 'Failed to connect');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.socket.addEventListener('open', () => {
|
|
97
|
+
this.socket.send(JSON.stringify({
|
|
98
|
+
type: 'init',
|
|
99
|
+
key: this.cmsKey,
|
|
100
|
+
channel: this.channel,
|
|
101
|
+
}));
|
|
102
|
+
this.isConnected = true;
|
|
103
|
+
this.lastMessageAt = Date.now();
|
|
104
|
+
this.emit('connected');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this.socket.addEventListener('close', () => {
|
|
108
|
+
this.isConnected = false;
|
|
109
|
+
this.emit('disconnected');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
this.socket.addEventListener('error', () => {
|
|
113
|
+
this.emit('error', 'error');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.socket.addEventListener('message', (event) => {
|
|
117
|
+
this.lastMessageAt = Date.now();
|
|
118
|
+
|
|
119
|
+
// Heartbeat
|
|
120
|
+
if (event.data === 'H') return;
|
|
121
|
+
|
|
122
|
+
// JSON action message
|
|
123
|
+
try {
|
|
124
|
+
const message = JSON.parse(event.data);
|
|
125
|
+
if (!message.action) return;
|
|
126
|
+
|
|
127
|
+
// TTL check: createdDt (ISO 8601) + ttl seconds > now
|
|
128
|
+
if (message.createdDt && message.ttl) {
|
|
129
|
+
const created = Date.parse(message.createdDt);
|
|
130
|
+
if (!isNaN(created)) {
|
|
131
|
+
const expiresAt = created + parseInt(message.ttl) * 1000;
|
|
132
|
+
if (expiresAt < Date.now()) return; // expired
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Generic dispatch — every CMS action works automatically
|
|
137
|
+
this.emit(message.action, message);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error('XmrClient: failed to parse message:', e);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Stop the connection and clear the reconnect interval.
|
|
146
|
+
*/
|
|
147
|
+
async stop() {
|
|
148
|
+
this.isConnectionWanted = false;
|
|
149
|
+
if (this._interval) {
|
|
150
|
+
clearInterval(this._interval);
|
|
151
|
+
this._interval = null;
|
|
152
|
+
}
|
|
153
|
+
if (this.socket) {
|
|
154
|
+
this.socket.close();
|
|
155
|
+
this.socket = null;
|
|
156
|
+
this.isConnected = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Send a message to the server via WebSocket.
|
|
162
|
+
* @param {string} action - Action name
|
|
163
|
+
* @param {*} data - Data payload
|
|
164
|
+
*/
|
|
165
|
+
async send(action, data) {
|
|
166
|
+
if (!this.socket || !this.isConnected) {
|
|
167
|
+
throw new Error('Not connected');
|
|
168
|
+
}
|
|
169
|
+
this.socket.send(JSON.stringify({ action, ...data }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if the connection is active (connected + message within 15min).
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
*/
|
|
176
|
+
isActive() {
|
|
177
|
+
return this.isConnected && (Date.now() - this.lastMessageAt) < 15 * 60 * 1000;
|
|
178
|
+
}
|
|
179
|
+
}
|