Add support for external USB drives to Synology DSM (#138661)

* Add external usb drives

* Add partition percentage used

* Move icons to icons.json

* Add external usb to diagnostics

* Add assert for external usb entity

* Fix reset external_usb

* Update homeassistant/components/synology_dsm/diagnostics.py

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>

* Update homeassistant/components/synology_dsm/diagnostics.py

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>

* Fix diagnostics

* Make each partition a device

* Add usb sensor tests

* Add diagnostics tests

* It is possible that api.external_usb is None

* Merge upstream into syno_external_usb

* add manufacturer and model to partition

* fix tests

---------

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>
Co-authored-by: mib1185 <mail@mib85.de>
This commit is contained in:
Patrick 2025-04-29 07:32:21 -04:00 committed by GitHub
parent 15aff9662c
commit 9ce920b35a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 797 additions and 2 deletions

View File

@ -9,6 +9,7 @@ import logging
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from synology_dsm import SynologyDSM 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.security import SynoCoreSecurity
from synology_dsm.api.core.system import SynoCoreSystem from synology_dsm.api.core.system import SynoCoreSystem
from synology_dsm.api.core.upgrade import SynoCoreUpgrade from synology_dsm.api.core.upgrade import SynoCoreUpgrade
@ -78,6 +79,7 @@ class SynoApi:
self.system: SynoCoreSystem | None = None self.system: SynoCoreSystem | None = None
self.upgrade: SynoCoreUpgrade | None = None self.upgrade: SynoCoreUpgrade | None = None
self.utilisation: SynoCoreUtilization | None = None self.utilisation: SynoCoreUtilization | None = None
self.external_usb: SynoCoreExternalUSB | None = None
# Should we fetch them # Should we fetch them
self._fetching_entities: dict[str, set[str]] = {} self._fetching_entities: dict[str, set[str]] = {}
@ -90,6 +92,7 @@ class SynoApi:
self._with_system = True self._with_system = True
self._with_upgrade = True self._with_upgrade = True
self._with_utilisation = True self._with_utilisation = True
self._with_external_usb = True
self._login_future: asyncio.Future[None] | None = None self._login_future: asyncio.Future[None] | None = None
@ -261,6 +264,9 @@ class SynoApi:
self._with_information = bool( self._with_information = bool(
self._fetching_entities.get(SynoDSMInformation.API_KEY) 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 # Reset not used API, information is not reset since it's used in device_info
if not self._with_security: if not self._with_security:
@ -322,6 +328,15 @@ class SynoApi:
self.dsm.reset(self.utilisation) self.dsm.reset(self.utilisation)
self.utilisation = None 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: async def _fetch_device_configuration(self) -> None:
"""Fetch initial device config.""" """Fetch initial device config."""
self.network = self.dsm.network self.network = self.dsm.network
@ -366,6 +381,12 @@ class SynoApi:
) )
self.surveillance_station = self.dsm.surveillance_station 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: async def _syno_api_executer(self, api_call: Callable) -> None:
"""Synology api call wrapper.""" """Synology api call wrapper."""
try: try:

View File

@ -32,6 +32,7 @@ async def async_get_config_entry_diagnostics(
"uptime": dsm_info.uptime, "uptime": dsm_info.uptime,
"temperature": dsm_info.temperature, "temperature": dsm_info.temperature,
}, },
"external_usb": {"devices": {}, "partitions": {}},
"network": {"interfaces": {}}, "network": {"interfaces": {}},
"storage": {"disks": {}, "volumes": {}}, "storage": {"disks": {}, "volumes": {}},
"surveillance_station": {"cameras": {}, "camera_diagnostics": {}}, "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: if syno_api.network is not None:
for intf in syno_api.network.interfaces: for intf in syno_api.network.interfaces:
diag_data["network"]["interfaces"][intf["id"]] = { diag_data["network"]["interfaces"][intf["id"]] = {

View File

@ -93,6 +93,7 @@ class SynologyDSMDeviceEntity(
storage = api.storage storage = api.storage
information = api.information information = api.information
network = api.network network = api.network
external_usb = api.external_usb
assert information is not None assert information is not None
assert storage is not None assert storage is not None
assert network is not None assert network is not None
@ -121,6 +122,26 @@ class SynologyDSMDeviceEntity(
self._device_model = disk["model"].strip() self._device_model = disk["model"].strip()
self._device_firmware = disk["firm"] self._device_firmware = disk["firm"]
self._device_type = disk["diskType"] 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_unique_id += f"_{self._device_id}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(

View File

@ -22,6 +22,12 @@
"cpu_15min_load": { "cpu_15min_load": {
"default": "mdi:chip" "default": "mdi:chip"
}, },
"device_size_total": {
"default": "mdi:chart-pie"
},
"device_status": {
"default": "mdi:checkbox-marked-circle-outline"
},
"memory_real_usage": { "memory_real_usage": {
"default": "mdi:memory" "default": "mdi:memory"
}, },
@ -49,6 +55,15 @@
"network_down": { "network_down": {
"default": "mdi:download" "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": { "volume_status": {
"default": "mdi:checkbox-marked-circle-outline", "default": "mdi:checkbox-marked-circle-outline",
"state": { "state": {

View File

@ -6,6 +6,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import cast 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.core.utilization import SynoCoreUtilization
from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.information import SynoDSMInformation
from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.storage.storage import SynoStorage
@ -17,6 +18,7 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICES,
CONF_DISKS, CONF_DISKS,
PERCENTAGE, PERCENTAGE,
EntityCategory, EntityCategory,
@ -261,6 +263,53 @@ STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC, 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, ...] = ( INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = (
SynologyDSMSensorEntityDescription( SynologyDSMSensorEntityDescription(
@ -294,8 +343,14 @@ async def async_setup_entry(
coordinator = data.coordinator_central coordinator = data.coordinator_central
storage = api.storage storage = api.storage
assert storage is not None 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) SynoDSMUtilSensor(api, coordinator, description)
for description in UTILISATION_SENSORS 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( entities.extend(
[ [
SynoDSMInfoSensor(api, coordinator, description) 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): class SynoDSMInfoSensor(SynoDSMSensor):
"""Representation a Synology information sensor.""" """Representation a Synology information sensor."""

View File

@ -113,6 +113,12 @@
"cpu_user_load": { "cpu_user_load": {
"name": "CPU utilization (user)" "name": "CPU utilization (user)"
}, },
"device_size_total": {
"name": "Device size"
},
"device_status": {
"name": "Status"
},
"disk_smart_status": { "disk_smart_status": {
"name": "Status (smart)" "name": "Status (smart)"
}, },
@ -149,6 +155,15 @@
"network_up": { "network_up": {
"name": "Upload throughput" "name": "Upload throughput"
}, },
"partition_percentage_used": {
"name": "Partition used"
},
"partition_size_total": {
"name": "Partition size"
},
"partition_size_used": {
"name": "Partition used space"
},
"temperature": { "temperature": {
"name": "[%key:component::sensor::entity_component::temperature::name%]" "name": "[%key:component::sensor::entity_component::temperature::name%]"
}, },

View File

@ -12,11 +12,21 @@ from .consts import SERIAL
def mock_dsm_information( def mock_dsm_information(
serial: str | None = SERIAL, serial: str | None = SERIAL,
update_result: bool = True, 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:
"""Mock SynologyDSM information.""" """Mock SynologyDSM information."""
return Mock( return Mock(
serial=serial, serial=serial,
update=AsyncMock(return_value=update_result), update=AsyncMock(return_value=update_result),
awesome_version=AwesomeVersion(awesome_version), awesome_version=AwesomeVersion(awesome_version),
model=model,
version_string=version_string,
ram=ram,
temperature=temperature,
uptime=uptime,
) )

View File

@ -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,
}),
]),
}),
})
# ---

View File

@ -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")
)

View File

@ -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