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.
- package/LICENSE +201 -0
- package/README.md +29 -0
- package/cli/zp.js +15 -0
- package/index.js +9 -0
- package/jsdoc.json +21 -0
- package/lib/ZpClient.js +1736 -0
- package/lib/ZpListener.js +176 -0
- package/lib/ZpTool.js +1262 -0
- package/lib/ZpXmlParser.js +291 -0
- package/package.json +50 -0
|
@@ -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
|
+
}
|