homebridge-melcloud-control 4.12.0 → 4.13.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 +19 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/deviceata.js +24 -4
- package/src/deviceatw.js +31 -3
- package/src/deviceerv.js +10 -0
- package/src/hadiscovery.js +61 -1
- package/src/melcloudata.js +4 -3
- package/src/melcloudatw.js +4 -3
- package/src/melclouderv.js +4 -3
- package/src/mqtt.js +5 -0
- package/src/restful.js +43 -0
package/CHANGELOG.md
CHANGED
|
@@ -25,6 +25,25 @@ 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.1] - (29.09.2026)
|
|
29
|
+
|
|
30
|
+
### Changes
|
|
31
|
+
|
|
32
|
+
- fixed: MQTT, when the broker was not running yet at the start of Homebridge the device never published over MQTT and the Home Assistant discovery was not set up until a restart. The device now starts publishing and sets up the discovery as soon as the broker comes online
|
|
33
|
+
- fixed: Home Assistant discovery, the config and the state are published again after a restart of the broker, a broker without persistence loses the retained messages
|
|
34
|
+
|
|
35
|
+
## [4.13.0] - (24.09.2026)
|
|
36
|
+
|
|
37
|
+
### Changes
|
|
38
|
+
|
|
39
|
+
- added: HA Discovery, device locks as one `select` in the device configuration
|
|
40
|
+
- Air Conditioner (MELCloud only): `Off`, `Power`, `Mode`, `Mode Power`, `Temp`, `Temp Power`, `Temp Mode`, `All`
|
|
41
|
+
- Heat Pump: `Off`, `Zone 1`, `Zone 1 Zone 2`, `Zone 1 Water`, `Zone 2`, `Zone 2 Water`, `Water`, `All` (zone locks MELCloud only)
|
|
42
|
+
- 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
|
|
43
|
+
- 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
|
|
44
|
+
- 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
|
|
45
|
+
- readme update
|
|
46
|
+
|
|
28
47
|
## [4.12.0] - (23.09.2026)
|
|
29
48
|
|
|
30
49
|
### 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.
|
|
4
|
+
"version": "4.13.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",
|
package/src/deviceata.js
CHANGED
|
@@ -64,6 +64,8 @@ class DeviceAta extends EventEmitter {
|
|
|
64
64
|
this.mqtt = account.mqtt ?? {};
|
|
65
65
|
this.mqtt1 = mqtt1;
|
|
66
66
|
this.mqttConnected = mqttConnected;
|
|
67
|
+
// The broker may come online after the start or restart, the device then publishes again
|
|
68
|
+
this.mqtt1?.on('online', () => this.mqttOnline().catch((error) => this.emit('warn', `MQTT online error: ${error}`)));
|
|
67
69
|
|
|
68
70
|
const serviceType = [null, Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor, Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor, null];
|
|
69
71
|
const characteristicType = [null, Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState, Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState, null];
|
|
@@ -1321,12 +1323,28 @@ class DeviceAta extends EventEmitter {
|
|
|
1321
1323
|
}
|
|
1322
1324
|
}
|
|
1323
1325
|
|
|
1326
|
+
// MQTT connected, at start when the broker was not running yet or after a restart of the broker
|
|
1327
|
+
async mqttOnline() {
|
|
1328
|
+
this.mqttConnected = true;
|
|
1329
|
+
this.ha?.reset();
|
|
1330
|
+
if (this.haLast) await this.haPublish(...this.haLast);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1324
1333
|
async haPublish(capabilities, state) {
|
|
1334
|
+
this.haLast = [capabilities, state];
|
|
1325
1335
|
if (!this.haReady()) return;
|
|
1326
1336
|
|
|
1327
1337
|
try {
|
|
1328
1338
|
await this.ha.publishEntity('climate', '', this.ha.ataClimate(capabilities));
|
|
1329
|
-
|
|
1339
|
+
const haState = HaDiscovery.ataState(state);
|
|
1340
|
+
|
|
1341
|
+
// Locks work only with MELCloud
|
|
1342
|
+
if (capabilities.supportsProhibit) {
|
|
1343
|
+
const parts = [{ name: 'Temp', key: 'ProhibitSetTemperature' }, { name: 'Mode', key: 'ProhibitOperationMode' }, { name: 'Power', key: 'ProhibitPower' }];
|
|
1344
|
+
await this.ha.publishEntity('select', 'prohibit', this.ha.prohibitSelect(parts));
|
|
1345
|
+
haState.prohibit = HaDiscovery.prohibitName(parts, [state.prohibitSetTemperature, state.prohibitOperationMode, state.prohibitPower]);
|
|
1346
|
+
}
|
|
1347
|
+
await this.ha.updateState(haState);
|
|
1330
1348
|
} catch (error) {
|
|
1331
1349
|
if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
|
|
1332
1350
|
}
|
|
@@ -1658,13 +1676,15 @@ class DeviceAta extends EventEmitter {
|
|
|
1658
1676
|
supportsFanSpeed,
|
|
1659
1677
|
numberOfFanSpeeds,
|
|
1660
1678
|
supportsAutomaticFanSpeed,
|
|
1661
|
-
|
|
1679
|
+
// AirDirectionFunction is the i-See air direction, not the vertical vane itself
|
|
1680
|
+
supportsVaneVertical: vaneVerticalDirection !== null && vaneVerticalDirection !== undefined && !hideVaneControls,
|
|
1662
1681
|
supportsSwingFunction,
|
|
1663
1682
|
supportsWideVane: supportsWideVane && !hideVaneControls,
|
|
1664
1683
|
minTemp: minSetHeatRoomTemperature,
|
|
1665
1684
|
maxTemp: maxSetHeatCoolDryAutoRoomTemperature,
|
|
1666
|
-
tempStep: temperatureStep
|
|
1667
|
-
|
|
1685
|
+
tempStep: temperatureStep,
|
|
1686
|
+
supportsProhibit: accountTypeMelCloud
|
|
1687
|
+
}, { power, operationMode, inStandbyMode, roomTemperature, setTemperature, setFanSpeed, vaneVerticalDirection, vaneHorizontalDirection, prohibitSetTemperature, prohibitOperationMode, prohibitPower });
|
|
1668
1688
|
|
|
1669
1689
|
//update services
|
|
1670
1690
|
for (const { type, value } of characteristics) {
|
package/src/deviceatw.js
CHANGED
|
@@ -71,6 +71,8 @@ class DeviceAtw extends EventEmitter {
|
|
|
71
71
|
this.mqtt = account.mqtt ?? {};
|
|
72
72
|
this.mqtt1 = mqtt1;
|
|
73
73
|
this.mqttConnected = mqttConnected;
|
|
74
|
+
// The broker may come online after the start or restart, the device then publishes again
|
|
75
|
+
this.mqtt1?.on('online', () => this.mqttOnline().catch((error) => this.emit('warn', `MQTT online error: ${error}`)));
|
|
74
76
|
|
|
75
77
|
const serviceType = [null, Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor, Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor, null];
|
|
76
78
|
const characteristicType = [null, Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState, Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState, null];
|
|
@@ -1593,7 +1595,15 @@ class DeviceAtw extends EventEmitter {
|
|
|
1593
1595
|
}
|
|
1594
1596
|
}
|
|
1595
1597
|
|
|
1598
|
+
// MQTT connected, at start when the broker was not running yet or after a restart of the broker
|
|
1599
|
+
async mqttOnline() {
|
|
1600
|
+
this.mqttConnected = true;
|
|
1601
|
+
this.ha?.reset();
|
|
1602
|
+
if (this.haLast) await this.haPublish(...this.haLast);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1596
1605
|
async haPublish(capabilities, power, zones, tank) {
|
|
1606
|
+
this.haLast = [capabilities, power, zones, tank];
|
|
1597
1607
|
if (!this.haReady()) return;
|
|
1598
1608
|
|
|
1599
1609
|
try {
|
|
@@ -1607,6 +1617,23 @@ class DeviceAtw extends EventEmitter {
|
|
|
1607
1617
|
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
1618
|
state.tank = HaDiscovery.atwTankState(tank);
|
|
1609
1619
|
}
|
|
1620
|
+
|
|
1621
|
+
// Zone locks work only with MELCloud, the hot water lock with both accounts
|
|
1622
|
+
const parts = [];
|
|
1623
|
+
const values = [];
|
|
1624
|
+
for (const zone of zones) {
|
|
1625
|
+
if (!capabilities.supportsZoneProhibit) continue;
|
|
1626
|
+
parts.push({ name: `Zone ${zone.zone}`, key: `ProhibitZone${zone.zone}` });
|
|
1627
|
+
values.push(zone.prohibit);
|
|
1628
|
+
}
|
|
1629
|
+
if (tank) {
|
|
1630
|
+
parts.push({ name: 'Water', key: 'ProhibitHotWater' });
|
|
1631
|
+
values.push(tank.prohibit);
|
|
1632
|
+
}
|
|
1633
|
+
if (parts.length > 0) {
|
|
1634
|
+
await this.ha.publishEntity('select', 'prohibit', this.ha.prohibitSelect(parts, 'lexical'));
|
|
1635
|
+
state.prohibit = HaDiscovery.prohibitName(parts, values);
|
|
1636
|
+
}
|
|
1610
1637
|
await this.ha.updateState(state);
|
|
1611
1638
|
} catch (error) {
|
|
1612
1639
|
if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
|
|
@@ -2294,13 +2321,14 @@ class DeviceAtw extends EventEmitter {
|
|
|
2294
2321
|
|
|
2295
2322
|
//home assistant, zones and tank from device capabilities (not the HomeKit hide zone setting)
|
|
2296
2323
|
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 }] : [])
|
|
2324
|
+
{ zone: 1, name: zone1Name, operationMode: operationModeZone1, setTemperature: setTemperatureZone1, setHeatFlowTemperature: setHeatFlowTemperatureZone1, setCoolFlowTemperature: setCoolFlowTemperatureZone1, roomTemperature: roomTemperatureZone1, idle: idleZone1, prohibit: prohibitZone1 },
|
|
2325
|
+
...(deviceData.Device.HasZone2 ? [{ zone: 2, name: zone2Name, operationMode: operationModeZone2, setTemperature: setTemperatureZone2, setHeatFlowTemperature: setHeatFlowTemperatureZone2, setCoolFlowTemperature: setCoolFlowTemperatureZone2, roomTemperature: roomTemperatureZone2, idle: idleZone2, prohibit: prohibitZone2 }] : [])
|
|
2299
2326
|
];
|
|
2300
|
-
const haTank = deviceData.Device[supportHotWaterKey] ? { name: hotWaterName, forcedHotWaterMode, ecoHotWater, setTankWaterTemperature, tankWaterTemperature, minTemp: minSetTankTemperature, maxTemp: maxSetTankTemperature } : null;
|
|
2327
|
+
const haTank = deviceData.Device[supportHotWaterKey] ? { name: hotWaterName, forcedHotWaterMode, ecoHotWater, setTankWaterTemperature, tankWaterTemperature, minTemp: minSetTankTemperature, maxTemp: maxSetTankTemperature, prohibit: prohibitHotWater } : null;
|
|
2301
2328
|
this.haPublish({
|
|
2302
2329
|
supportsCool,
|
|
2303
2330
|
supportsEco: accountTypeMelCloud,
|
|
2331
|
+
supportsZoneProhibit: accountTypeMelCloud,
|
|
2304
2332
|
tempStep: temperatureIncrement === true || temperatureIncrement === 0.5 ? 0.5 : 1
|
|
2305
2333
|
}, power, haZones, haTank);
|
|
2306
2334
|
|
package/src/deviceerv.js
CHANGED
|
@@ -59,6 +59,8 @@ class DeviceErv extends EventEmitter {
|
|
|
59
59
|
this.mqtt = account.mqtt ?? {};
|
|
60
60
|
this.mqtt1 = mqtt1;
|
|
61
61
|
this.mqttConnected = mqttConnected;
|
|
62
|
+
// The broker may come online after the start or restart, the device then publishes again
|
|
63
|
+
this.mqtt1?.on('online', () => this.mqttOnline().catch((error) => this.emit('warn', `MQTT online error: ${error}`)));
|
|
62
64
|
|
|
63
65
|
const serviceType = [null, Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor, Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor, null];
|
|
64
66
|
const characteristicType = [null, Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState, Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState, null];
|
|
@@ -995,7 +997,15 @@ class DeviceErv extends EventEmitter {
|
|
|
995
997
|
}
|
|
996
998
|
}
|
|
997
999
|
|
|
1000
|
+
// MQTT connected, at start when the broker was not running yet or after a restart of the broker
|
|
1001
|
+
async mqttOnline() {
|
|
1002
|
+
this.mqttConnected = true;
|
|
1003
|
+
this.ha?.reset();
|
|
1004
|
+
if (this.haLast) await this.haPublish(...this.haLast);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
998
1007
|
async haPublish(capabilities, state) {
|
|
1008
|
+
this.haLast = [capabilities, state];
|
|
999
1009
|
if (!this.haReady()) return;
|
|
1000
1010
|
|
|
1001
1011
|
try {
|
package/src/hadiscovery.js
CHANGED
|
@@ -33,6 +33,12 @@ class HaDiscovery {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// Publish (or republish when changed) one retained discovery message
|
|
36
|
+
// The broker may have lost the retained messages (restart without persistence), the next publish sends everything again
|
|
37
|
+
reset() {
|
|
38
|
+
this.lastConfig = {};
|
|
39
|
+
this.lastState = '';
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
async publishEntity(component, suffix, entity) {
|
|
37
43
|
const objectId = suffix ? `${this.baseId}_${suffix}` : this.baseId;
|
|
38
44
|
const topic = `${this.mqtt.haPrefix}/${component}/${objectId}/config`;
|
|
@@ -104,7 +110,7 @@ class HaDiscovery {
|
|
|
104
110
|
});
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
if (c.
|
|
113
|
+
if (c.supportsVaneVertical) {
|
|
108
114
|
const swingModes = ['auto', '1', '2', '3', '4', '5', ...(c.supportsSwingFunction ? ['swing'] : [])];
|
|
109
115
|
Object.assign(entity, {
|
|
110
116
|
swing_modes: swingModes,
|
|
@@ -156,6 +162,60 @@ class HaDiscovery {
|
|
|
156
162
|
};
|
|
157
163
|
}
|
|
158
164
|
|
|
165
|
+
// ---------------------------------------------------------------- prohibit
|
|
166
|
+
// One select with every combination of the device locks, parts: [{ name, key }].
|
|
167
|
+
// order 'binary' - first part is the highest bit: Off, Power, Mode, Mode Power, Temp, Temp Power, Temp Mode, All
|
|
168
|
+
// order 'lexical' - combinations in part order: Off, Zone 1, Zone 1 Zone 2, Zone 1 Water, Zone 2, Zone 2 Water, Water, All
|
|
169
|
+
prohibitSelect(parts, order = 'binary') {
|
|
170
|
+
const combos = HaDiscovery.prohibitCombos(parts, order);
|
|
171
|
+
const options = combos.map(values => HaDiscovery.prohibitName(parts, values));
|
|
172
|
+
const masks = JSON.stringify(Object.fromEntries(combos.map((values, i) => [options[i], values.reduce((mask, v, bit) => mask | (v ? 1 << bit : 0), 0)])));
|
|
173
|
+
const values = parts.map((p, bit) => `"${p.key}": {{ "true" if (m[value] // ${1 << bit}) % 2 == 1 else "false" }}`).join(', ');
|
|
174
|
+
return {
|
|
175
|
+
name: 'Prohibit',
|
|
176
|
+
icon: 'mdi:lock',
|
|
177
|
+
entity_category: 'config',
|
|
178
|
+
options,
|
|
179
|
+
state_topic: this.stateTopic,
|
|
180
|
+
value_template: '{{ value_json.prohibit }}',
|
|
181
|
+
command_topic: this.commandTopic,
|
|
182
|
+
command_template: `{% set m = ${masks} %}{${values}}`
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Every lock combination as an array of booleans in part order
|
|
187
|
+
static prohibitCombos(parts, order = 'binary') {
|
|
188
|
+
const n = parts.length;
|
|
189
|
+
if (order === 'binary') {
|
|
190
|
+
return [...Array(1 << n).keys()].map(mask => parts.map((p, i) => ((mask >> (n - 1 - i)) & 1) === 1));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const combos = [];
|
|
194
|
+
const walk = (start, picked) => {
|
|
195
|
+
for (let i = start; i < n; i++) {
|
|
196
|
+
const next = [...picked, i];
|
|
197
|
+
combos.push(next);
|
|
198
|
+
walk(i + 1, next);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
walk(0, []);
|
|
202
|
+
const toValues = (picked) => parts.map((p, i) => picked.includes(i));
|
|
203
|
+
const partial = combos.filter(picked => n === 1 || picked.length < n).map(toValues);
|
|
204
|
+
return [parts.map(() => false), ...partial, ...(n > 1 ? [parts.map(() => true)] : [])];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
static prohibitOptions(parts, order = 'binary') {
|
|
208
|
+
return HaDiscovery.prohibitCombos(parts, order).map(values => HaDiscovery.prohibitName(parts, values));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Name of a lock combination, values in the same order as parts
|
|
212
|
+
static prohibitName(parts, values) {
|
|
213
|
+
const locked = parts.filter((p, i) => values[i]);
|
|
214
|
+
if (locked.length === 0) return 'Off';
|
|
215
|
+
if (parts.length > 1 && locked.length === parts.length) return 'All';
|
|
216
|
+
return locked.map(p => p.name).join(' ');
|
|
217
|
+
}
|
|
218
|
+
|
|
159
219
|
// ---------------------------------------------------------------- ATW
|
|
160
220
|
atwZoneClimate(zone, c) {
|
|
161
221
|
const z = `zone${zone}`;
|
package/src/melcloudata.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/src/melcloudatw.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/src/melclouderv.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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/mqtt.js
CHANGED
|
@@ -36,6 +36,7 @@ class Mqtt extends EventEmitter {
|
|
|
36
36
|
} : {})
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
this.connected = false;
|
|
39
40
|
const startTime = Date.now();
|
|
40
41
|
let hasConnected = false;
|
|
41
42
|
let warnedStalled = false;
|
|
@@ -45,6 +46,9 @@ class Mqtt extends EventEmitter {
|
|
|
45
46
|
hasConnected = true;
|
|
46
47
|
this.emit('connected', `MQTT v${protocolVersion} connected.`);
|
|
47
48
|
if (this.haDiscovery) this.publishRetained(this.availabilityTopic, 'online').catch(() => { });
|
|
49
|
+
// Every connect, also when the broker was not running at start or restarted, devices publish again
|
|
50
|
+
this.connected = true;
|
|
51
|
+
this.emit('online');
|
|
48
52
|
|
|
49
53
|
try {
|
|
50
54
|
await new Promise((resolve, reject) => {
|
|
@@ -90,6 +94,7 @@ class Mqtt extends EventEmitter {
|
|
|
90
94
|
if (config.logDebug) this.emit('debug', 'MQTT Reconnecting...');
|
|
91
95
|
})
|
|
92
96
|
.on('close', () => {
|
|
97
|
+
this.connected = false;
|
|
93
98
|
if (!hasConnected && !warnedStalled && Date.now() - startTime > 30000) {
|
|
94
99
|
warnedStalled = true;
|
|
95
100
|
this.emit('warn', `MQTT has not connected after 30s of retries using protocol v${protocolVersion}. Check broker address, credentials, and whether the broker supports this MQTT protocol version.`);
|
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>"
|