matterbridge-roborock-vacuum-plugin 1.1.1-rc01 → 1.1.1-rc02

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.
Files changed (29) hide show
  1. package/dist/roborockCommunication/broadcast/abstractClient.js +3 -3
  2. package/dist/roborockCommunication/broadcast/client/LocalNetworkClient.js +1 -0
  3. package/dist/roborockCommunication/broadcast/client/LocalNetworkUDPClient.js +1 -1
  4. package/dist/roborockCommunication/broadcast/clientRouter.js +5 -2
  5. package/dist/roborockCommunication/broadcast/model/contentMessage.js +1 -0
  6. package/dist/roborockCommunication/broadcast/model/headerMessage.js +1 -0
  7. package/dist/roborockCommunication/broadcast/model/messageContext.js +18 -7
  8. package/dist/roborockCommunication/broadcast/model/requestMessage.js +5 -0
  9. package/dist/roborockCommunication/helper/messageDeserializer.js +31 -18
  10. package/dist/roborockCommunication/helper/messageSerializer.js +17 -11
  11. package/dist/roborockService.js +6 -1
  12. package/dist/rvc.js +2 -1
  13. package/matterbridge-roborock-vacuum-plugin.config.json +1 -1
  14. package/matterbridge-roborock-vacuum-plugin.schema.json +1 -1
  15. package/package.json +1 -2
  16. package/src/roborockCommunication/broadcast/abstractClient.ts +3 -3
  17. package/src/roborockCommunication/broadcast/client/LocalNetworkClient.ts +4 -2
  18. package/src/roborockCommunication/broadcast/client/LocalNetworkUDPClient.ts +3 -3
  19. package/src/roborockCommunication/broadcast/clientRouter.ts +6 -2
  20. package/src/roborockCommunication/broadcast/model/contentMessage.ts +5 -0
  21. package/src/roborockCommunication/broadcast/model/headerMessage.ts +7 -0
  22. package/src/roborockCommunication/broadcast/model/messageContext.ts +24 -11
  23. package/src/roborockCommunication/broadcast/model/requestMessage.ts +12 -5
  24. package/src/roborockCommunication/helper/messageDeserializer.ts +42 -31
  25. package/src/roborockCommunication/helper/messageSerializer.ts +19 -14
  26. package/src/roborockService.ts +8 -1
  27. package/src/rvc.ts +2 -2
  28. package/src/tests/roborockCommunication/broadcast/client/LocalNetworkClient.test.ts +0 -19
  29. package/src/tests/roborockCommunication/broadcast/client/MQTTClient.test.ts +0 -32
@@ -11,9 +11,9 @@ export class AbstractClient {
11
11
  messageListeners = new ChainedMessageListener();
12
12
  serializer;
13
13
  deserializer;
14
+ context;
14
15
  connected = false;
15
16
  logger;
16
- context;
17
17
  syncMessageListener;
18
18
  constructor(logger, context) {
19
19
  this.context = context;
@@ -40,8 +40,8 @@ export class AbstractClient {
40
40
  return undefined;
41
41
  });
42
42
  }
43
- registerDevice(duid, localKey, pv) {
44
- this.context.registerDevice(duid, localKey, pv);
43
+ registerDevice(duid, localKey, pv, nonce) {
44
+ this.context.registerDevice(duid, localKey, pv, nonce);
45
45
  }
46
46
  registerConnectionListener(listener) {
47
47
  this.connectionListeners.register(listener);
@@ -148,6 +148,7 @@ export class LocalNetworkClient extends AbstractClient {
148
148
  const request = new RequestMessage({
149
149
  protocol: Protocol.hello_request,
150
150
  messageId: this.messageIdSeq.next(),
151
+ nonce: this.context.nonce,
151
152
  });
152
153
  await this.send(this.duid, request);
153
154
  }
@@ -1,5 +1,5 @@
1
1
  import * as dgram from 'node:dgram';
2
- import { Parser } from 'binary-parser';
2
+ import { Parser } from 'binary-parser/dist/binary_parser.js';
3
3
  import crypto from 'node:crypto';
4
4
  import CRC32 from 'crc-32';
5
5
  import { AbstractClient } from '../abstractClient.js';
@@ -17,8 +17,11 @@ export class ClientRouter {
17
17
  this.mqttClient.registerConnectionListener(this.connectionListeners);
18
18
  this.mqttClient.registerMessageListener(this.messageListeners);
19
19
  }
20
- registerDevice(duid, localKey, pv) {
21
- this.context.registerDevice(duid, localKey, pv);
20
+ registerDevice(duid, localKey, pv, nonce) {
21
+ this.context.registerDevice(duid, localKey, pv, nonce);
22
+ }
23
+ updateNonce(duid, nonce) {
24
+ this.context.updateNonce(duid, nonce);
22
25
  }
23
26
  registerClient(duid, ip) {
24
27
  const localClient = new LocalNetworkClient(this.logger, this.context, duid, ip);
@@ -1,18 +1,26 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { randomBytes, randomInt } from 'node:crypto';
2
2
  import * as CryptoUtils from '../../helper/cryptoHelper.js';
3
3
  export class MessageContext {
4
4
  endpoint;
5
- nonce;
6
5
  devices = new Map();
6
+ nonce;
7
+ serializeNonce;
7
8
  constructor(userdata) {
8
9
  this.endpoint = CryptoUtils.md5bin(userdata.rriot.k).subarray(8, 14).toString('base64');
9
- this.nonce = randomBytes(16);
10
+ this.nonce = randomInt(1000, 1000000);
11
+ this.serializeNonce = randomBytes(16);
10
12
  }
11
- registerDevice(duid, localKey, pv) {
12
- this.devices.set(duid, { localKey: localKey, protocolVersion: pv });
13
+ registerDevice(duid, localKey, pv, nonce) {
14
+ this.devices.set(duid, { localKey: localKey, protocolVersion: pv, nonce });
13
15
  }
14
- getNonceAsHex() {
15
- return this.nonce.toString('hex').toUpperCase();
16
+ updateNonce(duid, nonce) {
17
+ const device = this.devices.get(duid);
18
+ if (device) {
19
+ device.nonce = nonce;
20
+ }
21
+ }
22
+ getSerializeNonceAsHex() {
23
+ return this.serializeNonce.toString('hex').toUpperCase();
16
24
  }
17
25
  getLocalKey(duid) {
18
26
  return this.devices.get(duid)?.localKey;
@@ -20,6 +28,9 @@ export class MessageContext {
20
28
  getProtocolVersion(duid) {
21
29
  return this.devices.get(duid)?.protocolVersion;
22
30
  }
31
+ getDeviceNonce(duid) {
32
+ return this.devices.get(duid)?.nonce;
33
+ }
23
34
  getEndpoint() {
24
35
  return this.endpoint;
25
36
  }
@@ -6,12 +6,16 @@ export class RequestMessage {
6
6
  method;
7
7
  params;
8
8
  secure;
9
+ timestamp;
10
+ nonce;
9
11
  constructor(args) {
10
12
  this.messageId = args.messageId ?? randomInt(10000, 32767);
11
13
  this.protocol = args.protocol ?? Protocol.rpc_request;
12
14
  this.method = args.method;
13
15
  this.params = args.params;
14
16
  this.secure = args.secure ?? false;
17
+ this.nonce = args.nonce ?? randomInt(10000, 32767);
18
+ this.timestamp = args.timestamp ?? Math.floor(Date.now() / 1000);
15
19
  }
16
20
  toMqttRequest() {
17
21
  return this;
@@ -24,6 +28,7 @@ export class RequestMessage {
24
28
  method: this.method,
25
29
  params: this.params,
26
30
  secure: this.secure,
31
+ timestamp: this.timestamp,
27
32
  });
28
33
  }
29
34
  else {
@@ -1,26 +1,29 @@
1
1
  import crypto from 'node:crypto';
2
2
  import CRC32 from 'crc-32';
3
- import { Parser } from 'binary-parser';
4
3
  import { ResponseMessage } from '../broadcast/model/responseMessage.js';
5
4
  import * as CryptoUtils from './cryptoHelper.js';
6
5
  import { Protocol } from '../broadcast/model/protocol.js';
6
+ import { Parser } from 'binary-parser/dist/binary_parser.js';
7
7
  export class MessageDeserializer {
8
8
  context;
9
- messageParser;
9
+ headerMessageParser;
10
+ contentMessageParser;
10
11
  logger;
11
12
  supportedVersions = ['1.0', 'A01', 'B01'];
12
13
  constructor(context, logger) {
13
14
  this.context = context;
14
15
  this.logger = logger;
15
- this.messageParser = new Parser()
16
+ this.headerMessageParser = new Parser()
16
17
  .endianness('big')
17
18
  .string('version', {
18
19
  length: 3,
19
20
  })
20
21
  .uint32('seq')
21
- .uint32('random')
22
+ .uint32('nonce')
22
23
  .uint32('timestamp')
23
- .uint16('protocol')
24
+ .uint16('protocol');
25
+ this.contentMessageParser = new Parser()
26
+ .endianness('big')
24
27
  .uint16('payloadLen')
25
28
  .buffer('payload', {
26
29
  length: 'payloadLen',
@@ -28,10 +31,21 @@ export class MessageDeserializer {
28
31
  .uint32('crc32');
29
32
  }
30
33
  deserialize(duid, message) {
31
- const version = message.toString('latin1', 0, 3);
32
- if (!this.supportedVersions.includes(version)) {
33
- throw new Error('unknown protocol version ' + version);
34
+ const header = this.headerMessageParser.parse(message);
35
+ if (!this.supportedVersions.includes(header.version)) {
36
+ throw new Error('unknown protocol version ' + header.version);
37
+ }
38
+ if (header.protocol === Protocol.hello_response || header.protocol === Protocol.ping_response) {
39
+ const dpsValue = {
40
+ id: header.seq,
41
+ result: {
42
+ version: header.version,
43
+ nonce: header.nonce,
44
+ },
45
+ };
46
+ return new ResponseMessage(duid, { [header.protocol.toString()]: dpsValue });
34
47
  }
48
+ const data = this.contentMessageParser.parse(message.subarray(this.headerMessageParser.sizeOf()));
35
49
  const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
36
50
  const expectedCrc32 = message.readUInt32BE(message.length - 4);
37
51
  if (crc32 != expectedCrc32) {
@@ -42,30 +56,29 @@ export class MessageDeserializer {
42
56
  this.logger.notice(`Unable to retrieve local key for ${duid}, it should be from other vacuums`);
43
57
  return new ResponseMessage(duid, { dps: { id: 0, result: null } });
44
58
  }
45
- const data = this.messageParser.parse(message);
46
- if (version == '1.0') {
47
- const aesKey = CryptoUtils.md5bin(CryptoUtils.encodeTimestamp(data.timestamp) + localKey + CryptoUtils.SALT);
59
+ if (header.version == '1.0') {
60
+ const aesKey = CryptoUtils.md5bin(CryptoUtils.encodeTimestamp(header.timestamp) + localKey + CryptoUtils.SALT);
48
61
  const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null);
49
62
  data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
50
63
  }
51
- else if (version == 'A01') {
52
- const iv = CryptoUtils.md5hex(data.random.toString(16).padStart(8, '0') + '726f626f726f636b2d67a6d6da').substring(8, 24);
64
+ else if (header.version == 'A01') {
65
+ const iv = CryptoUtils.md5hex(header.nonce.toString(16).padStart(8, '0') + '726f626f726f636b2d67a6d6da').substring(8, 24);
53
66
  const decipher = crypto.createDecipheriv('aes-128-cbc', localKey, iv);
54
67
  data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
55
68
  }
56
- else if (version == 'B01') {
57
- const iv = CryptoUtils.md5hex(data.random.toString(16).padStart(8, '0') + '5wwh9ikChRjASpMU8cxg7o1d2E').substring(9, 25);
69
+ else if (header.version == 'B01') {
70
+ const iv = CryptoUtils.md5hex(header.nonce.toString(16).padStart(8, '0') + '5wwh9ikChRjASpMU8cxg7o1d2E').substring(9, 25);
58
71
  const decipher = crypto.createDecipheriv('aes-128-cbc', localKey, iv);
59
72
  data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
60
73
  }
61
- if (data.protocol == Protocol.map_response) {
74
+ if (header.protocol == Protocol.map_response) {
62
75
  return new ResponseMessage(duid, { dps: { id: 0, result: null } });
63
76
  }
64
- if (data.protocol == Protocol.rpc_response || data.protocol == Protocol.general_request) {
77
+ if (header.protocol == Protocol.rpc_response || header.protocol == Protocol.general_request) {
65
78
  return this.deserializeRpcResponse(duid, data);
66
79
  }
67
80
  else {
68
- this.logger.error('unknown protocol: ' + data.protocol);
81
+ this.logger.error('unknown protocol: ' + header.protocol);
69
82
  return new ResponseMessage(duid, { dps: { id: 0, result: null } });
70
83
  }
71
84
  }
@@ -1,11 +1,12 @@
1
1
  import * as CryptoUtils from './cryptoHelper.js';
2
2
  import crypto from 'node:crypto';
3
3
  import CRC32 from 'crc-32';
4
+ import { Protocol } from '../broadcast/model/protocol.js';
4
5
  export class MessageSerializer {
5
6
  context;
6
7
  logger;
7
- seq = 1;
8
- random = 4711;
8
+ sequence = 1;
9
+ supportedVersions = ['1.0', 'A01', 'B01'];
9
10
  constructor(context, logger) {
10
11
  this.context = context;
11
12
  this.logger = logger;
@@ -26,23 +27,28 @@ export class MessageSerializer {
26
27
  if (request.secure) {
27
28
  data.security = {
28
29
  endpoint: this.context.getEndpoint(),
29
- nonce: this.context.getNonceAsHex(),
30
+ nonce: this.context.getSerializeNonceAsHex(),
30
31
  };
31
32
  }
32
- const timestamp = Math.floor(Date.now() / 1000);
33
33
  return {
34
34
  dps: {
35
35
  [request.protocol]: JSON.stringify(data),
36
36
  },
37
- t: timestamp,
37
+ t: request.timestamp,
38
38
  };
39
39
  }
40
40
  buildBuffer(duid, messageId, request) {
41
41
  const version = this.context.getProtocolVersion(duid);
42
+ if (!version || !this.supportedVersions.includes(version)) {
43
+ throw new Error('unknown protocol version ' + version);
44
+ }
45
+ let encrypted;
46
+ if (request.protocol === Protocol.hello_response || request.protocol === Protocol.ping_response) {
47
+ encrypted = Buffer.alloc(0);
48
+ }
42
49
  const localKey = this.context.getLocalKey(duid);
43
50
  const payloadData = this.buildPayload(messageId, request);
44
51
  const payload = JSON.stringify(payloadData);
45
- let encrypted;
46
52
  if (version == '1.0') {
47
53
  const aesKey = CryptoUtils.md5bin(CryptoUtils.encodeTimestamp(payloadData.t) + localKey + CryptoUtils.SALT);
48
54
  const cipher = crypto.createCipheriv('aes-128-ecb', aesKey, null);
@@ -50,13 +56,13 @@ export class MessageSerializer {
50
56
  }
51
57
  else if (version == 'A01') {
52
58
  const encoder = new TextEncoder();
53
- const iv = CryptoUtils.md5hex(this.random.toString(16).padStart(8, '0') + '726f626f726f636b2d67a6d6da').substring(8, 24);
59
+ const iv = CryptoUtils.md5hex(request.nonce.toString(16).padStart(8, '0') + '726f626f726f636b2d67a6d6da').substring(8, 24);
54
60
  const cipher = crypto.createCipheriv('aes-128-cbc', encoder.encode(localKey), iv);
55
61
  encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
56
62
  }
57
63
  else if (version == 'B01') {
58
64
  const encoder = new TextEncoder();
59
- const iv = CryptoUtils.md5hex(this.random.toString(16).padStart(8, '0') + '5wwh9ikChRjASpMU8cxg7o1d2E').substring(9, 25);
65
+ const iv = CryptoUtils.md5hex(request.nonce.toString(16).padStart(8, '0') + '5wwh9ikChRjASpMU8cxg7o1d2E').substring(9, 25);
60
66
  const cipher = crypto.createCipheriv('aes-128-cbc', encoder.encode(localKey), iv);
61
67
  encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
62
68
  }
@@ -65,9 +71,9 @@ export class MessageSerializer {
65
71
  }
66
72
  const msg = Buffer.alloc(23 + encrypted.length);
67
73
  msg.write(version);
68
- msg.writeUint32BE(this.seq++ & 0xffffffff, 3);
69
- msg.writeUint32BE(this.random++ & 0xffffffff, 7);
70
- msg.writeUint32BE(payloadData.t, 11);
74
+ msg.writeUint32BE(this.sequence++ & 0xffffffff, 3);
75
+ msg.writeUint32BE(request.nonce & 0xffffffff, 7);
76
+ msg.writeUint32BE(request.timestamp, 11);
71
77
  msg.writeUint16BE(request.protocol, 15);
72
78
  msg.writeUint16BE(encrypted.length, 17);
73
79
  encrypted.copy(msg, 19);
@@ -374,7 +374,7 @@ export default class RoborockService {
374
374
  }
375
375
  const self = this;
376
376
  this.messageClient = this.clientManager.get(username, userdata);
377
- this.messageClient.registerDevice(device.duid, device.localKey, device.pv);
377
+ this.messageClient.registerDevice(device.duid, device.localKey, device.pv, undefined);
378
378
  this.messageClient.registerMessageListener({
379
379
  onMessage: (message) => {
380
380
  if (message instanceof ResponseMessage) {
@@ -385,6 +385,11 @@ export default class RoborockService {
385
385
  self.deviceNotify(NotifyMessageTypes.CloudMessage, message);
386
386
  }
387
387
  }
388
+ if (message instanceof ResponseMessage && message.contain(Protocol.hello_response)) {
389
+ const dps = message.dps[Protocol.hello_response];
390
+ const result = dps.result;
391
+ self.messageClient?.updateNonce(message.duid, result.nonce);
392
+ }
388
393
  },
389
394
  });
390
395
  this.messageClient.connect();
package/dist/rvc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { RoboticVacuumCleaner } from 'matterbridge/devices';
2
2
  import { getOperationalStates, getSupportedAreas, getSupportedCleanModes, getSupportedRunModes } from './initialData/index.js';
3
+ import { debugStringify } from 'matterbridge/logger';
3
4
  import { RvcOperationalState } from 'matterbridge/matter/clusters';
4
5
  export class RoborockVacuumCleaner extends RoboticVacuumCleaner {
5
6
  username;
@@ -25,7 +26,7 @@ export class RoborockVacuumCleaner extends RoboticVacuumCleaner {
25
26
  }
26
27
  configurateHandler(behaviorHandler) {
27
28
  this.addCommandHandler('identify', async ({ request, cluster, attributes, endpoint }) => {
28
- this.log.info(`Identify command received for endpoint ${endpoint}, cluster ${cluster}, attributes ${attributes}, request: ${JSON.stringify(request)}`);
29
+ this.log.info(`Identify command received for endpoint ${endpoint}, cluster ${cluster}, attributes ${debugStringify(attributes)}, request: ${JSON.stringify(request)}`);
29
30
  behaviorHandler.executeCommand('playSoundToLocate', request.identifyTime ?? 0);
30
31
  });
31
32
  this.addCommandHandler('selectAreas', async ({ request }) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "matterbridge-roborock-vacuum-plugin",
3
3
  "type": "DynamicPlatform",
4
- "version": "1.1.1-rc01",
4
+ "version": "1.1.1-rc02",
5
5
  "whiteList": [],
6
6
  "blackList": [],
7
7
  "useInterval": true,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "title": "Matterbridge Roborock Vacuum Plugin",
3
- "description": "matterbridge-roborock-vacuum-plugin v. 1.1.1-rc01 by https://github.com/RinDevJunior",
3
+ "description": "matterbridge-roborock-vacuum-plugin v. 1.1.1-rc02 by https://github.com/RinDevJunior",
4
4
  "type": "object",
5
5
  "required": ["username", "password"],
6
6
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "matterbridge-roborock-vacuum-plugin",
3
- "version": "1.1.1-rc01",
3
+ "version": "1.1.1-rc02",
4
4
  "description": "Matterbridge Roborock Vacuum Plugin",
5
5
  "author": "https://github.com/RinDevJunior",
6
6
  "license": "MIT",
@@ -35,7 +35,6 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@log4js-node/log4js-api": "^1.0.2",
38
- "@types/binary-parser": "^1.5.5",
39
38
  "@types/node-persist": "^3.1.8",
40
39
  "axios": "^1.9.0",
41
40
  "binary-parser": "^2.2.1",
@@ -20,13 +20,13 @@ export abstract class AbstractClient implements Client {
20
20
  protected readonly messageListeners = new ChainedMessageListener();
21
21
  protected readonly serializer: MessageSerializer;
22
22
  protected readonly deserializer: MessageDeserializer;
23
+ protected readonly context: MessageContext;
23
24
  protected connected = false;
24
25
  protected logger: AnsiLogger;
25
26
 
26
27
  protected abstract clientName: string;
27
28
  protected abstract shouldReconnect: boolean;
28
29
 
29
- private readonly context: MessageContext;
30
30
  private readonly syncMessageListener: SyncMessageListener;
31
31
 
32
32
  protected constructor(logger: AnsiLogger, context: MessageContext) {
@@ -62,8 +62,8 @@ export abstract class AbstractClient implements Client {
62
62
  });
63
63
  }
64
64
 
65
- public registerDevice(duid: string, localKey: string, pv: string): void {
66
- this.context.registerDevice(duid, localKey, pv);
65
+ public registerDevice(duid: string, localKey: string, pv: string, nonce: number | undefined): void {
66
+ this.context.registerDevice(duid, localKey, pv, nonce);
67
67
  }
68
68
 
69
69
  public registerConnectionListener(listener: AbstractConnectionListener): void {
@@ -17,8 +17,9 @@ export class LocalNetworkClient extends AbstractClient {
17
17
  private messageIdSeq: Sequence;
18
18
  private pingInterval?: NodeJS.Timeout;
19
19
  private keepConnectionAliveInterval: NodeJS.Timeout | undefined = undefined;
20
- duid: string;
21
- ip: string;
20
+
21
+ public duid: string;
22
+ public ip: string;
22
23
 
23
24
  constructor(logger: AnsiLogger, context: MessageContext, duid: string, ip: string) {
24
25
  super(logger, context);
@@ -184,6 +185,7 @@ export class LocalNetworkClient extends AbstractClient {
184
185
  const request = new RequestMessage({
185
186
  protocol: Protocol.hello_request,
186
187
  messageId: this.messageIdSeq.next(),
188
+ nonce: this.context.nonce,
187
189
  });
188
190
 
189
191
  await this.send(this.duid, request);
@@ -1,6 +1,6 @@
1
1
  import * as dgram from 'node:dgram';
2
2
  import { Socket } from 'node:dgram';
3
- import { Parser } from 'binary-parser';
3
+ import { Parser } from 'binary-parser/dist/binary_parser.js';
4
4
  import crypto from 'node:crypto';
5
5
  import CRC32 from 'crc-32';
6
6
  import { AnsiLogger } from 'matterbridge/logger';
@@ -16,8 +16,8 @@ export class LocalNetworkUDPClient extends AbstractClient {
16
16
  private readonly PORT = 58866;
17
17
  private server: Socket | undefined = undefined;
18
18
 
19
- private readonly V10Parser: Parser<any>;
20
- private readonly L01Parser: Parser<any>;
19
+ private readonly V10Parser: Parser;
20
+ private readonly L01Parser: Parser;
21
21
 
22
22
  constructor(logger: AnsiLogger, context: MessageContext) {
23
23
  super(logger, context);
@@ -28,8 +28,12 @@ export class ClientRouter implements Client {
28
28
  this.mqttClient.registerMessageListener(this.messageListeners);
29
29
  }
30
30
 
31
- public registerDevice(duid: string, localKey: string, pv: string): void {
32
- this.context.registerDevice(duid, localKey, pv);
31
+ public registerDevice(duid: string, localKey: string, pv: string, nonce: number | undefined): void {
32
+ this.context.registerDevice(duid, localKey, pv, nonce);
33
+ }
34
+
35
+ public updateNonce(duid: string, nonce: number): void {
36
+ this.context.updateNonce(duid, nonce);
33
37
  }
34
38
 
35
39
  public registerClient(duid: string, ip: string): Client {
@@ -0,0 +1,5 @@
1
+ export interface ContentMessage {
2
+ payloadLen?: number;
3
+ payload: Buffer<ArrayBufferLike>;
4
+ crc32: number;
5
+ }
@@ -0,0 +1,7 @@
1
+ export interface HeaderMessage {
2
+ version: string;
3
+ seq: number;
4
+ nonce: number;
5
+ timestamp: number;
6
+ protocol: number;
7
+ }
@@ -1,34 +1,47 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { randomBytes, randomInt } from 'node:crypto';
2
2
  import * as CryptoUtils from '../../helper/cryptoHelper.js';
3
3
  import { UserData } from '../../Zmodel/userData.js';
4
4
 
5
5
  export class MessageContext {
6
6
  private readonly endpoint: string;
7
- readonly nonce: Buffer;
8
- private readonly devices = new Map<string, { localKey: string; protocolVersion: string }>();
7
+ private readonly devices = new Map<string, { localKey: string; protocolVersion: string; nonce: number | undefined }>();
8
+ public readonly nonce: number;
9
+ public readonly serializeNonce: Buffer;
9
10
 
10
11
  constructor(userdata: UserData) {
11
12
  this.endpoint = CryptoUtils.md5bin(userdata.rriot.k).subarray(8, 14).toString('base64');
12
- this.nonce = randomBytes(16);
13
+ this.nonce = randomInt(1000, 1000000);
14
+ this.serializeNonce = randomBytes(16);
13
15
  }
14
16
 
15
- registerDevice(duid: string, localKey: string, pv: string) {
16
- this.devices.set(duid, { localKey: localKey, protocolVersion: pv });
17
+ public registerDevice(duid: string, localKey: string, pv: string, nonce: number | undefined) {
18
+ this.devices.set(duid, { localKey: localKey, protocolVersion: pv, nonce });
17
19
  }
18
20
 
19
- getNonceAsHex(): string {
20
- return this.nonce.toString('hex').toUpperCase();
21
+ public updateNonce(duid: string, nonce: number): void {
22
+ const device = this.devices.get(duid);
23
+ if (device) {
24
+ device.nonce = nonce;
25
+ }
21
26
  }
22
27
 
23
- getLocalKey(duid: string): string | undefined {
28
+ public getSerializeNonceAsHex(): string {
29
+ return this.serializeNonce.toString('hex').toUpperCase();
30
+ }
31
+
32
+ public getLocalKey(duid: string): string | undefined {
24
33
  return this.devices.get(duid)?.localKey;
25
34
  }
26
35
 
27
- getProtocolVersion(duid: string): string | undefined {
36
+ public getProtocolVersion(duid: string): string | undefined {
28
37
  return this.devices.get(duid)?.protocolVersion;
29
38
  }
30
39
 
31
- getEndpoint(): string {
40
+ public getDeviceNonce(duid: string): number | undefined {
41
+ return this.devices.get(duid)?.nonce;
42
+ }
43
+
44
+ public getEndpoint(): string {
32
45
  return this.endpoint;
33
46
  }
34
47
  }
@@ -7,14 +7,18 @@ export interface ProtocolRequest {
7
7
  method?: string | undefined;
8
8
  params?: unknown[] | Record<string, unknown> | undefined;
9
9
  secure?: boolean;
10
+ nonce?: number;
11
+ timestamp?: number;
10
12
  }
11
13
 
12
14
  export class RequestMessage {
13
- readonly messageId: number;
14
- readonly protocol: Protocol;
15
- readonly method: string | undefined;
16
- readonly params: unknown[] | Record<string, unknown> | undefined;
17
- readonly secure: boolean;
15
+ public readonly messageId: number;
16
+ public readonly protocol: Protocol;
17
+ public readonly method: string | undefined;
18
+ public readonly params: unknown[] | Record<string, unknown> | undefined;
19
+ public readonly secure: boolean;
20
+ public readonly timestamp: number;
21
+ public readonly nonce: number;
18
22
 
19
23
  constructor(args: ProtocolRequest) {
20
24
  this.messageId = args.messageId ?? randomInt(10000, 32767);
@@ -22,6 +26,8 @@ export class RequestMessage {
22
26
  this.method = args.method;
23
27
  this.params = args.params;
24
28
  this.secure = args.secure ?? false;
29
+ this.nonce = args.nonce ?? randomInt(10000, 32767);
30
+ this.timestamp = args.timestamp ?? Math.floor(Date.now() / 1000);
25
31
  }
26
32
 
27
33
  toMqttRequest() {
@@ -36,6 +42,7 @@ export class RequestMessage {
36
42
  method: this.method,
37
43
  params: this.params,
38
44
  secure: this.secure,
45
+ timestamp: this.timestamp,
39
46
  });
40
47
  } else {
41
48
  return this;
@@ -1,26 +1,19 @@
1
1
  import crypto from 'node:crypto';
2
2
  import CRC32 from 'crc-32';
3
- import { Parser } from 'binary-parser';
4
3
  import { ResponseMessage } from '../broadcast/model/responseMessage.js';
5
4
  import * as CryptoUtils from './cryptoHelper.js';
6
5
  import { Protocol } from '../broadcast/model/protocol.js';
7
6
  import { MessageContext } from '../broadcast/model/messageContext.js';
8
7
  import { AnsiLogger } from 'matterbridge/logger';
9
-
10
- export interface Message {
11
- version: string;
12
- seq: number;
13
- random: number;
14
- timestamp: number;
15
- protocol: number;
16
- payloadLen?: number;
17
- payload: Buffer<ArrayBufferLike>;
18
- crc32: number;
19
- }
8
+ import { Parser } from 'binary-parser/dist/binary_parser.js';
9
+ import { ContentMessage } from '../broadcast/model/contentMessage.js';
10
+ import { HeaderMessage } from '../broadcast/model/headerMessage.js';
11
+ import { DpsPayload } from '../broadcast/model/dps.js';
20
12
 
21
13
  export class MessageDeserializer {
22
14
  private readonly context: MessageContext;
23
- private readonly messageParser: Parser<Message>;
15
+ private readonly headerMessageParser: Parser;
16
+ private readonly contentMessageParser: Parser;
24
17
  private readonly logger: AnsiLogger;
25
18
  private readonly supportedVersions: string[] = ['1.0', 'A01', 'B01'];
26
19
 
@@ -28,15 +21,18 @@ export class MessageDeserializer {
28
21
  this.context = context;
29
22
  this.logger = logger;
30
23
 
31
- this.messageParser = new Parser()
24
+ this.headerMessageParser = new Parser()
32
25
  .endianness('big')
33
26
  .string('version', {
34
27
  length: 3,
35
28
  })
36
29
  .uint32('seq')
37
- .uint32('random')
30
+ .uint32('nonce')
38
31
  .uint32('timestamp')
39
- .uint16('protocol')
32
+ .uint16('protocol');
33
+
34
+ this.contentMessageParser = new Parser()
35
+ .endianness('big')
40
36
  .uint16('payloadLen')
41
37
  .buffer('payload', {
42
38
  length: 'payloadLen',
@@ -45,53 +41,68 @@ export class MessageDeserializer {
45
41
  }
46
42
 
47
43
  public deserialize(duid: string, message: Buffer<ArrayBufferLike>): ResponseMessage {
48
- const version = message.toString('latin1', 0, 3);
49
- if (!this.supportedVersions.includes(version)) {
50
- throw new Error('unknown protocol version ' + version);
44
+ // check message header
45
+ const header: HeaderMessage = this.headerMessageParser.parse(message);
46
+ if (!this.supportedVersions.includes(header.version)) {
47
+ throw new Error('unknown protocol version ' + header.version);
51
48
  }
52
49
 
50
+ if (header.protocol === Protocol.hello_response || header.protocol === Protocol.ping_response) {
51
+ const dpsValue: DpsPayload = {
52
+ id: header.seq,
53
+ result: {
54
+ version: header.version,
55
+ nonce: header.nonce,
56
+ },
57
+ };
58
+ return new ResponseMessage(duid, { [header.protocol.toString()]: dpsValue });
59
+ }
60
+
61
+ // parse message content
62
+ const data: ContentMessage = this.contentMessageParser.parse(message.subarray(this.headerMessageParser.sizeOf()));
63
+
64
+ // check crc32
53
65
  const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
54
66
  const expectedCrc32 = message.readUInt32BE(message.length - 4);
55
67
  if (crc32 != expectedCrc32) {
56
68
  throw new Error(`Wrong CRC32 ${crc32}, expected ${expectedCrc32}`);
57
69
  }
70
+
58
71
  const localKey = this.context.getLocalKey(duid);
59
72
  if (!localKey) {
60
73
  this.logger.notice(`Unable to retrieve local key for ${duid}, it should be from other vacuums`);
61
74
  return new ResponseMessage(duid, { dps: { id: 0, result: null } });
62
75
  }
63
76
 
64
- const data: Message = this.messageParser.parse(message);
65
-
66
- if (version == '1.0') {
67
- const aesKey = CryptoUtils.md5bin(CryptoUtils.encodeTimestamp(data.timestamp) + localKey + CryptoUtils.SALT);
77
+ if (header.version == '1.0') {
78
+ const aesKey = CryptoUtils.md5bin(CryptoUtils.encodeTimestamp(header.timestamp) + localKey + CryptoUtils.SALT);
68
79
  const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null);
69
80
  data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
70
- } else if (version == 'A01') {
71
- const iv = CryptoUtils.md5hex(data.random.toString(16).padStart(8, '0') + '726f626f726f636b2d67a6d6da').substring(8, 24);
81
+ } else if (header.version == 'A01') {
82
+ const iv = CryptoUtils.md5hex(header.nonce.toString(16).padStart(8, '0') + '726f626f726f636b2d67a6d6da').substring(8, 24);
72
83
  const decipher = crypto.createDecipheriv('aes-128-cbc', localKey, iv);
73
84
  data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
74
- } else if (version == 'B01') {
75
- const iv = CryptoUtils.md5hex(data.random.toString(16).padStart(8, '0') + '5wwh9ikChRjASpMU8cxg7o1d2E').substring(9, 25);
85
+ } else if (header.version == 'B01') {
86
+ const iv = CryptoUtils.md5hex(header.nonce.toString(16).padStart(8, '0') + '5wwh9ikChRjASpMU8cxg7o1d2E').substring(9, 25);
76
87
  const decipher = crypto.createDecipheriv('aes-128-cbc', localKey, iv);
77
88
  // unpad ??
78
89
  data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
79
90
  }
80
91
 
81
92
  // map visualization not support
82
- if (data.protocol == Protocol.map_response) {
93
+ if (header.protocol == Protocol.map_response) {
83
94
  return new ResponseMessage(duid, { dps: { id: 0, result: null } });
84
95
  }
85
96
 
86
- if (data.protocol == Protocol.rpc_response || data.protocol == Protocol.general_request) {
97
+ if (header.protocol == Protocol.rpc_response || header.protocol == Protocol.general_request) {
87
98
  return this.deserializeRpcResponse(duid, data);
88
99
  } else {
89
- this.logger.error('unknown protocol: ' + data.protocol);
100
+ this.logger.error('unknown protocol: ' + header.protocol);
90
101
  return new ResponseMessage(duid, { dps: { id: 0, result: null } });
91
102
  }
92
103
  }
93
104
 
94
- private deserializeRpcResponse(duid: string, data: Message): ResponseMessage {
105
+ private deserializeRpcResponse(duid: string, data: ContentMessage): ResponseMessage {
95
106
  const payload = JSON.parse(data.payload.toString());
96
107
  const dps = payload.dps;
97
108
  this.parseJsonInDps(dps, Protocol.general_request);
@@ -5,19 +5,20 @@ import CRC32 from 'crc-32';
5
5
  import { DpsPayload, Payload } from '../broadcast/model/dps.js';
6
6
  import { MessageContext } from '../broadcast/model/messageContext.js';
7
7
  import { AnsiLogger } from 'matterbridge/logger';
8
+ import { Protocol } from '../broadcast/model/protocol.js';
8
9
 
9
10
  export class MessageSerializer {
10
11
  private readonly context: MessageContext;
11
12
  private readonly logger: AnsiLogger;
12
- private seq = 1;
13
- private random = 4711;
13
+ private sequence = 1;
14
+ private readonly supportedVersions: string[] = ['1.0', 'A01', 'B01'];
14
15
 
15
16
  constructor(context: MessageContext, logger: AnsiLogger) {
16
17
  this.context = context;
17
18
  this.logger = logger;
18
19
  }
19
20
 
20
- serialize(duid: string, request: RequestMessage): { messageId: number; buffer: Buffer<ArrayBufferLike> } {
21
+ public serialize(duid: string, request: RequestMessage): { messageId: number; buffer: Buffer<ArrayBufferLike> } {
21
22
  const messageId = request.messageId;
22
23
  const buffer = this.buildBuffer(duid, messageId, request);
23
24
  return { messageId: messageId, buffer: buffer };
@@ -35,27 +36,31 @@ export class MessageSerializer {
35
36
  if (request.secure) {
36
37
  data.security = {
37
38
  endpoint: this.context.getEndpoint(),
38
- nonce: this.context.getNonceAsHex(),
39
+ nonce: this.context.getSerializeNonceAsHex(),
39
40
  };
40
41
  }
41
-
42
- const timestamp = Math.floor(Date.now() / 1000);
43
42
  return {
44
43
  dps: {
45
44
  [request.protocol]: JSON.stringify(data),
46
45
  },
47
- t: timestamp,
46
+ t: request.timestamp,
48
47
  };
49
48
  }
50
49
 
51
50
  private buildBuffer(duid: string, messageId: number, request: RequestMessage): Buffer<ArrayBufferLike> {
52
51
  const version = this.context.getProtocolVersion(duid);
53
- const localKey = this.context.getLocalKey(duid);
52
+ if (!version || !this.supportedVersions.includes(version)) {
53
+ throw new Error('unknown protocol version ' + version);
54
+ }
55
+ let encrypted;
56
+ if (request.protocol === Protocol.hello_response || request.protocol === Protocol.ping_response) {
57
+ encrypted = Buffer.alloc(0);
58
+ }
54
59
 
60
+ const localKey = this.context.getLocalKey(duid);
55
61
  const payloadData = this.buildPayload(messageId, request);
56
62
  const payload = JSON.stringify(payloadData);
57
63
 
58
- let encrypted;
59
64
  if (version == '1.0') {
60
65
  const aesKey = CryptoUtils.md5bin(CryptoUtils.encodeTimestamp(payloadData.t) + localKey + CryptoUtils.SALT);
61
66
  const cipher = crypto.createCipheriv('aes-128-ecb', aesKey, null);
@@ -63,12 +68,12 @@ export class MessageSerializer {
63
68
  } else if (version == 'A01') {
64
69
  // 726f626f726f636b2d67a6d6da is in version 4.0 of the roborock app
65
70
  const encoder = new TextEncoder();
66
- const iv = CryptoUtils.md5hex(this.random.toString(16).padStart(8, '0') + '726f626f726f636b2d67a6d6da').substring(8, 24);
71
+ const iv = CryptoUtils.md5hex(request.nonce.toString(16).padStart(8, '0') + '726f626f726f636b2d67a6d6da').substring(8, 24);
67
72
  const cipher = crypto.createCipheriv('aes-128-cbc', encoder.encode(localKey), iv);
68
73
  encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
69
74
  } else if (version == 'B01') {
70
75
  const encoder = new TextEncoder();
71
- const iv = CryptoUtils.md5hex(this.random.toString(16).padStart(8, '0') + '5wwh9ikChRjASpMU8cxg7o1d2E').substring(9, 25);
76
+ const iv = CryptoUtils.md5hex(request.nonce.toString(16).padStart(8, '0') + '5wwh9ikChRjASpMU8cxg7o1d2E').substring(9, 25);
72
77
  const cipher = crypto.createCipheriv('aes-128-cbc', encoder.encode(localKey), iv);
73
78
  // pad ??
74
79
  encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
@@ -78,9 +83,9 @@ export class MessageSerializer {
78
83
 
79
84
  const msg = Buffer.alloc(23 + encrypted.length);
80
85
  msg.write(version);
81
- msg.writeUint32BE(this.seq++ & 0xffffffff, 3);
82
- msg.writeUint32BE(this.random++ & 0xffffffff, 7);
83
- msg.writeUint32BE(payloadData.t, 11);
86
+ msg.writeUint32BE(this.sequence++ & 0xffffffff, 3);
87
+ msg.writeUint32BE(request.nonce & 0xffffffff, 7);
88
+ msg.writeUint32BE(request.timestamp, 11);
84
89
  msg.writeUint16BE(request.protocol, 15);
85
90
  msg.writeUint16BE(encrypted.length, 17);
86
91
  encrypted.copy(msg, 19);
@@ -25,6 +25,7 @@ import type { AbstractMessageHandler, AbstractMessageListener, BatteryMessage, D
25
25
  import { ServiceArea } from 'matterbridge/matter/clusters';
26
26
  import { LocalNetworkClient } from './roborockCommunication/broadcast/client/LocalNetworkClient.js';
27
27
  import { RoomIndexMap } from './model/roomIndexMap.js';
28
+ import { DpsPayload } from './roborockCommunication/broadcast/model/dps.js';
28
29
  export type Factory<A, T> = (logger: AnsiLogger, arg: A) => T;
29
30
 
30
31
  export default class RoborockService {
@@ -479,7 +480,7 @@ export default class RoborockService {
479
480
  // eslint-disable-next-line @typescript-eslint/no-this-alias
480
481
  const self = this;
481
482
  this.messageClient = this.clientManager.get(username, userdata);
482
- this.messageClient.registerDevice(device.duid, device.localKey, device.pv);
483
+ this.messageClient.registerDevice(device.duid, device.localKey, device.pv, undefined);
483
484
 
484
485
  this.messageClient.registerMessageListener({
485
486
  onMessage: (message: ResponseMessage) => {
@@ -493,6 +494,12 @@ export default class RoborockService {
493
494
  self.deviceNotify(NotifyMessageTypes.CloudMessage, message);
494
495
  }
495
496
  }
497
+
498
+ if (message instanceof ResponseMessage && message.contain(Protocol.hello_response)) {
499
+ const dps = message.dps[Protocol.hello_response] as DpsPayload;
500
+ const result = dps.result as { nonce: number };
501
+ self.messageClient?.updateNonce(message.duid, result.nonce);
502
+ }
496
503
  },
497
504
  } as AbstractMessageListener);
498
505
 
package/src/rvc.ts CHANGED
@@ -2,7 +2,7 @@ import { RoboticVacuumCleaner } from 'matterbridge/devices';
2
2
  import { RoomMap } from './model/RoomMap.js';
3
3
  import { Device } from './roborockCommunication/index.js';
4
4
  import { getOperationalStates, getSupportedAreas, getSupportedCleanModes, getSupportedRunModes } from './initialData/index.js';
5
- import { AnsiLogger } from 'matterbridge/logger';
5
+ import { AnsiLogger, debugStringify } from 'matterbridge/logger';
6
6
  import { BehaviorFactoryResult } from './behaviorFactory.js';
7
7
  import { ModeBase, RvcOperationalState, ServiceArea } from 'matterbridge/matter/clusters';
8
8
  import { ExperimentalFeatureSetting } from './model/ExperimentalFeatureSetting.js';
@@ -63,7 +63,7 @@ export class RoborockVacuumCleaner extends RoboticVacuumCleaner {
63
63
 
64
64
  public configurateHandler(behaviorHandler: BehaviorFactoryResult): void {
65
65
  this.addCommandHandler('identify', async ({ request, cluster, attributes, endpoint }) => {
66
- this.log.info(`Identify command received for endpoint ${endpoint}, cluster ${cluster}, attributes ${attributes}, request: ${JSON.stringify(request)}`);
66
+ this.log.info(`Identify command received for endpoint ${endpoint}, cluster ${cluster}, attributes ${debugStringify(attributes)}, request: ${JSON.stringify(request)}`);
67
67
  behaviorHandler.executeCommand('playSoundToLocate', (request as { identifyTime?: number }).identifyTime ?? 0);
68
68
  });
69
69
 
@@ -1,25 +1,6 @@
1
1
  import { LocalNetworkClient } from '../../../../roborockCommunication/broadcast/client/LocalNetworkClient';
2
2
  import { Protocol } from '../../../../roborockCommunication/broadcast/model/protocol';
3
3
 
4
- // Pseudocode plan:
5
- // 1. Mock dependencies: Socket, AnsiLogger, MessageContext, RequestMessage, Protocol, serializer/deserializer.
6
- // 2. Test constructor initializes fields correctly.
7
- // 3. Test connect(): should create a Socket, set up event handlers, and call socket.connect with correct params.
8
- // 4. Test disconnect(): should destroy socket, clear ping interval, and set socket to undefined.
9
- // 5. Test send():
10
- // - If socket is not connected, should log error and not write.
11
- // - If connected, should serialize and write the message.
12
- // 6. Test onConnect(): sets connected, logs, sends hello, sets ping interval, calls connectionListeners.onConnected.
13
- // 7. Test onDisconnect(): logs, sets connected false, destroys socket, clears ping interval, calls connectionListeners.onDisconnected.
14
- // 8. Test onError(): logs, sets connected false, destroys socket, calls connectionListeners.onError.
15
- // 9. Test onMessage():
16
- // - If no socket, logs error.
17
- // - If empty message, logs debug.
18
- // - If valid, appends, checks completeness, deserializes, calls messageListeners.onMessage.
19
- // 10. Test isMessageComplete(): returns true/false for various buffer scenarios.
20
- // 11. Test wrapWithLengthData(): prepends length to buffer.
21
- // 12. Test sendHelloMessage() and sendPingRequest(): calls send with correct protocol.
22
-
23
4
  const Sket = jest.fn();
24
5
 
25
6
  jest.mock('node:net', () => {
@@ -1,37 +1,5 @@
1
1
  import { MQTTClient } from '../../../../roborockCommunication/broadcast/client/MQTTClient';
2
2
 
3
- // Pseudocode plan:
4
- // 1. Mock dependencies: mqtt, CryptoUtils, AbstractClient, serializer, deserializer, logger, connectionListeners, messageListeners.
5
- // 2. Test constructor: verify username/password are generated as expected.
6
- // 3. Test connect():
7
- // - Should call mqtt.connect with correct params.
8
- // - Should set up event listeners.
9
- // - Should not connect if already connected.
10
- // 4. Test disconnect():
11
- // - Should call client.end if connected.
12
- // - Should not call if not connected.
13
- // - Should log error if exception thrown.
14
- // 5. Test send():
15
- // - Should publish correct topic/message if connected.
16
- // - Should log error if not connected.
17
- // 6. Test onConnect():
18
- // - Should set connected, call onConnected, subscribeToQueue.
19
- // 7. Test subscribeToQueue():
20
- // - Should call client.subscribe with correct topic.
21
- // 8. Test onSubscribe():
22
- // - Should log error and call onDisconnected if error.
23
- // - Should do nothing if no error.
24
- // 9. Test onDisconnect():
25
- // - Should call onDisconnected.
26
- // 10. Test onError():
27
- // - Should log error, set connected false, call onError.
28
- // 11. Test onReconnect():
29
- // - Should call subscribeToQueue.
30
- // 12. Test onMessage():
31
- // - Should call deserializer and messageListeners.onMessage if message.
32
- // - Should log notice if message is falsy.
33
- // - Should log error if deserializer throws.
34
-
35
3
  const mockConnect = jest.fn();
36
4
  jest.mock('mqtt', () => {
37
5
  const actual = jest.requireActual('mqtt');