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 +239 -190
- 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,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',
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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 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.
|
|
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.
|
|
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: [] };
|