UNPKG

hb-zp-tools

Version:
1,262 lines (1,039 loc) 35 kB
// hb-zp-tools/lib/ZpTool.js // Copyright © 2019-2025 Erik Baauw. All rights reserved. // // Command line interface to Sonos ZonePlayer. import { CommandLineParser } from 'hb-lib-tools/CommandLineParser' import { CommandLineTool } from 'hb-lib-tools/CommandLineTool' import { JsonFormatter } from 'hb-lib-tools/JsonFormatter' import { OptionParser } from 'hb-lib-tools/OptionParser' import { ZpClient } from './ZpClient.js' import { ZpListener } from './ZpListener.js' const { b, u } = CommandLineTool const { UsageError } = CommandLineParser const usage = { zp: `${b('zp')} [${b('-hVD')}] [${b('-H')} ${u('hostname')}[${b(':')}${u('port')}]] [${b('-t')} ${u('timeout')}] ${u('command')} [${u('argument')} ...]`, info: `${b('info')} [${b('-hnv')}]`, description: `${b('description')} [${b('-hnSs')}]`, topology: `${b('topology')} [${b('-hn')}] [${b('-pv')}|${b('-r')}]`, eventlog: `${b('eventlog')} [${b('-hnst')}]`, browse: `${b('browse')} [${b('-hn')}] [${u('object')}]`, play: `${b('play')} [${b('-h')}] [${u('uri')} [${u('meta')}]]`, queue: `${b('queue')} [${b('-h')}] ${u('uri')} [${u('meta')}]`, pause: `${b('pause')} [${b('-h')}]`, stop: `${b('stop')} [${b('-h')}]`, next: `${b('next')} [${b('-h')}]`, previous: `${b('previous')} [${b('-h')}]`, crossfade: `${b('crossfade')} [${b('-h')}] [${b('on')}|${b('off')}]`, repeat: `${b('repeat')} [${b('-h')}] [${b('on')}|${b('1')}|${b('off')}]`, shuffle: `${b('shuffle')} [${b('-h')}] [${b('on')}|${b('off')}]`, sleepTimer: `${b('sleepTimer')} [${b('-h')}] [${u('time')}|${b('off')}]`, groupVolume: `${b('groupVolume')} [${b('-h')}] [${u('volume')}]`, groupMute: `${b('groupMute')} [${b('-h')}] [${b('on')}|${b('off')}]`, join: `${b('join')} [${b('-h')}] ${u('zone')}`, leave: `${b('leave')} [${b('-h')}]`, volume: `${b('volume')} [${b('-h')}] [${u('volume')}]`, mute: `${b('mute')} [${b('-h')}] [${b('on')}|${b('off')}]`, bass: `${b('bass')} [${b('-h')}] [${b('--')}] [${u('bass')}]`, treble: `${b('treble')} [${b('-h')}] [${b('--')}] [${u('treble')}]`, loudness: `${b('loudness')} [${b('-h')}] [${b('on')}|${b('off')}]`, balance: `${b('balance')} [${b('-h')}] [${b('--')}] [${u('balance')}]`, nightSound: `${b('nightSound')} [${b('-h')}] [${b('on')}|${b('off')}]`, speechEnhancement: `${b('speechEnhancement')} [${b('-h')}] [${b('on')}|${b('off')}]`, surroundEnable: `${b('surroundEnable')} [${b('-h')}] [${b('on')}|${b('off')}]`, tvLevel: `${b('tvLevel')} [${b('-h')}] [${b('--')}] [${u('level')}]`, musicLevel: `${b('musicLevel')} [${b('-h')}] [${b('--')}] [${u('level')}]`, musicPlaybackFull: `${b('musicPlaybackFull')} [${b('-h')}] [${b('on')}|${b('off')}]`, heightLevel: `${b('heightLevel')} [${b('-h')}] [${b('--')}] [${u('level')}]`, subEnable: `${b('subEnable')} [${b('-h')}] [${b('on')}|${b('off')}]`, subLevel: `${b('subLevel')} [${b('-h')}] [${b('--')}] [${u('level')}]`, led: `${b('led')} [${b('-h')}] [${b('on')}|${b('off')}]`, buttonLock: `${b('buttonLock')} [${b('-h')}] [${b('on')}|${b('off')}]` } const description = { zp: 'Command line interface to Sonos ZonePlayer.', info: 'Print zone player properties.', description: 'Print zone player device description.', topology: 'Print zones and zone players known by the zone player.', eventlog: 'Log zone player events.', browse: 'Browse media.', play: 'Play.', queue: 'Queue.', pause: 'Pause.', stop: 'Stop.', next: 'Go to next track.', previous: 'Go to previous track.', crossfade: 'Get/set/clear crossfade.', repeat: 'Get/set repeat.', shuffle: 'Get/set/clear shuffle.', sleepTimer: 'Get/set/clear sleep timer.', groupVolume: 'Get/set group volume.', groupMute: 'Get/set/clear group mute.', join: 'Join ZoneGroup.', leave: 'Leave ZoneGroup.', volume: 'Get/set volume.', mute: 'Get/set/clear mute.', bass: 'Get/set bass.', treble: 'Get/set treble.', loudness: 'Get/set/clear loudness.', balance: 'Get/set balance.', nightSound: 'Get/set/clear nightsound.', speechEnhancement: 'Get/set/clear speech enhancement.', surroundEnable: 'Get/set/clear surround enabled state.', tvLevel: 'Get/set TV surround level.', musicLevel: 'Get/set music surround level.', musicPlaybackFull: 'Get/set/clear full music playback.', heightLevel: 'Get/set height channel level.', subEnable: 'Get/set/clear Sub enabled state.', subLevel: 'Get/set Sub level.', led: 'Get/set/clear LED state.', buttonLock: 'Get/set/clear button lock state.' } const help = { zp: `${description.zp} Usage: ${usage.zp} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('-V')}, ${b('--version')} Print version and exit. ${b('-D')}, ${b('--debug')} Print debug messages. ${b('-H')} ${u('hostname')}[${b(':')}${u('port')}], ${b('--host=')}${u('hostname')}[${b(':')}${u('port')}] Connect to ZonePlayer at ${u('hostname')}${b(':1400')} or ${u('hostname')}${b(':')}${u('port')}. Default ZonePlayer can be set in the ${b('ZP_HOST')} environment variable. ${b('-t')} ${u('timeout')}, ${b('--timeout=')}${u('timeout')} Set timeout to ${u('timeout')} seconds instead of default ${b('5')}. Commands: ${usage.info} ${description.info} ${usage.description} ${description.description} ${usage.topology} ${description.topology} ${usage.eventlog} ${description.eventlog} ${usage.browse} ${description.browse} ${usage.play} ${description.play} ${usage.queue} ${description.queue} ${usage.pause} ${description.pause} ${usage.stop} ${description.stop} ${usage.next} ${description.next} ${usage.previous} ${description.previous} ${usage.sleepTimer} ${description.sleepTimer} ${usage.groupVolume} ${description.groupVolume} ${usage.groupMute} ${description.groupMute} ${usage.join} ${description.join} ${usage.leave} ${description.leave} ${usage.volume} ${description.volume} ${usage.mute} ${description.mute} ${usage.bass} ${description.bass} ${usage.treble} ${description.treble} ${usage.loudness} ${description.loudness} ${usage.balance} ${description.balance} ${usage.nightSound} ${description.nightSound} ${usage.speechEnhancement} ${description.speechEnhancement} ${usage.surroundEnable} ${description.surroundEnable} ${usage.tvLevel} ${description.tvLevel} ${usage.musicLevel} ${description.musicLevel} ${usage.musicPlaybackFull} ${description.musicPlaybackFull} ${usage.heightLevel} ${description.heightLevel} ${usage.subEnable} ${description.subEnable} ${usage.subLevel} ${description.subLevel} ${usage.led} ${description.led} ${usage.buttonLock} ${description.buttonLock} For more help, issue: ${b('zp')} ${u('command')} ${b('-h')}`, info: `${description.info} Usage: ${b('zp')} ${usage.info} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('-n')}, ${b('--noWhiteSpace')} Do not include spaces nor newlines in JSON output. ${b('-v')}. ${b('--verbose')} Verbose. Include topology info.`, description: `${description.description} Usage: ${b('zp')} ${usage.description} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('-n')}, ${b('--noWhiteSpace')} Do not include spaces nor newlines in JSON output. ${b('-S')}, ${b('--scpd')} Include service control point definitions. ${b('-s')}, ${b('--sortKeys')} Sort JSON object key/value pairs alphabetically on key.`, topology: `${description.topology} Usage: ${b('zp')} ${usage.topology} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('-n')}, ${b('--noWhiteSpace')} Do not include spaces nor newlines in JSON output. ${b('-p')}, ${b('--playersOnly')} Do not include zones in the output. ${b('-r')}, ${b('--raw')} Output the raw zone group state. ${b('-v')}, ${b('--verify')} Verify that each zone player can be reached. Include the device description information for reachable zone players.`, eventlog: `${description.eventlog} Usage: ${b('zp')} ${usage.eventlog} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('-n')}, ${b('--noWhiteSpace')} Do not include spaces nor newlines in JSON output. ${b('-s')}, ${b('--service')} Do not output timestamps (useful when running as service). ${b('-t')}, ${b('--topology')} Show only parsed ZoneGroupTopology events, to monitor topology changes.`, browse: `${description.browse} Returns a list of media items with ${u('object')} for browsing or ${u('uri')} for playing. Usage: ${b('zp')} ${usage.browse} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('-n')}, ${b('--noWhiteSpace')} Do not include spaces nor newlines in JSON output. ${u('object')} Browse ${u('object')} instead of default (top level). Use ${b('zp browse')} to obtain the value for ${u('object')}.`, play: `${description.play} Usage: ${b('zp')} ${usage.play} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('uri')} Set source to ${u('uri')}. Use ${b('zp browse')} to obtain the value for ${u('uri')}. ${u('meta')} Set meta data for source to ${u('meta')}. Use ${b('zp browse')} to obtain the value for ${u('meta')}.`, queue: `${description.queue} Usage: ${b('zp')} ${usage.queue} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('uri')} Set source to ${u('uri')}. Use ${b('zp browse')} to obtain the value for ${u('uri')}. ${u('meta')} Set meta data for source to ${u('meta')}. Use ${b('zp browse')} to obtain the value for ${u('meta')}.`, pause: `${description.pause} Usage: ${b('zp')} ${usage.pause} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit.`, stop: `${description.stop} Usage: ${b('zp')} ${usage.stop} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit.`, next: `${description.next} Usage: ${b('zp')} ${usage.stop} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit.`, previous: `${description.previous} Usage: ${b('zp')} ${usage.previous} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit.`, crossfade: `${description.crossfade} Usage: ${b('zp')} ${usage.crossfade} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set crossfade. ${b('off')} Clear crossfade.`, repeat: `${description.repeat} Usage: ${b('zp')} ${usage.repeat} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Repeat all tracks. ${b('1')} Repeat current track. ${b('off')} Clear repeat.`, shuffle: `${description.shuffle} Usage: ${b('zp')} ${usage.shuffle} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set shuffle. ${b('off')} Clear shuffle.`, sleepTimer: `${description.sleepTimer} Usage: ${b('zp')} ${usage.sleepTimer} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('time')} Set sleep timer to ${u('time')} (from ${b('00:00:00')} to ${b('23:59:59')}). ${b('off')} Clear sleep timer.`, groupVolume: `${description.groupVolume} Usage: ${b('zp')} ${usage.groupVolume} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('volume')} Set group volume to ${u('volume')} (from 0 to 100).`, groupMute: `${description.groupMute} Usage: ${b('zp')} ${usage.groupMute} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set group mute. ${b('off')} Clear group mute.`, join: `${description.join} Usage: ${b('zp')} ${usage.join} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('zone')} Join ${u('zone')}'s group.`, leave: `${description.leave} Usage: ${b('zp')} ${usage.leave} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit.`, volume: `${description.volume} Usage: ${b('zp')} ${usage.volume} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('volume')} Set volume to ${u('volume')} (from 0 to 100).`, mute: `${description.mute} Usage: ${b('zp')} ${usage.mute} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set mute. ${b('off')} Clear mute.`, bass: `${description.bass} Usage: ${b('zp')} ${usage.bass} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('bass')} Set bass to ${u('bass')} (from -10 to 10).`, treble: `${description.treble} Usage: ${b('zp')} ${usage.treble} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('treble')} Set treble to ${u('treble')} (from -10 to 10).`, loudness: `${description.loudness} Usage: ${b('zp')} ${usage.loudness} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set loudness. ${b('off')} Clear loudness.`, balance: `${description.balance} Usage: ${b('zp')} ${usage.balance} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('balance')} Set balance to ${u('balance')} (from -100 to 100).`, nightSound: `${description.nightSound} Usage: ${b('zp')} ${usage.nightSound} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set night sound. ${b('off')} Clear night sound.`, speechEnhancement: `${description.speechEnhancement} Usage: ${b('zp')} ${usage.speechEnhancement} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set speech enhancement. ${b('off')} Clear speech enhancement.`, surroundEnable: `${description.surroundEnable} Usage: ${b('zp')} ${usage.surroundEnable} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Enable surround speakers. ${b('off')} Disable surround speakers.`, tvLevel: `${description.tvLevel} Usage: ${b('zp')} ${usage.tvLevel} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('level')} Set TV surround level to ${u('level')} (from -15 to 15).`, musicLevel: `${description.musicLevel} Usage: ${b('zp')} ${usage.musicLevel} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('level')} Set music surround level to ${u('level')} (from -15 to 15).`, musicPlaybackFull: `${description.musicPlaybackFull} Usage: ${b('zp')} ${usage.musicPlaybackFull} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set music playback to full. ${b('off')} Set music playback to ambient.`, heightLevel: `${description.heightLevel} Usage: ${b('zp')} ${usage.heightLevel} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('level')} Set height channel level to ${u('level')} (from -10 to 10).`, subEnable: `${description.subEnable} Usage: ${b('zp')} ${usage.subEnable} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Enable Sub. ${b('off')} Disable Sub.`, subLevel: `${description.subLevel} Usage: ${b('zp')} ${usage.subLevel} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${u('level')} Set Sub level to ${u('level')} (from -15 to 15).`, led: `${description.led} Usage: ${b('zp')} ${usage.led} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set ZonePlayer LED on. ${b('off')} Set ZonePlayer LED off.`, buttonLock: `${description.buttonLock} Usage: ${b('zp')} ${usage.buttonLock} Parameters: ${b('-h')}, ${b('--help')} Print this help and exit. ${b('on')} Set ZonePlayer button lock state (i.e. disable ZonePlayer buttons). ${b('off')} Clear ZonePlayer button lock state (i.e. enable ZonePlayer buttons).` } const unsupportedServices = [ 'ConnectionManager', // No useful information. 'MusicServices', // Not supported by homebridge-zp. 'QPlay', // Doesn't support SUBSCRIBE. 'Queue' // Not supported by homebridge-zp. ] class ZpTool extends CommandLineTool { constructor (pkgJson) { super() this.pkgJson = pkgJson this.usage = usage.zp this.clients = [] } createZpClient (options) { const zpClient = new ZpClient(options) zpClient .on('error', (error) => { if (error.request == null) { this.warn(error) return } if (error.request.id !== this.requestId) { if (error.request.body == null) { this.log( '%s: request %d: %s %s', error.request.name, error.request.id, error.request.method, error.request.resource ) } else { this.log( '%s: request %d: %s %s', error.request.name, error.request.id, error.request.method, error.request.resource, error.request.action ) } this.requestId = error.request.id } this.warn( '%s: request %d: %s', error.request.name, error.request.id, error ) }) // TODO: never called, since no UPnP listener is active .on('rebooted', (oldBootSeq) => { this.debug( '%s: rebooted (%d -> %d)', zpClient.name, oldBootSeq, zpClient.bootSeq ) }) .on('addressChanged', (oldAddress) => { this.debug( '%s: IP address changed (%s -> %s)', zpClient.name, oldAddress, zpClient.address ) }) .on('request', (request) => { this.debug( '%s: request %s: %s %s%s', request.name, request.id, request.method, request.resource, request.action == null ? '' : ' ' + request.action ) if (request.parsedBody != null) { this.vdebug( '%s: request %s: %s %s %j', request.name, request.id, request.method, request.url, request.parsedBody ) this.vvdebug( '%s: request %s: %s %s (headers: %j) %j', request.name, request.id, request.method, request.url, request.headers, request.body ) } else { this.vdebug( '%s: request %s: %s %s', request.name, request.id, request.method, request.url ) this.vvdebug( '%s: request %s: %s %s (headers: %j)', request.name, request.id, request.method, request.url, request.headers ) } }) .on('response', (response) => { this.debug( '%s: request %d: %d %s', response.request.name, response.request.id, response.statusCode, response.statusMessage ) if (response.parsedBody != null) { this.vvdebug( '%s: request %d: response (headers: %j): %j', response.request.name, response.request.id, response.headers, response.body ) this.vdebug( '%s: request %d: response: %j', response.request.name, response.request.id, response.parsedBody ) } }) .on('message', (message) => { const notify = message.device === 'ZonePlayer' ? message.service : message.device + '/' + message.service this.vvdebug( '%s: notify %s/Event: %s', this._clargs.options.host, notify, message.body ) this.vdebug( '%s: notify %s/Event: %j', this._clargs.options.host, notify, message.parsedBody ) this.debug('%s: notify %s/Event', this._clargs.options.host, notify) }) return zpClient } async main () { try { this._clargs = this.parseArguments() if (this._clargs.options.host == null || this._clargs.options.host === '') { throw new UsageError(`Missing host. Set ${b('ZP_HOST')} or specify ${b('-H')}.`) } this.zpListener = new ZpListener() this.zpListener .on('listening', (url) => { this.debug('listening on %s', url) }) .on('close', (url) => { this.debug('closed %s', url) }) .on('error', (error) => { this.warn(error) }) this._clargs.options.listener = this.zpListener this.zpClient = await this.createZpClient(this._clargs.options) await this.zpClient.init() this.debug( '%s: reached using local address %s', this._clargs.options.host, this.zpClient.localAddress ) this.name = 'zp ' + this._clargs.command this.usage = `${b('zp')} ${usage[this._clargs.command]}` this.help = help[this._clargs.command] await this[this._clargs.command](this._clargs.args) } catch (error) { this.error(error) } } parseArguments () { const parser = new CommandLineParser(this.pkgJson) const clargs = { options: { host: process.env.ZP_HOST, timeout: 5 } } parser .help('h', 'help', help.zp) .version('V', 'version') .flag('D', 'debug', () => { if (this.vdebugEnabled) { this.setOptions({ vvdebug: true }) } else if (this.debugEnabled) { this.setOptions({ vdebug: true }) } else { this.setOptions({ debug: true, chalk: true }) } }) .option('H', 'host', (value) => { OptionParser.toHost('host', value, false, true) clargs.options.host = value }) .option('t', 'timeout', (value) => { clargs.options.timeout = OptionParser.toInt( 'timeout', value, 1, 60, true ) }) .parameter('command', (value) => { if (usage[value] == null || typeof this[value] !== 'function') { throw new UsageError(`${value}: unknown command`) } clargs.command = value }) .remaining((list) => { clargs.args = list }) .parse() return clargs } async destroy () { await this.zpClient.close() } async info (...args) { const parser = new CommandLineParser(this.pkgJson) const clargs = { options: { sortKeys: true } } parser.help('h', 'help', this.help) parser.flag('n', 'noWhiteSpace', () => { clargs.options.noWhiteSpace = true }) parser.flag('v', 'verbose', () => { clargs.verbose = true }) parser.parse(...args) const jsonFormatter = new JsonFormatter(clargs.options) if (clargs.verbose) { await this.zpClient.initTopology() } this.print(jsonFormatter.stringify(this.zpClient.info)) } async description (...args) { const parser = new CommandLineParser(this.pkgJson) const clargs = { options: {} } parser.help('h', 'help', this.help) parser.flag('n', 'noWhiteSpace', () => { clargs.options.noWhiteSpace = true }) parser.flag('S', 'scpd', () => { clargs.scpd = true }) parser.flag('s', 'sortKeys', () => { clargs.options.sortKeys = true }) parser.parse(...args) const jsonFormatter = new JsonFormatter(clargs.options) const response = this.zpClient.description if (clargs.scpd) { const devices = [response.device] .concat(response.device.deviceList) for (const device of devices) { for (const service of device.serviceList) { service.scpd = await this.zpClient.get(service.scpdUrl) } } } const json = jsonFormatter.stringify(response) this.print(json) } async _getInfo (zonePlayer) { const zpClient = this.createZpClient({ host: zonePlayer.address, id: zonePlayer.id, timeout: this._clargs.options.timeout }) await zpClient.init() await zpClient.initTopology(this.zpClient) return zpClient.info } async topology (...args) { const parser = new CommandLineParser(this.pkgJson) const clargs = { options: {} } parser.help('h', 'help', this.help) parser.flag('n', 'noWhiteSpace', () => { clargs.options.noWhiteSpace = true }) parser.flag('p', 'playersOnly', () => { clargs.players = true }) parser.flag('r', 'raw', () => { clargs.raw = true }) parser.flag('v', 'verify', () => { clargs.verify = true }) parser.parse(...args) const jsonFormatter = new JsonFormatter(clargs.options) await this.zpClient.initTopology() let result = {} if (clargs.verify) { const zonePlayers = this.zpClient.zonePlayers const jobs = [] for (const id in zonePlayers) { if (id === this.zpClient.id) { result[id] = this.zpClient.info } else { result[id] = undefined const zonePlayer = this.zpClient.zonePlayers[id] if (zonePlayer == null) { delete result[id] this.error('%s: zone player not found', id) continue } jobs.push(this._getInfo(zonePlayer) .then((info) => { result[id] = info }).catch(() => { // delete result[id] }) ) } } for (const job of jobs) { await job } if (!clargs.players) { result = ZpClient.unflatten(result) } } else if (clargs.players) { result = this.zpClient.zonePlayers } else if (clargs.raw) { result = this.zpClient.zoneGroupState } else { result = this.zpClient.zones } const json = jsonFormatter.stringify(result) this.print(json) } async eventlog (...args) { const parser = new CommandLineParser(this.pkgJson) const clargs = { mode: 'daemon', options: {} } parser.help('h', 'help', this.help) parser.flag('n', 'noWhiteSpace', () => { clargs.options.noWhiteSpace = true }) parser.flag('s', 'service', () => { clargs.mode = 'service' }) parser.flag('t', 'topology', () => { clargs.topology = true }) parser.parse(...args) this.setOptions({ mode: clargs.mode }) const jsonFormatter = new JsonFormatter(clargs.options) this.zpClient .on('message', (message) => { if (clargs.topology) { if (message.service === 'ZoneGroupTopology') { this.log( '%s: topology %s', this._clargs.options.host, jsonFormatter.stringify(this.zpClient.zones) ) } } else { this.log( '%s: %s %s event: %s', this._clargs.options.host, message.device, message.service, jsonFormatter.stringify(message.parsedBody) ) } }) if (clargs.topology) { try { await this.zpClient.subscribe('/ZoneGroupTopology/Event') } catch (error) { this.error(error) } } else { const description = this.zpClient.description const deviceList = [description.device].concat(description.device.deviceList) for (const device of deviceList) { for (const service of device.serviceList) { const serviceName = service.serviceId.split(':')[3] if (unsupportedServices.includes(serviceName)) { continue } try { await this.zpClient.subscribe(service.eventSubUrl) } catch (error) { this.error(error) } } } } } async browse (...args) { const clargs = { options: {} } const parser = new CommandLineParser(this.pkgJson) parser.help('h', 'help', this.help) parser.flag('n', 'noWhiteSpace', () => { clargs.options.noWhiteSpace = true }) parser.remaining((list) => { if (list.length > 1) { throw new UsageError('too many arguments') } clargs.object = list[0] }) parser.parse(...args) const jsonFormatter = new JsonFormatter(clargs.options) await this.zpClient.initTopology() let result if (clargs.object == null) { result = { 'Music Library': { browse: 'A:' }, 'Music Library Servers': { browse: 'S:' }, 'Sonos Favorites': { browse: 'FV:' }, 'Sonos Playlists': { browse: 'SQ:' }, 'Sonos Queues': { browse: 'Q:' } } if (this.zpClient.airPlay) { result.AirPlay = { uri: 'x-sonosapi-vli:' + this.zpClient.id } } if (this.zpClient.audioIn) { result['Audio In'] = { uri: 'x-rincon-stream:' + this.zpClient.id } } if (this.zpClient.tvIn) { result.TV = { uri: 'x-sonos-htastream:' + this.zpClient.id + ':spdif' } } } else { result = await this.zpClient.browse(clargs.object) } const json = jsonFormatter.stringify(result) this.print(json) } async play (...args) { let uri let meta const parser = new CommandLineParser(this.pkgJson) parser.help('h', 'help', this.help) parser.remaining((list) => { if (list.length > 2) { throw new UsageError('too many arguments') } uri = list[0] meta = list[1] }) parser.parse(...args) if (uri != null) { await this.zpClient.setAvTransportUri(uri, meta) } await this.zpClient.play() } async queue (...args) { let uri let meta const parser = new CommandLineParser(this.pkgJson) parser.help('h', 'help', this.help) parser.parameter('uri', (value) => { uri = value }) parser.remaining((list) => { if (list.length > 1) { throw new UsageError('too many arguments') } meta = list[0] }) parser.parse(...args) await this.zpClient.setAvTransportQueue(uri, meta) } async pause (...args) { return this.simpleCommand('pause', ...args) } async stop (...args) { return this.simpleCommand('stop', ...args) } async next (...args) { return this.simpleCommand('next', ...args) } async previous (...args) { return this.simpleCommand('previous', ...args) } async crossfade (...args) { return this.onOffCommand('CrossfadeMode', ...args) } async repeat (...args) { const parser = new CommandLineParser(this.pkgJson) let repeat parser.help('h', 'help', this.help) parser.remaining((list) => { if (list.length > 1) { throw new UsageError('too many arguments') } if (list.length === 1) { if (['on', '1', 'off'].includes(list[0])) { repeat = list[0] } else { throw new UsageError(`${list[0]}: invalid repeat value`) } } }) parser.parse(...args) if (repeat != null) { await this.zpClient.setRepeat(repeat) } this.print(await this.zpClient.getRepeat()) } async shuffle (...args) { return this.onOffCommand('Shuffle', ...args) } async sleepTimer (...args) { const parser = new CommandLineParser(this.pkgJson) let duration parser.help('h', 'help', this.help) parser.remaining((list) => { if (list.length > 1) { throw new UsageError('too many arguments') } if (list.length === 1) { if (/^(:?2[0-3]|[01][0-9]):[0-5][0-9]:[0-5][0-9]$/.test(list[0])) { duration = list[0] } else if (list[0] === 'off') { duration = '' } else { throw new UsageError(`${list[0]}: invalid duration`) } } }) parser.parse(...args) if (duration != null) { await this.zpClient.setSleepTimer(duration) } const timer = await this.zpClient.getSleepTimer() this.print(timer === '' ? 'off' : timer) } async groupVolume (...args) { return this.valueCommand('GroupVolume', 0, 100, ...args) } async groupMute (...args) { return this.onOffCommand('GroupMute', ...args) } async join (...args) { let coordinator const parser = new CommandLineParser(this.pkgJson) parser.help('h', 'help', this.help) parser.parameter('zone', (value) => { coordinator = value }) parser.parse(...args) await this.zpClient.initTopology() for (const id in this.zpClient.zones) { if (this.zpClient.zones[id].zoneName === coordinator) { return this.zpClient.setAvTransportUri('x-rincon:' + id) } } throw new Error(`${coordinator}: zone not found`) } async leave (...args) { return this.simpleCommand('becomeCoordinatorOfStandaloneGroup', ...args) } async volume (...args) { return this.valueCommand('Volume', 0, 100, ...args) } async mute (...args) { return this.onOffCommand('Mute', ...args) } async bass (...args) { return this.valueCommand('Bass', -10, 10, ...args) } async treble (...args) { return this.valueCommand('Treble', -10, 10, ...args) } async balance (...args) { return this.valueCommand('Balance', -100, 100, ...args) } async loudness (...args) { return this.onOffCommand('Loudness', ...args) } async nightSound (...args) { return this.onOffCommand('NightSound', ...args) } async speechEnhancement (...args) { return this.onOffCommand('SpeechEnhancement', ...args) } async surroundEnable (...args) { return this.onOffCommand('SurroundEnable', ...args) } async tvLevel (...args) { return this.valueCommand('TvLevel', -15, 15, ...args) } async musicLevel (...args) { return this.valueCommand('MusicLevel', -15, 15, ...args) } async musicPlaybackFull (...args) { return this.onOffCommand('MusicPlaybackFull', ...args) } async heightLevel (...args) { return this.valueCommand('HeightLevel', -10, 10, ...args) } async subEnable (...args) { return this.onOffCommand('SubEnable', ...args) } async subLevel (...args) { return this.valueCommand('SubLevel', -15, 15, ...args) } async led (...args) { return this.onOffCommand('LedState', ...args) } async buttonLock (...args) { return this.onOffCommand('ButtonLockState', ...args) } async simpleCommand (command, ...args) { const parser = new CommandLineParser(this.pkgJson) parser.help('h', 'help', this.help) parser.parse(...args) return this.zpClient[command]() } async valueCommand (command, min, max, ...args) { const parser = new CommandLineParser(this.pkgJson) let value parser.help('h', 'help', this.help) parser.remaining((list) => { if (list.length > 1) { throw new UsageError('too many arguments') } if (list.length === 1) { value = OptionParser.toInt( command.toLowerCase(), list[0], min, max, true ) } }) parser.parse(...args) if (value != null) { await this.zpClient['set' + command](value) } this.print('' + await this.zpClient['get' + command]()) } async onOffCommand (command, ...args) { const parser = new CommandLineParser(this.pkgJson) let value if (args.length > 1) { throw new UsageError('too many arguments') } parser.help('h', 'help', this.help) parser.remaining((list) => { if (list.length > 1) { throw new UsageError('too many arguments') } if (list.length === 1) { value = OptionParser.toBool( command.toLowerCase(), list[0], true ) } }) parser.parse(...args) if (value != null) { await this.zpClient['set' + command](value) } this.print((await this.zpClient['get' + command]()) ? 'on' : 'off') } } export { ZpTool }