diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 7936cb6e6c3..30b59c73994 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -20,7 +20,9 @@ DEBOUNCE_TIMEOUT = 1 DISPATCHERS: Final = "dispatchers" GDM_DEBOUNCER: Final = "gdm_debouncer" GDM_SCANNER: Final = "gdm_scanner" -PLATFORMS = frozenset([Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR]) +PLATFORMS = frozenset( + [Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.UPDATE] +) PLATFORMS_COMPLETED: Final = "platforms_completed" PLAYER_SOURCE = "player_source" SERVERS: Final = "servers" diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py new file mode 100644 index 00000000000..2d258bab900 --- /dev/null +++ b/homeassistant/components/plex/update.py @@ -0,0 +1,76 @@ +"""Representation of Plex updates.""" +import logging +from typing import Any + +from plexapi.exceptions import PlexApiException +import plexapi.server +import requests.exceptions + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_SERVER_IDENTIFIER +from .helpers import get_plex_server + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Plex media_player from a config entry.""" + server_id = config_entry.data[CONF_SERVER_IDENTIFIER] + server = get_plex_server(hass, server_id) + plex_server = server.plex_server + can_update = await hass.async_add_executor_job(plex_server.canInstallUpdate) + async_add_entities([PlexUpdate(plex_server, can_update)], update_before_add=True) + + +class PlexUpdate(UpdateEntity): + """Representation of a Plex server update entity.""" + + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + _release_notes: str | None = None + + def __init__( + self, plex_server: plexapi.server.PlexServer, can_update: bool + ) -> None: + """Initialize the Update entity.""" + self.plex_server = plex_server + self._attr_name = f"Plex Media Server ({plex_server.friendlyName})" + self._attr_unique_id = plex_server.machineIdentifier + if can_update: + self._attr_supported_features |= UpdateEntityFeature.INSTALL + + def update(self) -> None: + """Update sync attributes.""" + self._attr_installed_version = self.plex_server.version + try: + if (release := self.plex_server.checkForUpdate()) is None: + return + except (requests.exceptions.RequestException, PlexApiException): + _LOGGER.debug("Polling update sensor failed, will try again") + return + self._attr_latest_version = release.version + if release.fixed: + self._release_notes = "\n".join( + f"* {line}" for line in release.fixed.split("\n") + ) + else: + self._release_notes = None + + def release_notes(self) -> str | None: + """Return release notes for the available upgrade.""" + return self._release_notes + + def install(self, version: str | None, backup: bool, **kwargs: Any) -> None: + """Install an update.""" + try: + self.plex_server.installUpdate() + except (requests.exceptions.RequestException, PlexApiException) as exc: + raise HomeAssistantError(str(exc)) from exc diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 78a3b7387ea..92818633df4 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -396,6 +396,24 @@ def hubs_music_library_fixture(): return load_fixture("plex/hubs_library_section.xml") +@pytest.fixture(name="update_check_nochange", scope="session") +def update_check_fixture_nochange() -> str: + """Load a no-change update resource payload and return it.""" + return load_fixture("plex/release_nochange.xml") + + +@pytest.fixture(name="update_check_new", scope="session") +def update_check_fixture_new() -> str: + """Load a changed update resource payload and return it.""" + return load_fixture("plex/release_new.xml") + + +@pytest.fixture(name="update_check_new_not_updatable", scope="session") +def update_check_fixture_new_not_updatable() -> str: + """Load a changed update resource payload (not updatable) and return it.""" + return load_fixture("plex/release_new_not_updatable.xml") + + @pytest.fixture(name="entry") async def mock_config_entry(): """Return the default mocked config entry.""" @@ -452,6 +470,7 @@ def mock_plex_calls( plex_server_clients, plex_server_default, security_token, + update_check_nochange, ): """Mock Plex API calls.""" requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) @@ -519,6 +538,8 @@ def mock_plex_calls( requests_mock.get(f"{url}/playlists", text=playlists) requests_mock.get(f"{url}/playlists/500/items", text=playlist_500) requests_mock.get(f"{url}/security/token", text=security_token) + requests_mock.put(f"{url}/updater/check") + requests_mock.get(f"{url}/updater/status", text=update_check_nochange) @pytest.fixture diff --git a/tests/components/plex/fixtures/release_new.xml b/tests/components/plex/fixtures/release_new.xml new file mode 100644 index 00000000000..4fd2b1e99f4 --- /dev/null +++ b/tests/components/plex/fixtures/release_new.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/fixtures/release_new_not_updatable.xml b/tests/components/plex/fixtures/release_new_not_updatable.xml new file mode 100644 index 00000000000..c83be0b964c --- /dev/null +++ b/tests/components/plex/fixtures/release_new_not_updatable.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/fixtures/release_nochange.xml b/tests/components/plex/fixtures/release_nochange.xml new file mode 100644 index 00000000000..788db7fd2ca --- /dev/null +++ b/tests/components/plex/fixtures/release_nochange.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/test_update.py b/tests/components/plex/test_update.py new file mode 100644 index 00000000000..ce50f67a0d9 --- /dev/null +++ b/tests/components/plex/test_update.py @@ -0,0 +1,111 @@ +"""Tests for update entities.""" +import pytest +import requests_mock + +from homeassistant.components.update import ( + DOMAIN as UPDATE_DOMAIN, + SCAN_INTERVAL as UPDATER_SCAN_INTERVAL, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, HomeAssistantError +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator + +UPDATE_ENTITY = "update.plex_media_server_plex_server_1" + + +async def test_plex_update( + hass: HomeAssistant, + entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + mock_plex_server, + requests_mock: requests_mock.Mocker, + empty_payload: str, + update_check_new: str, + update_check_new_not_updatable: str, +) -> None: + """Test Plex update entity.""" + ws_client = await hass_ws_client(hass) + + assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": UPDATE_ENTITY, + } + ) + result = await ws_client.receive_json() + assert result["result"] is None + + apply_mock = requests_mock.put("/updater/apply") + + # Failed updates + requests_mock.get("/updater/status", status_code=500) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + + requests_mock.get("/updater/status", text=empty_payload) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + + # New release (not updatable) + requests_mock.get("/updater/status", text=update_check_new_not_updatable) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPDATE_ENTITY).state == STATE_ON + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + assert not apply_mock.called + + # New release (updatable) + requests_mock.get("/updater/status", text=update_check_new) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(UPDATE_ENTITY).state == STATE_ON + + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": UPDATE_ENTITY, + } + ) + result = await ws_client.receive_json() + assert result["result"] == "* Summary of\n* release notes" + + # Successful upgrade request + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + assert apply_mock.called_once + + # Failed upgrade request + requests_mock.put("/updater/apply", status_code=500) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + )