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

View File

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

View File

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

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

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