homebridge-melcloud-control 4.11.2 → 4.12.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,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.12.0] - (23.09.2026)
29
+
30
+ ### Changes
31
+
32
+ - added: Home Assistant MQTT discovery (`HA Discovery` option in the MQTT section), using the built-in Home Assistant MQTT integration
33
+ - Air Conditioner as `climate` with modes, target and room temperature, action, fan speed, vertical and horizontal vane
34
+ - Heat Pump zones as `climate` with presets Room / Flow / Curve, hot water tank as `water_heater` with heat pump / eco / high demand modes
35
+ - Energy Recovery Ventilation as `fan` with speed and Lossnay / Bypass / Auto presets
36
+ - added: MQTT availability topic (`<prefix>/Availability`, retained `online` / `offline` last will) when HA Discovery is enabled
37
+ - readme update
38
+ - sample config update
39
+
40
+ ## [4.11.3] - (23.09.2026)
41
+
42
+ ### Changes
43
+
44
+ - readme update, added `mqtt.protocolVersion`
45
+ - sample config update, added `restFul.token` and `mqtt.protocolVersion`
46
+
28
47
  ## [4.11.2] - (23.09.2026)
29
48
 
30
49
  ### 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
@@ -365,6 +365,8 @@
365
365
  | `mqtt.port` | Here set the `Port` for MQTT Broker, default 1883. |
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
+ | `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`. |
368
370
  | `mqtt.auth{}` | MQTT authorization object. |
369
371
  | `mqtt.auth.enable` | Here enable authorization for MQTT Broker. |
370
372
  | `mqtt.auth.user` | Here set the MQTT Broker user. |
@@ -437,6 +439,13 @@
437
439
  ### MQTT Integration
438
440
 
439
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
+ * `HA State` - state for the entities above.
448
+ * `Availability` - `online`, `offline` (last will).
440
449
 
441
450
  | Direction | Topic | Message | Type |
442
451
  | --- | --- | --- | --- |
@@ -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.11.2",
4
+ "version": "4.12.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,32 @@ 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
+ await this.ha.updateState(HaDiscovery.ataState(state));
1330
+ } catch (error) {
1331
+ if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
1332
+ }
1333
+ }
1334
+
1308
1335
  //start
1309
1336
  async start() {
1310
1337
  try {
@@ -1622,6 +1649,23 @@ class DeviceAta extends EventEmitter {
1622
1649
  };
1623
1650
  this.accessory = obj;
1624
1651
 
1652
+ //home assistant, device capabilities (not the HomeKit mode mapping)
1653
+ this.haPublish({
1654
+ supportsHeat: supportsHeat1,
1655
+ supportsCool: supportsCool1,
1656
+ supportsDry: supportsDry && !hideDryModeControl,
1657
+ supportsAuto: supportsAuto1,
1658
+ supportsFanSpeed,
1659
+ numberOfFanSpeeds,
1660
+ supportsAutomaticFanSpeed,
1661
+ supportsAirDirectionFunction: supportsAirDirectionFunction && !hideVaneControls,
1662
+ supportsSwingFunction,
1663
+ supportsWideVane: supportsWideVane && !hideVaneControls,
1664
+ minTemp: minSetHeatRoomTemperature,
1665
+ maxTemp: maxSetHeatCoolDryAutoRoomTemperature,
1666
+ tempStep: temperatureStep
1667
+ }, { power, operationMode, inStandbyMode, roomTemperature, setTemperature, setFanSpeed, vaneVerticalDirection, vaneHorizontalDirection });
1668
+
1625
1669
  //update services
1626
1670
  for (const { type, value } of characteristics) {
1627
1671
  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,41 @@ 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
+ await this.ha.updateState(state);
1611
+ } catch (error) {
1612
+ if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error.message ?? error}`);
1613
+ }
1614
+ }
1615
+
1580
1616
  //start
1581
1617
  async start() {
1582
1618
  try {
@@ -2256,6 +2292,18 @@ class DeviceAtw extends EventEmitter {
2256
2292
  }
2257
2293
  this.accessory = obj;
2258
2294
 
2295
+ //home assistant, zones and tank from device capabilities (not the HomeKit hide zone setting)
2296
+ 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 }] : [])
2299
+ ];
2300
+ const haTank = deviceData.Device[supportHotWaterKey] ? { name: hotWaterName, forcedHotWaterMode, ecoHotWater, setTankWaterTemperature, tankWaterTemperature, minTemp: minSetTankTemperature, maxTemp: maxSetTankTemperature } : null;
2301
+ this.haPublish({
2302
+ supportsCool,
2303
+ supportsEco: accountTypeMelCloud,
2304
+ tempStep: temperatureIncrement === true || temperatureIncrement === 0.5 ? 0.5 : 1
2305
+ }, power, haZones, haTank);
2306
+
2259
2307
  //other sensors
2260
2308
  if (this.temperatureOutdoorSensor && supportsOutdoorTemperature) this.outdoorTemperatureSensorService?.updateCharacteristic(Characteristic.CurrentTemperature, outdoorTemperature);
2261
2309
  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,285 @@
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.supportsAirDirectionFunction) {
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
+ // ---------------------------------------------------------------- ATW
160
+ atwZoneClimate(zone, c) {
161
+ const z = `zone${zone}`;
162
+ const key = `Zone${zone}`;
163
+ const modes = ['off', 'heat', ...(c.supportsCool ? ['cool'] : [])];
164
+ const presets = ['Room', 'Flow', 'Curve'];
165
+
166
+ return {
167
+ name: c.name,
168
+ modes,
169
+ mode_state_topic: this.stateTopic,
170
+ mode_state_template: `{{ value_json.${z}.mode }}`,
171
+ mode_command_topic: this.commandTopic,
172
+ // Keep the current preset (room / flow / curve) when switching between heat and cool
173
+ 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 %}`,
174
+ preset_modes: presets,
175
+ preset_mode_state_topic: this.stateTopic,
176
+ preset_mode_value_template: `{{ value_json.${z}.preset }}`,
177
+ preset_mode_command_topic: this.commandTopic,
178
+ 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"]) }}}`,
179
+ temperature_state_topic: this.stateTopic,
180
+ temperature_state_template: `{{ value_json.${z}.target_temperature }}`,
181
+ temperature_command_topic: this.commandTopic,
182
+ // Flow presets set the flow temperature, the others the room temperature
183
+ 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 %}`,
184
+ current_temperature_topic: this.stateTopic,
185
+ current_temperature_template: `{{ value_json.${z}.current_temperature }}`,
186
+ action_topic: this.stateTopic,
187
+ action_template: `{{ value_json.${z}.action }}`,
188
+ power_command_topic: this.commandTopic,
189
+ power_command_template: '{"Power": {{ "true" if value == "ON" else "false" }}}',
190
+ min_temp: c.minTemp,
191
+ max_temp: c.maxTemp,
192
+ temp_step: c.tempStep,
193
+ precision: 0.1,
194
+ temperature_unit: 'C'
195
+ };
196
+ }
197
+
198
+ static atwZoneState(power, d) {
199
+ const preset = AtwZonePreset[d.operationMode] ?? 'Room';
200
+ const cool = d.operationMode === 3 || d.operationMode === 4;
201
+ const mode = !power ? 'off' : cool ? 'cool' : 'heat';
202
+ const target = preset === 'Flow' ? (cool ? d.setCoolFlowTemperature : d.setHeatFlowTemperature) : d.setTemperature;
203
+ const action = !power ? 'off' : d.idle ? 'idle' : cool ? 'cooling' : 'heating';
204
+ return { mode, preset, action, target_temperature: target, current_temperature: d.roomTemperature };
205
+ }
206
+
207
+ // Temperature range shown in Home Assistant for the current zone mode
208
+ static atwZoneRange(operationMode) {
209
+ switch (operationMode) {
210
+ case 1: return { minTemp: 25, maxTemp: 60 }; // heat flow
211
+ case 3: return { minTemp: 16, maxTemp: 30 }; // cool room
212
+ case 4: return { minTemp: 16, maxTemp: 30 }; // cool flow
213
+ default: return { minTemp: 10, maxTemp: 30 }; // heat room / curve
214
+ }
215
+ }
216
+
217
+ atwWaterHeater(c) {
218
+ const modes = ['heat_pump', ...(c.supportsEco ? ['eco'] : []), 'high_demand'];
219
+ const resetEco = c.supportsEco ? ', "EcoHotWater": false' : '';
220
+ return {
221
+ name: c.name,
222
+ modes,
223
+ mode_state_topic: this.stateTopic,
224
+ mode_state_template: '{{ value_json.tank.mode }}',
225
+ mode_command_topic: this.commandTopic,
226
+ mode_command_template: `{% if value == "high_demand" %}{"ForcedHotWaterMode": true}{% elif value == "eco" %}{"ForcedHotWaterMode": false, "EcoHotWater": true}{% else %}{"ForcedHotWaterMode": false${resetEco}}{% endif %}`,
227
+ temperature_state_topic: this.stateTopic,
228
+ temperature_state_template: '{{ value_json.tank.target_temperature }}',
229
+ temperature_command_topic: this.commandTopic,
230
+ temperature_command_template: '{"SetTankWaterTemperature": {{ value }}}',
231
+ current_temperature_topic: this.stateTopic,
232
+ current_temperature_template: '{{ value_json.tank.current_temperature }}',
233
+ min_temp: c.minTemp,
234
+ max_temp: c.maxTemp,
235
+ precision: c.tempStep === 0.5 ? 0.5 : 1,
236
+ temperature_unit: 'C'
237
+ };
238
+ }
239
+
240
+ static atwTankState(d) {
241
+ const mode = d.forcedHotWaterMode ? 'high_demand' : d.ecoHotWater ? 'eco' : 'heat_pump';
242
+ return { mode, target_temperature: d.setTankWaterTemperature, current_temperature: d.tankWaterTemperature };
243
+ }
244
+
245
+ // ---------------------------------------------------------------- ERV
246
+ ervFan(c) {
247
+ const presets = VentilationModes.filter((mode, i) => i === 0 || (i === 1 && c.supportsBypass) || (i === 2 && c.supportsAuto));
248
+ const entity = {
249
+ name: null,
250
+ state_topic: this.stateTopic,
251
+ state_value_template: '{{ "ON" if value_json.power else "OFF" }}',
252
+ command_topic: this.commandTopic,
253
+ command_template: '{"Power": {{ "true" if value == "ON" else "false" }}}',
254
+ preset_modes: presets,
255
+ preset_mode_state_topic: this.stateTopic,
256
+ preset_mode_value_template: '{{ value_json.ventilation_mode }}',
257
+ preset_mode_command_topic: this.commandTopic,
258
+ preset_mode_command_template: '{% set m = {"Lossnay": 0, "Bypass": 1, "Auto": 2} %}{"VentilationMode": {{ m[value] }}}'
259
+ };
260
+
261
+ if (c.numberOfFanSpeeds > 0) {
262
+ Object.assign(entity, {
263
+ speed_range_min: 1,
264
+ speed_range_max: c.numberOfFanSpeeds,
265
+ percentage_state_topic: this.stateTopic,
266
+ // 0 is automatic fan speed, reported as no percentage
267
+ percentage_value_template: '{{ value_json.fan_speed if value_json.fan_speed > 0 else "None" }}',
268
+ percentage_command_topic: this.commandTopic,
269
+ percentage_command_template: '{"SetFanSpeed": {{ value }}}'
270
+ });
271
+ }
272
+
273
+ return entity;
274
+ }
275
+
276
+ static ervState(d) {
277
+ return {
278
+ power: !!d.power,
279
+ fan_speed: d.setFanSpeed ?? 0,
280
+ ventilation_mode: VentilationModes[d.ventilationMode] ?? 'Lossnay'
281
+ };
282
+ }
283
+ }
284
+
285
+ export default HaDiscovery;
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}`;