diff --git a/supervisor/resolution/fixups/base.py b/supervisor/resolution/fixups/base.py index 906e78ed2..e422e346c 100644 --- a/supervisor/resolution/fixups/base.py +++ b/supervisor/resolution/fixups/base.py @@ -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.""" diff --git a/supervisor/resolution/fixups/store_execute_reload.py b/supervisor/resolution/fixups/store_execute_reload.py index cb512587b..17c232e01 100644 --- a/supervisor/resolution/fixups/store_execute_reload.py +++ b/supervisor/resolution/fixups/store_execute_reload.py @@ -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 diff --git a/supervisor/resolution/module.py b/supervisor/resolution/module.py index 8e40b273a..0ed484f6c 100644 --- a/supervisor/resolution/module.py +++ b/supervisor/resolution/module.py @@ -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( diff --git a/tests/resolution/fixup/test_store_execute_reload.py b/tests/resolution/fixup/test_store_execute_reload.py index 2117eced7..68f492485 100644 --- a/tests/resolution/fixup/test_store_execute_reload.py +++ b/tests/resolution/fixup/test_store_execute_reload.py @@ -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()