matterbridge-roborock-vacuum-plugin 1.1.0-rc12 → 1.1.0-rc14
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/README.md +1 -1
- package/dist/initialData/getSupportedAreas.js +7 -6
- package/dist/model/RoomMap.js +1 -0
- package/dist/platform.js +4 -4
- package/dist/platformRunner.js +51 -23
- package/dist/roborockCommunication/Zmodel/mapInfo.js +3 -1
- package/dist/roborockCommunication/broadcast/abstractClient.js +1 -1
- package/dist/roborockCommunication/broadcast/client/LocalNetworkClient.js +34 -34
- package/dist/roborockCommunication/broadcast/client/LocalNetworkUDPClient.js +129 -0
- package/dist/roborockCommunication/broadcast/client/MQTTClient.js +51 -24
- package/dist/roborockCommunication/broadcast/clientRouter.js +3 -3
- package/dist/roborockCommunication/broadcast/listener/implementation/chainedConnectionListener.js +7 -2
- package/dist/roborockCommunication/broadcast/listener/implementation/connectionStateListener.js +10 -9
- package/dist/roborockCommunication/broadcast/messageProcessor.js +9 -0
- package/dist/roborockCommunication/broadcast/model/protocol.js +9 -0
- package/dist/roborockCommunication/helper/messageDeserializer.js +1 -1
- package/dist/roborockService.js +25 -6
- package/matterbridge-roborock-vacuum-plugin.config.json +2 -2
- package/matterbridge-roborock-vacuum-plugin.schema.json +10 -37
- package/misc/status.md +119 -0
- package/package.json +2 -1
- package/src/initialData/getSupportedAreas.ts +8 -6
- package/src/model/RoomMap.ts +2 -0
- package/src/platform.ts +5 -4
- package/src/platformRunner.ts +68 -25
- package/src/roborockCommunication/Zmodel/map.ts +1 -0
- package/src/roborockCommunication/Zmodel/mapInfo.ts +5 -4
- package/src/roborockCommunication/broadcast/abstractClient.ts +1 -2
- package/src/roborockCommunication/broadcast/client/LocalNetworkClient.ts +47 -39
- package/src/roborockCommunication/broadcast/client/LocalNetworkUDPClient.ts +157 -0
- package/src/roborockCommunication/broadcast/client/MQTTClient.ts +68 -35
- package/src/roborockCommunication/broadcast/clientRouter.ts +3 -3
- 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 -9
- package/src/roborockCommunication/broadcast/messageProcessor.ts +12 -5
- package/src/roborockCommunication/broadcast/model/protocol.ts +9 -0
- package/src/roborockCommunication/helper/messageDeserializer.ts +2 -3
- package/src/roborockService.ts +27 -7
- package/src/tests/initialData/getSupportedAreas.test.ts +27 -0
- package/src/tests/platformRunner2.test.ts +91 -0
- package/src/tests/roborockCommunication/RESTAPI/roborockAuthenticateApi.test.ts +136 -0
- package/src/tests/roborockCommunication/RESTAPI/roborockIoTApi.test.ts +106 -0
- package/src/tests/roborockCommunication/broadcast/client/LocalNetworkClient.test.ts +3 -11
- package/src/tests/roborockCommunication/broadcast/client/MQTTClient.test.ts +13 -106
- package/src/tests/roborockCommunication/broadcast/clientRouter.test.ts +168 -0
- package/src/tests/roborockCommunication/broadcast/messageProcessor.test.ts +131 -0
- package/src/tests/roborockService.test.ts +102 -2
- package/src/tests/roborockService3.test.ts +133 -0
- package/src/tests/roborockService4.test.ts +76 -0
- package/src/tests/roborockService5.test.ts +79 -0
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ To get the **DUID** for your devices, you have two options:
|
|
|
69
69
|
### 🚧 Project Status
|
|
70
70
|
|
|
71
71
|
- **Under active development**
|
|
72
|
-
- Requires **`matterbridge@3.1.
|
|
72
|
+
- Requires **`matterbridge@3.1.7`**
|
|
73
73
|
- ⚠️ **Known Issue:**
|
|
74
74
|
+ Vacuum may appear as **two devices** in Apple Home
|
|
75
75
|
+ Error: Wrong CRC32 2241274590, expected 0 -> this is normal error and not impact to plugin functionality
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { debugStringify } from 'matterbridge/logger';
|
|
2
2
|
import { randomInt } from 'node:crypto';
|
|
3
|
-
export function getSupportedAreas(
|
|
4
|
-
log?.debug('getSupportedAreas', debugStringify(
|
|
5
|
-
log?.debug('getSupportedAreas', roomMap ? debugStringify(roomMap) : 'undefined');
|
|
6
|
-
if (!
|
|
7
|
-
if (!
|
|
3
|
+
export function getSupportedAreas(vacuumRooms, roomMap, log) {
|
|
4
|
+
log?.debug('getSupportedAreas-vacuum room', debugStringify(vacuumRooms));
|
|
5
|
+
log?.debug('getSupportedAreas-roomMap', roomMap ? debugStringify(roomMap) : 'undefined');
|
|
6
|
+
if (!vacuumRooms || vacuumRooms.length === 0 || !roomMap?.rooms || roomMap.rooms.length == 0) {
|
|
7
|
+
if (!vacuumRooms || vacuumRooms.length === 0) {
|
|
8
8
|
log?.error('No rooms found');
|
|
9
9
|
}
|
|
10
10
|
if (!roomMap || !roomMap.rooms || roomMap.rooms.length == 0) {
|
|
@@ -31,7 +31,7 @@ export function getSupportedAreas(rooms, roomMap, log) {
|
|
|
31
31
|
mapId: null,
|
|
32
32
|
areaInfo: {
|
|
33
33
|
locationInfo: {
|
|
34
|
-
locationName: room.displayName ??
|
|
34
|
+
locationName: room.displayName ?? vacuumRooms.find((r) => r.id == room.globalId || r.id == room.id)?.name ?? `Unknown Room ${randomInt(1000, 9999)}`,
|
|
35
35
|
floorNumber: null,
|
|
36
36
|
areaType: null,
|
|
37
37
|
},
|
|
@@ -39,6 +39,7 @@ export function getSupportedAreas(rooms, roomMap, log) {
|
|
|
39
39
|
},
|
|
40
40
|
};
|
|
41
41
|
});
|
|
42
|
+
log?.debug('getSupportedAreas - supportedAreas', debugStringify(supportedAreas));
|
|
42
43
|
const duplicated = findDuplicatedAreaIds(supportedAreas, log);
|
|
43
44
|
return duplicated
|
|
44
45
|
? [
|
package/dist/model/RoomMap.js
CHANGED
package/dist/platform.js
CHANGED
|
@@ -26,8 +26,8 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
|
|
|
26
26
|
rrHomeId;
|
|
27
27
|
constructor(matterbridge, log, config) {
|
|
28
28
|
super(matterbridge, log, config);
|
|
29
|
-
if (this.verifyMatterbridgeVersion === undefined || typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('3.1.
|
|
30
|
-
throw new Error(`This plugin requires Matterbridge version >= "3.1.
|
|
29
|
+
if (this.verifyMatterbridgeVersion === undefined || typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('3.1.7')) {
|
|
30
|
+
throw new Error(`This plugin requires Matterbridge version >= "3.1.7". Please update Matterbridge from ${this.matterbridge.matterbridgeVersion} to the latest version in the frontend.`);
|
|
31
31
|
}
|
|
32
32
|
this.log.info('Initializing platform:', this.config.name);
|
|
33
33
|
if (config.whiteList === undefined)
|
|
@@ -157,10 +157,10 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
|
|
|
157
157
|
return false;
|
|
158
158
|
}
|
|
159
159
|
if (vacuum.rooms === undefined || vacuum.rooms.length === 0) {
|
|
160
|
-
this.log.
|
|
160
|
+
this.log.notice(`Fetching map information for device: ${vacuum.name} (${vacuum.duid}) to get rooms`);
|
|
161
161
|
const map_info = await this.roborockService.getMapInformation(vacuum.duid);
|
|
162
162
|
const rooms = map_info?.maps?.[0]?.rooms ?? [];
|
|
163
|
-
vacuum.rooms = rooms;
|
|
163
|
+
vacuum.rooms = rooms.map((room) => ({ id: room.id, name: room.displayName }));
|
|
164
164
|
}
|
|
165
165
|
const roomMap = await this.platformRunner.getRoomMapFromDevice(vacuum);
|
|
166
166
|
this.log.debug('Initializing - roomMap: ', debugStringify(roomMap));
|
package/dist/platformRunner.js
CHANGED
|
@@ -35,18 +35,25 @@ export class PlatformRunner {
|
|
|
35
35
|
async getRoomMapFromDevice(device) {
|
|
36
36
|
const platform = this.platform;
|
|
37
37
|
const rooms = device?.rooms ?? [];
|
|
38
|
-
platform.log.
|
|
38
|
+
platform.log.notice('-------------------------------------------0--------------------------------------------------------');
|
|
39
|
+
platform.log.notice(`getRoomMapFromDevice: ${debugStringify(rooms)}`);
|
|
39
40
|
if (device && platform.roborockService) {
|
|
40
41
|
const roomData = await platform.roborockService.getRoomMappings(device.duid);
|
|
41
42
|
if (roomData !== undefined && roomData.length > 0) {
|
|
42
|
-
platform.log.
|
|
43
|
-
|
|
43
|
+
platform.log.notice(`getRoomMapFromDevice - roomData: ${debugStringify(roomData ?? [])}`);
|
|
44
|
+
const roomMap = new RoomMap(roomData ?? [], rooms);
|
|
45
|
+
platform.log.notice(`getRoomMapFromDevice - roomMap: ${debugStringify(roomMap)}`);
|
|
46
|
+
platform.log.notice('-------------------------------------------1--------------------------------------------------------');
|
|
47
|
+
return roomMap;
|
|
44
48
|
}
|
|
45
49
|
const mapInfo = await platform.roborockService.getMapInformation(device.duid);
|
|
50
|
+
platform.log.notice(`getRoomMapFromDevice - mapInfo: ${mapInfo ? debugStringify(mapInfo) : 'undefined'}`);
|
|
46
51
|
if (mapInfo && mapInfo.maps && mapInfo.maps.length > 0) {
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
52
|
+
const roomDataMap = mapInfo.maps[0].rooms.map((r) => [r.id, parseInt(r.iot_name_id), r.tag]);
|
|
53
|
+
const roomMap = new RoomMap(roomDataMap, rooms);
|
|
54
|
+
platform.log.notice(`getRoomMapFromDevice - roomMap: ${debugStringify(roomMap)}`);
|
|
55
|
+
platform.log.notice('-------------------------------------------2--------------------------------------------------------');
|
|
56
|
+
return roomMap;
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
return new RoomMap([], rooms);
|
|
@@ -72,7 +79,7 @@ export class PlatformRunner {
|
|
|
72
79
|
const mapInfo = await platform.roborockService.getMapInformation(robot.device.duid);
|
|
73
80
|
if (mapInfo && mapInfo.maps && mapInfo.maps.length > 0) {
|
|
74
81
|
platform.log.error(`getRoomMap - mapInfo: ${debugStringify(mapInfo.maps)}`);
|
|
75
|
-
const roomDataMap = mapInfo.maps[0].rooms.map((r) => [r.id, parseInt(r.
|
|
82
|
+
const roomDataMap = mapInfo.maps[0].rooms.map((r) => [r.id, parseInt(r.iot_name_id), r.tag]);
|
|
76
83
|
robot.roomInfo = new RoomMap(roomDataMap, rooms);
|
|
77
84
|
}
|
|
78
85
|
}
|
|
@@ -129,21 +136,40 @@ export class PlatformRunner {
|
|
|
129
136
|
if (state) {
|
|
130
137
|
robot.updateAttribute(RvcRunMode.Cluster.id, 'currentMode', getRunningMode(state), platform.log);
|
|
131
138
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const isTargetMappedArea = currentMappedAreas?.some((x) => x.areaId == targetRoom);
|
|
136
|
-
if (targetRoom !== -1 && isTargetMappedArea) {
|
|
137
|
-
this.platform.log.debug(`RoomMap: ${roomMap ? debugStringify(roomMap) : 'undefined'}`);
|
|
138
|
-
this.platform.log.debug(`TargetRoom: ${targetRoom}, room name: ${roomMap?.rooms.find((x) => x.id === targetRoom)?.displayName ?? 'unknown'}`);
|
|
139
|
-
robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', targetRoom, platform.log);
|
|
139
|
+
if (data.state === OperationStatusCode.Idle) {
|
|
140
|
+
const selectedAreas = platform.roborockService?.getSelectedAreas(duid) ?? [];
|
|
141
|
+
robot.updateAttribute(ServiceArea.Cluster.id, 'selectedAreas', selectedAreas, platform.log);
|
|
140
142
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
if (state === RvcRunMode.ModeTag.Cleaning && !data.cleaning_info) {
|
|
144
|
+
robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', null, platform.log);
|
|
145
|
+
robot.updateAttribute(ServiceArea.Cluster.id, 'selectedAreas', [], platform.log);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const currentMappedAreas = this.platform.roborockService?.getSupportedAreas(duid);
|
|
149
|
+
const roomMap = await this.getRoomMap(duid);
|
|
150
|
+
const segment_id = data.cleaning_info?.segment_id ?? -1;
|
|
151
|
+
const target_segment_id = data.cleaning_info?.target_segment_id ?? -1;
|
|
152
|
+
let target_room_id = roomMap?.rooms.find((x) => x.id === segment_id || x.alternativeId === segment_id.toString())?.id ?? -1;
|
|
153
|
+
this.platform.log.debug(`Target segment id: ${segment_id}, targetRoom: ${target_room_id}`);
|
|
154
|
+
const isMappedArea = currentMappedAreas?.some((x) => x.areaId == segment_id);
|
|
155
|
+
if (segment_id !== -1 && isMappedArea) {
|
|
156
|
+
this.platform.log.debug(`RoomMap: ${roomMap ? debugStringify(roomMap) : 'undefined'}`);
|
|
157
|
+
this.platform.log.debug(`Part1: CurrentRoom: ${segment_id}, room name: ${roomMap?.rooms.find((x) => x.id === segment_id || x.alternativeId === segment_id.toString())?.displayName ?? 'unknown'}`);
|
|
158
|
+
robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', segment_id, platform.log);
|
|
159
|
+
}
|
|
160
|
+
if (segment_id == -1) {
|
|
161
|
+
const isTargetMappedArea = currentMappedAreas?.some((x) => x.areaId == target_segment_id);
|
|
162
|
+
target_room_id = roomMap?.rooms.find((x) => x.id == target_segment_id || x.alternativeId === target_segment_id.toString())?.id ?? -1;
|
|
163
|
+
this.platform.log.debug(`Target segment id: ${target_segment_id}, targetRoom: ${target_room_id}`);
|
|
164
|
+
if (target_segment_id !== -1 && isTargetMappedArea) {
|
|
165
|
+
this.platform.log.debug(`RoomMap: ${roomMap ? debugStringify(roomMap) : 'undefined'}`);
|
|
166
|
+
this.platform.log.debug(`Part2: TargetRoom: ${target_segment_id}, room name: ${roomMap?.rooms.find((x) => x.id === target_segment_id || x.alternativeId === segment_id.toString())?.displayName ?? 'unknown'}`);
|
|
167
|
+
robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', target_segment_id, platform.log);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (target_segment_id == -1 && segment_id == -1) {
|
|
171
|
+
robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', null, platform.log);
|
|
172
|
+
}
|
|
147
173
|
}
|
|
148
174
|
if (data.battery) {
|
|
149
175
|
const batteryLevel = data.battery;
|
|
@@ -156,9 +182,11 @@ export class PlatformRunner {
|
|
|
156
182
|
waterFlow: data.cleaning_info?.water_box_status ?? data.water_box_mode,
|
|
157
183
|
distance_off: data.distance_off,
|
|
158
184
|
mopRoute: data.cleaning_info?.mop_mode ?? data.mop_mode,
|
|
185
|
+
segment_id: data.cleaning_info?.segment_id,
|
|
186
|
+
target_segment_id: data.cleaning_info?.target_segment_id,
|
|
159
187
|
};
|
|
160
188
|
this.platform.log.debug(`data: ${debugStringify(data)}`);
|
|
161
|
-
this.platform.log.
|
|
189
|
+
this.platform.log.notice(`currentCleanModeSetting: ${debugStringify(currentCleanModeSetting)}`);
|
|
162
190
|
if (currentCleanModeSetting.mopRoute && currentCleanModeSetting.suctionPower && currentCleanModeSetting.waterFlow) {
|
|
163
191
|
const currentCleanMode = getCurrentCleanModeFunc(deviceData.model, this.platform.enableExperimentalFeature?.advancedFeature?.forceRunAtDefault ?? false)(currentCleanModeSetting);
|
|
164
192
|
this.platform.log.debug(`Current clean mode: ${currentCleanMode}`);
|
|
@@ -248,7 +276,7 @@ export class PlatformRunner {
|
|
|
248
276
|
break;
|
|
249
277
|
}
|
|
250
278
|
default: {
|
|
251
|
-
platform.log.notice(`Unknown message type: ${Protocol[messageType]
|
|
279
|
+
platform.log.notice(`Unknown message type ${messageType}, protocol: ${Protocol[messageType]}, message: ${debugStringify(data)}`);
|
|
252
280
|
break;
|
|
253
281
|
}
|
|
254
282
|
}
|
|
@@ -24,7 +24,7 @@ export class AbstractClient {
|
|
|
24
24
|
this.logger = logger;
|
|
25
25
|
}
|
|
26
26
|
initializeConnectionStateListener() {
|
|
27
|
-
const connectionStateListener = new ConnectionStateListener(this.logger, this, this.clientName, this.
|
|
27
|
+
const connectionStateListener = new ConnectionStateListener(this.logger, this, this.clientName, this.shouldReconnect);
|
|
28
28
|
this.connectionListeners.register(connectionStateListener);
|
|
29
29
|
}
|
|
30
30
|
async get(duid, request) {
|
|
@@ -7,35 +7,35 @@ import { AbstractClient } from '../abstractClient.js';
|
|
|
7
7
|
import { Sequence } from '../../helper/sequence.js';
|
|
8
8
|
import { ChunkBuffer } from '../../helper/chunkBuffer.js';
|
|
9
9
|
export class LocalNetworkClient extends AbstractClient {
|
|
10
|
-
changeToSecureConnection;
|
|
11
10
|
clientName = 'LocalNetworkClient';
|
|
12
11
|
shouldReconnect = true;
|
|
13
12
|
socket = undefined;
|
|
14
13
|
buffer = new ChunkBuffer();
|
|
15
14
|
messageIdSeq;
|
|
16
15
|
pingInterval;
|
|
16
|
+
keepConnectionAliveInterval = undefined;
|
|
17
17
|
duid;
|
|
18
18
|
ip;
|
|
19
|
-
constructor(logger, context, duid, ip
|
|
19
|
+
constructor(logger, context, duid, ip) {
|
|
20
20
|
super(logger, context);
|
|
21
21
|
this.duid = duid;
|
|
22
22
|
this.ip = ip;
|
|
23
23
|
this.messageIdSeq = new Sequence(100000, 999999);
|
|
24
24
|
this.initializeConnectionStateListener();
|
|
25
|
-
this.changeToSecureConnection = inject;
|
|
26
25
|
}
|
|
27
26
|
connect() {
|
|
28
27
|
if (this.socket) {
|
|
29
|
-
this.socket.destroy();
|
|
30
|
-
this.socket = undefined;
|
|
31
28
|
return;
|
|
32
29
|
}
|
|
33
30
|
this.socket = new Socket();
|
|
34
31
|
this.socket.on('close', this.onDisconnect.bind(this));
|
|
35
32
|
this.socket.on('end', this.onEnd.bind(this));
|
|
36
33
|
this.socket.on('error', this.onError.bind(this));
|
|
34
|
+
this.socket.on('connect', this.onConnect.bind(this));
|
|
35
|
+
this.socket.on('timeout', this.onTimeout.bind(this));
|
|
37
36
|
this.socket.on('data', this.onMessage.bind(this));
|
|
38
|
-
this.socket.connect(58867, this.ip
|
|
37
|
+
this.socket.connect(58867, this.ip);
|
|
38
|
+
this.keepConnectionAlive();
|
|
39
39
|
}
|
|
40
40
|
async disconnect() {
|
|
41
41
|
if (!this.socket) {
|
|
@@ -50,7 +50,7 @@ export class LocalNetworkClient extends AbstractClient {
|
|
|
50
50
|
}
|
|
51
51
|
async send(duid, request) {
|
|
52
52
|
if (!this.socket || !this.connected) {
|
|
53
|
-
this.logger.error(`${duid}: socket is not online, ${debugStringify(request)}`);
|
|
53
|
+
this.logger.error(`${duid}: socket is not online, , ${debugStringify(request)}`);
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
56
|
const localRequest = request.toLocalRequest();
|
|
@@ -59,16 +59,16 @@ export class LocalNetworkClient extends AbstractClient {
|
|
|
59
59
|
this.socket.write(this.wrapWithLengthData(message.buffer));
|
|
60
60
|
}
|
|
61
61
|
async onConnect() {
|
|
62
|
-
this.connected
|
|
63
|
-
|
|
64
|
-
this.logger.debug(`${this.duid} connected to ${this.ip}, address: ${address ? debugStringify(address) : 'undefined'}`);
|
|
62
|
+
this.logger.debug(` [LocalNetworkClient]: ${this.duid} connected to ${this.ip}`);
|
|
63
|
+
this.logger.debug(` [LocalNetworkClient]: ${this.duid} socket writable: ${this.socket?.writable}, readable: ${this.socket?.readable}`);
|
|
65
64
|
await this.sendHelloMessage();
|
|
66
65
|
this.pingInterval = setInterval(this.sendPingRequest.bind(this), 5000);
|
|
67
|
-
|
|
66
|
+
this.connected = true;
|
|
68
67
|
this.retryCount = 0;
|
|
68
|
+
await this.connectionListeners.onConnected(this.duid);
|
|
69
69
|
}
|
|
70
|
-
async
|
|
71
|
-
this.logger.
|
|
70
|
+
async onDisconnect(hadError) {
|
|
71
|
+
this.logger.info(` [LocalNetworkClient]: ${this.duid} socket disconnected. Had error: ${hadError}`);
|
|
72
72
|
this.connected = false;
|
|
73
73
|
if (this.socket) {
|
|
74
74
|
this.socket.destroy();
|
|
@@ -77,32 +77,20 @@ export class LocalNetworkClient extends AbstractClient {
|
|
|
77
77
|
if (this.pingInterval) {
|
|
78
78
|
clearInterval(this.pingInterval);
|
|
79
79
|
}
|
|
80
|
-
await this.connectionListeners.onDisconnected(this.duid);
|
|
80
|
+
await this.connectionListeners.onDisconnected(this.duid, 'Socket disconnected. Had no error.');
|
|
81
81
|
}
|
|
82
|
-
async
|
|
83
|
-
this.logger.
|
|
84
|
-
this.
|
|
85
|
-
if (this.socket) {
|
|
86
|
-
this.socket.destroy();
|
|
87
|
-
this.socket = undefined;
|
|
88
|
-
}
|
|
89
|
-
if (this.pingInterval) {
|
|
90
|
-
clearInterval(this.pingInterval);
|
|
91
|
-
}
|
|
92
|
-
await this.connectionListeners.onDisconnected(this.duid);
|
|
82
|
+
async onError(error) {
|
|
83
|
+
this.logger.error(` [LocalNetworkClient]: Socket error for ${this.duid}: ${error.message}`);
|
|
84
|
+
await this.connectionListeners.onError(this.duid, error.message);
|
|
93
85
|
}
|
|
94
|
-
async
|
|
95
|
-
this.logger.error(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.socket = undefined;
|
|
100
|
-
}
|
|
101
|
-
await this.connectionListeners.onError(this.duid, result.toString());
|
|
86
|
+
async onTimeout() {
|
|
87
|
+
this.logger.error(` [LocalNetworkClient]: Socket for ${this.duid} timed out.`);
|
|
88
|
+
}
|
|
89
|
+
async onEnd() {
|
|
90
|
+
this.logger.debug(` [LocalNetworkClient]: ${this.duid} socket ended.`);
|
|
102
91
|
}
|
|
103
92
|
async onMessage(message) {
|
|
104
93
|
if (!this.socket) {
|
|
105
|
-
this.logger.error('unable to receive data if there is no socket available');
|
|
106
94
|
return;
|
|
107
95
|
}
|
|
108
96
|
if (!message || message.length == 0) {
|
|
@@ -170,4 +158,16 @@ export class LocalNetworkClient extends AbstractClient {
|
|
|
170
158
|
});
|
|
171
159
|
await this.send(this.duid, request);
|
|
172
160
|
}
|
|
161
|
+
keepConnectionAlive() {
|
|
162
|
+
if (this.keepConnectionAliveInterval) {
|
|
163
|
+
clearTimeout(this.keepConnectionAliveInterval);
|
|
164
|
+
this.keepConnectionAliveInterval.unref();
|
|
165
|
+
}
|
|
166
|
+
this.keepConnectionAliveInterval = setInterval(() => {
|
|
167
|
+
if (this.socket === undefined || !this.connected || !this.socket.writable || this.socket.readable) {
|
|
168
|
+
this.logger.debug(` [LocalNetworkClient]: ${this.duid} socket is not writable or readable, reconnecting...`);
|
|
169
|
+
this.connect();
|
|
170
|
+
}
|
|
171
|
+
}, 60 * 60 * 1000);
|
|
172
|
+
}
|
|
173
173
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as dgram from 'node:dgram';
|
|
2
|
+
import { Parser } from 'binary-parser';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import CRC32 from 'crc-32';
|
|
5
|
+
import { AbstractClient } from '../abstractClient.js';
|
|
6
|
+
export class LocalNetworkUDPClient extends AbstractClient {
|
|
7
|
+
clientName = 'LocalNetworkUDPClient';
|
|
8
|
+
shouldReconnect = false;
|
|
9
|
+
PORT = 58866;
|
|
10
|
+
server = undefined;
|
|
11
|
+
V10Parser;
|
|
12
|
+
L01Parser;
|
|
13
|
+
constructor(logger, context) {
|
|
14
|
+
super(logger, context);
|
|
15
|
+
this.V10Parser = new Parser()
|
|
16
|
+
.endianness('big')
|
|
17
|
+
.string('version', { length: 3 })
|
|
18
|
+
.uint32('seq')
|
|
19
|
+
.uint16('protocol')
|
|
20
|
+
.uint16('payloadLen')
|
|
21
|
+
.buffer('payload', { length: 'payloadLen' })
|
|
22
|
+
.uint32('crc32');
|
|
23
|
+
this.L01Parser = new Parser()
|
|
24
|
+
.endianness('big')
|
|
25
|
+
.string('version', { length: 3 })
|
|
26
|
+
.string('field1', { length: 4 })
|
|
27
|
+
.string('field2', { length: 2 })
|
|
28
|
+
.uint16('payloadLen')
|
|
29
|
+
.buffer('payload', { length: 'payloadLen' })
|
|
30
|
+
.uint32('crc32');
|
|
31
|
+
this.logger = logger;
|
|
32
|
+
}
|
|
33
|
+
connect() {
|
|
34
|
+
try {
|
|
35
|
+
this.server = dgram.createSocket('udp4');
|
|
36
|
+
this.server.bind(this.PORT);
|
|
37
|
+
this.server.on('message', this.onMessage.bind(this));
|
|
38
|
+
this.server.on('error', this.onError.bind(this));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
this.logger.error(`Failed to create UDP socket: ${err}`);
|
|
42
|
+
this.server = undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
disconnect() {
|
|
46
|
+
if (this.server) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
this.server?.close(() => {
|
|
49
|
+
this.server = undefined;
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return Promise.resolve();
|
|
55
|
+
}
|
|
56
|
+
send(duid, request) {
|
|
57
|
+
this.logger.debug(`Sending request to ${duid}: ${JSON.stringify(request)}`);
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
}
|
|
60
|
+
async onError(result) {
|
|
61
|
+
this.logger.error(`UDP socket error: ${result}`);
|
|
62
|
+
if (this.server) {
|
|
63
|
+
this.server.close();
|
|
64
|
+
this.server = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async onMessage(buffer) {
|
|
68
|
+
const message = await this.deserializeMessage(buffer);
|
|
69
|
+
this.logger.debug('Received message: ' + JSON.stringify(message));
|
|
70
|
+
}
|
|
71
|
+
async deserializeMessage(buffer) {
|
|
72
|
+
const version = buffer.toString('latin1', 0, 3);
|
|
73
|
+
if (version !== '1.0' && version !== 'L01' && version !== 'A01') {
|
|
74
|
+
throw new Error('unknown protocol version ' + version);
|
|
75
|
+
}
|
|
76
|
+
let data;
|
|
77
|
+
switch (version) {
|
|
78
|
+
case '1.0':
|
|
79
|
+
data = await this.deserializeV10Message(buffer);
|
|
80
|
+
return JSON.parse(data);
|
|
81
|
+
case 'L01':
|
|
82
|
+
data = await this.deserializeL01Message(buffer);
|
|
83
|
+
return JSON.parse(data);
|
|
84
|
+
case 'A01':
|
|
85
|
+
return undefined;
|
|
86
|
+
default:
|
|
87
|
+
throw new Error('unknown protocol version ' + version);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async deserializeV10Message(message) {
|
|
91
|
+
const data = this.V10Parser.parse(message);
|
|
92
|
+
const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
|
|
93
|
+
const expectedCrc32 = data.crc32;
|
|
94
|
+
if (crc32 != expectedCrc32) {
|
|
95
|
+
throw new Error('wrong CRC32 ' + crc32 + ', expected ' + expectedCrc32);
|
|
96
|
+
}
|
|
97
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from('qWKYcdQWrbm9hPqe', 'utf8'), null);
|
|
98
|
+
decipher.setAutoPadding(false);
|
|
99
|
+
let decrypted = decipher.update(data.payload, 'binary', 'utf8');
|
|
100
|
+
decrypted += decipher.final('utf8');
|
|
101
|
+
const paddingLength = decrypted.charCodeAt(decrypted.length - 1);
|
|
102
|
+
return decrypted.slice(0, -paddingLength);
|
|
103
|
+
}
|
|
104
|
+
async deserializeL01Message(message) {
|
|
105
|
+
const data = this.L01Parser.parse(message);
|
|
106
|
+
const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
|
|
107
|
+
const expectedCrc32 = data.crc32;
|
|
108
|
+
if (crc32 != expectedCrc32) {
|
|
109
|
+
throw new Error('wrong CRC32 ' + crc32 + ', expected ' + expectedCrc32);
|
|
110
|
+
}
|
|
111
|
+
const payload = data.payload;
|
|
112
|
+
const key = crypto.createHash('sha256').update(Buffer.from('qWKYcdQWrbm9hPqe', 'utf8')).digest();
|
|
113
|
+
const digestInput = message.subarray(0, 9);
|
|
114
|
+
const digest = crypto.createHash('sha256').update(digestInput).digest();
|
|
115
|
+
const iv = digest.subarray(0, 12);
|
|
116
|
+
const tag = payload.subarray(payload.length - 16);
|
|
117
|
+
const ciphertext = payload.subarray(0, payload.length - 16);
|
|
118
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
119
|
+
decipher.setAuthTag(tag);
|
|
120
|
+
try {
|
|
121
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
122
|
+
return decrypted.toString('utf8');
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
const message = e && typeof e === 'object' && 'message' in e ? e.message : String(e);
|
|
126
|
+
throw new Error('failed to decrypt: ' + message + ' / iv: ' + iv.toString('hex') + ' / tag: ' + tag.toString('hex') + ' / encrypted: ' + ciphertext.toString('hex'));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -3,28 +3,25 @@ import * as CryptoUtils from '../../helper/cryptoHelper.js';
|
|
|
3
3
|
import { AbstractClient } from '../abstractClient.js';
|
|
4
4
|
import { debugStringify } from 'matterbridge/logger';
|
|
5
5
|
export class MQTTClient extends AbstractClient {
|
|
6
|
-
changeToSecureConnection;
|
|
7
6
|
clientName = 'MQTTClient';
|
|
8
7
|
shouldReconnect = false;
|
|
9
8
|
rriot;
|
|
10
9
|
mqttUsername;
|
|
11
10
|
mqttPassword;
|
|
12
|
-
|
|
11
|
+
mqttClient = undefined;
|
|
12
|
+
keepConnectionAliveInterval = undefined;
|
|
13
13
|
constructor(logger, context, userdata) {
|
|
14
14
|
super(logger, context);
|
|
15
15
|
this.rriot = userdata.rriot;
|
|
16
16
|
this.mqttUsername = CryptoUtils.md5hex(userdata.rriot.u + ':' + userdata.rriot.k).substring(2, 10);
|
|
17
17
|
this.mqttPassword = CryptoUtils.md5hex(userdata.rriot.s + ':' + userdata.rriot.k).substring(16);
|
|
18
18
|
this.initializeConnectionStateListener();
|
|
19
|
-
this.changeToSecureConnection = (duid) => {
|
|
20
|
-
this.logger.info(`MqttClient for ${duid} has been disconnected`);
|
|
21
|
-
};
|
|
22
19
|
}
|
|
23
20
|
connect() {
|
|
24
|
-
if (this.
|
|
21
|
+
if (this.mqttClient) {
|
|
25
22
|
return;
|
|
26
23
|
}
|
|
27
|
-
this.
|
|
24
|
+
this.mqttClient = mqtt.connect(this.rriot.r.m, {
|
|
28
25
|
clientId: this.mqttUsername,
|
|
29
26
|
username: this.mqttUsername,
|
|
30
27
|
password: this.mqttPassword,
|
|
@@ -32,34 +29,52 @@ export class MQTTClient extends AbstractClient {
|
|
|
32
29
|
log: () => {
|
|
33
30
|
},
|
|
34
31
|
});
|
|
35
|
-
this.
|
|
36
|
-
this.
|
|
37
|
-
this.
|
|
38
|
-
this.
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
32
|
+
this.mqttClient.on('connect', this.onConnect.bind(this));
|
|
33
|
+
this.mqttClient.on('error', this.onError.bind(this));
|
|
34
|
+
this.mqttClient.on('reconnect', this.onReconnect.bind(this));
|
|
35
|
+
this.mqttClient.on('close', this.onClose.bind(this));
|
|
36
|
+
this.mqttClient.on('disconnect', this.onDisconnect.bind(this));
|
|
37
|
+
this.mqttClient.on('offline', this.onOffline.bind(this));
|
|
38
|
+
this.mqttClient.on('message', this.onMessage.bind(this));
|
|
39
|
+
this.keepConnectionAlive();
|
|
42
40
|
}
|
|
43
41
|
async disconnect() {
|
|
44
|
-
if (!this.
|
|
42
|
+
if (!this.mqttClient || !this.connected) {
|
|
45
43
|
return;
|
|
46
44
|
}
|
|
47
45
|
try {
|
|
48
46
|
this.isInDisconnectingStep = true;
|
|
49
|
-
this.
|
|
47
|
+
this.mqttClient.end();
|
|
50
48
|
}
|
|
51
49
|
catch (error) {
|
|
52
50
|
this.logger.error('MQTT client failed to disconnect with error: ' + error);
|
|
53
51
|
}
|
|
54
52
|
}
|
|
55
53
|
async send(duid, request) {
|
|
56
|
-
if (!this.
|
|
54
|
+
if (!this.mqttClient || !this.connected) {
|
|
57
55
|
this.logger.error(`${duid}: mqtt is not available, ${debugStringify(request)}`);
|
|
58
56
|
return;
|
|
59
57
|
}
|
|
60
58
|
const mqttRequest = request.toMqttRequest();
|
|
61
59
|
const message = this.serializer.serialize(duid, mqttRequest);
|
|
62
|
-
this.
|
|
60
|
+
this.logger.debug(`MQTTClient sending message to ${duid}: ${debugStringify(mqttRequest)}`);
|
|
61
|
+
this.mqttClient.publish(`rr/m/i/${this.rriot.u}/${this.mqttUsername}/${duid}`, message.buffer, { qos: 1 });
|
|
62
|
+
this.logger.debug(`MQTTClient published message to topic: rr/m/i/${this.rriot.u}/${this.mqttUsername}/${duid}`);
|
|
63
|
+
}
|
|
64
|
+
keepConnectionAlive() {
|
|
65
|
+
if (this.keepConnectionAliveInterval) {
|
|
66
|
+
clearTimeout(this.keepConnectionAliveInterval);
|
|
67
|
+
this.keepConnectionAliveInterval.unref();
|
|
68
|
+
}
|
|
69
|
+
this.keepConnectionAliveInterval = setInterval(() => {
|
|
70
|
+
if (this.mqttClient) {
|
|
71
|
+
this.mqttClient.end();
|
|
72
|
+
this.mqttClient.reconnect();
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
this.connect();
|
|
76
|
+
}
|
|
77
|
+
}, 30 * 60 * 1000);
|
|
63
78
|
}
|
|
64
79
|
async onConnect(result) {
|
|
65
80
|
if (!result) {
|
|
@@ -70,30 +85,42 @@ export class MQTTClient extends AbstractClient {
|
|
|
70
85
|
this.subscribeToQueue();
|
|
71
86
|
}
|
|
72
87
|
subscribeToQueue() {
|
|
73
|
-
if (!this.
|
|
88
|
+
if (!this.mqttClient || !this.connected) {
|
|
74
89
|
return;
|
|
75
90
|
}
|
|
76
|
-
this.
|
|
91
|
+
this.mqttClient.subscribe('rr/m/o/' + this.rriot.u + '/' + this.mqttUsername + '/#', this.onSubscribe.bind(this));
|
|
77
92
|
}
|
|
78
|
-
async onSubscribe(err) {
|
|
93
|
+
async onSubscribe(err, granted) {
|
|
79
94
|
if (!err) {
|
|
95
|
+
this.logger.info('onSubscribe: ' + JSON.stringify(granted));
|
|
80
96
|
return;
|
|
81
97
|
}
|
|
82
|
-
this.logger.error('failed to subscribe
|
|
98
|
+
this.logger.error('failed to subscribe: ' + err);
|
|
83
99
|
this.connected = false;
|
|
84
|
-
await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername);
|
|
100
|
+
await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'Failed to subscribe to the queue: ' + err.toString());
|
|
85
101
|
}
|
|
86
102
|
async onDisconnect() {
|
|
87
103
|
this.connected = false;
|
|
88
|
-
await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername);
|
|
104
|
+
await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'Disconnected from MQTT broker');
|
|
89
105
|
}
|
|
90
106
|
async onError(result) {
|
|
91
107
|
this.logger.error('MQTT connection error: ' + result);
|
|
92
108
|
this.connected = false;
|
|
93
109
|
await this.connectionListeners.onError('mqtt-' + this.mqttUsername, result.toString());
|
|
94
110
|
}
|
|
111
|
+
async onClose() {
|
|
112
|
+
if (this.connected) {
|
|
113
|
+
await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'MQTT connection closed');
|
|
114
|
+
}
|
|
115
|
+
this.connected = false;
|
|
116
|
+
}
|
|
117
|
+
async onOffline() {
|
|
118
|
+
this.connected = false;
|
|
119
|
+
await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'MQTT connection offline');
|
|
120
|
+
}
|
|
95
121
|
onReconnect() {
|
|
96
122
|
this.subscribeToQueue();
|
|
123
|
+
this.connectionListeners.onReconnect('mqtt-' + this.mqttUsername, 'Reconnected to MQTT broker');
|
|
97
124
|
}
|
|
98
125
|
async onMessage(topic, message) {
|
|
99
126
|
if (!message) {
|
|
@@ -7,9 +7,9 @@ export class ClientRouter {
|
|
|
7
7
|
connectionListeners = new ChainedConnectionListener();
|
|
8
8
|
messageListeners = new ChainedMessageListener();
|
|
9
9
|
context;
|
|
10
|
-
mqttClient;
|
|
11
10
|
localClients = new Map();
|
|
12
11
|
logger;
|
|
12
|
+
mqttClient;
|
|
13
13
|
constructor(logger, userdata) {
|
|
14
14
|
this.context = new MessageContext(userdata);
|
|
15
15
|
this.logger = logger;
|
|
@@ -20,8 +20,8 @@ export class ClientRouter {
|
|
|
20
20
|
registerDevice(duid, localKey, pv) {
|
|
21
21
|
this.context.registerDevice(duid, localKey, pv);
|
|
22
22
|
}
|
|
23
|
-
registerClient(duid, ip
|
|
24
|
-
const localClient = new LocalNetworkClient(this.logger, this.context, duid, ip
|
|
23
|
+
registerClient(duid, ip) {
|
|
24
|
+
const localClient = new LocalNetworkClient(this.logger, this.context, duid, ip);
|
|
25
25
|
localClient.registerConnectionListener(this.connectionListeners);
|
|
26
26
|
localClient.registerMessageListener(this.messageListeners);
|
|
27
27
|
this.localClients.set(duid, localClient);
|