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,291 @@
1
+ // hb-zp-tools/lib/ZpXmlParser.js
2
+ // Copyright © 2019-2024 Erik Baauw. All rights reserved.
3
+ //
4
+ // Homebridge ZP Tools.
5
+
6
+ // import debug from 'debug'
7
+ import he from 'he'
8
+ import { Parser } from 'xml2js'
9
+
10
+ /** XML Parser for Sonos ZonePlayer.
11
+ * <br>See {@link ZpXmlParser}.
12
+ * @name ZpXmlParser
13
+ * @type {Class}
14
+ * @memberof module:hb-zp-tools
15
+ */
16
+
17
+ // Words in uppercase to be converted to camelcase.
18
+ const _upperCaseWords = [
19
+ 'DIDL-Lite',
20
+ 'EQ',
21
+ 'HT',
22
+ 'IP',
23
+ 'IR',
24
+ 'LED',
25
+ 'LF',
26
+ 'MAC',
27
+ 'RF',
28
+ 'SCPD',
29
+ 'SSID',
30
+ 'SSL',
31
+ 'SW',
32
+ 'TOS',
33
+ 'TV',
34
+ 'UDN',
35
+ 'URI',
36
+ 'URL',
37
+ 'UUID',
38
+ 'ID'
39
+ ]
40
+ const upperCaseWords = {}
41
+ for (const word of _upperCaseWords) {
42
+ upperCaseWords[word] = {
43
+ regexp: new RegExp('^(.*)' + word + '(.*)$'),
44
+ lower: word.toLowerCase(),
45
+ camel: word.charAt(0) + word.slice(1).toLowerCase()
46
+ }
47
+ }
48
+
49
+ // Keys that might hold an HTML encoded value.
50
+ const encodedKeys = [
51
+ 'streamContent',
52
+ 'title'
53
+ ]
54
+
55
+ // Keys that always hold a string value.
56
+ const stringKeys = [
57
+ 'album',
58
+ 'title'
59
+ ]
60
+
61
+ // Keys to be replaced.
62
+ const replacementKeys = {
63
+ availableSoftwareUpdate: 'availableSoftwareUpdates',
64
+ satellite: 'satellites',
65
+ zoneGroupMember: 'zoneGroupMembers'
66
+ }
67
+
68
+ // Keys that contain lists and always should return an array.
69
+ // Value is the member key, or empty string if none.
70
+ const arrayKeys = {
71
+ albumArtUri: '',
72
+ availableSoftwareUpdates: 'updateItem',
73
+ currentAlarmList: 'alarm',
74
+ lastChange: '',
75
+ queueId: '',
76
+ satellites: '',
77
+ serviceStateTable: 'stateVariable',
78
+ vanishedDevices: 'device',
79
+ zoneGroups: 'zoneGroup',
80
+ zoneGroupMembers: ''
81
+ }
82
+
83
+ // Keys to be ignore at root.
84
+ const rootKeys = [
85
+ 'alarms',
86
+ 'didl-lite',
87
+ 'e:propertyset',
88
+ 'event',
89
+ 'instanceId',
90
+ 'item',
91
+ 'root',
92
+ 'scpd',
93
+ 'val',
94
+ 'zoneGroupState'
95
+ ]
96
+
97
+ // Keys with one or more channels.
98
+ const channelKeys = [
99
+ 'loudness',
100
+ 'mute',
101
+ 'volume'
102
+ ]
103
+
104
+ const moreInfoRegexp = /^RawBattPct:([0-9]+),BattPct:([0-9]+),BattChg:([A-Z_]+),BattTmp:([0-9]+)$/
105
+
106
+ // XML parser
107
+ class ZpXmlParser {
108
+ constructor () {
109
+ const xml2jsParserOptions = {
110
+ explicitArray: false,
111
+ mergeAttrs: true,
112
+ attrNameProcessors: [this._processKey.bind(this)],
113
+ attrValueProcessors: [this._processValue.bind(this)],
114
+ tagNameProcessors: [this._processKey.bind(this)],
115
+ valueProcessors: [this._processValue.bind(this)]
116
+ }
117
+ this._parser = new Parser(xml2jsParserOptions)
118
+ // this._debug = debug('ZpXmlParser')
119
+ }
120
+
121
+ async parse (xml) {
122
+ // this._debug('parse(%j)', xml)
123
+ const result = await this._parser.parseStringPromise(xml)
124
+ return this._process(result)
125
+ }
126
+
127
+ // Convert key to javascript standard key.
128
+ _processKey (key) {
129
+ // const oldKey = key
130
+ // this._debug('processKey(%j)', oldKey)
131
+
132
+ // Get rid of schema tags.
133
+ const a = key.split(':')
134
+ if (a != null && a.length === 2 && a[0] !== 'xmlns' && a[0] !== 'e' && a[0] !== 's') {
135
+ key = a[1]
136
+ }
137
+
138
+ // Convert uppercase keys to camelcase.
139
+ for (const wordKey in upperCaseWords) {
140
+ const word = upperCaseWords[wordKey]
141
+ const a = word.regexp.exec(key)
142
+ if (a != null) {
143
+ key = a[1] + (a[1] === '' ? word.lower : word.camel) + a[2]
144
+ }
145
+ }
146
+
147
+ // Convert initial uppercase to lowercase.
148
+ key = key.charAt(0).toLowerCase() + key.slice(1)
149
+
150
+ // Substite keys.
151
+ if (replacementKeys[key] != null) {
152
+ key = replacementKeys[key]
153
+ }
154
+
155
+ // this._debug('processKey(%j) => %j', oldKey, key)
156
+ return key
157
+ }
158
+
159
+ // Convert value to proper javascript value.
160
+ _processValue (value, key) {
161
+ // const oldValue = value
162
+ // this._debug('processValue(%j, %j)', oldValue, key)
163
+
164
+ // Convert integer string to integer
165
+ if (/^[0-9]+$/.test(value) && !stringKeys.includes(key)) {
166
+ value = parseInt(value)
167
+ }
168
+
169
+ if (encodedKeys.includes(key)) {
170
+ value = he.decode(value)
171
+ }
172
+
173
+ // this._debug('processValue(%j, %j) => %j', oldValue, key, value)
174
+ return value
175
+ }
176
+
177
+ // Post-process converted XML.
178
+ async _process (value) {
179
+ // Recursively parse XML strings.
180
+ if (typeof value === 'string' && /^<.*>$/.test(value)) {
181
+ return this.parse(value)
182
+ }
183
+
184
+ // Recursively post-process arrays.
185
+ if (Array.isArray(value)) {
186
+ const list = []
187
+ for (const elt of value) {
188
+ list.push(await this._process(elt))
189
+ }
190
+ return list
191
+ }
192
+
193
+ // Recursively post-process objects.
194
+ if (typeof value === 'object') {
195
+ // Ignore xmlns schemas.
196
+ for (const key in value) {
197
+ if (key.startsWith('xmlns')) {
198
+ delete value[key]
199
+ }
200
+ }
201
+ // Handle single-key objects.
202
+ const keys = Object.keys(value)
203
+ if (keys.length === 1) {
204
+ if (rootKeys.includes(keys[0])) {
205
+ return this._process(value[keys[0]])
206
+ }
207
+ }
208
+ // Recursively post-process key/value pairs.
209
+ const obj = {}
210
+ for (const key in value) {
211
+ // Handle lists.
212
+ const a = key.match(/^(.+)List$/)
213
+ if (a != null || arrayKeys[key] != null) {
214
+ const childKey = arrayKeys[key] == null ? a[1] : arrayKeys[key]
215
+ let newValue = await this._process(value[key])
216
+ const listKeys = Object.keys(newValue)
217
+ if (listKeys.length === 1 && listKeys[0] === childKey) {
218
+ newValue = newValue[childKey]
219
+ }
220
+ if (Array.isArray(newValue)) {
221
+ obj[key] = newValue
222
+ } else if (
223
+ typeof newValue === 'object' || typeof newValue === 'string'
224
+ ) {
225
+ obj[key] = [newValue]
226
+ } else {
227
+ obj[key] = []
228
+ }
229
+ continue
230
+ }
231
+ // Handle e:property.
232
+ if (key === 'e:property') {
233
+ const newValue = await this._process(value[key])
234
+ if (Array.isArray(newValue)) {
235
+ for (const property of newValue) {
236
+ for (const key in property) {
237
+ obj[key] = property[key]
238
+ }
239
+ }
240
+ } else {
241
+ for (const key in newValue) {
242
+ obj[key] = newValue[key]
243
+ }
244
+ }
245
+ continue
246
+ }
247
+ // Handle SOAP response.
248
+ if (key === 's:Envelope') {
249
+ if (value[key] != null && value[key]['s:Body'] != null) {
250
+ const keys = Object.keys(value[key]['s:Body'])
251
+ const newValue = await this._process(
252
+ keys.length === 1
253
+ ? value[key]['s:Body'][keys[0]]
254
+ : value[key]['s:Body']
255
+ )
256
+ for (const key in newValue) {
257
+ obj[key] = newValue[key]
258
+ }
259
+ continue
260
+ }
261
+ }
262
+ if (key === 'moreInfo') {
263
+ const a = moreInfoRegexp.exec(value[key])
264
+ if (a != null) {
265
+ obj.battery = {
266
+ rawPercentage: parseInt(a[1]),
267
+ percentage: parseInt(a[2]),
268
+ charging: a[3] === 'CHARGING',
269
+ temperature: parseInt(a[4])
270
+ }
271
+ }
272
+ }
273
+ // Handle keys (like volume) with multiple channels.
274
+ if (channelKeys.includes(key) && typeof value[key] === 'object') {
275
+ obj[key] = {}
276
+ const newValue = [].concat(await this._process(value[key]))
277
+ for (const elt of newValue) {
278
+ obj[key][this._processKey(elt.channel)] = elt.val
279
+ }
280
+ continue
281
+ }
282
+ obj[key] = await this._process(value[key])
283
+ }
284
+ return obj
285
+ }
286
+
287
+ return value
288
+ }
289
+ }
290
+
291
+ export { ZpXmlParser }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "hb-zp-tools",
3
+ "description": "Homebridge ZP Tools",
4
+ "author": "Erik Baauw",
5
+ "license": "Apache-2.0",
6
+ "version": "0.0.1",
7
+ "type": "module",
8
+ "exports": {
9
+ "./*": "./lib/*.js"
10
+ },
11
+ "keywords": [
12
+ "sonos",
13
+ "zoneplayer",
14
+ "sonos-zp",
15
+ "sonos-zoneplayer"
16
+ ],
17
+ "bin": {
18
+ "zp": "cli/zp.js"
19
+ },
20
+ "engines": {
21
+ "node": "^20||^18"
22
+ },
23
+ "dependencies": {
24
+ "hb-lib-tools": "~2.0.2",
25
+ "he": "^1.2.0",
26
+ "xml2js": "~0.6.2"
27
+ },
28
+ "scripts": {
29
+ "prepare": "standard && rm -rf out && jsdoc -c jsdoc.json",
30
+ "test": "standard && echo \"Error: no test specified\" && exit 1"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/ebaauw/hb-zp-tools.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/ebaauw/hb-zp-tools/issues"
38
+ },
39
+ "homepage": "https://github.com/ebaauw/hb-zp-tools#readme",
40
+ "funding": [
41
+ {
42
+ "type": "github",
43
+ "url": "https://github.com/sponsors/ebaauw"
44
+ },
45
+ {
46
+ "type": "paypal",
47
+ "url": "https://www.paypal.me/ebaauw/EUR"
48
+ }
49
+ ]
50
+ }