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.
Files changed (72) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -0
  4. package/cli/commands/control/execute.js +23 -0
  5. package/cli/commands/control/index.js +12 -0
  6. package/cli/commands/control/menu.js +193 -0
  7. package/cli/commands/control/params/generic.js +229 -0
  8. package/cli/commands/control/params/index.js +56 -0
  9. package/cli/commands/control/params/light.js +188 -0
  10. package/cli/commands/control/params/thermostat.js +166 -0
  11. package/cli/commands/control/params/timer.js +242 -0
  12. package/cli/commands/control/params/trigger.js +206 -0
  13. package/cli/commands/dump.js +35 -0
  14. package/cli/commands/index.js +34 -0
  15. package/cli/commands/info.js +221 -0
  16. package/cli/commands/list.js +112 -0
  17. package/cli/commands/mqtt.js +187 -0
  18. package/cli/commands/sniffer/device-sniffer.js +217 -0
  19. package/cli/commands/sniffer/fake-app.js +233 -0
  20. package/cli/commands/sniffer/index.js +7 -0
  21. package/cli/commands/sniffer/message-queue.js +65 -0
  22. package/cli/commands/sniffer/sniffer-menu.js +676 -0
  23. package/cli/commands/stats.js +90 -0
  24. package/cli/commands/status/device-status.js +1403 -0
  25. package/cli/commands/status/hub-status.js +72 -0
  26. package/cli/commands/status/index.js +50 -0
  27. package/cli/commands/status/subdevices/hub-smoke-detector.js +82 -0
  28. package/cli/commands/status/subdevices/hub-temp-hum-sensor.js +43 -0
  29. package/cli/commands/status/subdevices/hub-thermostat-valve.js +83 -0
  30. package/cli/commands/status/subdevices/hub-water-leak-sensor.js +27 -0
  31. package/cli/commands/status/subdevices/index.js +23 -0
  32. package/cli/commands/test/index.js +185 -0
  33. package/cli/config/users.js +108 -0
  34. package/cli/control-registry.js +875 -0
  35. package/cli/helpers/client.js +89 -0
  36. package/cli/helpers/meross.js +106 -0
  37. package/cli/menu/index.js +10 -0
  38. package/cli/menu/main.js +648 -0
  39. package/cli/menu/settings.js +789 -0
  40. package/cli/meross-cli.js +547 -0
  41. package/cli/tests/README.md +365 -0
  42. package/cli/tests/test-alarm.js +144 -0
  43. package/cli/tests/test-child-lock.js +248 -0
  44. package/cli/tests/test-config.js +133 -0
  45. package/cli/tests/test-control.js +189 -0
  46. package/cli/tests/test-diffuser.js +505 -0
  47. package/cli/tests/test-dnd.js +246 -0
  48. package/cli/tests/test-electricity.js +209 -0
  49. package/cli/tests/test-encryption.js +281 -0
  50. package/cli/tests/test-garage.js +259 -0
  51. package/cli/tests/test-helper.js +313 -0
  52. package/cli/tests/test-hub-mts100.js +355 -0
  53. package/cli/tests/test-hub-sensors.js +489 -0
  54. package/cli/tests/test-light.js +253 -0
  55. package/cli/tests/test-presence.js +497 -0
  56. package/cli/tests/test-registry.js +419 -0
  57. package/cli/tests/test-roller-shutter.js +628 -0
  58. package/cli/tests/test-runner.js +415 -0
  59. package/cli/tests/test-runtime.js +234 -0
  60. package/cli/tests/test-screen.js +133 -0
  61. package/cli/tests/test-sensor-history.js +146 -0
  62. package/cli/tests/test-smoke-config.js +138 -0
  63. package/cli/tests/test-spray.js +131 -0
  64. package/cli/tests/test-temp-unit.js +133 -0
  65. package/cli/tests/test-template.js +238 -0
  66. package/cli/tests/test-thermostat.js +919 -0
  67. package/cli/tests/test-timer.js +372 -0
  68. package/cli/tests/test-toggle.js +342 -0
  69. package/cli/tests/test-trigger.js +279 -0
  70. package/cli/utils/display.js +86 -0
  71. package/cli/utils/terminal.js +137 -0
  72. package/package.json +53 -0
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const inquirer = require('inquirer');
5
+ const { TriggerType, TriggerUtils } = require('meross-iot');
6
+ const { durationToSeconds, secondsToDuration } = TriggerUtils;
7
+
8
+ /**
9
+ * Collects parameters for setTriggerX with interactive prompts.
10
+ */
11
+ async function collectSetTriggerXParams(methodMetadata, device) {
12
+ const params = {};
13
+ const channel = methodMetadata.params.find(p => p.name === 'triggerx')?.properties?.find(prop => prop.name === 'channel')?.default || 0;
14
+
15
+ // Show existing triggers
16
+ let hasTriggers = false;
17
+ try {
18
+ if (typeof device.getTriggerX === 'function') {
19
+ console.log(chalk.dim('Fetching existing triggers...'));
20
+ const response = await device.getTriggerX({ channel });
21
+ if (response && response.triggerx && Array.isArray(response.triggerx) && response.triggerx.length > 0) {
22
+ hasTriggers = true;
23
+ console.log(chalk.cyan(`\nExisting Triggers (Channel ${channel}):`));
24
+ response.triggerx.forEach((trigger, index) => {
25
+ const durationSeconds = trigger.rule?.duration || 0;
26
+ const durationStr = secondsToDuration(durationSeconds);
27
+ const alias = trigger.alias || `Trigger ${index + 1}`;
28
+ const enabled = trigger.enable === 1 ? chalk.green('Enabled') : chalk.red('Disabled');
29
+ console.log(chalk.dim(` [${trigger.id}] ${alias} - ${durationStr} - ${enabled}`));
30
+ });
31
+ console.log();
32
+ }
33
+ }
34
+ } catch (e) {
35
+ // Failed to fetch, continue
36
+ }
37
+
38
+ if (!hasTriggers) {
39
+ console.log(chalk.yellow('No triggers currently set.\n'));
40
+ }
41
+
42
+ // Collect trigger configuration
43
+ const aliasAnswer = await inquirer.prompt([{
44
+ type: 'input',
45
+ name: 'alias',
46
+ message: 'Trigger Name',
47
+ default: 'My Trigger',
48
+ validate: (value) => {
49
+ if (!value || value.trim() === '') {
50
+ return 'Trigger name is required';
51
+ }
52
+ return true;
53
+ }
54
+ }]);
55
+
56
+ const durationAnswer = await inquirer.prompt([{
57
+ type: 'input',
58
+ name: 'duration',
59
+ message: 'Countdown Duration (e.g., "30m", "1h", "600", "30:00")',
60
+ default: '10m',
61
+ validate: (value) => {
62
+ if (!value || value.trim() === '') {
63
+ return 'Duration is required';
64
+ }
65
+ try {
66
+ durationToSeconds(value.trim());
67
+ return true;
68
+ } catch (e) {
69
+ return e.message;
70
+ }
71
+ }
72
+ }]);
73
+
74
+ const typeAnswer = await inquirer.prompt([{
75
+ type: 'list',
76
+ name: 'type',
77
+ message: 'Trigger Type',
78
+ choices: [
79
+ { name: 'Single Point Weekly Cycle (repeats every week)', value: TriggerType.SINGLE_POINT_WEEKLY_CYCLE },
80
+ { name: 'Single Point Single Shot (one time only)', value: TriggerType.SINGLE_POINT_SINGLE_SHOT },
81
+ { name: 'Continuous Weekly Cycle (active continuously, repeats weekly)', value: TriggerType.CONTINUOUS_WEEKLY_CYCLE },
82
+ { name: 'Continuous Single Shot (active continuously, one time only)', value: TriggerType.CONTINUOUS_SINGLE_SHOT }
83
+ ],
84
+ default: 0
85
+ }]);
86
+
87
+ const daysAnswer = await inquirer.prompt([{
88
+ type: 'checkbox',
89
+ name: 'days',
90
+ message: 'Days of week',
91
+ choices: [
92
+ { name: 'Monday', value: 'monday' },
93
+ { name: 'Tuesday', value: 'tuesday' },
94
+ { name: 'Wednesday', value: 'wednesday' },
95
+ { name: 'Thursday', value: 'thursday' },
96
+ { name: 'Friday', value: 'friday' },
97
+ { name: 'Saturday', value: 'saturday' },
98
+ { name: 'Sunday', value: 'sunday' }
99
+ ],
100
+ validate: (answer) => {
101
+ if (answer.length === 0) {
102
+ return 'Please select at least one day';
103
+ }
104
+ return true;
105
+ }
106
+ }]);
107
+
108
+ const enableAnswer = await inquirer.prompt([{
109
+ type: 'list',
110
+ name: 'enabled',
111
+ message: 'Trigger Status',
112
+ choices: [
113
+ { name: 'Enabled', value: true },
114
+ { name: 'Disabled', value: false }
115
+ ],
116
+ default: 0
117
+ }]);
118
+
119
+ // Pass user-friendly format to API (API handles conversion)
120
+ params.channel = channel;
121
+ params.alias = aliasAnswer.alias;
122
+ params.duration = durationAnswer.duration;
123
+ params.days = daysAnswer.days;
124
+ params.type = typeAnswer.type;
125
+ params.enabled = enableAnswer.enabled;
126
+
127
+ return params;
128
+ }
129
+
130
+ /**
131
+ * Collects parameters for deleteTriggerX with interactive prompts.
132
+ */
133
+ async function collectDeleteTriggerXParams(methodMetadata, device) {
134
+ const params = {};
135
+ const channel = methodMetadata.params.find(p => p.name === 'channel')?.default || 0;
136
+
137
+ try {
138
+ if (typeof device.getTriggerX === 'function') {
139
+ console.log(chalk.dim('Fetching existing triggers...'));
140
+ const response = await device.getTriggerX({ channel });
141
+ if (response && response.triggerx && Array.isArray(response.triggerx) && response.triggerx.length > 0) {
142
+ const items = response.triggerx;
143
+ console.log(chalk.cyan(`\nExisting Triggers (Channel ${channel}):`));
144
+ items.forEach((item, index) => {
145
+ const durationSeconds = item.rule?.duration || 0;
146
+ const durationStr = secondsToDuration(durationSeconds);
147
+ const alias = item.alias || `Trigger ${index + 1}`;
148
+ const enabled = item.enable === 1 ? chalk.green('Enabled') : chalk.red('Disabled');
149
+ console.log(chalk.dim(` [${item.id}] ${alias} - ${durationStr} - ${enabled}`));
150
+ });
151
+ console.log();
152
+
153
+ // Allow selection from list
154
+ const choices = items.map(item => {
155
+ const durationSeconds = item.rule?.duration || 0;
156
+ const durationStr = secondsToDuration(durationSeconds);
157
+ const alias = item.alias || 'Unnamed Trigger';
158
+ return {
159
+ name: `${alias} - ${durationStr}`,
160
+ value: item.id
161
+ };
162
+ });
163
+
164
+ choices.push(new inquirer.Separator());
165
+ choices.push({
166
+ name: 'Enter ID Manually',
167
+ value: '__manual__'
168
+ });
169
+
170
+ const selected = await inquirer.prompt([{
171
+ type: 'list',
172
+ name: 'id',
173
+ message: 'Select trigger to delete:',
174
+ choices
175
+ }]);
176
+
177
+ if (selected.id === '__manual__') {
178
+ const manualAnswer = await inquirer.prompt([{
179
+ type: 'input',
180
+ name: 'id',
181
+ message: 'Trigger ID',
182
+ validate: (value) => {
183
+ if (!value || value.trim() === '') {
184
+ return 'ID is required';
185
+ }
186
+ return true;
187
+ }
188
+ }]);
189
+ params.triggerId = manualAnswer.id;
190
+ } else {
191
+ params.triggerId = selected.id;
192
+ }
193
+
194
+ params.channel = channel;
195
+ return params;
196
+ }
197
+ }
198
+ } catch (e) {
199
+ // Failed to fetch, continue with generic collection
200
+ }
201
+
202
+ return null; // Return null to fall back to generic collection
203
+ }
204
+
205
+ module.exports = { collectSetTriggerXParams, collectDeleteTriggerXParams };
206
+
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ async function dumpRegistry(manager, filename) {
6
+ const devices = manager.getAllDevices();
7
+ const registry = devices.map(device => {
8
+ return {
9
+ uuid: device.uuid,
10
+ devName: device.name,
11
+ deviceType: device.deviceType,
12
+ fmwareVersion: device.firmwareVersion,
13
+ hdwareVersion: device.hardwareVersion,
14
+ onlineStatus: device.onlineStatus,
15
+ abilities: device.abilities || null,
16
+ macAddress: device.macAddress || null,
17
+ lanIp: device.lanIp || null,
18
+ mqttHost: device.mqttHost || null,
19
+ mqttPort: device.mqttPort || null
20
+ };
21
+ });
22
+
23
+ const output = {
24
+ timestamp: new Date().toISOString(),
25
+ deviceCount: devices.length,
26
+ devices: registry
27
+ };
28
+
29
+ fs.writeFileSync(filename, JSON.stringify(output, null, 2));
30
+ console.log(`Device registry dumped to ${filename}`);
31
+ console.log(`Total devices: ${devices.length}`);
32
+ }
33
+
34
+ module.exports = { dumpRegistry };
35
+
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // Export all command handlers
4
+ const { listDevices } = require('./list');
5
+ const { showStats } = require('./stats');
6
+ const { dumpRegistry } = require('./dump');
7
+ const { listMqttConnections } = require('./mqtt');
8
+ const { getDeviceStatus } = require('./status');
9
+ const { showDeviceInfo } = require('./info');
10
+ const { controlDeviceMenu, executeControlCommand, collectControlParameters } = require('./control');
11
+ const { runTestCommand, selectDeviceForTest } = require('./test');
12
+ const { snifferMenu } = require('./sniffer');
13
+
14
+ // Note: The following large functions still need to be extracted:
15
+ // - menuMode and all menu-related functions
16
+
17
+ // For now, these will remain in the main CLI file until extracted
18
+ // TODO: Extract remaining command handlers
19
+
20
+ module.exports = {
21
+ listDevices,
22
+ showStats,
23
+ dumpRegistry,
24
+ listMqttConnections,
25
+ getDeviceStatus,
26
+ showDeviceInfo,
27
+ controlDeviceMenu,
28
+ executeControlCommand,
29
+ collectControlParameters,
30
+ runTestCommand,
31
+ selectDeviceForTest,
32
+ snifferMenu
33
+ };
34
+
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const { OnlineStatus } = require('meross-iot');
5
+ const { getTransportModeName } = require('../helpers/client');
6
+
7
+ /**
8
+ * Displays formatted info rows with proper alignment.
9
+ */
10
+ function _displayInfoRows(infoData, maxLabelLength) {
11
+ infoData.forEach(([label, value]) => {
12
+ const padding = ' '.repeat(maxLabelLength - label.length);
13
+ const displayValue = value === 'Not available' ? chalk.gray.bold(value) : value;
14
+ console.log(` ${chalk.white.bold(label)}:${padding} ${chalk.italic(displayValue)}`);
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Builds basic device information array.
20
+ */
21
+ function _buildBasicDeviceInfo(device, manager) {
22
+ const info = [
23
+ ['Name', chalk.bold(device.name || 'Unknown')],
24
+ ['Type', device.deviceType || 'unknown'],
25
+ ['UUID', chalk.cyan(device.uuid || 'unknown')],
26
+ ['Firmware', device.firmwareVersion || 'unknown'],
27
+ ['Hardware', device.hardwareVersion || 'unknown']
28
+ ];
29
+
30
+ const status = device.deviceConnected ? chalk.green('Yes') : chalk.red('No');
31
+ const onlineStatus = device.isOnline
32
+ ? chalk.green('Online')
33
+ : chalk.red('Offline');
34
+
35
+ info.push(['Connected', status]);
36
+ info.push(['Online', onlineStatus]);
37
+ info.push(['Transport', getTransportModeName(manager.defaultTransportMode)]);
38
+
39
+ return info;
40
+ }
41
+
42
+ /**
43
+ * Fetches network info if needed and displays it.
44
+ */
45
+ async function _displayNetworkInfo(device, maxLabelLength) {
46
+ // Use device getters for network info (already populated from System.All)
47
+ const lanIp = device.lanIp;
48
+ const macAddress = device.macAddress;
49
+
50
+ // If not available and device is connected, try to fetch System.All to populate it
51
+ if (device.deviceConnected && (!lanIp || !macAddress)) {
52
+ try {
53
+ await device.getSystemAllData();
54
+ } catch (error) {
55
+ // Silently fail - network info is not critical
56
+ }
57
+ }
58
+
59
+ // Always show network info (use getters again after potential fetch)
60
+ const networkInfo = [
61
+ ['IP', device.lanIp || 'Not available'],
62
+ ['MAC', device.macAddress || 'Not available']
63
+ ];
64
+
65
+ console.log();
66
+ _displayInfoRows(networkInfo, maxLabelLength);
67
+ }
68
+
69
+ /**
70
+ * Displays channel information.
71
+ */
72
+ function _displayChannels(device) {
73
+ if (!device.channels || device.channels.length === 0) {
74
+ return;
75
+ }
76
+
77
+ console.log(`\n${chalk.bold.underline('Channels')}`);
78
+ console.log(` Total: ${chalk.cyan(device.channels.length)} channel${device.channels.length !== 1 ? 's' : ''}\n`);
79
+ device.channels.forEach((channel) => {
80
+ const channelLabel = channel.isMasterChannel ? 'Master' : `Channel ${channel.index}`;
81
+ const channelName = channel.name ? ` (${channel.name})` : '';
82
+ console.log(` ${chalk.white.bold(channelLabel)}:${channelName} - Index: ${chalk.cyan(channel.index)}`);
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Builds HTTP device info data array.
88
+ */
89
+ function _buildHttpInfoData(httpInfo) {
90
+ const httpInfoData = [];
91
+
92
+ if (httpInfo.domain) {
93
+ httpInfoData.push(['MQTT Domain', httpInfo.domain]);
94
+ }
95
+ if (httpInfo.reservedDomain) {
96
+ httpInfoData.push(['Reserved Domain', httpInfo.reservedDomain]);
97
+ }
98
+ if (httpInfo.subType) {
99
+ httpInfoData.push(['Sub Type', httpInfo.subType]);
100
+ }
101
+ if (httpInfo.region) {
102
+ httpInfoData.push(['Region', httpInfo.region]);
103
+ }
104
+ if (httpInfo.skillNumber) {
105
+ httpInfoData.push(['Skill Number', httpInfo.skillNumber]);
106
+ }
107
+ if (httpInfo.devIconId) {
108
+ httpInfoData.push(['Icon ID', httpInfo.devIconId]);
109
+ }
110
+ if (httpInfo.bindTime) {
111
+ httpInfoData.push(['Bind Time', httpInfo.bindTime.toLocaleString()]);
112
+ }
113
+ if (httpInfo.onlineStatus !== undefined) {
114
+ const onlineStatusText = httpInfo.onlineStatus === OnlineStatus.ONLINE ? chalk.green('Online') :
115
+ httpInfo.onlineStatus === OnlineStatus.OFFLINE ? chalk.red('Offline') :
116
+ chalk.yellow('Unknown');
117
+ httpInfoData.push(['Online Status', onlineStatusText]);
118
+ }
119
+
120
+ return httpInfoData;
121
+ }
122
+
123
+ /**
124
+ * Displays HTTP device info if available.
125
+ */
126
+ function _displayHttpInfo(device) {
127
+ if (!device.cachedHttpInfo) {
128
+ return;
129
+ }
130
+
131
+ const httpInfo = device.cachedHttpInfo;
132
+ const httpInfoData = _buildHttpInfoData(httpInfo);
133
+
134
+ if (httpInfoData.length === 0) {
135
+ return;
136
+ }
137
+
138
+ console.log(`\n${chalk.bold.underline('HTTP Device Info')}`);
139
+ const httpMaxLabelLength = Math.max(...httpInfoData.map(([label]) => label.length));
140
+ _displayInfoRows(httpInfoData, httpMaxLabelLength);
141
+ }
142
+
143
+ /**
144
+ * Builds ability categories grouped by namespace type.
145
+ */
146
+ function _buildAbilityCategories(abilityNames) {
147
+ return {
148
+ 'Config': abilityNames.filter(a => a.includes('.Config.')),
149
+ 'System': abilityNames.filter(a => a.includes('.System.')),
150
+ 'Control': abilityNames.filter(a => a.includes('.Control.')),
151
+ 'Digest': abilityNames.filter(a => a.includes('.Digest.')),
152
+ 'Other': abilityNames.filter(a =>
153
+ !a.includes('.Config.') &&
154
+ !a.includes('.System.') &&
155
+ !a.includes('.Control.') &&
156
+ !a.includes('.Digest.')
157
+ )
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Displays device capabilities.
163
+ */
164
+ function _displayCapabilities(device) {
165
+ if (!device.deviceConnected) {
166
+ console.log(`\n${chalk.yellow('Device is not connected. Connect to see capabilities.')}`);
167
+ return;
168
+ }
169
+
170
+ try {
171
+ const abilities = device.abilities;
172
+
173
+ if (!abilities) {
174
+ return;
175
+ }
176
+
177
+ const abilityNames = Object.keys(abilities);
178
+ const abilityCount = abilityNames.length;
179
+ const categories = _buildAbilityCategories(abilityNames);
180
+
181
+ console.log(`\n${chalk.bold.underline('Capabilities')}`);
182
+ console.log(` Total: ${chalk.cyan(abilityCount)} abilities\n`);
183
+
184
+ // Show abilities grouped by category
185
+ Object.entries(categories).forEach(([category, items]) => {
186
+ if (items.length > 0) {
187
+ console.log(` ${chalk.white.bold(category)}:`);
188
+ items.forEach(ability => {
189
+ console.log(` - ${ability}`);
190
+ });
191
+ console.log();
192
+ }
193
+ });
194
+ } catch (error) {
195
+ // Silently fail - abilities are not critical
196
+ }
197
+ }
198
+
199
+ async function showDeviceInfo(manager, uuid) {
200
+ const device = manager.getDevice(uuid);
201
+
202
+ if (!device) {
203
+ console.error(`Device with UUID ${chalk.cyan(uuid)} not found.`);
204
+ process.exit(1);
205
+ }
206
+
207
+ // Format device information nicely
208
+ console.log(`\n${chalk.bold.underline('Device Information')}\n`);
209
+
210
+ const info = _buildBasicDeviceInfo(device, manager);
211
+ const maxLabelLength = Math.max(...info.map(([label]) => label.length));
212
+ _displayInfoRows(info, maxLabelLength);
213
+
214
+ await _displayNetworkInfo(device, maxLabelLength);
215
+ _displayChannels(device);
216
+ _displayHttpInfo(device);
217
+ _displayCapabilities(device);
218
+ }
219
+
220
+ module.exports = { showDeviceInfo };
221
+
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const ora = require('ora');
5
+ const { MerossHubDevice, MerossSubDevice, OnlineStatus, parsePushNotification } = require('meross-iot');
6
+ const { formatDevice } = require('../utils/display');
7
+
8
+ async function listDevices(manager) {
9
+ const hubs = manager.getAllDevices().filter(device => device instanceof MerossHubDevice);
10
+ if (hubs.length > 0) {
11
+ const spinner = ora('Loading devices and refreshing hub status').start();
12
+ // Refresh hub state to update subdevice online status
13
+ // This queries the hub for current subdevice status
14
+ const refreshPromises = hubs
15
+ .filter(hub => hub.onlineStatus === OnlineStatus.ONLINE)
16
+ .map(async hub => {
17
+ try {
18
+ // Refresh state (queries sensors/MTS100 which may include online status)
19
+ await hub.refreshState().catch(err => {
20
+ const logger = manager.options?.logger || console.debug;
21
+ logger(`Failed to refresh hub ${hub.uuid}: ${err.message}`);
22
+ });
23
+
24
+ // Also explicitly query online status and route to subdevices
25
+ // Some hubs (e.g., msh300hk) may not include online status in sensor responses
26
+ try {
27
+ if (typeof hub.getHubOnline === 'function') {
28
+ const onlineResponse = await hub.getHubOnline();
29
+ if (onlineResponse && onlineResponse.online) {
30
+ // Parse and route the online status notification to subdevices
31
+ const notification = parsePushNotification('Appliance.Hub.Online', onlineResponse, hub.uuid);
32
+ if (notification && typeof notification.routeToSubdevices === 'function') {
33
+ notification.routeToSubdevices(hub);
34
+ }
35
+ }
36
+ }
37
+ } catch (onlineErr) {
38
+ // Log but don't fail - online status query might not be supported
39
+ const logger = manager.options?.logger || console.debug;
40
+ logger(`Failed to query online status for hub ${hub.uuid}: ${onlineErr.message}`);
41
+ }
42
+ } catch (err) {
43
+ const logger = manager.options?.logger || console.debug;
44
+ logger(`Error refreshing hub ${hub.uuid}: ${err.message}`);
45
+ }
46
+ });
47
+ await Promise.all(refreshPromises);
48
+ spinner.stop();
49
+ }
50
+
51
+ const allDevices = manager.getAllDevices();
52
+ const devices = allDevices.filter(device => !(device instanceof MerossSubDevice));
53
+
54
+ if (devices.length === 0) {
55
+ console.log(chalk.yellow('No devices found.'));
56
+ return;
57
+ }
58
+
59
+ console.log(`\n${chalk.bold.underline('Devices')}\n`);
60
+ devices.forEach((device, index) => {
61
+ const info = formatDevice(device);
62
+
63
+ console.log(` [${index}] ${chalk.bold.underline(info.name)}`);
64
+
65
+ const deviceInfo = [
66
+ ['Type', info.type],
67
+ ['UUID', chalk.cyan(info.uuid)]
68
+ ];
69
+
70
+ if (device.channels && device.channels.length > 0) {
71
+ deviceInfo.push(['Channels', chalk.cyan(device.channels.length.toString())]);
72
+ }
73
+
74
+ const maxLabelLength = Math.max(...deviceInfo.map(([label]) => label.length));
75
+
76
+ deviceInfo.forEach(([label, value]) => {
77
+ const padding = ' '.repeat(maxLabelLength - label.length);
78
+ console.log(` ${chalk.white.bold(label)}:${padding} ${chalk.italic(value)}`);
79
+ });
80
+
81
+ const statusColor = info.status === 'connected' ? chalk.green('Connected') : chalk.red('Disconnected');
82
+ const onlineColor = info.online === 'online' ? chalk.green('Online') : chalk.red('Offline');
83
+
84
+ const fwHwPadding = ' '.repeat(Math.max(0, 'Hardware'.length - 'Firmware'.length));
85
+ console.log(` ${chalk.white.bold('Hardware')}: ${chalk.italic(info.hardware)} ${chalk.white.bold('Firmware')}:${fwHwPadding} ${chalk.italic(info.firmware)}`);
86
+
87
+ const statusOnlinePadding = ' '.repeat(Math.max(0, 'Online'.length - 'Status'.length));
88
+ console.log(` ${chalk.white.bold('Status')}: ${chalk.italic(statusColor)} ${chalk.white.bold('Online')}:${statusOnlinePadding} ${chalk.italic(onlineColor)}`);
89
+
90
+ if (device instanceof MerossHubDevice) {
91
+ const subdevices = device.getSubdevices();
92
+ if (subdevices.length > 0) {
93
+ console.log(`\n ${chalk.white.bold(`Subdevices (${chalk.cyan(subdevices.length)}):`)}`);
94
+ subdevices.forEach((subdevice) => {
95
+ const subName = subdevice.name || subdevice.subdeviceId;
96
+ const subType = subdevice.type || 'unknown';
97
+ const subId = subdevice.subdeviceId;
98
+ const subOnline = subdevice.onlineStatus === OnlineStatus.ONLINE ? 'online' : 'offline';
99
+ const subOnlineColor = subOnline === 'online' ? chalk.green('Online') : chalk.red('Offline');
100
+ console.log(` ${chalk.bold(subName)} (${subType}) - ID: ${chalk.cyan(subId)} [${subOnlineColor}]`);
101
+ });
102
+ } else {
103
+ console.log(`\n ${chalk.white.bold('Subdevices: 0')}`);
104
+ }
105
+ }
106
+
107
+ console.log('');
108
+ });
109
+ }
110
+
111
+ module.exports = { listDevices };
112
+