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

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,268 @@ 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
+ // Each account is set up independently — a failure in one does not
30
+ // block the others. Promise.allSettled runs all in parallel.
31
+ const accountsName = [];
32
+ const tasks = config.accounts.map(account =>
33
+ this.setupAccount(account, accountsName, prefDir, log, api)
34
+ );
35
+ Promise.allSettled(tasks).then(results => {
36
+ results.forEach((result, i) => {
37
+ if (result.status === 'rejected') {
38
+ log.error(`Account[${i}] setup rejected: ${result.reason?.message ?? result.reason}`);
39
+ }
40
+ });
41
+ });
42
+ });
43
+ }
36
44
 
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
- };
54
-
55
- if (logLevel.debug) {
56
- log.info(`${name}, debug: did finish launching.`);
57
- const safeConfig = {
58
- ...account,
45
+ // ── Per-account setup ─────────────────────────────────────────────────────
46
+
47
+ async setupAccount(account, accountsName, prefDir, log, api) {
48
+ const { name, user, passwd, language, type } = account;
49
+
50
+ // Skip disabled accounts silently
51
+ if (type === 'disabled') return;
52
+
53
+ // Validate required fields
54
+ if (!name || !user || !passwd || !language || accountsName.includes(name)) {
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`);
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 error: ${error.message ?? error}, trying again in 120 s`);
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
+ // Determine timers and create the appropriate cloud class
122
+ let timers;
123
+ let melCloudClass;
124
+
125
+ switch (type) {
126
+ case 'melcloud':
127
+ timers = [{ name: 'checkDevicesList', sampling: accountRefreshInterval }];
128
+ melCloudClass = new MelCloud(account, true);
129
+ break;
130
+ case 'melcloudhome':
131
+ timers = [{ name: 'checkDevicesList', sampling: accountRefreshInterval }];
132
+ melCloudClass = new MelCloudHome(account, true);
133
+ break;
134
+ default:
135
+ if (logLevel.warn) log.warn(`${name}, unknown account type: ${type}`);
136
+ return;
137
+ }
138
+
139
+ melCloudClass
140
+ .on('success', msg => log.success(`${name}, ${msg}`))
141
+ .on('info', msg => log.info(`${name}, ${msg}`))
142
+ .on('debug', msg => log.info(`${name}, debug: ${msg}`))
143
+ .on('warn', msg => log.warn(`${name}, ${msg}`))
144
+ .on('error', msg => log.error(`${name}, ${msg}`));
145
+
146
+ // Connect (OAuth / token exchange)
147
+ const connectData = await melCloudClass.connect();
148
+ if (!connectData?.State) {
149
+ if (logLevel.warn) log.warn(`${name}, ${connectData?.Status ?? 'connect failed'}`);
150
+ return;
151
+ }
152
+ if (logLevel.success) log.success(`${name}, ${connectData.Status}`);
153
+
154
+ // Discover devices
155
+ const devicesData = await melCloudClass.checkDevicesList();
156
+ if (!devicesData.State) {
157
+ if (logLevel.warn) log.warn(`${name}, ${devicesData.Status}`);
158
+ return;
159
+ }
160
+ if (logLevel.debug) log.info(`${name}, ${devicesData.Status}`);
161
+
162
+ // Filter to only the devices explicitly configured in the plugin config
163
+ // and present in the API response. d.id is kept as string throughout.
164
+ const apiDeviceIds = (devicesData.Devices ?? []).map(d => String(d.DeviceID));
165
+
166
+ const configuredDevices = [
167
+ ...(account.ataDevices || []).filter(d => (d.displayType ?? 0) > 0 && apiDeviceIds.includes(String(d.id))),
168
+ ...(account.atwDevices || []).filter(d => (d.displayType ?? 0) > 0 && apiDeviceIds.includes(String(d.id))),
169
+ ...(account.ervDevices || []).filter(d => (d.displayType ?? 0) > 0 && apiDeviceIds.includes(String(d.id))),
170
+ ];
171
+
172
+ if (logLevel.debug) {
173
+ const ata = (account.ataDevices || []).filter(d => (d.displayType ?? 0) > 0 && apiDeviceIds.includes(String(d.id))).length;
174
+ const atw = (account.atwDevices || []).filter(d => (d.displayType ?? 0) > 0 && apiDeviceIds.includes(String(d.id))).length;
175
+ const erv = (account.ervDevices || []).filter(d => (d.displayType ?? 0) > 0 && apiDeviceIds.includes(String(d.id))).length;
176
+ log.info(`${name}, configured devices — ATA: ${ata}, ATW: ${atw}, ERV: ${erv}`);
177
+ }
178
+
179
+ // Register each configured device as a Homebridge accessory
180
+ for (const [index, device] of configuredDevices.entries()) {
181
+ await this.registerDevice({
182
+ device, index, name, type, accountMelcloud,
183
+ prefDir, logLevel, log, api,
184
+ melCloudClass, connectData, devicesData,
185
+ });
186
+ }
187
+
188
+ // Hand off periodic polling to the melcloud class and stop the startup generator
189
+ await impulseGenerator.state(false);
190
+ await melCloudClass.impulseGenerator.state(true, timers, false);
191
+ }
192
+
193
+ // ── Register a single device as a Homebridge accessory ───────────────────
194
+
195
+ async registerDevice({ device, index, name, type, accountMelcloud, prefDir, logLevel, log, api, melCloudClass, connectData, devicesData }) {
196
+ const deviceId = String(device.id);
197
+ const deviceName = device.name;
198
+ const deviceType = device.type;
199
+ const deviceTypeString = DeviceType[deviceType] ?? `type${deviceType}`;
200
+ const defaultTempsFile = join(prefDir, `${name}_${deviceId}_Temps`);
201
+
202
+ // Find the matching API device — both sides coerced to string
203
+ const apiDevice = devicesData.Devices.find(d => String(d.DeviceID) === deviceId);
204
+ if (!apiDevice) {
205
+ log.warn(`${name}, device ${deviceId} not found in API response — skipping`);
206
+ return;
207
+ }
208
+
209
+ apiDevice.Scenes = devicesData.Scenes ?? [];
210
+
211
+ // Presets, schedules and scenes are filtered to only IDs that exist in the API
212
+ const presetIds = (apiDevice.Presets ?? []).map(p => String(p.ID));
213
+ const scheduleIds = (apiDevice.Schedule ?? []).map(s => String(s.Id));
214
+ const sceneIds = (devicesData.Scenes ?? []).map(s => String(s.Id));
215
+
216
+ const presets = accountMelcloud ? (device.presets || []).filter(p => (p.displayType ?? 0) > 0 && presetIds.includes(String(p.id))) : [];
217
+ const schedules = !accountMelcloud ? (device.schedules || []).filter(s => (s.displayType ?? 0) > 0 && scheduleIds.includes(String(s.id))) : [];
218
+ const scenes = !accountMelcloud ? (device.scenes || []).filter(s => (s.displayType ?? 0) > 0 && sceneIds.includes(String(s.id))) : [];
219
+ const buttons = (device.buttonsSensors || []).filter(b => (b.displayType ?? 0) > 0);
220
+
221
+ // REST port is stored on the device copy, not on the shared account object,
222
+ // to prevent later iterations overwriting earlier devices' port values.
223
+ device.id = deviceId;
224
+ device.restFulPort = type === 'melcloudhome'
225
+ ? `${3000}${index}`
226
+ : deviceId.slice(-4).replace(/^0/, '9');
227
+
228
+ // Write default temperature file for melcloudhome devices if not yet present
229
+ if (type === 'melcloudhome') {
230
+ try {
231
+ if (!existsSync(defaultTempsFile)) {
232
+ writeFileSync(defaultTempsFile, JSON.stringify({
233
+ defaultCoolingSetTemperature: 24,
234
+ defaultHeatingSetTemperature: 20,
235
+ }, null, 2));
236
+ if (logLevel.debug) log.info(`${name}, default temperature file created: ${defaultTempsFile}`);
221
237
  }
238
+ } catch (error) {
239
+ if (logLevel.error) log.error(`${name}, ${deviceTypeString}, ${deviceName}, file init error: ${error.message}`);
240
+ return; // skip this device — cannot proceed without the temps file
222
241
  }
223
- });
242
+ }
243
+
244
+ // Construct the device class
245
+ const args = [api, device, presets, schedules, scenes, buttons, defaultTempsFile, melCloudClass, connectData, apiDevice];
246
+ let deviceClass;
247
+
248
+ switch (deviceType) {
249
+ case 0: deviceClass = new DeviceAta(...args); break; // ATA
250
+ case 1: deviceClass = new DeviceAtw(...args); break; // ATW
251
+ case 2: return; // reserved — not implemented
252
+ case 3: deviceClass = new DeviceErv(...args); break; // ERV
253
+ default:
254
+ if (logLevel.warn) log.warn(`${name}, ${deviceTypeString}, ${deviceName}, unknown device type: ${deviceType}`);
255
+ return;
256
+ }
257
+
258
+ deviceClass
259
+ .on('devInfo', info => log.info(info))
260
+ .on('success', msg => log.success(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
261
+ .on('info', msg => log.info(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
262
+ .on('debug', msg => log.info(`${name}, ${deviceTypeString}, ${deviceName}, debug: ${msg}`))
263
+ .on('warn', msg => log.warn(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
264
+ .on('error', msg => log.error(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`));
265
+
266
+ const accessory = await deviceClass.start();
267
+ if (accessory) {
268
+ api.publishExternalAccessories(PluginName, [accessory]);
269
+ if (logLevel.success) log.success(`${name}, ${deviceTypeString}, ${deviceName}, published as external accessory`);
270
+ }
224
271
  }
225
272
 
273
+ // ── Homebridge accessory cache ────────────────────────────────────────────
274
+
226
275
  configureAccessory(accessory) {
227
276
  this.accessories.push(accessory);
228
277
  }
@@ -230,4 +279,4 @@ class MelCloudPlatform {
230
279
 
231
280
  export default (api) => {
232
281
  api.registerPlatform(PluginName, PlatformName, MelCloudPlatform);
233
- }
282
+ };
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.1",
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: [] };