Add Squeezebox server service binary sensors (#122473)

* squeezebox add binary sensor + coordinator

* squeezebox add connected via for media_player

* squeezebox add Player type for player

* Add more type info

* Fix linter errors

* squeezebox use our own status entity

* squeezebox rework device handling based on freedback

* Fix device creation

* squeezebox rework coordinator error handling

* Fix lint type error

* Correct spelling

* Correct spelling

* remove large comments

* insert small comment

* add translation support

* Simply sensor

* clean update function, minimise comments to the useful bits

* Fix after testing

* Update homeassistant/components/squeezebox/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* move data prep out of Device assign for clarity

* stop being a generic api

* Humans need to read the sensors...

* ruff format

* Humans need to read the sensors...

* Revert "ruff format"

This reverts commit 8fcb8143e7c4427e75d31f9dd57f6c2027f8df6a.

* ruff format

* Humans need to read the sensors...

* errors after testing

* infered

* drop context

* cutdown coordinator for the binary sensors

* add tests for binary sensors

* Fix import

* add some basic media_player tests

* Fix spelling and file headers

* Fix spelling

* remove uuid and use service device cat

* use diag device

* assert execpted value

* ruff format

* Update homeassistant/components/squeezebox/__init__.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Simplify T/F

* Fix file header

* remove redudant check

* remove player tests from this commit

* Fix formatting

* remove unused

* Fix function Type

* Fix Any to bool

* Fix browser tests

* Patch our squeebox componemt not the server in the lib

* ruff

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Phill (pssc) 2024-09-05 15:49:07 +01:00 committed by GitHub
parent 86ae70780c
commit 38f3fa0210
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 334 additions and 10 deletions

View File

@ -1,6 +1,7 @@
"""The Squeezebox integration."""
from asyncio import timeout
from dataclasses import dataclass
import logging
from pysqueezebox import Server
@ -15,23 +16,42 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceEntryType,
format_mac,
)
from .const import (
CONF_HTTPS,
DISCOVERY_TASK,
DOMAIN,
MANUFACTURER,
SERVER_MODEL,
STATUS_API_TIMEOUT,
STATUS_QUERY_LIBRARYNAME,
STATUS_QUERY_MAC,
STATUS_QUERY_UUID,
STATUS_QUERY_VERSION,
)
from .coordinator import LMSStatusDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER]
type SqueezeboxConfigEntry = ConfigEntry[Server]
@dataclass
class SqueezeboxData:
"""SqueezeboxData data class."""
coordinator: LMSStatusDataUpdateCoordinator
server: Server
type SqueezeboxConfigEntry = ConfigEntry[SqueezeboxData]
async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -> bool:
@ -66,25 +86,51 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
_LOGGER.debug("LMS Status for setup = %s", status)
lms.uuid = status[STATUS_QUERY_UUID]
_LOGGER.debug("LMS %s = '%s' with uuid = %s ", lms.name, host, lms.uuid)
lms.name = (
(STATUS_QUERY_LIBRARYNAME in status and status[STATUS_QUERY_LIBRARYNAME])
and status[STATUS_QUERY_LIBRARYNAME]
or host
)
_LOGGER.debug("LMS %s = '%s' with uuid = %s ", lms.name, host, lms.uuid)
version = STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION] or None
# mac can be missing
mac_connect = (
{(CONNECTION_NETWORK_MAC, format_mac(status[STATUS_QUERY_MAC]))}
if STATUS_QUERY_MAC in status
else None
)
entry.runtime_data = lms
device_registry = dr.async_get(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, lms.uuid)},
name=lms.name,
manufacturer=MANUFACTURER,
model=SERVER_MODEL,
sw_version=version,
entry_type=DeviceEntryType.SERVICE,
connections=mac_connect,
)
_LOGGER.debug("LMS Device %s", device)
coordinator = LMSStatusDataUpdateCoordinator(hass, lms)
entry.runtime_data = SqueezeboxData(
coordinator=coordinator,
server=lms,
)
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -> bool:
"""Unload a config entry."""
# Stop player discovery task for this config entry.
_LOGGER.debug(
"Reached async_unload_entry for LMS=%s(%s)",
entry.runtime_data.name or "Unknown",
entry.runtime_data.server.name or "Unknown",
entry.entry_id,
)

View File

@ -0,0 +1,54 @@
"""Binary sensor platform for Squeezebox integration."""
from __future__ import annotations
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SqueezeboxConfigEntry
from .const import STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN
from .entity import LMSStatusEntity
SENSORS: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(
key=STATUS_SENSOR_RESCAN,
device_class=BinarySensorDeviceClass.RUNNING,
),
BinarySensorEntityDescription(
key=STATUS_SENSOR_NEEDSRESTART,
device_class=BinarySensorDeviceClass.UPDATE,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: SqueezeboxConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Platform setup using common elements."""
async_add_entities(
ServerStatusBinarySensor(entry.runtime_data.coordinator, description)
for description in SENSORS
)
class ServerStatusBinarySensor(LMSStatusEntity, BinarySensorEntity):
"""LMS Status based sensor from LMS via cooridnatior."""
@property
def is_on(self) -> bool:
"""LMS Status directly from coordinator data."""
return bool(self.coordinator.data[self.entity_description.key])

View File

@ -5,8 +5,25 @@ DISCOVERY_TASK = "discovery_task"
DOMAIN = "squeezebox"
DEFAULT_PORT = 9000
KNOWN_PLAYERS = "known_players"
MANUFACTURER = "https://lyrion.org/"
PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub"
SENSOR_UPDATE_INTERVAL = 60
SERVER_MODEL = "Lyron 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"
STATUS_SENSOR_INFO_TOTAL_DURATION = "info total duration"
STATUS_SENSOR_INFO_TOTAL_GENRES = "info total genres"
STATUS_SENSOR_INFO_TOTAL_SONGS = "info total songs"
STATUS_SENSOR_PLAYER_COUNT = "player count"
STATUS_SENSOR_OTHER_PLAYER_COUNT = "other player count"
STATUS_QUERY_LIBRARYNAME = "libraryname"
STATUS_QUERY_MAC = "mac"
STATUS_QUERY_UUID = "uuid"
STATUS_QUERY_VERSION = "version"
SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:")

View File

@ -0,0 +1,59 @@
"""DataUpdateCoordinator for the Squeezebox integration."""
from asyncio import timeout
from datetime import timedelta
import logging
from pysqueezebox import Server
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
SENSOR_UPDATE_INTERVAL,
STATUS_API_TIMEOUT,
STATUS_SENSOR_NEEDSRESTART,
STATUS_SENSOR_RESCAN,
)
_LOGGER = logging.getLogger(__name__)
class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator):
"""LMS Status custom coordinator."""
def __init__(self, hass: HomeAssistant, lms: Server) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
_LOGGER,
name=lms.name,
update_interval=timedelta(seconds=SENSOR_UPDATE_INTERVAL),
always_update=False,
)
self.lms = lms
async def _async_update_data(self) -> dict:
"""Fetch data fromn LMS status call.
Then we process only a subset to make then nice for HA
"""
async with timeout(STATUS_API_TIMEOUT):
data = await self.lms.async_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."""
# 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
_LOGGER.debug("Processed serverstatus %s=%s", self.lms.name, data)
return data

View File

@ -0,0 +1,31 @@
"""Base class for Squeezebox Sensor entities."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, STATUS_QUERY_UUID
from .coordinator import LMSStatusDataUpdateCoordinator
class LMSStatusEntity(CoordinatorEntity[LMSStatusDataUpdateCoordinator]):
"""Defines a base status sensor entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: LMSStatusDataUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize status sensor entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_translation_key = description.key
self._attr_unique_id = (
f"{coordinator.data[STATUS_QUERY_UUID]}_{description.key}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.data[STATUS_QUERY_UUID])},
)

View File

@ -113,7 +113,7 @@ async def async_setup_entry(
"""Set up an player discovery from a config entry."""
hass.data.setdefault(DOMAIN, {})
known_players = hass.data[DOMAIN].setdefault(KNOWN_PLAYERS, [])
lms = entry.runtime_data
lms = entry.runtime_data.server
async def _player_discovery(now: datetime | None = None) -> None:
"""Discover squeezebox players by polling server."""
@ -136,7 +136,7 @@ async def async_setup_entry(
if not entity:
_LOGGER.debug("Adding new entity: %s", player)
entity = SqueezeBoxEntity(player)
entity = SqueezeBoxEntity(player, lms)
known_players.append(entity)
async_add_entities([entity])
@ -212,7 +212,7 @@ class SqueezeBoxEntity(MediaPlayerEntity):
_last_update: datetime | None = None
_attr_available = True
def __init__(self, player: Player) -> None:
def __init__(self, player: Player, server: Server) -> None:
"""Initialize the SqueezeBox device."""
self._player = player
self._query_result: bool | dict = {}
@ -222,6 +222,7 @@ class SqueezeBoxEntity(MediaPlayerEntity):
identifiers={(DOMAIN, self._attr_unique_id)},
name=player.name,
connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)},
via_device=(DOMAIN, server.uuid),
)
@property

View File

@ -75,5 +75,15 @@
"name": "Unsync",
"description": "Removes this player from its sync group."
}
},
"entity": {
"binary_sensor": {
"rescan": {
"name": "Library rescan"
},
"needsrestart": {
"name": "Needs restart"
}
}
}
}

View File

@ -1 +1,69 @@
"""Tests for the Logitech Squeezebox integration."""
from homeassistant.components.squeezebox.const import (
DOMAIN,
STATUS_QUERY_LIBRARYNAME,
STATUS_QUERY_MAC,
STATUS_QUERY_UUID,
STATUS_QUERY_VERSION,
STATUS_SENSOR_RESCAN,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
# from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
FAKE_IP = "42.42.42.42"
FAKE_MAC = "deadbeefdead"
FAKE_UUID = "deadbeefdeadbeefbeefdeafbeef42"
FAKE_PORT = 9000
FAKE_VERSION = "42.0"
FAKE_QUERY_RESPONSE = {
STATUS_QUERY_UUID: FAKE_UUID,
STATUS_QUERY_MAC: FAKE_MAC,
STATUS_QUERY_VERSION: FAKE_VERSION,
STATUS_SENSOR_RESCAN: 1,
STATUS_QUERY_LIBRARYNAME: "FakeLib",
"players_loop": [
{
"isplaying": 0,
"name": "SqueezeLite-HA-Addon",
"seq_no": 0,
"modelname": "SqueezeLite-HA-Addon",
"playerindex": "status",
"model": "squeezelite",
"uuid": FAKE_UUID,
"canpoweroff": 1,
"ip": "192.168.78.86:57700",
"displaytype": "none",
"playerid": "f9:23:cd:37:c5:ff",
"power": 0,
"isplayer": 1,
"connected": 1,
"firmware": "v2.0.0-1488",
}
],
"count": 1,
}
async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry:
"""Mock ConfigEntry in Home Assistant."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=FAKE_UUID,
data={
CONF_HOST: FAKE_IP,
CONF_PORT: FAKE_PORT,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -130,4 +130,5 @@ def lms() -> MagicMock:
)
lms.async_get_players = AsyncMock(return_value=[player])
lms.async_query = AsyncMock(return_value={"uuid": format_mac(TEST_MAC)})
lms.async_status = AsyncMock(return_value={"uuid": format_mac(TEST_MAC)})
return lms

View File

@ -0,0 +1,33 @@
"""Test squeezebox binary sensors."""
from unittest.mock import patch
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import FAKE_QUERY_RESPONSE, setup_mocked_integration
async def test_binary_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test binary sensor states and attributes."""
# Setup component
with (
patch(
"homeassistant.components.squeezebox.PLATFORMS",
[Platform.BINARY_SENSOR],
),
patch(
"homeassistant.components.squeezebox.Server.async_query",
return_value=FAKE_QUERY_RESPONSE,
),
):
await setup_mocked_integration(hass)
state = hass.states.get("binary_sensor.fakelib_library_rescan")
assert state is not None
assert state.state == "on"

View File

@ -16,7 +16,7 @@ from homeassistant.components.squeezebox.browse_media import (
LIBRARY,
MEDIA_TYPE_TO_SQUEEZEBOX,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@ -30,6 +30,10 @@ async def setup_integration(
"""Fixture for setting up the component."""
with (
patch("homeassistant.components.squeezebox.Server", return_value=lms),
patch(
"homeassistant.components.squeezebox.PLATFORMS",
[Platform.MEDIA_PLAYER],
),
patch(
"homeassistant.components.squeezebox.media_player.start_server_discovery"
),