homebridge-melcloud-control 4.11.3 → 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 +24 -0
- package/README.md +10 -1
- package/config.schema.json +10 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/deviceata.js +54 -0
- package/src/deviceatw.js +66 -0
- package/src/deviceerv.js +34 -0
- package/src/hadiscovery.js +339 -0
- package/src/melcloudata.js +4 -3
- package/src/melcloudatw.js +4 -3
- package/src/melclouderv.js +4 -3
- package/src/mqtt.js +27 -0
- package/src/restful.js +43 -0
package/CHANGELOG.md
CHANGED
|
@@ -25,6 +25,30 @@ 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
|
+
|
|
40
|
+
## [4.12.0] - (23.09.2026)
|
|
41
|
+
|
|
42
|
+
### Changes
|
|
43
|
+
|
|
44
|
+
- added: Home Assistant MQTT discovery (`HA Discovery` option in the MQTT section), using the built-in Home Assistant MQTT integration
|
|
45
|
+
- Air Conditioner as `climate` with modes, target and room temperature, action, fan speed, vertical and horizontal vane
|
|
46
|
+
- Heat Pump zones as `climate` with presets Room / Flow / Curve, hot water tank as `water_heater` with heat pump / eco / high demand modes
|
|
47
|
+
- Energy Recovery Ventilation as `fan` with speed and Lossnay / Bypass / Auto presets
|
|
48
|
+
- added: MQTT availability topic (`<prefix>/Availability`, retained `online` / `offline` last will) when HA Discovery is enabled
|
|
49
|
+
- readme update
|
|
50
|
+
- sample config update
|
|
51
|
+
|
|
28
52
|
## [4.11.3] - (23.09.2026)
|
|
29
53
|
|
|
30
54
|
### Changes
|
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* Physical lock controls `LOCK/UNLOCK`, only MELCloud.
|
|
46
46
|
* Functions, using extra `Buttons`, switch to `OFF` restore previous state.
|
|
47
47
|
* Automations, shortcuts and Siri.
|
|
48
|
-
* External integrations, [RESTFul](https://github.com/grzegorz914/homebridge-melcloud-control?tab=readme-ov-file#restful-integration), [MQTT](https://github.com/grzegorz914/homebridge-melcloud-control?tab=readme-ov-file#mqtt-integration).
|
|
48
|
+
* External integrations, [RESTFul](https://github.com/grzegorz914/homebridge-melcloud-control?tab=readme-ov-file#restful-integration), [MQTT](https://github.com/grzegorz914/homebridge-melcloud-control?tab=readme-ov-file#mqtt-integration), [HA Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery).
|
|
49
49
|
* Control devices over local network You need use ESP module and [Tasmota Control](https://github.com/grzegorz914/homebridge-tasmota-control) plugin.
|
|
50
50
|
|
|
51
51
|
### Control Mode
|
|
@@ -366,6 +366,7 @@
|
|
|
366
366
|
| `mqtt.clientId` | Here optional set the `Client Id` of MQTT Broker. |
|
|
367
367
|
| `mqtt.prefix` | Here set the `Prefix` for `Topic` or leave empty. |
|
|
368
368
|
| `mqtt.protocolVersion` | Here select the MQTT protocol version, `5.0` (default) or `3.1.1` for brokers that only support 3.1.1 (e.g. ioBroker MQTT adapter). |
|
|
369
|
+
| `mqtt.haDiscovery` | If enabled, devices are published to Home Assistant with MQTT discovery. Air Conditioner as `climate`, Heat Pump zones as `climate` and hot water tank as `water_heater`, Energy Recovery Ventilation as `fan`. |
|
|
369
370
|
| `mqtt.auth{}` | MQTT authorization object. |
|
|
370
371
|
| `mqtt.auth.enable` | Here enable authorization for MQTT Broker. |
|
|
371
372
|
| `mqtt.auth.user` | Here set the MQTT Broker user. |
|
|
@@ -438,6 +439,14 @@
|
|
|
438
439
|
### MQTT Integration
|
|
439
440
|
|
|
440
441
|
* Subscribe data as a JSON Object `{"Power": true}`
|
|
442
|
+
* HA Discovery - if enabled, devices appear in Home Assistant through the built-in [MQTT integration](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery), no custom integration is needed. Additional retained topics:
|
|
443
|
+
* `homeassistant/climate/melcloud_<id>/config` - Air Conditioner, modes off / heat / cool / dry / fan_only / auto, target and room temperature, fan speed, vertical and horizontal vane.
|
|
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
|
+
* `homeassistant/water_heater/melcloud_<id>_tank/config` - Heat Pump hot water tank, modes `heat_pump`, `eco` (MELCloud only), `high_demand` (forced hot water).
|
|
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).
|
|
448
|
+
* `HA State` - state for the entities above.
|
|
449
|
+
* `Availability` - `online`, `offline` (last will).
|
|
441
450
|
|
|
442
451
|
| Direction | Topic | Message | Type |
|
|
443
452
|
| --- | --- | --- | --- |
|
package/config.schema.json
CHANGED
|
@@ -2441,6 +2441,15 @@
|
|
|
2441
2441
|
"functionBody": "return model.accounts[arrayIndices[0]].mqtt.enable === true;"
|
|
2442
2442
|
}
|
|
2443
2443
|
},
|
|
2444
|
+
"haDiscovery": {
|
|
2445
|
+
"title": "HA Discovery",
|
|
2446
|
+
"type": "boolean",
|
|
2447
|
+
"default": false,
|
|
2448
|
+
"description": "Publish Home Assistant MQTT discovery messages. Air Conditioner as climate, Heat Pump zones as climate and hot water tank as water_heater, Energy Recovery Ventilation as fan. Uses the built-in Home Assistant MQTT integration.",
|
|
2449
|
+
"condition": {
|
|
2450
|
+
"functionBody": "return model.accounts[arrayIndices[0]].mqtt.enable === true;"
|
|
2451
|
+
}
|
|
2452
|
+
},
|
|
2444
2453
|
"auth": {
|
|
2445
2454
|
"title": "Authorization",
|
|
2446
2455
|
"type": "object",
|
|
@@ -3098,6 +3107,7 @@
|
|
|
3098
3107
|
"accounts[].mqtt.clientId",
|
|
3099
3108
|
"accounts[].mqtt.prefix",
|
|
3100
3109
|
"accounts[].mqtt.protocolVersion",
|
|
3110
|
+
"accounts[].mqtt.haDiscovery",
|
|
3101
3111
|
{
|
|
3102
3112
|
"key": "accounts[].mqtt.auth",
|
|
3103
3113
|
"title": "Authorization",
|
package/index.js
CHANGED
|
@@ -296,6 +296,7 @@ class MelCloudPlatform {
|
|
|
296
296
|
clientId: mqtt.clientId ? `melcloud_${mqtt.clientId}_${Math.random().toString(16).slice(3)}` : `melcloud_${Math.random().toString(16).slice(3)}`,
|
|
297
297
|
prefix: mqtt.prefix ? `melcloud/${mqtt.prefix}/${deviceTypeString}/${deviceName}` : `melcloud/${deviceTypeString}/${deviceName}`,
|
|
298
298
|
protocolVersion: mqtt.protocolVersion,
|
|
299
|
+
haDiscovery: mqtt.haDiscovery,
|
|
299
300
|
user: mqtt.auth?.user,
|
|
300
301
|
passwd: mqtt.auth?.passwd,
|
|
301
302
|
logWarn: logLevel.warn,
|
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.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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import EventEmitter from 'events';
|
|
2
2
|
import MelCloudAta from './melcloudata.js';
|
|
3
3
|
import Functions from './functions.js';
|
|
4
|
+
import HaDiscovery from './hadiscovery.js';
|
|
4
5
|
import { TemperatureDisplayUnits, AirConditioner, DeviceType } from './constants.js';
|
|
5
6
|
let Accessory, Characteristic, Service, Categories, AccessoryUUID;
|
|
6
7
|
|
|
@@ -1305,6 +1306,40 @@ class DeviceAta extends EventEmitter {
|
|
|
1305
1306
|
}
|
|
1306
1307
|
}
|
|
1307
1308
|
|
|
1309
|
+
//home assistant discovery
|
|
1310
|
+
haReady() {
|
|
1311
|
+
if (!this.mqttConnected || !this.mqtt.haDiscovery) return false;
|
|
1312
|
+
if (this.ha) return true;
|
|
1313
|
+
|
|
1314
|
+
try {
|
|
1315
|
+
this.ha = new HaDiscovery(this.mqtt1, { deviceId: this.deviceId, name: this.deviceName, model: this.model });
|
|
1316
|
+
return true;
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (!this.haErrorLogged && this.logWarn) this.emit('warn', `HA Discovery setup error: ${error.message ?? error}`);
|
|
1319
|
+
this.haErrorLogged = true;
|
|
1320
|
+
return false;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async haPublish(capabilities, state) {
|
|
1325
|
+
if (!this.haReady()) return;
|
|
1326
|
+
|
|
1327
|
+
try {
|
|
1328
|
+
await this.ha.publishEntity('climate', '', this.ha.ataClimate(capabilities));
|
|
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);
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1308
1343
|
//start
|
|
1309
1344
|
async start() {
|
|
1310
1345
|
try {
|
|
@@ -1622,6 +1657,25 @@ class DeviceAta extends EventEmitter {
|
|
|
1622
1657
|
};
|
|
1623
1658
|
this.accessory = obj;
|
|
1624
1659
|
|
|
1660
|
+
//home assistant, device capabilities (not the HomeKit mode mapping)
|
|
1661
|
+
this.haPublish({
|
|
1662
|
+
supportsHeat: supportsHeat1,
|
|
1663
|
+
supportsCool: supportsCool1,
|
|
1664
|
+
supportsDry: supportsDry && !hideDryModeControl,
|
|
1665
|
+
supportsAuto: supportsAuto1,
|
|
1666
|
+
supportsFanSpeed,
|
|
1667
|
+
numberOfFanSpeeds,
|
|
1668
|
+
supportsAutomaticFanSpeed,
|
|
1669
|
+
// AirDirectionFunction is the i-See air direction, not the vertical vane itself
|
|
1670
|
+
supportsVaneVertical: vaneVerticalDirection !== null && vaneVerticalDirection !== undefined && !hideVaneControls,
|
|
1671
|
+
supportsSwingFunction,
|
|
1672
|
+
supportsWideVane: supportsWideVane && !hideVaneControls,
|
|
1673
|
+
minTemp: minSetHeatRoomTemperature,
|
|
1674
|
+
maxTemp: maxSetHeatCoolDryAutoRoomTemperature,
|
|
1675
|
+
tempStep: temperatureStep,
|
|
1676
|
+
supportsProhibit: accountTypeMelCloud
|
|
1677
|
+
}, { power, operationMode, inStandbyMode, roomTemperature, setTemperature, setFanSpeed, vaneVerticalDirection, vaneHorizontalDirection, prohibitSetTemperature, prohibitOperationMode, prohibitPower });
|
|
1678
|
+
|
|
1625
1679
|
//update services
|
|
1626
1680
|
for (const { type, value } of characteristics) {
|
|
1627
1681
|
if (!this.functions.isValidValue(value)) continue;
|
package/src/deviceatw.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import EventEmitter from 'events';
|
|
2
2
|
import MelCloudAtw from './melcloudatw.js';
|
|
3
3
|
import Functions from './functions.js';
|
|
4
|
+
import HaDiscovery from './hadiscovery.js';
|
|
4
5
|
import { TemperatureDisplayUnits, HeatPump, DeviceType } from './constants.js';
|
|
5
6
|
let Accessory, Characteristic, Service, Categories, AccessoryUUID;
|
|
6
7
|
|
|
@@ -1577,6 +1578,58 @@ class DeviceAtw extends EventEmitter {
|
|
|
1577
1578
|
};
|
|
1578
1579
|
}
|
|
1579
1580
|
|
|
1581
|
+
//home assistant discovery
|
|
1582
|
+
haReady() {
|
|
1583
|
+
if (!this.mqttConnected || !this.mqtt.haDiscovery) return false;
|
|
1584
|
+
if (this.ha) return true;
|
|
1585
|
+
|
|
1586
|
+
try {
|
|
1587
|
+
this.ha = new HaDiscovery(this.mqtt1, { deviceId: this.deviceId, name: this.deviceName, model: this.model });
|
|
1588
|
+
return true;
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
if (!this.haErrorLogged && this.logWarn) this.emit('warn', `HA Discovery setup error: ${error.message ?? error}`);
|
|
1591
|
+
this.haErrorLogged = true;
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
async haPublish(capabilities, power, zones, tank) {
|
|
1597
|
+
if (!this.haReady()) return;
|
|
1598
|
+
|
|
1599
|
+
try {
|
|
1600
|
+
const state = { power };
|
|
1601
|
+
for (const zone of zones) {
|
|
1602
|
+
const range = HaDiscovery.atwZoneRange(zone.operationMode);
|
|
1603
|
+
await this.ha.publishEntity('climate', `zone${zone.zone}`, this.ha.atwZoneClimate(zone.zone, { name: zone.name, supportsCool: capabilities.supportsCool, tempStep: capabilities.tempStep, ...range }));
|
|
1604
|
+
state[`zone${zone.zone}`] = HaDiscovery.atwZoneState(power, zone);
|
|
1605
|
+
}
|
|
1606
|
+
if (tank) {
|
|
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
|
+
state.tank = HaDiscovery.atwTankState(tank);
|
|
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
|
+
}
|
|
1627
|
+
await this.ha.updateState(state);
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1580
1633
|
//start
|
|
1581
1634
|
async start() {
|
|
1582
1635
|
try {
|
|
@@ -2256,6 +2309,19 @@ class DeviceAtw extends EventEmitter {
|
|
|
2256
2309
|
}
|
|
2257
2310
|
this.accessory = obj;
|
|
2258
2311
|
|
|
2312
|
+
//home assistant, zones and tank from device capabilities (not the HomeKit hide zone setting)
|
|
2313
|
+
const haZones = [
|
|
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 }] : [])
|
|
2316
|
+
];
|
|
2317
|
+
const haTank = deviceData.Device[supportHotWaterKey] ? { name: hotWaterName, forcedHotWaterMode, ecoHotWater, setTankWaterTemperature, tankWaterTemperature, minTemp: minSetTankTemperature, maxTemp: maxSetTankTemperature, prohibit: prohibitHotWater } : null;
|
|
2318
|
+
this.haPublish({
|
|
2319
|
+
supportsCool,
|
|
2320
|
+
supportsEco: accountTypeMelCloud,
|
|
2321
|
+
supportsZoneProhibit: accountTypeMelCloud,
|
|
2322
|
+
tempStep: temperatureIncrement === true || temperatureIncrement === 0.5 ? 0.5 : 1
|
|
2323
|
+
}, power, haZones, haTank);
|
|
2324
|
+
|
|
2259
2325
|
//other sensors
|
|
2260
2326
|
if (this.temperatureOutdoorSensor && supportsOutdoorTemperature) this.outdoorTemperatureSensorService?.updateCharacteristic(Characteristic.CurrentTemperature, outdoorTemperature);
|
|
2261
2327
|
if (this.inStandbySensor && inStandbyMode != null) this.inStandbyService?.updateCharacteristic(Characteristic.ContactSensorState, inStandbyMode);
|
package/src/deviceerv.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import EventEmitter from 'events';
|
|
2
2
|
import MelCloudErv from './melclouderv.js';
|
|
3
3
|
import Functions from './functions.js';
|
|
4
|
+
import HaDiscovery from './hadiscovery.js';
|
|
4
5
|
import { TemperatureDisplayUnits, Ventilation, DeviceType } from './constants.js';
|
|
5
6
|
let Accessory, Characteristic, Service, Categories, AccessoryUUID;
|
|
6
7
|
|
|
@@ -979,6 +980,32 @@ class DeviceErv extends EventEmitter {
|
|
|
979
980
|
};
|
|
980
981
|
}
|
|
981
982
|
|
|
983
|
+
//home assistant discovery
|
|
984
|
+
haReady() {
|
|
985
|
+
if (!this.mqttConnected || !this.mqtt.haDiscovery) return false;
|
|
986
|
+
if (this.ha) return true;
|
|
987
|
+
|
|
988
|
+
try {
|
|
989
|
+
this.ha = new HaDiscovery(this.mqtt1, { deviceId: this.deviceId, name: this.deviceName, model: this.model });
|
|
990
|
+
return true;
|
|
991
|
+
} catch (error) {
|
|
992
|
+
if (!this.haErrorLogged && this.logWarn) this.emit('warn', `HA Discovery setup error: ${error.message ?? error}`);
|
|
993
|
+
this.haErrorLogged = true;
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async haPublish(capabilities, state) {
|
|
999
|
+
if (!this.haReady()) return;
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
await this.ha.publishEntity('fan', '', this.ha.ervFan(capabilities));
|
|
1003
|
+
await this.ha.updateState(HaDiscovery.ervState(state));
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
982
1009
|
//start
|
|
983
1010
|
async start() {
|
|
984
1011
|
try {
|
|
@@ -1249,6 +1276,13 @@ class DeviceErv extends EventEmitter {
|
|
|
1249
1276
|
|
|
1250
1277
|
this.accessory = obj;
|
|
1251
1278
|
|
|
1279
|
+
//home assistant
|
|
1280
|
+
this.haPublish({
|
|
1281
|
+
supportsBypass: supportsBypassVentilationMode,
|
|
1282
|
+
supportsAuto: supportsAutoVentilationMode,
|
|
1283
|
+
numberOfFanSpeeds
|
|
1284
|
+
}, { power, setFanSpeed, ventilationMode });
|
|
1285
|
+
|
|
1252
1286
|
//update services
|
|
1253
1287
|
for (const { type, value } of characteristics) {
|
|
1254
1288
|
if (!this.functions.isValidValue(value)) continue;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Home Assistant MQTT discovery, native climate / water_heater / fan entities.
|
|
2
|
+
// Commands go back to the existing <prefix>/Set topic as the JSON keys the plugin already handles.
|
|
3
|
+
|
|
4
|
+
// MELCloud operation mode values
|
|
5
|
+
const AtaModeToHa = { 1: 'heat', 2: 'dry', 3: 'cool', 7: 'fan_only', 8: 'auto', 9: 'heat', 10: 'dry', 11: 'cool' };
|
|
6
|
+
const AtwZonePreset = { 0: 'Room', 1: 'Flow', 2: 'Curve', 3: 'Room', 4: 'Flow', 5: 'Room' };
|
|
7
|
+
const VaneVerticalToHa = { 0: 'auto', 1: '1', 2: '2', 3: '3', 4: '4', 5: '5', 7: 'swing' };
|
|
8
|
+
const VaneHorizontalToHa = { 0: 'auto', 1: '1', 2: '2', 3: '3', 4: '4', 5: '5', 8: 'split', 12: 'swing' };
|
|
9
|
+
const VentilationModes = ['Lossnay', 'Bypass', 'Auto'];
|
|
10
|
+
|
|
11
|
+
class HaDiscovery {
|
|
12
|
+
constructor(mqtt, config) {
|
|
13
|
+
// + and # are MQTT wildcards, Home Assistant would subscribe to a pattern instead of the device topic
|
|
14
|
+
if (/[+#]/.test(mqtt.config.prefix)) {
|
|
15
|
+
throw new Error(`MQTT prefix ${mqtt.config.prefix} must not contain + or #, rename the device or set a prefix`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
this.mqtt = mqtt;
|
|
19
|
+
this.baseId = `melcloud_${String(config.deviceId).replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
|
20
|
+
this.stateTopic = `${mqtt.config.prefix}/HA State`;
|
|
21
|
+
this.commandTopic = `${mqtt.config.prefix}/Set`;
|
|
22
|
+
this.device = {
|
|
23
|
+
identifiers: [this.baseId],
|
|
24
|
+
name: config.name,
|
|
25
|
+
manufacturer: 'Mitsubishi Electric',
|
|
26
|
+
...(config.model ? { model: String(config.model) } : {}),
|
|
27
|
+
...(config.swVersion ? { sw_version: String(config.swVersion) } : {})
|
|
28
|
+
};
|
|
29
|
+
this.origin = { name: 'homebridge-melcloud-control', url: 'https://github.com/grzegorz914/homebridge-melcloud-control' };
|
|
30
|
+
|
|
31
|
+
this.lastConfig = {};
|
|
32
|
+
this.lastState = '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Publish (or republish when changed) one retained discovery message
|
|
36
|
+
async publishEntity(component, suffix, entity) {
|
|
37
|
+
const objectId = suffix ? `${this.baseId}_${suffix}` : this.baseId;
|
|
38
|
+
const topic = `${this.mqtt.haPrefix}/${component}/${objectId}/config`;
|
|
39
|
+
const config = {
|
|
40
|
+
unique_id: objectId,
|
|
41
|
+
device: this.device,
|
|
42
|
+
origin: this.origin,
|
|
43
|
+
availability_topic: this.mqtt.availabilityTopic,
|
|
44
|
+
...entity
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const payload = JSON.stringify(config);
|
|
48
|
+
if (this.lastConfig[topic] === payload) return false;
|
|
49
|
+
this.lastConfig[topic] = payload;
|
|
50
|
+
await this.mqtt.publishRetained(topic, payload);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async updateState(state) {
|
|
55
|
+
const payload = JSON.stringify(state);
|
|
56
|
+
if (payload === this.lastState) return false;
|
|
57
|
+
this.lastState = payload;
|
|
58
|
+
await this.mqtt.publishRetained(this.stateTopic, payload);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------- ATA
|
|
63
|
+
ataClimate(c) {
|
|
64
|
+
const modes = ['off'];
|
|
65
|
+
if (c.supportsHeat) modes.push('heat');
|
|
66
|
+
if (c.supportsCool) modes.push('cool');
|
|
67
|
+
if (c.supportsDry) modes.push('dry');
|
|
68
|
+
modes.push('fan_only');
|
|
69
|
+
if (c.supportsAuto) modes.push('auto');
|
|
70
|
+
|
|
71
|
+
const entity = {
|
|
72
|
+
name: null,
|
|
73
|
+
modes,
|
|
74
|
+
mode_state_topic: this.stateTopic,
|
|
75
|
+
mode_state_template: '{{ value_json.mode }}',
|
|
76
|
+
mode_command_topic: this.commandTopic,
|
|
77
|
+
mode_command_template: '{% set m = {"heat": 1, "dry": 2, "cool": 3, "fan_only": 7, "auto": 8} %}{% if value == "off" %}{"Power": false}{% else %}{"Power": true, "OperationMode": {{ m[value] }}}{% endif %}',
|
|
78
|
+
power_command_topic: this.commandTopic,
|
|
79
|
+
power_command_template: '{"Power": {{ "true" if value == "ON" else "false" }}}',
|
|
80
|
+
temperature_state_topic: this.stateTopic,
|
|
81
|
+
temperature_state_template: '{{ value_json.target_temperature }}',
|
|
82
|
+
temperature_command_topic: this.commandTopic,
|
|
83
|
+
temperature_command_template: '{"SetTemperature": {{ value }}}',
|
|
84
|
+
current_temperature_topic: this.stateTopic,
|
|
85
|
+
current_temperature_template: '{{ value_json.current_temperature }}',
|
|
86
|
+
action_topic: this.stateTopic,
|
|
87
|
+
action_template: '{{ value_json.action }}',
|
|
88
|
+
min_temp: c.minTemp,
|
|
89
|
+
max_temp: c.maxTemp,
|
|
90
|
+
temp_step: c.tempStep,
|
|
91
|
+
precision: 0.1,
|
|
92
|
+
temperature_unit: 'C'
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (c.supportsFanSpeed && c.numberOfFanSpeeds > 0) {
|
|
96
|
+
const fanModes = c.supportsAutomaticFanSpeed ? ['auto'] : [];
|
|
97
|
+
for (let i = 1; i <= c.numberOfFanSpeeds; i++) fanModes.push(String(i));
|
|
98
|
+
Object.assign(entity, {
|
|
99
|
+
fan_modes: fanModes,
|
|
100
|
+
fan_mode_state_topic: this.stateTopic,
|
|
101
|
+
fan_mode_state_template: '{{ value_json.fan_mode }}',
|
|
102
|
+
fan_mode_command_topic: this.commandTopic,
|
|
103
|
+
fan_mode_command_template: '{"FanSpeed": {{ 0 if value == "auto" else value | int }}}'
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (c.supportsVaneVertical) {
|
|
108
|
+
const swingModes = ['auto', '1', '2', '3', '4', '5', ...(c.supportsSwingFunction ? ['swing'] : [])];
|
|
109
|
+
Object.assign(entity, {
|
|
110
|
+
swing_modes: swingModes,
|
|
111
|
+
swing_mode_state_topic: this.stateTopic,
|
|
112
|
+
swing_mode_state_template: '{{ value_json.swing_mode }}',
|
|
113
|
+
swing_mode_command_topic: this.commandTopic,
|
|
114
|
+
swing_mode_command_template: '{% set m = {"auto": 0, "swing": 7} %}{"VaneVerticalDirection": {{ m[value] if value in m else value | int }}}'
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (c.supportsWideVane) {
|
|
119
|
+
const swingModes = ['auto', '1', '2', '3', '4', '5', 'split', ...(c.supportsSwingFunction ? ['swing'] : [])];
|
|
120
|
+
Object.assign(entity, {
|
|
121
|
+
swing_horizontal_modes: swingModes,
|
|
122
|
+
swing_horizontal_mode_state_topic: this.stateTopic,
|
|
123
|
+
swing_horizontal_mode_state_template: '{{ value_json.swing_horizontal_mode }}',
|
|
124
|
+
swing_horizontal_mode_command_topic: this.commandTopic,
|
|
125
|
+
swing_horizontal_mode_command_template: '{% set m = {"auto": 0, "split": 8, "swing": 12} %}{"VaneHorizontalDirection": {{ m[value] if value in m else value | int }}}'
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return entity;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
static ataState(d) {
|
|
133
|
+
const power = !!d.power;
|
|
134
|
+
const mode = power ? (AtaModeToHa[d.operationMode] ?? 'auto') : 'off';
|
|
135
|
+
const room = d.roomTemperature;
|
|
136
|
+
const target = d.setTemperature;
|
|
137
|
+
let action = 'off';
|
|
138
|
+
if (power) {
|
|
139
|
+
if (d.inStandbyMode) action = 'idle';
|
|
140
|
+
else if (mode === 'dry') action = 'drying';
|
|
141
|
+
else if (mode === 'fan_only') action = 'fan';
|
|
142
|
+
else if (mode === 'heat') action = room < target ? 'heating' : 'idle';
|
|
143
|
+
else if (mode === 'cool') action = room > target ? 'cooling' : 'idle';
|
|
144
|
+
else action = room < target ? 'heating' : room > target ? 'cooling' : 'idle';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
power,
|
|
149
|
+
mode,
|
|
150
|
+
action,
|
|
151
|
+
target_temperature: target,
|
|
152
|
+
current_temperature: room,
|
|
153
|
+
fan_mode: d.setFanSpeed === 0 ? 'auto' : String(d.setFanSpeed ?? ''),
|
|
154
|
+
swing_mode: VaneVerticalToHa[d.vaneVerticalDirection] ?? 'auto',
|
|
155
|
+
swing_horizontal_mode: VaneHorizontalToHa[d.vaneHorizontalDirection] ?? 'auto'
|
|
156
|
+
};
|
|
157
|
+
}
|
|
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
|
+
|
|
213
|
+
// ---------------------------------------------------------------- ATW
|
|
214
|
+
atwZoneClimate(zone, c) {
|
|
215
|
+
const z = `zone${zone}`;
|
|
216
|
+
const key = `Zone${zone}`;
|
|
217
|
+
const modes = ['off', 'heat', ...(c.supportsCool ? ['cool'] : [])];
|
|
218
|
+
const presets = ['Room', 'Flow', 'Curve'];
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
name: c.name,
|
|
222
|
+
modes,
|
|
223
|
+
mode_state_topic: this.stateTopic,
|
|
224
|
+
mode_state_template: `{{ value_json.${z}.mode }}`,
|
|
225
|
+
mode_command_topic: this.commandTopic,
|
|
226
|
+
// Keep the current preset (room / flow / curve) when switching between heat and cool
|
|
227
|
+
mode_command_template: `{% set p = state_attr(entity_id, "preset_mode") %}{% if value == "off" %}{"Power": false}{% elif value == "cool" %}{"Power": true, "OperationMode${key}": {{ 4 if p == "Flow" else 3 }}}{% else %}{"Power": true, "OperationMode${key}": {{ 1 if p == "Flow" else 2 if p == "Curve" else 0 }}}{% endif %}`,
|
|
228
|
+
preset_modes: presets,
|
|
229
|
+
preset_mode_state_topic: this.stateTopic,
|
|
230
|
+
preset_mode_value_template: `{{ value_json.${z}.preset }}`,
|
|
231
|
+
preset_mode_command_topic: this.commandTopic,
|
|
232
|
+
preset_mode_command_template: `{% set cool = states(entity_id) == "cool" %}{% set m = {"Room": 3 if cool else 0, "Flow": 4 if cool else 1, "Curve": 3 if cool else 2} %}{"OperationMode${key}": {{ m.get(value, m["Room"]) }}}`,
|
|
233
|
+
temperature_state_topic: this.stateTopic,
|
|
234
|
+
temperature_state_template: `{{ value_json.${z}.target_temperature }}`,
|
|
235
|
+
temperature_command_topic: this.commandTopic,
|
|
236
|
+
// Flow presets set the flow temperature, the others the room temperature
|
|
237
|
+
temperature_command_template: `{% set p = state_attr(entity_id, "preset_mode") %}{% set cool = states(entity_id) == "cool" %}{% if p == "Flow" %}{"{{ "SetCoolFlowTemperature${key}" if cool else "SetHeatFlowTemperature${key}" }}": {{ value }}}{% else %}{"SetTemperature${key}": {{ value }}}{% endif %}`,
|
|
238
|
+
current_temperature_topic: this.stateTopic,
|
|
239
|
+
current_temperature_template: `{{ value_json.${z}.current_temperature }}`,
|
|
240
|
+
action_topic: this.stateTopic,
|
|
241
|
+
action_template: `{{ value_json.${z}.action }}`,
|
|
242
|
+
power_command_topic: this.commandTopic,
|
|
243
|
+
power_command_template: '{"Power": {{ "true" if value == "ON" else "false" }}}',
|
|
244
|
+
min_temp: c.minTemp,
|
|
245
|
+
max_temp: c.maxTemp,
|
|
246
|
+
temp_step: c.tempStep,
|
|
247
|
+
precision: 0.1,
|
|
248
|
+
temperature_unit: 'C'
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
static atwZoneState(power, d) {
|
|
253
|
+
const preset = AtwZonePreset[d.operationMode] ?? 'Room';
|
|
254
|
+
const cool = d.operationMode === 3 || d.operationMode === 4;
|
|
255
|
+
const mode = !power ? 'off' : cool ? 'cool' : 'heat';
|
|
256
|
+
const target = preset === 'Flow' ? (cool ? d.setCoolFlowTemperature : d.setHeatFlowTemperature) : d.setTemperature;
|
|
257
|
+
const action = !power ? 'off' : d.idle ? 'idle' : cool ? 'cooling' : 'heating';
|
|
258
|
+
return { mode, preset, action, target_temperature: target, current_temperature: d.roomTemperature };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Temperature range shown in Home Assistant for the current zone mode
|
|
262
|
+
static atwZoneRange(operationMode) {
|
|
263
|
+
switch (operationMode) {
|
|
264
|
+
case 1: return { minTemp: 25, maxTemp: 60 }; // heat flow
|
|
265
|
+
case 3: return { minTemp: 16, maxTemp: 30 }; // cool room
|
|
266
|
+
case 4: return { minTemp: 16, maxTemp: 30 }; // cool flow
|
|
267
|
+
default: return { minTemp: 10, maxTemp: 30 }; // heat room / curve
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
atwWaterHeater(c) {
|
|
272
|
+
const modes = ['heat_pump', ...(c.supportsEco ? ['eco'] : []), 'high_demand'];
|
|
273
|
+
const resetEco = c.supportsEco ? ', "EcoHotWater": false' : '';
|
|
274
|
+
return {
|
|
275
|
+
name: c.name,
|
|
276
|
+
modes,
|
|
277
|
+
mode_state_topic: this.stateTopic,
|
|
278
|
+
mode_state_template: '{{ value_json.tank.mode }}',
|
|
279
|
+
mode_command_topic: this.commandTopic,
|
|
280
|
+
mode_command_template: `{% if value == "high_demand" %}{"ForcedHotWaterMode": true}{% elif value == "eco" %}{"ForcedHotWaterMode": false, "EcoHotWater": true}{% else %}{"ForcedHotWaterMode": false${resetEco}}{% endif %}`,
|
|
281
|
+
temperature_state_topic: this.stateTopic,
|
|
282
|
+
temperature_state_template: '{{ value_json.tank.target_temperature }}',
|
|
283
|
+
temperature_command_topic: this.commandTopic,
|
|
284
|
+
temperature_command_template: '{"SetTankWaterTemperature": {{ value }}}',
|
|
285
|
+
current_temperature_topic: this.stateTopic,
|
|
286
|
+
current_temperature_template: '{{ value_json.tank.current_temperature }}',
|
|
287
|
+
min_temp: c.minTemp,
|
|
288
|
+
max_temp: c.maxTemp,
|
|
289
|
+
precision: c.tempStep === 0.5 ? 0.5 : 1,
|
|
290
|
+
temperature_unit: 'C'
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
static atwTankState(d) {
|
|
295
|
+
const mode = d.forcedHotWaterMode ? 'high_demand' : d.ecoHotWater ? 'eco' : 'heat_pump';
|
|
296
|
+
return { mode, target_temperature: d.setTankWaterTemperature, current_temperature: d.tankWaterTemperature };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------- ERV
|
|
300
|
+
ervFan(c) {
|
|
301
|
+
const presets = VentilationModes.filter((mode, i) => i === 0 || (i === 1 && c.supportsBypass) || (i === 2 && c.supportsAuto));
|
|
302
|
+
const entity = {
|
|
303
|
+
name: null,
|
|
304
|
+
state_topic: this.stateTopic,
|
|
305
|
+
state_value_template: '{{ "ON" if value_json.power else "OFF" }}',
|
|
306
|
+
command_topic: this.commandTopic,
|
|
307
|
+
command_template: '{"Power": {{ "true" if value == "ON" else "false" }}}',
|
|
308
|
+
preset_modes: presets,
|
|
309
|
+
preset_mode_state_topic: this.stateTopic,
|
|
310
|
+
preset_mode_value_template: '{{ value_json.ventilation_mode }}',
|
|
311
|
+
preset_mode_command_topic: this.commandTopic,
|
|
312
|
+
preset_mode_command_template: '{% set m = {"Lossnay": 0, "Bypass": 1, "Auto": 2} %}{"VentilationMode": {{ m[value] }}}'
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (c.numberOfFanSpeeds > 0) {
|
|
316
|
+
Object.assign(entity, {
|
|
317
|
+
speed_range_min: 1,
|
|
318
|
+
speed_range_max: c.numberOfFanSpeeds,
|
|
319
|
+
percentage_state_topic: this.stateTopic,
|
|
320
|
+
// 0 is automatic fan speed, reported as no percentage
|
|
321
|
+
percentage_value_template: '{{ value_json.fan_speed if value_json.fan_speed > 0 else "None" }}',
|
|
322
|
+
percentage_command_topic: this.commandTopic,
|
|
323
|
+
percentage_command_template: '{"SetFanSpeed": {{ value }}}'
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return entity;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
static ervState(d) {
|
|
331
|
+
return {
|
|
332
|
+
power: !!d.power,
|
|
333
|
+
fan_speed: d.setFanSpeed ?? 0,
|
|
334
|
+
ventilation_mode: VentilationModes[d.ventilationMode] ?? 'Lossnay'
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export default HaDiscovery;
|
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
|
@@ -12,12 +12,20 @@ class Mqtt extends EventEmitter {
|
|
|
12
12
|
const isV5 = protocolVersion === 5;
|
|
13
13
|
this.isV5 = isV5;
|
|
14
14
|
|
|
15
|
+
// Home Assistant discovery, availability is reported with a retained last will
|
|
16
|
+
this.haDiscovery = config.haDiscovery === true;
|
|
17
|
+
this.haPrefix = config.haPrefix || 'homeassistant';
|
|
18
|
+
this.availabilityTopic = `${config.prefix}/Availability`;
|
|
19
|
+
|
|
15
20
|
const options = {
|
|
16
21
|
clientId: config.clientId,
|
|
17
22
|
username: config.user,
|
|
18
23
|
password: config.passwd,
|
|
19
24
|
protocolVersion,
|
|
20
25
|
clean: !isV5,
|
|
26
|
+
...(this.haDiscovery ? {
|
|
27
|
+
will: { topic: this.availabilityTopic, payload: 'offline', qos: 1, retain: true }
|
|
28
|
+
} : {}),
|
|
21
29
|
...(isV5 ? {
|
|
22
30
|
properties: {
|
|
23
31
|
sessionExpiryInterval: 60 * 60,
|
|
@@ -36,6 +44,7 @@ class Mqtt extends EventEmitter {
|
|
|
36
44
|
.on('connect', async () => {
|
|
37
45
|
hasConnected = true;
|
|
38
46
|
this.emit('connected', `MQTT v${protocolVersion} connected.`);
|
|
47
|
+
if (this.haDiscovery) this.publishRetained(this.availabilityTopic, 'online').catch(() => { });
|
|
39
48
|
|
|
40
49
|
try {
|
|
41
50
|
await new Promise((resolve, reject) => {
|
|
@@ -89,6 +98,24 @@ class Mqtt extends EventEmitter {
|
|
|
89
98
|
});
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
publishRetained(fullTopic, payload) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
// + and # are wildcards, the client would never complete such a publish
|
|
104
|
+
if (/[+#]/.test(fullTopic)) {
|
|
105
|
+
return reject(new Error(`Invalid topic ${fullTopic}, the name or prefix must not contain + or #`));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.mqttClient.publish(fullTopic, payload, { qos: 1, retain: true }, (error) => {
|
|
109
|
+
if (error) {
|
|
110
|
+
if (this.config.logWarn) this.emit('warn', `MQTT Publish error: ${error.message}`);
|
|
111
|
+
return reject(error);
|
|
112
|
+
}
|
|
113
|
+
if (this.config.logDebug) this.emit('debug', `MQTT Publish retained Topic: ${fullTopic}, Payload: ${payload}`);
|
|
114
|
+
resolve();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
publish(topic, message) {
|
|
93
120
|
return new Promise((resolve, reject) => {
|
|
94
121
|
const fullTopic = `${this.config.prefix}/${topic}`;
|
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>"
|