diff --git a/.coveragerc b/.coveragerc index c41d5afe169..ca9a6422f1b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -688,8 +688,9 @@ omit = homeassistant/components/syncthru/sensor.py homeassistant/components/synology/camera.py homeassistant/components/synology_chat/notify.py + homeassistant/components/synology_dsm/__init__.py + homeassistant/components/synology_dsm/sensor.py homeassistant/components/synology_srm/device_tracker.py - homeassistant/components/synologydsm/sensor.py homeassistant/components/syslog/notify.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/* diff --git a/CODEOWNERS b/CODEOWNERS index 0ece14799a3..3983ae7364b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -370,6 +370,7 @@ homeassistant/components/switchbot/* @danielhiversen homeassistant/components/switcher_kis/* @tomerfi homeassistant/components/switchmate/* @danielhiversen homeassistant/components/syncthru/* @nielstron +homeassistant/components/synology_dsm/* @ProtoThis @Quentame homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff homeassistant/components/tado/* @michaelarnauts @bdraco diff --git a/homeassistant/components/synology_dsm/.translations/en.json b/homeassistant/components/synology_dsm/.translations/en.json new file mode 100644 index 00000000000..fea5ee3f466 --- /dev/null +++ b/homeassistant/components/synology_dsm/.translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Host already configured" + }, + "error": { + "login": "Login error: please check your username & password", + "unknown": "Unknown error: please retry later or an other configuration" + }, + "step": { + "user": { + "data": { + "api_version": "DSM version", + "host": "Host", + "name": "Name", + "password": "Password", + "port": "Port", + "ssl": "Use SSL/TLS to connect to your NAS", + "username": "Username" + }, + "title": "Synology DSM" + } + }, + "title": "Synology DSM" + } +} diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py new file mode 100644 index 00000000000..e2ada59ec1d --- /dev/null +++ b/homeassistant/components/synology_dsm/__init__.py @@ -0,0 +1,161 @@ +"""The Synology DSM component.""" +from datetime import timedelta + +from synology_dsm import SynologyDSM +from synology_dsm.api.core.utilization import SynoCoreUtilization +from synology_dsm.api.dsm.information import SynoDSMInformation +from synology_dsm.api.storage.storage import SynoStorage +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_API_VERSION, + CONF_DISKS, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType + +from .const import CONF_VOLUMES, DEFAULT_DSM_VERSION, DEFAULT_NAME, DEFAULT_SSL, DOMAIN + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_API_VERSION, default=DEFAULT_DSM_VERSION): cv.positive_int, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DISKS): cv.ensure_list, + vol.Optional(CONF_VOLUMES): cv.ensure_list, + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CONFIG_SCHEMA]))}, + extra=vol.ALLOW_EXTRA, +) + +SCAN_INTERVAL = timedelta(minutes=15) + + +async def async_setup(hass, config): + """Set up Synology DSM sensors from legacy config file.""" + + conf = config.get(DOMAIN) + if conf is None: + return True + + for dsm_conf in conf: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=dsm_conf, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Synology DSM sensors.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + unit = hass.config.units.temperature_unit + use_ssl = entry.data[CONF_SSL] + api_version = entry.data.get(CONF_API_VERSION, DEFAULT_DSM_VERSION) + + api = SynoApi(hass, host, port, username, password, unit, use_ssl, api_version) + + await api.async_setup() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.unique_id] = api + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload Synology DSM sensors.""" + api = hass.data[DOMAIN][entry.unique_id] + await api.async_unload() + return await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + +class SynoApi: + """Class to interface with Synology DSM API.""" + + def __init__( + self, + hass: HomeAssistantType, + host: str, + port: int, + username: str, + password: str, + temp_unit: str, + use_ssl: bool, + api_version: int, + ): + """Initialize the API wrapper class.""" + self._hass = hass + self._host = host + self._port = port + self._username = username + self._password = password + self._use_ssl = use_ssl + self._api_version = api_version + self.temp_unit = temp_unit + + self._dsm: SynologyDSM = None + self.information: SynoDSMInformation = None + self.utilisation: SynoCoreUtilization = None + self.storage: SynoStorage = None + + self._unsub_dispatcher = None + + @property + def signal_sensor_update(self) -> str: + """Event specific per Synology DSM entry to signal updates in sensors.""" + return f"{DOMAIN}-{self.information.serial}-sensor-update" + + async def async_setup(self): + """Start interacting with the NAS.""" + self._dsm = SynologyDSM( + self._host, + self._port, + self._username, + self._password, + self._use_ssl, + dsm_version=self._api_version, + ) + self.information = self._dsm.information + self.utilisation = self._dsm.utilisation + self.storage = self._dsm.storage + + await self.update() + + self._unsub_dispatcher = async_track_time_interval( + self._hass, self.update, SCAN_INTERVAL + ) + + async def async_unload(self): + """Stop interacting with the NAS and prepare for removal from hass.""" + self._unsub_dispatcher() + + async def update(self, now=None): + """Update function for updating API information.""" + await self._hass.async_add_executor_job(self._dsm.update) + async_dispatcher_send(self._hass, self.signal_sensor_update) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py new file mode 100644 index 00000000000..fd23931f13f --- /dev/null +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -0,0 +1,142 @@ +"""Config flow to configure the Synology DSM integration.""" +from synology_dsm import SynologyDSM +from synology_dsm.api.core.utilization import SynoCoreUtilization +from synology_dsm.api.dsm.information import SynoDSMInformation +from synology_dsm.api.storage.storage import SynoStorage +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_API_VERSION, + CONF_DISKS, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) + +from .const import ( + CONF_VOLUMES, + DEFAULT_DSM_VERSION, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_PORT_SSL, + DEFAULT_SSL, +) +from .const import DOMAIN # pylint: disable=unused-import + + +class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Optional(CONF_PORT, default=user_input.get(CONF_PORT, "")): str, + vol.Optional( + CONF_SSL, default=user_input.get(CONF_SSL, DEFAULT_SSL) + ): bool, + vol.Optional( + CONF_API_VERSION, + default=user_input.get(CONF_API_VERSION, DEFAULT_DSM_VERSION), + ): vol.All( + vol.Coerce(int), + vol.In([5, 6]), # DSM versions supported by the library + ), + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + } + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return await self._show_setup_form(user_input, None) + + name = user_input.get(CONF_NAME, DEFAULT_NAME) + host = user_input[CONF_HOST] + port = user_input.get(CONF_PORT) + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + use_ssl = user_input.get(CONF_SSL, DEFAULT_SSL) + api_version = user_input.get(CONF_API_VERSION, DEFAULT_DSM_VERSION) + + if not port: + if use_ssl is True: + port = DEFAULT_PORT_SSL + else: + port = DEFAULT_PORT + + api = SynologyDSM( + host, port, username, password, use_ssl, dsm_version=api_version, + ) + + if not await self.hass.async_add_executor_job(api.login): + errors[CONF_USERNAME] = "login" + return await self._show_setup_form(user_input, errors) + + information: SynoDSMInformation = await self.hass.async_add_executor_job( + getattr, api, "information" + ) + utilisation: SynoCoreUtilization = await self.hass.async_add_executor_job( + getattr, api, "utilisation" + ) + storage: SynoStorage = await self.hass.async_add_executor_job( + getattr, api, "storage" + ) + + if ( + information.serial is None + or utilisation.cpu_user_load is None + or storage.disks_ids is None + or storage.volumes_ids is None + ): + errors["base"] = "unknown" + return await self._show_setup_form(user_input, errors) + + # Check if already configured + await self.async_set_unique_id(information.serial) + self._abort_if_unique_id_configured() + + config_data = { + CONF_NAME: name, + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: use_ssl, + CONF_USERNAME: username, + CONF_PASSWORD: password, + } + if user_input.get(CONF_DISKS): + config_data.update({CONF_DISKS: user_input[CONF_DISKS]}) + if user_input.get(CONF_VOLUMES): + config_data.update({CONF_VOLUMES: user_input[CONF_VOLUMES]}) + + return self.async_create_entry(title=host, data=config_data,) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py new file mode 100644 index 00000000000..7323413636b --- /dev/null +++ b/homeassistant/components/synology_dsm/const.py @@ -0,0 +1,55 @@ +"""Constants for Synology DSM.""" +from homeassistant.const import ( + DATA_MEGABYTES, + DATA_RATE_KILOBYTES_PER_SECOND, + UNIT_PERCENTAGE, +) + +DOMAIN = "synology_dsm" + +CONF_VOLUMES = "volumes" +DEFAULT_NAME = "Synology DSM" +DEFAULT_SSL = True +DEFAULT_PORT = 5000 +DEFAULT_PORT_SSL = 5001 +DEFAULT_DSM_VERSION = 6 + +UTILISATION_SENSORS = { + "cpu_other_load": ["CPU Load (Other)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_user_load": ["CPU Load (User)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_system_load": ["CPU Load (System)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_total_load": ["CPU Load (Total)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_1min_load": ["CPU Load (1 min)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_5min_load": ["CPU Load (5 min)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_15min_load": ["CPU Load (15 min)", UNIT_PERCENTAGE, "mdi:chip"], + "memory_real_usage": ["Memory Usage (Real)", UNIT_PERCENTAGE, "mdi:memory"], + "memory_size": ["Memory Size", DATA_MEGABYTES, "mdi:memory"], + "memory_cached": ["Memory Cached", DATA_MEGABYTES, "mdi:memory"], + "memory_available_swap": ["Memory Available (Swap)", DATA_MEGABYTES, "mdi:memory"], + "memory_available_real": ["Memory Available (Real)", DATA_MEGABYTES, "mdi:memory"], + "memory_total_swap": ["Memory Total (Swap)", DATA_MEGABYTES, "mdi:memory"], + "memory_total_real": ["Memory Total (Real)", DATA_MEGABYTES, "mdi:memory"], + "network_up": ["Network Up", DATA_RATE_KILOBYTES_PER_SECOND, "mdi:upload"], + "network_down": ["Network Down", DATA_RATE_KILOBYTES_PER_SECOND, "mdi:download"], +} +STORAGE_VOL_SENSORS = { + "volume_status": ["Status", None, "mdi:checkbox-marked-circle-outline"], + "volume_device_type": ["Type", None, "mdi:harddisk"], + "volume_size_total": ["Total Size", None, "mdi:chart-pie"], + "volume_size_used": ["Used Space", None, "mdi:chart-pie"], + "volume_percentage_used": ["Volume Used", UNIT_PERCENTAGE, "mdi:chart-pie"], + "volume_disk_temp_avg": ["Average Disk Temp", None, "mdi:thermometer"], + "volume_disk_temp_max": ["Maximum Disk Temp", None, "mdi:thermometer"], +} +STORAGE_DISK_SENSORS = { + "disk_name": ["Name", None, "mdi:harddisk"], + "disk_device": ["Device", None, "mdi:dots-horizontal"], + "disk_smart_status": ["Status (Smart)", None, "mdi:checkbox-marked-circle-outline"], + "disk_status": ["Status", None, "mdi:checkbox-marked-circle-outline"], + "disk_exceed_bad_sector_thr": ["Exceeded Max Bad Sectors", None, "mdi:test-tube"], + "disk_below_remain_life_thr": ["Below Min Remaining Life", None, "mdi:test-tube"], + "disk_temp": ["Temperature", None, "mdi:thermometer"], +} + + +TEMP_SENSORS_KEYS = ["volume_disk_temp_avg", "volume_disk_temp_max", "disk_temp"] diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json new file mode 100644 index 00000000000..a6d171f6528 --- /dev/null +++ b/homeassistant/components/synology_dsm/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "synology_dsm", + "name": "Synology DSM", + "documentation": "https://www.home-assistant.io/integrations/synology_dsm", + "requirements": ["python-synology==0.5.0"], + "codeowners": ["@ProtoThis", "@Quentame"], + "config_flow": true +} diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py new file mode 100644 index 00000000000..aeefbc49893 --- /dev/null +++ b/homeassistant/components/synology_dsm/sensor.py @@ -0,0 +1,201 @@ +"""Support for Synology DSM Sensors.""" +from typing import Dict + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_DISKS, + CONF_NAME, + DATA_MEGABYTES, + DATA_RATE_KILOBYTES_PER_SECOND, + TEMP_CELSIUS, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from . import SynoApi +from .const import ( + CONF_VOLUMES, + DOMAIN, + STORAGE_DISK_SENSORS, + STORAGE_VOL_SENSORS, + TEMP_SENSORS_KEYS, + UTILISATION_SENSORS, +) + +ATTRIBUTION = "Data provided by Synology" + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Synology NAS Sensor.""" + name = entry.data[CONF_NAME] + + api = hass.data[DOMAIN][entry.unique_id] + + sensors = [ + SynoNasUtilSensor(api, name, sensor_type, UTILISATION_SENSORS[sensor_type]) + for sensor_type in UTILISATION_SENSORS + ] + + # Handle all volumes + if api.storage.volumes_ids: + for volume in entry.data.get(CONF_VOLUMES, api.storage.volumes_ids): + sensors += [ + SynoNasStorageSensor( + api, name, sensor_type, STORAGE_VOL_SENSORS[sensor_type], volume + ) + for sensor_type in STORAGE_VOL_SENSORS + ] + + # Handle all disks + if api.storage.disks_ids: + for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): + sensors += [ + SynoNasStorageSensor( + api, name, sensor_type, STORAGE_DISK_SENSORS[sensor_type], disk + ) + for sensor_type in STORAGE_DISK_SENSORS + ] + + async_add_entities(sensors, True) + + +class SynoNasSensor(Entity): + """Representation of a Synology NAS Sensor.""" + + def __init__( + self, + api: SynoApi, + name: str, + sensor_type: str, + sensor_info: Dict[str, str], + monitored_device: str = None, + ): + """Initialize the sensor.""" + self._api = api + self.sensor_type = sensor_type + self._name = f"{name} {sensor_info[0]}" + self._unit = sensor_info[1] + self._icon = sensor_info[2] + self.monitored_device = monitored_device + + if self.monitored_device: + self._name = f"{self._name} ({self.monitored_device})" + + self._unique_id = f"{self._api.information.serial} {self._name}" + + self._unsub_dispatcher = None + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name.""" + return self._name + + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon + + @property + def unit_of_measurement(self) -> str: + """Return the unit the value is expressed in.""" + if self.sensor_type in TEMP_SENSORS_KEYS: + return self._api.temp_unit + return self._unit + + @property + def device_state_attributes(self) -> Dict[str, any]: + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "identifiers": {(DOMAIN, self._api.information.serial)}, + "name": "Synology NAS", + "manufacturer": "Synology", + "model": self._api.information.model, + "sw_version": self._api.information.version_string, + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, self._api.signal_sensor_update, self.async_write_ha_state + ) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() + + +class SynoNasUtilSensor(SynoNasSensor): + """Representation a Synology Utilisation Sensor.""" + + @property + def state(self): + """Return the state.""" + if self._unit == DATA_RATE_KILOBYTES_PER_SECOND or self._unit == DATA_MEGABYTES: + attr = getattr(self._api.utilisation, self.sensor_type)(False) + + if attr is None: + return None + + if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: + return round(attr / 1024.0, 1) + if self._unit == DATA_MEGABYTES: + return round(attr / 1024.0 / 1024.0, 1) + else: + return getattr(self._api.utilisation, self.sensor_type) + + +class SynoNasStorageSensor(SynoNasSensor): + """Representation a Synology Storage Sensor.""" + + @property + def state(self): + """Return the state.""" + if self.monitored_device: + if self.sensor_type in TEMP_SENSORS_KEYS: + attr = getattr(self._api.storage, self.sensor_type)( + self.monitored_device + ) + + if attr is None: + return None + + if self._api.temp_unit == TEMP_CELSIUS: + return attr + + return round(attr * 1.8 + 32.0, 1) + + return getattr(self._api.storage, self.sensor_type)(self.monitored_device) + return None + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "identifiers": { + (DOMAIN, self._api.information.serial, self.monitored_device) + }, + "name": f"Synology NAS ({self.monitored_device})", + "manufacturer": "Synology", + "model": self._api.information.model, + "sw_version": self._api.information.version_string, + "via_device": (DOMAIN, self._api.information.serial), + } diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json new file mode 100644 index 00000000000..b9ccf8d1010 --- /dev/null +++ b/homeassistant/components/synology_dsm/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "Synology DSM", + "step": { + "user": { + "title": "Synology DSM", + "data": { + "name": "Name", + "host": "Host", + "port": "Port", + "ssl": "Use SSL/TLS to connect to your NAS", + "api_version": "DSM version", + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "login": "Login error: please check your username & password", + "unknown": "Unknown error: please retry later or an other configuration" + }, + "abort": { + "already_configured": "Host already configured" + } + } +} diff --git a/homeassistant/components/synologydsm/__init__.py b/homeassistant/components/synologydsm/__init__.py deleted file mode 100644 index 137a3975b99..00000000000 --- a/homeassistant/components/synologydsm/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The synologydsm component.""" diff --git a/homeassistant/components/synologydsm/manifest.json b/homeassistant/components/synologydsm/manifest.json deleted file mode 100644 index 1173d2de699..00000000000 --- a/homeassistant/components/synologydsm/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "synologydsm", - "name": "SynologyDSM", - "documentation": "https://www.home-assistant.io/integrations/synologydsm", - "requirements": ["python-synology==0.4.0"], - "codeowners": [] -} diff --git a/homeassistant/components/synologydsm/sensor.py b/homeassistant/components/synologydsm/sensor.py deleted file mode 100644 index 84921b3b8d3..00000000000 --- a/homeassistant/components/synologydsm/sensor.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Support for Synology NAS Sensors.""" -from datetime import timedelta -import logging - -from SynologyDSM import SynologyDSM -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_API_VERSION, - CONF_DISKS, - CONF_HOST, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_SSL, - CONF_USERNAME, - DATA_MEGABYTES, - DATA_RATE_KILOBYTES_PER_SECOND, - EVENT_HOMEASSISTANT_START, - TEMP_CELSIUS, - UNIT_PERCENTAGE, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Data provided by Synology" - -CONF_VOLUMES = "volumes" -DEFAULT_NAME = "Synology DSM" -DEFAULT_PORT = 5001 - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - -_UTILISATION_MON_COND = { - "cpu_other_load": ["CPU Load (Other)", UNIT_PERCENTAGE, "mdi:chip"], - "cpu_user_load": ["CPU Load (User)", UNIT_PERCENTAGE, "mdi:chip"], - "cpu_system_load": ["CPU Load (System)", UNIT_PERCENTAGE, "mdi:chip"], - "cpu_total_load": ["CPU Load (Total)", UNIT_PERCENTAGE, "mdi:chip"], - "cpu_1min_load": ["CPU Load (1 min)", UNIT_PERCENTAGE, "mdi:chip"], - "cpu_5min_load": ["CPU Load (5 min)", UNIT_PERCENTAGE, "mdi:chip"], - "cpu_15min_load": ["CPU Load (15 min)", UNIT_PERCENTAGE, "mdi:chip"], - "memory_real_usage": ["Memory Usage (Real)", UNIT_PERCENTAGE, "mdi:memory"], - "memory_size": ["Memory Size", DATA_MEGABYTES, "mdi:memory"], - "memory_cached": ["Memory Cached", DATA_MEGABYTES, "mdi:memory"], - "memory_available_swap": ["Memory Available (Swap)", DATA_MEGABYTES, "mdi:memory"], - "memory_available_real": ["Memory Available (Real)", DATA_MEGABYTES, "mdi:memory"], - "memory_total_swap": ["Memory Total (Swap)", DATA_MEGABYTES, "mdi:memory"], - "memory_total_real": ["Memory Total (Real)", DATA_MEGABYTES, "mdi:memory"], - "network_up": ["Network Up", DATA_RATE_KILOBYTES_PER_SECOND, "mdi:upload"], - "network_down": ["Network Down", DATA_RATE_KILOBYTES_PER_SECOND, "mdi:download"], -} -_STORAGE_VOL_MON_COND = { - "volume_status": ["Status", None, "mdi:checkbox-marked-circle-outline"], - "volume_device_type": ["Type", None, "mdi:harddisk"], - "volume_size_total": ["Total Size", None, "mdi:chart-pie"], - "volume_size_used": ["Used Space", None, "mdi:chart-pie"], - "volume_percentage_used": ["Volume Used", UNIT_PERCENTAGE, "mdi:chart-pie"], - "volume_disk_temp_avg": ["Average Disk Temp", None, "mdi:thermometer"], - "volume_disk_temp_max": ["Maximum Disk Temp", None, "mdi:thermometer"], -} -_STORAGE_DSK_MON_COND = { - "disk_name": ["Name", None, "mdi:harddisk"], - "disk_device": ["Device", None, "mdi:dots-horizontal"], - "disk_smart_status": ["Status (Smart)", None, "mdi:checkbox-marked-circle-outline"], - "disk_status": ["Status", None, "mdi:checkbox-marked-circle-outline"], - "disk_exceed_bad_sector_thr": ["Exceeded Max Bad Sectors", None, "mdi:test-tube"], - "disk_below_remain_life_thr": ["Below Min Remaining Life", None, "mdi:test-tube"], - "disk_temp": ["Temperature", None, "mdi:thermometer"], -} - -_MONITORED_CONDITIONS = ( - list(_UTILISATION_MON_COND.keys()) - + list(_STORAGE_VOL_MON_COND.keys()) - + list(_STORAGE_DSK_MON_COND.keys()) -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=True): cv.boolean, - vol.Optional(CONF_API_VERSION): cv.positive_int, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)] - ), - vol.Optional(CONF_DISKS): cv.ensure_list, - vol.Optional(CONF_VOLUMES): cv.ensure_list, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Synology NAS Sensor.""" - - def run_setup(event): - """Wait until Home Assistant is fully initialized before creating. - - Delay the setup until Home Assistant is fully initialized. - This allows any entities to be created already - """ - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - use_ssl = config.get(CONF_SSL) - unit = hass.config.units.temperature_unit - monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) - api_version = config.get(CONF_API_VERSION) - - api = SynoApi(host, port, username, password, unit, use_ssl, api_version) - - sensors = [ - SynoNasUtilSensor(api, name, variable, _UTILISATION_MON_COND[variable]) - for variable in monitored_conditions - if variable in _UTILISATION_MON_COND - ] - - # Handle all volumes - if api.storage.volumes is not None: - for volume in config.get(CONF_VOLUMES, api.storage.volumes): - sensors += [ - SynoNasStorageSensor( - api, name, variable, _STORAGE_VOL_MON_COND[variable], volume - ) - for variable in monitored_conditions - if variable in _STORAGE_VOL_MON_COND - ] - - # Handle all disks - if api.storage.disks is not None: - for disk in config.get(CONF_DISKS, api.storage.disks): - sensors += [ - SynoNasStorageSensor( - api, name, variable, _STORAGE_DSK_MON_COND[variable], disk - ) - for variable in monitored_conditions - if variable in _STORAGE_DSK_MON_COND - ] - - add_entities(sensors, True) - - # Wait until start event is sent to load this component. - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) - - -class SynoApi: - """Class to interface with Synology DSM API.""" - - def __init__(self, host, port, username, password, temp_unit, use_ssl, api_version): - """Initialize the API wrapper class.""" - - self.temp_unit = temp_unit - - try: - self._api = SynologyDSM( - host, - port, - username, - password, - use_https=use_ssl, - debugmode=False, - dsm_version=api_version, - ) - except: # noqa: E722 pylint: disable=bare-except - _LOGGER.error("Error setting up Synology DSM") - - # Will be updated when update() gets called. - self.utilisation = self._api.utilisation - self.storage = self._api.storage - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update function for updating api information.""" - self._api.update() - - -class SynoNasSensor(Entity): - """Representation of a Synology NAS Sensor.""" - - def __init__(self, api, name, variable, variable_info, monitor_device=None): - """Initialize the sensor.""" - self.var_id = variable - self.var_name = "{} {}".format(name, variable_info[0]) - self.var_units = variable_info[1] - self.var_icon = variable_info[2] - self.monitor_device = monitor_device - self._api = api - - @property - def name(self): - """Return the name of the sensor, if any.""" - if self.monitor_device is not None: - return f"{self.var_name} ({self.monitor_device})" - return self.var_name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self.var_icon - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - if self.var_id in ["volume_disk_temp_avg", "volume_disk_temp_max", "disk_temp"]: - return self._api.temp_unit - return self.var_units - - def update(self): - """Get the latest data for the states.""" - if self._api is not None: - self._api.update() - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - - -class SynoNasUtilSensor(SynoNasSensor): - """Representation a Synology Utilisation Sensor.""" - - @property - def state(self): - """Return the state of the sensor.""" - network_sensors = ["network_up", "network_down"] - memory_sensors = [ - "memory_size", - "memory_cached", - "memory_available_swap", - "memory_available_real", - "memory_total_swap", - "memory_total_real", - ] - - if self.var_id in network_sensors or self.var_id in memory_sensors: - attr = getattr(self._api.utilisation, self.var_id)(False) - - if attr is None: - return None - - if self.var_id in network_sensors: - return round(attr / 1024.0, 1) - if self.var_id in memory_sensors: - return round(attr / 1024.0 / 1024.0, 1) - else: - return getattr(self._api.utilisation, self.var_id) - - -class SynoNasStorageSensor(SynoNasSensor): - """Representation a Synology Utilisation Sensor.""" - - @property - def state(self): - """Return the state of the sensor.""" - temp_sensors = ["volume_disk_temp_avg", "volume_disk_temp_max", "disk_temp"] - - if self.monitor_device is not None: - if self.var_id in temp_sensors: - attr = getattr(self._api.storage, self.var_id)(self.monitor_device) - - if attr is None: - return None - - if self._api.temp_unit == TEMP_CELSIUS: - return attr - - return round(attr * 1.8 + 32.0, 1) - - return getattr(self._api.storage, self.var_id)(self.monitor_device) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e00cd1b5936..4d4509d7443 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -110,6 +110,7 @@ FLOWS = [ "sonos", "spotify", "starline", + "synology_dsm", "tellduslive", "tesla", "toon", diff --git a/requirements_all.txt b/requirements_all.txt index 48322760753..b755c16d90b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1666,8 +1666,8 @@ python-sochain-api==0.0.2 # homeassistant.components.songpal python-songpal==0.11.2 -# homeassistant.components.synologydsm -python-synology==0.4.0 +# homeassistant.components.synology_dsm +python-synology==0.5.0 # homeassistant.components.tado python-tado==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b064ca1b411..0e3be2edc4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -630,6 +630,9 @@ python-miio==0.5.0.1 # homeassistant.components.nest python-nest==4.1.0 +# homeassistant.components.synology_dsm +python-synology==0.5.0 + # homeassistant.components.tado python-tado==0.6.0 diff --git a/tests/components/synology_dsm/__init__.py b/tests/components/synology_dsm/__init__.py new file mode 100644 index 00000000000..c4710e59df9 --- /dev/null +++ b/tests/components/synology_dsm/__init__.py @@ -0,0 +1 @@ +"""Tests for the Synology DSM component.""" diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py new file mode 100644 index 00000000000..7829a3cc999 --- /dev/null +++ b/tests/components/synology_dsm/conftest.py @@ -0,0 +1,13 @@ +"""Configure Synology DSM tests.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(name="dsm_bypass_setup", autouse=True) +def dsm_bypass_setup_fixture(): + """Mock component setup.""" + with patch( + "homeassistant.components.synology_dsm.async_setup_entry", return_value=True + ): + yield diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py new file mode 100644 index 00000000000..664ffda7f8e --- /dev/null +++ b/tests/components/synology_dsm/test_config_flow.py @@ -0,0 +1,234 @@ +"""Tests for the Synology DSM config flow.""" +import logging +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.synology_dsm.const import ( + CONF_VOLUMES, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_PORT_SSL, + DEFAULT_SSL, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_DISKS, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +NAME = "My Syno" +HOST = "nas.meontheinternet.com" +SERIAL = "mySerial" +HOST_2 = "nas.worldwide.me" +SERIAL_2 = "mySerial2" +PORT = 1234 +SSL = True +USERNAME = "Home_Assistant" +PASSWORD = "password" + + +@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.login = Mock(return_value=True) + service_mock.return_value.information = Mock(serial=SERIAL) + service_mock.return_value.utilisation = Mock(cpu_user_load=1) + service_mock.return_value.storage = Mock(disks_ids=[], volumes_ids=[]) + yield service_mock + + +@pytest.fixture(name="service_login_failed") +def mock_controller_service_login_failed(): + """Mock a failed login.""" + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM" + ) as service_mock: + service_mock.return_value.login = Mock(return_value=False) + yield service_mock + + +@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.login = Mock(return_value=True) + service_mock.return_value.information = Mock(serial=None) + service_mock.return_value.utilisation = Mock(cpu_user_load=None) + service_mock.return_value.storage = Mock(disks_ids=None, volumes_ids=None) + yield service_mock + + +async def test_user(hass: HomeAssistantType, service: MagicMock): + """Test user config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=None + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # test with all provided + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == HOST + assert result["data"][CONF_NAME] == NAME + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_SSL] == SSL + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"].get(CONF_DISKS) is None + assert result["data"].get(CONF_VOLUMES) is None + + service.return_value.information = Mock(serial=SERIAL_2) + # test without port + False SSL + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_SSL: False, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL_2 + assert result["title"] == HOST + assert result["data"][CONF_NAME] == NAME + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == DEFAULT_PORT + assert not result["data"][CONF_SSL] + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"].get(CONF_DISKS) is None + assert result["data"].get(CONF_VOLUMES) is None + + +async def test_import(hass: HomeAssistantType, service: MagicMock): + """Test import step.""" + # import with minimum setup + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == HOST + assert result["data"][CONF_NAME] == DEFAULT_NAME + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == DEFAULT_PORT_SSL + assert result["data"][CONF_SSL] == DEFAULT_SSL + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"].get(CONF_DISKS) is None + assert result["data"].get(CONF_VOLUMES) is None + + service.return_value.information = Mock(serial=SERIAL_2) + # import with all + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: NAME, + CONF_HOST: HOST_2, + CONF_PORT: PORT, + CONF_SSL: SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_DISKS: ["sda", "sdb", "sdc"], + CONF_VOLUMES: ["volume_1"], + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL_2 + assert result["title"] == HOST_2 + assert result["data"][CONF_NAME] == NAME + assert result["data"][CONF_HOST] == HOST_2 + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_SSL] == SSL + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_DISKS] == ["sda", "sdb", "sdc"] + assert result["data"][CONF_VOLUMES] == ["volume_1"] + + +async def test_abort_if_already_setup(hass: HomeAssistantType, service: MagicMock): + """Test we abort if the account is already setup.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + unique_id=SERIAL, + ).add_to_hass(hass) + + # Should fail, same HOST:PORT (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Should fail, same HOST:PORT (flow) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_login_failed(hass: HomeAssistantType, service_login_failed: MagicMock): + """Test when we have errors during connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_USERNAME: "login"} + + +async def test_connection_failed(hass: HomeAssistantType, service_failed: MagicMock): + """Test when we have errors during connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"}