Skip to content

Commit

Permalink
feat: Stream to output devices through sound mode selection (#20)
Browse files Browse the repository at this point in the history
Added support for streaming audio to output devices using sound mode attribute
  • Loading branch information
albaintor authored Jul 8, 2024
1 parent be0dcae commit 5452b03
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 7 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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!

Expand Down
17 changes: 15 additions & 2 deletions intg-appletv/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
110 changes: 105 additions & 5 deletions intg-appletv/tv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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__(
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

0 comments on commit 5452b03

Please sign in to comment.