From 51c7cbc6b93a8bb2ed2acdba84897e15af01c3a2 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 3 Apr 2019 05:21:25 -0700 Subject: [PATCH] Add mobile_app notify platform (#22580) * Add mobile_app notify platform * Requested changes * Fix incorrect param for status code * Move push_registrations to notify platform file * Trim down registration information sent in push * quotes * Use async version of load_platform * Add warning for duplicate device names * Switch to async_get_service * add mobile_app.notify test * Update tests/components/mobile_app/test_notify.py * Update tests/components/mobile_app/test_notify.py --- .../components/mobile_app/__init__.py | 13 +- homeassistant/components/mobile_app/const.py | 2 + homeassistant/components/mobile_app/notify.py | 134 ++++++++++++++++++ tests/components/mobile_app/test_notify.py | 81 +++++++++++ 4 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/mobile_app/notify.py create mode 100644 tests/components/mobile_app/test_notify.py diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index ecbe8d70847..a4ae78959cf 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -2,13 +2,13 @@ from homeassistant import config_entries from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.components.webhook import async_register as webhook_register -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, discovery from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, - ATTR_MODEL, ATTR_OS_VERSION, DATA_BINARY_SENSOR, - DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, - DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, + ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, + DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, + DATA_DEVICES, DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION) from .http_api import RegistrationsView @@ -52,6 +52,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): except ValueError: pass + hass.async_create_task(discovery.async_load_platform( + hass, 'notify', DOMAIN, {}, config)) + return True diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 3aa4626da29..61c50e97c6e 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -40,6 +40,8 @@ ATTR_MANUFACTURER = 'manufacturer' ATTR_MODEL = 'model' ATTR_OS_NAME = 'os_name' ATTR_OS_VERSION = 'os_version' +ATTR_PUSH_TOKEN = 'push_token' +ATTR_PUSH_URL = 'push_url' ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption' ATTR_EVENT_DATA = 'event_data' diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py new file mode 100644 index 00000000000..0120b1a6ffb --- /dev/null +++ b/homeassistant/components/mobile_app/notify.py @@ -0,0 +1,134 @@ +"""Support for mobile_app push notifications.""" +import asyncio +from datetime import datetime, timezone +import logging + +import async_timeout + +from homeassistant.components.notify import ( + ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, + BaseNotificationService) +from homeassistant.components.mobile_app.const import ( + ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_VERSION, ATTR_DEVICE_NAME, + ATTR_OS_VERSION, ATTR_PUSH_TOKEN, ATTR_PUSH_URL, DATA_CONFIG_ENTRIES, + DOMAIN) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mobile_app'] + + +def push_registrations(hass): + """Return a dictionary of push enabled registrations.""" + targets = {} + for webhook_id, entry in hass.data[DOMAIN][DATA_CONFIG_ENTRIES].items(): + data = entry.data + app_data = data[ATTR_APP_DATA] + if ATTR_PUSH_TOKEN in app_data and ATTR_PUSH_URL in app_data: + device_name = data[ATTR_DEVICE_NAME] + if device_name in targets: + _LOGGER.warning("Found duplicate device name %s", device_name) + continue + targets[device_name] = webhook_id + return targets + + +# pylint: disable=invalid-name +def log_rate_limits(hass, device_name, resp, level=logging.INFO): + """Output rate limit log line at given level.""" + rate_limits = resp['rateLimits'] + resetsAt = dt_util.parse_datetime(rate_limits['resetsAt']) + resetsAtTime = resetsAt - datetime.now(timezone.utc) + rate_limit_msg = ("mobile_app push notification rate limits for %s: " + "%d sent, %d allowed, %d errors, " + "resets in %s") + _LOGGER.log(level, rate_limit_msg, + device_name, + rate_limits['successful'], + rate_limits['maximum'], rate_limits['errors'], + str(resetsAtTime).split(".")[0]) + + +async def async_get_service(hass, config, discovery_info=None): + """Get the mobile_app notification service.""" + session = async_get_clientsession(hass) + return MobileAppNotificationService(session) + + +class MobileAppNotificationService(BaseNotificationService): + """Implement the notification service for mobile_app.""" + + def __init__(self, session): + """Initialize the service.""" + self._session = session + + @property + def targets(self): + """Return a dictionary of registered targets.""" + return push_registrations(self.hass) + + async def async_send_message(self, message="", **kwargs): + """Send a message to the Lambda APNS gateway.""" + data = {ATTR_MESSAGE: message} + + if kwargs.get(ATTR_TITLE) is not None: + # Remove default title from notifications. + if kwargs.get(ATTR_TITLE) != ATTR_TITLE_DEFAULT: + data[ATTR_TITLE] = kwargs.get(ATTR_TITLE) + + targets = kwargs.get(ATTR_TARGET) + + if not targets: + targets = push_registrations(self.hass) + + if kwargs.get(ATTR_DATA) is not None: + data[ATTR_DATA] = kwargs.get(ATTR_DATA) + + for target in targets: + + entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target] + entry_data = entry.data + + app_data = entry_data[ATTR_APP_DATA] + push_token = app_data[ATTR_PUSH_TOKEN] + push_url = app_data[ATTR_PUSH_URL] + + data[ATTR_PUSH_TOKEN] = push_token + + reg_info = { + ATTR_APP_ID: entry_data[ATTR_APP_ID], + ATTR_APP_VERSION: entry_data[ATTR_APP_VERSION], + } + if ATTR_OS_VERSION in entry_data: + reg_info[ATTR_OS_VERSION] = entry_data[ATTR_OS_VERSION] + + data['registration_info'] = reg_info + + try: + with async_timeout.timeout(10, loop=self.hass.loop): + response = await self._session.post(push_url, json=data) + result = await response.json() + + if response.status == 201: + log_rate_limits(self.hass, + entry_data[ATTR_DEVICE_NAME], result) + return + + fallback_error = result.get("errorMessage", + "Unknown error") + fallback_message = ("Internal server error, " + "please try again later: " + "{}").format(fallback_error) + message = result.get("message", fallback_message) + if response.status == 429: + _LOGGER.warning(message) + log_rate_limits(self.hass, + entry_data[ATTR_DEVICE_NAME], + result, logging.WARNING) + else: + _LOGGER.error(message) + + except asyncio.TimeoutError: + _LOGGER.error("Timeout sending notification to %s", push_url) diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py new file mode 100644 index 00000000000..395dee6c117 --- /dev/null +++ b/tests/components/mobile_app/test_notify.py @@ -0,0 +1,81 @@ +"""Notify platform tests for mobile_app.""" +# pylint: disable=redefined-outer-name +import pytest + +from homeassistant.setup import async_setup_component + +from homeassistant.components.mobile_app.const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def setup_push_receiver(hass, aioclient_mock): + """Fixture that sets up a mocked push receiver.""" + push_url = 'https://mobile-push.home-assistant.dev/push' + + from datetime import datetime, timedelta + now = (datetime.now() + timedelta(hours=24)) + iso_time = now.strftime("%Y-%m-%dT%H:%M:%SZ") + + aioclient_mock.post(push_url, json={ + 'rateLimits': { + 'attempts': 1, + 'successful': 1, + 'errors': 0, + 'total': 1, + 'maximum': 150, + 'remaining': 149, + 'resetsAt': iso_time + } + }) + + entry = MockConfigEntry( + connection_class="cloud_push", + data={ + "app_data": { + "push_token": "PUSH_TOKEN", + "push_url": push_url + }, + "app_id": "io.homeassistant.mobile_app", + "app_name": "mobile_app tests", + "app_version": "1.0", + "device_id": "4d5e6f", + "device_name": "Test", + "manufacturer": "Home Assistant", + "model": "mobile_app", + "os_name": "Linux", + "os_version": "5.0.6", + "secret": "123abc", + "supports_encryption": False, + "user_id": "1a2b3c", + "webhook_id": "webhook_id" + }, + domain=DOMAIN, + source="registration", + title="mobile_app test entry", + version=1 + ) + entry.add_to_hass(hass) + + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_notify_works(hass, aioclient_mock, setup_push_receiver): + """Test notify works.""" + assert hass.services.has_service('notify', 'mobile_app_test') is True + assert await hass.services.async_call('notify', 'mobile_app_test', + {'message': 'Hello world'}, + blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + + assert call_json["push_token"] == "PUSH_TOKEN" + assert call_json["message"] == "Hello world" + assert call_json["registration_info"]["app_id"] == \ + "io.homeassistant.mobile_app" + assert call_json["registration_info"]["app_version"] == "1.0"