Add websocket api to fetch config entries (#73570)
* Add websocket api to fetch config entries * add coverage for failure case
This commit is contained in:
parent
9940a85e28
commit
adf0f62963
2 changed files with 265 additions and 45 deletions
|
@ -3,22 +3,29 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import aiohttp.web_exceptions
|
import aiohttp.web_exceptions
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries, data_entry_flow, loader
|
from homeassistant import config_entries, data_entry_flow
|
||||||
from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT
|
from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT
|
||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
from homeassistant.components.websocket_api.connection import ActiveConnection
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import DependencyError, Unauthorized
|
from homeassistant.exceptions import DependencyError, Unauthorized
|
||||||
from homeassistant.helpers.data_entry_flow import (
|
from homeassistant.helpers.data_entry_flow import (
|
||||||
FlowManagerIndexView,
|
FlowManagerIndexView,
|
||||||
FlowManagerResourceView,
|
FlowManagerResourceView,
|
||||||
)
|
)
|
||||||
from homeassistant.loader import Integration, async_get_config_flows
|
from homeassistant.loader import (
|
||||||
|
Integration,
|
||||||
|
IntegrationNotFound,
|
||||||
|
async_get_config_flows,
|
||||||
|
async_get_integration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass):
|
async def async_setup(hass):
|
||||||
|
@ -33,6 +40,7 @@ async def async_setup(hass):
|
||||||
hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options))
|
hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options))
|
||||||
hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options))
|
hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options))
|
||||||
|
|
||||||
|
websocket_api.async_register_command(hass, config_entries_get)
|
||||||
websocket_api.async_register_command(hass, config_entry_disable)
|
websocket_api.async_register_command(hass, config_entry_disable)
|
||||||
websocket_api.async_register_command(hass, config_entry_update)
|
websocket_api.async_register_command(hass, config_entry_update)
|
||||||
websocket_api.async_register_command(hass, config_entries_progress)
|
websocket_api.async_register_command(hass, config_entries_progress)
|
||||||
|
@ -50,49 +58,13 @@ class ConfigManagerEntryIndexView(HomeAssistantView):
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
"""List available config entries."""
|
"""List available config entries."""
|
||||||
hass: HomeAssistant = request.app["hass"]
|
hass: HomeAssistant = request.app["hass"]
|
||||||
|
domain = None
|
||||||
kwargs = {}
|
|
||||||
if "domain" in request.query:
|
if "domain" in request.query:
|
||||||
kwargs["domain"] = request.query["domain"]
|
domain = request.query["domain"]
|
||||||
|
type_filter = None
|
||||||
entries = hass.config_entries.async_entries(**kwargs)
|
if "type" in request.query:
|
||||||
|
type_filter = request.query["type"]
|
||||||
if "type" not in request.query:
|
return self.json(await async_matching_config_entries(hass, type_filter, domain))
|
||||||
return self.json([entry_json(entry) for entry in entries])
|
|
||||||
|
|
||||||
integrations = {}
|
|
||||||
type_filter = request.query["type"]
|
|
||||||
|
|
||||||
async def load_integration(
|
|
||||||
hass: HomeAssistant, domain: str
|
|
||||||
) -> Integration | None:
|
|
||||||
"""Load integration."""
|
|
||||||
try:
|
|
||||||
return await loader.async_get_integration(hass, domain)
|
|
||||||
except loader.IntegrationNotFound:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Fetch all the integrations so we can check their type
|
|
||||||
for integration in await asyncio.gather(
|
|
||||||
*(
|
|
||||||
load_integration(hass, domain)
|
|
||||||
for domain in {entry.domain for entry in entries}
|
|
||||||
)
|
|
||||||
):
|
|
||||||
if integration:
|
|
||||||
integrations[integration.domain] = integration
|
|
||||||
|
|
||||||
entries = [
|
|
||||||
entry
|
|
||||||
for entry in entries
|
|
||||||
if (type_filter != "helper" and entry.domain not in integrations)
|
|
||||||
or (
|
|
||||||
entry.domain in integrations
|
|
||||||
and integrations[entry.domain].integration_type == type_filter
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return self.json([entry_json(entry) for entry in entries])
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigManagerEntryResourceView(HomeAssistantView):
|
class ConfigManagerEntryResourceView(HomeAssistantView):
|
||||||
|
@ -415,6 +387,64 @@ async def ignore_config_flow(hass, connection, msg):
|
||||||
connection.send_result(msg["id"])
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "config_entries/get",
|
||||||
|
vol.Optional("type_filter"): str,
|
||||||
|
vol.Optional("domain"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def config_entries_get(
|
||||||
|
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Return matching config entries by type and/or domain."""
|
||||||
|
connection.send_result(
|
||||||
|
msg["id"],
|
||||||
|
await async_matching_config_entries(
|
||||||
|
hass, msg.get("type_filter"), msg.get("domain")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_matching_config_entries(
|
||||||
|
hass: HomeAssistant, type_filter: str | None, domain: str | None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return matching config entries by type and/or domain."""
|
||||||
|
kwargs = {}
|
||||||
|
if domain:
|
||||||
|
kwargs["domain"] = domain
|
||||||
|
entries = hass.config_entries.async_entries(**kwargs)
|
||||||
|
|
||||||
|
if type_filter is None:
|
||||||
|
return [entry_json(entry) for entry in entries]
|
||||||
|
|
||||||
|
integrations = {}
|
||||||
|
# Fetch all the integrations so we can check their type
|
||||||
|
tasks = (
|
||||||
|
async_get_integration(hass, domain)
|
||||||
|
for domain in {entry.domain for entry in entries}
|
||||||
|
)
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for integration_or_exc in results:
|
||||||
|
if isinstance(integration_or_exc, Integration):
|
||||||
|
integrations[integration_or_exc.domain] = integration_or_exc
|
||||||
|
elif not isinstance(integration_or_exc, IntegrationNotFound):
|
||||||
|
raise integration_or_exc
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
entry
|
||||||
|
for entry in entries
|
||||||
|
if (type_filter != "helper" and entry.domain not in integrations)
|
||||||
|
or (
|
||||||
|
entry.domain in integrations
|
||||||
|
and integrations[entry.domain].integration_type == type_filter
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return [entry_json(entry) for entry in entries]
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def entry_json(entry: config_entries.ConfigEntry) -> dict:
|
def entry_json(entry: config_entries.ConfigEntry) -> dict:
|
||||||
"""Return JSON value of a config entry."""
|
"""Return JSON value of a config entry."""
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import ANY, AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
@ -13,6 +13,7 @@ from homeassistant.config_entries import HANDLERS, ConfigFlow
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.generated import config_flows
|
from homeassistant.generated import config_flows
|
||||||
from homeassistant.helpers import config_entry_flow, config_validation as cv
|
from homeassistant.helpers import config_entry_flow, config_validation as cv
|
||||||
|
from homeassistant.loader import IntegrationNotFound
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from tests.common import (
|
from tests.common import (
|
||||||
|
@ -1113,3 +1114,192 @@ async def test_ignore_flow_nonexisting(hass, hass_ws_client):
|
||||||
|
|
||||||
assert not response["success"]
|
assert not response["success"]
|
||||||
assert response["error"]["code"] == "not_found"
|
assert response["error"]["code"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_entries_ws(hass, hass_ws_client, clear_handlers):
|
||||||
|
"""Test get entries with the websocket api."""
|
||||||
|
assert await async_setup_component(hass, "config", {})
|
||||||
|
mock_integration(hass, MockModule("comp1"))
|
||||||
|
mock_integration(
|
||||||
|
hass, MockModule("comp2", partial_manifest={"integration_type": "helper"})
|
||||||
|
)
|
||||||
|
mock_integration(hass, MockModule("comp3"))
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain="comp1",
|
||||||
|
title="Test 1",
|
||||||
|
source="bla",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
MockConfigEntry(
|
||||||
|
domain="comp2",
|
||||||
|
title="Test 2",
|
||||||
|
source="bla2",
|
||||||
|
state=core_ce.ConfigEntryState.SETUP_ERROR,
|
||||||
|
reason="Unsupported API",
|
||||||
|
).add_to_hass(hass)
|
||||||
|
MockConfigEntry(
|
||||||
|
domain="comp3",
|
||||||
|
title="Test 3",
|
||||||
|
source="bla3",
|
||||||
|
disabled_by=core_ce.ConfigEntryDisabler.USER,
|
||||||
|
).add_to_hass(hass)
|
||||||
|
|
||||||
|
ws_client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "config_entries/get",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await ws_client.receive_json()
|
||||||
|
assert response["id"] == 5
|
||||||
|
assert response["result"] == [
|
||||||
|
{
|
||||||
|
"disabled_by": None,
|
||||||
|
"domain": "comp1",
|
||||||
|
"entry_id": ANY,
|
||||||
|
"pref_disable_new_entities": False,
|
||||||
|
"pref_disable_polling": False,
|
||||||
|
"reason": None,
|
||||||
|
"source": "bla",
|
||||||
|
"state": "not_loaded",
|
||||||
|
"supports_options": False,
|
||||||
|
"supports_remove_device": False,
|
||||||
|
"supports_unload": False,
|
||||||
|
"title": "Test 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disabled_by": None,
|
||||||
|
"domain": "comp2",
|
||||||
|
"entry_id": ANY,
|
||||||
|
"pref_disable_new_entities": False,
|
||||||
|
"pref_disable_polling": False,
|
||||||
|
"reason": "Unsupported API",
|
||||||
|
"source": "bla2",
|
||||||
|
"state": "setup_error",
|
||||||
|
"supports_options": False,
|
||||||
|
"supports_remove_device": False,
|
||||||
|
"supports_unload": False,
|
||||||
|
"title": "Test 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disabled_by": "user",
|
||||||
|
"domain": "comp3",
|
||||||
|
"entry_id": ANY,
|
||||||
|
"pref_disable_new_entities": False,
|
||||||
|
"pref_disable_polling": False,
|
||||||
|
"reason": None,
|
||||||
|
"source": "bla3",
|
||||||
|
"state": "not_loaded",
|
||||||
|
"supports_options": False,
|
||||||
|
"supports_remove_device": False,
|
||||||
|
"supports_unload": False,
|
||||||
|
"title": "Test 3",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "config_entries/get",
|
||||||
|
"domain": "comp1",
|
||||||
|
"type_filter": "integration",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await ws_client.receive_json()
|
||||||
|
assert response["id"] == 6
|
||||||
|
assert response["result"] == [
|
||||||
|
{
|
||||||
|
"disabled_by": None,
|
||||||
|
"domain": "comp1",
|
||||||
|
"entry_id": ANY,
|
||||||
|
"pref_disable_new_entities": False,
|
||||||
|
"pref_disable_polling": False,
|
||||||
|
"reason": None,
|
||||||
|
"source": "bla",
|
||||||
|
"state": "not_loaded",
|
||||||
|
"supports_options": False,
|
||||||
|
"supports_remove_device": False,
|
||||||
|
"supports_unload": False,
|
||||||
|
"title": "Test 1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
# Verify we skip broken integrations
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.config.config_entries.async_get_integration",
|
||||||
|
side_effect=IntegrationNotFound("any"),
|
||||||
|
):
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "config_entries/get",
|
||||||
|
"type_filter": "integration",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert response["id"] == 7
|
||||||
|
assert response["result"] == [
|
||||||
|
{
|
||||||
|
"disabled_by": None,
|
||||||
|
"domain": "comp1",
|
||||||
|
"entry_id": ANY,
|
||||||
|
"pref_disable_new_entities": False,
|
||||||
|
"pref_disable_polling": False,
|
||||||
|
"reason": None,
|
||||||
|
"source": "bla",
|
||||||
|
"state": "not_loaded",
|
||||||
|
"supports_options": False,
|
||||||
|
"supports_remove_device": False,
|
||||||
|
"supports_unload": False,
|
||||||
|
"title": "Test 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disabled_by": None,
|
||||||
|
"domain": "comp2",
|
||||||
|
"entry_id": ANY,
|
||||||
|
"pref_disable_new_entities": False,
|
||||||
|
"pref_disable_polling": False,
|
||||||
|
"reason": "Unsupported API",
|
||||||
|
"source": "bla2",
|
||||||
|
"state": "setup_error",
|
||||||
|
"supports_options": False,
|
||||||
|
"supports_remove_device": False,
|
||||||
|
"supports_unload": False,
|
||||||
|
"title": "Test 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disabled_by": "user",
|
||||||
|
"domain": "comp3",
|
||||||
|
"entry_id": ANY,
|
||||||
|
"pref_disable_new_entities": False,
|
||||||
|
"pref_disable_polling": False,
|
||||||
|
"reason": None,
|
||||||
|
"source": "bla3",
|
||||||
|
"state": "not_loaded",
|
||||||
|
"supports_options": False,
|
||||||
|
"supports_remove_device": False,
|
||||||
|
"supports_unload": False,
|
||||||
|
"title": "Test 3",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Verify we raise if something really goes wrong
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.config.config_entries.async_get_integration",
|
||||||
|
side_effect=Exception,
|
||||||
|
):
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "config_entries/get",
|
||||||
|
"type_filter": "integration",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert response["id"] == 8
|
||||||
|
assert response["success"] is False
|
||||||
|
|
Loading…
Add table
Reference in a new issue