diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 6b7e48887e..45c19cdfdb 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -936,15 +936,14 @@ export default class HomeAssistant extends Extension { discovery_payload: { name: null, state_topic: true, - state_value_template: '{{ value_json.fan_state }}', command_topic: true, - command_topic_postfix: 'fan_state', }, }; - const speed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode'); + const modeEmulatedSpeed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode'); + const nativeSpeed = (firstExpose as zhc.Fan).features.filter(isNumericExpose).find((e) => e.name === 'speed'); // istanbul ignore else - if (speed) { + if (modeEmulatedSpeed) { // A fan entity in Home Assistant 2021.3 and above may have a speed, // controlled by a percentage from 1 to 100, and/or non-speed presets. // The MQTT Fan integration allows the speed percentage to be mapped @@ -958,9 +957,9 @@ export default class HomeAssistant extends Extension { // ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is // always a valid speed. let speeds = ['off'].concat( - ['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => speed.values.includes(s)), + ['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => modeEmulatedSpeed.values.includes(s)), ); - let presets = ['on', 'auto', 'smart'].filter((s) => speed.values.includes(s)); + let presets = ['on', 'auto', 'smart'].filter((s) => modeEmulatedSpeed.values.includes(s)); if (['99432'].includes(definition!.model)) { // The Hampton Bay 99432 fan implements 4 speeds using the ZCL @@ -972,22 +971,39 @@ export default class HomeAssistant extends Extension { } const allowed = [...speeds, ...presets]; - speed.values.forEach((s) => assert(allowed.includes(s.toString()))); + modeEmulatedSpeed.values.forEach((s) => assert(allowed.includes(s.toString()))); const percentValues = speeds.map((s, i) => `'${s}':${i}`).join(', '); const percentCommands = speeds.map((s, i) => `${i}:'${s}'`).join(', '); const presetList = presets.map((s) => `'${s}'`).join(', '); discoveryEntry.discovery_payload.percentage_state_topic = true; - discoveryEntry.discovery_payload.percentage_command_topic = true; - discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`; + discoveryEntry.discovery_payload.percentage_command_topic = 'fan_mode'; + discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${modeEmulatedSpeed.property}] | default('None') }}`; discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`; discoveryEntry.discovery_payload.speed_range_min = 1; discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1; assert(presets.length !== 0); discoveryEntry.discovery_payload.preset_mode_state_topic = true; discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode'; - discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}] else 'None' | default('None') }}`; + discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${modeEmulatedSpeed.property} if value_json.${modeEmulatedSpeed.property} in [${presetList}] else 'None' | default('None') }}`; discoveryEntry.discovery_payload.preset_modes = presets; + + // Emulate state based on mode + discoveryEntry.discovery_payload.state_value_template = '{{ value_json.fan_state }}'; + discoveryEntry.discovery_payload.command_topic_postfix = 'fan_state'; + } else if (nativeSpeed) { + discoveryEntry.discovery_payload.percentage_state_topic = true; + discoveryEntry.discovery_payload.percentage_command_topic = 'fan_speed'; + discoveryEntry.discovery_payload.percentage_value_template = `{{ value_json.${nativeSpeed.property} | default('None') }}`; + discoveryEntry.discovery_payload.percentage_command_template = `{{ value | default('') }}`; + discoveryEntry.discovery_payload.speed_range_min = nativeSpeed.value_min; + discoveryEntry.discovery_payload.speed_range_max = nativeSpeed.value_max; + + // Speed-controlled fans generally have an onOff cluster, use that for state + discoveryEntry.discovery_payload.state_value_template = '{{ value_json.state }}'; + discoveryEntry.discovery_payload.command_topic_postfix = 'state'; + } else { + assert(false, 'Fans need to be either mode- or speed-controlled'); } discoveryEntries.push(discoveryEntry); @@ -1727,7 +1743,7 @@ export default class HomeAssistant extends Extension { } if (payload.percentage_command_topic) { - payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/fan_mode`; + payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.percentage_command_topic}`; } if (payload.preset_mode_state_topic) { diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 55e746d1d2..d1be23e465 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -853,6 +853,45 @@ describe('HomeAssistant extension', () => { ); }); + it('Should discover devices with speed-controlled fan', async () => { + let payload; + + payload = { + state_topic: 'zigbee2mqtt/fanbee', + state_value_template: '{{ value_json.state }}', + command_topic: 'zigbee2mqtt/fanbee/set/state', + percentage_state_topic: 'zigbee2mqtt/fanbee', + percentage_command_topic: 'zigbee2mqtt/fanbee/set/fan_speed', + percentage_value_template: "{{ value_json.fan_speed | default('None') }}", + percentage_command_template: "{{ value | default('') }}", + speed_range_min: 1, + speed_range_max: 254, + json_attributes_topic: 'zigbee2mqtt/fanbee', + name: null, + object_id: 'fanbee', + unique_id: '0x00124b00cfcf3298_fan_zigbee2mqtt', + origin: origin, + device: { + identifiers: ['zigbee2mqtt_0x00124b00cfcf3298'], + name: 'fanbee', + sw_version: null, + model: 'Fan with valve (FanBee)', + manufacturer: 'Lorenz Brun', + via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae', + }, + availability: [{topic: 'zigbee2mqtt/bridge/state'}], + }; + const idx = MQTT.publish.mock.calls.findIndex(c => c[0] === 'homeassistant/fan/0x00124b00cfcf3298/fan/config'); + expect(idx).not.toBe(-1); + expect(MQTT.publish).toHaveBeenNthCalledWith( + idx+1, + 'homeassistant/fan/0x00124b00cfcf3298/fan/config', + stringify(payload), + {retain: true, qos: 1}, + expect.any(Function), + ); + }); + it('Should discover thermostat devices', async () => { const payload = { action_template: diff --git a/test/stub/data.js b/test/stub/data.js index 66a9f80e49..43591d4c0c 100644 --- a/test/stub/data.js +++ b/test/stub/data.js @@ -200,6 +200,10 @@ function writeDefaultConfiguration() { '0x0017880104e45562': { friendly_name: 'heating_actuator', }, + '0x00124b00cfcf3298': { + friendly_name: 'fanbee', + retain: true, + }, }, groups: { 1: { diff --git a/test/stub/zigbeeHerdsman.js b/test/stub/zigbeeHerdsman.js index 5cdfe3c563..e6e09c940b 100644 --- a/test/stub/zigbeeHerdsman.js +++ b/test/stub/zigbeeHerdsman.js @@ -839,6 +839,16 @@ const devices = { null, custom_clusters, ), + fanbee: new Device( + 'Router', + '0x00124b00cfcf3298', + 18129, + 0xfff1, + [new Endpoint(8, [0, 3, 4, 5, 6, 8], []), new Endpoint(242, [], [33])], + true, + 'DC Source', + 'FanBee1', + ), }; const mock = {