Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions homeassistant/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import attr

from homeassistant import data_entry_flow, loader
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
Expand Down Expand Up @@ -331,6 +331,17 @@ async def setup_again(*_: Any) -> None:
else:
self.state = ENTRY_STATE_SETUP_ERROR

async def async_shutdown(self) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why should this be a coroutine function? We don't await inside.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I made it a coroutine because I figured we would need to add more to it later (#49241) and it would end up being a coroutine anyways which would be a breaking change in the api I wanted to avoid in the future.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sounds good!

"""Call when Home Assistant is stopping."""
self.async_cancel_retry_setup()

@callback
def async_cancel_retry_setup(self) -> None:
"""Cancel retry setup."""
if self._async_cancel_retry_setup is not None:
self._async_cancel_retry_setup()
self._async_cancel_retry_setup = None

async def async_unload(
self, hass: HomeAssistant, *, integration: loader.Integration | None = None
) -> bool:
Expand Down Expand Up @@ -360,9 +371,7 @@ async def async_unload(
return False

if self.state != ENTRY_STATE_LOADED:
if self._async_cancel_retry_setup is not None:
self._async_cancel_retry_setup()
self._async_cancel_retry_setup = None
self.async_cancel_retry_setup()

self.state = ENTRY_STATE_NOT_LOADED
return True
Expand Down Expand Up @@ -778,6 +787,12 @@ async def async_remove(self, entry_id: str) -> dict[str, Any]:

return {"require_restart": not unload_success}

async def _async_shutdown(self, event: Event) -> None:
"""Call when Home Assistant is stopping."""
await asyncio.gather(
*[entry.async_shutdown() for entry in self._entries.values()]
)

async def async_initialize(self) -> None:
"""Initialize config entry config."""
# Migrating for config entries stored before 0.73
Expand All @@ -787,6 +802,8 @@ async def async_initialize(self) -> None:
old_conf_migrate_func=_old_conf_migrator,
)

self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown)

if config is None:
self._entries = {}
return
Expand Down
16 changes: 14 additions & 2 deletions homeassistant/helpers/entity_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@

from homeassistant import config as conf_util
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.const import (
CONF_ENTITY_NAMESPACE,
CONF_SCAN_INTERVAL,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_per_platform,
Expand Down Expand Up @@ -118,6 +122,8 @@ async def async_setup(self, config: ConfigType) -> None:

This method must be run in the event loop.
"""
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown)

self.config = config

# Look in config for Domain, Domain 2, Domain 3 etc and load them
Expand Down Expand Up @@ -322,3 +328,9 @@ def _async_init_entity_platform(
scan_interval=scan_interval,
entity_namespace=entity_namespace,
)

async def _async_shutdown(self, event: Event) -> None:
"""Call when Home Assistant is stopping."""
await asyncio.gather(
*[platform.async_shutdown() for platform in chain(self._platforms.values())]
)
23 changes: 19 additions & 4 deletions homeassistant/helpers/entity_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,18 @@ def async_create_setup_task() -> Coroutine:

await self._async_setup_platform(async_create_setup_task)

async def async_shutdown(self) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here.

"""Call when Home Assistant is stopping."""
self.async_cancel_retry_setup()
self.async_unsub_polling()

@callback
def async_cancel_retry_setup(self) -> None:
"""Cancel retry setup."""
if self._async_cancel_retry_setup is not None:
self._async_cancel_retry_setup()
self._async_cancel_retry_setup = None

async def async_setup_entry(self, config_entry: config_entries.ConfigEntry) -> bool:
"""Set up the platform from a config entry."""
# Store it so that we can save config entry ID in entity registry
Expand Down Expand Up @@ -549,9 +561,7 @@ async def async_reset(self) -> None:

This method must be run in the event loop.
"""
if self._async_cancel_retry_setup is not None:
self._async_cancel_retry_setup()
self._async_cancel_retry_setup = None
self.async_cancel_retry_setup()

if not self.entities:
return
Expand All @@ -560,10 +570,15 @@ async def async_reset(self) -> None:

await asyncio.gather(*tasks)

self.async_unsub_polling()
self._setup_complete = False

@callback
def async_unsub_polling(self) -> None:
"""Stop polling."""
if self._async_unsub_polling is not None:
self._async_unsub_polling()
self._async_unsub_polling = None
self._setup_complete = False

async def async_destroy(self) -> None:
Comment thread
bdraco marked this conversation as resolved.
"""Destroy an entity platform.
Expand Down
28 changes: 27 additions & 1 deletion tests/helpers/test_entity_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import pytest
import voluptuous as vol

from homeassistant.const import ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
from homeassistant.const import (
ENTITY_MATCH_ALL,
ENTITY_MATCH_NONE,
EVENT_HOMEASSISTANT_STOP,
)
import homeassistant.core as ha
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import discovery
Expand Down Expand Up @@ -487,3 +491,25 @@ def appender(**kwargs):
DOMAIN, "hello", {"area_id": ENTITY_MATCH_NONE, "some": "data"}, blocking=True
)
assert len(calls) == 2


async def test_platforms_shutdown_on_stop(hass):
"""Test that we shutdown platforms on stop."""
platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None])
mock_integration(hass, MockModule("mod1"))
mock_entity_platform(hass, "test_domain.mod1", MockPlatform(platform1_setup))

component = EntityComponent(_LOGGER, DOMAIN, hass)

await component.async_setup({DOMAIN: {"platform": "mod1"}})
await hass.async_block_till_done()
assert len(platform1_setup.mock_calls) == 1
assert "test_domain.mod1" not in hass.config.components

with patch.object(
component._platforms[DOMAIN], "async_shutdown"
) as mock_async_shutdown:
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()

assert mock_async_shutdown.called
23 changes: 23 additions & 0 deletions tests/helpers/test_entity_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,29 @@ async def test_reset_cancels_retry_setup_when_not_started(hass):
assert ent_platform._async_cancel_retry_setup is None


async def test_stop_shutdown_cancels_retry_setup_and_interval_listener(hass):
"""Test that shutdown will cancel scheduled a setup retry and interval listener."""
async_setup_entry = Mock(side_effect=PlatformNotReady)
platform = MockPlatform(async_setup_entry=async_setup_entry)
config_entry = MockConfigEntry()
ent_platform = MockEntityPlatform(
hass, platform_name=config_entry.domain, platform=platform
)

with patch.object(entity_platform, "async_call_later") as mock_call_later:
assert not await ent_platform.async_setup_entry(config_entry)

assert len(mock_call_later.mock_calls) == 1
assert len(mock_call_later.return_value.mock_calls) == 0
assert ent_platform._async_cancel_retry_setup is not None

await ent_platform.async_shutdown()

assert len(mock_call_later.return_value.mock_calls) == 1
assert ent_platform._async_unsub_polling is None
assert ent_platform._async_cancel_retry_setup is None


async def test_not_fails_with_adding_empty_entities_(hass):
"""Test for not fails on empty entities list."""
component = EntityComponent(_LOGGER, DOMAIN, hass)
Expand Down
41 changes: 39 additions & 2 deletions tests/test_config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest

from homeassistant import config_entries, data_entry_flow, loader
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CoreState, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
Expand Down Expand Up @@ -1405,7 +1405,7 @@ async def test_reload_entry_entity_registry_works(hass):
assert len(mock_unload_entry.mock_calls) == 1


async def test_unqiue_id_persisted(hass, manager):
async def test_unique_id_persisted(hass, manager):
"""Test that a unique ID is stored in the config entry."""
mock_setup_entry = AsyncMock(return_value=True)

Expand Down Expand Up @@ -2667,3 +2667,40 @@ async def _async_update_data():
assert entry.state == config_entries.ENTRY_STATE_LOADED
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1


async def test_initialize_and_shutdown(hass):
"""Test we call the shutdown function at stop."""
manager = config_entries.ConfigEntries(hass, {})

with patch.object(manager, "_async_shutdown") as mock_async_shutdown:
await manager.async_initialize()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()

assert mock_async_shutdown.called


async def test_setup_retrying_during_shutdown(hass):
"""Test if we shutdown an entry that is in retry mode."""
entry = MockConfigEntry(domain="test")

mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady)
mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry))
mock_entity_platform(hass, "config_flow.test", None)

with patch("homeassistant.helpers.event.async_call_later") as mock_call:
await entry.async_setup(hass)

assert entry.state == config_entries.ENTRY_STATE_SETUP_RETRY
assert len(mock_call.return_value.mock_calls) == 0

hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()

assert len(mock_call.return_value.mock_calls) == 0

async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=4))
await hass.async_block_till_done()

assert len(mock_call.return_value.mock_calls) == 0