homebridge-melcloud-control 4.12.0 → 4.13.0

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
@@ -25,6 +25,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
25
25
  - For plugin < v4.6.0 use Homebridge UI <= v5.5.0
26
26
  - For plugin >= v4.6.0 use Homebridge UI >= v5.13.0
27
27
 
28
+ ## [4.13.0] - (24.09.2026)
29
+
30
+ ### Changes
31
+
32
+ - added: HA Discovery, device locks as one `select` in the device configuration
33
+ - Air Conditioner (MELCloud only): `Off`, `Power`, `Mode`, `Mode Power`, `Temp`, `Temp Power`, `Temp Mode`, `All`
34
+ - Heat Pump: `Off`, `Zone 1`, `Zone 1 Zone 2`, `Zone 1 Water`, `Zone 2`, `Zone 2 Water`, `Water`, `All` (zone locks MELCloud only)
35
+ - added: RESTFul request rate limit, 600 requests per minute per client and at most 1000 clients per minute, answered with `429` and `Retry-After`, one log warning per client and minute. Replaces the approach proposed in #261 which blocked clients polling faster than every 0.6 s, kept every client address forever and logged every rejected request
36
+ - fix: HA Discovery, vertical vane (`swing_mode`) was not shown when the device reports `AirDirectionFunction: false` (the i-See air direction function, not the vane). It is now shown whenever the device reports a vertical vane position
37
+ - fix: MELCloud (classic) any setting change (temperature, fan speed, vane, mode, locks, hot water...) switched an OFF device ON. Since v4.7.9 every command was sent with a forced `Power: true` and the Power effective flag. Now only an explicit `Power` command changes the power state, like before v4.7.9 when the current power state was sent
38
+ - readme update
39
+
28
40
  ## [4.12.0] - (23.09.2026)
29
41
 
30
42
  ### Changes
package/README.md CHANGED
@@ -444,6 +444,7 @@
444
444
  * `homeassistant/climate/melcloud_<id>_zone1/config`, `..._zone2/config` - Heat Pump zones, modes off / heat / cool, presets `Room`, `Flow`, `Curve`. Target temperature is the room or flow temperature of the current preset. `off` turns the whole heat pump off.
445
445
  * `homeassistant/water_heater/melcloud_<id>_tank/config` - Heat Pump hot water tank, modes `heat_pump`, `eco` (MELCloud only), `high_demand` (forced hot water).
446
446
  * `homeassistant/fan/melcloud_<id>/config` - Energy Recovery Ventilation, on / off, fan speed, presets `Lossnay`, `Bypass`, `Auto`.
447
+ * `homeassistant/select/melcloud_<id>_prohibit/config` - locks as one select in the device configuration. Air Conditioner (MELCloud only): `Off`, `Power`, `Mode`, `Mode Power`, `Temp`, `Temp Power`, `Temp Mode`, `All`. Heat Pump: `Off`, `Zone 1`, `Zone 1 Zone 2`, `Zone 1 Water`, `Zone 2`, `Zone 2 Water`, `Water`, `All` (zone locks MELCloud only).
447
448
  * `HA State` - state for the entities above.
448
449
  * `Availability` - `online`, `offline` (last will).
449
450
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "MELCloud Control",
3
3
  "name": "homebridge-melcloud-control",
4
- "version": "4.12.0",
4
+ "version": "4.13.0",
5
5
  "description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
package/src/deviceata.js CHANGED
@@ -1326,7 +1326,15 @@ class DeviceAta extends EventEmitter {
1326
1326
 
1327
1327
  try {
1328
1328
  await this.ha.publishEntity('climate', '', this.ha.ataClimate(capabilities));
1329
- await this.ha.updateState(HaDiscovery.ataState(state));
1329
+ const haState = HaDiscovery.ataState(state);
1330
+
1331
+ // Locks work only with MELCloud
1332
+ if (capabilities.supportsProhibit) {
1333
+ const parts = [{ name: 'Temp', key: 'ProhibitSetTemperature' }, { name: 'Mode', key: 'ProhibitOperationMode' }, { name: 'Power', key: 'ProhibitPower' }];
1334
+ await this.ha.publishEntity('select', 'prohibit', this.ha.prohibitSelect(parts));
1335
+ haState.prohibit = HaDiscovery.prohibitName(parts, [state.prohibitSetTemperature, state.prohibitOperationMode, state.prohibitPower]);
1336
+ }
1337
+ await this.ha.updateState(haState);
1330
1338
  } catch (error) {
1331
1339
  if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
1332
1340
  }
@@ -1658,13 +1666,15 @@ class DeviceAta extends EventEmitter {
1658
1666
  supportsFanSpeed,
1659
1667
  numberOfFanSpeeds,
1660
1668
  supportsAutomaticFanSpeed,
1661
- supportsAirDirectionFunction: supportsAirDirectionFunction && !hideVaneControls,
1669
+ // AirDirectionFunction is the i-See air direction, not the vertical vane itself
1670
+ supportsVaneVertical: vaneVerticalDirection !== null && vaneVerticalDirection !== undefined && !hideVaneControls,
1662
1671
  supportsSwingFunction,
1663
1672
  supportsWideVane: supportsWideVane && !hideVaneControls,
1664
1673
  minTemp: minSetHeatRoomTemperature,
1665
1674
  maxTemp: maxSetHeatCoolDryAutoRoomTemperature,
1666
- tempStep: temperatureStep
1667
- }, { power, operationMode, inStandbyMode, roomTemperature, setTemperature, setFanSpeed, vaneVerticalDirection, vaneHorizontalDirection });
1675
+ tempStep: temperatureStep,
1676
+ supportsProhibit: accountTypeMelCloud
1677
+ }, { power, operationMode, inStandbyMode, roomTemperature, setTemperature, setFanSpeed, vaneVerticalDirection, vaneHorizontalDirection, prohibitSetTemperature, prohibitOperationMode, prohibitPower });
1668
1678
 
1669
1679
  //update services
1670
1680
  for (const { type, value } of characteristics) {
package/src/deviceatw.js CHANGED
@@ -1607,6 +1607,23 @@ class DeviceAtw extends EventEmitter {
1607
1607
  await this.ha.publishEntity('water_heater', 'tank', this.ha.atwWaterHeater({ name: tank.name, supportsEco: capabilities.supportsEco, minTemp: tank.minTemp, maxTemp: tank.maxTemp, tempStep: capabilities.tempStep }));
1608
1608
  state.tank = HaDiscovery.atwTankState(tank);
1609
1609
  }
1610
+
1611
+ // Zone locks work only with MELCloud, the hot water lock with both accounts
1612
+ const parts = [];
1613
+ const values = [];
1614
+ for (const zone of zones) {
1615
+ if (!capabilities.supportsZoneProhibit) continue;
1616
+ parts.push({ name: `Zone ${zone.zone}`, key: `ProhibitZone${zone.zone}` });
1617
+ values.push(zone.prohibit);
1618
+ }
1619
+ if (tank) {
1620
+ parts.push({ name: 'Water', key: 'ProhibitHotWater' });
1621
+ values.push(tank.prohibit);
1622
+ }
1623
+ if (parts.length > 0) {
1624
+ await this.ha.publishEntity('select', 'prohibit', this.ha.prohibitSelect(parts, 'lexical'));
1625
+ state.prohibit = HaDiscovery.prohibitName(parts, values);
1626
+ }
1610
1627
  await this.ha.updateState(state);
1611
1628
  } catch (error) {
1612
1629
  if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
@@ -2294,13 +2311,14 @@ class DeviceAtw extends EventEmitter {
2294
2311
 
2295
2312
  //home assistant, zones and tank from device capabilities (not the HomeKit hide zone setting)
2296
2313
  const haZones = [
2297
- { zone: 1, name: zone1Name, operationMode: operationModeZone1, setTemperature: setTemperatureZone1, setHeatFlowTemperature: setHeatFlowTemperatureZone1, setCoolFlowTemperature: setCoolFlowTemperatureZone1, roomTemperature: roomTemperatureZone1, idle: idleZone1 },
2298
- ...(deviceData.Device.HasZone2 ? [{ zone: 2, name: zone2Name, operationMode: operationModeZone2, setTemperature: setTemperatureZone2, setHeatFlowTemperature: setHeatFlowTemperatureZone2, setCoolFlowTemperature: setCoolFlowTemperatureZone2, roomTemperature: roomTemperatureZone2, idle: idleZone2 }] : [])
2314
+ { zone: 1, name: zone1Name, operationMode: operationModeZone1, setTemperature: setTemperatureZone1, setHeatFlowTemperature: setHeatFlowTemperatureZone1, setCoolFlowTemperature: setCoolFlowTemperatureZone1, roomTemperature: roomTemperatureZone1, idle: idleZone1, prohibit: prohibitZone1 },
2315
+ ...(deviceData.Device.HasZone2 ? [{ zone: 2, name: zone2Name, operationMode: operationModeZone2, setTemperature: setTemperatureZone2, setHeatFlowTemperature: setHeatFlowTemperatureZone2, setCoolFlowTemperature: setCoolFlowTemperatureZone2, roomTemperature: roomTemperatureZone2, idle: idleZone2, prohibit: prohibitZone2 }] : [])
2299
2316
  ];
2300
- const haTank = deviceData.Device[supportHotWaterKey] ? { name: hotWaterName, forcedHotWaterMode, ecoHotWater, setTankWaterTemperature, tankWaterTemperature, minTemp: minSetTankTemperature, maxTemp: maxSetTankTemperature } : null;
2317
+ const haTank = deviceData.Device[supportHotWaterKey] ? { name: hotWaterName, forcedHotWaterMode, ecoHotWater, setTankWaterTemperature, tankWaterTemperature, minTemp: minSetTankTemperature, maxTemp: maxSetTankTemperature, prohibit: prohibitHotWater } : null;
2301
2318
  this.haPublish({
2302
2319
  supportsCool,
2303
2320
  supportsEco: accountTypeMelCloud,
2321
+ supportsZoneProhibit: accountTypeMelCloud,
2304
2322
  tempStep: temperatureIncrement === true || temperatureIncrement === 0.5 ? 0.5 : 1
2305
2323
  }, power, haZones, haTank);
2306
2324
 
@@ -104,7 +104,7 @@ class HaDiscovery {
104
104
  });
105
105
  }
106
106
 
107
- if (c.supportsAirDirectionFunction) {
107
+ if (c.supportsVaneVertical) {
108
108
  const swingModes = ['auto', '1', '2', '3', '4', '5', ...(c.supportsSwingFunction ? ['swing'] : [])];
109
109
  Object.assign(entity, {
110
110
  swing_modes: swingModes,
@@ -156,6 +156,60 @@ class HaDiscovery {
156
156
  };
157
157
  }
158
158
 
159
+ // ---------------------------------------------------------------- prohibit
160
+ // One select with every combination of the device locks, parts: [{ name, key }].
161
+ // order 'binary' - first part is the highest bit: Off, Power, Mode, Mode Power, Temp, Temp Power, Temp Mode, All
162
+ // order 'lexical' - combinations in part order: Off, Zone 1, Zone 1 Zone 2, Zone 1 Water, Zone 2, Zone 2 Water, Water, All
163
+ prohibitSelect(parts, order = 'binary') {
164
+ const combos = HaDiscovery.prohibitCombos(parts, order);
165
+ const options = combos.map(values => HaDiscovery.prohibitName(parts, values));
166
+ const masks = JSON.stringify(Object.fromEntries(combos.map((values, i) => [options[i], values.reduce((mask, v, bit) => mask | (v ? 1 << bit : 0), 0)])));
167
+ const values = parts.map((p, bit) => `"${p.key}": {{ "true" if (m[value] // ${1 << bit}) % 2 == 1 else "false" }}`).join(', ');
168
+ return {
169
+ name: 'Prohibit',
170
+ icon: 'mdi:lock',
171
+ entity_category: 'config',
172
+ options,
173
+ state_topic: this.stateTopic,
174
+ value_template: '{{ value_json.prohibit }}',
175
+ command_topic: this.commandTopic,
176
+ command_template: `{% set m = ${masks} %}{${values}}`
177
+ };
178
+ }
179
+
180
+ // Every lock combination as an array of booleans in part order
181
+ static prohibitCombos(parts, order = 'binary') {
182
+ const n = parts.length;
183
+ if (order === 'binary') {
184
+ return [...Array(1 << n).keys()].map(mask => parts.map((p, i) => ((mask >> (n - 1 - i)) & 1) === 1));
185
+ }
186
+
187
+ const combos = [];
188
+ const walk = (start, picked) => {
189
+ for (let i = start; i < n; i++) {
190
+ const next = [...picked, i];
191
+ combos.push(next);
192
+ walk(i + 1, next);
193
+ }
194
+ };
195
+ walk(0, []);
196
+ const toValues = (picked) => parts.map((p, i) => picked.includes(i));
197
+ const partial = combos.filter(picked => n === 1 || picked.length < n).map(toValues);
198
+ return [parts.map(() => false), ...partial, ...(n > 1 ? [parts.map(() => true)] : [])];
199
+ }
200
+
201
+ static prohibitOptions(parts, order = 'binary') {
202
+ return HaDiscovery.prohibitCombos(parts, order).map(values => HaDiscovery.prohibitName(parts, values));
203
+ }
204
+
205
+ // Name of a lock combination, values in the same order as parts
206
+ static prohibitName(parts, values) {
207
+ const locked = parts.filter((p, i) => values[i]);
208
+ if (locked.length === 0) return 'Off';
209
+ if (parts.length > 1 && locked.length === parts.length) return 'All';
210
+ return locked.map(p => p.name).join(' ');
211
+ }
212
+
159
213
  // ---------------------------------------------------------------- ATW
160
214
  atwZoneClimate(zone, c) {
161
215
  const z = `zone${zone}`;
@@ -220,7 +220,7 @@ class MelCloudAta extends EventEmitter {
220
220
  changes.setTemperature = (deviceData.Device.DefaultCoolingSetTemperature + deviceData.Device.DefaultHeatingSetTemperature) / 2;
221
221
  }
222
222
 
223
- //rebuild effective flags from only the fields that actually changed, Power always stays forced
223
+ //rebuild effective flags from only the fields that actually changed
224
224
  const flagBits = {
225
225
  operationMode: AirConditioner.EffectiveFlags.OperationMode,
226
226
  setTemperature: AirConditioner.EffectiveFlags.SetTemperature,
@@ -231,11 +231,12 @@ class MelCloudAta extends EventEmitter {
231
231
  prohibitOperationMode: AirConditioner.EffectiveFlags.Prohibit,
232
232
  prohibitPower: AirConditioner.EffectiveFlags.Prohibit,
233
233
  };
234
- flag = Object.keys(changes).reduce((acc, key) => acc | (flagBits[key] ?? 0), AirConditioner.EffectiveFlags.Power);
234
+ // Only an explicit Power command may switch the device on or off, other settings keep the current power state
235
+ const powerFlag = changes.power !== undefined ? AirConditioner.EffectiveFlags.Power : 0;
236
+ flag = Object.keys(changes).reduce((acc, key) => acc | (flagBits[key] ?? 0), powerFlag);
235
237
 
236
238
  payload = this.functions.toPascalCaseKeys({
237
239
  ...changes,
238
- power: changes.power !== false,
239
240
  deviceID: deviceData.Device.DeviceID,
240
241
  effectiveFlags: flag,
241
242
  hasPendingCommand: true,
@@ -194,7 +194,7 @@ class MelCloudAtw extends EventEmitter {
194
194
  return true;
195
195
  }
196
196
 
197
- //rebuild effective flags from only the fields that actually changed, Power always stays forced
197
+ //rebuild effective flags from only the fields that actually changed
198
198
  //NOTE: use + not | - some HeatPump flags exceed 32 bits (e.g. SetHeatFlowTemperatureZone1 = 2^48), and
199
199
  //the bitwise OR operator truncates to a 32-bit int in JS, silently corrupting large flag values
200
200
  const flagBits = {
@@ -215,11 +215,12 @@ class MelCloudAtw extends EventEmitter {
215
215
  prohibitZone2: HeatPump.EffectiveFlags.ProhibitHeatingZone2,
216
216
  prohibitHotWater: HeatPump.EffectiveFlags.ProhibitHotWater,
217
217
  };
218
- flag = Object.keys(changes).reduce((acc, key) => acc + (flagBits[key] ?? 0), HeatPump.EffectiveFlags.Power);
218
+ // Only an explicit Power command may switch the device on or off, other settings keep the current power state
219
+ const powerFlag = changes.power !== undefined ? HeatPump.EffectiveFlags.Power : 0;
220
+ flag = Object.keys(changes).reduce((acc, key) => acc + (flagBits[key] ?? 0), powerFlag);
219
221
 
220
222
  payload = this.functions.toPascalCaseKeys({
221
223
  ...changes,
222
- power: changes.power !== false,
223
224
  deviceID: deviceData.Device.DeviceID,
224
225
  effectiveFlags: flag,
225
226
  hasPendingCommand: true,
@@ -211,7 +211,7 @@ class MelCloudErv extends EventEmitter {
211
211
  break;
212
212
  };
213
213
 
214
- //rebuild effective flags from only the fields that actually changed, Power always stays forced
214
+ //rebuild effective flags from only the fields that actually changed
215
215
  const flagBits = {
216
216
  operationMode: Ventilation.EffectiveFlags.OperationMode,
217
217
  ventilationMode: Ventilation.EffectiveFlags.VentilationMode,
@@ -222,11 +222,12 @@ class MelCloudErv extends EventEmitter {
222
222
  prohibitOperationMode: Ventilation.EffectiveFlags.Prohibit,
223
223
  prohibitPower: Ventilation.EffectiveFlags.Prohibit,
224
224
  };
225
- flag = Object.keys(changes).reduce((acc, key) => acc | (flagBits[key] ?? 0), Ventilation.EffectiveFlags.Power);
225
+ // Only an explicit Power command may switch the device on or off, other settings keep the current power state
226
+ const powerFlag = changes.power !== undefined ? Ventilation.EffectiveFlags.Power : 0;
227
+ flag = Object.keys(changes).reduce((acc, key) => acc | (flagBits[key] ?? 0), powerFlag);
226
228
 
227
229
  payload = this.functions.toPascalCaseKeys({
228
230
  ...changes,
229
- power: changes.power !== false,
230
231
  deviceID: deviceData.Device.DeviceID,
231
232
  effectiveFlags: flag,
232
233
  hasPendingCommand: true,
package/src/restful.js CHANGED
@@ -24,6 +24,49 @@ class RestFul extends EventEmitter {
24
24
  try {
25
25
  const app = express();
26
26
  app.set('json spaces', 2);
27
+
28
+ // Request rate limit per client, protects Homebridge from request floods (CWE-770)
29
+ const rateLimitWindow = 60 * 1000;
30
+ const rateLimitMax = 600;
31
+ const rateLimitClients = 1000;
32
+ const clients = new Map();
33
+ let clientsLimitWarned = 0;
34
+ app.use((req, res, next) => {
35
+ const now = Date.now();
36
+
37
+ // Drop expired windows so the map cannot grow without limit
38
+ if (clients.size > 100) {
39
+ for (const [ip, client] of clients) {
40
+ if (now - client.start >= rateLimitWindow) clients.delete(ip);
41
+ }
42
+ }
43
+
44
+ let client = clients.get(req.ip);
45
+ if (!client || now - client.start >= rateLimitWindow) {
46
+ // Hard limit of tracked addresses, new addresses wait until older windows expire
47
+ if (!client && clients.size >= rateLimitClients) {
48
+ if (now - clientsLimitWarned >= rateLimitWindow) {
49
+ clientsLimitWarned = now;
50
+ if (this.logWarn) this.emit('warn', `RESTFul too many clients (${rateLimitClients}) in one minute, new clients are rejected until the window ends`);
51
+ }
52
+ res.set('Retry-After', String(Math.ceil(rateLimitWindow / 1000)));
53
+ return res.status(429).json({ error: 'RESTFul Too Many Requests' });
54
+ }
55
+ client = { count: 0, start: now, warned: false };
56
+ clients.set(req.ip, client);
57
+ }
58
+
59
+ if (++client.count > rateLimitMax) {
60
+ // Warn once per client and window, a flood must not flood the log too
61
+ if (!client.warned && this.logWarn) this.emit('warn', `RESTFul rate limit ${rateLimitMax} requests per minute exceeded by: ${req.ip}, further requests are rejected until the window ends`);
62
+ client.warned = true;
63
+ res.set('Retry-After', String(Math.ceil((client.start + rateLimitWindow - now) / 1000)));
64
+ return res.status(429).json({ error: 'RESTFul Too Many Requests' });
65
+ }
66
+
67
+ next();
68
+ });
69
+
27
70
  app.use(json());
28
71
 
29
72
  // Optional token auth, when a token is configured every route requires "Authorization: Bearer <token>"