Apply store reload suggestion automatically on connectivity change (#6004)

* Apply store reload suggestion automatically on connectivity change

* Use sys_bus not coresys.bus

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Mike Degatano 2025-07-09 10:43:51 -04:00 committed by GitHub
parent 953f7d01d7
commit 806bd9f52c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 130 additions and 0 deletions

View File

@ -3,6 +3,7 @@
from abc import ABC, abstractmethod
import logging
from ...const import BusEvent
from ...coresys import CoreSys, CoreSysAttributes
from ...exceptions import ResolutionFixupError
from ..const import ContextType, IssueType, SuggestionType
@ -66,6 +67,11 @@ class FixupBase(ABC, CoreSysAttributes):
"""Return if a fixup can be apply as auto fix."""
return False
@property
def bus_event(self) -> BusEvent | None:
"""Return the BusEvent that triggers this fixup, or None if not event-based."""
return None
@property
def all_suggestions(self) -> list[Suggestion]:
"""List of all suggestions which when applied run this fixup."""

View File

@ -2,6 +2,7 @@
import logging
from ...const import BusEvent
from ...coresys import CoreSys
from ...exceptions import (
ResolutionFixupError,
@ -68,3 +69,8 @@ class FixupStoreExecuteReload(FixupBase):
def auto(self) -> bool:
"""Return if a fixup can be apply as auto fix."""
return True
@property
def bus_event(self) -> BusEvent | None:
"""Return the BusEvent that triggers this fixup, or None if not event-based."""
return BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE

View File

@ -5,6 +5,7 @@ from typing import Any
import attr
from ..bus import EventListener
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ResolutionError, ResolutionNotFound
from ..homeassistant.const import WSEvent
@ -46,6 +47,9 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
self._unsupported: list[UnsupportedReason] = []
self._unhealthy: list[UnhealthyReason] = []
# Map suggestion UUID to event listeners (list)
self._suggestion_listeners: dict[str, list[EventListener]] = {}
async def load_modules(self):
"""Load resolution evaluation, check and fixup modules."""
@ -105,6 +109,19 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
)
self._suggestions.append(suggestion)
# Register event listeners if fixups have a bus_event
listeners: list[EventListener] = []
for fixup in self.fixup.fixes_for_suggestion(suggestion):
if fixup.auto and fixup.bus_event:
def event_callback(reference, fixup=fixup):
return fixup(suggestion)
listener = self.sys_bus.register_event(fixup.bus_event, event_callback)
listeners.append(listener)
if listeners:
self._suggestion_listeners[suggestion.uuid] = listeners
# Event on suggestion added to issue
for issue in self.issues_for_suggestion(suggestion):
self.sys_homeassistant.websocket.supervisor_event(
@ -233,6 +250,11 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
)
self._suggestions.remove(suggestion)
# Remove event listeners if present
listeners = self._suggestion_listeners.pop(suggestion.uuid, [])
for listener in listeners:
self.sys_bus.remove_listener(listener)
# Event on suggestion removed from issues
for issue in self.issues_for_suggestion(suggestion):
self.sys_homeassistant.websocket.supervisor_event(

View File

@ -1,9 +1,14 @@
"""Test evaluation base."""
# pylint: disable=import-error,protected-access
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from supervisor.const import BusEvent
from supervisor.coresys import CoreSys
from supervisor.exceptions import ResolutionFixupError
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
from supervisor.resolution.fixups.store_execute_reload import FixupStoreExecuteReload
@ -32,3 +37,94 @@ async def test_fixup(coresys: CoreSys, supervisor_internet):
assert mock_repositorie.update.called
assert len(coresys.resolution.suggestions) == 0
assert len(coresys.resolution.issues) == 0
@pytest.mark.usefixtures("supervisor_internet")
async def test_store_execute_reload_runs_on_connectivity_true(coresys: CoreSys):
"""Test fixup runs when connectivity goes from false to true."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.supervisor.connectivity = False
await asyncio.sleep(0)
mock_repository = AsyncMock()
coresys.store.repositories["test_store"] = mock_repository
coresys.resolution.add_issue(
Issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference="test_store",
),
suggestions=[SuggestionType.EXECUTE_RELOAD],
)
with patch.object(coresys.store, "reload") as mock_reload:
# Fire event with connectivity True
coresys.supervisor.connectivity = True
await asyncio.sleep(0.1)
mock_repository.load.assert_called_once()
mock_reload.assert_awaited_once_with(mock_repository)
@pytest.mark.usefixtures("supervisor_internet")
async def test_store_execute_reload_does_not_run_on_connectivity_false(
coresys: CoreSys,
):
"""Test fixup does not run when connectivity goes from true to false."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.supervisor.connectivity = True
await asyncio.sleep(0)
mock_repository = AsyncMock()
coresys.store.repositories["test_store"] = mock_repository
coresys.resolution.add_issue(
Issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference="test_store",
),
suggestions=[SuggestionType.EXECUTE_RELOAD],
)
# Fire event with connectivity True
coresys.supervisor.connectivity = False
await asyncio.sleep(0.1)
mock_repository.load.assert_not_called()
@pytest.mark.usefixtures("supervisor_internet")
async def test_store_execute_reload_dismiss_suggestion_removes_listener(
coresys: CoreSys,
):
"""Test fixup does not run on event if suggestion has been dismissed."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.supervisor.connectivity = True
await asyncio.sleep(0)
mock_repository = AsyncMock()
coresys.store.repositories["test_store"] = mock_repository
coresys.resolution.add_issue(
issue := Issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference="test_store",
),
suggestions=[SuggestionType.EXECUTE_RELOAD],
)
with patch.object(
FixupStoreExecuteReload, "process_fixup", side_effect=ResolutionFixupError
) as mock_fixup:
# Fire event with issue there to trigger fixup
coresys.bus.fire_event(BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, True)
await asyncio.sleep(0.1)
mock_fixup.assert_called_once()
# Remove issue and suggestion and re-fire to see listener is gone
mock_fixup.reset_mock()
coresys.resolution.dismiss_issue(issue)
coresys.bus.fire_event(BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, True)
await asyncio.sleep(0.1)
mock_fixup.assert_not_called()