├── .vscode ├── settings.json └── launch.json ├── lib ├── lib.manager.base.js ├── lib.request.js ├── lib.device.base.js ├── setUriMetadata.template ├── lib.base.manager.js ├── lib.tools.js ├── lib.managerDisposer.js ├── lib.logger.js ├── lib.queueController.js ├── lib.device.base.upnp.js ├── lib.queueController.nativePlaylist.js ├── lib.base.js ├── lib.device.upnp.mediaServer.js ├── lib.discoverHostDevice.js ├── lib.device.upnp.mediaServer.raumfeld.js ├── lib.device.upnp.mediaRenderer.raumfeld.js ├── lib.manager.triggerManager.js ├── lib.manager.infodataManager.js ├── lib.queueController.zonePlaylist.js ├── lib.mediaDataConverter.js ├── lib.raumkernel.js ├── lib.manager.mediaListManager.js ├── lib.device.upnp.mediaRenderer.js └── lib.external.upnp-device-client.js ├── .gitignore ├── package.json ├── index.js ├── LICENSE ├── sample_contentBrowser.js ├── README.md └── test.js /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /lib/lib.manager.base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var BaseManager = require('./lib.base.manager'); 3 | 4 | module.exports = class ManagerBase extends BaseManager 5 | { 6 | constructor() 7 | { 8 | super(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/lib.request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Use old GOT version because of ES6 imports 4 | //import got from 'got'; 5 | //var got = require("got"); 6 | // npm install got@7.1.0 7 | 8 | // use node-fetch old version --> version 2!!!! later switch to nodejs version if nodejs 18 is used in iobroker 9 | 10 | module.exports = function Request(_requestOptions, _callback) 11 | { 12 | let options = {}; 13 | let request = got.get(_requestOptions.url, options); 14 | request.then((_response) => { 15 | _callback(false, _response, _response.body) 16 | }).catch((_error) => { 17 | _callback(true, err.response, err.response.body) 18 | }); 19 | 20 | // response.statusCode 21 | // response.headers 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /lib/lib.device.base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var BaseManager = require('./lib.base.manager'); 3 | 4 | /** 5 | * this is the base class to use for child's which are devices 6 | */ 7 | module.exports = class Device extends BaseManager 8 | { 9 | constructor() 10 | { 11 | super(); 12 | } 13 | 14 | /** 15 | * used to return a readable name for a device 16 | * @return {String} a readable name for the device 17 | */ 18 | name() 19 | { 20 | return ""; 21 | } 22 | 23 | /** 24 | * used to return a unique Id for a device 25 | * @return {String} a unique id for the device 26 | */ 27 | id() 28 | { 29 | return ""; 30 | } 31 | } -------------------------------------------------------------------------------- /lib/setUriMetadata.template: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Station 5 | object.item.audioItem.audioBroadcast.radio 6 | External 7 | 120 8 | 9 | 99 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "engines": { 3 | "node": ">=7.6.0" 4 | }, 5 | "name": "node-raumkernel", 6 | "version": "1.2.23", 7 | "description": "Library to control the raumfeld multiroom system", 8 | "main": "index.js", 9 | "scripts": { 10 | "start": "node index.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/ChriD/node-raumkernel.git" 15 | }, 16 | "keywords": [ 17 | "raumserver", 18 | "raumkernel", 19 | "raumfeld" 20 | ], 21 | "dependencies": { 22 | "bonjour": "^3.5.0", 23 | "node-fetch": "^3.3.0", 24 | "node-ssdp": "^3.2.0", 25 | "query-string": "^4.3.2", 26 | "request": "^2.79.0", 27 | "upnp-device-client": "^1.0.2", 28 | "xml2js": "^0.4.17" 29 | }, 30 | "author": "Christian Dürnberger", 31 | "bugs": { 32 | "url": "https://github.com/ChriD/node-raumkernel/issues" 33 | }, 34 | "homepage": "https://github.com/ChriD/node-raumkernel#readme" 35 | } 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = './lib/'; 4 | 5 | module.exports = { 6 | Raumkernel : require(path + "lib.raumkernel"), 7 | Logger : require(path + "lib.logger"), 8 | Base : require(path + "lib.base"), 9 | BaseManager : require(path + "lib.base.manager"), 10 | MediaRenderer : require(path + "lib.device.upnp.mediaRenderer"), 11 | MediaRendererRaumfeld : require(path + "lib.device.upnp.mediaRenderer.raumfeld"), 12 | MediaRendererRaumfeldVirtual : require(path + "lib.device.upnp.mediaRenderer.raumfeldVirtual"), 13 | MediaServer : require(path + "lib.device.upnp.mediaServer"), 14 | MediaServerRaumfeld : require(path + "lib.device.upnp.mediaServer.raumfeld"), 15 | ManagerBase : require(path + "lib.manager.base"), 16 | DeviceManager : require(path + "lib.manager.deviceManager"), 17 | ZoneManager : require(path + "lib.manager.zoneManager"), 18 | MediaListManager : require(path + "lib.manager.mediaListManager"), 19 | ManagerDisposer : require(path + "lib.managerDisposer"), 20 | PackageJSON : require("./package.json") 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceRoot}/test.js" 12 | }, 13 | { 14 | "type": "node", 15 | "request": "launch", 16 | "name": "Programm starten", 17 | "program": "${workspaceRoot}\\test.js" 18 | }, 19 | { 20 | "type": "node", 21 | "request": "launch", 22 | "name": "Content Browse test starten", 23 | "program": "${workspaceRoot}\\sample_contentBrowser.js" 24 | }, 25 | { 26 | "type": "node", 27 | "request": "attach", 28 | "name": "An den Prozess anfügen", 29 | "address": "localhost", 30 | "port": 5858 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Christian Dürnberger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/lib.base.manager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Logger = require('./lib.logger'); 3 | var Base = require('./lib.base'); 4 | 5 | /** 6 | * this is the base class to use for child's which have to have access to the manager instances 7 | */ 8 | module.exports = class BaseManager extends Base 9 | { 10 | constructor() 11 | { 12 | super(); 13 | this.managerDisposer = null; 14 | } 15 | 16 | /** 17 | * used to set or to get the manager disposer object 18 | * @param {Object} the instanced of the manager disposer 19 | * @return {Object} the instanced of the manager disposer 20 | */ 21 | parmManagerDisposer(_managerDisposer = this.managerDisposer) 22 | { 23 | this.managerDisposer = _managerDisposer; 24 | return this.managerDisposer; 25 | } 26 | 27 | /** 28 | * used to return the settings from the raumkernel instance 29 | * @return {Object} the settings of the kernel 30 | */ 31 | getSettings() 32 | { 33 | if(!this.managerDisposer || !this.managerDisposer.raumkernel) 34 | return {}; 35 | return this.managerDisposer.raumkernel.settings; 36 | } 37 | } -------------------------------------------------------------------------------- /lib/lib.tools.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * use this to encode a string for the content directory 5 | * @param {String} the string which should be encoded 6 | * @param {String} encoded string 7 | */ 8 | var encodeString = exports.encodeString=function(_string) 9 | { 10 | return encodeURIComponent(_string)/*.replace(/\-/g, "%2D").replace(/\_/g, "%5F")*/.replace(/\./g, "%2E").replace(/\!/g, "%21").replace(/\~/g, "%7E").replace(/\*/g, "%2A").replace(/\'/g, "%27").replace(/\(/g, "%28").replace(/\)/g, "%29"); 11 | } 12 | 13 | 14 | /** 15 | * use this to decode a string for the content directory 16 | * @param {String} the string which should be decoded 17 | * @param {String} decoded string 18 | */ 19 | var decodeString = exports.decodeString=function(_string) 20 | { 21 | return decodeURIComponent(_string/*.replace(/\%2D/g, "-").replace(/\%5F/g, "_")*/.replace(/\%2E/g, ".").replace(/\%21/g, "!").replace(/\%7E/g, "~").replace(/\%2A/g, "*").replace(/\%27/g, "'").replace(/\%28/g, "(").replace(/\%29/g, ")")); 22 | } 23 | 24 | 25 | var isUrl = exports.isUrl=function(_string) 26 | { 27 | var regexp = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/ 28 | return regexp.test(_string); 29 | } 30 | 31 | 32 | exports.createAVTransportUriForContainer=function(_mediaServerUdn, _containerId, _trackIndex = -1) 33 | { 34 | // a valid transport uri looks like this! 35 | //dlna-playcontainer://uuid%3Aed3bd3db-17b1-4dbe-82df-5201c78e632c?sid=urn%3Aupnp-org%3AserviceId%3AContentDirectory&cid=0%2FPlaylists%2FMyPlaylists%2FTest&md=0 36 | var uri = ""; 37 | uri = encodeString(_mediaServerUdn) + "?sid=" + encodeString("urn:upnp-org:serviceId:ContentDirectory") + "&cid=" + encodeString(_containerId) + "&md=0"; 38 | if(_trackIndex >= 0) 39 | uri += "&fii=" + encodeString(_trackIndex.toString()); 40 | uri = "dlna-playcontainer://" + uri; 41 | return uri; 42 | } 43 | 44 | exports.createAVTransportUriForSingle=function(_mediaServerUdn, _singleId) 45 | { 46 | // a valid transport uri looks like this! 47 | //dlna-playsingle://uuid%3Aed3bd3db-17b1-4dbe-82df-5201c78e632c?sid=urn%3Aupnp-org%3AserviceId%3AContentDirectory&iid=0%2FRadioTime%2FLocalRadio%2Fs-s68932 48 | var uri = ""; 49 | uri = encodeString(_mediaServerUdn) + "?sid=" + encodeString("urn:upnp-org:serviceId:ContentDirectory") + "&iid=" + encodeString(_singleId); 50 | uri = "dlna-playsingle://" + uri; 51 | return uri; 52 | } -------------------------------------------------------------------------------- /lib/lib.managerDisposer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var Logger = require('./lib.logger') 3 | var Base = require('./lib.base') 4 | var DeviceManager = require('./lib.manager.deviceManager') 5 | var ZoneManager = require('./lib.manager.zoneManager') 6 | var MediaListManager = require('./lib.manager.mediaListManager') 7 | var TriggerManager = require('./lib.manager.triggerManager') 8 | var InfodataManager = require('./lib.manager.infodataManager') 9 | 10 | /** 11 | * this is the manager disposer class. 12 | * it holds all available managers 13 | */ 14 | module.exports = class ManagerDisposer extends Base 15 | { 16 | constructor() 17 | { 18 | super() 19 | this.raumkernel = null 20 | this.deviceManager = null 21 | this.zoneManager = null 22 | this.mediaListManager = null; 23 | this.triggerManager = null 24 | this.infodataManager = null 25 | } 26 | 27 | parmRaumkernel(_raumkernel = this.raumkernel) 28 | { 29 | this.raumkernel = _raumkernel 30 | return this.raumkernel 31 | } 32 | 33 | additionalLogIdentifier() 34 | { 35 | return "ManagerDisposer" 36 | } 37 | 38 | createManagers() 39 | { 40 | this.createDeviceManager() 41 | this.createZoneManager() 42 | this.createMediaListManager() 43 | this.createTriggerManager() 44 | this.createInfodataManager() 45 | } 46 | 47 | createDeviceManager() 48 | { 49 | this.logVerbose("Creating device manager") 50 | this.deviceManager = new DeviceManager() 51 | this.deviceManager.parmLogger(this.parmLogger()) 52 | this.deviceManager.parmManagerDisposer(this) 53 | } 54 | 55 | createZoneManager() 56 | { 57 | this.logVerbose("Creating zone manager") 58 | this.zoneManager = new ZoneManager() 59 | this.zoneManager.parmLogger(this.parmLogger()) 60 | this.zoneManager.parmManagerDisposer(this) 61 | } 62 | 63 | createMediaListManager() 64 | { 65 | this.logVerbose("Creating media list manager") 66 | this.mediaListManager = new MediaListManager() 67 | this.mediaListManager.parmLogger(this.parmLogger()) 68 | this.mediaListManager.parmManagerDisposer(this) 69 | } 70 | 71 | createTriggerManager() 72 | { 73 | this.logVerbose("Creating trigger manager") 74 | this.triggerManager = new TriggerManager() 75 | this.triggerManager.parmLogger(this.parmLogger()) 76 | this.triggerManager.parmManagerDisposer(this) 77 | } 78 | 79 | createInfodataManager() 80 | { 81 | this.logVerbose("Creating infodata manager") 82 | this.infodataManager = new InfodataManager() 83 | this.infodataManager.parmLogger(this.parmLogger()) 84 | this.infodataManager.parmManagerDisposer(this) 85 | } 86 | 87 | } -------------------------------------------------------------------------------- /lib/lib.logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var EventEmitter = require("events").EventEmitter; 3 | 4 | 5 | /** 6 | * A simple logger class that will be used throughout the raumkernel lib 7 | * It emits an log event so any kind of logger can be attached 8 | */ 9 | module.exports = class Logger extends EventEmitter 10 | { 11 | /** 12 | * Constructor of the logger class 13 | * @param {Number} the maximum log level which should be logged 14 | * @return {Object} The logger object 15 | */ 16 | constructor(_logLevel) 17 | { 18 | super() 19 | // this is the maximum log level that will be logged 20 | this.logLevel = _logLevel; 21 | } 22 | 23 | /** 24 | * log a text to the activated outputs 25 | * @param {Number} the log type 26 | * @param {String} the log text 27 | * @param {Object} some additional meta data object 28 | */ 29 | log(_logType, _log, _metadata = null) 30 | { 31 | if(_logType <= this.logLevel) 32 | this.emit("log", { "logType" : _logType, "log" : _log, "metadata" : _metadata }); 33 | } 34 | 35 | /** 36 | * log a error 37 | * @param {String} the log text 38 | * @param {Object} some additional meta data object 39 | */ 40 | logError(_log, _metadata = null) 41 | { 42 | this.log(0, _log, _metadata); 43 | } 44 | 45 | /** 46 | * log a warning 47 | * @param {String} the log text 48 | * @param {Object} some additional meta data object 49 | */ 50 | logWarning(_log, _metadata = null) 51 | { 52 | this.log(1, _log, _metadata); 53 | } 54 | 55 | /** 56 | * log a info 57 | * @param {String} the log text 58 | * @param {Object} some additional meta data object 59 | */ 60 | logInfo(_log, _metadata = null) 61 | { 62 | this.log(2, _log, _metadata); 63 | } 64 | 65 | /** 66 | * log a verbose 67 | * @param {String} the log text 68 | * @param {Object} some additional meta data object 69 | */ 70 | logVerbose(_log, _metadata = null) 71 | { 72 | this.log(3, _log, _metadata); 73 | } 74 | 75 | /** 76 | * log a debug 77 | * @param {String} the log text 78 | * @param {Object} some additional meta data object 79 | */ 80 | logDebug(_log, _metadata = null) 81 | { 82 | this.log(4, _log, _metadata); 83 | } 84 | 85 | /** 86 | * log a silly 87 | * @param {String} the log text 88 | * @param {Object} some additional meta data object 89 | */ 90 | logSilly(_log, _metadata = null) 91 | { 92 | this.log(5, _log, _metadata); 93 | } 94 | 95 | 96 | 97 | } 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /lib/lib.queueController.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Logger = require('./lib.logger'); 3 | var BaseManager = require('./lib.base.manager'); 4 | var ManagerDisposer = require('./lib.managerDisposer'); 5 | 6 | module.exports = class QueueController extends BaseManager 7 | { 8 | constructor() 9 | { 10 | super(); 11 | this.mediaServer = null; 12 | this.queueBaseContainerId = ""; 13 | } 14 | 15 | 16 | init() 17 | { 18 | } 19 | 20 | additionalLogIdentifier() 21 | { 22 | return "QueueController"; 23 | } 24 | 25 | parmQueueBaseContainerId(_queueBaseContainerId = this.queueBaseContainerId) 26 | { 27 | this.queueBaseContainerId = _queueBaseContainerId; 28 | return this.queueBaseContainerId; 29 | } 30 | 31 | parmMediaServer(_mediaServer = this.mediaServer) 32 | { 33 | this.mediaServer = _mediaServer; 34 | return this.mediaServer; 35 | } 36 | 37 | getQueueIdFromNameAndBase(_queueName, _baseContainerId = this.queueBaseContainerId) 38 | { 39 | return _baseContainerId + "/" + this.encodeString(_queueName); 40 | } 41 | 42 | createQueue(_queueName) 43 | { 44 | if(!this.mediaServer) 45 | return Promise.reject(new Error("MediaServer not ready!")); 46 | return this.mediaServer.createQueue(_queueName, this.queueBaseContainerId); 47 | } 48 | 49 | deleteQueue(_queueName) 50 | { 51 | if(!this.mediaServer) 52 | return Promise.reject(new Error("MediaServer not ready!")); 53 | return this.mediaServer.destroyObject(this.getQueueIdFromNameAndBase(_queueName)); 54 | } 55 | 56 | renameQueue(_oldQueueName, _newQueueName) 57 | { 58 | if(!this.mediaServer) 59 | return Promise.reject(new Error("MediaServer not ready!")); 60 | return this.mediaServer.renameQueue(this.getQueueIdFromNameAndBase(_oldQueueName), _newQueueName); 61 | } 62 | 63 | 64 | addItemToQueue(_queueName, _mediaItemId, _position = 294967295, _isItemsContainer = false, _startIndex = 0, _endIndex = 294967295) 65 | { 66 | if(!this.mediaServer) 67 | return Promise.reject(new Error("MediaServer not ready!")); 68 | 69 | if(_isItemsContainer) 70 | { 71 | return this.mediaServer.addContainerToQueue(this.getQueueIdFromNameAndBase(_queueName), _mediaItemId, _mediaItemId, "*", "", _startIndex, _endIndex, _position); 72 | } 73 | else 74 | { 75 | return this.mediaServer.addItemToQueue(this.getQueueIdFromNameAndBase(_queueName), _mediaItemId, _position); 76 | } 77 | } 78 | 79 | 80 | removeItemsFromQueue(_queueName, _fromPosition, _toPosition) 81 | { 82 | if(!this.mediaServer) 83 | return Promise.reject(new Error("MediaServer not ready!")); 84 | return this.mediaServer.removeFromQueue(this.getQueueIdFromNameAndBase(_queueName), _fromPosition, _toPosition); 85 | } 86 | 87 | 88 | moveItemInQueue(_mediaItemId, _newPosition) 89 | { 90 | if(!this.mediaServer) 91 | return Promise.reject(new Error("MediaServer not ready!")); 92 | return this.mediaServer.moveInQueue(_mediaItemId, _newPosition); 93 | } 94 | 95 | 96 | } -------------------------------------------------------------------------------- /lib/lib.device.base.upnp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Url = require('url'); 3 | var Device = require('./lib.device.base'); 4 | 5 | /** 6 | * this is the base class to use for child's which are UPNP devices 7 | */ 8 | module.exports = class UPNPDevice extends Device 9 | { 10 | constructor(_upnpClient) 11 | { 12 | super(); 13 | this.upnpClient = _upnpClient; 14 | this.upnpClient.userAgent = "RaumfeldControl/3.10 RaumfeldProtocol/399"; 15 | } 16 | 17 | 18 | close() 19 | { 20 | this.upnpClient.releaseEventingServer(true); 21 | } 22 | 23 | name() 24 | { 25 | return this.upnpClient.deviceDescription.friendlyName; 26 | } 27 | 28 | id() 29 | { 30 | // in fact USN/UDN should not be used as an ID as mentioned here: https://www.w3.org/2009/dap/track/issues/151 31 | return this.upnpClient.deviceDescription.UDN; 32 | } 33 | 34 | /** 35 | * used to return the host (IP) of the device 36 | * @return {String} the host (IP) of the device 37 | */ 38 | host() 39 | { 40 | return Url.parse(this.upnpClient.url).hostname; 41 | } 42 | 43 | /** 44 | * used to return a the UDN of the device 45 | * @return {String} the UDN of the UPNP device 46 | */ 47 | udn() 48 | { 49 | return this.upnpClient.deviceDescription.UDN; 50 | } 51 | 52 | /** 53 | * used to return a the modelNumber of the device 54 | * @return {String} the modelNumber of the UPNP device 55 | */ 56 | modelNumber() 57 | { 58 | return this.upnpClient.deviceDescription.modelNumber; 59 | } 60 | 61 | /** 62 | * used to return a the manufacturer of the device 63 | * @return {String} the manufacturer of the UPNP device 64 | */ 65 | manufacturer() 66 | { 67 | return this.upnpClient.deviceDescription.manufacturer; 68 | } 69 | 70 | /** 71 | * used to return a the friendlyName of the device 72 | * @return {String} the friendlyName of the UPNP device 73 | */ 74 | friendlyName() 75 | { 76 | return this.upnpClient.deviceDescription.friendlyName; 77 | } 78 | 79 | /** 80 | * should be used to call an action on a service of the device 81 | * @return {Promise} a promise with the result as parameter 82 | */ 83 | callAction(_service, _action, _params, _resultSetFunction = null) 84 | { 85 | var self = this; 86 | this.logDebug("Call " + _action + " from " + this.name()); 87 | 88 | return new Promise(function(resolve, reject){ 89 | self.upnpClient.callAction(_service, _action, _params, function (_err, _result) { 90 | if(!_err && _result) 91 | { 92 | self.logDebug("Result of " + _action + " for " + self.name() + " is " + JSON.stringify(_result)); 93 | if(_resultSetFunction) 94 | resolve(_resultSetFunction(_result)); 95 | else 96 | { 97 | resolve(_result); 98 | } 99 | } 100 | else 101 | { 102 | self.logError(_action + " on " + self.name() + " failed with params: " + JSON.stringify(_params), _err); 103 | reject(_err); 104 | } 105 | }); 106 | }) 107 | } 108 | 109 | /** 110 | * use this method to subscribe to services of the upnp device 111 | */ 112 | subscribe() 113 | { 114 | } 115 | 116 | /** 117 | * use this method to unsubscribe to services of the upnp device 118 | */ 119 | unsubscribe() 120 | { 121 | } 122 | 123 | } -------------------------------------------------------------------------------- /lib/lib.queueController.nativePlaylist.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Logger = require('./lib.logger'); 3 | var QueueController = require('./lib.queueController'); 4 | var ManagerDisposer = require('./lib.managerDisposer'); 5 | 6 | module.exports = class QueueControllerNativePlaylist extends QueueController 7 | { 8 | constructor() 9 | { 10 | super(); 11 | // the container id for the native playlists 12 | this.parmQueueBaseContainerId("0/Playlists/MyPlaylists"); 13 | } 14 | 15 | 16 | additionalLogIdentifier() 17 | { 18 | return "QueueControllerNativePlaylist"; 19 | } 20 | 21 | 22 | createPlaylist(_playlistName) 23 | { 24 | var self = this; 25 | self.logVerbose("Create native playlist with name '" + _playlistName + "'"); 26 | return new Promise(function(_resolve, _reject){ 27 | self.createQueue(_playlistName).then(function(_data){ 28 | self.logVerbose("Native playlist with name '" + _playlistName + "' created"); 29 | _resolve(_data); 30 | }).catch(function(_data){ 31 | self.logError("Native playlist with name '" + _playlistName + "' could not be created"); 32 | _reject(_data); 33 | }); 34 | }); 35 | } 36 | 37 | 38 | deletePlaylist(_playlistName) 39 | { 40 | var self = this; 41 | self.logVerbose("Delete native playlist with name '" + _playlistName + "'"); 42 | return new Promise(function(_resolve, _reject){ 43 | self.deleteQueue(_playlistName).then(function(_data){ 44 | self.logVerbose("Native playlist with name '" + _playlistName + "' deleted"); 45 | _resolve(_data); 46 | }).catch(function(_data){ 47 | self.logError("Native playlist with name '" + _playlistName + "' could not be deleted"); 48 | _reject(_data); 49 | }); 50 | }); 51 | } 52 | 53 | 54 | renamePlaylist(_oldPlaylistName, _newPlaylistName) 55 | { 56 | var self = this; 57 | self.logVerbose("Rename native playlist with name '" + _oldPlaylistName + "' to '" + _newPlaylistName + "'"); 58 | return new Promise(function(_resolve, _reject){ 59 | self.renameQueue(_oldPlaylistName, _newPlaylistName).then(function(_data){ 60 | self.logVerbose("Playlist with name '" + _oldPlaylistName + "' renamed to '" + _newPlaylistName + "'"); 61 | _resolve(_data); 62 | }).catch(function(_data){ 63 | self.logError("Rename native playlist with name '" + _oldPlaylistName + "' to '" + _newPlaylistName + "' had errors"); 64 | _reject(_data); 65 | }); 66 | }); 67 | } 68 | 69 | 70 | addItemToPlaylist(_playlistName, _mediaItemId, _position = 294967295, _isItemsContainer = false, _startIndex = 0, _endIndex = 294967295) 71 | { 72 | var self = this; 73 | self.logVerbose("Add item '" + _mediaItemId + "' to '" + _playlistName + "'"); 74 | return this.addItemToQueue(_playlistName, _mediaItemId, _position, _isItemsContainer, _startIndex, _endIndex); 75 | } 76 | 77 | 78 | removeItemsFromPlaylist(_playlistName, _fromPosition, _toPosition) 79 | { 80 | var self = this; 81 | self.logVerbose("Remove items '" + _fromPosition.toString() + "' to '" + _toPosition.toString() + "' on playlist '" + _playlistName + "'"); 82 | return this.removeItemsFromQueue(_playlistName, _fromPosition, _toPosition); 83 | } 84 | 85 | 86 | moveItemInPlaylist(_playlistName, _mediaItemId, _newPosition) 87 | { 88 | var self = this; 89 | self.logVerbose("Move item '" + _mediaItemId + "' in playlist '" + _playlistName + "' to position " + _newPosition.toString()); 90 | // the "_mediaItemId" has to consist of the id of the playlist item of course, so on fact "_playlistName" is not really needed here 91 | this.moveItemInQueue(_mediaItemId, _newPosition); 92 | } 93 | 94 | } -------------------------------------------------------------------------------- /sample_contentBrowser.js: -------------------------------------------------------------------------------- 1 | const Readline = require('readline'); 2 | var Raumkernel = require('./lib/lib.raumkernel'); 3 | 4 | setWelcomeScreen(); 5 | 6 | var idStack = []; 7 | var tp0; 8 | var tpDuration; 9 | var raumkernel = new Raumkernel(); 10 | raumkernel.createLogger(1, "logs"); 11 | raumkernel.init(); 12 | 13 | 14 | function perfMeassure(_start) { 15 | if ( !_start ) return process.hrtime(); 16 | var end = process.hrtime(_start); 17 | return Math.round((end[0]*1000) + (end[1]/1000000)); 18 | } 19 | 20 | 21 | // set up a callback to show the "reading state" of a media list that is beeing read 22 | // of course this will be called on any browse wherer the "emit" parameter is active 23 | raumkernel.on("mediaListDataPackageReady", function(_id, _mediaListDataPkg, _pkgIdx, _pgkIdxEnd, _pkgDataCount) 24 | { 25 | console.log('\033[2J'); 26 | console.log("--------------------------------"); 27 | console.log("Reading Data: " + (_pgkIdxEnd+1).toString()); 28 | console.log("--------------------------------"); 29 | }); 30 | 31 | 32 | // browse to root when system is ready 33 | raumkernel.on("systemReady", function(_ready){ 34 | browse("0"); 35 | }); 36 | 37 | 38 | function browse(_id, _backwards = false, _addToStack = true) 39 | { 40 | setLoadingScreen(_id); 41 | 42 | if(_backwards) 43 | idStack.pop(); 44 | else if(_addToStack) 45 | idStack.push(_id); 46 | 47 | tp0 = perfMeassure(); 48 | 49 | raumkernel.managerDisposer.mediaListManager.getMediaList(_id, _id, "", true, true, 25).then(function(_data){ 50 | tpDuration = perfMeassure(tp0); 51 | viewBrowseResult(_id, _data); 52 | }).catch(function(_data){ 53 | viewError(_data); 54 | }); 55 | } 56 | 57 | 58 | function viewBrowseResult(_id, _data) 59 | { 60 | console.log('\033[2J'); 61 | console.log(JSON.stringify(idStack) + " --> " + _id); 62 | console.log("--------------------------------"); 63 | 64 | if(_data && _data.length) 65 | { 66 | for(var i=0; i<_data.length; i++) 67 | { 68 | console.log(i.toString() + " - " + _data[i].title + " (" + _data[i].id + ")"); 69 | } 70 | } 71 | else 72 | { 73 | console.log("# No data available"); 74 | } 75 | console.log("--------------------------------"); 76 | console.log("Loading time: " + (tpDuration) + " ms"); 77 | console.log("--------------------------------"); 78 | console.log("x BROWSE BACK"); 79 | console.log("--------------------------------"); 80 | 81 | var readLine1 = Readline.createInterface({input: process.stdin, output: process.stdout}); 82 | readLine1.question('Choose ID: ', (_input) => { 83 | readLine1.close(); 84 | if(_data && _data[_input]) 85 | { 86 | browse(_data[_input].id); 87 | } 88 | else 89 | { 90 | if(_input.toLowerCase() == "x" && idStack.length > 1) 91 | { 92 | var parentId = idStack[idStack.length-2]; 93 | browse(parentId, true); 94 | } 95 | else 96 | { 97 | browse(_id, false, false); 98 | } 99 | } 100 | }); 101 | } 102 | 103 | 104 | function viewError(_data) 105 | { 106 | console.log('\033[2J'); 107 | console.log('Error occured: ' + _data); 108 | 109 | var readLine1 = Readline.createInterface({input: process.stdin, output: process.stdout}); 110 | readLine1.question("Press [ENTER] to start at root again!", (_input) => { 111 | readLine1.close(); 112 | idStack = []; 113 | browse("0"); 114 | }); 115 | } 116 | 117 | 118 | function setLoadingScreen(_id) 119 | { 120 | console.log('\033[2J'); 121 | console.log("Loading list for id :" + _id); 122 | console.log("Please wait! Certain id's can take a lot of time!"); 123 | } 124 | 125 | 126 | function setWelcomeScreen() 127 | { 128 | console.log('\033[2J'); 129 | console.log("Please wait till the raumfeld system is found!") 130 | } 131 | 132 | 133 | function execute(){ 134 | } 135 | 136 | 137 | setInterval(execute,1000); -------------------------------------------------------------------------------- /lib/lib.base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Logger = require('./lib.logger'); 3 | var EventEmitter = require("events").EventEmitter; 4 | var Tools = require('./lib.tools'); 5 | 6 | 7 | /** 8 | * This is the base class all classes will be derived from. 9 | * It contains some basic functionality like log methods 10 | */ 11 | module.exports = class Base extends EventEmitter 12 | { 13 | 14 | /** 15 | * Constructor of the base class 16 | * @return {Object} The base object 17 | */ 18 | constructor() 19 | { 20 | super(); 21 | //EventEmitter.call(this); 22 | this.logger = null; 23 | } 24 | 25 | /** 26 | * used to set or to get the logger object 27 | * @param {Object} the instanced of the logger 28 | * @return {Object} the instanced of the logger 29 | */ 30 | parmLogger(_logger = this.logger) 31 | { 32 | this.logger = _logger; 33 | return this.logger; 34 | } 35 | 36 | /** 37 | * used to return an additional log identifier for specifying the log origin 38 | * @return {String} additional log identidier 39 | */ 40 | additionalLogIdentifier() 41 | { 42 | return ""; 43 | } 44 | 45 | /** 46 | * log to the logger object 47 | * @param {Number} the log type 48 | * @param {String} the log text 49 | * @param {Object} some additional meta data object 50 | */ 51 | log(_logType, _log, _metadata = null) 52 | { 53 | if(this.logger && _log) 54 | { 55 | if(this.additionalLogIdentifier()) 56 | //this.logger.log(_logType, "\x1b[90m[" + this.additionalLogIdentifier() + "]\x1b[0m" + " " + _log, _metadata); 57 | this.logger.log(_logType, "[" + this.additionalLogIdentifier() + "]" + " " + _log, _metadata); 58 | else 59 | this.logger.log(_logType, _log, _metadata); 60 | } 61 | } 62 | 63 | /** 64 | * log a error 65 | * @param {String} the log text 66 | * @param {Object} some additional meta data object 67 | */ 68 | logError(_log, _metadata = null) 69 | { 70 | this.log(0, _log, _metadata); 71 | } 72 | 73 | /** 74 | * log a warning 75 | * @param {String} the log text 76 | * @param {Object} some additional meta data object 77 | */ 78 | logWarning(_log, _metadata = null) 79 | { 80 | this.log(1, _log, _metadata); 81 | } 82 | 83 | /** 84 | * log a info 85 | * @param {String} the log text 86 | * @param {Object} some additional meta data object 87 | */ 88 | logInfo(_log, _metadata = null) 89 | { 90 | this.log(2, _log, _metadata); 91 | } 92 | 93 | /** 94 | * log a verbose 95 | * @param {String} the log text 96 | * @param {Object} some additional meta data object 97 | */ 98 | logVerbose(_log, _metadata = null) 99 | { 100 | this.log(3, _log, _metadata); 101 | } 102 | 103 | /** 104 | * log a debug 105 | * @param {String} the log text 106 | * @param {Object} some additional meta data object 107 | */ 108 | logDebug(_log, _metadata = null) 109 | { 110 | this.log(4, _log, _metadata); 111 | } 112 | 113 | /** 114 | * log a silly 115 | * @param {String} the log text 116 | * @param {Object} some additional meta data object 117 | */ 118 | logSilly(_log, _metadata = null) 119 | { 120 | this.log(5, _log, _metadata); 121 | } 122 | 123 | 124 | /** 125 | * use this to encode a string for the content directory 126 | * @param {String} the string which should be encoded 127 | * @param {String} encoded string 128 | */ 129 | encodeString(_string) 130 | { 131 | try 132 | { 133 | return Tools.encodeString(_string) 134 | } 135 | catch (_exception) 136 | { 137 | this.logError("Error encoding string: " + _string); 138 | } 139 | return ""; 140 | } 141 | 142 | 143 | /** 144 | * use this to decode a string for the content directory 145 | * @param {String} the string which should be decoded 146 | * @param {String} decoded string 147 | */ 148 | decodeString(_string) 149 | { 150 | try 151 | { 152 | return Tools.decodeString(_string) 153 | } 154 | catch (_exception) 155 | { 156 | this.logError("Error decoding string: " + _string); 157 | } 158 | return ""; 159 | } 160 | } 161 | 162 | -------------------------------------------------------------------------------- /lib/lib.device.upnp.mediaServer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var UPNPDevice = require('./lib.device.base.upnp'); 3 | 4 | /** 5 | * this is the base class to use for child's which are media server devices 6 | */ 7 | module.exports = class UPNPMediaServer extends UPNPDevice 8 | { 9 | constructor(_upnpClient) 10 | { 11 | super(_upnpClient); 12 | } 13 | 14 | 15 | isRaumfeldServer() 16 | { 17 | return false; 18 | } 19 | 20 | 21 | subscribe() 22 | { 23 | var self = this; 24 | if(!this.upnpClient) 25 | { 26 | this.logError("Trying to subscribe to services on device '" + this.name() + "' without client object"); 27 | return; 28 | } 29 | 30 | this.upnpClient.on("error", function(_err) { 31 | self.logError(_err); 32 | }); 33 | 34 | this.logVerbose("Set up ContentDirectory subscription on device '" + this.name() + "'") 35 | this.upnpClient.subscribe('ContentDirectory', this.contentDirectorySubscriptionCallback(this)); 36 | } 37 | 38 | 39 | unsubscribe() 40 | { 41 | if(!this.upnpClient) 42 | { 43 | this.logError("Trying to un-subscribe services on device '" + this.name() + "' without client object"); 44 | return; 45 | } 46 | 47 | this.logVerbose("Remove service subscriptions for device '" + this.name() + "'"); 48 | //this.upnpClient.unsubscribe("ContentDirectory", this.contentDirectorySubscriptionCallback(this)); 49 | this.upnpClient.unsubscribeAll("ContentDirectory"); 50 | } 51 | 52 | 53 | contentDirectorySubscriptionCallback(_self) 54 | { 55 | return function(_data) 56 | { 57 | _self.onContentDirectorySubscription(_data); 58 | } 59 | } 60 | 61 | 62 | /** 63 | * will be called when data of the ContentDirectory service was changed 64 | * @param {Object} a object with the changed data 65 | */ 66 | onContentDirectorySubscription(_keyDataArray) 67 | { 68 | this.logDebug("ContentDirectory subscription callback triggered on device '" + this.name() + "'"); 69 | this.managerDisposer.mediaListManager.loadMediaItemListsByContainerUpdateIds(this, _keyDataArray["ContainerUpdateIDs"]); 70 | } 71 | 72 | 73 | /** 74 | * enter the standby mode of a room 75 | * @return {Promise} a promise with a result data set 76 | */ 77 | browse(_objectId, _browseFlag = "BrowseDirectChildren", _filter = "*", _startingIndex = 0, _requestedCount = 0, _sortCriteria = "") 78 | { 79 | return this.callAction("ContentDirectory", "Browse", { "ObjectID" : _objectId, 80 | "BrowseFlag" : _browseFlag, 81 | "Filter" : _filter, 82 | "StartingIndex" : _startingIndex, 83 | "RequestedCount" : _requestedCount, 84 | "SortCriteria" : _sortCriteria 85 | }, 86 | function (_result){ 87 | return _result.Result; 88 | } 89 | ); 90 | } 91 | 92 | 93 | /** 94 | * enter the standby mode of a room 95 | * @return {Promise} a promise with a result data set 96 | */ 97 | search(_objectId, _searchCriteria = "", _filter = "*", _startingIndex = 0, _requestedCount = 0, _sortCriteria = "") 98 | { 99 | return this.callAction("ContentDirectory", "Search", { "ContainerID" : _objectId, 100 | "SearchCriteria" : _searchCriteria, 101 | "Filter" : _filter, 102 | "StartingIndex" : _startingIndex, 103 | "RequestedCount" : _requestedCount, 104 | "SortCriteria" : _sortCriteria 105 | }, 106 | function (_result){ 107 | return _result.Result; 108 | } 109 | ); 110 | } 111 | } -------------------------------------------------------------------------------- /lib/lib.discoverHostDevice.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Logger = require('./lib.logger'); 3 | var BaseManager = require('./lib.base.manager'); 4 | var ManagerDisposer = require('./lib.managerDisposer'); 5 | 6 | var SsdpClient = require("node-ssdp").Client; 7 | 8 | 9 | module.exports = class DiscoverHostDevice extends BaseManager 10 | { 11 | constructor() 12 | { 13 | super(); 14 | this.bonjourClient = null 15 | this.bonjourBrowser = null 16 | this.ssdpClient = null 17 | this.deviceFoundSign = false 18 | this.deviceLostSign = false 19 | } 20 | 21 | 22 | init() 23 | { 24 | var self = this 25 | 26 | self.stopDiscover() 27 | self.createBonjourClient() 28 | self.createSSDPClient(); 29 | 30 | } 31 | 32 | createBonjourClient() 33 | { 34 | if(this.bonjourClient) 35 | { 36 | this.bonjourClient.destroy() 37 | this.bonjourClient = null 38 | } 39 | this.bonjourClient = require('bonjour')() 40 | this.createBonjourBrowser() 41 | } 42 | 43 | createBonjourBrowser() 44 | { 45 | var self = this 46 | 47 | if(this.bonjourBrowser) 48 | this.bonjourBrowser.stop() 49 | this.bonjourBrowser = this.bonjourClient.find({}) 50 | 51 | this.bonjourBrowser.on("up", function (_service) { 52 | if(_service.fqdn.startsWith("RaumfeldControl")) 53 | { 54 | self.deviceFound(_service.referer.address, _service.fqdn, _service, "BONJOUR") 55 | } 56 | }) 57 | 58 | this.bonjourBrowser.on("down", function (_service) { 59 | if(_service.fqdn.startsWith("RaumfeldControl")) 60 | { 61 | self.deviceLost(_service.referer.address, _service.fqdn, _service, "BONJOUR") 62 | } 63 | }) 64 | } 65 | 66 | 67 | createSSDPClient() 68 | { 69 | var self = this 70 | 71 | if(this.ssdpClient) 72 | this.ssdpClient.stop() 73 | this.ssdpClient = new SsdpClient({explicitSocketBind : true}) 74 | 75 | this.ssdpClient.on('response', function (_headers, _statusCode, _rinfo) { 76 | if(_headers.ST && _headers.ST == "urn:schemas-raumfeld-com:device:ConfigDevice:1") 77 | self.deviceFound(_headers.LOCATION, "", _headers, "SSDP") 78 | }); 79 | 80 | this.ssdpClient.on('advertise-alive', function (_headers) { 81 | if(_headers.ST && _headers.ST == "urn:schemas-raumfeld-com:device:ConfigDevice:1") 82 | self.deviceFound(_headers.LOCATION, "", _headers, "SSDP") 83 | }); 84 | 85 | this.ssdpClient.on('advertise-bye', function (_headers) { 86 | if(_headers.ST && _headers.ST == "urn:schemas-raumfeld-com:device:ConfigDevice:1") 87 | self.deviceLost(_headers.LOCATION, "", _headers, "SSDP") 88 | }); 89 | 90 | self.ssdpClient.search('urn:schemas-raumfeld-com:device:ConfigDevice:1'); 91 | } 92 | 93 | 94 | deviceFound(_address, _name, _service, _type) 95 | { 96 | if(!this.deviceFoundSign) 97 | { 98 | this.deviceFoundSign = true 99 | this.emit("deviceFound", { "address" : _address, "name" : _name, "type" : _type, origService: _service }) 100 | } 101 | } 102 | 103 | deviceLost(_address, _name, _service, _type) 104 | { 105 | if(!this.devicLostSign) 106 | { 107 | this.deviceLostSign = true 108 | this.emit("deviceLost", { "address" : _address, "name" : _name, "type" : _type, origService: _service }) 109 | } 110 | } 111 | 112 | 113 | startDiscover() 114 | { 115 | this.logDebug("Start HOST discovering") 116 | // start discovering with both types of discover (bonjour and ssdp) 117 | // the one who will find the device firts is the winner 118 | if(this.bonjourBrowser) 119 | this.bonjourBrowser.start() 120 | if(this.ssdpClient) 121 | this.ssdpClient.start() 122 | } 123 | 124 | 125 | stopDiscover() 126 | { 127 | this.logDebug("Stop HOST discovering") 128 | if(this.bonjourBrowser) 129 | this.bonjourBrowser.stop() 130 | if(this.ssdpClient) 131 | this.ssdpClient.stop(); 132 | } 133 | 134 | 135 | updateDiscover() 136 | { 137 | this.logDebug("Updateing HOST dicovery...") 138 | 139 | // clear found devices list for the update to catch again 140 | this.deviceFoundSign = false 141 | this.deviceLostSign = false 142 | 143 | // recreate bonjour and ssdp client to be sure it works well 144 | this.createBonjourBrowser() 145 | this.bonjourBrowser.start() 146 | this.createSSDPClient() 147 | this.ssdpClient.start() 148 | } 149 | 150 | } -------------------------------------------------------------------------------- /lib/lib.device.upnp.mediaServer.raumfeld.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var UPNPMediaServer = require('./lib.device.upnp.mediaServer'); 3 | 4 | /** 5 | * this is the class which should be used for the raumfeld media server 6 | */ 7 | module.exports = class UPNPMediaServerRaumfeld extends UPNPMediaServer 8 | { 9 | constructor(_upnpClient) 10 | { 11 | super(_upnpClient); 12 | } 13 | 14 | 15 | isRaumfeldServer() 16 | { 17 | return true; 18 | } 19 | 20 | /** 21 | * create a queue in a container id 22 | * @param {String} the desired name of the queue 23 | * @param {String} the container id where the queue has to be created 24 | * @return {Promise} a promise with som result 25 | */ 26 | createQueue(_desiredName, _containerId) 27 | { 28 | return this.callAction("ContentDirectory", "CreateQueue", { "DesiredName" : (_desiredName), "ContainerID" : (_containerId) }); 29 | } 30 | 31 | /** 32 | * add a container or parts of containers into a queue 33 | * @param {String} the queue id 34 | * @param {String} the container id 35 | * @param {String} the source id 36 | * @return {Promise} a promise with some result 37 | */ 38 | addContainerToQueue(_queueId, _containerId, _sourceId, _searchCriteria = "*", _sortCriteria = "", _startIndex = 0, _endIndex = 294967295, _position = 0) 39 | { 40 | return this.callAction("ContentDirectory", "AddContainerToQueue", { "QueueID" : (_queueId), "ContainerID" : (_containerId), "SourceID" : (_sourceId), "SearchCriteria" : _searchCriteria, "SortCriteria" : _sortCriteria, "StartIndex" : _startIndex, "EndIndex" : _endIndex, "Position" : _position }); 41 | } 42 | 43 | /** 44 | * add one item into a queue 45 | * @param {String} the queue id 46 | * @param {String} the objectId which has to be inserted 47 | * @param {Integer} the position where the item has to be inserted 48 | * @return {Promise} a promise with some result 49 | */ 50 | addItemToQueue(_queueId, _objectId, _position = 0) 51 | { 52 | return this.callAction("ContentDirectory", "AddItemToQueue", { "QueueID" : (_queueId), "ObjectID" : (_objectId), "Position" : _position }); 53 | } 54 | 55 | /** 56 | * remove items from queue 57 | * @param {String} the queue id 58 | * @param {Integer} from position 59 | * @param {Integer} to position 60 | * @return {Promise} a promise with some result 61 | */ 62 | removeFromQueue(_queueId, _fromPosition, _toPosition) 63 | { 64 | return this.callAction("ContentDirectory", "RemoveFromQueue", { "QueueID" : (_queueId), "FromPosition" : _fromPosition, "ToPosition" : _toPosition }); 65 | } 66 | 67 | /** 68 | * rename queue 69 | * @param {String} the queue id 70 | * @param {String} the desired name 71 | * @return {Promise} a promise with some result 72 | */ 73 | renameQueue(_queueId, _desiredName) 74 | { 75 | return this.callAction("ContentDirectory", "RenameQueue", { "QueueID" : (_queueId), "DesiredName" : _desiredName }); 76 | } 77 | 78 | 79 | /** 80 | * move an object in a queue 81 | * @param {String} the object id (which includes the queue id) 82 | * @param {Integer} the new position of the object in the queue 83 | * @return {Promise} a promise with some result 84 | */ 85 | moveInQueue(_objectID, _newPosition) 86 | { 87 | return this.callAction("ContentDirectory", "MoveInQueue", { "ObjectID" : (_objectID), "NewPosition" : _newPosition }); 88 | } 89 | 90 | /** 91 | * destroys an object 92 | * @param {String} the object id to destroy 93 | * @return {Promise} a promise with some result 94 | */ 95 | destroyObject(_objectID) 96 | { 97 | return this.callAction("ContentDirectory", "DestroyObject", { "ObjectID" : (_objectID)}); 98 | } 99 | 100 | 101 | /** 102 | * creates a shuffle playlist 103 | * @param {String} container id where the shuffle should be generated from 104 | * @param {String} shuffle selection 105 | * @return {Promise} a promise with the playlist id and the playlist metadata 106 | */ 107 | shuffle(_shuffleContainerId, _shuffleSelection) 108 | { 109 | return this.callAction("ContentDirectory", "Shuffle", { "ContainerID" : _shuffleContainerId, "Selection" : _shuffleSelection }); 110 | } 111 | 112 | 113 | /** 114 | * assigns a station button for a renderer 115 | * @param {String} the renderer id for what the station button shoul be set 116 | * @param {Integer} the station button 117 | * @param {String} the object id to store 118 | * @return {Promise} a promise with some result 119 | */ 120 | assignStationButton(_rendererId, _buttonNum, _objectId) 121 | { 122 | return this.callAction("ContentDirectory", "AssignStationButton", { "Renderer" : (_rendererId), "Button" : _buttonNum, "ObjectId" : _objectId}); 123 | } 124 | 125 | /** 126 | * get the button assignement 127 | * @param {String} the renderer id for what the station button shoul be set 128 | * @param {Integer} the station button 129 | * @return {Promise} a promise with some result 130 | */ 131 | getStationButtonAssignment(_rendererId, _buttonNum) 132 | { 133 | return this.callAction("ContentDirectory", "GetStationButtonAssignment", { "Renderer" : (_rendererId), "Button" : _buttonNum}); 134 | } 135 | 136 | 137 | // TODO: @@@ 138 | // RescanSource 139 | // QueryDatabaseState 140 | // GetSourceInfo 141 | 142 | } -------------------------------------------------------------------------------- /lib/lib.device.upnp.mediaRenderer.raumfeld.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var UPNPMediaRenderer = require('./lib.device.upnp.mediaRenderer'); 3 | 4 | /** 5 | * this is the class for a virtual media renderer 6 | */ 7 | module.exports = class UPNPMediaRendererRaumfeld extends UPNPMediaRenderer 8 | { 9 | constructor(_upnpClient) 10 | { 11 | super(_upnpClient); 12 | } 13 | 14 | 15 | roomName() 16 | { 17 | // the room name is given in the zone configuration file, so every time we want to have the room name 18 | // we have to look up there. There is no other way of getting the room name 19 | return this.managerDisposer.zoneManager.getRoomNameForMediaRendererUDN(this.udn()); 20 | } 21 | 22 | roomUdn() 23 | { 24 | return this.managerDisposer.zoneManager.getRoomUdnForMediaRendererUDN(this.udn()); 25 | } 26 | 27 | 28 | isRaumfeldRenderer() 29 | { 30 | return true; 31 | } 32 | 33 | /** 34 | * starts a timer which will fade the renderer volume to a specific value 35 | * @return {Promise} a promise with nothing 36 | */ 37 | async fadeToVolume(_desiredVolume, _duration = 2000) 38 | { 39 | 40 | try 41 | { 42 | var self = this; 43 | 44 | // await the current volume from the renderer 45 | var currentVolume = await this.getVolume(); 46 | 47 | // create a new promise so that our caller can block and knows when we are finished 48 | return new Promise(function(_resolve, _reject){ 49 | 50 | try 51 | { 52 | _desiredVolume = parseInt(_desiredVolume); 53 | _duration = parseInt(_duration); 54 | currentVolume = parseInt(currentVolume); 55 | 56 | var volDifference = (_desiredVolume - currentVolume); 57 | var currentFadeVolume = currentVolume; 58 | 59 | // if there is no difference, the return 60 | if(!volDifference) 61 | _resolve({}); 62 | 63 | // calculate the time in MS for one volume step 64 | var timeForOneVolStep = _duration / Math.abs(volDifference); 65 | 66 | self.logDebug("Set fade to volume interval step to : " + timeForOneVolStep); 67 | 68 | // set an interval for the volume 1 step 69 | var volStepInterval = setInterval(function(){ 70 | if(volDifference > 0) 71 | currentFadeVolume += 1 72 | if(volDifference < 0) 73 | currentFadeVolume -= 1 74 | self.setVolume(currentFadeVolume); 75 | 76 | // if the volume is reached stop the interval 77 | if(currentFadeVolume == _desiredVolume || currentFadeVolume <= 0 || currentFadeVolume >= 100) 78 | { 79 | clearInterval(volStepInterval); 80 | _resolve({}); 81 | } 82 | 83 | }, timeForOneVolStep); 84 | } 85 | catch(exception) 86 | { 87 | self.logError("Error when fading volume on " + this.name()); 88 | _reject(exception); 89 | } 90 | }); 91 | } 92 | catch(_exception) 93 | { 94 | this.logError("Error when fading volume on " + this.name()); 95 | throw (_exception); 96 | } 97 | } 98 | 99 | 100 | /** 101 | * enter the standby mode of a room 102 | * @param {String} the UDN of the room which should go into standby mode 103 | * @return {Promise} a promise with no result 104 | */ 105 | enterManualStandby() 106 | { 107 | return this.callAction("AVTransport", "EnterManualStandby", {}); 108 | } 109 | 110 | 111 | /** 112 | * enter the automatic standby mode of a room 113 | * @param {String} the UDN of the room which should go into standby mode 114 | * @return {Promise} a promise with no result 115 | */ 116 | enterAutomaticStandby() 117 | { 118 | return this.callAction("AVTransport", "EnterAutomaticStandby", {}); 119 | } 120 | 121 | 122 | /** 123 | * enter the standby mode of a room 124 | * @param {String} the UDN of the room which should go into standby mode 125 | * @return {Promise} a promise with no result 126 | */ 127 | leaveStandby(_confirm = false) 128 | { 129 | //return this.callAction("AVTransport", "LeaveStandby", {}); 130 | return this.callActionWithTriggerWait("AVTransport", "LeaveStandby", {}, null, { "key" : "PowerState", "values" : ["ACTIVE", "IDLE"] } , _confirm) 131 | } 132 | 133 | 134 | /** 135 | * change the current volume for the renderer (for the full zone) 136 | * @param {Integer} the desired volume 137 | * @return {Promise} a promise with no result 138 | */ 139 | changeVolume(_amount) 140 | { 141 | return this.callAction("RenderingControl", "ChangeVolume", {"Amount": _amount}); 142 | } 143 | 144 | 145 | /** 146 | * change the current volume for the renderer (for the full zone) 147 | * @param {String} the sound id (Failure, Success) 148 | * @return {Promise} a promise with no result 149 | */ 150 | playSystemSound(_soundId) 151 | { 152 | return this.callAction("RenderingControl", "PlaySystemSound", {"Sound": _soundId}); 153 | } 154 | 155 | 156 | /** 157 | * returns the lineIns Stream url 158 | * @return {Promise} a promise with url and mimetype 159 | */ 160 | getLineInStream() 161 | { 162 | return this.callAction("RenderingControl", "GetLineInStreamURL", {}); 163 | } 164 | 165 | 166 | /** 167 | * sets the equalizer values 168 | * @return {Promise} a promise with nothing 169 | */ 170 | setFilter(_lowDB, _midDB, _highDB) 171 | { 172 | return this.callAction("RenderingControl", "SetFilter", {"LowDB" : _lowDB, "MidDB" : _midDB, "HighDB" : _highDB}); 173 | } 174 | 175 | 176 | /** 177 | * get the equalizer values 178 | * @return {Promise} a promise with the equalizer data 179 | */ 180 | getFilter() 181 | { 182 | return this.callAction("RenderingControl", "GetFilter", {}); 183 | } 184 | 185 | 186 | /** 187 | * sets a device setting 188 | * @param {string} id of the setting 189 | * SounbarId's : "Source Select" --> LineIn, OpticalIn, TV_ARC, Raumfeld 190 | * "Audio Mode" --> Arena, Voice, Theater ??? 191 | * "Subwoofer Playback Volume" --> ??? 192 | * "Subwoofer X-Over" --> ??? 193 | * and many more??? 194 | * @param {string} the value for the id 195 | * @return {Promise} a promise with nothing 196 | */ 197 | setDeviceSetting(_key, _value) 198 | { 199 | return this.callAction("RenderingControl", "SetDeviceSetting", {"Name" : _key, "Value" : _value }); 200 | } 201 | 202 | 203 | /** 204 | * get a device setting 205 | * @return {Promise} a promise with nothing 206 | */ 207 | getDeviceSetting(_key) 208 | { 209 | return this.callAction("RenderingControl", "GetDeviceSetting", {"Name" : _key }); 210 | } 211 | 212 | 213 | // AvTransport --> 214 | // setNextStartTriggerTime 215 | 216 | // RenderingControl --> 217 | // Get Balance 218 | // SetBalance 219 | // QueryFilter 220 | // ToggleFilter 221 | // SetVolumeDB 222 | // setNextAvTransportUri 223 | } -------------------------------------------------------------------------------- /lib/lib.manager.triggerManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Url = require('url'); 3 | var ManagerBase = require('./lib.manager.base'); 4 | 5 | 6 | module.exports = class TriggerManager extends ManagerBase 7 | { 8 | constructor() 9 | { 10 | super() 11 | this.triggers = {} 12 | } 13 | 14 | additionalLogIdentifier() 15 | { 16 | return "TriggerManager"; 17 | } 18 | 19 | /** 20 | * trigger a change in a state of the system (renderer, zones, ...) 21 | * @param {String} context of the trigger 22 | * @param {String} type of the trigger 23 | * @param {Object} dta for the trigger 24 | * @return {Promise} ??? 25 | */ 26 | trigger(_context, _type, _data) 27 | { 28 | var triggerFound = true; 29 | // search triggers for valid trigger and do a shot if found. If its a one shot trigger, then remove it from the array 30 | // i have to keep an eye on the performance here... 31 | var mapKey = this.createTriggerKey(_context, _type, _data) 32 | //while(triggerFound) // oh my goodness... endless loop :-) 33 | { 34 | if(this.triggers[mapKey] && this.triggers[mapKey].length) 35 | { 36 | var triggerArray = this.triggers[mapKey] 37 | var i = triggerArray.length 38 | while (i--) 39 | { 40 | var keyValueIsSet = false 41 | if(_context == "renderer" && _type == "rendererStateKeyValueChanged") 42 | { 43 | // we have to bes sure we trigger for the right renderer 44 | if(triggerArray[i].data.rendererUdn && triggerArray[i].data.rendererUdn != _data.rendererUdn) 45 | continue 46 | 47 | // we may have ranged a room in the trigger event, so check it 48 | if(triggerArray[i].data.roomUdn && triggerArray[i].data.roomUdn != _data.roomUdn) 49 | continue 50 | 51 | if(triggerArray[i].data.values) 52 | { 53 | for(var x=0; x 0) 235 | { 236 | var mediaRenderer = self.managerDisposer.deviceManager.getVirtualMediaRenderer(_rendererUdn); 237 | if (mediaRenderer) 238 | { 239 | // in this case we came here the rendere should have a queue (container) refereced so we can use this queue 240 | //mediaRenderer.loadContainer(mediaRenderer.mediaOriginData.containerId, "", foundTrackNumber, true); 241 | mediaRenderer.loadContainer(self.getQueueIdFromNameAndBase(self.getQueueNameFromRendererUdn(_rendererUdn)), "", foundTrackNumber, true).then(function(_data){ 242 | _resolve(foundTrackNumber); 243 | }).catch(function(_error){ 244 | _reject(_error); 245 | }); 246 | } 247 | else 248 | { 249 | _reject(new Error("No media renderer with udn '" + _rendererUdn + "' found!")); 250 | } 251 | return; 252 | } 253 | 254 | // we came here if the track number was not found, that means we may have deleted it? 255 | // TODO: not really sure what we should do here, we may set the query again with the current track number (no binding) 256 | _resolve(foundTrackNumber); 257 | 258 | }).catch(function(_data){ 259 | _reject(_data); 260 | }); 261 | }); 262 | } 263 | 264 | } -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Raumkernel = require('./lib/lib.raumkernel'); 3 | 4 | var raumkernel = new Raumkernel(); 5 | 6 | raumkernel.settings.raumfeldHost = "0.0.0.0" 7 | 8 | raumkernel.createLogger(5); 9 | raumkernel.logger.on('log', (_logData) =>{ 10 | console.log(`${_logData.logType}: ${_logData.log}`); 11 | }) 12 | raumkernel.init(); 13 | 14 | /* 15 | raumkernel.on("zoneCreated", function(_zoneUDN) { raumkernel.logWarning("Zone created: " + _zoneUDN); }); 16 | raumkernel.on("zoneRemoved", function(_zoneUDN) { raumkernel.logError("Zone removed: " + _zoneUDN); }); 17 | raumkernel.on("roomAddedToZone", function(_zoneUDN, _roomUDN) { raumkernel.logWarning("Room: " + _roomUDN + " added to zone: " + _zoneUDN); }); 18 | raumkernel.on("roomRemovedFromZone", function(_zoneUDN, _roomUDN) { raumkernel.logError("Room: " + _roomUDN + " removed from zone: " + _zoneUDN); }); 19 | */ 20 | 21 | 22 | 23 | raumkernel.on("systemReady", function(_ready){ 24 | raumkernel.logInfo("System ready: " + _ready); 25 | 26 | // 27 | 28 | 29 | 30 | //raumkernel.logWarning("Try create playlist"); 31 | //raumkernel.nativePlaylistController.createPlaylist("RAUMKERNELTEST").then(function(){ 32 | //raumkernel.logWarning("Try rename playlist"); 33 | //raumkernel.nativePlaylistController.renamePlaylist("RAUMKERNELTEST", "RAUMKERNELTEST X") 34 | 35 | //raumkernel.logWarning("Add a container item to playlist"); 36 | //raumkernel.nativePlaylistController.addItemToPlaylist("RAUMKERNELTEST", "0/My Music/Artists/4%20Non%20Blondes/4%20Non%20Blondes+What%27s%20Up", 294967295, true); 37 | 38 | //raumkernel.logWarning("Add one item to playlist"); 39 | //raumkernel.nativePlaylistController.addItemToPlaylist("RAUMKERNELTEST", "0/My Music/Artists/Dido/Dido+No%20Angel/c7e7ad4423927a75c5017b2640db6574"); 40 | 41 | //raumkernel.logWarning("Mobe item in playlist"); 42 | //raumkernel.nativePlaylistController.moveItemInPlaylist("RAUMKERNELTEST", "0/Playlists/MyPlaylists/RAUMKERNELTEST/31990", 1); 43 | 44 | //raumkernel.logWarning("remove items from playlist"); 45 | //raumkernel.nativePlaylistController.removeItemsFromPlaylist("RAUMKERNELTEST", 1, 1); 46 | 47 | //var mediaRenderer = raumkernel.managerDisposer.deviceManager.getVirtualMediaRenderer("Küche") 48 | 49 | //var rendererUdns = mediaRenderer.getRoomRendererUDNs(); 50 | //console.log(JSON.stringify(rendererUdns)); 51 | //mediaRenderer.loadPlaylist("Rock", 2).catch(function(_data){ 52 | // console.log(_data.toString()); 53 | //}); 54 | 55 | /*mediaRenderer.loadUri("http://mp3channels.webradio.rockantenne.de/heavy-metal").catch(function(_data){ 56 | console.log(_data.toString()); 57 | });*/ 58 | 59 | /* 60 | if (source == "recentartists") 61 | source = "0/Playlists/Shuffles/RecentArtists"; 62 | if (source == "topartists") 63 | source = "0/Playlists/Shuffles/TopArtists"; 64 | if (source == "all") 65 | source = "0/Playlists/Shuffles/All"; 66 | // on following types we can add selections 67 | if (source == "genre") 68 | source = "0/Playlists/Shuffles/Genre"; 69 | if (source == "genre") 70 | source = "0/Playlists/Shuffles/Artists"; 71 | 72 | 73 | object.container.playlistContainer.shuffle 0/Playlists/Shuffles/RecentArtists 74 | object.container.playlistContainer.shuffle 0/Playlists/Shuffles/TopArtists 75 | object.container.playlistContainer.shuffle 0/Playlists/Shuffles/All 76 | object.container.playlistContainer.shuffle.search 0/Playlists/Shuffles/Genre 77 | object.container.playlistContainer.shuffle.search 0/Playlists/Shuffles/Artists 78 | */ 79 | 80 | 81 | //mediaRenderer.loadShuffle("0/Playlists/Shuffles/All", "").catch(function(_data){ 82 | // console.log(_data.toString()); 83 | //}); 84 | 85 | 86 | setTimeout(function(){ 87 | /* 88 | raumkernel.logWarning("Trying to add a media item to a zone playlist"); 89 | raumkernel.zonePlaylistController.addItemToPlaylist(mediaRenderer.udn(), "0/My Music/Artists/Dido/Dido+No%20Angel/c7e7ad4423927a75c5017b2640db6574", 0).then(function(_data){ 90 | raumkernel.logWarning(_data); 91 | }).catch(function(_data){ 92 | raumkernel.logWarning(_data); 93 | }); 94 | */ 95 | 96 | var mediaRenderer = raumkernel.managerDisposer.deviceManager.getMediaRenderer("Küche") 97 | var mediaRendererVirtual = raumkernel.managerDisposer.deviceManager.getVirtualMediaRenderer("Küche") 98 | mediaRendererVirtual.leaveStandby(raumkernel.managerDisposer.zoneManager.getRoomUdnForMediaRendererUDN(mediaRenderer.udn()), true).then(function(){ 99 | var nice = "" 100 | }).catch(function(){ 101 | var nice = "" 102 | }) 103 | 104 | 105 | 106 | //var mediaRendererK = raumkernel.managerDisposer.deviceManager.getVirtualMediaRenderer("Küche") 107 | //var mediaRendererB = raumkernel.managerDisposer.deviceManager.getVirtualMediaRenderer("Bad") 108 | //var rendererUdns = mediaRenderer.getRoomRendererUDNs(); 109 | //console.log(JSON.stringify(rendererUdns)); 110 | //mediaRenderer.loadLineIn("Schlafzimmer"); 111 | 112 | var confirm = true; 113 | 114 | /* 115 | 116 | mediaRendererK.loadPlaylist("Rock", 2, confirm).then(function(_data){ 117 | raumkernel.logWarning("Playlist Rock loaded"); 118 | mediaRendererK.loadShuffle("0/Playlists/Shuffles/All", "", false, confirm).then(function(_data){ 119 | raumkernel.logWarning("Schuffle All loaded"); 120 | mediaRendererK.loadUri("http://mp3channels.webradio.rockantenne.de/heavy-metal", false, confirm).then(function(_data){ 121 | raumkernel.logWarning("Rockantenne loaded"); 122 | mediaRendererK.loadLineIn("Schlafzimmer", confirm).then(function(_data){ 123 | raumkernel.logWarning("Line In loaded"); 124 | mediaRendererK.loadPlaylist("Rock", 2, confirm).then(function(_data){ 125 | raumkernel.logWarning("Done!!!", _data); 126 | }).catch(function(_data){ 127 | raumkernel.logError("Catched", _data); 128 | }); 129 | }).catch(function(_data){ 130 | raumkernel.logError("Catched", _data); 131 | }); 132 | }).catch(function(_data){ 133 | raumkernel.logError("Catched", _data); 134 | }); 135 | }).catch(function(_data){ 136 | raumkernel.logError("Catched", _data); 137 | }); 138 | }).catch(function(_data){ 139 | raumkernel.logError("Catched", _data); 140 | }); 141 | 142 | */ 143 | 144 | /*mediaRenderer.loadUri("http://mp3channels.webradio.rockantenne.de/heavy-metal").catch(function(_data){ 145 | console.log(_data.toString()); 146 | });*/ 147 | 148 | //mediaRenderer.loadShuffle("0/Playlists/Shuffles/All", "").catch(function(_data){ 149 | // console.log(_data.toString()); 150 | //}); 151 | 152 | 153 | /* 154 | mediaRendererK.next(confirm).then(function(_data){ 155 | mediaRendererK.prev(confirm).then(function(_data){ 156 | raumkernel.logWarning("Done!!!", _data); 157 | }).catch(function(_data){ 158 | raumkernel.logError("Catched", _data); 159 | }); 160 | }).catch(function(_data){ 161 | raumkernel.logError("Catched", _data); 162 | }); 163 | */ 164 | 165 | 166 | /* 167 | raumkernel.logInfo("SetMute true"); 168 | mediaRendererK.setMute(true, confirm).then(function(_data){ 169 | raumkernel.logInfo("SetVolume 33"); 170 | mediaRendererK.setVolume(33, confirm).then(function(_data){ 171 | //mediaRendererK.setMute(true, confirm).then(function(_data){ 172 | raumkernel.logInfo("SetMute false"); 173 | mediaRendererK.setMute(false, confirm).then(function(_data){ 174 | raumkernel.logInfo("SetVolume 22"); 175 | mediaRendererK.setVolume(22, confirm).then(function(_data){ 176 | raumkernel.logWarning("Done!!!", _data); 177 | }).catch(function(_data){ 178 | raumkernel.logError("Catched", _data); 179 | }); 180 | }).catch(function(_data){ 181 | raumkernel.logError("Catched", _data); 182 | }); 183 | }).catch(function(_data){ 184 | raumkernel.logError("Catched", _data); 185 | }); 186 | }).catch(function(_data){ 187 | raumkernel.logError("Catched", _data); 188 | }); 189 | */ 190 | 191 | /* 192 | mediaRendererB.pause(true).then(function(_data){ 193 | raumkernel.logWarning("Paused", _data); 194 | 195 | mediaRendererB.play(true).then(function(_data){ 196 | raumkernel.logWarning("Playing", _data); 197 | 198 | mediaRendererB.setPlayMode("REPEAT_ALL", true).then(function(_data){ 199 | raumkernel.logWarning("REPEAT_ALL", _data); 200 | }).catch(function(_data){ 201 | raumkernel.logError("Catched", _data); 202 | }); 203 | 204 | }).catch(function(_data){ 205 | raumkernel.logError("Catched", _data); 206 | }); 207 | 208 | }).catch(function(_data){ 209 | raumkernel.logError("Catched", _data); 210 | }); 211 | */ 212 | 213 | 214 | 215 | 216 | }, 5000); 217 | 218 | 219 | 220 | 221 | /*setTimeout(function(){ 222 | 223 | raumkernel.logWarning("Trying to move a media item in a zone playlist"); 224 | raumkernel.zonePlaylistController.moveItemInPlaylist(mediaRenderer.udn(), "0/Zones/uuid%3A00000000-5416-48eb-0000-0000541648eb/33761", 1).then(function(_data){ 225 | raumkernel.logWarning(_data); 226 | }).catch(function(_data){ 227 | raumkernel.logWarning(_data); 228 | }); 229 | 230 | 231 | }, 15000);*/ 232 | 233 | 234 | /* 235 | setTimeout(function(){ 236 | 237 | raumkernel.logWarning("Trying to delete first two item of zone playlist"); 238 | raumkernel.zonePlaylistController.removeItemsFromPlaylist(mediaRenderer.udn(), 0, 1).then(function(_data){ 239 | raumkernel.logWarning(_data); 240 | }).catch(function(_data){ 241 | raumkernel.logWarning(_data); 242 | }); 243 | 244 | 245 | }, 15000);*/ 246 | 247 | 248 | 249 | 250 | //}); 251 | //raumkernel.nativePlaylistController.deletePlaylist("RAUMKERNELTEST (2)").catch(function(){}); 252 | //raumkernel.nativePlaylistController.deletePlaylist("RAUMKERNELTEST"); 253 | //raumkernel.nativePlaylistController.deletePlaylist("RAUMKERNELTEST X"); 254 | 255 | }); 256 | 257 | raumkernel.on("mediaListReady", function(_listId, _data){ 258 | //raumkernel.logInfo("MediaList ready: " + _listId); 259 | //raumkernel.logWarning(JSON.stringify(_data)); 260 | }); 261 | 262 | raumkernel.on("rendererMediaItemDataChanged", function(_mediaRenderer, _data){ 263 | //raumkernel.logInfo("MediaItem changed: " + JSON.stringify(_data)); 264 | //raumkernel.logWarning(JSON.stringify(_data)); 265 | }); 266 | 267 | raumkernel.on("mediaRendererPlaylistReady", function(_rendererUdn, _data){ 268 | //raumkernel.logInfo("mediaRendererPlaylistReady ready: " + _rendererUdn); 269 | //raumkernel.logWarning(JSON.stringify(_data)); 270 | }); 271 | 272 | raumkernel.on("mediaServerRaumfeldAdded", function(_udn, _mediaServer){ 273 | 274 | //raumkernel.logWarning("Raumfeld Media Server found!"); 275 | /* 276 | _mediaServer.browse("0").then(function(_data){ 277 | raumkernel.logWarning(JSON.stringify(_data)); 278 | }) 279 | 280 | _mediaServer.browse("0/My Music").then(function(_data){ 281 | raumkernel.logWarning(JSON.stringify(_data)); 282 | }) 283 | */ 284 | /* 285 | raumkernel.managerDisposer.mediaListManager.searchMediaList("DUMMYLISTID", "0/RadioTime/Search", "OE3").then(function(_data){ 286 | raumkernel.logWarning(JSON.stringify(_data)); 287 | }) 288 | */ 289 | 290 | 291 | /* 292 | raumkernel.managerDisposer.mediaListManager.getMediaList("0", 0).then(function(_data){ 293 | raumkernel.logWarning(JSON.stringify(_data)); 294 | }).catch(function(_data){ 295 | raumkernel.logError(JSON.stringify(_data)); 296 | }); 297 | */ 298 | 299 | }); 300 | 301 | 302 | function execute(){ 303 | 304 | } 305 | 306 | setInterval(execute,1000); -------------------------------------------------------------------------------- /lib/lib.mediaDataConverter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var BaseManager = require('./lib.base.manager'); 3 | var ParseString = require('xml2js').parseString; 4 | 5 | module.exports = class Raumkernel extends BaseManager 6 | { 7 | constructor() 8 | { 9 | super(); 10 | } 11 | 12 | additionalLogIdentifier() 13 | { 14 | return "MediaDataConverter"; 15 | } 16 | 17 | 18 | convertXMLToMediaList(_xmlString) 19 | { 20 | var self = this; 21 | 22 | return new Promise(function(_resolve, _reject){ 23 | 24 | // direct conversion to json 25 | ParseString(_xmlString, function (_err, _result) { 26 | if(!_err && _result) 27 | { 28 | try 29 | { 30 | var jsonMediaList = []; 31 | var containerId = ""; 32 | 33 | if(!_result["DIDL-Lite"]) 34 | { 35 | self.logError("Wrong formatted XML", { "xml": _xmlString } ); 36 | _reject(new Error("Wrong formatted XML")); 37 | return; 38 | } 39 | 40 | // we may get an empty list 41 | if((!_result["DIDL-Lite"].item && !_result["DIDL-Lite"].container)) 42 | { 43 | self.logDebug("Got empty list"); 44 | _resolve(jsonMediaList); 45 | return; 46 | } 47 | 48 | // The result is a direct conversion from an xml to a json object. 49 | // That's ok but ist not very nice for handling, so we do a conversion. 50 | // If we will get into a performance problem we should consider to change this code to parse directly into a nice 51 | // json format without the step xml to xml-json to nice-json 52 | 53 | // there may be 2 types of containers that are returned. "items"" and "containers" and both may may be in the same result 54 | // so we have to 'loop' over the 2 types beginning with the containers 55 | for(var type=1; type<=2; type++) 56 | { 57 | 58 | containerId = ""; 59 | if(type === 1 && _result["DIDL-Lite"].container) 60 | containerId = "container"; 61 | if(type === 2 && _result["DIDL-Lite"].item) 62 | containerId = "item"; 63 | 64 | if(containerId) 65 | { 66 | var containerArray = _result["DIDL-Lite"][containerId]; 67 | for (var item of containerArray) 68 | { 69 | try 70 | { 71 | jsonMediaList.push(self.convertContainer(item)); 72 | } 73 | catch(_exception) 74 | { 75 | // to keep the correct size of the array we put a dummy media info into the lists 76 | jsonMediaList.push({ "title": "UNKNOWN" }); 77 | self.logError("Error converting media item: " + JSON.stringify(item), _exception); 78 | } 79 | } 80 | } 81 | } 82 | 83 | 84 | _resolve(jsonMediaList); 85 | 86 | } 87 | catch(_exception) 88 | { 89 | self.logError("Error converting media item list", { "xml": _xmlString } ); 90 | _reject(_exception); 91 | } 92 | } 93 | else 94 | { 95 | self.logError("Error parsing media item list", { "xml": _xmlString } ); 96 | _reject(new Error("Error parsing media item list")); 97 | } 98 | }); 99 | }); 100 | } 101 | 102 | 103 | convertContainer(_mediaContainer) 104 | { 105 | var newObject = {}; 106 | 107 | // copy all the main keys 108 | this.copyRootData(newObject, _mediaContainer); 109 | 110 | switch (newObject.class.toLowerCase()) 111 | { 112 | case "object.container": 113 | this.copyContainerData(newObject, _mediaContainer); 114 | break; 115 | case "object.container.person.musicartist": 116 | this.copyArtistData(newObject, _mediaContainer); 117 | break; 118 | case "object.item.audioitem.musictrack": 119 | this.copyTrackData(newObject, _mediaContainer); 120 | break; 121 | case "object.container.album.musicalbum": 122 | this.copyAlbumData(newObject, _mediaContainer); 123 | break; 124 | case "object.container.trackcontainer.alltracks": 125 | this.copyAlbumData(newObject, _mediaContainer); 126 | break; 127 | case "object.item.audioitem.audiobroadcast.radio": 128 | this.copyRadioData(newObject, _mediaContainer); 129 | break; 130 | case "object.container.favoritescontainer": 131 | this.copyFavouritesContainerData(newObject, _mediaContainer); 132 | break; 133 | case "object.container.playlistcontainer": 134 | this.copyPlaylistContainerData(newObject, _mediaContainer); 135 | break; 136 | case "object.item.audioitem.audiobroadcast.linein": 137 | this.copyLineInData(newObject, _mediaContainer); 138 | break; 139 | case "object.container.album.musicalbum.compilation": 140 | this.copyMusicalbumCompilationData(newObject, _mediaContainer); 141 | break; 142 | case "object.container.playlistcontainer.shuffle": 143 | this.copyPlaylistcontainerShuffleData(newObject, _mediaContainer); 144 | break; 145 | case "object.container.playlistcontainer.queue": 146 | this.copyPlaylistcontainerQueueData(newObject, _mediaContainer); 147 | break; 148 | case "object.container.albumcontainer": 149 | this.copyAlbumcontainerData(newObject, _mediaContainer); 150 | break; 151 | case "object.container.genre.musicgenre": 152 | this.copyGenreMusicgenreData(newObject, _mediaContainer); 153 | break; 154 | case "object.container.person.musiccomposer": 155 | this.copyMusiccomposerData(newObject, _mediaContainer); 156 | break; 157 | case "object.container.storagefolder": 158 | this.copyStorageFolderData(newObject, _mediaContainer); 159 | break; 160 | case "object.container.playlistcontainer.shuffle.search": 161 | this.copyPlaylistcontainerShuffleSearchData(newObject, _mediaContainer); 162 | break; 163 | case "object.container.trackcontainer": 164 | this.copyTrackContainerData(newObject, _mediaContainer); 165 | break; 166 | case "object.item.audioitem.audiobroadcast.rhapsody": 167 | this.copyAudiobroadcastRhapsodyData(newObject, _mediaContainer); 168 | break; 169 | default: 170 | this.logWarning("Unhandled media item type: " + newObject.class.toLowerCase(), _mediaContainer); 171 | this.copyContainerData(newObject, _mediaContainer); 172 | } 173 | 174 | return newObject; 175 | } 176 | 177 | 178 | getData(_object, _id, _stdValue = null) 179 | { 180 | if(_object[_id]) 181 | { 182 | if(_object[_id].length) 183 | return _object[_id][0]; 184 | else 185 | return _object[_id]; 186 | } 187 | return _stdValue; 188 | } 189 | 190 | 191 | copyRootData(_newObject, _mediaContainer) 192 | { 193 | _newObject["class"] = this.getData(_mediaContainer, "upnp:class"); 194 | _newObject["section"] = this.getData(_mediaContainer, "raumfeld:section"); 195 | _newObject["name"] = this.getData(_mediaContainer, "raumfeld:name"); 196 | _newObject["durability"] = this.getData(_mediaContainer, "raumfeld:durability"); 197 | _newObject["childCount"] = this.getData(_mediaContainer, "childCount"); 198 | 199 | for(var key in _mediaContainer.$) 200 | { 201 | _newObject[key] = _mediaContainer.$[key]; 202 | } 203 | } 204 | 205 | 206 | copyContainerData(_newObject, _mediaContainer) 207 | { 208 | _newObject["title"] = this.getData(_mediaContainer, "dc:title"); 209 | _newObject["description"] = this.getData(_mediaContainer, "dc:description"); 210 | } 211 | 212 | 213 | copyArtistData(_newObject, _mediaContainer) 214 | { 215 | this.copyContainerData(_newObject, _mediaContainer); 216 | _newObject["artist"] = this.getData(_mediaContainer, "upnp:artist"); 217 | 218 | // if there is an "album art uri" then use it 219 | if(_mediaContainer["upnp:albumArtURI"] && _mediaContainer["upnp:albumArtURI"][0]) 220 | _newObject["albumArtURI"] = _mediaContainer["upnp:albumArtURI"][0]._; 221 | } 222 | 223 | 224 | copyAlbumData(_newObject, _mediaContainer) 225 | { 226 | this.copyArtistData(_newObject, _mediaContainer); 227 | this.copyGenreData(_newObject, _mediaContainer); 228 | _newObject["album"] = this.getData(_mediaContainer, "upnp:album"); 229 | _newObject["date"] = this.getData(_mediaContainer, "dc:date"); 230 | _newObject["creator"] = this.getData(_mediaContainer, "dc:creator"); 231 | } 232 | 233 | 234 | copyGenreData(_newObject, _mediaContainer) 235 | { 236 | _newObject["genre"] = this.getData(_mediaContainer, "dc:genre"); 237 | } 238 | 239 | 240 | copyTrackData(_newObject, _mediaContainer) 241 | { 242 | this.copyAlbumData(_newObject, _mediaContainer); 243 | 244 | _newObject["originalTrackNumber"] = this.getData(_mediaContainer, "upnp:originalTrackNumber"); 245 | 246 | // if there is a "res" section the copy the data from there 247 | if(_mediaContainer["res"] && _mediaContainer["res"].length) 248 | { 249 | for(var key in _mediaContainer.res[0].$) 250 | { 251 | _newObject[key] = _mediaContainer.res[0].$[key]; 252 | } 253 | } 254 | } 255 | 256 | 257 | copyRadioData(_newObject, _mediaContainer) 258 | { 259 | this.copyTrackData(_newObject, _mediaContainer); 260 | 261 | _newObject["signalStrength"] = this.getData(_mediaContainer, "upnp:signalStrength"); 262 | _newObject["ebrowse"] = this.getData(_mediaContainer, "raumfeld:ebrowse"); 263 | } 264 | 265 | 266 | copyFavouritesContainerData(_newObject, _mediaContainer) 267 | { 268 | this.copyContainerData(_newObject, _mediaContainer); 269 | } 270 | 271 | 272 | copyPlaylistContainerData(_newObject, _mediaContainer) 273 | { 274 | this.copyContainerData(_newObject, _mediaContainer); 275 | } 276 | 277 | 278 | copyLineInData(_newObject, _mediaContainer) 279 | { 280 | this.copyContainerData(_newObject, _mediaContainer); 281 | 282 | if(_mediaContainer["res"] && _mediaContainer["res"].length) 283 | { 284 | if(_mediaContainer["res"][0]["_"]) 285 | _newObject["stream"] = _mediaContainer["res"][0]["_"]; 286 | if(_mediaContainer["res"][0]["$"] && _mediaContainer["res"][0]["$"]["protocolInfo"]) 287 | _newObject["protocolInfo"] = _mediaContainer["res"][0]["$"]["protocolInfo"]; 288 | } 289 | } 290 | 291 | 292 | copyMusicalbumCompilationData(_newObject, _mediaContainer) 293 | { 294 | this.copyAlbumData(_newObject, _mediaContainer); 295 | } 296 | 297 | 298 | copyPlaylistcontainerShuffleData(_newObject, _mediaContainer) 299 | { 300 | this.copyContainerData(_newObject, _mediaContainer); 301 | } 302 | 303 | 304 | copyPlaylistcontainerQueueData(_newObject, _mediaContainer) 305 | { 306 | this.copyContainerData(_newObject, _mediaContainer); 307 | } 308 | 309 | 310 | copyAlbumcontainerData(_newObject, _mediaContainer) 311 | { 312 | this.copyContainerData(_newObject, _mediaContainer); 313 | } 314 | 315 | 316 | copyGenreMusicgenreData(_newObject, _mediaContainer) 317 | { 318 | this.copyContainerData(_newObject, _mediaContainer); 319 | this.copyGenreData(_newObject, _mediaContainer); 320 | } 321 | 322 | 323 | copyMusiccomposerData(_newObject, _mediaContainer) 324 | { 325 | this.copyContainerData(_newObject, _mediaContainer); 326 | _newObject["numberOfAlbums"] = this.getData(_mediaContainer, "numberOfAlbums"); 327 | } 328 | 329 | 330 | copyStorageFolderData(_newObject, _mediaContainer) 331 | { 332 | this.copyContainerData(_newObject, _mediaContainer); 333 | } 334 | 335 | 336 | copyPlaylistcontainerShuffleSearchData(_newObject, _mediaContainer) 337 | { 338 | this.copyContainerData(_newObject, _mediaContainer); 339 | } 340 | 341 | 342 | copyTrackContainerData(_newObject, _mediaContainer) 343 | { 344 | this.copyContainerData(_newObject, _mediaContainer); 345 | 346 | // if there is an "album art uri" then use it 347 | if(_mediaContainer["upnp:albumArtURI"] && _mediaContainer["upnp:albumArtURI"][0]) 348 | _newObject["albumArtURI"] = _mediaContainer["upnp:albumArtURI"][0]._; 349 | } 350 | 351 | 352 | copyAudiobroadcastRhapsodyData(_newObject, _mediaContainer) 353 | { 354 | this.copyAlbumData(_newObject, _mediaContainer); 355 | } 356 | 357 | } 358 | 359 | -------------------------------------------------------------------------------- /lib/lib.raumkernel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Logger = require('./lib.logger'); 3 | var BaseManager = require('./lib.base.manager'); 4 | var ManagerDisposer = require('./lib.managerDisposer'); 5 | 6 | var QueueControllerNativePlaylist = require('./lib.queueController.nativePlaylist'); 7 | var QueueControllerZonePlaylist = require('./lib.queueController.zonePlaylist'); 8 | 9 | module.exports = class Raumkernel extends BaseManager 10 | { 11 | constructor() 12 | { 13 | super(); 14 | this.systemReady = false; 15 | this.systemHostReady = false; 16 | this.mediaServerReady = false; 17 | this.zoneConfigReady = false; 18 | this.deviceListReady = false; 19 | 20 | this.nativePlaylistController = new QueueControllerNativePlaylist(); 21 | this.zonePlaylistController = new QueueControllerZonePlaylist(); 22 | 23 | this.settings = {} 24 | this.settings.raumfeldHost = "0.0.0.0"; 25 | this.settings.raumfeldHostRequestPort = 47365; 26 | this.settings.raumfeldManufacturerIds = new Array(); 27 | this.settings.raumfeldManufacturerIds[0] = "Raumfeld GmbH"; 28 | this.settings.raumfeldManufacturerIds[1] = "Lautsprecher Teufel GmbH"; 29 | this.settings.raumfeldVirtualMediaPlayerModelDescription = "Virtual Media Player"; 30 | this.settings.alivePingerIntervall = 3500; 31 | this.settings.ssdpDiscovertimeout = 5000; 32 | this.settings.bonjourDiscoverTimeout = 3000; 33 | this.settings.uriMetaDataTemplateFile = "lib/setUriMetadata.template"; 34 | this.settings.rendererStateTriggerConfirmationTimout = 3500; 35 | this.settings.zoneTriggerConfirmationTimout = 6000; 36 | } 37 | 38 | additionalLogIdentifier() 39 | { 40 | return "Raumkernel"; 41 | } 42 | 43 | /** 44 | * construct and set a default logger 45 | * @param {Number} the log level which should be logged 46 | */ 47 | createLogger(_logLevel = 2, _path = "") 48 | { 49 | this.parmLogger(new Logger(_logLevel, _path)); 50 | } 51 | 52 | /** 53 | * should be called after the class was instanced and after an external logger was set (otherwise a standard logger will be created) 54 | * this method starts up the searching for the upnp devices ans the discovering of the raumfeld master device 55 | */ 56 | init() 57 | { 58 | var self = this; 59 | 60 | // TODO: disbale logger 61 | // if there is no logger defined we do create a standard logger 62 | //if(!this.parmLogger()) 63 | // this.createLogger(); 64 | 65 | this.logVerbose("Setting up manager disposer"); 66 | 67 | // create the manager disposer and let him create the managers 68 | this.managerDisposer = new ManagerDisposer(); 69 | this.managerDisposer.parmLogger(this.parmLogger()); 70 | this.managerDisposer.parmRaumkernel(this); 71 | this.managerDisposer.createManagers(); 72 | 73 | this.logVerbose("Creating controllers"); 74 | 75 | this.nativePlaylistController = new QueueControllerNativePlaylist(); 76 | this.nativePlaylistController.parmLogger(this.parmLogger()); 77 | this.nativePlaylistController.parmManagerDisposer(this.managerDisposer); 78 | this.nativePlaylistController.init(); 79 | this.zonePlaylistController = new QueueControllerZonePlaylist(); 80 | this.zonePlaylistController.parmLogger(this.parmLogger()); 81 | this.zonePlaylistController.parmManagerDisposer(this.managerDisposer); 82 | this.zonePlaylistController.init(); 83 | 84 | this.logDebug("Bind manager events"); 85 | 86 | this.managerDisposer.deviceManager.on("systemHostFound", function(_host) { self.onSystemHostFound(_host); } ); 87 | this.managerDisposer.deviceManager.on("systemHostLost", function() { self.onSystemHostLost(); } ); 88 | this.managerDisposer.deviceManager.on("deviceListChanged", function(_deviceList) { self.onDeviceListChanged(_deviceList); } ); 89 | this.managerDisposer.deviceManager.on("mediaRendererAdded", function(_deviceUdn, _device) { self.onMediaRendererAdded(_deviceUdn, _device); } ); 90 | this.managerDisposer.deviceManager.on("mediaRendererRaumfeldAdded", function(_deviceUdn, _device) { self.onMediaRendererRaumfeldAdded(_deviceUdn, _device); } ); 91 | this.managerDisposer.deviceManager.on("mediaRendererRaumfeldVirtualAdded", function(_deviceUdn, _device) { self.onMediaRendererRaumfeldVirtualAdded(_deviceUdn, _device); } ); 92 | this.managerDisposer.deviceManager.on("mediaServerAdded", function(_deviceUdn, _device) { self.onMediaServerAdded(_deviceUdn, _device); } ); 93 | this.managerDisposer.deviceManager.on("mediaServerRaumfeldAdded", function(_deviceUdn, _device) { self.onMediaServerRaumfeldAdded(_deviceUdn, _device); } ); 94 | this.managerDisposer.deviceManager.on("mediaRendererRemoved", function(_deviceUdn, _name) { self.onMediaRendererRemoved(_deviceUdn, _name); } ); 95 | this.managerDisposer.deviceManager.on("mediaRendererRaumfeldRemoved", function(_deviceUdn, _name) { self.onMediaRendererRaumfeldRemoved(_deviceUdn, _name); } ); 96 | this.managerDisposer.deviceManager.on("mediaRendererRaumfeldVirtualRemoved",function(_deviceUdn, _name) { self.onMediaRendererRaumfeldVirtualRemoved(_deviceUdn, _name); } ); 97 | this.managerDisposer.deviceManager.on("mediaServerRemoved", function(_deviceUdn, _name) { self.onMediaServerRemoved(_deviceUdn, _name); } ); 98 | this.managerDisposer.deviceManager.on("mediaServerRaumfeldRemoved", function(_deviceUdn, _name) { self.onMediaServerRaumfeldRemoved(_deviceUdn, _name); } ); 99 | this.managerDisposer.deviceManager.on("rendererStateChanged", function(_mediaRenderer, _rendererState) { self.onRendererStateChanged(_mediaRenderer, _rendererState); } ); 100 | this.managerDisposer.deviceManager.on("rendererStateKeyValueChanged", function(_mediaRenderer, _key, _oldValue, _newValue, _roomUdn) { self.onRendererStateKeyValueChanged(_mediaRenderer, _key, _oldValue, _newValue, _roomUdn); } ); 101 | this.managerDisposer.deviceManager.on("rendererMediaItemDataChanged", function(_mediaRenderer, _mediaItemData) { self.onRendererMediaItemDataChanged(_mediaRenderer, _mediaItemData); } ); 102 | 103 | this.managerDisposer.zoneManager.on("zoneCreated", function(_zoneUDN) { self.onZoneCreated(_zoneUDN); } ); 104 | this.managerDisposer.zoneManager.on("zoneRemoved", function(_zoneUDN) { self.onZoneRemoved(_zoneUDN); } ); 105 | this.managerDisposer.zoneManager.on("roomAddedToZone", function(_zoneUDN, _roomUDN) { self.onRoomAddedToZone(_zoneUDN, _roomUDN); } ); 106 | this.managerDisposer.zoneManager.on("roomRemovedFromZone", function(_zoneUDN, _roomUDN) { self.onRoomRemovedFromZone(_zoneUDN, _roomUDN); } ); 107 | this.managerDisposer.zoneManager.on("zoneConfigurationChanged", function(_zoneConfiguration) { self.onZoneConfigurationChanged(_zoneConfiguration); } ); 108 | 109 | this.managerDisposer.mediaListManager.on("mediaListDataReady", function(_id, _mediaListData) { self.onMediaListDataReady(_id, _mediaListData); } ); 110 | this.managerDisposer.mediaListManager.on("mediaListDataPackageReady", function(_id, _mediaListDataPkg, _pkgIdx, _pgkIdxEnd, _pkgDataCount) { self.onMediaListDataPackageReady(_id, _mediaListDataPkg, _pkgIdx, _pgkIdxEnd,_pkgDataCount); } ); 111 | this.managerDisposer.mediaListManager.on("mediaRendererPlaylistReady", function(_id, _mediaListData) { self.onMediaRendererPlaylistReady(_id, _mediaListData); } ); 112 | 113 | this.managerDisposer.infodataManager.on("combinedZoneStateChanged", function(_combinedZoneState) { self.onCombinedZoneStateChanged(_combinedZoneState) } ) 114 | 115 | // start the search for the devices (media servers, renderers, ...) 116 | this.managerDisposer.deviceManager.discover(); 117 | } 118 | 119 | 120 | setSystemReady() 121 | { 122 | // system is ready when the zones are retrieved, the device list was gathered and if there is an raumfeld media server active 123 | var oldSystemReady = this.systemReady; 124 | this.systemReady = this.mediaServerReady && this.zoneConfigReady && this.deviceListReady && this.systemHostReady 125 | if(this.systemReady != oldSystemReady) 126 | this.emit("systemReady", this.systemReady); 127 | } 128 | 129 | 130 | onSystemHostFound(_host) 131 | { 132 | this.logInfo("Found raumfeld host on: " + _host); 133 | this.managerDisposer.zoneManager.parmSystemHost(_host); 134 | this.managerDisposer.zoneManager.requestZones(); 135 | this.systemHostReady = true; 136 | this.setSystemReady(); 137 | this.emit("systemHostFound", _host); 138 | } 139 | 140 | onSystemHostLost() 141 | { 142 | this.logError("Raumfeld host lost!"); 143 | // tell the zone manager that he now may stop discovering the zone configuration because no host is online 144 | this.managerDisposer.zoneManager.parmSystemHost(""); 145 | this.managerDisposer.zoneManager.stopRequestZones(); 146 | this.systemHostReady = false; 147 | this.mediaServerReady = false; 148 | this.zoneConfigReady = false; 149 | this.deviceListReady = false; 150 | this.setSystemReady(); 151 | this.emit("systemHostLost"); 152 | } 153 | 154 | onDeviceListChanged(_deviceList) 155 | { 156 | this.deviceListReady = true; 157 | this.setSystemReady(); 158 | this.emit("deviceListChanged", _deviceList); 159 | } 160 | 161 | onMediaRendererAdded(_deviceUdn, _device) 162 | { 163 | this.emit("mediaRendererAdded", _deviceUdn, _device) 164 | } 165 | 166 | onMediaRendererRaumfeldAdded(_deviceUdn, _device) 167 | { 168 | this.emit("mediaRendererRaumfeldAdded", _deviceUdn, _device) 169 | } 170 | 171 | onMediaRendererRaumfeldVirtualAdded(_deviceUdn, _device) 172 | { 173 | this.emit("mediaRendererRaumfeldVirtualAdded", _deviceUdn, _device) 174 | // try to trigger the 'zoneCreated' event 175 | this.managerDisposer.infodataManager.mediaRendererRaumfeldVirtualAdded(_deviceUdn, _device) 176 | this.managerDisposer.zoneManager.triggerZoneCreated(_deviceUdn) 177 | } 178 | 179 | onMediaServerAdded(_deviceUdn, _device) 180 | { 181 | this.emit("mediaServerAdded", _deviceUdn, _device); 182 | } 183 | 184 | onMediaServerRaumfeldAdded(_deviceUdn, _device) 185 | { 186 | this.nativePlaylistController.parmMediaServer(_device); 187 | this.zonePlaylistController.parmMediaServer(_device); 188 | this.managerDisposer.mediaListManager.parmMediaServer(_device); 189 | this.mediaServerReady = true; 190 | this.setSystemReady(); 191 | 192 | // refresh lists for all renderers. 193 | this.managerDisposer.deviceManager.refreshMediaRendererMediaLists(); 194 | 195 | this.emit("mediaServerRaumfeldAdded", _deviceUdn, _device); 196 | } 197 | 198 | onMediaRendererRemoved(_deviceUdn, _name) 199 | { 200 | this.emit("mediaRendererRemoved", _deviceUdn, _name); 201 | } 202 | 203 | onMediaRendererRaumfeldRemoved(_deviceUdn, _name) 204 | { 205 | this.emit("mediaRendererRaumfeldRemoved", _deviceUdn, _name); 206 | } 207 | 208 | onMediaRendererRaumfeldVirtualRemoved(_deviceUdn, _name) 209 | { 210 | this.emit("mediaRendererRaumfeldVirtualRemoved", _deviceUdn, _name); 211 | // try to trigger the 'zoneRemoved' event. 212 | this.managerDisposer.zoneManager.triggerZoneRemoved(_deviceUdn); 213 | 214 | } 215 | 216 | onMediaServerRemoved(_deviceUdn, _name) 217 | { 218 | this.emit("mediaServerRemoved", _deviceUdn, _name); 219 | } 220 | 221 | onMediaServerRaumfeldRemoved(_deviceUdn, _name) 222 | { 223 | this.nativePlaylistController.parmMediaServer(null); 224 | this.zonePlaylistController.parmMediaServer(null); 225 | this.managerDisposer.mediaListManager.parmMediaServer(null); 226 | this.mediaServerReady = false; 227 | this.setSystemReady(); 228 | this.emit("mediaServerRaumfeldRemoved", _deviceUdn, _name); 229 | } 230 | 231 | onZoneConfigurationChanged(_zoneConfiguration) 232 | { 233 | this.zoneConfigReady = true 234 | this.setSystemReady() 235 | this.managerDisposer.infodataManager.zoneConfigurationChanged(_zoneConfiguration) 236 | this.emit("zoneConfigurationChanged", _zoneConfiguration) 237 | } 238 | 239 | onRendererStateKeyValueChanged(_mediaRenderer, _key, _oldValue, _newValue, _roomUdn) 240 | { 241 | // when the track uri is changing we have to refresh the media list for the renderer 242 | // we can do this of course only when the media renderer is found already, otherwise it will be triggered by the media renderer aapearance 243 | if(this.mediaServerReady) 244 | { 245 | if(_key.toLowerCase() == "avtransporturi") 246 | { 247 | if(_newValue && _mediaRenderer.rendererState.AVTransportURIMetaData) 248 | this.managerDisposer.mediaListManager.loadMediaListForRendererUri(_mediaRenderer.udn(), _newValue, _mediaRenderer.rendererState.AVTransportURIMetaData).catch(function(_data){}); 249 | } 250 | if(_key.toLowerCase() == "avtransporturimetadata") 251 | { 252 | if(_newValue && _mediaRenderer.rendererState.AVTransportURI) 253 | this.managerDisposer.mediaListManager.loadMediaListForRendererUri(_mediaRenderer.udn(), _mediaRenderer.rendererState.AVTransportURI, _newValue).catch(function(_data){}); 254 | // av transport uri metadata will containe the datata as currenttrackmetadata and some more (album ect.) 255 | // we do only update the "track" type, this is done in the "updateCurrentMediaItemInfo" itself 256 | //_mediaRenderer.updateCurrentMediaItemInfo(_newValue); 257 | } 258 | if(_key.toLowerCase() == "currenttrackmetadata") 259 | { 260 | _mediaRenderer.lastCurrentTrackMetadata = _newValue 261 | _mediaRenderer.updateCurrentMediaItemInfo(_newValue); 262 | } 263 | 264 | } 265 | 266 | this.managerDisposer.infodataManager.rendererStateKeyValueChanged(_mediaRenderer, _key, _oldValue, _newValue, _roomUdn) 267 | // renderer states can do some triggers, so add it to the trigger manager 268 | this.managerDisposer.triggerManager.trigger("renderer", "rendererStateKeyValueChanged", { "rendererUdn": _mediaRenderer.udn(), 269 | "key": _key, 270 | "value": _newValue, 271 | "oldValue": _oldValue, 272 | "roomUdn": _roomUdn }); 273 | 274 | this.emit("rendererStateKeyValueChanged", _mediaRenderer, _key, _oldValue, _newValue, _roomUdn); 275 | } 276 | 277 | onRendererStateChanged(_mediaRenderer, _rendereState) 278 | { 279 | this.managerDisposer.infodataManager.rendererStateChanged( _mediaRenderer, _rendereState) 280 | this.emit("rendererStateChanged", _mediaRenderer, _rendereState) 281 | } 282 | 283 | 284 | onMediaListDataReady(_id, _mediaListData) 285 | { 286 | this.emit("mediaListDataReady", _id, _mediaListData); 287 | } 288 | 289 | 290 | onMediaListDataPackageReady(_id, _mediaListDataPkg, _pkgIdx, _pgkIdxEnd, _pkgDataCount) 291 | { 292 | this.emit("mediaListDataPackageReady", _id, _mediaListDataPkg, _pkgIdx, _pgkIdxEnd, _pkgDataCount); 293 | } 294 | 295 | 296 | onMediaRendererPlaylistReady(_id, _mediaListData) 297 | { 298 | this.emit("mediaRendererPlaylistReady", _id, _mediaListData); 299 | } 300 | 301 | 302 | onRendererMediaItemDataChanged(_mediaRenderer, _currentMediaItemData) 303 | { 304 | this.managerDisposer.infodataManager.rendererMediaItemDataChanged(_mediaRenderer, _currentMediaItemData) 305 | this.emit("rendererMediaItemDataChanged", _mediaRenderer, _currentMediaItemData); 306 | } 307 | 308 | 309 | onZoneCreated(_zoneUDN) 310 | { 311 | this.emit("zoneCreated", _zoneUDN); 312 | } 313 | 314 | 315 | onZoneRemoved(_zoneUDN) 316 | { 317 | this.emit("zoneRemoved", _zoneUDN); 318 | } 319 | 320 | 321 | onRoomAddedToZone(_zoneUDN, _roomUDN) 322 | { 323 | this.emit("roomAddedToZone", _zoneUDN, _roomUDN); 324 | } 325 | 326 | 327 | onRoomRemovedFromZone(_zoneUDN, _roomUDN) 328 | { 329 | this.emit("roomRemovedFromZone", _zoneUDN, _roomUDN); 330 | } 331 | 332 | 333 | onCombinedZoneStateChanged(_combinedZoneState) 334 | { 335 | this.emit("combinedZoneStateChanged", _combinedZoneState) 336 | } 337 | 338 | 339 | getSettings() 340 | { 341 | return this.settings; 342 | } 343 | 344 | } 345 | -------------------------------------------------------------------------------- /lib/lib.manager.mediaListManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Url = require('url'); 3 | var ManagerBase = require('./lib.manager.base'); 4 | var MediaDataConverter = require('./lib.mediaDataConverter'); 5 | 6 | 7 | module.exports = class MediaListManager extends ManagerBase 8 | { 9 | constructor() 10 | { 11 | super(); 12 | this.mediaServer = null 13 | this.listCache = new Map(); 14 | } 15 | 16 | additionalLogIdentifier() 17 | { 18 | return "MediaListManager"; 19 | } 20 | 21 | parmMediaServer(_mediaServer = this.mediaServer) 22 | { 23 | this.mediaServer = _mediaServer 24 | return this.mediaServer; 25 | } 26 | 27 | 28 | checkForMediaServer() 29 | { 30 | if(!this.mediaServer) 31 | { 32 | this.logError("Calling Action on MediaListManager without having a valid media server!"); 33 | return false; 34 | } 35 | return true; 36 | } 37 | 38 | /* 39 | DEL_getMediaList(_listId, _objectId, _useListCache = true, _emitReady = true) 40 | { 41 | var self = this; 42 | this.logVerbose("Get media list for objectId: " + _objectId); 43 | 44 | return new Promise(function(_resolve, _reject){ 45 | try 46 | { 47 | if(!self.checkForMediaServer()) 48 | { 49 | _reject(new Error("Calling Action on MediaListManager without having a valid media server!")); 50 | return; 51 | } 52 | 53 | // if list is in cache and we should us the cache then return list from the cache 54 | // lists like zone playlists or favourites are always up to date in cache 55 | if(_useListCache && self.listCache.has(_listId)) 56 | { 57 | if(_emitReady) 58 | self.emit("mediaListDataReady", _listId, self.listCache[_listId]); 59 | _resolve(self.listCache[_listId]); 60 | return; 61 | } 62 | 63 | self.mediaServer.browse(_objectId).then(function(_data){ 64 | // convert given xml data to nice JSON array 65 | var mediaDataConverter = new MediaDataConverter(); 66 | mediaDataConverter.parmLogger(self.parmLogger()); 67 | mediaDataConverter.parmManagerDisposer(self.parmManagerDisposer()); 68 | mediaDataConverter.convertXMLToMediaList(_data).then(function(_data){ 69 | self.listCache[_listId] = _data; 70 | if(_emitReady) 71 | self.emit("mediaListDataReady", _listId, _data); 72 | _resolve(_data); 73 | }).catch(function(_data){ 74 | _reject(_data) 75 | }); 76 | }).catch(function(_data){ 77 | _reject(_data) 78 | }); 79 | } 80 | catch(exception) 81 | { 82 | self.logError(exception.toString()); 83 | _reject(exception); 84 | } 85 | }); 86 | } 87 | */ 88 | 89 | 90 | loadMediaListForUri(_listId, _uri, _uriMetadata, _useCache = false, _emitReady = true, _dataPackageCount = 25, _dataPackageCallback = null) 91 | { 92 | var self = this; 93 | 94 | return new Promise(function(_resolve, _reject){ 95 | self.logVerbose("Get media list for uri " + _uri); 96 | 97 | try 98 | { 99 | // we have to decide if its a playcontainer or not. This is the case when the uri begins with "dlna-playcontainer://" 100 | // if there is no playcontainer we have to use the medadata to create the list (in fact then its only one item) 101 | var parsedUrl = Url.parse(_uri, true); 102 | if(parsedUrl.protocol != "dlna-playcontainer:") 103 | { 104 | if(_uriMetadata) 105 | { 106 | var mediaDataConverter = new MediaDataConverter(); 107 | mediaDataConverter.parmLogger(self.parmLogger()); 108 | mediaDataConverter.parmManagerDisposer(self.parmManagerDisposer()); 109 | mediaDataConverter.convertXMLToMediaList(_uriMetadata).then(function(_data){ 110 | self.listCache.set(_listId, _data); 111 | if(_emitReady) 112 | self.emit("mediaListDataReady", _listId, _data); 113 | _resolve(_data); 114 | }).catch(function(_data){ 115 | self.listCache.delete(_listId); 116 | if(_emitReady) 117 | self.emit("mediaListDataReady", _listId, null); 118 | _reject(_data); 119 | }); 120 | } 121 | else 122 | { 123 | self.listCache.delete(_listId); 124 | if(_emitReady) 125 | self.emit("mediaListDataReady", _listId, null); 126 | _resolve(null); 127 | } 128 | } 129 | // we do have a playcontainer, so we try to get the cid from the query. This is the id where we can search the content directory with 130 | else 131 | { 132 | self.getMediaList(_listId, parsedUrl.query.cid, "", _useCache, _emitReady, _dataPackageCount, _dataPackageCallback).then(function(_data){ 133 | _resolve(_data); 134 | }).catch(function(_data){ 135 | _reject(_data); 136 | }); 137 | } 138 | } 139 | catch(_exception) 140 | { 141 | self.logError("Error resolving url on loadMediaListForUri", _exception); 142 | _reject(new Error("Error resolving url on loadMediaListForUri: " + _exception.toString())); 143 | } 144 | }); 145 | } 146 | 147 | 148 | loadMediaListForRendererUri(_rendererUdn, _uri, _uriMetadata, _useCache = false, _dataPackageCount = 25, _dataPackageCallback = null) 149 | { 150 | var self = this; 151 | 152 | // sometimes a "false" is returned to say hey, i have no metadata! 153 | if(_uriMetadata == "false") 154 | _uriMetadata = ""; 155 | 156 | return new Promise(function(_resolve, _reject){ 157 | self.logDebug("Update media list for " + _rendererUdn, _uri); 158 | self.loadMediaListForUri(_rendererUdn, _uri, _uriMetadata, _useCache, false, _dataPackageCount, _dataPackageCallback).then(function(_data){ 159 | self.logDebug("Media list for renderer " + _rendererUdn + " is ready"); 160 | self.emit("mediaRendererPlaylistReady", _rendererUdn, _data); 161 | _resolve(_data); 162 | }).catch(function(_data){ 163 | self.logError("Error getting media list for renderer " + _rendererUdn, _data.toString()); 164 | //self.emit("mediaRendererPlaylistReady", _rendererUdn, null); 165 | _reject(_data); 166 | }); 167 | }); 168 | } 169 | 170 | 171 | loadMediaItemListsByContainerUpdateIds(_mediaServer, _containerUpdateIds) 172 | { 173 | if(!_mediaServer.isRaumfeldServer() || !_containerUpdateIds) 174 | return; 175 | var updateIds = _containerUpdateIds.split(","); 176 | for(var i=0; i 0) 235 | { 236 | _dataPackageCallback(_listId, packageData, pkgIdx, (pkgIdx + curPackageDataCount - 1) , curPackageDataCount); 237 | if(_emitReady) 238 | self.emit("mediaListDataPackageReady", _listId, packageData, pkgIdx, (pkgIdx + curPackageDataCount - 1), curPackageDataCount); 239 | } 240 | } 241 | } 242 | // emit or resolve the list if we get it from the cache immediately, we do not read 243 | // it from the media server of course. 244 | if(_emitReady) 245 | self.emit("mediaListDataReady", _listId, self.listCache.get(_listId)); 246 | _resolve(self.listCache.get(_listId)); 247 | return; 248 | } 249 | 250 | // we do have to browse recursivle to get data parts 251 | self.listCache.delete(_listId); 252 | self.getMediaList_Rec(_listId, _objectId, _searchCriteria, _emitReady, _dataPackageCount, _dataPackageCallback, 0).then(function(_data){ 253 | if(_emitReady) 254 | self.emit("mediaListDataReady", _listId, _data); 255 | _resolve(self.listCache.get(_listId)); 256 | }).catch(function(_data){ 257 | _reject(_data); 258 | }); 259 | 260 | 261 | } 262 | catch(exception) 263 | { 264 | self.logError(exception.toString()); 265 | _reject(exception); 266 | } 267 | }); 268 | } 269 | 270 | 271 | getMediaList_Rec(_listId, _objectId, _searchCriteria, _emitReady, _dataPackageCount, _dataPackageCallback, _pkgIdx) 272 | { 273 | var self = this; 274 | 275 | return new Promise(function(_resolve, _reject){ 276 | try 277 | { 278 | // we do use "search" instead of browse s owe cann use this method for searching too 279 | // the result is the same so it does not matter. If there are problems we will have to jump in with an if (browse and search) 280 | self.mediaServer.search(_objectId, _searchCriteria, "*", _pkgIdx, _dataPackageCount).then(function(_data){ 281 | //self.mediaServer.browse(_objectId, "BrowseDirectChildren", "*", _pkgIdx, _dataPackageCount).then(function(_data){ 282 | // convert given xml data to nice JSON array 283 | var mediaDataConverter = new MediaDataConverter(); 284 | mediaDataConverter.parmLogger(self.parmLogger()); 285 | mediaDataConverter.parmManagerDisposer(self.parmManagerDisposer()); 286 | mediaDataConverter.convertXMLToMediaList(_data).then(function(_data){ 287 | // get the current package data which should always be equal to "_dataPackageCount" except we have read all data 288 | var curPackageDataCount = _data.length; 289 | if(curPackageDataCount > 0) 290 | { 291 | // update the main list data by adding the current part 292 | // Im not sure if this is the fastest way, maybe heer we should do some performance tuning? 293 | if(self.listCache.has(_listId)) 294 | self.listCache.set(_listId, self.listCache.get(_listId).concat(_data)); 295 | else 296 | self.listCache.set(_listId, _data); 297 | 298 | if(_dataPackageCallback) 299 | _dataPackageCallback(_listId, _data, _pkgIdx, (_pkgIdx + curPackageDataCount - 1) , curPackageDataCount); 300 | if(_emitReady) 301 | self.emit("mediaListDataPackageReady", _listId, _data, _pkgIdx, (_pkgIdx + curPackageDataCount - 1), curPackageDataCount); 302 | } 303 | 304 | // do not resolve until we do have read all data 305 | if(curPackageDataCount < _dataPackageCount) 306 | { 307 | _resolve(_data); 308 | } 309 | // if we do not have all data start browsing again with the next package idx 310 | else 311 | { 312 | self.getMediaList_Rec(_listId, _objectId, _searchCriteria, _emitReady, _dataPackageCount, _dataPackageCallback, (_pkgIdx + curPackageDataCount)).then(function(_data){ 313 | _resolve(_data); 314 | }).catch(function(_data){ 315 | _reject(_data); 316 | }); 317 | } 318 | 319 | }).catch(function(_data){ 320 | _reject(_data); 321 | }); 322 | }).catch(function(_data){ 323 | _reject(_data); 324 | }); 325 | } 326 | catch(_exception) 327 | { 328 | _reject(_data); 329 | } 330 | }); 331 | } 332 | 333 | /** 334 | * may be used to search on a special "search" container id 335 | * Examples of special search containers are: 336 | * 0/My Music/Search/Albums 337 | * 0/Napster/Search/Artist 338 | * 0/RadioTime/Search 339 | * @param {string} a list id where the result will be stored in 340 | * @param {string} a special search container id 341 | * @param {string} the search value 342 | */ 343 | searchMediaList(_listId, _searchContainerId, _searchValue = "", _useListCache = true, _emitReady = true, _dataPackageCount = 25, _dataPackageCallback = null) 344 | { 345 | /* search only works on "search" containers e.g.: 346 | 0/My Music/Search/Albums 347 | 0/Napster/Search/Artist 348 | 0/RadioTime/Search 349 | ... 350 | So in fact a client has to check the 2nd level for a "Search" node 351 | */ 352 | var searchCriteria = _searchValue ? 'dc:title contains "' + _searchValue + '"' : ''; 353 | return this.getMediaList(_listId, _searchContainerId, searchCriteria, _useListCache, _emitReady, _dataPackageCount, _dataPackageCallback); 354 | } 355 | 356 | } -------------------------------------------------------------------------------- /lib/lib.device.upnp.mediaRenderer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Url = require('url'); 3 | var ParseString = require('xml2js').parseString; 4 | var UPNPDevice = require('./lib.device.base.upnp'); 5 | var MediaDataConverter = require('./lib.mediaDataConverter'); 6 | 7 | /** 8 | * this is the base class to use for child's which are media Renderer devices 9 | */ 10 | module.exports = class UPNPMediaRenderer extends UPNPDevice 11 | { 12 | constructor(_upnpClient) 13 | { 14 | super(_upnpClient); 15 | // this object holds the current state/values of the renderer and is crated/updated from the 'avTransportData' and the 'renderingControlData' objects 16 | this.rendererState = {}; 17 | // this object holds the last data which was sent from the AVTransport service subscription 18 | this.lastChangedAvTransportData = []; 19 | // this object holds the last data which was sent from the Rendering service subscription 20 | this.lastChangedRenderingControlData = []; 21 | // the last update id for the renderer state, will be updated whenever a state changes 22 | this.lastUpdateIdRendererState = ""; 23 | // 24 | this.lastCurrentTrackMetadata = ""; 25 | // 26 | this.currentMediaItemData = {}; 27 | // the mediaOriginData reflects the current loaded item dta (eg. container or single object id) 28 | this.mediaOriginData = { 29 | containerId : "", 30 | singleId : "", 31 | uri : "" 32 | }; 33 | 34 | this.setRandomUpdateId(); 35 | } 36 | 37 | 38 | additionalLogIdentifier() 39 | { 40 | return "MediaRenderer|" + this.name(); 41 | } 42 | 43 | 44 | roomName() 45 | { 46 | return this.name(); 47 | } 48 | 49 | roomUdn() 50 | { 51 | return this.udn(); 52 | } 53 | 54 | 55 | isRaumfeldRenderer() 56 | { 57 | return false; 58 | } 59 | 60 | subscribe() 61 | { 62 | var self = this; 63 | if(!this.upnpClient) 64 | { 65 | this.logError("Trying to subscribe to services on device '" + this.name() + "' without client object"); 66 | return; 67 | } 68 | 69 | this.upnpClient.on("error", function(_err) { 70 | self.logError(_err); 71 | }); 72 | 73 | this.logVerbose("Set up AVTransport subscription on device '" + this.name() + "'") 74 | this.upnpClient.subscribe('AVTransport', this.avTransportSubscriptionCallback(this)); 75 | 76 | this.logVerbose("Set up RenderingControl subscription on device '" + this.name() + "'") 77 | this.upnpClient.subscribe('RenderingControl', this.renderingControlSubscriptionCallback(this)); 78 | } 79 | 80 | unsubscribe() 81 | { 82 | if(!this.upnpClient) 83 | { 84 | this.logError("Trying to un-subscribe services on device '" + this.name() + "' without client object"); 85 | return; 86 | } 87 | 88 | this.logVerbose("Remove service subscriptions for device '" + this.name() + "'"); 89 | //this.upnpClient.unsubscribe("AVTransport", this.avTransportSubscriptionCallback(this)); 90 | //this.upnpClient.unsubscribe("RenderingControl", this.renderingControlSubscriptionCallback(this)); 91 | this.upnpClient.unsubscribeAll("AVTransport"); 92 | this.upnpClient.unsubscribeAll("RenderingControl"); 93 | } 94 | 95 | 96 | avTransportSubscriptionCallback(_self) 97 | { 98 | return function(_data) 99 | { 100 | _self.onAvTransportSubscription(_data); 101 | } 102 | } 103 | 104 | 105 | renderingControlSubscriptionCallback(_self) 106 | { 107 | return function(_data) 108 | { 109 | _self.onRenderingControlSubscription(_data); 110 | } 111 | } 112 | 113 | 114 | /** 115 | * should be called whenever any state of renderer (subscription) is changed 116 | * so it will be called on onAvTransportSubscription and on onRenderingControlSubscription 117 | */ 118 | updateRendererState() 119 | { 120 | // update the renderer state object 121 | this.updateRendererStateObject(); 122 | 123 | // create a new update id for the render 124 | this.updateRandomUpdateId(); 125 | 126 | this.emit("rendererStateChanged", this, this.rendererState); 127 | } 128 | 129 | /** 130 | * this method will update the renderer state object from the lastChanged Data 131 | */ 132 | updateRendererStateObject() 133 | { 134 | // copy keys and values of both subscription returned data into the renderer state so the renderer 135 | // state will fill up with the gathered and always upToDate values 136 | for(var key in this.lastChangedAvTransportData) 137 | { 138 | // check if value has changed or if new key is not existent, if so then we do emit an event with the key and value 139 | if(this.rendererState[key] != this.lastChangedAvTransportData[key]) 140 | { 141 | this.logVerbose(key + " has changed from '" + this.rendererState[key] + "' to '" + this.lastChangedAvTransportData[key] + "'"); 142 | this.emit("rendererStateKeyValueChanged", this, key, this.rendererState[key], this.lastChangedAvTransportData[key], ""); 143 | } 144 | this.rendererState[key]=this.lastChangedAvTransportData[key]; 145 | } 146 | for(var key in this.lastChangedRenderingControlData) 147 | { 148 | // check if value has changed or if new key is not existent, if so then we do emit an event with the key and value 149 | if(this.rendererState[key] != this.lastChangedRenderingControlData[key]) 150 | { 151 | this.logVerbose(key + " has changed from '" + this.rendererState[key] + "' to '" + this.lastChangedRenderingControlData[key] + "'"); 152 | this.emit("rendererStateKeyValueChanged", this, key, this.rendererState[key], this.lastChangedRenderingControlData[key], ""); 153 | } 154 | this.rendererState[key]=this.lastChangedRenderingControlData[key]; 155 | } 156 | 157 | // update the mediaOriginData from the new given renderer state 158 | this.updateMediaOriginData(); 159 | } 160 | 161 | 162 | /** 163 | * will fill the 'mediaOriginData' with the help of the the avTransportUri given in the rendereState 164 | */ 165 | updateMediaOriginData() 166 | { 167 | //if there is no uri we have to clear the origin data because there is no origin 168 | if(!this.rendererState.AVTransportURI) 169 | { 170 | this.mediaOriginData.containerId = ""; 171 | this.mediaOriginData.singleId = ""; 172 | this.mediaOriginData.uri = ""; 173 | return; 174 | } 175 | 176 | var parsedUrl = Url.parse(this.rendererState.AVTransportURI, true); 177 | if(parsedUrl.protocol = "dlna-playcontainer:") 178 | { 179 | this.mediaOriginData.containerId = parsedUrl.query.cid; 180 | this.mediaOriginData.singleId = ""; 181 | this.mediaOriginData.uri = ""; 182 | } 183 | else if(parsedUrl.protocol = "dlna-playsingle:") 184 | { 185 | this.mediaOriginData.containerId = ""; 186 | this.mediaOriginData.singleId = parsedUrl.query.sid; 187 | this.mediaOriginData.uri = ""; 188 | } 189 | // well here we do have not container and no playsingle, so we do havea direct uri set by any application 190 | else 191 | { 192 | this.mediaOriginData.containerId = ""; 193 | this.mediaOriginData.singleId = ""; 194 | this.mediaOriginData.uri = this.rendererState.AVTransportURI; 195 | } 196 | } 197 | 198 | 199 | /** 200 | * will be called when data of the AvTransport service was changed 201 | * @param {Object} a object with the changed data 202 | */ 203 | onAvTransportSubscription(_keyDataArray) 204 | { 205 | this.logDebug("AVTransport subscription callback triggered on device '" + this.name() + "'"); 206 | this.lastChangedAvTransportData = _keyDataArray; 207 | this.updateRendererState(); 208 | } 209 | 210 | /** 211 | * will be called when data of the RenderingControl service was changed 212 | * @param {Object} a object with the changed data 213 | */ 214 | onRenderingControlSubscription(_keyDataArray) 215 | { 216 | this.logDebug("RenderingControl subscription callback triggered on device '" + this.name() + "'"); 217 | this.lastChangedRenderingControlData = _keyDataArray; 218 | this.updateRendererState(); 219 | } 220 | 221 | /** 222 | * returns the current volume for the device 223 | * @return {Promise} a promise with the volume as result 224 | */ 225 | getVolume() 226 | { 227 | return this.callAction("RenderingControl", "GetVolume", {"Channel": "Master"}, function (_result){ 228 | return _result.CurrentVolume; 229 | }); 230 | } 231 | 232 | /** 233 | * returns the current mute for the device 234 | * @return {Promise} a promise with the mute state as result (0 or 1) 235 | */ 236 | getMute() 237 | { 238 | return this.callAction("RenderingControl", "GetMute", {"Channel": "Master"}, function (_result){ 239 | return _result.CurrentMute; 240 | }); 241 | } 242 | 243 | /** 244 | * set the volume for the device 245 | * @param {Integer} the desired volume 246 | * @return {Promise} a promise with no result 247 | */ 248 | setVolume(_volume, _confirm = false) 249 | { 250 | this.logDebug("Set volume to " + _volume + " on room " + this.name()); 251 | return this.callActionWithTriggerWait("RenderingControl", "SetVolume", {"Channel": "Master", "DesiredVolume": _volume}, null, { "key" : "Volume", "value" : _volume} , _confirm) 252 | } 253 | 254 | /** 255 | * set the mute for the device 256 | * @param {boolean} true or false 257 | * @return {Promise} a promise with no result 258 | */ 259 | setMute(_mute, _confirm = false) 260 | { 261 | this.logDebug("Set mute to " + _mute + " on room " + this.name()); 262 | return this.callActionWithTriggerWait("RenderingControl", "SetMute", {"Channel": "Master", "DesiredMute": _mute}, null, { "key" : "mute", "value" : _mute ? "1" : "0"} , _confirm) 263 | } 264 | 265 | /** 266 | * start playing 267 | * @return {Promise} a promise with no result 268 | */ 269 | play(_confirm = false) 270 | { 271 | return this.callActionWithTriggerWait("AVTransport", "Play", {}, null, { "key" : "TransportState", "value" : "PLAYING"} , _confirm) 272 | } 273 | 274 | /** 275 | * stop playing 276 | * @return {Promise} a promise with no result 277 | */ 278 | stop(_confirm = false) 279 | { 280 | return this.callActionWithTriggerWait("AVTransport", "Stop", {}, null, { "key" : "TransportState", "value" : "STOPPED" } , _confirm) 281 | } 282 | 283 | /** 284 | * pause playing 285 | * @return {Promise} a promise with no result 286 | */ 287 | pause(_confirm = false) 288 | { 289 | return this.callActionWithTriggerWait("AVTransport", "Pause", {}, null, { "key" : "TransportState", "value" : "PAUSED_PLAYBACK"} , _confirm) 290 | } 291 | 292 | /** 293 | * play next 294 | * @return {Promise} a promise with no result 295 | */ 296 | next(/*_confirm = false*/) 297 | { 298 | return this.callAction("AVTransport", "Next", {}); 299 | //var toValue = parseInt(this.rendererState.CurrentTrack); 300 | //if (parseInt(this.rendererState.NumberOfTracks) > 1 && toValue > 1 && toValue < parseInt(this.rendererState.NumberOfTracks)) 301 | // toValue = toValue + 1; 302 | //return this.callActionWithTriggerWait("AVTransport", "Next", {}, null, { "key" : "CurrentTrack", "value" : toValue} , _confirm) 303 | 304 | } 305 | 306 | /** 307 | * play previous 308 | * @return {Promise} a promise with no result 309 | */ 310 | prev(/*_confirm = false*/) 311 | { 312 | return this.callAction("AVTransport", "Previous", {}); 313 | // INFO: we got a problem here. 'prev' does not have to trigger the last number. it may start the current one! 314 | //var toValue = parseInt(this.rendererState.CurrentTrack); 315 | //if (parseInt(this.rendererState.NumberOfTracks) > 1 && toValue > 1) 316 | // toValue = toValue - 1; 317 | //return this.callActionWithTriggerWait("AVTransport", "Previous", {}, null, { "key" : "CurrentTrack", "value" : toValue} , _confirm) 318 | } 319 | 320 | /** 321 | * set play mode 322 | * @param {String} the play mode as a string (NORMAL, SHUFFLE, REPEAT_ALL, RANDOM, REPEAT_ONE, DIRECT_1) 323 | * @return {Promise} a promise with no result 324 | */ 325 | setPlayMode(_playMode, _confirm = false) 326 | { 327 | return this.callActionWithTriggerWait("AVTransport", "SetPlayMode", {"NewPlayMode": _playMode}, null, { "key" : "CurrentPlayMode", "value" : _playMode} , _confirm) 328 | } 329 | 330 | /** 331 | * set av transport uri 332 | * @param {String} the transport uri 333 | * @param {String} the transport uri metadata 334 | * @return {Promise} a promise with no result 335 | */ 336 | setAvTransportUri(_transportUri, _transporUriMetadata, _confirm = false) 337 | { 338 | //return this.callAction("AVTransport", "SetAVTransportURI", {"CurrentURI": _transportUri, "CurrentURIMetaData": _transporUriMetadata}); 339 | return this.callActionWithTriggerWait("AVTransport", "SetAVTransportURI", {"CurrentURI": _transportUri, "CurrentURIMetaData": _transporUriMetadata}, null, { "key" : "AVTransportURI", "value" : _transportUri} , _confirm) 340 | } 341 | 342 | /** 343 | * seek 344 | * @param {String} the seek unit (ABS_TIME, REL_TIME, TRACK_NR) 345 | * @param {String} the seek target 346 | * @return {Promise} a promise with no result 347 | */ 348 | seek(_unit, _target) 349 | { 350 | return this.callAction("AVTransport", "Seek", {"Unit": _unit, "Target": _target}); 351 | } 352 | 353 | /** 354 | * getPositionInfo 355 | * @return {Promise} a promise with the position info 356 | */ 357 | getPositionInfo() 358 | { 359 | return this.callAction("AVTransport", "GetPositionInfo", {}); 360 | } 361 | 362 | /** 363 | * getTransportInfo 364 | * @return {Promise} a promise with the transport info 365 | */ 366 | getTransportInfo() 367 | { 368 | return this.callAction("AVTransport", "GetTransportInfo", {}); 369 | } 370 | 371 | /** 372 | * getTransportSettings 373 | * @return {Promise} a promise with the transport settings 374 | */ 375 | getTransportSettings() 376 | { 377 | return this.callAction("AVTransport", "GetTransportSettings", {}); 378 | } 379 | 380 | 381 | /** 382 | * sets a random update id 383 | */ 384 | setRandomUpdateId(_min = 100000, _max = 990000) 385 | { 386 | this.lastUpdateIdRendererState = (Math.floor(Math.random() * (_max - _min + 1)) + _min).toString(); 387 | } 388 | 389 | 390 | /** 391 | * update a random update id 392 | */ 393 | updateRandomUpdateId(_min = 100000, _max = 990000) 394 | { 395 | this.lastUpdateIdRendererState = (parseInt(this.lastUpdateIdRendererState) + 1).toString(); 396 | this.logDebug("Set new updateId to: " + this.lastUpdateIdRendererState); 397 | } 398 | 399 | 400 | /** 401 | * updates the data of the current media item on the renderer 402 | */ 403 | updateCurrentMediaItemInfo(_xmlMetadata) 404 | { 405 | var self = this; 406 | if(!_xmlMetadata) 407 | { 408 | self.currentMediaItemData = {}; 409 | return; 410 | } 411 | 412 | var mediaDataConverter = new MediaDataConverter(); 413 | mediaDataConverter.parmLogger(self.parmLogger()); 414 | mediaDataConverter.parmManagerDisposer(self.parmManagerDisposer()); 415 | mediaDataConverter.convertXMLToMediaList(_xmlMetadata).then(function(_data){ 416 | // be sure the item is type of "audiotItem" and not album or something like that 417 | // Sometimes the AVTransportUriMetadata comes twice (Album and track) and current track metadata 418 | // doesn't show up so we have to do this kind of fix 419 | if(_data.length && _data[0].class.startsWith("object.item.audioItem")) 420 | { 421 | self.currentMediaItemData = _data[0]; 422 | self.logDebug("Media Item on renderer changed", self.currentMediaItemData); 423 | self.emit("rendererMediaItemDataChanged", self, self.currentMediaItemData); 424 | } 425 | }).catch(function(_data){ 426 | self.currentMediaItemData = {}; 427 | self.logError("Can not create current media item on renderer " + self.name(), _data); 428 | self.emit("rendererMediaItemDataChanged", self, self.currentMediaItemData); 429 | }); 430 | } 431 | 432 | 433 | compareRendererTriggerValue(_triggerData) 434 | { 435 | var triggerDataObject = null 436 | var keyAlreadySet = false 437 | var self = this 438 | 439 | // get trigger data object 440 | if(_triggerData.roomUdn) 441 | triggerDataObject = this.rendererState.rooms[_triggerData.roomUdn] 442 | else 443 | triggerDataObject = this.rendererState 444 | 445 | if(triggerDataObject) 446 | { 447 | if(_triggerData.values) 448 | { 449 | for(var i=0; i<_triggerData.values.length; i++) 450 | { 451 | if(triggerDataObject[_triggerData.key] && triggerDataObject[_triggerData.key].toString().toLowerCase() == _triggerData.values[i].toString().toLowerCase()) 452 | keyAlreadySet = true 453 | } 454 | } 455 | else 456 | { 457 | if(triggerDataObject[_triggerData.key]) 458 | keyAlreadySet = triggerDataObject[_triggerData.key].toString().toLowerCase() == _triggerData.value.toString().toLowerCase() 459 | } 460 | } 461 | else 462 | { 463 | this.logError("No trigger data object found") 464 | } 465 | return keyAlreadySet 466 | } 467 | 468 | 469 | /** 470 | * getTransportInfo 471 | * @return {Promise} a promise which will return when the given trigger is triggeerd 472 | */ 473 | callActionWithTriggerWait(_service, _action, _params, _resultSetFunction = null, _triggerData = null, _waitForTrigger = false, _triggerContext = 'renderer', _triggerType = 'rendererStateKeyValueChanged', _timeout = this.getSettings().rendererStateTriggerConfirmationTimout) 474 | //waitForActionTrigger(_actionPromise, _triggerData, _waitForTrigger = false, _triggerContext = 'renderer', _triggerType = 'rendererStateKeyValueChanged') 475 | { 476 | var self = this 477 | var timeoutId = 0; 478 | 479 | // if we should not wait for any trigger then return the action promise 480 | if(!_waitForTrigger) 481 | return this.callAction(_service, _action, _params); 482 | 483 | // be sure we have the renderer identificatrion in the trigger 484 | if(_triggerData && !_triggerData.rendererUdn) 485 | _triggerData.rendererUdn = this.udn() 486 | 487 | return new Promise(function(resolve, reject){ 488 | var triggerCreated = false 489 | var keyAlreadySet = false; 490 | 491 | // check if one of the values is already set on the renderer, then we do not have to set up a trigger because 492 | // value is already there! 493 | keyAlreadySet = self.compareRendererTriggerValue(_triggerData) 494 | if(!keyAlreadySet) 495 | { 496 | // setup a new 'oneShot' trigger before calling the action 497 | triggerCreated = true; 498 | self.managerDisposer.triggerManager.setupTrigger(_triggerContext, _triggerType, _triggerData, true, function(_data){ 499 | // clear the reponse timeout 500 | // TODO: be sure it was not already rejected! 501 | if(timeoutId) 502 | clearTimeout(timeoutId) 503 | resolve(_data) 504 | }) 505 | // add a timeout for states that will not change 506 | timeoutId = setTimeout(function() { 507 | reject(new Error("State was not triggered in a valid time!")); 508 | }, _timeout) 509 | } 510 | // call the action and on 'then' do not resolve, the resolve will be done by the trigger which was set up 511 | // there is a pitfall because we do not really know that the state changed by our call of 'setupTrigger' 512 | self.callAction(_service, _action, _params).then(function(_data){ 513 | // well.. no resolve here! Trigger has to be set up before the action call 514 | if(!triggerCreated) 515 | resolve(_data) 516 | }).catch(function(_data){ 517 | reject(_data) 518 | }) 519 | }) 520 | } 521 | 522 | } 523 | -------------------------------------------------------------------------------- /lib/lib.external.upnp-device-client.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var util = require('util'); 3 | var EventEmitter = require('events').EventEmitter; 4 | var et = require('elementtree'); 5 | var parseUrl = require('url').parse; 6 | var os = require('os'); 7 | var concat = require('concat-stream'); 8 | var ip = require('ip'); 9 | var debug = require('debug')('upnp-device-client'); 10 | 11 | var OS_VERSION = ""; 12 | var PACKAGE_VERSION = ""; 13 | 14 | var SUBSCRIPTION_TIMEOUT = 300; 15 | 16 | 17 | function DeviceClient(url) { 18 | EventEmitter.call(this); 19 | 20 | this.url = url; 21 | this.deviceDescription = null; 22 | this.serviceDescriptions = {}; 23 | this.server = null; 24 | this.listening = false; 25 | this.subscriptions = {}; 26 | this.userAgent = ""; 27 | } 28 | 29 | util.inherits(DeviceClient, EventEmitter); 30 | 31 | 32 | DeviceClient.prototype.getDeviceDescription = function(callback) { 33 | var self = this; 34 | 35 | // Use cache if available 36 | if(this.deviceDescription) { 37 | process.nextTick(function() { 38 | callback(null, self.deviceDescription); 39 | }); 40 | return; 41 | } 42 | 43 | debug('fetch device description'); 44 | fetch(this.url, function(err, body) { 45 | if(err) return callback(err); 46 | var desc = parseDeviceDescription(body, self.url); 47 | self.deviceDescription = desc // Store in cache for next call 48 | callback(null, desc); 49 | }); 50 | }; 51 | 52 | 53 | DeviceClient.prototype.getServiceDescription = function(serviceId, callback) { 54 | var self = this; 55 | 56 | serviceId = resolveService(serviceId); 57 | 58 | this.getDeviceDescription(function(err, desc) { 59 | if(err) return callback(err); 60 | 61 | var service = desc.services[serviceId]; 62 | if(!service) { 63 | var err = new Error('Service ' + serviceId + ' not provided by device'); 64 | err.code = 'ENOSERVICE'; 65 | return callback(err); 66 | } 67 | 68 | // Use cache if available 69 | if(self.serviceDescriptions[serviceId]) { 70 | return callback(null, self.serviceDescriptions[serviceId]); 71 | } 72 | 73 | debug('fetch service description (%s)', serviceId); 74 | fetch(service.SCPDURL, function(err, body) { 75 | if(err) return callback(err); 76 | var desc = parseServiceDescription(body); 77 | self.serviceDescriptions[serviceId] = desc; // Store in cache for next call 78 | callback(null, desc); 79 | }); 80 | }); 81 | }; 82 | 83 | 84 | DeviceClient.prototype.callAction = function(serviceId, actionName, params, callback) { 85 | var self = this; 86 | serviceId = resolveService(serviceId); 87 | 88 | this.getServiceDescription(serviceId, function(err, desc) { 89 | if(err) return callback(err); 90 | 91 | if(!desc.actions[actionName]) { 92 | var err = new Error('Action ' + actionName + ' not implemented by service'); 93 | err.code = 'ENOACTION'; 94 | return callback(err); 95 | } 96 | 97 | var service = self.deviceDescription.services[serviceId]; 98 | 99 | // Build SOAP action body 100 | var envelope = et.Element('s:Envelope'); 101 | envelope.set('xmlns:s', 'http://schemas.xmlsoap.org/soap/envelope/'); 102 | envelope.set('s:encodingStyle', 'http://schemas.xmlsoap.org/soap/encoding/'); 103 | 104 | var body = et.SubElement(envelope, 's:Body'); 105 | var action = et.SubElement(body, 'u:' + actionName); 106 | action.set('xmlns:u', service.serviceType); 107 | 108 | Object.keys(params).forEach(function(paramName) { 109 | var tmp = et.SubElement(action, paramName); 110 | var value = params[paramName]; 111 | tmp.text = (value === null) 112 | ? '' 113 | : params[paramName].toString(); 114 | }); 115 | 116 | var doc = new et.ElementTree(envelope); 117 | var xml = doc.write({ 118 | xml_declaration: true, 119 | }); 120 | 121 | // Send action request 122 | var options = parseUrl(service.controlURL); 123 | options.method = 'POST'; 124 | options.headers = { 125 | 'HOST': options.host, 126 | 'User-Agent': [self.userAgent, OS_VERSION, 'UPnP/1.1', PACKAGE_VERSION].join(' '), 127 | 'Content-Type': 'text/xml; charset="utf-8"', 128 | 'Content-Length': xml.length, 129 | 'Connection': 'close', 130 | 'SOAPACTION': '"' + service.serviceType + '#' + actionName + '"' 131 | }; 132 | 133 | debug('call action %s on service %s with params %j', actionName, serviceId, params); 134 | 135 | var req = http.request(options, function(res) { 136 | res.pipe(concat(function(buf) { 137 | var doc = et.parse(buf.toString()); 138 | 139 | if(res.statusCode !== 200) { 140 | var errorCode = doc.findtext('.//errorCode'); 141 | var errorDescription = doc.findtext('.//errorDescription').trim(); 142 | 143 | var err = new Error(errorDescription + ' (' + errorCode + ')'); 144 | err.code = 'EUPNP'; 145 | err.statusCode = res.statusCode; 146 | err.errorCode = errorCode; 147 | return callback(err); 148 | } 149 | 150 | // Extract response outputs 151 | var serviceDesc = self.serviceDescriptions[serviceId]; 152 | var actionDesc = serviceDesc.actions[actionName]; 153 | var outputs = actionDesc.outputs.map(function(desc) { 154 | return desc.name; 155 | }); 156 | 157 | var result = {}; 158 | outputs.forEach(function(name) { 159 | result[name] = doc.findtext('.//' + name); 160 | }); 161 | 162 | callback(null, result) 163 | })); 164 | }); 165 | 166 | req.on('error', callback); 167 | req.end(xml); 168 | }); 169 | }; 170 | 171 | 172 | DeviceClient.prototype.subscribe = function(serviceId, listener) { 173 | var self = this; 174 | serviceId = resolveService(serviceId); 175 | 176 | if(this.subscriptions[serviceId]) { 177 | // If we already have a subscription to this service, 178 | // add the provided callback to the listeners and return 179 | this.subscriptions[serviceId].listeners.push(listener); 180 | return; 181 | } 182 | 183 | // If there's no subscription to this service, create one 184 | // by first fetching the event subscription URL ... 185 | this.getDeviceDescription(function(err, desc) { 186 | if(err) return self.emit('error', err); 187 | 188 | var service = desc.services[serviceId]; 189 | 190 | if(!service) { 191 | var err = new Error('Service ' + serviceId + ' not provided by device'); 192 | err.code = 'ENOSERVICE'; 193 | return self.emit('error', err); 194 | } 195 | 196 | // ... and ensuring the event server is created and listening 197 | self.ensureEventingServer(function() { 198 | 199 | var options = parseUrl(service.eventSubURL); 200 | var server = self.server; 201 | 202 | options.method = 'SUBSCRIBE'; 203 | options.headers = { 204 | 'HOST': options.host, 205 | 'USER-AGENT': [self.userAgent, OS_VERSION, 'UPnP/1.1', PACKAGE_VERSION].join(' '), 206 | 'CALLBACK': '', 207 | 'NT': 'upnp:event', 208 | 'TIMEOUT': 'Second-' + SUBSCRIPTION_TIMEOUT 209 | }; 210 | 211 | var req = http.request(options, function(res) { 212 | if(res.statusCode !== 200) { 213 | var err = new Error('SUBSCRIBE error'); 214 | err.statusCode = res.statusCode; 215 | self.releaseEventingServer(); 216 | self.emit('error', err); 217 | return; 218 | } 219 | 220 | var sid = res.headers['sid']; 221 | var timeout = parseTimeout(res.headers['timeout']); 222 | 223 | function renew() { 224 | debug('renew subscription to %s', serviceId); 225 | 226 | var options = parseUrl(service.eventSubURL); 227 | options.method = 'SUBSCRIBE'; 228 | options.headers = { 229 | 'HOST': options.host, 230 | 'SID': sid, 231 | 'TIMEOUT': 'Second-' + SUBSCRIPTION_TIMEOUT 232 | }; 233 | 234 | var req = http.request(options, function(res) { 235 | if(res.statusCode !== 200) { 236 | var err = new Error('SUBSCRIBE renewal error'); 237 | err.statusCode = res.statusCode; 238 | // XXX: should we clear the subscription and release the server here ? 239 | self.emit('error', err); 240 | return; 241 | } 242 | 243 | var timeout = parseTimeout(res.headers['timeout']); 244 | 245 | var renewTimeout = Math.max(timeout - 30, 30); // renew 30 seconds before expiration 246 | debug('renewing subscription to %s in %d seconds', serviceId, renewTimeout); 247 | var timer = setTimeout(renew, renewTimeout * 1000); 248 | self.subscriptions[serviceId].timer = timer; 249 | }); 250 | 251 | req.on('error', function(err) { 252 | self.emit('error', err); 253 | }); 254 | 255 | req.end(); 256 | } 257 | 258 | var renewTimeout = Math.max(timeout - 30, 30); // renew 30 seconds before expiration 259 | debug('renewing subscription to %s in %d seconds', serviceId, renewTimeout); 260 | var timer = setTimeout(renew, renewTimeout * 1000); 261 | 262 | self.subscriptions[serviceId] = { 263 | sid: sid, 264 | url: service.eventSubURL, 265 | timer: timer, 266 | listeners: [listener] 267 | }; 268 | 269 | }); 270 | 271 | req.on('error', function(err) { 272 | self.releaseEventingServer(); 273 | self.emit('error', err); 274 | }); 275 | 276 | req.end(); 277 | }); 278 | 279 | }); 280 | }; 281 | 282 | 283 | DeviceClient.prototype.unsubscribe = function(serviceId, listener) { 284 | var self = this; 285 | serviceId = resolveService(serviceId); 286 | 287 | // First make sure there are subscriptions for this service ... 288 | var subscription = this.subscriptions[serviceId]; 289 | if(!subscription) return; 290 | 291 | // ... and we know about this listener 292 | var idx = subscription.listeners.indexOf(listener); 293 | if(idx === -1) return; 294 | 295 | // Remove the listener from the list 296 | subscription.listeners.splice(idx, 1); 297 | 298 | if(subscription.listeners.length === 0) { 299 | // If there's no listener left for this service, unsubscribe from it 300 | debug('unsubscribe from service %s', serviceId); 301 | 302 | var options = parseUrl(subscription.url); 303 | 304 | options.method = 'UNSUBSCRIBE'; 305 | options.headers = { 306 | 'HOST': options.host, 307 | 'SID': subscription.sid 308 | }; 309 | 310 | var req = http.request(options, function(res) { 311 | if(res.statusCode !== 200) { 312 | var err = new Error('UNSUBSCRIBE error'); 313 | err.statusCode = res.statusCode; 314 | return self.emit('error', err); 315 | } 316 | 317 | clearTimeout(self.subscriptions[serviceId].timer); 318 | delete self.subscriptions[serviceId]; 319 | // Make sure the eventing server is shutdown if there is no 320 | // subscription left for any service 321 | self.releaseEventingServer(); 322 | }); 323 | 324 | req.on('error', function(err) { 325 | self.emit('error', err); 326 | // well this may occur if the client was removed and can not answer anymore, so we have to remove the subscription! 327 | clearTimeout(self.subscriptions[serviceId].timer); 328 | delete self.subscriptions[serviceId]; 329 | self.releaseEventingServer(); 330 | }); 331 | 332 | req.end(); 333 | } 334 | }; 335 | 336 | 337 | DeviceClient.prototype.unsubscribeAll = function(serviceId) { 338 | var self = this; 339 | serviceId = resolveService(serviceId); 340 | 341 | // First make sure there are subscriptions for this service ... 342 | var subscription = this.subscriptions[serviceId]; 343 | if(!subscription) return; 344 | 345 | // Remove the listener from the list 346 | subscription.listeners.splice(0, subscription.listeners.length); 347 | 348 | if(subscription.listeners.length === 0) { 349 | // If there's no listener left for this service, unsubscribe from it 350 | debug('unsubscribe from service %s', serviceId); 351 | 352 | var options = parseUrl(subscription.url); 353 | 354 | options.method = 'UNSUBSCRIBE'; 355 | options.headers = { 356 | 'HOST': options.host, 357 | 'SID': subscription.sid 358 | }; 359 | 360 | var req = http.request(options, function(res) { 361 | if(res.statusCode !== 200) { 362 | var err = new Error('UNSUBSCRIBE error'); 363 | err.statusCode = res.statusCode; 364 | return self.emit('error', err); 365 | } 366 | 367 | clearTimeout(self.subscriptions[serviceId].timer); 368 | delete self.subscriptions[serviceId]; 369 | // Make sure the eventing server is shutdown if there is no 370 | // subscription left for any service 371 | self.releaseEventingServer(); 372 | }); 373 | 374 | req.on('error', function(err) { 375 | self.emit('error', err); 376 | // well this may occur if the client was removed and can not answer anymore, so we have to remove the subscription! 377 | clearTimeout(self.subscriptions[serviceId].timer); 378 | delete self.subscriptions[serviceId]; 379 | self.releaseEventingServer(); 380 | }); 381 | 382 | req.end(); 383 | } 384 | }; 385 | 386 | 387 | 388 | DeviceClient.prototype.ensureEventingServer = function(callback) { 389 | var self = this; 390 | 391 | if(!this.server) { 392 | debug('create eventing server'); 393 | this.server = http.createServer(function(req, res) { 394 | 395 | req.pipe(concat(function(buf) { 396 | var sid = req.headers['sid']; 397 | var seq = req.headers['seq']; 398 | var events; 399 | try { 400 | events = parseEvents(buf); 401 | } 402 | catch (ex) { 403 | return; 404 | } 405 | //console.log(events); 406 | //console.log('received events %s %d %j', sid, seq, events); 407 | 408 | debug('received events %s %d %j', sid, seq, events); 409 | 410 | var keys = Object.keys(self.subscriptions); 411 | var sids = keys.map(function(key) { 412 | return self.subscriptions[key].sid; 413 | }) 414 | 415 | var idx = sids.indexOf(sid); 416 | if(idx === -1) { 417 | debug('WARNING unknown SID %s', sid); 418 | // silently ignore unknown SIDs 419 | return; 420 | } 421 | 422 | var serviceId = keys[idx]; 423 | var listeners = self.subscriptions[serviceId].listeners; 424 | 425 | 426 | 427 | // Dispatch each event to each listener registered for 428 | // this service's events 429 | listeners.forEach(function(listener) { 430 | events.forEach(function(e) { 431 | listener(e); 432 | }); 433 | }); 434 | 435 | // be sure we quit response by sending back a 200 OK, otherwise well developed UPNP Devices will kick us out of their subscription list 436 | res.end(); 437 | 438 | })); 439 | 440 | }); 441 | 442 | // be sure that we are listening on the correct interface where the client resides 443 | var iface = this.getIfaceForUrl(this.url); 444 | this.server.listen(0, ip.address(iface)); 445 | } 446 | 447 | if(!this.listening) { 448 | this.server.on('listening', function() { 449 | self.listening = true; 450 | callback(); 451 | }); 452 | } else { 453 | process.nextTick(callback); 454 | } 455 | }; 456 | 457 | 458 | DeviceClient.prototype.releaseEventingServer = function(_force = false) { 459 | if(Object.keys(this.subscriptions).length === 0 || _force) { 460 | debug('shutdown eventing server'); 461 | if(this.server) 462 | { 463 | this.server.close(); 464 | this.server = null; 465 | this.listening = false; 466 | } 467 | } 468 | }; 469 | 470 | 471 | DeviceClient.prototype.getIfaceForUrl = function(_url) { 472 | var options = parseUrl(_url); 473 | var interfaces = os.networkInterfaces(); 474 | var retIface = ""; 475 | 476 | Object.keys(interfaces).map(function (nic) { 477 | for (var i = 0; i < interfaces[nic].length; i++) 478 | { 479 | if(interfaces[nic][i].family.toLowerCase() == "ipv4") 480 | { 481 | var base1 = ip.mask(interfaces[nic][i].address, interfaces[nic][i].netmask); 482 | var base2 = ip.mask(options.hostname, interfaces[nic][i].netmask); // TODO: maybe resolve IP here from hostname if hostname is not an ip 483 | if (base1 == base2) 484 | retIface = nic; 485 | } 486 | } 487 | }); 488 | return retIface; 489 | }; 490 | 491 | 492 | function parseEvents(buf) { 493 | var events = []; 494 | var doc = et.parse(buf.toString()); 495 | 496 | var lastChange = doc.findtext('.//LastChange'); 497 | if(lastChange) { 498 | // AVTransport and RenderingControl services embed event data 499 | // in an `` element stored as an URIencoded string. 500 | doc = et.parse(lastChange); 501 | 502 | // The `` element contains one `` 503 | // subtree per stream instance reporting its status. 504 | var instances = doc.findall('./InstanceID'); 505 | instances.forEach(function(instance) { 506 | var data = { 507 | InstanceID: Number(instance.get('val')) 508 | }; 509 | instance.findall('./*').forEach(function(node) { 510 | data[node.tag] = node.get('val'); 511 | }); 512 | events.push(data); 513 | }); 514 | } else { 515 | // In any other case, each variable is stored separately in a 516 | // `` tag 517 | var data = {}; 518 | doc.findall('./property/*').forEach(function(node) { 519 | data[node.tag] = node.text; 520 | }); 521 | events.push(data); 522 | } 523 | 524 | var systemUpdateId = doc.findtext('.//SystemUpdateID'); 525 | if(systemUpdateId) { 526 | var data = {}; 527 | data["SystemUpdateID"] = systemUpdateId; 528 | events.push(data); 529 | }; 530 | 531 | //console.log("xxxxxxxxxxxxxxxxxxxxxx"); 532 | var containerUpdateIds = doc.findtext('.//ContainerUpdateIDs'); 533 | if(containerUpdateIds) { 534 | var data = {}; 535 | data["ContainerUpdateIDs"] = containerUpdateIds; 536 | events.push(data); 537 | }; 538 | 539 | return events; 540 | } 541 | 542 | 543 | function parseTimeout(header) { 544 | return Number(header.split('-')[1]); 545 | } 546 | 547 | 548 | function parseDeviceDescription(xml, url) { 549 | var doc = et.parse(xml); 550 | 551 | var desc = extractFields(doc.find('./device'), [ 552 | 'deviceType', 553 | 'friendlyName', 554 | 'manufacturer', 555 | 'manufacturerURL', 556 | 'modelName', 557 | 'modelNumber', 558 | 'modelDescription', 559 | 'UDN' 560 | ]); 561 | 562 | var nodes = doc.findall('./device/iconList/icon'); 563 | desc.icons = nodes.map(function(icon) { 564 | return extractFields(icon, [ 565 | 'mimetype', 566 | 'width', 567 | 'height', 568 | 'depth', 569 | 'url' 570 | ]); 571 | }); 572 | 573 | var nodes = doc.findall('./device/serviceList/service'); 574 | desc.services = {}; 575 | nodes.forEach(function(service) { 576 | var tmp = extractFields(service, [ 577 | 'serviceType', 578 | 'serviceId', 579 | 'SCPDURL', 580 | 'controlURL', 581 | 'eventSubURL' 582 | ]); 583 | 584 | var id = tmp.serviceId; 585 | delete tmp.serviceId; 586 | desc.services[id] = tmp; 587 | }); 588 | 589 | // Make URLs absolute 590 | var baseUrl = extractBaseUrl(url); 591 | 592 | desc.icons.map(function(icon) { 593 | icon.url = buildAbsoluteUrl(baseUrl, icon.url); 594 | return icon; 595 | }); 596 | 597 | Object.keys(desc.services).forEach(function(id) { 598 | var service = desc.services[id]; 599 | service.SCPDURL = buildAbsoluteUrl(baseUrl, service.SCPDURL); 600 | service.controlURL = buildAbsoluteUrl(baseUrl, service.controlURL); 601 | service.eventSubURL = buildAbsoluteUrl(baseUrl, service.eventSubURL); 602 | }); 603 | 604 | return desc; 605 | } 606 | 607 | 608 | function parseServiceDescription(xml) { 609 | var doc = et.parse(xml); 610 | var desc = {}; 611 | 612 | desc.actions = {}; 613 | var nodes = doc.findall('./actionList/action'); 614 | nodes.forEach(function(action) { 615 | var name = action.findtext('./name'); 616 | var inputs = []; 617 | var outputs = []; 618 | 619 | var nodes = action.findall('./argumentList/argument'); 620 | nodes.forEach(function(argument) { 621 | var arg = extractFields(argument, [ 622 | 'name', 623 | 'direction', 624 | 'relatedStateVariable' 625 | ]); 626 | 627 | var direction = arg.direction; 628 | delete arg.direction; 629 | 630 | if(direction === 'in') inputs.push(arg); 631 | else outputs.push(arg); 632 | }); 633 | 634 | desc.actions[name] = { 635 | inputs: inputs, 636 | outputs: outputs 637 | }; 638 | }); 639 | 640 | desc.stateVariables = {}; 641 | var nodes = doc.findall('./serviceStateTable/stateVariable'); 642 | nodes.forEach(function(stateVariable) { 643 | var name = stateVariable.findtext('./name'); 644 | 645 | var nodes = stateVariable.findall('./allowedValueList/allowedValue'); 646 | var allowedValues = nodes.map(function(allowedValue) { 647 | return allowedValue.text; 648 | }); 649 | 650 | desc.stateVariables[name] = { 651 | dataType: stateVariable.findtext('./dataType'), 652 | sendEvents: stateVariable.get('sendEvents'), 653 | allowedValues: allowedValues, 654 | defaultValue: stateVariable.findtext('./defaultValue') 655 | }; 656 | }); 657 | 658 | return desc; 659 | } 660 | 661 | 662 | function fetch(url, callback) { 663 | var req = http.get(url, function(res) { 664 | if(res.statusCode !== 200) { 665 | var err = new Error('Request failed'); 666 | err.statusCode = res.statusCode; 667 | return callback(err); 668 | } 669 | res.pipe(concat(function(buf) { 670 | callback(null, buf.toString()) 671 | })); 672 | }); 673 | 674 | req.on('error', callback); 675 | req.end(); 676 | } 677 | 678 | 679 | function extractFields(node, fields) { 680 | var data = {}; 681 | fields.forEach(function(field) { 682 | var value = node.findtext('./' + field); 683 | if(typeof value !== 'undefined') { 684 | data[field] = value; 685 | } 686 | }); 687 | return data; 688 | } 689 | 690 | 691 | function buildAbsoluteUrl(base, url) { 692 | if(url === '') return ''; 693 | if(url.substring(0, 4) === 'http') return url; 694 | if(url[0] === '/') { 695 | var root = base.split('/').slice(0, 3).join('/'); // http://host:port 696 | return root + url; 697 | } else { 698 | return base + '/' + url; 699 | } 700 | } 701 | 702 | 703 | function extractBaseUrl(url) { 704 | return url.split('/').slice(0, -1).join('/'); 705 | } 706 | 707 | 708 | function resolveService(serviceId) { 709 | return (serviceId.indexOf(':') === -1) 710 | ? 'urn:upnp-org:serviceId:' + serviceId 711 | : serviceId; 712 | } 713 | 714 | 715 | module.exports = DeviceClient; --------------------------------------------------------------------------------