diff --git a/CODEOWNERS b/CODEOWNERS index 1fb19af7ceb..fff21a1d590 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/wiim/__init__.py b/homeassistant/components/wiim/__init__.py new file mode 100644 index 00000000000..e6407467db2 --- /dev/null +++ b/homeassistant/components/wiim/__init__.py @@ -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 diff --git a/homeassistant/components/wiim/config_flow.py b/homeassistant/components/wiim/config_flow.py new file mode 100644 index 00000000000..72836574624 --- /dev/null +++ b/homeassistant/components/wiim/config_flow.py @@ -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.""" diff --git a/homeassistant/components/wiim/const.py b/homeassistant/components/wiim/const.py new file mode 100644 index 00000000000..a1504968865 --- /dev/null +++ b/homeassistant/components/wiim/const.py @@ -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." diff --git a/homeassistant/components/wiim/entity.py b/homeassistant/components/wiim/entity.py new file mode 100644 index 00000000000..3c1dbcbafa9 --- /dev/null +++ b/homeassistant/components/wiim/entity.py @@ -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 diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json new file mode 100644 index 00000000000..f9080754a74 --- /dev/null +++ b/homeassistant/components/wiim/manifest.json @@ -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."] +} diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py new file mode 100644 index 00000000000..dbb8d8edb8b --- /dev/null +++ b/homeassistant/components/wiim/media_player.py @@ -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}") diff --git a/homeassistant/components/wiim/models.py b/homeassistant/components/wiim/models.py new file mode 100644 index 00000000000..43d347b7636 --- /dev/null +++ b/homeassistant/components/wiim/models.py @@ -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) diff --git a/homeassistant/components/wiim/quality_scale.yaml b/homeassistant/components/wiim/quality_scale.yaml new file mode 100644 index 00000000000..17d92d5fb24 --- /dev/null +++ b/homeassistant/components/wiim/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/wiim/strings.json b/homeassistant/components/wiim/strings.json new file mode 100644 index 00000000000..cb8bff55f0e --- /dev/null +++ b/homeassistant/components/wiim/strings.json @@ -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" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e37eb3c84fc..6f2757d8191 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -803,6 +803,7 @@ FLOWS = { "whirlpool", "whois", "wiffi", + "wiim", "wilight", "withings", "wiz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b7b93e53039..4c0cf22db37 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 53ae4d945d4..8cd43f195af 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -711,6 +711,9 @@ ZEROCONF = { { "domain": "linkplay", }, + { + "domain": "wiim", + }, ], "_lookin._tcp.local.": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 1a7d8b1dc40..fdfbc89d01e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7de20aab4f..8f0d3f1c60b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/wiim/__init__.py b/tests/components/wiim/__init__.py new file mode 100644 index 00000000000..3aff9fe1b9b --- /dev/null +++ b/tests/components/wiim/__init__.py @@ -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() diff --git a/tests/components/wiim/conftest.py b/tests/components/wiim/conftest.py new file mode 100644 index 00000000000..f3d94bd622a --- /dev/null +++ b/tests/components/wiim/conftest.py @@ -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 diff --git a/tests/components/wiim/test_config_flow.py b/tests/components/wiim/test_config_flow.py new file mode 100644 index 00000000000..1edaa204096 --- /dev/null +++ b/tests/components/wiim/test_config_flow.py @@ -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" diff --git a/tests/components/wiim/test_init.py b/tests/components/wiim/test_init.py new file mode 100644 index 00000000000..76a545b8189 --- /dev/null +++ b/tests/components/wiim/test_init.py @@ -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 diff --git a/tests/components/wiim/test_media_player.py b/tests/components/wiim/test_media_player.py new file mode 100644 index 00000000000..973f2ecd9fb --- /dev/null +++ b/tests/components/wiim/test_media_player.py @@ -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", + ]