diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 1e6e66baee0..6a66a2a110e 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -14,14 +14,16 @@ import json import homeassistant.core as ha from homeassistant.helpers.state import TrackStates import homeassistant.remote as rem +from homeassistant.util import template from homeassistant.bootstrap import ERROR_LOG_FILENAME from homeassistant.const import ( URL_API, URL_API_STATES, URL_API_EVENTS, URL_API_SERVICES, URL_API_STREAM, URL_API_EVENT_FORWARD, URL_API_STATES_ENTITY, URL_API_COMPONENTS, URL_API_CONFIG, URL_API_BOOTSTRAP, URL_API_ERROR_LOG, URL_API_LOG_OUT, - EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, + URL_API_TEMPLATE, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, HTTP_OK, HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, - HTTP_UNPROCESSABLE_ENTITY) + HTTP_UNPROCESSABLE_ENTITY, HTTP_HEADER_CONTENT_TYPE, + CONTENT_TYPE_TEXT_PLAIN) DOMAIN = 'api' @@ -91,6 +93,9 @@ def setup(hass, config): hass.http.register_path('POST', URL_API_LOG_OUT, _handle_post_api_log_out) + hass.http.register_path('POST', URL_API_TEMPLATE, + _handle_post_api_template) + return True @@ -359,6 +364,17 @@ def _handle_post_api_log_out(handler, path_match, data): handler.end_headers() +def _handle_post_api_template(handler, path_match, data): + """ Log user out. """ + template_string = data.get('template', '') + + handler.send_response(HTTP_OK) + handler.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN) + handler.end_headers() + handler.wfile.write( + template.render(handler.server.hass, template_string).encode('utf-8')) + + def _services_json(hass): """ Generate services data to JSONify. """ return [{"domain": key, "services": value} diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 2c5dbf82923..37a7a63c72b 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -6,7 +6,6 @@ MQTT component, using paho-mqtt. For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/ """ -import json import logging import os import socket @@ -33,7 +32,7 @@ DEFAULT_RETAIN = False SERVICE_PUBLISH = 'publish' EVENT_MQTT_MESSAGE_RECEIVED = 'MQTT_MESSAGE_RECEIVED' -REQUIREMENTS = ['paho-mqtt==1.1', 'jsonpath-rw==1.4.0'] +REQUIREMENTS = ['paho-mqtt==1.1'] CONF_BROKER = 'broker' CONF_PORT = 'port' @@ -136,33 +135,6 @@ def setup(hass, config): return True -# pylint: disable=too-few-public-methods -class _JsonFmtParser(object): - """ Implements a JSON parser on xpath. """ - def __init__(self, jsonpath): - import jsonpath_rw - self._expr = jsonpath_rw.parse(jsonpath) - - def __call__(self, payload): - match = self._expr.find(json.loads(payload)) - return match[0].value if len(match) > 0 else payload - - -# pylint: disable=too-few-public-methods -class FmtParser(object): - """ Wrapper for all supported formats. """ - def __init__(self, fmt): - self._parse = lambda x: x - if fmt: - if fmt.startswith('json:'): - self._parse = _JsonFmtParser(fmt[5:]) - - def __call__(self, payload): - return self._parse(payload) - - -# This is based on one of the paho-mqtt examples: -# http://git.eclipse.org/c/paho/org.eclipse.paho.mqtt.python.git/tree/examples/sub-class.py # pylint: disable=too-many-arguments class MQTT(object): """ Implements messaging service for MQTT. """ diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 9182f1dbf3a..245b9c6fde3 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -13,6 +13,7 @@ import os import homeassistant.bootstrap as bootstrap from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_per_platform +from homeassistant.util import template from homeassistant.const import CONF_NAME @@ -33,9 +34,16 @@ SERVICE_NOTIFY = "notify" _LOGGER = logging.getLogger(__name__) -def send_message(hass, message): +def send_message(hass, message, title=None): """ Send a notification message. """ - hass.services.call(DOMAIN, SERVICE_NOTIFY, {ATTR_MESSAGE: message}) + data = { + ATTR_MESSAGE: message + } + + if title is not None: + data[ATTR_TITLE] = title + + hass.services.call(DOMAIN, SERVICE_NOTIFY, data) def setup(hass, config): @@ -70,8 +78,10 @@ def setup(hass, config): if message is None: return - title = call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + title = template.render( + hass, call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)) target = call.data.get(ATTR_TARGET) + message = template.render(hass, message) notify_service.send_message(message, title=title, target=target) diff --git a/homeassistant/components/rollershutter/mqtt.py b/homeassistant/components/rollershutter/mqtt.py index f5eb5652516..2951d772be0 100644 --- a/homeassistant/components/rollershutter/mqtt.py +++ b/homeassistant/components/rollershutter/mqtt.py @@ -8,7 +8,10 @@ https://home-assistant.io/components/rollershutter.mqtt/ """ import logging import homeassistant.components.mqtt as mqtt +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.components.rollershutter import RollershutterDevice +from homeassistant.util import template + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] @@ -36,14 +39,14 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): config.get('payload_up', DEFAULT_PAYLOAD_UP), config.get('payload_down', DEFAULT_PAYLOAD_DOWN), config.get('payload_stop', DEFAULT_PAYLOAD_STOP), - config.get('state_format'))]) + config.get(CONF_VALUE_TEMPLATE))]) # pylint: disable=too-many-arguments, too-many-instance-attributes class MqttRollershutter(RollershutterDevice): """ Represents a rollershutter that can be controlled using MQTT. """ def __init__(self, hass, name, state_topic, command_topic, qos, - payload_up, payload_down, payload_stop, state_format): + payload_up, payload_down, payload_stop, value_template): self._state = None self._hass = hass self._name = name @@ -53,16 +56,17 @@ class MqttRollershutter(RollershutterDevice): self._payload_up = payload_up self._payload_down = payload_down self._payload_stop = payload_stop - self._parse = mqtt.FmtParser(state_format) if self._state_topic is None: return def message_received(topic, payload, qos): """ A new MQTT message has been received. """ - value = self._parse(payload) - if value.isnumeric() and 0 <= int(value) <= 100: - self._state = int(value) + if value_template is not None: + payload = template.render_with_possible_json_value( + hass, value_template, payload) + if payload.isnumeric() and 0 <= int(payload) <= 100: + self._state = int(payload) self.update_ha_state() else: _LOGGER.warning( diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 2bbed97e40c..8cf2569acd5 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -7,7 +7,9 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mqtt/ """ import logging +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.helpers.entity import Entity +from homeassistant.util import template import homeassistant.components.mqtt as mqtt _LOGGER = logging.getLogger(__name__) @@ -32,25 +34,27 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): config.get('state_topic'), config.get('qos', DEFAULT_QOS), config.get('unit_of_measurement'), - config.get('state_format'))]) + config.get(CONF_VALUE_TEMPLATE))]) # pylint: disable=too-many-arguments, too-many-instance-attributes class MqttSensor(Entity): """ Represents a sensor that can be updated using MQTT. """ def __init__(self, hass, name, state_topic, qos, unit_of_measurement, - state_format): + value_template): self._state = "-" self._hass = hass self._name = name self._state_topic = state_topic self._qos = qos self._unit_of_measurement = unit_of_measurement - self._parse = mqtt.FmtParser(state_format) def message_received(topic, payload, qos): """ A new MQTT message has been received. """ - self._state = self._parse(payload) + if value_template is not None: + payload = template.render_with_possible_json_value( + hass, value_template, payload) + self._state = payload self.update_ha_state() mqtt.subscribe(hass, self._state_topic, message_received, self._qos) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 7b973799eed..c27709cc522 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -8,7 +8,9 @@ https://home-assistant.io/components/switch.mqtt/ """ import logging import homeassistant.components.mqtt as mqtt +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.components.switch import SwitchDevice +from homeassistant.util import template _LOGGER = logging.getLogger(__name__) @@ -40,14 +42,14 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): config.get('payload_on', DEFAULT_PAYLOAD_ON), config.get('payload_off', DEFAULT_PAYLOAD_OFF), config.get('optimistic', DEFAULT_OPTIMISTIC), - config.get('state_format'))]) + config.get(CONF_VALUE_TEMPLATE))]) # pylint: disable=too-many-arguments, too-many-instance-attributes class MqttSwitch(SwitchDevice): """ Represents a switch that can be toggled using MQTT. """ def __init__(self, hass, name, state_topic, command_topic, qos, retain, - payload_on, payload_off, optimistic, state_format): + payload_on, payload_off, optimistic, value_template): self._state = False self._hass = hass self._name = name @@ -58,11 +60,12 @@ class MqttSwitch(SwitchDevice): self._payload_on = payload_on self._payload_off = payload_off self._optimistic = optimistic - self._parse = mqtt.FmtParser(state_format) def message_received(topic, payload, qos): """ A new MQTT message has been received. """ - payload = self._parse(payload) + if value_template is not None: + payload = template.render_with_possible_json_value( + hass, value_template, payload) if payload == self._payload_on: self._state = True self.update_ha_state() diff --git a/homeassistant/const.py b/homeassistant/const.py index 9a17622a7dd..287ae7998d2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,6 +25,8 @@ CONF_PASSWORD = "password" CONF_API_KEY = "api_key" CONF_ACCESS_TOKEN = "access_token" +CONF_VALUE_TEMPLATE = "value_template" + # #### EVENTS #### EVENT_HOMEASSISTANT_START = "homeassistant_start" EVENT_HOMEASSISTANT_STOP = "homeassistant_stop" @@ -165,6 +167,7 @@ URL_API_COMPONENTS = "/api/components" URL_API_BOOTSTRAP = "/api/bootstrap" URL_API_ERROR_LOG = "/api/error_log" URL_API_LOG_OUT = "/api/log_out" +URL_API_TEMPLATE = "/api/template" HTTP_OK = 200 HTTP_CREATED = 201 diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py new file mode 100644 index 00000000000..107532db776 --- /dev/null +++ b/homeassistant/util/template.py @@ -0,0 +1,86 @@ +""" +homeassistant.util.template +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Template utility methods for rendering strings with HA data. +""" +# pylint: disable=too-few-public-methods +import json +from jinja2.sandbox import ImmutableSandboxedEnvironment + + +def render_with_possible_json_value(hass, template, value): + """ Renders template with value exposed. + If valid JSON will expose value_json too. """ + variables = { + 'value': value + } + try: + variables['value_json'] = json.loads(value) + except ValueError: + pass + + return render(hass, template, variables) + + +def render(hass, template, variables=None, **kwargs): + """ Render given template. """ + if variables is not None: + kwargs.update(variables) + + return ENV.from_string(template, { + 'states': AllStates(hass) + }).render(kwargs) + + +class AllStates(object): + """ Class to expose all HA states as attributes. """ + def __init__(self, hass): + self._hass = hass + + def __getattr__(self, name): + return DomainStates(self._hass, name) + + def __iter__(self): + return iter(sorted(self._hass.states.all(), + key=lambda state: state.entity_id)) + + +class DomainStates(object): + """ Class to expose a specific HA domain as attributes. """ + + def __init__(self, hass, domain): + self._hass = hass + self._domain = domain + + def __getattr__(self, name): + return self._hass.states.get('{}.{}'.format(self._domain, name)) + + def __iter__(self): + return iter(sorted( + (state for state in self._hass.states.all() + if state.domain == self._domain), + key=lambda state: state.entity_id)) + + +def forgiving_round(value, precision=0): + """ Rounding method that accepts strings. """ + try: + return int(float(value)) if precision == 0 else round(float(value), + precision) + except ValueError: + # If value can't be converted to float + return value + + +def multiply(value, amount): + """ Converts to float and multiplies value. """ + try: + return float(value) * amount + except ValueError: + # If value can't be converted to float + return value + +ENV = ImmutableSandboxedEnvironment() +ENV.filters['round'] = forgiving_round +ENV.filters['multiply'] = multiply diff --git a/requirements_all.txt b/requirements_all.txt index 15bdeb60a3c..9587ba55d27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,6 +4,7 @@ pyyaml>=3.11,<4 pytz>=2015.4 pip>=7.0.0 vincenty==0.1.3 +jinja2>=2.8 # homeassistant.components.arduino PyMata==2.07a diff --git a/setup.py b/setup.py index 945138cd34b..2508b44e2fd 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,8 @@ REQUIRES = [ 'pyyaml>=3.11,<4', 'pytz>=2015.4', 'pip>=7.0.0', - 'vincenty==0.1.3' + 'vincenty==0.1.3', + 'jinja2>=2.8' ] setup( diff --git a/tests/components/notify/__init__.py b/tests/components/notify/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py new file mode 100644 index 00000000000..182a2a38610 --- /dev/null +++ b/tests/components/notify/test_demo.py @@ -0,0 +1,41 @@ +""" +tests.components.notify.test_demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Tests notify demo component +""" +import unittest + +import homeassistant.core as ha +import homeassistant.components.notify as notify +from homeassistant.components.notify import demo + + +class TestNotifyDemo(unittest.TestCase): + """ Test the demo notify. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.assertTrue(notify.setup(self.hass, { + 'notify': { + 'platform': 'demo' + } + })) + self.events = [] + + def record_event(event): + self.events.append(event) + + self.hass.bus.listen(demo.EVENT_NOTIFY, record_event) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_sending_templated_message(self): + self.hass.states.set('sensor.temperature', 10) + notify.send_message(self.hass, '{{ states.sensor.temperature.state }}', + '{{ states.sensor.temperature.name }}') + self.hass.pool.block_till_done() + last_event = self.events[-1] + self.assertEqual(last_event.data[notify.ATTR_TITLE], 'temperature') + self.assertEqual(last_event.data[notify.ATTR_MESSAGE], '10') diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index 0c17b95e212..ce98a9399a5 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -47,7 +47,7 @@ class TestSensorMQTT(unittest.TestCase): 'name': 'test', 'state_topic': 'test-topic', 'unit_of_measurement': 'fav unit', - 'state_format': 'json:val' + 'value_template': '{{ value_json.val }}' } })) @@ -56,4 +56,3 @@ class TestSensorMQTT(unittest.TestCase): state = self.hass.states.get('sensor.test') self.assertEqual('100', state.state) - diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 2cfe29c2910..b7c20e5ff94 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -90,7 +90,7 @@ class TestSensorMQTT(unittest.TestCase): 'command_topic': 'command-topic', 'payload_on': 'beer on', 'payload_off': 'beer off', - 'state_format': 'json:val' + 'value_template': '{{ value_json.val }}' } })) diff --git a/tests/components/test_api.py b/tests/components/test_api.py index ab76ed0e3db..cf530c1f301 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -326,6 +326,20 @@ class TestAPI(unittest.TestCase): self.assertEqual(1, len(test_value)) + def test_api_template(self): + """ Test template API. """ + hass.states.set('sensor.temperature', 10) + + req = requests.post( + _url(const.URL_API_TEMPLATE), + data=json.dumps({"template": + '{{ states.sensor.temperature.state }}'}), + headers=HA_HEADERS) + + hass.pool.block_till_done() + + self.assertEqual('10', req.text) + def test_api_event_forward(self): """ Test setting up event forwarding. """ diff --git a/tests/util/test_template.py b/tests/util/test_template.py new file mode 100644 index 00000000000..5c1dfff1f85 --- /dev/null +++ b/tests/util/test_template.py @@ -0,0 +1,86 @@ +""" +tests.test_util +~~~~~~~~~~~~~~~~~ + +Tests Home Assistant util methods. +""" +# pylint: disable=too-many-public-methods +import unittest +import homeassistant.core as ha + +from homeassistant.util import template + + +class TestUtilTemplate(unittest.TestCase): + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_referring_states_by_entity_id(self): + self.hass.states.set('test.object', 'happy') + self.assertEqual( + 'happy', + template.render(self.hass, '{{ states.test.object.state }}')) + + def test_iterating_all_states(self): + self.hass.states.set('test.object', 'happy') + self.hass.states.set('sensor.temperature', 10) + + self.assertEqual( + '10happy', + template.render( + self.hass, + '{% for state in states %}{{ state.state }}{% endfor %}')) + + def test_iterating_domain_states(self): + self.hass.states.set('test.object', 'happy') + self.hass.states.set('sensor.back_door', 'open') + self.hass.states.set('sensor.temperature', 10) + + self.assertEqual( + 'open10', + template.render( + self.hass, + '{% for state in states.sensor %}{{ state.state }}{% endfor %}')) + + def test_rounding_value(self): + self.hass.states.set('sensor.temperature', 12.78) + + self.assertEqual( + '12.8', + template.render( + self.hass, + '{{ states.sensor.temperature.state | round(1) }}')) + + def test_rounding_value2(self): + self.hass.states.set('sensor.temperature', 12.72) + + self.assertEqual( + '127', + template.render( + self.hass, + '{{ states.sensor.temperature.state | multiply(10) | round }}')) + + def test_passing_vars_as_keywords(self): + self.assertEqual( + '127', template.render(self.hass, '{{ hello }}', hello=127)) + + def test_passing_vars_as_vars(self): + self.assertEqual( + '127', template.render(self.hass, '{{ hello }}', {'hello': 127})) + + def test_render_with_possible_json_value_with_valid_json(self): + self.assertEqual( + 'world', + template.render_with_possible_json_value( + self.hass, '{{ value_json.hello }}', '{"hello": "world"}')) + + def test_render_with_possible_json_value_with_invalid_json(self): + self.assertEqual( + '', + template.render_with_possible_json_value( + self.hass, '{{ value_json }}', '{ I AM NOT JSON }'))