diff --git a/API.md b/API.md index 509e432..74e65fb 100644 --- a/API.md +++ b/API.md @@ -17,7 +17,7 @@ This module exports 3 items: s.currentTrack(console.log); // sonos.Services - wrappers arounds all UPNP services provided by sonsos - // These aren't used internally by the module at all but may be usefull + // These aren't used internally by the module at all but may be useful // for more complex projects. ###var Sonos = new sonos.Sonos(host, port)### diff --git a/examples/logicalDeviceVolume.js b/examples/logicalDeviceVolume.js new file mode 100644 index 0000000..a33e91f --- /dev/null +++ b/examples/logicalDeviceVolume.js @@ -0,0 +1,40 @@ +var Sonos = require('../'); +var keypress = require('keypress'); + +Sonos.LogicalDevice.search(function(err, groups) { + console.log(err, groups); +}); + +var dev = new Sonos.LogicalDevice([ + { host: '172.17.106.196', port: 1400 }, + { host: '172.17.107.66', port: 1400 } +]); + +dev.initialize(function() { + console.log('use up / down keys to change volume'); + + keypress(process.stdin); + + process.stdin.on('keypress', function (ch, key) { + if (key.name === 'down') { + + dev.getVolume(function(vol) { + dev.setVolume(vol - 5); + }); + + } else if (key.name === 'up') { + + dev.getVolume(function(vol) { + dev.setVolume(vol + 5); + }); + + } + if (key && key.ctrl && key.name === 'c') { + process.exit(); + } + }); + + process.stdin.setRawMode(true); + process.stdin.resume(); + +}); diff --git a/examples/volumeWatcher.js b/examples/volumeWatcher.js new file mode 100644 index 0000000..8965931 --- /dev/null +++ b/examples/volumeWatcher.js @@ -0,0 +1,8 @@ +var Sonos = require('../index').Sonos; + +var device = new Sonos(process.env.SONOS_HOST || '192.168.2.11'); +device.initialize(function() { + device.on('volumeChange', function(volume) { + console.log(volume); + }); +}); diff --git a/lib/events/listener.js b/lib/events/listener.js index b62c60c..f4ec3a8 100644 --- a/lib/events/listener.js +++ b/lib/events/listener.js @@ -1,3 +1,4 @@ +var async = require('async'); var request = require('request'); var http = require('http'); var ip = require('ip'); @@ -6,11 +7,16 @@ var util = require('util'); var _ = require('underscore'); var events = require('events'); -var Listener = function(device) { +/** + * Listener "Class" + * @param {Sonos} device Corresponding Sonos device + */ +function Listener(device) { this.device = device; this.parser = new xml2js.Parser(); this.services = {}; -}; + this.serviceEndpoints = {}; +} util.inherits(Listener, events.EventEmitter); @@ -37,6 +43,40 @@ Listener.prototype._startInternalServer = function(callback) { }; +/** + * Add event handler to the endpoint + * @param {String} serviceEndpoint Endpoint to subscribe to + * @param {Function} handler handler to call for events + * @param {Function} callback {err, sonosServiceId} + */ +Listener.prototype.addHandler = function(serviceEndpoint, handler, callback) { + + this.on('serviceEvent', function(endpoint, sid, data) { + if (endpoint === serviceEndpoint) { + handler(data); + } + }); + + if (!this.serviceEndpoints[serviceEndpoint]) + this._addService(serviceEndpoint, callback); +}; + +/** + * Remove all handlers from endpoint + * @param {Function} callback {err, results} + */ +Listener.prototype.removeAllHandlers = function(callback) { + + this.removeAllListeners('serviceEvent'); + + async.parallel(Object.keys(this.services).map(function(sid) { + return function(cb) { + this._removeService(sid, cb); + }.bind(this); + }.bind(this)), callback); + +}; + Listener.prototype._messageHandler = function(req, res) { if (req.method.toUpperCase() === 'NOTIFY' && req.url.toLowerCase() === '/notify') { @@ -61,9 +101,11 @@ Listener.prototype._messageHandler = function(req, res) { } }; -Listener.prototype.addService = function(serviceEndpoint, callback) { +Listener.prototype._addService = function(serviceEndpoint, callback) { if (!this.server) { - throw 'Service endpoints can only be added after listen() is called'; + callback(new Error('Service endpoints can only be added after listen() is finished')); + } else if (this.serviceEndpoints[serviceEndpoint]) { + callback(new Error('Service endpoint already added (' + serviceEndpoint + ')')); } else { var opt = { @@ -78,35 +120,31 @@ Listener.prototype.addService = function(serviceEndpoint, callback) { request(opt, function(err, response) { if (err || response.statusCode !== 200) { - console.log(response.message || response.statusCode); callback(err || response.statusMessage); } else { - callback(null, response.headers.sid); + var sid = response.headers.sid; - this.services[response.headers.sid] = { + this.services[sid] = { endpoint: serviceEndpoint, data: {} }; + + this.serviceEndpoints[serviceEndpoint] = sid; + + if (callback) + callback(null, sid); } }.bind(this)); } }; -Listener.prototype.listen = function(callback) { +Listener.prototype._removeService = function(sid, callback) { if (!this.server) { - this._startInternalServer(callback); - } else { - throw 'Service listener is already listening'; - } -}; - -Listener.prototype.removeService = function(sid, callback) { - if (!this.server) { - throw 'Service endpoints can only be modified after listen() is called'; + callback(new Error('Service endpoints can only be modified after listen() is called')); } else if (!this.services[sid]) { - throw 'Service with sid ' + sid + ' is not registered'; + callback(new Error('Service with sid ' + sid + ' is not registered')); } else { var opt = { @@ -125,8 +163,17 @@ Listener.prototype.removeService = function(sid, callback) { callback(null, true); } }); + } + +}; + +Listener.prototype.listen = function(callback) { + if (!this.server) { + this._startInternalServer(callback); + } else { + callback(new Error('Service listener is already listening')); } }; -module.exports = Listener; \ No newline at end of file +module.exports = Listener; diff --git a/lib/events/volumeListener.js b/lib/events/volumeListener.js new file mode 100644 index 0000000..14b5b03 --- /dev/null +++ b/lib/events/volumeListener.js @@ -0,0 +1,29 @@ +var SERVICE_ENDPOINT = '/MediaRenderer/RenderingControl/Event'; + +var initVolumeListener = function(baseListener, callback) { + + var initialized = false; + + baseListener.addHandler(SERVICE_ENDPOINT, function(data) { + + // wait for initial data before callback + if (!initialized) { + initialized = true; + callback(null); + } + + baseListener.parser.parseString(data.LastChange, function(err, result) { + + if (!result.Event.InstanceID[0].Volume) return; // non-volume related change to rendering + + baseListener.device.state.volume = parseInt(result.Event.InstanceID[0].Volume[0].$.val); + baseListener.device.emit('volumeChange', baseListener.device.state.volume); + }); + + }, function(err) { + if (err) callback(err); + }); + +}; + +module.exports = initVolumeListener; \ No newline at end of file diff --git a/lib/logicalDevice.js b/lib/logicalDevice.js new file mode 100644 index 0000000..88f3d2c --- /dev/null +++ b/lib/logicalDevice.js @@ -0,0 +1,126 @@ +var async = require('async'), + Sonos = require('./sonos').Sonos, + sonosSearch = require('./sonos').search, + util = require('util'), + url = require('url'); + +function LogicalDevice(devices, coordinator, groupId) { + if (devices.length === 0) { + throw new Error('Logical device must be initialized with at least one device (' + devices.length + ' given)'); + } + + var coordinatorDevice = coordinator || devices[0]; + + Sonos.call(this, coordinatorDevice.host, coordinatorDevice.port); + var sonosDevices = devices.map(function(device) { + return new Sonos(device.host, device.post); + }); + + this.devices = sonosDevices; + this.groupId = groupId; +} + +util.inherits(LogicalDevice, Sonos); + +LogicalDevice.prototype.initialize = function(cb) { + async.forEach(this.devices, function(device, done) { + device.initialize(done); + }, cb); +}; + +LogicalDevice.prototype.destroy = function(cb) { + async.forEach(this.devices, function(device, done) { + device.destroy(done); + }, cb); +}; + +LogicalDevice.prototype.setVolume = function(volume, cb) { + this.getVolume(function(oldVolume) { + + var diff = volume - oldVolume; + + async.forEach(this.devices, function(device, done) { + var oldDeviceVolume = device.state.volume; + var newDeviceVolume = oldDeviceVolume + diff; + + newDeviceVolume = Math.max(newDeviceVolume, 0); + newDeviceVolume = Math.min(newDeviceVolume, 100); + + device.setVolume(newDeviceVolume, done); + + }, cb); + + }.bind(this)); +}; + +LogicalDevice.prototype.getVolume = function(cb) { + + var sum = 0; + + this.devices.forEach(function(device) { + sum += device.state.volume || 0; + }); + + cb(sum / this.devices.length); +}; + +/** + * Create a Search Instance (emits 'DeviceAvailable' with a found Logical Sonos Component) + * @param {Function} Optional 'DeviceAvailable' listener (sonos) + * @return {Search/EventEmitter Instance} + */ +var search = function(callback) { + var search = sonosSearch(); + + search.once('DeviceAvailable', function(device) { + device.getTopology(function(err, topology) { + if (err) callback(err); + else { + + var devices = topology.zones; + var groups = {}; + + // bucket devices by groupid + devices.forEach(function(device) { + + if (device.coordinator === 'false' || device.name === 'BRIDGE' || device.name === 'BOOST') return; // devices to ignore in search + + if (!groups[device.group]) groups[device.group] = { members: [] }; + + var parsedLocation = url.parse(device.location); + var sonosDevice = new Sonos(parsedLocation.hostname, parsedLocation.port); + + if (device.coordinator === 'true') groups[device.group].coordinator = sonosDevice; + groups[device.group].members.push(sonosDevice); + + }); + + // initialze all of the logical devices brefore callback + var logicalDevices = Object.keys(groups).map(function(groupId) { + var group = groups[groupId]; + return new LogicalDevice(group.members, group.coordinator, groupId); + }); + + async.forEach(logicalDevices, function(device, done) { + device.initialize(function(err) { + if (err) done(err); + else { + done(null); + } + }); + }, function(err) { + if (err) callback(err); + else { + callback(null, logicalDevices); + } + }); + } + + }); + }); + + return search; +}; + +module.exports = LogicalDevice; +module.exports.search = search; diff --git a/lib/sonos.js b/lib/sonos.js index 1ac2b84..d07f6ec 100644 --- a/lib/sonos.js +++ b/lib/sonos.js @@ -18,7 +18,9 @@ var util = require('util'), xml2js = require('xml2js'), debug = require('debug')('sonos'), fs = require('fs'), - _ = require('underscore'); + _ = require('underscore'), + Listener = require('./events/listener'), + async = require('async'); /** * Services @@ -64,6 +66,44 @@ var htmlEntities = function (str) { var Sonos = function Sonos(host, port) { this.host = host; this.port = port || 1400; + this.state = {}; +}; + +util.inherits(Sonos, EventEmitter); + +var serviceListeners = [ + require('./events/volumeListener') +]; + +/** + * Start service event listeners + * @param {Function} callback {err, results} + */ +Sonos.prototype.initialize = function(callback) { + + this.listener = new Listener(this); + + this.listener.listen(function(err) { + if (err) callback(err); + else { + + async.parallel(serviceListeners.map(function(listenerInit) { + return function(callback) { + listenerInit(this.listener, callback); + }.bind(this); + }.bind(this)), callback); + + } + + }.bind(this)); +}; + +/** + * Stop service event listeners + * @param {Function} callback {err, results} + */ +Sonos.prototype.destroy = function(callback) { + this.listener.removeAllHandlers(callback); }; /** @@ -740,7 +780,7 @@ var Search = function Search() { } }); - this.on('error', function(err) { + this.socket.on('error', function(err) { _this.emit('error', err); }); @@ -775,4 +815,5 @@ var search = function(listener) { module.exports.Sonos = Sonos; module.exports.search = search; +module.exports.LogicalDevice = require('./logicalDevice'); module.exports.Services = Services; diff --git a/package.json b/package.json index 7dc4a85..9b62956 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sonos", - "version": "0.6.1", + "version": "0.7.0", "description": "Node.js Sonos Interface", "main": "index.js", "scripts": { @@ -30,19 +30,21 @@ ], "license": "MIT", "dependencies": { + "async": "^0.9.0", "debug": "~0.7.2", + "ip": "~0.3.0", + "request": "~2.27.0", "underscore": "~1.5.1", "upnp-client": "0.0.1", - "xml2js": "~0.2.8", - "request": "~2.27.0", - "ip": "~0.3.0" + "xml2js": "~0.2.8" }, "bugs": { "url": "http://github.com/bencevans/node-sonos/issues" }, "devDependencies": { - "grunt-contrib-jshint": "~0.8.0", "grunt": "~0.4.2", - "grunt-mocha-test": "~0.10.2" + "grunt-contrib-jshint": "~0.8.0", + "grunt-mocha-test": "~0.10.2", + "keypress": "^0.2.1" } }