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.
- package/dist/initialData/getSupportedAreas.js +12 -6
- package/dist/model/RoomMap.js +1 -0
- package/dist/platform.js +4 -3
- package/dist/platformRunner.js +67 -12
- package/dist/roborockCommunication/Zmodel/mapInfo.js +4 -2
- package/dist/roborockCommunication/broadcast/abstractClient.js +1 -1
- package/dist/roborockCommunication/broadcast/client/LocalNetworkClient.js +25 -25
- package/dist/roborockCommunication/broadcast/client/LocalNetworkUDPClient.js +129 -0
- package/dist/roborockCommunication/broadcast/client/MQTTClient.js +53 -25
- package/dist/roborockCommunication/broadcast/clientRouter.js +2 -2
- package/dist/roborockCommunication/broadcast/listener/implementation/chainedConnectionListener.js +7 -2
- package/dist/roborockCommunication/broadcast/listener/implementation/connectionStateListener.js +9 -6
- package/dist/roborockCommunication/helper/messageDeserializer.js +1 -1
- package/dist/roborockService.js +5 -7
- package/matterbridge-roborock-vacuum-plugin.config.json +1 -1
- package/matterbridge-roborock-vacuum-plugin.schema.json +1 -1
- package/package.json +2 -1
- package/src/initialData/getSupportedAreas.ts +15 -6
- package/src/model/RoomMap.ts +2 -0
- package/src/platform.ts +5 -3
- package/src/platformRunner.ts +88 -13
- package/src/roborockCommunication/Zmodel/device.ts +0 -1
- package/src/roborockCommunication/Zmodel/map.ts +1 -0
- package/src/roborockCommunication/Zmodel/mapInfo.ts +6 -5
- package/src/roborockCommunication/broadcast/abstractClient.ts +1 -2
- package/src/roborockCommunication/broadcast/client/LocalNetworkClient.ts +34 -28
- package/src/roborockCommunication/broadcast/client/LocalNetworkUDPClient.ts +157 -0
- package/src/roborockCommunication/broadcast/client/MQTTClient.ts +69 -36
- package/src/roborockCommunication/broadcast/clientRouter.ts +2 -2
- package/src/roborockCommunication/broadcast/listener/abstractConnectionListener.ts +2 -1
- package/src/roborockCommunication/broadcast/listener/implementation/chainedConnectionListener.ts +8 -2
- package/src/roborockCommunication/broadcast/listener/implementation/connectionStateListener.ts +11 -6
- package/src/roborockCommunication/helper/messageDeserializer.ts +2 -3
- package/src/roborockService.ts +6 -9
- package/src/tests/initialData/getSupportedAreas.test.ts +27 -0
- package/src/tests/platformRunner2.test.ts +91 -0
- package/src/tests/roborockCommunication/broadcast/client/LocalNetworkClient.test.ts +1 -7
- package/src/tests/roborockCommunication/broadcast/client/MQTTClient.test.ts +13 -106
- 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
|
|
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.
|
|
34
|
-
return;
|
|
30
|
+
if (this.mqttClient) {
|
|
31
|
+
return; // Already connected
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
this.
|
|
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
|
-
|
|
39
|
+
log: (...args: unknown[]) => {
|
|
40
|
+
this.logger.debug(`MQTTClient args: ${debugStringify(args)}`);
|
|
44
41
|
},
|
|
45
42
|
});
|
|
46
43
|
|
|
47
|
-
this.
|
|
48
|
-
this.
|
|
49
|
-
this.
|
|
50
|
-
this.
|
|
51
|
-
this.
|
|
52
|
-
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
|
-
|
|
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.
|
|
56
|
+
if (!this.mqttClient || !this.connected) {
|
|
60
57
|
return;
|
|
61
58
|
}
|
|
62
59
|
try {
|
|
63
60
|
this.isInDisconnectingStep = true;
|
|
64
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
109
|
+
private subscribeToQueue(): void {
|
|
110
|
+
if (!this.mqttClient || !this.connected) {
|
|
94
111
|
return;
|
|
95
112
|
}
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
|
36
|
-
const localClient = new LocalNetworkClient(this.logger, this.context, duid, ip
|
|
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
|
}
|
package/src/roborockCommunication/broadcast/listener/implementation/chainedConnectionListener.ts
CHANGED
|
@@ -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
|
}
|
package/src/roborockCommunication/broadcast/listener/implementation/connectionStateListener.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
32
|
+
.endianness('big')
|
|
34
33
|
.string('version', {
|
|
35
34
|
length: 3,
|
|
36
35
|
})
|
package/src/roborockService.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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.
|
|
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));
|