diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py index 0215c83f0cc..68234077976 100644 --- a/homeassistant/components/uptime_kuma/__init__.py +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -2,16 +2,37 @@ from __future__ import annotations +from pythonkuma.update import UpdateChecker + from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.hass_dict import HassKey -from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import ( + UptimeKumaConfigEntry, + UptimeKumaDataUpdateCoordinator, + UptimeKumaSoftwareUpdateCoordinator, +) -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +UPTIME_KUMA_KEY: HassKey[UptimeKumaSoftwareUpdateCoordinator] = HassKey(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: """Set up Uptime Kuma from a config entry.""" + if UPTIME_KUMA_KEY not in hass.data: + session = async_get_clientsession(hass) + update_checker = UpdateChecker(session) + + update_coordinator = UptimeKumaSoftwareUpdateCoordinator(hass, update_checker) + await update_coordinator.async_request_refresh() + + hass.data[UPTIME_KUMA_KEY] = update_coordinator coordinator = UptimeKumaDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -24,4 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + if not hass.config_entries.async_loaded_entries(DOMAIN): + await hass.data[UPTIME_KUMA_KEY].async_shutdown() + hass.data.pop(UPTIME_KUMA_KEY) + return unload_ok diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 297bd83e7c8..58eed420fd8 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -6,12 +6,14 @@ from datetime import timedelta import logging from pythonkuma import ( + UpdateException, UptimeKuma, UptimeKumaAuthenticationException, UptimeKumaException, UptimeKumaMonitor, UptimeKumaVersion, ) +from pythonkuma.update import LatestRelease, UpdateChecker from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL @@ -25,6 +27,9 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL_UPDATES = timedelta(hours=3) + type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator] @@ -45,7 +50,7 @@ class UptimeKumaDataUpdateCoordinator( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=SCAN_INTERVAL, ) session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]) self.api = UptimeKuma( @@ -105,3 +110,28 @@ def async_migrate_entities_unique_ids( registry_entry.entity_id, new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", ) + + +class UptimeKumaSoftwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): + """Uptime Kuma coordinator for retrieving update information.""" + + def __init__(self, hass: HomeAssistant, update_checker: UpdateChecker) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=None, + name=DOMAIN, + update_interval=SCAN_INTERVAL_UPDATES, + ) + self.update_checker = update_checker + + async def _async_update_data(self) -> LatestRelease: + """Fetch data.""" + try: + return await self.update_checker.latest_release() + except UpdateException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 87dcf6e8cf7..62b1ccbdd9a 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -106,6 +106,11 @@ "port": { "name": "Monitored port" } + }, + "update": { + "update": { + "name": "Uptime Kuma version" + } } }, "exceptions": { @@ -114,6 +119,9 @@ }, "request_failed_exception": { "message": "Connection to Uptime Kuma failed" + }, + "update_check_failed": { + "message": "Failed to check for latest Uptime Kuma update" } } } diff --git a/homeassistant/components/uptime_kuma/update.py b/homeassistant/components/uptime_kuma/update.py new file mode 100644 index 00000000000..6fe4e477f0b --- /dev/null +++ b/homeassistant/components/uptime_kuma/update.py @@ -0,0 +1,122 @@ +"""Update platform for the Uptime Kuma integration.""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import UPTIME_KUMA_KEY +from .const import DOMAIN +from .coordinator import ( + UptimeKumaConfigEntry, + UptimeKumaDataUpdateCoordinator, + UptimeKumaSoftwareUpdateCoordinator, +) + +PARALLEL_UPDATES = 0 + + +class UptimeKumaUpdate(StrEnum): + """Uptime Kuma update.""" + + UPDATE = "update" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UptimeKumaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up update platform.""" + + coordinator = entry.runtime_data + async_add_entities( + [UptimeKumaUpdateEntity(coordinator, hass.data[UPTIME_KUMA_KEY])] + ) + + +class UptimeKumaUpdateEntity( + CoordinatorEntity[UptimeKumaDataUpdateCoordinator], UpdateEntity +): + """Representation of an update entity.""" + + entity_description = UpdateEntityDescription( + key=UptimeKumaUpdate.UPDATE, + translation_key=UptimeKumaUpdate.UPDATE, + ) + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UptimeKumaDataUpdateCoordinator, + update_coordinator: UptimeKumaSoftwareUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.update_checker = update_coordinator + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.config_entry.title, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Uptime Kuma", + configuration_url=coordinator.config_entry.data[CONF_URL], + sw_version=coordinator.api.version.version, + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{self.entity_description.key}" + ) + + @property + def installed_version(self) -> str | None: + """Current version.""" + + return self.coordinator.api.version.version + + @property + def title(self) -> str | None: + """Title of the release.""" + + return f"Uptime Kuma {self.update_checker.data.name}" + + @property + def release_url(self) -> str | None: + """URL to the full release notes.""" + + return self.update_checker.data.html_url + + @property + def latest_version(self) -> str | None: + """Latest version.""" + + return self.update_checker.data.tag_name + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + return self.update_checker.data.body + + async def async_added_to_hass(self) -> None: + """When entity is added to hass. + + Register extra update listener for the software update coordinator. + """ + await super().async_added_to_hass() + self.async_on_remove( + self.update_checker.async_add_listener(self._handle_coordinator_update) + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.update_checker.last_update_success diff --git a/tests/components/uptime_kuma/conftest.py b/tests/components/uptime_kuma/conftest.py index 4b7710a48b4..7895f068b31 100644 --- a/tests/components/uptime_kuma/conftest.py +++ b/tests/components/uptime_kuma/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion from pythonkuma.models import MonitorStatus +from pythonkuma.update import LatestRelease from homeassistant.components.uptime_kuma.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL @@ -99,3 +100,22 @@ def mock_pythonkuma() -> Generator[AsyncMock]: ) yield client + + +@pytest.fixture(autouse=True) +def mock_update_checker() -> Generator[AsyncMock]: + """Mock Update checker.""" + + with patch( + "homeassistant.components.uptime_kuma.UpdateChecker", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.latest_release.return_value = LatestRelease( + html_url="https://github.com/louislam/uptime-kuma/releases/tag/2.0.1", + name="2.0.1", + tag_name="2.0.1", + body="**RELEASE_NOTES**", + ) + + yield client diff --git a/tests/components/uptime_kuma/snapshots/test_update.ambr b/tests/components/uptime_kuma/snapshots/test_update.ambr new file mode 100644 index 00000000000..225584a5181 --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_update.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_update[update.uptime_example_org_uptime_kuma_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.uptime_example_org_uptime_kuma_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Uptime Kuma version', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': '123456789_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.uptime_example_org_uptime_kuma_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/uptime_kuma/icon.png', + 'friendly_name': 'uptime.example.org Uptime Kuma version', + 'in_progress': False, + 'installed_version': '2.0.0', + 'latest_version': '2.0.1', + 'release_summary': None, + 'release_url': 'https://github.com/louislam/uptime-kuma/releases/tag/2.0.1', + 'skipped_version': None, + 'supported_features': , + 'title': 'Uptime Kuma 2.0.1', + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.uptime_example_org_uptime_kuma_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/uptime_kuma/test_update.py b/tests/components/uptime_kuma/test_update.py new file mode 100644 index 00000000000..38d58b979a1 --- /dev/null +++ b/tests/components/uptime_kuma/test_update.py @@ -0,0 +1,77 @@ +"""Test the Uptime Kuma update platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from pythonkuma import UpdateException +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def update_only() -> AsyncGenerator[None]: + """Enable only the update platform.""" + with patch( + "homeassistant.components.uptime_kuma._PLATFORMS", + [Platform.UPDATE], + ): + yield + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the update platform.""" + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.uptime_example_org_uptime_kuma_version", + } + ) + result = await ws_client.receive_json() + assert result["result"] == "**RELEASE_NOTES**" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_update_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_update_checker: AsyncMock, +) -> None: + """Test update entity unavailable on error.""" + + mock_update_checker.latest_release.side_effect = UpdateException + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("update.uptime_example_org_uptime_kuma_version") + assert state is not None + assert state.state == STATE_UNAVAILABLE