From 29eb51b4a159643aaac62ca5f976c35fffe36867 Mon Sep 17 00:00:00 2001 From: Stephan van Rooij Date: Sat, 3 Feb 2018 18:13:42 +0100 Subject: [PATCH] feat: Play Notifications (and new communication with sonos) --- README.md | 26 +++++- src/bridge.js | 224 +++++++++++++++++++++++++------------------------- 2 files changed, 134 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index ca566f2..fe3bf3f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Check out the other bridges in the [software list](https://github.com/mqtt-smart ## Installation -Using sonos2mqtt is really easy, but it requires at least [Node.js](https://nodejs.org/) v6 or higher. (This app is tested against v6, v8 and v9). +Using sonos2mqtt is really easy, but it requires at least [Node.js](https://nodejs.org/) v6 or higher. (This app is tested against v8 and v9). `sudo npm install -g sonos2mqtt` @@ -95,6 +95,9 @@ Speaker commands: * `mute` - Mute the volume * `unmute` - Unmute the volume * `sleep` (payload requires number) - Set a sleeptimer for x amount of minutes (from payload) +* `notify` - Play a **notification** on this device :tada: (and revert to current state) see [parameters](https://github.com/bencevans/node-sonos/blob/master/docs/sonos.md#sonossonosplaynotificationoptions) +* `queue` - add a song to the queue, payload should be json string or uri. See [parameters](https://github.com/bencevans/node-sonos/blob/master/docs/sonos.md#sonossonosqueueoptions-positioninqueue) +* `setavtransporturi` - See [parameters](https://github.com/bencevans/node-sonos/blob/master/docs/sonos.md#sonossonossetavtransporturioptions) ### Generic commands @@ -104,19 +107,36 @@ Generic commands: * `pauseall` - Pause all speakers know to the bridge. * `listalarms` - This will fetch all the current alarms and sends them to `sonos/alarms`. +* `setalarm` - This allows you to set/unset an alarm. Requires json object with `id` and `enabled` +* `notify` - Play a notification on all devices (and revert to current state) see [parameters](https://github.com/bencevans/node-sonos/blob/master/docs/sonos.md#sonossonosplaynotificationoptions) + +#### Notification sample + +To play a notification on all devices you send the following json string to `sonos/cmd/notify` + +```json +{ + "uri": "https://archive.org/download/Doorbell_1/doorbell.mp3", + "volume": 10 +} +``` ## Use [PM2](http://pm2.keymetrics.io) to run in background If everything works as expected, you should make the app run in the background automatically. Personally I use PM2 for this. And they have a great [guide for this](http://pm2.keymetrics.io/docs/usage/quick-start/). -## Special thanks +## Node-sonos -The latest version of this bridge is inspired on [hue2mqtt.js](https://github.com/hobbyquaker/hue2mqtt.js) by [Sabastian Raff](https://github.com/hobbyquaker). That was a great sample on how to create a globally installed, command-line, something2mqtt bridge. +This library depends on [node-sonos](https://github.com/bencevans/node-sonos/) that I just completly promistified. All other libraries using node-sonos should also be able to implemented all the nice features included there. Like **notifications** which is the coolest new addition for **sonos2mqtt**! ## Beer This bridge and [my work](https://github.com/bencevans/node-sonos/pull/195) on the sonos library took me quite some time, so I invite everyone using this bridge to [Buy me a beer](https://svrooij.nl/buy-me-a-beer/). +## Special thanks + +The latest version of this bridge is inspired on [hue2mqtt.js](https://github.com/hobbyquaker/hue2mqtt.js) by [Sabastian Raff](https://github.com/hobbyquaker). That was a great sample on how to create a globally installed, command-line, something2mqtt bridge. + [badge_paypal_donate]: https://svrooij.nl/badges/paypal_donate.svg [badge_patreon]: https://svrooij.nl/badges/patreon.svg [paypal-donations]: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=T9XFJYUSPE4SG diff --git a/src/bridge.js b/src/bridge.js index a27bd58..056de7c 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -3,7 +3,6 @@ const pkg = require('../package.json') const log = require('yalm') const config = require('./config.js') -const parser = require('xml2json') const mqtt = require('mqtt') const s = require('sonos') @@ -52,48 +51,47 @@ function start () { log.info('mqtt reconnect') }) + log.debug('Starting event server') + s.Listener.startListener() + // Start searching for devices log.info('Start searching for Sonos players') - search = s.search({timeout: 4000}) - search.on('DeviceAvailable', (device, model) => { + search = s.DeviceDiscovery({timeout: 4000}) + search.on('DeviceAvailable', async (device, model) => { log.debug('Found device (%s) with IP: %s', model, device.host) - device.getZoneAttrs((err, attrs) => { - if (err) { + device.getZoneAttrs() + .then(attrs => { + log.info('Found player (%s): %s with IP: %s', model, attrs.CurrentZoneName, device.host) + device.name = attrs.CurrentZoneName + // hosts.push(host) + addDevice(device) + }) + .catch(err => { log.error('Get Zone error ', err) - return - } - // log.info('Found: ' + host.name + ' with IP: ' + host.ip) - log.info('Found player (%s): %s with IP: %s', model, attrs.CurrentZoneName, device.host) - device.name = attrs.CurrentZoneName - // hosts.push(host) - addDevice(device) - }) + }) }) search.on('timeout', () => { publishConnectionStatus() + s.Listener.on('AlarmClock', listAlarms) }) - process.on('SIGINT', () => { + process.on('SIGINT', async () => { log.info('Shutting down listeners, please wait') - devices.forEach(device => { - device.stopListening((err, success) => { - if (err) { - log.error('Error shutting down listner %j', err) - return - } - + return s.Listener.stopListener() + .then(result => { log.info('Listener shutdown successfully') + process.exit() + }) + .catch(err => { + log.error('Error shutting down listner %j', err) + process.exit() }) - }) - setTimeout(() => { - process.exit() - }, 1000) }) } // This function will receive all incoming messages from MQTT -function handleIncomingMessage (topic, payload) { +async function handleIncomingMessage (topic, payload) { payload = payload.toString() log.debug('Incoming message to %s %j', topic, payload) @@ -103,90 +101,83 @@ function handleIncomingMessage (topic, payload) { if (parts[1] === 'set' && parts.length === 4) { let device = devices.find((device) => { return device.name.toLowerCase() === parts[2] }) if (device) { - handleDeviceCommand(device, parts[3], payload) + return handleDeviceCommand(device, parts[3], payload) + .then(result => { + log.debug('Executed %s for %s result: %j', parts[3], device.name, result) + }) + .catch(err => { + log.error('Error executing %s for %s %j', parts[3], device.name, err) + }) } else { log.error('Device with name %s not found', parts[2]) } } else if (parts[1] === 'cmd' && parts.length === 3) { - handleGenericCommand(parts[2], payload) + return handleGenericCommand(parts[2], payload) + .then(result => { + log.debug('Executed %s result: %j', parts[2], result) + }) + .catch(err => { + log.error('Error executing %s %j', parts[2], err) + }) } } // This function is called when a device command is recognized by 'handleIncomingMessage' -function handleDeviceCommand (device, command, payload) { +async function handleDeviceCommand (device, command, payload) { log.debug('Incoming device command %s for %s payload %s', command, device.name, payload) switch (command) { // ------------------ Playback commands case 'next': - device.next((err, res) => { - log.debug([err, res]) - }) - break + return device.next() case 'pause': case 'pauze': - device.pause((err, res) => { - log.debug([err, res]) - }) - break + return device.pause() case 'play': - device.play((err, res) => { - log.debug([err, res]) - }) - break + return device.play() case 'previous': - device.previous((err, res) => { - log.debug([err, res]) - }) - break + return device.previous() case 'stop': - device.stop((err, res) => { - log.debug([err, res]) - }) - break + return device.stop() // ------------------ Volume commands case 'volume': if (IsNumeric(payload)) { var vol = parseInt(payload) if (vol >= 0 && vol <= 100) { - device.setVolume(vol, (err, success) => { - if (!err && success) { - log.info('Changed volume to %d', vol) - } - }) + return device.setVolume(vol) } } else { log.error('Payload for setting volume is not numeric') } break case 'volumeup': - handleVolumeCommand(device, payload, 1) - break + return handleVolumeCommand(device, payload, 1) case 'volumedown': - handleVolumeCommand(device, payload, -1) - break + return handleVolumeCommand(device, payload, -1) case 'mute': - device.setMuted(true, (err, res) => { - log.debug([err, res]) - }) - break + return device.setMuted(true) case 'unmute': - device.setMuted(false, (err, res) => { - log.debug([err, res]) - }) - break + return device.setMuted(false) // ------------------ Sleeptimer case 'sleep': if (IsNumeric(payload)) { var minutes = parseInt(payload) if (minutes > 0 && minutes < 1000) { - device.configureSleepTimer(minutes, (err, success) => { - log.debug('Sleeptimer set %j', [err, success]) + return device.configureSleepTimer(minutes).then(result => { + log.debug('Sleeptimer set %j', result) }) } } else { log.error('Payload for setting sleeptimer is not numeric') } break + // ------------------ Queue a song to play next accepts string or json object + case 'queue': + return device.queue(ConvertToObjectIfPossible(payload)) + // ----------------- Possibly the coolest feature of this library, play a notification and revert back to old state see https://github.com/bencevans/node-sonos/blob/master/docs/sonos.md#sonossonosplaynotificationoptions for parameters + case 'notify': + return device.playNotification(ConvertToObjectIfPossible(payload)) + case 'setavtransporturi': + return device.setAVTransportURI(ConvertToObjectIfPossible(payload)) default: log.debug('Command %s not yet supported', command) break @@ -194,7 +185,7 @@ function handleDeviceCommand (device, command, payload) { } // This function is used by 'handleDeviceCommand' for handeling the volume up/down commands -function handleVolumeCommand (device, payload, modifier) { +async function handleVolumeCommand (device, payload, modifier) { let change = 5 if (IsNumeric(payload)) { let tempIncrement = parseInt(payload) @@ -202,34 +193,37 @@ function handleVolumeCommand (device, payload, modifier) { change = tempIncrement } } - device.getVolume((err, vol) => { - if (err) { - log.error('Error getting volume', err) - return - } - let newVolume = vol + (change * modifier) - device.setVolume(newVolume, (err2, res) => { - log.info('Volume modified from %d to %d', vol, newVolume) + return device.getVolume() + .then(vol => { + return vol + (change * modifier) + }) + .then(device.setVolume) + .then(result => { + log.info('Volume changed %d', (change * modifier)) }) - }) } // This function is called when a generic command is recognized by 'handleIncomingMessages' -function handleGenericCommand (command, payload) { +async function handleGenericCommand (command, payload) { switch (command) { // ------------------ Alarms case 'listalarms': - listAlarms() - break + return listAlarms() + case 'setalarm': + return setalarm(payload) // ------------------ Control all devices case 'pauseall': - devices.forEach(device => { - device.pause((err) => { - log.debug(err) - }) - }) - break - + const pauseall = async function (device) { + await device.pause() + } + return Promise.all(devices.map(pauseall)) + // ------------------ Play a notification on all devices, see https://github.com/bencevans/node-sonos/blob/master/docs/sonos.md#sonossonosplaynotificationoptions for parameters + case 'notify': + const parsedPayload = ConvertToObjectIfPossible(payload) + const notifyAll = async function (device) { + await device.playNotification(parsedPayload) + } + return Promise.all(devices.map(notifyAll)) default: log.error('Command %s isn\' implemented', command) break @@ -237,25 +231,18 @@ function handleGenericCommand (command, payload) { } // Loading the alarms and publishing them to 'sonos/alarms' -function listAlarms () { - var alarmService = new s.Services.AlarmClock(devices[0].host) - alarmService.ListAlarms({}, (err, res) => { - if (err) { - log.error('Error loading alarms from %s %j', devices[0].host, err) - return - } - - var alarms = JSON.parse(parser.toJson(res.CurrentAlarmList)).Alarms.Alarm - - // For better reading we remove the metadata. - alarms.forEach(alarm => { - delete alarm.ProgramMetaData - }) - +async function listAlarms () { + return devices[0].alarmClockService().ListAlarms().then(alarms => { log.debug('Got alarms %j', alarms) - publishData(config.name + '/alarms', alarms) + mqttClient.publish(config.name + '/alarms', JSON.stringify(alarms), {retain: false}) }) } +async function setalarm (payload) { + payload = ConvertToObjectIfPossible(payload) + if (payload.id && payload.enabled) { + return devices[0].alarmClockService().SetAlarm(payload.id, payload.enabled) + } +} function publishConnectionStatus () { let status = '1' @@ -269,16 +256,16 @@ function publishConnectionStatus () { // This function is called by the device discovery, used to setup listening for certain events. function addDevice (device) { // Start listening for those events! - device.on('TrackChanged', track => { + device.on('CurrentTrack', track => { publishCurrentTrack(device, track) }) - device.on('StateChanged', state => { + device.on('PlayState', state => { publishState(device, state) }) device.on('Muted', muted => { publishMuted(device, muted) }) - device.on('VolumeChanged', volume => { + device.on('Volume', volume => { publishVolume(device, volume) }) @@ -307,18 +294,20 @@ function publishCurrentTrack (device, track) { publishData(config.name + '/status/' + device.name + '/title', track.title, device.name) publishData(config.name + '/status/' + device.name + '/artist', track.artist, device.name) publishData(config.name + '/status/' + device.name + '/album', track.album, device.name) - publishData(config.name + '/status/' + device.name + '/albumart', track.albumArtURL, device.name) + publishData(config.name + '/status/' + device.name + '/albumart', track.albumArtURI, device.name) } else { let val = (track && track.title) ? { title: track.title, artist: track.artist, album: track.album, - albumArt: track.albumArtURL + albumArt: track.albumArtURI } : null - if (device.lastTrack !== val) { - publishData(config.name + '/status/' + device.name + '/track', val, device.name) - device.lastTrack = val - } + + publishData(config.name + '/status/' + device.name + '/track', val, device.name) + // if (device.lastTrack !== val) { + // publishData(config.name + '/status/' + device.name + '/track', val, device.name) + // device.lastTrack = val + // } } } @@ -345,4 +334,13 @@ function IsNumeric (val) { return !isNaN(parseInt(val)) } +function ConvertToObjectIfPossible (input) { + try { + return JSON.parse(input) + } catch (e) { + log.debug('Error parsing json %j', e) + } + return input +} + start()