UNPKG

sonos

Version:
354 lines (325 loc) 11.1 kB
/** * Sonos library to control (almost) everything from your sonos devices * @module sonos * @requires 'events' * @requires 'axios' * @requires 'debug' */ const EventEmitter = require('events').EventEmitter const request = require('axios') const debug = require('debug')('sonos-listener') const http = require('http') const ip = require('ip') const parseXml = require('../helpers').ParseXml const ParseAndEmitEvents = require('./eventParser').ParseAndEmitEvents const listenAddress = process.env.SONOS_LISTENER || ip.address('public') const globalEventEndpoints = [ '/AlarmClock/Event', '/ZoneGroupTopology/Event', '/MediaServer/ContentDirectory/Event' ] const deviceEventEndpoints = [ '/MediaRenderer/AVTransport/Event', '/MediaRenderer/RenderingControl/Event', '/MediaRenderer/GroupRenderingControl/Event', '/MediaRenderer/Queue/Event' ] /** * An event listener for sonos events. (Just a small http server) * @class SonosListener */ class SonosListener extends EventEmitter { /** * Creates a new SonosListener (called automatically) */ constructor () { super() this.port = parseInt(process.env.SONOS_LISTENER_PORT || 4000) this._listening = false this._deviceSubscriptions = [] this._requestHandler = function (req, resp) { const body = [] req .on('data', (chunk) => { body.push(chunk) }) .on('end', () => { const bodyString = Buffer.concat(body).toString() defaultListener._handleMessage(req, resp, bodyString) }) .on('error', (err) => { // If we don't listen to this it will crash https://nodejs.org/en/docs/guides/anatomy-of-an-http-transaction/ debug('Error on http request %j', err) }) } this._eventServer = http.createServer(this._requestHandler) } /** * Start the listener, has to be called before subscribing */ startListener () { if (!this._listening) { debug('startListen %d', this.port) this._eventServer.listen(this.port) this._listening = true } } /** * Stop the listener and unsubscribes for all events. * Very important to call or you'll get wrong notifications */ async stopListener () { if (this._listening) { this._eventServer.close() this._listening = false const cancel = function (s) { return s.cancelAllSubscriptions() } var cancelAll = this._deviceSubscriptions.map(cancel) return Promise.all(cancelAll) } else { return new Promise((resolve, reject) => { reject(new Error('Not listening')) }) } } isListening () { return this._listening } /** * Subscribe to all events for this device. * @param {Sonos} device Pass in the Sonos device, it will be the eventemitter */ async subscribeTo (device) { return new Promise((resolve, reject) => { this.startListener() if (device._isSubscribed) { debug('Already subscribed to this device') resolve(true) return } device._isSubscribed = true // This should be improved but it works for now. const sub = new DeviceSubscription(device) const add = function (endpoint) { return sub.addSubscription(endpoint) } if (this._deviceSubscriptions.length === 0) { const addAll = globalEventEndpoints.concat(deviceEventEndpoints).map(add) Promise.all(addAll) .then(result => { this._deviceSubscriptions.push(sub) }).then(resolve).catch(reject) } else { const addDevice = deviceEventEndpoints.map(add) Promise.all(addDevice) .then(result => { this._deviceSubscriptions.push(sub) }).then(resolve).catch(reject) } }) } _handleMessage (req, resp, body) { const { method, headers, url } = req const self = this if (method.toUpperCase() !== 'NOTIFY' || url.toLowerCase() !== '/notify') { debug('Unknown request %s %s %j %s', method, url, headers, body) resp.statuscode = 404 resp.end('404 - Not found') } else { // Find the subscription matching this sid const deviceSubscription = this._deviceSubscriptions.find((el, index, arr) => { return el.hasSubscription(headers.sid) }) // If the device subscription isnt't found, we have to return an error if (!deviceSubscription) { debug('Request received for unknown subscription %s', headers.sid) // This will stop these requests from coming. resp.statuscode = 404 resp.end('404 - Not found') } else { parseXml(body) .then(parsedBody => { const endpoint = deviceSubscription.subscriptions[headers.sid].endpoint const eventBody = parsedBody['e:propertyset']['e:property'] if (deviceEventEndpoints.indexOf(endpoint) > -1) { // Send the parsed body to the device subscription deviceSubscription.handleNotification(endpoint, eventBody) } else { self._handleGlobalNotification(endpoint, eventBody) } resp.statuscode = 200 resp.end('OK') }) .catch(err => { debug('Error parsing the body %j', err) resp.statuscode = 500 resp.end('500 - Error parsing body') }) } } } async _handleGlobalNotification (endpoint, body) { debug('Global notification %s %j', endpoint, body) const sonosEvent = await ParseAndEmitEvents(endpoint, body, this._deviceSubscriptions[0].device) if (sonosEvent.name === 'ZoneGroupTopology') { this.emit('ZonesChanged', sonosEvent.eventBody.Zones) delete sonosEvent.eventBody.Zones } this.emit(sonosEvent.name || 'UnknownEvent', sonosEvent) } } // Initialize the listener and export it. (Single instance https://k94n.com/es6-modules-single-instance-pattern) const defaultListener = new SonosListener() module.exports = defaultListener /** * DeviceSubscription, used internally to keep record of subscriptions * @class DeviceSubscription */ class DeviceSubscription { /** * Create new subscription * @param {Sonos} device Pass in the sonos device */ constructor (device) { this.subscriptions = [] this.device = device } /** * Subscribe to specefic endpoint for this device * @param {String} endpoint What endpoint do we need to subscribe to? */ async addSubscription (endpoint) { const opt = { url: 'http://' + this.device.host + ':' + this.device.port + endpoint, method: 'SUBSCRIBE', headers: { callback: '<http://' + listenAddress + ':' + defaultListener.port + '/notify>', NT: 'upnp:event', Timeout: 'Second-1800' }, resolveWithFullResponse: true } // TODO check migration return request(opt) .then(resp => { const sid = resp.headers.sid debug('Got %s for %s', sid, endpoint) this._startTimer() this.subscriptions[sid] = { endpoint: endpoint, renew_at: this.headerToDateTime(resp.headers.timeout) } return sid }) } /** * Use this function to start the renew timer */ _startTimer () { if (this._renewTimer) clearInterval(this._renewTimer) const self = this this._renewTimer = setInterval(async () => { try { await self.renewAllSubscriptions() } catch (err) { debug('Timer error renewing subscriptions %j', err) if (err.cause && err.cause.code === 'EHOSTUNREACH') { debug('Continue renewing subscription') } else { debug('Stop renewing subscription') clearInterval(self._renewTimer) } } }, 25 * 60 * 1000) // 25 min } /** * Renew all subscriptions for this device */ async renewAllSubscriptions () { const self = this const renew = function (sid) { return self.renewSubscription(sid) } const renewAll = Object.keys(this.subscriptions).map(renew) return Promise.all(renewAll) } /** * Renew a single subscription * @param {String} sid Subscription id you want to renew */ async renewSubscription (sid) { return new Promise((resolve, reject) => { const subscription = this.subscriptions[sid] const opt = { url: 'http://' + this.device.host + ':' + this.device.port + subscription.endpoint, method: 'SUBSCRIBE', headers: { SID: sid, Timeout: 'Second-1800' }, resolveWithFullResponse: true } request(opt) .then(resp => { debug('Renew %s success', sid) this.subscriptions[sid].renew_at = this.headerToDateTime(resp.headers.timeout) resolve(sid) }) .catch(err => { debug('Renew %s failed %j', sid, err) delete this.subscriptions[sid] this.addSubscription(subscription.endpoint) .then(resolve) .catch(reject) }) }) } /** * Does this deivce have a subscription with a specific sid * @param {String} sid Subscription id */ hasSubscription (sid) { return (this.subscriptions[sid] !== undefined) } /** * This will be called by the SonosListener for device specific devices * @param {String} endpoint The endpoint used for the subscription * @param {String} body The body of the event */ async handleNotification (endpoint, body) { // debug('Incoming message on %s %s %j', this.device.host, endpoint, body) const sonosEvent = await ParseAndEmitEvents(endpoint, body, this.device) debug('Sonos event %s', JSON.stringify(sonosEvent, null, 2)) // TODO Handle the notification like in https://github.com/thkl/node-sonos/tree/master/lib/events or something } /** * Cancel all the subscriptions for this device. Important to stop the notifications from returing. */ async cancelAllSubscriptions () { const self = this const cancel = function (s) { return self.cancelSubscription(s) } const cancelAll = Object.keys(this.subscriptions).map(cancel) return Promise.all(cancelAll) } /** * Cancel a single subscribtion * @param {String} sid Subscription id */ async cancelSubscription (sid) { return request({ url: 'http://' + this.device.host + ':' + this.device.port + this.subscriptions[sid].endpoint, method: 'UNSUBSCRIBE', headers: { sid: sid } }).then(result => { debug('Cancelled subscription %s for %s', sid, this.device.host) delete this.subscriptions[sid] }) } /** * Convert the Timeout header to datetime (legacy code...) * @param {String} timeout TimeOut header */ headerToDateTime (timeout) { var seconds if ((!!timeout) && (timeout.indexOf('Second-') === 0)) timeout = timeout.substr(7) seconds = (((!!timeout) && (!isNaN(timeout))) ? parseInt(timeout, 10) : 3600) - 15 if (seconds < 0) seconds = 15; else if (seconds > 1200) seconds = 1200 return (new Date().getTime() + (seconds * 1000)) } }