mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
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:
parent
86ae70780c
commit
38f3fa0210
@ -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,
|
||||
)
|
||||
|
||||
|
54
homeassistant/components/squeezebox/binary_sensor.py
Normal file
54
homeassistant/components/squeezebox/binary_sensor.py
Normal 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])
|
@ -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:")
|
||||
|
59
homeassistant/components/squeezebox/coordinator.py
Normal file
59
homeassistant/components/squeezebox/coordinator.py
Normal 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
|
31
homeassistant/components/squeezebox/entity.py
Normal file
31
homeassistant/components/squeezebox/entity.py
Normal 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])},
|
||||
)
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
33
tests/components/squeezebox/test_binary_sensor.py
Normal file
33
tests/components/squeezebox/test_binary_sensor.py
Normal 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"
|
@ -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"
|
||||
),
|
||||
|
Loading…
x
Reference in New Issue
Block a user