meross-cli 0.1.0
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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/cli/commands/control/execute.js +23 -0
- package/cli/commands/control/index.js +12 -0
- package/cli/commands/control/menu.js +193 -0
- package/cli/commands/control/params/generic.js +229 -0
- package/cli/commands/control/params/index.js +56 -0
- package/cli/commands/control/params/light.js +188 -0
- package/cli/commands/control/params/thermostat.js +166 -0
- package/cli/commands/control/params/timer.js +242 -0
- package/cli/commands/control/params/trigger.js +206 -0
- package/cli/commands/dump.js +35 -0
- package/cli/commands/index.js +34 -0
- package/cli/commands/info.js +221 -0
- package/cli/commands/list.js +112 -0
- package/cli/commands/mqtt.js +187 -0
- package/cli/commands/sniffer/device-sniffer.js +217 -0
- package/cli/commands/sniffer/fake-app.js +233 -0
- package/cli/commands/sniffer/index.js +7 -0
- package/cli/commands/sniffer/message-queue.js +65 -0
- package/cli/commands/sniffer/sniffer-menu.js +676 -0
- package/cli/commands/stats.js +90 -0
- package/cli/commands/status/device-status.js +1403 -0
- package/cli/commands/status/hub-status.js +72 -0
- package/cli/commands/status/index.js +50 -0
- package/cli/commands/status/subdevices/hub-smoke-detector.js +82 -0
- package/cli/commands/status/subdevices/hub-temp-hum-sensor.js +43 -0
- package/cli/commands/status/subdevices/hub-thermostat-valve.js +83 -0
- package/cli/commands/status/subdevices/hub-water-leak-sensor.js +27 -0
- package/cli/commands/status/subdevices/index.js +23 -0
- package/cli/commands/test/index.js +185 -0
- package/cli/config/users.js +108 -0
- package/cli/control-registry.js +875 -0
- package/cli/helpers/client.js +89 -0
- package/cli/helpers/meross.js +106 -0
- package/cli/menu/index.js +10 -0
- package/cli/menu/main.js +648 -0
- package/cli/menu/settings.js +789 -0
- package/cli/meross-cli.js +547 -0
- package/cli/tests/README.md +365 -0
- package/cli/tests/test-alarm.js +144 -0
- package/cli/tests/test-child-lock.js +248 -0
- package/cli/tests/test-config.js +133 -0
- package/cli/tests/test-control.js +189 -0
- package/cli/tests/test-diffuser.js +505 -0
- package/cli/tests/test-dnd.js +246 -0
- package/cli/tests/test-electricity.js +209 -0
- package/cli/tests/test-encryption.js +281 -0
- package/cli/tests/test-garage.js +259 -0
- package/cli/tests/test-helper.js +313 -0
- package/cli/tests/test-hub-mts100.js +355 -0
- package/cli/tests/test-hub-sensors.js +489 -0
- package/cli/tests/test-light.js +253 -0
- package/cli/tests/test-presence.js +497 -0
- package/cli/tests/test-registry.js +419 -0
- package/cli/tests/test-roller-shutter.js +628 -0
- package/cli/tests/test-runner.js +415 -0
- package/cli/tests/test-runtime.js +234 -0
- package/cli/tests/test-screen.js +133 -0
- package/cli/tests/test-sensor-history.js +146 -0
- package/cli/tests/test-smoke-config.js +138 -0
- package/cli/tests/test-spray.js +131 -0
- package/cli/tests/test-temp-unit.js +133 -0
- package/cli/tests/test-template.js +238 -0
- package/cli/tests/test-thermostat.js +919 -0
- package/cli/tests/test-timer.js +372 -0
- package/cli/tests/test-toggle.js +342 -0
- package/cli/tests/test-trigger.js +279 -0
- package/cli/utils/display.js +86 -0
- package/cli/utils/terminal.js +137 -0
- package/package.json +53 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
async function listMqttConnections(manager, options = {}) {
|
|
6
|
+
const { verbose = false, json = false } = options;
|
|
7
|
+
const mqttConnections = manager.mqttConnections || {};
|
|
8
|
+
const domains = Object.keys(mqttConnections);
|
|
9
|
+
|
|
10
|
+
if (domains.length === 0) {
|
|
11
|
+
if (json) {
|
|
12
|
+
console.log(JSON.stringify({ connections: [] }, null, 2));
|
|
13
|
+
} else {
|
|
14
|
+
console.log(chalk.yellow('No MQTT connections found.'));
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const connectionsData = domains.map(domain => {
|
|
20
|
+
const conn = mqttConnections[domain];
|
|
21
|
+
const client = conn?.client;
|
|
22
|
+
|
|
23
|
+
let status = 'disconnected';
|
|
24
|
+
if (client) {
|
|
25
|
+
if (client.connected) {
|
|
26
|
+
status = 'connected';
|
|
27
|
+
} else if (client.reconnecting) {
|
|
28
|
+
status = 'reconnecting';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const deviceCount = conn?.deviceList?.length || 0;
|
|
33
|
+
const deviceUuids = conn?.deviceList || [];
|
|
34
|
+
|
|
35
|
+
const subscribedTopics = [];
|
|
36
|
+
if (manager.userId) {
|
|
37
|
+
subscribedTopics.push(`/app/${manager.userId}/subscribe`);
|
|
38
|
+
}
|
|
39
|
+
if (manager.clientResponseTopic) {
|
|
40
|
+
subscribedTopics.push(manager.clientResponseTopic);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const namespaces = new Set();
|
|
44
|
+
deviceUuids.forEach(uuid => {
|
|
45
|
+
const device = manager.getDevice(uuid);
|
|
46
|
+
if (device && device.abilities) {
|
|
47
|
+
Object.keys(device.abilities).forEach(namespace => {
|
|
48
|
+
namespaces.add(namespace);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const namespacesList = Array.from(namespaces).sort();
|
|
53
|
+
|
|
54
|
+
let clientId = 'N/A';
|
|
55
|
+
let keepalive = 'N/A';
|
|
56
|
+
let port = 'N/A';
|
|
57
|
+
let protocol = 'N/A';
|
|
58
|
+
let protocolRaw = 'N/A';
|
|
59
|
+
|
|
60
|
+
if (client && client.options) {
|
|
61
|
+
clientId = client.options.clientId || 'N/A';
|
|
62
|
+
keepalive = client.options.keepalive || 'N/A';
|
|
63
|
+
port = client.options.port || 'N/A';
|
|
64
|
+
const protocolValue = client.options.protocol || client.options.protocolId;
|
|
65
|
+
if (protocolValue) {
|
|
66
|
+
protocolRaw = protocolValue;
|
|
67
|
+
protocol = protocolValue === 'mqtts' ? 'MQTTS (TLS)' : protocolValue.toUpperCase();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
domain,
|
|
73
|
+
status,
|
|
74
|
+
deviceCount,
|
|
75
|
+
deviceUuids,
|
|
76
|
+
clientId,
|
|
77
|
+
keepalive,
|
|
78
|
+
port,
|
|
79
|
+
protocol: protocolRaw,
|
|
80
|
+
protocolDisplay: protocol,
|
|
81
|
+
subscribedTopics,
|
|
82
|
+
namespaces: namespacesList
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (json) {
|
|
87
|
+
const jsonData = connectionsData.map(conn => ({
|
|
88
|
+
domain: conn.domain,
|
|
89
|
+
status: conn.status,
|
|
90
|
+
deviceCount: conn.deviceCount,
|
|
91
|
+
deviceUuids: conn.deviceUuids,
|
|
92
|
+
clientId: conn.clientId,
|
|
93
|
+
keepalive: conn.keepalive,
|
|
94
|
+
port: conn.port,
|
|
95
|
+
protocol: conn.protocol,
|
|
96
|
+
subscribedTopics: conn.subscribedTopics,
|
|
97
|
+
namespaces: conn.namespaces
|
|
98
|
+
}));
|
|
99
|
+
console.log(JSON.stringify({ connections: jsonData }, null, 2));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(`\n${chalk.bold.underline('MQTT Connections')}\n`);
|
|
104
|
+
|
|
105
|
+
connectionsData.forEach((conn, index) => {
|
|
106
|
+
console.log(` [${index}] ${chalk.bold.underline(conn.domain)}`);
|
|
107
|
+
|
|
108
|
+
const statusColor = conn.status === 'connected'
|
|
109
|
+
? chalk.green('Connected')
|
|
110
|
+
: conn.status === 'reconnecting'
|
|
111
|
+
? chalk.yellow('Reconnecting')
|
|
112
|
+
: chalk.red('Disconnected');
|
|
113
|
+
|
|
114
|
+
const connectionInfo = [
|
|
115
|
+
['Status', statusColor],
|
|
116
|
+
['Port', chalk.cyan(conn.port)],
|
|
117
|
+
['Protocol', chalk.cyan(conn.protocolDisplay || conn.protocol)],
|
|
118
|
+
['Devices', chalk.cyan(conn.deviceCount.toString())]
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const maxLabelLength = Math.max(...connectionInfo.map(([label]) => label.length));
|
|
122
|
+
|
|
123
|
+
connectionInfo.forEach(([label, value]) => {
|
|
124
|
+
const padding = ' '.repeat(maxLabelLength - label.length);
|
|
125
|
+
console.log(` ${chalk.white.bold(label)}:${padding} ${chalk.italic(value)}`);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (conn.subscribedTopics.length > 0) {
|
|
129
|
+
console.log(`\n ${chalk.white.bold(`Subscribed Topics (${chalk.cyan(conn.subscribedTopics.length)}):`)}`);
|
|
130
|
+
conn.subscribedTopics.forEach(topic => {
|
|
131
|
+
console.log(` ${chalk.cyan(topic)}`);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (conn.namespaces.length > 0) {
|
|
136
|
+
console.log(`\n ${chalk.white.bold(`Namespaces (${chalk.cyan(conn.namespaces.length)}):`)}`);
|
|
137
|
+
if (verbose) {
|
|
138
|
+
conn.namespaces.forEach(namespace => {
|
|
139
|
+
console.log(` ${chalk.cyan(namespace)}`);
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
const displayCount = Math.min(5, conn.namespaces.length);
|
|
143
|
+
conn.namespaces.slice(0, displayCount).forEach(namespace => {
|
|
144
|
+
console.log(` ${chalk.cyan(namespace)}`);
|
|
145
|
+
});
|
|
146
|
+
const remainingCount = conn.namespaces.length - displayCount;
|
|
147
|
+
if (remainingCount > 0) {
|
|
148
|
+
console.log(` ${chalk.gray(`... and ${remainingCount} more (use --verbose to see all)`)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else if (conn.deviceCount > 0) {
|
|
152
|
+
console.log(`\n ${chalk.gray('Namespaces: No abilities loaded yet')}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (verbose) {
|
|
156
|
+
const clientIdDisplay = conn.clientId !== 'N/A' && conn.clientId.length > 20
|
|
157
|
+
? `${conn.clientId.substring(0, 8)}...${conn.clientId.substring(conn.clientId.length - 8)}`
|
|
158
|
+
: conn.clientId;
|
|
159
|
+
|
|
160
|
+
const verboseInfo = [
|
|
161
|
+
['Client ID', chalk.cyan(clientIdDisplay)],
|
|
162
|
+
['Keepalive', chalk.cyan(`${conn.keepalive}s`)]
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
verboseInfo.forEach(([label, value]) => {
|
|
166
|
+
const padding = ' '.repeat(maxLabelLength - label.length);
|
|
167
|
+
console.log(` ${chalk.white.bold(label)}:${padding} ${chalk.italic(value)}`);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (conn.deviceUuids.length > 0) {
|
|
171
|
+
console.log(`\n ${chalk.white.bold(`Device UUIDs (${chalk.cyan(conn.deviceUuids.length)}):`)}`);
|
|
172
|
+
conn.deviceUuids.forEach((uuid) => {
|
|
173
|
+
const device = manager.getDevice(uuid);
|
|
174
|
+
const deviceName = device ? (device.name || 'Unknown') : 'Unknown';
|
|
175
|
+
console.log(` ${chalk.cyan(uuid)} ${chalk.gray(`(${deviceName})`)}`);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (index < connectionsData.length - 1) {
|
|
181
|
+
console.log('');
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { listMqttConnections };
|
|
187
|
+
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const mqtt = require('mqtt');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { buildDeviceRequestTopic } = require('meross-iot/lib/utilities/mqtt');
|
|
6
|
+
const MessageQueue = require('./message-queue');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* DeviceSniffer - Impersonates a Meross device to intercept app commands.
|
|
10
|
+
*
|
|
11
|
+
* Connects to MQTT broker using device credentials and subscribes to the device's
|
|
12
|
+
* request topic to capture commands sent from the Meross app.
|
|
13
|
+
*/
|
|
14
|
+
class DeviceSniffer {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
const { uuid, macAddress, userId, cloudKey, mqttHost, mqttPort, logger } = options;
|
|
17
|
+
|
|
18
|
+
this._uuid = uuid.toLowerCase();
|
|
19
|
+
this._macAddress = macAddress;
|
|
20
|
+
this._userId = userId;
|
|
21
|
+
this._cloudKey = cloudKey;
|
|
22
|
+
this._mqttHost = mqttHost;
|
|
23
|
+
this._mqttPort = mqttPort || 2001;
|
|
24
|
+
this._logger = logger || console.log;
|
|
25
|
+
|
|
26
|
+
this._client = null;
|
|
27
|
+
this._msgQueue = new MessageQueue();
|
|
28
|
+
this._connected = false;
|
|
29
|
+
this._subscribed = false;
|
|
30
|
+
this._deviceTopic = buildDeviceRequestTopic(this._uuid);
|
|
31
|
+
|
|
32
|
+
// Build client ID: fmware:{uuid}_random
|
|
33
|
+
this._clientId = `fmware:${this._uuid}_random`;
|
|
34
|
+
|
|
35
|
+
// Build device password: {userId}_{MD5(macAddress + cloudKey).toLowerCase()}
|
|
36
|
+
const macKeyDigest = crypto.createHash('md5')
|
|
37
|
+
.update(`${macAddress}${cloudKey}`)
|
|
38
|
+
.digest('hex')
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
this._devicePassword = `${userId}_${macKeyDigest}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Start the device sniffer and connect to MQTT broker
|
|
45
|
+
* @param {number} timeout - Connection timeout in milliseconds
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
async start(timeout = 5000) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const timeoutId = setTimeout(() => {
|
|
51
|
+
if (!this._connected) {
|
|
52
|
+
this._client?.end();
|
|
53
|
+
reject(new Error('Device sniffer connection timeout'));
|
|
54
|
+
}
|
|
55
|
+
}, timeout);
|
|
56
|
+
|
|
57
|
+
this._client = mqtt.connect({
|
|
58
|
+
protocol: 'mqtts',
|
|
59
|
+
host: this._mqttHost,
|
|
60
|
+
port: this._mqttPort,
|
|
61
|
+
clientId: this._clientId,
|
|
62
|
+
username: this._macAddress,
|
|
63
|
+
password: this._devicePassword,
|
|
64
|
+
rejectUnauthorized: true,
|
|
65
|
+
reconnectPeriod: 0 // Disable auto-reconnect for sniffer
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this._client.on('connect', (connack) => {
|
|
69
|
+
this._connected = true;
|
|
70
|
+
clearTimeout(timeoutId);
|
|
71
|
+
this._logger(`Device sniffer connected to ${this._mqttHost}:${this._mqttPort}`);
|
|
72
|
+
|
|
73
|
+
if (connack && connack.returnCode !== 0) {
|
|
74
|
+
const error = new Error(`Connection refused: return code ${connack.returnCode}`);
|
|
75
|
+
this._logger(`Connection error: ${error.message}`);
|
|
76
|
+
reject(error);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Subscribe to device topic after connection is established
|
|
81
|
+
// Match Python pattern: subscribe after connect, wait for subscription callback
|
|
82
|
+
this._logger(`Subscribing to device topic: ${this._deviceTopic}`);
|
|
83
|
+
this._client.subscribe(this._deviceTopic, (err) => {
|
|
84
|
+
if (err) {
|
|
85
|
+
const errorMsg = err.message || err.toString() || 'Unknown subscription error';
|
|
86
|
+
this._logger(`Warning: Subscription to device topic failed: ${errorMsg}`);
|
|
87
|
+
this._logger(`Topic: ${this._deviceTopic}`);
|
|
88
|
+
this._logger(`Client ID: ${this._clientId}`);
|
|
89
|
+
this._logger(`Username: ${this._macAddress}`);
|
|
90
|
+
// In Python, subscription failure doesn't stop the sniffer
|
|
91
|
+
// Messages may still be routed automatically based on client ID
|
|
92
|
+
this._subscribed = true;
|
|
93
|
+
this._logger('Continuing anyway - messages may be routed automatically');
|
|
94
|
+
resolve();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Subscription successful - match Python's _on_subscribe behavior
|
|
98
|
+
this._subscribed = true;
|
|
99
|
+
this._logger(`Subscribed to topic: ${this._deviceTopic}`);
|
|
100
|
+
resolve();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Handle subscription acknowledgments (like Python's on_subscribe)
|
|
105
|
+
this._client.on('packetsend', (packet) => {
|
|
106
|
+
if (packet.cmd === 'subscribe') {
|
|
107
|
+
this._logger(`Subscription packet sent for topic: ${this._deviceTopic}`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
this._client.on('error', (error) => {
|
|
112
|
+
clearTimeout(timeoutId);
|
|
113
|
+
this._logger(`Device sniffer MQTT error: ${error.message}`);
|
|
114
|
+
if (!this._connected) {
|
|
115
|
+
reject(error);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
this._client.on('message', (topic, message) => {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(message.toString());
|
|
122
|
+
this._logger(`Device sniffer received message on ${topic}: ${JSON.stringify(parsed)}`);
|
|
123
|
+
this._msgQueue.syncPut({ topic, message: message.toString(), parsed });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
this._logger(`Error parsing message: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
this._client.on('close', () => {
|
|
130
|
+
this._connected = false;
|
|
131
|
+
this._subscribed = false;
|
|
132
|
+
this._logger('Device sniffer disconnected');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Stop the device sniffer and disconnect from MQTT broker
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
async stop() {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
if (!this._client) {
|
|
144
|
+
resolve();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Remove event listeners to prevent memory leaks
|
|
149
|
+
this._client.removeAllListeners();
|
|
150
|
+
|
|
151
|
+
// Set a timeout to force resolve if end() doesn't callback
|
|
152
|
+
const timeout = setTimeout(() => {
|
|
153
|
+
this._connected = false;
|
|
154
|
+
this._subscribed = false;
|
|
155
|
+
this._client = null;
|
|
156
|
+
resolve();
|
|
157
|
+
}, 2000);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
this._client.end(false, () => {
|
|
161
|
+
clearTimeout(timeout);
|
|
162
|
+
this._connected = false;
|
|
163
|
+
this._subscribed = false;
|
|
164
|
+
this._client = null;
|
|
165
|
+
resolve();
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
this._connected = false;
|
|
170
|
+
this._subscribed = false;
|
|
171
|
+
this._client = null;
|
|
172
|
+
resolve();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Wait for a message from the device topic
|
|
179
|
+
* Filters for SET/GET methods only (ignores ACKs and PUSH)
|
|
180
|
+
* @param {string[]} validMethods - Array of valid methods to accept (default: ['SET', 'GET'])
|
|
181
|
+
* @returns {Promise<{topic: string, message: string, parsed: Object, namespace: string, method: string, payload: Object}>}
|
|
182
|
+
*/
|
|
183
|
+
async waitForMessage(validMethods = ['SET', 'GET']) {
|
|
184
|
+
while (true) {
|
|
185
|
+
const { topic, message, parsed } = await this._msgQueue.asyncGet();
|
|
186
|
+
|
|
187
|
+
if (!parsed || !parsed.header) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { namespace, method } = parsed.header;
|
|
192
|
+
const payload = parsed.payload || {};
|
|
193
|
+
|
|
194
|
+
// Filter for valid methods only
|
|
195
|
+
if (validMethods.includes(method)) {
|
|
196
|
+
return {
|
|
197
|
+
topic,
|
|
198
|
+
message,
|
|
199
|
+
parsed,
|
|
200
|
+
namespace,
|
|
201
|
+
method,
|
|
202
|
+
payload
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if the sniffer is connected
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
get isConnected() {
|
|
213
|
+
return this._connected && this._subscribed;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = DeviceSniffer;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const mqtt = require('mqtt');
|
|
4
|
+
const {
|
|
5
|
+
buildDeviceRequestTopic,
|
|
6
|
+
buildClientResponseTopic,
|
|
7
|
+
buildClientUserTopic,
|
|
8
|
+
generateClientAndAppId,
|
|
9
|
+
generateMqttPassword
|
|
10
|
+
} = require('meross-iot/lib/utilities/mqtt');
|
|
11
|
+
const MessageQueue = require('./message-queue');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* AppSniffer - Impersonates a Meross app client to capture push notifications.
|
|
15
|
+
*
|
|
16
|
+
* Connects to MQTT broker using app credentials and subscribes to multiple topics
|
|
17
|
+
* to capture push notifications and device responses.
|
|
18
|
+
*/
|
|
19
|
+
class AppSniffer {
|
|
20
|
+
constructor(options) {
|
|
21
|
+
const { userId, cloudKey, deviceUuid, mqttHost, mqttPort, logger } = options;
|
|
22
|
+
|
|
23
|
+
this._userId = userId;
|
|
24
|
+
this._cloudKey = cloudKey;
|
|
25
|
+
this._deviceUuid = deviceUuid.toLowerCase();
|
|
26
|
+
this._mqttHost = mqttHost;
|
|
27
|
+
this._mqttPort = mqttPort || 2001;
|
|
28
|
+
this._logger = logger || console.log;
|
|
29
|
+
|
|
30
|
+
this._client = null;
|
|
31
|
+
this._pushQueue = new MessageQueue();
|
|
32
|
+
this._connected = false;
|
|
33
|
+
this._subscribed = false;
|
|
34
|
+
|
|
35
|
+
// Generate app ID and client ID
|
|
36
|
+
// Match Python: app_id is "sniffer", client_id is "app:sniffer-{randomHash}"
|
|
37
|
+
const { clientId } = generateClientAndAppId();
|
|
38
|
+
this._appId = 'sniffer'; // Fixed app ID like Python version
|
|
39
|
+
this._clientId = `app:sniffer-${clientId.replace('app:', '')}`;
|
|
40
|
+
|
|
41
|
+
// Build topics
|
|
42
|
+
this._deviceTopic = buildDeviceRequestTopic(this._deviceUuid);
|
|
43
|
+
this._clientResponseTopic = buildClientResponseTopic(this._userId, this._appId);
|
|
44
|
+
this._userTopic = buildClientUserTopic(this._userId);
|
|
45
|
+
|
|
46
|
+
// Generate MQTT password
|
|
47
|
+
this._mqttPassword = generateMqttPassword(this._userId, this._cloudKey);
|
|
48
|
+
|
|
49
|
+
// Track subscription state
|
|
50
|
+
this._subscriptionPromise = null;
|
|
51
|
+
this._subscriptionResolve = null;
|
|
52
|
+
this._subscriptionReject = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to topics sequentially, waiting for each subscription to complete
|
|
57
|
+
* Matches Python implementation pattern
|
|
58
|
+
* @private
|
|
59
|
+
*/
|
|
60
|
+
_subscribeSequentially(resolve, _reject) {
|
|
61
|
+
const topics = [
|
|
62
|
+
{ topic: this._deviceTopic, name: 'device' },
|
|
63
|
+
{ topic: this._clientResponseTopic, name: 'client-response' },
|
|
64
|
+
{ topic: this._userTopic, name: 'user' }
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
let currentIndex = 0;
|
|
68
|
+
|
|
69
|
+
const subscribeNext = () => {
|
|
70
|
+
if (currentIndex >= topics.length) {
|
|
71
|
+
// All subscriptions completed
|
|
72
|
+
this._subscribed = true;
|
|
73
|
+
resolve();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { topic, name } = topics[currentIndex];
|
|
78
|
+
this._logger(`Subscribing to ${name} topic: ${topic}`);
|
|
79
|
+
|
|
80
|
+
// Subscribe and wait for acknowledgment
|
|
81
|
+
this._client.subscribe(topic, (err) => {
|
|
82
|
+
if (err) {
|
|
83
|
+
const errorMsg = err.message || err.toString() || 'Unknown subscription error';
|
|
84
|
+
this._logger(`Warning: Subscription to ${name} topic failed: ${errorMsg}`);
|
|
85
|
+
this._logger(`Topic: ${topic}`);
|
|
86
|
+
// Continue to next subscription even if this one fails
|
|
87
|
+
currentIndex++;
|
|
88
|
+
setImmediate(() => subscribeNext());
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Success - proceed to next subscription
|
|
92
|
+
this._logger(`Subscribed to ${name} topic: ${topic}`);
|
|
93
|
+
currentIndex++;
|
|
94
|
+
// Wait a tiny bit before next subscription (like Python's event.wait())
|
|
95
|
+
setImmediate(() => subscribeNext());
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Start sequential subscriptions
|
|
100
|
+
subscribeNext();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Start the app sniffer and connect to MQTT broker
|
|
105
|
+
* @param {number} timeout - Connection timeout in milliseconds
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
async start(timeout = 5000) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const timeoutId = setTimeout(() => {
|
|
111
|
+
if (!this._connected) {
|
|
112
|
+
this._client?.end();
|
|
113
|
+
reject(new Error('App sniffer connection timeout'));
|
|
114
|
+
}
|
|
115
|
+
}, timeout);
|
|
116
|
+
|
|
117
|
+
this._client = mqtt.connect({
|
|
118
|
+
protocol: 'mqtts',
|
|
119
|
+
host: this._mqttHost,
|
|
120
|
+
port: this._mqttPort,
|
|
121
|
+
clientId: this._clientId,
|
|
122
|
+
username: this._userId,
|
|
123
|
+
password: this._mqttPassword,
|
|
124
|
+
rejectUnauthorized: true,
|
|
125
|
+
reconnectPeriod: 0 // Disable auto-reconnect for sniffer
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
this._client.on('connect', (connack) => {
|
|
129
|
+
this._connected = true;
|
|
130
|
+
clearTimeout(timeoutId);
|
|
131
|
+
this._logger(`App sniffer connected to ${this._mqttHost}:${this._mqttPort}`);
|
|
132
|
+
|
|
133
|
+
if (connack && connack.returnCode !== 0) {
|
|
134
|
+
const error = new Error(`Connection refused: return code ${connack.returnCode}`);
|
|
135
|
+
this._logger(`Connection error: ${error.message}`);
|
|
136
|
+
reject(error);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Subscribe to topics sequentially, matching Python implementation
|
|
141
|
+
// Python subscribes one at a time, waiting for each to complete
|
|
142
|
+
this._subscribeSequentially(resolve, reject);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
this._client.on('error', (error) => {
|
|
146
|
+
clearTimeout(timeoutId);
|
|
147
|
+
this._logger(`App sniffer MQTT error: ${error.message}`);
|
|
148
|
+
if (!this._connected) {
|
|
149
|
+
reject(error);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
this._client.on('message', (topic, message) => {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(message.toString());
|
|
156
|
+
const method = parsed.header?.method || 'UNKNOWN';
|
|
157
|
+
|
|
158
|
+
// Only capture PUSH notifications
|
|
159
|
+
if (method === 'PUSH') {
|
|
160
|
+
this._logger(`App sniffer received PUSH on ${topic}: ${JSON.stringify(parsed)}`);
|
|
161
|
+
this._pushQueue.syncPut(parsed);
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this._logger(`Error parsing message: ${err.message}`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
this._client.on('close', () => {
|
|
169
|
+
this._connected = false;
|
|
170
|
+
this._subscribed = false;
|
|
171
|
+
this._logger('App sniffer disconnected');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Stop the app sniffer and disconnect from MQTT broker
|
|
178
|
+
* @returns {Promise<void>}
|
|
179
|
+
*/
|
|
180
|
+
async stop() {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
if (!this._client) {
|
|
183
|
+
resolve();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Remove event listeners to prevent memory leaks
|
|
188
|
+
this._client.removeAllListeners();
|
|
189
|
+
|
|
190
|
+
// Set a timeout to force resolve if end() doesn't callback
|
|
191
|
+
const timeout = setTimeout(() => {
|
|
192
|
+
this._connected = false;
|
|
193
|
+
this._subscribed = false;
|
|
194
|
+
this._client = null;
|
|
195
|
+
resolve();
|
|
196
|
+
}, 2000);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
this._client.end(false, () => {
|
|
200
|
+
clearTimeout(timeout);
|
|
201
|
+
this._connected = false;
|
|
202
|
+
this._subscribed = false;
|
|
203
|
+
this._client = null;
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
this._connected = false;
|
|
209
|
+
this._subscribed = false;
|
|
210
|
+
this._client = null;
|
|
211
|
+
resolve();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Wait for a push notification
|
|
218
|
+
* @returns {Promise<Object>} Parsed push notification message
|
|
219
|
+
*/
|
|
220
|
+
async waitForPushNotification() {
|
|
221
|
+
return await this._pushQueue.asyncGet();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check if the sniffer is connected
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
get isConnected() {
|
|
229
|
+
return this._connected && this._subscribed;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = AppSniffer;
|