Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for disabling config entries #46779

Merged
merged 8 commits into from Feb 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
89 changes: 54 additions & 35 deletions homeassistant/components/config/config_entries.py
@@ -1,7 +1,6 @@
"""Http views to control the config manager."""
import aiohttp.web_exceptions
import voluptuous as vol
import voluptuous_serialize

from homeassistant import config_entries, data_entry_flow
from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT
Expand All @@ -10,7 +9,6 @@
from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND
from homeassistant.core import callback
from homeassistant.exceptions import Unauthorized
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.data_entry_flow import (
FlowManagerIndexView,
FlowManagerResourceView,
Expand All @@ -30,6 +28,7 @@ async def async_setup(hass):
hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options))
hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options))

hass.components.websocket_api.async_register_command(config_entry_disable)
hass.components.websocket_api.async_register_command(config_entry_update)
hass.components.websocket_api.async_register_command(config_entries_progress)
hass.components.websocket_api.async_register_command(system_options_list)
Expand All @@ -39,24 +38,6 @@ async def async_setup(hass):
return True


def _prepare_json(result):
"""Convert result for JSON."""
if result["type"] != data_entry_flow.RESULT_TYPE_FORM:
return result

data = result.copy()

schema = data["data_schema"]
if schema is None:
data["data_schema"] = []
else:
data["data_schema"] = voluptuous_serialize.convert(
schema, custom_serializer=cv.custom_serializer
)

return data


class ConfigManagerEntryIndexView(HomeAssistantView):
"""View to get available config entries."""

Expand Down Expand Up @@ -265,6 +246,21 @@ async def system_options_list(hass, connection, msg):
connection.send_result(msg["id"], entry.system_options.as_dict())


def send_entry_not_found(connection, msg_id):
"""Send Config entry not found error."""
connection.send_error(
msg_id, websocket_api.const.ERR_NOT_FOUND, "Config entry not found"
)


def get_entry(hass, connection, entry_id, msg_id):
"""Get entry, send error message if it doesn't exist."""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None:
send_entry_not_found(connection, msg_id)
return entry


@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
Expand All @@ -279,13 +275,10 @@ async def system_options_update(hass, connection, msg):
changes = dict(msg)
changes.pop("id")
changes.pop("type")
entry_id = changes.pop("entry_id")
entry = hass.config_entries.async_get_entry(entry_id)
changes.pop("entry_id")

entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
if entry is None:
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found"
)
return

hass.config_entries.async_update_entry(entry, system_options=changes)
Expand All @@ -302,20 +295,47 @@ async def config_entry_update(hass, connection, msg):
changes = dict(msg)
changes.pop("id")
changes.pop("type")
entry_id = changes.pop("entry_id")

entry = hass.config_entries.async_get_entry(entry_id)
changes.pop("entry_id")

entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
if entry is None:
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found"
)
return

hass.config_entries.async_update_entry(entry, **changes)
connection.send_result(msg["id"], entry_json(entry))


@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
"type": "config_entries/disable",
"entry_id": str,
# We only allow setting disabled_by user via API.
"disabled_by": vol.Any("user", None),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make this a new WS command and not make it part of config_entry_update ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh it's the require restart. get it now.

}
)
async def config_entry_disable(hass, connection, msg):
"""Disable config entry."""
disabled_by = msg["disabled_by"]

result = False
try:
result = await hass.config_entries.async_set_disabled_by(
msg["entry_id"], disabled_by
)
except config_entries.OperationNotAllowed:
# Failed to unload the config entry
pass
except config_entries.UnknownEntry:
send_entry_not_found(connection, msg["id"])
return

result = {"require_restart": not result}

connection.send_result(msg["id"], result)


@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
Expand All @@ -333,9 +353,7 @@ async def ignore_config_flow(hass, connection, msg):
)

if flow is None:
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found"
)
send_entry_not_found(connection, msg["id"])
return

if "unique_id" not in flow["context"]:
Expand All @@ -357,7 +375,7 @@ def entry_json(entry: config_entries.ConfigEntry) -> dict:
"""Return JSON value of a config entry."""
handler = config_entries.HANDLERS.get(entry.domain)
supports_options = (
# Guard in case handler is no longer registered (custom compnoent etc)
# Guard in case handler is no longer registered (custom component etc)
handler is not None
# pylint: disable=comparison-with-callable
and handler.async_get_options_flow
Expand All @@ -372,4 +390,5 @@ def entry_json(entry: config_entries.ConfigEntry) -> dict:
"connection_class": entry.connection_class,
"supports_options": supports_options,
"supports_unload": entry.supports_unload,
"disabled_by": entry.disabled_by,
}
46 changes: 44 additions & 2 deletions homeassistant/config_entries.py
Expand Up @@ -11,6 +11,7 @@
import attr

from homeassistant import data_entry_flow, loader
from homeassistant.const import EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import entity_registry
Expand Down Expand Up @@ -68,6 +69,8 @@
ENTRY_STATE_NOT_LOADED = "not_loaded"
# An error occurred when trying to unload the entry
ENTRY_STATE_FAILED_UNLOAD = "failed_unload"
# The config entry is disabled
ENTRY_STATE_DISABLED = "disabled"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is never used.


UNRECOVERABLE_STATES = (ENTRY_STATE_MIGRATION_ERROR, ENTRY_STATE_FAILED_UNLOAD)

Expand All @@ -92,6 +95,8 @@
CONN_CLASS_ASSUMED = "assumed"
CONN_CLASS_UNKNOWN = "unknown"

DISABLED_USER = "user"

RELOAD_AFTER_UPDATE_DELAY = 30


Expand Down Expand Up @@ -126,6 +131,7 @@ class ConfigEntry:
"source",
"connection_class",
"state",
"disabled_by",
"_setup_lock",
"update_listeners",
"_async_cancel_retry_setup",
Expand All @@ -144,6 +150,7 @@ def __init__(
unique_id: Optional[str] = None,
entry_id: Optional[str] = None,
state: str = ENTRY_STATE_NOT_LOADED,
disabled_by: Optional[str] = None,
) -> None:
"""Initialize a config entry."""
# Unique id of the config entry
Expand Down Expand Up @@ -179,6 +186,9 @@ def __init__(
# Unique ID of this entry.
self.unique_id = unique_id

# Config entry is disabled
self.disabled_by = disabled_by

# Supports unload
self.supports_unload = False

Expand All @@ -198,7 +208,7 @@ async def async_setup(
tries: int = 0,
) -> None:
"""Set up an entry."""
if self.source == SOURCE_IGNORE:
if self.source == SOURCE_IGNORE or self.disabled_by:
return

if integration is None:
Expand Down Expand Up @@ -441,6 +451,7 @@ def as_dict(self) -> Dict[str, Any]:
"source": self.source,
"connection_class": self.connection_class,
"unique_id": self.unique_id,
"disabled_by": self.disabled_by,
}


Expand Down Expand Up @@ -711,6 +722,8 @@ async def async_initialize(self) -> None:
system_options=entry.get("system_options", {}),
# New in 0.104
unique_id=entry.get("unique_id"),
# New in 2021.3
disabled_by=entry.get("disabled_by"),
)
for entry in config["entries"]
]
Expand Down Expand Up @@ -759,13 +772,42 @@ async def async_reload(self, entry_id: str) -> bool:

If an entry was not loaded, will just load.
"""
entry = self.async_get_entry(entry_id)

if entry is None:
raise UnknownEntry

unload_result = await self.async_unload(entry_id)

if not unload_result:
if not unload_result or entry.disabled_by:
return unload_result

return await self.async_setup(entry_id)

async def async_set_disabled_by(
self, entry_id: str, disabled_by: Optional[str]
) -> bool:
"""Disable an entry.

If disabled_by is changed, the config entry will be reloaded.
"""
entry = self.async_get_entry(entry_id)

if entry is None:
raise UnknownEntry

if entry.disabled_by == disabled_by:
return True

entry.disabled_by = disabled_by
self._async_schedule_save()

self.hass.bus.async_fire(
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED, {"config_entry_id": entry_id}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This event causes an error in combination with platform unload when the integration is reloaded.

When the entity registry updates the entity entries it will remove the entities since the entity entry is disabled. Since the entities have already been removed by platform unload, removing the entities again will trigger an error.

2021-02-26 23:53:55 ERROR (MainThread) [homeassistant] Error doing job: Task exception was never retrieved
Traceback (most recent call last):
  File "/usr/src/homeassistant/homeassistant/helpers/entity.py", line 638, in _async_registry_updated
    await self.async_remove()
  File "/usr/src/homeassistant/homeassistant/helpers/entity.py", line 545, in async_remove
    raise HomeAssistantError(
homeassistant.exceptions.HomeAssistantError: Entity switch.metered_wall_plug_switch async_remove called twice

#47171

)

return await self.async_reload(entry_id)

@callback
def async_update_entry(
self,
Expand Down
1 change: 1 addition & 0 deletions homeassistant/const.py
Expand Up @@ -202,6 +202,7 @@
# #### EVENTS ####
EVENT_CALL_SERVICE = "call_service"
EVENT_COMPONENT_LOADED = "component_loaded"
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED = "config_entry_disabled_by_updated"
EVENT_CORE_CONFIG_UPDATE = "core_config_updated"
EVENT_HOMEASSISTANT_CLOSE = "homeassistant_close"
EVENT_HOMEASSISTANT_START = "homeassistant_start"
Expand Down
43 changes: 42 additions & 1 deletion homeassistant/helpers/device_registry.py
Expand Up @@ -6,7 +6,10 @@

import attr

from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.const import (
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED,
EVENT_HOMEASSISTANT_STARTED,
)
from homeassistant.core import Event, callback
from homeassistant.loader import bind_hass
import homeassistant.util.uuid as uuid_util
Expand Down Expand Up @@ -37,6 +40,7 @@
REGISTERED_DEVICE = "registered"
DELETED_DEVICE = "deleted"

DISABLED_CONFIG_ENTRY = "config_entry"
DISABLED_INTEGRATION = "integration"
DISABLED_USER = "user"

Expand Down Expand Up @@ -65,6 +69,7 @@ class DeviceEntry:
default=None,
validator=attr.validators.in_(
(
DISABLED_CONFIG_ENTRY,
DISABLED_INTEGRATION,
DISABLED_USER,
None,
Expand Down Expand Up @@ -138,6 +143,10 @@ def __init__(self, hass: HomeAssistantType) -> None:
self.hass = hass
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
self._clear_index()
self.hass.bus.async_listen(
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED,
self.async_config_entry_disabled_by_changed,
)

@callback
def async_get(self, device_id: str) -> Optional[DeviceEntry]:
Expand Down Expand Up @@ -609,6 +618,38 @@ def async_clear_area_id(self, area_id: str) -> None:
if area_id == device.area_id:
self._async_update_device(dev_id, area_id=None)

@callback
def async_config_entry_disabled_by_changed(self, event: Event) -> None:
"""Handle a config entry being disabled or enabled.

Disable devices in the registry that are associated to a config entry when
the config entry is disabled.
"""
config_entry = self.hass.config_entries.async_get_entry(
event.data["config_entry_id"]
)

# The config entry may be deleted already if the event handling is late
if not config_entry:
return

if not config_entry.disabled_by:
devices = async_entries_for_config_entry(
self, event.data["config_entry_id"]
)
for device in devices:
if device.disabled_by != DISABLED_CONFIG_ENTRY:
continue
self.async_update_device(device.id, disabled_by=None)
return

devices = async_entries_for_config_entry(self, event.data["config_entry_id"])
for device in devices:
if device.disabled:
# Entity already disabled, do not overwrite
continue
self.async_update_device(device.id, disabled_by=DISABLED_CONFIG_ENTRY)


@callback
def async_get(hass: HomeAssistantType) -> DeviceRegistry:
Expand Down