homebridge-melcloud-control 4.9.0-beta.9 → 4.9.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/CHANGELOG.md CHANGED
@@ -24,6 +24,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
24
24
  - For plugin < v4.6.0 use Homebridge UI <= v5.5.0
25
25
  - For plugin >= v4.6.0 use Homebridge UI >= v5.13.0
26
26
 
27
+ # [4.9.1] - (15.04.2026)
28
+
29
+ ## Changes
30
+
31
+ - fix scene and shedule enable/disable for MELCloud Home
32
+
33
+ # [4.9.0] - (15.04.2026)
34
+
35
+ ## Changes
36
+
37
+ - refactor MELCloud Home to use Bearer token for authentication instead of cookies, fix [#240]
38
+ - bump dependencies
39
+ - cleanup
40
+
27
41
  # [4.8.7] - (10.04.2026)
28
42
 
29
43
  ## Changes
package/index.js CHANGED
@@ -83,7 +83,7 @@ class MelCloudPlatform {
83
83
  melCloudClass = new MelCloud(account, true);
84
84
  break;
85
85
  case 'melcloudhome':
86
- timmers = [{ name: 'connect', sampling: 3300000 }, { name: 'checkDevicesList', sampling: 5000 }];
86
+ timmers = [{ name: 'checkDevicesList', sampling: 7000 }];
87
87
  melCloudClass = new MelCloudHome(account, true);
88
88
  break;
89
89
  default:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "MELCloud Control",
3
3
  "name": "homebridge-melcloud-control",
4
- "version": "4.9.0-beta.9",
4
+ "version": "4.9.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",
@@ -40,8 +40,7 @@
40
40
  "axios": "^1.15.0",
41
41
  "axios-cookiejar-support": "^6.0.5",
42
42
  "tough-cookie": "^6.0.1",
43
- "express": "^5.2.1",
44
- "ws": "^8.20.0"
43
+ "express": "^5.2.1"
45
44
  },
46
45
  "keywords": [
47
46
  "homebridge",
package/src/constants.js CHANGED
@@ -22,7 +22,8 @@ export const ApiUrls = {
22
22
  },
23
23
  Home: {
24
24
  UserAgent: "MonitorAndControl.App.Mobile/52 CFNetwork/3860.400.51 Darwin/25.3.0",
25
- Base: "https://mobile.bff.melcloudhome.com",
25
+ Base: "https://melcloudhome.com",
26
+ BaseMobile: "https://mobile.bff.melcloudhome.com",
26
27
  AuthBase: "https://auth.melcloudhome.com",
27
28
  MockBase: "http://localhost:8080",
28
29
  OauthClientId: "homemobile",
@@ -33,41 +34,42 @@ export const ApiUrls = {
33
34
  WebSocket: "wss://ws.melcloudhome.com/?hash=",
34
35
  Get: {
35
36
  Configuration: "/api/configuration",
36
- ListDevices: "/api/user/context",
37
- Scenes: "/api/user/scenes",
37
+ Context: "/context",
38
+ Scenes: "/monitor/user/scenes",
38
39
  TelemetryEnergy: "/telemetry/telemetry/energy/deviceid",
39
40
  TelemetryActual: "/telemetry/telemetry/actual/deviceid",
40
- ReportTrendSummary: "/report/v1/trendsummary"
41
+ ReportTrendSummary: "/report/v1/trendsummary",
42
+ SystemInvites: "/systeminvites"
41
43
  },
42
- },
43
- Post: {
44
- ProtectionFrost: "/api/protection/frost", //{"enabled":true,"min":13,"max":16,"units":{"ATA":["deviceid"]}}
45
- ProtectionOverheat: "/api/protection/overheat", //{"enabled":true,"min":32,"max":35,"units":{"ATA":["deviceid"]}}
46
- HolidayMode: "/api/holidaymode", //{"enabled":true,"startDate":"2025-11-11T17:42:24.913","endDate":"2026-06-01T09:18:00","units":{"ATA":["deviceid"]}}
47
- Schedule: "/api/cloudschedule/deviceid", //{"days":[2],"time":"17:59:00","enabled":true,"id":"scheduleid","power":false,"operationMode":null,"setPoint":null,"vaneVerticalDirection":null,"vaneHorizontalDirection":null,"setFanSpeed":null}
48
- Scene: "/api/scene", //{"id": "sceneid", "userId": "userid","name": "Poza domem","enabled": false,"icon": "AwayIcon","ataSceneSettings": [{"unitId": "deviceid","ataSettings": { "power": false, "operationMode": "heat","setFanSpeed": "auto","vaneHorizontalDirection": "auto", "vaneVerticalDirection": "auto", "setTemperature": 21,"temperatureIncrementOverride": null,"inStandbyMode": null},"previousSettings": null}],"atwSceneSettings": []}
49
- },
50
- Put: {
51
- Ata: "/api/ataunit/deviceid", //{ power: true,setTemperature: 22, setFanSpeed: "auto", operationMode: "heat", vaneHorizontalDirection: "auto",vaneVerticalDirection: "auto", temperatureIncrementOverride: null, inStandbyMode: null}
52
- AtaMobile: "/monitor/ataunit/deviceid",
53
- Atw: "/api/atwunit/deviceid",
54
- AtwMobile: "/monitor/atwunit/deviceid",
55
- Erv: "/api/ervunit/deviceid",
56
- ErvMobile: "/monitor/ervunit/deviceid",
57
- ScheduleEnableDisable: "/api/cloudschedule/deviceid/enabled", // {"enabled": true}
58
- SceneEnableDisable: "/api/scene/sceneid",
59
- },
60
- Delete: {
61
- Schedule: "/api/cloudschedule/deviceid/scheduleid",
62
- Scene: "/api/scene/sceneid"
63
- },
64
- Referers: {
65
- GetPutScenes: "https://melcloudhome.com/scenes",
66
- PostHolidayMode: "https://melcloudhome.com/ata/deviceid/holidaymode",
67
- PostProtectionFrost: "https://melcloudhome.com/ata/deviceid/frostprotection",
68
- PostProtectionOverheat: "https://melcloudhome.com/ata/deviceid/overheatprotection",
69
- PutDeviceSettings: "https://melcloudhome.com/dashboard",
70
- PutScheduleEnabled: "https://melcloudhome.com/ata/deviceid/schedule",
44
+ Post: {
45
+ ProtectionFrost: "/monitor/protection/frost", //{"enabled":true,"min":13,"max":16,"units":{"ATA":["deviceid"]}}
46
+ ProtectionOverheat: "/monitor/protection/overheat", //{"enabled":true,"min":32,"max":35,"units":{"ATA":["deviceid"]}}
47
+ HolidayMode: "/monitor/holidaymode", //{"enabled":true,"startDate":"2025-11-11T17:42:24.913","endDate":"2026-06-01T09:18:00","units":{"ATA":["deviceid"]}}
48
+ Schedule: "/monitor/cloudschedule/deviceid", //{"days":[2],"time":"17:59:00","enabled":true,"id":"scheduleid","power":false,"operationMode":null,"setPoint":null,"vaneVerticalDirection":null,"vaneHorizontalDirection":null,"setFanSpeed":null}
49
+ Scene: "/monitor/scene", //{"id": "sceneid", "userId": "userid","name": "Poza domem","enabled": false,"icon": "AwayIcon","ataSceneSettings": [{"unitId": "deviceid","ataSettings": { "power": false, "operationMode": "heat","setFanSpeed": "auto","vaneHorizontalDirection": "auto", "vaneVerticalDirection": "auto", "setTemperature": 21,"temperatureIncrementOverride": null,"inStandbyMode": null},"previousSettings": null}],"atwSceneSettings": []}
50
+ },
51
+ Put: {
52
+ Ata: "/api/ataunit/deviceid", //{ power: true,setTemperature: 22, setFanSpeed: "auto", operationMode: "heat", vaneHorizontalDirection: "auto",vaneVerticalDirection: "auto", temperatureIncrementOverride: null, inStandbyMode: null}
53
+ AtaMobile: "/monitor/ataunit/deviceid",
54
+ Atw: "/api/atwunit/deviceid",
55
+ AtwMobile: "/monitor/atwunit/deviceid",
56
+ Erv: "/api/ervunit/deviceid",
57
+ ErvMobile: "/monitor/ervunit/deviceid",
58
+ ScheduleEnableDisable: "/monitor/cloudschedule/deviceid/enabled", // {"enabled": true}
59
+ SceneEnableDisable: "/monitor/scene/sceneid",
60
+ },
61
+ Delete: {
62
+ Schedule: "/api/cloudschedule/deviceid/scheduleid",
63
+ Scene: "/api/scene/sceneid"
64
+ },
65
+ Referers: {
66
+ GetPutScenes: "https://melcloudhome.com/scenes",
67
+ PostHolidayMode: "https://melcloudhome.com/ata/deviceid/holidaymode",
68
+ PostProtectionFrost: "https://melcloudhome.com/ata/deviceid/frostprotection",
69
+ PostProtectionOverheat: "https://melcloudhome.com/ata/deviceid/overheatprotection",
70
+ PutDeviceSettings: "https://melcloudhome.com/dashboard",
71
+ PutScheduleEnabled: "https://melcloudhome.com/ata/deviceid/schedule",
72
+ }
71
73
  }
72
74
  };
73
75
 
@@ -87,8 +89,8 @@ export const AirConditioner = {
87
89
  FanSpeedMapEnumToString: { 0: "Auto", 1: "One", 2: "Two", 3: "Three", 4: "Four", 5: "Five" },
88
90
  SetFanSpeedMapStringToEnum: { "Auto": 0, "One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5, "0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5 },
89
91
  SetFanSpeedMapEnumToString: { 0: "Auto", 1: "One", 2: "Two", 3: "Three", 4: "Four", 5: "Five" },
90
- AktualFanSpeedMapStringToEnum: { "Auto": 0, "One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5, "0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5 },
91
- AktualFanSpeedMapEnumToString: { 0: "Quiet", 1: "One", 2: "Two", 3: "Three", 4: "Four", 5: "Five" },
92
+ AktualFanSpeedMapStringToEnum: { "Auto": 0, "One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5, "Off": 6, "0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5 },
93
+ AktualFanSpeedMapEnumToString: { 0: "Quiet", 1: "One", 2: "Two", 3: "Three", 4: "Four", 5: "Five", 6: "Off" },
92
94
  VaneVerticalDirectionMapStringToEnum: { "Auto": 0, "One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5, "Six": 6, "Swing": 7 },
93
95
  VaneVerticalDirectionMapEnumToString: { 0: "Auto", 1: "One", 2: "Two", 3: "Three", 4: "Four", 5: "Five", 6: "Six", 7: "Swing" },
94
96
  VaneVerticalDirectionMapEnumToEnumWs: { 6: 7 },
package/src/functions.js CHANGED
@@ -54,126 +54,6 @@ class Functions extends EventEmitter {
54
54
  }
55
55
  }
56
56
 
57
- async ensureChromiumInstalled() {
58
- try {
59
- const { stdout: osOut } = await execPromise('uname -s');
60
- const osName = osOut.trim();
61
-
62
- const { stdout: archOut } = await execPromise('uname -m');
63
- const rawArch = archOut.trim() || 'unknown';
64
-
65
- let arch;
66
- if (/^aarch64|^arm/.test(rawArch)) arch = 'arm';
67
- else if (rawArch === 'x86_64' || rawArch === 'amd64') arch = 'x64';
68
- else arch = 'x86';
69
-
70
- const isARM = arch === 'arm';
71
- const isMac = osName === 'Darwin';
72
- const isLinux = osName === 'Linux';
73
- const isQnap = fs.existsSync('/etc/config/uLinux.conf') || fs.existsSync('/etc/config/qpkg.conf');
74
-
75
- // Docker detection
76
- let isDocker = false;
77
- try {
78
- await access('/.dockerenv');
79
- isDocker = true;
80
- } catch { }
81
-
82
- try {
83
- const { stdout } = await execPromise('cat /proc/1/cgroup');
84
- if (stdout.includes('docker') || stdout.includes('containerd')) isDocker = true;
85
- } catch { }
86
-
87
- /* ===================== macOS ===================== */
88
- if (isMac) {
89
- const macCandidates = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium'];
90
- for (const path of macCandidates) {
91
- try {
92
- await access(path, fs.constants.X_OK);
93
- return { path, arch, system: 'macOS' };
94
- } catch { }
95
- }
96
-
97
- return { path: null, arch, system: 'macOS' };
98
- }
99
-
100
- /* ===================== QNAP ===================== */
101
- if (isQnap) {
102
- const qnapCandidates = ['/opt/bin/chromium', '/opt/bin/chromium-browser'];
103
- for (const path of qnapCandidates) {
104
- try {
105
- await access(path, fs.constants.X_OK);
106
- return { path, arch, system: 'Qnap' };
107
- } catch { }
108
- }
109
-
110
- try {
111
- await access('/opt/bin/opkg', fs.constants.X_OK);
112
- await execPromise('/opt/bin/opkg update');
113
- await execPromise('opkg install chromium nspr nss libx11 libxcomposite libxdamage libxrandr atk libcups libdrm libgbm alsa-lib');
114
- process.env.LD_LIBRARY_PATH = `/opt/lib:${process.env.LD_LIBRARY_PATH || ''}`;
115
- } catch (error) {
116
- if (this.logDebug) this.emit('debug', `Install Chromium for Qnap error: ${error}`);
117
- return { path: null, arch, system: 'Qnap' };
118
- }
119
-
120
- for (const path of qnapCandidates) {
121
- try {
122
- await access(path, fs.constants.X_OK);
123
- return { path, arch, system: 'Qnap' };
124
- } catch { }
125
- }
126
-
127
- return { path: null, arch, system: 'Qnap' };
128
- }
129
-
130
- /* ===================== Linux ===================== */
131
- if (isLinux) {
132
- const linuxCandidates = ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium'];
133
-
134
- // Detect existing browser (ARM + x64)
135
- for (const path of linuxCandidates) {
136
- try {
137
- await access(path, fs.constants.X_OK);
138
- return { path, arch, system: isDocker ? 'Linux Docker' : (isARM ? 'Linux ARM' : 'Linux') };
139
- } catch { }
140
- }
141
-
142
- // ARM → detect only
143
- if (isARM) {
144
- return { path: null, arch, system: 'Linux ARM' };
145
- }
146
-
147
- // Docker → install Chrome
148
- if (isDocker) {
149
- try {
150
- await execPromise('apt-get update -y');
151
- await execPromise('wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb');
152
- await execPromise('apt-get install -y ./google-chrome-stable_current_amd64.deb');
153
- } catch (error) {
154
- if (this.logDebug) this.emit('debug', `Install Chrome for Docker error: ${error}`);
155
- return { path: null, arch, system: 'Linux Docker' };
156
- }
157
-
158
- for (const path of linuxCandidates) {
159
- try {
160
- await access(path, fs.constants.X_OK);
161
- return { path, arch, system: 'Linux Docker' };
162
- } catch { }
163
- }
164
- }
165
-
166
- return { path: null, arch, system: isDocker ? 'Linux Docker' : 'Linux' };
167
- }
168
-
169
- return { path: null, arch, system: 'unknown' };
170
- } catch (error) {
171
- if (this.logDebug) this.emit('debug', `Chromium detection error: ${error.message}`);
172
- return { path: null, arch: 'unknown', system: 'unknown' };
173
- }
174
- }
175
-
176
-
177
57
  isValidValue(v) {
178
58
  return v !== undefined && v !== null && !(typeof v === 'number' && Number.isNaN(v));
179
59
  }
@@ -315,7 +315,7 @@ class MelCloudAta extends EventEmitter {
315
315
  });
316
316
 
317
317
  method = 'PUT';
318
- path = ApiUrls.Home.Put.Ata.replace('deviceid', deviceData.DeviceID);
318
+ path = ApiUrls.Home.Put.AtaMobile.replace('deviceid', deviceData.DeviceID);
319
319
  break;
320
320
  }
321
321
 
@@ -265,7 +265,7 @@ class MelCloudAtw extends EventEmitter {
265
265
  });
266
266
 
267
267
  method = 'PUT';
268
- path = ApiUrls.Home.Put.Atw.replace('deviceid', deviceData.DeviceID);
268
+ path = ApiUrls.Home.Put.AtwMobile.replace('deviceid', deviceData.DeviceID);
269
269
  break
270
270
  }
271
271
 
@@ -280,7 +280,7 @@ class MelCloudErv extends EventEmitter {
280
280
  });
281
281
 
282
282
  method = 'PUT';
283
- path = ApiUrls.Home.Put.Erv.replace('deviceid', deviceData.DeviceID);
283
+ path = ApiUrls.Home.Put.ErvMobile.replace('deviceid', deviceData.DeviceID);
284
284
  break
285
285
  }
286
286
 
@@ -1,6 +1,5 @@
1
1
  import axios from 'axios';
2
2
  import crypto from 'crypto';
3
- import WebSocket from 'ws';
4
3
  import EventEmitter from 'events';
5
4
  import ImpulseGenerator from './impulsegenerator.js';
6
5
  import Functions from './functions.js';
@@ -21,10 +20,6 @@ class MelCloudHome extends EventEmitter {
21
20
  this.logError = account.log?.error;
22
21
  this.logDebug = account.log?.debug;
23
22
 
24
- this.client = null;
25
- this.socketConnected = false;
26
- this.heartbeat = null;
27
-
28
23
  this.functions = new Functions(this.logWarn, this.logError, this.logDebug)
29
24
  .on('warn', warn => this.emit('warn', warn))
30
25
  .on('error', error => this.emit('error', error))
@@ -32,102 +27,54 @@ class MelCloudHome extends EventEmitter {
32
27
 
33
28
  this.pacer = new RequestPacer();
34
29
 
35
- // axios auth client and token state
36
- this.authClient = null;
37
- this._accessToken = null; // podkreślnik unika konfliktu z getterami
38
- this._refreshToken = null;
39
- this._tokenExpiry = 0; // Unix timestamp (sekundy)
40
- this._authenticated = false;
30
+ // Axios clients
31
+ this.authClient = null; // cookie-jar client używany tylko podczas auth flow
32
+ this.client = null; // API client używany do requestów po zalogowaniu
41
33
 
42
- if (pluginStart) {
43
- this.locks = {
44
- connect: false,
45
- checkDevicesList: false,
46
- };
34
+ // Token state
35
+ this.accessToken = null;
36
+ this.refreshToken = null;
37
+ this.tokenExpiry = 0; // Unix timestamp (sekundy)
47
38
 
39
+ // Flaga zapobiegająca wielokrotnemu dodaniu interceptorów
40
+ this._interceptorsAttached = false;
41
+
42
+ if (pluginStart) {
48
43
  this.impulseGenerator = new ImpulseGenerator()
49
- .on('connect', () => this.handleWithLock('connect', async () => {
50
- await this.connect();
51
- }))
52
- .on('checkDevicesList', () => this.handleWithLock('checkDevicesList', async () => {
44
+ .on('checkDevicesList', async () => {
53
45
  await this.checkDevicesList();
54
- }))
46
+ })
55
47
  .on('state', (state) => {
56
48
  this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`);
57
49
  });
58
50
  }
59
51
  }
60
52
 
61
- // ── Lock helper ───────────────────────────────────────────────────────────
62
-
63
- async handleWithLock(lockKey, fn) {
64
- if (this.locks[lockKey]) return;
65
- this.locks[lockKey] = true;
66
- try {
67
- await fn();
68
- } catch (error) {
69
- this.emit('error', `Impulse generator error: ${error}`);
70
- } finally {
71
- this.locks[lockKey] = false;
72
- }
73
- }
53
+ // ── Utils ─────────────────────────────────────────────────────────────────
74
54
 
75
- cleanupSocket() {
76
- if (this.heartbeat) {
77
- clearInterval(this.heartbeat);
78
- this.heartbeat = null;
55
+ capitalizeKeysDeep(obj) {
56
+ if (Array.isArray(obj)) return obj.map(item => this.capitalizeKeysDeep(item));
57
+ if (obj && typeof obj === 'object') {
58
+ return Object.fromEntries(
59
+ Object.entries(obj).map(([k, v]) => [
60
+ k.charAt(0).toUpperCase() + k.slice(1),
61
+ this.capitalizeKeysDeep(v),
62
+ ])
63
+ );
79
64
  }
80
- this.socketConnected = false;
81
- }
82
-
83
- // ── Pacer helper ──────────────────────────────────────────────────────────
84
-
85
- pace(fn) {
86
- return this.pacer.run(fn);
87
- }
88
-
89
- // ── Token gettery (nie kolidują z polami instancji) ───────────────────────
90
-
91
- get accessToken() { return this._accessToken; }
92
- get refreshToken() { return this._refreshToken; }
93
-
94
- get isTokenExpired() {
95
- if (!this._accessToken) return true;
96
- return Date.now() / 1000 >= this._tokenExpiry - 60;
97
- }
98
-
99
- get isAuthenticated() {
100
- return this._authenticated && !this.isTokenExpired;
101
- }
102
-
103
- // ── Token persistence ─────────────────────────────────────────────────────
104
-
105
- restoreTokens(accessToken, refreshToken, tokenExpiry) {
106
- this._accessToken = accessToken;
107
- this._refreshToken = refreshToken;
108
- this._tokenExpiry = tokenExpiry;
109
- if (accessToken && refreshToken) this._authenticated = true;
110
- }
111
-
112
- getTokenSnapshot() {
113
- return {
114
- access_token: this._accessToken,
115
- refresh_token: this._refreshToken,
116
- token_expiry: this._tokenExpiry,
117
- };
65
+ return obj;
118
66
  }
119
67
 
120
- // ── PKCE ──────────────────────────────────────────────────────────────────
68
+ // ── Token state ───────────────────────────────────────────────────────────
121
69
 
122
- generatePkce() {
123
- const verifier = crypto.randomBytes(32).toString('base64url');
124
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
125
- return { verifier, challenge };
70
+ isTokenExpired() {
71
+ if (!this.accessToken) return true;
72
+ return Date.now() / 1000 >= this.tokenExpiry - 60;
126
73
  }
127
74
 
128
- // ── Axios client z cookie jar ─────────────────────────────────────────────
75
+ // ── Axios clients ─────────────────────────────────────────────────────────
129
76
 
130
- ensureClient() {
77
+ ensureAuthClient() {
131
78
  if (this.authClient) return this.authClient;
132
79
 
133
80
  const jar = new CookieJar();
@@ -136,8 +83,8 @@ class MelCloudHome extends EventEmitter {
136
83
  jar,
137
84
  timeout: 30_000,
138
85
  headers: {
139
- 'User-Agent': ApiUrls.Home.UserAgent,
140
86
  Accept: 'application/json',
87
+ 'User-Agent': ApiUrls.Home.UserAgent,
141
88
  },
142
89
  maxRedirects: 5,
143
90
  validateStatus: () => true,
@@ -148,6 +95,35 @@ class MelCloudHome extends EventEmitter {
148
95
  return instance;
149
96
  }
150
97
 
98
+ ensureClient() {
99
+ if (this.client) return this.client;
100
+
101
+ this.client = axios.create({
102
+ baseURL: ApiUrls.Home.BaseMobile,
103
+ timeout: 30_000,
104
+ headers: {
105
+ Accept: 'application/json',
106
+ 'User-Agent': ApiUrls.Home.UserAgent,
107
+ },
108
+ });
109
+
110
+ return this.client;
111
+ }
112
+
113
+ // ── Pacer helper ──────────────────────────────────────────────────────────
114
+
115
+ pace(fn) {
116
+ return this.pacer.run(fn);
117
+ }
118
+
119
+ // ── PKCE ──────────────────────────────────────────────────────────────────
120
+
121
+ generatePkce() {
122
+ const verifier = crypto.randomBytes(32).toString('base64url');
123
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
124
+ return { verifier, challenge };
125
+ }
126
+
151
127
  // ── CSRF token ────────────────────────────────────────────────────────────
152
128
 
153
129
  extractCsrfToken(html) {
@@ -221,10 +197,9 @@ class MelCloudHome extends EventEmitter {
221
197
  if (resp.status >= 500) throw new Error(`Token exchange server error: HTTP ${resp.status}`);
222
198
  if (resp.status !== 200) throw new Error(`Token exchange failed: HTTP ${resp.status}`);
223
199
 
224
- this._accessToken = resp.data.access_token;
225
- this._refreshToken = resp.data.refresh_token ?? this._refreshToken;
226
- this._tokenExpiry = Date.now() / 1000 + (resp.data.expires_in ?? 3600);
227
- this._authenticated = true;
200
+ this.accessToken = resp.data.access_token;
201
+ this.refreshToken = resp.data.refresh_token ?? this.refreshToken;
202
+ this.tokenExpiry = Date.now() / 1000 + (resp.data.expires_in ?? 3600);
228
203
 
229
204
  if (this.logDebug) this.emit('debug', 'Authentication successful');
230
205
  return true;
@@ -233,16 +208,16 @@ class MelCloudHome extends EventEmitter {
233
208
  // ── Token refresh ─────────────────────────────────────────────────────────
234
209
 
235
210
  async refreshAccessToken() {
236
- if (!this._refreshToken) throw new Error('No refresh token available');
211
+ if (!this.refreshToken) throw new Error('No refresh token available');
237
212
 
238
- const client = this.ensureClient();
213
+ const client = this.ensureAuthClient();
239
214
 
240
215
  const resp = await this.pace(() =>
241
216
  client.post(
242
217
  `${ApiUrls.Home.AuthBase}/connect/token`,
243
218
  new URLSearchParams({
244
219
  grant_type: 'refresh_token',
245
- refresh_token: this._refreshToken,
220
+ refresh_token: this.refreshToken,
246
221
  client_id: ApiUrls.Home.OauthClientId,
247
222
  }),
248
223
  { headers: { 'User-Agent': ApiUrls.Home.UserAgent } }
@@ -250,19 +225,207 @@ class MelCloudHome extends EventEmitter {
250
225
  );
251
226
 
252
227
  if (resp.status !== 200) {
253
- this._authenticated = false;
254
- this._accessToken = null;
255
- this._refreshToken = null;
228
+ this.accessToken = null;
229
+ this.refreshToken = null;
256
230
  throw new Error('Refresh token rejected');
257
231
  }
258
232
 
259
- this._accessToken = resp.data.access_token;
260
- this._refreshToken = resp.data.refresh_token ?? this._refreshToken;
261
- this._tokenExpiry = Date.now() / 1000 + (resp.data.expires_in ?? 3600);
262
- this._authenticated = true;
233
+ this.accessToken = resp.data.access_token;
234
+ this.refreshToken = resp.data.refresh_token ?? this.refreshToken;
235
+ this.tokenExpiry = Date.now() / 1000 + (resp.data.expires_in ?? 3600);
263
236
  return true;
264
237
  }
265
238
 
239
+ // ── Auto-refresh: refresh token lub pełne logowanie od nowa ──────────────
240
+
241
+ async refreshOrRelogin() {
242
+ if (this.refreshToken) {
243
+ try {
244
+ await this.refreshAccessToken();
245
+ if (this.logDebug) this.emit('debug', 'Token refreshed successfully');
246
+ return;
247
+ } catch (err) {
248
+ if (this.logDebug) this.emit('debug', `Refresh token rejected (${err.message}), falling back to full re-login`);
249
+ }
250
+ }
251
+
252
+ if (this.logDebug) this.emit('debug', 'Performing full re-login');
253
+ await this.connect();
254
+ }
255
+
256
+ // ── Interceptory do automatycznego odświeżania tokena ─────────────────────
257
+
258
+ attachTokenInterceptors() {
259
+ if (this._interceptorsAttached) return;
260
+ this._interceptorsAttached = true;
261
+
262
+ const apiClient = this.ensureClient();
263
+
264
+ // Request interceptor — dokłada aktualny token przed każdym requestem.
265
+ // Jeśli token wygasł, odświeża go najpierw.
266
+ apiClient.interceptors.request.use(async (config) => {
267
+ if (this.isTokenExpired()) {
268
+ if (this.logDebug) this.emit('debug', 'Token expired or missing — refreshing before request');
269
+ await this.refreshOrRelogin();
270
+ }
271
+ config.headers['Authorization'] = `Bearer ${this.accessToken}`;
272
+ return config;
273
+ });
274
+
275
+ // Response interceptor — obsługuje 401 który może przyjść mimo świeżego tokena
276
+ // (np. token odwołany po stronie serwera). Ponawia request dokładnie raz.
277
+ apiClient.interceptors.response.use(
278
+ response => response,
279
+ async (error) => {
280
+ const originalRequest = error.config;
281
+
282
+ if (error.response?.status === 401 && !originalRequest._retried) {
283
+ originalRequest._retried = true;
284
+ if (this.logDebug) this.emit('debug', 'Got 401 — refreshing token and retrying request');
285
+
286
+ try {
287
+ await this.refreshOrRelogin();
288
+ originalRequest.headers['Authorization'] = `Bearer ${this.accessToken}`;
289
+ return apiClient(originalRequest);
290
+ } catch (refreshError) {
291
+ this.emit('error', `Token refresh failed: ${refreshError.message}`);
292
+ return Promise.reject(refreshError);
293
+ }
294
+ }
295
+
296
+ return Promise.reject(error);
297
+ }
298
+ );
299
+ }
300
+
301
+ // ── Buduje connectInfo po udanym token exchange ───────────────────────────
302
+
303
+ buildConnectInfo(connectInfo, exchangeRes) {
304
+ if (exchangeRes) {
305
+ // ensureClient() tworzy client jeśli nie istnieje.
306
+ // attachTokenInterceptors() dodaje interceptory tylko przy pierwszym wywołaniu.
307
+ this.ensureClient();
308
+ this.attachTokenInterceptors();
309
+ this.emit('client', this.client);
310
+ }
311
+
312
+ connectInfo.State = exchangeRes;
313
+ connectInfo.Status = exchangeRes ? 'Connect Success' : 'Connect Failed at token exchange';
314
+
315
+ return connectInfo;
316
+ }
317
+
318
+ // ── Scenes & Devices ──────────────────────────────────────────────────────
319
+
320
+ async checkScenesList() {
321
+ try {
322
+ if (this.logDebug) this.emit('debug', 'Scanning for scenes');
323
+
324
+ const resp = await this.client.get(ApiUrls.Home.Get.Scenes);
325
+ const scenesList = resp.data;
326
+
327
+ if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(scenesList, null, 2)}`);
328
+
329
+ return this.capitalizeKeysDeep(scenesList);
330
+ } catch (error) {
331
+ throw new Error(`Check scenes list error: ${error.message}`);
332
+ }
333
+ }
334
+
335
+ async checkDevicesList() {
336
+ try {
337
+ const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };
338
+ if (this.logDebug) this.emit('debug', 'Scanning for devices');
339
+
340
+ const resp = await this.client.get(ApiUrls.Home.Get.Context);
341
+ const userContext = resp.data;
342
+ //if (this.logDebug) this.emit('debug', `User Context: ${JSON.stringify(userContext, null, 2)}`);
343
+
344
+ const buildings = userContext.buildings ?? [];
345
+ const guestBuildings = userContext.guestBuildings ?? [];
346
+ const buildingsList = [...buildings, ...guestBuildings];
347
+
348
+ if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
349
+
350
+ if (buildingsList.length === 0) {
351
+ result.Status = 'No buildings found';
352
+ return result;
353
+ }
354
+
355
+ const capitalizeKeys = obj => Object.fromEntries(
356
+ Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v])
357
+ );
358
+
359
+ const createDevice = (device, type) => {
360
+ const settingsObject = Object.fromEntries(
361
+ (device.Settings || []).map(({ name, value }) => [
362
+ name.charAt(0).toUpperCase() + name.slice(1),
363
+ this.functions.convertValue(value),
364
+ ])
365
+ );
366
+
367
+ const deviceObject = {
368
+ ...capitalizeKeys(device.Capabilities || {}),
369
+ ...settingsObject,
370
+ DeviceType: type,
371
+ FirmwareAppVersion: device.ConnectedInterfaceIdentifier,
372
+ IsConnected: device.IsConnected,
373
+ };
374
+
375
+ if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection);
376
+ if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection);
377
+ if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode);
378
+ if (Array.isArray(device.Schedule)) device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s));
379
+
380
+ const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
381
+
382
+ return {
383
+ ...rest,
384
+ Type: type,
385
+ DeviceID: Id,
386
+ DeviceName: GivenDisplayName,
387
+ SerialNumber: Id,
388
+ Device: deviceObject,
389
+ };
390
+ };
391
+
392
+ const devices = buildingsList.flatMap(building => [
393
+ ...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
394
+ ...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
395
+ ...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3)),
396
+ ]);
397
+
398
+ if (devices.length === 0) {
399
+ result.Status = 'No devices found';
400
+ return result;
401
+ }
402
+
403
+ // Sceny
404
+ let scenes = [];
405
+ try {
406
+ scenes = await this.checkScenesList();
407
+ if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`);
408
+ } catch (error) {
409
+ if (this.logError) this.emit('error', `Get scenes error: ${error}`);
410
+ }
411
+
412
+ result.State = true;
413
+ result.Status = `Found ${devices.length} devices${scenes.length > 0 ? ` and ${scenes.length} scenes` : ''}`;
414
+ result.Buildings = userContext;
415
+ result.Devices = devices;
416
+ result.Scenes = scenes;
417
+
418
+ for (const deviceData of result.Devices) {
419
+ deviceData.Scenes = result.Scenes;
420
+ this.emit(deviceData.DeviceID, 'request', deviceData);
421
+ }
422
+
423
+ return result;
424
+ } catch (error) {
425
+ throw new Error(`Check devices list error: ${error.message}`);
426
+ }
427
+ }
428
+
266
429
  // ── Connect ───────────────────────────────────────────────────────────────
267
430
 
268
431
  async connect() {
@@ -271,7 +434,7 @@ class MelCloudHome extends EventEmitter {
271
434
  try {
272
435
  const connectInfo = { State: false, Status: '', Account: {}, UseFahrenheit: false };
273
436
 
274
- const client = this.ensureClient();
437
+ const client = this.ensureAuthClient();
275
438
  const { verifier: codeVerifier, challenge: codeChallenge } = this.generatePkce();
276
439
  const state = crypto.randomBytes(16).toString('base64url');
277
440
 
@@ -333,7 +496,7 @@ class MelCloudHome extends EventEmitter {
333
496
  cognitoLoginUrl = finalUrl;
334
497
  if (this.logDebug) this.emit('debug', 'Cognito login page OK');
335
498
  } else {
336
- // Fast path: istniejąca sesja
499
+ // Fast path: istniejąca sesja — kod dostępny od razu
337
500
  const codeMatch = /code=([^&"' ]+)/.exec(finalUrl) || /code=([^&"' ]+)/.exec(body);
338
501
  if (codeMatch) {
339
502
  authCode = codeMatch[1];
@@ -352,9 +515,8 @@ class MelCloudHome extends EventEmitter {
352
515
  // Fast-path: pomiń etap logowania
353
516
  if (authCode) {
354
517
  if (this.logDebug) this.emit('debug', 'Re-login with existing session (skipping credentials)');
355
- // WAŻNE: await — bez niego exchangeRes to Promise, nie boolean
356
518
  const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
357
- return this._buildConnectInfo(connectInfo, exchangeRes);
519
+ return this.buildConnectInfo(connectInfo, exchangeRes);
358
520
  }
359
521
 
360
522
  // ── Step 3: Wyślij dane logowania do Cognito ──────────────────────
@@ -362,11 +524,8 @@ class MelCloudHome extends EventEmitter {
362
524
 
363
525
  const cognitoHostname = new URL(cognitoLoginUrl).hostname;
364
526
 
365
- // maxRedirects: 0 — Cognito używa response_mode=form_post, więc po udanym
366
- // logowaniu robi POST do auth.melcloudhome.com/signin-oidc-meu (nie GET redirect).
367
- // Axios nie potrafi tego obsłużyć automatycznie — musimy przechwycić
368
- // odpowiedź Cognito (302 z Location) zanim axios spróbuje podążyć za nią
369
- // i dostanie 500 z IdentityServera.
527
+ // maxRedirects: 0 — Cognito używa response_mode=form_post.
528
+ // Przechwytujemy 302 zanim axios podąży za nim do IdentityServera ( 500).
370
529
  const credResp = await this.pace(() =>
371
530
  client.post(
372
531
  cognitoLoginUrl,
@@ -383,7 +542,7 @@ class MelCloudHome extends EventEmitter {
383
542
  Origin: `https://${cognitoHostname}`,
384
543
  Referer: cognitoLoginUrl,
385
544
  },
386
- maxRedirects: 0, // przechwytujemy redirect zanim trafi do IdentityServera
545
+ maxRedirects: 0,
387
546
  }
388
547
  )
389
548
  );
@@ -393,18 +552,13 @@ class MelCloudHome extends EventEmitter {
393
552
  this.emit('debug', `Step 3 response location: ${credResp.headers?.location ?? '(none)'}`);
394
553
  }
395
554
 
396
- // Cognito przy złym haśle wraca na stronę logowania (200 lub 302 na /login)
397
- if (credResp.status === 200) {
398
- // Zostaliśmy na stronie Cognito — złe hasło
399
- throw new Error('Authentication failed: Invalid username or password');
400
- }
401
-
555
+ // status 200 = zostaliśmy na stronie Cognito złe hasło
556
+ if (credResp.status === 200) throw new Error('Authentication failed: Invalid username or password');
402
557
  if (credResp.status >= 500) throw new Error(`Cognito server error: HTTP ${credResp.status}`);
403
558
 
404
- // ── Step 4: Podążaj za redirectem z Cognito → IdentityServer ─────────
405
- // Cognito odpowiada 302 z Location: https://auth.melcloudhome.com/signin-oidc-meu?...
406
- // Musimy samodzielnie wysłać GET do tego URL (IdentityServer przetworzy callback
407
- // i przekieruje dalej do melcloudhome:// z auth code).
559
+ // ── Step 4: POST do signin-oidc-meu (emulacja form_post z Cognito) ──
560
+ // Cognito normalnie robi POST z code+state w body do IdentityServera.
561
+ // My dostaliśmy 302 z tymi parametrami w query wysyłamy je jako POST body.
408
562
  if (this.logDebug) this.emit('debug', 'Step 4: Follow Cognito → IdentityServer redirect');
409
563
 
410
564
  const cognitoRedirectLocation = credResp.headers?.location ?? '';
@@ -412,10 +566,6 @@ class MelCloudHome extends EventEmitter {
412
566
 
413
567
  if (this.logDebug) this.emit('debug', `Step 4 location: ${cognitoRedirectLocation}`);
414
568
 
415
- // signin-oidc-meu to endpoint IdentityServera obsługujący callback z Cognito.
416
- // Cognito normalnie robi tam form_post (POST z code i state w body),
417
- // ale my dostaliśmy 302 z parametrami w query stringu — wysyłamy więc POST
418
- // z tymi samymi parametrami w body (tak jak zrobiłoby to Cognito).
419
569
  const signinParsed = new URL(cognitoRedirectLocation);
420
570
  const signinBase = `${signinParsed.protocol}//${signinParsed.host}${signinParsed.pathname}`;
421
571
  const signinParams = new URLSearchParams(signinParsed.search);
@@ -437,201 +587,73 @@ class MelCloudHome extends EventEmitter {
437
587
  this.emit('debug', `Step 4 signin location: ${signinResp.headers?.location ?? '(none)'}`);
438
588
  }
439
589
 
440
- // ── Step 5: Wyciągnij auth code z redirectu melcloudhome:// ───────────
441
- if (this.logDebug) this.emit('debug', 'Step 5: Extract auth code from redirect');
590
+ // ── Step 5: Podążaj za łańcuchem redirectów do auth code ──────────
591
+ // IdentityServer przekierowuje przez kilka etapów:
592
+ // /ExternalLogin/Callback → /connect/authorize/callback → melcloudhome://
593
+ if (this.logDebug) this.emit('debug', 'Step 5: Following redirect chain to auth code');
442
594
 
443
- const signinLocation = signinResp.headers?.location ?? '';
444
- const signinBody = typeof signinResp.data === 'string' ? signinResp.data : '';
595
+ let currentResp = signinResp;
596
+ const MAX_HOPS = 6;
445
597
 
446
- // Przypadek A: bezpośredni redirect na melcloudhome://
447
- let codeMatch = /code=([^&"' ]+)/.exec(signinLocation);
598
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
599
+ const hopStatus = currentResp.status;
600
+ const hopLocation = currentResp.headers?.location ?? '';
601
+ const hopBody = typeof currentResp.data === 'string' ? currentResp.data : '';
448
602
 
449
- // Przypadek B: IdentityServer przekierowuje do /connect/authorize/callback
450
- if (!codeMatch) {
451
- const callbackMatch = /\/connect\/authorize\/callback\?([^"' ]+)/.exec(signinLocation)
452
- || /\/connect\/authorize\/callback\?([^"' ]+)/.exec(signinBody);
603
+ if (this.logDebug) this.emit('debug', `Step 5 hop ${hop}: status=${hopStatus} location=${hopLocation || '(none)'}`);
453
604
 
454
- if (callbackMatch) {
455
- if (this.logDebug) this.emit('debug', 'Step 5: Following authorize/callback');
456
- authCode = await this.followCallbackForCode(client, callbackMatch[1]);
457
- } else {
458
- // Przypadek C: kod bezpośrednio w body (np. form hidden field)
459
- codeMatch = /code=([^&"' ]+)/.exec(signinBody);
460
- if (!codeMatch) throw new Error(`Failed to extract auth code. signin status=${signinResp.status}, location=${signinLocation}`);
461
- authCode = codeMatch[1];
605
+ // A: melcloudhome:// z code=
606
+ if (hopLocation.startsWith('melcloudhome://')) {
607
+ const m = /code=([^&"' ]+)/.exec(hopLocation);
608
+ if (m) { authCode = m[1]; break; }
462
609
  }
463
- } else {
464
- authCode = codeMatch[1];
465
- }
466
-
467
- if (this.logDebug) this.emit('debug', `Got auth code: ${authCode.slice(0, 20)}...`);
468
-
469
- // ── Step 6: Wymień kod na tokeny (WAŻNE: await!) ──────────────────
470
- const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
471
- return this._buildConnectInfo(connectInfo, exchangeRes);
472
-
473
- } catch (error) {
474
- throw new Error(`Connect error: ${error.message}`);
475
- }
476
- }
477
-
478
- // ── Buduje connectInfo i tworzy właściwy client API ───────────────────────
479
-
480
- _buildConnectInfo(connectInfo, exchangeRes) {
481
- if (exchangeRes) {
482
- this.client = axios.create({
483
- baseURL: ApiUrls.Home.Base,
484
- timeout: 30_000,
485
- headers: {
486
- Accept: 'application/json',
487
- 'User-Agent': ApiUrls.Home.UserAgent,
488
- Authorization: `Bearer ${this._accessToken}`,
489
- },
490
- });
491
-
492
- this.emit('client', this.client);
493
- }
494
-
495
- connectInfo.State = exchangeRes;
496
- connectInfo.Status = exchangeRes
497
- ? `Connect Success${this.socketConnected ? ', Web Socket Connected' : ''}`
498
- : 'Connect Failed at token exchange';
499
-
500
- return connectInfo;
501
- }
502
-
503
- // ── Scenes & Devices ──────────────────────────────────────────────────────
504
-
505
- async checkScenesList() {
506
- try {
507
- if (this.logDebug) this.emit('debug', 'Scanning for scenes');
508
-
509
- const resp = await this.client.get(ApiUrls.Home.Get.Scenes);
510
- const scenesList = resp.data;
511
-
512
- if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(scenesList, null, 2)}`);
513
-
514
- return this._capitalizeKeysDeep(scenesList);
515
- } catch (error) {
516
- throw new Error(`Check scenes list error: ${error.message}`);
517
- }
518
- }
519
-
520
- async checkDevicesList() {
521
- try {
522
- const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };
523
- if (this.logDebug) this.emit('debug', 'Scanning for devices');
524
610
 
525
- const resp = await this.client.get(ApiUrls.Home.Get.ListDevices);
526
- const userContext = resp.data;
527
-
528
- const buildings = userContext.buildings ?? [];
529
- const guestBuildings = userContext.guestBuildings ?? [];
530
- const buildingsList = [...buildings, ...guestBuildings];
531
-
532
- if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
533
-
534
- if (buildingsList.length === 0) {
535
- result.Status = 'No buildings found';
536
- return result;
537
- }
538
-
539
- const devices = buildingsList.flatMap(building => {
540
- const capitalizeKeys = obj => Object.fromEntries(
541
- Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v])
542
- );
611
+ // B: /connect/authorize/callback w location lub body
612
+ const cbMatch = /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopLocation)
613
+ || /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopBody);
614
+ if (cbMatch) {
615
+ if (this.logDebug) this.emit('debug', 'Step 5: delegating to followCallbackForCode');
616
+ authCode = await this.followCallbackForCode(client, cbMatch[1]);
617
+ break;
618
+ }
543
619
 
544
- const createDevice = (device, type) => {
545
- const settingsObject = Object.fromEntries(
546
- (device.Settings || []).map(({ name, value }) => [
547
- name.charAt(0).toUpperCase() + name.slice(1),
548
- this.functions.convertValue(value),
549
- ])
620
+ // C: code= bezpośrednio w location
621
+ const codeInLocation = /code=([^&"' ]+)/.exec(hopLocation);
622
+ if (codeInLocation) { authCode = codeInLocation[1]; break; }
623
+
624
+ // D: code= w body
625
+ const codeInBody = /code=([^&"' ]+)/.exec(hopBody);
626
+ if (codeInBody) { authCode = codeInBody[1]; break; }
627
+
628
+ // Zwykły redirect — podążaj dalej
629
+ if ((hopStatus === 301 || hopStatus === 302 || hopStatus === 303) && hopLocation) {
630
+ const nextUrl = hopLocation.startsWith('http')
631
+ ? hopLocation
632
+ : `${ApiUrls.Home.AuthBase}${hopLocation}`;
633
+
634
+ currentResp = await this.pace(() =>
635
+ client.get(nextUrl, {
636
+ headers: { 'User-Agent': ApiUrls.Home.UserAgent },
637
+ maxRedirects: 0,
638
+ })
550
639
  );
640
+ continue;
641
+ }
551
642
 
552
- const deviceObject = {
553
- ...capitalizeKeys(device.Capabilities || {}),
554
- ...settingsObject,
555
- DeviceType: type,
556
- FirmwareAppVersion: device.ConnectedInterfaceIdentifier,
557
- IsConnected: device.IsConnected,
558
- };
559
-
560
- if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection);
561
- if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection);
562
- if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode);
563
- if (Array.isArray(device.Schedule)) device.Schedule = device.Schedule.map(s => this._capitalizeKeysDeep(s));
564
-
565
- const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
566
-
567
- return {
568
- ...rest,
569
- Type: type,
570
- DeviceID: Id,
571
- DeviceName: GivenDisplayName,
572
- SerialNumber: Id,
573
- Device: deviceObject,
574
- };
575
- };
576
-
577
- return [
578
- ...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
579
- ...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
580
- ...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3)),
581
- ];
582
- });
583
-
584
- if (devices.length === 0) {
585
- result.Status = 'No devices found';
586
- return result;
643
+ throw new Error(`Unexpected response in redirect chain: status=${hopStatus}, location=${hopLocation}`);
587
644
  }
588
645
 
589
- // Sceny
590
- let scenes = [];
591
- try {
592
- scenes = await this.checkScenesList();
593
- if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`);
594
- } catch (error) {
595
- if (this.logError) this.emit('error', `Get scenes error: ${error}`);
596
- }
646
+ if (!authCode) throw new Error('Failed to extract auth code after redirect chain');
597
647
 
598
- result.State = true;
599
- result.Status = `Found ${devices.length} devices${scenes.length > 0 ? ` and ${scenes.length} scenes` : ''}`;
600
- result.Buildings = userContext;
601
- result.Devices = devices;
602
- result.Scenes = scenes;
648
+ if (this.logDebug) this.emit('debug', `Got auth code: ${authCode.slice(0, 20)}...`);
603
649
 
604
- for (const deviceData of result.Devices) {
605
- deviceData.Scenes = result.Scenes;
606
- this.emit(deviceData.DeviceID, 'request', deviceData);
607
- }
650
+ // ── Step 6: Wymień kod na tokeny ──────────────────────────────────
651
+ const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
652
+ return this.buildConnectInfo(connectInfo, exchangeRes);
608
653
 
609
- return result;
610
654
  } catch (error) {
611
- throw new Error(`Check devices list error: ${error.message}`);
612
- }
613
- }
614
-
615
- // ── Utils ─────────────────────────────────────────────────────────────────
616
-
617
- _capitalizeKeysDeep(obj) {
618
- if (Array.isArray(obj)) return obj.map(item => this._capitalizeKeysDeep(item));
619
- if (obj && typeof obj === 'object') {
620
- return Object.fromEntries(
621
- Object.entries(obj).map(([k, v]) => [
622
- k.charAt(0).toUpperCase() + k.slice(1),
623
- this._capitalizeKeysDeep(v),
624
- ])
625
- );
655
+ throw new Error(`Connect error: ${error.message}`);
626
656
  }
627
- return obj;
628
- }
629
-
630
- logout() {
631
- this._authenticated = false;
632
- this._accessToken = null;
633
- this._refreshToken = null;
634
- this._tokenExpiry = 0;
635
657
  }
636
658
  }
637
659