From bd284528072ff22a0751014f4ffa7527128188b1 Mon Sep 17 00:00:00 2001 From: "Phill (pssc)" Date: Fri, 9 May 2025 14:35:10 +0100 Subject: [PATCH] Add Squeezebox service update entities (#125764) * first cut at update entties * remove sensors for now * make update vserion less wordy * fix re escape * Use name * use Caps * fix translation * move all data manipulation to data prepare fn, refine regexes and provide as much info as possible * fix formatting * update return type * fix class inherit * Fix ruff * update tests * fix spelling * ruff * Update homeassistant/components/squeezebox/update.py Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> * Update tests/components/squeezebox/test_update.py Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> * Update tests/components/squeezebox/test_update.py Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> * Update tests/components/squeezebox/test_update.py Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> * Update tests/components/squeezebox/test_update.py Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> * Update tests/components/squeezebox/test_update.py Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> * fix tests * ruff * update text based on feedback from docs * make the plugin update entity smarter * update plugin updater tests * define attr * Callable type * callable guard * ruff * add local release info page * fix typing * refactor use release notes for LMS update * Make update simple and produce a release summary instead * Update tests * Fix tests * Tighten english * test for restart fail * be more explicit with coordinator error * remove unused regex * revert error msg unrealted * Fix newline * Fix socket usage during tests * Simplify based on new lib version * CI Fixes * fix typing * fix enitiy call back * fix enitiy call back types * remove some unrelated titdying --------- Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> Co-authored-by: Franck Nijhof --- .../components/squeezebox/__init__.py | 1 + homeassistant/components/squeezebox/const.py | 6 +- .../components/squeezebox/coordinator.py | 38 +-- .../components/squeezebox/strings.json | 8 + homeassistant/components/squeezebox/update.py | 170 +++++++++++++ tests/components/squeezebox/conftest.py | 9 +- tests/components/squeezebox/test_update.py | 232 ++++++++++++++++++ 7 files changed, 434 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/squeezebox/update.py create mode 100644 tests/components/squeezebox/test_update.py diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 78a97e38833..d29e7287340 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -56,6 +56,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, + Platform.UPDATE, ] diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 5ce95d25632..3f355951acf 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -13,8 +13,6 @@ SERVER_MODEL = "Lyrion Music Server" STATUS_API_TIMEOUT = 10 STATUS_SENSOR_LASTSCAN = "lastscan" STATUS_SENSOR_NEEDSRESTART = "needsrestart" -STATUS_SENSOR_NEWVERSION = "newversion" -STATUS_SENSOR_NEWPLUGINS = "newplugins" STATUS_SENSOR_RESCAN = "rescan" STATUS_SENSOR_INFO_TOTAL_ALBUMS = "info total albums" STATUS_SENSOR_INFO_TOTAL_ARTISTS = "info total artists" @@ -27,6 +25,8 @@ STATUS_QUERY_LIBRARYNAME = "libraryname" STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" STATUS_QUERY_VERSION = "version" +STATUS_UPDATE_NEWVERSION = "newversion" +STATUS_UPDATE_NEWPLUGINS = "newplugins" SQUEEZEBOX_SOURCE_STRINGS = ( "source:", "wavin:", @@ -44,3 +44,5 @@ DEFAULT_VOLUME_STEP = 5 ATTR_ANNOUNCE_VOLUME = "announce_volume" ATTR_ANNOUNCE_TIMEOUT = "announce_timeout" UNPLAYABLE_TYPES = ("text", "actions") +UPDATE_PLUGINS_RELEASE_SUMMARY = "update_plugins_release_summary" +UPDATE_RELEASE_SUMMARY = "update_release_summary" diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 955e2896947..e5d78024ef0 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -6,7 +6,6 @@ from asyncio import timeout from collections.abc import Callable from datetime import timedelta import logging -import re from typing import TYPE_CHECKING, Any from pysqueezebox import Player, Server @@ -14,7 +13,6 @@ from pysqueezebox import Player, Server from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from . import SqueezeboxConfigEntry @@ -24,9 +22,6 @@ from .const import ( SENSOR_UPDATE_INTERVAL, SIGNAL_PLAYER_REDISCOVERED, STATUS_API_TIMEOUT, - STATUS_SENSOR_LASTSCAN, - STATUS_SENSOR_NEEDSRESTART, - STATUS_SENSOR_RESCAN, ) _LOGGER = logging.getLogger(__name__) @@ -50,7 +45,16 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): always_update=False, ) self.lms = lms - self.newversion_regex = re.compile("<.*$") + self.can_server_restart = False + + async def _async_setup(self) -> None: + """Query LMS capabilities.""" + result = await self.lms.async_query("can", "restartserver", "?") + if result and "_can" in result and result["_can"] == 1: + _LOGGER.debug("Can restart %s", self.lms.name) + self.can_server_restart = True + else: + _LOGGER.warning("Can't query server capabilities %s", self.lms.name) async def _async_update_data(self) -> dict: """Fetch data from LMS status call. @@ -58,32 +62,12 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): Then we process only a subset to make then nice for HA """ async with timeout(STATUS_API_TIMEOUT): - data = await self.lms.async_status() + data: dict | None = await self.lms.async_prepared_status() if not data: raise UpdateFailed("No data from status poll") _LOGGER.debug("Raw serverstatus %s=%s", self.lms.name, data) - return self._prepare_status_data(data) - - def _prepare_status_data(self, data: dict) -> dict: - """Sensors that need the data changing for HA presentation.""" - - # Binary sensors - # rescan bool are we rescanning alter poll not present if false - data[STATUS_SENSOR_RESCAN] = STATUS_SENSOR_RESCAN in data - # needsrestart bool pending lms plugin updates not present if false - data[STATUS_SENSOR_NEEDSRESTART] = STATUS_SENSOR_NEEDSRESTART in data - - # Sensors that need special handling - # 'lastscan': '1718431678', epoc -> ISO 8601 not always present - data[STATUS_SENSOR_LASTSCAN] = ( - dt_util.utc_from_timestamp(int(data[STATUS_SENSOR_LASTSCAN])) - if STATUS_SENSOR_LASTSCAN in data - else None - ) - - _LOGGER.debug("Processed serverstatus %s=%s", self.lms.name, data) return data diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 83c5d7dd5d0..9109a378ea8 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -125,6 +125,14 @@ "name": "Player count off service", "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]" } + }, + "update": { + "newversion": { + "name": "Lyrion Music Server" + }, + "newplugins": { + "name": "Updated plugins" + } } }, "options": { diff --git a/homeassistant/components/squeezebox/update.py b/homeassistant/components/squeezebox/update.py new file mode 100644 index 00000000000..c37594d346d --- /dev/null +++ b/homeassistant/components/squeezebox/update.py @@ -0,0 +1,170 @@ +"""Platform for update integration for squeezebox.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +import logging +from typing import Any + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from . import SqueezeboxConfigEntry +from .const import ( + SERVER_MODEL, + STATUS_QUERY_VERSION, + STATUS_UPDATE_NEWPLUGINS, + STATUS_UPDATE_NEWVERSION, + UPDATE_PLUGINS_RELEASE_SUMMARY, + UPDATE_RELEASE_SUMMARY, +) +from .entity import LMSStatusEntity + +newserver = UpdateEntityDescription( + key=STATUS_UPDATE_NEWVERSION, +) + +newplugins = UpdateEntityDescription( + key=STATUS_UPDATE_NEWPLUGINS, +) + +POLL_AFTER_INSTALL = 120 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SqueezeboxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Platform setup using common elements.""" + + async_add_entities( + [ + ServerStatusUpdateLMS(entry.runtime_data.coordinator, newserver), + ServerStatusUpdatePlugins(entry.runtime_data.coordinator, newplugins), + ] + ) + + +class ServerStatusUpdate(LMSStatusEntity, UpdateEntity): + """LMS Status update sensors via cooridnatior.""" + + @property + def latest_version(self) -> str: + """LMS Status directly from coordinator data.""" + return str(self.coordinator.data[self.entity_description.key]) + + +class ServerStatusUpdateLMS(ServerStatusUpdate): + """LMS Status update sensor from LMS via cooridnatior.""" + + title: str = SERVER_MODEL + + @property + def installed_version(self) -> str: + """LMS Status directly from coordinator data.""" + return str(self.coordinator.data[STATUS_QUERY_VERSION]) + + @property + def release_url(self) -> str: + """LMS Update info page.""" + return str(self.coordinator.lms.generate_image_url("updateinfo.html")) + + @property + def release_summary(self) -> None | str: + """If install is supported give some info.""" + return ( + str(self.coordinator.data[UPDATE_RELEASE_SUMMARY]) + if self.coordinator.data[UPDATE_RELEASE_SUMMARY] + else None + ) + + +class ServerStatusUpdatePlugins(ServerStatusUpdate): + """LMS Plugings update sensor from LMS via cooridnatior.""" + + auto_update = True + title: str = SERVER_MODEL + " Plugins" + installed_version = "Current" + restart_triggered = False + _cancel_update: Callable | None = None + + @property + def supported_features(self) -> UpdateEntityFeature: + """Support install if we can.""" + return ( + (UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS) + if self.coordinator.can_server_restart + else UpdateEntityFeature(0) + ) + + @property + def release_summary(self) -> None | str: + """If install is supported give some info.""" + rs = self.coordinator.data[UPDATE_PLUGINS_RELEASE_SUMMARY] + return ( + (rs or "") + + "The Plugins will be updated on the next restart triggred by selecting the Install button. Allow enough time for the service to restart. It will become briefly unavailable." + if self.coordinator.can_server_restart + else rs + ) + + @property + def release_url(self) -> str: + """LMS Plugins info page.""" + return str( + self.coordinator.lms.generate_image_url( + "/settings/index.html?activePage=SETUP_PLUGINS" + ) + ) + + @property + def in_progress(self) -> bool: + """Are we restarting.""" + if self.latest_version == self.installed_version and self.restart_triggered: + _LOGGER.debug("plugin progress reset %s", self.coordinator.lms.name) + if callable(self._cancel_update): + self._cancel_update() + self.restart_triggered = False + return self.restart_triggered + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install all plugin updates.""" + _LOGGER.debug( + "server restart for plugin install on %s", self.coordinator.lms.name + ) + self.restart_triggered = True + self.async_write_ha_state() + + result = await self.coordinator.lms.async_query("restartserver") + _LOGGER.debug("restart server result %s", result) + if not result: + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_catchall + ) + else: + self.restart_triggered = False + self.async_write_ha_state() + raise HomeAssistantError( + "Error trying to update LMS Plugins: Restart failed" + ) + + async def _async_update_catchall(self, now: datetime | None = None) -> None: + """Request update. clear restart catchall.""" + if self.restart_triggered: + _LOGGER.debug("server restart catchall for %s", self.coordinator.lms.name) + self.restart_triggered = False + self.async_write_ha_state() + await self.async_update() diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 769e611bf28..fb2428ba758 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -25,6 +25,8 @@ from homeassistant.components.squeezebox.const import ( STATUS_SENSOR_OTHER_PLAYER_COUNT, STATUS_SENSOR_PLAYER_COUNT, STATUS_SENSOR_RESCAN, + STATUS_UPDATE_NEWPLUGINS, + STATUS_UPDATE_NEWVERSION, ) from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant @@ -69,6 +71,9 @@ FAKE_QUERY_RESPONSE = { STATUS_SENSOR_INFO_TOTAL_SONGS: 42, STATUS_SENSOR_PLAYER_COUNT: 10, STATUS_SENSOR_OTHER_PLAYER_COUNT: 0, + STATUS_UPDATE_NEWVERSION: 'A new version of Logitech Media Server is available (8.5.2 - 0). Click here for further information.', + STATUS_UPDATE_NEWPLUGINS: "Plugins have been updated - Restart Required (Big Sounds)", + "_can": 1, "players_loop": [ { "isplaying": 0, @@ -299,7 +304,9 @@ def mock_pysqueezebox_server( mock_lms.uuid = uuid mock_lms.name = TEST_SERVER_NAME mock_lms.async_query = AsyncMock(return_value={"uuid": format_mac(uuid)}) - mock_lms.async_status = AsyncMock(return_value={"uuid": format_mac(uuid)}) + mock_lms.async_status = AsyncMock( + return_value={"uuid": format_mac(uuid), "version": FAKE_VERSION} + ) return mock_lms diff --git a/tests/components/squeezebox/test_update.py b/tests/components/squeezebox/test_update.py new file mode 100644 index 00000000000..b233afbcde1 --- /dev/null +++ b/tests/components/squeezebox/test_update.py @@ -0,0 +1,232 @@ +"""Test squeezebox update platform.""" + +import copy +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.squeezebox.const import ( + SENSOR_UPDATE_INTERVAL, + STATUS_UPDATE_NEWPLUGINS, +) +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util + +from .conftest import FAKE_QUERY_RESPONSE + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_update_lms( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("update.fakelib_lyrion_music_server") + + assert state is not None + assert state.state == STATE_ON + + +async def test_update_plugins_install_fallback( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + polltime = 30 + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=False, + ), + patch( + "homeassistant.components.squeezebox.update.POLL_AFTER_INSTALL", + polltime, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + attrs = state.attributes + assert attrs[ATTR_IN_PROGRESS] + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_status", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=polltime + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS] + + +async def test_update_plugins_install_restart_fail( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=True, + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS] + + +async def test_update_plugins_install_ok( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=False, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert attrs[ATTR_IN_PROGRESS] + + resp = copy.deepcopy(FAKE_QUERY_RESPONSE) + del resp[STATUS_UPDATE_NEWPLUGINS] + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_status", + return_value=resp, + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=SENSOR_UPDATE_INTERVAL + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS]