@xiboplayer/xmds 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @xiboplayer/xmds Documentation
2
+
3
+ **XMDS (XML-based Media Distribution Service) SOAP client.**
4
+
5
+ ## Overview
6
+
7
+ SOAP client for Xibo CMS communication:
8
+
9
+ - Display registration
10
+ - Content synchronization
11
+ - File downloads
12
+ - Proof of play submission
13
+ - Log reporting
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @xiboplayer/xmds
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```javascript
24
+ import { XMDSClient } from '@xiboplayer/xmds';
25
+
26
+ const client = new XMDSClient({
27
+ cmsUrl: 'https://cms.example.com',
28
+ serverKey: 'abc123',
29
+ hardwareKey: 'def456'
30
+ });
31
+
32
+ // Register display
33
+ await client.registerDisplay();
34
+
35
+ // Get required files
36
+ const files = await client.requiredFiles();
37
+
38
+ // Download file
39
+ const blob = await client.getFile(fileId, fileType);
40
+ ```
41
+
42
+ ## SOAP Methods
43
+
44
+ - `RegisterDisplay` - Register/verify display
45
+ - `RequiredFiles` - Get content to download
46
+ - `GetFile` - Download media file
47
+ - `SubmitStats` - Send proof of play
48
+ - `SubmitLog` - Report errors
49
+
50
+ ## Dependencies
51
+
52
+ - `@xiboplayer/utils` - Logger
53
+
54
+ ## Related Packages
55
+
56
+ - [@xiboplayer/core](../../core/docs/) - Player core
57
+
58
+ ---
59
+
60
+ **Package Version**: 1.0.0
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@xiboplayer/xmds",
3
+ "version": "0.1.0",
4
+ "description": "XMDS SOAP client for Xibo CMS communication",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./xmds": "./src/xmds.js"
10
+ },
11
+ "dependencies": {
12
+ "@xiboplayer/utils": "0.1.0"
13
+ },
14
+ "devDependencies": {
15
+ "vitest": "^2.0.0"
16
+ },
17
+ "keywords": [
18
+ "xibo",
19
+ "digital-signage",
20
+ "xmds",
21
+ "soap",
22
+ "cms"
23
+ ],
24
+ "author": "Pau Aliagas <linuxnow@gmail.com>",
25
+ "license": "AGPL-3.0-or-later",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/xibo-players/xiboplayer.git",
29
+ "directory": "packages/xmds"
30
+ },
31
+ "scripts": {
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "test:coverage": "vitest run --coverage"
35
+ }
36
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @xiboplayer/xmds - XMDS clients (REST and SOAP)
2
+ export { RestClient } from './rest-client.js';
3
+ export { XmdsClient } from './xmds-client.js';
4
+ export { parseScheduleResponse } from './schedule-parser.js';
@@ -0,0 +1,342 @@
1
+ /**
2
+ * REST transport client for Xibo CMS.
3
+ *
4
+ * Uses the /pwa REST API endpoints with JSON payloads and ETag caching.
5
+ * Lighter than SOAP — ~30% smaller payloads, standard HTTP semantics.
6
+ *
7
+ * Protocol: https://github.com/linuxnow/xibo_players_docs
8
+ */
9
+ import { createLogger, fetchWithRetry } from '@xiboplayer/utils';
10
+ import { parseScheduleResponse } from './schedule-parser.js';
11
+
12
+ const log = createLogger('REST');
13
+
14
+ export class RestClient {
15
+ constructor(config) {
16
+ this.config = config;
17
+ this.schemaVersion = 7;
18
+ this.retryOptions = config.retryOptions || { maxRetries: 2, baseDelayMs: 2000 };
19
+
20
+ // ETag-based HTTP caching
21
+ this._etags = new Map(); // endpoint → ETag string
22
+ this._responseCache = new Map(); // endpoint → cached parsed response
23
+
24
+ log.info('Using REST transport');
25
+ }
26
+
27
+ // ─── Transport helpers ──────────────────────────────────────────
28
+
29
+ /**
30
+ * Get the REST API base URL.
31
+ * Falls back to /pwa path relative to the CMS address.
32
+ */
33
+ getRestBaseUrl() {
34
+ const base = this.config.restApiUrl || `${this.config.cmsAddress}/pwa`;
35
+ return base.replace(/\/+$/, '');
36
+ }
37
+
38
+ /**
39
+ * Make a REST GET request with optional ETag caching.
40
+ * Returns the parsed JSON body, or cached data on 304.
41
+ */
42
+ async restGet(path, queryParams = {}) {
43
+ const url = new URL(`${this.getRestBaseUrl()}${path}`);
44
+ url.searchParams.set('serverKey', this.config.cmsKey);
45
+ url.searchParams.set('hardwareKey', this.config.hardwareKey);
46
+ url.searchParams.set('v', String(this.schemaVersion));
47
+ for (const [key, value] of Object.entries(queryParams)) {
48
+ url.searchParams.set(key, String(value));
49
+ }
50
+
51
+ const cacheKey = path;
52
+ const headers = {};
53
+ const cachedEtag = this._etags.get(cacheKey);
54
+ if (cachedEtag) {
55
+ headers['If-None-Match'] = cachedEtag;
56
+ }
57
+
58
+ log.debug(`GET ${path}`, queryParams);
59
+
60
+ const response = await fetchWithRetry(url.toString(), {
61
+ method: 'GET',
62
+ headers,
63
+ }, this.retryOptions);
64
+
65
+ // 304 Not Modified — return cached response
66
+ if (response.status === 304) {
67
+ const cached = this._responseCache.get(cacheKey);
68
+ if (cached) {
69
+ log.debug(`${path} → 304 (using cache)`);
70
+ return cached;
71
+ }
72
+ // Cache miss despite 304 — fall through to fetch fresh
73
+ }
74
+
75
+ if (!response.ok) {
76
+ const errorBody = await response.text().catch(() => '');
77
+ throw new Error(`REST GET ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
78
+ }
79
+
80
+ // Store ETag for future requests
81
+ const etag = response.headers.get('ETag');
82
+ if (etag) {
83
+ this._etags.set(cacheKey, etag);
84
+ }
85
+
86
+ const contentType = response.headers.get('Content-Type') || '';
87
+ let data;
88
+ if (contentType.includes('application/json')) {
89
+ data = await response.json();
90
+ } else {
91
+ // XML or HTML — return raw text
92
+ data = await response.text();
93
+ }
94
+
95
+ // Cache parsed response for 304 reuse
96
+ this._responseCache.set(cacheKey, data);
97
+ return data;
98
+ }
99
+
100
+ /**
101
+ * Make a REST POST/PUT request with JSON body.
102
+ * Returns the parsed JSON response.
103
+ */
104
+ async restSend(method, path, body = {}) {
105
+ const url = new URL(`${this.getRestBaseUrl()}${path}`);
106
+ url.searchParams.set('v', String(this.schemaVersion));
107
+
108
+ log.debug(`${method} ${path}`);
109
+
110
+ const response = await fetchWithRetry(url.toString(), {
111
+ method,
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({
114
+ serverKey: this.config.cmsKey,
115
+ hardwareKey: this.config.hardwareKey,
116
+ ...body,
117
+ }),
118
+ }, this.retryOptions);
119
+
120
+ if (!response.ok) {
121
+ const errorBody = await response.text().catch(() => '');
122
+ throw new Error(`REST ${method} ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
123
+ }
124
+
125
+ const contentType = response.headers.get('Content-Type') || '';
126
+ if (contentType.includes('application/json')) {
127
+ return await response.json();
128
+ }
129
+ return await response.text();
130
+ }
131
+
132
+ // ─── Public API ─────────────────────────────────────────────────
133
+
134
+ /**
135
+ * RegisterDisplay - authenticate and get settings
136
+ * POST /register → JSON with display settings
137
+ */
138
+ async registerDisplay() {
139
+ const os = typeof navigator !== 'undefined'
140
+ ? `${navigator.platform} ${navigator.userAgent}`
141
+ : 'unknown';
142
+
143
+ const json = await this.restSend('POST', '/register', {
144
+ displayName: this.config.displayName,
145
+ clientType: 'chromeOS',
146
+ clientVersion: '0.1.0',
147
+ clientCode: 1,
148
+ operatingSystem: os,
149
+ macAddress: this.config.macAddress || 'n/a',
150
+ xmrChannel: this.config.xmrChannel,
151
+ xmrPubKey: '',
152
+ });
153
+
154
+ return this._parseRegisterDisplayJson(json);
155
+ }
156
+
157
+ /**
158
+ * Parse REST JSON RegisterDisplay response into the same format as SOAP.
159
+ */
160
+ _parseRegisterDisplayJson(json) {
161
+ // Handle both direct object and wrapped {display: ...} forms
162
+ const display = json.display || json;
163
+ const attrs = display['@attributes'] || {};
164
+ const code = attrs.code || display.code;
165
+ const message = attrs.message || display.message || '';
166
+
167
+ if (code !== 'READY') {
168
+ return { code, message, settings: null };
169
+ }
170
+
171
+ const settings = {};
172
+ for (const [key, value] of Object.entries(display)) {
173
+ if (key === '@attributes' || key === 'commands' || key === 'file') continue;
174
+ settings[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
175
+ }
176
+
177
+ const checkRf = attrs.checkRf || '';
178
+ const checkSchedule = attrs.checkSchedule || '';
179
+
180
+ // Extract sync group config if present (multi-display sync coordination)
181
+ // syncGroup: "lead" if this display is leader, or leader's LAN IP if follower
182
+ const syncConfig = display.syncGroup ? {
183
+ syncGroup: String(display.syncGroup),
184
+ syncPublisherPort: parseInt(display.syncPublisherPort || '9590', 10),
185
+ syncSwitchDelay: parseInt(display.syncSwitchDelay || '750', 10),
186
+ syncVideoPauseDelay: parseInt(display.syncVideoPauseDelay || '100', 10),
187
+ isLead: String(display.syncGroup) === 'lead',
188
+ } : null;
189
+
190
+ return { code, message, settings, checkRf, checkSchedule, syncConfig };
191
+ }
192
+
193
+ /**
194
+ * RequiredFiles - get list of files to download
195
+ * GET /requiredFiles → JSON file manifest (with ETag caching)
196
+ */
197
+ async requiredFiles() {
198
+ const json = await this.restGet('/requiredFiles');
199
+ return this._parseRequiredFilesJson(json);
200
+ }
201
+
202
+ /**
203
+ * Parse REST JSON RequiredFiles into the same array format as SOAP.
204
+ */
205
+ _parseRequiredFilesJson(json) {
206
+ const files = [];
207
+ let fileList = json.file || [];
208
+
209
+ // Normalize single item to array
210
+ if (!Array.isArray(fileList)) {
211
+ fileList = [fileList];
212
+ }
213
+
214
+ for (const f of fileList) {
215
+ const attrs = f['@attributes'] || f;
216
+ const path = attrs.path || null;
217
+ files.push({
218
+ type: attrs.type || null,
219
+ id: attrs.id || null,
220
+ size: parseInt(attrs.size || '0'),
221
+ md5: attrs.md5 || null,
222
+ download: attrs.download || null,
223
+ path,
224
+ code: attrs.code || null,
225
+ layoutid: attrs.layoutid || null,
226
+ regionid: attrs.regionid || null,
227
+ mediaid: attrs.mediaid || null,
228
+ });
229
+ }
230
+
231
+ return files;
232
+ }
233
+
234
+ /**
235
+ * Schedule - get layout schedule
236
+ * GET /schedule → XML (preserved for layout parser compatibility, with ETag caching)
237
+ */
238
+ async schedule() {
239
+ const xml = await this.restGet('/schedule');
240
+ return parseScheduleResponse(xml);
241
+ }
242
+
243
+ /**
244
+ * GetResource - get rendered widget HTML
245
+ * GET /getResource → HTML string
246
+ */
247
+ async getResource(layoutId, regionId, mediaId) {
248
+ return this.restGet('/getResource', {
249
+ layoutId: String(layoutId),
250
+ regionId: String(regionId),
251
+ mediaId: String(mediaId),
252
+ });
253
+ }
254
+
255
+ /**
256
+ * NotifyStatus - report current status
257
+ * PUT /status → JSON acknowledgement
258
+ * @param {Object} status - Status object with currentLayoutId, deviceName, etc.
259
+ */
260
+ async notifyStatus(status) {
261
+ // Enrich with storage estimate if available
262
+ if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
263
+ try {
264
+ const estimate = await navigator.storage.estimate();
265
+ status.availableSpace = estimate.quota - estimate.usage;
266
+ status.totalSpace = estimate.quota;
267
+ } catch (_) { /* storage estimate not supported */ }
268
+ }
269
+
270
+ // Add timezone if not already provided
271
+ if (!status.timeZone && typeof Intl !== 'undefined') {
272
+ status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
273
+ }
274
+
275
+ return this.restSend('PUT', '/status', {
276
+ statusData: status,
277
+ });
278
+ }
279
+
280
+ /**
281
+ * MediaInventory - report downloaded files
282
+ * POST /mediaInventory → JSON acknowledgement
283
+ */
284
+ async mediaInventory(inventoryXml) {
285
+ // Accept array (JSON-native) or string (XML) — send under the right key
286
+ const body = Array.isArray(inventoryXml)
287
+ ? { inventoryItems: inventoryXml }
288
+ : { inventory: inventoryXml };
289
+ return this.restSend('POST', '/mediaInventory', body);
290
+ }
291
+
292
+ /**
293
+ * BlackList - report broken media to CMS
294
+ *
295
+ * BlackList has no REST equivalent endpoint.
296
+ * Log a warning and return false.
297
+ */
298
+ async blackList(mediaId, type, reason) {
299
+ log.warn(`BlackList not available via REST (${type}/${mediaId}: ${reason})`);
300
+ return false;
301
+ }
302
+
303
+ /**
304
+ * SubmitLog - submit player logs to CMS
305
+ * POST /log → JSON acknowledgement
306
+ */
307
+ async submitLog(logXml) {
308
+ // Accept array (JSON-native) or string (XML) — send under the right key
309
+ const body = Array.isArray(logXml) ? { logs: logXml } : { logXml };
310
+ const result = await this.restSend('POST', '/log', body);
311
+ return result?.success === true;
312
+ }
313
+
314
+ /**
315
+ * SubmitScreenShot - submit screenshot to CMS
316
+ * POST /screenshot → JSON acknowledgement
317
+ */
318
+ async submitScreenShot(base64Image) {
319
+ const result = await this.restSend('POST', '/screenshot', {
320
+ screenshot: base64Image,
321
+ });
322
+ return result?.success === true;
323
+ }
324
+
325
+ /**
326
+ * SubmitStats - submit proof of play statistics
327
+ * POST /stats → JSON acknowledgement
328
+ */
329
+ async submitStats(statsXml) {
330
+ try {
331
+ // Accept array (JSON-native) or string (XML) — send under the right key
332
+ const body = Array.isArray(statsXml) ? { stats: statsXml } : { statXml: statsXml };
333
+ const result = await this.restSend('POST', '/stats', body);
334
+ const success = result?.success === true;
335
+ log.info(`SubmitStats result: ${success}`);
336
+ return success;
337
+ } catch (error) {
338
+ log.error('SubmitStats failed:', error);
339
+ throw error;
340
+ }
341
+ }
342
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Shared schedule XML parser used by both RestClient and XmdsClient.
3
+ *
4
+ * Both transports return the same XML structure for the Schedule endpoint,
5
+ * so the parsing logic lives here to avoid duplication.
6
+ */
7
+
8
+ /**
9
+ * Parse criteria child elements from a layout/overlay element.
10
+ * Criteria are conditions that must be met for the item to display.
11
+ *
12
+ * XML format: <criteria metric="dayOfWeek" condition="equals" type="string">Monday</criteria>
13
+ *
14
+ * @param {Element} parentEl - Parent XML element containing <criteria> children
15
+ * @returns {Array<{metric: string, condition: string, type: string, value: string}>}
16
+ */
17
+ function parseCriteria(parentEl) {
18
+ const criteria = [];
19
+ for (const criteriaEl of parentEl.querySelectorAll(':scope > criteria')) {
20
+ criteria.push({
21
+ metric: criteriaEl.getAttribute('metric') || '',
22
+ condition: criteriaEl.getAttribute('condition') || '',
23
+ type: criteriaEl.getAttribute('type') || 'string',
24
+ value: criteriaEl.textContent || ''
25
+ });
26
+ }
27
+ return criteria;
28
+ }
29
+
30
+ /**
31
+ * Parse Schedule XML response into a normalized schedule object.
32
+ *
33
+ * @param {string} xml - Raw XML string from CMS schedule endpoint
34
+ * @returns {Object} Parsed schedule with default, layouts, campaigns, overlays, actions, commands, dataConnectors
35
+ */
36
+ export function parseScheduleResponse(xml) {
37
+ const parser = new DOMParser();
38
+ const doc = parser.parseFromString(xml, 'text/xml');
39
+
40
+ const schedule = {
41
+ default: null,
42
+ layouts: [],
43
+ campaigns: [],
44
+ overlays: [],
45
+ actions: [],
46
+ commands: [],
47
+ dataConnectors: []
48
+ };
49
+
50
+ const defaultEl = doc.querySelector('default');
51
+ if (defaultEl) {
52
+ schedule.default = defaultEl.getAttribute('file');
53
+ }
54
+
55
+ // Parse campaigns (groups of layouts with shared priority)
56
+ for (const campaignEl of doc.querySelectorAll('campaign')) {
57
+ const campaign = {
58
+ id: campaignEl.getAttribute('id'),
59
+ priority: parseInt(campaignEl.getAttribute('priority') || '0'),
60
+ fromdt: campaignEl.getAttribute('fromdt'),
61
+ todt: campaignEl.getAttribute('todt'),
62
+ scheduleid: campaignEl.getAttribute('scheduleid'),
63
+ layouts: []
64
+ };
65
+
66
+ // Parse layouts within this campaign
67
+ for (const layoutEl of campaignEl.querySelectorAll('layout')) {
68
+ const fileId = layoutEl.getAttribute('file');
69
+ campaign.layouts.push({
70
+ id: String(fileId), // Normalized string ID for consistent type usage
71
+ file: fileId,
72
+ // Layouts in campaigns inherit timing from campaign level
73
+ fromdt: layoutEl.getAttribute('fromdt') || campaign.fromdt,
74
+ todt: layoutEl.getAttribute('todt') || campaign.todt,
75
+ scheduleid: campaign.scheduleid,
76
+ priority: campaign.priority, // Priority at campaign level
77
+ campaignId: campaign.id,
78
+ maxPlaysPerHour: parseInt(layoutEl.getAttribute('maxPlaysPerHour') || '0'),
79
+ isGeoAware: layoutEl.getAttribute('isGeoAware') === '1',
80
+ geoLocation: layoutEl.getAttribute('geoLocation') || '',
81
+ syncEvent: layoutEl.getAttribute('syncEvent') === '1',
82
+ shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
83
+ criteria: parseCriteria(layoutEl)
84
+ });
85
+ }
86
+
87
+ schedule.campaigns.push(campaign);
88
+ }
89
+
90
+ // Parse standalone layouts (not in campaigns)
91
+ for (const layoutEl of doc.querySelectorAll('schedule > layout')) {
92
+ const fileId = layoutEl.getAttribute('file');
93
+ schedule.layouts.push({
94
+ id: String(fileId), // Normalized string ID for consistent type usage
95
+ file: fileId,
96
+ fromdt: layoutEl.getAttribute('fromdt'),
97
+ todt: layoutEl.getAttribute('todt'),
98
+ scheduleid: layoutEl.getAttribute('scheduleid'),
99
+ priority: parseInt(layoutEl.getAttribute('priority') || '0'),
100
+ campaignId: null, // Standalone layout
101
+ maxPlaysPerHour: parseInt(layoutEl.getAttribute('maxPlaysPerHour') || '0'),
102
+ isGeoAware: layoutEl.getAttribute('isGeoAware') === '1',
103
+ geoLocation: layoutEl.getAttribute('geoLocation') || '',
104
+ syncEvent: layoutEl.getAttribute('syncEvent') === '1',
105
+ shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
106
+ criteria: parseCriteria(layoutEl)
107
+ });
108
+ }
109
+
110
+ // Parse overlay layouts (appear on top of main layouts)
111
+ const overlaysContainer = doc.querySelector('overlays');
112
+ if (overlaysContainer) {
113
+ for (const overlayEl of overlaysContainer.querySelectorAll('overlay')) {
114
+ const fileId = overlayEl.getAttribute('file');
115
+ schedule.overlays.push({
116
+ id: String(fileId), // Normalized string ID for consistent type usage
117
+ duration: parseInt(overlayEl.getAttribute('duration') || '60'),
118
+ file: fileId,
119
+ fromDt: overlayEl.getAttribute('fromdt'),
120
+ toDt: overlayEl.getAttribute('todt'),
121
+ priority: parseInt(overlayEl.getAttribute('priority') || '0'),
122
+ scheduleId: overlayEl.getAttribute('scheduleid'),
123
+ isGeoAware: overlayEl.getAttribute('isGeoAware') === '1',
124
+ geoLocation: overlayEl.getAttribute('geoLocation') || '',
125
+ syncEvent: overlayEl.getAttribute('syncEvent') === '1',
126
+ maxPlaysPerHour: parseInt(overlayEl.getAttribute('maxPlaysPerHour') || '0'),
127
+ criteria: parseCriteria(overlayEl)
128
+ });
129
+ }
130
+ }
131
+
132
+ // Parse action events (scheduled triggers)
133
+ const actionsContainer = doc.querySelector('actions');
134
+ if (actionsContainer) {
135
+ for (const actionEl of actionsContainer.querySelectorAll('action')) {
136
+ schedule.actions.push({
137
+ actionType: actionEl.getAttribute('actionType') || '',
138
+ triggerCode: actionEl.getAttribute('triggerCode') || '',
139
+ layoutCode: actionEl.getAttribute('layoutCode') || '',
140
+ commandCode: actionEl.getAttribute('commandCode') || '',
141
+ duration: parseInt(actionEl.getAttribute('duration') || '0'),
142
+ fromDt: actionEl.getAttribute('fromdt'),
143
+ toDt: actionEl.getAttribute('todt'),
144
+ priority: parseInt(actionEl.getAttribute('priority') || '0'),
145
+ scheduleId: actionEl.getAttribute('scheduleid'),
146
+ isGeoAware: actionEl.getAttribute('isGeoAware') === '1',
147
+ geoLocation: actionEl.getAttribute('geoLocation') || ''
148
+ });
149
+ }
150
+ }
151
+
152
+ // Parse server commands (remote control)
153
+ for (const cmdEl of doc.querySelectorAll('schedule > command')) {
154
+ schedule.commands.push({
155
+ code: cmdEl.getAttribute('command') || '',
156
+ date: cmdEl.getAttribute('date') || ''
157
+ });
158
+ }
159
+
160
+ // 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
+ });
169
+ }
170
+
171
+ return schedule;
172
+ }