diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 2e80624ca5d..8b4cf655388 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -9,6 +9,7 @@ import logging from awesomeversion import AwesomeVersion from synology_dsm import SynologyDSM +from synology_dsm.api.core.external_usb import SynoCoreExternalUSB from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.system import SynoCoreSystem from synology_dsm.api.core.upgrade import SynoCoreUpgrade @@ -78,6 +79,7 @@ class SynoApi: self.system: SynoCoreSystem | None = None self.upgrade: SynoCoreUpgrade | None = None self.utilisation: SynoCoreUtilization | None = None + self.external_usb: SynoCoreExternalUSB | None = None # Should we fetch them self._fetching_entities: dict[str, set[str]] = {} @@ -90,6 +92,7 @@ class SynoApi: self._with_system = True self._with_upgrade = True self._with_utilisation = True + self._with_external_usb = True self._login_future: asyncio.Future[None] | None = None @@ -261,6 +264,9 @@ class SynoApi: self._with_information = bool( self._fetching_entities.get(SynoDSMInformation.API_KEY) ) + self._with_external_usb = bool( + self._fetching_entities.get(SynoCoreExternalUSB.API_KEY) + ) # Reset not used API, information is not reset since it's used in device_info if not self._with_security: @@ -322,6 +328,15 @@ class SynoApi: self.dsm.reset(self.utilisation) self.utilisation = None + if not self._with_external_usb: + LOGGER.debug( + "Disable external usb api from being updated for '%s'", + self._entry.unique_id, + ) + if self.external_usb: + self.dsm.reset(self.external_usb) + self.external_usb = None + async def _fetch_device_configuration(self) -> None: """Fetch initial device config.""" self.network = self.dsm.network @@ -366,6 +381,12 @@ class SynoApi: ) self.surveillance_station = self.dsm.surveillance_station + if self._with_external_usb: + LOGGER.debug( + "Enable external usb api updates for '%s'", self._entry.unique_id + ) + self.external_usb = self.dsm.external_usb + async def _syno_api_executer(self, api_call: Callable) -> None: """Synology api call wrapper.""" try: diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index a673be23096..5cba9ed5aac 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -32,6 +32,7 @@ async def async_get_config_entry_diagnostics( "uptime": dsm_info.uptime, "temperature": dsm_info.temperature, }, + "external_usb": {"devices": {}, "partitions": {}}, "network": {"interfaces": {}}, "storage": {"disks": {}, "volumes": {}}, "surveillance_station": {"cameras": {}, "camera_diagnostics": {}}, @@ -43,6 +44,27 @@ async def async_get_config_entry_diagnostics( }, } + if syno_api.external_usb is not None: + for device in syno_api.external_usb.get_devices.values(): + if device is not None: + diag_data["external_usb"]["devices"][device.device_id] = { + "name": device.device_name, + "manufacturer": device.device_manufacturer, + "model": device.device_product_name, + "type": device.device_type, + "status": device.device_status, + "size_total": device.device_size_total(False), + } + for partition in device.device_partitions.values(): + if partition is not None: + diag_data["external_usb"]["partitions"][partition.name_id] = { + "name": partition.partition_title, + "filesystem": partition.filesystem, + "share_name": partition.share_name, + "size_used": partition.partition_size_used(False), + "size_total": partition.partition_size_total(False), + } + if syno_api.network is not None: for intf in syno_api.network.interfaces: diag_data["network"]["interfaces"][intf["id"]] = { diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index d8800282c21..85269b9c480 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -93,6 +93,7 @@ class SynologyDSMDeviceEntity( storage = api.storage information = api.information network = api.network + external_usb = api.external_usb assert information is not None assert storage is not None assert network is not None @@ -121,6 +122,26 @@ class SynologyDSMDeviceEntity( self._device_model = disk["model"].strip() self._device_firmware = disk["firm"] self._device_type = disk["diskType"] + elif "device" in description.key: + assert self._device_id is not None + assert external_usb is not None + for device in external_usb.get_devices.values(): + if device.device_name == self._device_id: + self._device_name = device.device_name + self._device_manufacturer = device.device_manufacturer + self._device_model = device.device_product_name + self._device_type = device.device_type + break + elif "partition" in description.key: + assert self._device_id is not None + assert external_usb is not None + for device in external_usb.get_devices.values(): + for partition in device.device_partitions.values(): + if partition.partition_title == self._device_id: + self._device_name = partition.partition_title + self._device_manufacturer = "Synology" + self._device_model = partition.filesystem + break self._attr_unique_id += f"_{self._device_id}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/synology_dsm/icons.json b/homeassistant/components/synology_dsm/icons.json index 3c4d028dc7a..cc3f42a33fd 100644 --- a/homeassistant/components/synology_dsm/icons.json +++ b/homeassistant/components/synology_dsm/icons.json @@ -22,6 +22,12 @@ "cpu_15min_load": { "default": "mdi:chip" }, + "device_size_total": { + "default": "mdi:chart-pie" + }, + "device_status": { + "default": "mdi:checkbox-marked-circle-outline" + }, "memory_real_usage": { "default": "mdi:memory" }, @@ -49,6 +55,15 @@ "network_down": { "default": "mdi:download" }, + "partition_percentage_used": { + "default": "mdi:chart-pie" + }, + "partition_size_total": { + "default": "mdi:chart-pie" + }, + "partition_size_used": { + "default": "mdi:chart-pie" + }, "volume_status": { "default": "mdi:checkbox-marked-circle-outline", "state": { diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 566885e3989..613938f078f 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -6,6 +6,7 @@ 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.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.storage.storage import SynoStorage @@ -17,6 +18,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONF_DEVICES, CONF_DISKS, PERCENTAGE, EntityCategory, @@ -261,6 +263,53 @@ STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), ) +EXTERNAL_USB_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="device_status", + translation_key="device_status", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="device_size_total", + translation_key="device_size_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), +) +EXTERNAL_USB_PARTITION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_size_total", + translation_key="partition_size_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_size_used", + translation_key="partition_size_used", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_percentage_used", + translation_key="partition_percentage_used", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +) INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( @@ -294,8 +343,14 @@ async def async_setup_entry( coordinator = data.coordinator_central storage = api.storage assert storage is not None + external_usb = api.external_usb - entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ + entities: list[ + SynoDSMUtilSensor + | SynoDSMStorageSensor + | SynoDSMInfoSensor + | SynoDSMExternalUSBSensor + ] = [ SynoDSMUtilSensor(api, coordinator, description) for description in UTILISATION_SENSORS ] @@ -320,6 +375,32 @@ 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) @@ -396,6 +477,45 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): ) +class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor): + """Representation a Synology Storage sensor.""" + + entity_description: SynologyDSMSensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: SynologyDSMCentralUpdateCoordinator, + description: SynologyDSMSensorEntityDescription, + device_id: str | None = None, + ) -> None: + """Initialize the Synology DSM external usb sensor entity.""" + super().__init__(api, coordinator, description, device_id) + + @property + def native_value(self) -> StateType: + """Return the state.""" + external_usb = self._api.external_usb + assert external_usb is not None + if "device" in self.entity_description.key: + for device in external_usb.get_devices.values(): + if device.device_name == self._device_id: + attr = getattr(device, self.entity_description.key) + break + elif "partition" in self.entity_description.key: + for device in external_usb.get_devices.values(): + for partition in device.device_partitions.values(): + if partition.partition_title == self._device_id: + attr = getattr(partition, self.entity_description.key) + break + if callable(attr): + attr = attr() + if attr is None: + return None + + return attr # type: ignore[no-any-return] + + class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index e4da480d67f..2589f04959c 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -113,6 +113,12 @@ "cpu_user_load": { "name": "CPU utilization (user)" }, + "device_size_total": { + "name": "Device size" + }, + "device_status": { + "name": "Status" + }, "disk_smart_status": { "name": "Status (smart)" }, @@ -149,6 +155,15 @@ "network_up": { "name": "Upload throughput" }, + "partition_percentage_used": { + "name": "Partition used" + }, + "partition_size_total": { + "name": "Partition size" + }, + "partition_size_used": { + "name": "Partition used space" + }, "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py index e98b0d21d66..3b069d04ebe 100644 --- a/tests/components/synology_dsm/common.py +++ b/tests/components/synology_dsm/common.py @@ -12,11 +12,21 @@ from .consts import SERIAL def mock_dsm_information( serial: str | None = SERIAL, update_result: bool = True, - awesome_version: str = "7.2", + awesome_version: str = "7.2.2", + model: str = "DS1821+", + version_string: str = "DSM 7.2.2-72806 Update 3", + ram: int = 32768, + temperature: int = 58, + uptime: int = 123456, ) -> Mock: """Mock SynologyDSM information.""" return Mock( serial=serial, update=AsyncMock(return_value=update_result), awesome_version=AwesomeVersion(awesome_version), + model=model, + version_string=version_string, + ram=ram, + temperature=temperature, + uptime=uptime, ) diff --git a/tests/components/synology_dsm/snapshots/test_diagnostics.ambr b/tests/components/synology_dsm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..cd8b1be42b2 --- /dev/null +++ b/tests/components/synology_dsm/snapshots/test_diagnostics.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'model': 'DS1821+', + 'ram': 32768, + 'temperature': 58, + 'uptime': 123456, + 'version': 'DSM 7.2.2-72806 Update 3', + }), + 'entry': dict({ + 'data': dict({ + 'host': 'nas.meontheinternet.com', + 'mac': '00-11-32-XX-XX-59', + 'password': '**REDACTED**', + 'port': 1234, + 'ssl': True, + 'username': '**REDACTED**', + 'verify_ssl': False, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'synology_dsm', + 'minor_version': 1, + 'options': dict({ + 'backup_path': None, + 'backup_share': None, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'mySerial', + 'version': 1, + }), + 'external_usb': dict({ + 'devices': dict({ + 'usb1': dict({ + 'manufacturer': 'Western Digital Technologies, Inc.', + 'model': 'easystore 264D', + 'name': 'USB Disk 1', + 'size_total': 16000900661248, + 'status': 'normal', + 'type': 'usbDisk', + }), + }), + 'partitions': dict({ + 'usb1p1': dict({ + 'filesystem': 'ntfs', + 'name': 'USB Disk 1 Partition 1', + 'share_name': 'usbshare1', + 'size_total': 16000898564096, + 'size_used': 6231101014016, + }), + }), + }), + 'is_system_loaded': True, + 'network': dict({ + 'interfaces': dict({ + 'ovs_eth0': dict({ + 'ip': list([ + dict({ + 'address': '127.0.0.1', + 'netmask': '255.255.255.0', + }), + ]), + 'type': 'ovseth', + }), + }), + }), + 'storage': dict({ + 'disks': dict({ + }), + 'volumes': dict({ + }), + }), + 'surveillance_station': dict({ + 'camera_diagnostics': dict({ + }), + 'cameras': dict({ + }), + }), + 'upgrade': dict({ + 'available_version': None, + 'reboot_needed': None, + 'service_restarts': None, + 'update_available': False, + }), + 'utilisation': dict({ + 'cpu': dict({ + '15min_load': 461, + '1min_load': 410, + '5min_load': 404, + 'device': 'System', + 'other_load': 5, + 'system_load': 11, + 'user_load': 11, + }), + 'memory': dict({ + 'avail_real': 463628, + 'avail_swap': 0, + 'buffer': 10556600, + 'cached': 5297776, + 'device': 'Memory', + 'memory_size': 33554432, + 'real_usage': 50, + 'si_disk': 0, + 'so_disk': 0, + 'swap_usage': 100, + 'total_real': 32841680, + 'total_swap': 2097084, + }), + 'network': list([ + dict({ + 'device': 'total', + 'rx': 1065612, + 'tx': 36311, + }), + dict({ + 'device': 'eth0', + 'rx': 1065612, + 'tx': 36311, + }), + ]), + }), + }) +# --- diff --git a/tests/components/synology_dsm/test_diagnostics.py b/tests/components/synology_dsm/test_diagnostics.py new file mode 100644 index 00000000000..f2bb35f488d --- /dev/null +++ b/tests/components/synology_dsm/test_diagnostics.py @@ -0,0 +1,199 @@ +"""Test Synology DSM diagnostics.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice +from synology_dsm.api.dsm.network import NetworkInterface +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.synology_dsm.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture +def mock_dsm_with_usb(): + """Mock a successful service with USB support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade = Mock( + update_available=False, + available_version=None, + reboot_needed=None, + service_restarts=None, + update=AsyncMock(return_value=True), + ) + dsm.utilisation = Mock( + cpu={ + "15min_load": 461, + "1min_load": 410, + "5min_load": 404, + "device": "System", + "other_load": 5, + "system_load": 11, + "user_load": 11, + }, + memory={ + "avail_real": 463628, + "avail_swap": 0, + "buffer": 10556600, + "cached": 5297776, + "device": "Memory", + "memory_size": 33554432, + "real_usage": 50, + "si_disk": 0, + "so_disk": 0, + "swap_usage": 100, + "total_real": 32841680, + "total_swap": 2097084, + }, + network=[ + {"device": "total", "rx": 1065612, "tx": 36311}, + {"device": "eth0", "rx": 1065612, "tx": 36311}, + ], + memory_available_swap=Mock(return_value=0), + memory_available_real=Mock(return_value=463628), + memory_total_swap=Mock(return_value=2097084), + memory_total_real=Mock(return_value=32841680), + network_up=Mock(return_value=1065612), + network_down=Mock(return_value=36311), + update=AsyncMock(return_value=True), + ) + dsm.network = Mock( + update=AsyncMock(return_value=True), + macs=MACS, + hostname=HOST, + interfaces=[ + NetworkInterface( + { + "id": "ovs_eth0", + "ip": [{"address": "127.0.0.1", "netmask": "255.255.255.0"}], + "type": "ovseth", + } + ) + ], + ) + dsm.information = mock_dsm_information() + 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, + } + ], + } + ) + }, + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_usb( + hass: HomeAssistant, + mock_dsm_with_usb: MagicMock, +): + """Mock setup of synology dsm config entry with USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_with_usb + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_usb: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for Synology DSM config entry.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot( + exclude=props("api_details", "created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py new file mode 100644 index 00000000000..654cade2462 --- /dev/null +++ b/tests/components/synology_dsm/test_sensor.py @@ -0,0 +1,242 @@ +"""Tests for Synology DSM USB.""" + +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 ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_dsm_with_usb(): + """Mock a successful service with USB support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) + dsm.information = mock_dsm_information() + 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, + } + ], + } + ) + }, + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +def mock_dsm_without_usb(): + """Mock a successful service without USB devices.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) + dsm.information = mock_dsm_information() + dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_usb( + hass: HomeAssistant, + mock_dsm_with_usb: MagicMock, +): + """Mock setup of synology dsm config entry with USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_with_usb + + +@pytest.fixture +async def setup_dsm_without_usb( + hass: HomeAssistant, + mock_dsm_without_usb: MagicMock, +): + """Mock setup of synology dsm config entry without USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_without_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_without_usb + + +async def test_external_usb( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB sensors.""" + # test disabled device size sensor + entity_id = "sensor.nas_meontheinternet_com_usb_disk_1_device_size" + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # test partition size sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size" + ) + assert sensor is not None + assert sensor.state == "14901.998046875" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition size" + ) + assert sensor.attributes["device_class"] == "data_size" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "GiB" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + # test partition used space sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space" + ) + assert sensor is not None + assert sensor.state == "5803.1650390625" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition used space" + ) + assert sensor.attributes["device_class"] == "data_size" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "GiB" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + # test partition used sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used" + ) + assert sensor is not None + assert sensor.state == "38.9" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition used" + ) + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "%" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + +async def test_no_external_usb( + hass: HomeAssistant, + setup_dsm_without_usb: MagicMock, +) -> None: + """Test Synology DSM without USB.""" + sensor = hass.states.get("sensor.nas_meontheinternet_com_usb_disk_1_device_size") + assert sensor is None