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
@@ -1,10 +1,15 @@
1
1
  import { debugStringify } from 'matterbridge/logger';
2
2
  import { randomInt } from 'node:crypto';
3
- export function getSupportedAreas(rooms, roomMap, log) {
4
- log?.debug('getSupportedAreas', debugStringify(rooms));
5
- log?.debug('getSupportedAreas', roomMap ? debugStringify(roomMap) : 'undefined');
6
- if (!rooms || rooms.length === 0 || !roomMap?.rooms || roomMap.rooms.length == 0) {
7
- log?.error('No rooms found');
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
+ log?.error('No rooms found');
9
+ }
10
+ if (!roomMap || !roomMap.rooms || roomMap.rooms.length == 0) {
11
+ log?.error('No room map found');
12
+ }
8
13
  return [
9
14
  {
10
15
  areaId: 1,
@@ -26,7 +31,7 @@ export function getSupportedAreas(rooms, roomMap, log) {
26
31
  mapId: null,
27
32
  areaInfo: {
28
33
  locationInfo: {
29
- locationName: room.displayName ?? rooms.find((r) => r.id == room.globalId)?.name ?? `Unknown Room ${randomInt(1000, 9999)}`,
34
+ locationName: room.displayName ?? vacuumRooms.find((r) => r.id == room.globalId || r.id == room.id)?.name ?? `Unknown Room ${randomInt(1000, 9999)}`,
30
35
  floorNumber: null,
31
36
  areaType: null,
32
37
  },
@@ -34,6 +39,7 @@ export function getSupportedAreas(rooms, roomMap, log) {
34
39
  },
35
40
  };
36
41
  });
42
+ log?.debug('getSupportedAreas - supportedAreas', debugStringify(supportedAreas));
37
43
  const duplicated = findDuplicatedAreaIds(supportedAreas, log);
38
44
  return duplicated
39
45
  ? [
@@ -6,6 +6,7 @@ export default class RoomMap {
6
6
  id: entry[0],
7
7
  globalId: Number(entry[1]),
8
8
  displayName: rooms.find((r) => Number(r.id) == Number(entry[1]))?.name,
9
+ alternativeId: `${entry[0]}${entry[2]}`,
9
10
  };
10
11
  });
11
12
  }
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.5')) {
30
- throw new Error(`This plugin requires Matterbridge version >= "3.1.5". Please update Matterbridge from ${this.matterbridge.matterbridgeVersion} to the latest version in the frontend.`);
29
+ if (this.verifyMatterbridgeVersion === undefined || typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('3.1.6')) {
30
+ throw new Error(`This plugin requires Matterbridge version >= "3.1.6". 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,9 +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.notice(`Fetching map information for device: ${vacuum.name} (${vacuum.duid}) to get rooms`);
160
161
  const map_info = await this.roborockService.getMapInformation(vacuum.duid);
161
162
  const rooms = map_info?.maps?.[0]?.rooms ?? [];
162
- vacuum.rooms = rooms;
163
+ vacuum.rooms = rooms.map((room) => ({ id: room.id, name: room.displayName }));
163
164
  }
164
165
  const roomMap = await this.platformRunner.getRoomMapFromDevice(vacuum);
165
166
  this.log.debug('Initializing - roomMap: ', debugStringify(roomMap));
@@ -35,9 +35,26 @@ export class PlatformRunner {
35
35
  async getRoomMapFromDevice(device) {
36
36
  const platform = this.platform;
37
37
  const rooms = device?.rooms ?? [];
38
+ platform.log.notice('-------------------------------------------0--------------------------------------------------------');
39
+ platform.log.notice(`getRoomMapFromDevice: ${debugStringify(rooms)}`);
38
40
  if (device && platform.roborockService) {
39
41
  const roomData = await platform.roborockService.getRoomMappings(device.duid);
40
- return new RoomMap(roomData ?? [], rooms);
42
+ if (roomData !== undefined && roomData.length > 0) {
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;
48
+ }
49
+ const mapInfo = await platform.roborockService.getMapInformation(device.duid);
50
+ platform.log.notice(`getRoomMapFromDevice - mapInfo: ${mapInfo ? debugStringify(mapInfo) : 'undefined'}`);
51
+ if (mapInfo && mapInfo.maps && mapInfo.maps.length > 0) {
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;
57
+ }
41
58
  }
42
59
  return new RoomMap([], rooms);
43
60
  }
@@ -53,8 +70,18 @@ export class PlatformRunner {
53
70
  const rooms = robot.device.rooms ?? [];
54
71
  if (robot.roomInfo === undefined) {
55
72
  const roomData = await platform.roborockService.getRoomMappings(robot.device.duid);
56
- robot.roomInfo = new RoomMap(roomData ?? [], rooms);
57
- return robot.roomInfo;
73
+ if (roomData !== undefined && roomData.length > 0) {
74
+ robot.roomInfo = new RoomMap(roomData ?? [], rooms);
75
+ return robot.roomInfo;
76
+ }
77
+ }
78
+ if (robot.roomInfo === undefined) {
79
+ const mapInfo = await platform.roborockService.getMapInformation(robot.device.duid);
80
+ if (mapInfo && mapInfo.maps && mapInfo.maps.length > 0) {
81
+ platform.log.error(`getRoomMap - mapInfo: ${debugStringify(mapInfo.maps)}`);
82
+ const roomDataMap = mapInfo.maps[0].rooms.map((r) => [r.id, parseInt(r.iot_name_id), r.tag]);
83
+ robot.roomInfo = new RoomMap(roomDataMap, rooms);
84
+ }
58
85
  }
59
86
  return robot.roomInfo;
60
87
  }
@@ -109,14 +136,40 @@ export class PlatformRunner {
109
136
  if (state) {
110
137
  robot.updateAttribute(RvcRunMode.Cluster.id, 'currentMode', getRunningMode(state), platform.log);
111
138
  }
112
- const currentRoom = data.cleaning_info?.segment_id ?? -1;
113
- const currentMappedAreas = this.platform.roborockService?.getSupportedAreas(duid);
114
- const isMappedArea = currentMappedAreas?.some((x) => x.areaId == currentRoom);
115
- if (currentRoom !== -1 && isMappedArea) {
139
+ if (data.state === OperationStatusCode.Idle) {
140
+ const selectedAreas = platform.roborockService?.getSelectedAreas(duid) ?? [];
141
+ robot.updateAttribute(ServiceArea.Cluster.id, 'selectedAreas', selectedAreas, platform.log);
142
+ }
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);
116
149
  const roomMap = await this.getRoomMap(duid);
117
- this.platform.log.debug(`RoomMap: ${roomMap ? debugStringify(roomMap) : 'undefined'}`);
118
- this.platform.log.debug('CurrentRoom:', currentRoom);
119
- robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', currentRoom, platform.log);
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
+ }
120
173
  }
121
174
  if (data.battery) {
122
175
  const batteryLevel = data.battery;
@@ -129,9 +182,11 @@ export class PlatformRunner {
129
182
  waterFlow: data.cleaning_info?.water_box_status ?? data.water_box_mode,
130
183
  distance_off: data.distance_off,
131
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,
132
187
  };
133
188
  this.platform.log.debug(`data: ${debugStringify(data)}`);
134
- this.platform.log.debug(`currentCleanModeSetting: ${debugStringify(currentCleanModeSetting)}`);
189
+ this.platform.log.notice(`currentCleanModeSetting: ${debugStringify(currentCleanModeSetting)}`);
135
190
  if (currentCleanModeSetting.mopRoute && currentCleanModeSetting.suctionPower && currentCleanModeSetting.waterFlow) {
136
191
  const currentCleanMode = getCurrentCleanModeFunc(deviceData.model, this.platform.enableExperimentalFeature?.advancedFeature?.forceRunAtDefault ?? false)(currentCleanModeSetting);
137
192
  this.platform.log.debug(`Current clean mode: ${currentCleanMode}`);
@@ -221,7 +276,7 @@ export class PlatformRunner {
221
276
  break;
222
277
  }
223
278
  default: {
224
- platform.log.notice(`Unknown message type: ${Protocol[messageType] ?? messageType} ,`, debugStringify(data));
279
+ platform.log.notice(`Unknown message type ${messageType}, protocol: ${Protocol[messageType]}, message: ${debugStringify(data)}`);
225
280
  break;
226
281
  }
227
282
  }
@@ -9,8 +9,10 @@ export class MapInfo {
9
9
  rooms: map.rooms && map.rooms.length > 0
10
10
  ? map.rooms.map((room) => {
11
11
  return {
12
- id: room.iot_name_id,
13
- name: room.iot_name,
12
+ id: room.id,
13
+ iot_name_id: room.iot_name_id,
14
+ tag: room.tag,
15
+ displayName: room.iot_name,
14
16
  };
15
17
  })
16
18
  : [],
@@ -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.changeToSecureConnection, this.shouldReconnect);
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,7 +7,6 @@ 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;
@@ -16,26 +15,25 @@ export class LocalNetworkClient extends AbstractClient {
16
15
  pingInterval;
17
16
  duid;
18
17
  ip;
19
- constructor(logger, context, duid, ip, inject) {
18
+ constructor(logger, context, duid, ip) {
20
19
  super(logger, context);
21
20
  this.duid = duid;
22
21
  this.ip = ip;
23
22
  this.messageIdSeq = new Sequence(100000, 999999);
24
23
  this.initializeConnectionStateListener();
25
- this.changeToSecureConnection = inject;
26
24
  }
27
25
  connect() {
28
26
  if (this.socket) {
29
- this.socket.destroy();
30
- this.socket = undefined;
31
27
  return;
32
28
  }
33
29
  this.socket = new Socket();
34
30
  this.socket.on('close', this.onDisconnect.bind(this));
35
31
  this.socket.on('end', this.onEnd.bind(this));
36
32
  this.socket.on('error', this.onError.bind(this));
33
+ this.socket.on('connect', this.onConnect.bind(this));
34
+ this.socket.on('timeout', this.onTimeout.bind(this));
37
35
  this.socket.on('data', this.onMessage.bind(this));
38
- this.socket.connect(58867, this.ip, this.onConnect.bind(this));
36
+ this.socket.connect(58867, this.ip);
39
37
  }
40
38
  async disconnect() {
41
39
  if (!this.socket) {
@@ -50,7 +48,7 @@ export class LocalNetworkClient extends AbstractClient {
50
48
  }
51
49
  async send(duid, request) {
52
50
  if (!this.socket || !this.connected) {
53
- this.logger.error(`${duid}: socket is not online, ${debugStringify(request)}`);
51
+ this.logger.error(`${duid}: socket is not online, , ${debugStringify(request)}`);
54
52
  return;
55
53
  }
56
54
  const localRequest = request.toLocalRequest();
@@ -59,16 +57,29 @@ export class LocalNetworkClient extends AbstractClient {
59
57
  this.socket.write(this.wrapWithLengthData(message.buffer));
60
58
  }
61
59
  async onConnect() {
60
+ this.logger.debug(`LocalNetworkClient: ${this.duid} connected to ${this.ip}`);
61
+ this.logger.debug(`LocalNetworkClient: ${this.duid} socket writable: ${this.socket?.writable}, readable: ${this.socket?.readable}`);
62
62
  this.connected = true;
63
- const address = this.socket?.address();
64
- this.logger.debug(`${this.duid} connected to ${this.ip}, address: ${address ? debugStringify(address) : 'undefined'}`);
63
+ this.retryCount = 0;
65
64
  await this.sendHelloMessage();
66
65
  this.pingInterval = setInterval(this.sendPingRequest.bind(this), 5000);
67
66
  await this.connectionListeners.onConnected(this.duid);
68
- this.retryCount = 0;
69
67
  }
70
68
  async onEnd() {
71
- this.logger.notice('LocalNetworkClient: Socket has ended.');
69
+ await this.destroySocket('Socket has ended.');
70
+ await this.connectionListeners.onDisconnected(this.duid, 'Socket has ended.');
71
+ }
72
+ async onDisconnect(hadError) {
73
+ await this.destroySocket(`Socket disconnected. Had error: ${hadError}`);
74
+ if (!hadError) {
75
+ await this.connectionListeners.onDisconnected(this.duid, 'Socket disconnected. Had no error.');
76
+ }
77
+ }
78
+ async onTimeout() {
79
+ this.logger.error(`LocalNetworkClient: Socket for ${this.duid} timed out.`);
80
+ }
81
+ async destroySocket(message) {
82
+ this.logger.error(`LocalNetworkClient: Destroying socket for ${this.duid} due to: ${message}`);
72
83
  this.connected = false;
73
84
  if (this.socket) {
74
85
  this.socket.destroy();
@@ -77,10 +88,9 @@ export class LocalNetworkClient extends AbstractClient {
77
88
  if (this.pingInterval) {
78
89
  clearInterval(this.pingInterval);
79
90
  }
80
- await this.connectionListeners.onDisconnected(this.duid);
81
91
  }
82
- async onDisconnect() {
83
- this.logger.notice('LocalNetworkClient: Socket has disconnected.');
92
+ async onError(error) {
93
+ this.logger.error('LocalNetworkClient: Socket connection error: ' + error.message);
84
94
  this.connected = false;
85
95
  if (this.socket) {
86
96
  this.socket.destroy();
@@ -89,20 +99,10 @@ export class LocalNetworkClient extends AbstractClient {
89
99
  if (this.pingInterval) {
90
100
  clearInterval(this.pingInterval);
91
101
  }
92
- await this.connectionListeners.onDisconnected(this.duid);
93
- }
94
- async onError(result) {
95
- this.logger.error('LocalNetworkClient: Socket connection error: ' + result);
96
- this.connected = false;
97
- if (this.socket) {
98
- this.socket.destroy();
99
- this.socket = undefined;
100
- }
101
- await this.connectionListeners.onError(this.duid, result.toString());
102
+ await this.connectionListeners.onError(this.duid, error.message);
102
103
  }
103
104
  async onMessage(message) {
104
105
  if (!this.socket) {
105
- this.logger.error('unable to receive data if there is no socket available');
106
106
  return;
107
107
  }
108
108
  if (!message || message.length == 0) {
@@ -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,63 +3,79 @@ 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
- client = undefined;
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.client) {
21
+ if (this.mqttClient) {
25
22
  return;
26
23
  }
27
- this.client = mqtt.connect(this.rriot.r.m, {
24
+ this.mqttClient = mqtt.connect(this.rriot.r.m, {
28
25
  clientId: this.mqttUsername,
29
26
  username: this.mqttUsername,
30
27
  password: this.mqttPassword,
31
28
  keepalive: 30,
32
- log: () => {
29
+ log: (...args) => {
30
+ this.logger.debug(`MQTTClient args: ${debugStringify(args)}`);
33
31
  },
34
32
  });
35
- this.client.on('connect', this.onConnect.bind(this));
36
- this.client.on('error', this.onError.bind(this));
37
- this.client.on('reconnect', this.onReconnect.bind(this));
38
- this.client.on('close', this.onDisconnect.bind(this));
39
- this.client.on('disconnect', this.onDisconnect.bind(this));
40
- this.client.on('offline', this.onDisconnect.bind(this));
41
- this.client.on('message', this.onMessage.bind(this));
33
+ this.mqttClient.on('connect', this.onConnect.bind(this));
34
+ this.mqttClient.on('error', this.onError.bind(this));
35
+ this.mqttClient.on('reconnect', this.onReconnect.bind(this));
36
+ this.mqttClient.on('close', this.onClose.bind(this));
37
+ this.mqttClient.on('disconnect', this.onDisconnect.bind(this));
38
+ this.mqttClient.on('offline', this.onOffline.bind(this));
39
+ this.mqttClient.on('message', this.onMessage.bind(this));
40
+ this.keepConnectionAlive();
42
41
  }
43
42
  async disconnect() {
44
- if (!this.client || !this.connected) {
43
+ if (!this.mqttClient || !this.connected) {
45
44
  return;
46
45
  }
47
46
  try {
48
47
  this.isInDisconnectingStep = true;
49
- this.client.end();
48
+ this.mqttClient.end();
50
49
  }
51
50
  catch (error) {
52
51
  this.logger.error('MQTT client failed to disconnect with error: ' + error);
53
52
  }
54
53
  }
55
54
  async send(duid, request) {
56
- if (!this.client || !this.connected) {
55
+ if (!this.mqttClient || !this.connected) {
57
56
  this.logger.error(`${duid}: mqtt is not available, ${debugStringify(request)}`);
58
57
  return;
59
58
  }
60
59
  const mqttRequest = request.toMqttRequest();
61
60
  const message = this.serializer.serialize(duid, mqttRequest);
62
- this.client.publish('rr/m/i/' + this.rriot.u + '/' + this.mqttUsername + '/' + duid, message.buffer, { qos: 1 });
61
+ this.logger.debug(`MQTTClient sending message to ${duid}: ${debugStringify(mqttRequest)}`);
62
+ this.mqttClient.publish(`rr/m/i/${this.rriot.u}/${this.mqttUsername}/${duid}`, message.buffer, { qos: 1 });
63
+ this.logger.debug(`MQTTClient published message to topic: rr/m/i/${this.rriot.u}/${this.mqttUsername}/${duid}`);
64
+ }
65
+ keepConnectionAlive() {
66
+ if (this.keepConnectionAliveInterval) {
67
+ clearTimeout(this.keepConnectionAliveInterval);
68
+ this.keepConnectionAliveInterval.unref();
69
+ }
70
+ this.keepConnectionAliveInterval = setInterval(() => {
71
+ if (this.mqttClient) {
72
+ this.mqttClient.end();
73
+ this.mqttClient.reconnect();
74
+ }
75
+ else {
76
+ this.connect();
77
+ }
78
+ }, 30 * 60 * 1000);
63
79
  }
64
80
  async onConnect(result) {
65
81
  if (!result) {
@@ -70,30 +86,42 @@ export class MQTTClient extends AbstractClient {
70
86
  this.subscribeToQueue();
71
87
  }
72
88
  subscribeToQueue() {
73
- if (!this.client) {
89
+ if (!this.mqttClient || !this.connected) {
74
90
  return;
75
91
  }
76
- this.client.subscribe('rr/m/o/' + this.rriot.u + '/' + this.mqttUsername + '/#', this.onSubscribe.bind(this));
92
+ this.mqttClient.subscribe('rr/m/o/' + this.rriot.u + '/' + this.mqttUsername + '/#', this.onSubscribe.bind(this));
77
93
  }
78
- async onSubscribe(err) {
94
+ async onSubscribe(err, granted) {
79
95
  if (!err) {
96
+ this.logger.info('onSubscribe: ' + JSON.stringify(granted));
80
97
  return;
81
98
  }
82
- this.logger.error('failed to subscribe to the queue: ' + err);
99
+ this.logger.error('failed to subscribe: ' + err);
83
100
  this.connected = false;
84
- await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername);
101
+ await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'Failed to subscribe to the queue: ' + err.toString());
85
102
  }
86
103
  async onDisconnect() {
87
104
  this.connected = false;
88
- await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername);
105
+ await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'Disconnected from MQTT broker');
89
106
  }
90
107
  async onError(result) {
91
108
  this.logger.error('MQTT connection error: ' + result);
92
109
  this.connected = false;
93
110
  await this.connectionListeners.onError('mqtt-' + this.mqttUsername, result.toString());
94
111
  }
112
+ async onClose() {
113
+ if (this.connected) {
114
+ await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'MQTT connection closed');
115
+ }
116
+ this.connected = false;
117
+ }
118
+ async onOffline() {
119
+ this.connected = false;
120
+ await this.connectionListeners.onDisconnected('mqtt-' + this.mqttUsername, 'MQTT connection offline');
121
+ }
95
122
  onReconnect() {
96
123
  this.subscribeToQueue();
124
+ this.connectionListeners.onReconnect('mqtt-' + this.mqttUsername, 'Reconnected to MQTT broker');
97
125
  }
98
126
  async onMessage(topic, message) {
99
127
  if (!message) {
@@ -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, onDisconnect) {
24
- const localClient = new LocalNetworkClient(this.logger, this.context, duid, ip, onDisconnect);
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);
@@ -8,9 +8,9 @@ export class ChainedConnectionListener {
8
8
  await listener.onConnected(duid);
9
9
  }
10
10
  }
11
- async onDisconnected(duid) {
11
+ async onDisconnected(duid, message) {
12
12
  for (const listener of this.listeners) {
13
- await listener.onDisconnected(duid);
13
+ await listener.onDisconnected(duid, message);
14
14
  }
15
15
  }
16
16
  async onError(duid, message) {
@@ -18,4 +18,9 @@ export class ChainedConnectionListener {
18
18
  await listener.onError(duid, message);
19
19
  }
20
20
  }
21
+ async onReconnect(duid, message) {
22
+ for (const listener of this.listeners) {
23
+ await listener.onReconnect(duid, message);
24
+ }
25
+ }
21
26
  }