hb-zp-tools
Version:
Homebridge ZP Tools
177 lines (169 loc) • 6.57 kB
JavaScript
// hb-zp-tools/lib/ZpListener.js
// Copyright © 2019-2026 Erik Baauw. All rights reserved.
//
// Homebridge ZP Tools.
import { EventEmitter, once } from 'node:events'
import { createServer } from 'node:http'
/** Listener for events from Sonos ZonePlayer.
* <br>See {@link ZpListener}.
* @name ZpListener
* @type {Class}
* @memberof module:hb-zp-tools
*/
/** Listener class for receiving notifications by Sonos zone players.
*
* This class implements a web server to receive notifications from Sonos
* zone players.
* The web server can handle notifications from multiple zone players.
*
* Use {@link ZpListener#addClient addClient()} to register a zone player.
* When a notification for a registered zone player is recevied, a
* {@link ZpListener#event:notify notify} event
* is issued, using the zone player ID as event name.
*
* Use {@link ZpListener#removeClient removeClient()} to unregister a
* zone player.
* @extends EventEmitter
*/
class ZpListener extends EventEmitter {
/** Create a new listener instance.
* @param {integer} [port=0] - The port for the web server.
*/
constructor (port = 0) {
super()
this._myPort = port
this._clients = {}
this._server = createServer((request, response) => {
let buffer = ''
request.on('data', (data) => {
buffer += data
})
request.on('end', async () => {
try {
request.body = buffer
if (request.method === 'GET' && request.url === '/notify') {
// Provide an easy way to check that listener is reachable.
response.writeHead(200, { 'Content-Type': 'text/html' })
response.write('<table>')
response.write(`<caption><h3>Listening to ${Object.keys(this._clients).length} clients</h3></caption>`)
response.write('<tr><th scope="col">ZonePlayer</th>')
response.write('<th scope="col">ID</th>')
response.write('<th scope="col">IP Address</th>')
response.write('<th scope="col">Local IP Address</th>')
response.write('<th scope="col">Subscriptions</th></tr>')
const names = {}
for (const id of Object.keys(this._clients)) {
const zpClient = this._clients[id]
const name = zpClient.zonePlayerName == null ? zpClient.id : zpClient.zonePlayerName
names[name] = zpClient
}
for (const name of Object.keys(names).sort()) {
const zpClient = names[name]
response.write(`<tr><td>${name}</td>`)
response.write(`<td>${zpClient.id}</td>`)
response.write(`<td>${zpClient.address}</td>`)
response.write(`<td>${zpClient.localAddress}</td>`)
const subs = zpClient.subscriptions.map((sub) => {
return sub.slice(0, -6)
}).join(', ')
response.write(`<td>${subs}</td></tr>`)
}
response.write('</table>')
} else if (request.method === 'NOTIFY') {
const array = request.url.split('/')
if (array.length === 5) {
array.splice(3, 0, 'ZonePlayer')
}
if (
array[1] === 'notify' && this._clients[array[2]] !== null &&
array[3] != null && array[4] != null && array[5] === 'Event'
) {
/** Emitted when receiving a notification from a registered
* zone player.
*
* Note: the actual event name is the ID of the zone player.
* @event ZpListener#notify
* @param {object} params - The notification paramaters.
* @param {string} params.device - The device that issued the
* notification or `ZonePlayer` for the default device.
* @param {string} params.service - The service that issued the
* notification.
* @param {string} params.body - The body of the notification
* (in XML).
*/
this.emit(array[2], {
device: array[3],
service: array[4],
body: request.body
})
}
}
response.end()
} catch (error) {
/** Emitted when the web server encounters an error.
* @event ZpListener#error
* @param {Error} error - The error.
*/
this.emit('error', error)
}
})
})
this._server
.on('error', (error) => { this.emit('error', error) })
.on('close', () => {
/** Emitted when the web server is closed.
* @event ZpListener#close
* @param {string} url - The url the web server was listening on.
*/
this.emit('close', this._callbackUrl)
delete this._callbackUrl
})
}
// Start the web server.
async _listen () {
if (this._server.listening) {
return
}
this._server.listen(this._myPort, '0.0.0.0')
await once(this._server, 'listening')
const address = this._server.address()
this._myIp = address.address
this._myPort = address.port
this._callbackUrl = 'http://' + this._myIp + ':' + this._myPort + '/notify'
/** Emitted when the web server has started.
* @event ZpListener#listening
* @param {string} url - The url the web server is listening on.
*/
this.emit('listening', this._callbackUrl)
}
/** Registers a zone player for notifications.
*
* Starts the web server if not already started.
* @param {ZpClient} zpClient - The {@link ZpClient} instance for the zone
* player.
* @return {string} callbackUrl - The callback url to pass to the zone
* player when subscribing to notifications.
* See {@link ZpClient#subscribe subscribe()}.
*/
async addClient (zpClient) {
await this._listen()
this._clients[zpClient.id] = zpClient
const callbackUrl = 'http://' + zpClient.localAddress + ':' + this._myPort +
'/notify/' + zpClient.id
return callbackUrl
}
/** Deregisters a zone player for notifications.
*
* Stops the web server when no more clients remain.
* @param {ZpClient} zpClient - The {@link ZpClient} instance for the zone
* player.
*/
async removeClient (zpClient) {
this.removeAllListeners(zpClient.id)
delete this._clients[zpClient.id]
if (Object.keys(this._clients).length === 0) {
await this._server.close()
}
}
}
export { ZpListener }