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 <git@frenck.dev>
This commit is contained in:
Phill (pssc) 2025-05-09 14:35:10 +01:00 committed by GitHub
parent ed6cfa42f0
commit bd28452807
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 434 additions and 30 deletions

View File

@ -56,6 +56,7 @@ PLATFORMS = [
Platform.BUTTON,
Platform.MEDIA_PLAYER,
Platform.SENSOR,
Platform.UPDATE,
]

View File

@ -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"

View File

@ -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

View File

@ -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": {

View File

@ -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()

View File

@ -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). <a href="updateinfo.html?installerFile=/var/lib/squeezeboxserver/cache/updates/logitechmediaserver_8.5.2_amd64.deb" target="update">Click here for further information</a>.',
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

View File

@ -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]