From aaf2846a53fd85f4696df456a46e5a2860f3c303 Mon Sep 17 00:00:00 2001 From: Barry Williams Date: Wed, 28 Jun 2023 21:06:24 +0100 Subject: [PATCH] Add Update Entity for Linn devices (#95217) * added update entity for Linn devices * Update homeassistant/components/openhome/update.py Co-authored-by: Paulus Schoutsen * use parent methods for version attributes * fixed issue with mocking openhome device * Update homeassistant/components/openhome/update.py Co-authored-by: Paulus Schoutsen * update entity name in tests --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/openhome/__init__.py | 2 +- .../components/openhome/manifest.json | 2 +- .../components/openhome/media_player.py | 6 +- homeassistant/components/openhome/update.py | 102 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/openhome/test_update.py | 172 ++++++++++++++++++ 7 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/openhome/update.py create mode 100644 tests/components/openhome/test_update.py diff --git a/homeassistant/components/openhome/__init__.py b/homeassistant/components/openhome/__init__.py index d201646e81c..c7ee5a7d00c 100644 --- a/homeassistant/components/openhome/__init__.py +++ b/homeassistant/components/openhome/__init__.py @@ -17,7 +17,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.UPDATE] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/openhome/manifest.json b/homeassistant/components/openhome/manifest.json index 61d425895bf..de6c56a01dd 100644 --- a/homeassistant/components/openhome/manifest.json +++ b/homeassistant/components/openhome/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/openhome", "iot_class": "local_polling", "loggers": ["async_upnp_client", "openhomedevice"], - "requirements": ["openhomedevice==2.0.2"], + "requirements": ["openhomedevice==2.2.0"], "ssdp": [ { "st": "urn:av-openhome-org:service:Product:1" diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index c0941906e40..77ab0ac0aaf 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -126,9 +126,9 @@ class OpenhomeDevice(MediaPlayerEntity): identifiers={ (DOMAIN, self._device.uuid()), }, - manufacturer=self._device.device.manufacturer, - model=self._device.device.model_name, - name=self._device.device.friendly_name, + manufacturer=self._device.manufacturer(), + model=self._device.model_name(), + name=self._device.friendly_name(), ) @property diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py new file mode 100644 index 00000000000..22bffad44d8 --- /dev/null +++ b/homeassistant/components/openhome/update.py @@ -0,0 +1,102 @@ +"""Update entities for Linn devices.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import aiohttp +from async_upnp_client.client import UpnpError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up update entities for Reolink component.""" + + _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) + + device = hass.data[DOMAIN][config_entry.entry_id] + + entity = OpenhomeUpdateEntity(device) + + await entity.async_update() + + async_add_entities([entity]) + + +class OpenhomeUpdateEntity(UpdateEntity): + """Update entity for a Linn DS device.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_has_entity_name = True + + def __init__(self, device): + """Initialize a Linn DS update entity.""" + self._device = device + self._attr_unique_id = f"{device.uuid()}-update" + + @property + def device_info(self): + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self._device.uuid()), + }, + manufacturer=self._device.manufacturer(), + model=self._device.model_name(), + name=self._device.friendly_name(), + ) + + async def async_update(self) -> None: + """Update state of entity.""" + + software_status = await self._device.software_status() + + if not software_status: + self._attr_installed_version = None + self._attr_latest_version = None + self._attr_release_summary = None + self._attr_release_url = None + return + + self._attr_installed_version = software_status["current_software"]["version"] + + if software_status["status"] == "update_available": + self._attr_latest_version = software_status["update_info"]["updates"][0][ + "version" + ] + self._attr_release_summary = software_status["update_info"]["updates"][0][ + "description" + ] + self._attr_release_url = software_status["update_info"]["releasenotesuri"] + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + try: + if self.latest_version: + await self._device.update_firmware() + except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError) as err: + raise HomeAssistantError( + f"Error updating {self._device.device.friendly_name}: {err}" + ) from err diff --git a/requirements_all.txt b/requirements_all.txt index 875c138aa12..b3cb0d2a6a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1348,7 +1348,7 @@ openerz-api==0.2.0 openevsewifi==1.1.2 # homeassistant.components.openhome -openhomedevice==2.0.2 +openhomedevice==2.2.0 # homeassistant.components.opensensemap opensensemap-api==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7af3d317830..4d309c5f18b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1026,7 +1026,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.openhome -openhomedevice==2.0.2 +openhomedevice==2.2.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/tests/components/openhome/test_update.py b/tests/components/openhome/test_update.py new file mode 100644 index 00000000000..d975cc29af4 --- /dev/null +++ b/tests/components/openhome/test_update.py @@ -0,0 +1,172 @@ +"""Tests for the Openhome update platform.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.openhome.const import DOMAIN +from homeassistant.components.update import ( + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_INSTALL, + UpdateDeviceClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + CONF_HOST, + STATE_ON, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + +LATEST_FIRMWARE_INSTALLED = { + "status": "on_latest", + "current_software": {"version": "4.100.502", "topic": "main", "channel": "release"}, +} + +FIRMWARE_UPDATE_AVAILABLE = { + "status": "update_available", + "current_software": {"version": "4.99.491", "topic": "main", "channel": "release"}, + "update_info": { + "legal": { + "licenseurl": "http://products.linn.co.uk/VersionInfo/licenseV2.txt", + "privacyurl": "https://www.linn.co.uk/privacy", + "privacyuri": "https://products.linn.co.uk/VersionInfo/PrivacyV1.json", + "privacyversion": 1, + }, + "releasenotesuri": "http://docs.linn.co.uk/wiki/index.php/ReleaseNotes", + "updates": [ + { + "channel": "release", + "date": "07 Jun 2023 12:29:48", + "description": "Release build version 4.100.502 (07 Jun 2023 12:29:48)", + "exaktlink": "3", + "manifest": "https://cloud.linn.co.uk/update/components/836/4.100.502/manifest.json", + "topic": "main", + "variant": "836", + "version": "4.100.502", + } + ], + "exaktUpdates": [], + }, +} + + +async def setup_integration( + hass: HomeAssistant, + software_status: dict, + update_firmware: AsyncMock, +) -> None: + """Load an openhome platform with mocked device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "http://localhost"}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.openhome.PLATFORMS", [Platform.UPDATE]), patch( + "homeassistant.components.openhome.Device", MagicMock() + ) as mock_device: + mock_device.return_value.init = AsyncMock() + mock_device.return_value.uuid = MagicMock(return_value="uuid") + mock_device.return_value.manufacturer = MagicMock(return_value="manufacturer") + mock_device.return_value.model_name = MagicMock(return_value="model_name") + mock_device.return_value.friendly_name = MagicMock(return_value="friendly_name") + mock_device.return_value.software_status = AsyncMock( + return_value=software_status + ) + mock_device.return_value.update_firmware = update_firmware + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def test_not_supported(hass: HomeAssistant): + """Ensure update entity works if service not supported.""" + + update_firmware = AsyncMock() + await setup_integration(hass, None, update_firmware) + + state = hass.states.get("update.friendly_name") + + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE + assert state.attributes[ATTR_INSTALLED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] is None + assert state.attributes[ATTR_RELEASE_URL] is None + assert state.attributes[ATTR_RELEASE_SUMMARY] is None + update_firmware.assert_not_called() + + +async def test_on_latest_firmware(hass: HomeAssistant): + """Test device on latest firmware.""" + + update_firmware = AsyncMock() + await setup_integration(hass, LATEST_FIRMWARE_INSTALLED, update_firmware) + + state = hass.states.get("update.friendly_name") + + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE + assert state.attributes[ATTR_INSTALLED_VERSION] == "4.100.502" + assert state.attributes[ATTR_LATEST_VERSION] is None + assert state.attributes[ATTR_RELEASE_URL] is None + assert state.attributes[ATTR_RELEASE_SUMMARY] is None + update_firmware.assert_not_called() + + +async def test_update_available(hass: HomeAssistant): + """Test device has firmware update available.""" + + update_firmware = AsyncMock() + await setup_integration(hass, FIRMWARE_UPDATE_AVAILABLE, update_firmware) + + state = hass.states.get("update.friendly_name") + + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE + assert state.attributes[ATTR_INSTALLED_VERSION] == "4.99.491" + assert state.attributes[ATTR_LATEST_VERSION] == "4.100.502" + assert ( + state.attributes[ATTR_RELEASE_URL] + == "http://docs.linn.co.uk/wiki/index.php/ReleaseNotes" + ) + assert ( + state.attributes[ATTR_RELEASE_SUMMARY] + == "Release build version 4.100.502 (07 Jun 2023 12:29:48)" + ) + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.friendly_name"}, + blocking=True, + ) + await hass.async_block_till_done() + + update_firmware.assert_called_once() + + +async def test_firmware_update_not_required(hass: HomeAssistant): + """Ensure firmware install does nothing if up to date.""" + + update_firmware = AsyncMock() + await setup_integration(hass, LATEST_FIRMWARE_INSTALLED, update_firmware) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.friendly_name"}, + blocking=True, + ) + update_firmware.assert_not_called()