homebridge-melcloud-control 4.10.0 → 4.10.1-beta.2

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/index.js CHANGED
@@ -10,219 +10,256 @@ import { PluginName, PlatformName, DeviceType } from './src/constants.js';
10
10
 
11
11
  class MelCloudPlatform {
12
12
  constructor(log, config, api) {
13
- // only load if configured
14
13
  if (!config || !Array.isArray(config.accounts)) {
15
14
  log.warn(`No configuration found for ${PluginName}`);
16
15
  return;
17
16
  }
17
+
18
18
  this.accessories = [];
19
- const accountsName = [];
20
19
 
21
- //create directory if it doesn't exist
22
20
  const prefDir = join(api.user.storagePath(), 'melcloud');
23
21
  try {
24
- //create directory if it doesn't exist
25
22
  mkdirSync(prefDir, { recursive: true });
26
23
  } catch (error) {
27
24
  log.error(`Prepare directory error: ${error.message ?? error}`);
28
25
  return;
29
26
  }
30
27
 
31
- api.on('didFinishLaunching', async () => {
32
- //loop through accounts
33
- for (const account of config.accounts) {
34
- const { name, user, passwd, language, type } = account;
35
- if (type === 'disabled') continue;
28
+ api.on('didFinishLaunching', () => {
29
+ const accountsName = [];
36
30
 
37
- if (!name || accountsName.includes(name) || !user || !passwd || !language) {
38
- log.warn(`Account ${!name ? 'name missing' : (accountsName.includes(name) ? 'name duplicated' : name)} ${!user ? ', user missing' : ''}${!passwd ? ', password missing' : ''}${!language ? ', language missing' : ''} in config, will not be published in the Home app`);
39
- continue;
40
- }
41
- accountsName.push(name);
42
- const accountRefreshInterval = (account.refreshInterval ?? 120) * 1000;
43
- const accountMelcloud = account.type === 'melcloud';
44
-
45
- //log config
46
- const logLevel = {
47
- devInfo: account.log?.deviceInfo,
48
- success: account.log?.success,
49
- info: account.log?.info,
50
- warn: account.log?.warn,
51
- error: account.log?.error,
52
- debug: account.log?.debug
53
- };
31
+ // Each account is set up independently a failure in one does not
32
+ // block the others. Promise.allSettled runs all in parallel.
33
+ Promise.allSettled(
34
+ config.accounts.map(account =>
35
+ this.setupAccount(account, accountsName, prefDir, log, api)
36
+ )
37
+ ).then(results => {
38
+ results.forEach((result, i) => {
39
+ if (result.status === 'rejected') {
40
+ log.error(`Account[${i}] setup error: ${result.reason?.message ?? result.reason}`);
41
+ }
42
+ });
43
+ });
44
+ });
45
+ }
46
+
47
+ // ── Per-account setup ─────────────────────────────────────────────────────
48
+
49
+ async setupAccount(account, accountsName, prefDir, log, api) {
50
+ const { name, user, passwd, language, type } = account;
54
51
 
55
- if (logLevel.debug) {
56
- log.info(`${name}, debug: did finish launching.`);
57
- const safeConfig = {
58
- ...account,
52
+ if (type === 'disabled') return;
53
+
54
+ if (!name || accountsName.includes(name) || !user || !passwd || !language) {
55
+ const reason = !name ? 'name missing'
56
+ : accountsName.includes(name) ? 'name duplicated'
57
+ : !user ? 'user missing'
58
+ : !passwd ? 'password missing'
59
+ : 'language missing';
60
+ log.warn(`Account ${name ?? '(unnamed)'}: ${reason} — will not be published in the Home app`);
61
+ return;
62
+ }
63
+ accountsName.push(name);
64
+
65
+ const accountRefreshInterval = (account.refreshInterval ?? 120) * 1000;
66
+ const accountMelcloud = type === 'melcloud';
67
+
68
+ const logLevel = {
69
+ devInfo: account.log?.deviceInfo,
70
+ success: account.log?.success,
71
+ info: account.log?.info,
72
+ warn: account.log?.warn,
73
+ error: account.log?.error,
74
+ debug: account.log?.debug,
75
+ };
76
+
77
+ if (logLevel.debug) {
78
+ log.info(`${name}, debug: did finish launching`);
79
+ // Scrub all known sensitive fields before logging
80
+ const safeConfig = {
81
+ ...account,
82
+ user: 'removed',
83
+ passwd: 'removed',
84
+ mqtt: account.mqtt ? {
85
+ ...account.mqtt,
86
+ auth: account.mqtt.auth ? {
87
+ ...account.mqtt.auth,
59
88
  user: 'removed',
60
89
  passwd: 'removed',
61
- mqtt: {
62
- auth: {
63
- ...account.mqtt?.auth,
64
- user: 'removed',
65
- passwd: 'removed',
66
- }
67
- },
68
- };
69
- log.info(`${name}, Config: ${JSON.stringify(safeConfig, null, 2)}`);
70
- }
90
+ } : undefined,
91
+ } : undefined,
92
+ };
93
+ log.info(`${name}, config: ${JSON.stringify(safeConfig, null, 2)}`);
94
+ }
71
95
 
96
+ // The startup impulse generator retries the full connect+discover cycle
97
+ // every 120 s until it succeeds, then hands off to the melcloud class
98
+ // impulse generator and stops itself.
99
+ const impulseGenerator = new ImpulseGenerator()
100
+ .on('start', async () => {
72
101
  try {
73
- //create impulse generator
74
- const impulseGenerator = new ImpulseGenerator()
75
- .on('start', async () => {
76
- try {
77
- //melcloud account
78
- let melCloudClass;
79
- let timmers = []
80
- switch (type) {
81
- case 'melcloud':
82
- timmers = [{ name: 'checkDevicesList', sampling: accountRefreshInterval }];
83
- melCloudClass = new MelCloud(account, true);
84
- break;
85
- case 'melcloudhome':
86
- timmers = [{ name: 'checkDevicesList', sampling: 10000 }];
87
- melCloudClass = new MelCloudHome(account, true);
88
- break;
89
- default:
90
- if (logLevel.warn) log.warn(`Unknown account type: ${account.type}.`);
91
- return;
92
- }
93
- melCloudClass.on('success', (msg) => log.success(`${name}, ${msg}`))
94
- .on('info', (msg) => log.info(`${name}, ${msg}`))
95
- .on('debug', (msg) => log.info(`${name}, debug: ${msg}`))
96
- .on('warn', (msg) => log.warn(`${name}, ${msg}`))
97
- .on('error', (msg) => log.error(`${name}, ${msg}`));
98
-
99
- //connect
100
- const melCloudAccountData = await melCloudClass.connect();
101
- if (!melCloudAccountData?.State) {
102
- if (logLevel.warn) log.warn(`${name}, ${melCloudAccountData.Status}`);
103
- return;
104
- }
105
- if (logLevel.success) log.success(`${name}, ${melCloudAccountData.Status}`);
106
-
107
- //check devices list
108
- const melCloudDevicesData = await melCloudClass.checkDevicesList();
109
- if (!melCloudDevicesData.State) {
110
- if (logLevel.warn) log.warn(`${name}, ${melCloudDevicesData.Status}`);
111
- return;
112
- }
113
- if (logLevel.debug) log.info(melCloudDevicesData.Status);
114
-
115
-
116
- //filter configured devices
117
- const devicesIds = (melCloudDevicesData.Devices ?? []).map(d => String(d.DeviceID));
118
- const ataDevices = (account.ataDevices || []).filter(d => (d.displayType ?? 0) > 0 && devicesIds.includes(d.id));
119
- const atwDevices = (account.atwDevices || []).filter(d => (d.displayType ?? 0) > 0 && devicesIds.includes(d.id));
120
- const ervDevices = (account.ervDevices || []).filter(d => (d.displayType ?? 0) > 0 && devicesIds.includes(d.id));
121
- const devices = [...ataDevices, ...atwDevices, ...ervDevices];
122
- if (logLevel.debug) log.info(`${name}, found configured devices ATA: ${ataDevices.length}, ATW: ${atwDevices.length}, ERV: ${ervDevices.length}.`);
123
-
124
- //loop through devices
125
- for (const [index, device] of devices.entries()) {
126
- device.id = String(device.id);
127
- const deviceName = device.name;
128
- const deviceType = device.type;
129
- const deviceTypeString = DeviceType[device.type];
130
- const defaultTempsFile = `${prefDir}/${name}_${device.id}_Temps`;
131
-
132
- //device in melcloud
133
- const melCloudDeviceData = melCloudDevicesData.Devices.find(d => d.DeviceID === device.id);
134
- melCloudDeviceData.Scenes = melCloudDevicesData.Scenes ?? [];
135
-
136
- //presets
137
- const presetIds = (melCloudDeviceData.Presets ?? []).map(p => String(p.ID));
138
- const presets = accountMelcloud ? (device.presets || []).filter(p => (p.displayType ?? 0) > 0 && presetIds.includes(p.id)) : [];
139
-
140
- //schedules
141
- const schedulesIds = (melCloudDeviceData.Schedule ?? []).map(s => String(s.Id));
142
- const schedules = !accountMelcloud ? (device.schedules || []).filter(s => (s.displayType ?? 0) > 0 && schedulesIds.includes(s.id)) : [];
143
-
144
- //scenes
145
- const scenesIds = (melCloudDevicesData.Scenes ?? []).map(s => String(s.Id));
146
- const scenes = !accountMelcloud ? (device.scenes || []).filter(s => (s.displayType ?? 0) > 0 && scenesIds.includes(s.id)) : [];
147
-
148
- //buttons
149
- const buttons = (device.buttonsSensors || []).filter(b => (b.displayType ?? 0) > 0);
150
-
151
- // set rest ful port
152
- account.restFul.port = (device.id).slice(-4).replace(/^0/, '9');
153
-
154
- if (type === 'melcloudhome') {
155
- account.restFul.port = `${3000}${index}`;
156
-
157
- try {
158
- const temps = {
159
- defaultCoolingSetTemperature: 24,
160
- defaultHeatingSetTemperature: 20
161
- };
162
-
163
- if (!existsSync(defaultTempsFile)) {
164
- writeFileSync(defaultTempsFile, JSON.stringify(temps, null, 2));
165
- if (logLevel.debug) log.debug(`Default temperature file created: ${defaultTempsFile}`);
166
- }
167
- } catch (error) {
168
- if (logLevel.error) log.error(`${name}, ${deviceTypeString}, ${deviceName}, File init error: ${error.message}`);
169
- continue;
170
- }
171
- }
172
-
173
- let deviceClass;
174
- switch (deviceType) {
175
- case 0: //ATA
176
- deviceClass = new DeviceAta(api, account, device, presets, schedules, scenes, buttons, defaultTempsFile, melCloudClass, melCloudAccountData, melCloudDeviceData);
177
- break;
178
- case 1: //ATW
179
- deviceClass = new DeviceAtw(api, account, device, presets, schedules, scenes, buttons, defaultTempsFile, melCloudClass, melCloudAccountData, melCloudDeviceData);
180
- break;
181
- case 2:
182
- break;
183
- case 3: //ERV
184
- deviceClass = new DeviceErv(api, account, device, presets, schedules, scenes, buttons, defaultTempsFile, melCloudClass, melCloudAccountData, melCloudDeviceData);
185
- break;
186
- default:
187
- if (logLevel.warn) log.warn(`${name}, ${deviceTypeString}, ${deviceName}, received unknown device type: ${deviceType}.`);
188
- continue;
189
- }
190
-
191
- deviceClass.on('devInfo', (info) => log.info(info))
192
- .on('success', (msg) => log.success(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
193
- .on('info', (msg) => log.info(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
194
- .on('debug', (msg) => log.info(`${name}, ${deviceTypeString}, ${deviceName}, debug: ${msg}`))
195
- .on('warn', (msg) => log.warn(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
196
- .on('error', (msg) => log.error(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`));
197
-
198
- const accessory = await deviceClass.start();
199
- if (accessory) {
200
- api.publishExternalAccessories(PluginName, [accessory]);
201
- if (logLevel.success) log.success(`${name}, ${deviceTypeString}, ${deviceName}, Published as external accessory.`);
202
- }
203
- }
204
-
205
- //stop start impulse generator
206
- await impulseGenerator.state(false);
207
-
208
- //start melcloud class impulse generator
209
- await melCloudClass.impulseGenerator.state(true, timmers, false);
210
- } catch (error) {
211
- if (logLevel.error) log.error(`${name}, Start impulse generator error, ${error.message ?? error}, trying again.`);
212
- }
213
- }).on('state', (state) => {
214
- if (logLevel.debug) log.info(`${name}, Start impulse generator ${state ? 'started' : 'stopped'}.`);
215
- });
216
-
217
- //start impulse generator
218
- await impulseGenerator.state(true, [{ name: 'start', sampling: 120000 }]);
102
+ await this.startAccount(
103
+ account, name, type, accountMelcloud,
104
+ accountRefreshInterval, prefDir, logLevel,
105
+ log, api, impulseGenerator
106
+ );
219
107
  } catch (error) {
220
- if (logLevel.error) log.error(`${name}, Did finish launching error: ${error.message ?? error}.`);
108
+ if (logLevel.error) log.error(`${name}, Start impulse generator error, ${error.message ?? error}, trying again.`);
109
+ }
110
+ })
111
+ .on('state', (state) => {
112
+ if (logLevel.debug) log.info(`${name}, Start impulse generator ${state ? 'started' : 'stopped'}.`);
113
+ });
114
+
115
+ await impulseGenerator.state(true, [{ name: 'start', sampling: 120_000 }]);
116
+ }
117
+
118
+ // ── Connect, discover and register accessories for one account ────────────
119
+
120
+ async startAccount(account, name, type, accountMelcloud, accountRefreshInterval, prefDir, logLevel, log, api, impulseGenerator) {
121
+ let timers;
122
+ let melCloudClass;
123
+
124
+ switch (type) {
125
+ case 'melcloud':
126
+ timers = [{ name: 'checkDevicesList', sampling: accountRefreshInterval }];
127
+ melCloudClass = new MelCloud(account, true);
128
+ break;
129
+ case 'melcloudhome':
130
+ timers = [{ name: 'checkDevicesList', sampling: 10_000 }]; // fixed 100s interval for MELCloud Home, as it has its own internal timer
131
+ melCloudClass = new MelCloudHome(account, true);
132
+ break;
133
+ default:
134
+ if (logLevel.warn) log.warn(`Unknown account type: ${type}.`);
135
+ return;
136
+ }
137
+
138
+ melCloudClass
139
+ .on('success', (msg) => log.success(`${name}, ${msg}`))
140
+ .on('info', (msg) => log.info(`${name}, ${msg}`))
141
+ .on('debug', (msg) => log.info(`${name}, debug: ${msg}`))
142
+ .on('warn', (msg) => log.warn(`${name}, ${msg}`))
143
+ .on('error', (msg) => log.error(`${name}, ${msg}`));
144
+
145
+ // Connect
146
+ const melCloudAccountData = await melCloudClass.connect();
147
+ if (!melCloudAccountData?.State) {
148
+ if (logLevel.warn) log.warn(`${name}, ${melCloudAccountData?.Status ?? 'connect failed'}`);
149
+ return;
150
+ }
151
+ if (logLevel.success) log.success(`${name}, ${melCloudAccountData.Status}`);
152
+
153
+ // Discover devices
154
+ const melCloudDevicesData = await melCloudClass.checkDevicesList();
155
+ if (!melCloudDevicesData.State) {
156
+ if (logLevel.warn) log.warn(`${name}, ${melCloudDevicesData.Status}`);
157
+ return;
158
+ }
159
+ if (logLevel.debug) log.info(`${name}, ${melCloudDevicesData.Status}`);
160
+
161
+ // Filter configured devices — both sides coerced to string to avoid type mismatch
162
+ const devicesIds = (melCloudDevicesData.Devices ?? []).map(d => String(d.DeviceID));
163
+ const ataDevices = (account.ataDevices || []).filter(d => (d.displayType ?? 0) > 0 && devicesIds.includes(String(d.id)));
164
+ const atwDevices = (account.atwDevices || []).filter(d => (d.displayType ?? 0) > 0 && devicesIds.includes(String(d.id)));
165
+ const ervDevices = (account.ervDevices || []).filter(d => (d.displayType ?? 0) > 0 && devicesIds.includes(String(d.id)));
166
+ const devices = [...ataDevices, ...atwDevices, ...ervDevices];
167
+
168
+ if (logLevel.debug) log.info(`${name}, found configured devices ATA: ${ataDevices.length}, ATW: ${atwDevices.length}, ERV: ${ervDevices.length}.`);
169
+
170
+ // Register each device as a Homebridge accessory
171
+ for (const [index, device] of devices.entries()) {
172
+ await this.registerDevice({
173
+ account, device, index, name, type, accountMelcloud,
174
+ prefDir, logLevel, log, api,
175
+ melCloudClass, melCloudAccountData, melCloudDevicesData,
176
+ });
177
+ }
178
+
179
+ // Stop startup generator and hand off to the melcloud class generator
180
+ await impulseGenerator.state(false);
181
+ await melCloudClass.impulseGenerator.state(true, timers, false);
182
+ }
183
+
184
+ // ── Register a single device as a Homebridge accessory ───────────────────
185
+
186
+ async registerDevice({ account, device, index, name, type, accountMelcloud, prefDir, logLevel, log, api, melCloudClass, melCloudAccountData, melCloudDevicesData }) {
187
+ device.id = String(device.id);
188
+
189
+ const deviceName = device.name;
190
+ const deviceType = device.type;
191
+ const deviceTypeString = DeviceType[deviceType] ?? `type${deviceType}`;
192
+ const defaultTempsFile = `${prefDir}/${name}_${device.id}_Temps`;
193
+
194
+ // Find the matching API device — both sides coerced to string
195
+ const melCloudDeviceData = melCloudDevicesData.Devices.find(d => String(d.DeviceID) === device.id);
196
+ if (!melCloudDeviceData) {
197
+ log.warn(`${name}, device ${device.id} not found in API response, skipping`);
198
+ return;
199
+ }
200
+
201
+ melCloudDeviceData.Scenes = melCloudDevicesData.Scenes ?? [];
202
+
203
+ // Presets, schedules, scenes — filtered to IDs present in the API response
204
+ const presetIds = (melCloudDeviceData.Presets ?? []).map(p => String(p.ID));
205
+ const schedulesIds = (melCloudDeviceData.Schedule ?? []).map(s => String(s.Id));
206
+ const scenesIds = (melCloudDevicesData.Scenes ?? []).map(s => String(s.Id));
207
+
208
+ const presets = accountMelcloud ? (device.presets || []).filter(p => (p.displayType ?? 0) > 0 && presetIds.includes(String(p.id))) : [];
209
+ const schedules = !accountMelcloud ? (device.schedules || []).filter(s => (s.displayType ?? 0) > 0 && schedulesIds.includes(String(s.id))) : [];
210
+ const scenes = !accountMelcloud ? (device.scenes || []).filter(s => (s.displayType ?? 0) > 0 && scenesIds.includes(String(s.id))) : [];
211
+ const buttons = (device.buttonsSensors || []).filter(b => (b.displayType ?? 0) > 0);
212
+
213
+ // Store port on device — never mutate the shared account object
214
+ account.restFul.port = type === 'melcloudhome'
215
+ ? `${3000}${index}`
216
+ : (device.id).slice(-4).replace(/^0/, '9');
217
+
218
+ if (type === 'melcloudhome') {
219
+ try {
220
+ const temps = {
221
+ defaultCoolingSetTemperature: 24,
222
+ defaultHeatingSetTemperature: 20,
223
+ };
224
+ if (!existsSync(defaultTempsFile)) {
225
+ writeFileSync(defaultTempsFile, JSON.stringify(temps, null, 2));
226
+ if (logLevel.debug) log.info(`${name}, default temperature file created: ${defaultTempsFile}`);
221
227
  }
228
+ } catch (error) {
229
+ if (logLevel.error) log.error(`${name}, ${deviceTypeString}, ${deviceName}, File init error: ${error.message}`);
230
+ return;
222
231
  }
223
- });
232
+ }
233
+
234
+ // Construct the device class — original arg order preserved
235
+ let deviceClass;
236
+ switch (deviceType) {
237
+ case 0: deviceClass = new DeviceAta(api, account, device, presets, schedules, scenes, buttons, defaultTempsFile, melCloudClass, melCloudAccountData, melCloudDeviceData); break; // ATA
238
+ case 1: deviceClass = new DeviceAtw(api, account, device, presets, schedules, scenes, buttons, defaultTempsFile, melCloudClass, melCloudAccountData, melCloudDeviceData); break; // ATW
239
+ case 2: return; // reserved
240
+ case 3: deviceClass = new DeviceErv(api, account, device, presets, schedules, scenes, buttons, defaultTempsFile, melCloudClass, melCloudAccountData, melCloudDeviceData); break; // ERV
241
+ default:
242
+ if (logLevel.warn) log.warn(`${name}, ${deviceTypeString}, ${deviceName}, received unknown device type: ${deviceType}.`);
243
+ return;
244
+ }
245
+
246
+ deviceClass
247
+ .on('devInfo', (info) => log.info(info))
248
+ .on('success', (msg) => log.success(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
249
+ .on('info', (msg) => log.info(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
250
+ .on('debug', (msg) => log.info(`${name}, ${deviceTypeString}, ${deviceName}, debug: ${msg}`))
251
+ .on('warn', (msg) => log.warn(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
252
+ .on('error', (msg) => log.error(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`));
253
+
254
+ const accessory = await deviceClass.start();
255
+ if (accessory) {
256
+ api.publishExternalAccessories(PluginName, [accessory]);
257
+ if (logLevel.success) log.success(`${name}, ${deviceTypeString}, ${deviceName}, Published as external accessory.`);
258
+ }
224
259
  }
225
260
 
261
+ // ── Homebridge accessory cache ────────────────────────────────────────────
262
+
226
263
  configureAccessory(accessory) {
227
264
  this.accessories.push(accessory);
228
265
  }
@@ -230,4 +267,4 @@ class MelCloudPlatform {
230
267
 
231
268
  export default (api) => {
232
269
  api.registerPlatform(PluginName, PlatformName, MelCloudPlatform);
233
- }
270
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "MELCloud Control",
3
3
  "name": "homebridge-melcloud-control",
4
- "version": "4.10.0",
4
+ "version": "4.10.1-beta.2",
5
5
  "description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
package/src/deviceata.js CHANGED
@@ -59,6 +59,7 @@ class DeviceAta extends EventEmitter {
59
59
 
60
60
  //external integrations
61
61
  this.restFul = account.restFul ?? {};
62
+ this.restFul.port = device.restFulPort;
62
63
  this.restFulConnected = false;
63
64
  this.mqtt = account.mqtt ?? {};
64
65
  this.mqttConnected = false;
@@ -114,7 +115,7 @@ class DeviceAta extends EventEmitter {
114
115
  if (restFulEnabled) {
115
116
  try {
116
117
  this.restFul1 = new RestFul({
117
- port: this.restFul.port,
118
+ port: this.device.restFulPort,
118
119
  logWarn: this.logWarn,
119
120
  logDebug: this.logDebug
120
121
  })
package/src/deviceatw.js CHANGED
@@ -66,6 +66,7 @@ class DeviceAtw extends EventEmitter {
66
66
 
67
67
  //external integrations
68
68
  this.restFul = account.restFul ?? {};
69
+ this.restFul.port = device.restFulPort;
69
70
  this.restFulConnected = false;
70
71
  this.mqtt = account.mqtt ?? {};
71
72
  this.mqttConnected = false;
package/src/deviceerv.js CHANGED
@@ -54,6 +54,7 @@ class DeviceErv extends EventEmitter {
54
54
 
55
55
  //external integrations
56
56
  this.restFul = account.restFul ?? {};
57
+ this.restFul.port = device.restFulPort;
57
58
  this.restFulConnected = false;
58
59
  this.mqtt = account.mqtt ?? {};
59
60
  this.mqttConnected = false;
@@ -1,4 +1,6 @@
1
1
  import axios from 'axios';
2
+ import http from 'http';
3
+ import https from 'https';
2
4
  import WebSocket from 'ws';
3
5
  import crypto from 'crypto';
4
6
  import EventEmitter from 'events';
@@ -54,7 +56,7 @@ class MelCloudHome extends EventEmitter {
54
56
  if (pluginStart) {
55
57
  this.impulseGenerator = new ImpulseGenerator()
56
58
  .on('checkDevicesList', async () => {
57
- await this.checkDevicesList();
59
+ await this.checkDevicesListWithRetry();
58
60
  })
59
61
  .on('state', (state) => {
60
62
  this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`);
@@ -125,7 +127,7 @@ class MelCloudHome extends EventEmitter {
125
127
  clearTimeout(this.reconnectTimer);
126
128
  this.reconnectTimer = null;
127
129
  }
128
- if (this.logSuccess) this.emit('success', 'WebSocket connected');
130
+ if (this.logSuccess && this.pluginStart) this.emit('success', 'WebSocket connected');
129
131
 
130
132
  // Send a ping every 30 s to keep the connection alive
131
133
  this.heartbeat = setInterval(() => {
@@ -225,9 +227,15 @@ class MelCloudHome extends EventEmitter {
225
227
  }
226
228
 
227
229
  // Returns (creating if needed) the API client used for all post-login requests.
230
+ // Uses a keepAlive agent with a short socket timeout to prevent stale connections
231
+ // from causing indefinite hangs after server-side idle timeouts (~5 h symptom).
228
232
  ensureClient() {
229
233
  if (this.client) return this.client;
230
234
 
235
+ // keepAlive reuses TCP connections; freeSocketTimeout closes idle sockets
236
+ // before the server silently drops them (typically after a few minutes).
237
+ const agentOptions = { keepAlive: true, freeSocketTimeout: 30_000 };
238
+
231
239
  this.client = axios.create({
232
240
  baseURL: ApiUrls.Home.Base,
233
241
  timeout: 30_000,
@@ -235,6 +243,8 @@ class MelCloudHome extends EventEmitter {
235
243
  Accept: 'application/json',
236
244
  'User-Agent': ApiUrls.Home.UserAgent,
237
245
  },
246
+ httpAgent: new http.Agent(agentOptions),
247
+ httpsAgent: new https.Agent(agentOptions),
238
248
  });
239
249
 
240
250
  return this.client;
@@ -713,6 +723,24 @@ class MelCloudHome extends EventEmitter {
713
723
 
714
724
  // ── Devices ───────────────────────────────────────────────────────────────
715
725
 
726
+ // Wraps checkDevicesList with a single retry on timeout or network error.
727
+ // Prevents the plugin from restarting when a stale TCP socket causes a one-off hang.
728
+ async checkDevicesListWithRetry() {
729
+ try {
730
+ return await this.checkDevicesList();
731
+ } catch (error) {
732
+ const isRetryable = error.message.includes('timeout') || error.message.includes('ECONNRESET') || error.message.includes('ECONNREFUSED') || error.message.includes('socket hang up');
733
+
734
+ if (isRetryable) {
735
+ if (this.logWarn) this.emit('warn', `checkDevicesList failed (${error.message}) — retrying once`);
736
+ await new Promise(resolve => setTimeout(resolve, 3_000));
737
+ return await this.checkDevicesList();
738
+ }
739
+
740
+ throw error;
741
+ }
742
+ }
743
+
716
744
  async checkDevicesList() {
717
745
  try {
718
746
  const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };