Integrations v2.1: Differentiating hubs, devices and services (#80524)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Franck Nijhof 2022-10-19 12:41:43 +02:00 committed by GitHub
parent 0c8884fd51
commit c4bbc439a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1376 additions and 105 deletions

View file

@ -6,5 +6,6 @@
"requirements": ["adguardhome==0.5.1"],
"codeowners": ["@frenck"],
"iot_class": "local_polling",
"integration_type": "service",
"loggers": ["adguardhome"]
}

View file

@ -15,6 +15,7 @@ from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import DependencyError, Unauthorized
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.data_entry_flow import (
FlowManagerIndexView,
FlowManagerResourceView,
@ -64,7 +65,7 @@ class ConfigManagerEntryIndexView(HomeAssistantView):
domain = request.query["domain"]
type_filter = None
if "type" in request.query:
type_filter = request.query["type"]
type_filter = [request.query["type"]]
return self.json(await async_matching_config_entries(hass, type_filter, domain))
@ -405,7 +406,7 @@ async def ignore_config_flow(
@websocket_api.websocket_command(
{
vol.Required("type"): "config_entries/get",
vol.Optional("type_filter"): str,
vol.Optional("type_filter"): vol.All(cv.ensure_list, [str]),
vol.Optional("domain"): str,
}
)
@ -427,7 +428,7 @@ async def config_entries_get(
@websocket_api.websocket_command(
{
vol.Required("type"): "config_entries/subscribe",
vol.Optional("type_filter"): str,
vol.Optional("type_filter"): vol.All(cv.ensure_list, [str]),
}
)
@websocket_api.async_response
@ -445,7 +446,7 @@ async def config_entries_subscribe(
"""Forward config entry state events to websocket."""
if type_filter:
integration = await async_get_integration(hass, entry.domain)
if integration.integration_type != type_filter:
if integration.integration_type not in type_filter:
return
connection.send_message(
@ -475,7 +476,7 @@ async def config_entries_subscribe(
async def async_matching_config_entries(
hass: HomeAssistant, type_filter: str | None, domain: str | None
hass: HomeAssistant, type_filter: list[str] | None, domain: str | None
) -> list[dict[str, Any]]:
"""Return matching config entries by type and/or domain."""
kwargs = {}
@ -483,7 +484,7 @@ async def async_matching_config_entries(
kwargs["domain"] = domain
entries = hass.config_entries.async_entries(**kwargs)
if type_filter is None:
if not type_filter:
return [entry_json(entry) for entry in entries]
integrations = {}
@ -499,13 +500,17 @@ async def async_matching_config_entries(
elif not isinstance(integration_or_exc, IntegrationNotFound):
raise integration_or_exc
# Filter out entries that don't match the type filter
# when only helpers are requested, also filter out entries
# from unknown integrations. This prevent them from showing
# up in the helpers UI.
entries = [
entry
for entry in entries
if (type_filter != "helper" and entry.domain not in integrations)
if (type_filter != ["helper"] and entry.domain not in integrations)
or (
entry.domain in integrations
and integrations[entry.domain].integration_type == type_filter
and integrations[entry.domain].integration_type in type_filter
)
]

View file

@ -9,5 +9,6 @@
"codeowners": ["@OttoWinter", "@jesserockz"],
"after_dependencies": ["bluetooth", "zeroconf", "tag"],
"iot_class": "local_push",
"integration_type": "device",
"loggers": ["aioesphomeapi", "noiseprotocol"]
}

View file

@ -25,5 +25,6 @@
"codeowners": ["@balloob", "@marcelveldt"],
"quality_scale": "platinum",
"iot_class": "local_push",
"integration_type": "hub",
"loggers": ["aiohue"]
}

File diff suppressed because it is too large Load diff

View file

@ -130,7 +130,9 @@ class Manifest(TypedDict, total=False):
name: str
disabled: str
domain: str
integration_type: Literal["entity", "integration", "hardware", "helper", "system"]
integration_type: Literal[
"entity", "device", "hardware", "helper", "hub", "service", "system"
]
dependencies: list[str]
after_dependencies: list[str]
requirements: list[str]
@ -224,7 +226,7 @@ async def async_get_custom_components(
async def async_get_config_flows(
hass: HomeAssistant,
type_filter: Literal["helper", "integration"] | None = None,
type_filter: Literal["device", "helper", "hub", "service"] | None = None,
) -> set[str]:
"""Return cached list of config flows."""
# pylint: disable=import-outside-toplevel
@ -262,9 +264,11 @@ async def async_get_integration_descriptions(
core_flows: dict[str, Any] = json_loads(flow)
custom_integrations = await async_get_custom_components(hass)
custom_flows: dict[str, Any] = {
"integration": {},
"device": {},
"hardware": {},
"helper": {},
"hub": {},
"service": {},
}
for integration in custom_integrations.values():
@ -272,7 +276,7 @@ async def async_get_integration_descriptions(
if integration.integration_type in ("entity", "system"):
continue
for integration_type in ("integration", "hardware", "helper"):
for integration_type in ("device", "hardware", "helper", "hub", "service"):
if integration.domain not in core_flows[integration_type]:
continue
del core_flows[integration_type][integration.domain]
@ -281,6 +285,7 @@ async def async_get_integration_descriptions(
metadata = {
"config_flow": integration.config_flow,
"integration_type": integration.integration_type,
"iot_class": integration.iot_class,
"name": integration.name,
}
@ -599,9 +604,9 @@ class Integration:
@property
def integration_type(
self,
) -> Literal["entity", "integration", "hardware", "helper", "system"]:
) -> Literal["entity", "device", "hardware", "helper", "hub", "service", "system"]:
"""Return the integration type."""
return self.manifest.get("integration_type", "integration")
return self.manifest.get("integration_type", "hub")
@property
def mqtt(self) -> list[str] | None:

View file

@ -86,7 +86,10 @@ def _generate_and_validate(integrations: dict[str, Integration], config: Config)
_validate_integration(config, integration)
domains[integration.integration_type].append(domain)
if integration.integration_type == "helper":
domains["helper"].append(domain)
else:
domains["integration"].append(domain)
return black.format_str(BASE.format(to_string(domains)), mode=black.Mode())
@ -106,6 +109,7 @@ def _populate_brand_integrations(
metadata = {}
metadata["config_flow"] = integration.config_flow
metadata["iot_class"] = integration.iot_class
metadata["integration_type"] = integration.integration_type
if integration.translated_name:
integration_data["translated_name"].add(domain)
else:
@ -169,11 +173,16 @@ def _generate_integrations(
continue
metadata["config_flow"] = integration.config_flow
metadata["iot_class"] = integration.iot_class
metadata["integration_type"] = integration.integration_type
if integration.translated_name:
result["translated_name"].add(domain)
else:
metadata["name"] = integration.name
result[integration.integration_type][domain] = metadata
if integration.integration_type == "helper":
result["helper"][domain] = metadata
else:
result["integration"][domain] = metadata
return json.dumps(
result | {"translated_name": sorted(result["translated_name"])}, indent=2

View file

@ -162,8 +162,16 @@ MANIFEST_SCHEMA = vol.Schema(
{
vol.Required("domain"): str,
vol.Required("name"): str,
vol.Optional("integration_type"): vol.In(
["entity", "hardware", "helper", "system"]
vol.Optional("integration_type", default="hub"): vol.In(
[
"device",
"entity",
"hardware",
"helper",
"hub",
"service",
"system",
]
),
vol.Optional("config_flow"): bool,
vol.Optional("mqtt"): [str],

View file

@ -177,7 +177,7 @@ class Integration:
@property
def integration_type(self) -> str:
"""Get integration_type."""
return self.manifest.get("integration_type", "integration")
return self.manifest.get("integration_type", "hub")
@property
def iot_class(self) -> str | None:

View file

@ -37,7 +37,6 @@ async def test_options_flow_disabled_not_setup(
"id": 5,
"type": "config_entries/get",
"domain": "bluetooth",
"type_filter": "integration",
}
)
response = await ws_client.receive_json()
@ -341,7 +340,6 @@ async def test_options_flow_disabled_macos(
"id": 5,
"type": "config_entries/get",
"domain": "bluetooth",
"type_filter": "integration",
}
)
response = await ws_client.receive_json()
@ -371,7 +369,6 @@ async def test_options_flow_enabled_linux(
"id": 5,
"type": "config_entries/get",
"domain": "bluetooth",
"type_filter": "integration",
}
)
response = await ws_client.receive_json()

View file

@ -51,7 +51,15 @@ async def test_get_entries(hass, client, clear_handlers):
mock_integration(
hass, MockModule("comp2", partial_manifest={"integration_type": "helper"})
)
mock_integration(hass, MockModule("comp3"))
mock_integration(
hass, MockModule("comp3", partial_manifest={"integration_type": "hub"})
)
mock_integration(
hass, MockModule("comp4", partial_manifest={"integration_type": "device"})
)
mock_integration(
hass, MockModule("comp5", partial_manifest={"integration_type": "service"})
)
@HANDLERS.register("comp1")
class Comp1ConfigFlow:
@ -91,6 +99,16 @@ async def test_get_entries(hass, client, clear_handlers):
source="bla3",
disabled_by=core_ce.ConfigEntryDisabler.USER,
).add_to_hass(hass)
MockConfigEntry(
domain="comp4",
title="Test 4",
source="bla4",
).add_to_hass(hass)
MockConfigEntry(
domain="comp5",
title="Test 5",
source="bla5",
).add_to_hass(hass)
resp = await client.get("/api/config/config_entries/entry")
assert resp.status == HTTPStatus.OK
@ -137,6 +155,32 @@ async def test_get_entries(hass, client, clear_handlers):
"disabled_by": core_ce.ConfigEntryDisabler.USER,
"reason": None,
},
{
"domain": "comp4",
"title": "Test 4",
"source": "bla4",
"state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supports_options": False,
"supports_remove_device": False,
"supports_unload": False,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"disabled_by": None,
"reason": None,
},
{
"domain": "comp5",
"title": "Test 5",
"source": "bla5",
"state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supports_options": False,
"supports_remove_device": False,
"supports_unload": False,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"disabled_by": None,
"reason": None,
},
]
resp = await client.get("/api/config/config_entries/entry?domain=comp3")
@ -150,20 +194,25 @@ async def test_get_entries(hass, client, clear_handlers):
data = await resp.json()
assert len(data) == 0
resp = await client.get(
"/api/config/config_entries/entry?domain=comp3&type=integration"
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert len(data) == 1
resp = await client.get("/api/config/config_entries/entry?type=integration")
resp = await client.get("/api/config/config_entries/entry?type=hub")
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert len(data) == 2
assert data[0]["domain"] == "comp1"
assert data[1]["domain"] == "comp3"
resp = await client.get("/api/config/config_entries/entry?type=device")
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert len(data) == 1
assert data[0]["domain"] == "comp4"
resp = await client.get("/api/config/config_entries/entry?type=service")
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert len(data) == 1
assert data[0]["domain"] == "comp5"
async def test_remove_entry(hass, client):
"""Test removing an entry via the API."""
@ -1123,7 +1172,16 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers):
mock_integration(
hass, MockModule("comp2", partial_manifest={"integration_type": "helper"})
)
mock_integration(hass, MockModule("comp3"))
mock_integration(
hass, MockModule("comp3", partial_manifest={"integration_type": "hub"})
)
mock_integration(
hass, MockModule("comp4", partial_manifest={"integration_type": "device"})
)
mock_integration(
hass, MockModule("comp5", partial_manifest={"integration_type": "service"})
)
entry = MockConfigEntry(
domain="comp1",
title="Test 1",
@ -1143,6 +1201,16 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers):
source="bla3",
disabled_by=core_ce.ConfigEntryDisabler.USER,
).add_to_hass(hass)
MockConfigEntry(
domain="comp4",
title="Test 4",
source="bla4",
).add_to_hass(hass)
MockConfigEntry(
domain="comp5",
title="Test 5",
source="bla5",
).add_to_hass(hass)
ws_client = await hass_ws_client(hass)
@ -1197,6 +1265,34 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers):
"supports_unload": False,
"title": "Test 3",
},
{
"disabled_by": None,
"domain": "comp4",
"entry_id": ANY,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla4",
"state": "not_loaded",
"supports_options": False,
"supports_remove_device": False,
"supports_unload": False,
"title": "Test 4",
},
{
"disabled_by": None,
"domain": "comp5",
"entry_id": ANY,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla5",
"state": "not_loaded",
"supports_options": False,
"supports_remove_device": False,
"supports_unload": False,
"title": "Test 5",
},
]
await ws_client.send_json(
@ -1204,7 +1300,7 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers):
"id": 6,
"type": "config_entries/get",
"domain": "comp1",
"type_filter": "integration",
"type_filter": "hub",
}
)
response = await ws_client.receive_json()
@ -1225,22 +1321,102 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers):
"title": "Test 1",
}
]
# Verify we skip broken integrations
await ws_client.send_json(
{
"id": 7,
"type": "config_entries/get",
"type_filter": ["service", "device"],
}
)
response = await ws_client.receive_json()
assert response["id"] == 7
assert response["result"] == [
{
"disabled_by": None,
"domain": "comp4",
"entry_id": ANY,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla4",
"state": "not_loaded",
"supports_options": False,
"supports_remove_device": False,
"supports_unload": False,
"title": "Test 4",
},
{
"disabled_by": None,
"domain": "comp5",
"entry_id": ANY,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla5",
"state": "not_loaded",
"supports_options": False,
"supports_remove_device": False,
"supports_unload": False,
"title": "Test 5",
},
]
await ws_client.send_json(
{
"id": 8,
"type": "config_entries/get",
"type_filter": "hub",
}
)
response = await ws_client.receive_json()
assert response["id"] == 8
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": "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 skip broken integrations
with patch(
"homeassistant.components.config.config_entries.async_get_integration",
side_effect=IntegrationNotFound("any"),
):
await ws_client.send_json(
{
"id": 7,
"id": 9,
"type": "config_entries/get",
"type_filter": "integration",
"type_filter": "hub",
}
)
response = await ws_client.receive_json()
assert response["id"] == 7
assert response["id"] == 9
assert response["result"] == [
{
"disabled_by": None,
@ -1284,8 +1460,53 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers):
"supports_unload": False,
"title": "Test 3",
},
{
"disabled_by": None,
"domain": "comp4",
"entry_id": ANY,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla4",
"state": "not_loaded",
"supports_options": False,
"supports_remove_device": False,
"supports_unload": False,
"title": "Test 4",
},
{
"disabled_by": None,
"domain": "comp5",
"entry_id": ANY,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla5",
"state": "not_loaded",
"supports_options": False,
"supports_remove_device": False,
"supports_unload": False,
"title": "Test 5",
},
]
# Verify we don't send config entries when only helpers are requested
with patch(
"homeassistant.components.config.config_entries.async_get_integration",
side_effect=IntegrationNotFound("any"),
):
await ws_client.send_json(
{
"id": 10,
"type": "config_entries/get",
"type_filter": ["helper"],
}
)
response = await ws_client.receive_json()
assert response["id"] == 10
assert response["result"] == []
# Verify we raise if something really goes wrong
with patch(
@ -1294,14 +1515,14 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers):
):
await ws_client.send_json(
{
"id": 8,
"id": 11,
"type": "config_entries/get",
"type_filter": "integration",
"type_filter": ["device", "hub", "service"],
}
)
response = await ws_client.receive_json()
assert response["id"] == 8
assert response["id"] == 11
assert response["success"] is False
@ -1312,7 +1533,9 @@ async def test_subscribe_entries_ws(hass, hass_ws_client, clear_handlers):
mock_integration(
hass, MockModule("comp2", partial_manifest={"integration_type": "helper"})
)
mock_integration(hass, MockModule("comp3"))
mock_integration(
hass, MockModule("comp3", partial_manifest={"integration_type": "device"})
)
entry = MockConfigEntry(
domain="comp1",
title="Test 1",
@ -1476,7 +1699,12 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler
mock_integration(
hass, MockModule("comp2", partial_manifest={"integration_type": "helper"})
)
mock_integration(hass, MockModule("comp3"))
mock_integration(
hass, MockModule("comp3", partial_manifest={"integration_type": "device"})
)
mock_integration(
hass, MockModule("comp4", partial_manifest={"integration_type": "service"})
)
entry = MockConfigEntry(
domain="comp1",
title="Test 1",
@ -1491,12 +1719,19 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler
reason="Unsupported API",
)
entry2.add_to_hass(hass)
MockConfigEntry(
entry3 = MockConfigEntry(
domain="comp3",
title="Test 3",
source="bla3",
disabled_by=core_ce.ConfigEntryDisabler.USER,
).add_to_hass(hass)
)
entry3.add_to_hass(hass)
entry4 = MockConfigEntry(
domain="comp4",
title="Test 4",
source="bla4",
)
entry4.add_to_hass(hass)
ws_client = await hass_ws_client(hass)
@ -1504,7 +1739,7 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler
{
"id": 5,
"type": "config_entries/subscribe",
"type_filter": "integration",
"type_filter": ["hub", "device"],
}
)
response = await ws_client.receive_json()
@ -1551,6 +1786,8 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler
},
]
assert hass.config_entries.async_update_entry(entry, title="changed")
assert hass.config_entries.async_update_entry(entry3, title="changed too")
assert hass.config_entries.async_update_entry(entry4, title="changed but ignored")
response = await ws_client.receive_json()
assert response["id"] == 5
assert response["event"] == [
@ -1572,6 +1809,27 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler
"type": "updated",
}
]
response = await ws_client.receive_json()
assert response["id"] == 5
assert response["event"] == [
{
"entry": {
"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": "changed too",
},
"type": "updated",
}
]
await hass.config_entries.async_remove(entry.entry_id)
await hass.config_entries.async_remove(entry2.entry_id)
response = await ws_client.receive_json()