diff --git a/CODEOWNERS b/CODEOWNERS index 5054a6c1b09..f2be766e5ad 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -796,6 +796,8 @@ build.json @home-assistant/supervisor /homeassistant/components/qbittorrent/ @geoffreylagaisse /homeassistant/components/qld_bushfire/ @exxamalte /tests/components/qld_bushfire/ @exxamalte +/homeassistant/components/qnap_qsw/ @Noltari +/tests/components/qnap_qsw/ @Noltari /homeassistant/components/quantum_gateway/ @cisasteelersfan /homeassistant/components/qvr_pro/ @oblogic7 /homeassistant/components/qwikswitch/ @kellerza diff --git a/homeassistant/components/qnap_qsw/__init__.py b/homeassistant/components/qnap_qsw/__init__.py new file mode 100644 index 00000000000..c1b96a3298e --- /dev/null +++ b/homeassistant/components/qnap_qsw/__init__.py @@ -0,0 +1,89 @@ +"""The QNAP QSW integration.""" +from __future__ import annotations + +from typing import Any + +from aioqsw.const import ( + QSD_FIRMWARE, + QSD_FIRMWARE_INFO, + QSD_MAC, + QSD_PRODUCT, + QSD_SYSTEM_BOARD, +) +from aioqsw.localapi import ConnectionOptions, QnapQswApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import QswUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +class QswEntity(CoordinatorEntity[QswUpdateCoordinator]): + """Define an QNAP QSW entity.""" + + def __init__( + self, + coordinator: QswUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + configuration_url=entry.data[CONF_URL], + connections={ + ( + CONNECTION_NETWORK_MAC, + self.get_device_value(QSD_SYSTEM_BOARD, QSD_MAC), + ) + }, + manufacturer=MANUFACTURER, + model=self.get_device_value(QSD_SYSTEM_BOARD, QSD_PRODUCT), + name=self.get_device_value(QSD_SYSTEM_BOARD, QSD_PRODUCT), + sw_version=self.get_device_value(QSD_FIRMWARE_INFO, QSD_FIRMWARE), + ) + + def get_device_value(self, key: str, subkey: str) -> Any: + """Return device value by key.""" + value = None + if key in self.coordinator.data: + data = self.coordinator.data[key] + if subkey in data: + value = data[subkey] + return value + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up QNAP QSW from a config entry.""" + options = ConnectionOptions( + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + + qsw = QnapQswApi(aiohttp_client.async_get_clientsession(hass), options) + + coordinator = QswUpdateCoordinator(hass, qsw) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py new file mode 100644 index 00000000000..891c72c9911 --- /dev/null +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -0,0 +1,65 @@ +"""Config flow for QNAP QSW.""" +from __future__ import annotations + +from typing import Any + +from aioqsw.exceptions import LoginError, QswError +from aioqsw.localapi import ConnectionOptions, QnapQswApi +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for a QNAP QSW device.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + url = user_input[CONF_URL] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + qsw = QnapQswApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions(url, username, password), + ) + + try: + system_board = await qsw.validate() + except LoginError: + errors[CONF_PASSWORD] = "invalid_auth" + except QswError: + errors[CONF_URL] = "cannot_connect" + else: + mac = system_board.get_mac() + if mac is None: + raise AbortFlow("invalid_id") + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured() + + title = f"QNAP {system_board.get_product()} {mac}" + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/qnap_qsw/const.py b/homeassistant/components/qnap_qsw/const.py new file mode 100644 index 00000000000..b55a817927f --- /dev/null +++ b/homeassistant/components/qnap_qsw/const.py @@ -0,0 +1,12 @@ +"""Constants for the QNAP QSW integration.""" + +from typing import Final + +ATTR_MAX: Final = "max" + +DOMAIN: Final = "qnap_qsw" +MANUFACTURER: Final = "QNAP" + +RPM: Final = "rpm" + +QSW_TIMEOUT_SEC: Final = 25 diff --git a/homeassistant/components/qnap_qsw/coordinator.py b/homeassistant/components/qnap_qsw/coordinator.py new file mode 100644 index 00000000000..064953b1446 --- /dev/null +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -0,0 +1,42 @@ +"""The QNAP QSW coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioqsw.exceptions import QswError +from aioqsw.localapi import QnapQswApi +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, QSW_TIMEOUT_SEC + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +class QswUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the QNAP QSW device.""" + + def __init__(self, hass: HomeAssistant, qsw: QnapQswApi) -> None: + """Initialize.""" + self.qsw = qsw + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Update data via library.""" + async with async_timeout.timeout(QSW_TIMEOUT_SEC): + try: + await self.qsw.update() + except QswError as error: + raise UpdateFailed(error) from error + return self.qsw.data() diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json new file mode 100644 index 00000000000..17709b275ca --- /dev/null +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "qnap_qsw", + "name": "QNAP QSW", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/qnap_qsw", + "requirements": ["aioqsw==0.0.5"], + "codeowners": ["@Noltari"], + "iot_class": "local_polling", + "loggers": ["aioqsw"] +} diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py new file mode 100644 index 00000000000..a1fd7c5a17f --- /dev/null +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -0,0 +1,135 @@ +"""Support for the QNAP QSW sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from aioqsw.const import ( + QSD_FAN1_SPEED, + QSD_FAN2_SPEED, + QSD_PRODUCT, + QSD_SYSTEM_BOARD, + QSD_SYSTEM_SENSOR, + QSD_SYSTEM_TIME, + QSD_TEMP, + QSD_TEMP_MAX, + QSD_UPTIME, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS, TIME_SECONDS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import QswEntity +from .const import ATTR_MAX, DOMAIN, RPM +from .coordinator import QswUpdateCoordinator + + +@dataclass +class QswSensorEntityDescription(SensorEntityDescription): + """A class that describes QNAP QSW sensor entities.""" + + attributes: dict[str, list[str]] | None = None + subkey: str = "" + + +SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = ( + QswSensorEntityDescription( + key=QSD_SYSTEM_SENSOR, + name="Fan 1 Speed", + native_unit_of_measurement=RPM, + state_class=SensorStateClass.MEASUREMENT, + subkey=QSD_FAN1_SPEED, + ), + QswSensorEntityDescription( + key=QSD_SYSTEM_SENSOR, + name="Fan 2 Speed", + native_unit_of_measurement=RPM, + state_class=SensorStateClass.MEASUREMENT, + subkey=QSD_FAN2_SPEED, + ), + QswSensorEntityDescription( + attributes={ + ATTR_MAX: [QSD_SYSTEM_SENSOR, QSD_TEMP_MAX], + }, + device_class=SensorDeviceClass.TEMPERATURE, + key=QSD_SYSTEM_SENSOR, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + subkey=QSD_TEMP, + ), + QswSensorEntityDescription( + key=QSD_SYSTEM_TIME, + entity_category=EntityCategory.DIAGNOSTIC, + name="Uptime", + native_unit_of_measurement=TIME_SECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + subkey=QSD_UPTIME, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add QNAP QSW sensors from a config_entry.""" + coordinator: QswUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + QswSensor(coordinator, description, entry) + for description in SENSOR_TYPES + if ( + description.key in coordinator.data + and description.subkey in coordinator.data[description.key] + ) + ) + + +class QswSensor(QswEntity, SensorEntity): + """Define a QNAP QSW sensor.""" + + entity_description: QswSensorEntityDescription + + def __init__( + self, + coordinator: QswUpdateCoordinator, + description: QswSensorEntityDescription, + entry: ConfigEntry, + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry) + self._attr_name = ( + f"{self.get_device_value(QSD_SYSTEM_BOARD, QSD_PRODUCT)} {description.name}" + ) + self._attr_unique_id = ( + f"{entry.unique_id}_{description.key}_{description.subkey}" + ) + self.entity_description = description + self._async_update_attrs() + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update sensor attributes.""" + self._attr_native_value = self.get_device_value( + self.entity_description.key, self.entity_description.subkey + ) + + if self.entity_description.attributes: + self._attr_extra_state_attributes = { + key: self.get_device_value(val[0], val[1]) + for key, val in self.entity_description.attributes.items() + } diff --git a/homeassistant/components/qnap_qsw/strings.json b/homeassistant/components/qnap_qsw/strings.json new file mode 100644 index 00000000000..351245a9591 --- /dev/null +++ b/homeassistant/components/qnap_qsw/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_id": "Device returned an invalid unique ID" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } + } +} diff --git a/homeassistant/components/qnap_qsw/translations/en.json b/homeassistant/components/qnap_qsw/translations/en.json new file mode 100644 index 00000000000..2d0180d1670 --- /dev/null +++ b/homeassistant/components/qnap_qsw/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "invalid_id": "Device returned an invalid unique ID" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "user": { + "data": { + "url": "URL", + "username": "Username", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c51c4a9733a..706053e15a5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -271,6 +271,7 @@ FLOWS = { "pure_energie", "pvoutput", "pvpc_hourly_pricing", + "qnap_qsw", "rachio", "radio_browser", "rainforest_eagle", diff --git a/requirements_all.txt b/requirements_all.txt index 6cee9119726..bffda584c2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -225,6 +225,9 @@ aiopvpc==3.0.0 # homeassistant.components.sonarr aiopyarr==22.2.2 +# homeassistant.components.qnap_qsw +aioqsw==0.0.5 + # homeassistant.components.recollect_waste aiorecollect==1.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d05d1625904..017c15128ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -191,6 +191,9 @@ aiopvpc==3.0.0 # homeassistant.components.sonarr aiopyarr==22.2.2 +# homeassistant.components.qnap_qsw +aioqsw==0.0.5 + # homeassistant.components.recollect_waste aiorecollect==1.0.8 diff --git a/tests/components/qnap_qsw/__init__.py b/tests/components/qnap_qsw/__init__.py new file mode 100644 index 00000000000..bea1dccd3b2 --- /dev/null +++ b/tests/components/qnap_qsw/__init__.py @@ -0,0 +1 @@ +"""Tests for the QNAP QSW integration.""" diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py new file mode 100644 index 00000000000..e8cc9c56c0a --- /dev/null +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -0,0 +1,136 @@ +"""Define tests for the QNAP QSW config flow.""" + +from unittest.mock import MagicMock, patch + +from aioqsw.const import API_MAC_ADDR, API_PRODUCT, API_RESULT +from aioqsw.exceptions import LoginError, QswError + +from homeassistant import data_entry_flow +from homeassistant.components.qnap_qsw.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from .util import CONFIG, LIVE_MOCK, SYSTEM_BOARD_MOCK, USERS_LOGIN_MOCK + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test that the form is served with valid input.""" + + with patch( + "homeassistant.components.qnap_qsw.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_board", + return_value=SYSTEM_BOARD_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.post_users_login", + return_value=USERS_LOGIN_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG + ) + + await hass.async_block_till_done() + + conf_entries = hass.config_entries.async_entries(DOMAIN) + entry = conf_entries[0] + assert entry.state is ConfigEntryState.LOADED + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result["title"] + == f"QNAP {SYSTEM_BOARD_MOCK[API_RESULT][API_PRODUCT]} {SYSTEM_BOARD_MOCK[API_RESULT][API_MAC_ADDR]}" + ) + assert result["data"][CONF_URL] == CONFIG[CONF_URL] + assert result["data"][CONF_USERNAME] == CONFIG[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == CONFIG[CONF_PASSWORD] + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_duplicated_id(hass: HomeAssistant) -> None: + """Test setting up duplicated entry.""" + + system_board = MagicMock() + system_board.get_mac = MagicMock( + return_value=SYSTEM_BOARD_MOCK[API_RESULT][API_MAC_ADDR] + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + unique_id=format_mac(SYSTEM_BOARD_MOCK[API_RESULT][API_MAC_ADDR]), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + return_value=system_board, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_form_unique_id_error(hass: HomeAssistant): + """Test unique ID error.""" + + system_board = MagicMock() + system_board.get_mac = MagicMock(return_value=None) + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + return_value=system_board, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == "abort" + assert result["reason"] == "invalid_id" + + +async def test_connection_error(hass: HomeAssistant): + """Test connection to host error.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + side_effect=QswError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["errors"] == {CONF_URL: "cannot_connect"} + + +async def test_login_error(hass: HomeAssistant): + """Test login error.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + side_effect=LoginError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} diff --git a/tests/components/qnap_qsw/test_coordinator.py b/tests/components/qnap_qsw/test_coordinator.py new file mode 100644 index 00000000000..b8e4855fea9 --- /dev/null +++ b/tests/components/qnap_qsw/test_coordinator.py @@ -0,0 +1,83 @@ +"""Define tests for the QNAP QSW coordinator.""" + +from unittest.mock import patch + +from aioqsw.exceptions import QswError + +from homeassistant.components.qnap_qsw.const import DOMAIN +from homeassistant.components.qnap_qsw.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .util import ( + CONFIG, + FIRMWARE_CONDITION_MOCK, + FIRMWARE_INFO_MOCK, + SYSTEM_BOARD_MOCK, + SYSTEM_SENSOR_MOCK, + SYSTEM_TIME_MOCK, + USERS_LOGIN_MOCK, + USERS_VERIFICATION_MOCK, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: + """Test ClientConnectorError on coordinator update.""" + + entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_condition", + return_value=FIRMWARE_CONDITION_MOCK, + ) as mock_firmware_condition, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_info", + return_value=FIRMWARE_INFO_MOCK, + ) as mock_firmware_info, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_board", + return_value=SYSTEM_BOARD_MOCK, + ) as mock_system_board, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_sensor", + return_value=SYSTEM_SENSOR_MOCK, + ) as mock_system_sensor, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_time", + return_value=SYSTEM_TIME_MOCK, + ) as mock_system_time, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_users_verification", + return_value=USERS_VERIFICATION_MOCK, + ) as mock_users_verification, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.post_users_login", + return_value=USERS_LOGIN_MOCK, + ) as mock_users_login: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_firmware_condition.assert_called_once() + mock_firmware_info.assert_called_once() + mock_system_board.assert_called_once() + mock_system_sensor.assert_called_once() + mock_system_time.assert_called_once() + mock_users_verification.assert_not_called() + mock_users_login.assert_called_once() + + mock_firmware_condition.reset_mock() + mock_firmware_info.reset_mock() + mock_system_board.reset_mock() + mock_system_sensor.reset_mock() + mock_system_time.reset_mock() + mock_users_verification.reset_mock() + mock_users_login.reset_mock() + + mock_system_sensor.side_effect = QswError + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + mock_system_sensor.assert_called_once() + mock_users_verification.assert_called_once() + mock_users_login.assert_not_called() + + state = hass.states.get("sensor.qsw_m408_4c_temperature") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/qnap_qsw/test_init.py b/tests/components/qnap_qsw/test_init.py new file mode 100644 index 00000000000..211cd7ed41d --- /dev/null +++ b/tests/components/qnap_qsw/test_init.py @@ -0,0 +1,35 @@ +"""Define tests for the QNAP QSW init.""" + +from unittest.mock import patch + +from homeassistant.components.qnap_qsw.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .util import CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unload.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="qsw_unique_id", data=CONFIG + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + return_value=None, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.update", + return_value=None, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/qnap_qsw/test_sensor.py b/tests/components/qnap_qsw/test_sensor.py new file mode 100644 index 00000000000..3caf223c808 --- /dev/null +++ b/tests/components/qnap_qsw/test_sensor.py @@ -0,0 +1,25 @@ +"""The sensor tests for the QNAP QSW platform.""" + +from homeassistant.components.qnap_qsw.const import ATTR_MAX +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_qnap_qsw_create_sensors(hass: HomeAssistant) -> None: + """Test creation of sensors.""" + + await async_init_integration(hass) + + state = hass.states.get("sensor.qsw_m408_4c_fan_1_speed") + assert state.state == "1991" + + state = hass.states.get("sensor.qsw_m408_4c_fan_2_speed") + assert state is None + + state = hass.states.get("sensor.qsw_m408_4c_temperature") + assert state.state == "31" + assert state.attributes.get(ATTR_MAX) == 85 + + state = hass.states.get("sensor.qsw_m408_4c_uptime") + assert state.state == "91" diff --git a/tests/components/qnap_qsw/util.py b/tests/components/qnap_qsw/util.py new file mode 100644 index 00000000000..501c31f55e9 --- /dev/null +++ b/tests/components/qnap_qsw/util.py @@ -0,0 +1,153 @@ +"""Tests for the QNAP QSW integration.""" + +from unittest.mock import patch + +from aioqsw.const import ( + API_ANOMALY, + API_BUILD_NUMBER, + API_CHIP_ID, + API_CI_BRANCH, + API_CI_COMMIT, + API_CI_PIPELINE, + API_COMMIT_CPSS, + API_COMMIT_ISS, + API_DATE, + API_ERROR_CODE, + API_ERROR_MESSAGE, + API_FAN1_SPEED, + API_FAN2_SPEED, + API_MAC_ADDR, + API_MAX_SWITCH_TEMP, + API_MESSAGE, + API_MODEL, + API_NUMBER, + API_PORT_NUM, + API_PRODUCT, + API_PUB_DATE, + API_RESULT, + API_SERIAL, + API_SWITCH_TEMP, + API_TRUNK_NUM, + API_UPTIME, + API_VERSION, +) + +from homeassistant.components.qnap_qsw import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +CONFIG = { + CONF_URL: "http://192.168.1.100", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", +} + +LIVE_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: "None", +} + +SYSTEM_BOARD_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: { + API_MAC_ADDR: "MAC", + API_SERIAL: "SERIAL", + API_CHIP_ID: "ALLEYCAT3", + API_MODEL: "M408", + API_PORT_NUM: 12, + API_PRODUCT: "QSW-M408-4C", + API_TRUNK_NUM: 0, + }, +} + +FIRMWARE_CONDITION_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: { + API_ANOMALY: False, + API_MESSAGE: "", + }, +} + +FIRMWARE_INFO_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: { + API_VERSION: "1.2.0", + API_DATE: "20220128", + API_PUB_DATE: "Fri, 28 Jan 2022 01:17:39 +0800", + API_BUILD_NUMBER: "20220128", + API_NUMBER: "29649", + API_CI_COMMIT: "b2eb4c8ffb549995aeb4f9c4e645c6d882997c17", + API_CI_BRANCH: "m408/codesigning", + API_CI_PIPELINE: "9898", + API_COMMIT_CPSS: "", + API_COMMIT_ISS: "448a3208e5ea744c393b2580f4b9733add9c2faa", + }, +} + +SYSTEM_SENSOR_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: { + API_FAN1_SPEED: 1991, + API_FAN2_SPEED: -2, + API_MAX_SWITCH_TEMP: 85, + API_SWITCH_TEMP: 31, + }, +} + +SYSTEM_TIME_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: { + API_UPTIME: 91, + }, +} + +USERS_LOGIN_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: "TOKEN", +} + +USERS_VERIFICATION_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: "None", +} + + +async def async_init_integration( + hass: HomeAssistant, +) -> None: + """Set up the QNAP QSW integration in Home Assistant.""" + + entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_condition", + return_value=FIRMWARE_CONDITION_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_info", + return_value=FIRMWARE_INFO_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_board", + return_value=SYSTEM_BOARD_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_sensor", + return_value=SYSTEM_SENSOR_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_time", + return_value=SYSTEM_TIME_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.post_users_login", + return_value=USERS_LOGIN_MOCK, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done()