├── .gitignore ├── package.json ├── config.schema.json ├── test ├── init.js └── README.md ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-sonos", 3 | "version": "0.4.0", 4 | "description": "Sonos plugin for homebridge: https://github.com/nfarina/homebridge", 5 | "license": "ISC", 6 | "keywords": [ 7 | "homebridge-plugin" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/nfarina/homebridge-sonos.git" 12 | }, 13 | "bugs": { 14 | "url": "http://github.com/nfarina/homebridge-sonos/issues" 15 | }, 16 | "engines": { 17 | "node": ">=0.12.0", 18 | "homebridge": ">=0.2.0" 19 | }, 20 | "dependencies": { 21 | "sonos": "^1.7.0", 22 | "underscore": "^1.8.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "Sonos", 3 | "pluginType": "accessory", 4 | "singular": false, 5 | "headerDisplay": "For Details on how to setup see [project page](https://github.com/nfarina/homebridge-sonos#readme).", 6 | "schema": { 7 | "type": "object", 8 | "properties": { 9 | "name": { 10 | "title": "Name", 11 | "type": "string", 12 | "default": "Bedroom Speakers", 13 | "required": true, 14 | "description": "Speaker name that Siri will recognize." 15 | }, 16 | "room": { 17 | "title": "Room", 18 | "type": "string", 19 | "default": "Bedroom", 20 | "required": true, 21 | "description": "This must match the room name in Sonos exactly." 22 | }, 23 | "mute": { 24 | "title": "Mute", 25 | "type": "boolean", 26 | "description": "This is optional. Checking this will mute/unmute the speaker instead of a stop/play." 27 | }, 28 | "groupCacheLifetime": { 29 | "title": "Group Cache Lifetime", 30 | "type": "integer", 31 | "default": 15, 32 | "required": false, 33 | "description": "Amount of time (in seconds) before refreshing the Sonos goup configuration." 34 | }, 35 | "deviceCacheLifetime": { 36 | "title": "Device Cache Lifetime", 37 | "type": "integer", 38 | "default": 3600, 39 | "required": false, 40 | "description": "Amount of time (in seconds) before refreshing detailed information about a given Sonos device on the network." 41 | } 42 | } 43 | }, 44 | "footerDisplay": "Please raise any issues on the [project page](https://github.com/nfarina/homebridge-sonos/issues)." 45 | } 46 | -------------------------------------------------------------------------------- /test/init.js: -------------------------------------------------------------------------------- 1 | var HomebridgeSonos = require('../index.js'); 2 | 3 | // Config to pass to instance of accessory created. 4 | // Modify to reflect your setup for testing. 5 | var config = { 6 | "name": "Bedroom Speaker", 7 | "room": "Bedroom", 8 | "mute": false, 9 | "groupCacheExpiration": 15, 10 | "deviceCacheExpiration": 3600 11 | }; 12 | 13 | // Control what log levels are visible during testing 14 | var logLevelsEnabled = { 15 | warn: true, 16 | error: true, 17 | debug: true 18 | }; 19 | 20 | function mockHomebridgeOutput(message, ...parameters) { 21 | // Simple logging function to demonstrate plugin performing setup with Homebridge. 22 | // Comment line below to suppress these messages. 23 | console.log("Mock homebridge: " + message, ...parameters); 24 | }; 25 | 26 | // Mock logger to pass to plugin 27 | var log = (msg, ...parameters) => console.log(msg, ...parameters); 28 | log.info = log; 29 | log.warn = (msg, ...parameters) => { if (logLevelsEnabled.warn) console.log("WARN: " + msg, ...parameters); }; 30 | log.error = (msg, ...parameters) => { if (logLevelsEnabled.error) console.log("ERROR: " + msg, ...parameters); }; 31 | log.debug = (msg, ...parameters) => { if (logLevelsEnabled.debug) console.log("DEBUG: " + msg, ...parameters); }; 32 | 33 | // Mock Homebridge - just a placeholder structure to satisfy requirements of SonosAccessory. 34 | // May need to be expanded in case further Homebridge functionality is used in plugin later. 35 | var homebridge = { 36 | registerAccessory: (pluginName, accessoryName, accessoryPluginConstructor) => 37 | mockHomebridgeOutput(`registered plugin=${pluginName}, accessory=${accessoryName}, constructor=${accessoryPluginConstructor.name}.`), 38 | hap: { 39 | Service: { 40 | Switch: function (name) { 41 | var eventActions = {}; 42 | function onEvent(event, boundFunction) { 43 | mockHomebridgeOutput(`adding action for event ${event}.`); 44 | eventActions[event] = boundFunction; 45 | return { 46 | on: onEvent 47 | }; 48 | }; 49 | return { 50 | getCharacteristic: (characteristic) => { 51 | mockHomebridgeOutput(`getting Characteristic.${characteristic}.`); 52 | return { 53 | on: onEvent 54 | }; 55 | }, 56 | addCharacteristic: (characteristic) => { 57 | mockHomebridgeOutput(`adding for Characteristic.${characteristic}.`); 58 | return { 59 | on: onEvent 60 | }; 61 | } 62 | }; 63 | } 64 | }, 65 | Characteristic: { 66 | On: "On", 67 | Volume: "Volume" 68 | } 69 | } 70 | }; 71 | 72 | // Instantiate an instance of a plugin accessory 73 | var SonosAccessory = HomebridgeSonos(homebridge); 74 | var sonos = new SonosAccessory(log, config); 75 | 76 | // Simple callback to pass to plugin actions to observe what is happening 77 | function callback(err, r) { console.log(`callback: result=${r}; error=${err}`); }; 78 | 79 | module.exports = { 80 | 81 | // The instantiated plugin, to invoke directly 82 | sonos: sonos, 83 | 84 | // Exposing shorthands to plugin functionality 85 | getOn: function() { sonos.getOn(callback); }, 86 | setOn: function(on) { sonos.setOn(on, callback); }, 87 | 88 | // Additional exports for playing around with in the console, 89 | // with ability to dynamically update accessory behavior 90 | log: log, 91 | config: config, 92 | logLevels: logLevelsEnabled, 93 | 94 | // Export constructor directly so additional accessories can be created on-the-fly if desired 95 | Sonos: SonosAccessory 96 | } 97 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Simple Test Environment for Development 2 | 3 | init.js is a setup script for quick and easy testing of the plugin during development. The script sets up a test environment that mocks Homebridge, allowing development without the overhead of actually running homebridge. It then allows the control of real world Sonos devices through the plugin to test scenarios and validate functionality. 4 | 5 | Example usage: 6 | 7 | $> node 8 | 9 | > var sonos = require('./init.js'); 10 | Mock homebridge: registered plugin=homebridge-sonos, accessory=Sonos, constructor=SonosAccessory. 11 | Mock homebridge: getting Characteristic.On. 12 | Mock homebridge: adding action for event get. 13 | Mock homebridge: adding action for event set. 14 | Mock homebridge: adding for Characteristic.Volume. 15 | Mock homebridge: adding action for event get. 16 | Mock homebridge: adding action for event set. 17 | DEBUG: Found sonos device at 192.168.1.1 18 | DEBUG: Refreshing cached description for device 192.168.1.1:1400 19 | Found a playable device at 192.168.1.1 for room 'Bedroom' 20 | 21 | > sonos.getOn() 22 | DEBUG: Refreshing group cache 23 | WARN: Current state for Sonos: stopped 24 | callback: result=false; error=null 25 | 26 | > sonos.setOn(true) 27 | Setting power to true 28 | Playback attempt with success: true 29 | callback: result=undefined; error=null 30 | 31 | > sonos.getOn() 32 | WARN: Current state for Sonos: playing 33 | callback: result=true; error=null 34 | 35 | > sonos.setOn(false) 36 | Setting power to false 37 | Pause attempt with success: true 38 | callback: result=undefined; error=null 39 | 40 | # Requirements 41 | 42 | Although this test environment avoids the requirement of running homebridge, it does require actually having Sonos devices on the same network with the development machine. The test environment allows real control of Sonos devices through the plugin, so without any devices, there will be nothing to control. 43 | 44 | # Options 45 | 46 | ## Configuration 47 | 48 | The initialization script contains a configuration object that is passed to the plugin so it can be configured for the specific Sonos setup of the developer. Values can be set to anything desired for testing purposes. 49 | 50 | var config = { 51 | "name": "Bedroom Speaker", 52 | "room": "Bedroom", 53 | "mute": false, 54 | "groupCacheExpiration": 15, 55 | "deviceCacheExpiration": 3600 56 | }; 57 | 58 | ## Logging 59 | 60 | A logger is passed to the plugin so full logging works. Deeper logging levels (warn, error, debug) can be suppressed by setting that level to `false`. 61 | 62 | var logLevelsEnabled = { 63 | warn: true, 64 | error: true, 65 | debug: true 66 | } 67 | 68 | ## Mock Homebridge Initialization Messages 69 | 70 | A function defines the console output of plugin initialization with Homebridge (or the mock, in this case). If seeing these messages is not desired, just comment out the console.log() line. 71 | 72 | function mockHomebridgeOutput(message, ...parameters) { 73 | // Simple logging function to demonstrate plugin performing setup with Homebridge. 74 | // Comment line below to suppress these messages. 75 | console.log("Mock homebridge: " + message, ...parameters); 76 | }; 77 | 78 | # Testing Ideas 79 | 80 | ## Direct Accessory Access and Shortcuts 81 | 82 | By default, the automatically-initialized accessory is exported to invoke directly, as well as simple shortcut functions into the accessory, just to reduce the amount of typing necessary when testing. 83 | 84 | > sonos.sonos.getOn() 85 | 86 | is equivalent to 87 | 88 | > sonos.getOn() 89 | 90 | ## On-The-Fly Log Levels 91 | 92 | The log level structure is exported to allow on-the-fly customization of what logs are printed while testing. This makes it possible to, for example, initialize the test environment with the bare-minimum logs output, then enable all the levels again to see what's going on in invocations of other features later. 93 | 94 | > var sonos = require('./init.js'); 95 | Found a playable device at 192.168.1.1 for room 'Bedroom' 96 | 97 | > sonos.logLevels.debug = sonos.logLevels.warn = true 98 | true 99 | 100 | > sonos.getOn() 101 | DEBUG: Refreshing group cache 102 | WARN: Current state for Sonos: stopped 103 | callback: result=false; error=null 104 | 105 | ## Additional Accessories 106 | 107 | The logger is also exported, along with the SonosAccessory() constructor, to allow creating a second (and third, and...) instance of an accessory directly from the console. 108 | 109 | > var config2 = { "name": "Kitchen Speaker", "room": "Kitchen" } 110 | > var device2 = new sonos.SonosAccessory(sonos.log, config2) 111 | Found a playable device at 192.168.1.2 for room 'Kitchen' 112 | 113 | > sonos.getOn() 114 | WARN: Current state for Sonos: playing 115 | callback: result=true; error=null -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sonos accessory 2 | 3 | This accessory allows you to turn Sonos speakers on and off using Siri and/or a HomeKit enabled iOS app. 4 | 5 | * _Siri, turn Bedroom Speakers on._ 6 | * _Siri, turn Bedroom Speakers off._ 7 | 8 | # Installation 9 | 10 | Homebridge is published through [NPM](https://www.npmjs.com/package/homebridge) and should be installed "globally" by typing: 11 | 12 | sudo npm install -g homebridge 13 | sudo npm install -g homebridge-sonos 14 | 15 | If you don't have Homebridge installed, [check the repository](https://github.com/nfarina/homebridge) for detailed setup instructions. 16 | 17 | # Configuration 18 | 19 | The plugin is configured as part of your Homebridge `config.json` file. 20 | 21 | ## Example addition to existing config.json: 22 | 23 | ,{ 24 | "accessories": [ 25 | { 26 | "accessory": "Sonos", 27 | "name": "Bedroom Speakers", 28 | "room": "Bedroom", 29 | "mute": true 30 | } 31 | ] 32 | } 33 | 34 | The `name` parameter is how the device will apear in Apple Homekit. 35 | 36 | The `room` parameter must match the room name in Sonos *exactly*. 37 | 38 | The `mute` parameter is optional. Setting it to `true` will mute/unmute the speaker instead of a pause/play. (More information about how this parameter affects devices in groups can be found [below](#Device-Behavior-When-Grouped).) 39 | 40 | ## Example new config.json: 41 | 42 | { 43 | "bridge": { 44 | "name": "Homebridge", 45 | "pin": "000-00-001" 46 | }, 47 | 48 | "description": "Example config for sonos only.", 49 | 50 | "accessories": [{ 51 | "accessory": "Sonos", 52 | "name": "Bedroom Speakers", 53 | "room": "Bedroom" 54 | }] 55 | } 56 | 57 | ## Advanced Configuration Values: 58 | 59 | These are additional (and optional) configuration parameters available, with their default values. 60 | 61 | "groupCacheLifetime": 15, 62 | "deviceCacheLifetime": 3600, 63 | 64 | `groupCacheLifetime` is the maximum amount of time (in seconds) the current Sonos group configuration will be cached in this plugin. The group configuration is how the devices on the current Sonos network are grouped (controlled via the Sonos app). 65 | 66 | Note: This setting is currently only used if `mute` is `false`. If `mute` is `true`, Sonos group information isn't used. 67 | 68 | `deviceCacheLifetime` is the maximum amount of time (in seconds) the information about each Sonos device discovered on the network will be cached. 69 | 70 | For the curious, an explanation of what these configurations do can be found [below](#Advanced-Configuration-explained). 71 | 72 | # Run Homebridge: 73 | 74 | $ homebridge 75 | 76 | # Notes 77 | 78 | The name "Speakers" is used in the name for the above example configurations instead of something more intuitive like "Sonos" or "Music" or "Radio". 79 | 80 | This is because Siri has many stronger associations for those words. For instance, including "Sonos" in the name will likely cause Siri to just launch the Sonos app. And including "Music" in the name will cause Siri to launch the built-in Music app. 81 | 82 | You could of course pick any other unique name, like "Turn on the croissants" if you want. Or add it to a Scene with a custom phrase. 83 | 84 | ## Device Behavior When Grouped 85 | 86 | ### When `mute` is `false` 87 | The device status displayed in Homekit will reflect the status of the group the device is part of. On/off commands will result in the entire group being played or paused. 88 | 89 | ### When `mute` is `true` 90 | The device status displayed in Homekit will reflect the status of the individual device. On/off commands will apply only to the individual device. 91 | 92 | # Alternative 93 | 94 | You also might check out this [fork of `homebridge-sonos`](https://github.com/dominicstelljes/homebridge-sonos) by [dominicstelljes](https://github.com/dominicstelljes) that exposes the Sonos as a "lightbulb" instead of a switch. This will allow you control the volume through Siri - "Set the Speakers to 50%" for example. But it will have some side-effects. Check out his README for more details. 95 | 96 | # Advanced Configuration explained: 97 | 98 | Additional network calls to Sonos devices are required to get detailed descriptions of each device and information about groups. If `mute` is `false` (using pause/play for control instead of mute/unmute), this information is used whenever Homekit requests status updates of devices and when requests to turn on/off (play/pause) are made from Homekit. Typically, Homekit will additionally request another status update for the device after a command is issued to play or pause the device. 99 | 100 | Requesting these details from Sonos devices every time simple actions are performed in Homekit are unnecessary, so the details retrieved from devices are cached to be used in the next lookup. 101 | 102 | Detailed device descriptions are unlikely to change often, so these can be cached for a longer period of time. The lifetime of this cache is controlled by `deviceCacheLifetime`, and is set to 1 hour by default. In most networks that are unlikely to change, this can practically be any large amount of time with no consequences. 103 | 104 | Groups, however, may change frequently or infrequently depending on the preferences of the user, and updates through the Sonos app will not be updated in this plugin until the group cache is refreshed. The lifetime of this cache is controlled by `groupCacheLifetime`. The default value of 15 seconds is long enough to ensure a single network call is enough to get the information for multiple uses instead of making the request every time, but (ideally) short enough to make the delay between group updates in Sonos app and the information being discovered in this plugin negligible. 105 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Sonos = require('sonos'); 2 | var _ = require('underscore'); 3 | var inherits = require('util').inherits; 4 | var Service, Characteristic, VolumeCharacteristic; 5 | var sonosDevices = new Map(); 6 | var sonosAccessories = []; 7 | 8 | module.exports = function(homebridge) { 9 | Service = homebridge.hap.Service; 10 | Characteristic = homebridge.hap.Characteristic; 11 | 12 | homebridge.registerAccessory("homebridge-sonos", "Sonos", SonosAccessory); 13 | 14 | // Not expected by Homebridge, but enables easy testing via test/init.js 15 | return SonosAccessory; 16 | } 17 | 18 | // 19 | // Node-Sonos Functions to process device information 20 | // 21 | function getZoneGroupCoordinator (zone) { 22 | var coordinator; 23 | sonosDevices.forEach(function (device) { 24 | if (device.CurrentZoneName == zone && device.coordinator == 'true') { 25 | coordinator = device; 26 | } 27 | }); 28 | if (coordinator == undefined) { 29 | var zoneGroups = getZoneGroupNames(zone); 30 | zoneGroups.forEach(function (group) { 31 | sonosDevices.forEach(function (device) { 32 | if (device.group == group && device.coordinator == 'true') { 33 | coordinator = device; 34 | } 35 | }); 36 | }); 37 | } 38 | return coordinator; 39 | } 40 | 41 | function getZoneGroupNames(zone) { 42 | var groups = []; 43 | sonosDevices.forEach(function (device) { 44 | if (device.CurrentZoneName == zone) { 45 | groups.push(device.group); 46 | } 47 | }); 48 | return groups; 49 | } 50 | 51 | function listenGroupMgmtEvents(device) { 52 | var devListener = new Listener(device); 53 | devListener.listen(function (listenErr) { 54 | if (!listenErr) { 55 | devListener.addService('/GroupManagement/Event', function (addServErr, sid) { 56 | if (!addServErr) { 57 | devListener.on('serviceEvent', function (endpoint, sid, data) { 58 | sonosDevices.forEach(function (devData) { 59 | var dev = new Sonos(devData.ip); 60 | dev.getZoneAttrs(function (err, zoneAttrs) { 61 | if (!err && zoneAttrs) { 62 | device.getTopology(function (err, topology) { 63 | if (!err && topology) { 64 | var bChangeDetected = false; 65 | topology.zones.forEach(function (group) { 66 | if (group.location == 'http://' + devData.ip + ':' + devData.port + '/xml/device_description.xml') { 67 | if (zoneAttrs.CurrentZoneName != devData.CurrentZoneName) { 68 | devData.CurrentZoneName = zoneAttrs.CurrentZoneName; 69 | } 70 | if (group.coordinator != devData.coordinator || group.group != devData.group) { 71 | devData.coordinator = group.coordinator; 72 | devData.group = group.group; 73 | bChangeDetected = true; 74 | } 75 | } 76 | else { 77 | var grpDevIP = group.location.substring(7, group.location.lastIndexOf(":")); 78 | var grpDevData = sonosDevices.get(grpDevIP); 79 | if (grpDevData != undefined) { 80 | if (group.name != grpDevData.CurrentZoneName) { 81 | grpDevData.CurrentZoneName = group.Name; 82 | } 83 | if (group.coordinator != grpDevData.coordinator || group.group != grpDevData.group) { 84 | grpDevData.coordinator = group.coordinator; 85 | grpDevData.group = group.group; 86 | bChangeDetected = true; 87 | } 88 | } 89 | } 90 | 91 | }); 92 | if (bChangeDetected) { 93 | sonosAccessories.forEach(function (accessory) { 94 | var coordinator = getZoneGroupCoordinator(accessory.room); 95 | accessory.log.debug("Target Zone Group Coordinator identified as: %s", JSON.stringify(coordinator)); 96 | if (coordinator == undefined) { 97 | accessory.log.debug("Removing coordinator device from %s", JSON.stringify(accessory.device)); 98 | accessory.device = coordinator; 99 | } 100 | else { 101 | var bUpdate = false; 102 | if (accessory.device != undefined) { 103 | if (accessory.device.host != coordinator.ip) bUpdate = true; 104 | } 105 | else { 106 | bUpdate = true; 107 | } 108 | if (bUpdate) { 109 | accessory.log("Changing coordinator device from %s to %s (from sonos zone %s) for accessory '%s' in accessory room '%s'.", accessory.device.host, coordinator.ip, coordinator.CurrentZoneName, accessory.name, accessory.room); 110 | accessory.device = new Sonos(coordinator.ip); 111 | } 112 | else { 113 | accessory.log.debug("No coordinator device change required!"); 114 | } 115 | } 116 | }); 117 | } 118 | } 119 | }); 120 | } 121 | }); 122 | }); 123 | }); 124 | } 125 | }); 126 | } 127 | }); 128 | } 129 | 130 | 131 | 132 | // 133 | // Sonos Accessory 134 | // 135 | 136 | function SonosAccessory(log, config) { 137 | this.log = log; 138 | this.config = config; 139 | this.name = config["name"]; 140 | this.room = config["room"]; 141 | this.mute = config["mute"]; 142 | 143 | // cache lifetimes 144 | this.groupCacheLifetime = (config["groupCacheLifetime"] || 15) * 1000; 145 | this.deviceCacheLifetime = (config["deviceCacheLifetime"] || 3600) * 1000; 146 | 147 | if (!this.room) throw new Error("You must provide a config value for 'room'."); 148 | 149 | this.service = new Service.Switch(this.name); 150 | 151 | this.service 152 | .getCharacteristic(Characteristic.On) 153 | .on('get', this.getOn.bind(this)) 154 | .on('set', this.setOn.bind(this)); 155 | 156 | this.service 157 | .addCharacteristic(Characteristic.Volume) 158 | .on('get', this.getVolume.bind(this)) 159 | .on('set', this.setVolume.bind(this)); 160 | 161 | this.search(); 162 | } 163 | 164 | SonosAccessory.zoneTypeIsPlayable = function(zoneType) { 165 | // 8 is the Sonos SUB, 4 is the Sonos Bridge, 11 is unknown 166 | return zoneType != '11' && zoneType != '8' && zoneType != '4'; 167 | } 168 | 169 | SonosAccessory.prototype.search = function() { 170 | var search = Sonos.DeviceDiscovery({ timeout: 30000 }); 171 | search.on('DeviceAvailable', function (device, model) { 172 | var host = device.host; 173 | this.log.debug("Found sonos device at %s", host); 174 | 175 | this.getDeviceDescription(device).then(description => { 176 | 177 | if (description == undefined) { 178 | this.log.debug('Ignoring callback because description is undefined.'); 179 | return; 180 | } 181 | 182 | var zoneType = description["zoneType"]; 183 | var roomName = description["roomName"]; 184 | 185 | if (!SonosAccessory.zoneTypeIsPlayable(zoneType)) { 186 | this.log.debug("Sonos device %s is not playable (has an unknown zone type of %s); ignoring", host, zoneType); 187 | return; 188 | } 189 | 190 | if (roomName != this.room) { 191 | this.log.debug("Ignoring device %s because the room name '%s' does not match the desired name '%s'.", host, roomName, this.room); 192 | return; 193 | } 194 | 195 | if (null == this.device) { // avoid multiple call of search.destroy in multi-device rooms 196 | this.log("Found a playable device at %s for room '%s'", host, roomName); 197 | this.device = device; 198 | search.destroy(); // we don't need to continue searching. 199 | } 200 | }) 201 | .catch(reason => this.log.debug("Unexpected error getting device description: " + reason)); 202 | }.bind(this)); 203 | } 204 | 205 | SonosAccessory.prototype.oldSearch = function() { 206 | 207 | sonosAccessories.push(this); 208 | 209 | var search = sonos.search(function(device, model) { 210 | this.log.debug("Found device at %s", device.host); 211 | 212 | var data = {ip: device.host, port: device.port, discoverycompleted: 'false'}; 213 | device.getZoneAttrs(function (err, attrs) { 214 | if (!err && attrs) { 215 | _.extend(data, {CurrentZoneName: attrs.CurrentZoneName}); 216 | } 217 | device.getTopology(function (err, topology) { 218 | if (!err && topology) { 219 | topology.zones.forEach(function (group) { 220 | if (group.location == 'http://' + data.ip + ':' + data.port + '/xml/device_description.xml') { 221 | _.extend(data, group); 222 | data.discoverycompleted = 'true'; 223 | } 224 | else { 225 | var grpDevIP = group.location.substring(7, group.location.lastIndexOf(":")); 226 | var grpDevData = {ip: grpDevIP, discoverycompleted: 'false', CurrentZoneName: group.name}; 227 | _.extend(grpDevData, group); 228 | if (sonosDevices.get(grpDevIP) == undefined) { 229 | sonosDevices.set(grpDevIP, grpDevData); 230 | } 231 | } 232 | }.bind(this)); 233 | } 234 | if (sonosDevices.get(data.ip) == undefined) { 235 | sonosDevices.set(data.ip, data); 236 | } 237 | else { 238 | if (sonosDevices.get(data.ip).discoverycompleted == 'false') { 239 | sonosDevices.set(data.ip, data); 240 | } 241 | } 242 | var coordinator = getZoneGroupCoordinator(this.room); 243 | if (coordinator != undefined) { 244 | if (coordinator.ip == data.ip) { 245 | this.log("Found a playable coordinator device at %s in zone '%s' for accessory '%s' in accessory room '%s'", data.ip, data.CurrentZoneName, this.name, this.room); 246 | this.device = device; 247 | search.destroy(); // we don't need to continue searching. 248 | } 249 | } 250 | 251 | listenGroupMgmtEvents(device); 252 | 253 | }.bind(this)); 254 | }.bind(this)); 255 | }.bind(this)); 256 | } 257 | 258 | SonosAccessory.prototype.getServices = function() { 259 | return [this.service]; 260 | } 261 | 262 | // Device and description cache. 263 | // Although this format should be 'static' and shared 264 | // across SonosAccessory instances, HomeBridge seems 265 | // to instantiate plugins in a way that keeps them entirely 266 | // separate, so this cache is distinct per instance. 267 | // However, the cache is still valuable to the individual 268 | // instance using it. 269 | SonosAccessory.deviceCache = { 270 | groups: { 271 | groups: null, 272 | lastUpdate: 0 273 | }, 274 | descriptions: {} 275 | }; 276 | 277 | SonosAccessory.prototype.getGroups = function() { 278 | if (Date.now() < SonosAccessory.deviceCache.groups.lastUpdate + (this.groupCacheLifetime)) { 279 | // return cached group status if it was refreshed less than the configured lifetime ago 280 | return Promise.resolve(SonosAccessory.deviceCache.groups.groups); 281 | } 282 | 283 | this.log.debug("Refreshing group cache"); 284 | return this.device.getAllGroups().then(groups => { 285 | SonosAccessory.deviceCache.groups.lastUpdate = Date.now(); 286 | SonosAccessory.deviceCache.groups.groups = groups; 287 | return groups; 288 | }); 289 | } 290 | 291 | SonosAccessory.prototype.getDeviceDescription = function(device) { 292 | // use cached description if it's available and refreshed less than the configured lifetime ago 293 | var cacheKey = `${device.host}:${device.port}`; 294 | var desc = SonosAccessory.deviceCache.descriptions[cacheKey]; 295 | if (desc != undefined && desc.lastUpdate > Date.now() - this.deviceCacheLifetime) { 296 | return Promise.resolve(desc.desc); 297 | } 298 | 299 | this.log.debug("Refreshing cached description for device %s", cacheKey); 300 | return device.deviceDescription().then(desc => { 301 | SonosAccessory.deviceCache.descriptions[cacheKey] = { 302 | desc: desc, 303 | lastUpdate: Date.now() 304 | }; 305 | return desc; 306 | }) 307 | } 308 | 309 | SonosAccessory.prototype.getGroupCoordinator = function() { 310 | return this.getGroups().then(groups => { 311 | var myGroup = groups.find(group => 312 | group.ZoneGroupMember.some( 313 | member => member.ZoneName == this.room)); 314 | if (myGroup) { 315 | var coordinator = myGroup.CoordinatorDevice(); 316 | this.getDeviceDescription(coordinator).then(desc => { 317 | if (desc["roomName"] != this.room) { 318 | this.log.debug("Found group coordinator " + desc["roomName"]); 319 | } 320 | }) 321 | return coordinator; 322 | } 323 | else { 324 | return Promise.reject("Coordinator could not be found."); 325 | } 326 | }); 327 | } 328 | 329 | SonosAccessory.prototype.getOn = function(callback) { 330 | if (!this.device) { 331 | this.log.warn("Ignoring request; Sonos device has not yet been discovered."); 332 | callback(new Error("Sonos has not been discovered yet.")); 333 | return; 334 | } 335 | 336 | if (!this.mute) { 337 | this.getGroupCoordinator().then(coordinator => { 338 | coordinator.getCurrentState().then(state => { 339 | this.log.warn("Current state for Sonos: " + state); 340 | var on = (state == "playing"); 341 | callback(null, on); 342 | }) 343 | .catch(reason => callback(reason)); 344 | }) 345 | .catch(reason => callback(reason)); 346 | } 347 | else { 348 | this.device.getMuted().then(muted => { 349 | this.log.warn("Current state for Sonos: " + (muted ? "muted" : "unmuted")); 350 | callback(null, !muted); 351 | }) 352 | .catch(reason => callback(reason)); 353 | } 354 | } 355 | 356 | SonosAccessory.prototype.setOn = function(on, callback) { 357 | if (!this.device) { 358 | this.log.warn("Ignoring request; Sonos device has not yet been discovered."); 359 | callback(new Error("Sonos has not been discovered yet.")); 360 | return; 361 | } 362 | 363 | var action = this.mute ? (on ? "Unmute" : "Mute") : (on ? "Play" : "Pause"); 364 | this.log("Setting status to " + action); 365 | 366 | if (!this.mute){ 367 | this.getGroupCoordinator().then(coordinator => { 368 | if (on) { 369 | coordinator.play().then(success => { 370 | this.log("Playback attempt with success: " + success); 371 | callback(null); 372 | }) 373 | .catch(reason => callback(reason)); 374 | } 375 | else { 376 | coordinator.pause().then(success => { 377 | this.log("Pause attempt with success: " + success); 378 | callback(null); 379 | }) 380 | .catch(reason => callback(reason)); 381 | } 382 | }) 383 | .catch(reason => callback(reason)); 384 | } 385 | else { 386 | this.device.setMuted(!on).then(result => { 387 | // The node-sonos library seems to return an empty object (i.e. {}) 388 | // after muting/unmuting, so we can only check what's in the object 389 | // to see if there's anything unexpected. 390 | if (result && (typeof(result) == 'object' && Object.entries(result).length == 0) 391 | || result == true) { 392 | this.log(action + " attempt succeeded."); 393 | callback(null); 394 | } 395 | else { 396 | this.log(`Unexpected result trying to ${action}: ${JSON.stringify(result)}`); 397 | callback(result); 398 | } 399 | }) 400 | .catch(reason => callback(reason)); 401 | } 402 | } 403 | 404 | SonosAccessory.prototype.getVolume = function(callback) { 405 | if (!this.device) { 406 | this.log.warn("Ignoring request; Sonos device has not yet been discovered."); 407 | callback(new Error("Sonos has not been discovered yet.")); 408 | return; 409 | } 410 | 411 | this.device.getVolume().then(volume => { 412 | this.log("Current volume: %s", volume); 413 | callback(null, Number(volume)); 414 | }) 415 | .catch(reason => callback(reason)); 416 | } 417 | 418 | SonosAccessory.prototype.setVolume = function(volume, callback) { 419 | if (!this.device) { 420 | this.log.warn("Ignoring request; Sonos device has not yet been discovered."); 421 | callback(new Error("Sonos has not been discovered yet.")); 422 | return; 423 | } 424 | 425 | this.log("Setting volume to %s", volume); 426 | 427 | this.device.setVolume(volume + "").then(data => { 428 | this.log("Set volume response with data: " + data); 429 | callback(null); 430 | }) 431 | .catch(reason => callback(reason)); 432 | } 433 | --------------------------------------------------------------------------------