From 65d6862e9734701c97dfed2df2cc538601ab6d97 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 16:53:15 +0200 Subject: [PATCH 01/10] Add webhook + IFTTT example --- homeassistant/components/ifttt.py | 74 ---------- .../components/ifttt/.translations/en.json | 14 ++ homeassistant/components/ifttt/__init__.py | 129 ++++++++++++++++++ homeassistant/components/ifttt/strings.json | 14 ++ homeassistant/components/webhook.py | 83 +++++++++++ homeassistant/config_entries.py | 1 + 6 files changed, 241 insertions(+), 74 deletions(-) delete mode 100644 homeassistant/components/ifttt.py create mode 100644 homeassistant/components/ifttt/.translations/en.json create mode 100644 homeassistant/components/ifttt/__init__.py create mode 100644 homeassistant/components/ifttt/strings.json create mode 100644 homeassistant/components/webhook.py diff --git a/homeassistant/components/ifttt.py b/homeassistant/components/ifttt.py deleted file mode 100644 index 9497282ab..000000000 --- a/homeassistant/components/ifttt.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Support to trigger Maker IFTTT recipes. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/ifttt/ -""" -import logging - -import requests -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pyfttt==0.3'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_EVENT = 'event' -ATTR_VALUE1 = 'value1' -ATTR_VALUE2 = 'value2' -ATTR_VALUE3 = 'value3' - -CONF_KEY = 'key' - -DOMAIN = 'ifttt' - -SERVICE_TRIGGER = 'trigger' - -SERVICE_TRIGGER_SCHEMA = vol.Schema({ - vol.Required(ATTR_EVENT): cv.string, - vol.Optional(ATTR_VALUE1): cv.string, - vol.Optional(ATTR_VALUE2): cv.string, - vol.Optional(ATTR_VALUE3): cv.string, -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_KEY): cv.string, - }), -}, extra=vol.ALLOW_EXTRA) - - -def trigger(hass, event, value1=None, value2=None, value3=None): - """Trigger a Maker IFTTT recipe.""" - data = { - ATTR_EVENT: event, - ATTR_VALUE1: value1, - ATTR_VALUE2: value2, - ATTR_VALUE3: value3, - } - hass.services.call(DOMAIN, SERVICE_TRIGGER, data) - - -def setup(hass, config): - """Set up the IFTTT service component.""" - key = config[DOMAIN][CONF_KEY] - - def trigger_service(call): - """Handle IFTTT trigger service calls.""" - event = call.data[ATTR_EVENT] - value1 = call.data.get(ATTR_VALUE1) - value2 = call.data.get(ATTR_VALUE2) - value3 = call.data.get(ATTR_VALUE3) - - try: - import pyfttt - pyfttt.send_event(key, event, value1, value2, value3) - except requests.exceptions.RequestException: - _LOGGER.exception("Error communicating with IFTTT") - - hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service, - schema=SERVICE_TRIGGER_SCHEMA) - - return True diff --git a/homeassistant/components/ifttt/.translations/en.json b/homeassistant/components/ifttt/.translations/en.json new file mode 100644 index 000000000..690fe52dd --- /dev/null +++ b/homeassistant/components/ifttt/.translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "step": { + "user": { + "description": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data.", + "title": "Set up the Webhook Applet" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py new file mode 100644 index 000000000..358f266a3 --- /dev/null +++ b/homeassistant/components/ifttt/__init__.py @@ -0,0 +1,129 @@ +""" +Support to trigger Maker IFTTT recipes. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ifttt/ +""" +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries + +REQUIREMENTS = ['pyfttt==0.3'] +DEPENDENCIES = ['webhook'] + +_LOGGER = logging.getLogger(__name__) + +EVENT_RECEIVED = 'ifttt_webhook_received' + +ATTR_EVENT = 'event' +ATTR_VALUE1 = 'value1' +ATTR_VALUE2 = 'value2' +ATTR_VALUE3 = 'value3' + +CONF_KEY = 'key' +CONF_WEBHOOK_ID = 'webhook_id' + +DOMAIN = 'ifttt' + +SERVICE_TRIGGER = 'trigger' + +SERVICE_TRIGGER_SCHEMA = vol.Schema({ + vol.Required(ATTR_EVENT): cv.string, + vol.Optional(ATTR_VALUE1): cv.string, + vol.Optional(ATTR_VALUE2): cv.string, + vol.Optional(ATTR_VALUE3): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def trigger(hass, event, value1=None, value2=None, value3=None): + """Trigger a Maker IFTTT recipe.""" + data = { + ATTR_EVENT: event, + ATTR_VALUE1: value1, + ATTR_VALUE2: value2, + ATTR_VALUE3: value3, + } + hass.services.call(DOMAIN, SERVICE_TRIGGER, data) + + +def async_setup(hass, config): + """Set up the IFTTT service component.""" + key = config[DOMAIN][CONF_KEY] + + def trigger_service(call): + """Handle IFTTT trigger service calls.""" + event = call.data[ATTR_EVENT] + value1 = call.data.get(ATTR_VALUE1) + value2 = call.data.get(ATTR_VALUE2) + value3 = call.data.get(ATTR_VALUE3) + + try: + import pyfttt + pyfttt.send_event(key, event, value1, value2, value3) + except requests.exceptions.RequestException: + _LOGGER.exception("Error communicating with IFTTT") + + hass.services.async_register(DOMAIN, SERVICE_TRIGGER, trigger_service, + schema=SERVICE_TRIGGER_SCHEMA) + + return True + + +async def handle_webhook(hass, webhook_id, data): + """Handle webhook callback.""" + data['webhook_id'] = webhook_id + hass.bus.async_fire(EVENT_RECEIVED, data) + + +def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + entry.data['webhook_id'], handle_webhook) + + +@config_entries.HANDLERS.register(DOMAIN) +class ConfigFlow(config_entries.ConfigFlow): + """Handle an IFTTT config flow.""" + + webhook_id = None + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + if self.webhook_id is None: + self.webhook_id = \ + self.hass.components.webhook.async_generate_webhook_id() + + if not user_input: + webhook_url = \ + self.hass.components.webhook.\ + async_generate_webhook_url(self.webhook_id) + + return self.async_show_form( + step_id='user', + description_placeholders={ + 'applet_url': 'https://ifttt.com/maker_webhooks', + 'webhook_url': webhook_url, + 'docs_url': + 'https://www.home-assistant.io/components/ifttt/' + }, + ) + + return self.async_create_entry( + title='IFTTT Webhook', + data={ + CONF_WEBHOOK_ID: self.webhook_id + }, + ) diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json new file mode 100644 index 000000000..7edd4a251 --- /dev/null +++ b/homeassistant/components/ifttt/strings.json @@ -0,0 +1,14 @@ +{ + "config": { + "title": "IFTTT", + "step": { + "user": { + "title": "Set up the IFTTT Webhook Applet", + "description": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + } + } +} diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py new file mode 100644 index 000000000..5492e8b94 --- /dev/null +++ b/homeassistant/components/webhook.py @@ -0,0 +1,83 @@ +"""Webhooks for Home Assistant. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/webhook/ +""" +import logging + +from aiohttp.web import Response + +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.auth.util import generate_secret +from homeassistant.components.http.view import HomeAssistantView + +DOMAIN = 'webhook' +DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) + + +@callback +@bind_hass +def async_register(hass, webhook_id, handler): + """Register a webhook.""" + handlers = hass.data.setdefault(webhook_id, {}) + + if webhook_id in handlers: + raise ValueError('Handler is already defined!') + + handlers[webhook_id] = handler + + +@callback +def async_generate_webhook_id(): + """Generate a webhook_id.""" + return generate_secret(entropy=32) + + +@callback +@bind_hass +def async_generate_webhook_url(hass, webhook_id): + """Generate a webhook_id.""" + return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id) + + +async def async_setup(hass, config): + """Initialize the webhook component.""" + hass.http.register_view(WebhookView) + return True + + +class WebhookView(HomeAssistantView): + + url = "/api/webhook/{webhook_id}" + name = "api:webhook" + requires_auth = False + + async def post(self, request, webhook_id): + """Handle webhook call.""" + hass = request.app['hass'] + handlers = hass.data.setdefault(webhook_id, {}) + handler = handlers.get(webhook_id) + + # Always respond successfully to not give away if a hook exists or not. + if handler is None: + _LOGGER.warning( + 'Received message for unregistered webhook %s', webhook_id) + return Response(status=200) + + try: + data = await request.json() + except ValueError: + _LOGGER.warning( + 'Received webhook %s with invalid JSON', webhook_id) + return Response(status=200) + + try: + response = await handler(hass, webhook_id, data) + if response is None: + response = Response(status=200) + return response + except Exception: # pylint: disable-bare-except + _LOGGER.exception("Error processing webhook %s", webhook_id) + return Response(status=200) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 83bf9d22d..fcc8a1f92 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -141,6 +141,7 @@ FLOWS = [ 'deconz', 'homematicip_cloud', 'hue', + 'ifttt', 'ios', 'mqtt', 'nest', From 687ca4bfb67f55772ee62f4074c6edb6a09e9e69 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 17:40:13 +0200 Subject: [PATCH 02/10] Abort if not externally accessible --- homeassistant/components/auth/indieauth.py | 18 ++--------------- homeassistant/components/ifttt/__init__.py | 11 +++++++++++ homeassistant/components/ifttt/strings.json | 3 ++- homeassistant/util/network.py | 22 +++++++++++++++++++++ 4 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 homeassistant/util/network.py diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index bcf73258f..841516c59 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,24 +1,12 @@ """Helpers to resolve client ID/secret.""" import asyncio from html.parser import HTMLParser -from ipaddress import ip_address, ip_network from urllib.parse import urlparse, urljoin import aiohttp from aiohttp.client_exceptions import ClientError -# IP addresses of loopback interfaces -ALLOWED_IPS = ( - ip_address('127.0.0.1'), - ip_address('::1'), -) - -# RFC1918 - Address allocation for Private Internets -ALLOWED_NETWORKS = ( - ip_network('10.0.0.0/8'), - ip_network('172.16.0.0/12'), - ip_network('192.168.0.0/16'), -) +from homeassistant.util.network import is_local async def verify_redirect_uri(hass, client_id, redirect_uri): @@ -185,9 +173,7 @@ def _parse_client_id(client_id): # Not an ip address pass - if (address is None or - address in ALLOWED_IPS or - any(address in network for network in ALLOWED_NETWORKS)): + if address is None or is_local(address): return parts raise ValueError('Hostname should be a domain name or local IP address') diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 358f266a3..8b3ae5981 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -4,13 +4,16 @@ Support to trigger Maker IFTTT recipes. For more details about this component, please refer to the documentation at https://home-assistant.io/components/ifttt/ """ +from ipaddress import ip_address import logging +from urllib.parse import urlparse import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant import config_entries +from homeassistant.util.network import is_local REQUIREMENTS = ['pyfttt==0.3'] DEPENDENCIES = ['webhook'] @@ -102,6 +105,14 @@ class ConfigFlow(config_entries.ConfigFlow): if self._async_current_entries(): return self.async_abort(reason='one_instance_allowed') + try: + url_parts = urlparse(self.hass.config.api.base_url) + + if is_local(ip_address(url_parts.hostname)): + return self.async_abort(reason='not_internet_accessible') + except ValueError: + return self.async_abort(reason='not_internet_accessible') + if self.webhook_id is None: self.webhook_id = \ self.hass.components.webhook.async_generate_webhook_id() diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json index 7edd4a251..52dd7d7fa 100644 --- a/homeassistant/components/ifttt/strings.json +++ b/homeassistant/components/ifttt/strings.json @@ -8,7 +8,8 @@ } }, "abort": { - "one_instance_allowed": "Only a single instance is necessary." + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages." } } } diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py new file mode 100644 index 000000000..5a81dc757 --- /dev/null +++ b/homeassistant/util/network.py @@ -0,0 +1,22 @@ +"""Network utilities.""" +from ipaddress import ip_address, ip_network + + +# IP addresses of loopback interfaces +LOCAL_IPS = ( + ip_address('127.0.0.1'), + ip_address('::1'), +) + +# RFC1918 - Address allocation for Private Internets +LOCAL_NETWORKS = ( + ip_network('10.0.0.0/8'), + ip_network('172.16.0.0/12'), + ip_network('192.168.0.0/16'), +) + + +def is_local(address) -> bool: + """Check if an address is local.""" + return address in LOCAL_IPS or \ + any(address in network for network in LOCAL_NETWORKS) From 26f6857a7214b8ee128dc152786041aa5339cab2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 17:53:44 +0200 Subject: [PATCH 03/10] Abort on local url --- homeassistant/components/ifttt/.translations/en.json | 3 ++- homeassistant/components/webhook.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ifttt/.translations/en.json b/homeassistant/components/ifttt/.translations/en.json index 690fe52dd..547825fc0 100644 --- a/homeassistant/components/ifttt/.translations/en.json +++ b/homeassistant/components/ifttt/.translations/en.json @@ -1,12 +1,13 @@ { "config": { "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages.", "one_instance_allowed": "Only a single instance is necessary." }, "step": { "user": { "description": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data.", - "title": "Set up the Webhook Applet" + "title": "Set up the IFTTT Webhook Applet" } }, "title": "IFTTT" diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index 5492e8b94..3e9afab52 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -49,6 +49,7 @@ async def async_setup(hass, config): class WebhookView(HomeAssistantView): + """Handle incoming webhook requests.""" url = "/api/webhook/{webhook_id}" name = "api:webhook" From e16fea8999927ab682655b8dd56a5c45a2b3c357 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 20:09:19 +0200 Subject: [PATCH 04/10] Add description to create entry --- homeassistant/components/auth/indieauth.py | 1 + .../components/ifttt/.translations/en.json | 5 +++- homeassistant/components/ifttt/__init__.py | 29 ++++++++++++------- homeassistant/components/ifttt/strings.json | 5 +++- homeassistant/components/webhook.py | 10 ++++++- homeassistant/data_entry_flow.py | 7 ++++- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 841516c59..30432a612 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,5 +1,6 @@ """Helpers to resolve client ID/secret.""" import asyncio +from ipaddress import ip_address from html.parser import HTMLParser from urllib.parse import urlparse, urljoin diff --git a/homeassistant/components/ifttt/.translations/en.json b/homeassistant/components/ifttt/.translations/en.json index 547825fc0..dae4b24de 100644 --- a/homeassistant/components/ifttt/.translations/en.json +++ b/homeassistant/components/ifttt/.translations/en.json @@ -4,9 +4,12 @@ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages.", "one_instance_allowed": "Only a single instance is necessary." }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + }, "step": { "user": { - "description": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data.", + "description": "Are you sure you want to set up IFTTT?", "title": "Set up the IFTTT Webhook Applet" } }, diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 8b3ae5981..e9a5145c0 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -92,6 +92,13 @@ def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( entry.data['webhook_id'], handle_webhook) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data['webhook_id']) + return True @config_entries.HANDLERS.register(DOMAIN) @@ -117,24 +124,24 @@ class ConfigFlow(config_entries.ConfigFlow): self.webhook_id = \ self.hass.components.webhook.async_generate_webhook_id() - if not user_input: - webhook_url = \ - self.hass.components.webhook.\ - async_generate_webhook_url(self.webhook_id) - + if user_input is None: return self.async_show_form( step_id='user', - description_placeholders={ - 'applet_url': 'https://ifttt.com/maker_webhooks', - 'webhook_url': webhook_url, - 'docs_url': - 'https://www.home-assistant.io/components/ifttt/' - }, ) + webhook_url = \ + self.hass.components.webhook.async_generate_webhook_url( + self.webhook_id) + return self.async_create_entry( title='IFTTT Webhook', data={ CONF_WEBHOOK_ID: self.webhook_id }, + description_placeholders={ + 'applet_url': 'https://ifttt.com/maker_webhooks', + 'webhook_url': webhook_url, + 'docs_url': + 'https://www.home-assistant.io/components/ifttt/' + } ) diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json index 52dd7d7fa..9fc47504b 100644 --- a/homeassistant/components/ifttt/strings.json +++ b/homeassistant/components/ifttt/strings.json @@ -4,12 +4,15 @@ "step": { "user": { "title": "Set up the IFTTT Webhook Applet", - "description": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "description": "Are you sure you want to set up IFTTT?" } }, "abort": { "one_instance_allowed": "Only a single instance is necessary.", "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index 3e9afab52..5ae20230d 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -29,6 +29,14 @@ def async_register(hass, webhook_id, handler): handlers[webhook_id] = handler +@callback +@bind_hass +def async_unregister(hass, webhook_id): + """Remove a webhook.""" + handlers = hass.data.setdefault(webhook_id, {}) + handlers.pop(webhook_id, None) + + @callback def async_generate_webhook_id(): """Generate a webhook_id.""" @@ -58,7 +66,7 @@ class WebhookView(HomeAssistantView): async def post(self, request, webhook_id): """Handle webhook call.""" hass = request.app['hass'] - handlers = hass.data.setdefault(webhook_id, {}) + handlers = hass.data.setdefault(DOMAIN, {}) handler = handlers.get(webhook_id) # Always respond successfully to not give away if a hook exists or not. diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index ecf9850a6..743f735d5 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -153,7 +153,10 @@ class FlowHandler: } @callback - def async_create_entry(self, *, title: str, data: Dict) -> Dict: + def async_create_entry(self, *, title: str, data: Dict, + description: str = None, + description_placeholders: Optional[Dict] = None) \ + -> Dict: """Finish config flow and create a config entry.""" return { 'version': self.VERSION, @@ -162,6 +165,8 @@ class FlowHandler: 'handler': self.handler, 'title': title, 'data': data, + 'description': description, + 'description_placeholders': description_placeholders, } @callback From d14cf22f33e0de7c5c63f214e07a6c9cbefb3e90 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 20:18:49 +0200 Subject: [PATCH 05/10] Make body optional --- homeassistant/components/webhook.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index 5ae20230d..990fdaca9 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -3,6 +3,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/webhook/ """ +import json import logging from aiohttp.web import Response @@ -75,8 +76,9 @@ class WebhookView(HomeAssistantView): 'Received message for unregistered webhook %s', webhook_id) return Response(status=200) + body = await request.text() try: - data = await request.json() + data = json.load(body) if body else {} except ValueError: _LOGGER.warning( 'Received webhook %s with invalid JSON', webhook_id) From de66af9eb19d15e5f162620982fb5f045904a202 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 20:43:17 +0200 Subject: [PATCH 06/10] Allow ifttt setup without config --- homeassistant/components/ifttt/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index e9a5145c0..c742b69b7 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -61,6 +61,9 @@ def trigger(hass, event, value1=None, value2=None, value3=None): def async_setup(hass, config): """Set up the IFTTT service component.""" + if DOMAIN not in config: + return + key = config[DOMAIN][CONF_KEY] def trigger_service(call): From 61605ea6a22abc4d2a79c2032d2150db443a6e7d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Sep 2018 17:13:02 +0200 Subject: [PATCH 07/10] Add tests --- homeassistant/components/ifttt/__init__.py | 37 +++----- homeassistant/components/webhook.py | 10 +-- tests/components/ifttt/__init__.py | 1 + tests/components/ifttt/test_init.py | 54 ++++++++++++ tests/components/test_webhook.py | 98 ++++++++++++++++++++++ 5 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 tests/components/ifttt/__init__.py create mode 100644 tests/components/ifttt/test_init.py create mode 100644 tests/components/test_webhook.py diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index c742b69b7..534217a7b 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -42,27 +42,16 @@ SERVICE_TRIGGER_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ vol.Required(CONF_KEY): cv.string, }), }, extra=vol.ALLOW_EXTRA) -def trigger(hass, event, value1=None, value2=None, value3=None): - """Trigger a Maker IFTTT recipe.""" - data = { - ATTR_EVENT: event, - ATTR_VALUE1: value1, - ATTR_VALUE2: value2, - ATTR_VALUE3: value3, - } - hass.services.call(DOMAIN, SERVICE_TRIGGER, data) - - -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the IFTTT service component.""" if DOMAIN not in config: - return + return True key = config[DOMAIN][CONF_KEY] @@ -87,11 +76,12 @@ def async_setup(hass, config): async def handle_webhook(hass, webhook_id, data): """Handle webhook callback.""" - data['webhook_id'] = webhook_id + if isinstance(data, dict): + data['webhook_id'] = webhook_id hass.bus.async_fire(EVENT_RECEIVED, data) -def async_setup_entry(hass, entry): +async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( entry.data['webhook_id'], handle_webhook) @@ -108,8 +98,6 @@ async def async_unload_entry(hass, entry): class ConfigFlow(config_entries.ConfigFlow): """Handle an IFTTT config flow.""" - webhook_id = None - async def async_step_user(self, user_input=None): """Handle a user initiated set up flow.""" if self._async_current_entries(): @@ -121,25 +109,22 @@ class ConfigFlow(config_entries.ConfigFlow): if is_local(ip_address(url_parts.hostname)): return self.async_abort(reason='not_internet_accessible') except ValueError: - return self.async_abort(reason='not_internet_accessible') - - if self.webhook_id is None: - self.webhook_id = \ - self.hass.components.webhook.async_generate_webhook_id() + # If it's not an IP address, it's very likely publicly accessible + pass if user_input is None: return self.async_show_form( step_id='user', ) + webhook_id = self.hass.components.webhook.async_generate_id() webhook_url = \ - self.hass.components.webhook.async_generate_webhook_url( - self.webhook_id) + self.hass.components.webhook.async_generate_url(webhook_id) return self.async_create_entry( title='IFTTT Webhook', data={ - CONF_WEBHOOK_ID: self.webhook_id + CONF_WEBHOOK_ID: webhook_id }, description_placeholders={ 'applet_url': 'https://ifttt.com/maker_webhooks', diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index 990fdaca9..14699e7d7 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) @bind_hass def async_register(hass, webhook_id, handler): """Register a webhook.""" - handlers = hass.data.setdefault(webhook_id, {}) + handlers = hass.data.setdefault(DOMAIN, {}) if webhook_id in handlers: raise ValueError('Handler is already defined!') @@ -34,19 +34,19 @@ def async_register(hass, webhook_id, handler): @bind_hass def async_unregister(hass, webhook_id): """Remove a webhook.""" - handlers = hass.data.setdefault(webhook_id, {}) + handlers = hass.data.setdefault(DOMAIN, {}) handlers.pop(webhook_id, None) @callback -def async_generate_webhook_id(): +def async_generate_id(): """Generate a webhook_id.""" return generate_secret(entropy=32) @callback @bind_hass -def async_generate_webhook_url(hass, webhook_id): +def async_generate_url(hass, webhook_id): """Generate a webhook_id.""" return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id) @@ -78,7 +78,7 @@ class WebhookView(HomeAssistantView): body = await request.text() try: - data = json.load(body) if body else {} + data = json.loads(body) if body else {} except ValueError: _LOGGER.warning( 'Received webhook %s with invalid JSON', webhook_id) diff --git a/tests/components/ifttt/__init__.py b/tests/components/ifttt/__init__.py new file mode 100644 index 000000000..2fe2f4027 --- /dev/null +++ b/tests/components/ifttt/__init__.py @@ -0,0 +1 @@ +"""Tests for the IFTTT component.""" diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py new file mode 100644 index 000000000..236ed57e7 --- /dev/null +++ b/tests/components/ifttt/test_init.py @@ -0,0 +1,54 @@ +"""Test the init file of IFTTT.""" +from unittest.mock import Mock, patch + +from homeassistant import data_entry_flow +from homeassistant.core import callback +from homeassistant.components import ifttt + +# @pytest.fixture +# def mock_client(hass, aiohttp_client): +# """Create http client for webhooks.""" +# hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) +# return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + + +async def test_config_flow_registers_webhook(hass, aiohttp_client): + """Test setting up IFTTT and sending webhook.""" + with patch('homeassistant.util.get_local_ip', return_value='example.com'): + result = await hass.config_entries.flow.async_init('ifttt', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure(result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + webhook_id = result['result'].data['webhook_id'] + + ifttt_events = [] + + @callback + def handle_event(event): + """Handle IFTTT event.""" + ifttt_events.append(event) + + hass.bus.async_listen(ifttt.EVENT_RECEIVED, handle_event) + + client = await aiohttp_client(hass.http.app) + await client.post('/api/webhook/{}'.format(webhook_id), json={ + 'hello': 'ifttt' + }) + + assert len(ifttt_events) == 1 + assert ifttt_events[0].data['webhook_id'] == webhook_id + assert ifttt_events[0].data['hello'] == 'ifttt' + + +async def test_config_flow_aborts_external_url(hass, aiohttp_client): + """Test setting up IFTTT and sending webhook.""" + hass.config.api = Mock(base_url='http://192.168.1.10') + result = await hass.config_entries.flow.async_init('ifttt', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'not_internet_accessible' diff --git a/tests/components/test_webhook.py b/tests/components/test_webhook.py new file mode 100644 index 000000000..c87687292 --- /dev/null +++ b/tests/components/test_webhook.py @@ -0,0 +1,98 @@ +"""Test the webhook component.""" +from unittest.mock import Mock + +import pytest + +from homeassistant.setup import async_setup_component + + +@pytest.fixture +def mock_client(hass, aiohttp_client): + """Create http client for webhooks.""" + hass.loop.run_until_complete(async_setup_component(hass, 'webhook', {})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +async def test_unregistering_webhook(hass, mock_client): + """Test unregistering a webhook.""" + hooks = [] + webhook_id = hass.components.webhook.async_generate_id() + + async def handle(*args): + """Handle webhook.""" + hooks.append(args) + + hass.components.webhook.async_register(webhook_id, handle) + + resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) + assert resp.status == 200 + assert len(hooks) == 1 + + hass.components.webhook.async_unregister(webhook_id) + + resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) + assert resp.status == 200 + assert len(hooks) == 1 + + +async def test_generate_webhook_url(hass): + """Test we generate a webhook url correctly.""" + hass.config.api = Mock(base_url='https://example.com') + url = hass.components.webhook.async_generate_url('some_id') + + assert url == 'https://example.com/api/webhook/some_id' + + +async def test_posting_webhook_nonexisting(hass, mock_client): + """Test posting to a nonexisting webhook.""" + resp = await mock_client.post('/api/webhook/non-existing') + assert resp.status == 200 + + +async def test_posting_webhook_invalid_json(hass, mock_client): + """Test posting to a nonexisting webhook.""" + hass.components.webhook.async_register('hello', None) + resp = await mock_client.post('/api/webhook/hello', data='not-json') + assert resp.status == 200 + + +async def test_posting_webhook_json(hass, mock_client): + """Test posting a webhook with JSON data.""" + hooks = [] + webhook_id = hass.components.webhook.async_generate_id() + + async def handle(*args): + """Handle webhook.""" + hooks.append(args) + + hass.components.webhook.async_register(webhook_id, handle) + + resp = await mock_client.post('/api/webhook/{}'.format(webhook_id), json={ + 'data': True + }) + assert resp.status == 200 + assert len(hooks) == 1 + assert hooks[0][0] is hass + assert hooks[0][1] == webhook_id + assert hooks[0][2] == { + 'data': True + } + + +async def test_posting_webhook_no_data(hass, mock_client): + """Test posting a webhook with no data.""" + hooks = [] + webhook_id = hass.components.webhook.async_generate_id() + + async def handle(*args): + """Handle webhook.""" + hooks.append(args) + + hass.components.webhook.async_register(webhook_id, handle) + + resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) + assert resp.status == 200 + assert len(hooks) == 1 + assert hooks[0][0] is hass + assert hooks[0][1] == webhook_id + assert hooks[0][2] == {} From 419f8e852595b486ea3a4026e7224cf8942269ad Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Sep 2018 17:21:20 +0200 Subject: [PATCH 08/10] Lint --- tests/components/ifttt/test_init.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index 236ed57e7..61d6654ba 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -5,13 +5,6 @@ from homeassistant import data_entry_flow from homeassistant.core import callback from homeassistant.components import ifttt -# @pytest.fixture -# def mock_client(hass, aiohttp_client): -# """Create http client for webhooks.""" -# hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) -# return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) - - async def test_config_flow_registers_webhook(hass, aiohttp_client): """Test setting up IFTTT and sending webhook.""" @@ -21,7 +14,8 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): }) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result - result = await hass.config_entries.flow.async_configure(result['flow_id'], {}) + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY webhook_id = result['result'].data['webhook_id'] From ff9f059e45320cfd5568c433a6f1d9022e52ddfa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Sep 2018 09:01:04 +0200 Subject: [PATCH 09/10] Fix Lint + Tests --- homeassistant/components/webhook.py | 2 +- homeassistant/data_entry_flow.py | 2 +- homeassistant/util/network.py | 2 +- tests/components/config/test_config_entries.py | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index 14699e7d7..0e44ffbab 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -89,6 +89,6 @@ class WebhookView(HomeAssistantView): if response is None: response = Response(status=200) return response - except Exception: # pylint: disable-bare-except + except Exception: # pylint: disable=broad-except _LOGGER.exception("Error processing webhook %s", webhook_id) return Response(status=200) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 743f735d5..57265cf69 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -154,7 +154,7 @@ class FlowHandler: @callback def async_create_entry(self, *, title: str, data: Dict, - description: str = None, + description: Optional[str] = None, description_placeholders: Optional[Dict] = None) \ -> Dict: """Finish config flow and create a config entry.""" diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 5a81dc757..b35a8063f 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -16,7 +16,7 @@ LOCAL_NETWORKS = ( ) -def is_local(address) -> bool: +def is_local(address: ip_address) -> bool: """Check if an address is local.""" return address in LOCAL_IPS or \ any(address in network for network in LOCAL_NETWORKS) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 1e3b50772..67d7eebbf 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -206,6 +206,8 @@ def test_create_account(hass, client): 'title': 'Test Entry', 'type': 'create_entry', 'version': 1, + 'description': None, + 'description_placeholders': None, } @@ -266,6 +268,8 @@ def test_two_step_flow(hass, client): 'type': 'create_entry', 'title': 'user-title', 'version': 1, + 'description': None, + 'description_placeholders': None, } From afdc022c1f59c1060be159953fdfbaf2e7bea39b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Sep 2018 18:06:44 +0200 Subject: [PATCH 10/10] Fix typing --- homeassistant/util/network.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index b35a8063f..48840f339 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -1,6 +1,6 @@ """Network utilities.""" -from ipaddress import ip_address, ip_network - +from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network +from typing import Union # IP addresses of loopback interfaces LOCAL_IPS = ( @@ -16,7 +16,7 @@ LOCAL_NETWORKS = ( ) -def is_local(address: ip_address) -> bool: +def is_local(address: Union[IPv4Address, IPv6Address]) -> bool: """Check if an address is local.""" return address in LOCAL_IPS or \ any(address in network for network in LOCAL_NETWORKS)