Add device action to mobile app to notify (#43814)

This commit is contained in:
Paulus Schoutsen
2020-12-01 18:14:01 +01:00
committed by GitHub
parent 52217f1f60
commit 7d23ff6511
17 changed files with 263 additions and 32 deletions

View File

@@ -541,7 +541,6 @@ omit =
homeassistant/components/minio/* homeassistant/components/minio/*
homeassistant/components/mitemp_bt/sensor.py homeassistant/components/mitemp_bt/sensor.py
homeassistant/components/mjpeg/camera.py homeassistant/components/mjpeg/camera.py
homeassistant/components/mobile_app/*
homeassistant/components/mochad/* homeassistant/components/mochad/*
homeassistant/components/modbus/climate.py homeassistant/components/modbus/climate.py
homeassistant/components/modbus/cover.py homeassistant/components/modbus/cover.py

View File

@@ -123,6 +123,7 @@ async def async_unload_entry(hass, entry):
webhook_unregister(hass, webhook_id) webhook_unregister(hass, webhook_id)
del hass.data[DOMAIN][DATA_CONFIG_ENTRIES][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) await hass_notify.async_reload(hass, DOMAIN)
return True return True

View File

@@ -15,6 +15,7 @@ DATA_DELETED_IDS = "deleted_ids"
DATA_DEVICES = "devices" DATA_DEVICES = "devices"
DATA_SENSOR = "sensor" DATA_SENSOR = "sensor"
DATA_STORE = "store" DATA_STORE = "store"
DATA_NOTIFY = "notify"
ATTR_APP_DATA = "app_data" ATTR_APP_DATA = "app_data"
ATTR_APP_ID = "app_id" ATTR_APP_ID = "app_id"

View File

@@ -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,
}
)
}

View File

@@ -35,8 +35,10 @@ from .const import (
ATTR_PUSH_TOKEN, ATTR_PUSH_TOKEN,
ATTR_PUSH_URL, ATTR_PUSH_URL,
DATA_CONFIG_ENTRIES, DATA_CONFIG_ENTRIES,
DATA_NOTIFY,
DOMAIN, DOMAIN,
) )
from .util import supports_push
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -44,15 +46,13 @@ _LOGGER = logging.getLogger(__name__)
def push_registrations(hass): def push_registrations(hass):
"""Return a dictionary of push enabled registrations.""" """Return a dictionary of push enabled registrations."""
targets = {} targets = {}
for webhook_id, entry in hass.data[DOMAIN][DATA_CONFIG_ENTRIES].items(): for webhook_id, entry in hass.data[DOMAIN][DATA_CONFIG_ENTRIES].items():
data = entry.data if not supports_push(hass, webhook_id):
app_data = data[ATTR_APP_DATA] continue
if ATTR_PUSH_TOKEN in app_data and ATTR_PUSH_URL in app_data:
device_name = data[ATTR_DEVICE_NAME] targets[entry.data[ATTR_DEVICE_NAME]] = webhook_id
if device_name in targets:
_LOGGER.warning("Found duplicate device name %s", device_name)
continue
targets[device_name] = webhook_id
return targets 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): async def async_get_service(hass, config, discovery_info=None):
"""Get the mobile_app notification service.""" """Get the mobile_app notification service."""
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
return MobileAppNotificationService(session) service = hass.data[DOMAIN][DATA_NOTIFY] = MobileAppNotificationService(session)
return service
class MobileAppNotificationService(BaseNotificationService): class MobileAppNotificationService(BaseNotificationService):

View File

@@ -8,5 +8,10 @@
"abort": { "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." "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"
}
} }
} }

View File

@@ -8,5 +8,10 @@
"description": "Do you want to set up the Mobile App component?" "description": "Do you want to set up the Mobile App component?"
} }
} }
},
"device_automation": {
"action_type": {
"notify": "Send a notification"
}
} }
} }

View File

@@ -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

View File

@@ -110,6 +110,8 @@ class BaseNotificationService:
"""An abstract class for notification services.""" """An abstract class for notification services."""
hass: Optional[HomeAssistantType] = None hass: Optional[HomeAssistantType] = None
# Name => target
registered_targets: Dict[str, str]
def send_message(self, message, **kwargs): def send_message(self, message, **kwargs):
"""Send a message. """Send a message.
@@ -135,8 +137,8 @@ class BaseNotificationService:
title.hass = self.hass title.hass = self.hass
kwargs[ATTR_TITLE] = title.async_render(parse_result=False) kwargs[ATTR_TITLE] = title.async_render(parse_result=False)
if self._registered_targets.get(service.service) is not None: if self.registered_targets.get(service.service) is not None:
kwargs[ATTR_TARGET] = [self._registered_targets[service.service]] kwargs[ATTR_TARGET] = [self.registered_targets[service.service]]
elif service.data.get(ATTR_TARGET) is not None: elif service.data.get(ATTR_TARGET) is not None:
kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET) kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET)
@@ -157,23 +159,23 @@ class BaseNotificationService:
self.hass = hass self.hass = hass
self._service_name = service_name self._service_name = service_name
self._target_service_name_prefix = target_service_name_prefix self._target_service_name_prefix = target_service_name_prefix
self._registered_targets: Dict = {} self.registered_targets = {}
async def async_register_services(self) -> None: async def async_register_services(self) -> None:
"""Create or update the notify services.""" """Create or update the notify services."""
assert self.hass assert self.hass
if hasattr(self, "targets"): if hasattr(self, "targets"):
stale_targets = set(self._registered_targets) stale_targets = set(self.registered_targets)
# pylint: disable=no-member # pylint: disable=no-member
for name, target in self.targets.items(): # type: ignore for name, target in self.targets.items(): # type: ignore
target_name = slugify(f"{self._target_service_name_prefix}_{name}") target_name = slugify(f"{self._target_service_name_prefix}_{name}")
if target_name in stale_targets: if target_name in stale_targets:
stale_targets.remove(target_name) stale_targets.remove(target_name)
if target_name in self._registered_targets: if target_name in self.registered_targets:
continue continue
self._registered_targets[target_name] = target self.registered_targets[target_name] = target
self.hass.services.async_register( self.hass.services.async_register(
DOMAIN, DOMAIN,
target_name, target_name,
@@ -182,7 +184,7 @@ class BaseNotificationService:
) )
for stale_target_name in stale_targets: 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( self.hass.services.async_remove(
DOMAIN, DOMAIN,
stale_target_name, stale_target_name,
@@ -202,10 +204,10 @@ class BaseNotificationService:
"""Unregister the notify services.""" """Unregister the notify services."""
assert self.hass assert self.hass
if self._registered_targets: if self.registered_targets:
remove_targets = set(self._registered_targets) remove_targets = set(self.registered_targets)
for remove_target_name in remove_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( self.hass.services.async_remove(
DOMAIN, DOMAIN,
remove_target_name, remove_target_name,

View File

@@ -1,4 +1,4 @@
"""Provides device automations for NEW_NAME.""" """Provides device actions for NEW_NAME."""
from typing import List, Optional from typing import List, Optional
import voluptuous as vol import voluptuous as vol
@@ -72,8 +72,6 @@ async def async_call_action_from_config(
hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context]
) -> None: ) -> None:
"""Execute a device action.""" """Execute a device action."""
config = ACTION_SCHEMA(config)
service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]}
if config[CONF_TYPE] == "turn_on": if config[CONF_TYPE] == "turn_on":

View File

@@ -1,8 +1,8 @@
"""The tests for NEW_NAME device actions.""" """The tests for NEW_NAME device actions."""
import pytest import pytest
from homeassistant.components import automation
from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.components.NEW_DOMAIN import DOMAIN
import homeassistant.components.automation as automation
from homeassistant.helpers import device_registry from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component

View File

@@ -1,4 +1,4 @@
"""Provide the device automations for NEW_NAME.""" """Provide the device conditions for NEW_NAME."""
from typing import Dict, List from typing import Dict, List
import voluptuous as vol import voluptuous as vol

View File

@@ -1,8 +1,8 @@
"""The tests for NEW_NAME device conditions.""" """The tests for NEW_NAME device conditions."""
import pytest import pytest
from homeassistant.components import automation
from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.components.NEW_DOMAIN import DOMAIN
import homeassistant.components.automation as automation
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.helpers import device_registry from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component

View File

@@ -1,4 +1,4 @@
"""Provides device automations for NEW_NAME.""" """Provides device triggers for NEW_NAME."""
from typing import List from typing import List
import voluptuous as vol import voluptuous as vol
@@ -80,11 +80,8 @@ async def async_attach_trigger(
automation_info: dict, automation_info: dict,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Attach a trigger.""" """Attach a trigger."""
config = TRIGGER_SCHEMA(config)
# TODO Implement your own logic to attach triggers. # TODO Implement your own logic to attach triggers.
# Generally we suggest to re-use the existing state or event # Use the existing state or event triggers from the automation integration.
# triggers from the automation integration.
if config[CONF_TYPE] == "turned_on": if config[CONF_TYPE] == "turned_on":
from_state = STATE_OFF from_state = STATE_OFF

View File

@@ -1,8 +1,8 @@
"""The tests for NEW_NAME device triggers.""" """The tests for NEW_NAME device triggers."""
import pytest import pytest
from homeassistant.components import automation
from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.components.NEW_DOMAIN import DOMAIN
import homeassistant.components.automation as automation
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.helpers import device_registry from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component

View File

@@ -40,6 +40,26 @@ async def create_registrations(hass, authed_api_client):
return (enc_reg_json, clear_reg_json) 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 @pytest.fixture
async def webhook_client(hass, authed_api_client, aiohttp_client): async def webhook_client(hass, authed_api_client, aiohttp_client):
"""mobile_app mock client.""" """mobile_app mock client."""

View File

@@ -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,
}