Add sensor platform to backup integration (#138663)

* add sensor platform to backup integration

* adjust namings, remove system integration flag

* add first simple test

* apply review comments

* fix test

* add sensor tests

* adjustements to use backup helper

* remove obsolet async_get_manager from init

* unsubscribe from events on entry unload

* add configuration_url

* fix doc string

* fix sensor tests

* mark async_unsubscribe as callback

* set integration_type service

* extend sensor test

* set integration_type on correct integration :)

* fix after online conflict resolution

* add sensor update tests

* simplify the sensor update tests

* avoid io during tests

* Add comment

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Michael 2025-03-24 12:54:16 +01:00 committed by GitHub
parent 265a2ace90
commit e96e95c32d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 607 additions and 4 deletions

View File

@ -1,7 +1,9 @@
"""The Backup integration."""
from homeassistant.config_entries import SOURCE_SYSTEM
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
@ -18,10 +20,12 @@ from .agent import (
)
from .config import BackupConfig, CreateBackupParametersDict
from .const import DATA_MANAGER, DOMAIN
from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator
from .http import async_register_http_views
from .manager import (
BackupManager,
BackupManagerError,
BackupPlatformEvent,
BackupPlatformProtocol,
BackupReaderWriter,
BackupReaderWriterError,
@ -52,6 +56,7 @@ __all__ = [
"BackupConfig",
"BackupManagerError",
"BackupNotFound",
"BackupPlatformEvent",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
@ -74,6 +79,8 @@ __all__ = [
"suggested_filename_from_name_date",
]
PLATFORMS = [Platform.SENSOR]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@ -128,4 +135,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_http_views(hass)
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
"""Set up a config entry."""
backup_manager: BackupManager = hass.data[DATA_MANAGER]
coordinator = BackupDataUpdateCoordinator(hass, entry, backup_manager)
await coordinator.async_config_entry_first_refresh()
entry.async_on_unload(coordinator.async_unsubscribe)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,21 @@
"""Config flow for Home Assistant Backup integration."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class BackupConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Home Assistant Backup."""
VERSION = 1
async def async_step_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_create_entry(title="Backup", data={})

View File

@ -0,0 +1,81 @@
"""Coordinator for Home Assistant Backup integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.backup import (
async_subscribe_events,
async_subscribe_platform_events,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, LOGGER
from .manager import (
BackupManager,
BackupManagerState,
BackupPlatformEvent,
ManagerStateEvent,
)
type BackupConfigEntry = ConfigEntry[BackupDataUpdateCoordinator]
@dataclass
class BackupCoordinatorData:
"""Class to hold backup data."""
backup_manager_state: BackupManagerState
last_successful_automatic_backup: datetime | None
next_scheduled_automatic_backup: datetime | None
class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
"""Class to retrieve backup status."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
backup_manager: BackupManager,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=None,
)
self.unsubscribe: list[Callable[[], None]] = [
async_subscribe_events(hass, self._on_event),
async_subscribe_platform_events(hass, self._on_event),
]
self.backup_manager = backup_manager
@callback
def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None:
"""Handle new event."""
LOGGER.debug("Received backup event: %s", event)
self.config_entry.async_create_task(self.hass, self.async_refresh())
async def _async_update_data(self) -> BackupCoordinatorData:
"""Update backup manager data."""
return BackupCoordinatorData(
self.backup_manager.state,
self.backup_manager.config.data.last_completed_automatic_backup,
self.backup_manager.config.data.schedule.next_automatic_backup,
)
@callback
def async_unsubscribe(self) -> None:
"""Unsubscribe from events."""
for unsub in self.unsubscribe:
unsub()

View File

@ -0,0 +1,36 @@
"""Base for backup entities."""
from __future__ import annotations
from homeassistant.const import __version__ as HA_VERSION
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BackupDataUpdateCoordinator
class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
"""Base entity for backup manager."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BackupDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = entity_description.key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "backup_manager")},
manufacturer="Home Assistant",
model="Home Assistant Backup",
sw_version=HA_VERSION,
name="Backup",
entry_type=DeviceEntryType.SERVICE,
configuration_url="homeassistant://config/backup",
)

View File

@ -229,6 +229,13 @@ class RestoreBackupEvent(ManagerStateEvent):
state: RestoreBackupState
@dataclass(frozen=True, kw_only=True, slots=True)
class BackupPlatformEvent:
"""Backup platform class."""
domain: str
@dataclass(frozen=True, kw_only=True, slots=True)
class BlockedEvent(ManagerStateEvent):
"""Backup manager blocked, Home Assistant is starting."""
@ -355,6 +362,9 @@ class BackupManager:
self._backup_event_subscriptions = hass.data[
DATA_BACKUP
].backup_event_subscriptions
self._backup_platform_event_subscriptions = hass.data[
DATA_BACKUP
].backup_platform_event_subscriptions
async def async_setup(self) -> None:
"""Set up the backup manager."""
@ -465,6 +475,9 @@ class BackupManager:
LOGGER.debug("%s platforms loaded in total", len(self.platforms))
LOGGER.debug("%s agents loaded in total", len(self.backup_agents))
LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents))
event = BackupPlatformEvent(domain=integration_domain)
for subscription in self._backup_platform_event_subscriptions:
subscription(event)
async def async_pre_backup_actions(self) -> None:
"""Perform pre backup actions."""

View File

@ -5,8 +5,9 @@
"codeowners": ["@home-assistant/core"],
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/backup",
"integration_type": "system",
"integration_type": "service",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.6", "securetar==2025.2.1"]
"requirements": ["cronsim==2.6", "securetar==2025.2.1"],
"single_config_entry": true
}

View File

@ -0,0 +1,75 @@
"""Sensor platform for Home Assistant Backup integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import BackupConfigEntry, BackupCoordinatorData
from .entity import BackupManagerEntity
from .manager import BackupManagerState
@dataclass(kw_only=True, frozen=True)
class BackupSensorEntityDescription(SensorEntityDescription):
"""Description for Home Assistant Backup sensor entities."""
value_fn: Callable[[BackupCoordinatorData], str | datetime | None]
BACKUP_MANAGER_DESCRIPTIONS = (
BackupSensorEntityDescription(
key="backup_manager_state",
translation_key="backup_manager_state",
device_class=SensorDeviceClass.ENUM,
options=[state.value for state in BackupManagerState],
value_fn=lambda data: data.backup_manager_state,
),
BackupSensorEntityDescription(
key="next_scheduled_automatic_backup",
translation_key="next_scheduled_automatic_backup",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.next_scheduled_automatic_backup,
),
BackupSensorEntityDescription(
key="last_successful_automatic_backup",
translation_key="last_successful_automatic_backup",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.last_successful_automatic_backup,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BackupConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Sensor set up for backup config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
BackupManagerSensor(coordinator, description)
for description in BACKUP_MANAGER_DESCRIPTIONS
)
class BackupManagerSensor(BackupManagerEntity, SensorEntity):
"""Sensor to track backup manager state."""
entity_description: BackupSensorEntityDescription
@property
def native_value(self) -> str | datetime | None:
"""Return native value of entity."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -22,5 +22,24 @@
"name": "Create automatic backup",
"description": "Creates a new backup with automatic backup settings."
}
},
"entity": {
"sensor": {
"backup_manager_state": {
"name": "Backup Manager State",
"state": {
"idle": "Idle",
"create_backup": "Creating a backup",
"receive_backup": "Receiving a backup",
"restore_backup": "Restoring a backup"
}
},
"next_scheduled_automatic_backup": {
"name": "Next scheduled automatic backup"
},
"last_successful_automatic_backup": {
"name": "Last successful automatic backup"
}
}
}
}

View File

@ -611,6 +611,13 @@
"config_flow": true,
"iot_class": "local_push"
},
"backup": {
"name": "Backup",
"integration_type": "service",
"config_flow": false,
"iot_class": "calculated",
"single_config_entry": true
},
"baf": {
"name": "Big Ass Fans",
"integration_type": "hub",

View File

@ -12,7 +12,11 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from homeassistant.components.backup import BackupManager, ManagerStateEvent
from homeassistant.components.backup import (
BackupManager,
BackupPlatformEvent,
ManagerStateEvent,
)
DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data")
DATA_MANAGER: HassKey[BackupManager] = HassKey("backup")
@ -25,6 +29,9 @@ class BackupData:
backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field(
default_factory=list
)
backup_platform_event_subscriptions: list[Callable[[BackupPlatformEvent], None]] = (
field(default_factory=list)
)
manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future)
@ -68,3 +75,20 @@ def async_subscribe_events(
backup_event_subscriptions.append(on_event)
return remove_subscription
@callback
def async_subscribe_platform_events(
hass: HomeAssistant,
on_event: Callable[[BackupPlatformEvent], None],
) -> Callable[[], None]:
"""Subscribe to backup platform events."""
backup_platform_event_subscriptions = hass.data[
DATA_BACKUP
].backup_platform_event_subscriptions
def remove_subscription() -> None:
backup_platform_event_subscriptions.remove(on_event)
backup_platform_event_subscriptions.append(on_event)
return remove_subscription

View File

@ -0,0 +1,160 @@
# serializer version: 1
# name: test_sensors[sensor.backup_backup_manager_state-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'idle',
'create_backup',
'blocked',
'receive_backup',
'restore_backup',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.backup_backup_manager_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Backup Manager State',
'platform': 'backup',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'backup_manager_state',
'unique_id': 'backup_manager_state',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.backup_backup_manager_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Backup Backup Manager State',
'options': list([
'idle',
'create_backup',
'blocked',
'receive_backup',
'restore_backup',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.backup_backup_manager_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'idle',
})
# ---
# name: test_sensors[sensor.backup_last_successful_automatic_backup-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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.backup_last_successful_automatic_backup',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last successful automatic backup',
'platform': 'backup',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'last_successful_automatic_backup',
'unique_id': 'last_successful_automatic_backup',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.backup_last_successful_automatic_backup-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Backup Last successful automatic backup',
}),
'context': <ANY>,
'entity_id': 'sensor.backup_last_successful_automatic_backup',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.backup_next_scheduled_automatic_backup-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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.backup_next_scheduled_automatic_backup',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Next scheduled automatic backup',
'platform': 'backup',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'next_scheduled_automatic_backup',
'unique_id': 'next_scheduled_automatic_backup',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.backup_next_scheduled_automatic_backup-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Backup Next scheduled automatic backup',
}),
'context': <ANY>,
'entity_id': 'sensor.backup_next_scheduled_automatic_backup',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -6,11 +6,13 @@ from unittest.mock import patch
import pytest
from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotFound
from .common import setup_backup_integration
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
@ -141,3 +143,17 @@ async def test_create_automatic_service(
)
generate_backup.assert_called_once_with(**expected_kwargs)
async def test_setup_entry(
hass: HomeAssistant,
) -> None:
"""Test setup backup config entry."""
await setup_backup_integration(hass, with_hassio=False)
entry = MockConfigEntry(domain=DOMAIN, source=SOURCE_SYSTEM)
entry.add_to_hass(hass)
with patch("homeassistant.components.backup.PLATFORMS", return_value=[]):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED

View File

@ -0,0 +1,119 @@
"""Tests for the sensors of the Backup integration."""
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.backup import store
from homeassistant.components.backup.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .common import setup_backup_integration
from tests.common import async_fire_time_changed, snapshot_platform
from tests.typing import WebSocketGenerator
@pytest.mark.usefixtures("mock_backup_generation")
async def test_sensors(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test setup of backup sensors."""
with patch("homeassistant.components.backup.PLATFORMS", [Platform.SENSOR]):
await setup_backup_integration(hass, with_hassio=False)
await hass.async_block_till_done(wait_background_tasks=True)
entry = hass.config_entries.async_entries(DOMAIN)[0]
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
# start backup and check sensor states again
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["backup.local"]}
)
assert await client.receive_json()
state = hass.states.get("sensor.backup_backup_manager_state")
assert state.state == "create_backup"
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.backup_backup_manager_state")
assert state.state == "idle"
async def test_sensor_updates(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
hass_storage: dict[str, Any],
create_backup: AsyncMock,
) -> None:
"""Test update of backup sensors."""
# Ensure created backup is already protected,
# to avoid manager creating a new EncryptedBackupStreamer
# instead of using the already mocked stream writer.
created_backup: MagicMock = create_backup.return_value[1].result().backup
created_backup.protected = True
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to("2024-11-12T12:00:00+01:00")
storage_data = {
"backups": [],
"config": {
"agents": {},
"automatic_backups_configured": True,
"create_backup": {
"agent_ids": ["test.remote"],
"include_addons": [],
"include_all_addons": False,
"include_database": True,
"include_folders": [],
"name": "test-name",
"password": "test-password",
},
"retention": {"copies": None, "days": None},
"last_attempted_automatic_backup": "2024-11-11T04:45:00+01:00",
"last_completed_automatic_backup": "2024-11-11T04:45:00+01:00",
"schedule": {
"days": [],
"recurrence": "daily",
"state": "never",
"time": "06:00",
},
},
}
hass_storage[DOMAIN] = {
"data": storage_data,
"key": DOMAIN,
"version": store.STORAGE_VERSION,
"minor_version": store.STORAGE_VERSION_MINOR,
}
with patch("homeassistant.components.backup.PLATFORMS", [Platform.SENSOR]):
await setup_backup_integration(
hass, with_hassio=False, remote_agents=["test.remote"]
)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.backup_last_successful_automatic_backup")
assert state.state == "2024-11-11T03:45:00+00:00"
state = hass.states.get("sensor.backup_next_scheduled_automatic_backup")
assert state.state == "2024-11-13T05:00:00+00:00"
freezer.move_to("2024-11-13T12:00:00+01:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.backup_last_successful_automatic_backup")
assert state.state == "2024-11-13T11:00:00+00:00"
state = hass.states.get("sensor.backup_next_scheduled_automatic_backup")
assert state.state == "2024-11-14T05:00:00+00:00"