From 5452b03fe7fc22c3463a9e7b9fa31d306f95f45b Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Mon, 8 Jul 2024 22:03:26 +0200 Subject: [PATCH] feat: Stream to output devices through sound mode selection (#20) Added support for streaming audio to output devices using sound mode attribute --- README.md | 2 + intg-appletv/driver.py | 17 ++++++- intg-appletv/tv.py | 110 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1f13b63..4dc9e47 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Supported attributes: - Artwork - Media duration - Media position +- Sound mode (list of output device(s) streamed to) Supported commands: - Turn on & off (device will be put into standby) @@ -31,6 +32,7 @@ Supported commands: - Launch application - App switcher - Start screensaver +- Stream audio to one or multiple output devices Please note that certain commands like channel up & down are app dependant and don't work with every app! diff --git a/intg-appletv/driver.py b/intg-appletv/driver.py index 6d51c81..282eeb2 100644 --- a/intg-appletv/driver.py +++ b/intg-appletv/driver.py @@ -223,7 +223,6 @@ async def media_player_cmd_handler( res = await device.rewind() case media_player.Commands.FAST_FORWARD: res = await device.fast_forward() - case media_player.Commands.REPEAT: mode = _get_cmd_param("repeat", params) res = await device.set_repeat(mode) if mode else ucapi.StatusCodes.BAD_REQUEST @@ -234,7 +233,6 @@ async def media_player_cmd_handler( res = await device.context_menu() case media_player.Commands.MENU: res = await device.control_center() - case media_player.Commands.HOME: res = await device.home() @@ -275,6 +273,9 @@ async def media_player_cmd_handler( res = await device.fast_forward_companion() case SimpleCommands.REWIND_BEGIN: res = await device.rewind_companion() + case media_player.Commands.SELECT_SOUND_MODE: + mode = _get_cmd_param("mode", params) + res = await device.set_output_device(mode) return res @@ -393,6 +394,17 @@ async def on_atv_update(entity_id: str, update: dict[str, Any] | None) -> None: attributes[media_player.Attributes.SOURCE_LIST] = update["sourceList"] else: attributes[media_player.Attributes.SOURCE_LIST] = update["sourceList"] + if ( + "sound_mode" in update + and target_entity.attributes.get(media_player.Attributes.SOUND_MODE, "") != update["sound_mode"] + ): + attributes[media_player.Attributes.SOUND_MODE] = update["sound_mode"] + if "sound_mode_list" in update: + if media_player.Attributes.SOUND_MODE_LIST in target_entity.attributes: + if len(target_entity.attributes[media_player.Attributes.SOUND_MODE_LIST]) != len(update["sound_mode_list"]): + attributes[media_player.Attributes.SOUND_MODE_LIST] = update["sound_mode_list"] + else: + attributes[media_player.Attributes.SOUND_MODE_LIST] = update["sound_mode_list"] if "media_type" in update: if update["media_type"] == pyatv.const.MediaType.Music: media_type = media_player.MediaType.MUSIC @@ -495,6 +507,7 @@ def _register_available_entities(identifier: str, name: str) -> bool: media_player.Features.MENU, media_player.Features.REWIND, media_player.Features.FAST_FORWARD, + media_player.Features.SELECT_SOUND_MODE, ] if ENABLE_REPEAT_FEAT: features.append(media_player.Features.REPEAT) diff --git a/intg-appletv/tv.py b/intg-appletv/tv.py index 2626946..1e5dfed 100644 --- a/intg-appletv/tv.py +++ b/intg-appletv/tv.py @@ -10,9 +10,11 @@ import asyncio import base64 +import itertools import logging import random from asyncio import AbstractEventLoop +from collections import OrderedDict from enum import IntEnum from functools import wraps from typing import ( @@ -21,15 +23,16 @@ Callable, Concatenate, Coroutine, + List, ParamSpec, TypeVar, cast, ) import pyatv -import pyatv.const import ucapi from config import AtvDevice, AtvProtocol +from pyatv import interface from pyatv.const import ( DeviceState, FeatureName, @@ -41,6 +44,7 @@ ShuffleState, ) from pyatv.core.facade import FacadeRemoteControl +from pyatv.interface import BaseConfig, OutputDevice from pyatv.protocols.companion import CompanionAPI, MediaControlCommand, SystemStatus from pyee import AsyncIOEventEmitter @@ -134,7 +138,7 @@ async def wrapper(self: _AppleTvT, *args: _P.args, **kwargs: _P.kwargs) -> ucapi return wrapper -class AppleTv: +class AppleTv(interface.AudioListener): """Representing an Apple TV Device.""" def __init__( @@ -159,6 +163,8 @@ def __init__( self._poll_interval: int = 10 self._state: DeviceState | None = None self._app_list: dict[str, str] = {} + self._available_output_devices: dict[str, str] = {} + self._output_devices: OrderedDict[str, [str]] = OrderedDict() @property def identifier(self) -> str: @@ -194,6 +200,19 @@ def state(self) -> DeviceState | None: """Return the device state.""" return self._state + @property + def output_devices_combinations(self) -> [str]: + """Return the list of possible selection (combinations) of output devices.""" + return list(self._output_devices.keys()) + + @property + def output_devices(self) -> str: + """Return the current selection of output devices.""" + device_names = [] + for device in self._atv.audio.output_devices: + device_names.append(device.name) + return ", ".join(sorted(device_names, key=str.casefold)) + def _backoff(self) -> float: if self._connection_attempts * BACKOFF_SEC >= BACKOFF_MAX: return BACKOFF_MAX @@ -244,10 +263,10 @@ def volume_update(self, _old_level: float, new_level: float) -> None: update = {"volume": new_level} self.events.emit(EVENTS.UPDATE, self._device.identifier, update) - def outputdevices_update(self, old_devices, new_devices) -> None: + def outputdevices_update(self, old_devices: List[OutputDevice], new_devices: List[OutputDevice]) -> None: """Output device change callback handler, for example airplay speaker.""" - # print('Output devices changed from {0:s} to {1:s}'.format(old_devices, new_devices)) - # TODO check if this could be used to better handle volume control (if it's available or not) + _LOG.debug("[%s] Changed output devices to %s", self.log_id, self.output_devices) + self.events.emit(EVENTS.UPDATE, self._device.identifier, {"sound_mode": self.output_devices}) async def _find_atv(self) -> pyatv.interface.BaseConfig | None: """Find a specific Apple TV on the network by identifier.""" @@ -355,6 +374,8 @@ async def _connect_loop(self) -> None: if self._atv.features.in_state(FeatureState.Available, FeatureName.AppList): self._loop.create_task(self._update_app_list()) + self._loop.create_task(self._update_output_devices()) + self.events.emit(EVENTS.CONNECTED, self._device.identifier) _LOG.debug("[%s] Connected", self.log_id) @@ -514,6 +535,61 @@ async def _update_app_list(self) -> None: self.events.emit(EVENTS.UPDATE, self._device.identifier, update) + async def _update_output_devices(self) -> None: + _LOG.debug("[%s] Updating available output devices list", self.log_id) + try: + atvs = await pyatv.scan(self._loop) + if self._atv is None: + return + current_output_devices = self._available_output_devices + current_output_device = self.output_devices + device_ids = [] + self._available_output_devices = {} + for atv in atvs: + if atv.device_info.output_device_id == self._atv.device_info.output_device_id: + continue + if atv.device_info.output_device_id not in device_ids: + device_ids.append(atv.device_info.output_device_id) + self._available_output_devices[atv.device_info.output_device_id] = atv.name + except pyatv.exceptions.NotSupportedError: + _LOG.warning("[%s] Output devices listing is not supported", self.log_id) + return + except pyatv.exceptions.ProtocolError: + _LOG.warning("[%s] Output devices: protocol error", self.log_id) + return + update = {} + if set(current_output_devices.keys()) != set(self._available_output_devices.keys()) and len(device_ids) > 0: + # Build combinations of output devices. First device in the list is the current Apple TV + # When selecting this entry, it will disable all output devices + self._output_devices = OrderedDict() + self._output_devices[self._device.name] = [] + self._build_output_devices_list(atvs, device_ids) + update["sound_mode_list"] = list(self._output_devices.keys()) + + if current_output_device != self.output_devices: + update["sound_mode"] = self.output_devices + + _LOG.debug("Updated sound mode list : %s", update) + + if update: + self.events.emit(EVENTS.UPDATE, self._device.identifier, update) + + def _build_output_devices_list(self, atvs: list[BaseConfig], device_ids: [str]): + """Build possible combinations of output devices.""" + # Don't go beyond combinations of 5 devices + max_len = min(len(device_ids), 4) + for i in range(0, max_len): + combinations = itertools.combinations(device_ids, i + 1) + for combination in combinations: + device_names: [str] = [] + for device_id in combination: + for atv in atvs: + if atv.device_info.output_device_id == device_id: + device_names.append(atv.name) + break + entry_name: str = ", ".join(sorted(device_names, key=str.casefold)) + self._output_devices[entry_name] = combination + async def _poll_worker(self) -> None: await asyncio.sleep(2) while self._atv is not None: @@ -752,3 +828,27 @@ async def launch_app(self, app_name: str) -> ucapi.StatusCodes: async def app_switcher(self) -> ucapi.StatusCodes: """Press the TV/Control Center button two times to open the App Switcher.""" await self._atv.remote_control.home(InputAction.DoubleTap) + + @async_handle_atvlib_errors + async def set_output_device(self, device_name: str) -> ucapi.StatusCodes: + """Set output device selection.""" + if device_name is None: + return ucapi.StatusCodes.BAD_REQUEST + device_entry = self._output_devices.get(device_name, []) + if device_entry is None: + _LOG.warning( + "Output device not found in the list %s (list : %s)", device_name, self.output_devices_combinations + ) + return ucapi.StatusCodes.BAD_REQUEST + output_devices = self._atv.audio.output_devices + if len(device_entry) == 0 and len(output_devices) == 0: + return ucapi.StatusCodes.OK + device_ids = [] + for device in output_devices: + device_ids.append(device.identifier) + _LOG.debug("Removing output devices %s", device_ids) + await self._atv.audio.remove_output_devices(*device_ids) + if len(device_entry) == 0: + return ucapi.StatusCodes.OK + _LOG.debug("Setting output devices %s", device_entry) + await self._atv.audio.set_output_devices(*device_entry)