Allow mobile app registrations only supporting websocket push (#63208)
This commit is contained in:
parent
9f0805f512
commit
ad8af5fc7a
9 changed files with 124 additions and 20 deletions
|
@ -551,8 +551,8 @@ homeassistant/components/minecraft_server/* @elmurato
|
||||||
tests/components/minecraft_server/* @elmurato
|
tests/components/minecraft_server/* @elmurato
|
||||||
homeassistant/components/minio/* @tkislan
|
homeassistant/components/minio/* @tkislan
|
||||||
tests/components/minio/* @tkislan
|
tests/components/minio/* @tkislan
|
||||||
homeassistant/components/mobile_app/* @robbiet480
|
homeassistant/components/mobile_app/* @home-assistant/core
|
||||||
tests/components/mobile_app/* @robbiet480
|
tests/components/mobile_app/* @home-assistant/core
|
||||||
homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik
|
homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik
|
||||||
tests/components/modbus/* @adamchengtkc @janiversen @vzahradnik
|
tests/components/modbus/* @adamchengtkc @janiversen @vzahradnik
|
||||||
homeassistant/components/modem_callerid/* @tkdrob
|
homeassistant/components/modem_callerid/* @tkdrob
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
"""Constants for mobile_app."""
|
"""Constants for mobile_app."""
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
DOMAIN = "mobile_app"
|
DOMAIN = "mobile_app"
|
||||||
|
|
||||||
STORAGE_KEY = DOMAIN
|
STORAGE_KEY = DOMAIN
|
||||||
|
@ -26,6 +30,7 @@ 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_WEBSOCKET_CHANNEL = "push_websocket_channel"
|
||||||
ATTR_PUSH_TOKEN = "push_token"
|
ATTR_PUSH_TOKEN = "push_token"
|
||||||
ATTR_PUSH_URL = "push_url"
|
ATTR_PUSH_URL = "push_url"
|
||||||
ATTR_PUSH_RATE_LIMITS = "rateLimits"
|
ATTR_PUSH_RATE_LIMITS = "rateLimits"
|
||||||
|
@ -76,3 +81,14 @@ SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update"
|
||||||
SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}"
|
SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}"
|
||||||
|
|
||||||
ATTR_CAMERA_ENTITY_ID = "camera_entity_id"
|
ATTR_CAMERA_ENTITY_ID = "camera_entity_id"
|
||||||
|
|
||||||
|
SCHEMA_APP_DATA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Inclusive(ATTR_PUSH_TOKEN, "push_cloud"): cv.string,
|
||||||
|
vol.Inclusive(ATTR_PUSH_URL, "push_cloud"): cv.url,
|
||||||
|
# Set to True to indicate that this registration will connect via websocket channel
|
||||||
|
# to receive push notifications.
|
||||||
|
vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean,
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
|
|
@ -32,6 +32,7 @@ from .const import (
|
||||||
CONF_SECRET,
|
CONF_SECRET,
|
||||||
CONF_USER_ID,
|
CONF_USER_ID,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
SCHEMA_APP_DATA,
|
||||||
)
|
)
|
||||||
from .helpers import supports_encryption
|
from .helpers import supports_encryption
|
||||||
|
|
||||||
|
@ -45,7 +46,7 @@ class RegistrationsView(HomeAssistantView):
|
||||||
@RequestDataValidator(
|
@RequestDataValidator(
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_APP_DATA, default={}): dict,
|
vol.Optional(ATTR_APP_DATA, default={}): SCHEMA_APP_DATA,
|
||||||
vol.Required(ATTR_APP_ID): cv.string,
|
vol.Required(ATTR_APP_ID): cv.string,
|
||||||
vol.Required(ATTR_APP_NAME): cv.string,
|
vol.Required(ATTR_APP_NAME): cv.string,
|
||||||
vol.Required(ATTR_APP_VERSION): cv.string,
|
vol.Required(ATTR_APP_VERSION): cv.string,
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
"requirements": ["PyNaCl==1.4.0", "emoji==1.5.0"],
|
"requirements": ["PyNaCl==1.4.0", "emoji==1.5.0"],
|
||||||
"dependencies": ["http", "webhook", "person", "tag", "websocket_api"],
|
"dependencies": ["http", "webhook", "person", "tag", "websocket_api"],
|
||||||
"after_dependencies": ["cloud", "camera", "notify"],
|
"after_dependencies": ["cloud", "camera", "notify"],
|
||||||
"codeowners": ["@robbiet480"],
|
"codeowners": ["@home-assistant/core"],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"iot_class": "local_push"
|
"iot_class": "local_push"
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ from homeassistant.components.notify import (
|
||||||
ATTR_TITLE_DEFAULT,
|
ATTR_TITLE_DEFAULT,
|
||||||
BaseNotificationService,
|
BaseNotificationService,
|
||||||
)
|
)
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
|
@ -118,20 +119,28 @@ class MobileAppNotificationService(BaseNotificationService):
|
||||||
local_push_channels = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL]
|
local_push_channels = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL]
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
|
registration = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target].data
|
||||||
|
|
||||||
if target in local_push_channels:
|
if target in local_push_channels:
|
||||||
local_push_channels[target].async_send_notification(
|
local_push_channels[target].async_send_notification(
|
||||||
data, partial(self._async_send_remote_message_target, target)
|
data,
|
||||||
|
partial(
|
||||||
|
self._async_send_remote_message_target, target, registration
|
||||||
|
),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await self._async_send_remote_message_target(target, data)
|
# Test if local push only.
|
||||||
|
if ATTR_PUSH_URL not in registration[ATTR_APP_DATA]:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Device not connected to local push notifications"
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_send_remote_message_target(self, target, data):
|
await self._async_send_remote_message_target(target, registration, data)
|
||||||
|
|
||||||
|
async def _async_send_remote_message_target(self, target, registration, data):
|
||||||
"""Send a message to a target."""
|
"""Send a message to a target."""
|
||||||
entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target]
|
app_data = registration[ATTR_APP_DATA]
|
||||||
entry_data = entry.data
|
|
||||||
|
|
||||||
app_data = entry_data[ATTR_APP_DATA]
|
|
||||||
push_token = app_data[ATTR_PUSH_TOKEN]
|
push_token = app_data[ATTR_PUSH_TOKEN]
|
||||||
push_url = app_data[ATTR_PUSH_URL]
|
push_url = app_data[ATTR_PUSH_URL]
|
||||||
|
|
||||||
|
@ -139,12 +148,12 @@ class MobileAppNotificationService(BaseNotificationService):
|
||||||
target_data[ATTR_PUSH_TOKEN] = push_token
|
target_data[ATTR_PUSH_TOKEN] = push_token
|
||||||
|
|
||||||
reg_info = {
|
reg_info = {
|
||||||
ATTR_APP_ID: entry_data[ATTR_APP_ID],
|
ATTR_APP_ID: registration[ATTR_APP_ID],
|
||||||
ATTR_APP_VERSION: entry_data[ATTR_APP_VERSION],
|
ATTR_APP_VERSION: registration[ATTR_APP_VERSION],
|
||||||
ATTR_WEBHOOK_ID: target,
|
ATTR_WEBHOOK_ID: target,
|
||||||
}
|
}
|
||||||
if ATTR_OS_VERSION in entry_data:
|
if ATTR_OS_VERSION in registration:
|
||||||
reg_info[ATTR_OS_VERSION] = entry_data[ATTR_OS_VERSION]
|
reg_info[ATTR_OS_VERSION] = registration[ATTR_OS_VERSION]
|
||||||
|
|
||||||
target_data["registration_info"] = reg_info
|
target_data["registration_info"] = reg_info
|
||||||
|
|
||||||
|
@ -160,7 +169,7 @@ class MobileAppNotificationService(BaseNotificationService):
|
||||||
HTTPStatus.CREATED,
|
HTTPStatus.CREATED,
|
||||||
HTTPStatus.ACCEPTED,
|
HTTPStatus.ACCEPTED,
|
||||||
):
|
):
|
||||||
log_rate_limits(self.hass, entry_data[ATTR_DEVICE_NAME], result)
|
log_rate_limits(self.hass, registration[ATTR_DEVICE_NAME], result)
|
||||||
return
|
return
|
||||||
|
|
||||||
fallback_error = result.get("errorMessage", "Unknown error")
|
fallback_error = result.get("errorMessage", "Unknown error")
|
||||||
|
@ -177,7 +186,7 @@ class MobileAppNotificationService(BaseNotificationService):
|
||||||
if response.status == HTTPStatus.TOO_MANY_REQUESTS:
|
if response.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||||
_LOGGER.warning(message)
|
_LOGGER.warning(message)
|
||||||
log_rate_limits(
|
log_rate_limits(
|
||||||
self.hass, entry_data[ATTR_DEVICE_NAME], result, logging.WARNING
|
self.hass, registration[ATTR_DEVICE_NAME], result, logging.WARNING
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.error(message)
|
_LOGGER.error(message)
|
||||||
|
|
|
@ -9,6 +9,7 @@ from .const import (
|
||||||
ATTR_APP_DATA,
|
ATTR_APP_DATA,
|
||||||
ATTR_PUSH_TOKEN,
|
ATTR_PUSH_TOKEN,
|
||||||
ATTR_PUSH_URL,
|
ATTR_PUSH_URL,
|
||||||
|
ATTR_PUSH_WEBSOCKET_CHANNEL,
|
||||||
DATA_CONFIG_ENTRIES,
|
DATA_CONFIG_ENTRIES,
|
||||||
DATA_DEVICES,
|
DATA_DEVICES,
|
||||||
DATA_NOTIFY,
|
DATA_NOTIFY,
|
||||||
|
@ -37,7 +38,9 @@ def supports_push(hass, webhook_id: str) -> bool:
|
||||||
"""Return if push notifications is supported."""
|
"""Return if push notifications is supported."""
|
||||||
config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
|
config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
|
||||||
app_data = config_entry.data[ATTR_APP_DATA]
|
app_data = config_entry.data[ATTR_APP_DATA]
|
||||||
return ATTR_PUSH_TOKEN in app_data and ATTR_PUSH_URL in app_data
|
return (
|
||||||
|
ATTR_PUSH_TOKEN in app_data and ATTR_PUSH_URL in app_data
|
||||||
|
) or ATTR_PUSH_WEBSOCKET_CHANNEL in app_data
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
|
|
@ -91,6 +91,7 @@ from .const import (
|
||||||
ERR_ENCRYPTION_REQUIRED,
|
ERR_ENCRYPTION_REQUIRED,
|
||||||
ERR_INVALID_FORMAT,
|
ERR_INVALID_FORMAT,
|
||||||
ERR_SENSOR_NOT_REGISTERED,
|
ERR_SENSOR_NOT_REGISTERED,
|
||||||
|
SCHEMA_APP_DATA,
|
||||||
SIGNAL_LOCATION_UPDATE,
|
SIGNAL_LOCATION_UPDATE,
|
||||||
SIGNAL_SENSOR_UPDATE,
|
SIGNAL_SENSOR_UPDATE,
|
||||||
)
|
)
|
||||||
|
@ -332,7 +333,7 @@ async def webhook_update_location(hass, config_entry, data):
|
||||||
@WEBHOOK_COMMANDS.register("update_registration")
|
@WEBHOOK_COMMANDS.register("update_registration")
|
||||||
@validate_schema(
|
@validate_schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_APP_DATA, default={}): dict,
|
vol.Optional(ATTR_APP_DATA): SCHEMA_APP_DATA,
|
||||||
vol.Required(ATTR_APP_VERSION): cv.string,
|
vol.Required(ATTR_APP_VERSION): cv.string,
|
||||||
vol.Required(ATTR_DEVICE_NAME): cv.string,
|
vol.Required(ATTR_DEVICE_NAME): cv.string,
|
||||||
vol.Required(ATTR_MANUFACTURER): cv.string,
|
vol.Required(ATTR_MANUFACTURER): cv.string,
|
||||||
|
|
|
@ -286,7 +286,12 @@ async def async_test_home_assistant(loop, load_registries=True):
|
||||||
hass.config.media_dirs = {"local": get_test_config_dir("media")}
|
hass.config.media_dirs = {"local": get_test_config_dir("media")}
|
||||||
hass.config.skip_pip = True
|
hass.config.skip_pip = True
|
||||||
|
|
||||||
hass.config_entries = config_entries.ConfigEntries(hass, {})
|
hass.config_entries = config_entries.ConfigEntries(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"_": "Not empty or else some bad checks for hass config in discovery.py breaks"
|
||||||
|
},
|
||||||
|
)
|
||||||
hass.config_entries._entries = {}
|
hass.config_entries._entries = {}
|
||||||
hass.config_entries._store._async_ensure_stop_listener = lambda: None
|
hass.config_entries._store._async_ensure_stop_listener = lambda: None
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.mobile_app.const import DOMAIN
|
from homeassistant.components.mobile_app.const import DOMAIN
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
@ -102,6 +103,38 @@ async def setup_push_receiver(hass, aioclient_mock, hass_admin_user):
|
||||||
assert hass.services.has_service("notify", "mobile_app_loaded_late")
|
assert hass.services.has_service("notify", "mobile_app_loaded_late")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def setup_websocket_channel_only_push(hass, hass_admin_user):
|
||||||
|
"""Set up local push."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
data={
|
||||||
|
"app_data": {"push_websocket_channel": True},
|
||||||
|
"app_id": "io.homeassistant.mobile_app",
|
||||||
|
"app_name": "mobile_app tests",
|
||||||
|
"app_version": "1.0",
|
||||||
|
"device_id": "websocket-push-device-id",
|
||||||
|
"device_name": "Websocket Push Name",
|
||||||
|
"manufacturer": "Home Assistant",
|
||||||
|
"model": "mobile_app",
|
||||||
|
"os_name": "Linux",
|
||||||
|
"os_version": "5.0.6",
|
||||||
|
"secret": "123abc2",
|
||||||
|
"supports_encryption": False,
|
||||||
|
"user_id": hass_admin_user.id,
|
||||||
|
"webhook_id": "websocket-push-webhook-id",
|
||||||
|
},
|
||||||
|
domain=DOMAIN,
|
||||||
|
source="registration",
|
||||||
|
title="websocket push test entry",
|
||||||
|
version=1,
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert hass.services.has_service("notify", "mobile_app_websocket_push_name")
|
||||||
|
|
||||||
|
|
||||||
async def test_notify_works(hass, aioclient_mock, setup_push_receiver):
|
async def test_notify_works(hass, aioclient_mock, setup_push_receiver):
|
||||||
"""Test notify works."""
|
"""Test notify works."""
|
||||||
assert hass.services.has_service("notify", "mobile_app_test") is True
|
assert hass.services.has_service("notify", "mobile_app_test") is True
|
||||||
|
@ -333,3 +366,39 @@ async def test_notify_ws_not_confirming(
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(aioclient_mock.mock_calls) == 3
|
assert len(aioclient_mock.mock_calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_local_push_only(hass, hass_ws_client, setup_websocket_channel_only_push):
|
||||||
|
"""Test a local only push registration."""
|
||||||
|
with pytest.raises(HomeAssistantError) as e_info:
|
||||||
|
assert await hass.services.async_call(
|
||||||
|
"notify",
|
||||||
|
"mobile_app_websocket_push_name",
|
||||||
|
{"message": "Not connected"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert str(e_info.value) == "Device not connected to local push notifications"
|
||||||
|
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "mobile_app/push_notification_channel",
|
||||||
|
"webhook_id": "websocket-push-webhook-id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
sub_result = await client.receive_json()
|
||||||
|
assert sub_result["success"]
|
||||||
|
|
||||||
|
assert await hass.services.async_call(
|
||||||
|
"notify",
|
||||||
|
"mobile_app_websocket_push_name",
|
||||||
|
{"message": "Hello world 1"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = await client.receive_json()
|
||||||
|
assert msg == {"id": 5, "type": "event", "event": {"message": "Hello world 1"}}
|
||||||
|
|
Loading…
Add table
Reference in a new issue