@xiboplayer/xmds 0.3.7 → 0.4.1
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/README.md +2 -2
- package/package.json +2 -2
- package/src/rest-client.js +123 -15
- package/src/schedule-parser.js +80 -20
- package/src/schedule-parser.test.js +282 -0
- package/src/xmds-client.js +82 -13
- package/src/xmds.overlays.test.js +2 -2
- package/src/xmds.rest.test.js +53 -21
- package/src/xmds.test.js +99 -0
package/README.md
CHANGED
|
@@ -43,9 +43,9 @@ const schedule = await client.schedule();
|
|
|
43
43
|
| `getResource(regionId, mediaId)` | Get rendered widget HTML |
|
|
44
44
|
| `notifyStatus(status)` | Report display status to CMS |
|
|
45
45
|
| `mediaInventory(inventory)` | Report cached media inventory |
|
|
46
|
-
| `submitStats(stats)` | Submit proof of play statistics |
|
|
46
|
+
| `submitStats(stats, hardwareKeyOverride?)` | Submit proof of play statistics (optional `hardwareKeyOverride` for delegated submissions on behalf of another display) |
|
|
47
47
|
| `submitScreenShot(base64)` | Upload a screenshot to the CMS |
|
|
48
|
-
| `submitLog(logs)` | Submit display logs |
|
|
48
|
+
| `submitLog(logs, hardwareKeyOverride?)` | Submit display logs (optional `hardwareKeyOverride` for delegated submissions on behalf of another display) |
|
|
49
49
|
|
|
50
50
|
## Dependencies
|
|
51
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/xmds",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "XMDS SOAP client for Xibo CMS communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"./xmds": "./src/xmds.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@xiboplayer/utils": "0.
|
|
12
|
+
"@xiboplayer/utils": "0.4.1"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"vitest": "^2.0.0"
|
package/src/rest-client.js
CHANGED
|
@@ -35,6 +35,32 @@ export class RestClient {
|
|
|
35
35
|
return base.replace(/\/+$/, '');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Check if running behind the local proxy (Electron or Chromium kiosk).
|
|
40
|
+
*/
|
|
41
|
+
_isProxyMode() {
|
|
42
|
+
return typeof window !== 'undefined' &&
|
|
43
|
+
(window.electronAPI?.isElectron ||
|
|
44
|
+
(window.location.hostname === 'localhost' && window.location.port === '8765'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Rewrite an absolute REST URL to go through /rest-proxy.
|
|
49
|
+
* Preserves all query params from the original URL.
|
|
50
|
+
*/
|
|
51
|
+
_rewriteForProxy(urlString) {
|
|
52
|
+
if (!this._isProxyMode() || !urlString.startsWith('http')) return urlString;
|
|
53
|
+
const parsed = new URL(urlString);
|
|
54
|
+
const proxyUrl = new URL('/rest-proxy', window.location.origin);
|
|
55
|
+
proxyUrl.searchParams.set('cms', parsed.origin);
|
|
56
|
+
proxyUrl.searchParams.set('path', parsed.pathname);
|
|
57
|
+
// Forward all original query params
|
|
58
|
+
for (const [key, value] of parsed.searchParams) {
|
|
59
|
+
proxyUrl.searchParams.set(key, value);
|
|
60
|
+
}
|
|
61
|
+
return proxyUrl.toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
/**
|
|
39
65
|
* Make a REST GET request with optional ETag caching.
|
|
40
66
|
* Returns the parsed JSON body, or cached data on 304.
|
|
@@ -57,7 +83,7 @@ export class RestClient {
|
|
|
57
83
|
|
|
58
84
|
log.debug(`GET ${path}`, queryParams);
|
|
59
85
|
|
|
60
|
-
const response = await fetchWithRetry(url.toString(), {
|
|
86
|
+
const response = await fetchWithRetry(this._rewriteForProxy(url.toString()), {
|
|
61
87
|
method: 'GET',
|
|
62
88
|
headers,
|
|
63
89
|
}, this.retryOptions);
|
|
@@ -107,7 +133,7 @@ export class RestClient {
|
|
|
107
133
|
|
|
108
134
|
log.debug(`${method} ${path}`);
|
|
109
135
|
|
|
110
|
-
const response = await fetchWithRetry(url.toString(), {
|
|
136
|
+
const response = await fetchWithRetry(this._rewriteForProxy(url.toString()), {
|
|
111
137
|
method,
|
|
112
138
|
headers: { 'Content-Type': 'application/json' },
|
|
113
139
|
body: JSON.stringify({
|
|
@@ -142,13 +168,14 @@ export class RestClient {
|
|
|
142
168
|
|
|
143
169
|
const json = await this.restSend('POST', '/register', {
|
|
144
170
|
displayName: this.config.displayName,
|
|
145
|
-
clientType: 'chromeOS',
|
|
146
|
-
clientVersion: '0.1.0',
|
|
147
|
-
clientCode: 1,
|
|
171
|
+
clientType: this.config.clientType || 'chromeOS',
|
|
172
|
+
clientVersion: this.config.clientVersion || '0.1.0',
|
|
173
|
+
clientCode: this.config.clientCode || 1,
|
|
148
174
|
operatingSystem: os,
|
|
149
175
|
macAddress: this.config.macAddress || 'n/a',
|
|
150
176
|
xmrChannel: this.config.xmrChannel,
|
|
151
177
|
xmrPubKey: this.config.xmrPubKey || '',
|
|
178
|
+
licenceResult: 'licensed',
|
|
152
179
|
});
|
|
153
180
|
|
|
154
181
|
return this._parseRegisterDisplayJson(json);
|
|
@@ -169,14 +196,53 @@ export class RestClient {
|
|
|
169
196
|
}
|
|
170
197
|
|
|
171
198
|
const settings = {};
|
|
199
|
+
let tags = [];
|
|
200
|
+
let commands = [];
|
|
172
201
|
for (const [key, value] of Object.entries(display)) {
|
|
173
|
-
if (key === '@attributes' || key === '
|
|
202
|
+
if (key === '@attributes' || key === 'file') continue;
|
|
203
|
+
if (key === 'commands') {
|
|
204
|
+
// Parse commands: array of {code/commandCode, commandString} objects
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
commands = value.map(c => ({
|
|
207
|
+
commandCode: c.code || c.commandCode || '',
|
|
208
|
+
commandString: c.commandString || ''
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (key === 'tags') {
|
|
214
|
+
// Parse tags from CMS JSON (SimpleXMLElement serialization varies):
|
|
215
|
+
// Array of strings: ["geoApiKey|AIzaSy..."]
|
|
216
|
+
// Array of objects: [{tag: "geoApiKey|AIzaSy..."}]
|
|
217
|
+
// Single-tag object: {tag: "geoApiKey|AIzaSy..."} (SimpleXMLElement collapses single-element arrays)
|
|
218
|
+
// String: "geoApiKey|AIzaSy..."
|
|
219
|
+
const extractTag = (t) => typeof t === 'object' ? (t.tag || t.value || '') : String(t);
|
|
220
|
+
if (Array.isArray(value)) {
|
|
221
|
+
tags = value.map(extractTag).filter(Boolean);
|
|
222
|
+
} else if (value && typeof value === 'object') {
|
|
223
|
+
// Single tag: {tag: "value"} — wrap in array
|
|
224
|
+
const t = extractTag(value);
|
|
225
|
+
if (t) tags = [t];
|
|
226
|
+
} else if (typeof value === 'string' && value) {
|
|
227
|
+
tags = [value];
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
174
231
|
settings[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
175
232
|
}
|
|
176
233
|
|
|
177
234
|
const checkRf = attrs.checkRf || '';
|
|
178
235
|
const checkSchedule = attrs.checkSchedule || '';
|
|
179
236
|
|
|
237
|
+
// Extract display-level attributes from CMS (server time, status, version info)
|
|
238
|
+
const displayAttrs = {
|
|
239
|
+
date: attrs.date || display.date || null,
|
|
240
|
+
timezone: attrs.timezone || display.timezone || null,
|
|
241
|
+
status: attrs.status || display.status || null,
|
|
242
|
+
localDate: attrs.localDate || display.localDate || null,
|
|
243
|
+
version_instructions: attrs.version_instructions || display.version_instructions || null,
|
|
244
|
+
};
|
|
245
|
+
|
|
180
246
|
// Extract sync group config if present (multi-display sync coordination)
|
|
181
247
|
// syncGroup: "lead" if this display is leader, or leader's LAN IP if follower
|
|
182
248
|
const syncConfig = display.syncGroup ? {
|
|
@@ -187,7 +253,7 @@ export class RestClient {
|
|
|
187
253
|
isLead: String(display.syncGroup) === 'lead',
|
|
188
254
|
} : null;
|
|
189
255
|
|
|
190
|
-
return { code, message, settings, checkRf, checkSchedule, syncConfig };
|
|
256
|
+
return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
|
|
191
257
|
}
|
|
192
258
|
|
|
193
259
|
/**
|
|
@@ -221,6 +287,8 @@ export class RestClient {
|
|
|
221
287
|
md5: attrs.md5 || null,
|
|
222
288
|
download: attrs.download || null,
|
|
223
289
|
path,
|
|
290
|
+
saveAs: attrs.saveAs || null,
|
|
291
|
+
fileType: attrs.fileType || null,
|
|
224
292
|
code: attrs.code || null,
|
|
225
293
|
layoutid: attrs.layoutid || null,
|
|
226
294
|
regionid: attrs.regionid || null,
|
|
@@ -228,7 +296,19 @@ export class RestClient {
|
|
|
228
296
|
});
|
|
229
297
|
}
|
|
230
298
|
|
|
231
|
-
|
|
299
|
+
// Parse purge items — files CMS wants the player to delete
|
|
300
|
+
const purgeItems = [];
|
|
301
|
+
let purgeList = json.purge?.item || [];
|
|
302
|
+
if (!Array.isArray(purgeList)) purgeList = [purgeList];
|
|
303
|
+
for (const p of purgeList) {
|
|
304
|
+
const pAttrs = p['@attributes'] || p;
|
|
305
|
+
purgeItems.push({
|
|
306
|
+
id: pAttrs.id || null,
|
|
307
|
+
storedAs: pAttrs.storedAs || null,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { files, purge: purgeItems };
|
|
232
312
|
}
|
|
233
313
|
|
|
234
314
|
/**
|
|
@@ -272,6 +352,11 @@ export class RestClient {
|
|
|
272
352
|
status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
273
353
|
}
|
|
274
354
|
|
|
355
|
+
// Add statusDialog (summary for CMS display status page) if not provided
|
|
356
|
+
if (!status.statusDialog) {
|
|
357
|
+
status.statusDialog = `Current Layout: ${status.currentLayoutId || 'None'}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
275
360
|
return this.restSend('PUT', '/status', {
|
|
276
361
|
statusData: status,
|
|
277
362
|
});
|
|
@@ -291,22 +376,35 @@ export class RestClient {
|
|
|
291
376
|
|
|
292
377
|
/**
|
|
293
378
|
* BlackList - report broken media to CMS
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
379
|
+
* POST /blacklist → JSON acknowledgement
|
|
380
|
+
* @param {string|number} mediaId - The media ID
|
|
381
|
+
* @param {string} type - File type ('media' or 'layout')
|
|
382
|
+
* @param {string} reason - Reason for blacklisting
|
|
383
|
+
* @returns {Promise<boolean>}
|
|
297
384
|
*/
|
|
298
385
|
async blackList(mediaId, type, reason) {
|
|
299
|
-
|
|
300
|
-
|
|
386
|
+
try {
|
|
387
|
+
const result = await this.restSend('POST', '/blacklist', {
|
|
388
|
+
mediaId: String(mediaId),
|
|
389
|
+
type: type || 'media',
|
|
390
|
+
reason: reason || 'Failed to render',
|
|
391
|
+
});
|
|
392
|
+
log.info(`BlackListed ${type}/${mediaId}: ${reason}`);
|
|
393
|
+
return result?.success === true;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
log.warn('BlackList failed:', error);
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
301
398
|
}
|
|
302
399
|
|
|
303
400
|
/**
|
|
304
401
|
* SubmitLog - submit player logs to CMS
|
|
305
402
|
* POST /log → JSON acknowledgement
|
|
306
403
|
*/
|
|
307
|
-
async submitLog(logXml) {
|
|
404
|
+
async submitLog(logXml, hardwareKeyOverride = null) {
|
|
308
405
|
// Accept array (JSON-native) or string (XML) — send under the right key
|
|
309
406
|
const body = Array.isArray(logXml) ? { logs: logXml } : { logXml };
|
|
407
|
+
if (hardwareKeyOverride) body.hardwareKey = hardwareKeyOverride;
|
|
310
408
|
const result = await this.restSend('POST', '/log', body);
|
|
311
409
|
return result?.success === true;
|
|
312
410
|
}
|
|
@@ -337,10 +435,20 @@ export class RestClient {
|
|
|
337
435
|
return result?.success === true;
|
|
338
436
|
}
|
|
339
437
|
|
|
340
|
-
|
|
438
|
+
/**
|
|
439
|
+
* GetWeather - get current weather data for schedule criteria
|
|
440
|
+
* GET /weather → JSON weather data
|
|
441
|
+
* @returns {Promise<Object>} Weather data from CMS
|
|
442
|
+
*/
|
|
443
|
+
async getWeather() {
|
|
444
|
+
return this.restGet('/weather');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async submitStats(statsXml, hardwareKeyOverride = null) {
|
|
341
448
|
try {
|
|
342
449
|
// Accept array (JSON-native) or string (XML) — send under the right key
|
|
343
450
|
const body = Array.isArray(statsXml) ? { stats: statsXml } : { statXml: statsXml };
|
|
451
|
+
if (hardwareKeyOverride) body.hardwareKey = hardwareKeyOverride;
|
|
344
452
|
const result = await this.restSend('POST', '/stats', body);
|
|
345
453
|
const success = result?.success === true;
|
|
346
454
|
log.info(`SubmitStats result: ${success}`);
|
package/src/schedule-parser.js
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
function parseCriteria(parentEl) {
|
|
18
18
|
const criteria = [];
|
|
19
|
-
for (const
|
|
19
|
+
for (const child of parentEl.children) {
|
|
20
|
+
if (child.tagName !== 'criteria') continue;
|
|
20
21
|
criteria.push({
|
|
21
|
-
metric:
|
|
22
|
-
condition:
|
|
23
|
-
type:
|
|
24
|
-
value:
|
|
22
|
+
metric: child.getAttribute('metric') || '',
|
|
23
|
+
condition: child.getAttribute('condition') || '',
|
|
24
|
+
type: child.getAttribute('type') || 'string',
|
|
25
|
+
value: child.textContent || ''
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
28
|
return criteria;
|
|
@@ -39,6 +40,8 @@ export function parseScheduleResponse(xml) {
|
|
|
39
40
|
|
|
40
41
|
const schedule = {
|
|
41
42
|
default: null,
|
|
43
|
+
defaultDependants: [],
|
|
44
|
+
dependants: [], // Global dependants that gate ALL layouts
|
|
42
45
|
layouts: [],
|
|
43
46
|
campaigns: [],
|
|
44
47
|
overlays: [],
|
|
@@ -47,9 +50,29 @@ export function parseScheduleResponse(xml) {
|
|
|
47
50
|
dataConnectors: []
|
|
48
51
|
};
|
|
49
52
|
|
|
53
|
+
// Parse global dependants (root-level <dependants> — must be cached before any layout plays)
|
|
54
|
+
const scheduleEl = doc.querySelector('schedule');
|
|
55
|
+
if (scheduleEl) {
|
|
56
|
+
const globalDeps = Array.from(scheduleEl.children).filter(
|
|
57
|
+
el => el.tagName === 'dependants'
|
|
58
|
+
);
|
|
59
|
+
for (const depContainer of globalDeps) {
|
|
60
|
+
// Skip if this is nested inside <default>, <layout>, etc.
|
|
61
|
+
if (depContainer.parentElement !== scheduleEl) continue;
|
|
62
|
+
for (const fileEl of depContainer.querySelectorAll('file')) {
|
|
63
|
+
if (fileEl.textContent) schedule.dependants.push(fileEl.textContent);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
const defaultEl = doc.querySelector('default');
|
|
51
69
|
if (defaultEl) {
|
|
52
70
|
schedule.default = defaultEl.getAttribute('file');
|
|
71
|
+
// Parse dependants — files that must be cached before this layout plays
|
|
72
|
+
const defaultDeps = defaultEl.querySelectorAll('dependants > file');
|
|
73
|
+
if (defaultDeps.length > 0) {
|
|
74
|
+
schedule.defaultDependants = [...defaultDeps].map(el => el.textContent);
|
|
75
|
+
}
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
// Parse campaigns (groups of layouts with shared priority)
|
|
@@ -60,12 +83,23 @@ export function parseScheduleResponse(xml) {
|
|
|
60
83
|
fromdt: campaignEl.getAttribute('fromdt'),
|
|
61
84
|
todt: campaignEl.getAttribute('todt'),
|
|
62
85
|
scheduleid: campaignEl.getAttribute('scheduleid'),
|
|
86
|
+
maxPlaysPerHour: parseInt(campaignEl.getAttribute('maxPlaysPerHour') || '0'),
|
|
87
|
+
shareOfVoice: parseInt(campaignEl.getAttribute('shareOfVoice') || '0'),
|
|
88
|
+
isGeoAware: campaignEl.getAttribute('isGeoAware') === '1',
|
|
89
|
+
geoLocation: campaignEl.getAttribute('geoLocation') || '',
|
|
90
|
+
syncEvent: campaignEl.getAttribute('syncEvent') === '1',
|
|
91
|
+
recurrenceType: campaignEl.getAttribute('recurrenceType') || null,
|
|
92
|
+
recurrenceDetail: parseInt(campaignEl.getAttribute('recurrenceDetail') || '0') || null,
|
|
93
|
+
recurrenceRepeatsOn: campaignEl.getAttribute('recurrenceRepeatsOn') || null,
|
|
94
|
+
recurrenceRange: campaignEl.getAttribute('recurrenceRange') || null,
|
|
95
|
+
criteria: parseCriteria(campaignEl),
|
|
63
96
|
layouts: []
|
|
64
97
|
};
|
|
65
98
|
|
|
66
99
|
// Parse layouts within this campaign
|
|
67
100
|
for (const layoutEl of campaignEl.querySelectorAll('layout')) {
|
|
68
101
|
const fileId = layoutEl.getAttribute('file');
|
|
102
|
+
const depEls = layoutEl.querySelectorAll('dependants > file');
|
|
69
103
|
campaign.layouts.push({
|
|
70
104
|
id: String(fileId), // Normalized string ID for consistent type usage
|
|
71
105
|
file: fileId,
|
|
@@ -80,6 +114,11 @@ export function parseScheduleResponse(xml) {
|
|
|
80
114
|
geoLocation: layoutEl.getAttribute('geoLocation') || '',
|
|
81
115
|
syncEvent: layoutEl.getAttribute('syncEvent') === '1',
|
|
82
116
|
shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
|
|
117
|
+
duration: parseInt(layoutEl.getAttribute('duration') || '0'),
|
|
118
|
+
cyclePlayback: layoutEl.getAttribute('cyclePlayback') === '1',
|
|
119
|
+
groupKey: layoutEl.getAttribute('groupKey') || null,
|
|
120
|
+
playCount: parseInt(layoutEl.getAttribute('playCount') || '0'),
|
|
121
|
+
dependants: depEls.length > 0 ? [...depEls].map(el => el.textContent) : [],
|
|
83
122
|
criteria: parseCriteria(layoutEl)
|
|
84
123
|
});
|
|
85
124
|
}
|
|
@@ -90,6 +129,7 @@ export function parseScheduleResponse(xml) {
|
|
|
90
129
|
// Parse standalone layouts (not in campaigns)
|
|
91
130
|
for (const layoutEl of doc.querySelectorAll('schedule > layout')) {
|
|
92
131
|
const fileId = layoutEl.getAttribute('file');
|
|
132
|
+
const depEls = layoutEl.querySelectorAll('dependants > file');
|
|
93
133
|
schedule.layouts.push({
|
|
94
134
|
id: String(fileId), // Normalized string ID for consistent type usage
|
|
95
135
|
file: fileId,
|
|
@@ -103,6 +143,15 @@ export function parseScheduleResponse(xml) {
|
|
|
103
143
|
geoLocation: layoutEl.getAttribute('geoLocation') || '',
|
|
104
144
|
syncEvent: layoutEl.getAttribute('syncEvent') === '1',
|
|
105
145
|
shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
|
|
146
|
+
duration: parseInt(layoutEl.getAttribute('duration') || '0'),
|
|
147
|
+
cyclePlayback: layoutEl.getAttribute('cyclePlayback') === '1',
|
|
148
|
+
groupKey: layoutEl.getAttribute('groupKey') || null,
|
|
149
|
+
playCount: parseInt(layoutEl.getAttribute('playCount') || '0'),
|
|
150
|
+
recurrenceType: layoutEl.getAttribute('recurrenceType') || null,
|
|
151
|
+
recurrenceDetail: parseInt(layoutEl.getAttribute('recurrenceDetail') || '0') || null,
|
|
152
|
+
recurrenceRepeatsOn: layoutEl.getAttribute('recurrenceRepeatsOn') || null,
|
|
153
|
+
recurrenceRange: layoutEl.getAttribute('recurrenceRange') || null,
|
|
154
|
+
dependants: depEls.length > 0 ? [...depEls].map(el => el.textContent) : [],
|
|
106
155
|
criteria: parseCriteria(layoutEl)
|
|
107
156
|
});
|
|
108
157
|
}
|
|
@@ -116,14 +165,18 @@ export function parseScheduleResponse(xml) {
|
|
|
116
165
|
id: String(fileId), // Normalized string ID for consistent type usage
|
|
117
166
|
duration: parseInt(overlayEl.getAttribute('duration') || '60'),
|
|
118
167
|
file: fileId,
|
|
119
|
-
|
|
120
|
-
|
|
168
|
+
fromdt: overlayEl.getAttribute('fromdt'),
|
|
169
|
+
todt: overlayEl.getAttribute('todt'),
|
|
121
170
|
priority: parseInt(overlayEl.getAttribute('priority') || '0'),
|
|
122
|
-
|
|
171
|
+
scheduleid: overlayEl.getAttribute('scheduleid'),
|
|
123
172
|
isGeoAware: overlayEl.getAttribute('isGeoAware') === '1',
|
|
124
173
|
geoLocation: overlayEl.getAttribute('geoLocation') || '',
|
|
125
174
|
syncEvent: overlayEl.getAttribute('syncEvent') === '1',
|
|
126
175
|
maxPlaysPerHour: parseInt(overlayEl.getAttribute('maxPlaysPerHour') || '0'),
|
|
176
|
+
recurrenceType: overlayEl.getAttribute('recurrenceType') || null,
|
|
177
|
+
recurrenceDetail: parseInt(overlayEl.getAttribute('recurrenceDetail') || '0') || null,
|
|
178
|
+
recurrenceRepeatsOn: overlayEl.getAttribute('recurrenceRepeatsOn') || null,
|
|
179
|
+
recurrenceRange: overlayEl.getAttribute('recurrenceRange') || null,
|
|
127
180
|
criteria: parseCriteria(overlayEl)
|
|
128
181
|
});
|
|
129
182
|
}
|
|
@@ -139,10 +192,10 @@ export function parseScheduleResponse(xml) {
|
|
|
139
192
|
layoutCode: actionEl.getAttribute('layoutCode') || '',
|
|
140
193
|
commandCode: actionEl.getAttribute('commandCode') || '',
|
|
141
194
|
duration: parseInt(actionEl.getAttribute('duration') || '0'),
|
|
142
|
-
|
|
143
|
-
|
|
195
|
+
fromdt: actionEl.getAttribute('fromdt'),
|
|
196
|
+
todt: actionEl.getAttribute('todt'),
|
|
144
197
|
priority: parseInt(actionEl.getAttribute('priority') || '0'),
|
|
145
|
-
|
|
198
|
+
scheduleid: actionEl.getAttribute('scheduleid'),
|
|
146
199
|
isGeoAware: actionEl.getAttribute('isGeoAware') === '1',
|
|
147
200
|
geoLocation: actionEl.getAttribute('geoLocation') || ''
|
|
148
201
|
});
|
|
@@ -152,20 +205,27 @@ export function parseScheduleResponse(xml) {
|
|
|
152
205
|
// Parse server commands (remote control)
|
|
153
206
|
for (const cmdEl of doc.querySelectorAll('schedule > command')) {
|
|
154
207
|
schedule.commands.push({
|
|
155
|
-
code: cmdEl.getAttribute('
|
|
208
|
+
code: cmdEl.getAttribute('code') || '',
|
|
156
209
|
date: cmdEl.getAttribute('date') || ''
|
|
157
210
|
});
|
|
158
211
|
}
|
|
159
212
|
|
|
160
213
|
// Parse data connectors (real-time data sources for widgets)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
214
|
+
// Spec: <dataConnectors><connector dataSetId="" dataParams="" js=""/></dataConnectors>
|
|
215
|
+
const dataConnectorsContainer = doc.querySelector('dataConnectors');
|
|
216
|
+
if (dataConnectorsContainer) {
|
|
217
|
+
for (const dcEl of dataConnectorsContainer.querySelectorAll('connector')) {
|
|
218
|
+
schedule.dataConnectors.push({
|
|
219
|
+
id: dcEl.getAttribute('id') || '',
|
|
220
|
+
dataConnectorId: dcEl.getAttribute('dataConnectorId') || '',
|
|
221
|
+
dataSetId: dcEl.getAttribute('dataSetId') || '',
|
|
222
|
+
dataKey: dcEl.getAttribute('dataKey') || '',
|
|
223
|
+
dataParams: dcEl.getAttribute('dataParams') || '',
|
|
224
|
+
js: dcEl.getAttribute('js') || '',
|
|
225
|
+
url: dcEl.getAttribute('url') || '',
|
|
226
|
+
updateInterval: parseInt(dcEl.getAttribute('updateInterval') || '300', 10)
|
|
227
|
+
});
|
|
228
|
+
}
|
|
169
229
|
}
|
|
170
230
|
|
|
171
231
|
return schedule;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule Parser Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for XML parsing of schedule responses, ensuring all attributes
|
|
5
|
+
* are correctly extracted — especially recurrence/dayparting fields.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { parseScheduleResponse } from './schedule-parser.js';
|
|
10
|
+
|
|
11
|
+
describe('parseScheduleResponse', () => {
|
|
12
|
+
it('should parse default layout', () => {
|
|
13
|
+
const xml = '<schedule><default file="100.xlf"/></schedule>';
|
|
14
|
+
const result = parseScheduleResponse(xml);
|
|
15
|
+
expect(result.default).toBe('100.xlf');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should parse standalone layout with basic attributes', () => {
|
|
19
|
+
const xml = `<schedule>
|
|
20
|
+
<layout file="200.xlf" fromdt="2025-01-01 09:00:00" todt="2025-12-31 17:00:00"
|
|
21
|
+
scheduleid="5" priority="3"/>
|
|
22
|
+
</schedule>`;
|
|
23
|
+
const result = parseScheduleResponse(xml);
|
|
24
|
+
expect(result.layouts).toHaveLength(1);
|
|
25
|
+
expect(result.layouts[0].file).toBe('200.xlf');
|
|
26
|
+
expect(result.layouts[0].priority).toBe(3);
|
|
27
|
+
expect(result.layouts[0].scheduleid).toBe('5');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should parse recurrence attributes on standalone layouts', () => {
|
|
31
|
+
const xml = `<schedule>
|
|
32
|
+
<layout file="300.xlf" fromdt="2025-01-06 09:00:00" todt="2025-01-06 17:00:00"
|
|
33
|
+
scheduleid="10" priority="1"
|
|
34
|
+
recurrenceType="Week"
|
|
35
|
+
recurrenceRepeatsOn="1,2,3,4,5"
|
|
36
|
+
recurrenceRange="2025-12-31 23:59:59"/>
|
|
37
|
+
</schedule>`;
|
|
38
|
+
const result = parseScheduleResponse(xml);
|
|
39
|
+
const layout = result.layouts[0];
|
|
40
|
+
|
|
41
|
+
expect(layout.recurrenceType).toBe('Week');
|
|
42
|
+
expect(layout.recurrenceRepeatsOn).toBe('1,2,3,4,5');
|
|
43
|
+
expect(layout.recurrenceRange).toBe('2025-12-31 23:59:59');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should set recurrence fields to null when absent', () => {
|
|
47
|
+
const xml = `<schedule>
|
|
48
|
+
<layout file="400.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
49
|
+
scheduleid="20" priority="0"/>
|
|
50
|
+
</schedule>`;
|
|
51
|
+
const result = parseScheduleResponse(xml);
|
|
52
|
+
const layout = result.layouts[0];
|
|
53
|
+
|
|
54
|
+
expect(layout.recurrenceType).toBeNull();
|
|
55
|
+
expect(layout.recurrenceRepeatsOn).toBeNull();
|
|
56
|
+
expect(layout.recurrenceRange).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should parse recurrence attributes on campaigns', () => {
|
|
60
|
+
const xml = `<schedule>
|
|
61
|
+
<campaign id="c1" priority="5" fromdt="2025-01-06 08:00:00" todt="2025-01-06 18:00:00"
|
|
62
|
+
scheduleid="30"
|
|
63
|
+
recurrenceType="Week"
|
|
64
|
+
recurrenceRepeatsOn="6,7">
|
|
65
|
+
<layout file="500.xlf"/>
|
|
66
|
+
<layout file="501.xlf"/>
|
|
67
|
+
</campaign>
|
|
68
|
+
</schedule>`;
|
|
69
|
+
const result = parseScheduleResponse(xml);
|
|
70
|
+
|
|
71
|
+
expect(result.campaigns).toHaveLength(1);
|
|
72
|
+
const campaign = result.campaigns[0];
|
|
73
|
+
expect(campaign.recurrenceType).toBe('Week');
|
|
74
|
+
expect(campaign.recurrenceRepeatsOn).toBe('6,7');
|
|
75
|
+
expect(campaign.recurrenceRange).toBeNull();
|
|
76
|
+
expect(campaign.layouts).toHaveLength(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should parse recurrence attributes on overlays', () => {
|
|
80
|
+
const xml = `<schedule>
|
|
81
|
+
<overlays>
|
|
82
|
+
<overlay file="600.xlf" fromdt="2025-01-01 12:00:00" todt="2025-01-01 13:00:00"
|
|
83
|
+
scheduleid="40" priority="2" duration="30"
|
|
84
|
+
recurrenceType="Week"
|
|
85
|
+
recurrenceRepeatsOn="1,3,5"/>
|
|
86
|
+
</overlays>
|
|
87
|
+
</schedule>`;
|
|
88
|
+
const result = parseScheduleResponse(xml);
|
|
89
|
+
|
|
90
|
+
expect(result.overlays).toHaveLength(1);
|
|
91
|
+
const overlay = result.overlays[0];
|
|
92
|
+
expect(overlay.recurrenceType).toBe('Week');
|
|
93
|
+
expect(overlay.recurrenceRepeatsOn).toBe('1,3,5');
|
|
94
|
+
expect(overlay.recurrenceRange).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should parse criteria on layouts', () => {
|
|
98
|
+
const xml = `<schedule>
|
|
99
|
+
<layout file="700.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
100
|
+
scheduleid="50" priority="1">
|
|
101
|
+
<criteria metric="dayOfWeek" condition="equals" type="string">Monday</criteria>
|
|
102
|
+
</layout>
|
|
103
|
+
</schedule>`;
|
|
104
|
+
const result = parseScheduleResponse(xml);
|
|
105
|
+
const layout = result.layouts[0];
|
|
106
|
+
|
|
107
|
+
expect(layout.criteria).toHaveLength(1);
|
|
108
|
+
expect(layout.criteria[0].metric).toBe('dayOfWeek');
|
|
109
|
+
expect(layout.criteria[0].condition).toBe('equals');
|
|
110
|
+
expect(layout.criteria[0].value).toBe('Monday');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should parse dependants on default layout', () => {
|
|
114
|
+
const xml = `<schedule>
|
|
115
|
+
<default file="100.xlf">
|
|
116
|
+
<dependants><file>bg.jpg</file><file>logo.png</file></dependants>
|
|
117
|
+
</default>
|
|
118
|
+
</schedule>`;
|
|
119
|
+
const result = parseScheduleResponse(xml);
|
|
120
|
+
expect(result.default).toBe('100.xlf');
|
|
121
|
+
expect(result.defaultDependants).toEqual(['bg.jpg', 'logo.png']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should parse dependants on standalone layouts', () => {
|
|
125
|
+
const xml = `<schedule>
|
|
126
|
+
<layout file="200.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
127
|
+
scheduleid="5" priority="1">
|
|
128
|
+
<dependants><file>video.mp4</file></dependants>
|
|
129
|
+
</layout>
|
|
130
|
+
</schedule>`;
|
|
131
|
+
const result = parseScheduleResponse(xml);
|
|
132
|
+
expect(result.layouts[0].dependants).toEqual(['video.mp4']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should parse dependants on campaign layouts', () => {
|
|
136
|
+
const xml = `<schedule>
|
|
137
|
+
<campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
138
|
+
scheduleid="30">
|
|
139
|
+
<layout file="300.xlf">
|
|
140
|
+
<dependants><file>font.woff2</file></dependants>
|
|
141
|
+
</layout>
|
|
142
|
+
</campaign>
|
|
143
|
+
</schedule>`;
|
|
144
|
+
const result = parseScheduleResponse(xml);
|
|
145
|
+
expect(result.campaigns[0].layouts[0].dependants).toEqual(['font.woff2']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should return empty array when no dependants', () => {
|
|
149
|
+
const xml = `<schedule>
|
|
150
|
+
<layout file="400.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
151
|
+
scheduleid="20" priority="0"/>
|
|
152
|
+
</schedule>`;
|
|
153
|
+
const result = parseScheduleResponse(xml);
|
|
154
|
+
expect(result.layouts[0].dependants).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should parse duration, cyclePlayback, groupKey, playCount on standalone layouts', () => {
|
|
158
|
+
const xml = `<schedule>
|
|
159
|
+
<layout file="500.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
160
|
+
scheduleid="60" priority="1" duration="120"
|
|
161
|
+
cyclePlayback="1" groupKey="group-A" playCount="3"/>
|
|
162
|
+
</schedule>`;
|
|
163
|
+
const result = parseScheduleResponse(xml);
|
|
164
|
+
const layout = result.layouts[0];
|
|
165
|
+
expect(layout.duration).toBe(120);
|
|
166
|
+
expect(layout.cyclePlayback).toBe(true);
|
|
167
|
+
expect(layout.groupKey).toBe('group-A');
|
|
168
|
+
expect(layout.playCount).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should parse command code attribute correctly', () => {
|
|
172
|
+
const xml = `<schedule>
|
|
173
|
+
<command code="collectNow" date="2026-01-01"/>
|
|
174
|
+
<command code="reboot" date="2026-01-02"/>
|
|
175
|
+
</schedule>`;
|
|
176
|
+
const result = parseScheduleResponse(xml);
|
|
177
|
+
expect(result.commands).toHaveLength(2);
|
|
178
|
+
expect(result.commands[0].code).toBe('collectNow');
|
|
179
|
+
expect(result.commands[1].code).toBe('reboot');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should parse dataConnectors with connector child elements', () => {
|
|
183
|
+
const xml = `<schedule>
|
|
184
|
+
<dataConnectors>
|
|
185
|
+
<connector id="dc1" dataSetId="42" dataParams="limit=10" js="render.js"
|
|
186
|
+
url="http://cms.example.com/data" updateInterval="60"/>
|
|
187
|
+
</dataConnectors>
|
|
188
|
+
</schedule>`;
|
|
189
|
+
const result = parseScheduleResponse(xml);
|
|
190
|
+
expect(result.dataConnectors).toHaveLength(1);
|
|
191
|
+
expect(result.dataConnectors[0].id).toBe('dc1');
|
|
192
|
+
expect(result.dataConnectors[0].dataSetId).toBe('42');
|
|
193
|
+
expect(result.dataConnectors[0].dataParams).toBe('limit=10');
|
|
194
|
+
expect(result.dataConnectors[0].js).toBe('render.js');
|
|
195
|
+
expect(result.dataConnectors[0].url).toBe('http://cms.example.com/data');
|
|
196
|
+
expect(result.dataConnectors[0].updateInterval).toBe(60);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should return empty dataConnectors when no dataConnectors element', () => {
|
|
200
|
+
const xml = '<schedule><default file="0"/></schedule>';
|
|
201
|
+
const result = parseScheduleResponse(xml);
|
|
202
|
+
expect(result.dataConnectors).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should parse global dependants from schedule root', () => {
|
|
206
|
+
const xml = `<schedule>
|
|
207
|
+
<default file="0"/>
|
|
208
|
+
<dependants><file>global-font.woff2</file><file>shared-bg.jpg</file></dependants>
|
|
209
|
+
</schedule>`;
|
|
210
|
+
const result = parseScheduleResponse(xml);
|
|
211
|
+
expect(result.dependants).toEqual(['global-font.woff2', 'shared-bg.jpg']);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should default global dependants to empty array', () => {
|
|
215
|
+
const xml = '<schedule><default file="0"/></schedule>';
|
|
216
|
+
const result = parseScheduleResponse(xml);
|
|
217
|
+
expect(result.dependants).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should parse campaign-level geo/sync/shareOfVoice attributes', () => {
|
|
221
|
+
const xml = `<schedule>
|
|
222
|
+
<campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
223
|
+
scheduleid="30" shareOfVoice="50" isGeoAware="1"
|
|
224
|
+
geoLocation="51.5,-0.1" syncEvent="1">
|
|
225
|
+
<layout file="500.xlf"/>
|
|
226
|
+
</campaign>
|
|
227
|
+
</schedule>`;
|
|
228
|
+
const result = parseScheduleResponse(xml);
|
|
229
|
+
const campaign = result.campaigns[0];
|
|
230
|
+
expect(campaign.shareOfVoice).toBe(50);
|
|
231
|
+
expect(campaign.isGeoAware).toBe(true);
|
|
232
|
+
expect(campaign.geoLocation).toBe('51.5,-0.1');
|
|
233
|
+
expect(campaign.syncEvent).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should parse criteria on campaigns', () => {
|
|
237
|
+
const xml = `<schedule>
|
|
238
|
+
<campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
239
|
+
scheduleid="30">
|
|
240
|
+
<criteria metric="temperature" condition="gt" type="number">25</criteria>
|
|
241
|
+
<layout file="500.xlf"/>
|
|
242
|
+
</campaign>
|
|
243
|
+
</schedule>`;
|
|
244
|
+
const result = parseScheduleResponse(xml);
|
|
245
|
+
expect(result.campaigns[0].criteria).toHaveLength(1);
|
|
246
|
+
expect(result.campaigns[0].criteria[0].metric).toBe('temperature');
|
|
247
|
+
expect(result.campaigns[0].criteria[0].value).toBe('25');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should parse duration, cyclePlayback, groupKey, playCount on campaign layouts', () => {
|
|
251
|
+
const xml = `<schedule>
|
|
252
|
+
<campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
253
|
+
scheduleid="30">
|
|
254
|
+
<layout file="500.xlf" duration="90" cyclePlayback="1" groupKey="group-B" playCount="2"/>
|
|
255
|
+
</campaign>
|
|
256
|
+
</schedule>`;
|
|
257
|
+
const result = parseScheduleResponse(xml);
|
|
258
|
+
const layout = result.campaigns[0].layouts[0];
|
|
259
|
+
expect(layout.duration).toBe(90);
|
|
260
|
+
expect(layout.cyclePlayback).toBe(true);
|
|
261
|
+
expect(layout.groupKey).toBe('group-B');
|
|
262
|
+
expect(layout.playCount).toBe(2);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should use consistent lowercase casing for fromdt/todt/scheduleid on overlays', () => {
|
|
266
|
+
const xml = `<schedule>
|
|
267
|
+
<overlays>
|
|
268
|
+
<overlay file="600.xlf" fromdt="2025-01-01 12:00:00" todt="2025-01-01 13:00:00"
|
|
269
|
+
scheduleid="40" priority="2"/>
|
|
270
|
+
</overlays>
|
|
271
|
+
</schedule>`;
|
|
272
|
+
const result = parseScheduleResponse(xml);
|
|
273
|
+
const overlay = result.overlays[0];
|
|
274
|
+
expect(overlay.fromdt).toBe('2025-01-01 12:00:00');
|
|
275
|
+
expect(overlay.todt).toBe('2025-01-01 13:00:00');
|
|
276
|
+
expect(overlay.scheduleid).toBe('40');
|
|
277
|
+
// Verify old camelCase properties are NOT present
|
|
278
|
+
expect(overlay.fromDt).toBeUndefined();
|
|
279
|
+
expect(overlay.toDt).toBeUndefined();
|
|
280
|
+
expect(overlay.scheduleId).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
});
|
package/src/xmds-client.js
CHANGED
|
@@ -149,13 +149,14 @@ export class XmdsClient {
|
|
|
149
149
|
serverKey: this.config.cmsKey,
|
|
150
150
|
hardwareKey: this.config.hardwareKey,
|
|
151
151
|
displayName: this.config.displayName,
|
|
152
|
-
clientType: 'chromeOS',
|
|
153
|
-
clientVersion: '0.1.0',
|
|
154
|
-
clientCode: '1',
|
|
152
|
+
clientType: this.config.clientType || 'chromeOS',
|
|
153
|
+
clientVersion: this.config.clientVersion || '0.1.0',
|
|
154
|
+
clientCode: this.config.clientCode || '1',
|
|
155
155
|
operatingSystem: os,
|
|
156
156
|
macAddress: this.config.macAddress || 'n/a',
|
|
157
|
-
xmrChannel: this.config.xmrChannel,
|
|
158
|
-
xmrPubKey: this.config.xmrPubKey || ''
|
|
157
|
+
xmrChannel: this.config.xmrChannel || '',
|
|
158
|
+
xmrPubKey: this.config.xmrPubKey || '',
|
|
159
|
+
licenceResult: 'licensed'
|
|
159
160
|
});
|
|
160
161
|
|
|
161
162
|
return this.parseRegisterDisplayResponse(xml);
|
|
@@ -181,16 +182,54 @@ export class XmdsClient {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
const settings = {};
|
|
185
|
+
const tags = [];
|
|
186
|
+
const commands = [];
|
|
184
187
|
for (const child of display.children) {
|
|
185
|
-
|
|
186
|
-
|
|
188
|
+
const name = child.tagName.toLowerCase();
|
|
189
|
+
if (name === 'file') continue;
|
|
190
|
+
if (name === 'commands') {
|
|
191
|
+
// Parse <commands><command code="xyz" commandString="args"/></commands>
|
|
192
|
+
for (const cmdEl of child.querySelectorAll('command')) {
|
|
193
|
+
commands.push({
|
|
194
|
+
commandCode: cmdEl.getAttribute('code') || cmdEl.getAttribute('commandCode') || '',
|
|
195
|
+
commandString: cmdEl.getAttribute('commandString') || ''
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
187
199
|
}
|
|
200
|
+
if (name === 'tags') {
|
|
201
|
+
// Parse <tags><tag>value</tag>...</tags> into array
|
|
202
|
+
for (const tagEl of child.querySelectorAll('tag')) {
|
|
203
|
+
if (tagEl.textContent) tags.push(tagEl.textContent);
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
settings[child.tagName] = child.textContent;
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
const checkRf = display.getAttribute('checkRf') || '';
|
|
191
211
|
const checkSchedule = display.getAttribute('checkSchedule') || '';
|
|
192
212
|
|
|
193
|
-
|
|
213
|
+
// Extract display-level attributes from CMS (server time, status, version info)
|
|
214
|
+
const displayAttrs = {
|
|
215
|
+
date: display.getAttribute('date') || null,
|
|
216
|
+
timezone: display.getAttribute('timezone') || null,
|
|
217
|
+
status: display.getAttribute('status') || null,
|
|
218
|
+
localDate: display.getAttribute('localDate') || null,
|
|
219
|
+
version_instructions: display.getAttribute('version_instructions') || null,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Extract sync group config if present (multi-display sync coordination)
|
|
223
|
+
const syncGroupVal = settings.syncGroup || null;
|
|
224
|
+
const syncConfig = syncGroupVal ? {
|
|
225
|
+
syncGroup: syncGroupVal,
|
|
226
|
+
syncPublisherPort: parseInt(settings.syncPublisherPort || '9590', 10),
|
|
227
|
+
syncSwitchDelay: parseInt(settings.syncSwitchDelay || '750', 10),
|
|
228
|
+
syncVideoPauseDelay: parseInt(settings.syncVideoPauseDelay || '100', 10),
|
|
229
|
+
isLead: syncGroupVal === 'lead',
|
|
230
|
+
} : null;
|
|
231
|
+
|
|
232
|
+
return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
|
|
194
233
|
}
|
|
195
234
|
|
|
196
235
|
/**
|
|
@@ -221,6 +260,8 @@ export class XmdsClient {
|
|
|
221
260
|
md5: fileEl.getAttribute('md5'),
|
|
222
261
|
download: fileEl.getAttribute('download'),
|
|
223
262
|
path: fileEl.getAttribute('path'),
|
|
263
|
+
saveAs: fileEl.getAttribute('saveAs') || null,
|
|
264
|
+
fileType: fileEl.getAttribute('fileType') || null,
|
|
224
265
|
code: fileEl.getAttribute('code'),
|
|
225
266
|
layoutid: fileEl.getAttribute('layoutid'),
|
|
226
267
|
regionid: fileEl.getAttribute('regionid'),
|
|
@@ -228,7 +269,19 @@ export class XmdsClient {
|
|
|
228
269
|
});
|
|
229
270
|
}
|
|
230
271
|
|
|
231
|
-
|
|
272
|
+
// Parse purge block — files CMS wants the player to delete
|
|
273
|
+
const purgeItems = [];
|
|
274
|
+
const purgeEl = doc.querySelector('purge');
|
|
275
|
+
if (purgeEl) {
|
|
276
|
+
for (const itemEl of purgeEl.querySelectorAll('item')) {
|
|
277
|
+
purgeItems.push({
|
|
278
|
+
id: itemEl.getAttribute('id'),
|
|
279
|
+
storedAs: itemEl.getAttribute('storedAs')
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { files, purge: purgeItems };
|
|
232
285
|
}
|
|
233
286
|
|
|
234
287
|
/**
|
|
@@ -277,6 +330,11 @@ export class XmdsClient {
|
|
|
277
330
|
status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
278
331
|
}
|
|
279
332
|
|
|
333
|
+
// Add statusDialog (summary for CMS display status page) if not provided
|
|
334
|
+
if (!status.statusDialog) {
|
|
335
|
+
status.statusDialog = `Current Layout: ${status.currentLayoutId || 'None'}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
280
338
|
return await this.call('NotifyStatus', {
|
|
281
339
|
serverKey: this.config.cmsKey,
|
|
282
340
|
hardwareKey: this.config.hardwareKey,
|
|
@@ -324,10 +382,10 @@ export class XmdsClient {
|
|
|
324
382
|
* @param {string} logXml - XML string containing log entries
|
|
325
383
|
* @returns {Promise<boolean>} - true if logs were successfully submitted
|
|
326
384
|
*/
|
|
327
|
-
async submitLog(logXml) {
|
|
385
|
+
async submitLog(logXml, hardwareKeyOverride = null) {
|
|
328
386
|
const xml = await this.call('SubmitLog', {
|
|
329
387
|
serverKey: this.config.cmsKey,
|
|
330
|
-
hardwareKey: this.config.hardwareKey,
|
|
388
|
+
hardwareKey: hardwareKeyOverride || this.config.hardwareKey,
|
|
331
389
|
logXml: logXml
|
|
332
390
|
});
|
|
333
391
|
|
|
@@ -367,11 +425,22 @@ export class XmdsClient {
|
|
|
367
425
|
});
|
|
368
426
|
}
|
|
369
427
|
|
|
370
|
-
|
|
428
|
+
/**
|
|
429
|
+
* GetWeather - get current weather data for schedule criteria
|
|
430
|
+
* @returns {Promise<string>} Weather data XML from CMS
|
|
431
|
+
*/
|
|
432
|
+
async getWeather() {
|
|
433
|
+
return this.call('GetWeather', {
|
|
434
|
+
serverKey: this.config.cmsKey,
|
|
435
|
+
hardwareKey: this.config.hardwareKey
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async submitStats(statsXml, hardwareKeyOverride = null) {
|
|
371
440
|
try {
|
|
372
441
|
const xml = await this.call('SubmitStats', {
|
|
373
442
|
serverKey: this.config.cmsKey,
|
|
374
|
-
hardwareKey: this.config.hardwareKey,
|
|
443
|
+
hardwareKey: hardwareKeyOverride || this.config.hardwareKey,
|
|
375
444
|
statXml: statsXml
|
|
376
445
|
});
|
|
377
446
|
|
|
@@ -25,8 +25,8 @@ describe('Schedule Parsing - Overlays', () => {
|
|
|
25
25
|
expect(schedule.overlays[0].file).toBe('101.xlf');
|
|
26
26
|
expect(schedule.overlays[0].duration).toBe(60);
|
|
27
27
|
expect(schedule.overlays[0].priority).toBe(10);
|
|
28
|
-
expect(schedule.overlays[0].
|
|
29
|
-
expect(schedule.overlays[0].
|
|
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
30
|
});
|
|
31
31
|
|
|
32
32
|
it('should parse multiple overlays', () => {
|
package/src/xmds.rest.test.js
CHANGED
|
@@ -353,17 +353,18 @@ describe('RestClient - RequiredFiles', () => {
|
|
|
353
353
|
]
|
|
354
354
|
}, { etag: '"files-v1"' }));
|
|
355
355
|
|
|
356
|
-
const
|
|
356
|
+
const result = await client.requiredFiles();
|
|
357
357
|
|
|
358
|
-
expect(files).toHaveLength(3);
|
|
359
|
-
expect(files[0]).toEqual({
|
|
358
|
+
expect(result.files).toHaveLength(3);
|
|
359
|
+
expect(result.files[0]).toEqual(expect.objectContaining({
|
|
360
360
|
type: 'media', id: '42', size: 1024, md5: 'abc', download: 'http',
|
|
361
361
|
path: '/media/42.jpg', code: null, layoutid: null, regionid: null, mediaid: null,
|
|
362
|
-
});
|
|
363
|
-
expect(files[1].type).toBe('layout');
|
|
364
|
-
expect(files[2].layoutid).toBe('10');
|
|
365
|
-
expect(files[2].regionid).toBe('5');
|
|
366
|
-
expect(files[2].mediaid).toBe('99');
|
|
362
|
+
}));
|
|
363
|
+
expect(result.files[1].type).toBe('layout');
|
|
364
|
+
expect(result.files[2].layoutid).toBe('10');
|
|
365
|
+
expect(result.files[2].regionid).toBe('5');
|
|
366
|
+
expect(result.files[2].mediaid).toBe('99');
|
|
367
|
+
expect(result.purge).toEqual([]);
|
|
367
368
|
});
|
|
368
369
|
|
|
369
370
|
it('should handle single file (not array)', async () => {
|
|
@@ -371,16 +372,16 @@ describe('RestClient - RequiredFiles', () => {
|
|
|
371
372
|
file: { '@attributes': { type: 'media', id: '1', size: '100', md5: 'x' } }
|
|
372
373
|
}));
|
|
373
374
|
|
|
374
|
-
const
|
|
375
|
-
expect(files).toHaveLength(1);
|
|
376
|
-
expect(files[0].id).toBe('1');
|
|
375
|
+
const result = await client.requiredFiles();
|
|
376
|
+
expect(result.files).toHaveLength(1);
|
|
377
|
+
expect(result.files[0].id).toBe('1');
|
|
377
378
|
});
|
|
378
379
|
|
|
379
380
|
it('should handle empty file list', async () => {
|
|
380
381
|
mockFetch.mockResolvedValue(jsonResponse({}));
|
|
381
382
|
|
|
382
|
-
const
|
|
383
|
-
expect(files).toHaveLength(0);
|
|
383
|
+
const result = await client.requiredFiles();
|
|
384
|
+
expect(result.files).toHaveLength(0);
|
|
384
385
|
});
|
|
385
386
|
|
|
386
387
|
it('should use ETag caching', async () => {
|
|
@@ -407,12 +408,12 @@ describe('RestClient - RequiredFiles', () => {
|
|
|
407
408
|
|
|
408
409
|
const result = client._parseRequiredFilesJson(json);
|
|
409
410
|
|
|
410
|
-
expect(result[0].type).toBe('media');
|
|
411
|
-
expect(result[0].id).toBe('42');
|
|
412
|
-
expect(result[0].size).toBe(1024);
|
|
413
|
-
expect(result[0].md5).toBe('abc');
|
|
414
|
-
expect(result[0].download).toBe('http');
|
|
415
|
-
expect(result[0].path).toBe('/media/42.jpg');
|
|
411
|
+
expect(result.files[0].type).toBe('media');
|
|
412
|
+
expect(result.files[0].id).toBe('42');
|
|
413
|
+
expect(result.files[0].size).toBe(1024);
|
|
414
|
+
expect(result.files[0].md5).toBe('abc');
|
|
415
|
+
expect(result.files[0].download).toBe('http');
|
|
416
|
+
expect(result.files[0].path).toBe('/media/42.jpg');
|
|
416
417
|
});
|
|
417
418
|
});
|
|
418
419
|
|
|
@@ -676,10 +677,41 @@ describe('RestClient - MediaInventory', () => {
|
|
|
676
677
|
});
|
|
677
678
|
});
|
|
678
679
|
|
|
679
|
-
// ─── BlackList
|
|
680
|
+
// ─── BlackList ───────────────────────────────────────────────────────
|
|
680
681
|
|
|
681
682
|
describe('RestClient - BlackList', () => {
|
|
682
|
-
|
|
683
|
+
let mockFetch;
|
|
684
|
+
|
|
685
|
+
beforeEach(() => {
|
|
686
|
+
mockFetch = vi.fn();
|
|
687
|
+
global.fetch = mockFetch;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('should POST to /blacklist endpoint', async () => {
|
|
691
|
+
mockFetch.mockResolvedValueOnce({
|
|
692
|
+
ok: true,
|
|
693
|
+
status: 200,
|
|
694
|
+
headers: { get: () => 'application/json' },
|
|
695
|
+
json: () => Promise.resolve({ success: true }),
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const client = createRestClient();
|
|
699
|
+
const result = await client.blackList('42', 'media', 'Broken file');
|
|
700
|
+
|
|
701
|
+
expect(result).toBe(true);
|
|
702
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
703
|
+
expect.stringContaining('/blacklist'),
|
|
704
|
+
expect.objectContaining({ method: 'POST' })
|
|
705
|
+
);
|
|
706
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
707
|
+
expect(body.mediaId).toBe('42');
|
|
708
|
+
expect(body.type).toBe('media');
|
|
709
|
+
expect(body.reason).toBe('Broken file');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('should return false on failure', async () => {
|
|
713
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
714
|
+
|
|
683
715
|
const client = createRestClient();
|
|
684
716
|
const result = await client.blackList('42', 'media', 'Broken');
|
|
685
717
|
expect(result).toBe(false);
|
package/src/xmds.test.js
CHANGED
|
@@ -883,6 +883,105 @@ describe('XmdsClient - NotifyStatus', () => {
|
|
|
883
883
|
});
|
|
884
884
|
});
|
|
885
885
|
|
|
886
|
+
describe('XmdsClient - GetWeather', () => {
|
|
887
|
+
let client;
|
|
888
|
+
let mockFetch;
|
|
889
|
+
|
|
890
|
+
beforeEach(() => {
|
|
891
|
+
client = new XmdsClient({
|
|
892
|
+
cmsAddress: 'https://cms.example.com',
|
|
893
|
+
cmsKey: 'test-server-key',
|
|
894
|
+
hardwareKey: 'test-hardware-key',
|
|
895
|
+
retryOptions: { maxRetries: 0 }
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
mockFetch = vi.fn();
|
|
899
|
+
global.fetch = mockFetch;
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('should build correct SOAP envelope for GetWeather', async () => {
|
|
903
|
+
mockFetch.mockResolvedValue({
|
|
904
|
+
ok: true,
|
|
905
|
+
text: async () => `<?xml version="1.0"?>
|
|
906
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
907
|
+
<soap:Body>
|
|
908
|
+
<GetWeatherResponse>
|
|
909
|
+
<weather>{"temperature":22,"humidity":65,"windSpeed":10,"condition":"Clear","cloudCover":15}</weather>
|
|
910
|
+
</GetWeatherResponse>
|
|
911
|
+
</soap:Body>
|
|
912
|
+
</soap:Envelope>`
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
await client.getWeather();
|
|
916
|
+
|
|
917
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
918
|
+
'https://cms.example.com/xmds.php?v=5&method=GetWeather',
|
|
919
|
+
expect.objectContaining({
|
|
920
|
+
method: 'POST',
|
|
921
|
+
headers: {
|
|
922
|
+
'Content-Type': 'text/xml; charset=utf-8'
|
|
923
|
+
}
|
|
924
|
+
})
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
const body = mockFetch.mock.calls[0][1].body;
|
|
928
|
+
expect(body).toContain('<tns:GetWeather>');
|
|
929
|
+
expect(body).toContain('<serverKey xsi:type="xsd:string">test-server-key</serverKey>');
|
|
930
|
+
expect(body).toContain('<hardwareKey xsi:type="xsd:string">test-hardware-key</hardwareKey>');
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('should return weather data from SOAP response', async () => {
|
|
934
|
+
const weatherJson = '{"temperature":22,"humidity":65,"windSpeed":10,"condition":"Clear","cloudCover":15}';
|
|
935
|
+
mockFetch.mockResolvedValue({
|
|
936
|
+
ok: true,
|
|
937
|
+
text: async () => `<?xml version="1.0"?>
|
|
938
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
939
|
+
<soap:Body>
|
|
940
|
+
<GetWeatherResponse>
|
|
941
|
+
<weather>${weatherJson}</weather>
|
|
942
|
+
</GetWeatherResponse>
|
|
943
|
+
</soap:Body>
|
|
944
|
+
</soap:Envelope>`
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
const result = await client.getWeather();
|
|
948
|
+
expect(result).toBe(weatherJson);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('should handle SOAP fault', async () => {
|
|
952
|
+
mockFetch.mockResolvedValue({
|
|
953
|
+
ok: true,
|
|
954
|
+
text: async () => `<?xml version="1.0"?>
|
|
955
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
956
|
+
<soap:Body>
|
|
957
|
+
<soap:Fault>
|
|
958
|
+
<faultcode>Server</faultcode>
|
|
959
|
+
<faultstring>Weather service unavailable</faultstring>
|
|
960
|
+
</soap:Fault>
|
|
961
|
+
</soap:Body>
|
|
962
|
+
</soap:Envelope>`
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
await expect(client.getWeather()).rejects.toThrow('SOAP Fault: Weather service unavailable');
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it('should handle HTTP errors', async () => {
|
|
969
|
+
mockFetch.mockResolvedValue({
|
|
970
|
+
ok: false,
|
|
971
|
+
status: 500,
|
|
972
|
+
statusText: 'Internal Server Error'
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
await expect(client.getWeather()).rejects.toThrow('XMDS GetWeather failed: 500 Internal Server Error');
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it('should handle network errors', async () => {
|
|
979
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
980
|
+
|
|
981
|
+
await expect(client.getWeather()).rejects.toThrow('Network error');
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
886
985
|
describe('XmdsClient - MediaInventory', () => {
|
|
887
986
|
let client;
|
|
888
987
|
let mockFetch;
|