UNPKG

node-red-contrib-sonos-plus

Version:

A set of Node-RED nodes to control SONOS player in your local network.

1,113 lines (1,028 loc) 46.3 kB
/** * Collection of * - Node-RED related such as failure, success * - simple HTML commands * - simple SOAP commands based on executeAction and sendSoapToPlayer * - SONOS related helper for parsing xml data * - executeAction, sendSoapToPlayer. * * @module Sonos-Extensions.js * * @author Henning Klages * * @since 2021-03-04 */ 'use strict' const { PACKAGE_PREFIX, REGEX_ANYCHAR } = require('./Globals.js') const { decodeHtmlEntity, getNestedProperty, isTruthy, isTruthyProperty, isTruthyPropertyStringNotEmpty, isTruthyStringNotEmpty, validRegex, validToInteger } = require('./Helper.js') const request = require('axios').default const { XMLParser } = require('fast-xml-parser') const debug = require('debug')(`${PACKAGE_PREFIX}extensions`) module.exports = { NODE_SONOS_ERRORPREFIX: 'upnp: ', // all errors from services _requests NODE_SONOS_UPNP500: 'upnp: statusCode 500 & upnpErrorCode ', // only those with 500 (subset) SOAP_ERRORS: require('./Db-Soap-Errorcodes.json'), MUSIC_SERVICES: require('./Db-MusicServices.json'), // // NODE-RED STATUS & ERROR HANDLING // /** * Validates general group properties msg.playerName, msg.volume, msg.sameVolume, msg.clearQueue * Returns default if is NOT isTruthyProperty (undefined, null, ...) * but throws error if has wrong type or wrong value (such as out of range, regex, ...) * @param {object} msg incoming message * @param {string} [msg.playerName = ''] playerName * @param {string/number} [msg.volume = -1] volume. if not set don't touch original volume. * @param {boolean} [msg.sameVolume = true] sameVolume * @param {boolean} [msg.clearQueue = true] indicator for clear queue * * @returns {promise} object {playerName, volume, sameVolume, flushQueue} * * @throws {error} 'sameVolume (msg.sameVolume) is not boolean', * 'sameVolume (msg.sameVolume) is true but msg.volume is not specified', * 'clearQueue (msg.cleanQueue) is not boolean' * @throws {error} all methods */ validatedGroupProperties: async (msg) => { // playerName const playerName = validRegex(msg, 'playerName', REGEX_ANYCHAR, 'player name', '') // volume const volume = validToInteger(msg, 'volume', 0, 100, 'volume', -1) // sameVolume let sameVolume = true if (isTruthyProperty(msg, ['sameVolume'])) { if (typeof msg.sameVolume !== 'boolean') { throw new Error(`${PACKAGE_PREFIX}: sameVolume (msg.sameVolume) is not boolean`) } if (volume === -1 && msg.sameVolume === true) { throw new Error( `${PACKAGE_PREFIX}: sameVolume (msg.sameVolume) is true but msg.volume is not specified`) } sameVolume = msg.sameVolume } // clearQueue let clearQueue = true if (isTruthyProperty(msg, ['clearQueue'])) { if (typeof msg.clearQueue !== 'boolean') { throw new Error(`${PACKAGE_PREFIX}: clearQueue (msg.cleanQueue) is not boolean`) } clearQueue = msg.clearQueue } return { playerName, volume, sameVolume, clearQueue } }, /** Show any error occurring during processing of messages in the node status * and create node error. * * @param {object} node current node * @param {object} msg current msg * @param {object} done Node-RED function done * @param {object} error standard node.js or created with new Error ('') * @param {string} [functionName] name of calling function * * @throws nothing * * @returns nothing */ failureV2: (node, msg, done, error, functionName) => { // 1. Is the error a standard nodejs error? Indicator: .code exists // nodejs provides an error object with properties: .code, .message .name .stack // See https://nodejs.org/api/errors.html for more about the error object. // .code provides the best information. // See https://nodejs.org/api/errors.html#errors_common_system_errors // // 2. Is the error thrown in node-sonos - service _request? Indicator: // message starts with NODE_SONOS_ERRORPREFIX // The .message then contains either NODE_SONOS_ERRORPREFIX statusCode 500 & upnpErrorCode ' // and the error.response.data or NODE_SONOS_ERRORPREFIX error.message and // error.response.data // // 3. Is the error from this package? Indicator: .message starts with PACKAGE_PREFIX // // 4. All other error throw inside all modules (node-sonos, axio, ...) let msgShort = 'unknown' // default text used for status message let msgDet = 'unknown' // default text for error message in addition to msgShort if (isTruthyPropertyStringNotEmpty(error, ['code'])) { // 1. nodejs errors - convert into readable message if (error.code === 'ECONNREFUSED') { msgShort = 'Player refused to connect' msgDet = 'Validate players ip address' } else if (error.code === 'EHOSTUNREACH') { msgShort = 'Player is unreachable' msgDet = 'Validate players ip address / power on' } else if (error.code === 'ETIMEDOUT') { msgShort = 'Request timed out' msgDet = 'Validate players IP address / power on' } else { // Caution: getOwn is necessary for some error messages eg play mode! msgShort = 'nodejs error - contact developer' msgDet = JSON.stringify(error, Object.getOwnPropertyNames(error)) } } else { // Caution: getOwn is necessary for some error messages eg play mode! if (isTruthyPropertyStringNotEmpty(error, ['message'])) { if (error.message.startsWith(module.exports.NODE_SONOS_ERRORPREFIX)) { // 2. node sonos upnp errors from service _request if (error.message.startsWith(module.exports.NODE_SONOS_UPNP500)) { const uppnText = error.message.substring(module.exports.NODE_SONOS_UPNP500.length) const upnpEc = module.exports.getErrorCodeFromEnvelope(uppnText) msgShort = `statusCode 500 & upnpError ${upnpEc}` msgDet = module.exports.getErrorMessageV1(upnpEc, module.exports.SOAP_ERRORS.UPNP, '') } else { // unlikely as all UPNP errors throw 500 msgShort = 'statusCode NOT 500' msgDet = `upnp envelope: ${error.message}` } } else if (error.message.startsWith(PACKAGE_PREFIX)) { // 3. my thrown errors msgDet = 'none' msgShort = error.message.replace(PACKAGE_PREFIX, '') } else { // Caution: getOwn is necessary for some error messages eg play mode! msgShort = error.message msgDet = JSON.stringify(error, Object.getOwnPropertyNames(error)) } } else { // 4. all the others msgShort = 'Unknown error/ exception -see node.error' msgDet = JSON.stringify(error, Object.getOwnPropertyNames(error)) } } // incompatibility to 0.x done(`${functionName}:${msgShort} :: Details: ${msgDet}`) node.status({ 'fill': 'red', 'shape': 'dot', 'text': `error: ${functionName} - ${msgShort}` }) }, // // SPECIAL COMMANDS - SIMPLE HTML REQUEST // /** Check whether device is a SONOS-Player and online. * @param {object} playerUrlObject player JavaScript build-in URL * @param {number} timeout in milliseconds * * @returns {Promise<boolean>} true if SONOS player response * * Does not validate parameter! * * Method: Every SONOS player will answer to http request with * end point /info and provide the household id. * * @throws none - they are caught insight */ isOnlineSonosPlayer: async (playerUrlObject, timeout) => { debug('method:%s', 'isOnlineSonosPlayer') let response try { response = await request.get(`${playerUrlObject.origin}/info`, { 'timeout': timeout }) } catch (error) { // timeout will show up here debug('Error: SONOS endpoint does not respond >>%s', playerUrlObject.host + '-' + JSON.stringify(error, Object.getOwnPropertyNames(error))) return false } if (!isTruthyPropertyStringNotEmpty(response, ['data', 'householdId'])) { debug('Error: missing householdId >>%s', JSON.stringify(response, Object.getOwnPropertyNames(response))) return false } return true }, /** Get device info and verifies existence of capabilities and id. * @param {object} playerUrlObject player JavaScript build-in URL * @param {number} timeout in milliseconds * * @returns {Promise<object>} device properties as object * * @throws {error} response from player is invalid - data missing|id missing|capabilities missing * @throws {error} all methods especially a timeout */ getDeviceInfo: async (playerUrlObject, timeout) => { debug('method:%s', 'getDeviceInfo') // error is thrown if not status code 200 const response = await request.get(`${playerUrlObject.origin}/info`, { 'timeout': timeout, 'validateStatus': (status) => (status === 200) // Resolve only if the status code is 200 }) if (!isTruthyProperty(response, ['data'])) { throw new Error(`${PACKAGE_PREFIX} response from player is invalid - data missing`) } if (!isTruthyProperty(response, ['data', 'device', 'id'])) { throw new Error(`${PACKAGE_PREFIX} response from player is invalid - id missing`) } if (!isTruthyProperty(response, ['data', 'device', 'capabilities'])) { throw new Error(`${PACKAGE_PREFIX} response from player is invalid - capabilities missing`) } return response.data }, /** Get battery info for new roam device * @param {object} playerUrlObject player JavaScript build-in URL * @param {number} timeout in milliseconds * * @returns {Promise<object>} battery level as integer 0 .. 100 * * @throws {error} response from player is invalid - data missing|id missing|capabilities missing * @throws {error} all methods especially a timeout */ getDeviceBatteryLevel: async (playerUrlObject, timeout) => { debug('method:%s', 'getDeviceBatteryLevel') // error is thrown if not status code 200 const endpoint = '/status/batterystatus' const response = await request({ 'method': 'get', 'baseURL': playerUrlObject.origin, 'url': endpoint, 'headers': { 'Content-type': 'text/xml; charset=utf8', }, 'timeout': timeout, 'validateStatus': (status) => (status === 200) // Resolve only if the status code is 200 }) if (!isTruthyProperty(response, ['data'])) { throw new Error(`${PACKAGE_PREFIX} response from player is invalid - data missing`) } // Test data // eslint-disable-next-line max-len // response.data = '<?xml version="1.0" ?><?xml-stylesheet type="text/xsl" href="/xml/review.xsl"?><ZPSupportInfo><LocalBatteryStatus><Data name="Health">GREEN</Data><Data name="Level">89</Data><Data name="Temperature">NORMAL</Data><Data name="PowerSource">BATTERY</Data></LocalBatteryStatus><!-- SDT: 0 ms --></ZPSupportInfo>' let clean = response.data.replace('<?xml', '<xml') clean = clean.replace('?>', '>') // strange but necessary const parser = new XMLParser({ parseTagValue: false, }) const parsed = await parser.parse(clean) if (!isTruthyProperty(parsed, ['xml', 'ZPSupportInfo', 'LocalBatteryStatus'])) { throw new Error(`${PACKAGE_PREFIX} SONOS player did not provide battery level status!`) } const result = { 'level': Number(parsed.xml.ZPSupportInfo.LocalBatteryStatus.Data[1]), 'powerSource': parsed.xml.ZPSupportInfo.LocalBatteryStatus.Data[3] } return result }, /** Get device properties. * @param {object} playerUrlObject player JavaScript build-in URL * * @returns {Promise<object>} device properties as object * * @throws {error} 'invalid response from player - response', * 'response from player is invalid - data missing', 'xml parser: invalid response', * 'xml parser: invalid response * @throws {error} all methods */ getDeviceProperties: async (playerUrlObject) => { debug('method:%s', 'getDeviceProperties') const endpoint = '/xml/device_description.xml' const response = await request({ 'method': 'get', 'baseURL': playerUrlObject.origin, 'url': endpoint, 'headers': { 'Content-type': 'text/xml; charset=utf8' } }) if (!isTruthy(response)) { throw new Error(`${PACKAGE_PREFIX} invalid response from player - response`) } let properties = {} if (!isTruthyPropertyStringNotEmpty(response, ['data'])) { throw new Error(`${PACKAGE_PREFIX} response from player is invalid - data missing`) } let clean = response.data.replace('<?xml', '<xml') clean = clean.replace('?>', '>') // strange but necessary const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '_', parseTagValue: false, }) properties = await parser.parse(clean) if (!isTruthy) { throw new Error(`${PACKAGE_PREFIX} xml parser: invalid response`) } if (!isTruthyProperty(properties, ['xml', 'root', 'device'])) { throw new Error(`${PACKAGE_PREFIX} xml parser: invalid response`) } return properties.xml.root.device }, // // SIMPLE COMMANDS - EXECUTE ACTION AND SIMPLE TRANSFORMATION // // @param {object} playerUrlObject/coordinatorUrlObject JavaScript build-in URL urlObject // @returns always a promise // @throws {error} from executeAction // .......................................................... // Get media info of given player. // Difference between standard sonos-ts and this implementation // 1. Track is number versus string // 2. CurrentURIMetaData is object versus string <DIDL-lite> // 3. Most likely Next Metadata is also object // 4. undefined instead of '' getMediaInfo: async (coordinatorUrlObject) => { debug('method:%s', 'getMediaInfo') return await module.exports.executeActionV8(coordinatorUrlObject, '/MediaRenderer/AVTransport/Control', 'GetMediaInfo', { 'InstanceID': 0 }) }, // // SONOS RELATED HELPER // .................... /** Comparing player UUID and serial number. Returns true if matching. * @param {string} serial the string such as 00-0E-58-FE-3A-EA:5 * @param {string} uuid the string such as RINCON_000E58FE3AEA01400 * RINCONG_xxxxxxxxxxxx01400 (01400 is port) * * @returns {Promise<boolean>} true if matching but ignores last digit here 5 * * @throws only split, replace exceptions * * Algorithm: only checks the first part of serial number * * @since 2022-01-11 */ matchSerialUuid: (serial, uuid) => { debug('method:%s', 'matchSerialUuid') let serialClean = serial.split(':')[0] serialClean = serialClean.replace(/-/g, '') let uuidClean = uuid.replace(/^(RINCON_)/, '') uuidClean = uuidClean.replace(/(01400)$/, '') return (uuidClean === serialClean) }, /** * Returns an array (always) of items (DidlBrowseItem) extracted from action "Browse" output. * title, id, artist, album are html decoded. uri, r:resMD (string) aren't! * @param {object} browseOutcome Browse outcome since ts sonos v2.6.0-beta.9 encoded! * @param {number} browseOutcome.NumberReturned amount returned items * @param {number} browseOutcome.TotalMatches amount of total item * @param {string} browseOutcome.Result Didl-Light format, xml * @param {string} itemName DIDL-Light property holding the data. Such as "item" or "container" * * @returns {Promise<DidlBrowseItem[]>} Promise, array of {@link Sonos-CommandsTs#DidlBrowseItem}, * maybe empty array. * * @throws {error} if any parameter is missing * @throws {error} from method xml2js and invalid response (missing id, title) * * Browse provides the results (property Result) in form of a DIDL-Lite xml format. * The <DIDL-Lite> includes several attributes such as xmlns:dc" and entries * all named "container" or "item". These include xml tags such as 'res'. */ parseBrowseToArray: async (browseOutcome, itemName) => { // validate method parameter if (!isTruthy(PACKAGE_PREFIX)) { throw new Error('parameter package name is missing') } if (!isTruthy(browseOutcome)) { throw new Error('parameter browse input is missing') } if (!isTruthyStringNotEmpty(itemName)) { throw new Error('parameter item name such as container is missing') } if (!isTruthyProperty(browseOutcome, ['NumberReturned'])) { throw new Error(`${PACKAGE_PREFIX} invalid response Browse: - missing NumberReturned`) } if (browseOutcome.NumberReturned < 1) { return [] // no My Sonos favorites } // process the Result with Didl-Light if (!isTruthyPropertyStringNotEmpty(browseOutcome, ['Result'])) { throw new Error(`${PACKAGE_PREFIX} invalid response Browse: - missing Result DIDL XML`) } // From sonos@2.6.0-beta.9 there is no need to decode the data - therefor it is commented out // const decodedResult = await decodeHtmlEntity(browseOutcome['Result']) // stopNodes because we use that value for export and import and no further processing const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '_', parseAttributeValue: false, // is default parseTagValue: false, // is default - example Title 49 will otherwise be converted arrayMode: false, stopNodes: ['r:resMD'], // for My-Sonos items, play export! textNodeName: '#text', //is default, just to remember processEntities: false // very important to keep the didl in "r:resMD"! }) const browseJson = await parser.parse(browseOutcome['Result']) if (!isTruthyProperty(browseJson, ['DIDL-Lite'])) { throw new Error(`${PACKAGE_PREFIX} invalid response Browse: missing DIDL-Lite`) } // The following section is because of fast-xml-parser with 'arrayMode' = false // if only ONE item then convert it to array with one let itemsAlwaysArray = [] const path = ['DIDL-Lite', itemName] if (isTruthyProperty(browseJson, path)) { const itemsOrOne = browseJson[path[0]][path[1]] if (Array.isArray(itemsOrOne)) { itemsAlwaysArray = itemsOrOne.slice() } else { // single item - convert to array itemsAlwaysArray = [itemsOrOne] } } // transform properties const transformedItems = await Promise.all(itemsAlwaysArray.map(async (item) => { const newItem = { 'id': '', // required 'title': '', // required 'artist': '', 'album': '', 'description': '', 'uri': '', 'artUri': '', 'metadata': '', 'sid': '', 'serviceName': '', 'upnpClass': '', // might be overwritten 'processingType': 'queue' // has to be updated in calling program } // String() not necessary, see parsing options. But used in case // there might be a number. // special property, required. if (!isTruthyProperty(item, ['_id'])) { throw new Error(`${PACKAGE_PREFIX} id is missing`) // should never happen } newItem.id = String(item['_id']) if (!isTruthyProperty(item, ['dc:title'])) { throw new Error(`${PACKAGE_PREFIX} title is missing`) // should never happen } newItem.title = await decodeHtmlEntity(String(item['dc:title'])) // properties, optional if (isTruthyProperty(item, ['dc:creator'])) { newItem.artist = await decodeHtmlEntity(String(item['dc:creator'])) } if (isTruthyProperty(item, ['upnp:album'])) { newItem.album = await decodeHtmlEntity(String(item['upnp:album'])) } if (isTruthyProperty(item, ['res', '#text'])) { newItem.uri = item['res']['#text'] // HTML entity encoded, URI encoded newItem.sid = await module.exports.getMusicServiceId(newItem.uri) newItem.serviceName = module.exports.getMusicServiceName(newItem.sid) } if (isTruthyProperty(item, ['r:description'])) { // my sonos newItem.description = item['r:description'] } if (isTruthyProperty(item, ['upnp:class'])) { newItem.upnpClass = item['upnp:class'] } // artURI (cover) maybe an array (one for each track) then choose first let artUri = '' if (isTruthyProperty(item, ['upnp:albumArtURI'])) { artUri = item['upnp:albumArtURI'] if (Array.isArray(artUri)) { if (artUri.length > 0) { newItem.artUri = artUri[0] } } else { newItem.artUri = artUri } } // special case My Sonos favorites. It include metadata in DIDL-lite format. // these metadata include the original title, original upnp:class (processingType) if (isTruthyProperty(item, ['r:resMD'])) { newItem.metadata = item['r:resMD'] } return newItem }) ) return transformedItems // properties see transformedItems definition }, /** Get music service id (sid) from HTML ENTITY DECODED Transport URI. * @param {string} uri such as (masked) * 'x-rincon-cpcontainer:1004206ccatalog%2falbums%***%2f%23album_desc?sid=201&flags=8300&sn=14' * '' * * @returns {promise<string>} service id or if not found empty string * * prerequisites: uri is string where the sid is in between "?sid=" and "&flags=" */ getMusicServiceId: async (uri) => { debug('method:%s', 'getMusicServiceId') let sid = '' // default even if uri undefined. if (isTruthyStringNotEmpty(uri)) { const decodedUri = await decodeHtmlEntity(uri) const positionStart = decodedUri.indexOf('?sid=') + '$sid='.length const positionEnd = decodedUri.indexOf('&flags=') if (positionStart > 1 && positionEnd > positionStart) { sid = decodedUri.substring(positionStart, positionEnd) } } return sid }, /** Get service name for given service id. * @param {string} sid service id (integer) such as "201" or blank * * @returns {string} service name such as "Amazon Music" or empty string * * @uses database of services (map music service id to musics service name) */ getMusicServiceName: (sid) => { debug('method:%s', 'getMusicServiceName') let serviceName = '' // default even if sid is blank if (sid !== '') { const list = module.exports.MUSIC_SERVICES const index = list.findIndex((service) => (service.sid === sid)) if (index >= 0) { serviceName = list[index].name } } return serviceName }, /** Get UpnP class from string metadata. * @param {string} metadataEncoded DIDL-Lite metadata, encoded * * @returns {Promise<string>} Upnp class such as "object.container.album.musicAlbum" * * prerequisites: metadata containing xml tag <upnp:class> */ getUpnpClassEncoded: async (metadataEncoded) => { debug('method:%s', 'getUpnpClassEncoded') const decoded = await decodeHtmlEntity(metadataEncoded) let upnpClass = '' // default if (isTruthyStringNotEmpty(decoded)) { const positionStart = decoded.indexOf('<upnp:class>') + '<upnp:class>'.length const positionEnd = decoded.indexOf('</upnp:class>') if (positionStart >= 0 && positionEnd > positionStart) { upnpClass = decoded.substring(positionStart, positionEnd) } } return upnpClass }, /** guessProcessingType from UPnP string * @param {string} upnpClass the UPNP class such as 'object.item.audioItem.audioBroadcast' * * @returns {Promise<string>} processingType 'queue'|'stream' * * prerequisites: metadata containing xml tag <upnp:class> */ guessProcessingType: async (upnpClass) => { debug('method:%s', 'guessProcessingType') // startsWith because object.item.audioItem.audioBroadcast#swimlane-genre for Sonos Radio const UPNP_CLASSES_STREAM = 'object.item.audioItem.audioBroadcast' const UPNP_CLASSES_QUEUE = [ 'object.container.album.musicAlbum', 'object.container.playlistContainer', 'object.item.audioItem.musicTrack', 'object.container', 'object.container.playlistContainer#playlistItem', 'object.container.playlistContainer.#playlistItem', 'object.container.playlistContainer.#PlaylistView' ] // unsupported: // 'object.container.podcast.#podcastContainer', // 'object.container.albumlist' let processingType if (upnpClass.startsWith(UPNP_CLASSES_STREAM)) { debug('upnp class is of type stream ') processingType = 'stream' } else if (UPNP_CLASSES_QUEUE.includes(upnpClass)) { debug('upnp class is of type queue ') processingType = 'queue' } else { debug('upnp class is unsupported - we assume queue') processingType = 'queue' } return processingType }, /** * Returns an array (always) of alarms from ListAlarms * @param {object} currentAlarmList property CurrentAlarmList from ListAlarm * * @returns {Promise<Alarms[]>} Promise, array of alarms, can be empty. * * @throws {error} if any parameter is missing, illegal response from ListAlarms * * @throws {error} if decodeHtmlEntity, parser.parse * * currentAlarmList provides html-decoded xml data containing the alarms. All alamr properties * are attributes- therefor attribute prefix is set to '' */ parseAlarmsToArray: async (currentAlarmList) => { // validate method parameter if (!isTruthy(PACKAGE_PREFIX)) { throw new Error('parameter package name is missing') } if (!isTruthy(currentAlarmList)) { throw new Error(`${PACKAGE_PREFIX} parameter alarmList input is missing`) } const decodedAlarmXml = await decodeHtmlEntity(currentAlarmList) let alarmsAlwaysArray if (decodedAlarmXml === '<Alarms></Alarms>') { // no alarms alarmsAlwaysArray = [] } else { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseAttributeValue: false, // is default parseTagValue: false, arrayMode: false, // watch fields of type array! textNodeName: '#text', //is default, just to remember processEntities: false // - see above decodedAlarmXml }) const alarmsJson = await parser.parse(decodedAlarmXml) // convert single object to array if (isTruthyProperty(alarmsJson, ['Alarms', 'Alarm'])) { if (Array.isArray(alarmsJson.Alarms.Alarm)) { alarmsAlwaysArray = alarmsJson.Alarms.Alarm.slice() } else { alarmsAlwaysArray = [alarmsJson.Alarms.Alarm] } } else { throw new Error(`${PACKAGE_PREFIX} illegal response from ListAlarms`) } } return alarmsAlwaysArray }, /** Parse outcome of GetZoneGroupState and create an array of all groups in household. * Each group consist of an array of player <playerGroupData>. * Coordinator is always in position 0. Group array may have size 1 (standalone). * Hidden players (example stereopair) are removed, if removeHiden = true (default). * @param {string} zoneGroupState the xml data from GetZoneGroupState * @param {boolean} removeHidden removes all hidden payers * * @returns {promise<playerGroupData[]>} array of arrays with playerGroupData * First group member is coordinator * * @throws {error} 'response form parse xml is invalid', 'parameter package name is missing', * 'parameter zoneGroupState is missing` * @throws {error} all methods * * CAUTION: To be on the safe side: playerName uses String (see parse*Value) * CAUTION: We use arrayMode false and do it manually */ parseZoneGroupToArray: async (zoneGroupState, removeHidden = true) => { debug('method:%s', 'parseZoneGroupToArray') // Validate method parameter if (!isTruthyStringNotEmpty(zoneGroupState)) { throw new Error('parameter zoneGroupState is missing') } const decoded = await decodeHtmlEntity(zoneGroupState) const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '_', parseAttributeValue: false, parseTagValue: false, arrayMode: false, processEntities: false }) const groupState = await parser.parse(decoded) // The following section is because of fast-xml-parser with 'arrayMode' = false // If only ONE group then convert it to array of groups with one member let groupsAlwaysArray if (isTruthyProperty(groupState, ['ZoneGroupState', 'ZoneGroups', 'ZoneGroup'])) { // This is the standard case for new firmware! if (Array.isArray(groupState.ZoneGroupState.ZoneGroups.ZoneGroup)) { groupsAlwaysArray = groupState.ZoneGroupState.ZoneGroups.ZoneGroup.slice() } else { groupsAlwaysArray = [groupState.ZoneGroupState.ZoneGroups.ZoneGroup] } // If a group has only ONE member then convert it to array with one member groupsAlwaysArray = groupsAlwaysArray.map(group => { if (!Array.isArray(group.ZoneGroupMember)) group.ZoneGroupMember = [group.ZoneGroupMember] return group }) } else { // Try this for very old firmware version, where ZoneGroupState is missing if (isTruthyProperty(groupState, ['ZoneGroups', 'ZoneGroup'])) { if (Array.isArray(groupState.ZoneGroups.ZoneGroup)) { groupsAlwaysArray = groupState.ZoneGroups.ZoneGroup.slice() } else { groupsAlwaysArray = [groupState.ZoneGroups.ZoneGroup] } // If a group has only ONE member then convert it to array with one member groupsAlwaysArray = groupsAlwaysArray.map(group => { if (!Array.isArray(group.ZoneGroupMember)) group.ZoneGroupMember = [group.ZoneGroupMember] return group }) } else { throw new Error(`${PACKAGE_PREFIX} response form parse xml: properties missing.`) } } // Result is groupsAlwaysArray is array<groupDataRaw> and always arrays (not single item) // Sort all groups that coordinator is in position 0 and select properties // See typeDef playerGroupData. const groupsArraySorted = [] // result to be returned for (const group of groupsAlwaysArray) { let groupSorted = [] const coordinatorUuid = group._Coordinator const groupId = group._ID // First push coordinator, its other properties will be updated later! groupSorted.push({ groupId, 'uuid': coordinatorUuid }) const iCoord = 0 // used for later access for (const member of group.ZoneGroupMember) { const urlObject = new URL(member._Location) urlObject.pathname = '' // clean up const uuid = member._UUID // My naming is playerName instead of the SONOS ZoneName const playerName = String(member._ZoneName) // safety const invisible = (member._Invisible === '1') const channelMapSet = member._ChannelMapSet || '' const htSatChanMapSet = member._HTSatChanMapSet || '' if (member._UUID !== coordinatorUuid) { // Push new joiner groupSorted.push({ urlObject, playerName, uuid, groupId, invisible, channelMapSet, htSatChanMapSet }) } else { // Update coordinator groupSorted[iCoord].urlObject = urlObject groupSorted[iCoord].playerName = playerName groupSorted[iCoord].invisible = invisible groupSorted[iCoord].channelMapSet = channelMapSet groupSorted[iCoord].htSatChanMapSet = htSatChanMapSet } } if (removeHidden) { // Removes all hidden player groupSorted = groupSorted.filter((member) => member.invisible === false) } // Invisible player form a group with 1 - remove that. Should not happen if (groupSorted.length !== 0) groupsArraySorted.push(groupSorted) } return groupsArraySorted }, /** Extract group for a given player. playerName - if isTruthyStringNotEmpty- * is overruling playerUrlHost * @param {string} playerUrlHost (wikipedia) host such as 192.168.178.37 * @param {object} allGroupsData from getGroupsAll * @param {string} [playerName] SONOS-Playername such as Kitchen * * @returns {promise<object>} returns object: * { groupId, playerIndex, coordinatorIndex, members[]<playerGroupData> } * * @throws {error} 'could not find given player in any group' * @throws {error} all methods */ extractGroup: async (playerUrlHost, allGroupsData, playerName) => { debug('method:%s', 'extractGroup') // this ensures that playerName overrules given playerUrlHostname const searchByPlayerName = isTruthyStringNotEmpty(playerName) // find player in group bei playerName or playerUrlHostname // playerName - if valid - overrules playerUrlHostname! let foundGroupIndex = -1 // indicator for player NOT found let visible let groupId let usedPlayerUrlHost = '' for (let iGroup = 0; iGroup < allGroupsData.length; iGroup++) { for (let iMember = 0; iMember < allGroupsData[iGroup].length; iMember++) { visible = !allGroupsData[iGroup][iMember].invisible groupId = allGroupsData[iGroup][iMember].groupId if (searchByPlayerName) { // we compare playerName (string) such as Küche if (allGroupsData[iGroup][iMember].playerName === playerName && visible) { foundGroupIndex = iGroup usedPlayerUrlHost = allGroupsData[iGroup][iMember].urlObject.hostname break // inner loop } } else { // we compare by URL hostname such as '192.168.178.35' if (allGroupsData[iGroup][iMember].urlObject.hostname === playerUrlHost && visible) { foundGroupIndex = iGroup usedPlayerUrlHost = allGroupsData[iGroup][iMember].urlObject.hostname break // inner loop } } } if (foundGroupIndex >= 0) { break // break also outer loop } } if (foundGroupIndex === -1) { throw new Error(`${PACKAGE_PREFIX} could not find given player in any group`) } // remove all invisible players player (in stereopair there is one invisible) const members = allGroupsData[foundGroupIndex].filter((member) => (member.invisible === false)) // find our player index in that group. At this position because we did filter! // that helps to figure out role: coordinator, joiner, independent const playerIndex = members.findIndex((member) => (member.urlObject.hostname === usedPlayerUrlHost)) return { groupId, playerIndex, 'coordinatorIndex': 0, members } }, // // BASIC EXECUTE UPNP ACTION COMMAND AND SOAP REQUEST // /** Sends action with actionInArgs to endpoint at playerUrl.origin and returns result. * @param {object} playerUrl player URL (JavaScript build in) such as http://192.168.178.37:1400 * @param {string} endpoint the endpoint name such as /MediaRenderer/AVTransport/Control * @param {string} actionName the action name such as Seek * @param {object} actionInArgs all arguments - throws error if one argument is missing! * * @returns {Promise<(object|boolean)>} true or outArgs of that action * * @throws {error} http return invalid status or not 200, * missing body, unexpected response * @throws {error} fastxmlparser errors * * Everything OK if statusCode === 200 and body includes expected * response value (set) or value (get) */ executeActionV8: async (playerUrl, endpoint, actionName, actionInArgs) => { debug('method:%s', 'executeActionV7') // !no check of inArgs // generate serviceName from endpoint - its always the second last // SONOS endpoint is either /<component>/<serviceName>/Control or /<serviceName>/Control // component MediaRenderer or MediaServer const tmp = endpoint.split('/') const serviceName = tmp[tmp.length - 2] const response // eslint-disable-next-line max-len = await module.exports.sendSoapToPlayer(playerUrl.origin, endpoint, serviceName, actionName, actionInArgs) debug('xml response body as string >>%s', response.body) // Everything OK if statusCode === 200 // && body includes expected response value or requested value if (!isTruthyProperty(response, ['statusCode'])) { // This should never happen. Just to avoid unhandled exception. // eslint-disable-next-line max-len throw new Error(`${PACKAGE_PREFIX} status code from sendToPlayer is invalid - response.statusCode >>${JSON.stringify(response)}`) } if (response.statusCode !== 200) { // This should not happen as long as axios is being used. Just to avoid unhandled exception. // eslint-disable-next-line max-len throw new Error(`${PACKAGE_PREFIX} status code is not 200: ${response.statusCode} - response >>${JSON.stringify(response)}`) } if (!isTruthyProperty(response, ['body'])) { // This should not happen. Just to avoid unhandled exception. // eslint-disable-next-line max-len throw new Error(`${PACKAGE_PREFIX} body from sendToPlayer is invalid - response >>${JSON.stringify(response)}`) } // Convert XML to JSON - now with fast xml parser const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseAttributeValue: false, parseTagValue: false, arrayMode: false, processEntities: false // decoding will be done manually }) const bodyXml = await parser.parse(response.body) debug('parsed JSON response body >>%s', JSON.stringify(bodyXml)) // RESPONSE // The key to the core data is ['s:Envelope','s:Body',`u:${actionName}Response`] // There are 2 cases: // 1. no output argument thats typically in a "set" action: // expected response is just an envelope with // .... 'xmlns:u' = `urn:schemas-upnp-org:service:${serviceName}:1` // 2. one or more values typically in a "get" action: in addition // the values outArgs are included. // .... 'xmlns:u' = `urn:schemas-upnp-org:service:${serviceName}:1` // and in addition the properties from outArgs // const key = ['s:Envelope', 's:Body'] key.push(`u:${actionName}Response`) // check body response if (!isTruthyProperty(bodyXml, key)) { // eslint-disable-next-line max-len throw new Error(`${PACKAGE_PREFIX} body from sendToPlayer is invalid - response >>${JSON.stringify(response)}`) } const result = getNestedProperty(bodyXml, key) if (!isTruthyProperty(result, ['xmlns:u'])) { throw new Error(`${PACKAGE_PREFIX} xmlns:u property is missing`) } const expectedResponseValue = `urn:schemas-upnp-org:service:${serviceName}:1` if (result['xmlns:u'] !== expectedResponseValue) { throw new Error(`${PACKAGE_PREFIX} unexpected player response: urn:schemas ... is missing `) } else { delete result['xmlns:u'] } return result }, /** Send http request in SOAP format to player. * @param {string} playerUrlOrigin JavaScript URL origin such as http://192.168.178.37:1400 * @param {string} endpoint SOAP endpoint (URL pathname) such '/ZoneGroupTopology/Control' * @param {string} serviceName such as 'ZoneGroupTopology' * @param {string} actionName such as 'GetZoneGroupState' * @param {object} args such as { InstanceID: 0, EQType: "NightMode" } or just {} * * @returns {promise} response header/body/error code from player */ // eslint-disable-next-line max-len sendSoapToPlayer: async (playerUrlOrigin, endpoint, serviceName, actionName, args) => { debug('method:%s', 'sendSoapToPlayer') // create action used in header - notice the " inside const soapAction = `"urn:schemas-upnp-org:service:${serviceName}:1#${actionName}"` // create body let httpBody = `<u:${actionName} xmlns:u="urn:schemas-upnp-org:service:${serviceName}:1">` if (args) { Object.keys(args).forEach(key => { httpBody += `<${key}>${args[key]}</${key}>` }) } httpBody += `</u:${actionName}>` // body wrapped in envelope // eslint-disable-next-line max-len httpBody = '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' + '<s:Body>' + httpBody + '</s:Body>' + '</s:Envelope>' debug('soap action >>%s', JSON.stringify(soapAction)) debug('soap body >>%s', JSON.stringify(httpBody)) const response = await request({ 'method': 'post', 'baseURL': playerUrlOrigin, 'url': endpoint, 'headers': { SOAPAction: soapAction, 'Content-type': 'text/xml; charset=utf8' }, 'data': httpBody }) .catch((error) => { // Experience: When using reject(error) the error.response get lost. // Thats why error.response is checked and handled here! // In case of an SOAP error error.response held the details and status code 500 if (isTruthyProperty(error, ['response'])) { // Indicator for SOAP Error if (isTruthyProperty(error, ['message'])) { if (error.message.startsWith('Request failed with status code 500')) { const errorCode = module.exports.getErrorCodeFromEnvelope(error.response.data) let serviceErrorList = '' // eslint-disable-next-line max-len if (isTruthyPropertyStringNotEmpty(module.exports.SOAP_ERRORS, [serviceName.toUpperCase()])) { // look up in the service specific error codes 7xx serviceErrorList = module.exports.SOAP_ERRORS[serviceName.toUpperCase()] } // eslint-disable-next-line max-len const errorMessage = module.exports.getErrorMessageV1(errorCode, module.exports.SOAP_ERRORS.UPNP, serviceErrorList) // eslint-disable-next-line max-len throw new Error(`${PACKAGE_PREFIX} statusCode 500 & upnpErrorCode ${errorCode}. upnpErrorMessage >>${errorMessage}`) } else { // eslint-disable-next-line max-len throw new Error('error.message is not code 500' + JSON.stringify(error, Object.getOwnPropertyNames(error))) } } else { // eslint-disable-next-line max-len throw new Error('error.message is missing. error >>' + JSON.stringify(error, Object.getOwnPropertyNames(error))) } } else { // usually ECON.. or timed out. Is being handled in failure procedure // eslint-disable-next-line max-len debug('error without error.response >>%s', JSON.stringify(error, Object.getOwnPropertyNames(error))) throw error } }) return { 'headers': response.headers, 'body': response.data, 'statusCode': response.status } }, /** Get error code or empty string. * * @param {string} data upnp error response as envelope with <errorCode>xxx</errorCode> * * @returns {string} error code * * @throws nothing */ getErrorCodeFromEnvelope: (data) => { let errorCode = '' // default if (isTruthyStringNotEmpty(data)) { const positionStart = data.indexOf('<errorCode>') + '<errorCode>'.length const positionEnd = data.indexOf('</errorCode>') if (positionStart > 1 && positionEnd > positionStart) { errorCode = data.substring(positionStart, positionEnd) } } return errorCode.trim() }, /** Get error message from error code. If not found provide 'unknown error'. * * @param {string} errorCode * @param {JSON} upnpErrorList - simple mapping .code .message * @param {JSON} [serviceErrorList] - simple mapping .code .message * * @returns {string} error text (from mapping code - text) * * @throws nothing */ getErrorMessageV1: (errorCode, upnpErrorList, serviceErrorList) => { const errorText = 'unknown error' // default if (isTruthyStringNotEmpty(errorCode)) { if (serviceErrorList !== '') { for (let i = 0; i < serviceErrorList.length; i++) { if (serviceErrorList[i].code === errorCode) { return serviceErrorList[i].message } } } for (let i = 0; i < upnpErrorList.length; i++) { if (upnpErrorList[i].code === errorCode) { return upnpErrorList[i].message } } } return errorText }, /** Replace &apos; with %27. * * @param {string} uri uri from Music library query * * @returns {string} uri without &apos; instead %27 * * Example: x-rincon-playlist:RINCON_5CAAFD00223601400#A:ALBUM/A%20Hard%20Day&apos;s%20Night * @throws nothing */ replaceAposColon: (uri) => { const newUri = uri.replace(/&apos;/g, '%27') return newUri } }