Skip to content

Commit

Permalink
Add device action to mobile app to notify (#43814)
Browse files Browse the repository at this point in the history
  • Loading branch information
balloob authored Dec 1, 2020
1 parent 52217f1 commit 7d23ff6
Show file tree
Hide file tree
Showing 17 changed files with 263 additions and 32 deletions.
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/mobile_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/mobile_app/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
DATA_DEVICES = "devices"
DATA_SENSOR = "sensor"
DATA_STORE = "store"
DATA_NOTIFY = "notify"

ATTR_APP_DATA = "app_data"
ATTR_APP_ID = "app_id"
Expand Down
87 changes: 87 additions & 0 deletions homeassistant/components/mobile_app/device_action.py
Original file line number Diff line number Diff line change
@@ -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,
}
)
}
19 changes: 10 additions & 9 deletions homeassistant/components/mobile_app/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,24 @@
ATTR_PUSH_TOKEN,
ATTR_PUSH_URL,
DATA_CONFIG_ENTRIES,
DATA_NOTIFY,
DOMAIN,
)
from .util import supports_push

_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


Expand Down Expand Up @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/mobile_app/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
5 changes: 5 additions & 0 deletions homeassistant/components/mobile_app/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,10 @@
"description": "Do you want to set up the Mobile App component?"
}
}
},
"device_automation": {
"action_type": {
"notify": "Send a notification"
}
}
}
47 changes: 47 additions & 0 deletions homeassistant/components/mobile_app/util.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 12 additions & 10 deletions homeassistant/components/notify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -135,8 +137,8 @@ async def _async_notify_message_service(self, service: ServiceCall) -> None:
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)

Expand All @@ -157,23 +159,23 @@ async def async_setup(
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,
Expand All @@ -182,7 +184,7 @@ async def async_register_services(self) -> None:
)

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,
Expand All @@ -202,10 +204,10 @@ async def async_unregister_services(self) -> None:
"""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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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":
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Provides device automations for NEW_NAME."""
"""Provides device triggers for NEW_NAME."""
from typing import List

import voluptuous as vol
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 20 additions & 0 deletions tests/components/mobile_app/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading

0 comments on commit 7d23ff6

Please sign in to comment.