Command template support for MQTT fan (#48413)

* Command template support

* Fix typo in abbreviations

* Update homeassistant/components/mqtt/fan.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Fix initialize templates

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Jan Bouwhuis 2021-03-29 20:41:51 +02:00 committed by GitHub
parent 03b7b1305c
commit d8a4cf63f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 198 additions and 23 deletions

View File

@ -86,9 +86,11 @@ ABBREVIATIONS = {
"on_cmd_type": "on_command_type",
"opt": "optimistic",
"osc_cmd_t": "oscillation_command_topic",
"osc_cmd_tpl": "oscillation_command_template",
"osc_stat_t": "oscillation_state_topic",
"osc_val_tpl": "oscillation_value_template",
"pct_cmd_t": "percentage_command_topic",
"pct_cmd_tpl": "percentage_command_template",
"pct_stat_t": "percentage_state_topic",
"pct_val_tpl": "percentage_value_template",
"pl": "payload",
@ -128,6 +130,7 @@ ABBREVIATIONS = {
"pow_stat_t": "power_state_topic",
"pow_stat_tpl": "power_state_template",
"pr_mode_cmd_t": "preset_mode_command_topic",
"pr_mode_cmd_tpl": "preset_mode_command_template",
"pr_mode_stat_t": "preset_mode_state_topic",
"pr_mode_val_tpl": "preset_mode_value_template",
"pr_modes": "preset_modes",

View File

@ -6,6 +6,7 @@ import voluptuous as vol
from homeassistant.components import fan
from homeassistant.components.fan import (
ATTR_OSCILLATING,
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
ATTR_SPEED,
@ -51,14 +52,17 @@ from .debug_info import log_messages
from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
CONF_STATE_VALUE_TEMPLATE = "state_value_template"
CONF_COMMAND_TEMPLATE = "command_template"
CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic"
CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic"
CONF_PERCENTAGE_VALUE_TEMPLATE = "percentage_value_template"
CONF_PERCENTAGE_COMMAND_TEMPLATE = "percentage_command_template"
CONF_SPEED_RANGE_MIN = "speed_range_min"
CONF_SPEED_RANGE_MAX = "speed_range_max"
CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic"
CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic"
CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template"
CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template"
CONF_PRESET_MODES_LIST = "preset_modes"
CONF_SPEED_STATE_TOPIC = "speed_state_topic"
CONF_SPEED_COMMAND_TOPIC = "speed_command_topic"
@ -66,6 +70,7 @@ CONF_SPEED_VALUE_TEMPLATE = "speed_value_template"
CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic"
CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic"
CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template"
CONF_OSCILLATION_COMMAND_TEMPLATE = "oscillation_command_template"
CONF_PAYLOAD_OSCILLATION_ON = "payload_oscillation_on"
CONF_PAYLOAD_OSCILLATION_OFF = "payload_oscillation_off"
CONF_PAYLOAD_OFF_SPEED = "payload_off_speed"
@ -84,7 +89,6 @@ DEFAULT_SPEED_RANGE_MAX = 100
OSCILLATE_ON_PAYLOAD = "oscillate_on"
OSCILLATE_OFF_PAYLOAD = "oscillate_off"
OSCILLATION = "oscillation"
_LOGGER = logging.getLogger(__name__)
@ -122,10 +126,13 @@ PLATFORM_SCHEMA = vol.All(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): mqtt.valid_publish_topic,
vol.Optional(CONF_OSCILLATION_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_OSCILLATION_STATE_TOPIC): mqtt.valid_subscribe_topic,
vol.Optional(CONF_OSCILLATION_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_PERCENTAGE_COMMAND_TOPIC): mqtt.valid_publish_topic,
vol.Optional(CONF_PERCENTAGE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_PERCENTAGE_STATE_TOPIC): mqtt.valid_subscribe_topic,
vol.Optional(CONF_PERCENTAGE_VALUE_TEMPLATE): cv.template,
# CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together
@ -135,6 +142,7 @@ PLATFORM_SCHEMA = vol.All(
vol.Inclusive(
CONF_PRESET_MODES_LIST, "preset_modes", default=[]
): cv.ensure_list,
vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template,
vol.Optional(
@ -208,7 +216,8 @@ class MqttFan(MqttEntity, FanEntity):
self._topic = None
self._payload = None
self._templates = None
self._value_templates = None
self._command_templates = None
self._optimistic = None
self._optimistic_oscillation = None
self._optimistic_percentage = None
@ -243,13 +252,19 @@ class MqttFan(MqttEntity, FanEntity):
CONF_OSCILLATION_COMMAND_TOPIC,
)
}
self._templates = {
self._value_templates = {
CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE),
ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_VALUE_TEMPLATE),
ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_VALUE_TEMPLATE),
# ATTR_SPEED is deprecated in the schema, support will be removed after a quarter (2021.7)
ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE),
OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE),
ATTR_OSCILLATING: config.get(CONF_OSCILLATION_VALUE_TEMPLATE),
}
self._command_templates = {
CONF_STATE: config.get(CONF_COMMAND_TEMPLATE),
ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_COMMAND_TEMPLATE),
ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_COMMAND_TEMPLATE),
ATTR_OSCILLATING: config.get(CONF_OSCILLATION_COMMAND_TEMPLATE),
}
self._payload = {
"STATE_ON": config[CONF_PAYLOAD_ON],
@ -321,12 +336,13 @@ class MqttFan(MqttEntity, FanEntity):
if self._feature_preset_mode:
self._supported_features |= SUPPORT_PRESET_MODE
for key, tpl in list(self._templates.items()):
if tpl is None:
self._templates[key] = lambda value: value
else:
tpl.hass = self.hass
self._templates[key] = tpl.async_render_with_possible_json_value
for tpl_dict in [self._command_templates, self._value_templates]:
for key, tpl in tpl_dict.items():
if tpl is None:
tpl_dict[key] = lambda value: value
else:
tpl.hass = self.hass
tpl_dict[key] = tpl.async_render_with_possible_json_value
async def _subscribe_topics(self):
"""(Re)Subscribe to topics."""
@ -336,7 +352,7 @@ class MqttFan(MqttEntity, FanEntity):
@log_messages(self.hass, self.entity_id)
def state_received(msg):
"""Handle new received MQTT message."""
payload = self._templates[CONF_STATE](msg.payload)
payload = self._value_templates[CONF_STATE](msg.payload)
if payload == self._payload["STATE_ON"]:
self._state = True
elif payload == self._payload["STATE_OFF"]:
@ -354,7 +370,7 @@ class MqttFan(MqttEntity, FanEntity):
@log_messages(self.hass, self.entity_id)
def percentage_received(msg):
"""Handle new received MQTT message for the percentage."""
numeric_val_str = self._templates[ATTR_PERCENTAGE](msg.payload)
numeric_val_str = self._value_templates[ATTR_PERCENTAGE](msg.payload)
try:
percentage = ranged_value_to_percentage(
self._speed_range, int(numeric_val_str)
@ -388,7 +404,7 @@ class MqttFan(MqttEntity, FanEntity):
@log_messages(self.hass, self.entity_id)
def preset_mode_received(msg):
"""Handle new received MQTT message for preset mode."""
preset_mode = self._templates[ATTR_PRESET_MODE](msg.payload)
preset_mode = self._value_templates[ATTR_PRESET_MODE](msg.payload)
if preset_mode not in self.preset_modes:
_LOGGER.warning(
"'%s' received on topic %s is not a valid preset mode",
@ -417,7 +433,7 @@ class MqttFan(MqttEntity, FanEntity):
@log_messages(self.hass, self.entity_id)
def speed_received(msg):
"""Handle new received MQTT message for the speed."""
speed_payload = self._templates[ATTR_SPEED](msg.payload)
speed_payload = self._value_templates[ATTR_SPEED](msg.payload)
if speed_payload == self._payload["SPEED_LOW"]:
speed = SPEED_LOW
elif speed_payload == self._payload["SPEED_MEDIUM"]:
@ -461,7 +477,7 @@ class MqttFan(MqttEntity, FanEntity):
@log_messages(self.hass, self.entity_id)
def oscillation_received(msg):
"""Handle new received MQTT message for the oscillation."""
payload = self._templates[OSCILLATION](msg.payload)
payload = self._value_templates[ATTR_OSCILLATING](msg.payload)
if payload == self._payload["OSCILLATE_ON_PAYLOAD"]:
self._oscillation = True
elif payload == self._payload["OSCILLATE_OFF_PAYLOAD"]:
@ -559,10 +575,11 @@ class MqttFan(MqttEntity, FanEntity):
This method is a coroutine.
"""
mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_ON"])
mqtt.async_publish(
self.hass,
self._topic[CONF_COMMAND_TOPIC],
self._payload["STATE_ON"],
mqtt_payload,
self._config[CONF_QOS],
self._config[CONF_RETAIN],
)
@ -582,10 +599,11 @@ class MqttFan(MqttEntity, FanEntity):
This method is a coroutine.
"""
mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_OFF"])
mqtt.async_publish(
self.hass,
self._topic[CONF_COMMAND_TOPIC],
self._payload["STATE_OFF"],
mqtt_payload,
self._config[CONF_QOS],
self._config[CONF_RETAIN],
)
@ -601,6 +619,7 @@ class MqttFan(MqttEntity, FanEntity):
percentage_payload = int(
percentage_to_ranged_value(self._speed_range, percentage)
)
mqtt_payload = self._command_templates[ATTR_PERCENTAGE](percentage_payload)
if self._implemented_preset_mode:
if percentage:
await self.async_set_preset_mode(
@ -629,7 +648,7 @@ class MqttFan(MqttEntity, FanEntity):
mqtt.async_publish(
self.hass,
self._topic[CONF_PERCENTAGE_COMMAND_TOPIC],
percentage_payload,
mqtt_payload,
self._config[CONF_QOS],
self._config[CONF_RETAIN],
)
@ -653,7 +672,7 @@ class MqttFan(MqttEntity, FanEntity):
self._percentage = ordered_list_item_to_percentage(
self.speed_list, preset_mode
)
mqtt_payload = preset_mode
mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode)
mqtt.async_publish(
self.hass,
@ -705,15 +724,19 @@ class MqttFan(MqttEntity, FanEntity):
This method is a coroutine.
"""
if oscillating is False:
payload = self._payload["OSCILLATE_OFF_PAYLOAD"]
if oscillating:
mqtt_payload = self._command_templates[ATTR_OSCILLATING](
self._payload["OSCILLATE_ON_PAYLOAD"]
)
else:
payload = self._payload["OSCILLATE_ON_PAYLOAD"]
mqtt_payload = self._command_templates[ATTR_OSCILLATING](
self._payload["OSCILLATE_OFF_PAYLOAD"]
)
mqtt.async_publish(
self.hass,
self._topic[CONF_OSCILLATION_COMMAND_TOPIC],
payload,
mqtt_payload,
self._config[CONF_QOS],
self._config[CONF_RETAIN],
)

View File

@ -858,6 +858,155 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy(hass, mqtt_mock, c
await common.async_turn_on(hass, "fan.test", preset_mode="low")
async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog):
"""Test optimistic mode without state topic without legacy speed command topic."""
assert await async_setup_component(
hass,
fan.DOMAIN,
{
fan.DOMAIN: {
"platform": "mqtt",
"name": "test",
"command_topic": "command-topic",
"command_template": "state: {{ value }}",
"oscillation_command_topic": "oscillation-command-topic",
"oscillation_command_template": "oscillation: {{ value }}",
"percentage_command_topic": "percentage-command-topic",
"percentage_command_template": "percentage: {{ value }}",
"preset_mode_command_topic": "preset-mode-command-topic",
"preset_mode_command_template": "preset_mode: {{ value }}",
"preset_modes": [
"high",
"freaking-high",
"silent",
],
}
},
)
await hass.async_block_till_done()
state = hass.states.get("fan.test")
assert state.state == STATE_OFF
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_turn_on(hass, "fan.test")
mqtt_mock.async_publish.assert_called_once_with(
"command-topic", "state: ON", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.state == STATE_ON
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_turn_off(hass, "fan.test")
mqtt_mock.async_publish.assert_called_once_with(
"command-topic", "state: OFF", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.state == STATE_OFF
assert state.attributes.get(ATTR_ASSUMED_STATE)
with pytest.raises(MultipleInvalid):
await common.async_set_percentage(hass, "fan.test", -1)
with pytest.raises(MultipleInvalid):
await common.async_set_percentage(hass, "fan.test", 101)
await common.async_set_percentage(hass, "fan.test", 100)
mqtt_mock.async_publish.assert_any_call(
"percentage-command-topic", "percentage: 100", 0, False
)
mqtt_mock.async_publish.assert_any_call(
"preset-mode-command-topic", "preset_mode: freaking-high", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100
assert state.attributes.get(fan.ATTR_PRESET_MODE) == "freaking-high"
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_set_percentage(hass, "fan.test", 0)
mqtt_mock.async_publish.assert_called_once_with(
"percentage-command-topic", "percentage: 0", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0
assert state.attributes.get(fan.ATTR_SPEED) is None
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_set_preset_mode(hass, "fan.test", "low")
assert "not a valid preset mode" in caplog.text
caplog.clear()
await common.async_set_preset_mode(hass, "fan.test", "medium")
assert "not a valid preset mode" in caplog.text
caplog.clear()
await common.async_set_preset_mode(hass, "fan.test", "high")
mqtt_mock.async_publish.assert_called_once_with(
"preset-mode-command-topic", "preset_mode: high", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.attributes.get(fan.ATTR_PRESET_MODE) == "high"
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_set_preset_mode(hass, "fan.test", "freaking-high")
mqtt_mock.async_publish.assert_called_once_with(
"preset-mode-command-topic", "preset_mode: freaking-high", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.attributes.get(fan.ATTR_PRESET_MODE) == "freaking-high"
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_set_preset_mode(hass, "fan.test", "silent")
mqtt_mock.async_publish.assert_called_once_with(
"preset-mode-command-topic", "preset_mode: silent", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.attributes.get(fan.ATTR_PRESET_MODE) == "silent"
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_turn_on(hass, "fan.test", percentage=25)
assert mqtt_mock.async_publish.call_count == 3
mqtt_mock.async_publish.assert_any_call("command-topic", "state: ON", 0, False)
mqtt_mock.async_publish.assert_any_call(
"percentage-command-topic", "percentage: 25", 0, False
)
mqtt_mock.async_publish.assert_any_call(
"preset-mode-command-topic", "preset_mode: high", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.state == STATE_ON
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_turn_off(hass, "fan.test")
mqtt_mock.async_publish.assert_any_call("command-topic", "state: OFF", 0, False)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.state == STATE_OFF
assert state.attributes.get(ATTR_ASSUMED_STATE)
await common.async_turn_on(hass, "fan.test", preset_mode="high")
assert mqtt_mock.async_publish.call_count == 2
mqtt_mock.async_publish.assert_any_call("command-topic", "state: ON", 0, False)
mqtt_mock.async_publish.assert_any_call(
"preset-mode-command-topic", "preset_mode: high", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("fan.test")
assert state.state == STATE_ON
assert state.attributes.get(ATTR_ASSUMED_STATE)
with pytest.raises(NotValidPresetModeError):
await common.async_turn_on(hass, "fan.test", preset_mode="low")
async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic(
hass, mqtt_mock
):