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,
+ )