@xiboplayer/xmds 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/xmds",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
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.3.6"
12
+ "@xiboplayer/utils": "0.4.0"
13
13
  },
14
14
  "devDependencies": {
15
15
  "vitest": "^2.0.0"
@@ -142,13 +142,14 @@ export class RestClient {
142
142
 
143
143
  const json = await this.restSend('POST', '/register', {
144
144
  displayName: this.config.displayName,
145
- clientType: 'chromeOS',
146
- clientVersion: '0.1.0',
147
- clientCode: 1,
145
+ clientType: this.config.clientType || 'chromeOS',
146
+ clientVersion: this.config.clientVersion || '0.1.0',
147
+ clientCode: this.config.clientCode || 1,
148
148
  operatingSystem: os,
149
149
  macAddress: this.config.macAddress || 'n/a',
150
150
  xmrChannel: this.config.xmrChannel,
151
151
  xmrPubKey: this.config.xmrPubKey || '',
152
+ licenceResult: 'licensed',
152
153
  });
153
154
 
154
155
  return this._parseRegisterDisplayJson(json);
@@ -169,14 +170,42 @@ export class RestClient {
169
170
  }
170
171
 
171
172
  const settings = {};
173
+ let tags = [];
174
+ let commands = [];
172
175
  for (const [key, value] of Object.entries(display)) {
173
- if (key === '@attributes' || key === 'commands' || key === 'file') continue;
176
+ if (key === '@attributes' || key === 'file') continue;
177
+ if (key === 'commands') {
178
+ // Parse commands: array of {code/commandCode, commandString} objects
179
+ if (Array.isArray(value)) {
180
+ commands = value.map(c => ({
181
+ commandCode: c.code || c.commandCode || '',
182
+ commandString: c.commandString || ''
183
+ }));
184
+ }
185
+ continue;
186
+ }
187
+ if (key === 'tags') {
188
+ // Parse tags: array of strings, or array of {tag: "value"} objects
189
+ if (Array.isArray(value)) {
190
+ tags = value.map(t => typeof t === 'object' ? (t.tag || t.value || '') : String(t)).filter(Boolean);
191
+ }
192
+ continue;
193
+ }
174
194
  settings[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
175
195
  }
176
196
 
177
197
  const checkRf = attrs.checkRf || '';
178
198
  const checkSchedule = attrs.checkSchedule || '';
179
199
 
200
+ // Extract display-level attributes from CMS (server time, status, version info)
201
+ const displayAttrs = {
202
+ date: attrs.date || display.date || null,
203
+ timezone: attrs.timezone || display.timezone || null,
204
+ status: attrs.status || display.status || null,
205
+ localDate: attrs.localDate || display.localDate || null,
206
+ version_instructions: attrs.version_instructions || display.version_instructions || null,
207
+ };
208
+
180
209
  // Extract sync group config if present (multi-display sync coordination)
181
210
  // syncGroup: "lead" if this display is leader, or leader's LAN IP if follower
182
211
  const syncConfig = display.syncGroup ? {
@@ -187,7 +216,7 @@ export class RestClient {
187
216
  isLead: String(display.syncGroup) === 'lead',
188
217
  } : null;
189
218
 
190
- return { code, message, settings, checkRf, checkSchedule, syncConfig };
219
+ return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
191
220
  }
192
221
 
193
222
  /**
@@ -221,6 +250,8 @@ export class RestClient {
221
250
  md5: attrs.md5 || null,
222
251
  download: attrs.download || null,
223
252
  path,
253
+ saveAs: attrs.saveAs || null,
254
+ fileType: attrs.fileType || null,
224
255
  code: attrs.code || null,
225
256
  layoutid: attrs.layoutid || null,
226
257
  regionid: attrs.regionid || null,
@@ -228,7 +259,19 @@ export class RestClient {
228
259
  });
229
260
  }
230
261
 
231
- return files;
262
+ // Parse purge items — files CMS wants the player to delete
263
+ const purgeItems = [];
264
+ let purgeList = json.purge?.item || [];
265
+ if (!Array.isArray(purgeList)) purgeList = [purgeList];
266
+ for (const p of purgeList) {
267
+ const pAttrs = p['@attributes'] || p;
268
+ purgeItems.push({
269
+ id: pAttrs.id || null,
270
+ storedAs: pAttrs.storedAs || null,
271
+ });
272
+ }
273
+
274
+ return { files, purge: purgeItems };
232
275
  }
233
276
 
234
277
  /**
@@ -272,6 +315,11 @@ export class RestClient {
272
315
  status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
273
316
  }
274
317
 
318
+ // Add statusDialog (summary for CMS display status page) if not provided
319
+ if (!status.statusDialog) {
320
+ status.statusDialog = `Current Layout: ${status.currentLayoutId || 'None'}`;
321
+ }
322
+
275
323
  return this.restSend('PUT', '/status', {
276
324
  statusData: status,
277
325
  });
@@ -291,13 +339,25 @@ export class RestClient {
291
339
 
292
340
  /**
293
341
  * BlackList - report broken media to CMS
294
- *
295
- * BlackList has no REST equivalent endpoint.
296
- * Log a warning and return false.
342
+ * POST /blacklist → JSON acknowledgement
343
+ * @param {string|number} mediaId - The media ID
344
+ * @param {string} type - File type ('media' or 'layout')
345
+ * @param {string} reason - Reason for blacklisting
346
+ * @returns {Promise<boolean>}
297
347
  */
298
348
  async blackList(mediaId, type, reason) {
299
- log.warn(`BlackList not available via REST (${type}/${mediaId}: ${reason})`);
300
- return false;
349
+ try {
350
+ const result = await this.restSend('POST', '/blacklist', {
351
+ mediaId: String(mediaId),
352
+ type: type || 'media',
353
+ reason: reason || 'Failed to render',
354
+ });
355
+ log.info(`BlackListed ${type}/${mediaId}: ${reason}`);
356
+ return result?.success === true;
357
+ } catch (error) {
358
+ log.warn('BlackList failed:', error);
359
+ return false;
360
+ }
301
361
  }
302
362
 
303
363
  /**
@@ -337,6 +397,15 @@ export class RestClient {
337
397
  return result?.success === true;
338
398
  }
339
399
 
400
+ /**
401
+ * GetWeather - get current weather data for schedule criteria
402
+ * GET /weather → JSON weather data
403
+ * @returns {Promise<Object>} Weather data from CMS
404
+ */
405
+ async getWeather() {
406
+ return this.restGet('/weather');
407
+ }
408
+
340
409
  async submitStats(statsXml) {
341
410
  try {
342
411
  // Accept array (JSON-native) or string (XML) — send under the right key
@@ -16,12 +16,13 @@
16
16
  */
17
17
  function parseCriteria(parentEl) {
18
18
  const criteria = [];
19
- for (const criteriaEl of parentEl.querySelectorAll(':scope > criteria')) {
19
+ for (const child of parentEl.children) {
20
+ if (child.tagName !== 'criteria') continue;
20
21
  criteria.push({
21
- metric: criteriaEl.getAttribute('metric') || '',
22
- condition: criteriaEl.getAttribute('condition') || '',
23
- type: criteriaEl.getAttribute('type') || 'string',
24
- value: criteriaEl.textContent || ''
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
- fromDt: overlayEl.getAttribute('fromdt'),
120
- toDt: overlayEl.getAttribute('todt'),
168
+ fromdt: overlayEl.getAttribute('fromdt'),
169
+ todt: overlayEl.getAttribute('todt'),
121
170
  priority: parseInt(overlayEl.getAttribute('priority') || '0'),
122
- scheduleId: overlayEl.getAttribute('scheduleid'),
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
- fromDt: actionEl.getAttribute('fromdt'),
143
- toDt: actionEl.getAttribute('todt'),
195
+ fromdt: actionEl.getAttribute('fromdt'),
196
+ todt: actionEl.getAttribute('todt'),
144
197
  priority: parseInt(actionEl.getAttribute('priority') || '0'),
145
- scheduleId: actionEl.getAttribute('scheduleid'),
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('command') || '',
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
- for (const dcEl of doc.querySelectorAll('dataconnector')) {
162
- schedule.dataConnectors.push({
163
- id: dcEl.getAttribute('id') || '',
164
- dataConnectorId: dcEl.getAttribute('dataConnectorId') || '',
165
- dataKey: dcEl.getAttribute('dataKey') || '',
166
- url: dcEl.getAttribute('url') || '',
167
- updateInterval: parseInt(dcEl.getAttribute('updateInterval') || '300', 10)
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
+ });
@@ -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
- if (!['commands', 'file'].includes(child.tagName.toLowerCase())) {
186
- settings[child.tagName] = child.textContent;
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
- return { code, message, settings, checkRf, checkSchedule };
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
- return files;
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,
@@ -367,6 +425,17 @@ export class XmdsClient {
367
425
  });
368
426
  }
369
427
 
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
+
370
439
  async submitStats(statsXml) {
371
440
  try {
372
441
  const xml = await this.call('SubmitStats', {
@@ -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].fromDt).toBe('2026-01-01 00:00:00');
29
- expect(schedule.overlays[0].toDt).toBe('2026-12-31 23:59:59');
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', () => {
@@ -353,17 +353,18 @@ describe('RestClient - RequiredFiles', () => {
353
353
  ]
354
354
  }, { etag: '"files-v1"' }));
355
355
 
356
- const files = await client.requiredFiles();
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 files = await client.requiredFiles();
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 files = await client.requiredFiles();
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 (always SOAP) ─────────────────────────────────────────
680
+ // ─── BlackList ───────────────────────────────────────────────────────
680
681
 
681
682
  describe('RestClient - BlackList', () => {
682
- it('should return false since REST has no BlackList endpoint', async () => {
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;