"""Support for QNAP NAS Sensors.""" from __future__ import annotations from datetime import timedelta import logging from qnapstats import QNAPStats import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) from homeassistant.const import ( ATTR_NAME, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, PERCENTAGE, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) ATTR_DRIVE = "Drive" ATTR_DRIVE_SIZE = "Drive Size" ATTR_IP = "IP Address" ATTR_MAC = "MAC Address" ATTR_MASK = "Mask" ATTR_MAX_SPEED = "Max Speed" ATTR_MEMORY_SIZE = "Memory Size" ATTR_MODEL = "Model" ATTR_PACKETS_TX = "Packets (TX)" ATTR_PACKETS_RX = "Packets (RX)" ATTR_PACKETS_ERR = "Packets (Err)" ATTR_SERIAL = "Serial #" ATTR_TYPE = "Type" ATTR_UPTIME = "Uptime" ATTR_VOLUME_SIZE = "Volume Size" CONF_DRIVES = "drives" CONF_NICS = "nics" CONF_VOLUMES = "volumes" DEFAULT_NAME = "QNAP" DEFAULT_PORT = 8080 DEFAULT_TIMEOUT = 5 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) NOTIFICATION_ID = "qnap_notification" NOTIFICATION_TITLE = "QNAP Sensor Setup" _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="status", name="Status", icon="mdi:checkbox-marked-circle-outline", ), SensorEntityDescription( key="system_temp", name="System Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), ) _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="cpu_temp", name="CPU Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="cpu_usage", name="CPU Usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", ), ) _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="memory_free", name="Memory Available", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", ), SensorEntityDescription( key="memory_used", name="Memory Used", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", ), SensorEntityDescription( key="memory_percent_used", name="Memory Usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", ), ) _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="network_link_status", name="Network Link", icon="mdi:checkbox-marked-circle-outline", ), SensorEntityDescription( key="network_tx", name="Network Up", native_unit_of_measurement=UnitOfDataRate.MEBIBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", ), SensorEntityDescription( key="network_rx", name="Network Down", native_unit_of_measurement=UnitOfDataRate.MEBIBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", ), ) _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="drive_smart_status", name="SMART Status", icon="mdi:checkbox-marked-circle-outline", ), SensorEntityDescription( key="drive_temp", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), ) _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="volume_size_used", name="Used Space", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", ), SensorEntityDescription( key="volume_size_free", name="Free Space", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", ), SensorEntityDescription( key="volume_percentage_used", name="Volume Used", native_unit_of_measurement=PERCENTAGE, icon="mdi:chart-pie", ), ) SENSOR_KEYS: list[str] = [ desc.key for desc in ( *_SYSTEM_MON_COND, *_CPU_MON_COND, *_MEMORY_MON_COND, *_NETWORK_MON_COND, *_DRIVE_MON_COND, *_VOLUME_MON_COND, ) ] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): 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(SENSOR_KEYS)] ), vol.Optional(CONF_NICS): cv.ensure_list, vol.Optional(CONF_DRIVES): cv.ensure_list, vol.Optional(CONF_VOLUMES): cv.ensure_list, } ) def setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the QNAP NAS sensor.""" api = QNAPStatsAPI(config) api.update() # QNAP is not available if not api.data: raise PlatformNotReady monitored_conditions = config[CONF_MONITORED_CONDITIONS] sensors: list[QNAPSensor] = [] # Basic sensors sensors.extend( [ QNAPSystemSensor(api, description) for description in _SYSTEM_MON_COND if description.key in monitored_conditions ] ) sensors.extend( [ QNAPCPUSensor(api, description) for description in _CPU_MON_COND if description.key in monitored_conditions ] ) sensors.extend( [ QNAPMemorySensor(api, description) for description in _MEMORY_MON_COND if description.key in monitored_conditions ] ) # Network sensors sensors.extend( [ QNAPNetworkSensor(api, description, nic) for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]) for description in _NETWORK_MON_COND if description.key in monitored_conditions ] ) # Drive sensors sensors.extend( [ QNAPDriveSensor(api, description, drive) for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]) for description in _DRIVE_MON_COND if description.key in monitored_conditions ] ) # Volume sensors sensors.extend( [ QNAPVolumeSensor(api, description, volume) for volume in config.get(CONF_VOLUMES, api.data["volumes"]) for description in _VOLUME_MON_COND if description.key in monitored_conditions ] ) add_entities(sensors) def round_nicely(number): """Round a number based on its size (so it looks nice).""" if number < 10: return round(number, 2) if number < 100: return round(number, 1) return round(number) class QNAPStatsAPI: """Class to interface with the API.""" def __init__(self, config): """Initialize the API wrapper.""" protocol = "https" if config[CONF_SSL] else "http" self._api = QNAPStats( f"{protocol}://{config.get(CONF_HOST)}", config.get(CONF_PORT), config.get(CONF_USERNAME), config.get(CONF_PASSWORD), verify_ssl=config.get(CONF_VERIFY_SSL), timeout=config.get(CONF_TIMEOUT), ) self.data = {} @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update API information and store locally.""" try: self.data["system_stats"] = self._api.get_system_stats() self.data["system_health"] = self._api.get_system_health() self.data["smart_drive_health"] = self._api.get_smart_disk_health() self.data["volumes"] = self._api.get_volumes() self.data["bandwidth"] = self._api.get_bandwidth() except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to fetch QNAP stats from the NAS") class QNAPSensor(SensorEntity): """Base class for a QNAP sensor.""" def __init__( self, api, description: SensorEntityDescription, monitor_device=None ) -> None: """Initialize the sensor.""" self.entity_description = description self.monitor_device = monitor_device self._api = api @property def name(self): """Return the name of the sensor, if any.""" server_name = self._api.data["system_stats"]["system"]["name"] if self.monitor_device is not None: return ( f"{server_name} {self.entity_description.name} ({self.monitor_device})" ) return f"{server_name} {self.entity_description.name}" def update(self) -> None: """Get the latest data for the states.""" self._api.update() class QNAPCPUSensor(QNAPSensor): """A QNAP sensor that monitors CPU stats.""" @property def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "cpu_temp": return self._api.data["system_stats"]["cpu"]["temp_c"] if self.entity_description.key == "cpu_usage": return self._api.data["system_stats"]["cpu"]["usage_percent"] class QNAPMemorySensor(QNAPSensor): """A QNAP sensor that monitors memory stats.""" @property def native_value(self): """Return the state of the sensor.""" free = float(self._api.data["system_stats"]["memory"]["free"]) / 1024 if self.entity_description.key == "memory_free": return round_nicely(free) total = float(self._api.data["system_stats"]["memory"]["total"]) / 1024 used = total - free if self.entity_description.key == "memory_used": return round_nicely(used) if self.entity_description.key == "memory_percent_used": return round(used / total * 100) @property def extra_state_attributes(self): """Return the state attributes.""" if self._api.data: data = self._api.data["system_stats"]["memory"] size = round_nicely(float(data["total"]) / 1024) return {ATTR_MEMORY_SIZE: f"{size} {UnitOfInformation.GIBIBYTES}"} class QNAPNetworkSensor(QNAPSensor): """A QNAP sensor that monitors network stats.""" @property def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "network_link_status": nic = self._api.data["system_stats"]["nics"][self.monitor_device] return nic["link_status"] data = self._api.data["bandwidth"][self.monitor_device] if self.entity_description.key == "network_tx": return round_nicely(data["tx"] / 1024 / 1024) if self.entity_description.key == "network_rx": return round_nicely(data["rx"] / 1024 / 1024) @property def extra_state_attributes(self): """Return the state attributes.""" if self._api.data: data = self._api.data["system_stats"]["nics"][self.monitor_device] return { ATTR_IP: data["ip"], ATTR_MASK: data["mask"], ATTR_MAC: data["mac"], ATTR_MAX_SPEED: data["max_speed"], ATTR_PACKETS_TX: data["tx_packets"], ATTR_PACKETS_RX: data["rx_packets"], ATTR_PACKETS_ERR: data["err_packets"], } class QNAPSystemSensor(QNAPSensor): """A QNAP sensor that monitors overall system health.""" @property def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "status": return self._api.data["system_health"] if self.entity_description.key == "system_temp": return int(self._api.data["system_stats"]["system"]["temp_c"]) @property def extra_state_attributes(self): """Return the state attributes.""" if self._api.data: data = self._api.data["system_stats"] days = int(data["uptime"]["days"]) hours = int(data["uptime"]["hours"]) minutes = int(data["uptime"]["minutes"]) return { ATTR_NAME: data["system"]["name"], ATTR_MODEL: data["system"]["model"], ATTR_SERIAL: data["system"]["serial_number"], ATTR_UPTIME: f"{days:0>2d}d {hours:0>2d}h {minutes:0>2d}m", } class QNAPDriveSensor(QNAPSensor): """A QNAP sensor that monitors HDD/SSD drive stats.""" @property def native_value(self): """Return the state of the sensor.""" data = self._api.data["smart_drive_health"][self.monitor_device] if self.entity_description.key == "drive_smart_status": return data["health"] if self.entity_description.key == "drive_temp": return int(data["temp_c"]) if data["temp_c"] is not None else 0 @property def name(self): """Return the name of the sensor, if any.""" server_name = self._api.data["system_stats"]["system"]["name"] return ( f"{server_name} {self.entity_description.name} (Drive" f" {self.monitor_device})" ) @property def extra_state_attributes(self): """Return the state attributes.""" if self._api.data: data = self._api.data["smart_drive_health"][self.monitor_device] return { ATTR_DRIVE: data["drive_number"], ATTR_MODEL: data["model"], ATTR_SERIAL: data["serial"], ATTR_TYPE: data["type"], } class QNAPVolumeSensor(QNAPSensor): """A QNAP sensor that monitors storage volume stats.""" @property def native_value(self): """Return the state of the sensor.""" data = self._api.data["volumes"][self.monitor_device] free_gb = int(data["free_size"]) / 1024 / 1024 / 1024 if self.entity_description.key == "volume_size_free": return round_nicely(free_gb) total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 used_gb = total_gb - free_gb if self.entity_description.key == "volume_size_used": return round_nicely(used_gb) if self.entity_description.key == "volume_percentage_used": return round(used_gb / total_gb * 100) @property def extra_state_attributes(self): """Return the state attributes.""" if self._api.data: data = self._api.data["volumes"][self.monitor_device] total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 return { ATTR_VOLUME_SIZE: ( f"{round_nicely(total_gb)} {UnitOfInformation.GIBIBYTES}" ) }