@xiboplayer/xmds 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,373 @@
1
+ /**
2
+ * XMDS SOAP transport client for Xibo CMS.
3
+ *
4
+ * Uses the traditional SOAP/XML endpoint (xmds.php) for full protocol
5
+ * compatibility with all Xibo CMS versions.
6
+ *
7
+ * Protocol: https://github.com/linuxnow/xibo_players_docs
8
+ */
9
+ import { createLogger, fetchWithRetry } from '@xiboplayer/utils';
10
+ import { parseScheduleResponse } from './schedule-parser.js';
11
+
12
+ const log = createLogger('XMDS');
13
+
14
+ export class XmdsClient {
15
+ constructor(config) {
16
+ this.config = config;
17
+ this.schemaVersion = 5;
18
+ this.retryOptions = config.retryOptions || { maxRetries: 2, baseDelayMs: 2000 };
19
+ }
20
+
21
+ // ─── SOAP transport helpers ─────────────────────────────────────
22
+
23
+ /**
24
+ * Build SOAP envelope for a given method and parameters
25
+ */
26
+ buildEnvelope(method, params) {
27
+ const paramElements = Object.entries(params)
28
+ .map(([key, value]) => {
29
+ const escaped = String(value)
30
+ .replace(/&/g, '&')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;');
34
+ return `<${key} xsi:type="xsd:string">${escaped}</${key}>`;
35
+ })
36
+ .join('\n ');
37
+
38
+ return `<?xml version="1.0" encoding="UTF-8"?>
39
+ <soap:Envelope
40
+ xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
41
+ xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/"
42
+ xmlns:tns="urn:xmds"
43
+ xmlns:types="urn:xmds/encodedTypes"
44
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
45
+ xmlns:xsd="http://www.w3.org/2001/XMLSchema">
46
+ <soap:Body soap:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
47
+ <tns:${method}>
48
+ ${paramElements}
49
+ </tns:${method}>
50
+ </soap:Body>
51
+ </soap:Envelope>`;
52
+ }
53
+
54
+ /**
55
+ * Rewrite XMDS URL for Electron proxy.
56
+ * If running inside the Electron shell, use the local proxy to avoid CORS.
57
+ * Detection: preload.js exposes window.electronAPI.isElectron = true,
58
+ * or fallback to checking localhost:8765 (default Electron server port).
59
+ */
60
+ rewriteXmdsUrl(cmsUrl) {
61
+ if (typeof window !== 'undefined' &&
62
+ (window.electronAPI?.isElectron ||
63
+ (window.location.hostname === 'localhost' && window.location.port === '8765'))) {
64
+ const encodedCmsUrl = encodeURIComponent(cmsUrl);
65
+ return `/xmds-proxy?cms=${encodedCmsUrl}`;
66
+ }
67
+
68
+ return `${cmsUrl}/xmds.php`;
69
+ }
70
+
71
+ /**
72
+ * Call XMDS SOAP method
73
+ */
74
+ async call(method, params = {}) {
75
+ const xmdsUrl = this.rewriteXmdsUrl(this.config.cmsAddress);
76
+ const url = `${xmdsUrl}${xmdsUrl.includes('?') ? '&' : '?'}v=${this.schemaVersion}`;
77
+ const body = this.buildEnvelope(method, params);
78
+
79
+ log.debug(`${method}`, params);
80
+ log.debug(`URL: ${url}`);
81
+
82
+ const response = await fetchWithRetry(url, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'text/xml; charset=utf-8'
86
+ },
87
+ body
88
+ }, this.retryOptions);
89
+
90
+ if (!response.ok) {
91
+ throw new Error(`XMDS ${method} failed: ${response.status} ${response.statusText}`);
92
+ }
93
+
94
+ const xml = await response.text();
95
+ return this.parseResponse(xml, method);
96
+ }
97
+
98
+ /**
99
+ * Parse SOAP response
100
+ */
101
+ parseResponse(xml, method) {
102
+ const parser = new DOMParser();
103
+ const doc = parser.parseFromString(xml, 'text/xml');
104
+
105
+ // Check for SOAP fault (handle namespace prefix like soap:Fault)
106
+ let fault = doc.querySelector('Fault');
107
+ if (!fault) {
108
+ fault = Array.from(doc.querySelectorAll('*')).find(
109
+ el => el.localName === 'Fault' || el.tagName.endsWith(':Fault')
110
+ );
111
+ }
112
+ if (fault) {
113
+ const faultString = fault.querySelector('faultstring')?.textContent
114
+ || Array.from(fault.querySelectorAll('*')).find(el => el.localName === 'faultstring')?.textContent
115
+ || 'Unknown SOAP fault';
116
+ throw new Error(`SOAP Fault: ${faultString}`);
117
+ }
118
+
119
+ // Extract response element (handle namespace prefixes like ns1:MethodResponse)
120
+ const responseTag = `${method}Response`;
121
+ let responseEl = doc.querySelector(responseTag);
122
+ if (!responseEl) {
123
+ responseEl = Array.from(doc.querySelectorAll('*')).find(
124
+ el => el.localName === responseTag || el.tagName.endsWith(':' + responseTag)
125
+ );
126
+ }
127
+
128
+ if (!responseEl) {
129
+ throw new Error(`No ${responseTag} element in SOAP response`);
130
+ }
131
+
132
+ const returnEl = responseEl.firstElementChild;
133
+ if (!returnEl) {
134
+ return null;
135
+ }
136
+
137
+ return returnEl.textContent;
138
+ }
139
+
140
+ // ─── Public API ─────────────────────────────────────────────────
141
+
142
+ /**
143
+ * RegisterDisplay - authenticate and get settings
144
+ */
145
+ async registerDisplay() {
146
+ const os = `${navigator.platform} ${navigator.userAgent}`;
147
+
148
+ const xml = await this.call('RegisterDisplay', {
149
+ serverKey: this.config.cmsKey,
150
+ hardwareKey: this.config.hardwareKey,
151
+ displayName: this.config.displayName,
152
+ clientType: 'chromeOS',
153
+ clientVersion: '0.1.0',
154
+ clientCode: '1',
155
+ operatingSystem: os,
156
+ macAddress: this.config.macAddress || 'n/a',
157
+ xmrChannel: this.config.xmrChannel,
158
+ xmrPubKey: ''
159
+ });
160
+
161
+ return this.parseRegisterDisplayResponse(xml);
162
+ }
163
+
164
+ /**
165
+ * Parse RegisterDisplay XML response
166
+ */
167
+ parseRegisterDisplayResponse(xml) {
168
+ const parser = new DOMParser();
169
+ const doc = parser.parseFromString(xml, 'text/xml');
170
+
171
+ const display = doc.querySelector('display');
172
+ if (!display) {
173
+ throw new Error('Invalid RegisterDisplay response: no <display> element');
174
+ }
175
+
176
+ const code = display.getAttribute('code');
177
+ const message = display.getAttribute('message');
178
+
179
+ if (code !== 'READY') {
180
+ return { code, message, settings: null };
181
+ }
182
+
183
+ const settings = {};
184
+ for (const child of display.children) {
185
+ if (!['commands', 'file'].includes(child.tagName.toLowerCase())) {
186
+ settings[child.tagName] = child.textContent;
187
+ }
188
+ }
189
+
190
+ const checkRf = display.getAttribute('checkRf') || '';
191
+ const checkSchedule = display.getAttribute('checkSchedule') || '';
192
+
193
+ return { code, message, settings, checkRf, checkSchedule };
194
+ }
195
+
196
+ /**
197
+ * RequiredFiles - get list of files to download
198
+ */
199
+ async requiredFiles() {
200
+ const xml = await this.call('RequiredFiles', {
201
+ serverKey: this.config.cmsKey,
202
+ hardwareKey: this.config.hardwareKey
203
+ });
204
+
205
+ return this.parseRequiredFilesResponse(xml);
206
+ }
207
+
208
+ /**
209
+ * Parse RequiredFiles XML response
210
+ */
211
+ parseRequiredFilesResponse(xml) {
212
+ const parser = new DOMParser();
213
+ const doc = parser.parseFromString(xml, 'text/xml');
214
+
215
+ const files = [];
216
+ for (const fileEl of doc.querySelectorAll('file')) {
217
+ files.push({
218
+ type: fileEl.getAttribute('type'),
219
+ id: fileEl.getAttribute('id'),
220
+ size: parseInt(fileEl.getAttribute('size') || '0'),
221
+ md5: fileEl.getAttribute('md5'),
222
+ download: fileEl.getAttribute('download'),
223
+ path: fileEl.getAttribute('path'),
224
+ code: fileEl.getAttribute('code'),
225
+ layoutid: fileEl.getAttribute('layoutid'),
226
+ regionid: fileEl.getAttribute('regionid'),
227
+ mediaid: fileEl.getAttribute('mediaid')
228
+ });
229
+ }
230
+
231
+ return files;
232
+ }
233
+
234
+ /**
235
+ * Schedule - get layout schedule
236
+ */
237
+ async schedule() {
238
+ const xml = await this.call('Schedule', {
239
+ serverKey: this.config.cmsKey,
240
+ hardwareKey: this.config.hardwareKey
241
+ });
242
+
243
+ return parseScheduleResponse(xml);
244
+ }
245
+
246
+ /**
247
+ * GetResource - get rendered widget HTML
248
+ */
249
+ async getResource(layoutId, regionId, mediaId) {
250
+ const xml = await this.call('GetResource', {
251
+ serverKey: this.config.cmsKey,
252
+ hardwareKey: this.config.hardwareKey,
253
+ layoutId: String(layoutId),
254
+ regionId: String(regionId),
255
+ mediaId: String(mediaId)
256
+ });
257
+
258
+ return xml;
259
+ }
260
+
261
+ /**
262
+ * NotifyStatus - report current status
263
+ * @param {Object} status - Status object with currentLayoutId, deviceName, etc.
264
+ */
265
+ async notifyStatus(status) {
266
+ // Enrich with storage estimate if available
267
+ if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
268
+ try {
269
+ const estimate = await navigator.storage.estimate();
270
+ status.availableSpace = estimate.quota - estimate.usage;
271
+ status.totalSpace = estimate.quota;
272
+ } catch (_) { /* storage estimate not supported */ }
273
+ }
274
+
275
+ // Add timezone if not already provided
276
+ if (!status.timeZone && typeof Intl !== 'undefined') {
277
+ status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
278
+ }
279
+
280
+ return await this.call('NotifyStatus', {
281
+ serverKey: this.config.cmsKey,
282
+ hardwareKey: this.config.hardwareKey,
283
+ status: JSON.stringify(status)
284
+ });
285
+ }
286
+
287
+ /**
288
+ * MediaInventory - report downloaded files
289
+ */
290
+ async mediaInventory(inventoryXml) {
291
+ return await this.call('MediaInventory', {
292
+ serverKey: this.config.cmsKey,
293
+ hardwareKey: this.config.hardwareKey,
294
+ mediaInventory: inventoryXml
295
+ });
296
+ }
297
+
298
+ /**
299
+ * BlackList - report broken media to CMS
300
+ * @param {string} mediaId - The media file ID
301
+ * @param {string} type - File type ('media' or 'layout')
302
+ * @param {string} reason - Reason for blacklisting
303
+ * @returns {Promise<boolean>}
304
+ */
305
+ async blackList(mediaId, type, reason) {
306
+ try {
307
+ const xml = await this.call('BlackList', {
308
+ serverKey: this.config.cmsKey,
309
+ hardwareKey: this.config.hardwareKey,
310
+ mediaId: String(mediaId),
311
+ type: type || 'media',
312
+ reason: reason || 'Failed to render'
313
+ });
314
+ log.info(`BlackListed ${type}/${mediaId}: ${reason}`);
315
+ return xml === 'true';
316
+ } catch (error) {
317
+ log.warn('BlackList failed:', error);
318
+ return false;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * SubmitLog - submit player logs to CMS for remote debugging
324
+ * @param {string} logXml - XML string containing log entries
325
+ * @returns {Promise<boolean>} - true if logs were successfully submitted
326
+ */
327
+ async submitLog(logXml) {
328
+ const xml = await this.call('SubmitLog', {
329
+ serverKey: this.config.cmsKey,
330
+ hardwareKey: this.config.hardwareKey,
331
+ logXml: logXml
332
+ });
333
+
334
+ return xml === 'true';
335
+ }
336
+
337
+ /**
338
+ * SubmitScreenShot - submit screenshot to CMS for display verification
339
+ * @param {string} base64Image - Base64-encoded PNG image data
340
+ * @returns {Promise<boolean>} - true if screenshot was successfully submitted
341
+ */
342
+ async submitScreenShot(base64Image) {
343
+ const xml = await this.call('SubmitScreenShot', {
344
+ serverKey: this.config.cmsKey,
345
+ hardwareKey: this.config.hardwareKey,
346
+ screenShot: base64Image
347
+ });
348
+
349
+ return xml === 'true';
350
+ }
351
+
352
+ /**
353
+ * SubmitStats - submit proof of play statistics
354
+ * @param {string} statsXml - XML-encoded stats string
355
+ * @returns {Promise<boolean>} - true if stats were successfully submitted
356
+ */
357
+ async submitStats(statsXml) {
358
+ try {
359
+ const xml = await this.call('SubmitStats', {
360
+ serverKey: this.config.cmsKey,
361
+ hardwareKey: this.config.hardwareKey,
362
+ statXml: statsXml
363
+ });
364
+
365
+ const success = xml === 'true';
366
+ log.info(`SubmitStats result: ${success}`);
367
+ return success;
368
+ } catch (error) {
369
+ log.error('SubmitStats failed:', error);
370
+ throw error;
371
+ }
372
+ }
373
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tests for XMDS overlay parsing
3
+ *
4
+ * Tests that overlays are correctly parsed from Schedule XML response
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { parseScheduleResponse } from './schedule-parser.js';
9
+
10
+ describe('Schedule Parsing - Overlays', () => {
11
+ describe('parseScheduleResponse()', () => {
12
+ it('should parse overlays from Schedule XML', () => {
13
+ const xml = `<?xml version="1.0"?>
14
+ <schedule>
15
+ <default file="1.xlf"/>
16
+ <overlays>
17
+ <overlay duration="60" file="101.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="10" scheduleid="555" isGeoAware="0" geoLocation=""/>
18
+ </overlays>
19
+ </schedule>`;
20
+
21
+ const schedule = parseScheduleResponse(xml);
22
+
23
+ expect(schedule.overlays).toBeDefined();
24
+ expect(schedule.overlays.length).toBe(1);
25
+ expect(schedule.overlays[0].file).toBe('101.xlf');
26
+ expect(schedule.overlays[0].duration).toBe(60);
27
+ expect(schedule.overlays[0].priority).toBe(10);
28
+ expect(schedule.overlays[0].fromDt).toBe('2026-01-01 00:00:00');
29
+ expect(schedule.overlays[0].toDt).toBe('2026-12-31 23:59:59');
30
+ });
31
+
32
+ it('should parse multiple overlays', () => {
33
+ const xml = `<?xml version="1.0"?>
34
+ <schedule>
35
+ <default file="1.xlf"/>
36
+ <overlays>
37
+ <overlay duration="60" file="101.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="10" scheduleid="555" isGeoAware="0" geoLocation=""/>
38
+ <overlay duration="30" file="102.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="5" scheduleid="556" isGeoAware="0" geoLocation=""/>
39
+ <overlay duration="120" file="103.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="20" scheduleid="557" isGeoAware="1" geoLocation="location-1"/>
40
+ </overlays>
41
+ </schedule>`;
42
+
43
+ const schedule = parseScheduleResponse(xml);
44
+
45
+ expect(schedule.overlays.length).toBe(3);
46
+ expect(schedule.overlays[0].file).toBe('101.xlf');
47
+ expect(schedule.overlays[1].file).toBe('102.xlf');
48
+ expect(schedule.overlays[2].file).toBe('103.xlf');
49
+ });
50
+
51
+ it('should parse geo-aware overlay', () => {
52
+ const xml = `<?xml version="1.0"?>
53
+ <schedule>
54
+ <overlays>
55
+ <overlay duration="60" file="101.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="10" scheduleid="555" isGeoAware="1" geoLocation="geo-fence-1"/>
56
+ </overlays>
57
+ </schedule>`;
58
+
59
+ const schedule = parseScheduleResponse(xml);
60
+
61
+ expect(schedule.overlays[0].isGeoAware).toBe(true);
62
+ expect(schedule.overlays[0].geoLocation).toBe('geo-fence-1');
63
+ });
64
+
65
+ it('should handle overlays with no geo-awareness', () => {
66
+ const xml = `<?xml version="1.0"?>
67
+ <schedule>
68
+ <overlays>
69
+ <overlay duration="60" file="101.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="10" scheduleid="555" isGeoAware="0" geoLocation=""/>
70
+ </overlays>
71
+ </schedule>`;
72
+
73
+ const schedule = parseScheduleResponse(xml);
74
+
75
+ expect(schedule.overlays[0].isGeoAware).toBe(false);
76
+ expect(schedule.overlays[0].geoLocation).toBe('');
77
+ });
78
+
79
+ it('should handle schedule with no overlays element', () => {
80
+ const xml = `<?xml version="1.0"?>
81
+ <schedule>
82
+ <default file="1.xlf"/>
83
+ <layout file="2.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" scheduleid="123" priority="0"/>
84
+ </schedule>`;
85
+
86
+ const schedule = parseScheduleResponse(xml);
87
+
88
+ expect(schedule.overlays).toBeDefined();
89
+ expect(schedule.overlays.length).toBe(0);
90
+ });
91
+
92
+ it('should handle empty overlays element', () => {
93
+ const xml = `<?xml version="1.0"?>
94
+ <schedule>
95
+ <default file="1.xlf"/>
96
+ <overlays></overlays>
97
+ </schedule>`;
98
+
99
+ const schedule = parseScheduleResponse(xml);
100
+
101
+ expect(schedule.overlays).toBeDefined();
102
+ expect(schedule.overlays.length).toBe(0);
103
+ });
104
+
105
+ it('should parse overlay priorities correctly', () => {
106
+ const xml = `<?xml version="1.0"?>
107
+ <schedule>
108
+ <overlays>
109
+ <overlay duration="60" file="101.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="100" scheduleid="555" isGeoAware="0" geoLocation=""/>
110
+ <overlay duration="60" file="102.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="0" scheduleid="556" isGeoAware="0" geoLocation=""/>
111
+ </overlays>
112
+ </schedule>`;
113
+
114
+ const schedule = parseScheduleResponse(xml);
115
+
116
+ expect(schedule.overlays[0].priority).toBe(100);
117
+ expect(schedule.overlays[1].priority).toBe(0);
118
+ });
119
+
120
+ it('should combine overlays with regular layouts and campaigns', () => {
121
+ const xml = `<?xml version="1.0"?>
122
+ <schedule>
123
+ <default file="1.xlf"/>
124
+ <layout file="2.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" scheduleid="123" priority="5"/>
125
+ <campaign id="1" priority="10" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" scheduleid="456">
126
+ <layout file="3.xlf"/>
127
+ <layout file="4.xlf"/>
128
+ </campaign>
129
+ <overlays>
130
+ <overlay duration="60" file="101.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="10" scheduleid="555" isGeoAware="0" geoLocation=""/>
131
+ </overlays>
132
+ </schedule>`;
133
+
134
+ const schedule = parseScheduleResponse(xml);
135
+
136
+ expect(schedule.default).toBe('1.xlf');
137
+ expect(schedule.layouts.length).toBe(1);
138
+ expect(schedule.campaigns.length).toBe(1);
139
+ expect(schedule.overlays.length).toBe(1);
140
+ });
141
+
142
+ it('should parse overlay durations correctly', () => {
143
+ const xml = `<?xml version="1.0"?>
144
+ <schedule>
145
+ <overlays>
146
+ <overlay duration="30" file="101.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="10" scheduleid="555" isGeoAware="0" geoLocation=""/>
147
+ <overlay duration="120" file="102.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="5" scheduleid="556" isGeoAware="0" geoLocation=""/>
148
+ </overlays>
149
+ </schedule>`;
150
+
151
+ const schedule = parseScheduleResponse(xml);
152
+
153
+ expect(schedule.overlays[0].duration).toBe(30);
154
+ expect(schedule.overlays[1].duration).toBe(120);
155
+ });
156
+
157
+ it('should use default duration if not specified', () => {
158
+ const xml = `<?xml version="1.0"?>
159
+ <schedule>
160
+ <overlays>
161
+ <overlay file="101.xlf" fromdt="2026-01-01 00:00:00" todt="2026-12-31 23:59:59" priority="10" scheduleid="555" isGeoAware="0" geoLocation=""/>
162
+ </overlays>
163
+ </schedule>`;
164
+
165
+ const schedule = parseScheduleResponse(xml);
166
+
167
+ expect(schedule.overlays[0].duration).toBe(60); // Default
168
+ });
169
+ });
170
+ });