homebridge-roborock-matter 2.1.0
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/.gitattributes +13 -0
- package/CHANGELOG.md +469 -0
- package/LICENSE +23 -0
- package/README.md +45 -0
- package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
- package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
- package/config.schema.json +118 -0
- package/dist/crypto.js +65 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/live_message.js +25 -0
- package/dist/live_message.js.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/matter_vacuum_accessory.js +1877 -0
- package/dist/matter_vacuum_accessory.js.map +1 -0
- package/dist/platform.js +441 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.js +8 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/index.js +741 -0
- package/dist/ui/index.js.map +1 -0
- package/docs/matter-rvc-updating-homebridge-report.md +120 -0
- package/homebridge-roborock-vacuum.code-workspace +7 -0
- package/homebridge-ui/public/index.html +257 -0
- package/homebridge-ui/public/index.js +1276 -0
- package/homebridge-ui/public/styles.css +611 -0
- package/homebridge-ui/server.js +3 -0
- package/package.json +91 -0
- package/roborockLib/i18n/de/translations.json +188 -0
- package/roborockLib/i18n/en/translations.json +208 -0
- package/roborockLib/i18n/es/translations.json +188 -0
- package/roborockLib/i18n/fr/translations.json +188 -0
- package/roborockLib/i18n/it/translations.json +188 -0
- package/roborockLib/i18n/nl/translations.json +188 -0
- package/roborockLib/i18n/pl/translations.json +188 -0
- package/roborockLib/i18n/pt/translations.json +188 -0
- package/roborockLib/i18n/ru/translations.json +188 -0
- package/roborockLib/i18n/uk/translations.json +188 -0
- package/roborockLib/i18n/zh-cn/translations.json +188 -0
- package/roborockLib/lib/RRMapParser.js +459 -0
- package/roborockLib/lib/b01Q7Adapter.js +468 -0
- package/roborockLib/lib/deviceFeatures.js +1349 -0
- package/roborockLib/lib/localConnector.js +629 -0
- package/roborockLib/lib/map/map.html +137 -0
- package/roborockLib/lib/map/zones.js +851 -0
- package/roborockLib/lib/message.js +402 -0
- package/roborockLib/lib/messageQueueHandler.js +411 -0
- package/roborockLib/lib/roborockAuth.js +178 -0
- package/roborockLib/lib/roborockCrypto.js +55 -0
- package/roborockLib/lib/roborockPackageHelper.js +142 -0
- package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
- package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
- package/roborockLib/lib/vacuum.js +992 -0
- package/roborockLib/roborockAPI.js +3635 -0
- package/tsconfig.roborockLib.json +17 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const CRC32 = require("crc-32");
|
|
5
|
+
const Parser = require("binary-parser").Parser;
|
|
6
|
+
const roborockCrypto = require("./roborockCrypto");
|
|
7
|
+
|
|
8
|
+
let seq = 1;
|
|
9
|
+
let random = 4711; // Should be initialized with a number 0 - 1999?
|
|
10
|
+
|
|
11
|
+
// This value is stored hardcoded in librrcodec.so, encrypted by the value of "com.roborock.iotsdk.appsecret" from AndroidManifest.xml.
|
|
12
|
+
const salt = "TXdfu$jyZ#TZHsg4";
|
|
13
|
+
const b01Salt = "5wwh9ikChRjASpMU8cxg7o1d2E";
|
|
14
|
+
|
|
15
|
+
const messageParser = new Parser()
|
|
16
|
+
.endianess("big")
|
|
17
|
+
.string("version", {
|
|
18
|
+
length: 3,
|
|
19
|
+
})
|
|
20
|
+
.uint32("seq")
|
|
21
|
+
.uint32("random")
|
|
22
|
+
.uint32("timestamp")
|
|
23
|
+
.uint16("protocol")
|
|
24
|
+
.uint16("payloadLen")
|
|
25
|
+
.buffer("payload", {
|
|
26
|
+
length: "payloadLen",
|
|
27
|
+
})
|
|
28
|
+
.uint32("crc32");
|
|
29
|
+
|
|
30
|
+
class message {
|
|
31
|
+
constructor(adapter) {
|
|
32
|
+
this.adapter = adapter;
|
|
33
|
+
this.missingLocalKeyWarnings = new Set();
|
|
34
|
+
|
|
35
|
+
this.keys = roborockCrypto.generateRsaKeyPair();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async buildPayload(
|
|
39
|
+
duid,
|
|
40
|
+
protocol,
|
|
41
|
+
messageID,
|
|
42
|
+
method,
|
|
43
|
+
params,
|
|
44
|
+
secure = false,
|
|
45
|
+
photo = false
|
|
46
|
+
) {
|
|
47
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
48
|
+
const endpoint = this.adapter.rr_mqtt_connector.getEndpoint();
|
|
49
|
+
const version = await this.adapter.getRobotVersion(duid);
|
|
50
|
+
// this.adapter.log.debug("sendRequest started with: " + requestId);
|
|
51
|
+
|
|
52
|
+
if (photo) {
|
|
53
|
+
params.endpoint = endpoint;
|
|
54
|
+
params.security = {
|
|
55
|
+
cipher_suite: 0,
|
|
56
|
+
pub_key: this.keys.public,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const inner = {
|
|
61
|
+
id: messageID,
|
|
62
|
+
method: method,
|
|
63
|
+
params: params,
|
|
64
|
+
};
|
|
65
|
+
if (secure) {
|
|
66
|
+
if (!photo) {
|
|
67
|
+
inner.security = {
|
|
68
|
+
endpoint: endpoint,
|
|
69
|
+
nonce: this.adapter.nonce.toString("hex").toUpperCase(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let payload;
|
|
75
|
+
if (version == "B01" || version == "\x81S\x19") {
|
|
76
|
+
// Q7/B01 wire format (verified against the python-roborock reference
|
|
77
|
+
// fixtures): a single object on dps 10000 carrying method/msgId/params
|
|
78
|
+
// only. No top-level "t", no numeric "id", no security block; method
|
|
79
|
+
// translation happens in b01Q7Adapter before this point.
|
|
80
|
+
payload = JSON.stringify({
|
|
81
|
+
dps: {
|
|
82
|
+
10000: {
|
|
83
|
+
method: method,
|
|
84
|
+
msgId: String(messageID),
|
|
85
|
+
params: params !== undefined && params !== null ? params : [],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
payload = JSON.stringify({
|
|
91
|
+
dps: {
|
|
92
|
+
[protocol]: JSON.stringify(inner),
|
|
93
|
+
},
|
|
94
|
+
t: timestamp,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return payload;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async buildRoborockMessage(duid, protocol, timestamp, payload) {
|
|
102
|
+
const version = await this.adapter.getRobotVersion(duid);
|
|
103
|
+
|
|
104
|
+
let encrypted;
|
|
105
|
+
|
|
106
|
+
const currentSeq = seq & 0xffffffff;
|
|
107
|
+
const currentRandom = random & 0xffffffff;
|
|
108
|
+
|
|
109
|
+
if (protocol == 1) {
|
|
110
|
+
const msg = Buffer.alloc(23);
|
|
111
|
+
msg.write(version);
|
|
112
|
+
msg.writeUint32BE(currentSeq, 3);
|
|
113
|
+
msg.writeUint32BE(currentRandom, 7);
|
|
114
|
+
msg.writeUint32BE(timestamp, 11);
|
|
115
|
+
msg.writeUint16BE(protocol, 15);
|
|
116
|
+
msg.writeUint16BE(0, 17);
|
|
117
|
+
const crc32 = CRC32.buf(msg.subarray(0, msg.length - 4)) >>> 0;
|
|
118
|
+
msg.writeUint32BE(crc32, msg.length - 4);
|
|
119
|
+
seq++;
|
|
120
|
+
random++;
|
|
121
|
+
|
|
122
|
+
return msg;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (version == "1.0") {
|
|
126
|
+
const localKey =
|
|
127
|
+
this.adapter.localKeys instanceof Map
|
|
128
|
+
? this.adapter.localKeys.get(duid)
|
|
129
|
+
: null;
|
|
130
|
+
const aesKey = roborockCrypto.md5bin(
|
|
131
|
+
roborockCrypto.encodeTimestamp(timestamp) + localKey + salt
|
|
132
|
+
);
|
|
133
|
+
const cipher = crypto.createCipheriv("aes-128-ecb", aesKey, null);
|
|
134
|
+
encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
135
|
+
} else if (version == "A01") {
|
|
136
|
+
const localKey = this.adapter.localKeys.get(duid);
|
|
137
|
+
|
|
138
|
+
const iv = roborockCrypto
|
|
139
|
+
.md5hex(
|
|
140
|
+
currentRandom.toString(16).padStart(8, "0") +
|
|
141
|
+
"726f626f726f636b2d67a6d6da"
|
|
142
|
+
)
|
|
143
|
+
.substring(8, 24); // 726f626f726f636b2d67a6d6da can be found in librrcodec.so of version 4.0 of the roborock app
|
|
144
|
+
const cipher = crypto.createCipheriv("aes-128-cbc", localKey, iv);
|
|
145
|
+
encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
146
|
+
} else if (version == "L01") {
|
|
147
|
+
const localKey = this.adapter.localKeys.get(duid);
|
|
148
|
+
const { connectNonce, ackNonce } = this._getL01Nonces(duid);
|
|
149
|
+
const aesKey = crypto
|
|
150
|
+
.createHash("sha256")
|
|
151
|
+
.update(roborockCrypto.encodeTimestamp(timestamp) + localKey + salt)
|
|
152
|
+
.digest();
|
|
153
|
+
const iv = this._deriveL01Iv(currentSeq, currentRandom, timestamp);
|
|
154
|
+
const aad = this._deriveL01Aad(
|
|
155
|
+
currentSeq,
|
|
156
|
+
connectNonce,
|
|
157
|
+
ackNonce,
|
|
158
|
+
currentRandom,
|
|
159
|
+
timestamp
|
|
160
|
+
);
|
|
161
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
|
|
162
|
+
cipher.setAAD(aad);
|
|
163
|
+
const encryptedPayload = Buffer.concat([
|
|
164
|
+
cipher.update(payload),
|
|
165
|
+
cipher.final(),
|
|
166
|
+
]);
|
|
167
|
+
encrypted = Buffer.concat([encryptedPayload, cipher.getAuthTag()]);
|
|
168
|
+
} else if (version == "B01" || version == "\x81S\x19") {
|
|
169
|
+
const localKey = this.adapter.localKeys.get(duid);
|
|
170
|
+
const iv = this._deriveB01Iv(currentRandom);
|
|
171
|
+
const cipher = crypto.createCipheriv("aes-128-cbc", localKey, iv);
|
|
172
|
+
encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (encrypted) {
|
|
176
|
+
const msg = Buffer.alloc(23 + encrypted.length);
|
|
177
|
+
msg.write(version);
|
|
178
|
+
msg.writeUint32BE(currentSeq, 3);
|
|
179
|
+
msg.writeUint32BE(currentRandom, 7);
|
|
180
|
+
msg.writeUint32BE(timestamp, 11);
|
|
181
|
+
msg.writeUint16BE(protocol, 15);
|
|
182
|
+
msg.writeUint16BE(encrypted.length, 17);
|
|
183
|
+
encrypted.copy(msg, 19);
|
|
184
|
+
const crc32 = CRC32.buf(msg.subarray(0, msg.length - 4)) >>> 0;
|
|
185
|
+
msg.writeUint32BE(crc32, msg.length - 4);
|
|
186
|
+
seq++;
|
|
187
|
+
random++;
|
|
188
|
+
|
|
189
|
+
return msg;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_decodeMsg(message, duid) {
|
|
196
|
+
try {
|
|
197
|
+
// Do some checks before trying to decode the message.
|
|
198
|
+
const version = message.toString("latin1", 0, 3);
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
version !== "1.0" &&
|
|
202
|
+
version !== "A01" &&
|
|
203
|
+
version !== "L01" &&
|
|
204
|
+
version !== "B01" &&
|
|
205
|
+
version !== "\x81S\x19"
|
|
206
|
+
) {
|
|
207
|
+
throw new Error(`Unknown protocol version ${version}`);
|
|
208
|
+
}
|
|
209
|
+
const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
|
|
210
|
+
const expectedCrc32 = message.readUint32BE(message.length - 4);
|
|
211
|
+
if (crc32 != expectedCrc32) {
|
|
212
|
+
throw new Error(`Wrong CRC32 ${crc32}, expected ${expectedCrc32}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const data = this.getParsedData(message);
|
|
216
|
+
delete data.payloadLen;
|
|
217
|
+
|
|
218
|
+
const localKey = this.adapter.localKeys.get(duid);
|
|
219
|
+
if (!localKey) {
|
|
220
|
+
if (!this.missingLocalKeyWarnings.has(duid)) {
|
|
221
|
+
this.missingLocalKeyWarnings.add(duid);
|
|
222
|
+
this.adapter.log.warn(
|
|
223
|
+
`Skipping MQTT message for device ${duid}: no localKey available.`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const error = new Error(`No localKey found for device ${duid}`);
|
|
228
|
+
error.code = "ERR_MISSING_LOCAL_KEY";
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (version == "1.0") {
|
|
233
|
+
const aesKey = roborockCrypto.md5bin(
|
|
234
|
+
roborockCrypto.encodeTimestamp(data.timestamp) + localKey + salt
|
|
235
|
+
);
|
|
236
|
+
const decipher = crypto.createDecipheriv("aes-128-ecb", aesKey, null);
|
|
237
|
+
data.payload = Buffer.concat([
|
|
238
|
+
decipher.update(data.payload),
|
|
239
|
+
decipher.final(),
|
|
240
|
+
]);
|
|
241
|
+
} else if (version == "A01") {
|
|
242
|
+
const iv = roborockCrypto
|
|
243
|
+
.md5hex(
|
|
244
|
+
data.random.toString(16).padStart(8, "0") +
|
|
245
|
+
"726f626f726f636b2d67a6d6da"
|
|
246
|
+
)
|
|
247
|
+
.substring(8, 24);
|
|
248
|
+
const decipher = crypto.createDecipheriv("aes-128-cbc", localKey, iv);
|
|
249
|
+
data.payload = Buffer.concat([
|
|
250
|
+
decipher.update(data.payload),
|
|
251
|
+
decipher.final(),
|
|
252
|
+
]);
|
|
253
|
+
} else if (version == "L01") {
|
|
254
|
+
const { connectNonce, ackNonce } = this._getL01Nonces(duid);
|
|
255
|
+
const aesKey = crypto
|
|
256
|
+
.createHash("sha256")
|
|
257
|
+
.update(
|
|
258
|
+
roborockCrypto.encodeTimestamp(data.timestamp) + localKey + salt
|
|
259
|
+
)
|
|
260
|
+
.digest();
|
|
261
|
+
const iv = this._deriveL01Iv(data.seq, data.random, data.timestamp);
|
|
262
|
+
const aad = this._deriveL01Aad(
|
|
263
|
+
data.seq,
|
|
264
|
+
connectNonce,
|
|
265
|
+
ackNonce,
|
|
266
|
+
data.random,
|
|
267
|
+
data.timestamp
|
|
268
|
+
);
|
|
269
|
+
const authTag = data.payload.subarray(data.payload.length - 16);
|
|
270
|
+
const encryptedPayload = data.payload.subarray(
|
|
271
|
+
0,
|
|
272
|
+
data.payload.length - 16
|
|
273
|
+
);
|
|
274
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, iv);
|
|
275
|
+
decipher.setAAD(aad);
|
|
276
|
+
decipher.setAuthTag(authTag);
|
|
277
|
+
data.payload = Buffer.concat([
|
|
278
|
+
decipher.update(encryptedPayload),
|
|
279
|
+
decipher.final(),
|
|
280
|
+
]);
|
|
281
|
+
} else if (version == "B01" || version == "\x81S\x19") {
|
|
282
|
+
const iv = this._deriveB01Iv(data.random);
|
|
283
|
+
const decipher = crypto.createDecipheriv("aes-128-cbc", localKey, iv);
|
|
284
|
+
data.payload = Buffer.concat([
|
|
285
|
+
decipher.update(data.payload),
|
|
286
|
+
decipher.final(),
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return data;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error && error.code === "ERR_MISSING_LOCAL_KEY") {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const preview = message
|
|
297
|
+
.subarray(0, Math.min(message.length, 12))
|
|
298
|
+
.toString("hex");
|
|
299
|
+
const reason = error && error.message ? error.message : String(error);
|
|
300
|
+
this.adapter.log.error(
|
|
301
|
+
`failed to _decodeMsg for ${duid}: ${reason} (len=${message.length}, preview=${preview})`
|
|
302
|
+
);
|
|
303
|
+
// this.adapter.catchError(error, "_decodeMessage", "none");
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
getParsedData(data) {
|
|
309
|
+
return messageParser.parse(data);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
resolve102Message(messageID, message, secure = false) {
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
if (message?.code) {
|
|
315
|
+
reject(
|
|
316
|
+
new Error(
|
|
317
|
+
`There was an error processing the request with id ${messageID} error: ${JSON.stringify(message)}`
|
|
318
|
+
)
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
if (secure) {
|
|
322
|
+
if (message[0] !== "ok") {
|
|
323
|
+
reject(message);
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
resolve(message);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
resolve301Message(messageID, message) {
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
this.adapter.clearTimeout(
|
|
335
|
+
this.adapter.messageQueue.get(messageID)?.timeout301
|
|
336
|
+
);
|
|
337
|
+
(this.adapter.messageQueue.get(messageID) || {}).timeout301 = null;
|
|
338
|
+
this.adapter.checkAndClearRequest(messageID);
|
|
339
|
+
|
|
340
|
+
if (message?.code) {
|
|
341
|
+
reject(
|
|
342
|
+
new Error(
|
|
343
|
+
`There was an error processing the request with id ${messageID} error: ${JSON.stringify(message)}`
|
|
344
|
+
)
|
|
345
|
+
);
|
|
346
|
+
} else {
|
|
347
|
+
resolve(message);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_deriveB01Iv(randomSeed) {
|
|
353
|
+
const randomBuffer = Buffer.alloc(4);
|
|
354
|
+
randomBuffer.writeUInt32BE(randomSeed >>> 0, 0);
|
|
355
|
+
|
|
356
|
+
const randomHex = randomBuffer.toString("hex").toLowerCase();
|
|
357
|
+
const hash = roborockCrypto.md5hex(randomHex + b01Salt);
|
|
358
|
+
const iv = hash.substring(9, 25);
|
|
359
|
+
|
|
360
|
+
return Buffer.from(iv, "utf8");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
_deriveL01Iv(sequence, randomSeed, timestamp) {
|
|
364
|
+
const digestInput = Buffer.alloc(12);
|
|
365
|
+
digestInput.writeUInt32BE(sequence >>> 0, 0);
|
|
366
|
+
digestInput.writeUInt32BE(randomSeed >>> 0, 4);
|
|
367
|
+
digestInput.writeUInt32BE(timestamp >>> 0, 8);
|
|
368
|
+
return crypto
|
|
369
|
+
.createHash("sha256")
|
|
370
|
+
.update(digestInput)
|
|
371
|
+
.digest()
|
|
372
|
+
.subarray(0, 12);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
_deriveL01Aad(sequence, connectNonce, ackNonce, randomSeed, timestamp) {
|
|
376
|
+
const aad = Buffer.alloc(20);
|
|
377
|
+
aad.writeUInt32BE(sequence >>> 0, 0);
|
|
378
|
+
aad.writeUInt32BE(connectNonce >>> 0, 4);
|
|
379
|
+
aad.writeUInt32BE(ackNonce >>> 0, 8);
|
|
380
|
+
aad.writeUInt32BE(randomSeed >>> 0, 12);
|
|
381
|
+
aad.writeUInt32BE(timestamp >>> 0, 16);
|
|
382
|
+
return aad;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
_getL01Nonces(duid) {
|
|
386
|
+
const nonces =
|
|
387
|
+
this.adapter.localL01Nonces && this.adapter.localL01Nonces.get(duid);
|
|
388
|
+
if (
|
|
389
|
+
!nonces ||
|
|
390
|
+
typeof nonces.connectNonce !== "number" ||
|
|
391
|
+
typeof nonces.ackNonce !== "number"
|
|
392
|
+
) {
|
|
393
|
+
throw new Error(`Missing L01 nonces for device ${duid}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return nonces;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
module.exports = {
|
|
401
|
+
message,
|
|
402
|
+
};
|