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.
@@ -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 }