diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 613938f078f..a9f66e4762e 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -6,7 +6,10 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import cast -from synology_dsm.api.core.external_usb import SynoCoreExternalUSB +from synology_dsm.api.core.external_usb import ( + SynoCoreExternalUSB, + SynoCoreExternalUSBDevice, +) from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.storage.storage import SynoStorage @@ -343,14 +346,42 @@ async def async_setup_entry( coordinator = data.coordinator_central storage = api.storage assert storage is not None - external_usb = api.external_usb + known_usb_devices: set[str] = set() - entities: list[ - SynoDSMUtilSensor - | SynoDSMStorageSensor - | SynoDSMInfoSensor - | SynoDSMExternalUSBSensor - ] = [ + def _check_usb_devices() -> None: + """Check for new USB devices during and after initial setup.""" + if api.external_usb is not None and api.external_usb.get_devices: + current_usb_devices: set[str] = { + device.device_name for device in api.external_usb.get_devices.values() + } + new_usb_devices = current_usb_devices - known_usb_devices + if new_usb_devices: + known_usb_devices.update(new_usb_devices) + external_devices: list[SynoCoreExternalUSBDevice] = [ + device + for device in api.external_usb.get_devices.values() + if device.device_name in new_usb_devices + ] + new_usb_entities: list[SynoDSMExternalUSBSensor] = [ + SynoDSMExternalUSBSensor( + api, coordinator, description, device.device_name + ) + for device in entry.data.get(CONF_DEVICES, external_devices) + for description in EXTERNAL_USB_DISK_SENSORS + ] + new_usb_entities.extend( + [ + SynoDSMExternalUSBSensor( + api, coordinator, description, partition.partition_title + ) + for device in entry.data.get(CONF_DEVICES, external_devices) + for partition in device.device_partitions.values() + for description in EXTERNAL_USB_PARTITION_SENSORS + ] + ) + async_add_entities(new_usb_entities) + + entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ SynoDSMUtilSensor(api, coordinator, description) for description in UTILISATION_SENSORS ] @@ -375,32 +406,6 @@ async def async_setup_entry( ] ) - # Handle all external usb - if external_usb is not None and external_usb.get_devices: - entities.extend( - [ - SynoDSMExternalUSBSensor( - api, coordinator, description, device.device_name - ) - for device in entry.data.get( - CONF_DEVICES, external_usb.get_devices.values() - ) - for description in EXTERNAL_USB_DISK_SENSORS - ] - ) - entities.extend( - [ - SynoDSMExternalUSBSensor( - api, coordinator, description, partition.partition_title - ) - for device in entry.data.get( - CONF_DEVICES, external_usb.get_devices.values() - ) - for partition in device.device_partitions.values() - for description in EXTERNAL_USB_PARTITION_SENSORS - ] - ) - entities.extend( [ SynoDSMInfoSensor(api, coordinator, description) @@ -408,6 +413,9 @@ async def async_setup_entry( ] ) + _check_usb_devices() + entry.async_on_unload(coordinator.async_add_listener(_check_usb_devices)) + async_add_entities(entities) diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py index 3b069d04ebe..a9d05ce941e 100644 --- a/tests/components/synology_dsm/common.py +++ b/tests/components/synology_dsm/common.py @@ -5,6 +5,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock from awesomeversion import AwesomeVersion +from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice +from synology_dsm.api.storage.storage import SynoStorageDisk, SynoStorageVolume from .consts import SERIAL @@ -30,3 +32,168 @@ def mock_dsm_information( temperature=temperature, uptime=uptime, ) + + +def mock_dsm_storage_get_volume(volume_id: str) -> SynoStorageVolume: + """Mock SynologyDSM storage volume information for a specific volume.""" + volumes = mock_dsm_storage_volumes() + for volume in volumes: + if volume.get("id") == volume_id: + return volume + raise ValueError(f"Volume with id {volume_id} not found in mock data.") + + +def mock_dsm_storage_volumes() -> list[SynoStorageVolume]: + """Mock SynologyDSM storage volume information.""" + volumes_data = { + "volume_1": { + "id": "volume_1", + "device_type": "btrfs", + "size": { + "free_inode": "1000000", + "total": "24000277250048", + "total_device": "24000277250048", + "total_inode": "2000000", + "used": "12000138625024", + }, + "status": "normal", + "fs_type": "btrfs", + }, + } + return [SynoStorageVolume(**volume_info) for volume_info in volumes_data.values()] + + +def mock_dsm_storage_get_disk(disk_id: str) -> SynoStorageDisk: + """Mock SynologyDSM storage disk information for a specific disk.""" + disks = mock_dsm_storage_disks() + for disk in disks: + if disk.get("id") == disk_id: + return disk + raise ValueError(f"Disk with id {disk_id} not found in mock data.") + + +def mock_dsm_storage_disks() -> list[SynoStorageDisk]: + """Mock SynologyDSM storage disk information.""" + disks_data = { + "sata1": { + "id": "sata1", + "name": "Drive 1", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata1", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + "sata2": { + "id": "sata2", + "name": "Drive 2", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata2", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + "sata3": { + "id": "sata3", + "name": "Drive 3", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata3", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + } + return [SynoStorageDisk(**disk_info) for disk_info in disks_data.values()] + + +def mock_dsm_external_usb_devices_usb1() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with USB Disk 1.""" + return { + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + } + + +def mock_dsm_external_usb_devices_usb2() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with USB Disk 1 and USB Disk 2.""" + return { + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + "usb2": SynoCoreExternalUSBDevice( + { + "dev_id": "usb2", + "dev_type": "usbDisk", + "dev_title": "USB Disk 2", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb2p1", + "partition_title": "USB Disk 2 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + } diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py index 654cade2462..a02728dcc4c 100644 --- a/tests/components/synology_dsm/test_sensor.py +++ b/tests/components/synology_dsm/test_sensor.py @@ -1,9 +1,9 @@ """Tests for Synology DSM USB.""" +from itertools import chain from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice from homeassistant.components.synology_dsm.const import DOMAIN from homeassistant.const import ( @@ -17,7 +17,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import mock_dsm_information +from .common import ( + mock_dsm_external_usb_devices_usb1, + mock_dsm_external_usb_devices_usb2, + mock_dsm_information, + mock_dsm_storage_get_disk, + mock_dsm_storage_get_volume, +) from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry @@ -31,70 +37,33 @@ def mock_dsm_with_usb(): dsm.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) - dsm.upgrade.update = AsyncMock(return_value=True) + dsm.upgrade = Mock( + available_version=None, + available_version_details=None, + update=AsyncMock(return_value=True), + ) dsm.network = Mock( update=AsyncMock(return_value=True), macs=MACS, hostname=HOST ) dsm.information = mock_dsm_information() + dsm.storage = Mock( + get_disk=mock_dsm_storage_get_disk, + disk_temp=Mock(return_value=32), + disks_ids=["sata1", "sata2", "sata3"], + get_volume=mock_dsm_storage_get_volume, + volume_disk_temp_avg=Mock(return_value=32), + volume_size_used=Mock(return_value=12000138625024), + volume_percentage_used=Mock(return_value=38), + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) dsm.external_usb = Mock( update=AsyncMock(return_value=True), - get_device=Mock( - return_value=SynoCoreExternalUSBDevice( - { - "dev_id": "usb1", - "dev_type": "usbDisk", - "dev_title": "USB Disk 1", - "producer": "Western Digital Technologies, Inc.", - "product": "easystore 264D", - "formatable": True, - "progress": "", - "status": "normal", - "total_size_mb": 15259648, - "partitions": [ - { - "dev_fstype": "ntfs", - "filesystem": "ntfs", - "name_id": "usb1p1", - "partition_title": "USB Disk 1 Partition 1", - "share_name": "usbshare1", - "status": "normal", - "total_size_mb": 15259646, - "used_size_mb": 5942441, - } - ], - } - ) - ), - get_devices={ - "usb1": SynoCoreExternalUSBDevice( - { - "dev_id": "usb1", - "dev_type": "usbDisk", - "dev_title": "USB Disk 1", - "producer": "Western Digital Technologies, Inc.", - "product": "easystore 264D", - "formatable": True, - "progress": "", - "status": "normal", - "total_size_mb": 15259648, - "partitions": [ - { - "dev_fstype": "ntfs", - "filesystem": "ntfs", - "name_id": "usb1p1", - "partition_title": "USB Disk 1 Partition 1", - "share_name": "usbshare1", - "status": "normal", - "total_size_mb": 15259646, - "used_size_mb": 5942441, - } - ], - } - ) - }, + get_devices=mock_dsm_external_usb_devices_usb1(), ) dsm.logout = AsyncMock(return_value=True) + dsm.mock_entry = MockConfigEntry() yield dsm @@ -142,6 +111,8 @@ async def setup_dsm_with_usb( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + mock_dsm_with_usb.mock_entry = entry + yield mock_dsm_with_usb @@ -233,6 +204,84 @@ async def test_external_usb( assert sensor.attributes["attribution"] == "Data provided by Synology" +async def test_external_usb_new_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB adding new device.""" + + expected_sensors_disk_1 = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "14901.998046875", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "5803.1650390625", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "38.9", + {}, + ), + } + expected_sensors_disk_2 = { + "sensor.nas_meontheinternet_com_usb_disk_2_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_size": ( + "14901.998046875", + { + "device_class": "data_size", + "state_class": "measurement", + "unit_of_measurement": "GiB", + "attribution": "Data provided by Synology", + }, + ), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_used_space": ( + "5803.1650390625", + { + "device_class": "data_size", + "state_class": "measurement", + "unit_of_measurement": "GiB", + "attribution": "Data provided by Synology", + }, + ), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_used": ( + "38.9", + { + "state_class": "measurement", + "unit_of_measurement": "%", + "attribution": "Data provided by Synology", + }, + ), + } + + # Initial check of existing sensors + for sensor_id, (expected_state, expected_attrs) in expected_sensors_disk_1.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + for sensor_id in expected_sensors_disk_2: + assert hass.states.get(sensor_id) is None + + # Mock the get_devices method to simulate a USB disk being added + setup_dsm_with_usb.external_usb.get_devices = mock_dsm_external_usb_devices_usb2() + # Coordinator refresh + await setup_dsm_with_usb.mock_entry.runtime_data.coordinator_central.async_request_refresh() + await hass.async_block_till_done() + + for sensor_id, (expected_state, expected_attrs) in chain( + expected_sensors_disk_1.items(), expected_sensors_disk_2.items() + ): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + async def test_no_external_usb( hass: HomeAssistant, setup_dsm_without_usb: MagicMock,