From d4e8b831a03f4ce90f6f33194117ddcc645bde48 Mon Sep 17 00:00:00 2001 From: Antoine Bertin Date: Sun, 6 Nov 2016 18:09:01 +0100 Subject: [PATCH] Add mqtt_template light component (#4233) * Add mqtt_template component * Docstring copy paste party on overriden methods * pep8 E501 :star2: * Add missing docstrings on unittests --- .../components/light/mqtt_template.py | 252 ++++++++++++ tests/components/light/test_mqtt_template.py | 373 ++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100755 homeassistant/components/light/mqtt_template.py create mode 100755 tests/components/light/test_mqtt_template.py diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py new file mode 100755 index 00000000000..4566a383645 --- /dev/null +++ b/homeassistant/components/light/mqtt_template.py @@ -0,0 +1,252 @@ +""" +Support for MQTT Template lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.mqtt_template/ +""" + +import logging +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_TRANSITION, PLATFORM_SCHEMA, + ATTR_FLASH, SUPPORT_BRIGHTNESS, SUPPORT_FLASH, + SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light) +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'mqtt_template' + +DEPENDENCIES = ['mqtt'] + +DEFAULT_NAME = 'MQTT Template Light' +DEFAULT_OPTIMISTIC = False + +CONF_COMMAND_ON_TEMPLATE = 'command_on_template' +CONF_COMMAND_OFF_TEMPLATE = 'command_off_template' +CONF_STATE_TEMPLATE = 'state_template' +CONF_BRIGHTNESS_TEMPLATE = 'brightness_template' +CONF_RED_TEMPLATE = 'red_template' +CONF_GREEN_TEMPLATE = 'green_template' +CONF_BLUE_TEMPLATE = 'blue_template' + +SUPPORT_MQTT_TEMPLATE = (SUPPORT_BRIGHTNESS | SUPPORT_FLASH | + SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Required(CONF_COMMAND_ON_TEMPLATE): cv.template, + vol.Required(CONF_COMMAND_OFF_TEMPLATE): cv.template, + vol.Optional(CONF_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template, + vol.Optional(CONF_RED_TEMPLATE): cv.template, + vol.Optional(CONF_GREEN_TEMPLATE): cv.template, + vol.Optional(CONF_BLUE_TEMPLATE): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_QOS, default=mqtt.DEFAULT_QOS): + vol.All(vol.Coerce(int), vol.In([0, 1, 2])), + vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup a MQTT Template light.""" + add_devices([MqttTemplate( + hass, + config.get(CONF_NAME), + { + key: config.get(key) for key in ( + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC + ) + }, + { + key: config.get(key) for key in ( + CONF_COMMAND_ON_TEMPLATE, + CONF_COMMAND_OFF_TEMPLATE, + CONF_STATE_TEMPLATE, + CONF_BRIGHTNESS_TEMPLATE, + CONF_RED_TEMPLATE, + CONF_GREEN_TEMPLATE, + CONF_BLUE_TEMPLATE + ) + }, + config.get(CONF_OPTIMISTIC), + config.get(CONF_QOS), + config.get(CONF_RETAIN) + )]) + + +class MqttTemplate(Light): + """Representation of a MQTT Template light.""" + + def __init__(self, hass, name, topics, templates, optimistic, qos, retain): + """Initialize MQTT Template light.""" + self._hass = hass + self._name = name + self._topics = topics + self._templates = templates + for tpl in self._templates.values(): + if tpl is not None: + tpl.hass = hass + self._optimistic = optimistic or topics[CONF_STATE_TOPIC] is None \ + or templates[CONF_STATE_TEMPLATE] is None + self._qos = qos + self._retain = retain + + # features + self._state = False + if self._templates[CONF_BRIGHTNESS_TEMPLATE] is not None: + self._brightness = 255 + else: + self._brightness = None + + if (self._templates[CONF_RED_TEMPLATE] is not None and + self._templates[CONF_GREEN_TEMPLATE] is not None and + self._templates[CONF_BLUE_TEMPLATE] is not None): + self._rgb = [0, 0, 0] + else: + self._rgb = None + + def state_received(topic, payload, qos): + """A new MQTT message has been received.""" + # read state + state = self._templates[CONF_STATE_TEMPLATE].\ + render_with_possible_json_value(payload) + if state == STATE_ON: + self._state = True + elif state == STATE_OFF: + self._state = False + else: + _LOGGER.warning('Invalid state value received') + + # read brightness + if self._brightness is not None: + try: + self._brightness = int( + self._templates[CONF_BRIGHTNESS_TEMPLATE]. + render_with_possible_json_value(payload) + ) + except ValueError: + _LOGGER.warning('Invalid brightness value received') + + # read color + if self._rgb is not None: + try: + self._rgb[0] = int( + self._templates[CONF_RED_TEMPLATE]. + render_with_possible_json_value(payload)) + self._rgb[1] = int( + self._templates[CONF_GREEN_TEMPLATE]. + render_with_possible_json_value(payload)) + self._rgb[2] = int( + self._templates[CONF_BLUE_TEMPLATE]. + render_with_possible_json_value(payload)) + except ValueError: + _LOGGER.warning('Invalid color value received') + + self.update_ha_state() + + if self._topics[CONF_STATE_TOPIC] is not None: + mqtt.subscribe(self._hass, self._topics[CONF_STATE_TOPIC], + state_received, self._qos) + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def rgb_color(self): + """Return the RGB color value [int, int, int].""" + return self._rgb + + @property + def should_poll(self): + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def is_on(self): + """Return True if entity is on.""" + return self._state + + @property + def assumed_state(self): + """Return True if unable to access real state of the entity.""" + return self._optimistic + + def turn_on(self, **kwargs): + """Turn the entity on.""" + # state + values = {'state': True} + if self._optimistic: + self._state = True + + # brightness + if ATTR_BRIGHTNESS in kwargs: + values['brightness'] = int(kwargs[ATTR_BRIGHTNESS]) + + if self._optimistic: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + # color + if ATTR_RGB_COLOR in kwargs: + values['red'] = kwargs[ATTR_RGB_COLOR][0] + values['green'] = kwargs[ATTR_RGB_COLOR][1] + values['blue'] = kwargs[ATTR_RGB_COLOR][2] + + if self._optimistic: + self._rgb = kwargs[ATTR_RGB_COLOR] + + # flash + if ATTR_FLASH in kwargs: + values['flash'] = kwargs.get(ATTR_FLASH) + + # transition + if ATTR_TRANSITION in kwargs: + values['transition'] = kwargs[ATTR_TRANSITION] + + mqtt.publish( + self._hass, self._topics[CONF_COMMAND_TOPIC], + self._templates[CONF_COMMAND_ON_TEMPLATE].render(**values), + self._qos, self._retain + ) + + if self._optimistic: + self.update_ha_state() + + def turn_off(self, **kwargs): + """Turn the entity off.""" + # state + values = {'state': False} + if self._optimistic: + self._state = False + + # transition + if ATTR_TRANSITION in kwargs: + values['transition'] = kwargs[ATTR_TRANSITION] + + mqtt.publish( + self._hass, self._topics[CONF_COMMAND_TOPIC], + self._templates[CONF_COMMAND_OFF_TEMPLATE].render(**values), + self._qos, self._retain + ) + + if self._optimistic: + self.update_ha_state() diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py new file mode 100755 index 00000000000..94cd2a0a19f --- /dev/null +++ b/tests/components/light/test_mqtt_template.py @@ -0,0 +1,373 @@ +"""The tests for the MQTT Template light platform. + +Configuration example with all features: + +light: + platform: mqtt_template + name: mqtt_template_light_1 + state_topic: 'home/rgb1' + command_topic: 'home/rgb1/set' + command_on_template: > + on,{{ brightness|d }},{{ red|d }}-{{ green|d }}-{{ blue|d }} + command_off_template: 'off' + state_template: '{{ value.split(",")[0] }}' + brightness_template: '{{ value.split(",")[1] }}' + red_template: '{{ value.split(",")[2].split("-")[0] }}' + green_template: '{{ value.split(",")[2].split("-")[1] }}' + blue_template: '{{ value.split(",")[2].split("-")[2] }}' + +If your light doesn't support brightness feature, omit `brightness_template`. + +If your light doesn't support rgb feature, omit `(red|green|blue)_template`. +""" +import unittest + +from homeassistant.bootstrap import setup_component +from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE +import homeassistant.components.light as light +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, + assert_setup_component) + + +class TestLightMQTTTemplate(unittest.TestCase): + """Test the MQTT Template light.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_fails(self): \ + # pylint: disable=invalid-name + """Test that setup fails with missing required configuration items.""" + self.hass.config.components = ['mqtt'] + with assert_setup_component(0): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + } + }) + self.assertIsNone(self.hass.states.get('light.test')) + + def test_state_change_via_topic(self): \ + # pylint: disable=invalid-name + """Test state change via topic.""" + self.hass.config.components = ['mqtt'] + with assert_setup_component(1): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE)) + + fire_mqtt_message(self.hass, 'test_light_rgb', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + + def test_state_brightness_color_change_via_topic(self): \ + # pylint: disable=invalid-name + """Test state, brightness and color change via topic.""" + self.hass.config.components = ['mqtt'] + with assert_setup_component(1): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}', + 'brightness_template': '{{ value.split(",")[1] }}', + 'red_template': '{{ value.split(",")[2].' + 'split("-")[0] }}', + 'green_template': '{{ value.split(",")[2].' + 'split("-")[1] }}', + 'blue_template': '{{ value.split(",")[2].' + 'split("-")[2] }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,255-255-255') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + + # turn the light off + fire_mqtt_message(self.hass, 'test_light_rgb', 'off') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # lower the brightness + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,100') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(100, light_state.attributes['brightness']) + + # change the color + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,41-42-43') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual([41, 42, 43], light_state.attributes.get('rgb_color')) + + def test_optimistic(self): \ + # pylint: disable=invalid-name + """Test optimistic mode.""" + self.hass.config.components = ['mqtt'] + with assert_setup_component(1): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'qos': 2 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light + light.turn_on(self.hass, 'light.test') + self.hass.block_till_done() + + self.assertEqual(('test_light_rgb/set', 'on,,--', 2, False), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + # turn the light off + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.assertEqual(('test_light_rgb/set', 'off', 2, False), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # turn on the light with brightness and color + light.turn_on(self.hass, 'light.test', brightness=50, + rgb_color=[75, 75, 75]) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(2, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + + # check the payload + payload = self.mock_publish.mock_calls[-1][1][1] + self.assertEqual('on,50,75-75-75', payload) + + # check the state + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((75, 75, 75), state.attributes['rgb_color']) + self.assertEqual(50, state.attributes['brightness']) + + def test_flash(self): \ + # pylint: disable=invalid-name + """Test flash.""" + self.hass.config.components = ['mqtt'] + with assert_setup_component(1): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ flash }}', + 'command_off_template': 'off', + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # short flash + light.turn_on(self.hass, 'light.test', flash='short') + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + + # check the payload + payload = self.mock_publish.mock_calls[-1][1][1] + self.assertEqual('on,short', payload) + + # long flash + light.turn_on(self.hass, 'light.test', flash='long') + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + + # check the payload + payload = self.mock_publish.mock_calls[-1][1][1] + self.assertEqual('on,long', payload) + + def test_transition(self): + """Test for transition time being sent when included.""" + self.hass.config.components = ['mqtt'] + with assert_setup_component(1): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # transition on + light.turn_on(self.hass, 'light.test', transition=10) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + + # check the payload + payload = self.mock_publish.mock_calls[-1][1][1] + self.assertEqual('on,10', payload) + + # transition off + light.turn_off(self.hass, 'light.test', transition=4) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + + # check the payload + payload = self.mock_publish.mock_calls[-1][1][1] + self.assertEqual('off,4', payload) + + def test_invalid_values(self): \ + # pylint: disable=invalid-name + """Test that invalid values are ignored.""" + self.hass.config.components = ['mqtt'] + with assert_setup_component(1): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}', + 'brightness_template': '{{ value.split(",")[1] }}', + 'red_template': '{{ value.split(",")[2].' + 'split("-")[0] }}', + 'green_template': '{{ value.split(",")[2].' + 'split("-")[1] }}', + 'blue_template': '{{ value.split(",")[2].' + 'split("-")[2] }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,255-255-255') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + + # bad state value + fire_mqtt_message(self.hass, 'test_light_rgb', 'offf') + self.hass.block_till_done() + + # state should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + # bad brightness values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,off,255-255-255') + self.hass.block_till_done() + + # brightness should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(255, state.attributes.get('brightness')) + + # bad color values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c') + self.hass.block_till_done() + + # color should not have changed + state = self.hass.states.get('light.test') + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color'))