├── .vscode └── settings.json ├── index.js ├── package.json ├── LICENSE ├── .gitignore ├── config.schema.json ├── README.md └── src ├── sonos-multiroom-platform.js ├── sonos-zone.js └── sonos-api.js /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "LEDs", 4 | "Playbar", 5 | "Playbase", 6 | "Sonos", 7 | "automations", 8 | "multiroom", 9 | "spdif" 10 | ] 11 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | const SonosMultiroomPlatform = require('./src/sonos-multiroom-platform'); 3 | 4 | /** 5 | * Defines the export of the plugin entry point. 6 | * @param homebridge The homebridge API that contains all classes, objects and functions for communicating with HomeKit. 7 | */ 8 | module.exports = function (homebridge) { 9 | homebridge.registerPlatform('homebridge-sonos-multiroom', 'SonosMultiroomPlatform', SonosMultiroomPlatform, true); 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-sonos-multiroom", 3 | "version": "0.6.0", 4 | "description": "Plugin for real Sonos multiroom experience in homebridge.", 5 | "license": "MIT", 6 | "keywords": [ 7 | "homebridge-plugin", 8 | "homebridge", 9 | "homebridge-sonos-multiroom", 10 | "sonos" 11 | ], 12 | "dependencies": { 13 | "sonos": "^1.12.5" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/lukasroegner/homebridge-sonos-multiroom.git" 18 | }, 19 | "bugs": { 20 | "url": "http://github.com/lukasroegner/homebridge-sonos-multiroom/issues" 21 | }, 22 | "engines": { 23 | "node": ">=0.12.0", 24 | "homebridge": ">=0.2.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lukas Rögner 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Local configuration 64 | config.json 65 | persist 66 | accessories 67 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "SonosMultiroomPlatform", 3 | "pluginType": "platform", 4 | "headerDisplay": "This project is a homebridge plugin for the Sonos system. Instead of trying to support all features of the Sonos devices, it aims to provide a simple feature set while enabling a real multiroom experience.", 5 | "footerDisplay": "For help please visit the [GitHub repository](https://github.com/lukasroegner/homebridge-sonos-multiroom).", 6 | "schema":{ 7 | "type": "object", 8 | "properties": { 9 | "zones": { 10 | "type": "array", 11 | "title": "Zones", 12 | "items": { 13 | "type": "object", 14 | "properties": { 15 | "name": { 16 | "type": "string", 17 | "title": "Name", 18 | "placeholder": "Living Room" 19 | }, 20 | "isNightModeEnabled": { 21 | "type": "boolean", 22 | "title": "Enable Night Mode" 23 | }, 24 | "isSpeechEnhancementEnabled": { 25 | "type": "boolean", 26 | "title": "Enable Speech Enhancement" 27 | }, 28 | "priorities": { 29 | "type": "array", 30 | "title": "Priorities", 31 | "items": { 32 | "type": "string", 33 | "title": "Priorities", 34 | "placeholder": "Bedroom" 35 | } 36 | }, 37 | "isAutoPlayDisabled": { 38 | "type": "boolean", 39 | "title": "Disable Auto-Play" 40 | } 41 | } 42 | } 43 | }, 44 | "discoveryTimeout": { 45 | "type": "integer", 46 | "title": "Discovery Timeout", 47 | "placeholder": "5000" 48 | }, 49 | "isApiEnabled": { 50 | "type": "boolean", 51 | "title": "Enable API" 52 | }, 53 | "apiPort": { 54 | "type": "integer", 55 | "title": "API Port", 56 | "placeholder": "40809" 57 | }, 58 | "apiToken": { 59 | "type": "string", 60 | "title": "API Token" 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-sonos-multiroom 2 | 3 | This project is a homebridge plugin for the Sonos system. Instead of trying to support all features of the Sonos devices, it aims to provide a simple feature set while enabling a real multiroom experience. 4 | 5 | ## Who is it for? 6 | 7 | The use case for this plugin is simple: you want your music or TV audio stream to follow you around in your home. This can be accomplished with a combination of this plugin and HomeKit motion/occupancy sensors. 8 | 9 | ## Which HomeKit accessories are provided? 10 | 11 | The plugin exposes each zone (e.g. room) of your Sonos system as an outlet, which can be switched ON and OFF (you can also enable switches for night mode and speech enhancement for the Playbar and Playbase). 12 | 13 | ## How does multiroom handling work? 14 | 15 | The exposed accessories have the following logic when being switched ON: 16 | * Do nothing if the zone is already playing 17 | * Check if there is another Sonos zone that is already playing (if more than one Sonos zones are currently playing, a priority list that can be configured is used to determine which group to enter) 18 | * If found, enter the group of the Sonos zone that is already playing 19 | * If not, start playback if auto-play is enabled (which means the last source, stream or radio of the respective Sonos zone is played back) 20 | 21 | The exposed accessories have the following logic when being switched OFF: 22 | * Check if you are playing your TV stream (i.e. Playbar/Playbase) 23 | * If so, do nothing (as TV stream should not be "paused") 24 | * If not, stop playback and leave any group you are in 25 | 26 | Now, create HomeKit automations for your motion/occupancy sensors for each room 27 | * "If motion is detected, switch to ON" 28 | * "If no motion is detected, switch to OFF" 29 | 30 | **Result**: If you enter a room, it will automatically start playback of music/TV that is playing in another room. If you leave the room, music playback stops. 31 | 32 | ## Installation 33 | 34 | Install the plugin via npm: 35 | 36 | ```bash 37 | npm install homebridge-sonos-multiroom -g 38 | ``` 39 | 40 | ## Configuration 41 | 42 | ```json 43 | { 44 | "platforms": [ 45 | { 46 | "platform": "SonosMultiroomPlatform", 47 | "discoveryTimeout": 5000, 48 | "zones": [ 49 | { 50 | "name": "Living Room", 51 | "isNightModeEnabled": true, 52 | "isSpeechEnhancementEnabled": true, 53 | "priorities": [ 54 | "Bedroom", 55 | "Bathroom" 56 | ], 57 | "isAutoPlayDisabled": false 58 | }, 59 | { 60 | "name": "Bathroom", 61 | "priorities": [ 62 | "Living Room", 63 | "Bedroom" 64 | ], 65 | "isAutoPlayDisabled": false 66 | }, 67 | { 68 | "name": "Bedroom", 69 | "priorities": [ 70 | "Living Room", 71 | "Bathroom" 72 | ], 73 | "isAutoPlayDisabled": false 74 | } 75 | ], 76 | "isApiEnabled": false, 77 | "apiPort": 40809, 78 | "apiToken": "" 79 | } 80 | ] 81 | } 82 | ``` 83 | 84 | **discoveryTimeout** (optional): Time span in milliseconds for which the plugin searches for Sonos devices. Defaults to `5000`. 85 | 86 | **zones**: An array of all zone (e.g. rooms) that you want the plugin to expose to HomeKit. 87 | 88 | **name**: The name of the zone. Must match the name in the Sonos app. 89 | 90 | **isNightModeEnabled** (optional): If set to true, a switch is exposed for the night mode. Defaults to `false`. (only for Playbar/Playbase) 91 | 92 | **isSpeechEnhancementEnabled** (optional): If set to true, a switch is exposed for the speech enhancement. Defaults to `false`. (only for Playbar/Playbase) 93 | 94 | **priorities** (optional): If provided, this list of zone names defines the priority when searching for a music/TV stream to play when the accessories is switched to ON. 95 | 96 | **isAutoPlayDisabled** (optional): If set to `true`, the Sonos won't start playing if no other Sonos is currently playing. Defaults to `false`. 97 | 98 | **isApiEnabled** (optional): Enables an HTTP API for controlling Sonos zones. Defaults to `false`. See **API** for more information. 99 | 100 | **apiPort** (optional): The port that the API (if enabled) runs on. Defaults to `40809`, please change this setting of the port is already in use. 101 | 102 | **apiToken** (optional): The token that has to be included in each request of the API. Is required if the API is enabled and has no default value. 103 | 104 | ## API 105 | 106 | This plugin also provides an HTTP API to control some features of the Sonos system. It has been created so that you can further automate the system with HomeKit shortcuts. Starting with iOS 13, you can use shortcuts for HomeKit automation. Those automations that are executed on the HomeKit coordinator (i.e. iPad, AppleTV or HomePod) also support HTTP requests, which means you can automate your Sonos system without annoying switches and buttons exposed in HomeKit. 107 | 108 | If the API is enabled, it can be reached at the specified port on the host of this plugin. 109 | ``` 110 | http://: 111 | ``` 112 | 113 | The token has to be specified as value of the `Authorization` header on each request: 114 | ``` 115 | Authorization: 116 | ``` 117 | 118 | ### API - Get single value of Sonos zone 119 | 120 | Use the `zones//` endpoint to retrieve a single value of a Sonos zone. The HTTP method has to be `GET`: 121 | ``` 122 | http://:/zones// 123 | ``` 124 | 125 | The response is a plain text response (easier to handle in HomeKit shortcuts), the following property names are supported: 126 | 127 | * **led-state** The LED state of the master device of the zone (possible values: `true` if ON, `false` if OFF) 128 | * **current-track-uri** The URI of the current track (possible values: `null`, `TV` or a URI) 129 | * **current-track-title** The title of the current track (possible values: `null`, `TV` or a string) 130 | * **current-track-artist** The artist of the current track (possible values: `null`, `TV` or a string) 131 | * **current-track-album** The album of the current track (possible values: `null`, `TV` or a string) 132 | * **current-state** The playback state of the zone (possible values: `playing`, `paused`, `stopped`) 133 | * **volume** The current volume of the zone as integer value (range: `0-100`) 134 | * **mute** Mute state as boolean (possible values: `true` if zone is muted, otherwise `false`). 135 | 136 | ### API - Get all values of Sonos zone 137 | 138 | Use the `zones/` endpoint to retrieve all values of a Sonos zone. The HTTP method has to be `GET`: 139 | ``` 140 | http://:/zones/ 141 | ``` 142 | 143 | The response is a JSON object containing all values: 144 | ``` 145 | { 146 | "led-state": true, 147 | "current-track": { 148 | "title": "...", 149 | "artist": "...", 150 | "album": "..." 151 | } 152 | "current-state": "playing", 153 | "volume": 16, 154 | "mute": false 155 | } 156 | ``` 157 | 158 | When retrieving all values, the `current-track` property may also be `null` or `TV`. 159 | 160 | ### API - Set values of Sonos zone 161 | 162 | Use the `zones/` endpoint to set values of a Sonos zone. The HTTP method has to be `POST`: 163 | ``` 164 | http://:/zones/ 165 | ``` 166 | 167 | The body of the request has to be JSON containing the new values: 168 | ``` 169 | { 170 | "": 171 | } 172 | ``` 173 | Multiple properties can be set with one request. 174 | 175 | The following property names are supported: 176 | 177 | * **led-state** The target LED state of all devices of the zone (possible values: `true` to switch on ON, `false` to switch OFF) 178 | * **current-track-uri** Play the provided URI (e.g. retrieved from the `sonos-favorites` endpoint). 179 | * **current-state** The target playback state of the zone (possible values: `playing`, `paused`, `stopped`, `next`, `previous`) 180 | * **adjust-volume** The relative change of the volume as integer value. May also be negative. 181 | * **mute** Mute/unmute the zone (possible values: `true`, `false`). 182 | * **volume** The target volume of the zone as integer value (range: `0-100`) 183 | 184 | ### API - Get Sonos favorites 185 | 186 | Use the `sonos-favorite` endpoint to retrieve your Sonos favorites including the URI. The HTTP method has to be `GET`: 187 | ``` 188 | http://:/sonos-favorites 189 | ``` 190 | 191 | The response is a JSON array containing all values: 192 | ``` 193 | [ 194 | { 195 | "uri": "..." 196 | "title": "...", 197 | "artist": "...", 198 | "album": "..." 199 | }, 200 | { 201 | "uri": "..." 202 | "title": "...", 203 | "artist": "...", 204 | "album": "..." 205 | }, 206 | ... 207 | ] 208 | ``` 209 | 210 | The fields for `artist` and `album` may be `null` if it is a playlist. 211 | 212 | ## Tips 213 | 214 | * Configure your router to provide all Sonos devices with static IP addresses 215 | * You can add conditions to the HomeKit automations to prevent Sonos devices from playing at night (e.g. only switch ON between 6am and 10pm) 216 | * The automatic switching to the TV input (Playbar/Playbase) works well with this plugin 217 | * If your HomeKit motion sensors do not support an occupancy mode (i.e. they only show "motion detected" for some seconds), you can use delay switches (e.g. **homebridge-delay-switch**) with a timeout of some minutes to switch the Sonos accessories ON and OFF. 218 | * Use HomeKit shortcuts to set a default volume for each zone in the early morning. 219 | * Use HomeKit shortcuts to disable LEDs at night. 220 | * Add radio stations or playlists into the Sonos favorites. Use the `sonos-favorites` endpoint to retrieve the URI. Now, you can use the `POST` action with the parameter `current-track-uri` to play your radio station or playlist. 221 | -------------------------------------------------------------------------------- /src/sonos-multiroom-platform.js: -------------------------------------------------------------------------------- 1 | 2 | const { Listener, DeviceDiscovery } = require('sonos'); 3 | 4 | const SonosZone = require('./sonos-zone'); 5 | const SonosApi = require('./sonos-api'); 6 | 7 | /** 8 | * Initializes a new platform instance for the Sonos multiroom plugin. 9 | * @param log The logging function. 10 | * @param config The configuration that is passed to the plugin (from the config.json file). 11 | * @param api The API instance of homebridge (may be null on older homebridge versions). 12 | */ 13 | function SonosMultiroomPlatform(log, config, api) { 14 | const platform = this; 15 | 16 | // Saves objects for functions 17 | platform.Accessory = api.platformAccessory; 18 | platform.Categories = api.hap.Accessory.Categories; 19 | platform.Service = api.hap.Service; 20 | platform.Characteristic = api.hap.Characteristic; 21 | platform.UUIDGen = api.hap.uuid; 22 | platform.hap = api.hap; 23 | platform.pluginName = 'homebridge-sonos-multiroom'; 24 | platform.platformName = 'SonosMultiroomPlatform'; 25 | 26 | // Checks whether a configuration is provided, otherwise the plugin should not be initialized 27 | if (!config) { 28 | return; 29 | } 30 | 31 | // Defines the variables that are used throughout the platform 32 | platform.log = log; 33 | platform.config = config; 34 | platform.accessories = []; 35 | platform.devices = []; 36 | platform.zones = []; 37 | 38 | // Initializes the configuration 39 | platform.config.zones = platform.config.zones || []; 40 | platform.config.discoveryTimeout = platform.config.discoveryTimeout || 5000; 41 | platform.config.isApiEnabled = platform.config.isApiEnabled || false; 42 | platform.config.apiPort = platform.config.apiPort || 40809; 43 | platform.config.apiToken = platform.config.apiToken || null; 44 | 45 | // Checks whether the API object is available 46 | if (!api) { 47 | platform.log('Homebridge API not available, please update your homebridge version!'); 48 | return; 49 | } 50 | 51 | // Saves the API object to register new devices later on 52 | platform.log('Homebridge API available.'); 53 | platform.api = api; 54 | 55 | // Subscribes to the event that is raised when homebridge finished loading cached accessories 56 | platform.api.on('didFinishLaunching', function () { 57 | platform.log('Cached accessories loaded.'); 58 | 59 | // Registers the shutdown event 60 | platform.api.on('shutdown', function () { 61 | Listener.stopListener().then(function() {}, function() {}); 62 | }); 63 | 64 | // Discovers the Sonos devices 65 | const discovery = DeviceDiscovery({ timeout: platform.config.discoveryTimeout }); 66 | discovery.on('DeviceAvailable', function (sonos) { 67 | platform.log('Device discovered: ' + sonos.host); 68 | platform.devices.push({ 69 | sonos: sonos 70 | }); 71 | }) 72 | discovery.once('timeout', function () { 73 | platform.log('Discovery completed, ' + platform.devices.length + ' device(s) found.'); 74 | 75 | // Checks if any devices have been found 76 | if (!platform.devices.length) { 77 | return; 78 | } 79 | 80 | // Gets the device information 81 | let promises = []; 82 | for (let i = 0; i < platform.devices.length; i++) { 83 | const device = platform.devices[i]; 84 | 85 | // Gets the zone attributes of the device 86 | promises.push(device.sonos.getZoneAttrs().then(function(zoneAttrs) { 87 | device.zoneName = zoneAttrs.CurrentZoneName; 88 | }, function() { 89 | platform.log('Error while getting zone attributes of ' + device.sonos.host + '.'); 90 | })); 91 | 92 | // Gets the zone group attributes of the zone 93 | promises.push(device.sonos.zoneGroupTopologyService().GetZoneGroupAttributes().then(function(zoneGroupAttrs) { 94 | device.isZoneMaster = zoneGroupAttrs.CurrentZoneGroupID !== ''; 95 | }, function() { 96 | platform.log('Error while getting zone group attributes of ' + device.sonos.host + '.'); 97 | })); 98 | 99 | // Gets the device description 100 | promises.push(device.sonos.deviceDescription().then(function(deviceDescription) { 101 | device.manufacturer = deviceDescription.manufacturer; 102 | device.modelNumber = deviceDescription.modelNumber; 103 | device.modelName = deviceDescription.modelName; 104 | device.serialNumber = deviceDescription.serialNum; 105 | device.softwareVersion = deviceDescription.softwareVersion; 106 | device.hardwareVersion = deviceDescription.hardwareVersion; 107 | 108 | // Gets the possible inputs 109 | for (let j = 0; j < deviceDescription.serviceList.service.length; j++) { 110 | const service = deviceDescription.serviceList.service[j]; 111 | if (service.serviceId.split(':')[3] === 'AudioIn') { 112 | device.audioIn = true; 113 | } 114 | if (service.serviceId.split(':')[3] === 'HTControl') { 115 | device.htControl = true; 116 | } 117 | } 118 | }, function() { 119 | platform.log('Error while getting device description of ' + device.sonos.host + '.'); 120 | })); 121 | } 122 | 123 | // Creates the zone objects 124 | Promise.all(promises).then(function() { 125 | const zoneMasterDevices = platform.devices.filter(function(d) { return d.isZoneMaster; }); 126 | for (let i = 0; i < zoneMasterDevices.length; i++) { 127 | const zoneMasterDevice = zoneMasterDevices[i]; 128 | 129 | // Gets the corresponding zone configuration 130 | const config = platform.config.zones.find(function(z) { return z.name === zoneMasterDevice.zoneName; }); 131 | if (!config) { 132 | platform.log('No configuration provided for zone with name ' + zoneMasterDevice.zoneName + '.'); 133 | continue; 134 | } 135 | 136 | // Creates the zone instance and adds it to the list of all zones 137 | platform.log('Create zone with name ' + zoneMasterDevice.zoneName + '.'); 138 | platform.zones.push(new SonosZone(platform, zoneMasterDevice, config)); 139 | } 140 | 141 | // Removes the accessories that are not bound to a zone 142 | let unusedAccessories = []; 143 | let undiscoveredAccessories = platform.accessories.filter(function(a) { return !platform.zones.some(function(z) { return z.name === a.context.name; }); }); 144 | for (let i = 0; i < undiscoveredAccessories.length; i++) { 145 | const undiscoveredAccessory = undiscoveredAccessories[i]; 146 | 147 | // In case the discovery hasn't found the Sonos device, its corresponding accessories are not removed if they are present in the configuration 148 | const config = platform.config.zones.find(function(z) { return z.name === undiscoveredAccessory.context.name; }); 149 | if (!config) { 150 | platform.log('Removing accessory with name ' + undiscoveredAccessory.context.name + ' and kind ' + undiscoveredAccessory.context.kind + '.'); 151 | unusedAccessories.push(undiscoveredAccessory); 152 | } else { 153 | platform.log('No device discovered for accessory with name ' + undiscoveredAccessory.context.name + ' and kind ' + undiscoveredAccessory.context.kind + '. Try increasing the discovery timeout.'); 154 | } 155 | 156 | platform.accessories.splice(platform.accessories.indexOf(undiscoveredAccessory), 1); 157 | } 158 | platform.api.unregisterPlatformAccessories(platform.pluginName, platform.platformName, unusedAccessories); 159 | platform.log('Initialization completed.'); 160 | }, function() { 161 | platform.log('Error while initializing plugin.'); 162 | }); 163 | }); 164 | 165 | // Starts the API if requested 166 | if (platform.config.isApiEnabled) { 167 | platform.sonosApi = new SonosApi(platform); 168 | } 169 | }); 170 | } 171 | 172 | /** 173 | * Gets the play state of the group of the specified device. 174 | * @param device The device. 175 | * @returns Returns a promise with the play state of the group of the device. 176 | */ 177 | SonosMultiroomPlatform.prototype.getGroupPlayState = function (device) { 178 | const platform = this; 179 | 180 | // Gets the coordinator based on all groups 181 | return device.sonos.getAllGroups().then(function(groups) { 182 | const group = groups.find(function(g) { return g.ZoneGroupMember.some(function(m) { return m.ZoneName === device.zoneName; }); }); 183 | const coordinatorDevice = platform.devices.find(function(d) { return d.sonos.host === group.host; }); 184 | return coordinatorDevice.sonos.getCurrentState().then(function(playState) { 185 | return playState; 186 | }); 187 | }); 188 | } 189 | 190 | /** 191 | * Gets the coordinator for the group of the specified device. 192 | * @param device The device. 193 | * @returns Returns a promise with the group coordinator of the device. 194 | */ 195 | SonosMultiroomPlatform.prototype.getGroupCoordinator = function (device) { 196 | const platform = this; 197 | 198 | // Gets the coordinator based on all groups 199 | return device.sonos.getAllGroups().then(function(groups) { 200 | const group = groups.find(function(g) { return g.ZoneGroupMember.some(function(m) { return m.ZoneName === device.zoneName; }); }); 201 | const coordinatorDevice = platform.devices.find(function(d) { return d.sonos.host === group.host; }); 202 | return coordinatorDevice.sonos; 203 | }); 204 | } 205 | 206 | /** 207 | * Configures a previously cached accessory. 208 | * @param accessory The cached accessory. 209 | */ 210 | SonosMultiroomPlatform.prototype.configureAccessory = function (accessory) { 211 | const platform = this; 212 | 213 | // Adds the cached accessory to the list 214 | platform.accessories.push(accessory); 215 | } 216 | 217 | /** 218 | * Defines the export of the file. 219 | */ 220 | module.exports = SonosMultiroomPlatform; 221 | -------------------------------------------------------------------------------- /src/sonos-zone.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Represents a Sonos zone. 4 | * @param platform The SonosMultiroomPlatform instance. 5 | * @param zoneMasterDevice The master device of the zone. 6 | * @param config The zone configuration. 7 | */ 8 | function SonosZone(platform, zoneMasterDevice, config) { 9 | const zone = this; 10 | const { UUIDGen, Accessory, Characteristic, Service } = platform; 11 | 12 | // Sets the master device, name and platform 13 | zone.masterDevice = zoneMasterDevice; 14 | zone.name = zoneMasterDevice.zoneName; 15 | zone.platform = platform; 16 | 17 | // Gets all accessories from the platform that match the zone name 18 | let unusedDeviceAccessories = platform.accessories.filter(function(a) { return a.context.name === zone.name; }); 19 | let newDeviceAccessories = []; 20 | let deviceAccessories = []; 21 | 22 | // Gets the outlet accessory 23 | let outletAccessory = unusedDeviceAccessories.find(function(a) { return a.context.kind === 'OutletAccessory'; }); 24 | if (outletAccessory) { 25 | unusedDeviceAccessories.splice(unusedDeviceAccessories.indexOf(outletAccessory), 1); 26 | } else { 27 | platform.log('Adding new accessory with zone name ' + zone.name + ' and kind OutletAccessory.'); 28 | outletAccessory = new Accessory(zone.name, UUIDGen.generate(zone.name + 'OutletAccessory')); 29 | outletAccessory.context.name = zone.name; 30 | outletAccessory.context.kind = 'OutletAccessory'; 31 | newDeviceAccessories.push(outletAccessory); 32 | } 33 | deviceAccessories.push(outletAccessory); 34 | 35 | // Gets the switch accessory 36 | let switchAccessory = null; 37 | if (zone.masterDevice.htControl && (config.isNightModeEnabled || config.isSpeechEnhancementEnabled)) { 38 | switchAccessory = unusedDeviceAccessories.find(function(a) { return a.context.kind === 'SwitchAccessory'; }); 39 | if (switchAccessory) { 40 | unusedDeviceAccessories.splice(unusedDeviceAccessories.indexOf(switchAccessory), 1); 41 | } else { 42 | platform.log('Adding new accessory with zone name ' + zone.name + ' and kind SwitchAccessory.'); 43 | switchAccessory = new Accessory(zone.name + ' Settings', UUIDGen.generate(zone.name + 'SwitchAccessory')); 44 | switchAccessory.context.name = zone.name; 45 | switchAccessory.context.kind = 'SwitchAccessory'; 46 | newDeviceAccessories.push(switchAccessory); 47 | } 48 | deviceAccessories.push(switchAccessory); 49 | } 50 | 51 | // Registers the newly created accessories 52 | platform.api.registerPlatformAccessories(platform.pluginName, platform.platformName, newDeviceAccessories); 53 | 54 | // Removes all unused accessories 55 | for (let i = 0; i < unusedDeviceAccessories.length; i++) { 56 | const unusedDeviceAccessory = unusedDeviceAccessories[i]; 57 | platform.log('Removing unused accessory with zone name ' + unusedDeviceAccessory.context.name + ' and kind ' + unusedDeviceAccessory.context.kind + '.'); 58 | platform.accessories.splice(platform.accessories.indexOf(unusedDeviceAccessory), 1); 59 | } 60 | platform.api.unregisterPlatformAccessories(platform.pluginName, platform.platformName, unusedDeviceAccessories); 61 | 62 | // Updates the accessory information 63 | for (let i = 0; i < deviceAccessories.length; i++) { 64 | const deviceAccessory = deviceAccessories[i]; 65 | let accessoryInformationService = deviceAccessory.getService(Service.AccessoryInformation); 66 | if (!accessoryInformationService) { 67 | accessoryInformationService = deviceAccessory.addService(Service.AccessoryInformation); 68 | } 69 | accessoryInformationService 70 | .setCharacteristic(Characteristic.Manufacturer, zone.masterDevice.manufacturer) 71 | .setCharacteristic(Characteristic.Model, zone.masterDevice.modelName) 72 | .setCharacteristic(Characteristic.SerialNumber, zone.masterDevice.serialNumber) 73 | .setCharacteristic(Characteristic.FirmwareRevision, zone.masterDevice.softwareVersion) 74 | .setCharacteristic(Characteristic.HardwareRevision, zone.masterDevice.hardwareVersion); 75 | } 76 | 77 | // Updates the outlet 78 | let outletService = outletAccessory.getService(Service.Outlet); 79 | if (!outletService) { 80 | outletService = outletAccessory.addService(Service.Outlet); 81 | } 82 | outletService.setCharacteristic(Characteristic.OutletInUse, true); 83 | 84 | // Stores the outlet service 85 | zone.outletService = outletService; 86 | 87 | // Updates the night mode switch 88 | let nightModeSwitchService = null; 89 | if (switchAccessory && config.isNightModeEnabled) { 90 | nightModeSwitchService = switchAccessory.getServiceByUUIDAndSubType(Service.Switch, 'NightMode'); 91 | if (!nightModeSwitchService) { 92 | nightModeSwitchService = switchAccessory.addService(Service.Switch, 'Night Mode', 'NightMode'); 93 | } 94 | 95 | // Stores the service 96 | zone.nightModeSwitchService = nightModeSwitchService; 97 | } 98 | 99 | // Updates the speech enhancement switch 100 | let speechEnhancementSwitchService = null; 101 | if (switchAccessory && config.isSpeechEnhancementEnabled) { 102 | speechEnhancementSwitchService = switchAccessory.getServiceByUUIDAndSubType(Service.Switch, 'SpeechEnhancement'); 103 | if (!speechEnhancementSwitchService) { 104 | speechEnhancementSwitchService = switchAccessory.addService(Service.Switch, 'Speech Enhancement', 'SpeechEnhancement'); 105 | } 106 | 107 | // Stores the service 108 | zone.speechEnhancementSwitchService = speechEnhancementSwitchService; 109 | } 110 | 111 | // Subscribes for changes of the on characteristic 112 | outletService.getCharacteristic(Characteristic.On).on('set', function (value, callback) { 113 | if (value) { 114 | zone.platform.log(zone.name + ' - Set outlet state: ON'); 115 | 116 | // Checks if the zone is already playing, in this case, nothing has to be done 117 | if (!outletService.getCharacteristic(Characteristic.On).value) { 118 | if (config.priorities) { 119 | zone.platform.log(zone.name + ' - Set outlet state: ON - has priorities'); 120 | 121 | // Cycles over the priority list and checks the play state 122 | for (let i = 0; i < config.priorities.length; i++) { 123 | const priority = config.priorities[i]; 124 | zone.platform.log(zone.name + ' - Set outlet state: ON - trying priority ' + (i + 1) + ': ' + priority); 125 | 126 | // Gets the zone of the priority 127 | const priorityZone = zone.platform.zones.find(function(z) { return z.name === priority; }); 128 | if (!priorityZone) { 129 | zone.platform.log(zone.name + ' - Set outlet state: ON - priority not found'); 130 | continue; 131 | } 132 | 133 | // Checks the outlet state 134 | if (!priorityZone.outletService.getCharacteristic(Characteristic.On).value) { 135 | zone.platform.log(zone.name + ' - Set outlet state: ON - priority not ON'); 136 | continue; 137 | } 138 | 139 | // Joins the group 140 | zone.platform.log(zone.name + ' - Set outlet state: ON - joining'); 141 | zone.masterDevice.sonos.joinGroup(priorityZone.name).then(function () {}, function () { 142 | zone.platform.log(zone.name + ' - Error while joining group ' + priorityZone.name + '.'); 143 | }); 144 | callback(null); 145 | return; 146 | } 147 | 148 | // Tries to just play (if auto-play is enabled) 149 | if (!config.isAutoPlayDisabled) { 150 | zone.platform.log(zone.name + ' - Set outlet state: ON - no priorities matches'); 151 | zone.masterDevice.sonos.play().then(function () { }, function () { 152 | zone.platform.log(zone.name + ' - Error while trying to play.'); 153 | }); 154 | } else { 155 | zone.platform.log(zone.name + ' - No auto-play'); 156 | setTimeout(function() { zone.outletService.updateCharacteristic(Characteristic.On, false); }, 250); 157 | } 158 | } else { 159 | 160 | // Tries to just play (if auto-play is enabled) 161 | if (!config.isAutoPlayDisabled) { 162 | zone.platform.log(zone.name + ' - Set outlet state: ON - no priorities'); 163 | zone.masterDevice.sonos.play().then(function () { }, function () { 164 | zone.platform.log(zone.name + ' - Error while trying to play.'); 165 | }); 166 | } else { 167 | zone.platform.log(zone.name + ' - No auto-play'); 168 | setTimeout(function() { zone.outletService.updateCharacteristic(Characteristic.On, false); }, 250); 169 | } 170 | } 171 | } else { 172 | zone.platform.log(zone.name + ' - Set outlet state: ON - already ON'); 173 | } 174 | } else { 175 | zone.platform.log(zone.name + ' - Set outlet state: OFF'); 176 | 177 | // Checks if the zone is playing back its own TV stream, in this case, nothing should be done 178 | if (zone.masterDevice.htControl) { 179 | zone.platform.log(zone.name + ' - Set outlet state: OFF - TV, checking current track'); 180 | zone.masterDevice.sonos.currentTrack().then(function(currentTrack) { 181 | if (currentTrack && currentTrack.uri && currentTrack.uri.endsWith(':spdif')) { 182 | zone.platform.log(zone.name + ' - Set outlet state: OFF - TV, current track'); 183 | setTimeout(function() { zone.outletService.updateCharacteristic(Characteristic.On, true); }, 250); 184 | } else { 185 | zone.platform.log(zone.name + ' - Set outlet state: OFF - TV, not current track, leaving group'); 186 | zone.masterDevice.sonos.leaveGroup().then(function () {}, function () { 187 | zone.platform.log(zone.name + ' - Error while leaving group.'); 188 | }); 189 | } 190 | }, function() { 191 | zone.platform.log(zone.name + ' - Error while getting current track.'); 192 | }); 193 | } else { 194 | zone.platform.log(zone.name + ' - Set outlet state: OFF - Not TV, leaving group'); 195 | zone.masterDevice.sonos.leaveGroup().then(function () {}, function () { 196 | zone.platform.log(zone.name + ' - Error while leaving group.'); 197 | }); 198 | } 199 | } 200 | callback(null); 201 | }); 202 | 203 | // Subscribes for changes of the night mode 204 | if (nightModeSwitchService) { 205 | nightModeSwitchService.getCharacteristic(Characteristic.On).on('set', function (value, callback) { 206 | zone.platform.log(zone.name + ' - Set night mode: ' + (value ? 'ON' : 'OFF')); 207 | zone.masterDevice.sonos.renderingControlService()._request('SetEQ', { InstanceID: 0, EQType: 'NightMode', DesiredValue: value ? '1' : '0' }).then(function () {}, function () { 208 | zone.platform.log(zone.name + ' - Error switching night mode to ' + (value ? 'ON' : 'OFF') + '.'); 209 | }); 210 | callback(null); 211 | }); 212 | } 213 | 214 | // Subscribes for changes of the speech enhancement 215 | if (speechEnhancementSwitchService) { 216 | speechEnhancementSwitchService.getCharacteristic(Characteristic.On).on('set', function (value, callback) { 217 | zone.platform.log(zone.name + ' - Set speech enhancement: ' + (value ? 'ON' : 'OFF')); 218 | zone.masterDevice.sonos.renderingControlService()._request('SetEQ', { InstanceID: 0, EQType: 'DialogLevel', DesiredValue: value ? '1' : '0' }).then(function () {}, function () { 219 | zone.platform.log(zone.name + ' - Error switching speech enhancement to ' + (value ? 'ON' : 'OFF') + '.'); 220 | }); 221 | callback(null); 222 | }); 223 | } 224 | 225 | // Subscribes for changes of the transport control 226 | zone.masterDevice.sonos.on('AVTransport', function () { 227 | zone.updatePlayState(); 228 | }); 229 | 230 | // Subscribes for changes in the rendering control 231 | zone.masterDevice.sonos.on('RenderingControl', function (eventData) { 232 | 233 | // Updates the night mode 234 | if (nightModeSwitchService && eventData.NightMode) { 235 | zone.platform.log(zone.name + ' - Updating night mode: ' + (eventData.NightMode.val === '1' ? 'ON' : 'OFF')); 236 | zone.nightModeSwitchService.updateCharacteristic(Characteristic.On, eventData.NightMode.val === '1'); 237 | } 238 | 239 | // Updates the speed enhancement 240 | if (speechEnhancementSwitchService && eventData.DialogLevel) { 241 | zone.platform.log(zone.name + ' - Updating speech enhancement: ' + (eventData.DialogLevel.val === '1' ? 'ON' : 'OFF')); 242 | zone.speechEnhancementSwitchService.updateCharacteristic(Characteristic.On, eventData.DialogLevel.val === '1'); 243 | } 244 | 245 | // Updates the play state 246 | zone.updatePlayState(); 247 | }); 248 | 249 | // Subscribes for changes of the group rendering 250 | zone.masterDevice.sonos.on('RenderingControl', function () { 251 | zone.updatePlayState(); 252 | }); 253 | } 254 | 255 | /** 256 | * Updates the play state of the zone. 257 | */ 258 | SonosZone.prototype.updatePlayState = function () { 259 | const zone = this; 260 | const { Characteristic } = zone.platform; 261 | 262 | // Updates the play state based on the group play state 263 | zone.platform.getGroupPlayState(zone.masterDevice).then(function(playState) { 264 | zone.platform.log(zone.name + ' - Updated play state: ' + (playState === 'playing' ? 'ON' : 'OFF')); 265 | zone.outletService.updateCharacteristic(Characteristic.On, playState === 'playing'); 266 | }, function() { 267 | zone.platform.log(zone.name + ' - Error while updating group play state.'); 268 | }); 269 | } 270 | 271 | /** 272 | * Defines the export of the file. 273 | */ 274 | module.exports = SonosZone; 275 | -------------------------------------------------------------------------------- /src/sonos-api.js: -------------------------------------------------------------------------------- 1 | 2 | const http = require('http'); 3 | const url = require('url'); 4 | 5 | /** 6 | * Represents the API. 7 | * @param platform The SonosMultiroomPlatform instance. 8 | */ 9 | function SonosApi(platform) { 10 | const api = this; 11 | 12 | // Sets the platform 13 | api.platform = platform; 14 | 15 | // Checks if all required information is provided 16 | if (!api.platform.config.apiPort) { 17 | api.platform.log('No API port provided.'); 18 | return; 19 | } 20 | if (!api.platform.config.apiToken) { 21 | api.platform.log('No API token provided.'); 22 | return; 23 | } 24 | 25 | // Starts the server 26 | try { 27 | http.createServer(function (request, response) { 28 | const payload = []; 29 | 30 | // Subscribes for events of the request 31 | request.on('error', function () { 32 | api.platform.log('API - Error received.'); 33 | }).on('data', function (chunk) { 34 | payload.push(chunk); 35 | }).on('end', function () { 36 | 37 | // Subscribes to errors when sending the response 38 | response.on('error', function () { 39 | api.platform.log('API - Error sending the response.'); 40 | }); 41 | 42 | // Validates the token 43 | if (!request.headers['authorization']) { 44 | api.platform.log('Authorization header missing.'); 45 | response.statusCode = 401; 46 | response.end(); 47 | return; 48 | } 49 | if (request.headers['authorization'] !== api.platform.config.apiToken) { 50 | api.platform.log('Token invalid.'); 51 | response.statusCode = 401; 52 | response.end(); 53 | return; 54 | } 55 | 56 | // Validates the endpoint 57 | const endpoint = api.getEndpoint(request.url); 58 | if (!endpoint) { 59 | api.platform.log('No endpoint found.'); 60 | response.statusCode = 404; 61 | response.end(); 62 | return; 63 | } 64 | 65 | // Validates the body 66 | let body = null; 67 | if (payload && payload.length > 0) { 68 | try { 69 | body = JSON.parse(Buffer.concat(payload).toString()); 70 | } catch { 71 | api.platform.log('Malformed JSON has been sent to the API.'); 72 | response.statusCode = 400; 73 | response.end(); 74 | return; 75 | } 76 | } 77 | 78 | // Performs the action based on the endpoint and method 79 | switch (endpoint.name) { 80 | case 'propertyByZone': 81 | switch (request.method) { 82 | case 'GET': 83 | api.handleGetPropertyByZone(endpoint, response); 84 | return; 85 | } 86 | break; 87 | 88 | case 'zone': 89 | switch (request.method) { 90 | case 'GET': 91 | api.handleGetZone(endpoint, response); 92 | return; 93 | 94 | case 'POST': 95 | api.handlePostZone(endpoint, body, response); 96 | return; 97 | } 98 | break; 99 | 100 | case 'sonosFavorites': 101 | switch (request.method) { 102 | case 'GET': 103 | api.handleGetSonosFavorites(response); 104 | return; 105 | } 106 | break; 107 | } 108 | 109 | api.platform.log('No action matched.'); 110 | response.statusCode = 404; 111 | response.end(); 112 | }); 113 | }).listen(api.platform.config.apiPort, "0.0.0.0"); 114 | api.platform.log('API started.'); 115 | } catch (e) { 116 | api.platform.log('API could not be started: ' + JSON.stringify(e)); 117 | } 118 | } 119 | 120 | /** 121 | * Handles requests to GET /sonos-favorites. 122 | * @param response The response object. 123 | */ 124 | SonosApi.prototype.handleGetSonosFavorites = function (response) { 125 | const api = this; 126 | 127 | // Checks if the zone exists 128 | const zoneMasterDevice = api.platform.devices.filter(function(d) { return d.isZoneMaster; })[0]; 129 | 130 | // Gets all properties 131 | const promises = []; 132 | const responseArray = []; 133 | promises.push(promise = zoneMasterDevice.sonos.getFavorites().then(function(favorites) { 134 | for (let i = 0; i < favorites.items.length; i++) { 135 | responseArray.push({ 136 | title: favorites.items[i].title, 137 | artist: favorites.items[i].artist, 138 | album: favorites.items[i].album, 139 | uri: favorites.items[i].uri 140 | }); 141 | } 142 | })); 143 | 144 | // Writes the response 145 | Promise.all(promises).then(function() { 146 | response.setHeader('Content-Type', 'application/json'); 147 | response.write(JSON.stringify(responseArray)); 148 | response.statusCode = 200; 149 | response.end(); 150 | }, function() { 151 | api.platform.log('Error while retrieving values.'); 152 | response.statusCode = 400; 153 | response.end(); 154 | }); 155 | } 156 | 157 | /** 158 | * Handles requests to GET /zones/{zoneName}/{propertyName}. 159 | * @param endpoint The endpoint information. 160 | * @param response The response object. 161 | */ 162 | SonosApi.prototype.handleGetPropertyByZone = function (endpoint, response) { 163 | const api = this; 164 | 165 | // Checks if the zone exists 166 | const zoneMasterDevice = api.platform.devices.find(function(d) { return d.zoneName === endpoint.zoneName && d.isZoneMaster; }); 167 | if (!zoneMasterDevice) { 168 | api.platform.log('Zone not found.'); 169 | response.statusCode = 400; 170 | response.end(); 171 | return; 172 | } 173 | 174 | // Gets the value based on property name 175 | let promise = null; 176 | let content = null; 177 | switch (endpoint.propertyName) { 178 | case 'led-state': 179 | promise = zoneMasterDevice.sonos.getLEDState().then(function(state) { content = (state === 'On').toString(); }); 180 | break; 181 | 182 | case 'current-state': 183 | promise = api.platform.getGroupPlayState(zoneMasterDevice).then(function(playState) { content = playState; }); 184 | break; 185 | 186 | case 'volume': 187 | promise = zoneMasterDevice.sonos.getVolume().then(function(volume) { content = volume.toString(); }); 188 | break; 189 | 190 | case 'mute': 191 | promise = zoneMasterDevice.sonos.getMuted().then(function(muted) { content = muted.toString(); }); 192 | break; 193 | 194 | case 'current-track-uri': 195 | promise = api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 196 | return coordinator.currentTrack().then(function(currentTrack) { 197 | if (!currentTrack || !currentTrack.uri) { 198 | content = 'null'; 199 | } else if (currentTrack.uri.endsWith(':spdif')) { 200 | content = 'TV'; 201 | } else { 202 | content = currentTrack.uri; 203 | } 204 | }); 205 | }); 206 | break; 207 | 208 | case 'current-track-title': 209 | promise = api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 210 | return coordinator.currentTrack().then(function(currentTrack) { 211 | if (!currentTrack || !currentTrack.uri) { 212 | content = 'null'; 213 | } else if (currentTrack.uri.endsWith(':spdif')) { 214 | content = 'TV'; 215 | } else if (!currentTrack.title) { 216 | content = 'null'; 217 | } else { 218 | content = currentTrack.title; 219 | } 220 | }); 221 | }); 222 | break; 223 | 224 | case 'current-track-artist': 225 | promise = api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 226 | return coordinator.currentTrack().then(function(currentTrack) { 227 | if (!currentTrack || !currentTrack.uri) { 228 | content = 'null'; 229 | } else if (currentTrack.uri.endsWith(':spdif')) { 230 | content = 'TV'; 231 | } else if (!currentTrack.artist) { 232 | content = 'null'; 233 | } else { 234 | content = currentTrack.artist; 235 | } 236 | }); 237 | }); 238 | break; 239 | 240 | case 'current-track-album': 241 | promise = api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 242 | return coordinator.currentTrack().then(function(currentTrack) { 243 | if (!currentTrack || !currentTrack.uri) { 244 | content = 'null'; 245 | } else if (currentTrack.uri.endsWith(':spdif')) { 246 | content = 'TV'; 247 | } else if (!currentTrack.album) { 248 | content = 'null'; 249 | } else { 250 | content = currentTrack.album; 251 | } 252 | }); 253 | }); 254 | break; 255 | 256 | default: 257 | api.platform.log('Property not found.'); 258 | response.statusCode = 400; 259 | response.end(); 260 | return; 261 | } 262 | 263 | // Writes the response 264 | promise.then(function() { 265 | response.setHeader('Content-Type', 'text/plain'); 266 | response.write(content); 267 | response.statusCode = 200; 268 | response.end(); 269 | }, function() { 270 | api.platform.log('Error while retrieving value.'); 271 | response.statusCode = 400; 272 | response.end(); 273 | }); 274 | } 275 | 276 | /** 277 | * Handles requests to GET /zones/{zoneName}. 278 | * @param endpoint The endpoint information. 279 | * @param response The response object. 280 | */ 281 | SonosApi.prototype.handleGetZone = function (endpoint, response) { 282 | const api = this; 283 | 284 | // Checks if the zone exists 285 | const zoneMasterDevice = api.platform.devices.find(function(d) { return d.zoneName === endpoint.zoneName && d.isZoneMaster; }); 286 | if (!zoneMasterDevice) { 287 | api.platform.log('Zone not found.'); 288 | response.statusCode = 400; 289 | response.end(); 290 | return; 291 | } 292 | 293 | // Gets all properties 294 | const promises = []; 295 | const responseObject = {}; 296 | promises.push(zoneMasterDevice.sonos.getLEDState().then(function(state) { responseObject['led-state'] = state === 'On'; })); 297 | promises.push(api.platform.getGroupPlayState(zoneMasterDevice).then(function(playState) { responseObject['current-state'] = playState; })); 298 | promises.push(zoneMasterDevice.sonos.getVolume().then(function(volume) { responseObject['volume'] = volume; })); 299 | promises.push(zoneMasterDevice.sonos.getMuted().then(function(muted) { responseObject['mute'] = muted; })); 300 | promises.push(api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 301 | return coordinator.currentTrack().then(function(currentTrack) { 302 | if (!currentTrack || !currentTrack.uri) { 303 | responseObject['current-track'] = null; 304 | } else if (currentTrack.uri.endsWith(':spdif')) { 305 | responseObject['current-track'] = 'TV'; 306 | } else { 307 | responseObject['current-track'] = { 308 | uri: currentTrack.uri, 309 | title: currentTrack.title, 310 | artist: currentTrack.artist, 311 | album: currentTrack.album 312 | }; 313 | } 314 | }); 315 | })); 316 | 317 | // Writes the response 318 | Promise.all(promises).then(function() { 319 | response.setHeader('Content-Type', 'application/json'); 320 | response.write(JSON.stringify(responseObject)); 321 | response.statusCode = 200; 322 | response.end(); 323 | }, function() { 324 | api.platform.log('Error while retrieving values.'); 325 | response.statusCode = 400; 326 | response.end(); 327 | }); 328 | } 329 | 330 | /** 331 | * Handles requests to POST /zones/{zoneName}. 332 | * @param endpoint The endpoint information. 333 | * @param body The body of the request. 334 | * @param response The response object. 335 | */ 336 | SonosApi.prototype.handlePostZone = function (endpoint, body, response) { 337 | const api = this; 338 | 339 | // Checks if the zone exists 340 | const zoneMasterDevice = api.platform.devices.find(function(d) { return d.zoneName === endpoint.zoneName && d.isZoneMaster; }); 341 | if (!zoneMasterDevice) { 342 | api.platform.log('Zone not found.'); 343 | response.statusCode = 400; 344 | response.end(); 345 | return; 346 | } 347 | 348 | // Validates the content 349 | if (!body) { 350 | api.platform.log('Body invalid.'); 351 | response.statusCode = 400; 352 | response.end(); 353 | return; 354 | } 355 | 356 | // Sets the new value 357 | const promises = []; 358 | for (let propertyName in body) { 359 | const zonePropertyValue = body[propertyName]; 360 | switch (propertyName) { 361 | case 'led-state': 362 | promises.push(zoneMasterDevice.sonos.setLEDState(zonePropertyValue === true ? 'On' : 'Off')); 363 | break; 364 | 365 | case 'current-state': 366 | if (zonePropertyValue == 'playing') { 367 | promises.push(api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 368 | return coordinator.play(); 369 | })); 370 | } else if (zonePropertyValue == 'paused') { 371 | promises.push(api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 372 | return coordinator.pause(); 373 | })); 374 | } else if (zonePropertyValue == 'stopped') { 375 | promises.push(zoneMasterDevice.sonos.stop().catch(function() { return zoneMasterDevice.sonos.leaveGroup(); })); 376 | } else if (zonePropertyValue == 'previous') { 377 | promises.push(api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 378 | return coordinator.previous(); 379 | })); 380 | } else if (zonePropertyValue == 'next') { 381 | promises.push(api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 382 | return coordinator.next(); 383 | })); 384 | } 385 | break; 386 | 387 | case 'current-track-uri': 388 | promises.push(api.platform.getGroupCoordinator(zoneMasterDevice).then(function(coordinator) { 389 | return coordinator.setAVTransportURI(zonePropertyValue); 390 | })); 391 | break; 392 | 393 | case 'adjust-volume': 394 | promises.push(zoneMasterDevice.sonos.adjustVolume(zonePropertyValue)); 395 | break; 396 | 397 | case 'mute': 398 | promises.push(zoneMasterDevice.sonos.setMuted(zonePropertyValue)); 399 | break; 400 | 401 | case 'volume': 402 | promises.push(zoneMasterDevice.sonos.setVolume(zonePropertyValue)); 403 | break; 404 | } 405 | } 406 | 407 | // Writes the response 408 | Promise.all(promises).then(function() { 409 | response.statusCode = 200; 410 | response.end(); 411 | }, function(e) {console.log(e); 412 | api.platform.log('Error while setting value.'); 413 | response.statusCode = 400; 414 | response.end(); 415 | }); 416 | } 417 | 418 | /** 419 | * Gets the endpoint information based on the URL. 420 | * @param uri The uri of the request. 421 | * @returns Returns the endpoint information. 422 | */ 423 | SonosApi.prototype.getEndpoint = function (uri) { 424 | 425 | // Parses the request path 426 | const uriParts = url.parse(uri); 427 | 428 | // Checks if the URL matches the zones endpoint with property name 429 | let uriMatch = /\/zones\/(.+)\/(.+)/g.exec(uriParts.pathname); 430 | if (uriMatch && uriMatch.length === 3) { 431 | return { 432 | name: 'propertyByZone', 433 | zoneName: decodeURI(uriMatch[1]), 434 | propertyName: decodeURI(uriMatch[2]) 435 | }; 436 | } 437 | 438 | // Checks if the URL matches the zones endpoint without property name 439 | uriMatch = /\/zones\/(.+)/g.exec(uriParts.pathname); 440 | if (uriMatch && uriMatch.length === 2) { 441 | return { 442 | name: 'zone', 443 | zoneName: decodeURI(uriMatch[1]) 444 | }; 445 | } 446 | 447 | // Checks if the URL matches the Sonos favorites endpoint 448 | uriMatch = /\/sonos-favorites/g.exec(uriParts.pathname); 449 | if (uriMatch && uriMatch.length === 1) { 450 | return { 451 | name: 'sonosFavorites' 452 | }; 453 | } 454 | 455 | // Returns null as no endpoint matched. 456 | return null; 457 | } 458 | 459 | /** 460 | * Defines the export of the file. 461 | */ 462 | module.exports = SonosApi; 463 | --------------------------------------------------------------------------------