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

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.
@@ -0,0 +1,359 @@
1
+ import fs from 'fs';
2
+ import axios from 'axios';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import EventEmitter from 'events';
6
+ import puppeteer from 'puppeteer';
7
+ import ImpulseGenerator from './impulsegenerator.js';
8
+ import Functions from './functions.js';
9
+ import { ApiUrlsHome, LanguageLocaleMap } from './constants.js';
10
+ const execPromise = promisify(exec);
11
+
12
+ class MelCloud extends EventEmitter {
13
+ constructor(account, accountFile, buildingsFile, devicesFile, pluginStart = false) {
14
+ super();
15
+ this.accountType = account.type;
16
+ this.user = account.user;
17
+ this.passwd = account.passwd;
18
+ this.language = account.language;
19
+ this.logWarn = account.log?.warn;
20
+ this.logError = account.log?.error;
21
+ this.logDebug = account.log?.debug;
22
+ this.accountFile = accountFile;
23
+ this.buildingsFile = buildingsFile;
24
+ this.devicesFile = devicesFile;
25
+ this.headers = {};
26
+
27
+ this.functions = new Functions(this.logWarn, this.logError, this.logDebug)
28
+ .on('warn', warn => this.emit('warn', warn))
29
+ .on('error', error => this.emit('error', error))
30
+ .on('debug', debug => this.emit('debug', debug));
31
+
32
+ if (pluginStart) {
33
+ //lock flags
34
+ this.locks = {
35
+ connect: false,
36
+ checkDevicesList: false
37
+ };
38
+ this.impulseGenerator = new ImpulseGenerator()
39
+ .on('connect', () => this.handleWithLock('connect', async () => {
40
+ await this.connect();
41
+ }))
42
+ .on('checkDevicesList', () => this.handleWithLock('checkDevicesList', async () => {
43
+ await this.checkDevicesList();
44
+ }))
45
+ .on('state', (state) => {
46
+ this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`);
47
+ });
48
+ }
49
+ }
50
+
51
+ async handleWithLock(lockKey, fn) {
52
+ if (this.locks[lockKey]) return;
53
+
54
+ this.locks[lockKey] = true;
55
+ try {
56
+ await fn();
57
+ } catch (error) {
58
+ this.emit('error', `Inpulse generator error: ${error}`);
59
+ } finally {
60
+ this.locks[lockKey] = false;
61
+ }
62
+ }
63
+
64
+ // MELCloud Home
65
+ async checkDevicesList() {
66
+ try {
67
+ const devicesList = { State: false, Info: null, Devices: [] }
68
+ if (this.logDebug) this.emit('debug', `Scanning for devices`);
69
+ const listDevicesData = await axios(ApiUrlsHome.GetUserContext, {
70
+ method: 'GET',
71
+ baseURL: ApiUrlsHome.BaseURL,
72
+ timeout: 25000,
73
+ headers: this.headers
74
+ });
75
+ const buildingsList = listDevicesData.data.buildings;
76
+ if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
77
+
78
+ if (!buildingsList) {
79
+ devicesList.Info = 'No building found'
80
+ return devicesList;
81
+ }
82
+
83
+ await this.functions.saveData(this.buildingsFile, buildingsList);
84
+ if (this.logDebug) this.emit('debug', `Buildings list saved`);
85
+
86
+ const devices = buildingsList.flatMap(building => {
87
+ // Funkcja kapitalizująca klucze obiektu
88
+ const capitalizeKeys = obj =>
89
+ Object.fromEntries(
90
+ Object.entries(obj).map(([key, value]) => [
91
+ key.charAt(0).toUpperCase() + key.slice(1),
92
+ value
93
+ ])
94
+ );
95
+
96
+ // Rekurencyjna kapitalizacja kluczy w obiekcie lub tablicy
97
+ const capitalizeKeysDeep = obj => {
98
+ if (Array.isArray(obj)) return obj.map(capitalizeKeysDeep);
99
+ if (obj && typeof obj === 'object') {
100
+ return Object.fromEntries(
101
+ Object.entries(obj).map(([key, value]) => [
102
+ key.charAt(0).toUpperCase() + key.slice(1),
103
+ capitalizeKeysDeep(value)
104
+ ])
105
+ );
106
+ }
107
+ return obj;
108
+ };
109
+
110
+ // Funkcja tworząca finalny obiekt Device
111
+ const createDevice = (device, type) => {
112
+ // Settings już kapitalizowane w nazwach
113
+ const settingsArray = device.Settings || [];
114
+
115
+ const settingsObject = Object.fromEntries(
116
+ settingsArray.map(({ name, value }) => {
117
+ let parsedValue = value;
118
+ if (value === "True") parsedValue = true;
119
+ else if (value === "False") parsedValue = false;
120
+ else if (!isNaN(value) && value !== "") parsedValue = Number(value);
121
+
122
+ const key = name.charAt(0).toUpperCase() + name.slice(1);
123
+ return [key, parsedValue];
124
+ })
125
+ );
126
+
127
+ // Scal Capabilities + Settings + DeviceType w Device
128
+ const deviceObject = {
129
+ ...capitalizeKeys(device.Capabilities || {}),
130
+ ...settingsObject,
131
+ DeviceType: type,
132
+ FirmwareAppVersion: device.ConnectedInterfaceIdentifier,
133
+ IsConnected: device.IsConnected
134
+ };
135
+
136
+ // Kapitalizacja brakujących obiektów/tablic
137
+ if (device.FrostProtection) device.FrostProtection = { ...capitalizeKeys(device.FrostProtection || {}) };
138
+ if (device.OverheatProtection) device.OverheatProtection = { ...capitalizeKeys(device.OverheatProtection || {}) };
139
+ if (device.HolidayMode) device.HolidayMode = { ...capitalizeKeys(device.HolidayMode || {}) };
140
+ if (Array.isArray(device.Schedule)) device.Schedule = device.Schedule.map(capitalizeKeysDeep);
141
+
142
+ // Usuń stare pola Settings i Capabilities
143
+ const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
144
+
145
+ return {
146
+ ...rest,
147
+ Type: type,
148
+ DeviceID: Id,
149
+ DeviceName: GivenDisplayName,
150
+ SerialNumber: Id,
151
+ Device: deviceObject,
152
+ Headers: this.headers
153
+ };
154
+ };
155
+
156
+ return [
157
+ ...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
158
+ ...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
159
+ ...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3))
160
+ ];
161
+ });
162
+
163
+ const devicesCount = devices.length;
164
+ if (devicesCount === 0) {
165
+ devicesList.Info = 'No devices found'
166
+ return devicesList;
167
+ }
168
+
169
+ await this.functions.saveData(this.devicesFile, devices);
170
+ if (this.logDebug) this.emit('debug', `${devicesCount} devices saved`);
171
+
172
+ devicesList.State = true;
173
+ devicesList.Info = `Found ${devicesCount} devices`;
174
+ devicesList.Devices = devices;
175
+ return devicesList;
176
+ } catch (error) {
177
+ if (error.response?.status === 401) {
178
+ await connectToMelCloudHome();
179
+ if (this.logWarn) this.emit('warn', 'Check devices list not possible, cookies expired, trying to get new.');
180
+ return;
181
+ }
182
+
183
+ throw new Error(`Check devices list error: ${error.message}`);
184
+ }
185
+ }
186
+
187
+ async connect() {
188
+ if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
189
+ const GLOBAL_TIMEOUT = 90000;
190
+
191
+ let browser;
192
+ try {
193
+ const accountInfo = { State: false, Info: '', Headers: {}, UseFahrenheit: false };
194
+ let chromiumPath = await this.functions.ensureChromiumInstalled();
195
+
196
+ // === Fallback to Puppeteer's built-in Chromium ===
197
+ if (!chromiumPath) {
198
+ try {
199
+ const puppeteerPath = puppeteer.executablePath();
200
+ if (puppeteerPath && fs.existsSync(puppeteerPath)) {
201
+ chromiumPath = puppeteerPath;
202
+ if (this.logDebug) this.emit('debug', `Using puppeteer Chromium at ${chromiumPath}`);
203
+ }
204
+ } catch { }
205
+ } else {
206
+ if (this.logDebug) this.emit('debug', `Using system Chromium at ${chromiumPath}`);
207
+ }
208
+
209
+ if (!chromiumPath) {
210
+ accountInfo.Info = 'Chromium not found on Your device, please install it manually and try again';
211
+ return accountInfo;
212
+ }
213
+
214
+ // Verify executable works
215
+ try {
216
+ const { stdout } = await execPromise(`"${chromiumPath}" --version`);
217
+ if (this.logDebug) this.emit('debug', `Chromium detected: ${stdout.trim()}`);
218
+ } catch (error) {
219
+ accountInfo.Info = `Chromium found at ${chromiumPath}, but cannot be executed: ${error.message}`;
220
+ return accountInfo;
221
+ }
222
+
223
+ if (this.logDebug) this.emit('debug', `Launching Chromium...`);
224
+ browser = await puppeteer.launch({
225
+ headless: true,
226
+ executablePath: chromiumPath,
227
+ timeout: GLOBAL_TIMEOUT,
228
+ args: [
229
+ '--no-sandbox',
230
+ '--disable-setuid-sandbox',
231
+ '--disable-dev-shm-usage',
232
+ '--single-process',
233
+ '--disable-gpu',
234
+ '--no-zygote'
235
+ ]
236
+ });
237
+ browser.on('disconnected', () => this.emit('debug', 'Browser disconnected'));
238
+
239
+ const page = await browser.newPage();
240
+ page.on('error', error => this.emit('error', `Page crashed: ${error.message}`));
241
+ page.on('pageerror', error => this.emit('error', `Browser error: ${error.message}`));
242
+ page.setDefaultTimeout(GLOBAL_TIMEOUT);
243
+ page.setDefaultNavigationTimeout(GLOBAL_TIMEOUT);
244
+
245
+ // Clear cookies before navigation
246
+ try {
247
+ const client = await page.createCDPSession();
248
+ await client.send('Network.clearBrowserCookies');
249
+ } catch (error) {
250
+ if (this.logError) this.emit('error', `Clear cookies error: ${error.message}`);
251
+ }
252
+
253
+ try {
254
+ await page.goto(ApiUrlsHome.BaseURL, { waitUntil: ['domcontentloaded', 'networkidle2'], timeout: GLOBAL_TIMEOUT });
255
+ } catch (error) {
256
+ accountInfo.Info = `Navigation to ${ApiUrlsHome.BaseURL} failed: ${error.message}`;
257
+ return accountInfo;
258
+ }
259
+
260
+ // Wait extra to ensure UI is rendered
261
+ await new Promise(r => setTimeout(r, 3000));
262
+ const loginBtn = await page.waitForSelector('button.btn--blue', { timeout: GLOBAL_TIMEOUT / 4 });
263
+ const loginText = await page.evaluate(el => el.textContent.trim(), loginBtn);
264
+
265
+ if (!['Zaloguj', 'Sign In', 'Login'].includes(loginText)) {
266
+ accountInfo.Info = `Login button ${loginText} not found`;
267
+ return accountInfo;
268
+ }
269
+
270
+ await loginBtn.click();
271
+ await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: GLOBAL_TIMEOUT / 3 });
272
+
273
+ const usernameInput = await page.$('input[name="username"]');
274
+ const passwordInput = await page.$('input[name="password"]');
275
+ if (!usernameInput || !passwordInput) {
276
+ accountInfo.Info = 'Username or password input not found';
277
+ return accountInfo;
278
+ }
279
+
280
+ await page.type('input[name="username"]', this.user, { delay: 50 });
281
+ await page.type('input[name="password"]', this.passwd, { delay: 50 });
282
+
283
+ const submitButton = await page.$('input[type="submit"], button[type="submit"]');
284
+ if (!submitButton) {
285
+ accountInfo.Info = 'Submit button not found';
286
+ return accountInfo;
287
+ }
288
+ await Promise.race([Promise.all([submitButton.click(), page.waitForNavigation({ waitUntil: ['domcontentloaded', 'networkidle2'], timeout: GLOBAL_TIMEOUT / 4 })]), new Promise(r => setTimeout(r, GLOBAL_TIMEOUT / 3))]);
289
+
290
+ // Extract cookies
291
+ let c1 = null, c2 = null;
292
+ const start = Date.now();
293
+ while ((!c1 || !c2) && Date.now() - start < GLOBAL_TIMEOUT / 2) {
294
+ const cookies = await page.browserContext().cookies();
295
+ c1 = cookies.find(c => c.name === '__Secure-monitorandcontrolC1')?.value || c1;
296
+ c2 = cookies.find(c => c.name === '__Secure-monitorandcontrolC2')?.value || c2;
297
+ if (!c1 || !c2) await new Promise(r => setTimeout(r, 500));
298
+ }
299
+
300
+ if (!c1 || !c2) {
301
+ accountInfo.Info = 'Cookies C1/C2 missing';
302
+ return accountInfo;
303
+ }
304
+
305
+ const cookies = [
306
+ '__Secure-monitorandcontrol=chunks-2',
307
+ `__Secure-monitorandcontrolC1=${c1}`,
308
+ `__Secure-monitorandcontrolC2=${c2}`
309
+ ].join('; ');
310
+
311
+ this.headers = {
312
+ 'Accept': '*/*',
313
+ 'Accept-Encoding': 'gzip, deflate, br',
314
+ 'Accept-Language': LanguageLocaleMap[this.language],
315
+ 'Cookie': cookies,
316
+ 'Priority': 'u=3, i',
317
+ 'Referer': ApiUrlsHome.Dashboard,
318
+ 'Sec-Fetch-Dest': 'empty',
319
+ 'Sec-Fetch-Mode': 'cors',
320
+ 'Sec-Fetch-Site': 'same-origin',
321
+ 'x-csrf': '1'
322
+ };
323
+
324
+ accountInfo.State = true;
325
+ accountInfo.Info = 'Connect to MELCloud Home Success';
326
+ accountInfo.Headers = this.headers;
327
+ await this.functions.saveData(this.accountFile, accountInfo);
328
+
329
+ return accountInfo;
330
+ } catch (error) {
331
+ throw new Error(`Connect error: ${error.message}`);
332
+ } finally {
333
+ if (browser) {
334
+ try { await browser.close(); }
335
+ catch (closeErr) {
336
+ if (this.logError) this.emit('error', `Failed to close Puppeteer: ${closeErr.message}`);
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ async send(accountInfo) {
343
+ try {
344
+ await axios(ApiUrlsHome.UpdateApplicationOptions, {
345
+ method: 'POST',
346
+ baseURL: ApiUrlsHome.BaseURL,
347
+ timeout: 15000,
348
+ headers: accountInfo.Headers
349
+ });
350
+ await this.functions.saveData(this.accountFile, accountInfo);
351
+ return true;
352
+ } catch (error) {
353
+ throw new Error(`Send data error: ${error.message}`);
354
+ }
355
+ }
356
+ }
357
+
358
+ export default MelCloud;
359
+