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 +14 -0
- package/index.js +1 -1
- package/package.json +2 -3
- package/src/constants.js +37 -35
- package/src/functions.js +0 -120
- package/src/melcloudata.js +1 -1
- package/src/melcloudatw.js +1 -1
- package/src/melclouderv.js +1 -1
- package/src/melcloudhome.js +323 -301
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: '
|
|
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.
|
|
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://
|
|
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
|
-
|
|
37
|
-
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
}
|
package/src/melcloudata.js
CHANGED
package/src/melcloudatw.js
CHANGED
package/src/melclouderv.js
CHANGED
package/src/melcloudhome.js
CHANGED
|
@@ -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
|
-
//
|
|
36
|
-
this.authClient = null;
|
|
37
|
-
this.
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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('
|
|
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
|
-
// ──
|
|
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
|
-
|
|
76
|
-
if (this.
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
68
|
+
// ── Token state ───────────────────────────────────────────────────────────
|
|
121
69
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
75
|
+
// ── Axios clients ─────────────────────────────────────────────────────────
|
|
129
76
|
|
|
130
|
-
|
|
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.
|
|
225
|
-
this.
|
|
226
|
-
this.
|
|
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.
|
|
211
|
+
if (!this.refreshToken) throw new Error('No refresh token available');
|
|
237
212
|
|
|
238
|
-
const client = this.
|
|
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.
|
|
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.
|
|
254
|
-
this.
|
|
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.
|
|
260
|
-
this.
|
|
261
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
|
366
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
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:
|
|
405
|
-
// Cognito
|
|
406
|
-
//
|
|
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:
|
|
441
|
-
|
|
590
|
+
// ── Step 5: Podążaj za łańcuchem redirectów aż 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
|
-
|
|
444
|
-
const
|
|
595
|
+
let currentResp = signinResp;
|
|
596
|
+
const MAX_HOPS = 6;
|
|
445
597
|
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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(`
|
|
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
|
|