Add update platform to Uptime Kuma (#148973)

This commit is contained in:
Manu 2025-07-27 18:02:33 +02:00 committed by GitHub
parent 0e9ced3c00
commit dac75d1902
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 348 additions and 4 deletions

View File

@ -2,16 +2,37 @@
from __future__ import annotations from __future__ import annotations
from pythonkuma.update import UpdateChecker
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant 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: async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool:
"""Set up Uptime Kuma from a config entry.""" """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) coordinator = UptimeKumaDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh() 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: async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool:
"""Unload a config entry.""" """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

View File

@ -6,12 +6,14 @@ from datetime import timedelta
import logging import logging
from pythonkuma import ( from pythonkuma import (
UpdateException,
UptimeKuma, UptimeKuma,
UptimeKumaAuthenticationException, UptimeKumaAuthenticationException,
UptimeKumaException, UptimeKumaException,
UptimeKumaMonitor, UptimeKumaMonitor,
UptimeKumaVersion, UptimeKumaVersion,
) )
from pythonkuma.update import LatestRelease, UpdateChecker
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
@ -25,6 +27,9 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
SCAN_INTERVAL_UPDATES = timedelta(hours=3)
type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator] type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator]
@ -45,7 +50,7 @@ class UptimeKumaDataUpdateCoordinator(
_LOGGER, _LOGGER,
config_entry=config_entry, config_entry=config_entry,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=30), update_interval=SCAN_INTERVAL,
) )
session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]) session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL])
self.api = UptimeKuma( self.api = UptimeKuma(
@ -105,3 +110,28 @@ def async_migrate_entities_unique_ids(
registry_entry.entity_id, registry_entry.entity_id,
new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", 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

View File

@ -106,6 +106,11 @@
"port": { "port": {
"name": "Monitored port" "name": "Monitored port"
} }
},
"update": {
"update": {
"name": "Uptime Kuma version"
}
} }
}, },
"exceptions": { "exceptions": {
@ -114,6 +119,9 @@
}, },
"request_failed_exception": { "request_failed_exception": {
"message": "Connection to Uptime Kuma failed" "message": "Connection to Uptime Kuma failed"
},
"update_check_failed": {
"message": "Failed to check for latest Uptime Kuma update"
} }
} }
} }

View File

@ -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

View File

@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion
from pythonkuma.models import MonitorStatus from pythonkuma.models import MonitorStatus
from pythonkuma.update import LatestRelease
from homeassistant.components.uptime_kuma.const import DOMAIN from homeassistant.components.uptime_kuma.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
@ -99,3 +100,22 @@ def mock_pythonkuma() -> Generator[AsyncMock]:
) )
yield client 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

View File

@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'update.uptime_example_org_uptime_kuma_version',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <UpdateEntityFeature: 16>,
'translation_key': <UptimeKumaUpdate.UPDATE: 'update'>,
'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': <UpdateEntityFeature: 16>,
'title': 'Uptime Kuma 2.0.1',
'update_percentage': None,
}),
'context': <ANY>,
'entity_id': 'update.uptime_example_org_uptime_kuma_version',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@ -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