Add update platform to paperless integration (#145638)

* Add uüdate platform to paperless integration

* Add tests to paperless

* Add translation

* Fixed update unavailable

* Fetch remote version in update platform

* changed diagnostics

* changed diagnostic data

* Code quality

* revert changes

* code quality
This commit is contained in:
Florian von Garrel 2025-05-26 23:24:53 +02:00 committed by GitHub
parent 13a8e5e021
commit 2ee6bf7340
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 323 additions and 8 deletions

View File

@ -26,7 +26,7 @@ from .coordinator import (
PaperlessStatusCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE]
async def async_setup_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> bool:

View File

@ -16,6 +16,7 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"pngx_version": entry.runtime_data.status.api.host_version,
"data": {
"statistics": asdict(entry.runtime_data.statistics.data),
"status": asdict(entry.runtime_data.status.data),

View File

@ -126,6 +126,11 @@
"error": "[%key:common::state::error%]"
}
}
},
"update": {
"paperless_update": {
"name": "Software"
}
}
},
"exceptions": {

View File

@ -0,0 +1,90 @@
"""Update platform for Paperless-ngx."""
from __future__ import annotations
from datetime import timedelta
from pypaperless.exceptions import PaperlessConnectionError
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .coordinator import PaperlessConfigEntry, PaperlessStatusCoordinator
from .entity import PaperlessEntity
PAPERLESS_CHANGELOGS = "https://docs.paperless-ngx.com/changelog/"
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=24)
async def async_setup_entry(
hass: HomeAssistant,
entry: PaperlessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Paperless-ngx update entities."""
description = UpdateEntityDescription(
key="paperless_update",
translation_key="paperless_update",
device_class=UpdateDeviceClass.FIRMWARE,
)
async_add_entities(
[
PaperlessUpdate(
coordinator=entry.runtime_data.status,
description=description,
)
],
update_before_add=True,
)
class PaperlessUpdate(PaperlessEntity[PaperlessStatusCoordinator], UpdateEntity):
"""Defines a Paperless-ngx update entity."""
release_url = PAPERLESS_CHANGELOGS
@property
def should_poll(self) -> bool:
"""Return True because we need to poll the latest version."""
return True
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._attr_available
@property
def installed_version(self) -> str | None:
"""Return the installed version."""
return self.coordinator.api.host_version
async def async_update(self) -> None:
"""Update the entity."""
remote_version = None
try:
remote_version = await self.coordinator.api.remote_version()
except PaperlessConnectionError as err:
if self._attr_available:
LOGGER.warning("Could not fetch remote version: %s", err)
self._attr_available = False
return
if remote_version.version is None or remote_version.version == "0.0.0":
if self._attr_available:
LOGGER.warning("Remote version is not available or invalid")
self._attr_available = False
return
self._attr_latest_version = remote_version.version.lstrip("v")
self._attr_available = True

View File

@ -1,10 +1,9 @@
"""Common fixtures for the Paperless-ngx tests."""
from collections.abc import Generator
import json
from unittest.mock import AsyncMock, MagicMock, patch
from pypaperless.models import Statistic, Status
from pypaperless.models import RemoteVersion, Statistic, Status
import pytest
from homeassistant.components.paperless_ngx.const import DOMAIN
@ -13,30 +12,44 @@ from homeassistant.core import HomeAssistant
from . import setup_integration
from .const import USER_INPUT_ONE
from tests.common import MockConfigEntry, load_fixture
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
def mock_status_data() -> Generator[MagicMock]:
"""Return test status data."""
return json.loads(load_fixture("test_data_status.json", DOMAIN))
return load_json_object_fixture("test_data_status.json", DOMAIN)
@pytest.fixture
def mock_remote_version_data() -> Generator[MagicMock]:
"""Return test remote version data."""
return load_json_object_fixture("test_data_remote_version.json", DOMAIN)
@pytest.fixture
def mock_remote_version_data_unavailable() -> Generator[MagicMock]:
"""Return test remote version data."""
return load_json_object_fixture("test_data_remote_version_unavailable.json", DOMAIN)
@pytest.fixture
def mock_statistic_data() -> Generator[MagicMock]:
"""Return test statistic data."""
return json.loads(load_fixture("test_data_statistic.json", DOMAIN))
return load_json_object_fixture("test_data_statistic.json", DOMAIN)
@pytest.fixture
def mock_statistic_data_update() -> Generator[MagicMock]:
"""Return updated test statistic data."""
return json.loads(load_fixture("test_data_statistic_update.json", DOMAIN))
return load_json_object_fixture("test_data_statistic_update.json", DOMAIN)
@pytest.fixture(autouse=True)
def mock_paperless(
mock_statistic_data: MagicMock, mock_status_data: MagicMock
mock_statistic_data: MagicMock,
mock_status_data: MagicMock,
mock_remote_version_data: MagicMock,
) -> Generator[AsyncMock]:
"""Mock the pypaperless.Paperless client."""
with (
@ -68,6 +81,11 @@ def mock_paperless(
paperless, data=mock_status_data, fetched=True
)
)
paperless.remote_version = AsyncMock(
return_value=RemoteVersion.create_with_data(
paperless, data=mock_remote_version_data, fetched=True
)
)
yield paperless

View File

@ -0,0 +1,4 @@
{
"version": "v2.3.0",
"update_available": true
}

View File

@ -0,0 +1,4 @@
{
"version": "0.0.0",
"update_available": true
}

View File

@ -82,5 +82,6 @@
}),
}),
}),
'pngx_version': '2.3.0',
})
# ---

View File

@ -0,0 +1,62 @@
# serializer version: 1
# name: test_update_platfom[update.paperless_ngx_software-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.paperless_ngx_software',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
'original_icon': None,
'original_name': 'Software',
'platform': 'paperless_ngx',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'paperless_update',
'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_paperless_update',
'unit_of_measurement': None,
})
# ---
# name: test_update_platfom[update.paperless_ngx_software-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/paperless_ngx/icon.png',
'friendly_name': 'Paperless-ngx Software',
'in_progress': False,
'installed_version': '2.3.0',
'latest_version': '2.3.0',
'release_summary': None,
'release_url': 'https://docs.paperless-ngx.com/changelog/',
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 0>,
'title': None,
'update_percentage': None,
}),
'context': <ANY>,
'entity_id': 'update.paperless_ngx_software',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@ -0,0 +1,130 @@
"""Tests for Paperless-ngx update platform."""
from unittest.mock import AsyncMock, MagicMock
from freezegun.api import FrozenDateTimeFactory
from pypaperless.exceptions import PaperlessConnectionError
from pypaperless.models import RemoteVersion
import pytest
from homeassistant.components.paperless_ngx.update import SCAN_INTERVAL
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import (
MockConfigEntry,
SnapshotAssertion,
async_fire_time_changed,
patch,
snapshot_platform,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_update_platfom(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test paperless_ngx update sensors."""
with patch("homeassistant.components.paperless_ngx.PLATFORMS", [Platform.UPDATE]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_update_sensor_downgrade_upgrade(
hass: HomeAssistant,
mock_paperless: AsyncMock,
freezer: FrozenDateTimeFactory,
init_integration: MockConfigEntry,
) -> None:
"""Ensure update entities are updating properly on downgrade and upgrade."""
state = hass.states.get("update.paperless_ngx_software")
assert state.state == STATE_OFF
# downgrade host version
mock_paperless.host_version = "2.2.0"
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.paperless_ngx_software")
assert state.state == STATE_ON
# upgrade host version
mock_paperless.host_version = "2.3.0"
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.paperless_ngx_software")
assert state.state == STATE_OFF
@pytest.mark.usefixtures("init_integration")
async def test_update_sensor_state_on_error(
hass: HomeAssistant,
mock_paperless: AsyncMock,
freezer: FrozenDateTimeFactory,
mock_remote_version_data: MagicMock,
) -> None:
"""Ensure update entities handle errors properly."""
# simulate error
mock_paperless.remote_version.side_effect = PaperlessConnectionError
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.paperless_ngx_software")
assert state.state == STATE_UNAVAILABLE
# recover from not auth errors
mock_paperless.remote_version = AsyncMock(
return_value=RemoteVersion.create_with_data(
mock_paperless, data=mock_remote_version_data, fetched=True
)
)
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.paperless_ngx_software")
assert state.state == STATE_OFF
@pytest.mark.usefixtures("init_integration")
async def test_update_sensor_version_unavailable(
hass: HomeAssistant,
mock_paperless: AsyncMock,
freezer: FrozenDateTimeFactory,
mock_remote_version_data_unavailable: MagicMock,
) -> None:
"""Ensure update entities handle version unavailable properly."""
state = hass.states.get("update.paperless_ngx_software")
assert state.state == STATE_OFF
# set version unavailable
mock_paperless.remote_version = AsyncMock(
return_value=RemoteVersion.create_with_data(
mock_paperless, data=mock_remote_version_data_unavailable, fetched=True
)
)
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.paperless_ngx_software")
assert state.state == STATE_UNAVAILABLE