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 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
- Configuration: "/api/configuration",
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: "/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",
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: "/api/cloudschedule/deviceid/scheduleid",
63
- Scene: "/api/scene/sceneid"
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
  };
@@ -279,7 +279,7 @@ class MelCloudAta extends EventEmitter {
279
279
  break;
280
280
  case 'schedule':
281
281
  method = 'PUT';
282
- path = ApiUrls.Home.Put.ScheduleEnableDisable.replace('deviceid', deviceData.DeviceID);
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.AtaMobile.replace('deviceid', deviceData.DeviceID);
318
+ path = ApiUrls.Home.Put.Ata.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.AtwMobile.replace('deviceid', deviceData.DeviceID);
268
+ path = ApiUrls.Home.Put.Atw.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.ErvMobile.replace('deviceid', deviceData.DeviceID);
283
+ path = ApiUrls.Home.Put.Erv.replace('deviceid', deviceData.DeviceID);
284
284
  break
285
285
  }
286
286
 
@@ -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._interceptorsAttached = false;
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.BaseMobile,
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._interceptorsAttached) return;
260
- this._interceptorsAttached = true;
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
  }