Add config flow to Synology DSM (#32704)

* Add config flow to Synology DSM

* Use proper sensor unique ID from flow + sensor name

* Add description to api_version

* Add authentication flow check

* Add device_info

* Add test_login_failed test

* Bump python-synology to 0.5.0

* 0.5.0 test updates

* Use NAS S/N as config_flow unique_id

* Add missed conf disks + volumes

* Review: async_unload the async_track_time_interval

* Fix NoneType for disks or volumes

* Keep all disks and volumes forever

* Update homeassistant/components/synology_dsm/.translations/en.json

Co-Authored-By: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/synology_dsm/strings.json

Co-Authored-By: J. Nick Koston <nick@koston.org>

* Fix "Keep all disks and volumes forever" for empty import

* Fix prettier

* Remove useless LOGGER in config flow

* Fix Synology DSM tests doing I/O

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Quentame 2020-04-06 00:50:52 +02:00 committed by GitHub
parent e3e2e817e5
commit d99e228983
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 876 additions and 290 deletions

View file

@ -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/*

View file

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

View file

@ -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"
}
}

View file

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

View file

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

View file

@ -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"]

View file

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

View file

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

View file

@ -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"
}
}
}

View file

@ -1 +0,0 @@
"""The synologydsm component."""

View file

@ -1,7 +0,0 @@
{
"domain": "synologydsm",
"name": "SynologyDSM",
"documentation": "https://www.home-assistant.io/integrations/synologydsm",
"requirements": ["python-synology==0.4.0"],
"codeowners": []
}

View file

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

View file

@ -110,6 +110,7 @@ FLOWS = [
"sonos",
"spotify",
"starline",
"synology_dsm",
"tellduslive",
"tesla",
"toon",

View file

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

View file

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

View file

@ -0,0 +1 @@
"""Tests for the Synology DSM component."""

View file

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

View file

@ -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"}