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, PaperlessStatusCoordinator,
) )
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE]
async def async_setup_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> bool: 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]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
return { return {
"pngx_version": entry.runtime_data.status.api.host_version,
"data": { "data": {
"statistics": asdict(entry.runtime_data.statistics.data), "statistics": asdict(entry.runtime_data.statistics.data),
"status": asdict(entry.runtime_data.status.data), "status": asdict(entry.runtime_data.status.data),

View File

@ -126,6 +126,11 @@
"error": "[%key:common::state::error%]" "error": "[%key:common::state::error%]"
} }
} }
},
"update": {
"paperless_update": {
"name": "Software"
}
} }
}, },
"exceptions": { "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.""" """Common fixtures for the Paperless-ngx tests."""
from collections.abc import Generator from collections.abc import Generator
import json
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from pypaperless.models import Statistic, Status from pypaperless.models import RemoteVersion, Statistic, Status
import pytest import pytest
from homeassistant.components.paperless_ngx.const import DOMAIN from homeassistant.components.paperless_ngx.const import DOMAIN
@ -13,30 +12,44 @@ from homeassistant.core import HomeAssistant
from . import setup_integration from . import setup_integration
from .const import USER_INPUT_ONE from .const import USER_INPUT_ONE
from tests.common import MockConfigEntry, load_fixture from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture @pytest.fixture
def mock_status_data() -> Generator[MagicMock]: def mock_status_data() -> Generator[MagicMock]:
"""Return test status data.""" """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 @pytest.fixture
def mock_statistic_data() -> Generator[MagicMock]: def mock_statistic_data() -> Generator[MagicMock]:
"""Return test statistic data.""" """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 @pytest.fixture
def mock_statistic_data_update() -> Generator[MagicMock]: def mock_statistic_data_update() -> Generator[MagicMock]:
"""Return updated test statistic data.""" """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) @pytest.fixture(autouse=True)
def mock_paperless( 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]: ) -> Generator[AsyncMock]:
"""Mock the pypaperless.Paperless client.""" """Mock the pypaperless.Paperless client."""
with ( with (
@ -68,6 +81,11 @@ def mock_paperless(
paperless, data=mock_status_data, fetched=True 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 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