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 +226 -189
- package/package.json +1 -1
- package/src/deviceata.js +2 -1
- package/src/deviceatw.js +1 -0
- package/src/deviceerv.js +1 -0
- package/src/melcloudhome.js +30 -2
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',
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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},
|
|
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.
|
|
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.
|
|
118
|
+
port: this.device.restFulPort,
|
|
118
119
|
logWarn: this.logWarn,
|
|
119
120
|
logDebug: this.logDebug
|
|
120
121
|
})
|
package/src/deviceatw.js
CHANGED
package/src/deviceerv.js
CHANGED
package/src/melcloudhome.js
CHANGED
|
@@ -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.
|
|
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: [] };
|