Compare commits

...

19 Commits

Author SHA1 Message Date
Erik
c0ef7212b2 Update kitchen_sink 2025-01-07 13:08:53 +01:00
Erik
92dad98b14 Update kitchen_sink 2025-01-07 13:05:18 +01:00
Erik
f6214a22c1 Rename supported_subentry_flows to supported_subentry_types 2025-01-07 13:04:55 +01:00
Erik
8541e6e9cd Minor adjustment 2025-01-07 11:31:46 +01:00
Erik
1fbf929819 Store subentry type in subentry 2025-01-07 11:29:48 +01:00
Erik
07734239b4 Update entity platform 2025-01-07 11:22:17 +01:00
Erik
82b13d6b75 Update entity registry 2025-01-07 11:20:50 +01:00
Erik
3ef9f7360f Add subentry reconfigure support to kitchen_sink 2025-01-07 09:56:23 +01:00
Erik
fa6cc8edfe Add reconfigure support to config subentries 2025-01-07 09:56:23 +01:00
Erik
a12a42710f Add subentry support to kitchen sink 2025-01-07 09:56:23 +01:00
Erik
a4653bb8dc Clean up registries when removing subentry 2025-01-07 09:56:23 +01:00
Erik
d2bf58e1ba Add config subentry support to entity platform 2025-01-07 09:56:23 +01:00
Erik
53fd84a5a4 Allow a device to be connected to no or a single subentry of a config entry 2025-01-07 09:56:23 +01:00
Erik
6b4c27e700 Address review comments 2025-01-07 09:56:23 +01:00
Erik
04754ac83a Update syrupy serializer 2025-01-07 09:56:23 +01:00
Erik Montnemery
42e465a4f3 Apply suggestions from code review 2025-01-07 09:56:23 +01:00
Erik
6fca5022b1 Add config subentry support to device registry 2025-01-07 09:56:23 +01:00
Erik
68f8c3e9ed Add config subentry support to entity registry 2025-01-07 09:56:23 +01:00
Erik
90b2504d5a Reapply "Add support for subentries to config entries" (#133470)
This reverts commit ecb3bf79f3.
2025-01-07 09:56:13 +01:00
112 changed files with 3867 additions and 85 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(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_entry_disable)
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, ignore_config_flow)
websocket_api.async_register_command(hass, config_subentry_delete)
websocket_api.async_register_command(hass, config_subentry_list)
return True
@@ -285,6 +295,66 @@ class OptionManagerFlowResourceView(
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
if subentry_id := data.get("subentry_id"):
context["source"] = config_entries.SOURCE_RECONFIGURE
context["subentry_id"] = subentry_id
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.websocket_command({"type": "config_entries/flow/progress"})
def config_entries_progress(
@@ -588,3 +658,63 @@ async def _async_matching_config_entries_json_fragments(
)
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,
"subentry_type": subentry.subentry_type,
"title": subentry.title,
"unique_id": subentry.unique_id,
}
for subentry in entry.subentries.values()
]
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

@@ -70,11 +70,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set the config entry up."""
# Set up demo platforms with config entry
await hass.config_entries.async_forward_entry_setups(
config_entry, COMPONENTS_WITH_DEMO_PLATFORM
entry, COMPONENTS_WITH_DEMO_PLATFORM
)
# Create issues
@@ -85,7 +85,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
await _insert_statistics(hass)
# Start a reauth flow
config_entry.async_start_reauth(hass)
entry.async_start_reauth(hass)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
# Notify backup listeners
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
@@ -93,6 +95,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
# Notify backup listeners

View File

@@ -12,7 +12,9 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlow,
SubentryFlowResult,
)
from homeassistant.core import callback
@@ -35,6 +37,14 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return OptionsFlowHandler()
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"entity": SubentryFlowHandler}
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Set the config entry up from yaml."""
return self.async_create_entry(title="Kitchen Sink", data=import_data)
@@ -94,3 +104,60 @@ class OptionsFlowHandler(OptionsFlow):
}
),
)
class SubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage the options."""
return await self.async_step_add_sensor()
async def async_step_add_sensor(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a new sensor."""
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
return self.async_show_form(
step_id="add_sensor",
data_schema=vol.Schema(
{
vol.Required("name"): str,
vol.Required("state"): int,
}
),
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage the options."""
return await self.async_step_reconfigure_sensor()
async def async_step_reconfigure_sensor(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a new sensor."""
if user_input is not None:
title = user_input.pop("name")
return self.async_update_and_abort(
self._get_reconfigure_entry(),
self._get_reconfigure_subentry(),
data=user_input,
title=title,
)
return self.async_show_form(
step_id="reconfigure_sensor",
data_schema=vol.Schema(
{
vol.Required("name"): str,
vol.Required("state"): int,
}
),
)

View File

@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType
from . import DOMAIN
@@ -21,7 +21,8 @@ from .device import async_create_device
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
# pylint: disable-next=hass-argument-type
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
async_create_device(
@@ -90,6 +91,23 @@ async def async_setup_entry(
]
)
for subentry_id, subentry in config_entry.subentries.items():
async_add_entities(
[
DemoSensor(
device_unique_id=subentry_id,
unique_id=subentry_id,
device_name=subentry.title,
entity_name=None,
state=subentry.data["state"],
device_class=None,
state_class=None,
unit_of_measurement=None,
)
],
subentry_id=subentry_id,
)
class DemoSensor(SensorEntity):
"""Representation of a Demo sensor."""

View File

@@ -9,6 +9,20 @@
}
}
},
"config_subentries": {
"entity": {
"title": "Add entity",
"step": {
"add_sensor": {
"description": "Configure the new sensor",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"state": "Initial state"
}
}
}
}
},
"options": {
"step": {
"init": {

View File

@@ -15,6 +15,7 @@ from collections.abc import (
)
from contextvars import ContextVar
from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum, StrEnum
import functools
@@ -22,7 +23,7 @@ from functools import cache
import logging
from random import randint
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 propcache import cached_property
@@ -128,7 +129,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry()
STORAGE_KEY = "core.config_entries"
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 4
STORAGE_VERSION_MINOR = 5
SAVE_DELAY = 1
@@ -256,6 +257,10 @@ class UnknownEntry(ConfigError):
"""Unknown entry specified."""
class UnknownSubEntry(ConfigError):
"""Unknown subentry specified."""
class OperationNotAllowed(ConfigError):
"""Raised when a config entry operation is not allowed."""
@@ -300,6 +305,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
minor_version: int
options: Mapping[str, Any]
subentries: Iterable[ConfigSubentryData]
version: int
@@ -313,6 +319,61 @@ 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]
subentry_type: str
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 SubentryFlowContext(FlowContext, total=False):
"""Typed context dict for config flow."""
entry_id: str
subentry_id: str
class SubentryFlowResult(FlowResult[SubentryFlowContext, 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)
subentry_type: str
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,
"subentry_type": self.subentry_type,
"title": self.title,
"unique_id": self.unique_id,
}
class ConfigEntry(Generic[_DataT]):
"""Hold a configuration entry."""
@@ -322,6 +383,7 @@ class ConfigEntry(Generic[_DataT]):
data: MappingProxyType[str, Any]
runtime_data: _DataT
options: MappingProxyType[str, Any]
subentries: MappingProxyType[str, ConfigSubentry]
unique_id: str | None
state: ConfigEntryState
reason: str | None
@@ -337,6 +399,7 @@ class ConfigEntry(Generic[_DataT]):
supports_remove_device: bool | None
_supports_options: bool | None
_supports_reconfigure: bool | None
_supported_subentry_types: dict[str, dict[str, bool]] | None
update_listeners: list[UpdateListenerType]
_async_cancel_retry_setup: Callable[[], Any] | None
_on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None
@@ -366,6 +429,7 @@ class ConfigEntry(Generic[_DataT]):
pref_disable_polling: bool | None = None,
source: str,
state: ConfigEntryState = ConfigEntryState.NOT_LOADED,
subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None,
title: str,
unique_id: str | None,
version: int,
@@ -391,6 +455,25 @@ class ConfigEntry(Generic[_DataT]):
# Entry options
_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"]),
subentry_type=subentry_data["subentry_type"],
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
if pref_disable_new_entities is None:
pref_disable_new_entities = False
@@ -427,6 +510,9 @@ class ConfigEntry(Generic[_DataT]):
# Supports reconfigure
_setter(self, "_supports_reconfigure", None)
# Supports subentries
_setter(self, "_supported_subentry_types", None)
# Listeners to call on update
_setter(self, "update_listeners", [])
@@ -499,6 +585,28 @@ class ConfigEntry(Generic[_DataT]):
)
return self._supports_reconfigure or False
@property
def supported_subentry_types(self) -> dict[str, dict[str, bool]]:
"""Return supported subentry types."""
if self._supported_subentry_types is None and (
handler := HANDLERS.get(self.domain)
):
# work out sub entries supported by the handler
supported_flows = handler.async_get_supported_subentry_types(self)
object.__setattr__(
self,
"_supported_subentry_types",
{
subentry_flow_type: {
"supports_reconfigure": hasattr(
subentry_flow_handler, "async_step_reconfigure"
)
}
for subentry_flow_type, subentry_flow_handler in supported_flows.items()
},
)
return self._supported_subentry_types or {}
def clear_state_cache(self) -> None:
"""Clear cached properties that are included in as_json_fragment."""
self.__dict__.pop("as_json_fragment", None)
@@ -518,12 +626,14 @@ class ConfigEntry(Generic[_DataT]):
"supports_remove_device": self.supports_remove_device or False,
"supports_unload": self.supports_unload or False,
"supports_reconfigure": self.supports_reconfigure,
"supported_subentry_types": self.supported_subentry_types,
"pref_disable_new_entities": self.pref_disable_new_entities,
"pref_disable_polling": self.pref_disable_polling,
"disabled_by": self.disabled_by,
"reason": self.reason,
"error_reason_translation_key": self.error_reason_translation_key,
"error_reason_translation_placeholders": self.error_reason_translation_placeholders,
"num_subentries": len(self.subentries),
}
return json_fragment(json_bytes(json_repr))
@@ -1018,6 +1128,7 @@ class ConfigEntry(Generic[_DataT]):
"pref_disable_new_entities": self.pref_disable_new_entities,
"pref_disable_polling": self.pref_disable_polling,
"source": self.source,
"subentries": [subentry.as_dict() for subentry in self.subentries.values()],
"title": self.title,
"unique_id": self.unique_id,
"version": self.version,
@@ -1503,6 +1614,7 @@ class ConfigEntriesFlowManager(
minor_version=result["minor_version"],
options=result["options"],
source=flow.context["source"],
subentries_data=result["subentries"],
title=result["title"],
unique_id=flow.unique_id,
version=result["version"],
@@ -1793,6 +1905,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entry in data["entries"]:
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:
raise NotImplementedError
return data
@@ -1809,6 +1926,7 @@ class ConfigEntries:
self.hass = hass
self.flow = ConfigEntriesFlowManager(hass, self, hass_config)
self.options = OptionsFlowManager(hass)
self.subentries = ConfigSubentryFlowManager(hass)
self._hass_config = hass_config
self._entries = ConfigEntryItems(hass)
self._store = ConfigEntryStore(hass)
@@ -2011,6 +2129,7 @@ class ConfigEntries:
pref_disable_new_entities=entry["pref_disable_new_entities"],
pref_disable_polling=entry["pref_disable_polling"],
source=entry["source"],
subentries_data=entry["subentries"],
title=entry["title"],
unique_id=entry["unique_id"],
version=entry["version"],
@@ -2170,6 +2289,44 @@ class ConfigEntries:
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
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
not fired and this function returns False
"""
@@ -2232,11 +2389,21 @@ class ConfigEntries:
changed = True
_setter(entry, "options", MappingProxyType(options))
if subentries is not UNDEFINED:
if entry.subentries != subentries:
changed = True
_setter(entry, "subentries", MappingProxyType(subentries))
if not changed:
return False
_setter(entry, "modified_at", utcnow())
self._async_save_and_notify(entry)
return True
@callback
def _async_save_and_notify(self, entry: ConfigEntry) -> None:
for listener in entry.update_listeners:
self.hass.async_create_task(
listener(self.hass, entry),
@@ -2247,8 +2414,92 @@ class ConfigEntries:
entry.clear_state_cache()
entry.clear_storage_cache()
self._async_dispatch(ConfigEntryChange.UPDATED, entry)
@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
result = self._async_update_entry(entry, subentries=subentries)
dev_reg = dr.async_get(self.hass)
ent_reg = er.async_get(self.hass)
dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id)
ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id)
return result
@callback
def async_update_subentry(
self,
entry: ConfigEntry,
subentry: ConfigSubentry,
*,
data: Mapping[str, Any] | UndefinedType = UNDEFINED,
title: str | UndefinedType = UNDEFINED,
unique_id: str | None | 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
not fired and this function returns False
"""
if entry.entry_id not in self._entries:
raise UnknownEntry(entry.entry_id)
if subentry.subentry_id not in entry.subentries:
raise UnknownSubEntry(subentry.subentry_id)
self.hass.verify_event_loop_thread("hass.config_entries.async_update_subentry")
changed = False
_setter = object.__setattr__
if unique_id is not UNDEFINED and subentry.unique_id != unique_id:
self._raise_if_subentry_unique_id_exists(entry, unique_id)
changed = True
_setter(subentry, "unique_id", unique_id)
if title is not UNDEFINED and subentry.title != title:
changed = True
_setter(subentry, "title", title)
if data is not UNDEFINED and subentry.data != data:
changed = True
_setter(subentry, "data", MappingProxyType(data))
if not changed:
return False
_setter(entry, "modified_at", utcnow())
self._async_save_and_notify(entry)
return True
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
def _async_dispatch(
self, change_type: ConfigEntryChange, entry: ConfigEntry
@@ -2585,6 +2836,14 @@ class ConfigFlow(ConfigEntryBaseFlow):
"""Return options flow support for this handler."""
return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {}
@callback
def _async_abort_entries_match(
self, match_dict: dict[str, Any] | None = None
@@ -2893,6 +3152,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
description: str | None = None,
description_placeholders: Mapping[str, str] | None = None,
options: Mapping[str, Any] | None = None,
subentries: Iterable[ConfigSubentryData] | None = None,
) -> ConfigFlowResult:
"""Finish config flow and create a config entry."""
if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
@@ -2912,6 +3172,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
result["minor_version"] = self.MINOR_VERSION
result["options"] = options or {}
result["subentries"] = subentries or ()
result["version"] = self.VERSION
return result
@@ -3026,17 +3287,199 @@ class ConfigFlow(ConfigEntryBaseFlow):
)
class OptionsFlowManager(
data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult]
):
"""Flow to set options for a configuration entry."""
class _ConfigSubFlowManager:
"""Mixin class for flow managers which manage flows tied to a config entry."""
_flow_result = ConfigFlowResult
hass: HomeAssistant
def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry:
"""Return config entry or raise if not found."""
return self.hass.config_entries.async_get_known_entry(config_entry_id)
class ConfigSubentryFlowManager(
data_entry_flow.FlowManager[
SubentryFlowContext, 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, {})
subentry_types = handler.async_get_supported_subentry_types(entry)
if subentry_type not in subentry_types:
raise data_entry_flow.UnknownHandler(
f"Config entry '{entry.domain}' does not support subentry '{subentry_type}'"
)
subentry_flow = subentry_types[subentry_type]()
subentry_flow.init_step = context["source"]
return subentry_flow
async def async_finish_flow(
self,
flow: data_entry_flow.FlowHandler[
SubentryFlowContext, 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, subentry_type = flow.handler
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"]),
subentry_type=subentry_type,
title=result["title"],
unique_id=unique_id,
),
)
result["result"] = True
return result
class ConfigSubentryFlow(
data_entry_flow.FlowHandler[
SubentryFlowContext, 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."""
if self.source != SOURCE_USER:
raise ValueError(f"Source is {self.source}, expected {SOURCE_USER}")
result = super().async_create_entry(
title=title,
data=data,
description=description,
description_placeholders=description_placeholders,
)
result["unique_id"] = unique_id
return result
@callback
def async_update_and_abort(
self,
entry: ConfigEntry,
subentry: ConfigSubentry,
*,
unique_id: str | None | UndefinedType = UNDEFINED,
title: str | UndefinedType = UNDEFINED,
data: Mapping[str, Any] | UndefinedType = UNDEFINED,
data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED,
) -> SubentryFlowResult:
"""Update config subentry and finish subentry flow.
:param data: replace the subentry data with new data
:param data_updates: add items from data_updates to subentry data - existing
keys are overridden
:param title: replace the title of the subentry
:param unique_id: replace the unique_id of the subentry
"""
if data_updates is not UNDEFINED:
if data is not UNDEFINED:
raise ValueError("Cannot set both data and data_updates")
data = entry.data | data_updates
self.hass.config_entries.async_update_subentry(
entry=entry,
subentry=subentry,
unique_id=unique_id,
title=title,
data=data,
)
return self.async_abort(reason="reconfigure_successful")
@property
def _reconfigure_entry_id(self) -> str:
"""Return reconfigure entry id."""
if self.source != SOURCE_RECONFIGURE:
raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}")
return self.handler[0]
@callback
def _get_reconfigure_entry(self) -> ConfigEntry:
"""Return the reconfigure config entry linked to the current context."""
return self.hass.config_entries.async_get_known_entry(
self._reconfigure_entry_id
)
@property
def _reconfigure_subentry_id(self) -> str:
"""Return reconfigure subentry id."""
if self.source != SOURCE_RECONFIGURE:
raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}")
return self.context["subentry_id"]
@callback
def _get_reconfigure_subentry(self) -> ConfigSubentry:
"""Return the reconfigure config subentry linked to the current context."""
entry = self.hass.config_entries.async_get_known_entry(
self._reconfigure_entry_id
)
subentry_id = self._reconfigure_subentry_id
if subentry_id not in entry.subentries:
raise UnknownEntry
return entry.subentries[subentry_id]
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(
self,
handler_key: str,
@@ -3046,7 +3489,7 @@ class OptionsFlowManager(
) -> OptionsFlow:
"""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)
handler = await _async_get_flow_handler(self.hass, entry.domain, {})
@@ -3062,7 +3505,7 @@ class OptionsFlowManager(
This method is called when a flow step returns FlowResultType.ABORT or
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)

View File

@@ -18,7 +18,7 @@ from . import config_validation as cv
_FlowManagerT = TypeVar(
"_FlowManagerT",
bound=data_entry_flow.FlowManager[Any, Any],
bound=data_entry_flow.FlowManager[Any, Any, Any],
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:
"""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`
"""
return await self._post_impl(request, data)

View File

@@ -56,7 +56,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event
)
STORAGE_KEY = "core.device_registry"
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 8
STORAGE_VERSION_MINOR = 9
CLEANUP_DELAY = 10
@@ -272,6 +272,7 @@ class DeviceEntry:
area_id: str | None = attr.ib(default=None)
config_entries: set[str] = attr.ib(converter=set, factory=set)
config_subentries: dict[str, str | None] = attr.ib(factory=dict)
configuration_url: str | None = attr.ib(default=None)
connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set)
created_at: datetime = attr.ib(factory=utcnow)
@@ -311,6 +312,7 @@ class DeviceEntry:
"area_id": self.area_id,
"configuration_url": self.configuration_url,
"config_entries": list(self.config_entries),
"config_subentries": self.config_subentries,
"connections": list(self.connections),
"created_at": self.created_at.timestamp(),
"disabled_by": self.disabled_by,
@@ -354,7 +356,10 @@ class DeviceEntry:
json_bytes(
{
"area_id": self.area_id,
# The config_entries list can be removed from the storage
# representation in HA Core 2026.1
"config_entries": list(self.config_entries),
"config_subentries": self.config_subentries,
"configuration_url": self.configuration_url,
"connections": list(self.connections),
"created_at": self.created_at,
@@ -384,6 +389,7 @@ class DeletedDeviceEntry:
"""Deleted Device Registry Entry."""
config_entries: set[str] = attr.ib()
config_subentries: dict[str, str | None] = attr.ib()
connections: set[tuple[str, str]] = attr.ib()
identifiers: set[tuple[str, str]] = attr.ib()
id: str = attr.ib()
@@ -395,6 +401,7 @@ class DeletedDeviceEntry:
def to_device_entry(
self,
config_entry_id: str,
config_subentry_id: str | None,
connections: set[tuple[str, str]],
identifiers: set[tuple[str, str]],
) -> DeviceEntry:
@@ -402,6 +409,7 @@ class DeletedDeviceEntry:
return DeviceEntry(
# type ignores: likely https://github.com/python/mypy/issues/8625
config_entries={config_entry_id}, # type: ignore[arg-type]
config_subentries={config_entry_id: config_subentry_id},
connections=self.connections & connections, # type: ignore[arg-type]
created_at=self.created_at,
identifiers=self.identifiers & identifiers, # type: ignore[arg-type]
@@ -415,7 +423,10 @@ class DeletedDeviceEntry:
return json_fragment(
json_bytes(
{
# The config_entries list can be removed from the storage
# representation in HA Core 2026.1
"config_entries": list(self.config_entries),
"config_subentries": self.config_subentries,
"connections": list(self.connections),
"created_at": self.created_at,
"identifiers": list(self.identifiers),
@@ -458,7 +469,10 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
old_data: dict[str, list[dict[str, Any]]],
) -> dict[str, Any]:
"""Migrate to the new version."""
if old_major_version < 2:
# Support for a future major version bump to 2 added in HA Core 2025.1.
# Major versions 1 and 2 will be the same, except that version 2 will no
# longer store a list of config_entries.
if old_major_version < 3:
if old_minor_version < 2:
# Version 1.2 implements migration and freezes the available keys,
# populate keys which were introduced before version 1.2
@@ -505,8 +519,20 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
device["created_at"] = device["modified_at"] = created_at
for device in old_data["deleted_devices"]:
device["created_at"] = device["modified_at"] = created_at
if old_minor_version < 9:
# Introduced in 2025.1
for device in old_data["devices"]:
device["config_subentries"] = {
config_entry_id: None
for config_entry_id in device["config_entries"]
}
for device in old_data["deleted_devices"]:
device["config_subentries"] = {
config_entry_id: None
for config_entry_id in device["config_entries"]
}
if old_major_version > 1:
if old_major_version > 2:
raise NotImplementedError
return old_data
@@ -699,6 +725,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self,
*,
config_entry_id: str,
config_subentry_id: str | None | UndefinedType = UNDEFINED,
configuration_url: str | URL | None | UndefinedType = UNDEFINED,
connections: set[tuple[str, str]] | None | UndefinedType = UNDEFINED,
created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored
@@ -789,7 +816,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
else:
self.deleted_devices.pop(deleted_device.id)
device = deleted_device.to_device_entry(
config_entry_id, connections, identifiers
config_entry_id,
# Interpret not specifying a subentry as None
config_subentry_id if config_subentry_id is not UNDEFINED else None,
connections,
identifiers,
)
self.devices[device.id] = device
# If creating a new device, default to the config entry name
@@ -823,6 +854,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
device.id,
allow_collisions=True,
add_config_entry_id=config_entry_id,
add_config_subentry_id=config_subentry_id,
configuration_url=configuration_url,
device_info_type=device_info_type,
disabled_by=disabled_by,
@@ -851,6 +883,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
device_id: str,
*,
add_config_entry_id: str | UndefinedType = UNDEFINED,
add_config_subentry_id: str | None | UndefinedType = UNDEFINED,
# Temporary flag so we don't blow up when collisions are implicitly introduced
# by calls to async_get_or_create. Must not be set by integrations.
allow_collisions: bool = False,
@@ -876,20 +909,43 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
sw_version: str | None | UndefinedType = UNDEFINED,
via_device_id: str | None | UndefinedType = UNDEFINED,
) -> DeviceEntry | None:
"""Update device attributes."""
"""Update device attributes.
:param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id
"""
old = self.devices[device_id]
new_values: dict[str, Any] = {} # Dict with new key/value pairs
old_values: dict[str, Any] = {} # Dict with old key/value pairs
config_entries = old.config_entries
config_subentries = old.config_subentries
if add_config_entry_id is not UNDEFINED:
if self.hass.config_entries.async_get_entry(add_config_entry_id) is None:
if (
add_config_entry := self.hass.config_entries.async_get_entry(
add_config_entry_id
)
) is None:
raise HomeAssistantError(
f"Can't link device to unknown config entry {add_config_entry_id}"
)
if add_config_subentry_id is not UNDEFINED:
if add_config_entry_id is UNDEFINED:
raise HomeAssistantError(
"Can't add config subentry without specifying config entry"
)
if (
add_config_subentry_id
# mypy says add_config_entry can be None. That's impossible, because we
# raise above if that happens
and add_config_subentry_id not in add_config_entry.subentries # type: ignore[union-attr]
):
raise HomeAssistantError(
f"Config entry {add_config_entry_id} has no subentry {add_config_subentry_id}"
)
if not new_connections and not new_identifiers:
raise HomeAssistantError(
"A device must have at least one of identifiers or connections"
@@ -920,6 +976,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
area_id = area.id
if add_config_entry_id is not UNDEFINED:
if add_config_subentry_id is UNDEFINED:
# Interpret not specifying a subentry as None (the main entry)
add_config_subentry_id = None
if (
add_config_entry_id in config_subentries
and config_subentries[add_config_entry_id] != add_config_subentry_id
):
raise HomeAssistantError(
f"Device is already linked to config entry {add_config_entry_id} "
"with subentry {config_subentries[add_config_entry_id]}"
)
primary_entry_id = old.primary_config_entry
if (
device_info_type == "primary"
@@ -939,11 +1008,17 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if add_config_entry_id not in old.config_entries:
config_entries = old.config_entries | {add_config_entry_id}
config_subentries = old.config_subentries | {
add_config_entry_id: add_config_subentry_id
}
if (
remove_config_entry_id is not UNDEFINED
and remove_config_entry_id in config_entries
):
config_subentries = dict(config_subentries)
del config_subentries[remove_config_entry_id]
if config_entries == {remove_config_entry_id}:
self.async_remove_device(device_id)
return None
@@ -958,6 +1033,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
new_values["config_entries"] = config_entries
old_values["config_entries"] = old.config_entries
if config_subentries != old.config_subentries:
new_values["config_subentries"] = config_subentries
old_values["config_subentries"] = old.config_subentries
for attr_name, setvalue in (
("connections", merge_connections),
("identifiers", merge_identifiers),
@@ -1112,6 +1191,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
device = self.devices.pop(device_id)
self.deleted_devices[device_id] = DeletedDeviceEntry(
config_entries=device.config_entries,
config_subentries=device.config_subentries,
connections=device.connections,
created_at=device.created_at,
identifiers=device.identifiers,
@@ -1142,7 +1222,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
for device in data["devices"]:
devices[device["id"]] = DeviceEntry(
area_id=device["area_id"],
config_entries=set(device["config_entries"]),
config_entries=set(device["config_subentries"]),
config_subentries=device["config_subentries"],
configuration_url=device["configuration_url"],
# type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625
connections={
@@ -1182,6 +1263,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
for device in data["deleted_devices"]:
deleted_devices[device["id"]] = DeletedDeviceEntry(
config_entries=set(device["config_entries"]),
config_subentries=device["config_subentries"],
connections={tuple(conn) for conn in device["connections"]},
created_at=datetime.fromisoformat(device["created_at"]),
identifiers={tuple(iden) for iden in device["identifiers"]},
@@ -1228,6 +1310,50 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
)
self.async_schedule_save()
@callback
def async_clear_config_subentry(
self, config_entry_id: str, config_subentry_id: str
) -> None:
"""Clear config entry from registry entries."""
now_time = time.time()
now_time = time.time()
for device in self.devices.get_devices_for_config_entry_id(config_entry_id):
if device.config_subentries[config_entry_id] != config_subentry_id:
continue
self.async_update_device(
device.id,
remove_config_entry_id=config_entry_id,
)
for deleted_device in list(self.deleted_devices.values()):
config_entries = deleted_device.config_entries
config_subentries = deleted_device.config_subentries
if (
config_entry_id not in config_subentries
or config_subentries[config_entry_id] != config_subentry_id
):
continue
if config_subentries == {config_entry_id: config_subentry_id}:
# We're removing the last config entry, add a time stamp
# when the deleted device became orphaned
self.deleted_devices[deleted_device.id] = attr.evolve(
deleted_device,
orphaned_timestamp=now_time,
config_entries=set(),
config_subentries={},
)
else:
config_subentries = dict(config_subentries)
del config_subentries[config_entry_id]
config_entries = config_entries - {config_entry_id}
# No need to reindex here since we currently
# do not have a lookup by config entry
self.deleted_devices[deleted_device.id] = attr.evolve(
deleted_device,
config_entries=config_entries,
config_subentries=config_subentries,
)
self.async_schedule_save()
@callback
def async_purge_expired_orphaned_devices(self) -> None:
"""Purge expired orphaned devices from the registry.

View File

@@ -80,6 +80,22 @@ class AddEntitiesCallback(Protocol):
"""Define add_entities type."""
class AddConfigEntryEntitiesCallback(Protocol):
"""Protocol type for EntityPlatform.add_entities callback."""
def __call__(
self,
new_entities: Iterable[Entity],
update_before_add: bool = False,
*,
subentry_id: str | None = None,
) -> None:
"""Define add_entities type.
:param subentry_id: subentry which the entities should be added to
"""
class EntityPlatformModule(Protocol):
"""Protocol type for entity platform modules."""
@@ -105,7 +121,7 @@ class EntityPlatformModule(Protocol):
self,
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
async_add_entities: AddEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an integration platform from a config entry."""
@@ -516,13 +532,21 @@ class EntityPlatform:
@callback
def _async_schedule_add_entities_for_entry(
self, new_entities: Iterable[Entity], update_before_add: bool = False
self,
new_entities: Iterable[Entity],
update_before_add: bool = False,
*,
subentry_id: str | None = None,
) -> None:
"""Schedule adding entities for a single platform async and track the task."""
assert self.config_entry
task = self.config_entry.async_create_task(
self.hass,
self.async_add_entities(new_entities, update_before_add=update_before_add),
self.async_add_entities(
new_entities,
update_before_add=update_before_add,
subentry_id=subentry_id,
),
f"EntityPlatform async_add_entities_for_entry {self.domain}.{self.platform_name}",
eager_start=True,
)
@@ -624,12 +648,26 @@ class EntityPlatform:
)
async def async_add_entities(
self, new_entities: Iterable[Entity], update_before_add: bool = False
self,
new_entities: Iterable[Entity],
update_before_add: bool = False,
*,
subentry_id: str | None = None,
) -> None:
"""Add entities for a single platform async.
This method must be run in the event loop.
:param subentry_id: subentry which the entities should be added to
"""
if subentry_id and (
not self.config_entry or subentry_id not in self.config_entry.subentries
):
raise HomeAssistantError(
f"Can't add entities to unknown subentry {subentry_id} of config "
f"entry {self.config_entry.entry_id if self.config_entry else None}"
)
# handle empty list from component/platform
if not new_entities: # type: ignore[truthy-iterable]
return
@@ -640,7 +678,9 @@ class EntityPlatform:
entities: list[Entity] = []
for entity in new_entities:
coros.append(
self._async_add_entity(entity, update_before_add, entity_registry)
self._async_add_entity(
entity, update_before_add, entity_registry, subentry_id
)
)
entities.append(entity)
@@ -719,6 +759,7 @@ class EntityPlatform:
entity: Entity,
update_before_add: bool,
entity_registry: EntityRegistry,
subentry_id: str | None,
) -> None:
"""Add an entity to the platform."""
if entity is None:
@@ -778,6 +819,7 @@ class EntityPlatform:
try:
device = dev_reg.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id,
config_subentry_id=subentry_id,
**device_info,
)
except dev_reg.DeviceInfoError as exc:
@@ -824,6 +866,7 @@ class EntityPlatform:
entity.unique_id,
capabilities=entity.capability_attributes,
config_entry=self.config_entry,
config_subentry_id=subentry_id,
device_id=device.id if device else None,
disabled_by=disabled_by,
entity_category=entity.entity_category,

View File

@@ -79,7 +79,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 15
STORAGE_VERSION_MINOR = 16
STORAGE_KEY = "core.entity_registry"
CLEANUP_INTERVAL = 3600 * 24
@@ -179,6 +179,7 @@ class RegistryEntry:
categories: dict[str, str] = attr.ib(factory=dict)
capabilities: Mapping[str, Any] | None = attr.ib(default=None)
config_entry_id: str | None = attr.ib(default=None)
config_subentry_id: str | None = attr.ib(default=None)
created_at: datetime = attr.ib(factory=utcnow)
device_class: str | None = attr.ib(default=None)
device_id: str | None = attr.ib(default=None)
@@ -282,6 +283,7 @@ class RegistryEntry:
"area_id": self.area_id,
"categories": self.categories,
"config_entry_id": self.config_entry_id,
"config_subentry_id": self.config_subentry_id,
"created_at": self.created_at.timestamp(),
"device_id": self.device_id,
"disabled_by": self.disabled_by,
@@ -343,6 +345,7 @@ class RegistryEntry:
"categories": self.categories,
"capabilities": self.capabilities,
"config_entry_id": self.config_entry_id,
"config_subentry_id": self.config_subentry_id,
"created_at": self.created_at,
"device_class": self.device_class,
"device_id": self.device_id,
@@ -407,6 +410,7 @@ class DeletedRegistryEntry:
unique_id: str = attr.ib()
platform: str = attr.ib()
config_entry_id: str | None = attr.ib()
config_subentry_id: str | None = attr.ib()
domain: str = attr.ib(init=False, repr=False)
id: str = attr.ib()
orphaned_timestamp: float | None = attr.ib()
@@ -426,6 +430,7 @@ class DeletedRegistryEntry:
json_bytes(
{
"config_entry_id": self.config_entry_id,
"config_subentry_id": self.config_subentry_id,
"created_at": self.created_at,
"entity_id": self.entity_id,
"id": self.id,
@@ -541,6 +546,13 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entity in data["deleted_entities"]:
entity["created_at"] = entity["modified_at"] = created_at
if old_minor_version < 16:
# Version 1.16 adds config_subentry_id
for entity in data["entities"]:
entity["config_subentry_id"] = None
for entity in data["deleted_entities"]:
entity["config_subentry_id"] = None
if old_major_version > 1:
raise NotImplementedError
return data
@@ -648,9 +660,12 @@ def _validate_item(
domain: str,
platform: str,
*,
config_entry_id: str | None | UndefinedType = None,
config_subentry_id: str | None | UndefinedType = None,
disabled_by: RegistryEntryDisabler | None | UndefinedType = None,
entity_category: EntityCategory | None | UndefinedType = None,
hidden_by: RegistryEntryHider | None | UndefinedType = None,
old_config_subentry_id: str | None = None,
report_non_string_unique_id: bool = True,
unique_id: str | Hashable | UndefinedType | Any,
) -> None:
@@ -671,6 +686,26 @@ def _validate_item(
unique_id,
report_issue,
)
if (
config_entry_id
and config_entry_id is not UNDEFINED
and old_config_subentry_id
and config_subentry_id is UNDEFINED
):
raise ValueError("Can't change config entry without changing subentry")
if (
config_entry_id
and config_entry_id is not UNDEFINED
and config_subentry_id
and config_subentry_id is not UNDEFINED
):
if (
not (config_entry := hass.config_entries.async_get_entry(config_entry_id))
or config_subentry_id not in config_entry.subentries
):
raise ValueError(
f"Config entry {config_entry_id} has no subentry {config_subentry_id}"
)
if (
disabled_by
and disabled_by is not UNDEFINED
@@ -817,6 +852,7 @@ class EntityRegistry(BaseRegistry):
# Data that we want entry to have
capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED,
config_entry: ConfigEntry | None | UndefinedType = UNDEFINED,
config_subentry_id: str | None | UndefinedType = UNDEFINED,
device_id: str | None | UndefinedType = UNDEFINED,
entity_category: EntityCategory | UndefinedType | None = UNDEFINED,
has_entity_name: bool | UndefinedType = UNDEFINED,
@@ -843,6 +879,7 @@ class EntityRegistry(BaseRegistry):
entity_id,
capabilities=capabilities,
config_entry_id=config_entry_id,
config_subentry_id=config_subentry_id,
device_id=device_id,
entity_category=entity_category,
has_entity_name=has_entity_name,
@@ -859,6 +896,8 @@ class EntityRegistry(BaseRegistry):
self.hass,
domain,
platform,
config_entry_id=config_entry_id,
config_subentry_id=config_subentry_id,
disabled_by=disabled_by,
entity_category=entity_category,
hidden_by=hidden_by,
@@ -896,6 +935,7 @@ class EntityRegistry(BaseRegistry):
entry = RegistryEntry(
capabilities=none_if_undefined(capabilities),
config_entry_id=none_if_undefined(config_entry_id),
config_subentry_id=none_if_undefined(config_subentry_id),
created_at=created_at,
device_id=none_if_undefined(device_id),
disabled_by=disabled_by,
@@ -938,6 +978,7 @@ class EntityRegistry(BaseRegistry):
orphaned_timestamp = None if config_entry_id else time.time()
self.deleted_entities[key] = DeletedRegistryEntry(
config_entry_id=config_entry_id,
config_subentry_id=entity.config_subentry_id,
created_at=entity.created_at,
entity_id=entity_id,
id=entity.id,
@@ -997,6 +1038,19 @@ class EntityRegistry(BaseRegistry):
):
self.async_remove(entity.entity_id)
# Remove entities which belong to config subentries no longer associated with the
# device
entities = async_entries_for_device(
self, event.data["device_id"], include_disabled_entities=True
)
for entity in entities:
if (
(entry_id := entity.config_entry_id) is not None
and entry_id in device.config_entries
and entity.config_subentry_id != device.config_subentries[entry_id]
):
self.async_remove(entity.entity_id)
# Re-enable disabled entities if the device is no longer disabled
if not device.disabled:
entities = async_entries_for_device(
@@ -1030,6 +1084,7 @@ class EntityRegistry(BaseRegistry):
categories: dict[str, str] | UndefinedType = UNDEFINED,
capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED,
config_entry_id: str | None | UndefinedType = UNDEFINED,
config_subentry_id: str | None | UndefinedType = UNDEFINED,
device_class: str | None | UndefinedType = UNDEFINED,
device_id: str | None | UndefinedType = UNDEFINED,
disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED,
@@ -1062,6 +1117,7 @@ class EntityRegistry(BaseRegistry):
("categories", categories),
("capabilities", capabilities),
("config_entry_id", config_entry_id),
("config_subentry_id", config_subentry_id),
("device_class", device_class),
("device_id", device_id),
("disabled_by", disabled_by),
@@ -1090,9 +1146,12 @@ class EntityRegistry(BaseRegistry):
self.hass,
old.domain,
old.platform,
config_entry_id=config_entry_id,
config_subentry_id=config_subentry_id,
disabled_by=disabled_by,
entity_category=entity_category,
hidden_by=hidden_by,
old_config_subentry_id=old.config_subentry_id,
unique_id=new_unique_id,
)
@@ -1157,6 +1216,7 @@ class EntityRegistry(BaseRegistry):
categories: dict[str, str] | UndefinedType = UNDEFINED,
capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED,
config_entry_id: str | None | UndefinedType = UNDEFINED,
config_subentry_id: str | None | UndefinedType = UNDEFINED,
device_class: str | None | UndefinedType = UNDEFINED,
device_id: str | None | UndefinedType = UNDEFINED,
disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED,
@@ -1183,6 +1243,7 @@ class EntityRegistry(BaseRegistry):
categories=categories,
capabilities=capabilities,
config_entry_id=config_entry_id,
config_subentry_id=config_subentry_id,
device_class=device_class,
device_id=device_id,
disabled_by=disabled_by,
@@ -1209,6 +1270,7 @@ class EntityRegistry(BaseRegistry):
new_platform: str,
*,
new_config_entry_id: str | UndefinedType = UNDEFINED,
new_config_subentry_id: str | UndefinedType = UNDEFINED,
new_unique_id: str | UndefinedType = UNDEFINED,
new_device_id: str | None | UndefinedType = UNDEFINED,
) -> RegistryEntry:
@@ -1233,6 +1295,7 @@ class EntityRegistry(BaseRegistry):
entity_id,
new_unique_id=new_unique_id,
config_entry_id=new_config_entry_id,
config_subentry_id=new_config_subentry_id,
device_id=new_device_id,
platform=new_platform,
)
@@ -1295,6 +1358,7 @@ class EntityRegistry(BaseRegistry):
categories=entity["categories"],
capabilities=entity["capabilities"],
config_entry_id=entity["config_entry_id"],
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
device_class=entity["device_class"],
device_id=entity["device_id"],
@@ -1344,6 +1408,7 @@ class EntityRegistry(BaseRegistry):
)
deleted_entities[key] = DeletedRegistryEntry(
config_entry_id=entity["config_entry_id"],
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
entity_id=entity["entity_id"],
id=entity["id"],
@@ -1402,6 +1467,30 @@ class EntityRegistry(BaseRegistry):
)
self.async_schedule_save()
@callback
def async_clear_config_subentry(
self, config_entry_id: str, config_subentry_id: str
) -> None:
"""Clear config subentry from registry entries."""
now_time = time.time()
for entity_id in [
entry.entity_id
for entry in self.entities.get_entries_for_config_entry_id(config_entry_id)
if entry.config_subentry_id == config_subentry_id
]:
self.async_remove(entity_id)
for key, deleted_entity in list(self.deleted_entities.items()):
if config_subentry_id != deleted_entity.config_subentry_id:
continue
# Add a time stamp when the deleted entity became orphaned
self.deleted_entities[key] = attr.evolve(
deleted_entity,
orphaned_timestamp=now_time,
config_entry_id=None,
config_subentry_id=None,
)
self.async_schedule_save()
@callback
def async_purge_expired_orphaned_entities(self) -> None:
"""Purge expired orphaned entities from the registry.

View File

@@ -285,6 +285,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
"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(
config=config,
integration=integration,

View File

@@ -1000,6 +1000,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
reason=None,
source=config_entries.SOURCE_USER,
state=None,
subentries_data=None,
title="Mock Title",
unique_id=None,
version=1,
@@ -1016,6 +1017,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
"options": options or {},
"pref_disable_new_entities": pref_disable_new_entities,
"pref_disable_polling": pref_disable_polling,
"subentries_data": subentries_data or (),
"title": title,
"unique_id": unique_id,
"version": version,
@@ -1088,6 +1090,28 @@ class MockConfigEntry(config_entries.ConfigEntry):
},
)
async def start_subentry_reconfigure_flow(
self,
hass: HomeAssistant,
subentry_flow_type: str,
subentry_id: str,
*,
show_advanced_options: bool = False,
) -> ConfigFlowResult:
"""Start a subnetry reconfiguration flow."""
if self.entry_id not in hass.config_entries._entries:
raise ValueError(
"Config entry must be added to hass to start reconfiguration flow"
)
return await hass.config_entries.subentries.async_init(
(self.entry_id, subentry_flow_type),
context={
"source": config_entries.SOURCE_RECONFIGURE,
"subentry_id": subentry_id,
"show_advanced_options": show_advanced_options,
},
)
async def start_reauth_flow(
hass: HomeAssistant,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,8 @@
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Mock Title',
'unique_id': None,
'version': 1,
@@ -135,6 +137,8 @@
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Mock Title',
'unique_id': None,
'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_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supported_subentry_types": {},
"supports_options": True,
"supports_reconfigure": 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_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": "Unsupported API",
"source": "bla2",
"state": core_ce.ConfigEntryState.SETUP_ERROR.value,
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": 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_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla3",
"state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": 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_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla4",
"state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": 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_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla5",
"state": core_ce.ConfigEntryState.NOT_LOADED.value,
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": 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_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": core_ce.SOURCE_USER,
"state": core_ce.ConfigEntryState.LOADED.value,
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -586,6 +598,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None:
"description_placeholders": None,
"options": {},
"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_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": core_ce.SOURCE_USER,
"state": core_ce.ConfigEntryState.LOADED.value,
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -669,6 +684,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None:
"description_placeholders": None,
"options": {},
"minor_version": 1,
"subentries": [],
}
@@ -1088,6 +1104,326 @@ async def test_options_flow_with_invalid_data(
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):
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"},
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: core_ce.ConfigEntry
) -> dict[str, type[core_ce.ConfigSubentryFlow]]:
return {"test": TestFlow.SubentryFlowHandler}
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,
}
async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None:
"""Test we can start a subentry reconfigure flow."""
class TestFlow(core_ce.ConfigFlow):
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):
raise NotImplementedError
async def async_step_reconfigure(self, user_input=None):
schema = OrderedDict()
schema[vol.Required("enabled")] = bool
return self.async_show_form(
step_id="reconfigure",
data_schema=schema,
description_placeholders={"enabled": "Set to true to be true"},
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: core_ce.ConfigEntry
) -> dict[str, type[core_ce.ConfigSubentryFlow]]:
return {"test": TestFlow.SubentryFlowHandler}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.config_flow", None)
MockConfigEntry(
domain="test",
entry_id="test1",
source="bla",
subentries_data=[
core_ce.ConfigSubentryData(
data={},
subentry_id="mock_id",
subentry_type="test",
title="Title",
unique_id=None,
)
],
).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"], "subentry_id": "mock_id"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
data.pop("flow_id")
assert data == {
"type": "form",
"handler": ["test1", "test"],
"step_id": "reconfigure",
"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):
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"},
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: core_ce.ConfigEntry
) -> dict[str, type[core_ce.ConfigSubentryFlow]]:
return {"test": TestFlow.SubentryFlowHandler}
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):
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})
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: core_ce.ConfigEntry
) -> dict[str, type[core_ce.ConfigSubentryFlow]]:
return {"test": TestFlow.SubentryFlowHandler}
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):
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)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: core_ce.ConfigEntry
) -> dict[str, type[core_ce.ConfigSubentryFlow]]:
return {"test": TestFlow.SubentryFlowHandler}
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")
async def test_get_single(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
@@ -1120,11 +1456,13 @@ async def test_get_single(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "user",
"state": "loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1480,11 +1818,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1499,11 +1839,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": "Unsupported API",
"source": "bla2",
"state": "setup_error",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1518,11 +1860,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla3",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1537,11 +1881,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla4",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1556,11 +1902,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla5",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1586,11 +1934,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1615,11 +1965,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla4",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1634,11 +1986,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla5",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1663,11 +2017,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1682,11 +2038,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla3",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1717,11 +2075,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1736,11 +2096,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": "Unsupported API",
"source": "bla2",
"state": "setup_error",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1755,11 +2117,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla3",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1774,11 +2138,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla4",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1793,11 +2159,13 @@ async def test_get_matching_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": timestamp,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla5",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1900,11 +2268,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1922,11 +2292,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": "Unsupported API",
"source": "bla2",
"state": "setup_error",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1944,11 +2316,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla3",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -1972,11 +2346,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2001,11 +2377,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2029,11 +2407,13 @@ async def test_subscribe_entries_ws(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": entry.modified_at.timestamp(),
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2119,11 +2499,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2141,11 +2523,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": created,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla3",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2171,11 +2555,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2197,11 +2583,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla3",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2227,11 +2615,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": modified,
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2255,11 +2645,13 @@ async def test_subscribe_entries_ws_filtered(
"error_reason_translation_key": None,
"error_reason_translation_placeholders": None,
"modified_at": entry.modified_at.timestamp(),
"num_subentries": 0,
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"reason": None,
"source": "bla",
"state": "not_loaded",
"supported_subentry_types": {},
"supports_options": False,
"supports_reconfigure": False,
"supports_remove_device": False,
@@ -2470,3 +2862,142 @@ async def test_does_not_support_reconfigure(
response
== '{"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",
subentry_type="test",
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",
"subentry_type": "test",
"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",
subentry_type="test",
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

@@ -65,6 +65,7 @@ async def test_list_devices(
{
"area_id": None,
"config_entries": [entry.entry_id],
"config_subentries": {entry.entry_id: None},
"configuration_url": None,
"connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]],
"created_at": utcnow().timestamp(),
@@ -87,6 +88,7 @@ async def test_list_devices(
{
"area_id": None,
"config_entries": [entry.entry_id],
"config_subentries": {entry.entry_id: None},
"configuration_url": None,
"connections": [],
"created_at": utcnow().timestamp(),
@@ -121,6 +123,7 @@ async def test_list_devices(
{
"area_id": None,
"config_entries": [entry.entry_id],
"config_subentries": {entry.entry_id: None},
"configuration_url": None,
"connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]],
"created_at": utcnow().timestamp(),

View File

@@ -67,6 +67,7 @@ async def test_list_entities(
"area_id": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": utcnow().timestamp(),
"device_id": None,
"disabled_by": None,
@@ -89,6 +90,7 @@ async def test_list_entities(
"area_id": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": utcnow().timestamp(),
"device_id": None,
"disabled_by": None,
@@ -138,6 +140,7 @@ async def test_list_entities(
"area_id": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": utcnow().timestamp(),
"device_id": None,
"disabled_by": None,
@@ -374,6 +377,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) ->
"capabilities": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": name_created_at.timestamp(),
"device_class": None,
"device_id": None,
@@ -410,6 +414,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) ->
"capabilities": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": no_name_created_at.timestamp(),
"device_class": None,
"device_id": None,
@@ -477,6 +482,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket)
"capabilities": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": name_created_at.timestamp(),
"device_class": None,
"device_id": None,
@@ -504,6 +510,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket)
"capabilities": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": no_name_created_at.timestamp(),
"device_class": None,
"device_id": None,
@@ -586,6 +593,7 @@ async def test_update_entity(
"categories": {"scope1": "id", "scope2": "id"},
"created_at": created.timestamp(),
"config_entry_id": None,
"config_subentry_id": None,
"device_class": "custom_device_class",
"device_id": None,
"disabled_by": None,
@@ -668,6 +676,7 @@ async def test_update_entity(
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id"},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": created.timestamp(),
"device_class": "custom_device_class",
"device_id": None,
@@ -714,6 +723,7 @@ async def test_update_entity(
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id"},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": created.timestamp(),
"device_class": "custom_device_class",
"device_id": None,
@@ -759,6 +769,7 @@ async def test_update_entity(
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id", "scope3": "id"},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": created.timestamp(),
"device_class": "custom_device_class",
"device_id": None,
@@ -804,6 +815,7 @@ async def test_update_entity(
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id", "scope3": "other_id"},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": created.timestamp(),
"device_class": "custom_device_class",
"device_id": None,
@@ -849,6 +861,7 @@ async def test_update_entity(
"capabilities": None,
"categories": {"scope1": "id", "scope3": "other_id"},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": created.timestamp(),
"device_class": "custom_device_class",
"device_id": None,
@@ -911,6 +924,7 @@ async def test_update_entity_require_restart(
"capabilities": None,
"categories": {},
"config_entry_id": config_entry.entry_id,
"config_subentry_id": None,
"created_at": created.timestamp(),
"device_class": None,
"device_id": None,
@@ -1032,6 +1046,7 @@ async def test_update_entity_no_changes(
"capabilities": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": created.timestamp(),
"device_class": None,
"device_id": None,
@@ -1129,6 +1144,7 @@ async def test_update_entity_id(
"capabilities": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": created.timestamp(),
"device_class": None,
"device_id": None,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,3 +69,84 @@
}),
})
# ---
# name: test_states_with_subentry
set({
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Outlet 1 Power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.outlet_1_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '50',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Outlet 2 Power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.outlet_2_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1500',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Sensor test',
}),
'context': <ANY>,
'entity_id': 'sensor.sensor_test',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '15',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Statistics issues Issue 1',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.statistics_issues_issue_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Statistics issues Issue 2',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dogs',
}),
'context': <ANY>,
'entity_id': 'sensor.statistics_issues_issue_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Statistics issues Issue 3',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.statistics_issues_issue_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
}),
})
# ---

View File

@@ -104,3 +104,85 @@ async def test_options_flow(hass: HomeAssistant) -> None:
assert config_entry.options == {"section_1": {"bool": True, "int": 15}}
await hass.async_block_till_done()
@pytest.mark.usefixtures("no_platforms")
async def test_subentry_flow(hass: HomeAssistant) -> None:
"""Test config flow options."""
config_entry = MockConfigEntry(domain=DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "entity"),
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "add_sensor"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={"name": "Sensor 1", "state": 15},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
subentry_id = list(config_entry.subentries.keys())[0]
assert config_entry.subentries == {
subentry_id: config_entries.ConfigSubentry(
data={"state": 15},
subentry_id=subentry_id,
subentry_type="entity",
title="Sensor 1",
unique_id=None,
)
}
await hass.async_block_till_done()
@pytest.mark.usefixtures("no_platforms")
async def test_subentry_reconfigure_flow(hass: HomeAssistant) -> None:
"""Test config flow options."""
subentry_id = "mock_id"
config_entry = MockConfigEntry(
domain=DOMAIN,
subentries_data=[
config_entries.ConfigSubentryData(
data={"state": 15},
subentry_id="mock_id",
subentry_type="entity",
title="Sensor 1",
unique_id=None,
)
],
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await config_entry.start_subentry_reconfigure_flow(
hass, "entity", subentry_id
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sensor"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={"name": "Renamed sensor 1", "state": 5},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert config_entry.subentries == {
subentry_id: config_entries.ConfigSubentry(
data={"state": 5},
subentry_id=subentry_id,
subentry_type="entity",
title="Renamed sensor 1",
unique_id=None,
)
}
await hass.async_block_till_done()

View File

@@ -5,11 +5,14 @@ from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant import config_entries
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@pytest.fixture
async def sensor_only() -> None:
@@ -21,14 +24,40 @@ async def sensor_only() -> None:
yield
@pytest.fixture(autouse=True)
@pytest.fixture
async def setup_comp(hass: HomeAssistant, sensor_only):
"""Set up demo component."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
@pytest.mark.usefixtures("setup_comp")
async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
"""Test the expected sensor entities are added."""
states = hass.states.async_all()
assert set(states) == snapshot
@pytest.mark.usefixtures("sensor_only")
async def test_states_with_subentry(
hass: HomeAssistant, snapshot: SnapshotAssertion
) -> None:
"""Test the expected sensor entities are added."""
config_entry = MockConfigEntry(
domain=DOMAIN,
subentries_data=[
config_entries.ConfigSubentryData(
data={"state": 15},
subentry_id="blabla",
title="Sensor test",
unique_id=None,
)
],
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
states = hass.states.async_all()
assert set(states) == snapshot

View File

@@ -57,6 +57,7 @@ async def test_entry_diagnostics(
"created_at": ANY,
"modified_at": ANY,
"discovery_keys": {},
"subentries": [],
},
"client": {
"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_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Mock Title',
'unique_id': None,
'version': 1,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,8 @@
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Twinkly',
'unique_id': '00:2d:13:3b:aa:bb',
'version': 1,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More