matterbridge-roborock-vacuum-plugin 1.1.0-rc11 → 1.1.0-rc13

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 (39) hide show
  1. package/dist/initialData/getSupportedAreas.js +12 -6
  2. package/dist/model/RoomMap.js +1 -0
  3. package/dist/platform.js +4 -3
  4. package/dist/platformRunner.js +67 -12
  5. package/dist/roborockCommunication/Zmodel/mapInfo.js +4 -2
  6. package/dist/roborockCommunication/broadcast/abstractClient.js +1 -1
  7. package/dist/roborockCommunication/broadcast/client/LocalNetworkClient.js +25 -25
  8. package/dist/roborockCommunication/broadcast/client/LocalNetworkUDPClient.js +129 -0
  9. package/dist/roborockCommunication/broadcast/client/MQTTClient.js +53 -25
  10. package/dist/roborockCommunication/broadcast/clientRouter.js +2 -2
  11. package/dist/roborockCommunication/broadcast/listener/implementation/chainedConnectionListener.js +7 -2
  12. package/dist/roborockCommunication/broadcast/listener/implementation/connectionStateListener.js +9 -6
  13. package/dist/roborockCommunication/helper/messageDeserializer.js +1 -1
  14. package/dist/roborockService.js +5 -7
  15. package/matterbridge-roborock-vacuum-plugin.config.json +1 -1
  16. package/matterbridge-roborock-vacuum-plugin.schema.json +1 -1
  17. package/package.json +2 -1
  18. package/src/initialData/getSupportedAreas.ts +15 -6
  19. package/src/model/RoomMap.ts +2 -0
  20. package/src/platform.ts +5 -3
  21. package/src/platformRunner.ts +88 -13
  22. package/src/roborockCommunication/Zmodel/device.ts +0 -1
  23. package/src/roborockCommunication/Zmodel/map.ts +1 -0
  24. package/src/roborockCommunication/Zmodel/mapInfo.ts +6 -5
  25. package/src/roborockCommunication/broadcast/abstractClient.ts +1 -2
  26. package/src/roborockCommunication/broadcast/client/LocalNetworkClient.ts +34 -28
  27. package/src/roborockCommunication/broadcast/client/LocalNetworkUDPClient.ts +157 -0
  28. package/src/roborockCommunication/broadcast/client/MQTTClient.ts +69 -36
  29. package/src/roborockCommunication/broadcast/clientRouter.ts +2 -2
  30. package/src/roborockCommunication/broadcast/listener/abstractConnectionListener.ts +2 -1
  31. package/src/roborockCommunication/broadcast/listener/implementation/chainedConnectionListener.ts +8 -2
  32. package/src/roborockCommunication/broadcast/listener/implementation/connectionStateListener.ts +11 -6
  33. package/src/roborockCommunication/helper/messageDeserializer.ts +2 -3
  34. package/src/roborockService.ts +6 -9
  35. package/src/tests/initialData/getSupportedAreas.test.ts +27 -0
  36. package/src/tests/platformRunner2.test.ts +91 -0
  37. package/src/tests/roborockCommunication/broadcast/client/LocalNetworkClient.test.ts +1 -7
  38. package/src/tests/roborockCommunication/broadcast/client/MQTTClient.test.ts +13 -106
  39. package/web-for-testing/src/accountStore.ts +2 -0
@@ -0,0 +1,157 @@
1
+ import * as dgram from 'node:dgram';
2
+ import { Socket } from 'node:dgram';
3
+ import { Parser } from 'binary-parser';
4
+ import crypto from 'node:crypto';
5
+ import CRC32 from 'crc-32';
6
+ import { AnsiLogger } from 'matterbridge/logger';
7
+ import { AbstractClient } from '../abstractClient.js';
8
+ import { RequestMessage, ResponseMessage } from '../../index.js';
9
+ import { MessageContext } from '../model/messageContext.js';
10
+
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
12
+ export class LocalNetworkUDPClient extends AbstractClient {
13
+ protected override clientName = 'LocalNetworkUDPClient';
14
+ protected override shouldReconnect = false;
15
+
16
+ private readonly PORT = 58866;
17
+ private server: Socket | undefined = undefined;
18
+
19
+ private readonly V10Parser: Parser<any>;
20
+ private readonly L01Parser: Parser<any>;
21
+
22
+ constructor(logger: AnsiLogger, context: MessageContext) {
23
+ super(logger, context);
24
+ this.V10Parser = new Parser()
25
+ .endianness('big')
26
+ .string('version', { length: 3 })
27
+ .uint32('seq')
28
+ .uint16('protocol')
29
+ .uint16('payloadLen')
30
+ .buffer('payload', { length: 'payloadLen' })
31
+ .uint32('crc32');
32
+
33
+ this.L01Parser = new Parser()
34
+ .endianness('big')
35
+ .string('version', { length: 3 })
36
+ .string('field1', { length: 4 })
37
+ .string('field2', { length: 2 })
38
+ .uint16('payloadLen')
39
+ .buffer('payload', { length: 'payloadLen' })
40
+ .uint32('crc32');
41
+
42
+ this.logger = logger;
43
+ }
44
+
45
+ public connect(): void {
46
+ try {
47
+ this.server = dgram.createSocket('udp4');
48
+ this.server.bind(this.PORT);
49
+
50
+ this.server.on('message', this.onMessage.bind(this));
51
+ this.server.on('error', this.onError.bind(this));
52
+ } catch (err) {
53
+ this.logger.error(`Failed to create UDP socket: ${err}`);
54
+ this.server = undefined;
55
+ }
56
+ }
57
+
58
+ public disconnect(): Promise<void> {
59
+ if (this.server) {
60
+ return new Promise<void>((resolve) => {
61
+ this.server?.close(() => {
62
+ this.server = undefined;
63
+ resolve();
64
+ });
65
+ });
66
+ }
67
+
68
+ return Promise.resolve();
69
+ }
70
+
71
+ public override send(duid: string, request: RequestMessage): Promise<void> {
72
+ this.logger.debug(`Sending request to ${duid}: ${JSON.stringify(request)}`);
73
+ return Promise.resolve();
74
+ }
75
+
76
+ private async onError(result: any) {
77
+ this.logger.error(`UDP socket error: ${result}`);
78
+
79
+ if (this.server) {
80
+ this.server.close();
81
+ this.server = undefined;
82
+ }
83
+ }
84
+
85
+ private async onMessage(buffer: Buffer) {
86
+ const message = await this.deserializeMessage(buffer);
87
+ this.logger.debug('Received message: ' + JSON.stringify(message));
88
+ }
89
+
90
+ private async deserializeMessage(buffer: Buffer): Promise<ResponseMessage | undefined> {
91
+ const version = buffer.toString('latin1', 0, 3);
92
+
93
+ if (version !== '1.0' && version !== 'L01' && version !== 'A01') {
94
+ throw new Error('unknown protocol version ' + version);
95
+ }
96
+
97
+ let data;
98
+ switch (version) {
99
+ case '1.0':
100
+ data = await this.deserializeV10Message(buffer);
101
+ return JSON.parse(data);
102
+ case 'L01':
103
+ data = await this.deserializeL01Message(buffer);
104
+ return JSON.parse(data);
105
+ case 'A01':
106
+ // TODO: Implement A01 deserialization
107
+ return undefined; // Placeholder for A01 deserialization
108
+ default:
109
+ throw new Error('unknown protocol version ' + version);
110
+ }
111
+ }
112
+
113
+ private async deserializeV10Message(message: Buffer<ArrayBufferLike>): Promise<string> {
114
+ const data = this.V10Parser.parse(message);
115
+ const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
116
+ const expectedCrc32 = data.crc32;
117
+ if (crc32 != expectedCrc32) {
118
+ throw new Error('wrong CRC32 ' + crc32 + ', expected ' + expectedCrc32);
119
+ }
120
+
121
+ const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from('qWKYcdQWrbm9hPqe', 'utf8'), null);
122
+ decipher.setAutoPadding(false);
123
+
124
+ let decrypted = decipher.update(data.payload, 'binary', 'utf8');
125
+ decrypted += decipher.final('utf8');
126
+
127
+ const paddingLength = decrypted.charCodeAt(decrypted.length - 1);
128
+ return decrypted.slice(0, -paddingLength);
129
+ }
130
+
131
+ private async deserializeL01Message(message: Buffer<ArrayBufferLike>): Promise<string> {
132
+ const data = this.L01Parser.parse(message);
133
+ const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
134
+ const expectedCrc32 = data.crc32;
135
+ if (crc32 != expectedCrc32) {
136
+ throw new Error('wrong CRC32 ' + crc32 + ', expected ' + expectedCrc32);
137
+ }
138
+
139
+ const payload = data.payload;
140
+ const key = crypto.createHash('sha256').update(Buffer.from('qWKYcdQWrbm9hPqe', 'utf8')).digest();
141
+ const digestInput = message.subarray(0, 9);
142
+ const digest = crypto.createHash('sha256').update(digestInput).digest();
143
+ const iv = digest.subarray(0, 12);
144
+ const tag = payload.subarray(payload.length - 16);
145
+ const ciphertext = payload.subarray(0, payload.length - 16);
146
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
147
+ decipher.setAuthTag(tag);
148
+
149
+ try {
150
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
151
+ return decrypted.toString('utf8');
152
+ } catch (e: unknown) {
153
+ const message = e && typeof e === 'object' && 'message' in e ? (e as any).message : String(e);
154
+ throw new Error('failed to decrypt: ' + message + ' / iv: ' + iv.toString('hex') + ' / tag: ' + tag.toString('hex') + ' / encrypted: ' + ciphertext.toString('hex'));
155
+ }
156
+ }
157
+ }
@@ -1,4 +1,4 @@
1
- import mqtt, { ErrorWithReasonCode, IConnackPacket, MqttClient as MqttLibClient } from 'mqtt';
1
+ import mqtt, { ErrorWithReasonCode, IConnackPacket, ISubscriptionGrant, MqttClient as MqttLibClient } from 'mqtt';
2
2
  import * as CryptoUtils from '../../helper/cryptoHelper.js';
3
3
  import { RequestMessage } from '../model/requestMessage.js';
4
4
  import { AbstractClient } from '../abstractClient.js';
@@ -7,14 +7,14 @@ import { Rriot, UserData } from '../../Zmodel/userData.js';
7
7
  import { AnsiLogger, debugStringify } from 'matterbridge/logger';
8
8
 
9
9
  export class MQTTClient extends AbstractClient {
10
- protected override changeToSecureConnection: (duid: string) => void;
11
10
  protected override clientName = 'MQTTClient';
12
11
  protected override shouldReconnect = false;
13
12
 
14
13
  private readonly rriot: Rriot;
15
14
  private readonly mqttUsername: string;
16
15
  private readonly mqttPassword: string;
17
- private client: MqttLibClient | undefined = undefined;
16
+ private mqttClient: MqttLibClient | undefined = undefined;
17
+ private keepConnectionAliveInterval: NodeJS.Timeout | undefined = undefined;
18
18
 
19
19
  public constructor(logger: AnsiLogger, context: MessageContext, userdata: UserData) {
20
20
  super(logger, context);
@@ -24,106 +24,139 @@ export class MQTTClient extends AbstractClient {
24
24
  this.mqttPassword = CryptoUtils.md5hex(userdata.rriot.s + ':' + userdata.rriot.k).substring(16);
25
25
 
26
26
  this.initializeConnectionStateListener();
27
- this.changeToSecureConnection = (duid: string) => {
28
- this.logger.info(`MqttClient for ${duid} has been disconnected`);
29
- };
30
27
  }
31
28
 
32
29
  public connect(): void {
33
- if (this.client) {
34
- return;
30
+ if (this.mqttClient) {
31
+ return; // Already connected
35
32
  }
36
33
 
37
- this.client = mqtt.connect(this.rriot.r.m, {
34
+ this.mqttClient = mqtt.connect(this.rriot.r.m, {
38
35
  clientId: this.mqttUsername,
39
36
  username: this.mqttUsername,
40
37
  password: this.mqttPassword,
41
38
  keepalive: 30,
42
- log: () => {
43
- // ...args: unknown[] - this.logger.debug('MQTTClient args:: ' + args[0]);
39
+ log: (...args: unknown[]) => {
40
+ this.logger.debug(`MQTTClient args: ${debugStringify(args)}`);
44
41
  },
45
42
  });
46
43
 
47
- this.client.on('connect', this.onConnect.bind(this));
48
- this.client.on('error', this.onError.bind(this));
49
- this.client.on('reconnect', this.onReconnect.bind(this));
50
- this.client.on('close', this.onDisconnect.bind(this));
51
- this.client.on('disconnect', this.onDisconnect.bind(this));
52
- this.client.on('offline', this.onDisconnect.bind(this));
44
+ this.mqttClient.on('connect', this.onConnect.bind(this));
45
+ this.mqttClient.on('error', this.onError.bind(this));
46
+ this.mqttClient.on('reconnect', this.onReconnect.bind(this));
47
+ this.mqttClient.on('close', this.onClose.bind(this));
48
+ this.mqttClient.on('disconnect', this.onDisconnect.bind(this));
49
+ this.mqttClient.on('offline', this.onOffline.bind(this));
50
+ this.mqttClient.on('message', this.onMessage.bind(this));
53
51
 
54
- // message events
55
- this.client.on('message', this.onMessage.bind(this));
52
+ this.keepConnectionAlive();
56
53
  }
57
54
 
58
55
  public async disconnect(): Promise<void> {
59
- if (!this.client || !this.connected) {
56
+ if (!this.mqttClient || !this.connected) {
60
57
  return;
61
58
  }
62
59
  try {
63
60
  this.isInDisconnectingStep = true;
64
- this.client.end();
61
+ this.mqttClient.end();
65
62
  } catch (error) {
66
63
  this.logger.error('MQTT client failed to disconnect with error: ' + error);
67
64
  }
68
65
  }
69
66
 
70
67
  public async send(duid: string, request: RequestMessage): Promise<void> {
71
- if (!this.client || !this.connected) {
68
+ if (!this.mqttClient || !this.connected) {
72
69
  this.logger.error(`${duid}: mqtt is not available, ${debugStringify(request)}`);
73
70
  return;
74
71
  }
75
72
 
76
73
  const mqttRequest = request.toMqttRequest();
77
74
  const message = this.serializer.serialize(duid, mqttRequest);
78
- this.client.publish('rr/m/i/' + this.rriot.u + '/' + this.mqttUsername + '/' + duid, message.buffer, { qos: 1 });
75
+ this.logger.debug(`MQTTClient sending message to ${duid}: ${debugStringify(mqttRequest)}`);
76
+ this.mqttClient.publish(`rr/m/i/${this.rriot.u}/${this.mqttUsername}/${duid}`, message.buffer, { qos: 1 });
77
+ this.logger.debug(`MQTTClient published message to topic: rr/m/i/${this.rriot.u}/${this.mqttUsername}/${duid}`);
78
+ }
79
+
80
+ private keepConnectionAlive(): void {
81
+ if (this.keepConnectionAliveInterval) {
82
+ clearTimeout(this.keepConnectionAliveInterval);
83
+ this.keepConnectionAliveInterval.unref();
84
+ }
85
+
86
+ this.keepConnectionAliveInterval = setInterval(
87
+ () => {
88
+ if (this.mqttClient) {
89
+ this.mqttClient.end();
90
+ this.mqttClient.reconnect();
91
+ } else {
92
+ this.connect();
93
+ }
94
+ },
95
+ 30 * 60 * 1000,
96
+ );
79
97
  }
80
98
 
81
- private async onConnect(result: IConnackPacket) {
99
+ private async onConnect(result: IConnackPacket): Promise<void> {
82
100
  if (!result) {
83
101
  return;
84
102
  }
85
103
 
86
104
  this.connected = true;
87
105
  await this.connectionListeners.onConnected('mqtt-' + this.mqttUsername);
88
-
89
106
  this.subscribeToQueue();
90
107
  }
91
108
 
92
- private subscribeToQueue() {
93
- if (!this.client) {
109
+ private subscribeToQueue(): void {
110
+ if (!this.mqttClient || !this.connected) {
94
111
  return;
95
112
  }
96
- this.client.subscribe('rr/m/o/' + this.rriot.u + '/' + this.mqttUsername + '/#', this.onSubscribe.bind(this));
113
+
114
+ this.mqttClient.subscribe('rr/m/o/' + this.rriot.u + '/' + this.mqttUsername + '/#', this.onSubscribe.bind(this));
97
115
  }
98
116
 
99
- private async onSubscribe(err: Error | null) {
117
+ private async onSubscribe(err: Error | null, granted: ISubscriptionGrant[] | undefined): Promise<void> {
100
118
  if (!err) {
119
+ this.logger.info('onSubscribe: ' + JSON.stringify(granted));
101
120
  return;
102
121
  }
103
122
 
104
- this.logger.error('failed to subscribe to the queue: ' + err);
123
+ this.logger.error('failed to subscribe: ' + err);
105
124
  this.connected = false;
106
125
 
107
- await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername);
126
+ await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'Failed to subscribe to the queue: ' + err.toString());
108
127
  }
109
128
 
110
- private async onDisconnect() {
129
+ private async onDisconnect(): Promise<void> {
111
130
  this.connected = false;
112
- await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername);
131
+ await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'Disconnected from MQTT broker');
113
132
  }
114
133
 
115
- private async onError(result: Error | ErrorWithReasonCode) {
134
+ private async onError(result: Error | ErrorWithReasonCode): Promise<void> {
116
135
  this.logger.error('MQTT connection error: ' + result);
117
136
  this.connected = false;
118
137
 
119
138
  await this.connectionListeners.onError('mqtt-' + this.mqttUsername, result.toString());
120
139
  }
121
140
 
122
- private onReconnect() {
141
+ private async onClose(): Promise<void> {
142
+ if (this.connected) {
143
+ await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'MQTT connection closed');
144
+ }
145
+
146
+ this.connected = false;
147
+ }
148
+
149
+ private async onOffline(): Promise<void> {
150
+ this.connected = false;
151
+ await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'MQTT connection offline');
152
+ }
153
+
154
+ private onReconnect(): void {
123
155
  this.subscribeToQueue();
156
+ this.connectionListeners.onReconnect('mqtt-' + this.mqttUsername, 'Reconnected to MQTT broker');
124
157
  }
125
158
 
126
- private async onMessage(topic: string, message: Buffer<ArrayBufferLike>) {
159
+ private async onMessage(topic: string, message: Buffer<ArrayBufferLike>): Promise<void> {
127
160
  if (!message) {
128
161
  // Ignore empty messages
129
162
  this.logger.notice('MQTTClient received empty message from topic: ' + topic);
@@ -32,8 +32,8 @@ export class ClientRouter implements Client {
32
32
  this.context.registerDevice(duid, localKey, pv);
33
33
  }
34
34
 
35
- public registerClient(duid: string, ip: string, onDisconnect: (duid: string) => void): Client {
36
- const localClient = new LocalNetworkClient(this.logger, this.context, duid, ip, onDisconnect);
35
+ public registerClient(duid: string, ip: string): Client {
36
+ const localClient = new LocalNetworkClient(this.logger, this.context, duid, ip);
37
37
  localClient.registerConnectionListener(this.connectionListeners);
38
38
  localClient.registerMessageListener(this.messageListeners);
39
39
 
@@ -1,5 +1,6 @@
1
1
  export interface AbstractConnectionListener {
2
2
  onConnected(duid: string): Promise<void>;
3
- onDisconnected(duid: string): Promise<void>;
3
+ onDisconnected(duid: string, message: string): Promise<void>;
4
4
  onError(duid: string, message: string): Promise<void>;
5
+ onReconnect(duid: string, message: string): Promise<void>;
5
6
  }
@@ -13,9 +13,9 @@ export class ChainedConnectionListener implements AbstractConnectionListener {
13
13
  }
14
14
  }
15
15
 
16
- public async onDisconnected(duid: string): Promise<void> {
16
+ public async onDisconnected(duid: string, message: string): Promise<void> {
17
17
  for (const listener of this.listeners) {
18
- await listener.onDisconnected(duid);
18
+ await listener.onDisconnected(duid, message);
19
19
  }
20
20
  }
21
21
 
@@ -24,4 +24,10 @@ export class ChainedConnectionListener implements AbstractConnectionListener {
24
24
  await listener.onError(duid, message);
25
25
  }
26
26
  }
27
+
28
+ public async onReconnect(duid: string, message: string): Promise<void> {
29
+ for (const listener of this.listeners) {
30
+ await listener.onReconnect(duid, message);
31
+ }
32
+ }
27
33
  }
@@ -7,21 +7,24 @@ export class ConnectionStateListener implements AbstractConnectionListener {
7
7
  protected client: AbstractClient;
8
8
  protected clientName: string;
9
9
  protected shouldReconnect: boolean;
10
- protected changeToSecureConnection: (duid: string) => void;
11
10
 
12
- constructor(logger: AnsiLogger, client: AbstractClient, clientName: string, changeToSecureConnection: (duid: string) => void, shouldReconnect = false) {
11
+ constructor(logger: AnsiLogger, client: AbstractClient, clientName: string, shouldReconnect = false) {
13
12
  this.logger = logger;
14
13
  this.client = client;
15
14
  this.clientName = clientName;
16
15
  this.shouldReconnect = shouldReconnect;
17
- this.changeToSecureConnection = changeToSecureConnection;
18
16
  }
19
17
 
20
18
  public async onConnected(duid: string): Promise<void> {
21
19
  this.logger.notice(`Device ${duid} connected to ${this.clientName}`);
22
20
  }
23
21
 
24
- public async onDisconnected(duid: string): Promise<void> {
22
+ public async onReconnect(duid: string, message: string): Promise<void> {
23
+ this.logger.info(`Device ${duid} reconnected to ${this.clientName} with message: ${message}`);
24
+ }
25
+
26
+ public async onDisconnected(duid: string, message: string): Promise<void> {
27
+ this.logger.error(`Device ${duid} disconnected from ${this.clientName} with message: ${message}`);
25
28
  if (!this.shouldReconnect) {
26
29
  this.logger.notice(`Device ${duid} disconnected from ${this.clientName}, but re-registration is disabled.`);
27
30
  return;
@@ -29,7 +32,6 @@ export class ConnectionStateListener implements AbstractConnectionListener {
29
32
 
30
33
  if (this.client.retryCount > 10) {
31
34
  this.logger.error(`Device with DUID ${duid} has exceeded retry limit, not re-registering.`);
32
- this.changeToSecureConnection(duid);
33
35
  return;
34
36
  }
35
37
 
@@ -42,7 +44,10 @@ export class ConnectionStateListener implements AbstractConnectionListener {
42
44
  }
43
45
 
44
46
  this.logger.info(`Re-registering device with DUID ${duid} to ${this.clientName}`);
45
- this.client.connect();
47
+
48
+ setTimeout(() => {
49
+ this.client.connect();
50
+ }, 3000);
46
51
 
47
52
  this.client.isInDisconnectingStep = false;
48
53
  }
@@ -1,6 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
2
  import CRC32 from 'crc-32';
3
- // @ts-expect-error: binary-parser has no type definitions, using as-is for runtime parsing
4
3
  import { Parser } from 'binary-parser';
5
4
  import { ResponseMessage } from '../broadcast/model/responseMessage.js';
6
5
  import * as CryptoUtils from './cryptoHelper.js';
@@ -21,7 +20,7 @@ export interface Message {
21
20
 
22
21
  export class MessageDeserializer {
23
22
  private readonly context: MessageContext;
24
- private readonly messageParser: Parser;
23
+ private readonly messageParser: Parser<Message>;
25
24
  private readonly logger: AnsiLogger;
26
25
  private readonly supportedVersions: string[] = ['1.0', 'A01', 'B01'];
27
26
 
@@ -30,7 +29,7 @@ export class MessageDeserializer {
30
29
  this.logger = logger;
31
30
 
32
31
  this.messageParser = new Parser()
33
- .endianess('big')
32
+ .endianness('big')
34
33
  .string('version', {
35
34
  length: 3,
36
35
  })
@@ -98,6 +98,10 @@ export default class RoborockService {
98
98
  this.selectedAreas.set(duid, selectedAreas);
99
99
  }
100
100
 
101
+ public getSelectedAreas(duid: string): number[] {
102
+ return this.selectedAreas.get(duid) ?? [];
103
+ }
104
+
101
105
  public setSupportedAreas(duid: string, supportedAreas: ServiceArea.Area[]): void {
102
106
  this.supportedAreas.set(duid, supportedAreas);
103
107
  }
@@ -340,7 +344,6 @@ export default class RoborockService {
340
344
  model: homeData.products.find((p) => p.id === device.productId)?.model,
341
345
  category: homeData.products.find((p) => p.id === device.productId)?.category,
342
346
  batteryLevel: device.deviceStatus?.[Protocol.battery] ?? 100,
343
- schema: homeData.products.find((p) => p.id === device.productId)?.schema,
344
347
  },
345
348
 
346
349
  store: {
@@ -394,7 +397,6 @@ export default class RoborockService {
394
397
  model: homeData.products.find((p) => p.id === device.productId)?.model,
395
398
  category: homeData.products.find((p) => p.id === device.productId)?.category,
396
399
  batteryLevel: device.deviceStatus?.[Protocol.battery] ?? 100,
397
- schema: homeData.products.find((p) => p.id === device.productId)?.schema,
398
400
  },
399
401
 
400
402
  store: {
@@ -427,7 +429,6 @@ export default class RoborockService {
427
429
  this.logger.warn('messageClient not initialized. Waititing for next execution');
428
430
  return Promise.resolve(undefined);
429
431
  }
430
-
431
432
  return this.messageClient.get(duid, new RequestMessage({ method: 'get_room_mapping', secure: this.isRequestSecure(duid) }));
432
433
  }
433
434
 
@@ -528,14 +529,14 @@ export default class RoborockService {
528
529
 
529
530
  if (localIp) {
530
531
  this.logger.debug('initializing the local connection for this client towards ' + localIp);
531
- const localClient = this.messageClient.registerClient(device.duid, localIp, this.onLocalClientDisconnect) as LocalNetworkClient;
532
+ const localClient = this.messageClient.registerClient(device.duid, localIp) as LocalNetworkClient;
532
533
  localClient.connect();
533
534
 
534
535
  let count = 0;
535
536
  while (!localClient.isConnected() && count < 20) {
536
537
  this.logger.debug('Keep waiting for local client to connect');
537
538
  count++;
538
- await this.sleep(200);
539
+ await this.sleep(500);
539
540
  }
540
541
 
541
542
  if (!localClient.isConnected()) {
@@ -554,10 +555,6 @@ export default class RoborockService {
554
555
  return true;
555
556
  }
556
557
 
557
- private onLocalClientDisconnect(duid: string): void {
558
- this.mqttAlwaysOnDevices.set(duid, true);
559
- }
560
-
561
558
  private sleep(ms: number): Promise<void> {
562
559
  return new Promise((resolve) => setTimeout(resolve, ms));
563
560
  }
@@ -78,4 +78,31 @@ describe('getSupportedAreas', () => {
78
78
 
79
79
  expect(result.length).toEqual(5);
80
80
  });
81
+
82
+ it('returns default area when rooms and roomMap are empty', () => {
83
+ const result = getSupportedAreas(
84
+ [
85
+ { id: 11453731, name: 'Living room' },
86
+ { id: 11453727, name: 'Kitchen' },
87
+ { id: 11453415, name: 'Bathroom' },
88
+ { id: 11453409, name: 'Emile’s Room' },
89
+ { id: 11453404, name: 'Nadia’s Room' },
90
+ { id: 11453398, name: 'Hallway' },
91
+ { id: 11453391, name: 'Dining room' },
92
+ { id: 11453384, name: 'Outside' },
93
+ ],
94
+ {
95
+ rooms: [
96
+ { id: 16, globalId: 2775739, displayName: undefined },
97
+ { id: 17, globalId: 991195, displayName: undefined },
98
+ { id: 18, globalId: 991187, displayName: undefined },
99
+ { id: 19, globalId: 991185, displayName: undefined },
100
+ { id: 20, globalId: 991190, displayName: undefined },
101
+ ],
102
+ } as RoomMap,
103
+ mockLogger as any,
104
+ );
105
+
106
+ expect(result.length).toEqual(5);
107
+ });
81
108
  });
@@ -0,0 +1,91 @@
1
+ import RoomMap from '../model/RoomMap';
2
+ import { RoborockMatterbridgePlatform } from '../platform';
3
+ import { PlatformRunner } from '../platformRunner';
4
+ import { MapInfo } from '../roborockCommunication';
5
+
6
+ describe('PlatformRunner.getRoomMapFromDevice', () => {
7
+ let platform: any;
8
+ let runner: PlatformRunner;
9
+
10
+ beforeEach(() => {
11
+ platform = {
12
+ log: {
13
+ error: jest.fn(),
14
+ debug: jest.fn(),
15
+ notice: jest.fn(),
16
+ },
17
+ roborockService: {
18
+ getRoomMappings: jest.fn(),
19
+ getMapInformation: jest.fn(),
20
+ },
21
+ };
22
+ runner = new PlatformRunner(platform as RoborockMatterbridgePlatform);
23
+ });
24
+
25
+ it('returns RoomMap with roomData from getRoomMappings if available', async () => {
26
+ const device = {
27
+ duid: 'duid1',
28
+ rooms: [
29
+ { id: 1, name: 'Kitchen' },
30
+ { id: 2, name: 'Study' },
31
+ { id: 3, name: 'Living room' },
32
+ { id: 4, name: 'Bedroom' },
33
+ ],
34
+ };
35
+ const roomData = [
36
+ [1, '11100845', 14],
37
+ [2, '11100849', 9],
38
+ [3, '11100842', 6],
39
+ [4, '11100847', 1],
40
+ ];
41
+
42
+ platform.roborockService.getRoomMappings.mockResolvedValue(roomData);
43
+ platform.roborockService.getMapInformation.mockResolvedValue(undefined);
44
+
45
+ const result = await runner.getRoomMapFromDevice(device as any);
46
+
47
+ expect(result).toBeInstanceOf(RoomMap);
48
+ expect(result.rooms.length).toEqual(4);
49
+ });
50
+
51
+ it('returns RoomMap with roomData from getMapInformation if available', async () => {
52
+ const device = {
53
+ duid: 'duid1',
54
+ rooms: [
55
+ { id: 1, name: 'Kitchen' },
56
+ { id: 2, name: 'Study' },
57
+ { id: 3, name: 'Living room' },
58
+ { id: 4, name: 'Bedroom' },
59
+ ],
60
+ };
61
+
62
+ const mapInfo = new MapInfo({
63
+ max_multi_map: 1,
64
+ max_bak_map: 1,
65
+ multi_map_count: 1,
66
+ map_info: [
67
+ {
68
+ mapFlag: 0,
69
+ add_time: 1753281130,
70
+ length: 0,
71
+ name: '',
72
+ bak_maps: [{ mapFlag: 4, add_time: 1753187168 }],
73
+ rooms: [
74
+ { id: 1, tag: 14, iot_name_id: '11100845', iot_name: 'Kitchen' },
75
+ { id: 2, tag: 9, iot_name_id: '11100849', iot_name: 'Study' },
76
+ { id: 3, tag: 6, iot_name_id: '11100842', iot_name: 'Living room' },
77
+ { id: 4, tag: 1, iot_name_id: '11100847', iot_name: 'Bedroom' },
78
+ ],
79
+ },
80
+ ],
81
+ });
82
+
83
+ platform.roborockService.getRoomMappings.mockResolvedValue(undefined);
84
+ platform.roborockService.getMapInformation.mockResolvedValue(mapInfo);
85
+
86
+ const result = await runner.getRoomMapFromDevice(device as any);
87
+
88
+ expect(result).toBeInstanceOf(RoomMap);
89
+ expect(result.rooms.length).toEqual(4);
90
+ });
91
+ });
@@ -134,7 +134,7 @@ describe('LocalNetworkClient', () => {
134
134
  jest.fn();
135
135
  }, 1000);
136
136
  await (client as any).onDisconnect();
137
- expect(mockLogger.notice).toHaveBeenCalled();
137
+ expect(mockLogger.error).toHaveBeenCalled();
138
138
  expect(client['connected']).toBe(false);
139
139
  expect(mockSocket.destroy).toHaveBeenCalled();
140
140
  expect(client['socket']).toBeUndefined();
@@ -151,12 +151,6 @@ describe('LocalNetworkClient', () => {
151
151
  expect(client['connectionListeners'].onError).toHaveBeenCalledWith('duid1', expect.stringContaining('fail'));
152
152
  });
153
153
 
154
- it('onMessage() should log error if no socket', async () => {
155
- client['socket'] = undefined;
156
- await (client as any).onMessage(Buffer.from([1, 2, 3]));
157
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('unable to receive data'));
158
- });
159
-
160
154
  it('onMessage() should log debug if message is empty', async () => {
161
155
  client['socket'] = mockSocket;
162
156
  await (client as any).onMessage(Buffer.alloc(0));