Make Synology DSM integration fully async (#85904)

This commit is contained in:
Michael 2023-01-16 00:19:08 +01:00 committed by GitHub
parent 65ca62c991
commit a7ebec4d02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 235 additions and 169 deletions

View File

@ -84,12 +84,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# For SSDP compat
if not entry.data.get(CONF_MAC):
network = await hass.async_add_executor_job(getattr, api.dsm, "network")
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_MAC: network.macs}
entry, data={**entry.data, CONF_MAC: api.dsm.network.macs}
)
# These all create executor jobs so we do not gather here
coordinator_central = SynologyDSMCentralUpdateCoordinator(hass, entry, api)
await coordinator_central.async_config_entry_first_refresh()

View File

@ -1,7 +1,7 @@
"""Support for Synology DSM buttons."""
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import Any, Final
@ -27,7 +27,7 @@ LOGGER = logging.getLogger(__name__)
class SynologyDSMbuttonDescriptionMixin:
"""Mixin to describe a Synology DSM button entity."""
press_action: Callable[[SynoApi], Any]
press_action: Callable[[SynoApi], Callable[[], Coroutine[Any, Any, None]]]
@dataclass
@ -43,14 +43,14 @@ BUTTONS: Final = [
name="Reboot",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action=lambda syno_api: syno_api.async_reboot(),
press_action=lambda syno_api: syno_api.async_reboot,
),
SynologyDSMbuttonDescription(
key="shutdown",
name="Shutdown",
icon="mdi:power",
entity_category=EntityCategory.CONFIG,
press_action=lambda syno_api: syno_api.async_shutdown(),
press_action=lambda syno_api: syno_api.async_shutdown,
),
]
@ -92,4 +92,4 @@ class SynologyDSMButton(ButtonEntity):
self.entity_description.key,
self.syno_api.network.hostname,
)
await self.entity_description.press_action(self.syno_api)
await self.entity_description.press_action(self.syno_api)()

View File

@ -143,7 +143,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
self._listen_source_updates()
await super().async_added_to_hass()
def camera_image(
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return bytes of camera image."""
@ -154,7 +154,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
if not self.available:
return None
try:
return self._api.surveillance_station.get_camera_image(self.entity_description.key, self.snapshot_quality) # type: ignore[no-any-return]
return await self._api.surveillance_station.get_camera_image(self.entity_description.key, self.snapshot_quality) # type: ignore[no-any-return]
except (
SynologyDSMAPIErrorException,
SynologyDSMRequestException,
@ -178,22 +178,22 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
return self.camera_data.live_view.rtsp # type: ignore[no-any-return]
def enable_motion_detection(self) -> None:
async def async_enable_motion_detection(self) -> None:
"""Enable motion detection in the camera."""
_LOGGER.debug(
"SynoDSMCamera.enable_motion_detection(%s)",
self.camera_data.name,
)
self._api.surveillance_station.enable_motion_detection(
await self._api.surveillance_station.enable_motion_detection(
self.entity_description.key
)
def disable_motion_detection(self) -> None:
async def async_disable_motion_detection(self) -> None:
"""Disable motion detection in camera."""
_LOGGER.debug(
"SynoDSMCamera.disable_motion_detection(%s)",
self.camera_data.name,
)
self._api.surveillance_station.disable_motion_detection(
await self._api.surveillance_station.disable_motion_detection(
self.entity_description.key
)

View File

@ -30,6 +30,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_DEVICE_TOKEN, SYNOLOGY_CONNECTION_EXCEPTIONS
@ -71,21 +72,18 @@ class SynoApi:
async def async_setup(self) -> None:
"""Start interacting with the NAS."""
await self._hass.async_add_executor_job(self._setup)
def _setup(self) -> None:
"""Start interacting with the NAS in the executor."""
session = async_get_clientsession(self._hass, self._entry.data[CONF_VERIFY_SSL])
self.dsm = SynologyDSM(
session,
self._entry.data[CONF_HOST],
self._entry.data[CONF_PORT],
self._entry.data[CONF_USERNAME],
self._entry.data[CONF_PASSWORD],
self._entry.data[CONF_SSL],
self._entry.data[CONF_VERIFY_SSL],
timeout=self._entry.options.get(CONF_TIMEOUT),
device_token=self._entry.data.get(CONF_DEVICE_TOKEN),
)
self.dsm.login()
await self.dsm.login()
# check if surveillance station is used
self._with_surveillance_station = bool(
@ -93,7 +91,7 @@ class SynoApi:
)
if self._with_surveillance_station:
try:
self.dsm.surveillance_station.update()
await self.dsm.surveillance_station.update()
except SYNOLOGY_CONNECTION_EXCEPTIONS:
self._with_surveillance_station = False
self.dsm.reset(SynoSurveillanceStation.API_KEY)
@ -110,16 +108,16 @@ class SynoApi:
# check if upgrade is available
try:
self.dsm.upgrade.update()
await self.dsm.upgrade.update()
except SYNOLOGY_CONNECTION_EXCEPTIONS as ex:
self._with_upgrade = False
self.dsm.reset(SynoCoreUpgrade.API_KEY)
LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex)
self._fetch_device_configuration()
await self._fetch_device_configuration()
try:
self._update()
await self._update()
except SYNOLOGY_CONNECTION_EXCEPTIONS as err:
LOGGER.debug(
"Connection error during setup of '%s' with exception: %s",
@ -210,11 +208,11 @@ class SynoApi:
self.dsm.reset(self.utilisation)
self.utilisation = None
def _fetch_device_configuration(self) -> None:
async def _fetch_device_configuration(self) -> None:
"""Fetch initial device config."""
self.information = self.dsm.information
self.network = self.dsm.network
self.network.update()
await self.network.update()
if self._with_security:
LOGGER.debug("Enable security api updates for '%s'", self._entry.unique_id)
@ -248,7 +246,7 @@ class SynoApi:
async def _syno_api_executer(self, api_call: Callable) -> None:
"""Synology api call wrapper."""
try:
await self._hass.async_add_executor_job(api_call)
await api_call()
except (SynologyDSMAPIErrorException, SynologyDSMRequestException) as err:
LOGGER.debug(
"Error from '%s': %s", self._entry.unique_id, err, exc_info=True
@ -274,7 +272,7 @@ class SynoApi:
async def async_update(self) -> None:
"""Update function for updating API information."""
try:
await self._hass.async_add_executor_job(self._update)
await self._update()
except SYNOLOGY_CONNECTION_EXCEPTIONS as err:
LOGGER.debug(
"Connection error during update of '%s' with exception: %s",
@ -286,8 +284,8 @@ class SynoApi:
)
await self._hass.config_entries.async_reload(self._entry.entry_id)
def _update(self) -> None:
async def _update(self) -> None:
"""Update function for updating API information."""
LOGGER.debug("Start data update for '%s'", self._entry.unique_id)
self._setup_api_requests()
self.dsm.update(self._with_information)
await self.dsm.update(self._with_information)

View File

@ -35,6 +35,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import DiscoveryInfoType
@ -172,15 +173,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
else:
port = DEFAULT_PORT
api = SynologyDSM(
host, port, username, password, use_ssl, verify_ssl, timeout=30
)
session = async_get_clientsession(self.hass, verify_ssl)
api = SynologyDSM(session, host, port, username, password, use_ssl, timeout=30)
errors = {}
try:
serial = await self.hass.async_add_executor_job(
_login_and_fetch_syno_info, api, otp_code
)
serial = await _login_and_fetch_syno_info(api, otp_code)
except SynologyDSMLogin2SARequiredException:
return await self.async_step_2sa(user_input)
except SynologyDSMLogin2SAFailedException:
@ -386,13 +384,13 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow):
return self.async_show_form(step_id="init", data_schema=data_schema)
def _login_and_fetch_syno_info(api: SynologyDSM, otp_code: str | None) -> str:
async def _login_and_fetch_syno_info(api: SynologyDSM, otp_code: str | None) -> str:
"""Login to the NAS and fetch basic data."""
# These do i/o
api.login(otp_code)
api.utilisation.update()
api.storage.update()
api.network.update()
await api.login(otp_code)
await api.utilisation.update()
await api.storage.update()
await api.network.update()
if (
not api.information.serial

View File

@ -5,7 +5,6 @@ from datetime import timedelta
import logging
from typing import Any, TypeVar
import async_timeout
from synology_dsm.api.surveillance_station.camera import SynoCamera
from synology_dsm.exceptions import SynologyDSMAPIErrorException
@ -64,20 +63,14 @@ class SynologyDSMSwitchUpdateCoordinator(
async def async_setup(self) -> None:
"""Set up the coordinator initial data."""
info = await self.hass.async_add_executor_job(
self.api.dsm.surveillance_station.get_info
)
info = await self.api.dsm.surveillance_station.get_info()
self.version = info["data"]["CMSMinVersion"]
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch all data from api."""
surveillance_station = self.api.surveillance_station
return {
"switches": {
"home_mode": await self.hass.async_add_executor_job(
surveillance_station.get_home_mode_status
)
}
"switches": {"home_mode": await surveillance_station.get_home_mode_status()}
}
@ -131,8 +124,7 @@ class SynologyDSMCameraUpdateCoordinator(
}
try:
async with async_timeout.timeout(30):
await self.hass.async_add_executor_job(surveillance_station.update)
await surveillance_station.update()
except SynologyDSMAPIErrorException as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err

View File

@ -2,7 +2,7 @@
"domain": "synology_dsm",
"name": "Synology DSM",
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",
"requirements": ["py-synologydsm-api==1.0.8"],
"requirements": ["py-synologydsm-api==2.0.1"],
"codeowners": ["@hacf-fr", "@Quentame", "@mib1185"],
"config_flow": true,
"ssdp": [

View File

@ -87,9 +87,7 @@ class SynoDSMSurveillanceHomeModeToggle(
"SynoDSMSurveillanceHomeModeToggle.turn_on(%s)",
self._api.information.serial,
)
await self.hass.async_add_executor_job(
self._api.dsm.surveillance_station.set_home_mode, True
)
await self._api.dsm.surveillance_station.set_home_mode(True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
@ -98,9 +96,7 @@ class SynoDSMSurveillanceHomeModeToggle(
"SynoDSMSurveillanceHomeModeToggle.turn_off(%s)",
self._api.information.serial,
)
await self.hass.async_add_executor_job(
self._api.dsm.surveillance_station.set_home_mode, False
)
await self._api.dsm.surveillance_station.set_home_mode(False)
await self.coordinator.async_request_refresh()
@property

View File

@ -1433,7 +1433,7 @@ py-schluter==0.1.7
py-sucks==0.9.8
# homeassistant.components.synology_dsm
py-synologydsm-api==1.0.8
py-synologydsm-api==2.0.1
# homeassistant.components.zabbix
py-zabbix==1.1.7

View File

@ -1042,7 +1042,7 @@ py-melissa-climate==2.1.4
py-nightscout==1.2.2
# homeassistant.components.synology_dsm
py-synologydsm-api==1.0.8
py-synologydsm-api==2.0.1
# homeassistant.components.seventeentrack
py17track==2021.12.2

View File

@ -1,5 +1,5 @@
"""Configure Synology DSM tests."""
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
@ -21,3 +21,17 @@ def bypass_setup_fixture(request):
"homeassistant.components.synology_dsm.async_setup_entry", return_value=True
):
yield
@pytest.fixture(name="mock_dsm")
def fixture_dsm():
"""Set up SynologyDSM API fixture."""
with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm:
dsm.login = AsyncMock(return_value=True)
dsm.update = AsyncMock(return_value=True)
dsm.network.update = AsyncMock(return_value=True)
dsm.surveillance_station.update = AsyncMock(return_value=True)
dsm.upgrade.update = AsyncMock(return_value=True)
yield dsm

View File

@ -1,5 +1,5 @@
"""Tests for the Synology DSM config flow."""
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from synology_dsm.exceptions import (
@ -59,60 +59,89 @@ from tests.common import MockConfigEntry
@pytest.fixture(name="service")
def mock_controller_service():
"""Mock a successful service."""
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM"
) as service_mock:
service_mock.return_value.information.serial = SERIAL
service_mock.return_value.utilisation.cpu_user_load = 1
service_mock.return_value.storage.disks_ids = ["sda", "sdb", "sdc"]
service_mock.return_value.storage.volumes_ids = ["volume_1"]
service_mock.return_value.network.macs = MACS
yield service_mock
with patch("homeassistant.components.synology_dsm.config_flow.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.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.storage = Mock(
disks_ids=["sda", "sdb", "sdc"],
volumes_ids=["volume_1"],
update=AsyncMock(return_value=True),
)
dsm.information = Mock(serial=SERIAL)
yield dsm
@pytest.fixture(name="service_2sa")
def mock_controller_service_2sa():
"""Mock a successful service with 2SA login."""
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM"
) as service_mock:
service_mock.return_value.login = Mock(
with patch("homeassistant.components.synology_dsm.config_flow.SynologyDSM") as dsm:
dsm.login = AsyncMock(
side_effect=SynologyDSMLogin2SARequiredException(USERNAME)
)
service_mock.return_value.information.serial = SERIAL
service_mock.return_value.utilisation.cpu_user_load = 1
service_mock.return_value.storage.disks_ids = ["sda", "sdb", "sdc"]
service_mock.return_value.storage.volumes_ids = ["volume_1"]
service_mock.return_value.network.macs = MACS
yield service_mock
dsm.update = AsyncMock(return_value=True)
dsm.surveillance_station.update = AsyncMock(return_value=True)
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.storage = Mock(
disks_ids=["sda", "sdb", "sdc"],
volumes_ids=["volume_1"],
update=AsyncMock(return_value=True),
)
dsm.information = Mock(serial=SERIAL)
yield dsm
@pytest.fixture(name="service_vdsm")
def mock_controller_service_vdsm():
"""Mock a successful service."""
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM"
) as service_mock:
service_mock.return_value.information.serial = SERIAL
service_mock.return_value.utilisation.cpu_user_load = 1
service_mock.return_value.storage.disks_ids = []
service_mock.return_value.storage.volumes_ids = ["volume_1"]
service_mock.return_value.network.macs = MACS
yield service_mock
with patch("homeassistant.components.synology_dsm.config_flow.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.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS)
dsm.storage = Mock(
disks_ids=[],
volumes_ids=["volume_1"],
update=AsyncMock(return_value=True),
)
dsm.information = Mock(serial=SERIAL)
yield dsm
@pytest.fixture(name="service_failed")
def mock_controller_service_failed():
"""Mock a failed service."""
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM"
) as service_mock:
service_mock.return_value.information.serial = None
service_mock.return_value.utilisation.cpu_user_load = None
service_mock.return_value.storage.disks_ids = []
service_mock.return_value.storage.volumes_ids = []
service_mock.return_value.network.macs = []
yield service_mock
with patch("homeassistant.components.synology_dsm.config_flow.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.utilisation = Mock(cpu_user_load=None, update=AsyncMock(return_value=True))
dsm.network = Mock(update=AsyncMock(return_value=True), macs=[])
dsm.storage = Mock(
disks_ids=[],
volumes_ids=[],
update=AsyncMock(return_value=True),
)
dsm.information = Mock(serial=None)
yield dsm
async def test_user(hass: HomeAssistant, service: MagicMock):
@ -123,6 +152,10 @@ async def test_user(hass: HomeAssistant, service: MagicMock):
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service,
):
# test with all provided
result = await hass.config_entries.flow.async_init(
DOMAIN,
@ -150,7 +183,11 @@ async def test_user(hass: HomeAssistant, service: MagicMock):
assert result["data"].get(CONF_DISKS) is None
assert result["data"].get(CONF_VOLUMES) is None
service.return_value.information.serial = SERIAL_2
service.information.serial = SERIAL_2
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service,
):
# test without port + False SSL
result = await hass.config_entries.flow.async_init(
DOMAIN,
@ -180,6 +217,10 @@ async def test_user(hass: HomeAssistant, service: MagicMock):
async def test_user_2sa(hass: HomeAssistant, service_2sa: MagicMock):
"""Test user with 2sa authentication config."""
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service_2sa,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
@ -200,8 +241,13 @@ async def test_user_2sa(hass: HomeAssistant, service_2sa: MagicMock):
assert result["errors"] == {CONF_OTP_CODE: "otp_failed"}
# Successful login with 2SA code
service_2sa.return_value.login = Mock(return_value=True)
service_2sa.return_value.device_token = DEVICE_TOKEN
service_2sa.login = AsyncMock(return_value=True)
service_2sa.device_token = DEVICE_TOKEN
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service_2sa,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_OTP_CODE: "123456"}
)
@ -223,12 +269,20 @@ async def test_user_2sa(hass: HomeAssistant, service_2sa: MagicMock):
async def test_user_vdsm(hass: HomeAssistant, service_vdsm: MagicMock):
"""Test user config."""
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service_vdsm,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=None
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service_vdsm,
):
# test with all provided
result = await hass.config_entries.flow.async_init(
DOMAIN,
@ -292,6 +346,10 @@ async def test_reauth(hass: HomeAssistant, service: MagicMock):
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
@ -318,6 +376,9 @@ async def test_reconfig_user(hass: HomeAssistant, service: MagicMock):
with patch(
"homeassistant.config_entries.ConfigEntries.async_reload",
return_value=True,
), patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@ -375,6 +436,10 @@ async def test_unknown_failed(hass: HomeAssistant, service: MagicMock):
async def test_missing_data_after_login(hass: HomeAssistant, service_failed: MagicMock):
"""Test when we have errors during connection."""
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service_failed,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
@ -404,6 +469,10 @@ async def test_form_ssdp(hass: HomeAssistant, service: MagicMock):
assert result["step_id"] == "link"
assert result["errors"] == {}
with patch(
"homeassistant.components.synology_dsm.config_flow.SynologyDSM",
return_value=service,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)

View File

@ -1,5 +1,5 @@
"""Tests for the Synology DSM component."""
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from synology_dsm.exceptions import SynologyDSMLoginInvalidException
@ -22,11 +22,12 @@ from tests.common import MockConfigEntry
@pytest.mark.no_bypass_setup
async def test_services_registered(hass: HomeAssistant):
async def test_services_registered(hass: HomeAssistant, mock_dsm: MagicMock):
"""Test if all services are registered."""
with patch("homeassistant.components.synology_dsm.common.SynologyDSM"), patch(
"homeassistant.components.synology_dsm.PLATFORMS", return_value=[]
):
with patch(
"homeassistant.components.synology_dsm.common.SynologyDSM",
return_value=mock_dsm,
), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]):
entry = MockConfigEntry(
domain=DOMAIN,
data={