Add QNAP QSW Update platform (#71019)

* qnap_qsw: add Update platform

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* qnap_qsw: update: allow init if firmware coordinator fails

QSW API can return an error if update servers aren't reachable and this
prevents the integration from loading.

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* tests: qnap_qsw: achieve 100% coverage

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>
This commit is contained in:
Álvaro Fernández Rojas 2022-06-30 21:09:08 +02:00 committed by GitHub
parent 0caeeb56c5
commit 768b98ae77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 286 additions and 37 deletions

View File

@ -8,10 +8,15 @@ from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
from .coordinator import QswUpdateCoordinator
from .const import DOMAIN, QSW_COORD_DATA, QSW_COORD_FW
from .coordinator import QswDataCoordinator, QswFirmwareCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR]
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
Platform.UPDATE,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -24,10 +29,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
qsw = QnapQswApi(aiohttp_client.async_get_clientsession(hass), options)
coordinator = QswUpdateCoordinator(hass, qsw)
await coordinator.async_config_entry_first_refresh()
coord_data = QswDataCoordinator(hass, qsw)
await coord_data.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
coord_fw = QswFirmwareCoordinator(hass, qsw)
await coord_fw.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
QSW_COORD_DATA: coord_data,
QSW_COORD_FW: coord_fw,
}
hass.config_entries.async_setup_platforms(entry, PLATFORMS)

View File

@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ATTR_MESSAGE, DOMAIN
from .coordinator import QswUpdateCoordinator
from .const import ATTR_MESSAGE, DOMAIN, QSW_COORD_DATA
from .coordinator import QswDataCoordinator
from .entity import QswEntityDescription, QswSensorEntity
@ -48,7 +48,7 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add QNAP QSW binary sensors from a config_entry."""
coordinator: QswUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA]
async_add_entities(
QswBinarySensor(coordinator, description, entry)
for description in BINARY_SENSOR_TYPES
@ -66,7 +66,7 @@ class QswBinarySensor(QswSensorEntity, BinarySensorEntity):
def __init__(
self,
coordinator: QswUpdateCoordinator,
coordinator: QswDataCoordinator,
description: QswBinarySensorEntityDescription,
entry: ConfigEntry,
) -> None:

View File

@ -17,9 +17,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, QSW_REBOOT
from .coordinator import QswUpdateCoordinator
from .entity import QswEntity
from .const import DOMAIN, QSW_COORD_DATA, QSW_REBOOT
from .coordinator import QswDataCoordinator
from .entity import QswDataEntity
@dataclass
@ -49,20 +49,20 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add QNAP QSW buttons from a config_entry."""
coordinator: QswUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA]
async_add_entities(
QswButton(coordinator, description, entry) for description in BUTTON_TYPES
)
class QswButton(QswEntity, ButtonEntity):
class QswButton(QswDataEntity, ButtonEntity):
"""Define a QNAP QSW button."""
entity_description: QswButtonDescription
def __init__(
self,
coordinator: QswUpdateCoordinator,
coordinator: QswDataCoordinator,
description: QswButtonDescription,
entry: ConfigEntry,
) -> None:

View File

@ -10,5 +10,8 @@ MANUFACTURER: Final = "QNAP"
RPM: Final = "rpm"
QSW_COORD_DATA: Final = "coordinator-data"
QSW_COORD_FW: Final = "coordinator-firmware"
QSW_REBOOT = "reboot"
QSW_TIMEOUT_SEC: Final = 25
QSW_UPDATE: Final = "update"

View File

@ -5,7 +5,7 @@ from datetime import timedelta
import logging
from typing import Any
from aioqsw.exceptions import QswError
from aioqsw.exceptions import APIError, QswError
from aioqsw.localapi import QnapQswApi
import async_timeout
@ -14,12 +14,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, QSW_TIMEOUT_SEC
SCAN_INTERVAL = timedelta(seconds=60)
DATA_SCAN_INTERVAL = timedelta(seconds=60)
FW_SCAN_INTERVAL = timedelta(hours=12)
_LOGGER = logging.getLogger(__name__)
class QswUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the QNAP QSW device."""
def __init__(self, hass: HomeAssistant, qsw: QnapQswApi) -> None:
@ -30,7 +31,7 @@ class QswUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
update_interval=DATA_SCAN_INTERVAL,
)
async def _async_update_data(self) -> dict[str, Any]:
@ -41,3 +42,29 @@ class QswUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except QswError as error:
raise UpdateFailed(error) from error
return self.qsw.data()
class QswFirmwareCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching firmware 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=FW_SCAN_INTERVAL,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Update firmware data via library."""
async with async_timeout.timeout(QSW_TIMEOUT_SEC):
try:
await self.qsw.check_firmware()
except APIError as error:
_LOGGER.warning(error)
except QswError as error:
raise UpdateFailed(error) from error
return self.qsw.data()

View File

@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import QswUpdateCoordinator
from .const import DOMAIN, QSW_COORD_DATA, QSW_COORD_FW
from .coordinator import QswDataCoordinator, QswFirmwareCoordinator
TO_REDACT_CONFIG = [
CONF_USERNAME,
@ -29,9 +29,12 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: QswUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
entry_data = hass.data[DOMAIN][config_entry.entry_id]
coord_data: QswDataCoordinator = entry_data[QSW_COORD_DATA]
coord_fw: QswFirmwareCoordinator = entry_data[QSW_COORD_FW]
return {
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT_CONFIG),
"coord_data": async_redact_data(coordinator.data, TO_REDACT_DATA),
"coord_data": async_redact_data(coord_data.data, TO_REDACT_DATA),
"coord_fw": async_redact_data(coord_fw.data, TO_REDACT_DATA),
}

View File

@ -20,15 +20,15 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import MANUFACTURER
from .coordinator import QswUpdateCoordinator
from .coordinator import QswDataCoordinator, QswFirmwareCoordinator
class QswEntity(CoordinatorEntity[QswUpdateCoordinator]):
class QswDataEntity(CoordinatorEntity[QswDataCoordinator]):
"""Define an QNAP QSW entity."""
def __init__(
self,
coordinator: QswUpdateCoordinator,
coordinator: QswDataCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize."""
@ -72,7 +72,7 @@ class QswEntityDescription(EntityDescription, QswEntityDescriptionMixin):
attributes: dict[str, list[str]] | None = None
class QswSensorEntity(QswEntity):
class QswSensorEntity(QswDataEntity):
"""Base class for QSW sensor entities."""
entity_description: QswEntityDescription
@ -91,3 +91,38 @@ class QswSensorEntity(QswEntity):
key: self.get_device_value(val[0], val[1])
for key, val in self.entity_description.attributes.items()
}
class QswFirmwareEntity(CoordinatorEntity[QswFirmwareCoordinator]):
"""Define a QNAP QSW firmware entity."""
def __init__(
self,
coordinator: QswFirmwareCoordinator,
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

View File

@ -26,8 +26,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ATTR_MAX, DOMAIN, RPM
from .coordinator import QswUpdateCoordinator
from .const import ATTR_MAX, DOMAIN, QSW_COORD_DATA, RPM
from .coordinator import QswDataCoordinator
from .entity import QswEntityDescription, QswSensorEntity
@ -82,7 +82,7 @@ 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]
coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA]
async_add_entities(
QswSensor(coordinator, description, entry)
for description in SENSOR_TYPES
@ -100,7 +100,7 @@ class QswSensor(QswSensorEntity, SensorEntity):
def __init__(
self,
coordinator: QswUpdateCoordinator,
coordinator: QswDataCoordinator,
description: QswSensorEntityDescription,
entry: ConfigEntry,
) -> None:

View File

@ -0,0 +1,89 @@
"""Support for the QNAP QSW update."""
from __future__ import annotations
from typing import Final
from aioqsw.const import (
QSD_DESCRIPTION,
QSD_FIRMWARE_CHECK,
QSD_FIRMWARE_INFO,
QSD_PRODUCT,
QSD_SYSTEM_BOARD,
QSD_VERSION,
)
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, QSW_COORD_FW, QSW_UPDATE
from .coordinator import QswFirmwareCoordinator
from .entity import QswFirmwareEntity
UPDATE_TYPES: Final[tuple[UpdateEntityDescription, ...]] = (
UpdateEntityDescription(
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
key=QSW_UPDATE,
name="Firmware Update",
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add QNAP QSW updates from a config_entry."""
coordinator: QswFirmwareCoordinator = hass.data[DOMAIN][entry.entry_id][
QSW_COORD_FW
]
async_add_entities(
QswUpdate(coordinator, description, entry) for description in UPDATE_TYPES
)
class QswUpdate(QswFirmwareEntity, UpdateEntity):
"""Define a QNAP QSW update."""
entity_description: UpdateEntityDescription
def __init__(
self,
coordinator: QswFirmwareCoordinator,
description: UpdateEntityDescription,
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}"
self.entity_description = description
self._attr_installed_version = self.get_device_value(
QSD_FIRMWARE_INFO, QSD_VERSION
)
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 attributes."""
self._attr_latest_version = self.get_device_value(
QSD_FIRMWARE_CHECK, QSD_VERSION
)
self._attr_release_summary = self.get_device_value(
QSD_FIRMWARE_CHECK, QSD_DESCRIPTION
)

View File

@ -2,10 +2,13 @@
from unittest.mock import patch
from aioqsw.exceptions import QswError
from aioqsw.exceptions import APIError, QswError
from homeassistant.components.qnap_qsw.const import DOMAIN
from homeassistant.components.qnap_qsw.coordinator import SCAN_INTERVAL
from homeassistant.components.qnap_qsw.coordinator import (
DATA_SCAN_INTERVAL,
FW_SCAN_INTERVAL,
)
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
@ -14,6 +17,7 @@ from .util import (
CONFIG,
FIRMWARE_CONDITION_MOCK,
FIRMWARE_INFO_MOCK,
FIRMWARE_UPDATE_CHECK_MOCK,
SYSTEM_BOARD_MOCK,
SYSTEM_SENSOR_MOCK,
SYSTEM_TIME_MOCK,
@ -37,6 +41,9 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None:
"homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_info",
return_value=FIRMWARE_INFO_MOCK,
) as mock_firmware_info, patch(
"homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_update_check",
return_value=FIRMWARE_UPDATE_CHECK_MOCK,
) as mock_firmware_update_check, patch(
"homeassistant.components.qnap_qsw.QnapQswApi.get_system_board",
return_value=SYSTEM_BOARD_MOCK,
) as mock_system_board, patch(
@ -57,14 +64,16 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None:
mock_firmware_condition.assert_called_once()
mock_firmware_info.assert_called_once()
mock_firmware_update_check.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_verification.assert_called_once()
mock_users_login.assert_called_once()
mock_firmware_condition.reset_mock()
mock_firmware_info.reset_mock()
mock_firmware_update_check.reset_mock()
mock_system_board.reset_mock()
mock_system_sensor.reset_mock()
mock_system_time.reset_mock()
@ -72,12 +81,28 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None:
mock_users_login.reset_mock()
mock_system_sensor.side_effect = QswError
async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL)
async_fire_time_changed(hass, utcnow() + DATA_SCAN_INTERVAL)
await hass.async_block_till_done()
mock_system_sensor.assert_called_once()
mock_users_verification.assert_called_once()
mock_users_verification.assert_called()
mock_users_login.assert_not_called()
state = hass.states.get("sensor.qsw_m408_4c_temperature")
assert state.state == STATE_UNAVAILABLE
mock_firmware_update_check.side_effect = APIError
async_fire_time_changed(hass, utcnow() + FW_SCAN_INTERVAL)
await hass.async_block_till_done()
mock_firmware_update_check.assert_called_once()
mock_firmware_update_check.reset_mock()
mock_firmware_update_check.side_effect = QswError
async_fire_time_changed(hass, utcnow() + FW_SCAN_INTERVAL)
await hass.async_block_till_done()
mock_firmware_update_check.assert_called_once()
update = hass.states.get("update.qsw_m408_4c_firmware_update")
assert update.state == STATE_UNAVAILABLE

View File

@ -20,6 +20,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.qnap_qsw.QnapQswApi.check_firmware",
return_value=None,
), patch(
"homeassistant.components.qnap_qsw.QnapQswApi.validate",
return_value=None,
), patch(

View File

@ -0,0 +1,26 @@
"""The sensor tests for the QNAP QSW platform."""
from aioqsw.const import API_RESULT, API_VERSION
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from .util import FIRMWARE_INFO_MOCK, FIRMWARE_UPDATE_CHECK_MOCK, async_init_integration
async def test_qnap_qsw_update(hass: HomeAssistant) -> None:
"""Test creation of update entities."""
await async_init_integration(hass)
update = hass.states.get("update.qsw_m408_4c_firmware_update")
assert update is not None
assert update.state == STATE_OFF
assert (
update.attributes.get("installed_version")
== FIRMWARE_INFO_MOCK[API_RESULT][API_VERSION]
)
assert (
update.attributes.get("latest_version")
== FIRMWARE_UPDATE_CHECK_MOCK[API_RESULT][API_VERSION]
)

View File

@ -12,6 +12,8 @@ from aioqsw.const import (
API_COMMIT_CPSS,
API_COMMIT_ISS,
API_DATE,
API_DESCRIPTION,
API_DOWNLOAD_URL,
API_ERROR_CODE,
API_ERROR_MESSAGE,
API_FAN1_SPEED,
@ -20,6 +22,7 @@ from aioqsw.const import (
API_MAX_SWITCH_TEMP,
API_MESSAGE,
API_MODEL,
API_NEWER,
API_NUMBER,
API_PORT_NUM,
API_PRODUCT,
@ -90,6 +93,24 @@ FIRMWARE_INFO_MOCK = {
},
}
FIRMWARE_UPDATE_CHECK_MOCK = {
API_ERROR_CODE: 200,
API_ERROR_MESSAGE: "OK",
API_RESULT: {
API_VERSION: "1.2.0",
API_NUMBER: "29649",
API_BUILD_NUMBER: "20220128",
API_DATE: "Fri, 28 Jan 2022 01:17:39 +0800",
API_DESCRIPTION: "",
API_DOWNLOAD_URL: [
"https://download.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img",
"https://eu1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img",
"https://us1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img",
],
API_NEWER: False,
},
}
SYSTEM_COMMAND_MOCK = {
API_ERROR_CODE: 200,
API_ERROR_MESSAGE: "OK",
@ -146,6 +167,9 @@ async def async_init_integration(
), patch(
"homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_info",
return_value=FIRMWARE_INFO_MOCK,
), patch(
"homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_update_check",
return_value=FIRMWARE_UPDATE_CHECK_MOCK,
), patch(
"homeassistant.components.qnap_qsw.QnapQswApi.get_system_board",
return_value=SYSTEM_BOARD_MOCK,
@ -155,6 +179,9 @@ async def async_init_integration(
), patch(
"homeassistant.components.qnap_qsw.QnapQswApi.get_system_time",
return_value=SYSTEM_TIME_MOCK,
), patch(
"homeassistant.components.qnap_qsw.QnapQswApi.get_users_verification",
return_value=USERS_VERIFICATION_MOCK,
), patch(
"homeassistant.components.qnap_qsw.QnapQswApi.post_users_login",
return_value=USERS_LOGIN_MOCK,