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
This commit is contained in:
parent
b1cca25299
commit
b797b1513a
4 changed files with 225 additions and 5 deletions
|
@ -2,13 +2,13 @@
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_WEBHOOK_ID
|
from homeassistant.const import CONF_WEBHOOK_ID
|
||||||
from homeassistant.components.webhook import async_register as webhook_register
|
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 homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||||
|
|
||||||
from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER,
|
from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME,
|
||||||
ATTR_MODEL, ATTR_OS_VERSION, DATA_BINARY_SENSOR,
|
ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION,
|
||||||
DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES,
|
DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS,
|
||||||
DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY,
|
DATA_DEVICES, DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY,
|
||||||
STORAGE_VERSION)
|
STORAGE_VERSION)
|
||||||
|
|
||||||
from .http_api import RegistrationsView
|
from .http_api import RegistrationsView
|
||||||
|
@ -52,6 +52,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
hass.async_create_task(discovery.async_load_platform(
|
||||||
|
hass, 'notify', DOMAIN, {}, config))
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,8 @@ ATTR_MANUFACTURER = 'manufacturer'
|
||||||
ATTR_MODEL = 'model'
|
ATTR_MODEL = 'model'
|
||||||
ATTR_OS_NAME = 'os_name'
|
ATTR_OS_NAME = 'os_name'
|
||||||
ATTR_OS_VERSION = 'os_version'
|
ATTR_OS_VERSION = 'os_version'
|
||||||
|
ATTR_PUSH_TOKEN = 'push_token'
|
||||||
|
ATTR_PUSH_URL = 'push_url'
|
||||||
ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption'
|
ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption'
|
||||||
|
|
||||||
ATTR_EVENT_DATA = 'event_data'
|
ATTR_EVENT_DATA = 'event_data'
|
||||||
|
|
134
homeassistant/components/mobile_app/notify.py
Normal file
134
homeassistant/components/mobile_app/notify.py
Normal file
|
@ -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)
|
81
tests/components/mobile_app/test_notify.py
Normal file
81
tests/components/mobile_app/test_notify.py
Normal file
|
@ -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"
|
Loading…
Add table
Add a link
Reference in a new issue