From c14d17f88c4b8f9e3d65302994b584f2bf7d5ea4 Mon Sep 17 00:00:00 2001 From: Florian von Garrel Date: Mon, 26 May 2025 17:24:23 +0200 Subject: [PATCH] Add status sensors to paperless (#145591) * Add first status sensor and coordinator * New snapshot * Add comment * Add test for forbidden status endpoint * Changed comment * Fixed translation * Minor changes and code optimization * Add common translation; minor tweaks * Moved translation from common to integration --- .../components/paperless_ngx/__init__.py | 95 +++- .../components/paperless_ngx/coordinator.py | 138 +++--- .../components/paperless_ngx/diagnostics.py | 7 +- .../components/paperless_ngx/entity.py | 8 +- .../components/paperless_ngx/icons.json | 61 ++- .../components/paperless_ngx/sensor.py | 215 +++++++- .../components/paperless_ngx/strings.json | 54 +++ tests/components/paperless_ngx/conftest.py | 21 +- .../fixtures/test_data_status.json | 36 ++ .../snapshots/test_diagnostics.ambr | 97 +++- .../paperless_ngx/snapshots/test_sensor.ambr | 458 ++++++++++++++++++ tests/components/paperless_ngx/test_init.py | 20 +- tests/components/paperless_ngx/test_sensor.py | 12 +- 13 files changed, 1106 insertions(+), 116 deletions(-) create mode 100644 tests/components/paperless_ngx/fixtures/test_data_status.json diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py index 145f3ec2caf..22c05d798e8 100644 --- a/homeassistant/components/paperless_ngx/__init__.py +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -1,9 +1,30 @@ """The Paperless-ngx integration.""" -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from pypaperless import Paperless +from pypaperless.exceptions import ( + InitializationError, + PaperlessConnectionError, + PaperlessForbiddenError, + PaperlessInactiveOrDeletedError, + PaperlessInvalidTokenError, +) -from .coordinator import PaperlessConfigEntry, PaperlessCoordinator +from homeassistant.const import CONF_API_KEY, CONF_URL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER +from .coordinator import ( + PaperlessConfigEntry, + PaperlessData, + PaperlessStatisticCoordinator, + PaperlessStatusCoordinator, +) PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -11,10 +32,28 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> bool: """Set up Paperless-ngx from a config entry.""" - coordinator = PaperlessCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() + api = await _get_paperless_api(hass, entry) - entry.runtime_data = coordinator + statistics_coordinator = PaperlessStatisticCoordinator(hass, entry, api) + status_coordinator = PaperlessStatusCoordinator(hass, entry, api) + + await statistics_coordinator.async_config_entry_first_refresh() + + try: + await status_coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as err: + # Catch the error so the integration doesn't fail just because status coordinator fails. + LOGGER.warning("Could not initialize status coordinator: %s", err) + + entry.runtime_data = PaperlessData( + status=status_coordinator, + statistics=statistics_coordinator, + ) + + entry.runtime_data = PaperlessData( + status=status_coordinator, + statistics=statistics_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -24,3 +63,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> bool: """Unload paperless-ngx config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _get_paperless_api( + hass: HomeAssistant, + entry: PaperlessConfigEntry, +) -> Paperless: + """Create and initialize paperless-ngx API.""" + + api = Paperless( + entry.data[CONF_URL], + entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + + try: + await api.initialize() + await api.statistics() # test permissions on api + except PaperlessConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except PaperlessInvalidTokenError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err + except PaperlessInactiveOrDeletedError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="user_inactive_or_deleted", + ) from err + except PaperlessForbiddenError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="forbidden", + ) from err + except InitializationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + else: + return api diff --git a/homeassistant/components/paperless_ngx/coordinator.py b/homeassistant/components/paperless_ngx/coordinator.py index a8296bbda89..d5960bed49b 100644 --- a/homeassistant/components/paperless_ngx/coordinator.py +++ b/homeassistant/components/paperless_ngx/coordinator.py @@ -2,38 +2,45 @@ from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta +from typing import TypeVar from pypaperless import Paperless from pypaperless.exceptions import ( - InitializationError, PaperlessConnectionError, PaperlessForbiddenError, PaperlessInactiveOrDeletedError, PaperlessInvalidTokenError, ) -from pypaperless.models import Statistic +from pypaperless.models import Statistic, Status from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER -type PaperlessConfigEntry = ConfigEntry[PaperlessCoordinator] +type PaperlessConfigEntry = ConfigEntry[PaperlessData] -UPDATE_INTERVAL = 120 +TData = TypeVar("TData") + +UPDATE_INTERVAL_STATISTICS = timedelta(seconds=120) +UPDATE_INTERVAL_STATUS = timedelta(seconds=300) -class PaperlessCoordinator(DataUpdateCoordinator[Statistic]): - """Coordinator to manage Paperless-ngx statistic updates.""" +@dataclass +class PaperlessData: + """Data for the Paperless-ngx integration.""" + + statistics: PaperlessStatisticCoordinator + status: PaperlessStatusCoordinator + + +class PaperlessCoordinator(DataUpdateCoordinator[TData]): + """Coordinator to manage fetching Paperless-ngx API.""" config_entry: PaperlessConfigEntry @@ -41,28 +48,27 @@ class PaperlessCoordinator(DataUpdateCoordinator[Statistic]): self, hass: HomeAssistant, entry: PaperlessConfigEntry, + api: Paperless, + name: str, + update_interval: timedelta, ) -> None: - """Initialize my coordinator.""" + """Initialize Paperless-ngx statistics coordinator.""" + self.api = api + super().__init__( hass, LOGGER, config_entry=entry, - name="Paperless-ngx Coordinator", - update_interval=timedelta(seconds=UPDATE_INTERVAL), + name=name, + update_interval=update_interval, ) - self.api = Paperless( - entry.data[CONF_URL], - entry.data[CONF_API_KEY], - session=async_get_clientsession(self.hass), - ) - - async def _async_setup(self) -> None: + async def _async_update_data(self) -> TData: + """Update data via internal method.""" try: - await self.api.initialize() - await self.api.statistics() # test permissions on api + return await self._async_update_data_internal() except PaperlessConnectionError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", ) from err @@ -77,37 +83,57 @@ class PaperlessCoordinator(DataUpdateCoordinator[Statistic]): translation_key="user_inactive_or_deleted", ) from err except PaperlessForbiddenError as err: - raise ConfigEntryError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="forbidden", ) from err - except InitializationError as err: - raise ConfigEntryError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - async def _async_update_data(self) -> Statistic: - """Fetch data from API endpoint.""" - try: - return await self.api.statistics() - except PaperlessConnectionError as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except PaperlessForbiddenError as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="forbidden", - ) from err - except PaperlessInvalidTokenError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="invalid_api_key", - ) from err - except PaperlessInactiveOrDeletedError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="user_inactive_or_deleted", - ) from err + @abstractmethod + async def _async_update_data_internal(self) -> TData: + """Update data via paperless-ngx API.""" + + +class PaperlessStatisticCoordinator(PaperlessCoordinator[Statistic]): + """Coordinator to manage Paperless-ngx statistic updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: PaperlessConfigEntry, + api: Paperless, + ) -> None: + """Initialize Paperless-ngx status coordinator.""" + super().__init__( + hass, + entry, + api, + name="Statistics Coordinator", + update_interval=UPDATE_INTERVAL_STATISTICS, + ) + + async def _async_update_data_internal(self) -> Statistic: + """Fetch statistics data from API endpoint.""" + return await self.api.statistics() + + +class PaperlessStatusCoordinator(PaperlessCoordinator[Status]): + """Coordinator to manage Paperless-ngx status updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: PaperlessConfigEntry, + api: Paperless, + ) -> None: + """Initialize Paperless-ngx status coordinator.""" + super().__init__( + hass, + entry, + api, + name="Status Coordinator", + update_interval=UPDATE_INTERVAL_STATUS, + ) + + async def _async_update_data_internal(self) -> Status: + """Fetch status data from API endpoint.""" + return await self.api.status() diff --git a/homeassistant/components/paperless_ngx/diagnostics.py b/homeassistant/components/paperless_ngx/diagnostics.py index 3f8351c6dca..3222295d055 100644 --- a/homeassistant/components/paperless_ngx/diagnostics.py +++ b/homeassistant/components/paperless_ngx/diagnostics.py @@ -15,4 +15,9 @@ async def async_get_config_entry_diagnostics( entry: PaperlessConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return {"data": asdict(entry.runtime_data.data)} + return { + "data": { + "statistics": asdict(entry.runtime_data.statistics.data), + "status": asdict(entry.runtime_data.status.data), + }, + } diff --git a/homeassistant/components/paperless_ngx/entity.py b/homeassistant/components/paperless_ngx/entity.py index 934f460af8d..e7eb0f0edcf 100644 --- a/homeassistant/components/paperless_ngx/entity.py +++ b/homeassistant/components/paperless_ngx/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Generic, TypeVar + from homeassistant.components.sensor import EntityDescription from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -9,15 +11,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import PaperlessCoordinator +TCoordinator = TypeVar("TCoordinator", bound=PaperlessCoordinator) -class PaperlessEntity(CoordinatorEntity[PaperlessCoordinator]): + +class PaperlessEntity(CoordinatorEntity[TCoordinator], Generic[TCoordinator]): """Defines a base Paperless-ngx entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: PaperlessCoordinator, + coordinator: TCoordinator, description: EntityDescription, ) -> None: """Initialize the Paperless-ngx entity.""" diff --git a/homeassistant/components/paperless_ngx/icons.json b/homeassistant/components/paperless_ngx/icons.json index 5d5db9a6b51..1df7a7d701c 100644 --- a/homeassistant/components/paperless_ngx/icons.json +++ b/homeassistant/components/paperless_ngx/icons.json @@ -16,8 +16,65 @@ "correspondent_count": { "default": "mdi:account-group" }, - "document_type_count": { - "default": "mdi:format-list-bulleted-type" + "storage_total": { + "default": "mdi:harddisk" + }, + "storage_available": { + "default": "mdi:harddisk" + }, + "database_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "index_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "classifier_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "celery_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "redis_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "sanity_check_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } } } } diff --git a/homeassistant/components/paperless_ngx/sensor.py b/homeassistant/components/paperless_ngx/sensor.py index 4c358933ae7..e3f601b68e6 100644 --- a/homeassistant/components/paperless_ngx/sensor.py +++ b/homeassistant/components/paperless_ngx/sensor.py @@ -4,62 +4,73 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Generic -from pypaperless.models import Statistic +from pypaperless.models import Statistic, Status +from pypaperless.models.common import StatusType from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) +from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_conversion import InformationConverter -from .coordinator import PaperlessConfigEntry -from .entity import PaperlessEntity +from .coordinator import ( + PaperlessConfigEntry, + PaperlessStatisticCoordinator, + PaperlessStatusCoordinator, + TData, +) +from .entity import PaperlessEntity, TCoordinator PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class PaperlessEntityDescription(SensorEntityDescription): +class PaperlessEntityDescription(SensorEntityDescription, Generic[TData]): """Describes Paperless-ngx sensor entity.""" - value_fn: Callable[[Statistic], int | None] + value_fn: Callable[[TData], StateType] -SENSOR_DESCRIPTIONS: tuple[PaperlessEntityDescription, ...] = ( - PaperlessEntityDescription( +SENSOR_STATISTICS: tuple[PaperlessEntityDescription, ...] = ( + PaperlessEntityDescription[Statistic]( key="documents_total", translation_key="documents_total", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.documents_total, ), - PaperlessEntityDescription( + PaperlessEntityDescription[Statistic]( key="documents_inbox", translation_key="documents_inbox", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.documents_inbox, ), - PaperlessEntityDescription( + PaperlessEntityDescription[Statistic]( key="characters_count", translation_key="characters_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.character_count, ), - PaperlessEntityDescription( + PaperlessEntityDescription[Statistic]( key="tag_count", translation_key="tag_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.tag_count, ), - PaperlessEntityDescription( + PaperlessEntityDescription[Statistic]( key="correspondent_count", translation_key="correspondent_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.correspondent_count, ), - PaperlessEntityDescription( + PaperlessEntityDescription[Statistic]( key="document_type_count", translation_key="document_type_count", state_class=SensorStateClass.MEASUREMENT, @@ -67,6 +78,157 @@ SENSOR_DESCRIPTIONS: tuple[PaperlessEntityDescription, ...] = ( ), ) +SENSOR_STATUS: tuple[PaperlessEntityDescription, ...] = ( + PaperlessEntityDescription[Status]( + key="storage_total", + translation_key="storage_total", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=( + lambda data: round( + InformationConverter().convert( + data.storage.total, + UnitOfInformation.BYTES, + UnitOfInformation.GIGABYTES, + ), + 2, + ) + if data.storage is not None and data.storage.total is not None + else None + ), + ), + PaperlessEntityDescription[Status]( + key="storage_available", + translation_key="storage_available", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=( + lambda data: round( + InformationConverter().convert( + data.storage.available, + UnitOfInformation.BYTES, + UnitOfInformation.GIGABYTES, + ), + 2, + ) + if data.storage is not None and data.storage.available is not None + else None + ), + ), + PaperlessEntityDescription[Status]( + key="database_status", + translation_key="database_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.database.status.value.lower() + if ( + data.database is not None + and data.database.status is not None + and data.database.status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="index_status", + translation_key="index_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.index_status.value.lower() + if ( + data.tasks is not None + and data.tasks.index_status is not None + and data.tasks.index_status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="classifier_status", + translation_key="classifier_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.classifier_status.value.lower() + if ( + data.tasks is not None + and data.tasks.classifier_status is not None + and data.tasks.classifier_status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="celery_status", + translation_key="celery_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.celery_status.value.lower() + if ( + data.tasks is not None + and data.tasks.celery_status is not None + and data.tasks.celery_status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="redis_status", + translation_key="redis_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.redis_status.value.lower() + if ( + data.tasks is not None + and data.tasks.redis_status is not None + and data.tasks.redis_status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="sanity_check_status", + translation_key="sanity_check_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.sanity_check_status.value.lower() + if ( + data.tasks is not None + and data.tasks.sanity_check_status is not None + and data.tasks.sanity_check_status != StatusType.UNKNOWN + ) + else None + ), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -74,21 +236,34 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Paperless-ngx sensors.""" - async_add_entities( - PaperlessSensor( - coordinator=entry.runtime_data, - description=sensor_description, + + entities: list[PaperlessSensor] = [] + + entities += [ + PaperlessSensor[PaperlessStatisticCoordinator]( + coordinator=entry.runtime_data.statistics, + description=description, ) - for sensor_description in SENSOR_DESCRIPTIONS - ) + for description in SENSOR_STATISTICS + ] + + entities += [ + PaperlessSensor[PaperlessStatusCoordinator]( + coordinator=entry.runtime_data.status, + description=description, + ) + for description in SENSOR_STATUS + ] + + async_add_entities(entities) -class PaperlessSensor(PaperlessEntity, SensorEntity): +class PaperlessSensor(PaperlessEntity[TCoordinator], SensorEntity): """Defines a Paperless-ngx sensor entity.""" entity_description: PaperlessEntityDescription @property - def native_value(self) -> int | None: + def native_value(self) -> StateType: """Return the current value of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/paperless_ngx/strings.json b/homeassistant/components/paperless_ngx/strings.json index dbcd3cf37e1..4cceeb37a5a 100644 --- a/homeassistant/components/paperless_ngx/strings.json +++ b/homeassistant/components/paperless_ngx/strings.json @@ -71,6 +71,60 @@ "document_type_count": { "name": "Document types", "unit_of_measurement": "document types" + }, + "storage_total": { + "name": "Total storage" + }, + "storage_available": { + "name": "Available storage" + }, + "database_status": { + "name": "Status database", + "state": { + "ok": "OK", + "warning": "Warning", + "error": "[%key:common::state::error%]" + } + }, + "index_status": { + "name": "Status index", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + }, + "classifier_status": { + "name": "Status classifier", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + }, + "celery_status": { + "name": "Status celery", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + }, + "redis_status": { + "name": "Status redis", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + }, + "sanity_check_status": { + "name": "Status sanity", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } } } }, diff --git a/tests/components/paperless_ngx/conftest.py b/tests/components/paperless_ngx/conftest.py index a96a0b115e1..c57246eecf0 100644 --- a/tests/components/paperless_ngx/conftest.py +++ b/tests/components/paperless_ngx/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch -from pypaperless.models import Statistic +from pypaperless.models import Statistic, Status import pytest from homeassistant.components.paperless_ngx.const import DOMAIN @@ -16,6 +16,12 @@ from .const import USER_INPUT_ONE from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_status_data() -> Generator[MagicMock]: + """Return test status data.""" + return json.loads(load_fixture("test_data_status.json", DOMAIN)) + + @pytest.fixture def mock_statistic_data() -> Generator[MagicMock]: """Return test statistic data.""" @@ -29,7 +35,9 @@ def mock_statistic_data_update() -> Generator[MagicMock]: @pytest.fixture(autouse=True) -def mock_paperless(mock_statistic_data: MagicMock) -> Generator[AsyncMock]: +def mock_paperless( + mock_statistic_data: MagicMock, mock_status_data: MagicMock +) -> Generator[AsyncMock]: """Mock the pypaperless.Paperless client.""" with ( patch( @@ -40,6 +48,10 @@ def mock_paperless(mock_statistic_data: MagicMock) -> Generator[AsyncMock]: "homeassistant.components.paperless_ngx.config_flow.Paperless", new=paperless_mock, ), + patch( + "homeassistant.components.paperless_ngx.Paperless", + new=paperless_mock, + ), ): paperless = paperless_mock.return_value @@ -51,6 +63,11 @@ def mock_paperless(mock_statistic_data: MagicMock) -> Generator[AsyncMock]: paperless, data=mock_statistic_data, fetched=True ) ) + paperless.status = AsyncMock( + return_value=Status.create_with_data( + paperless, data=mock_status_data, fetched=True + ) + ) yield paperless diff --git a/tests/components/paperless_ngx/fixtures/test_data_status.json b/tests/components/paperless_ngx/fixtures/test_data_status.json new file mode 100644 index 00000000000..9a4ffc25cd0 --- /dev/null +++ b/tests/components/paperless_ngx/fixtures/test_data_status.json @@ -0,0 +1,36 @@ +{ + "pngx_version": "2.15.3", + "server_os": "Linux-6.6.74-haos-raspi-aarch64-with-glibc2.36", + "install_type": "docker", + "storage": { + "total": 62101651456, + "available": 25376927744 + }, + "database": { + "type": "sqlite", + "url": "/config/data/db.sqlite3", + "status": "OK", + "error": null, + "migration_status": { + "latest_migration": "paperless_mail.0029_mailrule_pdf_layout", + "unapplied_migrations": [] + } + }, + "tasks": { + "redis_url": "redis://localhost:6379", + "redis_status": "OK", + "redis_error": null, + "celery_status": "OK", + "celery_url": "celery@ca5234a0-paperless-ngx", + "celery_error": null, + "index_status": "OK", + "index_last_modified": "2025-05-25T00:00:27.053090+02:00", + "index_error": null, + "classifier_status": "OK", + "classifier_last_trained": "2025-05-25T15:05:15.824671Z", + "classifier_error": null, + "sanity_check_status": "OK", + "sanity_check_last_run": "2025-05-24T22:30:21.005536Z", + "sanity_check_error": null + } +} diff --git a/tests/components/paperless_ngx/snapshots/test_diagnostics.ambr b/tests/components/paperless_ngx/snapshots/test_diagnostics.ambr index 77adafd31f6..778d10d3d1b 100644 --- a/tests/components/paperless_ngx/snapshots/test_diagnostics.ambr +++ b/tests/components/paperless_ngx/snapshots/test_diagnostics.ambr @@ -2,28 +2,85 @@ # name: test_config_entry_diagnostics dict({ 'data': dict({ - 'character_count': 99999, - 'correspondent_count': 99, - 'current_asn': 99, - 'document_file_type_counts': list([ - dict({ - 'mime_type': 'application/pdf', - 'mime_type_count': 998, + 'statistics': dict({ + 'character_count': 99999, + 'correspondent_count': 99, + 'current_asn': 99, + 'document_file_type_counts': list([ + dict({ + 'mime_type': 'application/pdf', + 'mime_type_count': 998, + }), + dict({ + 'mime_type': 'image/png', + 'mime_type_count': 1, + }), + ]), + 'document_type_count': 99, + 'documents_inbox': 9, + 'documents_total': 999, + 'inbox_tag': 9, + 'inbox_tags': list([ + 9, + ]), + 'storage_path_count': 9, + 'tag_count': 99, + }), + 'status': dict({ + 'database': dict({ + 'error': None, + 'migration_status': dict({ + 'latest_migration': 'paperless_mail.0029_mailrule_pdf_layout', + 'unapplied_migrations': list([ + ]), + }), + 'status': dict({ + '__type': "", + 'repr': "", + }), + 'type': 'sqlite', + 'url': '/config/data/db.sqlite3', }), - dict({ - 'mime_type': 'image/png', - 'mime_type_count': 1, + 'install_type': 'docker', + 'pngx_version': '2.15.3', + 'server_os': 'Linux-6.6.74-haos-raspi-aarch64-with-glibc2.36', + 'storage': dict({ + 'available': 25376927744, + 'total': 62101651456, }), - ]), - 'document_type_count': 99, - 'documents_inbox': 9, - 'documents_total': 999, - 'inbox_tag': 9, - 'inbox_tags': list([ - 9, - ]), - 'storage_path_count': 9, - 'tag_count': 99, + 'tasks': dict({ + 'celery_error': None, + 'celery_status': dict({ + '__type': "", + 'repr': "", + }), + 'celery_url': 'celery@ca5234a0-paperless-ngx', + 'classifier_error': None, + 'classifier_last_trained': '2025-05-25T15:05:15.824671+00:00', + 'classifier_status': dict({ + '__type': "", + 'repr': "", + }), + 'index_error': None, + 'index_last_modified': '2025-05-25T00:00:27.053090+02:00', + 'index_status': dict({ + '__type': "", + 'repr': "", + }), + 'redis_error': None, + 'redis_status': dict({ + '__type': "", + 'repr': "", + }), + 'redis_url': 'redis://localhost:6379', + 'sanity_check_error': None, + 'sanity_check_last_run': '2025-05-24T22:30:21.005536+00:00', + 'sanity_check_status': dict({ + '__type': "", + 'repr': "", + }), + }), + }), }), }) # --- diff --git a/tests/components/paperless_ngx/snapshots/test_sensor.ambr b/tests/components/paperless_ngx/snapshots/test_sensor.ambr index cc197e23ff5..1f7c7b09d9c 100644 --- a/tests/components/paperless_ngx/snapshots/test_sensor.ambr +++ b/tests/components/paperless_ngx/snapshots/test_sensor.ambr @@ -1,4 +1,56 @@ # serializer version: 1 +# name: test_sensor_platform[sensor.paperless_ngx_available_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_available_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Available storage', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_available', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_storage_available', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_available_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Paperless-ngx Available storage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_available_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.38', + }) +# --- # name: test_sensor_platform[sensor.paperless_ngx_correspondents-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -152,6 +204,360 @@ 'state': '9', }) # --- +# name: test_sensor_platform[sensor.paperless_ngx_status_celery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_celery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status celery', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'celery_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_celery_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_celery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status celery', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_celery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_classifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_classifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status classifier', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'classifier_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_classifier_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_classifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status classifier', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_classifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_database-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_database', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status database', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'database_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_database_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_database-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status database', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_database', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status index', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'index_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_index_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status index', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_redis-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_redis', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status redis', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'redis_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_redis_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_redis-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status redis', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_redis', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_sanity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_sanity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status sanity', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sanity_check_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_sanity_check_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_sanity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status sanity', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_sanity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- # name: test_sensor_platform[sensor.paperless_ngx_tags-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -305,3 +711,55 @@ 'state': '999', }) # --- +# name: test_sensor_platform[sensor.paperless_ngx_total_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_total_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total storage', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_total', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_storage_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_total_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Paperless-ngx Total storage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_total_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62.1', + }) +# --- diff --git a/tests/components/paperless_ngx/test_init.py b/tests/components/paperless_ngx/test_init.py index 9a132cf7eff..fd459213ea0 100644 --- a/tests/components/paperless_ngx/test_init.py +++ b/tests/components/paperless_ngx/test_init.py @@ -34,10 +34,28 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_load_config_status_forbidden( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_paperless: AsyncMock, +) -> None: + """Test loading and unloading the integration.""" + mock_paperless.status.side_effect = PaperlessForbiddenError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + @pytest.mark.parametrize( ("side_effect", "expected_state", "expected_error_key"), [ - (PaperlessConnectionError(), ConfigEntryState.SETUP_RETRY, None), + (PaperlessConnectionError(), ConfigEntryState.SETUP_RETRY, "cannot_connect"), (PaperlessInvalidTokenError(), ConfigEntryState.SETUP_ERROR, "invalid_api_key"), ( PaperlessInactiveOrDeletedError(), diff --git a/tests/components/paperless_ngx/test_sensor.py b/tests/components/paperless_ngx/test_sensor.py index 33610d9b6d6..d2233a64ee2 100644 --- a/tests/components/paperless_ngx/test_sensor.py +++ b/tests/components/paperless_ngx/test_sensor.py @@ -1,7 +1,5 @@ """Tests for Paperless-ngx sensor platform.""" -from datetime import timedelta - from freezegun.api import FrozenDateTimeFactory from pypaperless.exceptions import ( PaperlessConnectionError, @@ -12,7 +10,9 @@ from pypaperless.exceptions import ( from pypaperless.models import Statistic import pytest -from homeassistant.components.paperless_ngx.coordinator import UPDATE_INTERVAL +from homeassistant.components.paperless_ngx.coordinator import ( + UPDATE_INTERVAL_STATISTICS, +) from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -61,7 +61,7 @@ async def test_statistic_sensor_state( ) ) - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(UPDATE_INTERVAL_STATISTICS) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -91,7 +91,7 @@ async def test__statistic_sensor_state_on_error( # simulate error mock_paperless.statistics.side_effect = error_cls - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(UPDATE_INTERVAL_STATISTICS) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -105,7 +105,7 @@ async def test__statistic_sensor_state_on_error( ) ) - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(UPDATE_INTERVAL_STATISTICS) async_fire_time_changed(hass) await hass.async_block_till_done()