From dbe89c1e021c86cd958fb36782cb45de1365932f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 11 Nov 2020 19:06:50 +0100 Subject: [PATCH] Add reboot and shutdown service to synology_dsm (#42697) * add reboot and shutdown service to synology_dsm * apply suggestions * make _async_setup_services() async * add comment to make sure unique_id is serial --- .../components/synology_dsm/__init__.py | 106 ++++++++++++++++-- .../components/synology_dsm/config_flow.py | 4 +- .../components/synology_dsm/const.py | 9 ++ .../components/synology_dsm/services.yaml | 15 +++ .../synology_dsm/test_config_flow.py | 22 ++++ 5 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/synology_dsm/services.yaml diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index dacb124aa77..d8acf29016c 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -6,13 +6,17 @@ from typing import Dict from synology_dsm import SynologyDSM from synology_dsm.api.core.security import SynoCoreSecurity +from synology_dsm.api.core.system import SynoCoreSystem from synology_dsm.api.core.upgrade import SynoCoreUpgrade from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation -from synology_dsm.exceptions import SynologyDSMRequestException +from synology_dsm.exceptions import ( + SynologyDSMLoginFailedException, + SynologyDSMRequestException, +) import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -29,7 +33,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import callback +from homeassistant.core import ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv @@ -42,6 +46,7 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType from .const import ( + CONF_SERIAL, CONF_VOLUMES, DEFAULT_SCAN_INTERVAL, DEFAULT_USE_SSL, @@ -53,6 +58,9 @@ from .const import ( ENTITY_NAME, ENTITY_UNIT, PLATFORMS, + SERVICE_REBOOT, + SERVICE_SHUTDOWN, + SERVICES, STORAGE_DISK_BINARY_SENSORS, STORAGE_DISK_SENSORS, STORAGE_VOL_SENSORS, @@ -176,7 +184,8 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): api = SynoApi(hass, entry) try: await api.async_setup() - except SynologyDSMRequestException as err: + except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: + _LOGGER.debug("async_setup_entry - Unable to connect to DSM: %s", str(err)) raise ConfigEntryNotReady from err undo_listener = entry.add_update_listener(_async_update_listener) @@ -187,6 +196,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): UNDO_UPDATE_LISTENER: undo_listener, } + # Services + await _async_setup_services(hass) + # For SSDP compat if not entry.data.get(CONF_MAC): network = await hass.async_add_executor_job(getattr, api.dsm, "network") @@ -227,6 +239,49 @@ async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry): await hass.config_entries.async_reload(entry.entry_id) +async def _async_setup_services(hass: HomeAssistantType): + """Service handler setup.""" + + async def service_handler(call: ServiceCall): + """Handle service call.""" + _LOGGER.debug( + "service_handler - called as '%s' with data: %s", call.service, call.data + ) + serial = call.data.get(CONF_SERIAL) + dsm_devices = hass.data[DOMAIN] + + if serial: + dsm_device = dsm_devices.get(serial) + elif len(dsm_devices) == 1: + dsm_device = next(iter(dsm_devices.values())) + serial = next(iter(dsm_devices)) + else: + _LOGGER.error( + "service_handler - more than one DSM configured, must specify one of serials %s", + sorted(dsm_devices), + ) + return + + if not dsm_device: + _LOGGER.error( + "service_handler - DSM with specified serial %s not found", serial + ) + return + + _LOGGER.info("%s DSM with serial %s", call.service, serial) + dsm_api = dsm_device[SYNO_API] + if call.service == SERVICE_REBOOT: + await dsm_api.async_reboot() + elif call.service == SERVICE_SHUTDOWN: + await dsm_api.system.shutdown() + + for service in SERVICES: + _LOGGER.debug( + "_async_setup_services - register service %s on domain %s", service, DOMAIN + ) + hass.services.async_register(DOMAIN, service, service_handler) + + class SynoApi: """Class to interface with Synology DSM API.""" @@ -240,19 +295,21 @@ class SynoApi: self.information: SynoDSMInformation = None self.network: SynoDSMNetwork = None self.security: SynoCoreSecurity = None - self.upgrade: SynoCoreUpgrade = None self.storage: SynoStorage = None - self.utilisation: SynoCoreUtilization = None self.surveillance_station: SynoSurveillanceStation = None + self.system: SynoCoreSystem = None + self.upgrade: SynoCoreUpgrade = None + self.utilisation: SynoCoreUtilization = None # Should we fetch them self._fetching_entities = {} + self._with_information = True self._with_security = True self._with_storage = True + self._with_surveillance_station = True + self._with_system = True self._with_upgrade = True self._with_utilisation = True - self._with_information = True - self._with_surveillance_station = True self._unsub_dispatcher = None @@ -320,6 +377,7 @@ class SynoApi: self._fetching_entities.get(SynoCoreSecurity.API_KEY) ) self._with_storage = bool(self._fetching_entities.get(SynoStorage.API_KEY)) + self._with_system = bool(self._fetching_entities.get(SynoCoreSystem.API_KEY)) self._with_upgrade = bool(self._fetching_entities.get(SynoCoreUpgrade.API_KEY)) self._with_utilisation = bool( self._fetching_entities.get(SynoCoreUtilization.API_KEY) @@ -342,6 +400,10 @@ class SynoApi: self.dsm.reset(self.storage) self.storage = None + if not self._with_system: + self.dsm.reset(self.system) + self.system = None + if not self._with_upgrade: self.dsm.reset(self.upgrade) self.upgrade = None @@ -369,12 +431,29 @@ class SynoApi: if self._with_upgrade: self.upgrade = self.dsm.upgrade + if self._with_system: + self.system = self.dsm.system + if self._with_utilisation: self.utilisation = self.dsm.utilisation if self._with_surveillance_station: self.surveillance_station = self.dsm.surveillance_station + async def async_reboot(self): + """Reboot NAS.""" + if not self.system: + _LOGGER.debug("async_reboot - System API not ready: %s", self) + return + self._hass.async_add_executor_job(self.system.reboot) + + async def async_shutdown(self): + """Shutdown NAS.""" + if not self.system: + _LOGGER.debug("async_shutdown - System API not ready: %s", self) + return + self._hass.async_add_executor_job(self.system.shutdown) + async def async_unload(self): """Stop interacting with the NAS and prepare for removal from hass.""" self._unsub_dispatcher() @@ -382,8 +461,17 @@ class SynoApi: async def async_update(self, now=None): """Update function for updating API information.""" self._async_setup_api_requests() - await self._hass.async_add_executor_job(self.dsm.update, self._with_information) - async_dispatcher_send(self._hass, self.signal_sensor_update) + try: + await self._hass.async_add_executor_job( + self.dsm.update, self._with_information + ) + async_dispatcher_send(self._hass, self.signal_sensor_update) + except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: + _LOGGER.warning( + "async_update - connection error during update, fallback by reloading the entry" + ) + _LOGGER.debug("async_update - exception: %s", str(err)) + await self._hass.config_entries.async_reload(self._entry.entry_id) class SynologyDSMEntity(Entity): diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index da792e3a4d3..5a1ab53b3f7 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -164,8 +164,10 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if errors: return await self._show_setup_form(user_input, errors) - # Check if already configured + # unique_id should be serial for services purpose await self.async_set_unique_id(serial, raise_on_progress=False) + + # Check if already configured self._abort_if_unique_id_configured() config_data = { diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 5e5bdf1c2d7..ba1a8034223 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -25,6 +25,7 @@ SYNO_API = "syno_api" UNDO_UPDATE_LISTENER = "undo_update_listener" # Configuration +CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" DEFAULT_USE_SSL = True @@ -42,6 +43,14 @@ ENTITY_ICON = "icon" ENTITY_CLASS = "device_class" ENTITY_ENABLE = "enable" +# Services +SERVICE_REBOOT = "reboot" +SERVICE_SHUTDOWN = "shutdown" +SERVICES = [ + SERVICE_REBOOT, + SERVICE_SHUTDOWN, +] + # Entity keys should start with the API_KEY to fetch # Binary sensors diff --git a/homeassistant/components/synology_dsm/services.yaml b/homeassistant/components/synology_dsm/services.yaml new file mode 100644 index 00000000000..f75b2f0ec8a --- /dev/null +++ b/homeassistant/components/synology_dsm/services.yaml @@ -0,0 +1,15 @@ +# synology-dsm service entries description. + +reboot: + description: Reboot the NAS. + fields: + serial: + description: serial of the NAS to reboot; required when multiple NAS are configured. + example: 1NDVC86409 + +shutdown: + description: Shutdown the NAS. + fields: + serial: + description: serial of the NAS to shutdown; required when multiple NAS are configured. + example: 1NDVC86409 diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index d4fb84f9053..f895ee7e7dc 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -10,6 +10,7 @@ from synology_dsm.exceptions import ( from homeassistant import data_entry_flow, setup from homeassistant.components import ssdp +from homeassistant.components.synology_dsm import _async_setup_services from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( CONF_VOLUMES, @@ -20,6 +21,7 @@ from homeassistant.components.synology_dsm.const import ( DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, + SERVICES, ) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( @@ -496,3 +498,23 @@ async def test_options_flow(hass: HomeAssistantType, service: MagicMock): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options[CONF_SCAN_INTERVAL] == 2 assert config_entry.options[CONF_TIMEOUT] == 30 + + +async def test_services_registered(hass: HomeAssistantType): + """Test if all services are registered.""" + with patch( + "homeassistant.core.ServiceRegistry.async_register", return_value=Mock(True) + ) as async_register: + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + await _async_setup_services(hass) + assert async_register.call_count == len(SERVICES)