homebridge-melcloud-control 4.9.2-beta.0 → 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 +5 -5
- package/src/melcloudata.js +1 -1
- package/src/melcloudhome.js +155 -3
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.2-beta.
|
|
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
|
@@ -38,7 +38,7 @@ export const ApiUrls = {
|
|
|
38
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"
|
|
41
|
+
ReportTrendSummary: "/report/v1/trendsummary"
|
|
42
42
|
},
|
|
43
43
|
Post: {
|
|
44
44
|
ProtectionFrost: "/monitor/protection/frost", //{"enabled":true,"min":13,"max":16,"units":{"ATA":["deviceid"]}}
|
|
@@ -51,12 +51,12 @@ export const ApiUrls = {
|
|
|
51
51
|
Ata: "/monitor/ataunit/deviceid", //{ power: true,setTemperature: 22, setFanSpeed: "auto", operationMode: "heat", vaneHorizontalDirection: "auto",vaneVerticalDirection: "auto", temperatureIncrementOverride: null, inStandbyMode: null}
|
|
52
52
|
Atw: "/monitor/atwunit/deviceid",
|
|
53
53
|
Erv: "/monitor/ervunit/deviceid",
|
|
54
|
-
|
|
54
|
+
ScheduleEnabled: "/monitor/cloudschedule/deviceid/enabled", // {"enabled": true}
|
|
55
55
|
SceneEnableDisable: "/monitor/scene/sceneid", //https://mobile.bff.melcloudhome.com/monitor/scene/sceneid/enable or disable
|
|
56
56
|
},
|
|
57
57
|
Delete: {
|
|
58
|
-
Schedule: "/
|
|
59
|
-
Scene: "/
|
|
58
|
+
Schedule: "/monitor/cloudschedule/deviceid/scheduleid",
|
|
59
|
+
Scene: "/monitor/scene/sceneid"
|
|
60
60
|
},
|
|
61
61
|
Referers: {
|
|
62
62
|
GetPutScenes: "https://melcloudhome.com/scenes",
|
|
@@ -64,7 +64,7 @@ export const ApiUrls = {
|
|
|
64
64
|
PostProtectionFrost: "https://melcloudhome.com/ata/deviceid/frostprotection",
|
|
65
65
|
PostProtectionOverheat: "https://melcloudhome.com/ata/deviceid/overheatprotection",
|
|
66
66
|
PutDeviceSettings: "https://melcloudhome.com/dashboard",
|
|
67
|
-
PutScheduleEnabled: "https://melcloudhome.com/ata/deviceid/schedule"
|
|
67
|
+
PutScheduleEnabled: "https://melcloudhome.com/ata/deviceid/schedule"
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
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':
|
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) {
|
|
@@ -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
|
}
|