mirror of
https://github.com/home-assistant/core.git
synced 2025-08-11 00:25:12 +02:00
Add device action to mobile app to notify (#43814)
This commit is contained in:
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
87
homeassistant/components/mobile_app/device_action.py
Normal file
87
homeassistant/components/mobile_app/device_action.py
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
@@ -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)
|
||||
if not supports_push(hass, webhook_id):
|
||||
continue
|
||||
targets[device_name] = webhook_id
|
||||
|
||||
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):
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,5 +8,10 @@
|
||||
"description": "Do you want to set up the Mobile App component?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"notify": "Send a notification"
|
||||
}
|
||||
}
|
||||
}
|
47
homeassistant/components/mobile_app/util.py
Normal file
47
homeassistant/components/mobile_app/util.py
Normal 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
|
@@ -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,
|
||||
|
@@ -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":
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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."""
|
||||
|
68
tests/components/mobile_app/test_device_action.py
Normal file
68
tests/components/mobile_app/test_device_action.py
Normal 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,
|
||||
}
|
Reference in New Issue
Block a user