Skip to content

Commit

Permalink
Beginning of refactor for MediaRemote protocol
Browse files Browse the repository at this point in the history
This is the start of moving the DAAP protocol logic into its own place
so that MediaRemote protocol can be implemented alongside it. More
changes must be made, for instance, a new fake Apple TV must be created
so that functional tests can be shared between the two protocol
implementations. Also, the AirPlay parts in the test suite should be
extracted to its own FakeAirPlayDevice (or similiar) to make re-use of
tests easier.
  • Loading branch information
postlund committed Sep 3, 2017
1 parent c2f3afe commit fce124a
Show file tree
Hide file tree
Showing 18 changed files with 242 additions and 236 deletions.
4 changes: 2 additions & 2 deletions pyatv/__init__.py
Expand Up @@ -10,7 +10,7 @@
from aiohttp import ClientSession

from pyatv.pairing import PairingHandler
from pyatv.internal.apple_tv import AppleTVInternal
from pyatv.dmap.apple_tv import DmapAppleTV

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -116,7 +116,7 @@ def connect_to_apple_tv(details, loop, session=None):

# If/when needed, the library should figure out the correct type of Apple
# TV and return the correct type for it.
return AppleTVInternal(loop, session, details)
return DmapAppleTV(loop, session, details)


def pair_with_apple_tv(loop, pin_code, name, pairing_guid=None):
Expand Down
6 changes: 4 additions & 2 deletions pyatv/__main__.py
Expand Up @@ -12,7 +12,9 @@

import pyatv
import pyatv.pairing
from pyatv import (const, dmap, exceptions, interface, tag_definitions)
from pyatv import (const, exceptions, interface)
from pyatv.dmap import tag_definitions
from pyatv.dmap.parser import pprint
from pyatv.interface import retrieve_commands


Expand Down Expand Up @@ -405,7 +407,7 @@ def _pretty_print(data):
if isinstance(data, bytes):
print(binascii.hexlify(data))
elif isinstance(data, list):
print(dmap.pprint(data, tag_definitions.lookup_tag))
print(pprint(data, tag_definitions.lookup_tag))
else:
print(data)

Expand Down
70 changes: 70 additions & 0 deletions pyatv/airplay/api.py
@@ -0,0 +1,70 @@
"""Implementation of external API for AirPlay."""

import logging
import asyncio
import binascii

from pyatv.interface import AirPlay

from pyatv.airplay.srp import (SRPAuthHandler, new_credentials)
from pyatv.airplay.auth import (AuthenticationVerifier, DeviceAuthenticator)

_LOGGER = logging.getLogger(__name__)


class AirPlayAPI(AirPlay):
"""Implementation of API for AirPlay support."""

def __init__(self, http, airplay_player):
"""Initialize a new AirPlayInternal instance."""
self.player = airplay_player
self.identifier = None
self.srp = SRPAuthHandler()
self.verifier = AuthenticationVerifier(http, self.srp)
self.auther = DeviceAuthenticator(http, self.srp)

@asyncio.coroutine
def generate_credentials(self):
"""Create new credentials for authentication.
Credentials that have been authenticated shall be saved and loaded with
load_credentials before playing anything. If credentials are lost,
authentication must be performed again.
"""
identifier, seed = new_credentials()
return '{0}:{1}'.format(identifier, seed.decode().upper())

@asyncio.coroutine
def load_credentials(self, credentials):
"""Load existing credentials."""
split = credentials.split(':')
self.identifier = split[0]
self.srp.initialize(binascii.unhexlify(split[1]))
_LOGGER.debug('Loaded AirPlay credentials: %s', credentials)

def verify_authenticated(self):
"""Check if loaded credentials are verified."""
return self.verifier.verify_authed()

def start_authentication(self):
"""Begin authentication proces (show PIN on screen)."""
return self.auther.start_authentication()

def finish_authentication(self, pin):
"""End authentication process with PIN code."""
return self.auther.finish_authentication(self.identifier, pin)

@asyncio.coroutine
def play_url(self, url, **kwargs):
"""Play media from an URL on the device.
Note: This method will not yield until the media has finished playing.
The Apple TV requires the request to stay open during the entire
play duration.
"""
# If credentials have been loaded, do device verification first
if self.identifier:
yield from self.verify_authenticated()

position = 0 if 'position' not in kwargs else int(kwargs['position'])
return (yield from self.player.play_url(url, position))
1 change: 1 addition & 0 deletions pyatv/dmap/__init__.py
@@ -0,0 +1 @@
"""Implementation of the DMAP protocol used by older devices."""
130 changes: 30 additions & 100 deletions pyatv/internal/apple_tv.py → pyatv/dmap/apple_tv.py
@@ -1,25 +1,19 @@
"""Implementation of the protocol used to interact with an Apple TV.
Only verified to work with a 3rd generation device. Classes should be
extracted and adjusted for differences if needed, to support newer/older
generations of devices. Everything is however left here for now.
"""
"""Implementation of the DMAP protocol used by ATV 1, 2 and 3."""

import logging
import asyncio
import binascii
import hashlib

from urllib.parse import urlparse

from pyatv import (const, exceptions, dmap, tags, convert)
from pyatv import (const, exceptions, convert)
from pyatv.airplay import player
from pyatv.daap import DaapRequester
from pyatv.dmap import (parser, tags)
from pyatv.dmap.daap import DaapRequester
from pyatv.net import HttpSession
from pyatv.interface import (AppleTV, RemoteControl, Metadata,
Playing, PushUpdater, AirPlay)
from pyatv.airplay.srp import (SRPAuthHandler, new_credentials)
from pyatv.airplay.auth import (AuthenticationVerifier, DeviceAuthenticator)
Playing, PushUpdater)
from pyatv.airplay.api import AirPlayAPI

_LOGGER = logging.getLogger(__name__)

Expand All @@ -28,19 +22,14 @@
_CTRL_PROMPT_CMD = 'ctrl-int/1/controlpromptentry?[AUTH]&prompt-id=0'


class BaseAppleTV:
class BaseDmapAppleTV:
"""Common protocol logic used to interact with an Apple TV."""

def __init__(self, requester):
"""Initialize a new Apple TV base implemenation."""
self.daap = requester
self.playstatus_revision = 0

def server_info(self):
"""Request and return server information."""
return (yield from self.daap.get(
'server-info', session=False, login_id=False))

@asyncio.coroutine
def playstatus(self, use_revision=False, timeout=None):
"""Request raw data about what is currently playing.
Expand All @@ -53,7 +42,7 @@ def playstatus(self, use_revision=False, timeout=None):
cmd_url = _PSU_CMD.format(
self.playstatus_revision if use_revision else 0)
resp = yield from self.daap.get(cmd_url, timeout=timeout)
self.playstatus_revision = dmap.first(resp, 'cmst', 'cmsr')
self.playstatus_revision = parser.first(resp, 'cmst', 'cmsr')
return resp

def artwork_url(self):
Expand Down Expand Up @@ -98,7 +87,7 @@ def set_property(self, prop, value):
return self.daap.post(cmd_url)


class RemoteControlInternal(RemoteControl):
class DmapRemoteControl(RemoteControl):
"""Implementation of API for controlling an Apple TV."""

def __init__(self, apple_tv):
Expand Down Expand Up @@ -212,7 +201,7 @@ def set_repeat(self, repeat_mode):
return self.apple_tv.set_property('dacp.repeatstate', repeat_mode)


class PlayingInternal(Playing):
class DmapPlaying(Playing):
"""Implementation of API for retrieving what is playing."""

def __init__(self, playstatus):
Expand All @@ -223,11 +212,11 @@ def __init__(self, playstatus):
@property
def media_type(self):
"""Type of media is currently playing, e.g. video, music."""
state = dmap.first(self.playstatus, 'cmst', 'caps')
state = parser.first(self.playstatus, 'cmst', 'caps')
if not state:
return const.MEDIA_TYPE_UNKNOWN

mediakind = dmap.first(self.playstatus, 'cmst', 'cmmk')
mediakind = parser.first(self.playstatus, 'cmst', 'cmmk')
if mediakind is not None:
return convert.media_kind(mediakind)

Expand All @@ -241,23 +230,23 @@ def media_type(self):
@property
def play_state(self):
"""Play state, e.g. playing or paused."""
state = dmap.first(self.playstatus, 'cmst', 'caps')
state = parser.first(self.playstatus, 'cmst', 'caps')
return convert.playstate(state)

@property
def title(self):
"""Title of the current media, e.g. movie or song name."""
return dmap.first(self.playstatus, 'cmst', 'cann')
return parser.first(self.playstatus, 'cmst', 'cann')

@property
def artist(self):
"""Arist of the currently playing song."""
return dmap.first(self.playstatus, 'cmst', 'cana')
return parser.first(self.playstatus, 'cmst', 'cana')

@property
def album(self):
"""Album of the currently playing song."""
return dmap.first(self.playstatus, 'cmst', 'canl')
return parser.first(self.playstatus, 'cmst', 'canl')

@property
def total_time(self):
Expand All @@ -272,19 +261,19 @@ def position(self):
@property
def shuffle(self):
"""If shuffle is enabled or not."""
return bool(dmap.first(self.playstatus, 'cmst', 'cash'))
return bool(parser.first(self.playstatus, 'cmst', 'cash'))

@property
def repeat(self):
"""Repeat mode."""
return dmap.first(self.playstatus, 'cmst', 'carp')
return parser.first(self.playstatus, 'cmst', 'carp')

def _get_time_in_seconds(self, tag):
time = dmap.first(self.playstatus, 'cmst', tag)
time = parser.first(self.playstatus, 'cmst', tag)
return convert.ms_to_s(time)


class MetadataInternal(Metadata):
class DmapMetadata(Metadata):
"""Implementation of API for retrieving metadata from an Apple TV."""

def __init__(self, apple_tv, daap):
Expand Down Expand Up @@ -314,14 +303,14 @@ def artwork_url(self):
def playing(self):
"""Return current device state."""
playstatus = yield from self.apple_tv.playstatus()
return PlayingInternal(playstatus)
return DmapPlaying(playstatus)


class PushUpdaterInternal(PushUpdater):
class DmapPushUpdater(PushUpdater):
"""Implementation of API for handling push update from an Apple TV."""

def __init__(self, loop, apple_tv):
"""Initialize a new PushUpdaterInternal instance."""
"""Initialize a new DmapPushUpdater instance."""
self._loop = loop
self._atv = apple_tv
self._future = None
Expand Down Expand Up @@ -389,7 +378,7 @@ def _poller(self, initial_delay):
use_revision=True, timeout=0)

self._loop.call_soon(self.listener.playstatus_update,
self, PlayingInternal(playstatus))
self, DmapPlaying(playstatus))
except asyncio.CancelledError:
break

Expand All @@ -403,66 +392,7 @@ def _poller(self, initial_delay):
self._future = None


# pylint: disable=too-few-public-methods
class AirPlayInternal(AirPlay):
"""Implementation of API for AirPlay support."""

def __init__(self, http, airplay_player):
"""Initialize a new AirPlayInternal instance."""
self.player = airplay_player
self.identifier = None
self.srp = SRPAuthHandler()
self.verifier = AuthenticationVerifier(http, self.srp)
self.auther = DeviceAuthenticator(http, self.srp)

@asyncio.coroutine
def generate_credentials(self):
"""Create new credentials for authentication.
Credentials that have been authenticated shall be saved and loaded with
load_credentials before playing anything. If credentials are lost,
authentication must be performed again.
"""
identifier, seed = new_credentials()
return '{0}:{1}'.format(identifier, seed.decode().upper())

@asyncio.coroutine
def load_credentials(self, credentials):
"""Load existing credentials."""
split = credentials.split(':')
self.identifier = split[0]
self.srp.initialize(binascii.unhexlify(split[1]))
_LOGGER.debug('Loaded AirPlay credentials: %s', credentials)

def verify_authenticated(self):
"""Check if loaded credentials are verified."""
return self.verifier.verify_authed()

def start_authentication(self):
"""Begin authentication proces (show PIN on screen)."""
return self.auther.start_authentication()

def finish_authentication(self, pin):
"""End authentication process with PIN code."""
return self.auther.finish_authentication(self.identifier, pin)

@asyncio.coroutine
def play_url(self, url, **kwargs):
"""Play media from an URL on the device.
Note: This method will not yield until the media has finished playing.
The Apple TV requires the request to stay open during the entire
play duration.
"""
# If credentials have been loaded, do device verification first
if self.identifier:
yield from self.verify_authenticated()

position = 0 if 'position' not in kwargs else int(kwargs['position'])
return (yield from self.player.play_url(url, position))


class AppleTVInternal(AppleTV):
class DmapAppleTV(AppleTV):
"""Implementation of API support for Apple TV."""

# This is a container class so it's OK with many attributes
Expand All @@ -476,17 +406,17 @@ def __init__(self, loop, session, details):
session, 'http://{0}:{1}/'.format(details.address, details.port))
self._requester = DaapRequester(daap_http, details.login_id)

self._apple_tv = BaseAppleTV(self._requester)
self._atv_remote = RemoteControlInternal(self._apple_tv)
self._atv_metadata = MetadataInternal(self._apple_tv, daap_http)
self._atv_push_updater = PushUpdaterInternal(loop, self._apple_tv)
self._apple_tv = BaseDmapAppleTV(self._requester)
self._atv_remote = DmapRemoteControl(self._apple_tv)
self._atv_metadata = DmapMetadata(self._apple_tv, daap_http)
self._atv_push_updater = DmapPushUpdater(loop, self._apple_tv)

airplay_player = player.AirPlayPlayer(
loop, session, details.address, details.airplay_port)
airplay_http = HttpSession(
session, 'http://{0}:{1}/'.format(
details.address, details.airplay_port))
self._airplay = AirPlayInternal(airplay_http, airplay_player)
self._airplay = AirPlayAPI(airplay_http, airplay_player)

def login(self):
"""Perform an explicit login.
Expand Down

0 comments on commit fce124a

Please sign in to comment.