homebridge-melcloud-control 4.2.3-beta.2 → 4.2.3-beta.20

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/src/melcloud.js CHANGED
@@ -1,13 +1,10 @@
1
- import fs from 'fs';
2
1
  import axios from 'axios';
3
2
  import { exec } from 'child_process';
4
3
  import { promisify } from 'util';
5
4
  import EventEmitter from 'events';
6
- import puppeteer from 'puppeteer';
7
5
  import ImpulseGenerator from './impulsegenerator.js';
8
6
  import Functions from './functions.js';
9
- import { ApiUrls, ApiUrlsHome } from './constants.js';
10
- const execPromise = promisify(exec);
7
+ import { ApiUrls } from './constants.js';
11
8
 
12
9
  class MelCloud extends EventEmitter {
13
10
  constructor(account, accountFile, buildingsFile, devicesFile, pluginStart = false) {
@@ -22,7 +19,8 @@ class MelCloud extends EventEmitter {
22
19
  this.accountFile = accountFile;
23
20
  this.buildingsFile = buildingsFile;
24
21
  this.devicesFile = devicesFile;
25
- this.contextKey = null;
22
+ this.headers = {};
23
+
26
24
  this.functions = new Functions(this.logWarn, this.logError, this.logDebug)
27
25
  .on('warn', warn => this.emit('warn', warn))
28
26
  .on('error', error => this.emit('error', error))
@@ -36,7 +34,7 @@ class MelCloud extends EventEmitter {
36
34
  };
37
35
  this.impulseGenerator = new ImpulseGenerator()
38
36
  .on('connect', () => this.handleWithLock('connect', async () => {
39
- await this.connect(true);
37
+ await this.connect();
40
38
  }))
41
39
  .on('checkDevicesList', () => this.handleWithLock('checkDevicesList', async () => {
42
40
  await this.checkDevicesList();
@@ -61,23 +59,17 @@ class MelCloud extends EventEmitter {
61
59
  }
62
60
 
63
61
  // MELCloud
64
- async checkMelcloudDevicesList() {
62
+ async checkDevicesList() {
65
63
  try {
66
64
  const devicesList = { State: false, Info: null, Devices: [] }
67
- const headers = {
68
- 'X-MitsContextKey': this.contextKey,
69
- 'Content-Type': 'application/json'
70
- }
71
- const axiosInstance = axios.create({
65
+ if (this.logDebug) this.emit('debug', `Scanning for devices...`);
66
+ const listDevicesData = await axios(ApiUrls.ListDevices, {
72
67
  method: 'GET',
73
68
  baseURL: ApiUrls.BaseURL,
74
69
  timeout: 15000,
75
- headers: headers
70
+ headers: this.headers
76
71
  });
77
72
 
78
- if (this.logDebug) this.emit('debug', `Scanning for devices...`);
79
- const listDevicesData = await axiosInstance(ApiUrls.ListDevices);
80
-
81
73
  if (!listDevicesData || !listDevicesData.data) {
82
74
  devicesList.Info = 'Invalid or empty response from MELCloud API'
83
75
  return devicesList;
@@ -138,24 +130,26 @@ class MelCloud extends EventEmitter {
138
130
  }
139
131
  }
140
132
 
141
- async connectToMelCloud() {
133
+ async connect() {
142
134
  if (this.logDebug) this.emit('debug', `Connecting to MELCloud`);
143
135
 
144
136
  try {
145
- const accountInfo = { State: false, Info: '', LoginData: null, ContextKey: null, UseFahrenheit: false }
137
+ const accountInfo = { State: false, Info: '', LoginData: null, Headers: {}, UseFahrenheit: false }
138
+
139
+ const payload = {
140
+ Email: this.user,
141
+ Password: this.passwd,
142
+ Language: this.language,
143
+ AppVersion: '1.34.12',
144
+ CaptchaChallenge: '',
145
+ CaptchaResponse: '',
146
+ Persist: true
147
+ };
146
148
  const accountData = await axios(ApiUrls.ClientLogin, {
147
149
  method: 'POST',
148
150
  baseURL: ApiUrls.BaseURL,
149
151
  timeout: 15000,
150
- data: {
151
- Email: this.user,
152
- Password: this.passwd,
153
- Language: this.language,
154
- AppVersion: '1.34.12',
155
- CaptchaChallenge: '',
156
- CaptchaResponse: '',
157
- Persist: true
158
- }
152
+ data: payload
159
153
  });
160
154
  const account = accountData.data;
161
155
  const loginData = account.LoginData ?? [];
@@ -176,12 +170,16 @@ class MelCloud extends EventEmitter {
176
170
  accountInfo.Info = 'Context key missing'
177
171
  return accountInfo;
178
172
  }
179
- this.contextKey = contextKey;
173
+
174
+ this.headers = {
175
+ 'X-MitsContextKey': contextKey,
176
+ 'Content-Type': 'application/json'
177
+ };
180
178
 
181
179
  accountInfo.State = true;
182
180
  accountInfo.Info = 'Connect to MELCloud Success';
183
181
  accountInfo.LoginData = loginData;
184
- accountInfo.ContextKey = contextKey;
182
+ accountInfo.Headers = this.headers;
185
183
  await this.functions.saveData(this.accountFile, accountInfo);
186
184
 
187
185
  return accountInfo
@@ -190,346 +188,16 @@ class MelCloud extends EventEmitter {
190
188
  }
191
189
  }
192
190
 
193
- // MELCloud Home
194
- async checkMelcloudHomeDevicesList() {
195
- try {
196
- const devicesList = { State: false, Info: null, Devices: [] }
197
- const headers = {
198
- 'Accept': '*/*',
199
- 'Accept-Language': 'en-US,en;q=0.9',
200
- 'Cookie': this.contextKey,
201
- 'User-Agent': 'homebridge-melcloud-control/4.0.0',
202
- 'DNT': '1',
203
- 'Origin': 'https://melcloudhome.com',
204
- 'Referer': 'https://melcloudhome.com/dashboard',
205
- 'Sec-Fetch-Dest': 'empty',
206
- 'Sec-Fetch-Mode': 'cors',
207
- 'Sec-Fetch-Site': 'same-origin',
208
- 'X-CSRF': '1'
209
- };
210
- const axiosInstance = axios.create({
211
- method: 'GET',
212
- baseURL: ApiUrlsHome.BaseURL,
213
- timeout: 25000,
214
- headers: headers
215
- });
216
-
217
- if (this.logDebug) this.emit('debug', `Scanning for devices`);
218
- const listDevicesData = await axiosInstance(ApiUrlsHome.GetUserContext);
219
- const buildingsList = listDevicesData.data.buildings;
220
- if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
221
-
222
- if (!buildingsList) {
223
- devicesList.Info = 'No building found'
224
- return devicesList;
225
- }
226
-
227
- await this.functions.saveData(this.buildingsFile, buildingsList);
228
- if (this.logDebug) this.emit('debug', `Buildings list saved`);
229
-
230
- const devices = buildingsList.flatMap(building => {
231
- // Funkcja kapitalizująca klucze obiektu
232
- const capitalizeKeys = obj =>
233
- Object.fromEntries(
234
- Object.entries(obj).map(([key, value]) => [
235
- key.charAt(0).toUpperCase() + key.slice(1),
236
- value
237
- ])
238
- );
239
-
240
- // Rekurencyjna kapitalizacja kluczy w obiekcie lub tablicy
241
- const capitalizeKeysDeep = obj => {
242
- if (Array.isArray(obj)) return obj.map(capitalizeKeysDeep);
243
- if (obj && typeof obj === 'object') {
244
- return Object.fromEntries(
245
- Object.entries(obj).map(([key, value]) => [
246
- key.charAt(0).toUpperCase() + key.slice(1),
247
- capitalizeKeysDeep(value)
248
- ])
249
- );
250
- }
251
- return obj;
252
- };
253
-
254
- // Funkcja tworząca finalny obiekt Device
255
- const createDevice = (device, type) => {
256
- // Settings już kapitalizowane w nazwach
257
- const settingsArray = device.Settings || [];
258
-
259
- const settingsObject = Object.fromEntries(
260
- settingsArray.map(({ name, value }) => {
261
- let parsedValue = value;
262
- if (value === "True") parsedValue = true;
263
- else if (value === "False") parsedValue = false;
264
- else if (!isNaN(value) && value !== "") parsedValue = Number(value);
265
-
266
- const key = name.charAt(0).toUpperCase() + name.slice(1);
267
- return [key, parsedValue];
268
- })
269
- );
270
-
271
- // Scal Capabilities + Settings + DeviceType w Device
272
- const deviceObject = {
273
- ...capitalizeKeys(device.Capabilities || {}),
274
- ...settingsObject,
275
- DeviceType: type,
276
- IsConnected: device.IsConnected
277
- };
278
-
279
- // Kapitalizacja brakujących obiektów/tablic
280
- if (device.FrostProtection) device.FrostProtection = { ...capitalizeKeys(device.FrostProtection || {}) };
281
- if (device.OverheatProtection) device.OverheatProtection = { ...capitalizeKeys(device.OverheatProtection || {}) };
282
- if (device.HolidayMode) device.HolidayMode = { ...capitalizeKeys(device.HolidayMode || {}) };
283
- if (Array.isArray(device.Schedule)) device.Schedule = device.Schedule.map(capitalizeKeysDeep);
284
-
285
- // Usuń stare pola Settings i Capabilities
286
- const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
287
-
288
- return {
289
- ...rest,
290
- Type: type,
291
- DeviceID: Id,
292
- DeviceName: GivenDisplayName,
293
- Device: deviceObject,
294
- Headers: headers
295
- };
296
- };
297
-
298
- return [
299
- ...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
300
- ...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
301
- ...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3))
302
- ];
303
- });
304
-
305
- const devicesCount = devices.length;
306
- if (devicesCount === 0) {
307
- devicesList.Info = 'No devices found'
308
- return devicesList;
309
- }
310
-
311
- await this.functions.saveData(this.devicesFile, devices);
312
- if (this.logDebug) this.emit('debug', `${devicesCount} devices saved`);
313
-
314
- devicesList.State = true;
315
- devicesList.Info = `Found ${devicesCount} devices`;
316
- devicesList.Devices = devices;
317
- return devicesList;
318
- } catch (error) {
319
- if (error.response?.status === 401) {
320
- await connectToMelCloudHome();
321
- if (this.logWarn) this.emit('warn', 'Check devices list not possible, cookies expired, trying to get new.');
322
- return;
323
- }
324
-
325
- throw new Error(`Check devices list error: ${error.message}`);
326
- }
327
- }
328
-
329
- async connectToMelCloudHome() {
330
- if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
331
- const GLOBAL_TIMEOUT = 90000;
332
-
333
- let browser;
334
- try {
335
- const accountInfo = { State: false, Info: '', ContextKey: null, UseFahrenheit: false };
336
- let chromiumPath = await this.functions.ensureChromiumInstalled();
337
-
338
- // === Fallback to Puppeteer's built-in Chromium ===
339
- if (!chromiumPath) {
340
- try {
341
- const puppeteerPath = puppeteer.executablePath();
342
- if (puppeteerPath && fs.existsSync(puppeteerPath)) {
343
- chromiumPath = puppeteerPath;
344
- if (this.logDebug) this.emit('debug', `Using puppeteer Chromium at ${chromiumPath}`);
345
- }
346
- } catch { }
347
- } else {
348
- if (this.logDebug) this.emit('debug', `Using system Chromium at ${chromiumPath}`);
349
- }
350
-
351
- if (!chromiumPath) {
352
- accountInfo.Info = 'Chromium not found on Your device, please install it manually and try again';
353
- return accountInfo;
354
- }
355
-
356
- // Verify executable works
357
- try {
358
- const { stdout } = await execPromise(`"${chromiumPath}" --version`);
359
- if (this.logDebug) this.emit('debug', `Chromium detected: ${stdout.trim()}`);
360
- } catch (error) {
361
- accountInfo.Info = `Chromium found at ${chromiumPath}, but cannot be executed: ${error.message}`;
362
- return accountInfo;
363
- }
364
-
365
- if (this.logDebug) this.emit('debug', `Launching Chromium...`);
366
- browser = await puppeteer.launch({
367
- headless: true,
368
- executablePath: chromiumPath,
369
- timeout: GLOBAL_TIMEOUT,
370
- args: [
371
- '--no-sandbox',
372
- '--disable-setuid-sandbox',
373
- '--disable-dev-shm-usage',
374
- '--single-process',
375
- '--disable-gpu',
376
- '--no-zygote'
377
- ]
378
- });
379
- browser.on('disconnected', () => this.emit('debug', 'Browser disconnected'));
380
-
381
- const page = await browser.newPage();
382
- page.on('error', error => this.emit('error', `Page crashed: ${error.message}`));
383
- page.on('pageerror', error => this.emit('error', `Browser error: ${error.message}`));
384
- page.setDefaultTimeout(GLOBAL_TIMEOUT);
385
- page.setDefaultNavigationTimeout(GLOBAL_TIMEOUT);
386
-
387
- // Clear cookies before navigation
388
- try {
389
- const client = await page.createCDPSession();
390
- await client.send('Network.clearBrowserCookies');
391
- } catch (error) {
392
- if (this.logError) this.emit('error', `Clear cookies error: ${error.message}`);
393
- }
394
-
395
- try {
396
- await page.goto(ApiUrlsHome.BaseURL, { waitUntil: ['domcontentloaded', 'networkidle2'], timeout: GLOBAL_TIMEOUT });
397
- } catch (error) {
398
- accountInfo.Info = `Navigation to ${ApiUrlsHome.BaseURL} failed: ${error.message}`;
399
- return accountInfo;
400
- }
401
-
402
- // Wait extra to ensure UI is rendered
403
- await new Promise(r => setTimeout(r, 3000));
404
- const loginBtn = await page.waitForSelector('button.btn--blue', { timeout: GLOBAL_TIMEOUT / 4 });
405
- const loginText = await page.evaluate(el => el.textContent.trim(), loginBtn);
406
-
407
- if (!['Zaloguj', 'Sign In', 'Login'].includes(loginText)) {
408
- accountInfo.Info = `Login button ${loginText} not found`;
409
- return accountInfo;
410
- }
411
-
412
- await loginBtn.click();
413
- await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: GLOBAL_TIMEOUT / 3 });
414
-
415
- const usernameInput = await page.$('input[name="username"]');
416
- const passwordInput = await page.$('input[name="password"]');
417
- if (!usernameInput || !passwordInput) {
418
- accountInfo.Info = 'Username or password input not found';
419
- return accountInfo;
420
- }
421
-
422
- await page.type('input[name="username"]', this.user, { delay: 50 });
423
- await page.type('input[name="password"]', this.passwd, { delay: 50 });
424
-
425
- const submitButton = await page.$('input[type="submit"], button[type="submit"]');
426
- if (!submitButton) {
427
- accountInfo.Info = 'Submit button not found';
428
- return accountInfo;
429
- }
430
- await Promise.race([Promise.all([submitButton.click(), page.waitForNavigation({ waitUntil: ['domcontentloaded', 'networkidle2'], timeout: GLOBAL_TIMEOUT / 4 })]), new Promise(r => setTimeout(r, GLOBAL_TIMEOUT / 3))]);
431
-
432
- // Extract cookies
433
- let c1 = null, c2 = null;
434
- const start = Date.now();
435
- while ((!c1 || !c2) && Date.now() - start < GLOBAL_TIMEOUT / 2) {
436
- const cookies = await page.browserContext().cookies();
437
- c1 = cookies.find(c => c.name === '__Secure-monitorandcontrolC1')?.value || c1;
438
- c2 = cookies.find(c => c.name === '__Secure-monitorandcontrolC2')?.value || c2;
439
- if (!c1 || !c2) await new Promise(r => setTimeout(r, 500));
440
- }
441
-
442
- if (!c1 || !c2) {
443
- accountInfo.Info = 'Cookies C1/C2 missing';
444
- return accountInfo;
445
- }
446
-
447
- const contextKey = [
448
- '__Secure-monitorandcontrol=chunks-2',
449
- `__Secure-monitorandcontrolC1=${c1}`,
450
- `__Secure-monitorandcontrolC2=${c2}`
451
- ].join('; ');
452
- this.contextKey = contextKey;
453
-
454
- accountInfo.State = true;
455
- accountInfo.Info = 'Connect to MELCloud Home Success';
456
- accountInfo.ContextKey = contextKey;
457
- await this.functions.saveData(this.accountFile, accountInfo);
458
-
459
- return accountInfo;
460
- } catch (error) {
461
- throw new Error(`Connect error: ${error.message}`);
462
- } finally {
463
- if (browser) {
464
- try { await browser.close(); }
465
- catch (closeErr) {
466
- if (this.logError) this.emit('error', `Failed to close Puppeteer: ${closeErr.message}`);
467
- }
468
- }
469
- }
470
- }
471
-
472
- async checkDevicesList() {
473
- const TIMEOUT_MS = 30000; // 30 seconds timeout
474
- try {
475
- const devicesList = await Promise.race([
476
- (async () => {
477
- switch (this.accountType) {
478
- case "melcloud":
479
- return await this.checkMelcloudDevicesList();
480
- case "melcloudhome":
481
- return await this.checkMelcloudHomeDevicesList();
482
- default:
483
- return [];
484
- }
485
- })(),
486
- new Promise((_, reject) => setTimeout(() => reject(new Error('Device list timeout (30s)')), TIMEOUT_MS))
487
- ]);
488
-
489
- return devicesList;
490
- } catch (error) {
491
- throw new Error(error);
492
- }
493
- }
494
-
495
- async connect() {
496
- const TIMEOUT_MS = 120000;
497
-
498
- try {
499
- const response = await Promise.race([
500
- (async () => {
501
- switch (this.accountType) {
502
- case "melcloud":
503
- return await this.connectToMelCloud();
504
- case "melcloudhome":
505
- return await this.connectToMelCloudHome();
506
- default:
507
- return {};
508
- }
509
- })(),
510
- new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (90s)')), TIMEOUT_MS))
511
- ]);
512
-
513
- return response;
514
- } catch (error) {
515
- throw new Error(error);
516
- }
517
- }
518
-
519
191
  async send(accountInfo) {
520
192
  try {
521
- const axiosInstance = axios.create({
193
+ const payload = { data: accountInfo.LoginData };
194
+ await axios(ApiUrls.UpdateApplicationOptions, {
522
195
  method: 'POST',
523
196
  baseURL: ApiUrls.BaseURL,
524
197
  timeout: 15000,
525
- headers: {
526
- 'X-MitsContextKey': accountInfo.ContextKey,
527
- 'content-type': 'application/json'
528
- }
198
+ headers: accountInfo.Headers,
199
+ data: payload
529
200
  });
530
-
531
- const payload = { data: accountInfo.LoginData };
532
- await axiosInstance(ApiUrls.UpdateApplicationOptions, payload);
533
201
  await this.functions.saveData(this.accountFile, accountInfo);
534
202
  return true;
535
203
  } catch (error) {
@@ -52,15 +52,9 @@ class MelCloudAta extends EventEmitter {
52
52
  try {
53
53
  //read device info from file
54
54
  const devicesData = await this.functions.readData(this.devicesFile, true);
55
- if (!Array.isArray(devicesData)) {
56
- if (this.logWarn) this.emit('warn', `Device data not found`);
57
- return null;
58
- }
59
55
  const deviceData = devicesData.find(device => device.DeviceID === this.deviceId);
60
56
 
61
57
  if (this.accountType === 'melcloudhome') {
62
- deviceData.SerialNumber = deviceData.DeviceID || '4.0.0';
63
- deviceData.Device.FirmwareAppVersion = deviceData.ConnectedInterfaceIdentifier || '4.0.0';
64
58
  deviceData.Device.OperationMode = AirConditioner.OperationModeMapStringToEnum[deviceData.Device.OperationMode] ?? deviceData.Device.OperationMode;
65
59
  deviceData.Device.ActualFanSpeed = AirConditioner.FanSpeedMapStringToEnum[deviceData.Device.ActualFanSpeed] ?? deviceData.Device.ActualFanSpeed;
66
60
  deviceData.Device.SetFanSpeed = AirConditioner.FanSpeedMapStringToEnum[deviceData.Device.SetFanSpeed] ?? deviceData.Device.SetFanSpeed;
@@ -80,13 +74,12 @@ class MelCloudAta extends EventEmitter {
80
74
  if (this.logDebug) this.emit('debug', `Device Data: ${JSON.stringify(safeConfig, null, 2)}`);
81
75
 
82
76
  //device
83
- const serialNumber = deviceData.SerialNumber;
84
- const firmwareAppVersion = deviceData.Device?.FirmwareAppVersion;
77
+ const serialNumber = deviceData.SerialNumber || '4.0.0';
78
+ const firmwareAppVersion = deviceData.Device?.FirmwareAppVersion || '4.0.0';
85
79
 
86
80
  //units
87
81
  const units = Array.isArray(deviceData.Device?.Units) ? deviceData.Device?.Units : [];
88
82
  const unitsCount = units.length;
89
- const manufacturer = 'Mitsubishi';
90
83
 
91
84
  const { indoor, outdoor } = units.reduce((acc, unit) => {
92
85
  const target = unit.IsIndoor ? 'indoor' : 'outdoor';
@@ -118,14 +111,11 @@ class MelCloudAta extends EventEmitter {
118
111
 
119
112
  //check state changes
120
113
  const deviceDataHasNotChanged = JSON.stringify(deviceData) === JSON.stringify(this.deviceData);
121
- if (deviceDataHasNotChanged) {
122
- if (this.logDebug) this.emit('debug', `Device state not changed`);
123
- return;
124
- }
114
+ if (deviceDataHasNotChanged) return;
125
115
  this.deviceData = deviceData;
126
116
 
127
117
  //emit info
128
- this.emit('deviceInfo', manufacturer, indoor.model, outdoor.model, serialNumber, firmwareAppVersion);
118
+ this.emit('deviceInfo', indoor.model, outdoor.model, serialNumber, firmwareAppVersion);
129
119
 
130
120
  //emit state
131
121
  this.emit('deviceState', deviceData);
@@ -167,17 +157,15 @@ class MelCloudAta extends EventEmitter {
167
157
  HideDryModeControl: deviceData.HideDryModeControl,
168
158
  HasPendingCommand: true
169
159
  };
170
- if (this.logDebug) this.emit('debug', `Send Data: ${JSON.stringify(payload, null, 2)}`);
171
160
 
172
- const axiosInstancePost = axios.create({
161
+ if (this.logDebug) this.emit('debug', `Send Data: ${JSON.stringify(payload, null, 2)}`);
162
+ await axios(ApiUrls.SetAta, {
173
163
  method: 'POST',
174
164
  baseURL: ApiUrls.BaseURL,
175
165
  timeout: 10000,
176
166
  headers: deviceData.Headers,
177
167
  data: payload
178
168
  });
179
-
180
- await axiosInstancePost(ApiUrls.SetAta);
181
169
  this.updateData(deviceData);
182
170
  return true;
183
171
  case "melcloudhome":
@@ -243,7 +231,7 @@ class MelCloudAta extends EventEmitter {
243
231
  break
244
232
  }
245
233
 
246
- if (this.logDebug) this.emit('debug', `Send Data: ${JSON.stringify(payload, null, 2)}`);
234
+ if (!this.logDebug) this.emit('warn', `Send Data: ${JSON.stringify(deviceData, null, 2)}`);
247
235
  await axios(path, {
248
236
  method: method,
249
237
  baseURL: ApiUrlsHome.BaseURL,
@@ -270,6 +258,5 @@ class MelCloudAta extends EventEmitter {
270
258
  this.lock = false
271
259
  }, 3000);
272
260
  }
273
-
274
261
  };
275
262
  export default MelCloudAta;