From 26cbca101ade36886ef387b66d944d02c24733f2 Mon Sep 17 00:00:00 2001 From: Quentame Date: Tue, 2 Jun 2020 18:22:51 +0200 Subject: [PATCH] Refactor Synology entries to allow not fetching the API when it's disabled + add security binary sensor (#35565) - add Synology DSM Security binary sensor (enabled by default) - use device name instead of id in names - add device type to name - show disk manufacturer, model and firmware version in devices - some entries are disabled by default (`entity_registry_enabled_default`) - binary sensor + sensor uses `device_class` when possible - do not fetch a concerned API if all entries of it are disabled - entity unique_id now uses key instead of label - entity entity_id changes for disk and volume: example from `sensor.synology_status_sda` to `sensor.synology_drive_1_status`, or from `sensor.synology_average_disk_temp_volume_1` to `sensor.synology_volume_1_average_disk_temp` - now binary sensor: - disk_exceed_bad_sector_thr - disk_below_remain_life_thr - removed sensor: - volume type (RAID, SHR ...) - disk name (Drive [X]) - disk device (/dev/sd[Y]) --- .coveragerc | 1 + .../components/synology_dsm/__init__.py | 326 +++++++++++++++++- .../components/synology_dsm/binary_sensor.py | 66 ++++ .../components/synology_dsm/const.py | 251 ++++++++++++-- .../components/synology_dsm/sensor.py | 138 +------- 5 files changed, 622 insertions(+), 160 deletions(-) create mode 100644 homeassistant/components/synology_dsm/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index e49103c3b3b..e17ba7a6503 100644 --- a/.coveragerc +++ b/.coveragerc @@ -757,6 +757,7 @@ omit = homeassistant/components/synology/camera.py homeassistant/components/synology_chat/notify.py homeassistant/components/synology_dsm/__init__.py + homeassistant/components/synology_dsm/binary_sensor.py homeassistant/components/synology_dsm/sensor.py homeassistant/components/synology_srm/device_tracker.py homeassistant/components/syslog/notify.py diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index b2ff2d2e8ef..d4dbecf8f1c 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -1,7 +1,11 @@ """The Synology DSM component.""" +import asyncio from datetime import timedelta +import logging +from typing import Dict from synology_dsm import SynologyDSM +from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.storage.storage import SynoStorage @@ -9,6 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_DISKS, CONF_HOST, CONF_MAC, @@ -18,18 +23,36 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.core import callback +from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType from .const import ( + BASE_NAME, CONF_VOLUMES, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN, + ENTITY_CLASS, + ENTITY_ENABLE, + ENTITY_ICON, + ENTITY_NAME, + ENTITY_UNIT, + PLATFORMS, + STORAGE_DISK_BINARY_SENSORS, + STORAGE_DISK_SENSORS, + STORAGE_VOL_SENSORS, SYNO_API, + TEMP_SENSORS_KEYS, UNDO_UPDATE_LISTENER, + UTILISATION_SENSORS, ) CONFIG_SCHEMA = vol.Schema( @@ -49,6 +72,11 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +ATTRIBUTION = "Data provided by Synology" + + +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass, config): """Set up Synology DSM sensors from legacy config file.""" @@ -71,6 +99,65 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Set up Synology DSM sensors.""" api = SynoApi(hass, entry) + # Migrate old unique_id + @callback + def _async_migrator(entity_entry: entity_registry.RegistryEntry): + """Migrate away from ID using label.""" + # Reject if new unique_id + if "SYNO." in entity_entry.unique_id: + return None + + entries = { + **STORAGE_DISK_BINARY_SENSORS, + **STORAGE_DISK_SENSORS, + **STORAGE_VOL_SENSORS, + **UTILISATION_SENSORS, + } + infos = entity_entry.unique_id.split("_") + serial = infos.pop(0) + label = infos.pop(0) + device_id = "_".join(infos) + + # Removed entity + if ( + "Type" in entity_entry.unique_id + or "Device" in entity_entry.unique_id + or "Name" in entity_entry.unique_id + ): + return None + + entity_type = None + for entity_key, entity_attrs in entries.items(): + if ( + device_id + and entity_attrs[ENTITY_NAME] == "Status" + and "Status" in entity_entry.unique_id + and "(Smart)" not in entity_entry.unique_id + ): + if "sd" in device_id and "disk" in entity_key: + entity_type = entity_key + continue + if "volume" in device_id and "volume" in entity_key: + entity_type = entity_key + continue + + if entity_attrs[ENTITY_NAME] == label: + entity_type = entity_key + + new_unique_id = "_".join([serial, entity_type]) + if device_id: + new_unique_id += f"_{device_id}" + + _LOGGER.info( + "Migrating unique_id from [%s] to [%s]", + entity_entry.unique_id, + new_unique_id, + ) + return {"new_unique_id": new_unique_id} + + await entity_registry.async_migrate_entries(hass, entry.entry_id, _async_migrator) + + # Continue setup await api.async_setup() undo_listener = entry.add_update_listener(_async_update_listener) @@ -88,16 +175,24 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): entry, data={**entry.data, CONF_MAC: network.macs} ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "sensor") - ) + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) return True async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): """Unload Synology DSM sensors.""" - unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor") + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) if unload_ok: entry_data = hass.data[DOMAIN][entry.unique_id] @@ -121,10 +216,18 @@ class SynoApi: self._hass = hass self._entry = entry + # DSM APIs self.dsm: SynologyDSM = None self.information: SynoDSMInformation = None - self.utilisation: SynoCoreUtilization = None + self.security: SynoCoreSecurity = None self.storage: SynoStorage = None + self.utilisation: SynoCoreUtilization = None + + # Should we fetch them + self._fetching_entities = {} + self._with_security = True + self._with_storage = True + self._with_utilisation = True self._unsub_dispatcher = None @@ -144,12 +247,14 @@ class SynoApi: device_token=self._entry.data.get("device_token"), ) + self._async_setup_api_requests() + await self._hass.async_add_executor_job(self._fetch_device_configuration) - await self.update() + await self.async_update() self._unsub_dispatcher = async_track_time_interval( self._hass, - self.update, + self.async_update, timedelta( minutes=self._entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL @@ -157,17 +262,216 @@ class SynoApi: ), ) + @callback + def subscribe(self, api_key, unique_id): + """Subscribe an entity from API fetches.""" + if api_key not in self._fetching_entities: + self._fetching_entities[api_key] = set() + self._fetching_entities[api_key].add(unique_id) + + @callback + def unsubscribe() -> None: + """Unsubscribe an entity from API fetches (when disable).""" + self._fetching_entities[api_key].remove(unique_id) + + return unsubscribe + + @callback + def _async_setup_api_requests(self): + """Determine if we should fetch each API, if one entity needs it.""" + # Entities not added yet, fetch all + if not self._fetching_entities: + return + + # Determine if we should fetch an API + self._with_security = bool( + self._fetching_entities.get(SynoCoreSecurity.API_KEY) + ) + self._with_storage = bool(self._fetching_entities.get(SynoStorage.API_KEY)) + self._with_utilisation = bool( + self._fetching_entities.get(SynoCoreUtilization.API_KEY) + ) + + # Reset not used API + if not self._with_security: + self.dsm.reset(self.security) + self.security = None + + if not self._with_storage: + self.dsm.reset(self.storage) + self.storage = None + + if not self._with_utilisation: + self.dsm.reset(self.utilisation) + self.utilisation = None + def _fetch_device_configuration(self): """Fetch initial device config.""" self.information = self.dsm.information - self.utilisation = self.dsm.utilisation - self.storage = self.dsm.storage + + if self._with_security: + self.security = self.dsm.security + + if self._with_storage: + self.storage = self.dsm.storage + + if self._with_utilisation: + self.utilisation = self.dsm.utilisation 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): + 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) async_dispatcher_send(self._hass, self.signal_sensor_update) + + +class SynologyDSMEntity(Entity): + """Representation of a Synology NAS entry.""" + + def __init__( + self, api: SynoApi, entity_type: str, entity_info: Dict[str, str], + ): + """Initialize the Synology DSM entity.""" + self._api = api + self._api_key = entity_type.split(":")[0] + self.entity_type = entity_type.split(":")[-1] + self._name = f"{BASE_NAME} {entity_info[ENTITY_NAME]}" + self._class = entity_info[ENTITY_CLASS] + self._enable_default = entity_info[ENTITY_ENABLE] + self._icon = entity_info[ENTITY_ICON] + self._unit = entity_info[ENTITY_UNIT] + self._unique_id = f"{self._api.information.serial}_{entity_type}" + + @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.entity_type in TEMP_SENSORS_KEYS: + return self.hass.config.units.temperature_unit + return self._unit + + @property + def device_class(self) -> str: + """Return the class of this device.""" + return self._class + + @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 entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enable_default + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_update(self): + """Only used by the generic entity update service.""" + if not self.enabled: + return + + await self._api.async_update() + + async def async_added_to_hass(self): + """Register state update callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, self._api.signal_sensor_update, self.async_write_ha_state + ) + ) + + self.async_on_remove(self._api.subscribe(self._api_key, self.unique_id)) + + +class SynologyDSMDeviceEntity(SynologyDSMEntity): + """Representation of a Synology NAS disk or volume entry.""" + + def __init__( + self, + api: SynoApi, + entity_type: str, + entity_info: Dict[str, str], + device_id: str = None, + ): + """Initialize the Synology DSM disk or volume entity.""" + super().__init__(api, entity_type, entity_info) + self._device_id = device_id + self._device_name = None + self._device_manufacturer = None + self._device_model = None + self._device_firmware = None + self._device_type = None + + if "volume" in entity_type: + volume = self._api.storage._get_volume(self._device_id) + # Volume does not have a name + self._device_name = volume["id"].replace("_", " ").capitalize() + self._device_manufacturer = "Synology" + self._device_model = self._api.information.model + self._device_firmware = self._api.information.version_string + self._device_type = ( + volume["device_type"] + .replace("_", " ") + .replace("raid", "RAID") + .replace("shr", "SHR") + ) + elif "disk" in entity_type: + disk = self._api.storage._get_disk(self._device_id) + self._device_name = disk["name"] + self._device_manufacturer = disk["vendor"] + self._device_model = disk["model"].strip() + self._device_firmware = disk["firm"] + self._device_type = disk["diskType"] + self._name = f"{BASE_NAME} {self._device_name} {entity_info[ENTITY_NAME]}" + self._unique_id += f"_{self._device_id}" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return bool(self._api.storage) + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "identifiers": {(DOMAIN, self._api.information.serial, self._device_id)}, + "name": f"Synology NAS ({self._device_name} - {self._device_type})", + "manufacturer": self._device_manufacturer, + "model": self._device_model, + "sw_version": self._device_firmware, + "via_device": (DOMAIN, self._api.information.serial), + } diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py new file mode 100644 index 00000000000..3dfc21b8a7b --- /dev/null +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -0,0 +1,66 @@ +"""Support for Synology DSM binary sensors.""" +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DISKS +from homeassistant.helpers.typing import HomeAssistantType + +from . import SynologyDSMDeviceEntity, SynologyDSMEntity +from .const import ( + DOMAIN, + SECURITY_BINARY_SENSORS, + STORAGE_DISK_BINARY_SENSORS, + SYNO_API, +) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Synology NAS binary sensor.""" + + api = hass.data[DOMAIN][entry.unique_id][SYNO_API] + + entities = [ + SynoDSMSecurityBinarySensor( + api, sensor_type, SECURITY_BINARY_SENSORS[sensor_type] + ) + for sensor_type in SECURITY_BINARY_SENSORS + ] + + # Handle all disks + if api.storage.disks_ids: + for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): + entities += [ + SynoDSMStorageBinarySensor( + api, sensor_type, STORAGE_DISK_BINARY_SENSORS[sensor_type], disk + ) + for sensor_type in STORAGE_DISK_BINARY_SENSORS + ] + + async_add_entities(entities) + + +class SynoDSMSecurityBinarySensor(SynologyDSMEntity, BinarySensorEntity): + """Representation a Synology Security binary sensor.""" + + @property + def is_on(self) -> bool: + """Return the state.""" + return getattr(self._api.security, self.entity_type) != "safe" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return bool(self._api.security) + + +class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, BinarySensorEntity): + """Representation a Synology Storage binary sensor.""" + + @property + def is_on(self) -> bool: + """Return the state.""" + attr = getattr(self._api.storage, self.entity_type)(self._device_id) + if attr is None: + return None + return attr diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index e0a166e908b..c525bec2229 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -1,4 +1,9 @@ """Constants for Synology DSM.""" + +from synology_dsm.api.core.security import SynoCoreSecurity +from synology_dsm.api.core.utilization import SynoCoreUtilization +from synology_dsm.api.storage.storage import SynoStorage + from homeassistant.const import ( DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, @@ -7,6 +12,8 @@ from homeassistant.const import ( ) DOMAIN = "synology_dsm" +PLATFORMS = ["binary_sensor", "sensor"] + BASE_NAME = "Synology" # Entry keys @@ -15,47 +22,231 @@ UNDO_UPDATE_LISTENER = "undo_update_listener" # Configuration CONF_VOLUMES = "volumes" + DEFAULT_SSL = True DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 # Options DEFAULT_SCAN_INTERVAL = 15 # min + +ENTITY_NAME = "name" +ENTITY_UNIT = "unit" +ENTITY_ICON = "icon" +ENTITY_CLASS = "device_class" +ENTITY_ENABLE = "enable" + +# Entity keys should start with the API_KEY to fetch + +# Binary sensors +STORAGE_DISK_BINARY_SENSORS = { + f"{SynoStorage.API_KEY}:disk_exceed_bad_sector_thr": { + ENTITY_NAME: "Exceeded Max Bad Sectors", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:test-tube", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoStorage.API_KEY}:disk_below_remain_life_thr": { + ENTITY_NAME: "Below Min Remaining Life", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:test-tube", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, +} + +SECURITY_BINARY_SENSORS = { + f"{SynoCoreSecurity.API_KEY}:status": { + ENTITY_NAME: "Security status", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:checkbox-marked-circle-outline", + ENTITY_CLASS: "safety", + ENTITY_ENABLE: True, + }, +} + +# Sensors 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"], + f"{SynoCoreUtilization.API_KEY}:cpu_other_load": { + ENTITY_NAME: "CPU Load (Other)", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:chip", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + }, + f"{SynoCoreUtilization.API_KEY}:cpu_user_load": { + ENTITY_NAME: "CPU Load (User)", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:chip", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:cpu_system_load": { + ENTITY_NAME: "CPU Load (System)", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:chip", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + }, + f"{SynoCoreUtilization.API_KEY}:cpu_total_load": { + ENTITY_NAME: "CPU Load (Total)", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:chip", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:cpu_1min_load": { + ENTITY_NAME: "CPU Load (1 min)", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:chip", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + }, + f"{SynoCoreUtilization.API_KEY}:cpu_5min_load": { + ENTITY_NAME: "CPU Load (5 min)", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:chip", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:cpu_15min_load": { + ENTITY_NAME: "CPU Load (15 min)", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:chip", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:memory_real_usage": { + ENTITY_NAME: "Memory Usage (Real)", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:memory", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:memory_size": { + ENTITY_NAME: "Memory Size", + ENTITY_UNIT: DATA_MEGABYTES, + ENTITY_ICON: "mdi:memory", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + }, + f"{SynoCoreUtilization.API_KEY}:memory_cached": { + ENTITY_NAME: "Memory Cached", + ENTITY_UNIT: DATA_MEGABYTES, + ENTITY_ICON: "mdi:memory", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + }, + f"{SynoCoreUtilization.API_KEY}:memory_available_swap": { + ENTITY_NAME: "Memory Available (Swap)", + ENTITY_UNIT: DATA_MEGABYTES, + ENTITY_ICON: "mdi:memory", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:memory_available_real": { + ENTITY_NAME: "Memory Available (Real)", + ENTITY_UNIT: DATA_MEGABYTES, + ENTITY_ICON: "mdi:memory", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:memory_total_swap": { + ENTITY_NAME: "Memory Total (Swap)", + ENTITY_UNIT: DATA_MEGABYTES, + ENTITY_ICON: "mdi:memory", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:memory_total_real": { + ENTITY_NAME: "Memory Total (Real)", + ENTITY_UNIT: DATA_MEGABYTES, + ENTITY_ICON: "mdi:memory", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:network_up": { + ENTITY_NAME: "Network Up", + ENTITY_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, + ENTITY_ICON: "mdi:upload", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoCoreUtilization.API_KEY}:network_down": { + ENTITY_NAME: "Network Down", + ENTITY_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, + ENTITY_ICON: "mdi:download", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, } STORAGE_VOL_SENSORS = { - "volume_status": ["Status", None, "mdi:checkbox-marked-circle-outline"], - "volume_device_type": ["Type", None, "mdi:harddisk"], - "volume_size_total": ["Total Size", DATA_TERABYTES, "mdi:chart-pie"], - "volume_size_used": ["Used Space", DATA_TERABYTES, "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"], + f"{SynoStorage.API_KEY}:volume_status": { + ENTITY_NAME: "Status", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:checkbox-marked-circle-outline", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoStorage.API_KEY}:volume_size_total": { + ENTITY_NAME: "Total Size", + ENTITY_UNIT: DATA_TERABYTES, + ENTITY_ICON: "mdi:chart-pie", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + }, + f"{SynoStorage.API_KEY}:volume_size_used": { + ENTITY_NAME: "Used Space", + ENTITY_UNIT: DATA_TERABYTES, + ENTITY_ICON: "mdi:chart-pie", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoStorage.API_KEY}:volume_percentage_used": { + ENTITY_NAME: "Volume Used", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:chart-pie", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoStorage.API_KEY}:volume_disk_temp_avg": { + ENTITY_NAME: "Average Disk Temp", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:thermometer", + ENTITY_CLASS: "temperature", + ENTITY_ENABLE: True, + }, + f"{SynoStorage.API_KEY}:volume_disk_temp_max": { + ENTITY_NAME: "Maximum Disk Temp", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:thermometer", + ENTITY_CLASS: "temperature", + ENTITY_ENABLE: False, + }, } 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"], + f"{SynoStorage.API_KEY}:disk_smart_status": { + ENTITY_NAME: "Status (Smart)", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:checkbox-marked-circle-outline", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + }, + f"{SynoStorage.API_KEY}:disk_status": { + ENTITY_NAME: "Status", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:checkbox-marked-circle-outline", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + }, + f"{SynoStorage.API_KEY}:disk_temp": { + ENTITY_NAME: "Temperature", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:thermometer", + ENTITY_CLASS: "temperature", + ENTITY_ENABLE: True, + }, } diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 81873cad4cd..22171fdf2f5 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -1,9 +1,6 @@ -"""Support for Synology DSM Sensors.""" -from typing import Dict - +"""Support for Synology DSM sensors.""" from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_DISKS, DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, @@ -11,14 +8,11 @@ from homeassistant.const import ( PRECISION_TENTHS, TEMP_CELSIUS, ) -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity from homeassistant.helpers.temperature import display_temp from homeassistant.helpers.typing import HomeAssistantType -from . import SynoApi +from . import SynologyDSMDeviceEntity, SynologyDSMEntity from .const import ( - BASE_NAME, CONF_VOLUMES, DOMAIN, STORAGE_DISK_SENSORS, @@ -28,8 +22,6 @@ from .const import ( UTILISATION_SENSORS, ) -ATTRIBUTION = "Data provided by Synology" - async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities @@ -38,16 +30,16 @@ async def async_setup_entry( api = hass.data[DOMAIN][entry.unique_id][SYNO_API] - sensors = [ - SynoNasUtilSensor(api, sensor_type, UTILISATION_SENSORS[sensor_type]) + entities = [ + SynoDSMUtilSensor(api, 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( + entities += [ + SynoDSMStorageSensor( api, sensor_type, STORAGE_VOL_SENSORS[sensor_type], volume ) for sensor_type in STORAGE_VOL_SENSORS @@ -56,106 +48,23 @@ async def async_setup_entry( # Handle all disks if api.storage.disks_ids: for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): - sensors += [ - SynoNasStorageSensor( + entities += [ + SynoDSMStorageSensor( api, sensor_type, STORAGE_DISK_SENSORS[sensor_type], disk ) for sensor_type in STORAGE_DISK_SENSORS ] - async_add_entities(sensors) + async_add_entities(entities) -class SynoNasSensor(Entity): - """Representation of a Synology NAS sensor.""" - - def __init__( - self, - api: SynoApi, - 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"{BASE_NAME} {sensor_info[0]}" - self._unit = sensor_info[1] - self._icon = sensor_info[2] - self.monitored_device = monitored_device - self._unique_id = f"{self._api.information.serial}_{sensor_info[0]}" - - if self.monitored_device: - self._name += f" ({self.monitored_device})" - self._unique_id += f"_{self.monitored_device}" - - 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.hass.config.units.temperature_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_update(self): - """Only used by the generic entity update service.""" - await self._api.update() - - 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): +class SynoDSMUtilSensor(SynologyDSMEntity): """Representation a Synology Utilisation sensor.""" @property def state(self): """Return the state.""" - attr = getattr(self._api.utilisation, self.sensor_type) + attr = getattr(self._api.utilisation, self.entity_type) if callable(attr): attr = attr() if attr is None: @@ -171,14 +80,19 @@ class SynoNasUtilSensor(SynoNasSensor): return attr + @property + def available(self) -> bool: + """Return True if entity is available.""" + return bool(self._api.utilisation) -class SynoNasStorageSensor(SynoNasSensor): + +class SynoDSMStorageSensor(SynologyDSMDeviceEntity): """Representation a Synology Storage sensor.""" @property def state(self): """Return the state.""" - attr = getattr(self._api.storage, self.sensor_type)(self.monitored_device) + attr = getattr(self._api.storage, self.entity_type)(self._device_id) if attr is None: return None @@ -187,21 +101,7 @@ class SynoNasStorageSensor(SynoNasSensor): return round(attr / 1024.0 ** 4, 2) # Temperature - if self.sensor_type in TEMP_SENSORS_KEYS: + if self.entity_type in TEMP_SENSORS_KEYS: return display_temp(self.hass, attr, TEMP_CELSIUS, PRECISION_TENTHS) return attr - - @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), - }