Add support for subentries to config entries (#117355)

* Add support for subentries to config entries

* Improve error handling and test coverage

* Include subentry_id in subentry containers

* Auto-generate subentry_id and add optional unique_id

* Tweak

* Update tests

* Fix stale docstring

* Address review comments

* Typing tweaks

* Add methods to ConfigEntries to add and remove subentry

* Improve ConfigSubentryData typed dict

* Update test snapshots

* Adjust tests

* Fix unique_id logic

* Allow multiple subentries with None unique_id

* Add number of subentries to config entry JSON representation

* Add subentry translation support

* Allow integrations to implement multiple subentry flows

* Update translations schema

* Adjust exception text

* Change subentry flow init step to user

* Prevent creating a subentry with colliding unique_id

* Update tests

* Address review comments

* Remove duplicaetd unique_id collision check

* Remove change from the future

* Improve test coverage

* Add default value for unique_id
This commit is contained in:
Erik Montnemery 2024-12-12 20:16:18 +01:00 committed by GitHub
parent 32c1b519ad
commit ad15786115
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 1771 additions and 30 deletions

View File

@ -46,6 +46,13 @@ def async_setup(hass: HomeAssistant) -> bool:
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))
hass.http.register_view(
SubentryManagerFlowIndexView(hass.config_entries.subentries)
)
hass.http.register_view(
SubentryManagerFlowResourceView(hass.config_entries.subentries)
)
websocket_api.async_register_command(hass, config_entries_get) 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_get_single) websocket_api.async_register_command(hass, config_entry_get_single)
@ -54,6 +61,9 @@ def async_setup(hass: HomeAssistant) -> bool:
websocket_api.async_register_command(hass, config_entries_progress) websocket_api.async_register_command(hass, config_entries_progress)
websocket_api.async_register_command(hass, ignore_config_flow) websocket_api.async_register_command(hass, ignore_config_flow)
websocket_api.async_register_command(hass, config_subentry_delete)
websocket_api.async_register_command(hass, config_subentry_list)
return True return True
@ -285,6 +295,63 @@ class OptionManagerFlowResourceView(
return await super().post(request, flow_id) return await super().post(request, flow_id)
class SubentryManagerFlowIndexView(
FlowManagerIndexView[config_entries.ConfigSubentryFlowManager]
):
"""View to create subentry flows."""
url = "/api/config/config_entries/subentries/flow"
name = "api:config:config_entries:subentries:flow"
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
@RequestDataValidator(
vol.Schema(
{
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
vol.Optional("show_advanced_options", default=False): cv.boolean,
},
extra=vol.ALLOW_EXTRA,
)
)
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle a POST request.
handler in request is [entry_id, subentry_type].
"""
return await super()._post_impl(request, data)
def get_context(self, data: dict[str, Any]) -> dict[str, Any]:
"""Return context."""
context = super().get_context(data)
context["source"] = config_entries.SOURCE_USER
return context
class SubentryManagerFlowResourceView(
FlowManagerResourceView[config_entries.ConfigSubentryFlowManager]
):
"""View to interact with the subentry flow manager."""
url = "/api/config/config_entries/subentries/flow/{flow_id}"
name = "api:config:config_entries:subentries:flow:resource"
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def get(self, request: web.Request, /, flow_id: str) -> web.Response:
"""Get the current state of a data_entry_flow."""
return await super().get(request, flow_id)
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def post(self, request: web.Request, flow_id: str) -> web.Response:
"""Handle a POST request."""
return await super().post(request, flow_id)
@websocket_api.require_admin @websocket_api.require_admin
@websocket_api.websocket_command({"type": "config_entries/flow/progress"}) @websocket_api.websocket_command({"type": "config_entries/flow/progress"})
def config_entries_progress( def config_entries_progress(
@ -588,3 +655,62 @@ async def _async_matching_config_entries_json_fragments(
) )
or (filter_is_not_helper and entry.domain not in integrations) or (filter_is_not_helper and entry.domain not in integrations)
] ]
@websocket_api.require_admin
@websocket_api.websocket_command(
{
"type": "config_entries/subentries/list",
"entry_id": str,
}
)
@websocket_api.async_response
async def config_subentry_list(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List subentries of a config entry."""
entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
if entry is None:
return
result = [
{
"subentry_id": subentry.subentry_id,
"title": subentry.title,
"unique_id": subentry.unique_id,
}
for subentry_id, subentry in entry.subentries.items()
]
connection.send_result(msg["id"], result)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
"type": "config_entries/subentries/delete",
"entry_id": str,
"subentry_id": str,
}
)
@websocket_api.async_response
async def config_subentry_delete(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Delete a subentry of a config entry."""
entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
if entry is None:
return
try:
hass.config_entries.async_remove_subentry(entry, msg["subentry_id"])
except config_entries.UnknownSubEntry:
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found"
)
return
connection.send_result(msg["id"])

View File

@ -15,6 +15,7 @@ from collections.abc import (
) )
from contextvars import ContextVar from contextvars import ContextVar
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum, StrEnum from enum import Enum, StrEnum
import functools import functools
@ -22,7 +23,7 @@ from functools import cache
import logging import logging
from random import randint from random import randint
from types import MappingProxyType from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Generic, Self, cast from typing import TYPE_CHECKING, Any, Generic, Self, TypedDict, cast
from async_interrupt import interrupt from async_interrupt import interrupt
from propcache import cached_property from propcache import cached_property
@ -128,7 +129,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry()
STORAGE_KEY = "core.config_entries" STORAGE_KEY = "core.config_entries"
STORAGE_VERSION = 1 STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 4 STORAGE_VERSION_MINOR = 5
SAVE_DELAY = 1 SAVE_DELAY = 1
@ -256,6 +257,10 @@ class UnknownEntry(ConfigError):
"""Unknown entry specified.""" """Unknown entry specified."""
class UnknownSubEntry(ConfigError):
"""Unknown subentry specified."""
class OperationNotAllowed(ConfigError): class OperationNotAllowed(ConfigError):
"""Raised when a config entry operation is not allowed.""" """Raised when a config entry operation is not allowed."""
@ -300,6 +305,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
minor_version: int minor_version: int
options: Mapping[str, Any] options: Mapping[str, Any]
subentries: Iterable[ConfigSubentryData]
version: int version: int
@ -313,6 +319,51 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N
) )
class ConfigSubentryData(TypedDict):
"""Container for configuration subentry data.
Returned by integrations, a subentry_id will be assigned automatically.
"""
data: Mapping[str, Any]
title: str
unique_id: str | None
class ConfigSubentryDataWithId(ConfigSubentryData):
"""Container for configuration subentry data.
This type is used when loading existing subentries from storage.
"""
subentry_id: str
class SubentryFlowResult(FlowResult[FlowContext, tuple[str, str]], total=False):
"""Typed result dict for subentry flow."""
unique_id: str | None
@dataclass(frozen=True, kw_only=True)
class ConfigSubentry:
"""Container for a configuration subentry."""
data: MappingProxyType[str, Any]
subentry_id: str = field(default_factory=ulid_util.ulid_now)
title: str
unique_id: str | None
def as_dict(self) -> ConfigSubentryDataWithId:
"""Return dictionary version of this subentry."""
return {
"data": dict(self.data),
"subentry_id": self.subentry_id,
"title": self.title,
"unique_id": self.unique_id,
}
class ConfigEntry(Generic[_DataT]): class ConfigEntry(Generic[_DataT]):
"""Hold a configuration entry.""" """Hold a configuration entry."""
@ -322,6 +373,7 @@ class ConfigEntry(Generic[_DataT]):
data: MappingProxyType[str, Any] data: MappingProxyType[str, Any]
runtime_data: _DataT runtime_data: _DataT
options: MappingProxyType[str, Any] options: MappingProxyType[str, Any]
subentries: MappingProxyType[str, ConfigSubentry]
unique_id: str | None unique_id: str | None
state: ConfigEntryState state: ConfigEntryState
reason: str | None reason: str | None
@ -337,6 +389,7 @@ class ConfigEntry(Generic[_DataT]):
supports_remove_device: bool | None supports_remove_device: bool | None
_supports_options: bool | None _supports_options: bool | None
_supports_reconfigure: bool | None _supports_reconfigure: bool | None
_supported_subentries: tuple[str, ...] | None
update_listeners: list[UpdateListenerType] update_listeners: list[UpdateListenerType]
_async_cancel_retry_setup: Callable[[], Any] | None _async_cancel_retry_setup: Callable[[], Any] | None
_on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None
@ -366,6 +419,7 @@ class ConfigEntry(Generic[_DataT]):
pref_disable_polling: bool | None = None, pref_disable_polling: bool | None = None,
source: str, source: str,
state: ConfigEntryState = ConfigEntryState.NOT_LOADED, state: ConfigEntryState = ConfigEntryState.NOT_LOADED,
subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None,
title: str, title: str,
unique_id: str | None, unique_id: str | None,
version: int, version: int,
@ -391,6 +445,24 @@ class ConfigEntry(Generic[_DataT]):
# Entry options # Entry options
_setter(self, "options", MappingProxyType(options or {})) _setter(self, "options", MappingProxyType(options or {}))
# Subentries
subentries_data = subentries_data or ()
subentries = {}
for subentry_data in subentries_data:
subentry_kwargs = {}
if "subentry_id" in subentry_data:
# If subentry_data has key "subentry_id", we're loading from storage
subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] # type: ignore[typeddict-item]
subentry = ConfigSubentry(
data=MappingProxyType(subentry_data["data"]),
title=subentry_data["title"],
unique_id=subentry_data.get("unique_id"),
**subentry_kwargs,
)
subentries[subentry.subentry_id] = subentry
_setter(self, "subentries", MappingProxyType(subentries))
# Entry system options # Entry system options
if pref_disable_new_entities is None: if pref_disable_new_entities is None:
pref_disable_new_entities = False pref_disable_new_entities = False
@ -427,6 +499,9 @@ class ConfigEntry(Generic[_DataT]):
# Supports reconfigure # Supports reconfigure
_setter(self, "_supports_reconfigure", None) _setter(self, "_supports_reconfigure", None)
# Supports subentries
_setter(self, "_supported_subentries", None)
# Listeners to call on update # Listeners to call on update
_setter(self, "update_listeners", []) _setter(self, "update_listeners", [])
@ -499,6 +574,18 @@ class ConfigEntry(Generic[_DataT]):
) )
return self._supports_reconfigure or False return self._supports_reconfigure or False
@property
def supported_subentries(self) -> tuple[str, ...]:
"""Return supported subentries."""
if self._supported_subentries is None and (
handler := HANDLERS.get(self.domain)
):
# work out sub entries supported by the handler
object.__setattr__(
self, "_supported_subentries", handler.async_supported_subentries(self)
)
return self._supported_subentries or ()
def clear_state_cache(self) -> None: def clear_state_cache(self) -> None:
"""Clear cached properties that are included in as_json_fragment.""" """Clear cached properties that are included in as_json_fragment."""
self.__dict__.pop("as_json_fragment", None) self.__dict__.pop("as_json_fragment", None)
@ -518,12 +605,14 @@ class ConfigEntry(Generic[_DataT]):
"supports_remove_device": self.supports_remove_device or False, "supports_remove_device": self.supports_remove_device or False,
"supports_unload": self.supports_unload or False, "supports_unload": self.supports_unload or False,
"supports_reconfigure": self.supports_reconfigure, "supports_reconfigure": self.supports_reconfigure,
"supported_subentries": self.supported_subentries,
"pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_new_entities": self.pref_disable_new_entities,
"pref_disable_polling": self.pref_disable_polling, "pref_disable_polling": self.pref_disable_polling,
"disabled_by": self.disabled_by, "disabled_by": self.disabled_by,
"reason": self.reason, "reason": self.reason,
"error_reason_translation_key": self.error_reason_translation_key, "error_reason_translation_key": self.error_reason_translation_key,
"error_reason_translation_placeholders": self.error_reason_translation_placeholders, "error_reason_translation_placeholders": self.error_reason_translation_placeholders,
"num_subentries": len(self.subentries),
} }
return json_fragment(json_bytes(json_repr)) return json_fragment(json_bytes(json_repr))
@ -1018,6 +1107,7 @@ class ConfigEntry(Generic[_DataT]):
"pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_new_entities": self.pref_disable_new_entities,
"pref_disable_polling": self.pref_disable_polling, "pref_disable_polling": self.pref_disable_polling,
"source": self.source, "source": self.source,
"subentries": [subentry.as_dict() for subentry in self.subentries.values()],
"title": self.title, "title": self.title,
"unique_id": self.unique_id, "unique_id": self.unique_id,
"version": self.version, "version": self.version,
@ -1503,6 +1593,7 @@ class ConfigEntriesFlowManager(
minor_version=result["minor_version"], minor_version=result["minor_version"],
options=result["options"], options=result["options"],
source=flow.context["source"], source=flow.context["source"],
subentries_data=result["subentries"],
title=result["title"], title=result["title"],
unique_id=flow.unique_id, unique_id=flow.unique_id,
version=result["version"], version=result["version"],
@ -1793,6 +1884,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entry in data["entries"]: for entry in data["entries"]:
entry["discovery_keys"] = {} entry["discovery_keys"] = {}
if old_minor_version < 5:
# Version 1.4 adds config subentries
for entry in data["entries"]:
entry.setdefault("subentries", entry.get("subentries", {}))
if old_major_version > 1: if old_major_version > 1:
raise NotImplementedError raise NotImplementedError
return data return data
@ -1809,6 +1905,7 @@ class ConfigEntries:
self.hass = hass self.hass = hass
self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.flow = ConfigEntriesFlowManager(hass, self, hass_config)
self.options = OptionsFlowManager(hass) self.options = OptionsFlowManager(hass)
self.subentries = ConfigSubentryFlowManager(hass)
self._hass_config = hass_config self._hass_config = hass_config
self._entries = ConfigEntryItems(hass) self._entries = ConfigEntryItems(hass)
self._store = ConfigEntryStore(hass) self._store = ConfigEntryStore(hass)
@ -2011,6 +2108,7 @@ class ConfigEntries:
pref_disable_new_entities=entry["pref_disable_new_entities"], pref_disable_new_entities=entry["pref_disable_new_entities"],
pref_disable_polling=entry["pref_disable_polling"], pref_disable_polling=entry["pref_disable_polling"],
source=entry["source"], source=entry["source"],
subentries_data=entry["subentries"],
title=entry["title"], title=entry["title"],
unique_id=entry["unique_id"], unique_id=entry["unique_id"],
version=entry["version"], version=entry["version"],
@ -2170,6 +2268,44 @@ class ConfigEntries:
If the entry was changed, the update_listeners are If the entry was changed, the update_listeners are
fired and this function returns True fired and this function returns True
If the entry was not changed, the update_listeners are
not fired and this function returns False
"""
return self._async_update_entry(
entry,
data=data,
discovery_keys=discovery_keys,
minor_version=minor_version,
options=options,
pref_disable_new_entities=pref_disable_new_entities,
pref_disable_polling=pref_disable_polling,
title=title,
unique_id=unique_id,
version=version,
)
@callback
def _async_update_entry(
self,
entry: ConfigEntry,
*,
data: Mapping[str, Any] | UndefinedType = UNDEFINED,
discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
| UndefinedType = UNDEFINED,
minor_version: int | UndefinedType = UNDEFINED,
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
pref_disable_polling: bool | UndefinedType = UNDEFINED,
subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED,
title: str | UndefinedType = UNDEFINED,
unique_id: str | None | UndefinedType = UNDEFINED,
version: int | UndefinedType = UNDEFINED,
) -> bool:
"""Update a config entry.
If the entry was changed, the update_listeners are
fired and this function returns True
If the entry was not changed, the update_listeners are If the entry was not changed, the update_listeners are
not fired and this function returns False not fired and this function returns False
""" """
@ -2232,6 +2368,11 @@ class ConfigEntries:
changed = True changed = True
_setter(entry, "options", MappingProxyType(options)) _setter(entry, "options", MappingProxyType(options))
if subentries is not UNDEFINED:
if entry.subentries != subentries:
changed = True
_setter(entry, "subentries", MappingProxyType(subentries))
if not changed: if not changed:
return False return False
@ -2249,6 +2390,37 @@ class ConfigEntries:
self._async_dispatch(ConfigEntryChange.UPDATED, entry) self._async_dispatch(ConfigEntryChange.UPDATED, entry)
return True return True
@callback
def async_add_subentry(self, entry: ConfigEntry, subentry: ConfigSubentry) -> bool:
"""Add a subentry to a config entry."""
self._raise_if_subentry_unique_id_exists(entry, subentry.unique_id)
return self._async_update_entry(
entry,
subentries=entry.subentries | {subentry.subentry_id: subentry},
)
@callback
def async_remove_subentry(self, entry: ConfigEntry, subentry_id: str) -> bool:
"""Remove a subentry from a config entry."""
subentries = dict(entry.subentries)
try:
subentries.pop(subentry_id)
except KeyError as err:
raise UnknownSubEntry from err
return self._async_update_entry(entry, subentries=subentries)
def _raise_if_subentry_unique_id_exists(
self, entry: ConfigEntry, unique_id: str | None
) -> None:
"""Raise if a subentry with the same unique_id exists."""
if unique_id is None:
return
for existing_subentry in entry.subentries.values():
if existing_subentry.unique_id == unique_id:
raise data_entry_flow.AbortFlow("already_configured")
@callback @callback
def _async_dispatch( def _async_dispatch(
self, change_type: ConfigEntryChange, entry: ConfigEntry self, change_type: ConfigEntryChange, entry: ConfigEntry
@ -2585,6 +2757,20 @@ class ConfigFlow(ConfigEntryBaseFlow):
"""Return options flow support for this handler.""" """Return options flow support for this handler."""
return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow
@staticmethod
@callback
def async_get_subentry_flow(
config_entry: ConfigEntry, subentry_type: str
) -> ConfigSubentryFlow:
"""Get the subentry flow for this handler."""
raise NotImplementedError
@classmethod
@callback
def async_supported_subentries(cls, config_entry: ConfigEntry) -> tuple[str, ...]:
"""Return subentries supported by this handler."""
return ()
@callback @callback
def _async_abort_entries_match( def _async_abort_entries_match(
self, match_dict: dict[str, Any] | None = None self, match_dict: dict[str, Any] | None = None
@ -2893,6 +3079,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
description: str | None = None, description: str | None = None,
description_placeholders: Mapping[str, str] | None = None, description_placeholders: Mapping[str, str] | None = None,
options: Mapping[str, Any] | None = None, options: Mapping[str, Any] | None = None,
subentries: Iterable[ConfigSubentryData] | None = None,
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Finish config flow and create a config entry.""" """Finish config flow and create a config entry."""
if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
@ -2912,6 +3099,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
result["minor_version"] = self.MINOR_VERSION result["minor_version"] = self.MINOR_VERSION
result["options"] = options or {} result["options"] = options or {}
result["subentries"] = subentries or ()
result["version"] = self.VERSION result["version"] = self.VERSION
return result return result
@ -3026,17 +3214,126 @@ class ConfigFlow(ConfigEntryBaseFlow):
) )
class OptionsFlowManager( class _ConfigSubFlowManager:
data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult] """Mixin class for flow managers which manage flows tied to a config entry."""
):
"""Flow to set options for a configuration entry."""
_flow_result = ConfigFlowResult hass: HomeAssistant
def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry:
"""Return config entry or raise if not found.""" """Return config entry or raise if not found."""
return self.hass.config_entries.async_get_known_entry(config_entry_id) return self.hass.config_entries.async_get_known_entry(config_entry_id)
class ConfigSubentryFlowManager(
data_entry_flow.FlowManager[FlowContext, SubentryFlowResult, tuple[str, str]],
_ConfigSubFlowManager,
):
"""Manage all the config subentry flows that are in progress."""
_flow_result = SubentryFlowResult
async def async_create_flow(
self,
handler_key: tuple[str, str],
*,
context: FlowContext | None = None,
data: dict[str, Any] | None = None,
) -> ConfigSubentryFlow:
"""Create a subentry flow for a config entry.
The entry_id and flow.handler[0] is the same thing to map entry with flow.
"""
if not context or "source" not in context:
raise KeyError("Context not set or doesn't have a source set")
entry_id, subentry_type = handler_key
entry = self._async_get_config_entry(entry_id)
handler = await _async_get_flow_handler(self.hass, entry.domain, {})
if subentry_type not in handler.async_supported_subentries(entry):
raise data_entry_flow.UnknownHandler(
f"Config entry '{entry.domain}' does not support subentry '{subentry_type}'"
)
subentry_flow = handler.async_get_subentry_flow(entry, subentry_type)
subentry_flow.init_step = context["source"]
return subentry_flow
async def async_finish_flow(
self,
flow: data_entry_flow.FlowHandler[
FlowContext, SubentryFlowResult, tuple[str, str]
],
result: SubentryFlowResult,
) -> SubentryFlowResult:
"""Finish a subentry flow and add a new subentry to the configuration entry.
The flow.handler[0] and entry_id is the same thing to map flow with entry.
"""
flow = cast(ConfigSubentryFlow, flow)
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
return result
entry_id = flow.handler[0]
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None:
raise UnknownEntry(entry_id)
unique_id = result.get("unique_id")
if unique_id is not None and not isinstance(unique_id, str):
raise HomeAssistantError("unique_id must be a string")
self.hass.config_entries.async_add_subentry(
entry,
ConfigSubentry(
data=MappingProxyType(result["data"]),
title=result["title"],
unique_id=unique_id,
),
)
result["result"] = True
return result
class ConfigSubentryFlow(
data_entry_flow.FlowHandler[FlowContext, SubentryFlowResult, tuple[str, str]]
):
"""Base class for config subentry flows."""
_flow_result = SubentryFlowResult
handler: tuple[str, str]
@callback
def async_create_entry(
self,
*,
title: str | None = None,
data: Mapping[str, Any],
description: str | None = None,
description_placeholders: Mapping[str, str] | None = None,
unique_id: str | None = None,
) -> SubentryFlowResult:
"""Finish config flow and create a config entry."""
result = super().async_create_entry(
title=title,
data=data,
description=description,
description_placeholders=description_placeholders,
)
result["unique_id"] = unique_id
return result
class OptionsFlowManager(
data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult],
_ConfigSubFlowManager,
):
"""Manage all the config entry option flows that are in progress."""
_flow_result = ConfigFlowResult
async def async_create_flow( async def async_create_flow(
self, self,
handler_key: str, handler_key: str,
@ -3046,7 +3343,7 @@ class OptionsFlowManager(
) -> OptionsFlow: ) -> OptionsFlow:
"""Create an options flow for a config entry. """Create an options flow for a config entry.
Entry_id and flow.handler is the same thing to map entry with flow. The entry_id and the flow.handler is the same thing to map entry with flow.
""" """
entry = self._async_get_config_entry(handler_key) entry = self._async_get_config_entry(handler_key)
handler = await _async_get_flow_handler(self.hass, entry.domain, {}) handler = await _async_get_flow_handler(self.hass, entry.domain, {})
@ -3062,7 +3359,7 @@ class OptionsFlowManager(
This method is called when a flow step returns FlowResultType.ABORT or This method is called when a flow step returns FlowResultType.ABORT or
FlowResultType.CREATE_ENTRY. FlowResultType.CREATE_ENTRY.
Flow.handler and entry_id is the same thing to map flow with entry. The flow.handler and the entry_id is the same thing to map flow with entry.
""" """
flow = cast(OptionsFlow, flow) flow = cast(OptionsFlow, flow)

View File

@ -18,7 +18,7 @@ from . import config_validation as cv
_FlowManagerT = TypeVar( _FlowManagerT = TypeVar(
"_FlowManagerT", "_FlowManagerT",
bound=data_entry_flow.FlowManager[Any, Any], bound=data_entry_flow.FlowManager[Any, Any, Any],
default=data_entry_flow.FlowManager, default=data_entry_flow.FlowManager,
) )
@ -71,7 +71,7 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]):
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Initialize a POST request. """Initialize a POST request.
Override `_post_impl` in subclasses which need Override `post` and call `_post_impl` in subclasses which need
to implement their own `RequestDataValidator` to implement their own `RequestDataValidator`
""" """
return await self._post_impl(request, data) return await self._post_impl(request, data)

View File

@ -285,6 +285,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
"user" if integration.integration_type == "helper" else None "user" if integration.integration_type == "helper" else None
), ),
), ),
vol.Optional("config_subentries"): cv.schema_with_slug_keys(
gen_data_entry_schema(
config=config,
integration=integration,
flow_title=REQUIRED,
require_step_title=False,
),
slug_validator=vol.Any("_", cv.slug),
),
vol.Optional("options"): gen_data_entry_schema( vol.Optional("options"): gen_data_entry_schema(
config=config, config=config,
integration=integration, integration=integration,

View File

@ -1000,6 +1000,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
reason=None, reason=None,
source=config_entries.SOURCE_USER, source=config_entries.SOURCE_USER,
state=None, state=None,
subentries_data=None,
title="Mock Title", title="Mock Title",
unique_id=None, unique_id=None,
version=1, version=1,
@ -1016,6 +1017,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
"options": options or {}, "options": options or {},
"pref_disable_new_entities": pref_disable_new_entities, "pref_disable_new_entities": pref_disable_new_entities,
"pref_disable_polling": pref_disable_polling, "pref_disable_polling": pref_disable_polling,
"subentries_data": subentries_data or (),
"title": title, "title": title,
"unique_id": unique_id, "unique_id": unique_id,
"version": version, "version": version,

View File

@ -21,6 +21,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -19,6 +19,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Home', 'title': 'Home',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -35,6 +35,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 2, 'version': 2,

View File

@ -47,6 +47,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 3, 'version': 3,

View File

@ -101,6 +101,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'XXXXXXX', 'unique_id': 'XXXXXXX',
'version': 1, 'version': 1,

View File

@ -287,6 +287,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -101,6 +101,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'installation1', 'unique_id': 'installation1',
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 2, 'version': 2,

View File

@ -47,6 +47,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 3, 'version': 3,

View File

@ -18,6 +18,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Beosound Balance-11111111', 'title': 'Beosound Balance-11111111',
'unique_id': '11111111', 'unique_id': '11111111',
'version': 1, 'version': 1,

View File

@ -48,6 +48,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 3, 'version': 3,

View File

@ -19,6 +19,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'very_unique_string', 'unique_id': 'very_unique_string',
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -44,6 +44,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -71,6 +71,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,
@ -135,6 +137,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -137,11 +137,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None:
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": core_ce.ConfigEntryState.NOT_LOADED.value, "state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supported_subentries": [],
"supports_options": True, "supports_options": True,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -155,11 +157,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None:
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": "Unsupported API", "reason": "Unsupported API",
"source": "bla2", "source": "bla2",
"state": core_ce.ConfigEntryState.SETUP_ERROR.value, "state": core_ce.ConfigEntryState.SETUP_ERROR.value,
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -173,11 +177,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None:
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla3", "source": "bla3",
"state": core_ce.ConfigEntryState.NOT_LOADED.value, "state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -191,11 +197,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None:
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla4", "source": "bla4",
"state": core_ce.ConfigEntryState.NOT_LOADED.value, "state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -209,11 +217,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None:
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla5", "source": "bla5",
"state": core_ce.ConfigEntryState.NOT_LOADED.value, "state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -571,11 +581,13 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None:
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": core_ce.SOURCE_USER, "source": core_ce.SOURCE_USER,
"state": core_ce.ConfigEntryState.LOADED.value, "state": core_ce.ConfigEntryState.LOADED.value,
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -586,6 +598,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None:
"description_placeholders": None, "description_placeholders": None,
"options": {}, "options": {},
"minor_version": 1, "minor_version": 1,
"subentries": [],
} }
@ -654,11 +667,13 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None:
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": core_ce.SOURCE_USER, "source": core_ce.SOURCE_USER,
"state": core_ce.ConfigEntryState.LOADED.value, "state": core_ce.ConfigEntryState.LOADED.value,
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -669,6 +684,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None:
"description_placeholders": None, "description_placeholders": None,
"options": {}, "options": {},
"minor_version": 1, "minor_version": 1,
"subentries": [],
} }
@ -1088,6 +1104,273 @@ async def test_options_flow_with_invalid_data(
assert data == {"errors": {"choices": "invalid is not a valid option"}} assert data == {"errors": {"choices": "invalid is not a valid option"}}
async def test_subentry_flow(hass: HomeAssistant, client) -> None:
"""Test we can start a subentry flow."""
class TestFlow(core_ce.ConfigFlow):
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
class SubentryFlowHandler(core_ce.ConfigSubentryFlow):
async def async_step_init(self, user_input=None):
raise NotImplementedError
async def async_step_user(self, user_input=None):
schema = OrderedDict()
schema[vol.Required("enabled")] = bool
return self.async_show_form(
step_id="user",
data_schema=schema,
description_placeholders={"enabled": "Set to true to be true"},
)
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(cls, config_entry):
return ("test",)
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
MockConfigEntry(
domain="test",
entry_id="test1",
source="bla",
).add_to_hass(hass)
entry = hass.config_entries.async_entries()[0]
with patch.dict(HANDLERS, {"test": TestFlow}):
url = "/api/config/config_entries/subentries/flow"
resp = await client.post(url, json={"handler": [entry.entry_id, "test"]})
assert resp.status == HTTPStatus.OK
data = await resp.json()
data.pop("flow_id")
assert data == {
"type": "form",
"handler": ["test1", "test"],
"step_id": "user",
"data_schema": [{"name": "enabled", "required": True, "type": "boolean"}],
"description_placeholders": {"enabled": "Set to true to be true"},
"errors": None,
"last_step": None,
"preview": None,
}
@pytest.mark.parametrize(
("endpoint", "method"),
[
("/api/config/config_entries/subentries/flow", "post"),
("/api/config/config_entries/subentries/flow/1", "get"),
("/api/config/config_entries/subentries/flow/1", "post"),
],
)
async def test_subentry_flow_unauth(
hass: HomeAssistant, client, hass_admin_user: MockUser, endpoint: str, method: str
) -> None:
"""Test unauthorized on subentry flow."""
class TestFlow(core_ce.ConfigFlow):
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
class SubentryFlowHandler(core_ce.ConfigSubentryFlow):
async def async_step_init(self, user_input=None):
schema = OrderedDict()
schema[vol.Required("enabled")] = bool
return self.async_show_form(
step_id="user",
data_schema=schema,
description_placeholders={"enabled": "Set to true to be true"},
)
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(cls, config_entry):
return ("test",)
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
MockConfigEntry(
domain="test",
entry_id="test1",
source="bla",
).add_to_hass(hass)
entry = hass.config_entries.async_entries()[0]
hass_admin_user.groups = []
with patch.dict(HANDLERS, {"test": TestFlow}):
resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id})
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None:
"""Test we can finish a two step subentry flow."""
mock_integration(
hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True))
)
mock_platform(hass, "test.config_flow", None)
class TestFlow(core_ce.ConfigFlow):
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
class SubentryFlowHandler(core_ce.ConfigSubentryFlow):
async def async_step_user(self, user_input=None):
return await self.async_step_finish()
async def async_step_finish(self, user_input=None):
if user_input:
return self.async_create_entry(
title="Mock title", data=user_input, unique_id="test"
)
return self.async_show_form(
step_id="finish", data_schema=vol.Schema({"enabled": bool})
)
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(cls, config_entry):
return ("test",)
MockConfigEntry(
domain="test",
entry_id="test1",
source="bla",
).add_to_hass(hass)
entry = hass.config_entries.async_entries()[0]
with patch.dict(HANDLERS, {"test": TestFlow}):
url = "/api/config/config_entries/subentries/flow"
resp = await client.post(url, json={"handler": [entry.entry_id, "test"]})
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
expected_data = {
"data_schema": [{"name": "enabled", "type": "boolean"}],
"description_placeholders": None,
"errors": None,
"flow_id": flow_id,
"handler": ["test1", "test"],
"last_step": None,
"preview": None,
"step_id": "finish",
"type": "form",
}
assert data == expected_data
resp = await client.get(f"/api/config/config_entries/subentries/flow/{flow_id}")
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == expected_data
resp = await client.post(
f"/api/config/config_entries/subentries/flow/{flow_id}",
json={"enabled": True},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"description_placeholders": None,
"description": None,
"flow_id": flow_id,
"handler": ["test1", "test"],
"title": "Mock title",
"type": "create_entry",
"unique_id": "test",
}
async def test_subentry_flow_with_invalid_data(hass: HomeAssistant, client) -> None:
"""Test a subentry flow with invalid_data."""
mock_integration(
hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True))
)
mock_platform(hass, "test.config_flow", None)
class TestFlow(core_ce.ConfigFlow):
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
class SubentryFlowHandler(core_ce.ConfigSubentryFlow):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="finish",
data_schema=vol.Schema(
{
vol.Required(
"choices", default=["invalid", "valid"]
): cv.multi_select({"valid": "Valid"})
}
),
)
async def async_step_finish(self, user_input=None):
return self.async_create_entry(
title="Enable disable", data=user_input
)
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(cls, config_entry):
return ("test",)
MockConfigEntry(
domain="test",
entry_id="test1",
source="bla",
).add_to_hass(hass)
entry = hass.config_entries.async_entries()[0]
with patch.dict(HANDLERS, {"test": TestFlow}):
url = "/api/config/config_entries/subentries/flow"
resp = await client.post(url, json={"handler": [entry.entry_id, "test"]})
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data.pop("flow_id")
assert data == {
"type": "form",
"handler": ["test1", "test"],
"step_id": "finish",
"data_schema": [
{
"default": ["invalid", "valid"],
"name": "choices",
"options": {"valid": "Valid"},
"required": True,
"type": "multi_select",
}
],
"description_placeholders": None,
"errors": None,
"last_step": None,
"preview": None,
}
with patch.dict(HANDLERS, {"test": TestFlow}):
resp = await client.post(
f"/api/config/config_entries/subentries/flow/{flow_id}",
json={"choices": ["valid", "invalid"]},
)
assert resp.status == HTTPStatus.BAD_REQUEST
data = await resp.json()
assert data == {"errors": {"choices": "invalid is not a valid option"}}
@pytest.mark.usefixtures("freezer") @pytest.mark.usefixtures("freezer")
async def test_get_single( async def test_get_single(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
@ -1120,11 +1403,13 @@ async def test_get_single(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "user", "source": "user",
"state": "loaded", "state": "loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1480,11 +1765,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1499,11 +1786,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": "Unsupported API", "reason": "Unsupported API",
"source": "bla2", "source": "bla2",
"state": "setup_error", "state": "setup_error",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1518,11 +1807,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla3", "source": "bla3",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1537,11 +1828,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla4", "source": "bla4",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1556,11 +1849,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla5", "source": "bla5",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1586,11 +1881,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1615,11 +1912,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla4", "source": "bla4",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1634,11 +1933,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla5", "source": "bla5",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1663,11 +1964,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1682,11 +1985,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla3", "source": "bla3",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1717,11 +2022,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1736,11 +2043,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": "Unsupported API", "reason": "Unsupported API",
"source": "bla2", "source": "bla2",
"state": "setup_error", "state": "setup_error",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1755,11 +2064,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla3", "source": "bla3",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1774,11 +2085,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla4", "source": "bla4",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1793,11 +2106,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": timestamp, "modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla5", "source": "bla5",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1900,11 +2215,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": created, "modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1922,11 +2239,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": created, "modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": "Unsupported API", "reason": "Unsupported API",
"source": "bla2", "source": "bla2",
"state": "setup_error", "state": "setup_error",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1944,11 +2263,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": created, "modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla3", "source": "bla3",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -1972,11 +2293,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": modified, "modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2001,11 +2324,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": modified, "modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2029,11 +2354,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": entry.modified_at.timestamp(), "modified_at": entry.modified_at.timestamp(),
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2119,11 +2446,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": created, "modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2141,11 +2470,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": created, "modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla3", "source": "bla3",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2171,11 +2502,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": modified, "modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2197,11 +2530,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": modified, "modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla3", "source": "bla3",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2227,11 +2562,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": modified, "modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2255,11 +2592,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None, "error_reason_translation_key": None,
"error_reason_translation_placeholders": None, "error_reason_translation_placeholders": None,
"modified_at": entry.modified_at.timestamp(), "modified_at": entry.modified_at.timestamp(),
"num_subentries": 0,
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"reason": None, "reason": None,
"source": "bla", "source": "bla",
"state": "not_loaded", "state": "not_loaded",
"supported_subentries": [],
"supports_options": False, "supports_options": False,
"supports_reconfigure": False, "supports_reconfigure": False,
"supports_remove_device": False, "supports_remove_device": False,
@ -2470,3 +2809,133 @@ async def test_does_not_support_reconfigure(
response response
== '{"message":"Handler ConfigEntriesFlowManager doesn\'t support step reconfigure"}' == '{"message":"Handler ConfigEntriesFlowManager doesn\'t support step reconfigure"}'
) )
async def test_list_subentries(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that we can list subentries."""
assert await async_setup_component(hass, "config", {})
ws_client = await hass_ws_client(hass)
entry = MockConfigEntry(
domain="test",
state=core_ce.ConfigEntryState.LOADED,
subentries_data=[
core_ce.ConfigSubentryData(
data={"test": "test"},
subentry_id="mock_id",
title="Mock title",
unique_id="test",
)
],
)
entry.add_to_hass(hass)
assert entry.pref_disable_new_entities is False
assert entry.pref_disable_polling is False
await ws_client.send_json_auto_id(
{
"type": "config_entries/subentries/list",
"entry_id": entry.entry_id,
}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == [
{"subentry_id": "mock_id", "title": "Mock title", "unique_id": "test"},
]
# Try listing subentries for an unknown entry
await ws_client.send_json_auto_id(
{
"type": "config_entries/subentries/list",
"entry_id": "no_such_entry",
}
)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_found",
"message": "Config entry not found",
}
async def test_delete_subentry(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that we can delete a subentry."""
assert await async_setup_component(hass, "config", {})
ws_client = await hass_ws_client(hass)
entry = MockConfigEntry(
domain="test",
state=core_ce.ConfigEntryState.LOADED,
subentries_data=[
core_ce.ConfigSubentryData(
data={"test": "test"}, subentry_id="mock_id", title="Mock title"
)
],
)
entry.add_to_hass(hass)
assert entry.pref_disable_new_entities is False
assert entry.pref_disable_polling is False
await ws_client.send_json_auto_id(
{
"type": "config_entries/subentries/delete",
"entry_id": entry.entry_id,
"subentry_id": "mock_id",
}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] is None
await ws_client.send_json_auto_id(
{
"type": "config_entries/subentries/list",
"entry_id": entry.entry_id,
}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == []
# Try deleting the subentry again
await ws_client.send_json_auto_id(
{
"type": "config_entries/subentries/delete",
"entry_id": entry.entry_id,
"subentry_id": "mock_id",
}
)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_found",
"message": "Config subentry not found",
}
# Try deleting subentry from an unknown entry
await ws_client.send_json_auto_id(
{
"type": "config_entries/subentries/delete",
"entry_id": "no_such_entry",
"subentry_id": "mock_id",
}
)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_found",
"message": "Config entry not found",
}

View File

@ -21,6 +21,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -47,6 +47,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '123456', 'unique_id': '123456',
'version': 1, 'version': 1,

View File

@ -32,6 +32,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'dsmr_reader', 'title': 'dsmr_reader',
'unique_id': 'UNIQUE_TEST_ID', 'unique_id': 'UNIQUE_TEST_ID',
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,
@ -70,6 +72,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -28,10 +28,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'EnergyZero', 'title': 'EnergyZero',
'unique_id': 'energyzero', 'unique_id': 'energyzero',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'EnergyZero', 'title': 'EnergyZero',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,

View File

@ -20,6 +20,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,
@ -454,6 +456,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,
@ -928,6 +932,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -20,6 +20,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'ESPHome Device', 'title': 'ESPHome Device',
'unique_id': '11:22:33:44:55:aa', 'unique_id': '11:22:33:44:55:aa',
'version': 1, 'version': 1,

View File

@ -79,6 +79,7 @@ async def test_diagnostics_with_bluetooth(
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"source": "user", "source": "user",
"subentries": [],
"title": "Mock Title", "title": "Mock Title",
"unique_id": "11:22:33:44:55:aa", "unique_id": "11:22:33:44:55:aa",
"version": 1, "version": 1,

View File

@ -23,6 +23,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Green House', 'title': 'Green House',
'unique_id': 'unique', 'unique_id': 'unique',
'version': 2, 'version': 2,

View File

@ -61,6 +61,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -19,6 +19,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'fyta_user', 'title': 'fyta_user',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -66,10 +66,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'bluetooth', 'source': 'bluetooth',
'subentries': list([
]),
'title': 'Gardena Water Computer', 'title': 'Gardena Water Computer',
'unique_id': '00000000-0000-0000-0000-000000000001', 'unique_id': '00000000-0000-0000-0000-000000000001',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Gardena Water Computer', 'title': 'Gardena Water Computer',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -223,10 +227,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Gardena Water Computer', 'title': 'Gardena Water Computer',
'unique_id': '00000000-0000-0000-0000-000000000001', 'unique_id': '00000000-0000-0000-0000-000000000001',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Gardena Water Computer', 'title': 'Gardena Water Computer',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Home', 'title': 'Home',
'unique_id': '123', 'unique_id': '123',
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -15,6 +15,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'import', 'source': 'import',
'subentries': list([
]),
'title': '1234', 'title': '1234',
'unique_id': '1234', 'unique_id': '1234',
'version': 1, 'version': 1,

View File

@ -42,6 +42,7 @@ async def test_entry_diagnostics(
"created_at": ANY, "created_at": ANY,
"modified_at": ANY, "modified_at": ANY,
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
"data": { "data": {
"valve_controller": { "valve_controller": {

View File

@ -30,10 +30,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'zeroconf', 'source': 'zeroconf',
'subentries': list([
]),
'title': 'P1 meter', 'title': 'P1 meter',
'unique_id': 'HWE-P1_5c2fafabcdef', 'unique_id': 'HWE-P1_5c2fafabcdef',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'P1 meter', 'title': 'P1 meter',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -74,10 +78,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'zeroconf', 'source': 'zeroconf',
'subentries': list([
]),
'title': 'P1 meter', 'title': 'P1 meter',
'unique_id': 'HWE-P1_5c2fafabcdef', 'unique_id': 'HWE-P1_5c2fafabcdef',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'P1 meter', 'title': 'P1 meter',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -118,10 +126,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'zeroconf', 'source': 'zeroconf',
'subentries': list([
]),
'title': 'Energy Socket', 'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef', 'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Energy Socket', 'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -158,10 +170,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'P1 meter', 'title': 'P1 meter',
'unique_id': 'HWE-P1_5c2fafabcdef', 'unique_id': 'HWE-P1_5c2fafabcdef',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'P1 meter', 'title': 'P1 meter',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,

View File

@ -190,6 +190,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Husqvarna Automower of Erika Mustermann', 'title': 'Husqvarna Automower of Erika Mustermann',
'unique_id': '123', 'unique_id': '123',
'version': 1, 'version': 1,

View File

@ -15,6 +15,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'River Name (Station Name)', 'title': 'River Name (Station Name)',
'unique_id': '123', 'unique_id': '123',
'version': 1, 'version': 1,

View File

@ -358,6 +358,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -57,6 +57,7 @@ async def test_entry_diagnostics(
"created_at": ANY, "created_at": ANY,
"modified_at": ANY, "modified_at": ANY,
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
"client": { "client": {
"version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'",

View File

@ -25,6 +25,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -73,6 +73,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'test-site-name', 'title': 'test-site-name',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'envy', 'title': 'envy',
'unique_id': '00:11:22:33:44:55', 'unique_id': '00:11:22:33:44:55',
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'melcloud', 'title': 'melcloud',
'unique_id': 'UNIQUE_TEST_ID', 'unique_id': 'UNIQUE_TEST_ID',
'version': 1, 'version': 1,

View File

@ -16,6 +16,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'AA:BB:CC:DD:EE:FF', 'unique_id': 'AA:BB:CC:DD:EE:FF',
'version': 1, 'version': 1,

View File

@ -28,6 +28,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -646,6 +646,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'netatmo', 'unique_id': 'netatmo',
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Fake Profile', 'title': 'Fake Profile',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -60,6 +60,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -37,6 +37,7 @@ async def test_entry_diagnostics(
"created_at": ANY, "created_at": ANY,
"modified_at": ANY, "modified_at": ANY,
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
"data": { "data": {
"bridges": [ "bridges": [

View File

@ -24,6 +24,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'aa:bb:cc:dd:ee:ff', 'unique_id': 'aa:bb:cc:dd:ee:ff',
'version': 1, 'version': 1,

View File

@ -39,6 +39,7 @@ async def test_entry_diagnostics(
"created_at": ANY, "created_at": ANY,
"modified_at": ANY, "modified_at": ANY,
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
"data": { "data": {
"protection_window": { "protection_window": {

View File

@ -16,6 +16,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'unique_thingy', 'unique_id': 'unique_thingy',
'version': 2, 'version': 2,
@ -38,6 +40,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'unique_thingy', 'unique_id': 'unique_thingy',
'version': 2, 'version': 2,

View File

@ -31,6 +31,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '70272185-xxxx-xxxx-xxxx-43bea330dcae', 'unique_id': '70272185-xxxx-xxxx-xxxx-43bea330dcae',
'version': 1, 'version': 1,

View File

@ -94,6 +94,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -155,6 +155,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry)
"version": 1, "version": 1,
"options": {}, "options": {},
"minor_version": 1, "minor_version": 1,
"subentries": (),
} }
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -33,6 +33,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -102,6 +102,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'home', 'title': 'home',
'unique_id': 'proximity_home', 'unique_id': 'proximity_home',
'version': 1, 'version': 1,

View File

@ -52,6 +52,7 @@ MOCK_FLOW_RESULT = {
"title": "test_ps4", "title": "test_ps4",
"data": MOCK_DATA, "data": MOCK_DATA,
"options": {}, "options": {},
"subentries": (),
} }
MOCK_ENTRY_ID = "SomeID" MOCK_ENTRY_ID = "SomeID"

View File

@ -38,6 +38,7 @@ async def test_entry_diagnostics(
"created_at": ANY, "created_at": ANY,
"modified_at": ANY, "modified_at": ANY,
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
"data": { "data": {
"fields": [ "fields": [

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,
@ -84,6 +86,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -1144,6 +1144,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 2, 'version': 2,
@ -2275,6 +2277,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 2, 'version': 2,

View File

@ -34,6 +34,7 @@ async def test_entry_diagnostics(
"created_at": ANY, "created_at": ANY,
"modified_at": ANY, "modified_at": ANY,
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
"data": [ "data": [
{ {

View File

@ -44,6 +44,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 2, 'version': 2,

View File

@ -51,6 +51,7 @@ async def test_entry_diagnostics(
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"source": "user", "source": "user",
"subentries": [],
"title": "Mock Title", "title": "Mock Title",
"unique_id": "any", "unique_id": "any",
"version": 2, "version": 2,
@ -91,6 +92,7 @@ async def test_entry_diagnostics_encrypted(
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"source": "user", "source": "user",
"subentries": [],
"title": "Mock Title", "title": "Mock Title",
"unique_id": "any", "unique_id": "any",
"version": 2, "version": 2,
@ -130,6 +132,7 @@ async def test_entry_diagnostics_encrypte_offline(
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"source": "user", "source": "user",
"subentries": [],
"title": "Mock Title", "title": "Mock Title",
"unique_id": "any", "unique_id": "any",
"version": 2, "version": 2,

View File

@ -18,6 +18,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Pentair: DD-EE-FF', 'title': 'Pentair: DD-EE-FF',
'unique_id': 'aa:bb:cc:dd:ee:ff', 'unique_id': 'aa:bb:cc:dd:ee:ff',
'version': 1, 'version': 1,

View File

@ -32,6 +32,7 @@ async def test_entry_diagnostics(
"created_at": ANY, "created_at": ANY,
"modified_at": ANY, "modified_at": ANY,
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
"subscription_data": { "subscription_data": {
"12345": { "12345": {

View File

@ -18,6 +18,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'solarlog', 'title': 'solarlog',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -136,6 +136,7 @@ async def test_user_form_pin_not_required(
"data": deepcopy(TEST_CONFIG), "data": deepcopy(TEST_CONFIG),
"options": {}, "options": {},
"minor_version": 1, "minor_version": 1,
"subentries": (),
} }
expected["data"][CONF_PIN] = None expected["data"][CONF_PIN] = None
@ -341,6 +342,7 @@ async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None:
"data": TEST_CONFIG, "data": TEST_CONFIG,
"options": {}, "options": {},
"minor_version": 1, "minor_version": 1,
"subentries": (),
} }
result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID
assert result == expected assert result == expected

View File

@ -69,5 +69,6 @@ async def test_diagnostics(
"created_at": ANY, "created_at": ANY,
"modified_at": ANY, "modified_at": ANY,
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
} }

View File

@ -56,6 +56,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'System Monitor', 'title': 'System Monitor',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,
@ -111,6 +113,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'System Monitor', 'title': 'System Monitor',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -37,6 +37,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -17,6 +17,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': 'very_unique_string', 'unique_id': 'very_unique_string',
'version': 1, 'version': 1,

View File

@ -24,6 +24,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '12345', 'title': '12345',
'unique_id': '12345', 'unique_id': '12345',
'version': 1, 'version': 1,
@ -54,6 +56,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Old Tuya configuration entry', 'title': 'Old Tuya configuration entry',
'unique_id': '12345', 'unique_id': '12345',
'version': 1, 'version': 1,
@ -107,10 +111,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'mocked_username', 'title': 'mocked_username',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'mocked_username', 'title': 'mocked_username',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,

View File

@ -37,6 +37,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Twinkly', 'title': 'Twinkly',
'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2',
'version': 1, 'version': 1,

View File

@ -42,6 +42,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': '1', 'unique_id': '1',
'version': 1, 'version': 1,

View File

@ -27,10 +27,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Uptime', 'title': 'Uptime',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Uptime', 'title': 'Uptime',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,

View File

@ -25,6 +25,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Energy Bill', 'title': 'Energy Bill',
'unique_id': None, 'unique_id': None,
'version': 2, 'version': 2,

View File

@ -16,6 +16,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': 'ABC123', 'unique_id': 'ABC123',
'version': 1, 'version': 1,

View File

@ -4731,6 +4731,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': 'ViCare', 'unique_id': 'ViCare',
'version': 1, 'version': 1,

View File

@ -35,6 +35,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -27,6 +27,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 1,

View File

@ -253,6 +253,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -61,5 +61,6 @@ async def test_diagnostics(
"created_at": entry.created_at.isoformat(), "created_at": entry.created_at.isoformat(),
"modified_at": entry.modified_at.isoformat(), "modified_at": entry.modified_at.isoformat(),
"discovery_keys": {}, "discovery_keys": {},
"subentries": [],
}, },
} }

View File

@ -38,6 +38,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -30,10 +30,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Example.com', 'title': 'Example.com',
'unique_id': 'example.com', 'unique_id': 'example.com',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Example.com', 'title': 'Example.com',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -70,10 +74,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Example.com', 'title': 'Example.com',
'unique_id': 'example.com', 'unique_id': 'example.com',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Example.com', 'title': 'Example.com',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -110,10 +118,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Example.com', 'title': 'Example.com',
'unique_id': 'example.com', 'unique_id': 'example.com',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Example.com', 'title': 'Example.com',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -150,10 +162,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Example.com', 'title': 'Example.com',
'unique_id': 'example.com', 'unique_id': 'example.com',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Example.com', 'title': 'Example.com',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -190,10 +206,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Example.com', 'title': 'Example.com',
'unique_id': 'example.com', 'unique_id': 'example.com',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Example.com', 'title': 'Example.com',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,

View File

@ -40,6 +40,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -36,10 +36,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'hassio', 'source': 'hassio',
'subentries': list([
]),
'title': 'Piper', 'title': 'Piper',
'unique_id': '1234', 'unique_id': '1234',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Piper', 'title': 'Piper',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -82,10 +86,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'hassio', 'source': 'hassio',
'subentries': list([
]),
'title': 'Piper', 'title': 'Piper',
'unique_id': '1234', 'unique_id': '1234',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Piper', 'title': 'Piper',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,
@ -127,10 +135,14 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'zeroconf', 'source': 'zeroconf',
'subentries': list([
]),
'title': 'Test Satellite', 'title': 'Test Satellite',
'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite',
'version': 1, 'version': 1,
}), }),
'subentries': tuple(
),
'title': 'Test Satellite', 'title': 'Test Satellite',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>, 'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1, 'version': 1,

View File

@ -113,6 +113,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 4, 'version': 4,

View File

@ -16,6 +16,8 @@
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([
]),
'title': 'Mock Title', 'title': 'Mock Title',
'unique_id': None, 'unique_id': None,
'version': 1, 'version': 1,

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Generator from collections.abc import Generator
from contextlib import AbstractContextManager, nullcontext as does_not_raise
from datetime import timedelta from datetime import timedelta
import logging import logging
import re import re
@ -905,7 +906,7 @@ async def test_entries_excludes_ignore_and_disabled(
async def test_saving_and_loading( async def test_saving_and_loading(
hass: HomeAssistant, freezer: FrozenDateTimeFactory hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any]
) -> None: ) -> None:
"""Test that we're saving and loading correctly.""" """Test that we're saving and loading correctly."""
mock_integration( mock_integration(
@ -922,7 +923,17 @@ async def test_saving_and_loading(
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Test user step.""" """Test user step."""
await self.async_set_unique_id("unique") await self.async_set_unique_id("unique")
return self.async_create_entry(title="Test Title", data={"token": "abcd"}) subentries = [
config_entries.ConfigSubentryData(
data={"foo": "bar"}, title="subentry 1"
),
config_entries.ConfigSubentryData(
data={"sun": "moon"}, title="subentry 2", unique_id="very_unique"
),
]
return self.async_create_entry(
title="Test Title", data={"token": "abcd"}, subentries=subentries
)
with mock_config_flow("test", TestFlow): with mock_config_flow("test", TestFlow):
await hass.config_entries.flow.async_init( await hass.config_entries.flow.async_init(
@ -971,6 +982,98 @@ async def test_saving_and_loading(
# To execute the save # To execute the save
await hass.async_block_till_done() await hass.async_block_till_done()
stored_data = hass_storage["core.config_entries"]
assert stored_data == {
"data": {
"entries": [
{
"created_at": ANY,
"data": {
"token": "abcd",
},
"disabled_by": None,
"discovery_keys": {},
"domain": "test",
"entry_id": ANY,
"minor_version": 1,
"modified_at": ANY,
"options": {},
"pref_disable_new_entities": True,
"pref_disable_polling": True,
"source": "user",
"subentries": [
{
"data": {"foo": "bar"},
"subentry_id": ANY,
"title": "subentry 1",
"unique_id": None,
},
{
"data": {"sun": "moon"},
"subentry_id": ANY,
"title": "subentry 2",
"unique_id": "very_unique",
},
],
"title": "Test Title",
"unique_id": "unique",
"version": 5,
},
{
"created_at": ANY,
"data": {
"username": "bla",
},
"disabled_by": None,
"discovery_keys": {
"test": [
{"domain": "test", "key": "blah", "version": 1},
],
},
"domain": "test",
"entry_id": ANY,
"minor_version": 1,
"modified_at": ANY,
"options": {},
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"source": "user",
"subentries": [],
"title": "Test 2 Title",
"unique_id": None,
"version": 3,
},
{
"created_at": ANY,
"data": {
"username": "bla",
},
"disabled_by": None,
"discovery_keys": {
"test": [
{"domain": "test", "key": ["a", "b"], "version": 1},
],
},
"domain": "test",
"entry_id": ANY,
"minor_version": 1,
"modified_at": ANY,
"options": {},
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"source": "user",
"subentries": [],
"title": "Test 2 Title",
"unique_id": None,
"version": 3,
},
],
},
"key": "core.config_entries",
"minor_version": 5,
"version": 1,
}
# Now load written data in new config manager # Now load written data in new config manager
manager = config_entries.ConfigEntries(hass, {}) manager = config_entries.ConfigEntries(hass, {})
await manager.async_initialize() await manager.async_initialize()
@ -983,6 +1086,25 @@ async def test_saving_and_loading(
): ):
assert orig.as_dict() == loaded.as_dict() assert orig.as_dict() == loaded.as_dict()
hass.config_entries.async_update_entry(
entry_1,
pref_disable_polling=False,
pref_disable_new_entities=False,
)
# To trigger the call_later
freezer.tick(1.0)
async_fire_time_changed(hass)
# To execute the save
await hass.async_block_till_done()
# Assert no data is lost when storing again
expected_stored_data = stored_data
expected_stored_data["data"]["entries"][0]["modified_at"] = ANY
expected_stored_data["data"]["entries"][0]["pref_disable_new_entities"] = False
expected_stored_data["data"]["entries"][0]["pref_disable_polling"] = False
assert hass_storage["core.config_entries"] == expected_stored_data | {}
@freeze_time("2024-02-14 12:00:00") @freeze_time("2024-02-14 12:00:00")
async def test_as_dict(snapshot: SnapshotAssertion) -> None: async def test_as_dict(snapshot: SnapshotAssertion) -> None:
@ -1416,6 +1538,42 @@ async def test_update_entry_options_and_trigger_listener(
assert len(update_listener_calls) == 1 assert len(update_listener_calls) == 1
async def test_update_subentry_and_trigger_listener(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test that we can update subentry and trigger listener."""
entry = MockConfigEntry(domain="test", options={"first": True})
entry.add_to_manager(manager)
update_listener_calls = []
subentry = config_entries.ConfigSubentry(
data={"test": "test"}, unique_id="test", title="Mock title"
)
async def update_listener(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> None:
"""Test function."""
assert entry.subentries == expected_subentries
update_listener_calls.append(None)
entry.add_update_listener(update_listener)
expected_subentries = {subentry.subentry_id: subentry}
assert manager.async_add_subentry(entry, subentry) is True
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.subentries == expected_subentries
assert len(update_listener_calls) == 1
expected_subentries = {}
assert manager.async_remove_subentry(entry, subentry.subentry_id) is True
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.subentries == expected_subentries
assert len(update_listener_calls) == 2
async def test_setup_raise_not_ready( async def test_setup_raise_not_ready(
hass: HomeAssistant, hass: HomeAssistant,
manager: config_entries.ConfigEntries, manager: config_entries.ConfigEntries,
@ -1742,17 +1900,453 @@ async def test_entry_options_unknown_config_entry(
mock_integration(hass, MockModule("test")) mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None) mock_platform(hass, "test.config_flow", None)
class TestFlow: with pytest.raises(config_entries.UnknownEntry):
await manager.options.async_create_flow(
"blah", context={"source": "test"}, data=None
)
async def test_create_entry_subentries(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test a config entry being created with subentries."""
subentrydata = config_entries.ConfigSubentryData(
data={"test": "test"},
title="Mock title",
unique_id="test",
)
async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Mock setup."""
hass.async_create_task(
hass.config_entries.flow.async_init(
"comp",
context={"source": config_entries.SOURCE_IMPORT},
data={"data": "data", "subentry": subentrydata},
)
)
return True
async_setup_entry = AsyncMock(return_value=True)
mock_integration(
hass,
MockModule(
"comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry
),
)
mock_platform(hass, "comp.config_flow", None)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
VERSION = 1
async def async_step_import(self, user_input):
"""Test import step creating entry, with subentry."""
return self.async_create_entry(
title="title",
data={"example": user_input["data"]},
subentries=[user_input["subentry"]],
)
with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}):
assert await async_setup_component(hass, "comp", {})
await hass.async_block_till_done()
assert len(async_setup_entry.mock_calls) == 1
entries = hass.config_entries.async_entries("comp")
assert len(entries) == 1
assert entries[0].supported_subentries == ()
assert entries[0].data == {"example": "data"}
assert len(entries[0].subentries) == 1
subentry_id = list(entries[0].subentries)[0]
subentry = config_entries.ConfigSubentry(
data=subentrydata["data"],
subentry_id=subentry_id,
title=subentrydata["title"],
unique_id="test",
)
assert entries[0].subentries == {subentry_id: subentry}
async def test_entry_subentry(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test that we can add a subentry to an entry."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
entry = MockConfigEntry(domain="test", data={"first": True})
entry.add_to_manager(manager)
class TestFlow(config_entries.ConfigFlow):
"""Test flow.""" """Test flow."""
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry): def async_get_subentry_flow(config_entry, subentry_type: str):
"""Test options flow.""" """Test subentry flow."""
class SubentryFlowHandler(data_entry_flow.FlowHandler):
"""Test subentry flow handler."""
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(
cls, config_entry: ConfigEntry
) -> tuple[str, ...]:
return ("test",)
with mock_config_flow("test", TestFlow):
flow = await manager.subentries.async_create_flow(
(entry.entry_id, "test"), context={"source": "test"}, data=None
)
flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry
await manager.subentries.async_finish_flow(
flow,
{
"data": {"second": True},
"title": "Mock title",
"type": data_entry_flow.FlowResultType.CREATE_ENTRY,
"unique_id": "test",
},
)
assert entry.data == {"first": True}
assert entry.options == {}
subentry_id = list(entry.subentries)[0]
assert entry.subentries == {
subentry_id: config_entries.ConfigSubentry(
data={"second": True},
subentry_id=subentry_id,
title="Mock title",
unique_id="test",
)
}
assert entry.supported_subentries == ("test",)
async def test_entry_subentry_non_string(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test adding an invalid subentry to an entry."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
entry = MockConfigEntry(domain="test", data={"first": True})
entry.add_to_manager(manager)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
"""Test subentry flow."""
class SubentryFlowHandler(data_entry_flow.FlowHandler):
"""Test subentry flow handler."""
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(
cls, config_entry: ConfigEntry
) -> tuple[str, ...]:
return ("test",)
with mock_config_flow("test", TestFlow):
flow = await manager.subentries.async_create_flow(
(entry.entry_id, "test"), context={"source": "test"}, data=None
)
flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry
with pytest.raises(HomeAssistantError):
await manager.subentries.async_finish_flow(
flow,
{
"data": {"second": True},
"title": "Mock title",
"type": data_entry_flow.FlowResultType.CREATE_ENTRY,
"unique_id": 123,
},
)
@pytest.mark.parametrize("context", [None, {}, {"bla": "bleh"}])
async def test_entry_subentry_no_context(
hass: HomeAssistant, manager: config_entries.ConfigEntries, context: dict | None
) -> None:
"""Test starting a subentry flow without "source" in context."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
entry = MockConfigEntry(domain="test", data={"first": True})
entry.add_to_manager(manager)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
"""Test subentry flow."""
class SubentryFlowHandler(data_entry_flow.FlowHandler):
"""Test subentry flow handler."""
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(
cls, config_entry: ConfigEntry
) -> tuple[str, ...]:
return ("test",)
with mock_config_flow("test", TestFlow), pytest.raises(KeyError):
await manager.subentries.async_create_flow(
(entry.entry_id, "test"), context=context, data=None
)
@pytest.mark.parametrize(
("unique_id", "expected_result"),
[(None, does_not_raise()), ("test", pytest.raises(HomeAssistantError))],
)
async def test_entry_subentry_duplicate(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
unique_id: str | None,
expected_result: AbstractContextManager,
) -> None:
"""Test adding a duplicated subentry to an entry."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
entry = MockConfigEntry(
domain="test",
data={"first": True},
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="blabla",
title="Mock title",
unique_id=unique_id,
)
],
)
entry.add_to_manager(manager)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
"""Test subentry flow."""
class SubentryFlowHandler(data_entry_flow.FlowHandler):
"""Test subentry flow handler."""
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(
cls, config_entry: ConfigEntry
) -> tuple[str, ...]:
return ("test",)
with mock_config_flow("test", TestFlow):
flow = await manager.subentries.async_create_flow(
(entry.entry_id, "test"), context={"source": "test"}, data=None
)
flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry
with expected_result:
await manager.subentries.async_finish_flow(
flow,
{
"data": {"second": True},
"title": "Mock title",
"type": data_entry_flow.FlowResultType.CREATE_ENTRY,
"unique_id": unique_id,
},
)
async def test_entry_subentry_abort(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test that we can abort subentry flow."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
entry = MockConfigEntry(domain="test", data={"first": True})
entry.add_to_manager(manager)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
"""Test subentry flow."""
class SubentryFlowHandler(data_entry_flow.FlowHandler):
"""Test subentry flow handler."""
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(
cls, config_entry: ConfigEntry
) -> tuple[str, ...]:
return ("test",)
with mock_config_flow("test", TestFlow):
flow = await manager.subentries.async_create_flow(
(entry.entry_id, "test"), context={"source": "test"}, data=None
)
flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry
assert await manager.subentries.async_finish_flow(
flow, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "test"}
)
async def test_entry_subentry_unknown_config_entry(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test attempting to start a subentry flow for an unknown config entry."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
with pytest.raises(config_entries.UnknownEntry): with pytest.raises(config_entries.UnknownEntry):
await manager.options.async_create_flow( await manager.subentries.async_create_flow(
"blah", context={"source": "test"}, data=None ("blah", "blah"), context={"source": "test"}, data=None
)
async def test_entry_subentry_deleted_config_entry(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test attempting to finish a subentry flow for a deleted config entry."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
entry = MockConfigEntry(domain="test", data={"first": True})
entry.add_to_manager(manager)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
"""Test subentry flow."""
class SubentryFlowHandler(data_entry_flow.FlowHandler):
"""Test subentry flow handler."""
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(
cls, config_entry: ConfigEntry
) -> tuple[str, ...]:
return ("test",)
with mock_config_flow("test", TestFlow):
flow = await manager.subentries.async_create_flow(
(entry.entry_id, "test"), context={"source": "test"}, data=None
)
flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry
await hass.config_entries.async_remove(entry.entry_id)
with pytest.raises(config_entries.UnknownEntry):
await manager.subentries.async_finish_flow(
flow,
{
"data": {"second": True},
"title": "Mock title",
"type": data_entry_flow.FlowResultType.CREATE_ENTRY,
"unique_id": "test",
},
)
async def test_entry_subentry_unsupported(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test attempting to start a subentry flow for a config entry without support."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
entry = MockConfigEntry(domain="test", data={"first": True})
entry.add_to_manager(manager)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
@staticmethod
@callback
def async_get_subentry_flow(config_entry, subentry_type: str):
"""Test subentry flow."""
class SubentryFlowHandler(data_entry_flow.FlowHandler):
"""Test subentry flow handler."""
return SubentryFlowHandler()
@classmethod
@callback
def async_supported_subentries(
cls, config_entry: ConfigEntry
) -> tuple[str, ...]:
return ("test",)
with (
mock_config_flow("test", TestFlow),
pytest.raises(data_entry_flow.UnknownHandler),
):
await manager.subentries.async_create_flow(
(
entry.entry_id,
"unknown",
),
context={"source": "test"},
data=None,
)
async def test_entry_subentry_unsupported_subentry_type(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test attempting to start a subentry flow for a config entry without support."""
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
entry = MockConfigEntry(domain="test", data={"first": True})
entry.add_to_manager(manager)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
with (
mock_config_flow("test", TestFlow),
pytest.raises(data_entry_flow.UnknownHandler),
):
await manager.subentries.async_create_flow(
(entry.entry_id, "test"), context={"source": "test"}, data=None
) )
@ -3911,21 +4505,20 @@ async def test_updating_entry_with_and_without_changes(
assert manager.async_update_entry(entry) is False assert manager.async_update_entry(entry) is False
for change in ( for change, expected_value in (
{"data": {"second": True, "third": 456}}, ({"data": {"second": True, "third": 456}}, {"second": True, "third": 456}),
{"data": {"second": True}}, ({"data": {"second": True}}, {"second": True}),
{"minor_version": 2}, ({"minor_version": 2}, 2),
{"options": {"hello": True}}, ({"options": {"hello": True}}, {"hello": True}),
{"pref_disable_new_entities": True}, ({"pref_disable_new_entities": True}, True),
{"pref_disable_polling": True}, ({"pref_disable_polling": True}, True),
{"title": "sometitle"}, ({"title": "sometitle"}, "sometitle"),
{"unique_id": "abcd1234"}, ({"unique_id": "abcd1234"}, "abcd1234"),
{"version": 2}, ({"version": 2}, 2),
): ):
assert manager.async_update_entry(entry, **change) is True assert manager.async_update_entry(entry, **change) is True
key = next(iter(change)) key = next(iter(change))
value = next(iter(change.values())) assert getattr(entry, key) == expected_value
assert getattr(entry, key) == value
assert manager.async_update_entry(entry, **change) is False assert manager.async_update_entry(entry, **change) is False
assert manager.async_entry_for_domain_unique_id("test", "abc123") is None assert manager.async_entry_for_domain_unique_id("test", "abc123") is None
@ -5459,6 +6052,7 @@ async def test_unhashable_unique_id_fails(
minor_version=1, minor_version=1,
options={}, options={},
source="test", source="test",
subentries_data=(),
title="title", title="title",
unique_id=unique_id, unique_id=unique_id,
version=1, version=1,
@ -5494,6 +6088,7 @@ async def test_unhashable_unique_id_fails_on_update(
minor_version=1, minor_version=1,
options={}, options={},
source="test", source="test",
subentries_data=(),
title="title", title="title",
unique_id="123", unique_id="123",
version=1, version=1,
@ -5524,6 +6119,7 @@ async def test_string_unique_id_no_warning(
minor_version=1, minor_version=1,
options={}, options={},
source="test", source="test",
subentries_data=(),
title="title", title="title",
unique_id="123", unique_id="123",
version=1, version=1,
@ -5566,6 +6162,7 @@ async def test_hashable_unique_id(
minor_version=1, minor_version=1,
options={}, options={},
source="test", source="test",
subentries_data=(),
title="title", title="title",
unique_id=unique_id, unique_id=unique_id,
version=1, version=1,
@ -5600,6 +6197,7 @@ async def test_no_unique_id_no_warning(
minor_version=1, minor_version=1,
options={}, options={},
source="test", source="test",
subentries_data=(),
title="title", title="title",
unique_id=None, unique_id=None,
version=1, version=1,
@ -6524,6 +7122,7 @@ async def test_migration_from_1_2(
"pref_disable_new_entities": False, "pref_disable_new_entities": False,
"pref_disable_polling": False, "pref_disable_polling": False,
"source": "import", "source": "import",
"subentries": {},
"title": "Sun", "title": "Sun",
"unique_id": None, "unique_id": None,
"version": 1, "version": 1,