Add system options to config entries (#25926)

* Add system options to config entries

* For feedback

* Follow most of balloobs comments

* Fix balloobs comments

* Improvements

* Fix second round of Balloobs comments

* Fix third round

* Add system options to mock config entry

* Fix integration tests

* Fix the last failing tests

* Fix disabled string

* Fix failing disabled_by tests

* New tests

* Config entry WS API tests

* Fix comments
pull/26035/head
Robert Svensson 2019-08-18 06:34:11 +02:00 committed by Paulus Schoutsen
parent fc716a45c9
commit a2589f56e1
29 changed files with 254 additions and 27 deletions

View File

@ -1,6 +1,9 @@
"""Http views to control the config manager."""
import voluptuous as vol
from homeassistant import config_entries, data_entry_flow
from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.exceptions import Unauthorized
from homeassistant.helpers.data_entry_flow import (
@ -17,12 +20,17 @@ async def async_setup(hass):
hass.http.register_view(ConfigManagerFlowIndexView(hass.config_entries.flow))
hass.http.register_view(ConfigManagerFlowResourceView(hass.config_entries.flow))
hass.http.register_view(ConfigManagerAvailableFlowView)
hass.http.register_view(
OptionManagerFlowIndexView(hass.config_entries.options.flow)
)
hass.http.register_view(
OptionManagerFlowResourceView(hass.config_entries.options.flow)
)
hass.components.websocket_api.async_register_command(system_options_list)
hass.components.websocket_api.async_register_command(system_options_update)
return True
@ -231,3 +239,40 @@ class OptionManagerFlowResourceView(FlowManagerResourceView):
# pylint: disable=no-value-for-parameter
return await super().post(request, flow_id)
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{"type": "config_entries/system_options/list", "entry_id": str}
)
async def system_options_list(hass, connection, msg):
"""List all system options for a config entry."""
entry_id = msg["entry_id"]
entry = hass.config_entries.async_get_entry(entry_id)
if entry:
connection.send_result(msg["id"], entry.system_options.as_dict())
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
"type": "config_entries/system_options/update",
"entry_id": str,
vol.Optional("disable_new_entities"): bool,
}
)
async def system_options_update(hass, connection, msg):
"""Update config entry system options."""
changes = dict(msg)
changes.pop("id")
changes.pop("type")
entry_id = changes.pop("entry_id")
entry = hass.config_entries.async_get_entry(entry_id)
if entry and changes:
entry.system_options.update(**changes)
connection.send_result(msg["id"], entry.system_options.as_dict())

View File

@ -127,7 +127,7 @@ async def async_migrate_entry(hass, entry):
DOMAIN,
unique_id,
suggested_object_id=new_id,
config_entry_id=e_entry.config_entry_id,
config_entry=entry,
device_id=e_entry.device_id,
)
entry.version = 3

View File

@ -12,13 +12,14 @@ from typing import (
)
import weakref
import attr
from homeassistant import data_entry_flow, loader
from homeassistant.core import callback, HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady
from homeassistant.setup import async_setup_component, async_process_deps_reqs
from homeassistant.util.decorator import Registry
# mypy: allow-untyped-defs
_LOGGER = logging.getLogger(__name__)
@ -88,6 +89,7 @@ class ConfigEntry:
"title",
"data",
"options",
"system_options",
"source",
"connection_class",
"state",
@ -104,6 +106,7 @@ class ConfigEntry:
data: dict,
source: str,
connection_class: str,
system_options: dict,
options: Optional[dict] = None,
entry_id: Optional[str] = None,
state: str = ENTRY_STATE_NOT_LOADED,
@ -127,6 +130,9 @@ class ConfigEntry:
# Entry options
self.options = options or {}
# Entry system options
self.system_options = SystemOptions(**system_options)
# Source of the configuration (user, discovery, cloud)
self.source = source
@ -355,6 +361,7 @@ class ConfigEntry:
"title": self.title,
"data": self.data,
"options": self.options,
"system_options": self.system_options.as_dict(),
"source": self.source,
"connection_class": self.connection_class,
}
@ -457,6 +464,8 @@ class ConfigEntries:
connection_class=entry.get("connection_class", CONN_CLASS_UNKNOWN),
# New in 0.89
options=entry.get("options"),
# New in 0.98
system_options=entry.get("system_options", {}),
)
for entry in config["entries"]
]
@ -580,6 +589,7 @@ class ConfigEntries:
title=result["title"],
data=result["data"],
options={},
system_options={},
source=flow.context["source"],
connection_class=flow.CONNECTION_CLASS,
)
@ -722,3 +732,18 @@ class OptionsFlow(data_entry_flow.FlowHandler):
"""Base class for config option flows."""
pass
@attr.s(slots=True)
class SystemOptions:
"""Config entry system options."""
disable_new_entities = attr.ib(type=bool, default=False)
def update(self, *, disable_new_entities):
"""Update properties."""
self.disable_new_entities = disable_new_entities
def as_dict(self):
"""Return dictionary version of this config entrys system options."""
return {"disable_new_entities": self.disable_new_entities}

View File

@ -343,7 +343,7 @@ class EntityPlatform:
self.platform_name,
entity.unique_id,
suggested_object_id=suggested_object_id,
config_entry_id=config_entry_id,
config_entry=self.config_entry,
device_id=device_id,
known_object_ids=self.entities.keys(),
disabled_by=disabled_by,

View File

@ -33,6 +33,7 @@ EVENT_ENTITY_REGISTRY_UPDATED = "entity_registry_updated"
SAVE_DELAY = 10
_LOGGER = logging.getLogger(__name__)
_UNDEF = object()
DISABLED_CONFIG_ENTRY = "config_entry"
DISABLED_HASS = "hass"
DISABLED_USER = "user"
DISABLED_INTEGRATION = "integration"
@ -55,7 +56,13 @@ class RegistryEntry:
type=str,
default=None,
validator=attr.validators.in_(
(DISABLED_HASS, DISABLED_USER, DISABLED_INTEGRATION, None)
(
DISABLED_HASS,
DISABLED_USER,
DISABLED_INTEGRATION,
DISABLED_CONFIG_ENTRY,
None,
)
),
) # type: Optional[str]
domain = attr.ib(type=str, init=False, repr=False)
@ -132,13 +139,18 @@ class EntityRegistry:
unique_id,
*,
suggested_object_id=None,
config_entry_id=None,
config_entry=None,
device_id=None,
known_object_ids=None,
disabled_by=None,
):
"""Get entity. Create if it doesn't exist."""
config_entry_id = None
if config_entry:
config_entry_id = config_entry.entry_id
entity_id = self.async_get_entity_id(domain, platform, unique_id)
if entity_id:
return self._async_update_entity(
entity_id,
@ -159,6 +171,13 @@ class EntityRegistry:
known_object_ids,
)
if (
disabled_by is None
and config_entry
and config_entry.system_options.disable_new_entities
):
disabled_by = DISABLED_INTEGRATION
entity = RegistryEntry(
entity_id=entity_id,
config_entry_id=config_entry_id,

View File

@ -665,6 +665,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
title="Mock Title",
state=None,
options={},
system_options={},
connection_class=config_entries.CONN_CLASS_UNKNOWN,
):
"""Initialize a mock config entry."""
@ -672,6 +673,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
"entry_id": entry_id or uuid.uuid4().hex,
"domain": domain,
"data": data or {},
"system_options": system_options,
"options": options,
"version": version,
"title": title,

View File

@ -57,6 +57,7 @@ async def setup_device(hass):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
options=ENTRY_OPTIONS,
)
device = axis.AxisNetworkDevice(hass, config_entry)

View File

@ -41,6 +41,7 @@ async def setup_device(hass):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
options=ENTRY_OPTIONS,
)
device = axis.AxisNetworkDevice(hass, config_entry)

View File

@ -59,6 +59,7 @@ async def setup_device(hass):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
options=ENTRY_OPTIONS,
)
device = axis.AxisNetworkDevice(hass, config_entry)

View File

@ -584,3 +584,47 @@ async def test_two_step_options_flow(hass, client):
"description": None,
"description_placeholders": None,
}
async def test_list_system_options(hass, hass_ws_client):
"""Test that we can list an entries system options."""
assert await async_setup_component(hass, "config", {})
ws_client = await hass_ws_client(hass)
entry = MockConfigEntry(domain="demo")
entry.add_to_hass(hass)
await ws_client.send_json(
{
"id": 5,
"type": "config_entries/system_options/list",
"entry_id": entry.entry_id,
}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == {"disable_new_entities": False}
async def test_update_system_options(hass, hass_ws_client):
"""Test that we can update system options."""
assert await async_setup_component(hass, "config", {})
ws_client = await hass_ws_client(hass)
entry = MockConfigEntry(domain="demo")
entry.add_to_hass(hass)
await ws_client.send_json(
{
"id": 5,
"type": "config_entries/system_options/update",
"entry_id": entry.entry_id,
"disable_new_entities": True,
}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"]["disable_new_entities"]
assert entry.system_options.disable_new_entities

View File

@ -59,7 +59,8 @@ async def setup_gateway(hass, data, allow_clip_sensor=True):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
ENTRY_OPTIONS,
system_options={},
options=ENTRY_OPTIONS,
)
gateway = deconz.DeconzGateway(hass, config_entry)
gateway.api = DeconzSession(loop, session, **config_entry.data)

View File

@ -71,7 +71,8 @@ async def setup_gateway(hass, data, allow_clip_sensor=True):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
ENTRY_OPTIONS,
system_options={},
options=ENTRY_OPTIONS,
)
gateway = deconz.DeconzGateway(hass, config_entry)
gateway.api = DeconzSession(hass.loop, session, **config_entry.data)

View File

@ -63,6 +63,7 @@ async def setup_gateway(hass, data):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
)
gateway = deconz.DeconzGateway(hass, config_entry)
gateway.api = DeconzSession(loop, session, **config_entry.data)

View File

@ -90,7 +90,8 @@ async def setup_gateway(hass, data, allow_deconz_groups=True):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
ENTRY_OPTIONS,
system_options={},
options=ENTRY_OPTIONS,
)
gateway = deconz.DeconzGateway(hass, config_entry)
gateway.api = DeconzSession(loop, session, **config_entry.data)

View File

@ -46,6 +46,7 @@ async def setup_gateway(hass, data):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
)
gateway = deconz.DeconzGateway(hass, config_entry)
gateway.api = DeconzSession(loop, session, **config_entry.data)

View File

@ -103,7 +103,8 @@ async def setup_gateway(hass, data, allow_clip_sensor=True):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
ENTRY_OPTIONS,
system_options={},
options=ENTRY_OPTIONS,
)
gateway = deconz.DeconzGateway(hass, config_entry)
gateway.api = DeconzSession(loop, session, **config_entry.data)

View File

@ -67,6 +67,7 @@ async def setup_gateway(hass, data):
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
)
gateway = deconz.DeconzGateway(hass, config_entry)
gateway.api = DeconzSession(loop, session, **config_entry.data)

View File

@ -97,6 +97,7 @@ async def test_storage_is_removed_on_config_entry_removal(hass, utcnow):
pairing_data,
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
)
assert hkid in hass.data[ENTITY_MAP].storage_data

View File

@ -221,6 +221,7 @@ async def setup_bridge(hass, mock_bridge):
{"host": "mock-host"},
"test",
config_entries.CONN_CLASS_LOCAL_POLL,
system_options={},
)
await hass.config_entries.async_forward_entry_setup(config_entry, "light")
# To flush out the service call to update the group

View File

@ -306,6 +306,7 @@ async def setup_bridge(hass, mock_bridge, hostname=None):
{"host": hostname},
"test",
config_entries.CONN_CLASS_LOCAL_POLL,
system_options={},
)
await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor")
await hass.config_entries.async_forward_entry_setup(config_entry, "sensor")

View File

@ -142,7 +142,7 @@ async def test_config_flow_entry_migrate(hass):
"media_player",
"ps4",
MOCK_UNIQUE_ID,
config_entry_id=MOCK_ENTRY_ID,
config_entry=mock_entry,
device_id=MOCK_DEVICE_ID,
)
assert len(mock_e_registry.entities) == 1

View File

@ -335,7 +335,7 @@ async def test_device_info_is_assummed(hass):
mock_unique_id = ps4.format_unique_id(MOCK_CREDS, MOCK_HOST_ID)
mock_e_registry = mock_registry(hass)
mock_e_registry.async_get_or_create(
"media_player", DOMAIN, mock_unique_id, config_entry_id=MOCK_ENTRY_ID
"media_player", DOMAIN, mock_unique_id, config_entry=MOCK_CONFIG
)
mock_entity_id = mock_e_registry.async_get_entity_id(
"media_player", DOMAIN, mock_unique_id

View File

@ -56,6 +56,7 @@ async def setup_platform(hass, platform: str, *, devices=None, scenes=None):
{CONF_INSTALLED_APP_ID: str(uuid4())},
SOURCE_USER,
CONN_CLASS_CLOUD_PUSH,
system_options={},
)
broker = DeviceBroker(
hass, config_entry, Mock(), Mock(), devices or [], scenes or []

View File

@ -145,6 +145,7 @@ async def setup_controller(hass, mock_controller):
"test",
config_entries.CONN_CLASS_LOCAL_POLL,
entry_id=1,
system_options={},
)
mock_controller.config_entry = config_entry
@ -235,20 +236,31 @@ async def test_restoring_client(hass, mock_controller):
mock_controller.mock_client_all_responses.append([CLIENT_1])
mock_controller.unifi_config = {unifi.CONF_BLOCK_CLIENT: True}
config_entry = config_entries.ConfigEntry(
1,
unifi.DOMAIN,
"Mock Title",
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_POLL,
entry_id=1,
system_options={},
)
registry = await entity_registry.async_get_registry(hass)
registry.async_get_or_create(
device_tracker.DOMAIN,
unifi_dt.UNIFI_DOMAIN,
"{}-mock-site".format(CLIENT_1["mac"]),
suggested_object_id=CLIENT_1["hostname"],
config_entry_id=1,
config_entry=config_entry,
)
registry.async_get_or_create(
device_tracker.DOMAIN,
unifi_dt.UNIFI_DOMAIN,
"{}-mock-site".format(CLIENT_2["mac"]),
suggested_object_id=CLIENT_2["hostname"],
config_entry_id=1,
config_entry=config_entry,
)
await setup_controller(hass, mock_controller)

View File

@ -262,6 +262,7 @@ async def setup_controller(hass, mock_controller):
"test",
config_entries.CONN_CLASS_LOCAL_POLL,
entry_id=1,
system_options={},
)
mock_controller.config_entry = config_entry
@ -468,20 +469,31 @@ async def test_restoring_client(hass, mock_controller):
mock_controller.mock_client_all_responses.append([CLIENT_1])
mock_controller.unifi_config = {unifi.CONF_BLOCK_CLIENT: ["random mac"]}
config_entry = config_entries.ConfigEntry(
1,
unifi.DOMAIN,
"Mock Title",
ENTRY_CONFIG,
"test",
config_entries.CONN_CLASS_LOCAL_POLL,
entry_id=1,
system_options={},
)
registry = await entity_registry.async_get_registry(hass)
registry.async_get_or_create(
switch.DOMAIN,
unifi.DOMAIN,
"poe-{}".format(CLIENT_1["mac"]),
suggested_object_id=CLIENT_1["hostname"],
config_entry_id=1,
config_entry=config_entry,
)
registry.async_get_or_create(
switch.DOMAIN,
unifi.DOMAIN,
"poe-{}".format(CLIENT_2["mac"]),
suggested_object_id=CLIENT_2["hostname"],
config_entry_id=1,
config_entry=config_entry,
)
await setup_controller(hass, mock_controller)

View File

@ -14,7 +14,13 @@ from homeassistant.components.zha.core.store import async_get_registry
def config_entry_fixture(hass):
"""Fixture representing a config entry."""
config_entry = config_entries.ConfigEntry(
1, DOMAIN, "Mock Title", {}, "test", config_entries.CONN_CLASS_LOCAL_PUSH
1,
DOMAIN,
"Mock Title",
{},
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
)
return config_entry

View File

@ -287,6 +287,7 @@ async def setup_ozw(hass, mock_openzwave):
{"usb_path": "mock-path", "network_key": "mock-key"},
"test",
config_entries.CONN_CLASS_LOCAL_PUSH,
system_options={},
)
await hass.config_entries.async_forward_entry_setup(config_entry, "lock")
await hass.async_block_till_done()

View File

@ -8,7 +8,7 @@ import pytest
from homeassistant.core import valid_entity_id, callback
from homeassistant.helpers import entity_registry
from tests.common import mock_registry, flush_store
from tests.common import MockConfigEntry, mock_registry, flush_store
YAML__OPEN_PATH = "homeassistant.util.yaml.loader.open"
@ -88,9 +88,11 @@ def test_create_triggers_save(hass, registry):
async def test_loading_saving_data(hass, registry):
"""Test that we load/save data correctly."""
mock_config = MockConfigEntry(domain="light")
orig_entry1 = registry.async_get_or_create("light", "hue", "1234")
orig_entry2 = registry.async_get_or_create(
"light", "hue", "5678", config_entry_id="mock-id"
"light", "hue", "5678", config_entry=mock_config
)
assert len(registry.entities) == 2
@ -104,7 +106,7 @@ async def test_loading_saving_data(hass, registry):
assert list(registry.entities) == list(registry2.entities)
new_entry1 = registry.async_get_or_create("light", "hue", "1234")
new_entry2 = registry.async_get_or_create(
"light", "hue", "5678", config_entry_id="mock-id"
"light", "hue", "5678", config_entry=mock_config
)
assert orig_entry1 == new_entry1
@ -198,11 +200,14 @@ def test_async_get_entity_id(registry):
async def test_updating_config_entry_id(hass, registry, update_events):
"""Test that we update config entry id in registry."""
mock_config_1 = MockConfigEntry(domain="light", entry_id="mock-id-1")
entry = registry.async_get_or_create(
"light", "hue", "5678", config_entry_id="mock-id-1"
"light", "hue", "5678", config_entry=mock_config_1
)
mock_config_2 = MockConfigEntry(domain="light", entry_id="mock-id-2")
entry2 = registry.async_get_or_create(
"light", "hue", "5678", config_entry_id="mock-id-2"
"light", "hue", "5678", config_entry=mock_config_2
)
assert entry.entity_id == entry2.entity_id
assert entry2.config_entry_id == "mock-id-2"
@ -218,8 +223,10 @@ async def test_updating_config_entry_id(hass, registry, update_events):
async def test_removing_config_entry_id(hass, registry, update_events):
"""Test that we update config entry id in registry."""
mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1")
entry = registry.async_get_or_create(
"light", "hue", "5678", config_entry_id="mock-id-1"
"light", "hue", "5678", config_entry=mock_config
)
assert entry.config_entry_id == "mock-id-1"
registry.async_clear_config_entry("mock-id-1")
@ -237,6 +244,8 @@ async def test_removing_config_entry_id(hass, registry, update_events):
async def test_migration(hass):
"""Test migration from old data to new."""
mock_config = MockConfigEntry(domain="test-platform", entry_id="test-config-id")
old_conf = {
"light.kitchen": {
"config_entry_id": "test-config-id",
@ -256,7 +265,7 @@ async def test_migration(hass):
domain="light",
platform="test-platform",
unique_id="test-unique",
config_entry_id="test-config-id",
config_entry=mock_config,
)
assert entry.name == "Test Name"
assert entry.disabled_by == "hass"
@ -326,8 +335,10 @@ async def test_loading_race_condition(hass):
async def test_update_entity_unique_id(registry):
"""Test entity's unique_id is updated."""
mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1")
entry = registry.async_get_or_create(
"light", "hue", "5678", config_entry_id="mock-id-1"
"light", "hue", "5678", config_entry=mock_config
)
new_unique_id = "1234"
with patch.object(registry, "async_schedule_save") as mock_schedule_save:
@ -341,11 +352,12 @@ async def test_update_entity_unique_id(registry):
async def test_update_entity_unique_id_conflict(registry):
"""Test migration raises when unique_id already in use."""
mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1")
entry = registry.async_get_or_create(
"light", "hue", "5678", config_entry_id="mock-id-1"
"light", "hue", "5678", config_entry=mock_config
)
entry2 = registry.async_get_or_create(
"light", "hue", "1234", config_entry_id="mock-id-1"
"light", "hue", "1234", config_entry=mock_config
)
with patch.object(
registry, "async_schedule_save"
@ -356,8 +368,9 @@ async def test_update_entity_unique_id_conflict(registry):
async def test_update_entity(registry):
"""Test updating entity."""
mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1")
entry = registry.async_get_or_create(
"light", "hue", "5678", config_entry_id="mock-id-1"
"light", "hue", "5678", config_entry=mock_config
)
for attr_name, new_value in (
@ -386,3 +399,21 @@ async def test_disabled_by(registry):
entry2 = registry.async_get_or_create("light", "hue", "1234")
assert entry2.disabled_by is None
async def test_disabled_by_system_options(registry):
"""Test system options setting disabled_by."""
mock_config = MockConfigEntry(
domain="light",
entry_id="mock-id-1",
system_options={"disable_new_entities": True},
)
entry = registry.async_get_or_create(
"light", "hue", "AAAA", config_entry=mock_config
)
assert entry.disabled_by == "integration"
entry2 = registry.async_get_or_create(
"light", "hue", "BBBB", config_entry=mock_config, disabled_by="user"
)
assert entry2.disabled_by == "user"

View File

@ -596,6 +596,22 @@ async def test_updating_entry_data(manager):
assert entry.data == {"second": True}
async def test_updating_entry_system_options(manager):
"""Test that we can update an entry data."""
entry = MockConfigEntry(
domain="test",
data={"first": True},
state=config_entries.ENTRY_STATE_SETUP_ERROR,
system_options={"disable_new_entities": True},
)
entry.add_to_manager(manager)
assert entry.system_options.disable_new_entities
entry.system_options.update(disable_new_entities=False)
assert not entry.system_options.disable_new_entities
async def test_update_entry_options_and_trigger_listener(hass, manager):
"""Test that we can update entry options and trigger listener."""
entry = MockConfigEntry(domain="test", options={"first": True})