hb-zp-tools 0.0.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/LICENSE +201 -0
- package/README.md +29 -0
- package/cli/zp.js +15 -0
- package/index.js +9 -0
- package/jsdoc.json +21 -0
- package/lib/ZpClient.js +1736 -0
- package/lib/ZpListener.js +176 -0
- package/lib/ZpTool.js +1262 -0
- package/lib/ZpXmlParser.js +291 -0
- package/package.json +50 -0
package/lib/ZpClient.js
ADDED
|
@@ -0,0 +1,1736 @@
|
|
|
1
|
+
// hb-zp-tools/lib/ZpClient.js
|
|
2
|
+
// Copyright © 2019-2024 Erik Baauw. All rights reserved.
|
|
3
|
+
//
|
|
4
|
+
// Homebridge ZP Tools.
|
|
5
|
+
|
|
6
|
+
import { lookup } from 'node:dns/promises'
|
|
7
|
+
import { once } from 'node:events'
|
|
8
|
+
|
|
9
|
+
import he from 'he'
|
|
10
|
+
|
|
11
|
+
import { HttpClient } from 'hb-lib-tools/HttpClient'
|
|
12
|
+
import { JsonFormatter } from 'hb-lib-tools/JsonFormatter'
|
|
13
|
+
import { OptionParser } from 'hb-lib-tools/OptionParser'
|
|
14
|
+
|
|
15
|
+
import { ZpListener } from './ZpListener.js'
|
|
16
|
+
import { ZpXmlParser } from './ZpXmlParser.js'
|
|
17
|
+
|
|
18
|
+
/** Sonos ZonePlayer API client.
|
|
19
|
+
* <br>See {@link ZpClient}.
|
|
20
|
+
* @name ZpClient
|
|
21
|
+
* @type {Class}
|
|
22
|
+
* @memberof module:hb-zp-tools
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Basic properties, populated from the device description by init().
|
|
26
|
+
const basicProps = Object.freeze([
|
|
27
|
+
'address',
|
|
28
|
+
'audioIn',
|
|
29
|
+
'id',
|
|
30
|
+
'lastSeen',
|
|
31
|
+
'memory',
|
|
32
|
+
'modelName',
|
|
33
|
+
'modelNumber',
|
|
34
|
+
'sonosOs',
|
|
35
|
+
'tvIn',
|
|
36
|
+
'version',
|
|
37
|
+
'zoneName'
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
// Basic properties, populated from the device description by init(), combined
|
|
41
|
+
// with advanced properties, populated from the topology by initTopology().
|
|
42
|
+
const allProps = Object.freeze([
|
|
43
|
+
'airPlay',
|
|
44
|
+
'balance',
|
|
45
|
+
'battery',
|
|
46
|
+
'bootSeq',
|
|
47
|
+
'channel',
|
|
48
|
+
'homeTheatre',
|
|
49
|
+
'household',
|
|
50
|
+
'invisible',
|
|
51
|
+
'name',
|
|
52
|
+
'role',
|
|
53
|
+
'satellites',
|
|
54
|
+
'slaves',
|
|
55
|
+
'stereoPair',
|
|
56
|
+
'zone',
|
|
57
|
+
'zoneDisplayName',
|
|
58
|
+
'zoneGroup',
|
|
59
|
+
'zoneGroupName',
|
|
60
|
+
'zoneGroupShortName',
|
|
61
|
+
'zonePlayerName'
|
|
62
|
+
].concat(basicProps).sort())
|
|
63
|
+
|
|
64
|
+
// Display channels in channelMapSet.
|
|
65
|
+
const channelMap = {
|
|
66
|
+
// Stereo pair.
|
|
67
|
+
'LF,LF': 'L',
|
|
68
|
+
'RF,RF': 'R',
|
|
69
|
+
'SW,SW': 'Sub',
|
|
70
|
+
// Home theatre setup.
|
|
71
|
+
'LF,RF': '', // master
|
|
72
|
+
SW: 'Sub',
|
|
73
|
+
LR: 'LS',
|
|
74
|
+
RR: 'RS',
|
|
75
|
+
'LR,RR': 'LS+RS', // Connect:Amp as surround.
|
|
76
|
+
'LR,LTR': 'LS', // Era 300 as surround
|
|
77
|
+
'RR,RTR': 'RS' // Era 300 as surround
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ZpClient error.
|
|
81
|
+
* @hideconstructor
|
|
82
|
+
* @extends HttpClient.HttpError
|
|
83
|
+
* @memberof ZpClient
|
|
84
|
+
*/
|
|
85
|
+
class ZpClientError extends HttpClient.HttpError {
|
|
86
|
+
/** The request that caused the error.
|
|
87
|
+
* @type {ZpClient.ZpClientRequest}
|
|
88
|
+
* @readonly
|
|
89
|
+
*/
|
|
90
|
+
get request () {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Notification from a zone player.
|
|
94
|
+
* @hideconstructor
|
|
95
|
+
* @memberof ZpClient
|
|
96
|
+
*/
|
|
97
|
+
class ZpClientNotification {
|
|
98
|
+
/** The zone player name.
|
|
99
|
+
* @type {string}
|
|
100
|
+
* @readonly
|
|
101
|
+
*/
|
|
102
|
+
get name () {}
|
|
103
|
+
|
|
104
|
+
/** The zone player UPnP device that issued the event.
|
|
105
|
+
*
|
|
106
|
+
* This is `ZonePlayer` for top-level services or the actual UPnP device,
|
|
107
|
+
* like `MediaRenderer` or `MediaServer`.
|
|
108
|
+
* services linked
|
|
109
|
+
* @type {string}
|
|
110
|
+
* @readonly
|
|
111
|
+
*/
|
|
112
|
+
get device () {}
|
|
113
|
+
|
|
114
|
+
/** The zone player service that issued the event.
|
|
115
|
+
* @type {string}
|
|
116
|
+
* @readonly
|
|
117
|
+
*/
|
|
118
|
+
get service () {}
|
|
119
|
+
|
|
120
|
+
/** The (raw) event body (in XML).
|
|
121
|
+
* @type {string}
|
|
122
|
+
* @readonly
|
|
123
|
+
*/
|
|
124
|
+
get body () {}
|
|
125
|
+
|
|
126
|
+
/** The (parsed) event body (in JavaScript).
|
|
127
|
+
* @type {*}
|
|
128
|
+
* @readonly
|
|
129
|
+
*/
|
|
130
|
+
get parsedBody () {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** ZpClient request.
|
|
134
|
+
* @hideconstructor
|
|
135
|
+
* @extends HttpClient.HttpRequest
|
|
136
|
+
* @memberof ZpClient
|
|
137
|
+
*/
|
|
138
|
+
class ZpClientRequest extends HttpClient.HttpRequest {
|
|
139
|
+
/** The zone player hostname.
|
|
140
|
+
* @type {string}
|
|
141
|
+
* @readonly
|
|
142
|
+
*/
|
|
143
|
+
get name () {}
|
|
144
|
+
|
|
145
|
+
/** The SOAP action of the request.
|
|
146
|
+
* @type {?string}
|
|
147
|
+
* @readonly
|
|
148
|
+
*/
|
|
149
|
+
get action () {}
|
|
150
|
+
|
|
151
|
+
/** The (raw) response body (in XML).
|
|
152
|
+
* @type {?string}
|
|
153
|
+
* @readonly
|
|
154
|
+
*/
|
|
155
|
+
get body () {}
|
|
156
|
+
|
|
157
|
+
/** The (parsed) request body (in JavaScript).
|
|
158
|
+
* @type {?*}
|
|
159
|
+
* @readonly
|
|
160
|
+
*/
|
|
161
|
+
get parsedBody () {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** ZpClient response.
|
|
165
|
+
* @hideconstructor
|
|
166
|
+
* @extends HttpClient.HttpResponse
|
|
167
|
+
* @memberof ZpClient
|
|
168
|
+
*/
|
|
169
|
+
class ZpClientResponse extends HttpClient.HttpResponse {
|
|
170
|
+
/** The request that generated the response.
|
|
171
|
+
* @type {ZpClientClient.ZpClientRequest}
|
|
172
|
+
* @readonly
|
|
173
|
+
*/
|
|
174
|
+
get request () {}
|
|
175
|
+
|
|
176
|
+
/** The (raw) response body (in XML).
|
|
177
|
+
* @type {?string}
|
|
178
|
+
* @readonly
|
|
179
|
+
*/
|
|
180
|
+
get body () {}
|
|
181
|
+
|
|
182
|
+
/** The (parsed) response body (in JavaScript).
|
|
183
|
+
* @type {?*}
|
|
184
|
+
* @readonly
|
|
185
|
+
*/
|
|
186
|
+
get parsedBody () {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Client to a Sonos zone player.
|
|
190
|
+
* @extends HttpClient
|
|
191
|
+
*/
|
|
192
|
+
class ZpClient extends HttpClient {
|
|
193
|
+
static get ZpClientError () { return ZpClientError }
|
|
194
|
+
static get ZpClientNotification () { return ZpClientNotification }
|
|
195
|
+
static get ZpClientRequest () { return ZpClientRequest }
|
|
196
|
+
static get ZpClientResponse () { return ZpClientResponse }
|
|
197
|
+
|
|
198
|
+
/** Parse a channel map set, as returned by
|
|
199
|
+
* {@link ZpClient.getZoneGroupState getZoneGroupState()} or by a
|
|
200
|
+
* `zoneGroupTopology` event.
|
|
201
|
+
* @params {string} channelMapSet - The channel map set.
|
|
202
|
+
* @returns {object} map - The parsed channel map set.
|
|
203
|
+
*/
|
|
204
|
+
static parseChannelMapSet (channelMapSet) {
|
|
205
|
+
const a = channelMapSet.split(';')
|
|
206
|
+
return {
|
|
207
|
+
ids: a.map((elt) => { return elt.split(':')[0] }),
|
|
208
|
+
channels: a.map((elt) => {
|
|
209
|
+
const channel = elt.split(':')[1]
|
|
210
|
+
return channelMap[channel] ?? channel
|
|
211
|
+
}).sort()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Parse a zoneGroupMembers entry, as returned by
|
|
216
|
+
* {@link ZpClient.getZoneGroupState getZoneGroupState()} or by a
|
|
217
|
+
* `zoneGroupTopology` event.
|
|
218
|
+
* @params {object} member - The`zoneGroupMembers` entry.
|
|
219
|
+
* @returns {object} props - The parsed properties.
|
|
220
|
+
*/
|
|
221
|
+
static parseMember (member) {
|
|
222
|
+
const props = {
|
|
223
|
+
address: member.location.split('/')[2].split(':')[0],
|
|
224
|
+
airPlay: member.airPlayEnabled === 1 ? true : undefined,
|
|
225
|
+
battery: member.battery,
|
|
226
|
+
bootSeq: member.bootSeq,
|
|
227
|
+
channel: undefined, // default
|
|
228
|
+
homeTheatre: undefined, // default
|
|
229
|
+
household: undefined, // default
|
|
230
|
+
id: member.uuid,
|
|
231
|
+
invisible: member.invisible === 1 ? true : undefined,
|
|
232
|
+
name: member.zoneName,
|
|
233
|
+
role: 'master', // default
|
|
234
|
+
satellites: undefined, // default
|
|
235
|
+
slaves: undefined, // default
|
|
236
|
+
stereoPair: undefined, // default
|
|
237
|
+
zone: member.uuid, // default
|
|
238
|
+
zoneDisplayName: member.zoneName, // default
|
|
239
|
+
zoneGroup: undefined,
|
|
240
|
+
zoneGroupName: undefined,
|
|
241
|
+
zoneGroupShortName: undefined,
|
|
242
|
+
zoneName: member.zoneName
|
|
243
|
+
}
|
|
244
|
+
let map
|
|
245
|
+
let slave
|
|
246
|
+
let channels
|
|
247
|
+
if (member.channelMapSet != null) {
|
|
248
|
+
props.stereoPair = true
|
|
249
|
+
map = ZpClient.parseChannelMapSet(member.channelMapSet)
|
|
250
|
+
slave = 'slave'
|
|
251
|
+
channels = map.channels
|
|
252
|
+
} else if (member.htSatChanMapSet != null) {
|
|
253
|
+
props.homeTheatre = true
|
|
254
|
+
map = ZpClient.parseChannelMapSet(member.htSatChanMapSet)
|
|
255
|
+
slave = 'satellite'
|
|
256
|
+
channels = map.channels.slice(1)
|
|
257
|
+
}
|
|
258
|
+
if (map != null) {
|
|
259
|
+
if (map.ids[0] === props.id) {
|
|
260
|
+
props.role = 'master'
|
|
261
|
+
props[slave + 's'] = map.ids.slice(1)
|
|
262
|
+
props.channel = map.channels[0]
|
|
263
|
+
} else {
|
|
264
|
+
props.role = slave
|
|
265
|
+
props.zone = map.ids[0]
|
|
266
|
+
for (let id = 1; id < map.ids.length; id++) {
|
|
267
|
+
if (map.ids[id] === props.id) {
|
|
268
|
+
props.channel = map.channels[id]
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
props.zoneDisplayName += ' (' + channels.join('+').replace('+Sub+Sub', '+Subx2') + ')'
|
|
274
|
+
if (props.channel !== '') {
|
|
275
|
+
props.name += ' (' + props.channel + ')'
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return props
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Unflatten a zonePlayers structure.
|
|
282
|
+
* @params {Object} zonePlayers - A flat map of zonePlayer objects,
|
|
283
|
+
* listing the slave and satellite zone players separately.
|
|
284
|
+
* @returns {Object} - A map of nested zonePlayer objects,
|
|
285
|
+
* listing the slave and satellite zone players under the master zone player.
|
|
286
|
+
*/
|
|
287
|
+
static unflatten (zonePlayers) {
|
|
288
|
+
const zones = {}
|
|
289
|
+
for (const id in zonePlayers) {
|
|
290
|
+
if (zonePlayers[id].role === 'master') {
|
|
291
|
+
zones[id] = Object.assign({}, zonePlayers[id])
|
|
292
|
+
if (zonePlayers[id].slaves != null) {
|
|
293
|
+
zones[id].slaves = []
|
|
294
|
+
for (const slave of zonePlayers[id].slaves) {
|
|
295
|
+
zones[id].slaves.push(zonePlayers[slave])
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (zonePlayers[id].satellites != null) {
|
|
299
|
+
zones[id].satellites = []
|
|
300
|
+
for (const satellite of zonePlayers[id].satellites) {
|
|
301
|
+
if (zonePlayers[satellite] != null) {
|
|
302
|
+
zones[id].satellites.push(zonePlayers[satellite])
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return zones
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Create a new instance of a client to a Sonos zone player.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} params - Parameters.
|
|
314
|
+
* @param {!string} params.host - Server hostname and port.
|
|
315
|
+
* @param {?string} params.name - The name of the server. Defaults to hostname.
|
|
316
|
+
* @param {integer} [params.subscriptionTimeout=30] - Subscription timeout
|
|
317
|
+
* (in minutes).
|
|
318
|
+
* @param {integer} [params.timeout=5] - Request timeout (in seconds).
|
|
319
|
+
*/
|
|
320
|
+
constructor (params = {}) {
|
|
321
|
+
const _params = {
|
|
322
|
+
port: 1400,
|
|
323
|
+
subscriptionTimeout: 30,
|
|
324
|
+
timeout: 5
|
|
325
|
+
}
|
|
326
|
+
const optionParser = new OptionParser(_params)
|
|
327
|
+
optionParser
|
|
328
|
+
.hostKey('host')
|
|
329
|
+
.stringKey('id')
|
|
330
|
+
.stringKey('household')
|
|
331
|
+
.intKey('timeout', 1, 60) // seconds
|
|
332
|
+
.intKey('subscriptionTimeout', 1, 1440) // minutes
|
|
333
|
+
.instanceKey('listener', ZpListener)
|
|
334
|
+
.parse(params)
|
|
335
|
+
_params.subscriptionTimeout *= 60 // minutes -> seconds
|
|
336
|
+
|
|
337
|
+
const parser = new ZpXmlParser()
|
|
338
|
+
const options = {
|
|
339
|
+
host: _params.hostname + ':' + _params.port,
|
|
340
|
+
keepAlive: true,
|
|
341
|
+
maxSockets: 1,
|
|
342
|
+
name: _params.hostname,
|
|
343
|
+
timeout: _params.timeout,
|
|
344
|
+
xmlParser: parser.parse.bind(parser)
|
|
345
|
+
}
|
|
346
|
+
super(options)
|
|
347
|
+
/** Emitted when an error has been received from the zone player.
|
|
348
|
+
* @event ZpClient#error
|
|
349
|
+
* @param {ZpClient.ZpClientError} error - The error.
|
|
350
|
+
*/
|
|
351
|
+
/** Emitted when a request has been sent to the zone player.
|
|
352
|
+
* @event ZpClient#request
|
|
353
|
+
* @param {ZpClient.ZpClientRequest} request - The request.
|
|
354
|
+
*/
|
|
355
|
+
/** Emitted when a valid response has been received from the zone player.
|
|
356
|
+
* @event ZpClient#response
|
|
357
|
+
* @param {ZpClient.ZpClientResponse} response - The response.
|
|
358
|
+
*/
|
|
359
|
+
|
|
360
|
+
this._params = _params
|
|
361
|
+
this._jsonFormatter = new JsonFormatter()
|
|
362
|
+
this._parser = parser
|
|
363
|
+
this._props = {
|
|
364
|
+
address: _params.host,
|
|
365
|
+
id: _params.id,
|
|
366
|
+
household: _params.household
|
|
367
|
+
}
|
|
368
|
+
this._subscriptions = {}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Error handling. Only emit 'error' when it wasn't already submitted by
|
|
372
|
+
// _request().
|
|
373
|
+
error (error) {
|
|
374
|
+
if (error.request == null) {
|
|
375
|
+
this.emit('error', error)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ***** Initialisation ******************************************************
|
|
380
|
+
|
|
381
|
+
/** Initialise the zpClient instance.
|
|
382
|
+
* Looks up the zone player's IP address using DNS (Sonos doens't accept
|
|
383
|
+
* requests issued to the hostname).
|
|
384
|
+
* Connects to the zone player to retrieve the device description,
|
|
385
|
+
* setting the basic properties.
|
|
386
|
+
*/
|
|
387
|
+
async init () {
|
|
388
|
+
this._props.lastSeen = null
|
|
389
|
+
this._props.address = (await lookup(this._params.hostname)).address
|
|
390
|
+
this._params.hostname = this._props.address
|
|
391
|
+
this.host = this._props.address + ':1400'
|
|
392
|
+
|
|
393
|
+
this._description = await this.get()
|
|
394
|
+
const id = this._description.device.udn.split(':')[1]
|
|
395
|
+
if (this._params.id != null && this._params.id !== id) {
|
|
396
|
+
this.emit('error', new Error('address mismatch'))
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
this._params.id = id
|
|
400
|
+
this._props.audioIn = undefined
|
|
401
|
+
this._props.balance = undefined
|
|
402
|
+
this._props.id = id
|
|
403
|
+
this._props.memory = this._description.device.memory
|
|
404
|
+
this._props.modelName = this._description.device.modelName
|
|
405
|
+
this._props.modelNumber = this._description.device.modelNumber
|
|
406
|
+
const majorVersion = this._description.device.displayVersion.split('.')[0]
|
|
407
|
+
this._props.sonosOs = majorVersion <= 11 ? 'S1' : 'S2'
|
|
408
|
+
this._props.version = this._description.device.displayVersion
|
|
409
|
+
this._props.tvIn = undefined
|
|
410
|
+
this._props.zoneName = this._description.device.roomName
|
|
411
|
+
for (const service of this._description.device.serviceList) {
|
|
412
|
+
switch (service.serviceId.split(':')[3]) {
|
|
413
|
+
case 'AudioIn':
|
|
414
|
+
this._props.audioIn = true
|
|
415
|
+
break
|
|
416
|
+
case 'HTControl':
|
|
417
|
+
this._props.tvIn = true
|
|
418
|
+
break
|
|
419
|
+
default:
|
|
420
|
+
break
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
delete this._info
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Check whether basic properties have been initialed.
|
|
427
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
428
|
+
*/
|
|
429
|
+
checkInit () {
|
|
430
|
+
if (this._description == null) {
|
|
431
|
+
throw new SyntaxError('init() not yet called')
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
_handleMessage (message) {
|
|
436
|
+
if (message.service === 'ZoneGroupTopology') {
|
|
437
|
+
this._zoneGroupState = message.parsedBody
|
|
438
|
+
this.emit('gotcha')
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Initialise the topology for this zpClient instance.
|
|
443
|
+
* Connects to the zone player to retrieve the a topology,
|
|
444
|
+
* setting the advanced properties, or sets the advanced proporties from
|
|
445
|
+
* the topology retrieved from another zone player.
|
|
446
|
+
* @param {?ZpClient} zpClient - Re-use the topology already retrieved from
|
|
447
|
+
* another zone player.
|
|
448
|
+
*/
|
|
449
|
+
async initTopology (zpClient = {}) {
|
|
450
|
+
// this.checkInit()
|
|
451
|
+
this._zonePlayers = {}
|
|
452
|
+
this._zonesByName = {}
|
|
453
|
+
let household = this._props.household
|
|
454
|
+
if (zpClient.zoneGroupState != null) {
|
|
455
|
+
this._zoneGroupState = zpClient.zoneGroupState
|
|
456
|
+
household = zpClient.household
|
|
457
|
+
} else {
|
|
458
|
+
this.on('message', this._handleMessage)
|
|
459
|
+
await this.open()
|
|
460
|
+
await this.subscribe('/ZoneGroupTopology/Event')
|
|
461
|
+
const timeout = setTimeout(() => {
|
|
462
|
+
this.emit('error', new Error(
|
|
463
|
+
`no ZoneGroupTopology event received in ${this._params.timeout}s`
|
|
464
|
+
))
|
|
465
|
+
}, this._params.timeout * 1000)
|
|
466
|
+
try {
|
|
467
|
+
await once(this, 'gotcha')
|
|
468
|
+
} catch (error) {}
|
|
469
|
+
clearTimeout(timeout)
|
|
470
|
+
await this.close()
|
|
471
|
+
this.removeListener('message', this._handleMessage)
|
|
472
|
+
}
|
|
473
|
+
if (this._zoneGroupState != null) {
|
|
474
|
+
if (this._zoneGroupState.museHouseholdId != null) {
|
|
475
|
+
household = this._zoneGroupState.museHouseholdId.split('.')[0]
|
|
476
|
+
}
|
|
477
|
+
if (household == null) {
|
|
478
|
+
household = await this.getHouseholdId()
|
|
479
|
+
}
|
|
480
|
+
for (const group of this._zoneGroupState.zoneGroups) {
|
|
481
|
+
const ids = []
|
|
482
|
+
let groupName
|
|
483
|
+
const groupMemberNames = []
|
|
484
|
+
for (const member of group.zoneGroupMembers) {
|
|
485
|
+
const props = ZpClient.parseMember(member)
|
|
486
|
+
props.household = household
|
|
487
|
+
props.zoneGroup = group.coordinator
|
|
488
|
+
if (props.id === this._params.id) {
|
|
489
|
+
this._checkAddress(props.address)
|
|
490
|
+
await this._checkBootSeq(props.bootSeq)
|
|
491
|
+
if (props.bootSeq < this._props.bootSeq) {
|
|
492
|
+
props.bootSeq = this._props.bootSeq
|
|
493
|
+
}
|
|
494
|
+
Object.assign(this._props, props)
|
|
495
|
+
}
|
|
496
|
+
this._zonePlayers[props.id] = props
|
|
497
|
+
ids.push(props.id)
|
|
498
|
+
if (props.role === 'master') {
|
|
499
|
+
this._zonesByName[props.zoneName + '|' + props.zone] =
|
|
500
|
+
props.zoneDisplayName
|
|
501
|
+
if (member.uuid === group.coordinator) {
|
|
502
|
+
groupName = props.zoneName
|
|
503
|
+
} else {
|
|
504
|
+
groupMemberNames.push(props.zoneName)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (member.satellites != null) {
|
|
508
|
+
const zoneDisplayName = props.zoneDisplayName
|
|
509
|
+
for (const satellite of member.satellites) {
|
|
510
|
+
const props = ZpClient.parseMember(satellite)
|
|
511
|
+
props.household = household
|
|
512
|
+
props.zoneGroup = group.coordinator
|
|
513
|
+
props.zoneDisplayName = zoneDisplayName
|
|
514
|
+
if (props.id === this._params.id) {
|
|
515
|
+
Object.assign(this._props, props)
|
|
516
|
+
}
|
|
517
|
+
this._zonePlayers[props.id] = props
|
|
518
|
+
ids.push(props.id)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const groupShortName = groupMemberNames.length > 0
|
|
523
|
+
? groupName + ' + ' + groupMemberNames.length
|
|
524
|
+
: groupName
|
|
525
|
+
groupName = [groupName].concat(groupMemberNames.sort()).join(' + ')
|
|
526
|
+
for (const id of ids) {
|
|
527
|
+
if (id === this._params.id) {
|
|
528
|
+
this._props.zoneGroupName = groupName
|
|
529
|
+
this._props.zoneGroupShortName = groupShortName
|
|
530
|
+
}
|
|
531
|
+
this._zonePlayers[id].zoneGroupName = groupName
|
|
532
|
+
this._zonePlayers[id].zoneGroupShortName = groupShortName
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
delete this._info
|
|
536
|
+
delete this._zonePlayersByName
|
|
537
|
+
delete this._zones
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Check whether advanced properties have been initialised.
|
|
542
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
543
|
+
* hasn't been called.
|
|
544
|
+
*/
|
|
545
|
+
checkInitTopology () {
|
|
546
|
+
if (this._zoneGroupState == null) {
|
|
547
|
+
throw new SyntaxError('initTopology() not yet called')
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ***** Properties **********************************************************
|
|
552
|
+
|
|
553
|
+
/** The zone player IP address.
|
|
554
|
+
* @type {string}
|
|
555
|
+
* @readonly
|
|
556
|
+
*/
|
|
557
|
+
get address () {
|
|
558
|
+
return this._props.address
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Whether the zone player supports AirPlay.
|
|
562
|
+
* @type {?boolean}
|
|
563
|
+
* @readonly
|
|
564
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
565
|
+
* hasn't been called.
|
|
566
|
+
*/
|
|
567
|
+
get airPlay () {
|
|
568
|
+
this.checkInitTopology()
|
|
569
|
+
return this._props.airPlay
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Whether the zone player supports audio in.
|
|
573
|
+
* @type {?boolean}
|
|
574
|
+
* @readonly
|
|
575
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
576
|
+
*/
|
|
577
|
+
get audioIn () {
|
|
578
|
+
this.checkInit()
|
|
579
|
+
return this._props.audioIn
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Whether the zone player supports balance.
|
|
583
|
+
* @type {?boolean}
|
|
584
|
+
* @readonly
|
|
585
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
586
|
+
* hasn't been called.
|
|
587
|
+
*/
|
|
588
|
+
get balance () {
|
|
589
|
+
this.checkInitTopology()
|
|
590
|
+
return this.audioIn || this.stereoPair ? true : undefined
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** The battery state of the zone player.
|
|
594
|
+
* @type {?object}
|
|
595
|
+
* @readonly
|
|
596
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
597
|
+
* hasn't been called.
|
|
598
|
+
*/
|
|
599
|
+
get battery () {
|
|
600
|
+
this.checkInitTopology()
|
|
601
|
+
return this._props.battery
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The zone player boot sequence.
|
|
605
|
+
*
|
|
606
|
+
* This value increases on each zone player reboot.
|
|
607
|
+
* @type {integer}
|
|
608
|
+
* @readonly
|
|
609
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
610
|
+
* hasn't been called.
|
|
611
|
+
*/
|
|
612
|
+
get bootSeq () {
|
|
613
|
+
this.checkInitTopology()
|
|
614
|
+
return this._props.bootSeq
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The zone player channel when it's part of a stereo pair
|
|
618
|
+
* or home theatre setup.
|
|
619
|
+
* @type {?string}
|
|
620
|
+
* @readonly
|
|
621
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
622
|
+
* hasn't been called.
|
|
623
|
+
*/
|
|
624
|
+
get channel () {
|
|
625
|
+
this.checkInitTopology()
|
|
626
|
+
return this._props.channel
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** The zone player's device description.
|
|
630
|
+
* @type {object}
|
|
631
|
+
* @readonly
|
|
632
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
633
|
+
*/
|
|
634
|
+
get description () {
|
|
635
|
+
this.checkInit()
|
|
636
|
+
return this._description
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Whether the zone player is part of a home theatre setup.
|
|
640
|
+
* @type {?boolean}
|
|
641
|
+
* @readonly
|
|
642
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
643
|
+
* hasn't been called.
|
|
644
|
+
*/
|
|
645
|
+
get homeTheatre () {
|
|
646
|
+
this.checkInitTopology()
|
|
647
|
+
return this._props.homeTheatre
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The household that the zone player is part of.
|
|
651
|
+
* @type {?string}
|
|
652
|
+
* @readonly
|
|
653
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
654
|
+
* hasn't been called.
|
|
655
|
+
*/
|
|
656
|
+
get household () {
|
|
657
|
+
this.checkInitTopology()
|
|
658
|
+
return this._props.household
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** The zone player ID.
|
|
662
|
+
*
|
|
663
|
+
* The ID has the format `RINCON_`_xxxxxxxxxxxx_`01400` where _xxxxxxxxxxxx_
|
|
664
|
+
* is the mac address of the zone player.
|
|
665
|
+
* Note that 1400 is the port on the zone player that serves the local
|
|
666
|
+
* SOAP/HTTP API.
|
|
667
|
+
* @type {string}
|
|
668
|
+
* @readonly
|
|
669
|
+
*/
|
|
670
|
+
get id () { return this._props.id }
|
|
671
|
+
|
|
672
|
+
/** The zone player info, i.e. the zone player static properties as a single
|
|
673
|
+
* object.
|
|
674
|
+
* @type {object}
|
|
675
|
+
* @readonly
|
|
676
|
+
* @throws {SyntaxError} When {@link ZpClient#init init()} hasn't been called.
|
|
677
|
+
*/
|
|
678
|
+
get info () {
|
|
679
|
+
this.checkInit()
|
|
680
|
+
if (this._info == null) {
|
|
681
|
+
this._info = {}
|
|
682
|
+
const props = (this._zoneGroupState == null) ? basicProps : allProps
|
|
683
|
+
for (const prop of props) {
|
|
684
|
+
this._info[prop] = this[prop]
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return this._info
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Whether the zone player is invisble (not shown as room in the Sonos app).
|
|
691
|
+
* @type {?boolean}
|
|
692
|
+
* @readonly
|
|
693
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
694
|
+
* hasn't been called.
|
|
695
|
+
*/
|
|
696
|
+
get invisible () {
|
|
697
|
+
this.checkInitTopology()
|
|
698
|
+
return this._props.invisible
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** The timestamp of the last communication from the zone player,
|
|
702
|
+
* i.e. the time when the most recent push notification, request
|
|
703
|
+
* response, or UPnP assouncement was recevied.
|
|
704
|
+
* @type {string}
|
|
705
|
+
* @readonly
|
|
706
|
+
*/
|
|
707
|
+
get lastSeen () {
|
|
708
|
+
return this._props.lastSeen == null
|
|
709
|
+
? 'n/a'
|
|
710
|
+
: String(this._props.lastSeen).substring(0, 24)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The amount of memory in the zone player.
|
|
714
|
+
* @type {integer}
|
|
715
|
+
* @readonly
|
|
716
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
717
|
+
*/
|
|
718
|
+
get memory () {
|
|
719
|
+
this.checkInit()
|
|
720
|
+
return this._props.memory
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** The zone player model name, e.g. "Sonos Playbar".
|
|
724
|
+
* @type {string}
|
|
725
|
+
* @readonly
|
|
726
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
727
|
+
*/
|
|
728
|
+
get modelName () {
|
|
729
|
+
this.checkInit()
|
|
730
|
+
return this._props.modelName
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** The zone player model number, e.g. "S9".
|
|
734
|
+
* @type {string}
|
|
735
|
+
* @readonly
|
|
736
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
737
|
+
*/
|
|
738
|
+
get modelNumber () {
|
|
739
|
+
this.checkInit()
|
|
740
|
+
return this._props.modelNumber
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** The zone player role in its zone: `master`, `slave`, or `satellite`.
|
|
744
|
+
* @type {string}
|
|
745
|
+
* @readonly
|
|
746
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
747
|
+
* hasn't been called.
|
|
748
|
+
*/
|
|
749
|
+
get role () {
|
|
750
|
+
this.checkInitTopology()
|
|
751
|
+
return this._props.role
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** The IDs of the satellite zone players (for the master zone player in a
|
|
755
|
+
* home theatre setup).
|
|
756
|
+
* @type {?string[]}
|
|
757
|
+
* @readonly
|
|
758
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
759
|
+
* hasn't been called.
|
|
760
|
+
*/
|
|
761
|
+
get satellites () {
|
|
762
|
+
this.checkInitTopology()
|
|
763
|
+
return this._props.satellites
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** The IDs of the slave zone players (for a master zone player in a
|
|
767
|
+
* stereo pair).
|
|
768
|
+
* @type {?string[]}
|
|
769
|
+
* @readonly
|
|
770
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
771
|
+
* hasn't been called.
|
|
772
|
+
*/
|
|
773
|
+
get slaves () {
|
|
774
|
+
this.checkInitTopology()
|
|
775
|
+
return this._props.slaves
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** The zone player OS version: `S1` or `S2`.
|
|
779
|
+
* @type {?string}
|
|
780
|
+
* @readonly
|
|
781
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
782
|
+
*/
|
|
783
|
+
get sonosOs () {
|
|
784
|
+
this.checkInit()
|
|
785
|
+
return this._props.sonosOs
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Whether the zone player is part of a stereo pair.
|
|
789
|
+
* @type {?boolean}
|
|
790
|
+
* @readonly
|
|
791
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
792
|
+
* hasn't been called.
|
|
793
|
+
*/
|
|
794
|
+
get stereoPair () {
|
|
795
|
+
this.checkInitTopology()
|
|
796
|
+
return this._props.stereoPair
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** The current subscriptions to the zone player, sorted by UPnP device and
|
|
800
|
+
* service.
|
|
801
|
+
* @type {string[]}
|
|
802
|
+
* @readonly
|
|
803
|
+
*/
|
|
804
|
+
get subscriptions () {
|
|
805
|
+
const a = []
|
|
806
|
+
for (const url in this._subscriptions) {
|
|
807
|
+
a.push(url)
|
|
808
|
+
}
|
|
809
|
+
return a.sort()
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Whether the zone player supports TV input.
|
|
813
|
+
* @type {?boolean}
|
|
814
|
+
* @readonly
|
|
815
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
816
|
+
*/
|
|
817
|
+
get tvIn () {
|
|
818
|
+
this.checkInit()
|
|
819
|
+
return this._props.tvIn
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** The zone player firmware version.
|
|
823
|
+
* @type {string}
|
|
824
|
+
* @readonly
|
|
825
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
826
|
+
*/
|
|
827
|
+
get version () {
|
|
828
|
+
this.checkInit()
|
|
829
|
+
return this._props.version
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** The zone player zone.
|
|
833
|
+
*
|
|
834
|
+
* This is the ID of the master zone player of that zone.
|
|
835
|
+
* @type {string}
|
|
836
|
+
* @readonly
|
|
837
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
838
|
+
* hasn't been called.
|
|
839
|
+
*/
|
|
840
|
+
get zone () {
|
|
841
|
+
this.checkInitTopology()
|
|
842
|
+
return this._props.zone
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** The zone player zone (room) display name, e.g. "Living Room (+LS+RS+Sub)".
|
|
846
|
+
* @type {string}
|
|
847
|
+
* @readonly
|
|
848
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
849
|
+
* hasn't been called.
|
|
850
|
+
*/
|
|
851
|
+
get zoneDisplayName () {
|
|
852
|
+
this.checkInitTopology()
|
|
853
|
+
return this._props.zoneDisplayName
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** The zone player zone group.
|
|
857
|
+
*
|
|
858
|
+
* This is the ID of the master zone player of the coordinator zone
|
|
859
|
+
* of the zone group.
|
|
860
|
+
*
|
|
861
|
+
* @type {string}
|
|
862
|
+
* @readonly
|
|
863
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
864
|
+
* hasn't been called.
|
|
865
|
+
*/
|
|
866
|
+
get zoneGroup () {
|
|
867
|
+
this.checkInitTopology()
|
|
868
|
+
return this._props.zoneGroup
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** The zone player zone group name, e.g. "Living Room + Bedroom".
|
|
872
|
+
* @type {string}
|
|
873
|
+
* @readonly
|
|
874
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
875
|
+
* hasn't been called.
|
|
876
|
+
*/
|
|
877
|
+
get zoneGroupName () {
|
|
878
|
+
this.checkInitTopology()
|
|
879
|
+
return this._props.zoneGroupName
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** The zone player zone group short name, e.g. "Living Room + 1".
|
|
883
|
+
* @type {string}
|
|
884
|
+
* @readonly
|
|
885
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
886
|
+
* hasn't been called.
|
|
887
|
+
*/
|
|
888
|
+
get zoneGroupShortName () {
|
|
889
|
+
this.checkInitTopology()
|
|
890
|
+
return this._props.zoneGroupShortName
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** The raw zone group state, as returned by
|
|
894
|
+
* {@link ZpClient.getZoneGroupState getZoneGroupState()} or by a
|
|
895
|
+
* `zoneGroupTopology` event.
|
|
896
|
+
* @type {object}
|
|
897
|
+
* @readonly
|
|
898
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
899
|
+
* hasn't been called.
|
|
900
|
+
*/
|
|
901
|
+
get zoneGroupState () {
|
|
902
|
+
this.checkInitTopology()
|
|
903
|
+
return this._zoneGroupState
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** The zone player zone (room) name, e.g. "Living Room".
|
|
907
|
+
* @type {?boolean}
|
|
908
|
+
* @readonly
|
|
909
|
+
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
|
|
910
|
+
*/
|
|
911
|
+
get zoneName () {
|
|
912
|
+
this.checkInit()
|
|
913
|
+
return this._props.zoneName
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** The zone player name, e.g. `Living Room (Sub)`.
|
|
917
|
+
* @type {string}
|
|
918
|
+
* @readonly
|
|
919
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
920
|
+
* hasn't been called.
|
|
921
|
+
*/
|
|
922
|
+
get zonePlayerName () { return this._props.name }
|
|
923
|
+
|
|
924
|
+
/** The cooked zone group state, as a flat map of zonePlayer objects
|
|
925
|
+
* @type {Object}
|
|
926
|
+
* @readonly
|
|
927
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
928
|
+
* hasn't been called.
|
|
929
|
+
*/
|
|
930
|
+
get zonePlayers () {
|
|
931
|
+
this.checkInitTopology()
|
|
932
|
+
if (this._zonePlayersByName == null) {
|
|
933
|
+
this._zonePlayersByName = {}
|
|
934
|
+
Object.keys(this._zonesByName).sort().forEach((key) => {
|
|
935
|
+
const id = key.split('|')[1]
|
|
936
|
+
this._zonePlayersByName[id] = Object.assign({}, this._zonePlayers[id])
|
|
937
|
+
if (this._zonePlayers[id].slaves != null) {
|
|
938
|
+
for (const slave of this._zonePlayers[id].slaves) {
|
|
939
|
+
if (this._zonePlayers[slave] != null) {
|
|
940
|
+
this._zonePlayers[slave].zoneDisplayName = this._zonesByName[key]
|
|
941
|
+
this._zonePlayersByName[slave] = this._zonePlayers[slave]
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (this._zonePlayers[id].satellites != null) {
|
|
946
|
+
for (const satellite of this._zonePlayers[id].satellites) {
|
|
947
|
+
if (this._zonePlayers[satellite] != null) {
|
|
948
|
+
this._zonePlayersByName[satellite] = this._zonePlayers[satellite]
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
})
|
|
953
|
+
}
|
|
954
|
+
return Object.assign({}, this._zonePlayersByName)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** The cooked zone group state, as a nested map of zonePlayer objects
|
|
958
|
+
* @type {Object}
|
|
959
|
+
* @readonly
|
|
960
|
+
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
|
|
961
|
+
* hasn't been called.
|
|
962
|
+
*/
|
|
963
|
+
get zones () {
|
|
964
|
+
this.checkInitTopology()
|
|
965
|
+
if (this._zones == null) {
|
|
966
|
+
this._zones = ZpClient.unflatten(this.zonePlayers)
|
|
967
|
+
}
|
|
968
|
+
return this._zones
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ***** Event Handling ******************************************************
|
|
972
|
+
|
|
973
|
+
/** Register the zone player for receiving push notifications.
|
|
974
|
+
* @param {ZpListener} listener - The {@link ZpListener} instance to
|
|
975
|
+
* reveive the notifications.
|
|
976
|
+
*/
|
|
977
|
+
async open () {
|
|
978
|
+
this._zpListener = this._params.listener
|
|
979
|
+
this._params.callbackUrl = await this._zpListener.addClient(this)
|
|
980
|
+
this._zpListener.on(this.id, async (message) => {
|
|
981
|
+
try {
|
|
982
|
+
await this._updateLastSeen()
|
|
983
|
+
message.name = this.name
|
|
984
|
+
if (message.body != null) {
|
|
985
|
+
message.parsedBody = await this._parser.parse(message.body)
|
|
986
|
+
}
|
|
987
|
+
if (
|
|
988
|
+
message.service === 'ZoneGroupTopology' &&
|
|
989
|
+
message.parsedBody.zoneGroups != null
|
|
990
|
+
) {
|
|
991
|
+
this._zoneGroupState = message.parsedBody
|
|
992
|
+
await this.initTopology(this)
|
|
993
|
+
}
|
|
994
|
+
/** Emitted when a push notification has been received from the zone player.
|
|
995
|
+
* @event ZpClient#message
|
|
996
|
+
* @param {ZpClient.ZpClientNotification} message - The message.
|
|
997
|
+
*/
|
|
998
|
+
this.emit('message', message)
|
|
999
|
+
} catch (error) { this.error(error) }
|
|
1000
|
+
})
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** De-register the zone player for receiving push notifcations.
|
|
1004
|
+
*/
|
|
1005
|
+
async close () {
|
|
1006
|
+
for (const url in this._subscriptions) {
|
|
1007
|
+
try {
|
|
1008
|
+
await this.unsubscribe(url)
|
|
1009
|
+
} catch (error) { this.error(error) }
|
|
1010
|
+
}
|
|
1011
|
+
if (this._params.callbackUrl != null) {
|
|
1012
|
+
await this._zpListener.removeClient(this)
|
|
1013
|
+
}
|
|
1014
|
+
delete this._params.callbackUrl
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Subscribe to push notifications.
|
|
1018
|
+
*
|
|
1019
|
+
* The subscription will be made with a timeout specified in the constructor
|
|
1020
|
+
* through `subscriptionTimeout`.
|
|
1021
|
+
* It will be renewed automatically before it expires.
|
|
1022
|
+
* In case the zone player reboots, a new subscription will be made
|
|
1023
|
+
* automatically.
|
|
1024
|
+
*
|
|
1025
|
+
* @param {string} url - The UPnP device and service URL, e.g.
|
|
1026
|
+
* `/MediaRenderer/AVTransport/Event`.
|
|
1027
|
+
*/
|
|
1028
|
+
async subscribe (url) {
|
|
1029
|
+
if (this._params.callbackUrl == null) {
|
|
1030
|
+
throw new SyntaxError('open() not yet called')
|
|
1031
|
+
}
|
|
1032
|
+
const callbackUrl = this._params.callbackUrl + url
|
|
1033
|
+
const headers = {
|
|
1034
|
+
TIMEOUT: 'Second-' + this._params.subscriptionTimeout + 30
|
|
1035
|
+
}
|
|
1036
|
+
if (this._subscriptions[url] == null) {
|
|
1037
|
+
this._subscriptions[url] = {}
|
|
1038
|
+
}
|
|
1039
|
+
if (this._subscriptions[url].sid == null) {
|
|
1040
|
+
headers.CALLBACK = '<' + callbackUrl + '>'
|
|
1041
|
+
headers.NT = 'upnp:event'
|
|
1042
|
+
} else {
|
|
1043
|
+
headers.SID = this._subscriptions[url].sid
|
|
1044
|
+
delete this._subscriptions[url].sid
|
|
1045
|
+
if (this._subscriptions[url].timeout != null) {
|
|
1046
|
+
clearTimeout(this._subscriptions[url].timeout)
|
|
1047
|
+
delete this._subscriptions[url].timeout
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
try {
|
|
1051
|
+
const response = await this._request('SUBSCRIBE', url, undefined, headers)
|
|
1052
|
+
this._subscriptions[url].sid = response.headers.sid
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
if (error.statusCode === 412) {
|
|
1055
|
+
return this.subscribe(url)
|
|
1056
|
+
}
|
|
1057
|
+
this._checkSubscriptions = true
|
|
1058
|
+
this.error(error)
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
this._subscriptions[url].timeout = setTimeout(async () => {
|
|
1062
|
+
try {
|
|
1063
|
+
await this.subscribe(url)
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
this._checkSubscriptions = true
|
|
1066
|
+
this.error(error)
|
|
1067
|
+
}
|
|
1068
|
+
}, this._params.subscriptionTimeout * 1000)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Unsubscribe from push notifications.
|
|
1072
|
+
* @param {string} url - The UPnP device and service URL, e.g.
|
|
1073
|
+
* `/MediaRenderer/AVTransport/Event`.
|
|
1074
|
+
*/
|
|
1075
|
+
async unsubscribe (url) {
|
|
1076
|
+
if (this._subscriptions[url] == null) {
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
const sid = this._subscriptions[url].sid
|
|
1080
|
+
if (this._subscriptions[url].timeout != null) {
|
|
1081
|
+
clearTimeout(this._subscriptions[url].timeout)
|
|
1082
|
+
}
|
|
1083
|
+
delete this._subscriptions[url]
|
|
1084
|
+
if (sid != null) {
|
|
1085
|
+
try {
|
|
1086
|
+
await this._request('UNSUBSCRIBE', url, undefined, { SID: sid })
|
|
1087
|
+
} catch (error) { this.error(error) }
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async _checkBootSeq (bootSeq) {
|
|
1092
|
+
if (this._props.bootSeq == null) {
|
|
1093
|
+
this._props.bootSeq = bootSeq
|
|
1094
|
+
}
|
|
1095
|
+
if (bootSeq <= this._props.bootSeq) {
|
|
1096
|
+
return
|
|
1097
|
+
}
|
|
1098
|
+
const oldBootSeq = this._props.bootSeq
|
|
1099
|
+
this._props.bootSeq = bootSeq
|
|
1100
|
+
await this.init()
|
|
1101
|
+
for (const url in this._subscriptions) {
|
|
1102
|
+
delete this._subscriptions[url].sid
|
|
1103
|
+
if (this._subscriptions[url].timeout != null) {
|
|
1104
|
+
clearTimeout(this._subscriptions[url].timeout)
|
|
1105
|
+
delete this._subscriptions[url].timeout
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
await this.subscribe(url)
|
|
1109
|
+
} catch (error) { this.error(error) }
|
|
1110
|
+
}
|
|
1111
|
+
/** Emitted when the zone player has rebooted.
|
|
1112
|
+
* @event ZpClient#rebooted
|
|
1113
|
+
* @param {integer} oldBootSeq - The old
|
|
1114
|
+
* {@link ZpClient#bootSeq bootSeq} value.
|
|
1115
|
+
*/
|
|
1116
|
+
this.emit('rebooted', oldBootSeq)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
_checkAddress (address) {
|
|
1120
|
+
if (address === this._props.address) {
|
|
1121
|
+
return
|
|
1122
|
+
}
|
|
1123
|
+
const oldAddress = this._props.address
|
|
1124
|
+
this._props.address = address
|
|
1125
|
+
this.host = this._props.address + ':1400'
|
|
1126
|
+
this._params.hostname = this._props.address
|
|
1127
|
+
/** Emitted when the zone player has a new IP address.
|
|
1128
|
+
* @event ZpClient#addressChanged
|
|
1129
|
+
* @param {string} oldAddress - The old
|
|
1130
|
+
* {@link ZpClient#address address} value.
|
|
1131
|
+
*/
|
|
1132
|
+
this.emit('addressChanged', oldAddress)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async _updateLastSeen () {
|
|
1136
|
+
if (this._reSubscribing) {
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
this._reSubscribing = true
|
|
1140
|
+
this._props.lastSeen = new Date()
|
|
1141
|
+
if (this._checksubscriptions) {
|
|
1142
|
+
for (const url in this._subscriptions) {
|
|
1143
|
+
if (this._subscriptions[url].sid == null) {
|
|
1144
|
+
try {
|
|
1145
|
+
await this.subscribe(url)
|
|
1146
|
+
} catch (error) { this.error(error) }
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
this.emit('lastSeenUpdated')
|
|
1151
|
+
this._reSubscribing = false
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Handle an mDNS or UPnP message that a zone player is alive.
|
|
1155
|
+
*
|
|
1156
|
+
* - Update {@link ZpClient#lastSeen lastSeen}.
|
|
1157
|
+
* - Update {@link ZpClient#address address} when the zone player's IP
|
|
1158
|
+
* address has changed.
|
|
1159
|
+
* - Call {@link ZpClient#init init()} when {@link ZpClient#bootSeq bootSeq}
|
|
1160
|
+
* has changed.
|
|
1161
|
+
* @param {object} message - The message.
|
|
1162
|
+
* @param {string} message.id - The zone player ID.
|
|
1163
|
+
* @param {string} message.address - The zone player IP address.
|
|
1164
|
+
* @param {string} message.household - The zone player household.
|
|
1165
|
+
* @param {interget} message.bootseq - The zone player boot sequence.
|
|
1166
|
+
*/
|
|
1167
|
+
async handleAliveMessage (message) {
|
|
1168
|
+
if (this._props.id != null && message.id !== this._props.id) {
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
if (this._props.household !== message.household) {
|
|
1172
|
+
this._props.household = message.household
|
|
1173
|
+
}
|
|
1174
|
+
this._checkAddress(message.address)
|
|
1175
|
+
await this._checkBootSeq(message.bootseq)
|
|
1176
|
+
await this._updateLastSeen()
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ***** Control *************************************************************
|
|
1180
|
+
|
|
1181
|
+
// AlarmClock
|
|
1182
|
+
|
|
1183
|
+
/** Issue `ListAlarms` action to `AlarmClock` service.
|
|
1184
|
+
* @return {object[]} - A list of alarm objects.
|
|
1185
|
+
*/
|
|
1186
|
+
async listAlarms () {
|
|
1187
|
+
return this.post('ZonePlayer', 'AlarmClock', 'ListAlarms', {})
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Issue `UpdateAlarm` action to `AlarmClock` service.
|
|
1191
|
+
* @param {object} alarm - The alarm parameters.
|
|
1192
|
+
*/
|
|
1193
|
+
async updateAlarm (alarm) {
|
|
1194
|
+
return this.post('ZonePlayer', 'AlarmClock', 'UpdateAlarm', {
|
|
1195
|
+
ID: alarm.id,
|
|
1196
|
+
StartLocalTime: alarm.startTime,
|
|
1197
|
+
Duration: alarm.duration,
|
|
1198
|
+
Recurrence: alarm.recurrence,
|
|
1199
|
+
Enabled: alarm.enabled,
|
|
1200
|
+
RoomUUID: alarm.roomUuid,
|
|
1201
|
+
ProgramURI: he.escape(alarm.programUri),
|
|
1202
|
+
ProgramMetaData: ZpClient.meta(alarm.programMetaData),
|
|
1203
|
+
PlayMode: alarm.playMode,
|
|
1204
|
+
Volume: alarm.volume,
|
|
1205
|
+
IncludeLinkedZones: alarm.includeLinkedZones
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// DeviceProperties
|
|
1210
|
+
|
|
1211
|
+
/** Get the zone player button lock state.
|
|
1212
|
+
* @return {boolean} - True off zone player buttons are locked.
|
|
1213
|
+
*/
|
|
1214
|
+
async getButtonLockState () {
|
|
1215
|
+
return (await this.post(
|
|
1216
|
+
'ZonePlayer', 'DeviceProperties', 'GetButtonLockState', {}
|
|
1217
|
+
)).currentButtonLockState === 'On'
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Set the zone player button lock state.
|
|
1221
|
+
* @param {boolean} state - True to lock the buttons, false to unlock them.
|
|
1222
|
+
* @return {boolean} - True off zone player buttons are now locked.
|
|
1223
|
+
*/
|
|
1224
|
+
async setButtonLockState (state) {
|
|
1225
|
+
return this.post('ZonePlayer', 'DeviceProperties', 'SetButtonLockState', {
|
|
1226
|
+
DesiredButtonLockState: state ? 'On' : 'Off'
|
|
1227
|
+
})
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
async getHouseholdId () {
|
|
1231
|
+
return (await this.post(
|
|
1232
|
+
'ZonePlayer', 'DeviceProperties', 'GetHouseholdID', {}
|
|
1233
|
+
)).currentHouseholdId
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/** Get the zone player LED state.
|
|
1237
|
+
* @return {boolean} - True iff zone player LED is on.
|
|
1238
|
+
*/
|
|
1239
|
+
async getLedState () {
|
|
1240
|
+
return (await this.post(
|
|
1241
|
+
'ZonePlayer', 'DeviceProperties', 'GetLEDState', {}
|
|
1242
|
+
)).currentLedState === 'On'
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Set the zone player LED state.
|
|
1246
|
+
* @param {boolean} state - True to turn the LED on, false to turn it off.
|
|
1247
|
+
* @return {boolean} - True iff zone player LED is now on.
|
|
1248
|
+
*/
|
|
1249
|
+
async setLedState (state) {
|
|
1250
|
+
return this.post('ZonePlayer', 'DeviceProperties', 'SetLEDState', {
|
|
1251
|
+
DesiredLEDState: state ? 'On' : 'Off'
|
|
1252
|
+
})
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
async getZoneAttributes () {
|
|
1256
|
+
return this.post('ZonePlayer', 'DeviceProperties', 'GetZoneAttributes', {})
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async getZoneInfo () {
|
|
1260
|
+
return this.post('ZonePlayer', 'DeviceProperties', 'GetZoneInfo', {})
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// ZoneGroupTopology
|
|
1264
|
+
|
|
1265
|
+
async getZoneGroupAttributes () {
|
|
1266
|
+
return this.post('ZonePlayer', 'ZoneGroupTopology', 'GetZoneGroupAttributes', {})
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
async getZoneGroupState () {
|
|
1270
|
+
return this.post('ZonePlayer', 'ZoneGroupTopology', 'GetZoneGroupState', {})
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// MediaRenderer AVTransport
|
|
1274
|
+
|
|
1275
|
+
async play () {
|
|
1276
|
+
return this.post('MediaRenderer', 'AVTransport', 'Play', {
|
|
1277
|
+
InstanceID: 0,
|
|
1278
|
+
Speed: 1
|
|
1279
|
+
})
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
async pause () {
|
|
1283
|
+
return this.post('MediaRenderer', 'AVTransport', 'Pause', {
|
|
1284
|
+
InstanceID: 0
|
|
1285
|
+
})
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async stop () {
|
|
1289
|
+
return this.post('MediaRenderer', 'AVTransport', 'Stop', {
|
|
1290
|
+
InstanceID: 0
|
|
1291
|
+
})
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async next () {
|
|
1295
|
+
return this.post('MediaRenderer', 'AVTransport', 'Next', {
|
|
1296
|
+
InstanceID: 0
|
|
1297
|
+
})
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async previous () {
|
|
1301
|
+
return this.post('MediaRenderer', 'AVTransport', 'Previous', {
|
|
1302
|
+
InstanceID: 0
|
|
1303
|
+
})
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
async getCrossfadeMode () {
|
|
1307
|
+
return (await this.post('MediaRenderer', 'AVTransport', 'GetCrossfadeMode', {
|
|
1308
|
+
InstanceID: 0
|
|
1309
|
+
})).crossfadeMode === 1
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
async setCrossfadeMode (mode) {
|
|
1313
|
+
return this.post('MediaRenderer', 'AVTransport', 'SetCrossfadeMode', {
|
|
1314
|
+
InstanceID: 0,
|
|
1315
|
+
CrossfadeMode: mode ? 1 : 0
|
|
1316
|
+
})
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
async _getPlayMode () {
|
|
1320
|
+
return (await this.post('MediaRenderer', 'AVTransport', 'GetTransportSettings', {
|
|
1321
|
+
InstanceID: 0
|
|
1322
|
+
})).playMode
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async _setPlayMode (repeat, shuffle) {
|
|
1326
|
+
let playMode
|
|
1327
|
+
if (repeat === 'on') {
|
|
1328
|
+
playMode = shuffle ? 'SHUFFLE' : 'REPEAT_ALL'
|
|
1329
|
+
} else if (repeat === '1') {
|
|
1330
|
+
playMode = shuffle ? 'SHUFFLE_REPEAT_ONE' : 'REPEAT_ONE'
|
|
1331
|
+
} else /* if (repeat === 'off') */ {
|
|
1332
|
+
playMode = shuffle ? 'SHUFFLE_NOREPEAT' : 'NORMAL'
|
|
1333
|
+
}
|
|
1334
|
+
return this.post('MediaRenderer', 'AVTransport', 'SetPlayMode', {
|
|
1335
|
+
InstanceID: 0,
|
|
1336
|
+
NewPlayMode: playMode
|
|
1337
|
+
})
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
async getRepeat () {
|
|
1341
|
+
const playMode = await this._getPlayMode()
|
|
1342
|
+
if (playMode === 'REPEAT_ALL' || playMode === 'SHUFFLE') {
|
|
1343
|
+
return 'on'
|
|
1344
|
+
} else if (playMode === 'REPEAT_ONE' || playMode === 'SHUFFLE_REPEAT_ONE') {
|
|
1345
|
+
return '1'
|
|
1346
|
+
} else /* if (playMode === 'NORMAL' || playMode === 'SHUFFLE_NOREPEAT') */ {
|
|
1347
|
+
return 'off'
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async getShuffle () {
|
|
1352
|
+
return ['SHUFFLE_NOREPEAT', 'SHUFFLE_REPEAT_ONE', 'SHUFFLE']
|
|
1353
|
+
.includes(await this._getPlayMode())
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
async setRepeat (repeat) {
|
|
1357
|
+
return this._setPlayMode(repeat, await this.getShuffle())
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
async setShuffle (shuffle) {
|
|
1361
|
+
return this._setPlayMode(await this.getRepeat(), shuffle)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async setAvTransportUri (uri, metaData = '') {
|
|
1365
|
+
return this.post('MediaRenderer', 'AVTransport', 'SetAVTransportURI', {
|
|
1366
|
+
InstanceID: 0,
|
|
1367
|
+
CurrentURI: uri,
|
|
1368
|
+
CurrentURIMetaData: metaData
|
|
1369
|
+
})
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async setAvTransportAirPlay () {
|
|
1373
|
+
// TODO test
|
|
1374
|
+
return this.setAvTransportUri('x-sonosapi-vli:' + this.id)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async setAvTransportAudioIn (id = this.id) {
|
|
1378
|
+
return this.setAvTransportUri('x-rincon-stream:' + id)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
async setAvTransportGroup (id) {
|
|
1382
|
+
return this.setAvTransportUri('x-rincon:' + id)
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
async setAvTransportTvIn () {
|
|
1386
|
+
return this.setAvTransportUri('x-sonos-htastream:' + this.id + ':spdif')
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async setAvTransportQueue (uri, metaData = '') {
|
|
1390
|
+
await this.post('MediaRenderer', 'AVTransport', 'RemoveAllTracksFromQueue', {
|
|
1391
|
+
InstanceID: 0
|
|
1392
|
+
})
|
|
1393
|
+
await this.post('MediaRenderer', 'AVTransport', 'AddURIToQueue', {
|
|
1394
|
+
InstanceID: 0,
|
|
1395
|
+
EnqueuedURI: uri,
|
|
1396
|
+
EnqueuedURIMetaData: metaData,
|
|
1397
|
+
DesiredFirstTrackNumberEnqueued: 1,
|
|
1398
|
+
EnqueueAsNext: 1
|
|
1399
|
+
})
|
|
1400
|
+
return this.setAvTransportUri('x-rincon-queue:' + this.id + '#0')
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
static meta (metaData, albumArtUri, description) {
|
|
1404
|
+
if (metaData == null || metaData === '') {
|
|
1405
|
+
return ''
|
|
1406
|
+
}
|
|
1407
|
+
let meta = '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">'
|
|
1408
|
+
meta += `<item id="${metaData.id}" parentID="${metaData.parentId}" restricted="${metaData.restricted}">`
|
|
1409
|
+
meta += `<dc:title>${metaData.title}</dc:title>`
|
|
1410
|
+
meta += `<upnp:class>${metaData.class}.sonos-favorite</upnp:class>`
|
|
1411
|
+
if (albumArtUri != null) {
|
|
1412
|
+
for (const uri of albumArtUri) {
|
|
1413
|
+
meta += `<upnp:albumArtURI>${he.escape(uri)}</upnp:albumArtURI>`
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
if (description != null) {
|
|
1417
|
+
meta += `<r:description>${description}</r:description>`
|
|
1418
|
+
}
|
|
1419
|
+
meta += `<desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">${metaData.desc._}</desc>`
|
|
1420
|
+
meta += '</item></DIDL-Lite>'
|
|
1421
|
+
return he.escape(meta)
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
async becomeCoordinatorOfStandaloneGroup () {
|
|
1425
|
+
return this.post('MediaRenderer', 'AVTransport', 'BecomeCoordinatorOfStandaloneGroup', {
|
|
1426
|
+
InstanceID: 0
|
|
1427
|
+
})
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
async delegateGroupCoordinationTo (id) {
|
|
1431
|
+
return this.post('MediaRenderer', 'AVTransport', 'DelegateGroupCoordinationTo', {
|
|
1432
|
+
InstanceID: 0,
|
|
1433
|
+
NewCoordinator: id,
|
|
1434
|
+
RejoinGroup: true
|
|
1435
|
+
})
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async getSleepTimer () {
|
|
1439
|
+
return (await this.post('MediaRenderer', 'AVTransport', 'GetRemainingSleepTimerDuration', {
|
|
1440
|
+
InstanceID: 0
|
|
1441
|
+
})).remainingSleepTimerDuration
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async setSleepTimer (value) {
|
|
1445
|
+
return this.post('MediaRenderer', 'AVTransport', 'ConfigureSleepTimer', {
|
|
1446
|
+
InstanceID: 0,
|
|
1447
|
+
NewSleepTimerDuration: value
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// MediaRenderer GroupRenderingControl
|
|
1452
|
+
|
|
1453
|
+
async getGroupVolume () {
|
|
1454
|
+
return (await this.post('MediaRenderer', 'GroupRenderingControl', 'GetGroupVolume', {
|
|
1455
|
+
InstanceID: 0
|
|
1456
|
+
})).currentVolume
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
async setGroupVolume (volume) {
|
|
1460
|
+
return this.post('MediaRenderer', 'GroupRenderingControl', 'SetGroupVolume', {
|
|
1461
|
+
InstanceID: 0,
|
|
1462
|
+
DesiredVolume: volume
|
|
1463
|
+
})
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
async setRelativeGroupVolume (volume) {
|
|
1467
|
+
return (await this.post('MediaRenderer', 'GroupRenderingControl', 'SetRelativeGroupVolume', {
|
|
1468
|
+
InstanceID: 0,
|
|
1469
|
+
Adjustment: volume
|
|
1470
|
+
})).newVolume
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
async getGroupMute () {
|
|
1474
|
+
return (await this.post('MediaRenderer', 'GroupRenderingControl', 'GetGroupMute', {
|
|
1475
|
+
InstanceID: 0
|
|
1476
|
+
})).currentMute === 1
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async setGroupMute (mute) {
|
|
1480
|
+
return this.post('MediaRenderer', 'GroupRenderingControl', 'SetGroupMute', {
|
|
1481
|
+
InstanceID: 0,
|
|
1482
|
+
DesiredMute: mute ? 1 : 0
|
|
1483
|
+
})
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// MediaRenderer RenderingControl
|
|
1487
|
+
|
|
1488
|
+
async getVolume (channel = 'Master') {
|
|
1489
|
+
return (await this.post('MediaRenderer', 'RenderingControl', 'GetVolume', {
|
|
1490
|
+
InstanceID: 0,
|
|
1491
|
+
Channel: channel
|
|
1492
|
+
})).currentVolume
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async setVolume (volume, channel = 'Master') {
|
|
1496
|
+
return this.post('MediaRenderer', 'RenderingControl', 'SetVolume', {
|
|
1497
|
+
InstanceID: 0,
|
|
1498
|
+
Channel: channel,
|
|
1499
|
+
DesiredVolume: volume
|
|
1500
|
+
})
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
async setRelativeVolume (volume, channel = 'Master') {
|
|
1504
|
+
return (await this.post('MediaRenderer', 'RenderingControl', 'SetRelativeVolume', {
|
|
1505
|
+
InstanceID: 0,
|
|
1506
|
+
Channel: channel,
|
|
1507
|
+
Adjustment: volume
|
|
1508
|
+
})).newVolume
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
async getMute (channel = 'Master') {
|
|
1512
|
+
return (await this.post('MediaRenderer', 'RenderingControl', 'GetMute', {
|
|
1513
|
+
InstanceID: 0,
|
|
1514
|
+
Channel: channel
|
|
1515
|
+
})).currentMute === 1
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async setMute (mute, channel = 'Master') {
|
|
1519
|
+
return this.post('MediaRenderer', 'RenderingControl', 'SetMute', {
|
|
1520
|
+
InstanceID: 0,
|
|
1521
|
+
Channel: channel,
|
|
1522
|
+
DesiredMute: mute ? 1 : 0
|
|
1523
|
+
})
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async getBass () {
|
|
1527
|
+
return (await this.post('MediaRenderer', 'RenderingControl', 'GetBass', {
|
|
1528
|
+
InstanceID: 0
|
|
1529
|
+
})).currentBass
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async setBass (level) {
|
|
1533
|
+
return this.post('MediaRenderer', 'RenderingControl', 'SetBass', {
|
|
1534
|
+
InstanceID: 0,
|
|
1535
|
+
DesiredBass: level
|
|
1536
|
+
})
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
async getTreble () {
|
|
1540
|
+
return (await this.post('MediaRenderer', 'RenderingControl', 'GetTreble', {
|
|
1541
|
+
InstanceID: 0
|
|
1542
|
+
})).currentTreble
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
async setTreble (level) {
|
|
1546
|
+
return this.post('MediaRenderer', 'RenderingControl', 'SetTreble', {
|
|
1547
|
+
InstanceID: 0,
|
|
1548
|
+
DesiredTreble: level
|
|
1549
|
+
})
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
async getBalance () {
|
|
1553
|
+
return (await this.getVolume('RF')) - (await this.getVolume('LF'))
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async setBalance (balance) {
|
|
1557
|
+
await this.setVolume(100, balance < 0 ? 'LF' : 'RF')
|
|
1558
|
+
return this.setVolume(
|
|
1559
|
+
balance < 0 ? 100 - -balance : 100 - balance, balance < 0 ? 'RF' : 'LF'
|
|
1560
|
+
)
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
async getLoudness (channel = 'Master') {
|
|
1564
|
+
return (await this.post('MediaRenderer', 'RenderingControl', 'GetLoudness', {
|
|
1565
|
+
InstanceID: 0,
|
|
1566
|
+
Channel: channel
|
|
1567
|
+
})).currentLoudness === 1
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
async setLoudness (loudness, channel = 'Master') {
|
|
1571
|
+
return this.post('MediaRenderer', 'RenderingControl', 'SetLoudness', {
|
|
1572
|
+
InstanceID: 0,
|
|
1573
|
+
Channel: channel,
|
|
1574
|
+
DesiredLoudness: loudness ? 1 : 0
|
|
1575
|
+
})
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
async getEq (type) {
|
|
1579
|
+
return (await this.post('MediaRenderer', 'RenderingControl', 'GetEQ', {
|
|
1580
|
+
InstanceID: 0,
|
|
1581
|
+
EQType: type
|
|
1582
|
+
})).currentValue
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
async setEq (type, value) {
|
|
1586
|
+
return this.post('MediaRenderer', 'RenderingControl', 'SetEQ', {
|
|
1587
|
+
InstanceID: 0,
|
|
1588
|
+
EQType: type,
|
|
1589
|
+
DesiredValue: value
|
|
1590
|
+
})
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
async getNightSound () { return (await this.getEq('NightMode')) === 1 }
|
|
1594
|
+
|
|
1595
|
+
async setNightSound (value) { return this.setEq('NightMode', value ? 1 : 0) }
|
|
1596
|
+
|
|
1597
|
+
async getSpeechEnhancement () { return (await this.getEq('DialogLevel')) === 1 }
|
|
1598
|
+
|
|
1599
|
+
async setSpeechEnhancement (value) { return this.setEq('DialogLevel', value ? 1 : 0) }
|
|
1600
|
+
|
|
1601
|
+
async getSurroundEnable () { return (await this.getEq('SurroundEnable')) === 1 }
|
|
1602
|
+
|
|
1603
|
+
async setSurroundEnable (value) { return this.setEq('SurroundEnable', value ? 1 : 0) }
|
|
1604
|
+
|
|
1605
|
+
async getTvLevel () { return this.getEq('SurroundLevel') }
|
|
1606
|
+
|
|
1607
|
+
async setTvLevel (value) { return this.setEq('SurroundLevel', value) }
|
|
1608
|
+
|
|
1609
|
+
async getMusicLevel () { return this.getEq('MusicSurroundLevel') }
|
|
1610
|
+
|
|
1611
|
+
async setMusicLevel (value) { return this.setEq('MusicSurroundLevel', value) }
|
|
1612
|
+
|
|
1613
|
+
async getMusicPlaybackFull () { return (await this.getEq('SurroundMode')) === 1 }
|
|
1614
|
+
|
|
1615
|
+
async setMusicPlaybackFull (value) { return this.setEq('SurroundMode', value ? 1 : 0) }
|
|
1616
|
+
|
|
1617
|
+
async getHeightLevel () { return this.getEq('HeightChannelLevel') }
|
|
1618
|
+
|
|
1619
|
+
async setHeightLevel (value) { return this.setEq('HeightChannelLevel', value) }
|
|
1620
|
+
|
|
1621
|
+
async getSubEnable () { return (await this.getEq('SubEnable')) === 1 }
|
|
1622
|
+
|
|
1623
|
+
async setSubEnable (value) { return this.setEq('SubEnable', value ? 1 : 0) }
|
|
1624
|
+
|
|
1625
|
+
async getSubLevel () { return this.getEq('SubGain') }
|
|
1626
|
+
|
|
1627
|
+
async setSubLevel (value) { return this.setEq('SubGain', value) }
|
|
1628
|
+
|
|
1629
|
+
// MediaServer ContentDirectory
|
|
1630
|
+
|
|
1631
|
+
async browse (object = 'FV:2', startingIndex = 0) {
|
|
1632
|
+
let result = await this.post('MediaServer', 'ContentDirectory', 'Browse', {
|
|
1633
|
+
ObjectID: object,
|
|
1634
|
+
BrowseFlag: 'BrowseDirectChildren',
|
|
1635
|
+
Filter: 'dc:title,res,dc:creator,upnp:artist,upnp:album,upnp:albumArtURI',
|
|
1636
|
+
StartingIndex: startingIndex,
|
|
1637
|
+
RequestedCount: 0,
|
|
1638
|
+
SortCriteria: ''
|
|
1639
|
+
})
|
|
1640
|
+
if (result.result != null) {
|
|
1641
|
+
result = result.result
|
|
1642
|
+
}
|
|
1643
|
+
let container
|
|
1644
|
+
if (result.container != null) {
|
|
1645
|
+
container = true
|
|
1646
|
+
result = result.container
|
|
1647
|
+
}
|
|
1648
|
+
if (!Array.isArray(result)) {
|
|
1649
|
+
if (Object.keys(result).length > 0) {
|
|
1650
|
+
result = [result]
|
|
1651
|
+
} else {
|
|
1652
|
+
result = []
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
const obj = {}
|
|
1656
|
+
result.forEach((element) => {
|
|
1657
|
+
obj[element.title] = {}
|
|
1658
|
+
if (container) {
|
|
1659
|
+
obj[element.title].browse = element.id
|
|
1660
|
+
}
|
|
1661
|
+
if (element.description != null) {
|
|
1662
|
+
obj[element.title].description = element.description
|
|
1663
|
+
}
|
|
1664
|
+
if (
|
|
1665
|
+
element.resMD != null && element.resMD.class != null &&
|
|
1666
|
+
element.resMD.class.startsWith('object.container.')
|
|
1667
|
+
) {
|
|
1668
|
+
obj[element.title].container = true
|
|
1669
|
+
}
|
|
1670
|
+
if (element.res != null && element.res._ != null) {
|
|
1671
|
+
obj[element.title].uri = he.escape(element.res._)
|
|
1672
|
+
}
|
|
1673
|
+
if (element.resMD != null) {
|
|
1674
|
+
obj[element.title].meta = ZpClient.meta(
|
|
1675
|
+
element.resMD, element.albumArtUri, element.description
|
|
1676
|
+
)
|
|
1677
|
+
}
|
|
1678
|
+
})
|
|
1679
|
+
return obj
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** Post a SOAP action to the zone player.
|
|
1683
|
+
* @param {string} device - The UPnP device, or `ZonePlayer` for the
|
|
1684
|
+
* main device, e.g. `MediaRenderer`.
|
|
1685
|
+
* @param {string} service - The UPnP sevice, e.g. `AVTransPort`.
|
|
1686
|
+
* @param {string} action - The SOAP action, e.g. `Play`.
|
|
1687
|
+
* @param {object} options - An object with key/value pairs for the
|
|
1688
|
+
* parameters.
|
|
1689
|
+
* @returns {?*} - The parsed response body as JavaScript object.
|
|
1690
|
+
*/
|
|
1691
|
+
async post (device, service, action, options) {
|
|
1692
|
+
const url = (device === 'ZonePlayer' ? '' : '/' + device) +
|
|
1693
|
+
'/' + service + '/Control'
|
|
1694
|
+
let body = '<s:Envelope '
|
|
1695
|
+
body += 'xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
|
|
1696
|
+
body += 's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
|
|
1697
|
+
body += '<s:Body>'
|
|
1698
|
+
body += `<u:${action} xmlns:u="urn:schemas-upnp-org:service:${service}:1">`
|
|
1699
|
+
if (options != null) {
|
|
1700
|
+
for (const key in options) {
|
|
1701
|
+
body += `<${key}>${options[key]}</${key}>`
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
body += `</u:${action}></s:Body></s:Envelope>`
|
|
1705
|
+
const headers = {
|
|
1706
|
+
SOAPaction: `"urn:schemas-upnp-org:service:${service}:1#${action}"`,
|
|
1707
|
+
'content-type': 'text/xml; charset=utf-8'
|
|
1708
|
+
}
|
|
1709
|
+
const info = {
|
|
1710
|
+
action,
|
|
1711
|
+
parsedBody: options
|
|
1712
|
+
}
|
|
1713
|
+
const response = await this._request('POST', url, body, headers, info)
|
|
1714
|
+
await this._updateLastSeen()
|
|
1715
|
+
return response.parsedBody
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/** Get a static url from the zone player.
|
|
1719
|
+
* @param {string} [url=/xml/device_description.xml] - The url.
|
|
1720
|
+
* @returns {?*} - The parsed response body as JavaScript object.
|
|
1721
|
+
*/
|
|
1722
|
+
async get (url = '/xml/device_description.xml') {
|
|
1723
|
+
const response = await this._request('GET', url)
|
|
1724
|
+
await this._updateLastSeen()
|
|
1725
|
+
return response.parsedBody
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async _request (method, resource, body, headers, info) {
|
|
1729
|
+
const response = await super.request(
|
|
1730
|
+
method, resource, body, headers, '', info
|
|
1731
|
+
)
|
|
1732
|
+
return response
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
export { ZpClient }
|