diff --git a/supervisor/homeassistant/const.py b/supervisor/homeassistant/const.py index 2e50e1386..b85ff3470 100644 --- a/supervisor/homeassistant/const.py +++ b/supervisor/homeassistant/const.py @@ -32,6 +32,8 @@ class WSEvent(str, Enum): """Websocket events.""" ADDON = "addon" + HEALTH_CHANGED = "health_changed" ISSUE_CHANGED = "issue_changed" ISSUE_REMOVED = "issue_removed" SUPERVISOR_UPDATE = "supervisor_update" + SUPPORTED_CHANGED = "supported_changed" diff --git a/supervisor/resolution/data.py b/supervisor/resolution/data.py index b1a5af156..9eef1b8b2 100644 --- a/supervisor/resolution/data.py +++ b/supervisor/resolution/data.py @@ -3,7 +3,13 @@ from uuid import UUID, uuid4 import attr -from .const import ContextType, IssueType, SuggestionType +from .const import ( + ContextType, + IssueType, + SuggestionType, + UnhealthyReason, + UnsupportedReason, +) @attr.s(frozen=True, slots=True) @@ -24,3 +30,19 @@ class Suggestion: context: ContextType = attr.ib() reference: str | None = attr.ib(default=None) uuid: UUID = attr.ib(factory=lambda: uuid4().hex, eq=False, init=False) + + +@attr.s(frozen=True, slots=True) +class HealthChanged: + """Describe change in system health.""" + + healthy: bool = attr.ib() + unhealthy_reasons: list[UnhealthyReason] | None = attr.ib(default=None) + + +@attr.s(frozen=True, slots=True) +class SupportedChanged: + """Describe change in system supported.""" + + supported: bool = attr.ib() + unsupported_reasons: list[UnsupportedReason] | None = attr.ib(default=None) diff --git a/supervisor/resolution/module.py b/supervisor/resolution/module.py index 82a818a7c..08ad36193 100644 --- a/supervisor/resolution/module.py +++ b/supervisor/resolution/module.py @@ -18,7 +18,7 @@ from .const import ( UnhealthyReason, UnsupportedReason, ) -from .data import Issue, Suggestion +from .data import HealthChanged, Issue, Suggestion, SupportedChanged from .evaluate import ResolutionEvaluation from .fixup import ResolutionFixup from .notify import ResolutionNotify @@ -125,6 +125,10 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): """Add a reason for unsupported.""" if reason not in self._unsupported: self._unsupported.append(reason) + self.sys_homeassistant.websocket.supervisor_event( + WSEvent.SUPPORTED_CHANGED, + attr.asdict(SupportedChanged(False, self.unsupported)), + ) @property def unhealthy(self) -> list[UnhealthyReason]: @@ -136,6 +140,10 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): """Add a reason for unsupported.""" if reason not in self._unhealthy: self._unhealthy.append(reason) + self.sys_homeassistant.websocket.supervisor_event( + WSEvent.HEALTH_CHANGED, + attr.asdict(HealthChanged(False, self.unhealthy)), + ) def get_suggestion(self, uuid: str) -> Suggestion: """Return suggestion with uuid.""" @@ -228,6 +236,12 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): if reason not in self._unsupported: raise ResolutionError(f"The reason {reason} is not active", _LOGGER.warning) self._unsupported.remove(reason) + self.sys_homeassistant.websocket.supervisor_event( + WSEvent.SUPPORTED_CHANGED, + attr.asdict( + SupportedChanged(self.sys_core.supported, self.unsupported or None) + ), + ) def suggestions_for_issue(self, issue: Issue) -> set[Suggestion]: """Get suggestions that fix an issue.""" diff --git a/tests/host/test_network.py b/tests/host/test_network.py index 8be0f55ab..2adfd68e9 100644 --- a/tests/host/test_network.py +++ b/tests/host/test_network.py @@ -240,7 +240,7 @@ async def test_host_connectivity_disabled(coresys: CoreSys): await asyncio.sleep(0) assert coresys.host.network.connectivity is True await asyncio.sleep(0) - client.async_send_command.assert_called_once_with( + client.async_send_command.assert_called_with( { "type": WSType.SUPERVISOR_EVENT, "data": { diff --git a/tests/resolution/test_resolution_manager.py b/tests/resolution/test_resolution_manager.py index 0a80c8ff8..160c59457 100644 --- a/tests/resolution/test_resolution_manager.py +++ b/tests/resolution/test_resolution_manager.py @@ -261,3 +261,106 @@ async def test_resolution_apply_suggestion_multiple_copies(coresys: CoreSys): assert remove_store_1 in coresys.resolution.suggestions assert remove_store_2 not in coresys.resolution.suggestions assert remove_store_3 in coresys.resolution.suggestions + + +async def test_events_on_unsupported_changed(coresys: CoreSys): + """Test events fired when unsupported changes.""" + with patch.object( + type(coresys.homeassistant.websocket), "async_send_message" + ) as send_message: + # Marking system as unsupported tells HA + assert coresys.resolution.unsupported == [] + coresys.resolution.unsupported = UnsupportedReason.CONNECTIVITY_CHECK + await asyncio.sleep(0) + assert coresys.resolution.unsupported == [UnsupportedReason.CONNECTIVITY_CHECK] + send_message.assert_called_once_with( + _supervisor_event_message( + "supported_changed", + {"supported": False, "unsupported_reasons": ["connectivity_check"]}, + ) + ) + + # Adding the same reason again does nothing + send_message.reset_mock() + coresys.resolution.unsupported = UnsupportedReason.CONNECTIVITY_CHECK + await asyncio.sleep(0) + assert coresys.resolution.unsupported == [UnsupportedReason.CONNECTIVITY_CHECK] + send_message.assert_not_called() + + # Adding and removing additional reasons tells HA unsupported reasons changed + coresys.resolution.unsupported = UnsupportedReason.JOB_CONDITIONS + await asyncio.sleep(0) + assert coresys.resolution.unsupported == [ + UnsupportedReason.CONNECTIVITY_CHECK, + UnsupportedReason.JOB_CONDITIONS, + ] + send_message.assert_called_once_with( + _supervisor_event_message( + "supported_changed", + { + "supported": False, + "unsupported_reasons": ["connectivity_check", "job_conditions"], + }, + ) + ) + + send_message.reset_mock() + coresys.resolution.dismiss_unsupported(UnsupportedReason.CONNECTIVITY_CHECK) + await asyncio.sleep(0) + assert coresys.resolution.unsupported == [UnsupportedReason.JOB_CONDITIONS] + send_message.assert_called_once_with( + _supervisor_event_message( + "supported_changed", + {"supported": False, "unsupported_reasons": ["job_conditions"]}, + ) + ) + + # Dismissing all unsupported reasons tells HA its supported again + send_message.reset_mock() + coresys.resolution.dismiss_unsupported(UnsupportedReason.JOB_CONDITIONS) + await asyncio.sleep(0) + assert coresys.resolution.unsupported == [] + send_message.assert_called_once_with( + _supervisor_event_message( + "supported_changed", {"supported": True, "unsupported_reasons": None} + ) + ) + + +async def test_events_on_unhealthy_changed(coresys: CoreSys): + """Test events fired when unhealthy changes.""" + with patch.object( + type(coresys.homeassistant.websocket), "async_send_message" + ) as send_message: + # Marking system as unhealthy tells HA + assert coresys.resolution.unhealthy == [] + coresys.resolution.unhealthy = UnhealthyReason.DOCKER + await asyncio.sleep(0) + assert coresys.resolution.unhealthy == [UnhealthyReason.DOCKER] + send_message.assert_called_once_with( + _supervisor_event_message( + "health_changed", + {"healthy": False, "unhealthy_reasons": ["docker"]}, + ) + ) + + # Adding the same reason again does nothing + send_message.reset_mock() + coresys.resolution.unhealthy = UnhealthyReason.DOCKER + await asyncio.sleep(0) + assert coresys.resolution.unhealthy == [UnhealthyReason.DOCKER] + send_message.assert_not_called() + + # Adding an additional reason tells HA unhealthy reasons changed + coresys.resolution.unhealthy = UnhealthyReason.UNTRUSTED + await asyncio.sleep(0) + assert coresys.resolution.unhealthy == [ + UnhealthyReason.DOCKER, + UnhealthyReason.UNTRUSTED, + ] + send_message.assert_called_once_with( + _supervisor_event_message( + "health_changed", + {"healthy": False, "unhealthy_reasons": ["docker", "untrusted"]}, + ) + )