Add new USB drives to Synology DSM without reloading integration (#146829)

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Patrick
2025-09-15 07:12:57 -04:00
committed by GitHub
parent c0af0159e3
commit 99fb64af9b
3 changed files with 315 additions and 91 deletions

View File

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

View File

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

View File

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