1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-13 17:44:45 +01:00

Add fan speed mode control to Synology DSM (#173051)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Michael
2026-06-22 18:06:45 +02:00
committed by GitHub
parent b24791fa71
commit a7fb69cf3e
17 changed files with 351 additions and 6 deletions
@@ -8,6 +8,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.hardware import SynoCoreHardware
from synology_dsm.api.core.security import SynoCoreSecurity
from synology_dsm.api.core.system import SynoCoreSystem
from synology_dsm.api.core.upgrade import SynoCoreUpgrade
@@ -68,6 +69,7 @@ class SynoApi:
# DSM APIs
self.file_station: SynoFileStation | None = None
self.hardware: SynoCoreHardware | None = None
self.information: SynoDSMInformation | None = None
self.network: SynoDSMNetwork | None = None
self.photos: SynoPhotos | None = None
@@ -82,6 +84,7 @@ class SynoApi:
# Should we fetch them
self._fetching_entities: dict[str, set[str]] = {}
self._with_file_station = True
self._with_hardware = True
self._with_information = True
self._with_photos = True
self._with_security = True
@@ -205,6 +208,14 @@ class SynoApi:
self._with_file_station,
)
# check if hardware info is available
try:
await self.dsm.hardware.update()
except SYNOLOGY_CONNECTION_EXCEPTIONS as ex:
self._with_hardware = False
self.dsm.reset(SynoCoreHardware.API_KEY)
LOGGER.debug("Disabled fetching hardware data during setup: %s", ex)
await self._fetch_device_configuration()
try:
@@ -265,6 +276,9 @@ class SynoApi:
self._with_external_usb = bool(
self._fetching_entities.get(SynoCoreExternalUSB.API_KEY)
)
self._with_hardware = bool(
self._fetching_entities.get(SynoCoreHardware.API_KEY)
)
# Reset not used API, information is not reset since it's used in device_info
if not self._with_security:
@@ -335,6 +349,15 @@ class SynoApi:
self.dsm.reset(self.external_usb)
self.external_usb = None
if not self._with_hardware:
LOGGER.debug(
"Disable hardware api from being updated for '%s'",
self._entry.unique_id,
)
if self.hardware:
self.dsm.reset(self.hardware)
self.hardware = None
async def _fetch_device_configuration(self) -> None:
"""Fetch initial device config."""
self.network = self.dsm.network
@@ -385,6 +408,10 @@ class SynoApi:
)
self.external_usb = self.dsm.external_usb
if self._with_hardware:
LOGGER.debug("Enable hardware api updates for '%s'", self._entry.unique_id)
self.hardware = self.dsm.hardware
async def _syno_api_executer(self, api_call: Callable) -> None:
"""Synology api call wrapper."""
try:
@@ -6,6 +6,7 @@ from aiohttp import ClientTimeout
from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED
from synology_dsm.exceptions import (
SynologyDSMAPIErrorException,
SynologyDSMAPINoDataException,
SynologyDSMLogin2SARequiredException,
SynologyDSMLoginDisabledAccountException,
SynologyDSMLoginFailedException,
@@ -26,6 +27,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CAMERA,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
@@ -76,6 +78,7 @@ SYNOLOGY_AUTH_FAILED_EXCEPTIONS = (
SYNOLOGY_CONNECTION_EXCEPTIONS = (
SynologyDSMAPIErrorException,
SynologyDSMAPINoDataException,
SynologyDSMLoginFailedException,
SynologyDSMRequestException,
)
@@ -63,6 +63,11 @@ async def async_get_config_entry_diagnostics(
"size_total": partition.partition_size_total(False),
}
if syno_api.hardware is not None:
diag_data["device_info"]["fan_speed_mode"] = (
syno_api.dsm.hardware.fan_speed.value
)
if syno_api.network is not None:
for intf in syno_api.network.interfaces:
diag_data["network"]["interfaces"][intf["id"]] = {
@@ -5,6 +5,16 @@
"default": "mdi:power"
}
},
"select": {
"fan_speed_mode": {
"default": "mdi:fan",
"state": {
"cool": "mdi:fan-speed-2",
"full_speed": "mdi:fan-speed-3",
"quiet": "mdi:fan-speed-1"
}
}
},
"sensor": {
"cpu_15min_load": {
"default": "mdi:chip"
@@ -0,0 +1,77 @@
"""Support for Synology DSM select entities."""
from dataclasses import dataclass
from typing import override
from synology_dsm.api.core.hardware import FanSpeed, SynoCoreHardware
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SynoApi
from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry
from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription
FAN_SPEED_MAP = {
FanSpeed.QUIET: "quiet",
FanSpeed.COOL: "cool",
FanSpeed.FULL: "full_speed",
}
FAN_SPEED_MAP_INVERSE = {v: k for k, v in FAN_SPEED_MAP.items()}
@dataclass(frozen=True, kw_only=True)
class SynologyDSMSelectEntityDescription(
SynologyDSMEntityDescription, SelectEntityDescription
):
"""Describes Synology DSM select entity."""
async def async_setup_entry(
hass: HomeAssistant,
entry: SynologyDSMConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set select entities for device."""
data = entry.runtime_data
if data.api.hardware is not None:
async_add_entities(
[SynologyDSMFanSpeedMode(data.api, data.coordinator_central)]
)
class SynologyDSMFanSpeedMode(
SynologyDSMBaseEntity[SynologyDSMCentralUpdateCoordinator], SelectEntity
):
"""Represent a Synology DSM fan speed mode select entity."""
_attr_options = list(FAN_SPEED_MAP.values())
entity_description: SynologyDSMSelectEntityDescription
def __init__(
self,
api: SynoApi,
coordinator: SynologyDSMCentralUpdateCoordinator,
) -> None:
"""Initialize a Synology DSM select entity."""
description = SynologyDSMSelectEntityDescription(
api_key=SynoCoreHardware.API_KEY,
key="fan_speed_mode",
translation_key="fan_speed_mode",
entity_category=EntityCategory.CONFIG,
)
super().__init__(api, coordinator, description)
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
return FAN_SPEED_MAP[self._api.dsm.hardware.fan_speed]
@override
async def async_select_option(self, option: str) -> None:
"""Set the fan speed mode."""
await self._api.dsm.hardware.set_fan_speed(FAN_SPEED_MAP_INVERSE[option])
await self._api.dsm.hardware.update()
self.async_write_ha_state()
@@ -81,6 +81,16 @@
"name": "Shut down"
}
},
"select": {
"fan_speed_mode": {
"name": "Fan speed mode",
"state": {
"cool": "Cool mode",
"full_speed": "Full-speed mode",
"quiet": "Quiet mode"
}
}
},
"sensor": {
"cpu_15min_load": {
"name": "CPU load average (15 min)"
+6
View File
@@ -4,11 +4,17 @@ from unittest.mock import AsyncMock, Mock
from awesomeversion import AwesomeVersion
from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice
from synology_dsm.api.core.hardware import FanSpeed
from synology_dsm.api.storage.storage import SynoStorageDisk, SynoStorageVolume
from .consts import SERIAL
def mock_dsm_hardware(fan_speed: FanSpeed = FanSpeed.COOL) -> Mock:
"""Mock SynologyDSM hardware information."""
return AsyncMock(update=AsyncMock(), fan_speed=fan_speed)
def mock_dsm_information(
serial: str | None = SERIAL,
update_result: bool = True,
+2 -1
View File
@@ -8,7 +8,7 @@ import pytest
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .common import mock_dsm_information
from .common import mock_dsm_hardware, mock_dsm_information
@pytest.fixture
@@ -35,6 +35,7 @@ def fixture_dsm():
dsm.information = mock_dsm_information()
dsm.network.update = AsyncMock(return_value=True)
dsm.hardware = mock_dsm_hardware()
dsm.surveillance_station.update = AsyncMock(return_value=True)
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None))
@@ -2,6 +2,7 @@
# name: test_entry_diagnostics
dict({
'device_info': dict({
'fan_speed_mode': 'coolfan',
'model': 'DS1821+',
'ram': 32768,
'temperature': 58,
@@ -0,0 +1,63 @@
# serializer version: 1
# name: test_fan_speed[select.nas_meontheinternet_com_fan_speed_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'quiet',
'cool',
'full_speed',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.nas_meontheinternet_com_fan_speed_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Fan speed mode',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Fan speed mode',
'platform': 'synology_dsm',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fan_speed_mode',
'unique_id': 'mySerial_SYNO.Core.Hardware:fan_speed_mode',
'unit_of_measurement': None,
})
# ---
# name: test_fan_speed[select.nas_meontheinternet_com_fan_speed_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Synology',
'friendly_name': 'nas.meontheinternet.com Fan speed mode',
'options': list([
'quiet',
'cool',
'full_speed',
]),
}),
'context': <ANY>,
'entity_id': 'select.nas_meontheinternet_com_fan_speed_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cool',
})
# ---
+3 -1
View File
@@ -35,7 +35,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.aiohttp import MockStreamReader, MockStreamReaderChunked
from .common import mock_dsm_information
from .common import mock_dsm_hardware, mock_dsm_information
from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME
from tests.common import MockConfigEntry
@@ -93,6 +93,7 @@ def mock_dsm_with_filestation():
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.hardware = mock_dsm_hardware()
dsm.storage = Mock(
disks_ids=["sda", "sdb", "sdc"],
volumes_ids=["volume_1"],
@@ -146,6 +147,7 @@ def mock_dsm_without_filestation():
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.hardware = mock_dsm_hardware()
dsm.information = mock_dsm_information()
dsm.storage = Mock(
disks_ids=["sda", "sdb", "sdc"],
@@ -40,7 +40,7 @@ from homeassistant.helpers.service_info.ssdp import (
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .common import mock_dsm_information
from .common import mock_dsm_hardware, mock_dsm_information
from .consts import (
DEVICE_TOKEN,
HOST,
@@ -68,6 +68,7 @@ def mock_controller_service():
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.hardware = mock_dsm_hardware()
dsm.storage = Mock(
disks_ids=["sda", "sdb", "sdc"],
volumes_ids=["volume_1"],
@@ -91,6 +92,7 @@ def mock_controller_service_2sa():
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.hardware = mock_dsm_hardware()
dsm.storage = Mock(
disks_ids=["sda", "sdb", "sdc"],
volumes_ids=["volume_1"],
@@ -112,6 +114,7 @@ def mock_controller_service_vdsm():
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.hardware = mock_dsm_hardware()
dsm.storage = Mock(
disks_ids=[],
volumes_ids=["volume_1"],
@@ -133,6 +136,7 @@ def mock_controller_service_with_filestation():
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.hardware = mock_dsm_hardware()
dsm.storage = Mock(
disks_ids=["sda", "sdb", "sdc"],
volumes_ids=["volume_1"],
@@ -166,6 +170,7 @@ def mock_controller_service_failed():
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.utilisation = Mock(cpu_user_load=None, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=[])
dsm.hardware = mock_dsm_hardware()
dsm.storage = Mock(
disks_ids=[],
volumes_ids=[],
@@ -19,7 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from .common import mock_dsm_information
from .common import mock_dsm_hardware, mock_dsm_information
from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME
from tests.common import MockConfigEntry
@@ -92,6 +92,7 @@ def mock_dsm_with_usb():
)
],
)
dsm.hardware = mock_dsm_hardware()
dsm.information = mock_dsm_information()
dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None))
dsm.external_usb = Mock(
@@ -28,7 +28,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.util.aiohttp import MockRequest
from .common import mock_dsm_information
from .common import mock_dsm_hardware, mock_dsm_information
from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME
from tests.common import MockConfigEntry
@@ -42,6 +42,7 @@ def dsm_with_photos() -> MagicMock:
dsm.update = AsyncMock(return_value=True)
dsm.information = mock_dsm_information()
dsm.network.update = AsyncMock(return_value=True)
dsm.hardware = mock_dsm_hardware()
dsm.surveillance_station.update = AsyncMock(return_value=True)
dsm.upgrade.update = AsyncMock(return_value=True)
@@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from .common import mock_dsm_information
from .common import mock_dsm_hardware, mock_dsm_information
from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME
from tests.common import ANY, MockConfigEntry
@@ -42,6 +42,7 @@ def mock_dsm_with_filestation():
dsm.upgrade.update = AsyncMock(return_value=True)
dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.hardware = mock_dsm_hardware()
dsm.storage = Mock(
disks_ids=["sda", "sdb", "sdc"],
volumes_ids=["volume_1"],
@@ -0,0 +1,129 @@
"""Tests for Synology DSM select entities."""
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from pylint_home_assistant.const import Platform
import pytest
from synology_dsm.api.core.hardware import FanSpeed
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.components.synology_dsm.const import DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_OPTION,
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_hardware, mock_dsm_information
from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def mock_dsm():
"""Mock a successful service."""
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.hardware = mock_dsm_hardware()
dsm.information = mock_dsm_information()
dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None))
dsm.logout = AsyncMock(return_value=True)
yield dsm
async def test_fan_speed(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_dsm: MagicMock,
) -> None:
"""Test Synology DSM fan speed mode select entity."""
with (
patch(
"homeassistant.components.synology_dsm.common.SynologyDSM",
return_value=mock_dsm,
),
patch("homeassistant.components.synology_dsm.PLATFORMS", ["select"]),
):
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()
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
@pytest.mark.parametrize(
("fan_speed", "fan_speed_parameter"),
[("full_speed", FanSpeed.FULL), ("cool", FanSpeed.COOL), ("quiet", FanSpeed.QUIET)],
)
async def test_fan_speed_select_option(
hass: HomeAssistant,
mock_dsm: MagicMock,
fan_speed: str,
fan_speed_parameter: FanSpeed,
) -> None:
"""Test selecting a fan speed mode option."""
with (
patch(
"homeassistant.components.synology_dsm.common.SynologyDSM",
return_value=mock_dsm,
),
patch("homeassistant.components.synology_dsm.PLATFORMS", [Platform.SELECT]),
):
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()
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.nas_meontheinternet_com_fan_speed_mode",
ATTR_OPTION: fan_speed,
},
blocking=True,
)
assert mock_dsm.hardware.set_fan_speed.call_args[0][0] == fan_speed_parameter
@@ -24,6 +24,7 @@ from .common import (
mock_dsm_external_usb_devices_usb0,
mock_dsm_external_usb_devices_usb1,
mock_dsm_external_usb_devices_usb2,
mock_dsm_hardware,
mock_dsm_information,
mock_dsm_storage_get_disk,
mock_dsm_storage_get_volume,
@@ -49,6 +50,7 @@ def mock_dsm_with_usb():
dsm.network = Mock(
update=AsyncMock(return_value=True), macs=MACS, hostname=HOST
)
dsm.hardware = mock_dsm_hardware()
dsm.information = mock_dsm_information()
dsm.storage = Mock(
get_disk=mock_dsm_storage_get_disk,
@@ -83,6 +85,7 @@ def mock_dsm_without_usb():
dsm.network = Mock(
update=AsyncMock(return_value=True), macs=MACS, hostname=HOST
)
dsm.hardware = mock_dsm_hardware()
dsm.information = mock_dsm_information()
dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None))
dsm.logout = AsyncMock(return_value=True)