mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 00:20:30 +01:00
Add WiiM media player integration (#148948)
Co-authored-by: Tao Jiang <tao.jiang@linkplay.com> Co-authored-by: Erik Montnemery <erik@montnemery.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Paulus Schoutsen <balloob@gmail.com> Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -1915,6 +1915,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/whois/ @frenck
|
||||
/homeassistant/components/wiffi/ @mampfes
|
||||
/tests/components/wiffi/ @mampfes
|
||||
/homeassistant/components/wiim/ @Linkplay2020
|
||||
/tests/components/wiim/ @Linkplay2020
|
||||
/homeassistant/components/wilight/ @leofig-rj
|
||||
/tests/components/wilight/ @leofig-rj
|
||||
/homeassistant/components/window/ @home-assistant/core
|
||||
|
||||
128
homeassistant/components/wiim/__init__.py
Normal file
128
homeassistant/components/wiim/__init__.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""The WiiM integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from wiim.controller import WiimController
|
||||
from wiim.discovery import async_create_wiim_device
|
||||
from wiim.exceptions import WiimDeviceException, WiimRequestException
|
||||
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
from .const import DATA_WIIM, DOMAIN, LOGGER, PLATFORMS, UPNP_PORT, WiimConfigEntry
|
||||
from .models import WiimData
|
||||
|
||||
DEFAULT_AVAILABILITY_POLLING_INTERVAL = 60
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool:
|
||||
"""Set up WiiM from a config entry.
|
||||
|
||||
This method owns the device connect/disconnect lifecycle.
|
||||
"""
|
||||
LOGGER.debug(
|
||||
"Setting up WiiM entry: %s (UDN: %s, Source: %s)",
|
||||
entry.title,
|
||||
entry.unique_id,
|
||||
entry.source,
|
||||
)
|
||||
|
||||
# This integration maintains shared domain-level state because:
|
||||
# - Multiple config entries can be loaded simultaneously.
|
||||
# - All WiiM devices share a single WiimController instance
|
||||
# to coordinate network communication and event handling.
|
||||
# - We also maintain a global entity_id -> UDN mapping
|
||||
# used for cross-entity event routing.
|
||||
#
|
||||
# The domain data must therefore be initialized once and reused
|
||||
# across all config entries.
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
if DATA_WIIM not in hass.data:
|
||||
hass.data[DATA_WIIM] = WiimData(controller=WiimController(session))
|
||||
|
||||
wiim_domain_data = hass.data[DATA_WIIM]
|
||||
controller = wiim_domain_data.controller
|
||||
|
||||
host = entry.data[CONF_HOST]
|
||||
upnp_location = f"http://{host}:{UPNP_PORT}/description.xml"
|
||||
|
||||
try:
|
||||
base_url = get_url(hass, prefer_external=False)
|
||||
except NoURLAvailableError as err:
|
||||
raise ConfigEntryNotReady("Failed to determine Home Assistant URL") from err
|
||||
|
||||
local_host = urlparse(base_url).hostname
|
||||
if TYPE_CHECKING:
|
||||
assert local_host is not None
|
||||
|
||||
try:
|
||||
wiim_device = await async_create_wiim_device(
|
||||
upnp_location,
|
||||
session,
|
||||
host=host,
|
||||
local_host=local_host,
|
||||
polling_interval=DEFAULT_AVAILABILITY_POLLING_INTERVAL,
|
||||
)
|
||||
except WiimRequestException as err:
|
||||
raise ConfigEntryNotReady(f"HTTP API request failed for {host}: {err}") from err
|
||||
except WiimDeviceException as err:
|
||||
raise ConfigEntryNotReady(f"Device setup failed for {host}: {err}") from err
|
||||
|
||||
await controller.add_device(wiim_device)
|
||||
|
||||
entry.runtime_data = wiim_device
|
||||
LOGGER.info(
|
||||
"WiiM device %s (UDN: %s) linked to HASS. Name: '%s', HTTP: %s, UPnP Location: %s",
|
||||
entry.entry_id,
|
||||
wiim_device.udn,
|
||||
wiim_device.name,
|
||||
host,
|
||||
upnp_location or "N/A",
|
||||
)
|
||||
|
||||
async def _async_shutdown_event_handler(event: Event) -> None:
|
||||
LOGGER.info(
|
||||
"Home Assistant stopping, disconnecting WiiM device: %s",
|
||||
wiim_device.name,
|
||||
)
|
||||
await wiim_device.disconnect()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, _async_shutdown_event_handler
|
||||
)
|
||||
)
|
||||
|
||||
async def _unload_entry_cleanup():
|
||||
"""Cleanup when unloading the config entry.
|
||||
|
||||
Removes the device from the controller and disconnects it.
|
||||
"""
|
||||
LOGGER.debug("Running unload cleanup for %s", wiim_device.name)
|
||||
await controller.remove_device(wiim_device.udn)
|
||||
await wiim_device.disconnect()
|
||||
|
||||
entry.async_on_unload(_unload_entry_cleanup)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
LOGGER.info("Unloading WiiM entry: %s (UDN: %s)", entry.title, entry.unique_id)
|
||||
|
||||
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
return False
|
||||
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
hass.data.pop(DATA_WIIM)
|
||||
LOGGER.info("Last WiiM entry unloaded, cleaning up domain data")
|
||||
return True
|
||||
132
homeassistant/components/wiim/config_flow.py
Normal file
132
homeassistant/components/wiim/config_flow.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Config flow for WiiM integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from wiim.discovery import async_probe_wiim_device
|
||||
from wiim.models import WiimProbeResult
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN, LOGGER, UPNP_PORT
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
|
||||
async def _async_probe_wiim_host(hass: HomeAssistant, host: str) -> WiimProbeResult:
|
||||
"""Probe the given host and return WiiM device information."""
|
||||
session = async_get_clientsession(hass)
|
||||
location = f"http://{host}:{UPNP_PORT}/description.xml"
|
||||
LOGGER.debug("Validating UPnP device at location: %s", location)
|
||||
try:
|
||||
probe_result = await async_probe_wiim_device(
|
||||
location,
|
||||
session,
|
||||
host=host,
|
||||
)
|
||||
except TimeoutError as err:
|
||||
raise CannotConnect from err
|
||||
|
||||
if probe_result is None:
|
||||
raise CannotConnect
|
||||
return probe_result
|
||||
|
||||
|
||||
class WiimConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for WiiM."""
|
||||
|
||||
_discovered_info: WiimProbeResult | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step when user adds integration manually."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
try:
|
||||
device_info = await _async_probe_wiim_host(self.hass, host)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(device_info.udn)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=device_info.name,
|
||||
data={
|
||||
CONF_HOST: device_info.host,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle Zeroconf discovery."""
|
||||
LOGGER.debug(
|
||||
"Zeroconf discovery received: Name: %s, Host: %s, Port: %s, Properties: %s",
|
||||
discovery_info.name,
|
||||
discovery_info.host,
|
||||
discovery_info.port,
|
||||
discovery_info.properties,
|
||||
)
|
||||
|
||||
host = discovery_info.host
|
||||
udn_from_txt = discovery_info.properties.get("uuid")
|
||||
if udn_from_txt:
|
||||
await self.async_set_unique_id(udn_from_txt)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
try:
|
||||
device_info = await _async_probe_wiim_host(self.hass, host)
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(device_info.udn)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: device_info.host})
|
||||
|
||||
self._discovered_info = device_info
|
||||
self.context["title_placeholders"] = {"name": device_info.name}
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user confirmation of discovered device."""
|
||||
discovered_info = self._discovered_info
|
||||
if user_input is not None and discovered_info is not None:
|
||||
return self.async_create_entry(
|
||||
title=discovered_info.name,
|
||||
data={
|
||||
CONF_HOST: discovered_info.host,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={
|
||||
"name": (
|
||||
discovered_info.name
|
||||
if discovered_info is not None
|
||||
else "Discovered WiiM Device"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
27
homeassistant/components/wiim/const.py
Normal file
27
homeassistant/components/wiim/const.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Constants for the WiiM integration."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wiim import WiimDevice
|
||||
|
||||
from .models import WiimData
|
||||
|
||||
type WiimConfigEntry = ConfigEntry[WiimDevice]
|
||||
|
||||
DOMAIN: Final = "wiim"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
DATA_WIIM: HassKey[WiimData] = HassKey(DOMAIN)
|
||||
|
||||
PLATFORMS: Final[list[Platform]] = [
|
||||
Platform.MEDIA_PLAYER,
|
||||
]
|
||||
|
||||
UPNP_PORT = 49152
|
||||
|
||||
ZEROCONF_TYPE_LINKPLAY: Final = "_linkplay._tcp.local."
|
||||
36
homeassistant/components/wiim/entity.py
Normal file
36
homeassistant/components/wiim/entity.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Base entity for the WiiM integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wiim.wiim_device import WiimDevice
|
||||
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class WiimBaseEntity(Entity):
|
||||
"""Base representation of a WiiM entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, wiim_device: WiimDevice) -> None:
|
||||
"""Initialize the WiiM base entity."""
|
||||
self._device = wiim_device
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, self._device.udn)},
|
||||
name=self._device.name,
|
||||
manufacturer=self._device.manufacturer,
|
||||
model=self._device.model_name,
|
||||
sw_version=self._device.firmware_version,
|
||||
)
|
||||
if self._device.presentation_url:
|
||||
self._attr_device_info["configuration_url"] = self._device.presentation_url
|
||||
elif self._device.http_api_url:
|
||||
self._attr_device_info["configuration_url"] = self._device.http_api_url
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._device.available
|
||||
13
homeassistant/components/wiim/manifest.json
Normal file
13
homeassistant/components/wiim/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "wiim",
|
||||
"name": "WiiM",
|
||||
"codeowners": ["@Linkplay2020"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/wiim",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["wiim.sdk", "async_upnp_client"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["wiim==0.1.0"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
794
homeassistant/components/wiim/media_player.py
Normal file
794
homeassistant/components/wiim/media_player.py
Normal file
@@ -0,0 +1,794 @@
|
||||
"""Support for WiiM Media Players."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from async_upnp_client.client import UpnpService, UpnpStateVariable
|
||||
from wiim.consts import PlayingStatus as SDKPlayingStatus
|
||||
from wiim.exceptions import WiimDeviceException, WiimException, WiimRequestException
|
||||
from wiim.models import (
|
||||
WiimGroupRole,
|
||||
WiimGroupSnapshot,
|
||||
WiimRepeatMode,
|
||||
WiimTransportCapabilities,
|
||||
)
|
||||
from wiim.wiim_device import WiimDevice
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import DATA_WIIM, LOGGER, WiimConfigEntry
|
||||
from .entity import WiimBaseEntity
|
||||
from .models import WiimData
|
||||
|
||||
MEDIA_TYPE_WIIM_LIBRARY = "wiim_library"
|
||||
MEDIA_CONTENT_ID_ROOT = "library_root"
|
||||
MEDIA_CONTENT_ID_FAVORITES = (
|
||||
f"{MEDIA_TYPE_WIIM_LIBRARY}/{MEDIA_CONTENT_ID_ROOT}/favorites"
|
||||
)
|
||||
MEDIA_CONTENT_ID_PLAYLISTS = (
|
||||
f"{MEDIA_TYPE_WIIM_LIBRARY}/{MEDIA_CONTENT_ID_ROOT}/playlists"
|
||||
)
|
||||
|
||||
SDK_TO_HA_STATE: dict[SDKPlayingStatus, MediaPlayerState] = {
|
||||
SDKPlayingStatus.PLAYING: MediaPlayerState.PLAYING,
|
||||
SDKPlayingStatus.PAUSED: MediaPlayerState.PAUSED,
|
||||
SDKPlayingStatus.STOPPED: MediaPlayerState.IDLE,
|
||||
SDKPlayingStatus.LOADING: MediaPlayerState.BUFFERING,
|
||||
}
|
||||
|
||||
# Define supported features
|
||||
SUPPORT_WIIM_BASE = (
|
||||
MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.GROUPING
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
)
|
||||
|
||||
|
||||
def media_player_exception_wrap[
|
||||
_WiimMediaPlayerEntityT: "WiimMediaPlayerEntity",
|
||||
**_P,
|
||||
_R,
|
||||
](
|
||||
func: Callable[Concatenate[_WiimMediaPlayerEntityT, _P], Awaitable[_R]],
|
||||
) -> Callable[Concatenate[_WiimMediaPlayerEntityT, _P], Coroutine[Any, Any, _R]]:
|
||||
"""Wrap media player commands to handle SDK exceptions consistently."""
|
||||
|
||||
@wraps(func)
|
||||
async def _wrap(
|
||||
self: _WiimMediaPlayerEntityT, *args: _P.args, **kwargs: _P.kwargs
|
||||
) -> _R:
|
||||
try:
|
||||
result = await func(self, *args, **kwargs)
|
||||
except (WiimDeviceException, WiimRequestException, WiimException) as err:
|
||||
await self._async_handle_critical_error(err)
|
||||
raise HomeAssistantError(
|
||||
f"{func.__name__} failed for {self.entity_id}"
|
||||
) from err
|
||||
except RuntimeError as err:
|
||||
raise HomeAssistantError(
|
||||
f"{func.__name__} failed for {self.entity_id}"
|
||||
) from err
|
||||
|
||||
self._update_ha_state_from_sdk_cache()
|
||||
|
||||
return result
|
||||
|
||||
return _wrap
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: WiimConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up WiiM media player from a config entry."""
|
||||
async_add_entities([WiimMediaPlayerEntity(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
|
||||
"""Representation of a WiiM media player."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_media_image_remotely_accessible = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, device: WiimDevice, entry: WiimConfigEntry) -> None:
|
||||
"""Initialize the WiiM entity."""
|
||||
super().__init__(device)
|
||||
self._entry = entry
|
||||
|
||||
self._attr_unique_id = device.udn
|
||||
self._attr_source_list = list(device.supported_input_modes) or None
|
||||
self._attr_shuffle: bool = False
|
||||
self._attr_repeat = RepeatMode.OFF
|
||||
self._transport_capabilities: WiimTransportCapabilities | None = None
|
||||
self._supported_features_update_in_flight = False
|
||||
|
||||
@property
|
||||
def _wiim_data(self) -> WiimData:
|
||||
"""Return shared WiiM domain data."""
|
||||
return self.hass.data[DATA_WIIM]
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Return the features supported by the current device state."""
|
||||
features = SUPPORT_WIIM_BASE
|
||||
if self._transport_capabilities is None:
|
||||
return features
|
||||
|
||||
if self._transport_capabilities.can_next:
|
||||
features |= MediaPlayerEntityFeature.NEXT_TRACK
|
||||
if self._transport_capabilities.can_previous:
|
||||
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
if self._transport_capabilities.can_repeat:
|
||||
features |= MediaPlayerEntityFeature.REPEAT_SET
|
||||
if self._transport_capabilities.can_shuffle:
|
||||
features |= MediaPlayerEntityFeature.SHUFFLE_SET
|
||||
|
||||
return features
|
||||
|
||||
@callback
|
||||
def _get_entity_id_for_udn(self, udn: str) -> str | None:
|
||||
"""Helper to get a WiimMediaPlayerEntity ID by UDN from shared data."""
|
||||
for entity_id, stored_udn in self._wiim_data.entity_id_to_udn_map.items():
|
||||
if stored_udn == udn:
|
||||
return entity_id
|
||||
|
||||
LOGGER.debug("No entity ID found for UDN: %s", udn)
|
||||
return None
|
||||
|
||||
def _get_group_snapshot(self) -> WiimGroupSnapshot:
|
||||
"""Return the typed group snapshot for the current device."""
|
||||
return self._wiim_data.controller.get_group_snapshot(self._device.udn)
|
||||
|
||||
@property
|
||||
def _metadata_device(self) -> WiimDevice:
|
||||
"""Return the device whose metadata should back this entity."""
|
||||
group_snapshot = self._get_group_snapshot()
|
||||
if group_snapshot.role != WiimGroupRole.FOLLOWER:
|
||||
return self._device
|
||||
|
||||
return self._wiim_data.controller.get_device(group_snapshot.leader_udn)
|
||||
|
||||
@callback
|
||||
def _clear_media_metadata(self) -> None:
|
||||
"""Clear media metadata attributes."""
|
||||
self._attr_media_title = None
|
||||
self._attr_media_artist = None
|
||||
self._attr_media_album_name = None
|
||||
self._attr_media_image_url = None
|
||||
self._attr_media_content_id = None
|
||||
self._attr_media_content_type = None
|
||||
self._attr_media_duration = None
|
||||
self._attr_media_position = None
|
||||
self._attr_media_position_updated_at = None
|
||||
|
||||
@callback
|
||||
def _get_command_target_device(self, action_name: str) -> WiimDevice:
|
||||
"""Return the device that should receive a grouped playback command."""
|
||||
group_snapshot = self._get_group_snapshot()
|
||||
if group_snapshot.role != WiimGroupRole.FOLLOWER:
|
||||
return self._device
|
||||
|
||||
target_device = self._wiim_data.controller.get_device(
|
||||
group_snapshot.command_target_udn
|
||||
)
|
||||
|
||||
LOGGER.info(
|
||||
"Routing %s command from follower %s to leader %s",
|
||||
action_name,
|
||||
self.entity_id,
|
||||
target_device.udn,
|
||||
)
|
||||
return target_device
|
||||
|
||||
@callback
|
||||
def _update_ha_state_from_sdk_cache(
|
||||
self,
|
||||
*,
|
||||
write_state: bool = True,
|
||||
update_supported_features: bool = True,
|
||||
) -> None:
|
||||
"""Update HA state from SDK's cache/HTTP poll attributes.
|
||||
|
||||
This is the main method for updating this entity's HA attributes.
|
||||
Crucially, it also handles propagating metadata to followers if this is a leader.
|
||||
"""
|
||||
LOGGER.debug(
|
||||
"Device %s: Updating HA state from SDK cache/HTTP poll",
|
||||
self.name or self.unique_id,
|
||||
)
|
||||
self._attr_available = self._device.available
|
||||
|
||||
if not self._attr_available:
|
||||
self._attr_state = None
|
||||
self._clear_media_metadata()
|
||||
self._attr_source = None
|
||||
self._transport_capabilities = None
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
# Update common attributes first
|
||||
self._attr_volume_level = self._device.volume / 100
|
||||
self._attr_is_volume_muted = self._device.is_muted
|
||||
self._attr_source_list = list(self._device.supported_input_modes) or None
|
||||
|
||||
# Determine current group role (leader/follower/standalone)
|
||||
group_snapshot = self._get_group_snapshot()
|
||||
|
||||
metadata_device = self._metadata_device
|
||||
if group_snapshot.role == WiimGroupRole.FOLLOWER:
|
||||
LOGGER.debug(
|
||||
"Follower %s: Actively pulling metadata from leader %s",
|
||||
self.entity_id,
|
||||
metadata_device.udn,
|
||||
)
|
||||
|
||||
if metadata_device.playing_status is not None:
|
||||
self._attr_state = SDK_TO_HA_STATE.get(
|
||||
metadata_device.playing_status, MediaPlayerState.IDLE
|
||||
)
|
||||
|
||||
if metadata_device.play_mode is not None:
|
||||
self._attr_source = metadata_device.play_mode
|
||||
|
||||
loop_state = metadata_device.loop_state
|
||||
self._attr_repeat = RepeatMode(loop_state.repeat)
|
||||
self._attr_shuffle = loop_state.shuffle
|
||||
|
||||
if media := metadata_device.current_media:
|
||||
self._attr_media_title = media.title
|
||||
self._attr_media_artist = media.artist
|
||||
self._attr_media_album_name = media.album
|
||||
self._attr_media_image_url = media.image_url
|
||||
self._attr_media_content_id = media.uri
|
||||
self._attr_media_content_type = MediaType.MUSIC
|
||||
self._attr_media_duration = media.duration
|
||||
if self._attr_media_position != media.position:
|
||||
self._attr_media_position = media.position
|
||||
self._attr_media_position_updated_at = utcnow()
|
||||
else:
|
||||
self._clear_media_metadata()
|
||||
|
||||
group_members = [
|
||||
entity_id
|
||||
for udn in group_snapshot.member_udns
|
||||
if (entity_id := self._get_entity_id_for_udn(udn)) is not None
|
||||
]
|
||||
self._attr_group_members = group_members or ([self.entity_id])
|
||||
|
||||
if update_supported_features:
|
||||
self._async_schedule_update_supported_features()
|
||||
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_sdk_general_device_update(self, device: WiimDevice) -> None:
|
||||
"""Handle general updates from the SDK (e.g., availability, polled data)."""
|
||||
LOGGER.debug(
|
||||
"Device %s: Received general SDK update from %s",
|
||||
self.entity_id,
|
||||
device.name,
|
||||
)
|
||||
if not self._device.available:
|
||||
self._update_ha_state_from_sdk_cache()
|
||||
self._entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._async_handle_critical_error(WiimException("Device offline.")),
|
||||
name=f"wiim_{self.entity_id}_critical_error",
|
||||
)
|
||||
return
|
||||
|
||||
async def _wrapped() -> None:
|
||||
await self._device.ensure_subscriptions()
|
||||
self._update_ha_state_from_sdk_cache()
|
||||
|
||||
if self._device.supports_http_api:
|
||||
self._entry.async_create_background_task(
|
||||
self.hass,
|
||||
_wrapped(),
|
||||
name=f"wiim_{self.entity_id}_general_update",
|
||||
)
|
||||
else:
|
||||
self._update_ha_state_from_sdk_cache()
|
||||
|
||||
@callback
|
||||
def _handle_sdk_av_transport_event(
|
||||
self, service: UpnpService, state_variables: list[UpnpStateVariable]
|
||||
) -> None:
|
||||
"""Handle AVTransport events from the SDK.
|
||||
|
||||
This method updates the internal SDK device state based on events,
|
||||
then triggers a full HA state refresh from the device's cache.
|
||||
"""
|
||||
|
||||
LOGGER.debug(
|
||||
"Device %s: Received AVTransport event: %s",
|
||||
self.entity_id,
|
||||
self._device.event_data,
|
||||
)
|
||||
|
||||
event_data = self._device.event_data
|
||||
|
||||
if "TransportState" in event_data:
|
||||
sdk_status_str = event_data["TransportState"]
|
||||
try:
|
||||
sdk_status = SDKPlayingStatus(sdk_status_str)
|
||||
except ValueError:
|
||||
LOGGER.warning(
|
||||
"Device %s: Unknown TransportState from event: %s",
|
||||
self.entity_id,
|
||||
sdk_status_str,
|
||||
)
|
||||
else:
|
||||
self._device.playing_status = sdk_status
|
||||
if sdk_status == SDKPlayingStatus.STOPPED:
|
||||
LOGGER.debug(
|
||||
"Device %s: TransportState is STOPPED. Resetting media position and metadata",
|
||||
self.entity_id,
|
||||
)
|
||||
self._device.current_position = 0
|
||||
self._device.current_track_duration = 0
|
||||
self._attr_media_position_updated_at = None
|
||||
self._attr_media_duration = None
|
||||
self._attr_media_position = None
|
||||
elif sdk_status in {SDKPlayingStatus.PAUSED, SDKPlayingStatus.PLAYING}:
|
||||
self._entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._device.sync_device_duration_and_position(),
|
||||
name=f"wiim_{self.entity_id}_sync_position",
|
||||
)
|
||||
|
||||
self._update_ha_state_from_sdk_cache()
|
||||
|
||||
@callback
|
||||
def _handle_sdk_refresh_event(
|
||||
self, _service: UpnpService, state_variables: list[UpnpStateVariable]
|
||||
) -> None:
|
||||
"""Handle SDK events that only require a state refresh."""
|
||||
LOGGER.debug(
|
||||
"Device %s: Received SDK refresh event: %s", self.entity_id, state_variables
|
||||
)
|
||||
self._update_ha_state_from_sdk_cache()
|
||||
|
||||
async def _async_get_transport_capabilities_for_device(
|
||||
self, device: WiimDevice
|
||||
) -> WiimTransportCapabilities | None:
|
||||
"""Return transport capabilities for a device."""
|
||||
try:
|
||||
return await device.async_get_transport_capabilities()
|
||||
except WiimRequestException as err:
|
||||
LOGGER.warning(
|
||||
"Device %s: Failed to fetch transport capabilities: %s",
|
||||
device.udn,
|
||||
err,
|
||||
)
|
||||
return None
|
||||
except RuntimeError as err:
|
||||
LOGGER.error(
|
||||
"Device %s: Unexpected error in transport capability detection: %s",
|
||||
device.udn,
|
||||
err,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _from_device_update_supported_features(
|
||||
self, *, write_state: bool = True
|
||||
) -> None:
|
||||
"""Fetches media info from the device to dynamically update supported features.
|
||||
|
||||
This method is asynchronous and makes a network call.
|
||||
"""
|
||||
metadata_device = self._metadata_device
|
||||
previous_capabilities = self._transport_capabilities
|
||||
if (
|
||||
transport_capabilities
|
||||
:= await self._async_get_transport_capabilities_for_device(metadata_device)
|
||||
) is not None:
|
||||
if self._transport_capabilities != transport_capabilities:
|
||||
self._transport_capabilities = transport_capabilities
|
||||
LOGGER.debug(
|
||||
"Device %s: Updated transport capabilities to %s",
|
||||
self.entity_id,
|
||||
transport_capabilities,
|
||||
)
|
||||
elif (
|
||||
metadata_device is not self._device
|
||||
and self._transport_capabilities is not None
|
||||
):
|
||||
self._transport_capabilities = None
|
||||
LOGGER.debug(
|
||||
"Device %s: Follower transport capabilities unavailable, using base features",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
if write_state and self._transport_capabilities != previous_capabilities:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_schedule_update_supported_features(self) -> None:
|
||||
"""Update supported features based on current state."""
|
||||
# Avoid parallel MEDIA_INFO request.
|
||||
if self._supported_features_update_in_flight:
|
||||
return
|
||||
|
||||
self._supported_features_update_in_flight = True
|
||||
|
||||
async def _refresh_supported_features() -> None:
|
||||
try:
|
||||
await self._from_device_update_supported_features()
|
||||
finally:
|
||||
self._supported_features_update_in_flight = False
|
||||
|
||||
self._entry.async_create_background_task(
|
||||
self.hass,
|
||||
_refresh_supported_features(),
|
||||
name=f"wiim_{self.entity_id}_refresh_supported_features",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_registry_updated(
|
||||
self, event: Event[er.EventEntityRegistryUpdatedData]
|
||||
) -> None:
|
||||
"""Keep the entity-to-UDN map in sync with entity registry updates."""
|
||||
if (
|
||||
event.data["action"] == "update"
|
||||
and (old_entity_id := event.data.get("old_entity_id"))
|
||||
and old_entity_id != (entity_id := event.data["entity_id"])
|
||||
):
|
||||
self._wiim_data.entity_id_to_udn_map.pop(old_entity_id, None)
|
||||
self._wiim_data.entity_id_to_udn_map[entity_id] = self._device.udn
|
||||
|
||||
super()._async_registry_updated(event)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity is added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
self._wiim_data.entity_id_to_udn_map[self.entity_id] = self._device.udn
|
||||
LOGGER.debug(
|
||||
"Added %s (UDN: %s) to entity maps in hass.data",
|
||||
self.entity_id,
|
||||
self._device.udn,
|
||||
)
|
||||
|
||||
await self._from_device_update_supported_features(write_state=False)
|
||||
self._update_ha_state_from_sdk_cache(
|
||||
write_state=False, update_supported_features=False
|
||||
)
|
||||
self._device.general_event_callback = self._handle_sdk_general_device_update
|
||||
self._device.av_transport_event_callback = self._handle_sdk_av_transport_event
|
||||
self._device.rendering_control_event_callback = self._handle_sdk_refresh_event
|
||||
self._device.play_queue_event_callback = self._handle_sdk_refresh_event
|
||||
LOGGER.debug(
|
||||
"Entity %s registered callbacks with WiimDevice %s",
|
||||
self.entity_id,
|
||||
self._device.name,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from Home Assistant."""
|
||||
# Unregister SDK callbacks
|
||||
self._device.general_event_callback = None
|
||||
self._device.av_transport_event_callback = None
|
||||
self._device.rendering_control_event_callback = None
|
||||
self._device.play_queue_event_callback = None
|
||||
LOGGER.debug(
|
||||
"Entity %s unregistered callbacks from WiimDevice %s",
|
||||
self.entity_id,
|
||||
self._device.name,
|
||||
)
|
||||
self._wiim_data.entity_id_to_udn_map.pop(self.entity_id, None)
|
||||
LOGGER.debug("Removed %s from entity_id_to_udn_map", self.entity_id)
|
||||
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
async def _async_handle_critical_error(self, error: WiimException) -> None:
|
||||
"""Handle communication failures by marking the device unavailable."""
|
||||
if self._device.available:
|
||||
LOGGER.info(
|
||||
"Lost connection to WiiM device %s: %s",
|
||||
self.entity_id,
|
||||
error,
|
||||
)
|
||||
self._device.set_available(False)
|
||||
self._update_ha_state_from_sdk_cache()
|
||||
|
||||
await self._wiim_data.controller.async_update_all_multiroom_status()
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0-1."""
|
||||
await self._device.async_set_volume(round(volume * 100))
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute (true) or unmute (false) media player."""
|
||||
await self._device.async_set_mute(mute)
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._get_command_target_device("media_play").async_play()
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
target_device = self._get_command_target_device("media_pause")
|
||||
await target_device.async_pause()
|
||||
await target_device.sync_device_duration_and_position()
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._get_command_target_device("media_stop").async_stop()
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self._get_command_target_device("media_next_track").async_next()
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self._get_command_target_device("media_previous_track").async_previous()
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Seek to a specific position in the track."""
|
||||
await self._get_command_target_device("media_seek").async_seek(int(position))
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
LOGGER.debug(
|
||||
"async_play_media: type=%s, id=%s, kwargs=%s", media_type, media_id, kwargs
|
||||
)
|
||||
target_device = self._get_command_target_device("play_media")
|
||||
|
||||
if media_source.is_media_source_id(media_id):
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
await self._async_play_url(target_device, play_item.url)
|
||||
elif media_type == MEDIA_TYPE_WIIM_LIBRARY:
|
||||
if not media_id.isdigit():
|
||||
raise ServiceValidationError(f"Invalid preset ID: {media_id}")
|
||||
|
||||
preset_number = int(media_id)
|
||||
await target_device.play_preset(preset_number)
|
||||
self._attr_media_content_id = f"wiim_preset_{preset_number}"
|
||||
self._attr_media_content_type = MediaType.PLAYLIST
|
||||
self._attr_state = MediaPlayerState.PLAYING
|
||||
elif media_type == MediaType.MUSIC:
|
||||
if media_id.isdigit():
|
||||
preset_number = int(media_id)
|
||||
await target_device.play_preset(preset_number)
|
||||
self._attr_media_content_id = f"wiim_preset_{preset_number}"
|
||||
self._attr_media_content_type = MediaType.PLAYLIST
|
||||
self._attr_state = MediaPlayerState.PLAYING
|
||||
else:
|
||||
await self._async_play_url(target_device, media_id)
|
||||
elif media_type == MediaType.URL:
|
||||
await self._async_play_url(target_device, media_id)
|
||||
elif media_type == MediaType.TRACK:
|
||||
if not media_id.isdigit():
|
||||
raise ServiceValidationError(
|
||||
f"Invalid media_id: {media_id}. Expected a valid track index."
|
||||
)
|
||||
|
||||
track_index = int(media_id)
|
||||
await target_device.async_play_queue_with_index(track_index)
|
||||
self._attr_media_content_id = f"wiim_track_{track_index}"
|
||||
self._attr_media_content_type = MediaType.TRACK
|
||||
self._attr_state = MediaPlayerState.PLAYING
|
||||
else:
|
||||
raise ServiceValidationError(f"Unsupported media type: {media_type}")
|
||||
|
||||
async def _async_play_url(self, target_device: WiimDevice, media_id: str) -> None:
|
||||
"""Play a direct media URL on the target device."""
|
||||
if not target_device.supports_http_api:
|
||||
raise ServiceValidationError(
|
||||
"Direct URL playback is not supported on this device"
|
||||
)
|
||||
|
||||
url = async_process_play_media_url(self.hass, media_id)
|
||||
LOGGER.debug("HTTP media_type for play_media: %s", url)
|
||||
await target_device.play_url(url)
|
||||
self._attr_state = MediaPlayerState.PLAYING
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set repeat mode."""
|
||||
target_device = self._get_command_target_device("repeat_set")
|
||||
await target_device.async_set_loop_mode(
|
||||
target_device.build_loop_mode(WiimRepeatMode(repeat), self._attr_shuffle)
|
||||
)
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Enable/disable shuffle mode."""
|
||||
repeat = self._attr_repeat or WiimRepeatMode.OFF
|
||||
target_device = self._get_command_target_device("shuffle_set")
|
||||
await target_device.async_set_loop_mode(
|
||||
target_device.build_loop_mode(WiimRepeatMode(repeat), shuffle)
|
||||
)
|
||||
|
||||
@media_player_exception_wrap
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input mode."""
|
||||
await self._get_command_target_device("select_source").async_set_play_mode(
|
||||
source
|
||||
)
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
media_content_type: MediaType | str | None = None,
|
||||
media_content_id: str | None = None,
|
||||
) -> BrowseMedia:
|
||||
"""Implement media Browse helper."""
|
||||
LOGGER.debug(
|
||||
"Browsing media: content_type=%s, content_id=%s",
|
||||
media_content_type,
|
||||
media_content_id,
|
||||
)
|
||||
|
||||
if media_content_id is not None and media_source.is_media_source_id(
|
||||
media_content_id
|
||||
):
|
||||
if not self._device.supports_http_api:
|
||||
raise BrowseError("Media sources are not supported on this device")
|
||||
|
||||
return await media_source.async_browse_media(
|
||||
self.hass,
|
||||
media_content_id,
|
||||
content_filter=lambda item: item.media_content_type.startswith(
|
||||
"audio/"
|
||||
),
|
||||
)
|
||||
|
||||
# Root browse
|
||||
if media_content_id is None or media_content_id == MEDIA_CONTENT_ID_ROOT:
|
||||
children: list[BrowseMedia] = []
|
||||
children.append(
|
||||
BrowseMedia(
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id=MEDIA_CONTENT_ID_FAVORITES,
|
||||
media_content_type=MediaType.PLAYLIST,
|
||||
title="Presets",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail=None,
|
||||
),
|
||||
)
|
||||
children.append(
|
||||
BrowseMedia(
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id=MEDIA_CONTENT_ID_PLAYLISTS,
|
||||
media_content_type=MediaType.PLAYLIST,
|
||||
title="Queue",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail=None,
|
||||
),
|
||||
)
|
||||
if self._device.supports_http_api:
|
||||
media_sources_item = await media_source.async_browse_media(
|
||||
self.hass,
|
||||
None,
|
||||
content_filter=lambda item: item.media_content_type.startswith(
|
||||
"audio/"
|
||||
),
|
||||
)
|
||||
|
||||
if media_sources_item.children:
|
||||
children.extend(media_sources_item.children)
|
||||
|
||||
return BrowseMedia(
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id=MEDIA_CONTENT_ID_ROOT,
|
||||
media_content_type=MEDIA_TYPE_WIIM_LIBRARY,
|
||||
title=self._device.name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=children,
|
||||
)
|
||||
|
||||
if media_content_id == MEDIA_CONTENT_ID_FAVORITES:
|
||||
sdk_favorites = await self._device.async_get_presets()
|
||||
favorites_items = [
|
||||
BrowseMedia(
|
||||
media_class=MediaClass.PLAYLIST,
|
||||
media_content_id=str(item.preset_id),
|
||||
media_content_type=MediaType.MUSIC,
|
||||
title=item.title,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=item.image_url,
|
||||
)
|
||||
for item in sdk_favorites
|
||||
]
|
||||
|
||||
return BrowseMedia(
|
||||
media_class=MediaClass.PLAYLIST,
|
||||
media_content_id=MEDIA_CONTENT_ID_FAVORITES,
|
||||
media_content_type=MediaType.PLAYLIST,
|
||||
title="Presets",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=favorites_items,
|
||||
)
|
||||
|
||||
if media_content_id == MEDIA_CONTENT_ID_PLAYLISTS:
|
||||
queue_snapshot = await self._device.async_get_queue_snapshot()
|
||||
if not queue_snapshot.is_active:
|
||||
return BrowseMedia(
|
||||
media_class=MediaClass.PLAYLIST,
|
||||
media_content_id=MEDIA_CONTENT_ID_PLAYLISTS,
|
||||
media_content_type=MediaType.PLAYLIST,
|
||||
title="Queue",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[],
|
||||
)
|
||||
|
||||
playlist_track_items = [
|
||||
BrowseMedia(
|
||||
media_class=MediaClass.TRACK,
|
||||
media_content_id=str(item.queue_index),
|
||||
media_content_type=MediaType.TRACK,
|
||||
title=item.title,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=item.image_url,
|
||||
)
|
||||
for item in queue_snapshot.items
|
||||
]
|
||||
|
||||
return BrowseMedia(
|
||||
media_class=MediaClass.PLAYLIST,
|
||||
media_content_id=MEDIA_CONTENT_ID_PLAYLISTS,
|
||||
media_content_type=MediaType.PLAYLIST,
|
||||
title="Queue",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=playlist_track_items,
|
||||
)
|
||||
|
||||
LOGGER.warning(
|
||||
"Unhandled browse_media request: content_type=%s, content_id=%s",
|
||||
media_content_type,
|
||||
media_content_id,
|
||||
)
|
||||
raise BrowseError(f"Invalid browse path: {media_content_id}")
|
||||
13
homeassistant/components/wiim/models.py
Normal file
13
homeassistant/components/wiim/models.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Runtime models for the WiiM integration."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from wiim.controller import WiimController
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiimData:
|
||||
"""Runtime data for the WiiM integration shared across platforms."""
|
||||
|
||||
controller: WiimController
|
||||
entity_id_to_udn_map: dict[str, str] = field(default_factory=dict)
|
||||
78
homeassistant/components/wiim/quality_scale.yaml
Normal file
78
homeassistant/components/wiim/quality_scale.yaml
Normal file
@@ -0,0 +1,78 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: todo
|
||||
comment: |
|
||||
- The calls to the api can be changed to return bool, and services can then raise HomeAssistantError
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration has no configuration parameters
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
- Increase test coverage for the media_player platform
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: |
|
||||
Set appropriate device classes for all entities where applicable.
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No known use cases for repair issues or flows, yet
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
35
homeassistant/components/wiim/strings.json
Normal file
35
homeassistant/components/wiim/strings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to set up {name}?"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the WiiM device."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_grouping_entity": {
|
||||
"message": "Entity with ID {entity_id} can't be added to the WiiM multiroom. Is the entity a WiiM media player?"
|
||||
},
|
||||
"missing_homeassistant_url": {
|
||||
"message": "Failed to determine Home Assistant URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -803,6 +803,7 @@ FLOWS = {
|
||||
"whirlpool",
|
||||
"whois",
|
||||
"wiffi",
|
||||
"wiim",
|
||||
"wilight",
|
||||
"withings",
|
||||
"wiz",
|
||||
|
||||
@@ -7777,6 +7777,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"wiim": {
|
||||
"name": "WiiM",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"wilight": {
|
||||
"name": "WiLight",
|
||||
"integration_type": "hub",
|
||||
|
||||
3
homeassistant/generated/zeroconf.py
generated
3
homeassistant/generated/zeroconf.py
generated
@@ -711,6 +711,9 @@ ZEROCONF = {
|
||||
{
|
||||
"domain": "linkplay",
|
||||
},
|
||||
{
|
||||
"domain": "wiim",
|
||||
},
|
||||
],
|
||||
"_lookin._tcp.local.": [
|
||||
{
|
||||
|
||||
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -3294,6 +3294,9 @@ whois==0.9.27
|
||||
# homeassistant.components.wiffi
|
||||
wiffi==1.1.2
|
||||
|
||||
# homeassistant.components.wiim
|
||||
wiim==0.1.0
|
||||
|
||||
# homeassistant.components.wirelesstag
|
||||
wirelesstagpy==0.8.1
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -2779,6 +2779,9 @@ whois==0.9.27
|
||||
# homeassistant.components.wiffi
|
||||
wiffi==1.1.2
|
||||
|
||||
# homeassistant.components.wiim
|
||||
wiim==0.1.0
|
||||
|
||||
# homeassistant.components.wled
|
||||
wled==0.21.0
|
||||
|
||||
|
||||
42
tests/components/wiim/__init__.py
Normal file
42
tests/components/wiim/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Tests for the wiim integration."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from wiim.consts import PlayingStatus
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core_config import async_process_ha_core_config
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Set up the component."""
|
||||
await async_process_ha_core_config(
|
||||
hass,
|
||||
{"internal_url": "http://192.168.1.10:8123"},
|
||||
)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def fire_general_update(hass: HomeAssistant, mock_device: MagicMock) -> None:
|
||||
"""Trigger the registered general update callback on the mock device."""
|
||||
assert mock_device.general_event_callback is not None
|
||||
mock_device.general_event_callback(mock_device)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def fire_transport_update(
|
||||
hass: HomeAssistant,
|
||||
mock_device: MagicMock,
|
||||
transport_state: PlayingStatus,
|
||||
) -> None:
|
||||
"""Trigger the registered AVTransport callback on the mock device."""
|
||||
assert mock_device.av_transport_event_callback is not None
|
||||
mock_device.event_data = {"TransportState": transport_state.value}
|
||||
mock_device.av_transport_event_callback(MagicMock(), [])
|
||||
await hass.async_block_till_done()
|
||||
171
tests/components/wiim/conftest.py
Normal file
171
tests/components/wiim/conftest.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Pytest fixtures and shared setup for the WiiM integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from wiim.consts import AudioOutputHwMode, InputMode, LoopMode, PlayingStatus
|
||||
from wiim.controller import WiimController
|
||||
from wiim.models import (
|
||||
WiimGroupRole,
|
||||
WiimGroupSnapshot,
|
||||
WiimLoopState,
|
||||
WiimProbeResult,
|
||||
WiimQueueSnapshot,
|
||||
WiimRepeatMode,
|
||||
WiimTransportCapabilities,
|
||||
)
|
||||
|
||||
from homeassistant.components.wiim import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Mock setting up a config entry."""
|
||||
with patch(
|
||||
"homeassistant.components.wiim.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
yield mock_setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Mock Home Assistant ConfigEntry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "192.168.1.100"},
|
||||
title="Test WiiM Device",
|
||||
unique_id="uuid:test-udn-1234",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wiim_device() -> Generator[AsyncMock]:
|
||||
"""Patch async_create_wiim_device and return the mocked WiimDevice instance."""
|
||||
with patch(
|
||||
"homeassistant.components.wiim.async_create_wiim_device",
|
||||
autospec=True,
|
||||
) as mock_create:
|
||||
mock = mock_create.return_value
|
||||
mock.udn = "uuid:test-udn-1234"
|
||||
mock.name = "Test WiiM Device"
|
||||
mock.model_name = "WiiM Pro"
|
||||
mock.manufacturer = "Linkplay Tech"
|
||||
mock.firmware_version = "4.8.523456"
|
||||
mock.ip_address = "192.168.1.100"
|
||||
mock.http_api_url = "http://192.168.1.100:8080"
|
||||
mock.presentation_url = "http://192.168.1.100:8080/web_interface"
|
||||
mock.available = True
|
||||
mock.model = "WiiM Pro"
|
||||
mock.volume = 50
|
||||
mock.is_muted = False
|
||||
mock.supports_http_api = False
|
||||
mock.playing_status = PlayingStatus.STOPPED
|
||||
mock.loop_mode = LoopMode.SHUFFLE_DISABLE_REPEAT_NONE
|
||||
mock.loop_state = WiimLoopState(
|
||||
repeat=WiimRepeatMode.OFF,
|
||||
shuffle=False,
|
||||
)
|
||||
mock.input_mode = InputMode.LINE_IN
|
||||
mock.audio_output_hw_mode = AudioOutputHwMode.SPEAKER_OUT.display_name # type: ignore[attr-defined]
|
||||
mock.mac_address = "AA:BB:CC:DD:EE:FF"
|
||||
mock.current_track_info = {}
|
||||
mock.current_media = None
|
||||
mock.current_track_duration = 0
|
||||
mock.play_mode = "Network"
|
||||
mock.equalizer_mode = ""
|
||||
mock.current_position = 0
|
||||
mock.next_track_uri = ""
|
||||
mock.event_data = {}
|
||||
mock.general_event_callback = None
|
||||
mock.av_transport_event_callback = None
|
||||
mock.rendering_control_event_callback = None
|
||||
mock.play_queue_event_callback = None
|
||||
mock.output_mode = "speaker"
|
||||
mock.supported_input_modes = (InputMode.LINE_IN.display_name,) # type: ignore[attr-defined]
|
||||
mock.supported_output_modes = (
|
||||
AudioOutputHwMode.SPEAKER_OUT.display_name, # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
upnp_device = MagicMock()
|
||||
upnp_device.udn = mock.udn
|
||||
upnp_device.friendly_name = mock.name
|
||||
upnp_device.model_name = mock.model_name
|
||||
upnp_device.manufacturer = mock.manufacturer
|
||||
upnp_device.serial_number = "TESTSERIAL123"
|
||||
upnp_device.presentation_url = mock.presentation_url
|
||||
upnp_device.get_device_info = MagicMock(
|
||||
return_value={
|
||||
"udn": mock.udn,
|
||||
"friendly_name": mock.name,
|
||||
"model_name": mock.model_name,
|
||||
"manufacturer": mock.manufacturer,
|
||||
"serial_number": upnp_device.serial_number,
|
||||
}
|
||||
)
|
||||
mock.upnp_device = upnp_device
|
||||
|
||||
mock.get_audio_output_hw_mode = AsyncMock(return_value="speaker")
|
||||
mock.async_get_play_queue = AsyncMock(return_value=[])
|
||||
mock.async_get_audio_output_modes = AsyncMock(return_value=[])
|
||||
mock.async_get_input_modes = AsyncMock(return_value=[])
|
||||
mock.async_get_play_mediums = AsyncMock(return_value=[])
|
||||
mock.async_get_transport_capabilities = AsyncMock(
|
||||
return_value=WiimTransportCapabilities(
|
||||
can_next=False,
|
||||
can_previous=False,
|
||||
can_repeat=False,
|
||||
can_shuffle=False,
|
||||
)
|
||||
)
|
||||
mock.async_get_presets = AsyncMock(return_value=())
|
||||
mock.async_get_queue_snapshot = AsyncMock(
|
||||
return_value=WiimQueueSnapshot(items=())
|
||||
)
|
||||
mock.build_loop_mode = MagicMock(
|
||||
return_value=LoopMode.SHUFFLE_DISABLE_REPEAT_NONE
|
||||
)
|
||||
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wiim_controller(mock_wiim_device: AsyncMock) -> Generator[MagicMock]:
|
||||
"""Mock a WiimController instance."""
|
||||
mock = MagicMock(spec=WiimController)
|
||||
mock.add_device = AsyncMock()
|
||||
mock.disconnect = AsyncMock()
|
||||
mock.remove_device = AsyncMock()
|
||||
mock.async_update_all_multiroom_status = AsyncMock()
|
||||
mock.get_group_snapshot.return_value = WiimGroupSnapshot(
|
||||
role=WiimGroupRole.STANDALONE,
|
||||
leader_udn=mock_wiim_device.udn,
|
||||
member_udns=(mock_wiim_device.udn,),
|
||||
)
|
||||
mock.get_device.side_effect = lambda _udn: mock_wiim_device
|
||||
with patch(
|
||||
"homeassistant.components.wiim.WiimController",
|
||||
return_value=mock,
|
||||
):
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_probe_player() -> Generator[AsyncMock]:
|
||||
"""Mock a WiimProbePlayer instance."""
|
||||
with patch(
|
||||
"homeassistant.components.wiim.config_flow.async_probe_wiim_device"
|
||||
) as mock_probe:
|
||||
mock_probe.return_value = WiimProbeResult(
|
||||
host="192.168.1.100",
|
||||
udn="uuid:test-udn-1234",
|
||||
name="WiiM Pro",
|
||||
location="http://192.168.1.100:49152/description.xml",
|
||||
model="WiiM Pro",
|
||||
)
|
||||
yield mock_probe
|
||||
208
tests/components/wiim/test_config_flow.py
Normal file
208
tests/components/wiim/test_config_flow.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Tests for the WiiM config flow."""
|
||||
|
||||
from ipaddress import ip_address
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.wiim.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||
|
||||
DISCOVERY_INFO = ZeroconfServiceInfo(
|
||||
ip_address=ip_address("192.168.1.100"),
|
||||
ip_addresses=[ip_address("192.168.1.100")],
|
||||
hostname="wiim-pro.local.",
|
||||
name="WiiM Pro._linkplay._tcp.local.",
|
||||
port=49152,
|
||||
properties={"uuid": "uuid:test-udn-1234"},
|
||||
type="_linkplay._tcp.local.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_probe_player", "mock_setup_entry")
|
||||
async def test_user_flow_create_entry(hass: HomeAssistant) -> None:
|
||||
"""Test the user flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.1.100"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "WiiM Pro"
|
||||
assert result["data"] == {CONF_HOST: "192.168.1.100"}
|
||||
assert result["result"].unique_id == "uuid:test-udn-1234"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_user_flow_cannot_connect(
|
||||
hass: HomeAssistant, mock_probe_player: AsyncMock
|
||||
) -> None:
|
||||
"""Test the user flow handles connection failures."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_probe_player.side_effect = TimeoutError
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.1.100"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
mock_probe_player.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.1.100"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_user_flow_no_probe(
|
||||
hass: HomeAssistant, mock_probe_player: AsyncMock
|
||||
) -> None:
|
||||
"""Test the user flow handles connection failures."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
old_return_value = mock_probe_player.return_value
|
||||
|
||||
mock_probe_player.return_value = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.1.100"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
mock_probe_player.return_value = old_return_value
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.1.100"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_probe_player")
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test the user flow aborts for an already configured device."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.1.100"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_probe_player", "mock_setup_entry")
|
||||
async def test_zeroconf_flow(hass: HomeAssistant) -> None:
|
||||
"""Test the zeroconf discovery flow."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=DISCOVERY_INFO,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
assert result["description_placeholders"] == {"name": "WiiM Pro"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "WiiM Pro"
|
||||
assert result["data"] == {CONF_HOST: "192.168.1.100"}
|
||||
assert result["result"].unique_id == "uuid:test-udn-1234"
|
||||
|
||||
|
||||
async def test_zeroconf_flow_cannot_connect(
|
||||
hass: HomeAssistant, mock_probe_player: AsyncMock
|
||||
) -> None:
|
||||
"""Test the zeroconf flow aborts on connection errors."""
|
||||
mock_probe_player.side_effect = TimeoutError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=DISCOVERY_INFO,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_zeroconf_flow_no_probe(
|
||||
hass: HomeAssistant, mock_probe_player: AsyncMock
|
||||
) -> None:
|
||||
"""Test the zeroconf flow aborts when probing failed."""
|
||||
mock_probe_player.return_value = None
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=DISCOVERY_INFO,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_zeroconf_flow_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test the zeroconf flow aborts for an already configured device."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=ZeroconfServiceInfo(
|
||||
ip_address=ip_address("192.168.1.101"),
|
||||
ip_addresses=[ip_address("192.168.1.101")],
|
||||
hostname="wiim-pro.local.",
|
||||
name="WiiM Pro._linkplay._tcp.local.",
|
||||
port=49152,
|
||||
properties={"uuid": "uuid:test-udn-1234"},
|
||||
type="_linkplay._tcp.local.",
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert mock_config_entry.data[CONF_HOST] == "192.168.1.101"
|
||||
87
tests/components/wiim/test_init.py
Normal file
87
tests/components/wiim/test_init.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tests for the WiiM integration initialization."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from wiim.exceptions import WiimDeviceException, WiimRequestException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core_config import async_process_ha_core_config
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_load_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: AsyncMock,
|
||||
mock_wiim_controller: AsyncMock,
|
||||
) -> None:
|
||||
"""Test loading and unloading a config entry."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await hass.config_entries.async_remove(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "state"),
|
||||
[
|
||||
(WiimDeviceException("device init failed"), ConfigEntryState.SETUP_RETRY),
|
||||
(WiimRequestException("http failure"), ConfigEntryState.SETUP_RETRY),
|
||||
],
|
||||
)
|
||||
async def test_setup_raises_config_entry_not_ready(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_controller: AsyncMock,
|
||||
exc: Exception,
|
||||
state: ConfigEntryState,
|
||||
) -> None:
|
||||
"""Test that setup raises ConfigEntryNotReady on device/request exceptions."""
|
||||
with patch(
|
||||
"homeassistant.components.wiim.async_create_wiim_device",
|
||||
side_effect=exc,
|
||||
):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is state
|
||||
|
||||
|
||||
async def test_setup_raises_config_entry_not_ready_when_no_url(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: AsyncMock,
|
||||
mock_wiim_controller: AsyncMock,
|
||||
) -> None:
|
||||
"""Test setup raises ConfigEntryNotReady when no internal URL is configured."""
|
||||
# Do NOT call async_process_ha_core_config so get_url raises NoURLAvailableError.
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_no_url_after_core_config(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: AsyncMock,
|
||||
mock_wiim_controller: AsyncMock,
|
||||
) -> None:
|
||||
"""Test that setup succeeds once internal_url is configured."""
|
||||
await async_process_ha_core_config(
|
||||
hass, {"internal_url": "http://192.168.1.10:8123"}
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
723
tests/components/wiim/test_media_player.py
Normal file
723
tests/components/wiim/test_media_player.py
Normal file
@@ -0,0 +1,723 @@
|
||||
"""Tests for the WiiM media player via services and the state machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from wiim.consts import PlayingStatus
|
||||
from wiim.models import (
|
||||
WiimGroupRole,
|
||||
WiimGroupSnapshot,
|
||||
WiimLoopState,
|
||||
WiimMediaMetadata,
|
||||
WiimPreset,
|
||||
WiimQueueItem,
|
||||
WiimQueueSnapshot,
|
||||
WiimRepeatMode,
|
||||
WiimTransportCapabilities,
|
||||
)
|
||||
from wiim.wiim_device import WiimDevice
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_MEDIA_ALBUM_NAME,
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
ATTR_MEDIA_DURATION,
|
||||
ATTR_MEDIA_POSITION,
|
||||
ATTR_MEDIA_REPEAT,
|
||||
ATTR_MEDIA_SHUFFLE,
|
||||
ATTR_MEDIA_TITLE,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_BROWSE_MEDIA,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_SEEK,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
SERVICE_REPEAT_SET,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
SERVICE_SHUFFLE_SET,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
SERVICE_VOLUME_SET,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import fire_general_update, fire_transport_update, setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MEDIA_PLAYER_ENTITY_ID = "media_player.test_wiim_device"
|
||||
|
||||
|
||||
async def test_state_machine_updates_from_device_callbacks(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test cached device state is reflected in Home Assistant."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.IDLE
|
||||
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5
|
||||
assert state.attributes[ATTR_INPUT_SOURCE] == "Network"
|
||||
assert state.attributes["supported_features"] == int(
|
||||
MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.GROUPING
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
)
|
||||
|
||||
mock_wiim_device.volume = 60
|
||||
mock_wiim_device.playing_status = PlayingStatus.PLAYING
|
||||
mock_wiim_device.play_mode = "Bluetooth"
|
||||
mock_wiim_device.output_mode = "optical"
|
||||
mock_wiim_device.loop_state = WiimLoopState(
|
||||
repeat=WiimRepeatMode.ALL,
|
||||
shuffle=True,
|
||||
)
|
||||
mock_wiim_device.current_media = WiimMediaMetadata(
|
||||
title="New Song",
|
||||
artist="Test Artist",
|
||||
album="Test Album",
|
||||
uri="http://example.com/song.flac",
|
||||
duration=180,
|
||||
position=42,
|
||||
)
|
||||
mock_wiim_device.async_get_transport_capabilities.return_value = (
|
||||
WiimTransportCapabilities(
|
||||
can_next=True,
|
||||
can_previous=False,
|
||||
can_repeat=True,
|
||||
can_shuffle=True,
|
||||
)
|
||||
)
|
||||
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.PLAYING
|
||||
assert state.attributes[ATTR_MEDIA_TITLE] == "New Song"
|
||||
assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Test Album"
|
||||
assert state.attributes[ATTR_MEDIA_DURATION] == 180
|
||||
assert state.attributes[ATTR_MEDIA_POSITION] == 42
|
||||
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.6
|
||||
assert state.attributes[ATTR_INPUT_SOURCE] == "Bluetooth"
|
||||
assert state.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.ALL
|
||||
assert state.attributes[ATTR_MEDIA_SHUFFLE] is True
|
||||
assert state.attributes["supported_features"] == int(
|
||||
MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.GROUPING
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.REPEAT_SET
|
||||
| MediaPlayerEntityFeature.SHUFFLE_SET
|
||||
)
|
||||
|
||||
|
||||
async def test_state_machine_updates_from_transport_events(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test transport events update the state machine."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_wiim_device.current_media = WiimMediaMetadata(
|
||||
title="Queued Song",
|
||||
duration=240,
|
||||
position=30,
|
||||
)
|
||||
|
||||
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PLAYING)
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.PLAYING
|
||||
assert state.attributes[ATTR_MEDIA_TITLE] == "Queued Song"
|
||||
|
||||
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PAUSED)
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.PAUSED
|
||||
|
||||
mock_wiim_device.current_media = None
|
||||
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.STOPPED)
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.IDLE
|
||||
assert state.attributes.get(ATTR_MEDIA_TITLE) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"service",
|
||||
"service_data",
|
||||
"device_method",
|
||||
"expected_args",
|
||||
"state_update",
|
||||
"state_attr",
|
||||
"state_value",
|
||||
),
|
||||
[
|
||||
(
|
||||
SERVICE_VOLUME_SET,
|
||||
{ATTR_MEDIA_VOLUME_LEVEL: 0.75},
|
||||
"async_set_volume",
|
||||
(75,),
|
||||
{"volume": 75},
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
0.75,
|
||||
),
|
||||
(
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_MEDIA_VOLUME_MUTED: True},
|
||||
"async_set_mute",
|
||||
(True,),
|
||||
{"is_muted": True},
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
True,
|
||||
),
|
||||
(
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_INPUT_SOURCE: "Bluetooth"},
|
||||
"async_set_play_mode",
|
||||
("Bluetooth",),
|
||||
{"play_mode": "Bluetooth"},
|
||||
ATTR_INPUT_SOURCE,
|
||||
"Bluetooth",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_control_services_update_state_machine(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
service: str,
|
||||
service_data: dict[str, object],
|
||||
device_method: str,
|
||||
expected_args: tuple[object, ...],
|
||||
state_update: dict[str, object],
|
||||
state_attr: str,
|
||||
state_value: object,
|
||||
) -> None:
|
||||
"""Test control services are exercised through Home Assistant."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, **service_data},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
getattr(mock_wiim_device, device_method).assert_awaited_once_with(*expected_args)
|
||||
|
||||
for attr_name, attr_value in state_update.items():
|
||||
setattr(mock_wiim_device, attr_name, attr_value)
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.attributes[state_attr] == state_value
|
||||
|
||||
|
||||
async def test_repeat_and_shuffle_services_update_state_machine(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test repeat and shuffle go through services and state updates."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_wiim_device.async_get_transport_capabilities.return_value = (
|
||||
WiimTransportCapabilities(
|
||||
can_next=True,
|
||||
can_previous=True,
|
||||
can_repeat=True,
|
||||
can_shuffle=True,
|
||||
)
|
||||
)
|
||||
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
|
||||
repeat_loop_mode = object()
|
||||
mock_wiim_device.build_loop_mode.return_value = repeat_loop_mode
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_REPEAT_SET,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_REPEAT: RepeatMode.ALL},
|
||||
blocking=True,
|
||||
)
|
||||
mock_wiim_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, False)
|
||||
mock_wiim_device.async_set_loop_mode.assert_awaited_once_with(repeat_loop_mode)
|
||||
|
||||
mock_wiim_device.loop_state = WiimLoopState(
|
||||
repeat=WiimRepeatMode.ALL,
|
||||
shuffle=False,
|
||||
)
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.ALL
|
||||
|
||||
mock_wiim_device.build_loop_mode.reset_mock()
|
||||
mock_wiim_device.async_set_loop_mode.reset_mock()
|
||||
shuffle_loop_mode = object()
|
||||
mock_wiim_device.build_loop_mode.return_value = shuffle_loop_mode
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_SHUFFLE_SET,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_SHUFFLE: True},
|
||||
blocking=True,
|
||||
)
|
||||
mock_wiim_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, True)
|
||||
mock_wiim_device.async_set_loop_mode.assert_awaited_once_with(shuffle_loop_mode)
|
||||
|
||||
mock_wiim_device.loop_state = WiimLoopState(
|
||||
repeat=WiimRepeatMode.ALL,
|
||||
shuffle=True,
|
||||
)
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.attributes[ATTR_MEDIA_SHUFFLE] is True
|
||||
|
||||
|
||||
async def test_play_pause_and_seek_services_update_state_machine(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test playback services drive the device and state machine."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
mock_wiim_device.async_play.assert_awaited_once()
|
||||
|
||||
mock_wiim_device.current_media = WiimMediaMetadata(
|
||||
title="Playing Song",
|
||||
duration=200,
|
||||
position=12,
|
||||
)
|
||||
mock_wiim_device.playing_status = PlayingStatus.PLAYING
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.PLAYING
|
||||
assert state.attributes[ATTR_MEDIA_TITLE] == "Playing Song"
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
mock_wiim_device.async_pause.assert_awaited_once()
|
||||
mock_wiim_device.sync_device_duration_and_position.assert_awaited_once()
|
||||
|
||||
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PAUSED)
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.PAUSED
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_SEEK,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, "seek_position": 60},
|
||||
blocking=True,
|
||||
)
|
||||
mock_wiim_device.async_seek.assert_awaited_once_with(60)
|
||||
|
||||
mock_wiim_device.current_media = WiimMediaMetadata(
|
||||
title="Playing Song",
|
||||
duration=200,
|
||||
position=60,
|
||||
)
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.attributes[ATTR_MEDIA_POSITION] == 60
|
||||
|
||||
|
||||
async def test_follower_routes_commands_and_reads_leader_metadata(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test follower commands are routed to the leader device."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
leader_device = AsyncMock(spec=WiimDevice)
|
||||
leader_device.udn = "uuid:leader-1234"
|
||||
leader_device.name = "Leader WiiM Device"
|
||||
leader_device.playing_status = PlayingStatus.STOPPED
|
||||
leader_device.play_mode = "Network"
|
||||
leader_device.loop_state = WiimLoopState(
|
||||
repeat=WiimRepeatMode.OFF,
|
||||
shuffle=False,
|
||||
)
|
||||
leader_device.output_mode = "speaker"
|
||||
leader_device.current_media = None
|
||||
leader_device.async_get_transport_capabilities = AsyncMock(
|
||||
return_value=WiimTransportCapabilities(
|
||||
can_next=True,
|
||||
can_previous=False,
|
||||
can_repeat=True,
|
||||
can_shuffle=False,
|
||||
)
|
||||
)
|
||||
|
||||
mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot(
|
||||
role=WiimGroupRole.FOLLOWER,
|
||||
leader_udn=leader_device.udn,
|
||||
member_udns=(leader_device.udn, mock_wiim_device.udn),
|
||||
)
|
||||
mock_wiim_controller.get_device.side_effect = lambda udn: (
|
||||
leader_device if udn == leader_device.udn else mock_wiim_device
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
leader_device.async_play.assert_awaited_once()
|
||||
mock_wiim_device.async_play.assert_not_awaited()
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_SEEK,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, "seek_position": 90},
|
||||
blocking=True,
|
||||
)
|
||||
leader_device.async_seek.assert_awaited_once_with(90)
|
||||
mock_wiim_device.async_seek.assert_not_awaited()
|
||||
|
||||
leader_device.playing_status = PlayingStatus.PLAYING
|
||||
leader_device.play_mode = "Spotify"
|
||||
leader_device.current_media = WiimMediaMetadata(
|
||||
title="Leader Song",
|
||||
album="Leader Album",
|
||||
duration=210,
|
||||
position=90,
|
||||
)
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.PLAYING
|
||||
assert state.attributes[ATTR_MEDIA_TITLE] == "Leader Song"
|
||||
assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Leader Album"
|
||||
assert state.attributes[ATTR_INPUT_SOURCE] == "Spotify"
|
||||
assert state.attributes[ATTR_MEDIA_POSITION] == 90
|
||||
|
||||
|
||||
async def test_follower_routes_repeat_shuffle_and_source_commands_to_leader(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test follower repeat, shuffle, and source changes are sent to the leader."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
leader_device = AsyncMock(spec=WiimDevice)
|
||||
leader_device.udn = "uuid:leader-1234"
|
||||
leader_device.playing_status = PlayingStatus.STOPPED
|
||||
leader_device.current_media = None
|
||||
leader_device.loop_state = WiimLoopState(
|
||||
repeat=WiimRepeatMode.OFF,
|
||||
shuffle=False,
|
||||
)
|
||||
leader_device.play_mode = "Network"
|
||||
leader_device.async_get_transport_capabilities = AsyncMock(
|
||||
return_value=WiimTransportCapabilities(
|
||||
can_next=True,
|
||||
can_previous=True,
|
||||
can_repeat=True,
|
||||
can_shuffle=True,
|
||||
)
|
||||
)
|
||||
|
||||
mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot(
|
||||
role=WiimGroupRole.FOLLOWER,
|
||||
leader_udn=leader_device.udn,
|
||||
member_udns=(leader_device.udn, mock_wiim_device.udn),
|
||||
)
|
||||
mock_wiim_controller.get_device.side_effect = lambda udn: (
|
||||
leader_device if udn == leader_device.udn else mock_wiim_device
|
||||
)
|
||||
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
|
||||
repeat_loop_mode = object()
|
||||
leader_device.build_loop_mode.return_value = repeat_loop_mode
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_REPEAT_SET,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_REPEAT: RepeatMode.ALL},
|
||||
blocking=True,
|
||||
)
|
||||
leader_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, False)
|
||||
leader_device.async_set_loop_mode.assert_awaited_once_with(repeat_loop_mode)
|
||||
mock_wiim_device.async_set_loop_mode.assert_not_awaited()
|
||||
|
||||
leader_device.build_loop_mode.reset_mock()
|
||||
leader_device.async_set_loop_mode.reset_mock()
|
||||
shuffle_loop_mode = object()
|
||||
leader_device.build_loop_mode.return_value = shuffle_loop_mode
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_SHUFFLE_SET,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_SHUFFLE: True},
|
||||
blocking=True,
|
||||
)
|
||||
leader_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.OFF, True)
|
||||
leader_device.async_set_loop_mode.assert_awaited_once_with(shuffle_loop_mode)
|
||||
mock_wiim_device.async_set_loop_mode.assert_not_awaited()
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_INPUT_SOURCE: "Bluetooth"},
|
||||
blocking=True,
|
||||
)
|
||||
leader_device.async_set_play_mode.assert_awaited_once_with("Bluetooth")
|
||||
mock_wiim_device.async_set_play_mode.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_play_media_services_call_device_commands(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test play_media services are driven through Home Assistant."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
|
||||
ATTR_MEDIA_CONTENT_ID: "1",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_wiim_device.play_preset.assert_awaited_once_with(1)
|
||||
|
||||
mock_wiim_device.current_media = WiimMediaMetadata(title="Preset 1")
|
||||
mock_wiim_device.playing_status = PlayingStatus.PLAYING
|
||||
await fire_general_update(hass, mock_wiim_device)
|
||||
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
||||
assert state.state == MediaPlayerState.PLAYING
|
||||
assert state.attributes[ATTR_MEDIA_TITLE] == "Preset 1"
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.TRACK,
|
||||
ATTR_MEDIA_CONTENT_ID: "2",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_wiim_device.async_play_queue_with_index.assert_awaited_once_with(2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("media_type", [MediaType.MUSIC, MediaType.URL])
|
||||
async def test_play_media_url_service_uses_processed_url(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
media_type: MediaType,
|
||||
) -> None:
|
||||
"""Test direct URL playback goes through the URL processor."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_wiim_device.supports_http_api = True
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.wiim.media_player.async_process_play_media_url",
|
||||
return_value="http://processed/song.mp3",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: media_type,
|
||||
ATTR_MEDIA_CONTENT_ID: "http://example.com/song.mp3",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_wiim_device.play_url.assert_awaited_once_with("http://processed/song.mp3")
|
||||
mock_wiim_device.play_preset.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_play_media_source_service_uses_resolved_url(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test media_source playback goes through the resolver."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_wiim_device.supports_http_api = True
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.wiim.media_player.media_source.is_media_source_id",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.wiim.media_player.media_source.async_resolve_media",
|
||||
AsyncMock(return_value=MagicMock(url="http://resolved/song.mp3")),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.wiim.media_player.async_process_play_media_url",
|
||||
return_value="http://processed/song.mp3",
|
||||
),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
|
||||
ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/song.mp3",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_wiim_device.play_url.assert_awaited_once_with("http://processed/song.mp3")
|
||||
|
||||
|
||||
async def test_browse_media_service_returns_wiim_library(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test browsing WiiM presets and queue via the media_player service."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
root_result = await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_BROWSE_MEDIA,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
root_browse = root_result[MEDIA_PLAYER_ENTITY_ID]
|
||||
assert root_browse.title == mock_wiim_device.name
|
||||
assert [child.title for child in root_browse.children] == ["Presets", "Queue"]
|
||||
|
||||
mock_wiim_device.async_get_presets.return_value = (
|
||||
WiimPreset(1, "Preset 1", "http://image1"),
|
||||
WiimPreset(2, "Preset 2", "http://image2"),
|
||||
)
|
||||
preset_result = await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_BROWSE_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST,
|
||||
ATTR_MEDIA_CONTENT_ID: "wiim_library/library_root/favorites",
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
preset_browse = preset_result[MEDIA_PLAYER_ENTITY_ID]
|
||||
assert [child.title for child in preset_browse.children] == ["Preset 1", "Preset 2"]
|
||||
|
||||
mock_wiim_device.async_get_queue_snapshot.return_value = WiimQueueSnapshot(
|
||||
items=(
|
||||
WiimQueueItem(1, "Song A", "http://image-a"),
|
||||
WiimQueueItem(2, "Song B", "http://image-b"),
|
||||
),
|
||||
is_active=True,
|
||||
)
|
||||
queue_result = await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_BROWSE_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST,
|
||||
ATTR_MEDIA_CONTENT_ID: "wiim_library/library_root/playlists",
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
queue_browse = queue_result[MEDIA_PLAYER_ENTITY_ID]
|
||||
assert [child.title for child in queue_browse.children] == ["Song A", "Song B"]
|
||||
|
||||
|
||||
async def test_browse_media_service_includes_media_sources_when_supported(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_wiim_device: MagicMock,
|
||||
mock_wiim_controller: MagicMock,
|
||||
) -> None:
|
||||
"""Test media sources are exposed through browse_media when HTTP API exists."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_wiim_device.supports_http_api = True
|
||||
|
||||
media_source_root = BrowseMedia(
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id="media-source://media_source",
|
||||
media_content_type=MediaType.APPS,
|
||||
title="Media Sources",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[
|
||||
BrowseMedia(
|
||||
media_class=MediaClass.MUSIC,
|
||||
media_content_id="media-source://media_source/local/song.mp3",
|
||||
media_content_type="audio/mpeg",
|
||||
title="song.mp3",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.wiim.media_player.media_source.async_browse_media",
|
||||
AsyncMock(return_value=media_source_root),
|
||||
):
|
||||
result = await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_BROWSE_MEDIA,
|
||||
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
browse = result[MEDIA_PLAYER_ENTITY_ID]
|
||||
assert [child.title for child in browse.children] == [
|
||||
"Presets",
|
||||
"Queue",
|
||||
"song.mp3",
|
||||
]
|
||||
Reference in New Issue
Block a user