homebridge-melcloud-control 4.9.1 → 4.9.2-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +3 -2
- package/src/constants.js +15 -19
- package/src/melcloudata.js +2 -2
- package/src/melcloudatw.js +1 -1
- package/src/melclouderv.js +1 -1
- package/src/melcloudhome.js +156 -4
package/CHANGELOG.md
CHANGED
|
@@ -24,6 +24,12 @@ 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.2] - (xx.04.2026)
|
|
28
|
+
|
|
29
|
+
## Changes
|
|
30
|
+
|
|
31
|
+
- cleanup
|
|
32
|
+
|
|
27
33
|
# [4.9.1] - (15.04.2026)
|
|
28
34
|
|
|
29
35
|
## Changes
|
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.1",
|
|
4
|
+
"version": "4.9.2-beta.1",
|
|
5
5
|
"description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "grzegorz914",
|
|
@@ -40,7 +40,8 @@
|
|
|
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"
|
|
43
|
+
"express": "^5.2.1",
|
|
44
|
+
"ws": "^8.20.0"
|
|
44
45
|
},
|
|
45
46
|
"keywords": [
|
|
46
47
|
"homebridge",
|
package/src/constants.js
CHANGED
|
@@ -22,10 +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://melcloudhome.com",
|
|
26
|
-
BaseMobile: "https://mobile.bff.melcloudhome.com",
|
|
25
|
+
Base: "https://mobile.bff.melcloudhome.com",
|
|
27
26
|
AuthBase: "https://auth.melcloudhome.com",
|
|
28
|
-
MockBase: "http://localhost:8080",
|
|
29
27
|
OauthClientId: "homemobile",
|
|
30
28
|
OauthRedirectUri: "melcloudhome://",
|
|
31
29
|
OauthScopes: "openid profile email offline_access IdentityServerApi",
|
|
@@ -33,13 +31,14 @@ export const ApiUrls = {
|
|
|
33
31
|
CognitoDomainSuffix: ".amazoncognito.com",
|
|
34
32
|
WebSocket: "wss://ws.melcloudhome.com/?hash=",
|
|
35
33
|
Get: {
|
|
36
|
-
|
|
37
|
-
Context: "/context",
|
|
38
|
-
Scenes: "/monitor/user/scenes",
|
|
34
|
+
Config: "/config", //{"feature_management":{"feature_flags":[{"id":"energyProduced","enabled":"true"},{"id":"enableManual4thGenClaiming","enabled":"false"}]},"sentryLogging":{"enableSentryLogging":"/mch/monitorAndControlMobile/enableSentryLogging"}}
|
|
35
|
+
Context: "/context", //{}
|
|
36
|
+
Scenes: "/monitor/user/scenes", //[{}]
|
|
37
|
+
SystemInvites: "/monitor/user/systeminvites", //[]
|
|
38
|
+
NotificationSettings: "/monitor.user/notificationsettings", //{"054dd950-f6e0-4195-bea7-59d8ea0668c2": true
|
|
39
39
|
TelemetryEnergy: "/telemetry/telemetry/energy/deviceid",
|
|
40
40
|
TelemetryActual: "/telemetry/telemetry/actual/deviceid",
|
|
41
|
-
ReportTrendSummary: "/report/v1/trendsummary"
|
|
42
|
-
SystemInvites: "/systeminvites"
|
|
41
|
+
ReportTrendSummary: "/report/v1/trendsummary"
|
|
43
42
|
},
|
|
44
43
|
Post: {
|
|
45
44
|
ProtectionFrost: "/monitor/protection/frost", //{"enabled":true,"min":13,"max":16,"units":{"ATA":["deviceid"]}}
|
|
@@ -49,18 +48,15 @@ export const ApiUrls = {
|
|
|
49
48
|
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
49
|
},
|
|
51
50
|
Put: {
|
|
52
|
-
Ata: "/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
ErvMobile: "/monitor/ervunit/deviceid",
|
|
58
|
-
ScheduleEnableDisable: "/monitor/cloudschedule/deviceid/enabled", // {"enabled": true}
|
|
59
|
-
SceneEnableDisable: "/monitor/scene/sceneid",
|
|
51
|
+
Ata: "/monitor/ataunit/deviceid", //{ power: true,setTemperature: 22, setFanSpeed: "auto", operationMode: "heat", vaneHorizontalDirection: "auto",vaneVerticalDirection: "auto", temperatureIncrementOverride: null, inStandbyMode: null}
|
|
52
|
+
Atw: "/monitor/atwunit/deviceid",
|
|
53
|
+
Erv: "/monitor/ervunit/deviceid",
|
|
54
|
+
ScheduleEnabled: "/monitor/cloudschedule/deviceid/enabled", // {"enabled": true}
|
|
55
|
+
SceneEnableDisable: "/monitor/scene/sceneid", //https://mobile.bff.melcloudhome.com/monitor/scene/sceneid/enable or disable
|
|
60
56
|
},
|
|
61
57
|
Delete: {
|
|
62
|
-
Schedule: "/
|
|
63
|
-
Scene: "/
|
|
58
|
+
Schedule: "/monitor/cloudschedule/deviceid/scheduleid",
|
|
59
|
+
Scene: "/monitor/scene/sceneid"
|
|
64
60
|
},
|
|
65
61
|
Referers: {
|
|
66
62
|
GetPutScenes: "https://melcloudhome.com/scenes",
|
|
@@ -68,7 +64,7 @@ export const ApiUrls = {
|
|
|
68
64
|
PostProtectionFrost: "https://melcloudhome.com/ata/deviceid/frostprotection",
|
|
69
65
|
PostProtectionOverheat: "https://melcloudhome.com/ata/deviceid/overheatprotection",
|
|
70
66
|
PutDeviceSettings: "https://melcloudhome.com/dashboard",
|
|
71
|
-
PutScheduleEnabled: "https://melcloudhome.com/ata/deviceid/schedule"
|
|
67
|
+
PutScheduleEnabled: "https://melcloudhome.com/ata/deviceid/schedule"
|
|
72
68
|
}
|
|
73
69
|
}
|
|
74
70
|
};
|
package/src/melcloudata.js
CHANGED
|
@@ -279,7 +279,7 @@ class MelCloudAta extends EventEmitter {
|
|
|
279
279
|
break;
|
|
280
280
|
case 'schedule':
|
|
281
281
|
method = 'PUT';
|
|
282
|
-
path = ApiUrls.Home.Put.
|
|
282
|
+
path = ApiUrls.Home.Put.ScheduleEnabled.replace('deviceid', deviceData.DeviceID);
|
|
283
283
|
deviceData.ScheduleEnabled = payload.enabled;
|
|
284
284
|
break;
|
|
285
285
|
case 'scene':
|
|
@@ -315,7 +315,7 @@ class MelCloudAta extends EventEmitter {
|
|
|
315
315
|
});
|
|
316
316
|
|
|
317
317
|
method = 'PUT';
|
|
318
|
-
path = ApiUrls.Home.Put.
|
|
318
|
+
path = ApiUrls.Home.Put.Ata.replace('deviceid', deviceData.DeviceID);
|
|
319
319
|
break;
|
|
320
320
|
}
|
|
321
321
|
|
package/src/melcloudatw.js
CHANGED
package/src/melclouderv.js
CHANGED
package/src/melcloudhome.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
+
import WebSocket from 'ws';
|
|
2
3
|
import crypto from 'crypto';
|
|
3
4
|
import EventEmitter from 'events';
|
|
4
5
|
import ImpulseGenerator from './impulsegenerator.js';
|
|
@@ -37,7 +38,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
37
38
|
this.tokenExpiry = 0; // Unix timestamp (sekundy)
|
|
38
39
|
|
|
39
40
|
// Flaga zapobiegająca wielokrotnemu dodaniu interceptorów
|
|
40
|
-
this.
|
|
41
|
+
this.interceptorsAttached = false;
|
|
41
42
|
|
|
42
43
|
if (pluginStart) {
|
|
43
44
|
this.impulseGenerator = new ImpulseGenerator()
|
|
@@ -50,6 +51,154 @@ class MelCloudHome extends EventEmitter {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// ── WebSocket ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
// Pobiera hash do URL WebSocket.
|
|
57
|
+
// Sprawdza kolejno: /api/configuration → /api/user/context → access token jako fallback.
|
|
58
|
+
// Gdy znajdziesz właściwe źródło, uproszcz tę metodę.
|
|
59
|
+
async fetchWsHash() {
|
|
60
|
+
// Próba 1: /api/configuration
|
|
61
|
+
try {
|
|
62
|
+
const resp = await this.client.get(ApiUrls.Home.Get.Config);
|
|
63
|
+
const hash = resp.data?.wsHash ?? resp.data?.webSocketHash ?? resp.data?.hash ?? null;
|
|
64
|
+
if (hash) {
|
|
65
|
+
if (!this.logDebug) this.emit('debug', `WS hash from configuration: ${hash}`);
|
|
66
|
+
return hash;
|
|
67
|
+
}
|
|
68
|
+
if (!this.logDebug) this.emit('debug', `Configuration response (no hash found): ${JSON.stringify(resp.data)}`);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (!this.logDebug) this.emit('debug', `fetchWsHash: configuration failed: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Próba 2: /api/user/context (buildingsList może zawierać hash)
|
|
74
|
+
try {
|
|
75
|
+
const resp = await this.client.get(ApiUrls.Home.Get.Context);
|
|
76
|
+
const hash = resp.data?.wsHash ?? resp.data?.webSocketHash ?? resp.data?.hash ?? null;
|
|
77
|
+
if (hash) {
|
|
78
|
+
if (!this.logDebug) this.emit('debug', `WS hash from context: ${hash}`);
|
|
79
|
+
return hash;
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (!this.logDebug) this.emit('debug', `fetchWsHash: context failed: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fallback: access token (JWT) — niektóre implementacje używają go bezpośrednio
|
|
86
|
+
if (this.accessToken) {
|
|
87
|
+
if (!this.logDebug) this.emit('debug', 'fetchWsHash: falling back to access token as hash');
|
|
88
|
+
return this.accessToken;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw new Error('Unable to obtain WebSocket hash — update fetchWsHash() when source is known');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
cleanupSocket() {
|
|
95
|
+
if (this.heartbeat) {
|
|
96
|
+
clearInterval(this.heartbeat);
|
|
97
|
+
this.heartbeat = null;
|
|
98
|
+
}
|
|
99
|
+
this.socketConnected = false;
|
|
100
|
+
this.connecting = false;
|
|
101
|
+
this.socket = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Łączy się z WebSocket. Wywoływane po udanym connect() lub po reconnect.
|
|
105
|
+
async connectSocket() {
|
|
106
|
+
if (this.connecting || this.socketConnected) return;
|
|
107
|
+
this.connecting = true;
|
|
108
|
+
|
|
109
|
+
let hash;
|
|
110
|
+
try {
|
|
111
|
+
hash = await this.fetchWsHash();
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (!this.logError) this.emit('error', `connectSocket: cannot get WS hash: ${err.message}`);
|
|
114
|
+
this.connecting = false;
|
|
115
|
+
this._scheduleReconnect();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const url = `${ApiUrls.Home.WebSocket}${hash}`;
|
|
120
|
+
const headers = {
|
|
121
|
+
Origin: ApiUrls.Home.Base,
|
|
122
|
+
Pragma: 'no-cache',
|
|
123
|
+
'Cache-Control': 'no-cache',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (!this.logDebug) this.emit('debug', `Connecting WebSocket: ${url.slice(0, 60)}...`);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const ws = new WebSocket(url, { headers });
|
|
130
|
+
this.socket = ws;
|
|
131
|
+
|
|
132
|
+
ws.on('error', (error) => {
|
|
133
|
+
if (this.logError) this.emit('error', `Web socket error: ${error.message}`);
|
|
134
|
+
try { ws.close(); } catch { /* ignoruj */ }
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
ws.on('close', () => {
|
|
138
|
+
if (this.logDebug) this.emit('debug', 'Web socket closed');
|
|
139
|
+
this.cleanupSocket();
|
|
140
|
+
this._scheduleReconnect();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
ws.on('open', () => {
|
|
144
|
+
this.socketConnected = true;
|
|
145
|
+
this.connecting = false;
|
|
146
|
+
this._reconnectDelay = 5_000; // reset backoff po udanym połączeniu
|
|
147
|
+
if (this._reconnectTimer) {
|
|
148
|
+
clearTimeout(this._reconnectTimer);
|
|
149
|
+
this._reconnectTimer = null;
|
|
150
|
+
}
|
|
151
|
+
if (this.logDebug) this.emit('debug', 'Web Socket Connected');
|
|
152
|
+
|
|
153
|
+
// Heartbeat co 30s
|
|
154
|
+
this.heartbeat = setInterval(() => {
|
|
155
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
156
|
+
if (this.logDebug) this.emit('debug', 'Web socket send heartbeat');
|
|
157
|
+
ws.ping();
|
|
158
|
+
}
|
|
159
|
+
}, 30_000);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
ws.on('pong', () => {
|
|
163
|
+
if (this.logDebug) this.emit('debug', 'Web socket received heartbeat');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
ws.on('message', (message) => {
|
|
167
|
+
try {
|
|
168
|
+
const parsedMessage = JSON.parse(message);
|
|
169
|
+
if (this.logDebug) this.emit('debug', `Web socket incoming message: ${JSON.stringify(parsedMessage, null, 2)}`);
|
|
170
|
+
|
|
171
|
+
// Format: array, pierwszy element ma Data.id
|
|
172
|
+
const messageData = parsedMessage?.[0]?.Data;
|
|
173
|
+
if (!messageData || parsedMessage.message === 'Forbidden') return;
|
|
174
|
+
|
|
175
|
+
this.emit(messageData.id, 'ws', parsedMessage[0]);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (this.logError) this.emit('error', `Web socket message parse error: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (this.logError) this.emit('error', `Web socket connection failed: ${error.message}`);
|
|
183
|
+
this.cleanupSocket();
|
|
184
|
+
this._scheduleReconnect();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Wykładniczy backoff: 5s → 10s → 20s → ... → max 5 minut
|
|
189
|
+
_scheduleReconnect() {
|
|
190
|
+
if (this._reconnectTimer) return; // już zaplanowany
|
|
191
|
+
|
|
192
|
+
if (this.logDebug) this.emit('debug', `Web socket reconnecting in ${this._reconnectDelay / 1000}s...`);
|
|
193
|
+
|
|
194
|
+
this._reconnectTimer = setTimeout(async () => {
|
|
195
|
+
this._reconnectTimer = null;
|
|
196
|
+
await this.connectSocket();
|
|
197
|
+
}, this._reconnectDelay);
|
|
198
|
+
|
|
199
|
+
this._reconnectDelay = Math.min(this._reconnectDelay * 2, this._reconnectDelayMax);
|
|
200
|
+
}
|
|
201
|
+
|
|
53
202
|
// ── Utils ─────────────────────────────────────────────────────────────────
|
|
54
203
|
|
|
55
204
|
capitalizeKeysDeep(obj) {
|
|
@@ -99,7 +248,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
99
248
|
if (this.client) return this.client;
|
|
100
249
|
|
|
101
250
|
this.client = axios.create({
|
|
102
|
-
baseURL: ApiUrls.Home.
|
|
251
|
+
baseURL: ApiUrls.Home.Base,
|
|
103
252
|
timeout: 30_000,
|
|
104
253
|
headers: {
|
|
105
254
|
Accept: 'application/json',
|
|
@@ -256,8 +405,8 @@ class MelCloudHome extends EventEmitter {
|
|
|
256
405
|
// ── Interceptory do automatycznego odświeżania tokena ─────────────────────
|
|
257
406
|
|
|
258
407
|
attachTokenInterceptors() {
|
|
259
|
-
if (this.
|
|
260
|
-
this.
|
|
408
|
+
if (this.interceptorsAttached) return;
|
|
409
|
+
this.interceptorsAttached = true;
|
|
261
410
|
|
|
262
411
|
const apiClient = this.ensureClient();
|
|
263
412
|
|
|
@@ -311,6 +460,9 @@ class MelCloudHome extends EventEmitter {
|
|
|
311
460
|
|
|
312
461
|
connectInfo.State = exchangeRes;
|
|
313
462
|
connectInfo.Status = exchangeRes ? 'Connect Success' : 'Connect Failed at token exchange';
|
|
463
|
+
this.connectSocket().catch(err => {
|
|
464
|
+
if (this.logError) this.emit('error', `Initial WebSocket connect failed: ${err.message}`);
|
|
465
|
+
});
|
|
314
466
|
|
|
315
467
|
return connectInfo;
|
|
316
468
|
}
|