From 7d23ff65118aa585a964250f440182a2a65ac14c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 1 Dec 2020 18:14:01 +0100 Subject: [PATCH] Add device action to mobile app to notify (#43814) --- .coveragerc | 1 - .../components/mobile_app/__init__.py | 1 + homeassistant/components/mobile_app/const.py | 1 + .../components/mobile_app/device_action.py | 87 +++++++++++++++++++ homeassistant/components/mobile_app/notify.py | 19 ++-- .../components/mobile_app/strings.json | 5 ++ .../mobile_app/translations/en.json | 5 ++ homeassistant/components/mobile_app/util.py | 47 ++++++++++ homeassistant/components/notify/__init__.py | 22 ++--- .../integration/device_action.py | 4 +- .../device_action/tests/test_device_action.py | 2 +- .../integration/device_condition.py | 2 +- .../tests/test_device_condition.py | 2 +- .../integration/device_trigger.py | 7 +- .../tests/test_device_trigger.py | 2 +- tests/components/mobile_app/conftest.py | 20 +++++ .../mobile_app/test_device_action.py | 68 +++++++++++++++ 17 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/mobile_app/device_action.py create mode 100644 homeassistant/components/mobile_app/util.py create mode 100644 tests/components/mobile_app/test_device_action.py diff --git a/.coveragerc b/.coveragerc index 522ce8172d1..40ae410eadc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -541,7 +541,6 @@ omit = homeassistant/components/minio/* homeassistant/components/mitemp_bt/sensor.py homeassistant/components/mjpeg/camera.py - homeassistant/components/mobile_app/* homeassistant/components/mochad/* homeassistant/components/modbus/climate.py homeassistant/components/modbus/cover.py diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 264017796aa..3bc95bf3e05 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -123,6 +123,7 @@ async def async_unload_entry(hass, entry): webhook_unregister(hass, webhook_id) del hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] + del hass.data[DOMAIN][DATA_DEVICES][webhook_id] await hass_notify.async_reload(hass, DOMAIN) return True diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 6174e34f57a..b35468a6fb3 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -15,6 +15,7 @@ DATA_DELETED_IDS = "deleted_ids" DATA_DEVICES = "devices" DATA_SENSOR = "sensor" DATA_STORE = "store" +DATA_NOTIFY = "notify" ATTR_APP_DATA = "app_data" ATTR_APP_ID = "app_id" diff --git a/homeassistant/components/mobile_app/device_action.py b/homeassistant/components/mobile_app/device_action.py new file mode 100644 index 00000000000..2592d4b486b --- /dev/null +++ b/homeassistant/components/mobile_app/device_action.py @@ -0,0 +1,87 @@ +"""Provides device actions for Mobile App.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.components import notify +from homeassistant.components.device_automation import InvalidDeviceAutomationConfig +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import config_validation as cv, template + +from .const import DOMAIN +from .util import get_notify_service, supports_push, webhook_id_from_device_id + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): "notify", + vol.Required(notify.ATTR_MESSAGE): cv.template, + vol.Optional(notify.ATTR_TITLE): cv.template, + vol.Optional(notify.ATTR_DATA): cv.template_complex, + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Mobile App devices.""" + webhook_id = webhook_id_from_device_id(hass, device_id) + + if webhook_id is None or not supports_push(hass, webhook_id): + return [] + + return [{CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, CONF_TYPE: "notify"}] + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + webhook_id = webhook_id_from_device_id(hass, config[CONF_DEVICE_ID]) + + if webhook_id is None: + raise InvalidDeviceAutomationConfig( + "Unable to resolve webhook ID from the device ID" + ) + + service_name = get_notify_service(hass, webhook_id) + + if service_name is None: + raise InvalidDeviceAutomationConfig( + "Unable to find notify service for webhook ID" + ) + + service_data = {notify.ATTR_TARGET: webhook_id} + + # Render it here because we have access to variables here. + for key in (notify.ATTR_MESSAGE, notify.ATTR_TITLE, notify.ATTR_DATA): + if key not in config: + continue + + value_template = config[key] + template.attach(hass, value_template) + + try: + service_data[key] = template.render_complex(value_template, variables) + except template.TemplateError as err: + raise InvalidDeviceAutomationConfig( + f"Error rendering {key}: {err}" + ) from err + + await hass.services.async_call( + notify.DOMAIN, service_name, service_data, blocking=True, context=context + ) + + +async def async_get_action_capabilities(hass, config): + """List action capabilities.""" + if config[CONF_TYPE] != "notify": + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Required(notify.ATTR_MESSAGE): str, + vol.Optional(notify.ATTR_TITLE): str, + } + ) + } diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 04d308a5a05..b3482a70fb9 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -35,8 +35,10 @@ from .const import ( ATTR_PUSH_TOKEN, ATTR_PUSH_URL, DATA_CONFIG_ENTRIES, + DATA_NOTIFY, DOMAIN, ) +from .util import supports_push _LOGGER = logging.getLogger(__name__) @@ -44,15 +46,13 @@ _LOGGER = logging.getLogger(__name__) 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 + if not supports_push(hass, webhook_id): + continue + + targets[entry.data[ATTR_DEVICE_NAME]] = webhook_id + return targets @@ -84,7 +84,8 @@ def log_rate_limits(hass, device_name, resp, level=logging.INFO): async def async_get_service(hass, config, discovery_info=None): """Get the mobile_app notification service.""" session = async_get_clientsession(hass) - return MobileAppNotificationService(session) + service = hass.data[DOMAIN][DATA_NOTIFY] = MobileAppNotificationService(session) + return service class MobileAppNotificationService(BaseNotificationService): diff --git a/homeassistant/components/mobile_app/strings.json b/homeassistant/components/mobile_app/strings.json index fd1cddbcb5f..b18f3e7265c 100644 --- a/homeassistant/components/mobile_app/strings.json +++ b/homeassistant/components/mobile_app/strings.json @@ -8,5 +8,10 @@ "abort": { "install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps." } + }, + "device_automation": { + "action_type": { + "notify": "Send a notification" + } } } diff --git a/homeassistant/components/mobile_app/translations/en.json b/homeassistant/components/mobile_app/translations/en.json index 6def5e98582..34631f86afa 100644 --- a/homeassistant/components/mobile_app/translations/en.json +++ b/homeassistant/components/mobile_app/translations/en.json @@ -8,5 +8,10 @@ "description": "Do you want to set up the Mobile App component?" } } + }, + "device_automation": { + "action_type": { + "notify": "Send a notification" + } } } \ No newline at end of file diff --git a/homeassistant/components/mobile_app/util.py b/homeassistant/components/mobile_app/util.py new file mode 100644 index 00000000000..d9a5f1643c7 --- /dev/null +++ b/homeassistant/components/mobile_app/util.py @@ -0,0 +1,47 @@ +"""Mobile app utility functions.""" +from typing import TYPE_CHECKING, Optional + +from homeassistant.core import callback + +from .const import ( + ATTR_APP_DATA, + ATTR_PUSH_TOKEN, + ATTR_PUSH_URL, + DATA_CONFIG_ENTRIES, + DATA_DEVICES, + DATA_NOTIFY, + DOMAIN, +) + +if TYPE_CHECKING: + from .notify import MobileAppNotificationService + + +@callback +def webhook_id_from_device_id(hass, device_id: str) -> Optional[str]: + """Get webhook ID from device ID.""" + for cur_webhook_id, cur_device in hass.data[DOMAIN][DATA_DEVICES].items(): + if cur_device.id == device_id: + return cur_webhook_id + + return None + + +@callback +def supports_push(hass, webhook_id: str) -> bool: + """Return if push notifications is supported.""" + config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] + app_data = config_entry.data[ATTR_APP_DATA] + return ATTR_PUSH_TOKEN in app_data and ATTR_PUSH_URL in app_data + + +@callback +def get_notify_service(hass, webhook_id: str) -> Optional[str]: + """Return the notify service for this webhook ID.""" + notify_service: "MobileAppNotificationService" = hass.data[DOMAIN][DATA_NOTIFY] + + for target_service, target_webhook_id in notify_service.registered_targets.items(): + if target_webhook_id == webhook_id: + return target_service + + return None diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 08219567ed6..426c28bccff 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -110,6 +110,8 @@ class BaseNotificationService: """An abstract class for notification services.""" hass: Optional[HomeAssistantType] = None + # Name => target + registered_targets: Dict[str, str] def send_message(self, message, **kwargs): """Send a message. @@ -135,8 +137,8 @@ class BaseNotificationService: title.hass = self.hass kwargs[ATTR_TITLE] = title.async_render(parse_result=False) - if self._registered_targets.get(service.service) is not None: - kwargs[ATTR_TARGET] = [self._registered_targets[service.service]] + if self.registered_targets.get(service.service) is not None: + kwargs[ATTR_TARGET] = [self.registered_targets[service.service]] elif service.data.get(ATTR_TARGET) is not None: kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET) @@ -157,23 +159,23 @@ class BaseNotificationService: self.hass = hass self._service_name = service_name self._target_service_name_prefix = target_service_name_prefix - self._registered_targets: Dict = {} + self.registered_targets = {} async def async_register_services(self) -> None: """Create or update the notify services.""" assert self.hass if hasattr(self, "targets"): - stale_targets = set(self._registered_targets) + stale_targets = set(self.registered_targets) # pylint: disable=no-member for name, target in self.targets.items(): # type: ignore target_name = slugify(f"{self._target_service_name_prefix}_{name}") if target_name in stale_targets: stale_targets.remove(target_name) - if target_name in self._registered_targets: + if target_name in self.registered_targets: continue - self._registered_targets[target_name] = target + self.registered_targets[target_name] = target self.hass.services.async_register( DOMAIN, target_name, @@ -182,7 +184,7 @@ class BaseNotificationService: ) for stale_target_name in stale_targets: - del self._registered_targets[stale_target_name] + del self.registered_targets[stale_target_name] self.hass.services.async_remove( DOMAIN, stale_target_name, @@ -202,10 +204,10 @@ class BaseNotificationService: """Unregister the notify services.""" assert self.hass - if self._registered_targets: - remove_targets = set(self._registered_targets) + if self.registered_targets: + remove_targets = set(self.registered_targets) for remove_target_name in remove_targets: - del self._registered_targets[remove_target_name] + del self.registered_targets[remove_target_name] self.hass.services.async_remove( DOMAIN, remove_target_name, diff --git a/script/scaffold/templates/device_action/integration/device_action.py b/script/scaffold/templates/device_action/integration/device_action.py index 3861ee8ebe9..27a27cb95ee 100644 --- a/script/scaffold/templates/device_action/integration/device_action.py +++ b/script/scaffold/templates/device_action/integration/device_action.py @@ -1,4 +1,4 @@ -"""Provides device automations for NEW_NAME.""" +"""Provides device actions for NEW_NAME.""" from typing import List, Optional import voluptuous as vol @@ -72,8 +72,6 @@ async def async_call_action_from_config( hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] ) -> None: """Execute a device action.""" - config = ACTION_SCHEMA(config) - service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} if config[CONF_TYPE] == "turn_on": diff --git a/script/scaffold/templates/device_action/tests/test_device_action.py b/script/scaffold/templates/device_action/tests/test_device_action.py index 3c7c7bb71a4..91a4693ebeb 100644 --- a/script/scaffold/templates/device_action/tests/test_device_action.py +++ b/script/scaffold/templates/device_action/tests/test_device_action.py @@ -1,8 +1,8 @@ """The tests for NEW_NAME device actions.""" import pytest +from homeassistant.components import automation from homeassistant.components.NEW_DOMAIN import DOMAIN -import homeassistant.components.automation as automation from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component diff --git a/script/scaffold/templates/device_condition/integration/device_condition.py b/script/scaffold/templates/device_condition/integration/device_condition.py index cb2489e4279..6ad89332f8e 100644 --- a/script/scaffold/templates/device_condition/integration/device_condition.py +++ b/script/scaffold/templates/device_condition/integration/device_condition.py @@ -1,4 +1,4 @@ -"""Provide the device automations for NEW_NAME.""" +"""Provide the device conditions for NEW_NAME.""" from typing import Dict, List import voluptuous as vol diff --git a/script/scaffold/templates/device_condition/tests/test_device_condition.py b/script/scaffold/templates/device_condition/tests/test_device_condition.py index 34217a61f9e..07e0afd05eb 100644 --- a/script/scaffold/templates/device_condition/tests/test_device_condition.py +++ b/script/scaffold/templates/device_condition/tests/test_device_condition.py @@ -1,8 +1,8 @@ """The tests for NEW_NAME device conditions.""" import pytest +from homeassistant.components import automation from homeassistant.components.NEW_DOMAIN import DOMAIN -import homeassistant.components.automation as automation from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index e1312148cbe..7709813957e 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -1,4 +1,4 @@ -"""Provides device automations for NEW_NAME.""" +"""Provides device triggers for NEW_NAME.""" from typing import List import voluptuous as vol @@ -80,11 +80,8 @@ async def async_attach_trigger( automation_info: dict, ) -> CALLBACK_TYPE: """Attach a trigger.""" - config = TRIGGER_SCHEMA(config) - # TODO Implement your own logic to attach triggers. - # Generally we suggest to re-use the existing state or event - # triggers from the automation integration. + # Use the existing state or event triggers from the automation integration. if config[CONF_TYPE] == "turned_on": from_state = STATE_OFF diff --git a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py index 82540566318..23daaf8dadd 100644 --- a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py +++ b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py @@ -1,8 +1,8 @@ """The tests for NEW_NAME device triggers.""" import pytest +from homeassistant.components import automation from homeassistant.components.NEW_DOMAIN import DOMAIN -import homeassistant.components.automation as automation from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index e15c5732ac4..7c611eb1010 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -40,6 +40,26 @@ async def create_registrations(hass, authed_api_client): return (enc_reg_json, clear_reg_json) +@pytest.fixture +async def push_registration(hass, authed_api_client): + """Return registration with push notifications enabled.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + enc_reg = await authed_api_client.post( + "/api/mobile_app/registrations", + json={ + **REGISTER, + "app_data": { + "push_url": "http://localhost/mock-push", + "push_token": "abcd", + }, + }, + ) + + assert enc_reg.status == 201 + return await enc_reg.json() + + @pytest.fixture async def webhook_client(hass, authed_api_client, aiohttp_client): """mobile_app mock client.""" diff --git a/tests/components/mobile_app/test_device_action.py b/tests/components/mobile_app/test_device_action.py new file mode 100644 index 00000000000..e5b15412e4d --- /dev/null +++ b/tests/components/mobile_app/test_device_action.py @@ -0,0 +1,68 @@ +"""The tests for Mobile App device actions.""" +from homeassistant.components import automation, device_automation +from homeassistant.components.mobile_app import DATA_DEVICES, DOMAIN, util +from homeassistant.setup import async_setup_component + +from tests.common import async_get_device_automations, patch + + +async def test_get_actions(hass, push_registration): + """Test we get the expected actions from a mobile_app.""" + webhook_id = push_registration["webhook_id"] + device_id = hass.data[DOMAIN][DATA_DEVICES][webhook_id].id + + assert await async_get_device_automations(hass, "action", device_id) == [ + {"domain": DOMAIN, "device_id": device_id, "type": "notify"} + ] + + capabilitites = await device_automation._async_get_device_automation_capabilities( + hass, "action", {"domain": DOMAIN, "device_id": device_id, "type": "notify"} + ) + assert "extra_fields" in capabilitites + + +async def test_action(hass, push_registration): + """Test for turn_on and turn_off actions.""" + webhook_id = push_registration["webhook_id"] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_notify", + }, + "action": [ + {"variables": {"name": "Paulus"}}, + { + "domain": DOMAIN, + "device_id": hass.data[DOMAIN]["devices"][webhook_id].id, + "type": "notify", + "message": "Hello {{ name }}", + }, + ], + }, + ] + }, + ) + + service_name = util.get_notify_service(hass, webhook_id) + + # Make sure it was actually registered + assert hass.services.has_service("notify", service_name) + + with patch( + "homeassistant.components.mobile_app.notify.MobileAppNotificationService.async_send_message" + ) as mock_send_message: + hass.bus.async_fire("test_notify") + await hass.async_block_till_done() + assert len(mock_send_message.mock_calls) == 1 + + assert mock_send_message.mock_calls[0][2] == { + "target": [webhook_id], + "message": "Hello Paulus", + "data": None, + }