hb-zp-tools
Version:
Homebridge ZP Tools
1,689 lines (1,524 loc) • 52.6 kB
JavaScript
// hb-zp-tools/lib/ZpClient.js
// Copyright © 2019-2026 Erik Baauw. All rights reserved.
//
// Homebridge ZP Tools.
import { lookup } from 'node:dns/promises'
import { once } from 'node:events'
import he from 'he'
import { HttpClient } from 'hb-lib-tools/HttpClient'
import { JsonFormatter } from 'hb-lib-tools/JsonFormatter'
import { OptionParser } from 'hb-lib-tools/OptionParser'
import { ZpListener } from './ZpListener.js'
import { ZpXmlParser } from './ZpXmlParser.js'
/** Sonos ZonePlayer API client.
* <br>See {@link ZpClient}.
* @name ZpClient
* @type {Class}
* @memberof module:hb-zp-tools
*/
// Basic properties, populated from the device description by init().
const basicProps = Object.freeze([
'address',
'audioIn',
'id',
'lastSeen',
'memory',
'modelName',
'modelNumber',
'sonosOs',
'tvIn',
'version',
'zoneName'
])
// Basic properties, populated from the device description by init(), combined
// with advanced properties, populated from the topology by initTopology().
const allProps = Object.freeze([
'airPlay',
'balance',
'battery',
'bootSeq',
'channel',
'homeTheatre',
'household',
'invisible',
'name',
'role',
'satellites',
'slaves',
'stereoPair',
'zone',
'zoneDisplayName',
'zoneGroup',
'zoneGroupName',
'zoneGroupShortName',
'zonePlayerName'
].concat(basicProps).sort())
// Display channels in channelMapSet.
const channelMap = {
// Stereo pair.
'LF,LF': 'L',
'RF,RF': 'R',
'SW,SW': 'Sub',
// Home theatre setup.
'LF,RF': '', // master
SW: 'Sub',
LR: 'LS',
RR: 'RS',
'LR,RR': 'LS+RS', // Connect:Amp as surround.
'LR,LTR': 'LS', // Era 300 as surround
'RR,RTR': 'RS' // Era 300 as surround
}
/** ZpClient error.
* @hideconstructor
* @extends HttpClient.HttpError
* @memberof ZpClient
*/
class ZpClientError extends HttpClient.HttpError {
/** The request that caused the error.
* @type {ZpClient.ZpClientRequest}
* @readonly
*/
get request () {}
}
/** Notification from a zone player.
* @hideconstructor
* @memberof ZpClient
*/
class ZpClientNotification {
/** The zone player name.
* @type {string}
* @readonly
*/
get name () {}
/** The zone player UPnP device that issued the event.
*
* This is `ZonePlayer` for top-level services or the actual UPnP device,
* like `MediaRenderer` or `MediaServer`.
* services linked
* @type {string}
* @readonly
*/
get device () {}
/** The zone player service that issued the event.
* @type {string}
* @readonly
*/
get service () {}
/** The (parsed) event body (in JavaScript).
* @type {string}
* @readonly
*/
get body () {}
/** The (raw) event body (in XML).
* @type {*}
* @readonly
*/
get rawBody () {}
}
/** ZpClient request.
* @hideconstructor
* @extends HttpClient.HttpRequest
* @memberof ZpClient
*/
class ZpClientRequest extends HttpClient.HttpRequest {
/** The zone player hostname.
* @type {string}
* @readonly
*/
get name () {}
/** The SOAP action of the request.
* @type {?string}
* @readonly
*/
get action () {}
/** The (raw) response body (in XML).
* @type {?string}
* @readonly
*/
get body () {}
/** The (parsed) request body (in JavaScript).
* @type {?string}
* @readonly
*/
get jsonBody () {}
}
/** ZpClient response.
* @hideconstructor
* @extends HttpClient.HttpResponse
* @memberof ZpClient
*/
class ZpClientResponse extends HttpClient.HttpResponse {
/** The request that generated the response.
* @type {ZpClientClient.ZpClientRequest}
* @readonly
*/
get request () {}
/** The (parsed) response body (in JavaScript).
* @type {?string}
* @readonly
*/
get body () {}
/** The (raw) response body (in XML).
* @type {?*}
* @readonly
*/
get rawBody () {}
}
/** Client to a Sonos zone player.
* @extends HttpClient
*/
class ZpClient extends HttpClient {
static get ZpClientError () { return ZpClientError }
static get ZpClientNotification () { return ZpClientNotification }
static get ZpClientRequest () { return ZpClientRequest }
static get ZpClientResponse () { return ZpClientResponse }
/** Parse a channel map set, as returned by
* {@link ZpClient.getZoneGroupState getZoneGroupState()} or by a
* `zoneGroupTopology` event.
* @params {string} channelMapSet - The channel map set.
* @returns {object} map - The parsed channel map set.
*/
static parseChannelMapSet (channelMapSet) {
const a = channelMapSet.split(';')
return {
ids: a.map((elt) => { return elt.split(':')[0] }),
channels: a.map((elt) => {
const channel = elt.split(':')[1]
return channelMap[channel] ?? channel
}).sort()
}
}
/** Parse a zoneGroupMembers entry, as returned by
* {@link ZpClient.getZoneGroupState getZoneGroupState()} or by a
* `zoneGroupTopology` event.
* @params {object} member - The`zoneGroupMembers` entry.
* @returns {object} props - The parsed properties.
*/
static parseMember (member) {
const props = {
address: member.location.split('/')[2].split(':')[0],
airPlay: member.airPlayEnabled === 1 ? true : undefined,
battery: member.battery,
bootSeq: member.bootSeq,
channel: undefined, // default
homeTheatre: undefined, // default
household: undefined, // default
id: member.uuid,
invisible: member.invisible === 1 ? true : undefined,
name: member.zoneName,
role: 'master', // default
satellites: undefined, // default
slaves: undefined, // default
stereoPair: undefined, // default
zone: member.uuid, // default
zoneDisplayName: member.zoneName, // default
zoneGroup: undefined,
zoneGroupName: undefined,
zoneGroupShortName: undefined,
zoneName: member.zoneName
}
let map
let slave
let channels
if (member.channelMapSet != null) {
props.stereoPair = true
map = ZpClient.parseChannelMapSet(member.channelMapSet)
slave = 'slave'
channels = map.channels
} else if (member.htSatChanMapSet != null) {
props.homeTheatre = true
map = ZpClient.parseChannelMapSet(member.htSatChanMapSet)
slave = 'satellite'
channels = map.channels.slice(1)
}
if (map != null) {
if (map.ids[0] === props.id) {
props.role = 'master'
props[slave + 's'] = map.ids.slice(1)
props.channel = map.channels[0]
} else {
props.role = slave
props.zone = map.ids[0]
for (let id = 1; id < map.ids.length; id++) {
if (map.ids[id] === props.id) {
props.channel = map.channels[id]
break
}
}
}
props.zoneDisplayName += ' (' + channels.join('+').replace('+Sub+Sub', '+Subx2') + ')'
if (props.channel !== '') {
props.name += ' (' + props.channel + ')'
}
}
return props
}
/** Unflatten a zonePlayers structure.
* @params {Object} zonePlayers - A flat map of zonePlayer objects,
* listing the slave and satellite zone players separately.
* @returns {Object} - A map of nested zonePlayer objects,
* listing the slave and satellite zone players under the master zone player.
*/
static unflatten (zonePlayers) {
const zones = {}
for (const id in zonePlayers) {
if (zonePlayers[id].role === 'master') {
zones[id] = Object.assign({}, zonePlayers[id])
if (zonePlayers[id].slaves != null) {
zones[id].slaves = []
for (const slave of zonePlayers[id].slaves) {
zones[id].slaves.push(zonePlayers[slave])
}
}
if (zonePlayers[id].satellites != null) {
zones[id].satellites = []
for (const satellite of zonePlayers[id].satellites) {
if (zonePlayers[satellite] != null) {
zones[id].satellites.push(zonePlayers[satellite])
}
}
}
}
}
return zones
}
/** Create a new instance of a client to a Sonos zone player.
*
* @param {object} params - Parameters.
* @param {!string} params.host - Server hostname and port.
* @param {?string} params.name - The name of the server. Defaults to hostname.
* @param {integer} [params.subscriptionTimeout=30] - Subscription timeout
* (in minutes).
* @param {integer} [params.timeout=5] - Request timeout (in seconds).
*/
constructor (params = {}) {
const _params = {
port: 1400,
subscriptionTimeout: 30,
timeout: 5
}
const optionParser = new OptionParser(_params)
optionParser
.hostKey('host')
.stringKey('id')
.stringKey('household')
.intKey('timeout', 1, 60) // seconds
.intKey('subscriptionTimeout', 1, 1440) // minutes
.instanceKey('listener', ZpListener)
.instanceKey('logger')
.parse(params)
_params.subscriptionTimeout *= 60 // minutes -> seconds
const parser = new ZpXmlParser()
const options = {
host: _params.hostname + ':' + _params.port,
keepAlive: true,
maxSockets: 1,
name: _params.hostname,
logger: _params.logger,
timeout: _params.timeout,
xmlParser: parser.parse.bind(parser)
}
super(options)
/** Emitted when an error has been received from the zone player.
* @event ZpClient#error
* @param {ZpClient.ZpClientError} error - The error.
*/
/** Emitted when a request has been sent to the zone player.
* @event ZpClient#request
* @param {ZpClient.ZpClientRequest} request - The request.
*/
/** Emitted when a valid response has been received from the zone player.
* @event ZpClient#response
* @param {ZpClient.ZpClientResponse} response - The response.
*/
this._params = _params
this._jsonFormatter = new JsonFormatter()
this._zpListener = this._params.listener
this._parser = parser
this._props = {
address: _params.host,
id: _params.id,
household: _params.household
}
this._subscriptions = {}
}
// Error handling. Only emit 'error' when it wasn't already submitted by
// _request().
error (error) {
if (error.request == null) {
this.warn(error)
this.emit('error', error)
}
}
// ***** Initialisation ******************************************************
/** Initialise the zpClient instance.
* Looks up the zone player's IP address using DNS (Sonos doens't accept
* requests issued to the hostname).
* Connects to the zone player to retrieve the device description,
* setting the basic properties.
*/
async init () {
this._props.lastSeen = null
this._props.address = (await lookup(this._params.hostname)).address
this._params.hostname = this._props.address
this.host = this._props.address + ':1400'
this._description = await this.get()
const id = this._description.device.udn.split(':')[1]
if (this._params.id != null && this._params.id !== id) {
this.emit('error', new Error('address mismatch'))
return
}
this._params.id = id
this._props.audioIn = undefined
this._props.balance = undefined
this._props.id = id
this._props.memory = this._description.device.memory
this._props.modelName = this._description.device.modelName
this._props.modelNumber = this._description.device.modelNumber
const majorVersion = this._description.device.displayVersion.split('.')[0]
this._props.sonosOs = majorVersion <= 11 ? 'S1' : 'S2'
this._props.version = this._description.device.displayVersion
this._props.tvIn = undefined
this._props.zoneName = this._description.device.roomName
for (const service of this._description.device.serviceList) {
switch (service.serviceId.split(':')[3]) {
case 'AudioIn':
this._props.audioIn = true
break
case 'HTControl':
this._props.tvIn = true
break
default:
break
}
}
delete this._info
}
/** Check whether basic properties have been initialed.
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
checkInit () {
if (this._description == null) {
throw new SyntaxError('init() not yet called')
}
}
_handleMessage (message) {
if (message.service === 'ZoneGroupTopology') {
this._zoneGroupState = message.body
this.emit('gotcha')
}
}
/** Initialise the topology for this zpClient instance.
* Connects to the zone player to retrieve the a topology,
* setting the advanced properties, or sets the advanced proporties from
* the topology retrieved from another zone player.
* @param {?ZpClient} zpClient - Re-use the topology already retrieved from
* another zone player.
*/
async initTopology (zpClient = {}) {
// this.checkInit()
this._zonePlayers = {}
this._zonesByName = {}
let household = this._props.household
if (zpClient.zoneGroupState != null) {
this._zoneGroupState = zpClient.zoneGroupState
household = zpClient.household
} else {
this.on('message', this._handleMessage)
await this.subscribe('/ZoneGroupTopology/Event')
const timeout = setTimeout(() => {
this.emit('error', new Error(
`no ZoneGroupTopology event received in ${this._params.timeout}s`
))
}, this._params.timeout * 1000)
try {
await once(this, 'gotcha')
} catch (error) {}
clearTimeout(timeout)
await this.unsubscribe('/ZoneGroupTopology/Event')
this.removeListener('message', this._handleMessage)
}
if (this._zoneGroupState != null) {
if (this._zoneGroupState.museHouseholdId != null) {
household = this._zoneGroupState.museHouseholdId.split('.')[0]
}
if (household == null) {
household = await this.getHouseholdId()
}
for (const group of this._zoneGroupState.zoneGroups) {
const ids = []
let groupName
const groupMemberNames = []
for (const member of group.zoneGroupMembers) {
const props = ZpClient.parseMember(member)
props.household = household
props.zoneGroup = group.coordinator
if (props.id === this._params.id) {
this.#checkAddress(props.address)
await this._checkBootSeq(props.bootSeq)
if (props.bootSeq < this._props.bootSeq) {
props.bootSeq = this._props.bootSeq
}
Object.assign(this._props, props)
}
this._zonePlayers[props.id] = props
ids.push(props.id)
if (props.role === 'master') {
this._zonesByName[props.zoneName + '|' + props.zone] =
props.zoneDisplayName
if (member.uuid === group.coordinator) {
groupName = props.zoneName
} else {
groupMemberNames.push(props.zoneName)
}
}
if (member.satellites != null) {
const zoneDisplayName = props.zoneDisplayName
for (const satellite of member.satellites) {
const props = ZpClient.parseMember(satellite)
props.household = household
props.zoneGroup = group.coordinator
props.zoneDisplayName = zoneDisplayName
if (props.id === this._params.id) {
Object.assign(this._props, props)
}
this._zonePlayers[props.id] = props
ids.push(props.id)
}
}
}
const groupShortName = groupMemberNames.length > 0
? groupName + ' + ' + groupMemberNames.length
: groupName
groupName = [groupName].concat(groupMemberNames.sort()).join(' + ')
for (const id of ids) {
if (id === this._params.id) {
this._props.zoneGroupName = groupName
this._props.zoneGroupShortName = groupShortName
}
this._zonePlayers[id].zoneGroupName = groupName
this._zonePlayers[id].zoneGroupShortName = groupShortName
}
}
delete this._info
delete this._zonePlayersByName
delete this._zones
}
}
/** Check whether advanced properties have been initialised.
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
checkInitTopology () {
if (this._zoneGroupState == null) {
throw new SyntaxError('initTopology() not yet called')
}
}
// ***** Properties **********************************************************
/** The zone player IP address.
* @type {string}
* @readonly
*/
get address () {
return this._props.address
}
/** Whether the zone player supports AirPlay.
* @type {?boolean}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get airPlay () {
this.checkInitTopology()
return this._props.airPlay
}
/** Whether the zone player supports audio in.
* @type {?boolean}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get audioIn () {
this.checkInit()
return this._props.audioIn
}
/** Whether the zone player supports balance.
* @type {?boolean}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get balance () {
this.checkInitTopology()
return this.audioIn || this.stereoPair ? true : undefined
}
/** The battery state of the zone player.
* @type {?object}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get battery () {
this.checkInitTopology()
return this._props.battery
}
/** The zone player boot sequence.
*
* This value increases on each zone player reboot.
* @type {integer}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get bootSeq () {
this.checkInitTopology()
return this._props.bootSeq
}
/** The zone player channel when it's part of a stereo pair
* or home theatre setup.
* @type {?string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get channel () {
this.checkInitTopology()
return this._props.channel
}
/** The zone player's device description.
* @type {object}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get description () {
this.checkInit()
return this._description
}
/** Whether the zone player is part of a home theatre setup.
* @type {?boolean}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get homeTheatre () {
this.checkInitTopology()
return this._props.homeTheatre
}
/** The household that the zone player is part of.
* @type {?string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get household () {
this.checkInitTopology()
return this._props.household
}
/** The zone player ID.
*
* The ID has the format `RINCON_`_xxxxxxxxxxxx_`01400` where _xxxxxxxxxxxx_
* is the mac address of the zone player.
* Note that 1400 is the port on the zone player that serves the local
* SOAP/HTTP API.
* @type {string}
* @readonly
*/
get id () { return this._props.id }
/** The zone player info, i.e. the zone player static properties as a single
* object.
* @type {object}
* @readonly
* @throws {SyntaxError} When {@link ZpClient#init init()} hasn't been called.
*/
get info () {
this.checkInit()
if (this._info == null) {
this._info = {}
const props = (this._zoneGroupState == null) ? basicProps : allProps
for (const prop of props) {
this._info[prop] = this[prop]
}
}
return this._info
}
/** Whether the zone player is invisble (not shown as room in the Sonos app).
* @type {?boolean}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get invisible () {
this.checkInitTopology()
return this._props.invisible
}
/** The timestamp of the last communication from the zone player,
* i.e. the time when the most recent push notification, request
* response, or UPnP assouncement was recevied.
* @type {string}
* @readonly
*/
get lastSeen () {
return this._props.lastSeen == null
? 'n/a'
: String(this._props.lastSeen).substring(0, 24)
}
/** The amount of memory in the zone player.
* @type {integer}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get memory () {
this.checkInit()
return this._props.memory
}
/** The zone player model name, e.g. "Sonos Playbar".
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get modelName () {
this.checkInit()
return this._props.modelName
}
/** The zone player model number, e.g. "S9".
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get modelNumber () {
this.checkInit()
return this._props.modelNumber
}
/** The zone player role in its zone: `master`, `slave`, or `satellite`.
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get role () {
this.checkInitTopology()
return this._props.role
}
/** The IDs of the satellite zone players (for the master zone player in a
* home theatre setup).
* @type {?string[]}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get satellites () {
this.checkInitTopology()
return this._props.satellites
}
/** The IDs of the slave zone players (for a master zone player in a
* stereo pair).
* @type {?string[]}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get slaves () {
this.checkInitTopology()
return this._props.slaves
}
/** The zone player OS version: `S1` or `S2`.
* @type {?string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get sonosOs () {
this.checkInit()
return this._props.sonosOs
}
/** Whether the zone player is part of a stereo pair.
* @type {?boolean}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get stereoPair () {
this.checkInitTopology()
return this._props.stereoPair
}
/** The current subscriptions to the zone player, sorted by UPnP device and
* service.
* @type {string[]}
* @readonly
*/
get subscriptions () {
const a = []
for (const url in this._subscriptions) {
a.push(url)
}
return a.sort()
}
/** Whether the zone player supports TV input.
* @type {?boolean}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get tvIn () {
this.checkInit()
return this._props.tvIn
}
/** The zone player firmware version.
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get version () {
this.checkInit()
return this._props.version
}
/** The zone player zone.
*
* This is the ID of the master zone player of that zone.
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zone () {
this.checkInitTopology()
return this._props.zone
}
/** The zone player zone (room) display name, e.g. "Living Room (+LS+RS+Sub)".
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zoneDisplayName () {
this.checkInitTopology()
return this._props.zoneDisplayName
}
/** The zone player zone group.
*
* This is the ID of the master zone player of the coordinator zone
* of the zone group.
*
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zoneGroup () {
this.checkInitTopology()
return this._props.zoneGroup
}
/** The zone player zone group name, e.g. "Living Room + Bedroom".
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zoneGroupName () {
this.checkInitTopology()
return this._props.zoneGroupName
}
/** The zone player zone group short name, e.g. "Living Room + 1".
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zoneGroupShortName () {
this.checkInitTopology()
return this._props.zoneGroupShortName
}
/** The raw zone group state, as returned by
* {@link ZpClient.getZoneGroupState getZoneGroupState()} or by a
* `zoneGroupTopology` event.
* @type {object}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zoneGroupState () {
this.checkInitTopology()
return this._zoneGroupState
}
/** The zone player zone (room) name, e.g. "Living Room".
* @type {?boolean}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#init init()} hasn't been called.
*/
get zoneName () {
this.checkInit()
return this._props.zoneName
}
/** The zone player name, e.g. `Living Room (Sub)`.
* @type {string}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zonePlayerName () { return this._props.name }
/** The cooked zone group state, as a flat map of zonePlayer objects
* @type {Object}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zonePlayers () {
this.checkInitTopology()
if (this._zonePlayersByName == null) {
this._zonePlayersByName = {}
Object.keys(this._zonesByName).sort().forEach((key) => {
const id = key.split('|')[1]
this._zonePlayersByName[id] = Object.assign({}, this._zonePlayers[id])
if (this._zonePlayers[id].slaves != null) {
for (const slave of this._zonePlayers[id].slaves) {
if (this._zonePlayers[slave] != null) {
this._zonePlayers[slave].zoneDisplayName = this._zonesByName[key]
this._zonePlayersByName[slave] = this._zonePlayers[slave]
}
}
}
if (this._zonePlayers[id].satellites != null) {
for (const satellite of this._zonePlayers[id].satellites) {
if (this._zonePlayers[satellite] != null) {
this._zonePlayersByName[satellite] = this._zonePlayers[satellite]
}
}
}
})
}
return Object.assign({}, this._zonePlayersByName)
}
/** The cooked zone group state, as a nested map of zonePlayer objects
* @type {Object}
* @readonly
* @throws {SyntaxError} - When {@link ZpClient#initTopology initTopology()}
* hasn't been called.
*/
get zones () {
this.checkInitTopology()
if (this._zones == null) {
this._zones = ZpClient.unflatten(this.zonePlayers)
}
return this._zones
}
// ***** Event Handling ******************************************************
/** Register the zone player for receiving push notifications.
* @param {ZpListener} listener - The {@link ZpListener} instance to
* reveive the notifications.
*/
async open () {
if (this._params.callbackUrl != null) {
return
}
this._params.callbackUrl = await this._zpListener.addClient(this)
this._zpListener.on(this.id, async (message) => {
try {
await this.#updateLastSeen()
message.name = this.name
if (message.body != null) {
message.rawBody = message.body
message.body = await this._parser.parse(message.body)
}
if (
message.service === 'ZoneGroupTopology' &&
message.body.zoneGroups != null
) {
this._zoneGroupState = message.body
await this.initTopology(this)
}
const notify = message.device === 'ZonePlayer'
? message.service
: message.device + '/' + message.service
this.vvdebug(
'%s: notify %s/Event: %j', this.name, notify, message.rawBody
)
this.vdebug(
'%s: notify %s/Event: %j', this.name, notify, message.body
)
this.debug('%s: notify %s/Event', this.name, notify)
/** Emitted when a push notification has been received from the zone player.
* @event ZpClient#message
* @param {ZpClient.ZpClientNotification} message - The message.
*/
this.emit('message', message)
} catch (error) { this.error(error) }
})
}
/** De-register the zone player for receiving push notifcations.
*/
async close () {
for (const url in this._subscriptions) {
try {
await this.unsubscribe(url)
} catch (error) { this.error(error) }
}
if (this._params.callbackUrl != null) {
await this._zpListener.removeClient(this)
}
delete this._params.callbackUrl
}
/** Subscribe to push notifications.
*
* The subscription will be made with a timeout specified in the constructor
* through `subscriptionTimeout`.
* It will be renewed automatically before it expires.
* In case the zone player reboots, a new subscription will be made
* automatically.
*
* @param {string} url - The UPnP device and service URL, e.g.
* `/MediaRenderer/AVTransport/Event`.
*/
async subscribe (url) {
await this.open()
const callbackUrl = this._params.callbackUrl + url
const headers = {
TIMEOUT: 'Second-' + this._params.subscriptionTimeout + 30
}
if (this._subscriptions[url] == null) {
this._subscriptions[url] = {}
}
if (this._subscriptions[url].sid == null) {
headers.CALLBACK = '<' + callbackUrl + '>'
headers.NT = 'upnp:event'
} else {
headers.SID = this._subscriptions[url].sid
delete this._subscriptions[url].sid
if (this._subscriptions[url].timeout != null) {
clearTimeout(this._subscriptions[url].timeout)
delete this._subscriptions[url].timeout
}
}
try {
const response = await this.#request('SUBSCRIBE', url, undefined, headers)
this._subscriptions[url].sid = response.headers.sid
} catch (error) {
if (error.statusCode === 412) {
return this.subscribe(url)
}
this._checkSubscriptions = true
this.error(error)
return
}
this._subscriptions[url].timeout = setTimeout(async () => {
try {
await this.subscribe(url)
} catch (error) {
this._checkSubscriptions = true
this.error(error)
}
}, this._params.subscriptionTimeout * 1000)
}
/** Unsubscribe from push notifications.
* @param {string} url - The UPnP device and service URL, e.g.
* `/MediaRenderer/AVTransport/Event`.
*/
async unsubscribe (url) {
if (this._subscriptions[url] == null) {
return
}
const sid = this._subscriptions[url].sid
if (this._subscriptions[url].timeout != null) {
clearTimeout(this._subscriptions[url].timeout)
}
delete this._subscriptions[url]
if (sid != null) {
try {
await this.#request('UNSUBSCRIBE', url, undefined, { SID: sid })
} catch (error) { this.error(error) }
}
if (Object.keys(this._subscriptions).length === 0) {
await this.close()
}
}
async _checkBootSeq (bootSeq) {
if (this._props.bootSeq == null) {
this._props.bootSeq = bootSeq
}
if (bootSeq <= this._props.bootSeq) {
return
}
const oldBootSeq = this._props.bootSeq
this._props.bootSeq = bootSeq
await this.init()
for (const url in this._subscriptions) {
delete this._subscriptions[url].sid
if (this._subscriptions[url].timeout != null) {
clearTimeout(this._subscriptions[url].timeout)
delete this._subscriptions[url].timeout
}
try {
await this.subscribe(url)
} catch (error) { this.error(error) }
}
this.debug(
'%s: rebooted (%d -> %d)', this.name,
oldBootSeq, this.bootSeq
)
/** Emitted when the zone player has rebooted.
* @event ZpClient#rebooted
* @param {integer} oldBootSeq - The old
* {@link ZpClient#bootSeq bootSeq} value.
*/
this.emit('rebooted', oldBootSeq)
}
#checkAddress (address) {
if (address === this._props.address) {
return
}
const oldAddress = this._props.address
this._props.address = address
this.host = this._props.address + ':1400'
this._params.hostname = this._props.address
this.debug(
'%s: IP address changed (%s -> %s)', this.name,
oldAddress, this.address
)
/** Emitted when the zone player has a new IP address.
* @event ZpClient#addressChanged
* @param {string} oldAddress - The old
* {@link ZpClient#address address} value.
*/
this.emit('addressChanged', oldAddress)
}
async #updateLastSeen () {
if (this._reSubscribing) {
return
}
this._reSubscribing = true
this._props.lastSeen = new Date()
if (this._checksubscriptions) {
for (const url in this._subscriptions) {
if (this._subscriptions[url].sid == null) {
try {
await this.subscribe(url)
} catch (error) { this.error(error) }
}
}
}
this.emit('lastSeenUpdated')
this._reSubscribing = false
}
/** Handle an mDNS or UPnP message that a zone player is alive.
*
* - Update {@link ZpClient#lastSeen lastSeen}.
* - Update {@link ZpClient#address address} when the zone player's IP
* address has changed.
* - Call {@link ZpClient#init init()} when {@link ZpClient#bootSeq bootSeq}
* has changed.
* @param {object} message - The message.
* @param {string} message.id - The zone player ID.
* @param {string} message.address - The zone player IP address.
* @param {string} message.household - The zone player household.
* @param {interget} message.bootseq - The zone player boot sequence.
*/
async handleAliveMessage (message) {
if (this._props.id != null && message.id !== this._props.id) {
return
}
if (this._props.household !== message.household) {
this._props.household = message.household
}
this.#checkAddress(message.address)
await this._checkBootSeq(message.bootseq)
await this.#updateLastSeen()
}
// ***** Control *************************************************************
// AlarmClock
/** Issue `ListAlarms` action to `AlarmClock` service.
* @return {object[]} - A list of alarm objects.
*/
async listAlarms () {
return this.post('ZonePlayer', 'AlarmClock', 'ListAlarms', {})
}
/** Issue `UpdateAlarm` action to `AlarmClock` service.
* @param {object} alarm - The alarm parameters.
*/
async updateAlarm (alarm) {
return this.post('ZonePlayer', 'AlarmClock', 'UpdateAlarm', {
ID: alarm.id,
StartLocalTime: alarm.startTime,
Duration: alarm.duration,
Recurrence: alarm.recurrence,
Enabled: alarm.enabled,
RoomUUID: alarm.roomUuid,
ProgramURI: he.escape(alarm.programUri),
ProgramMetaData: ZpClient.meta(alarm.programMetaData),
PlayMode: alarm.playMode,
Volume: alarm.volume,
IncludeLinkedZones: alarm.includeLinkedZones
// Note that when set from the Sonos iOS app, the alarm includes a
// <Content type="..." .../> tag. This tag is lost when updating the
// alarm here, but also when updating from the Sonos macOS app.
})
}
// DeviceProperties
/** Get the zone player button lock state.
* @return {boolean} - True off zone player buttons are locked.
*/
async getButtonLockState () {
return (await this.post(
'ZonePlayer', 'DeviceProperties', 'GetButtonLockState', {}
)).currentButtonLockState === 'On'
}
/** Set the zone player button lock state.
* @param {boolean} state - True to lock the buttons, false to unlock them.
* @return {boolean} - True off zone player buttons are now locked.
*/
async setButtonLockState (state) {
return this.post('ZonePlayer', 'DeviceProperties', 'SetButtonLockState', {
DesiredButtonLockState: state ? 'On' : 'Off'
})
}
async getHouseholdId () {
return (await this.post(
'ZonePlayer', 'DeviceProperties', 'GetHouseholdID', {}
)).currentHouseholdId
}
/** Get the zone player LED state.
* @return {boolean} - True iff zone player LED is on.
*/
async getLedState () {
return (await this.post(
'ZonePlayer', 'DeviceProperties', 'GetLEDState', {}
)).currentLedState === 'On'
}
/** Set the zone player LED state.
* @param {boolean} state - True to turn the LED on, false to turn it off.
* @return {boolean} - True iff zone player LED is now on.
*/
async setLedState (state) {
return this.post('ZonePlayer', 'DeviceProperties', 'SetLEDState', {
DesiredLEDState: state ? 'On' : 'Off'
})
}
async getZoneAttributes () {
return this.post('ZonePlayer', 'DeviceProperties', 'GetZoneAttributes', {})
}
async getZoneInfo () {
return this.post('ZonePlayer', 'DeviceProperties', 'GetZoneInfo', {})
}
// ZoneGroupTopology
async getZoneGroupAttributes () {
return this.post('ZonePlayer', 'ZoneGroupTopology', 'GetZoneGroupAttributes', {})
}
async getZoneGroupState () {
return this.post('ZonePlayer', 'ZoneGroupTopology', 'GetZoneGroupState', {})
}
// MediaRenderer AVTransport
async play () {
return this.post('MediaRenderer', 'AVTransport', 'Play', {
InstanceID: 0,
Speed: 1
})
}
async pause () {
return this.post('MediaRenderer', 'AVTransport', 'Pause', {
InstanceID: 0
})
}
async stop () {
return this.post('MediaRenderer', 'AVTransport', 'Stop', {
InstanceID: 0
})
}
async next () {
return this.post('MediaRenderer', 'AVTransport', 'Next', {
InstanceID: 0
})
}
async previous () {
return this.post('MediaRenderer', 'AVTransport', 'Previous', {
InstanceID: 0
})
}
async getCrossfadeMode () {
return (await this.post('MediaRenderer', 'AVTransport', 'GetCrossfadeMode', {
InstanceID: 0
})).crossfadeMode === 1
}
async setCrossfadeMode (mode) {
return this.post('MediaRenderer', 'AVTransport', 'SetCrossfadeMode', {
InstanceID: 0,
CrossfadeMode: mode ? 1 : 0
})
}
async _getPlayMode () {
return (await this.post('MediaRenderer', 'AVTransport', 'GetTransportSettings', {
InstanceID: 0
})).playMode
}
async _setPlayMode (repeat, shuffle) {
let playMode
if (repeat === 'on') {
playMode = shuffle ? 'SHUFFLE' : 'REPEAT_ALL'
} else if (repeat === '1') {
playMode = shuffle ? 'SHUFFLE_REPEAT_ONE' : 'REPEAT_ONE'
} else /* if (repeat === 'off') */ {
playMode = shuffle ? 'SHUFFLE_NOREPEAT' : 'NORMAL'
}
return this.post('MediaRenderer', 'AVTransport', 'SetPlayMode', {
InstanceID: 0,
NewPlayMode: playMode
})
}
async getRepeat () {
const playMode = await this._getPlayMode()
if (playMode === 'REPEAT_ALL' || playMode === 'SHUFFLE') {
return 'on'
} else if (playMode === 'REPEAT_ONE' || playMode === 'SHUFFLE_REPEAT_ONE') {
return '1'
} else /* if (playMode === 'NORMAL' || playMode === 'SHUFFLE_NOREPEAT') */ {
return 'off'
}
}
async getShuffle () {
return ['SHUFFLE_NOREPEAT', 'SHUFFLE_REPEAT_ONE', 'SHUFFLE']
.includes(await this._getPlayMode())
}
async setRepeat (repeat) {
return this._setPlayMode(repeat, await this.getShuffle())
}
async setShuffle (shuffle) {
return this._setPlayMode(await this.getRepeat(), shuffle)
}
async setAvTransportUri (uri, metaData = '') {
return this.post('MediaRenderer', 'AVTransport', 'SetAVTransportURI', {
InstanceID: 0,
CurrentURI: uri,
CurrentURIMetaData: metaData
})
}
async setAvTransportAirPlay () {
// TODO test
return this.setAvTransportUri('x-sonosapi-vli:' + this.id)
}
async setAvTransportAudioIn (id = this.id) {
return this.setAvTransportUri('x-rincon-stream:' + id)
}
async setAvTransportGroup (id) {
return this.setAvTransportUri('x-rincon:' + id)
}
async setAvTransportTvIn () {
return this.setAvTransportUri('x-sonos-htastream:' + this.id + ':spdif')
}
async setAvTransportQueue (uri, metaData = '') {
await this.post('MediaRenderer', 'AVTransport', 'RemoveAllTracksFromQueue', {
InstanceID: 0
})
await this.post('MediaRenderer', 'AVTransport', 'AddURIToQueue', {
InstanceID: 0,
EnqueuedURI: uri,
EnqueuedURIMetaData: metaData,
DesiredFirstTrackNumberEnqueued: 1,
EnqueueAsNext: 1
})
return this.setAvTransportUri('x-rincon-queue:' + this.id + '#0')
}
static meta (metaData, albumArtUri, description) {
if (metaData == null || metaData === '') {
return ''
}
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/">'
meta += `<item id="${metaData.id}" parentID="${metaData.parentId}" restricted="${metaData.restricted}">`
meta += `<dc:title>${metaData.title}</dc:title>`
meta += `<upnp:class>${metaData.class}</upnp:class>`
if (albumArtUri != null) {
for (const uri of albumArtUri) {
meta += `<upnp:albumArtURI>${he.escape(uri)}</upnp:albumArtURI>`
}
}
if (description != null) {
meta += `<r:description>${description}</r:description>`
}
meta += `<desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">${metaData.desc._}</desc>`
meta += '</item></DIDL-Lite>'
return he.escape(meta)
}
async becomeCoordinatorOfStandaloneGroup () {
return this.post('MediaRenderer', 'AVTransport', 'BecomeCoordinatorOfStandaloneGroup', {
InstanceID: 0
})
}
async delegateGroupCoordinationTo (id) {
return this.post('MediaRenderer', 'AVTransport', 'DelegateGroupCoordinationTo', {
InstanceID: 0,
NewCoordinator: id,
RejoinGroup: true
})
}
async getSleepTimer () {
return (await this.post('MediaRenderer', 'AVTransport', 'GetRemainingSleepTimerDuration', {
InstanceID: 0
})).remainingSleepTimerDuration
}
async setSleepTimer (value) {
return this.post('MediaRenderer', 'AVTransport', 'ConfigureSleepTimer', {
InstanceID: 0,
NewSleepTimerDuration: value
})
}
// MediaRenderer GroupRenderingControl
async getGroupVolume () {
return (await this.post('MediaRenderer', 'GroupRenderingControl', 'GetGroupVolume', {
InstanceID: 0
})).currentVolume
}
async setGroupVolume (volume) {
return this.post('MediaRenderer', 'GroupRenderingControl', 'SetGroupVolume', {
InstanceID: 0,
DesiredVolume: volume
})
}
async setRelativeGroupVolume (volume) {
return (await this.post('MediaRenderer', 'GroupRenderingControl', 'SetRelativeGroupVolume', {
InstanceID: 0,
Adjustment: volume
})).newVolume
}
async getGroupMute () {
return (await this.post('MediaRenderer', 'GroupRenderingControl', 'GetGroupMute', {
InstanceID: 0
})).currentMute === 1
}
async setGroupMute (mute) {
return this.post('MediaRenderer', 'GroupRenderingControl', 'SetGroupMute', {
InstanceID: 0,
DesiredMute: mute ? 1 : 0
})
}
// MediaRenderer RenderingControl
async getVolume (channel = 'Master') {
return (await this.post('MediaRenderer', 'RenderingControl', 'GetVolume', {
InstanceID: 0,
Channel: channel
})).currentVolume
}
async setVolume (volume, channel = 'Master') {
return this.post('MediaRenderer', 'RenderingControl', 'SetVolume', {
InstanceID: 0,
Channel: channel,
DesiredVolume: volume
})
}
async setRelativeVolume (volume, channel = 'Master') {
return (await this.post('MediaRenderer', 'RenderingControl', 'SetRelativeVolume', {
InstanceID: 0,
Channel: channel,
Adjustment: volume
})).newVolume
}
async getMute (channel = 'Master') {
return (await this.post('MediaRenderer', 'RenderingControl', 'GetMute', {
InstanceID: 0,
Channel: channel
})).currentMute === 1
}
async setMute (mute, channel = 'Master') {
return this.post('MediaRenderer', 'RenderingControl', 'SetMute', {
InstanceID: 0,
Channel: channel,
DesiredMute: mute ? 1 : 0
})
}
async getBass () {
return (await this.post('MediaRenderer', 'RenderingControl', 'GetBass', {
InstanceID: 0
})).currentBass
}
async setBass (level) {
return this.post('MediaRenderer', 'RenderingControl', 'SetBass', {
InstanceID: 0,
DesiredBass: level
})
}
async getTreble () {
return (await this.post('MediaRenderer', 'RenderingControl', 'GetTreble', {
InstanceID: 0
})).currentTreble
}
async setTreble (level) {
return this.post('MediaRenderer', 'RenderingControl', 'SetTreble', {
InstanceID: 0,
DesiredTreble: level
})
}
async getBalance () {
return (await this.getVolume('RF')) - (await this.getVolume('LF'))
}
async setBalance (balance) {
await this.setVolume(100, balance < 0 ? 'LF' : 'RF')
return this.setVolume(
balance < 0 ? 100 - -balance : 100 - balance, balance < 0 ? 'RF' : 'LF'
)
}
async getLoudness (channel = 'Master') {
return (await this.post('MediaRenderer', 'RenderingControl', 'GetLoudness', {
InstanceID: 0,
Channel: channel
})).currentLoudness === 1
}
async setLoudness (loudness, channel = 'Master') {
return this.post('MediaRenderer', 'RenderingControl', 'SetLoudness', {
InstanceID: 0,
Channel: channel,
DesiredLoudness: loudness ? 1 : 0
})
}
async getEq (type) {
return (await this.post('MediaRenderer', 'RenderingControl', 'GetEQ', {
InstanceID: 0,
EQType: type
})).currentValue
}
async setEq (type, value) {
return this.post('MediaRenderer', 'RenderingControl', 'SetEQ', {
InstanceID: 0,
EQType: type,
DesiredValue: value
})
}
async getNightSound () { return (await this.getEq('NightMode')) === 1 }
async setNightSound (value) { return this.setEq('NightMode', value ? 1 : 0) }
async getSpeechEnhancement () { return (await this.getEq('DialogLevel')) === 1 }
async setSpeechEnhancement (value) { return this.setEq('DialogLevel', value ? 1 : 0) }
async getSurroundEnable () { return (await this.getEq('SurroundEnable')) === 1 }
async setSurroundEnable (value) { return this.setEq('SurroundEnable', value ? 1 : 0) }
async getTvLevel () { return this.getEq('SurroundLevel') }
async setTvLevel (value) { return this.setEq('SurroundLevel', value) }
async getMusicLevel () { return this.getEq('MusicSurroundLevel') }
async setMusicLevel (value) { return this.setEq('MusicSurroundLevel', value) }
async getMusicPlaybackFull () { return (await this.getEq('SurroundMode')) === 1 }
async setMusicPlaybackFull (value) { return this.setEq('SurroundMode', value ? 1 : 0) }
async getHeightLevel () { return this.getEq('HeightChannelLevel') }
async setHeightLevel (value) { return this.setEq('HeightChannelLevel', value) }
async getSubEnable () { return (await this.getEq('SubEnable')) === 1 }
async setSubEnable (value) { return this.setEq('SubEnable', value ? 1 : 0) }
async getSubLevel () { return this.getEq('SubGain') }
async setSubLevel (value) { return this.setEq('SubGain', value) }
// MediaServer ContentDirectory
async browse (object = 'FV:2', startingIndex = 0) {
let result = await this.post('MediaServer', 'ContentDirectory', 'Browse', {
ObjectID: object,
BrowseFlag: 'BrowseDirectChildren',
Filter: 'dc:title,res,dc:creator,upnp:artist,upnp:album,upnp:albumArtURI',
StartingIndex: startingIndex,
RequestedCount: 0,
SortCriteria: ''
})
if (result.result != null) {
result = result.result
}
let container
if (result.container != null) {
container = true
result = result.container
}
if (!Array.isArray(result)) {
if (Object.keys(result).length > 0) {
result = [result]
} else {
result = []
}
}
const obj = {}
result.forEach((element) => {
obj[element.title] = {}
if (container) {
obj[element.title].browse = element.id