hb-zp-tools 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ // hb-zp-tools/lib/ZpListener.js
2
+ // Copyright © 2019-2024 Erik Baauw. All rights reserved.
3
+ //
4
+ // Homebridge ZP Tools.
5
+
6
+ import { EventEmitter, once } from 'node:events'
7
+ import { createServer } from 'node:http'
8
+
9
+ /** Listener for events from Sonos ZonePlayer.
10
+ * <br>See {@link ZpListener}.
11
+ * @name ZpListener
12
+ * @type {Class}
13
+ * @memberof module:hb-zp-tools
14
+ */
15
+
16
+ /** Listener class for receiving notifications by Sonos zone players.
17
+ *
18
+ * This class implements a web server to receive notifications from Sonos
19
+ * zone players.
20
+ * The web server can handle notifications from multiple zone players.
21
+ *
22
+ * Use {@link ZpListener#addClient addClient()} to register a zone player.
23
+ * When a notification for a registered zone player is recevied, a
24
+ * {@link ZpListener#event:notify notify} event
25
+ * is issued, using the zone player ID as event name.
26
+ *
27
+ * Use {@link ZpListener#removeClient removeClient()} to unregister a
28
+ * zone player.
29
+ * @extends EventEmitter
30
+ */
31
+ class ZpListener extends EventEmitter {
32
+ /** Create a new listener instance.
33
+ * @param {integer} [port=0] - The port for the web server.
34
+ */
35
+ constructor (port = 0) {
36
+ super()
37
+ this._myPort = port
38
+ this._clients = {}
39
+ this._server = createServer((request, response) => {
40
+ let buffer = ''
41
+ request.on('data', (data) => {
42
+ buffer += data
43
+ })
44
+ request.on('end', async () => {
45
+ try {
46
+ request.body = buffer
47
+ if (request.method === 'GET' && request.url === '/notify') {
48
+ // Provide an easy way to check that listener is reachable.
49
+ response.writeHead(200, { 'Content-Type': 'text/html' })
50
+ response.write('<table>')
51
+ response.write(`<caption><h3>Listening to ${Object.keys(this._clients).length} clients</h3></caption>`)
52
+ response.write('<tr><th scope="col">ZonePlayer</th>')
53
+ // response.write('<th scope="col">ID</th>')
54
+ response.write('<th scope="col">IP Address</th>')
55
+ response.write('<th scope="col">Local IP Address</th>')
56
+ response.write('<th scope="col">Subscriptions</th></tr>')
57
+ const names = {}
58
+ for (const id of Object.keys(this._clients)) {
59
+ const zpClient = this._clients[id]
60
+ const name = zpClient.name == null ? zpClient.id : zpClient.name
61
+ names[name] = zpClient
62
+ }
63
+ for (const name of Object.keys(names).sort()) {
64
+ const zpClient = names[name]
65
+ response.write(`<tr><td>${name}</td>`)
66
+ // response.write(`<td>${zpClient.id}</td>`)
67
+ response.write(`<td>${zpClient.address}</td>`)
68
+ response.write(`<td>${zpClient.localAddress}</td>`)
69
+ const subs = zpClient.subscriptions.map((sub) => {
70
+ return sub.slice(0, -6)
71
+ }).join(', ')
72
+ response.write(`<td>${subs}</td></tr>`)
73
+ }
74
+ response.write('</table>')
75
+ } else if (request.method === 'NOTIFY') {
76
+ const array = request.url.split('/')
77
+ if (array.length === 5) {
78
+ array.splice(3, 0, 'ZonePlayer')
79
+ }
80
+ if (
81
+ array[1] === 'notify' && this._clients[array[2]] !== null &&
82
+ array[3] != null && array[4] != null && array[5] === 'Event'
83
+ ) {
84
+ /** Emitted when receiving a notification from a registered
85
+ * zone player.
86
+ *
87
+ * Note: the actual event name is the ID of the zone player.
88
+ * @event ZpListener#notify
89
+ * @param {object} params - The notification paramaters.
90
+ * @param {string} params.device - The device that issued the
91
+ * notification or `ZonePlayer` for the default device.
92
+ * @param {string} params.service - The service that issued the
93
+ * notification.
94
+ * @param {string} params.body - The body of the notification
95
+ * (in XML).
96
+ */
97
+ this.emit(array[2], {
98
+ device: array[3],
99
+ service: array[4],
100
+ body: request.body
101
+ })
102
+ }
103
+ }
104
+ response.end()
105
+ } catch (error) {
106
+ /** Emitted when the web server encounters an error.
107
+ * @event ZpListener#error
108
+ * @param {Error} error - The error.
109
+ */
110
+ this.emit('error', error)
111
+ }
112
+ })
113
+ })
114
+ this._server
115
+ .on('error', (error) => { this.emit('error', error) })
116
+ .on('close', () => {
117
+ /** Emitted when the web server is closed.
118
+ * @event ZpListener#close
119
+ * @param {string} url - The url the web server was listening on.
120
+ */
121
+ this.emit('close', this._callbackUrl)
122
+ delete this._callbackUrl
123
+ })
124
+ }
125
+
126
+ // Start the web server.
127
+ async _listen () {
128
+ if (this._server.listening) {
129
+ return
130
+ }
131
+ this._server.listen(this._myPort, '0.0.0.0')
132
+ await once(this._server, 'listening')
133
+ const address = this._server.address()
134
+ this._myIp = address.address
135
+ this._myPort = address.port
136
+ this._callbackUrl = 'http://' + this._myIp + ':' + this._myPort + '/notify'
137
+ /** Emitted when the web server has started.
138
+ * @event ZpListener#listening
139
+ * @param {string} url - The url the web server is listening on.
140
+ */
141
+ this.emit('listening', this._callbackUrl)
142
+ }
143
+
144
+ /** Registers a zone player for notifications.
145
+ *
146
+ * Starts the web server if not already started.
147
+ * @param {ZpClient} zpClient - The {@link ZpClient} instance for the zone
148
+ * player.
149
+ * @return {string} callbackUrl - The callback url to pass to the zone
150
+ * player when subscribing to notifications.
151
+ * See {@link ZpClient#subscribe subscribe()}.
152
+ */
153
+ async addClient (zpClient) {
154
+ this._clients[zpClient.id] = zpClient
155
+ await this._listen()
156
+ const callbackUrl = 'http://' + zpClient.localAddress + ':' + this._myPort +
157
+ '/notify/' + zpClient.id
158
+ return callbackUrl
159
+ }
160
+
161
+ /** Deregisters a zone player for notifications.
162
+ *
163
+ * Stops the web server when no more clients remain.
164
+ * @param {ZpClient} zpClient - The {@link ZpClient} instance for the zone
165
+ * player.
166
+ */
167
+ async removeClient (zpClient) {
168
+ this.removeAllListeners(zpClient.id)
169
+ delete this._clients[zpClient.id] // FIXME: this doesn't work?!
170
+ if (Object.keys(this._clients).length === 0) {
171
+ await this._server.close()
172
+ }
173
+ }
174
+ }
175
+
176
+ export { ZpListener }