Module pyatv
Main routines for interacting with an Apple TV.
Expand source code
"""Main routines for interacting with an Apple TV."""
import os
import asyncio
import logging
import datetime # noqa
from abc import ABC, abstractmethod
from ipaddress import IPv4Address, ip_address
from typing import List, Dict
import aiohttp
from aiozeroconf import ServiceBrowser, Zeroconf
import netifaces
from pyatv import conf, exceptions, interface
from pyatv.airplay import AirPlayStreamAPI
from pyatv.const import Protocol
from pyatv.dmap import DmapAppleTV
from pyatv.dmap.pairing import DmapPairingHandler
from pyatv.mrp import MrpAppleTV
from pyatv.mrp.pairing import MrpPairingHandler
from pyatv.airplay.pairing import AirPlayPairingHandler
from pyatv.support import net, udns
_LOGGER = logging.getLogger(__name__)
HOMESHARING_SERVICE = "_appletv-v2._tcp.local."
DEVICE_SERVICE = "_touch-able._tcp.local."
MEDIAREMOTE_SERVICE = "_mediaremotetv._tcp.local."
AIRPLAY_SERVICE = "_airplay._tcp.local."
ALL_SERVICES = [
HOMESHARING_SERVICE,
DEVICE_SERVICE,
MEDIAREMOTE_SERVICE,
AIRPLAY_SERVICE,
]
def _decode_properties(properties) -> Dict[str, str]:
def _decode(value: bytes):
try:
# Remove non-breaking-space (0xA2A0) before decoding
return value.replace(b"\xC2\xA0", b" ").decode("utf-8")
except Exception: # pylint: disable=broad-except
return str(value)
return {k.decode("utf-8"): _decode(v) for k, v in properties.items()}
class BaseScanner(ABC): # pylint: disable=too-few-public-methods
"""Base scanner for service discovery."""
def __init__(self) -> None:
"""Initialize a new BaseScanner."""
self._found_devices = {} # type: Dict[IPv4Address, conf.BaseService]
@abstractmethod
async def discover(self, timeout: int):
"""Start discovery of devices and services."""
def service_discovered( # pylint: disable=too-many-arguments
self, service_type, service_name, address, port, properties
):
"""Call when a service was discovered."""
supported_types = {
HOMESHARING_SERVICE: self._hs_service,
DEVICE_SERVICE: self._non_hs_service,
MEDIAREMOTE_SERVICE: self._mrp_service,
AIRPLAY_SERVICE: self._airplay_service,
}
handler = supported_types.get(service_type)
if handler:
handler(service_name, address, port, properties)
else:
_LOGGER.warning("Discovered unknown device: %s", service_type)
def _hs_service(self, service_name, address, port, properties):
"""Add a new device to discovered list."""
identifier = service_name.split(".")[0]
name = properties.get("Name")
hsgid = properties.get("hG")
service = conf.DmapService(identifier, hsgid, port=port, properties=properties)
self._handle_service(address, name, service)
def _non_hs_service(self, service_name, address, port, properties):
"""Add a new device without Home Sharing to discovered list."""
identifier = service_name.split(".")[0]
name = properties.get("CtlN")
service = conf.DmapService(identifier, None, port=port, properties=properties)
self._handle_service(address, name, service)
def _mrp_service(self, _, address, port, properties):
"""Add a new MediaRemoteProtocol device to discovered list."""
identifier = properties.get("UniqueIdentifier")
name = properties.get("Name")
service = conf.MrpService(identifier, port, properties=properties)
self._handle_service(address, name, service)
def _airplay_service(self, service_name, address, port, properties):
"""Add a new AirPlay device to discovered list."""
identifier = properties.get("deviceid")
name = service_name.replace("." + AIRPLAY_SERVICE, "")
service = conf.AirPlayService(identifier, port, properties=properties)
self._handle_service(address, name, service)
def _handle_service(self, address, name, service):
if address not in self._found_devices:
self._found_devices[address] = conf.AppleTV(address, name)
_LOGGER.debug(
"Auto-discovered %s at %s:%d (%s)",
name,
address,
service.port,
service.protocol,
)
atv = self._found_devices[address]
atv.add_service(service)
class ZeroconfScanner(BaseScanner):
"""Service discovery based on Zeroconf."""
def __init__(self, loop):
"""Initialize a new ZeroconfScanner."""
super().__init__()
self.loop = loop
self.pending = set()
async def discover(self, timeout):
"""Start discovery of devices and services."""
zeroconf = Zeroconf(self.loop, address_family=[netifaces.AF_INET])
try:
ServiceBrowser(zeroconf, HOMESHARING_SERVICE, self)
ServiceBrowser(zeroconf, DEVICE_SERVICE, self)
ServiceBrowser(zeroconf, MEDIAREMOTE_SERVICE, self)
ServiceBrowser(zeroconf, AIRPLAY_SERVICE, self)
_LOGGER.debug("Discovering devices for %d seconds", timeout)
await asyncio.sleep(timeout)
if self.pending:
await asyncio.wait(self.pending)
finally:
await zeroconf.close()
return self._found_devices
def add_service(self, zeroconf, service_type, name):
"""Handle callback from zeroconf when a service has been discovered."""
self.pending.add(
asyncio.ensure_future(self._internal_add(zeroconf, service_type, name))
)
def remove_service(self, zeroconf, service_type, name):
"""Handle callback when a service is removed."""
async def _internal_add(self, zeroconf, service_type, name):
info = await zeroconf.get_service_info(service_type, name, timeout=2000)
if info.address is None:
_LOGGER.debug("Failed to resolve %s (%s)", service_type, name)
return
address = ip_address(info.address)
self.service_discovered(
info.type,
info.name,
address,
info.port,
_decode_properties(info.properties),
)
class UnicastMdnsScanner(BaseScanner):
"""Service discovery based on unicast MDNS."""
def __init__(self, hosts, loop):
"""Initialize a new UnicastMdnsScanner."""
super().__init__()
self.hosts = hosts
self.loop = loop
async def discover(self, timeout):
"""Start discovery of devices and services."""
results = await asyncio.gather(
*[self._get_services(host, timeout) for host in self.hosts]
)
for host, response in results:
if response is not None:
self._handle_response(host, response)
return self._found_devices
async def _get_services(self, host, timeout):
port = os.environ.get("PYATV_UDNS_PORT", 5353) # For testing purposes
services = [s[0:-1] for s in ALL_SERVICES]
try:
response = await udns.request(
self.loop, host, services, port=port, timeout=timeout
)
except asyncio.TimeoutError:
response = None
return host, response
def _handle_response(self, host, response):
for resource in response.resources:
if resource.qtype != udns.QTYPE_TXT:
continue
service_name = ".".join(resource.qname.split(".")[1:]) + "."
if service_name not in ALL_SERVICES:
continue
port = UnicastMdnsScanner._get_port(response, resource.qname)
if not port:
_LOGGER.warning("Missing port for %s", resource.qname)
continue
self.service_discovered(
service_name,
resource.qname + ".",
host,
port,
_decode_properties(resource.rd),
)
@staticmethod
def _get_port(response, qname):
for resource in response.resources:
if resource.qtype != udns.QTYPE_SRV:
continue
if resource.qname == qname:
return resource.rd.get("port")
return None
async def scan(
loop: asyncio.AbstractEventLoop,
timeout: int = 5,
identifier: str = None,
protocol: Protocol = None,
hosts: List[str] = None,
) -> List[conf.AppleTV]:
"""Scan for Apple TVs on network and return their configurations."""
def _should_include(atv):
if not atv.ready:
return False
if identifier and identifier not in atv.all_identifiers:
return False
if protocol and atv.get_service(protocol) is None:
return False
return True
scanner: BaseScanner
if hosts:
scanner = UnicastMdnsScanner(hosts, loop)
else:
scanner = ZeroconfScanner(loop)
devices = (await scanner.discover(timeout)).values()
return [device for device in devices if _should_include(device)]
async def connect(
config: conf.AppleTV,
loop: asyncio.AbstractEventLoop,
protocol: Protocol = None,
session: aiohttp.ClientSession = None,
) -> interface.AppleTV:
"""Connect to a device based on a configuration."""
if config.identifier is None:
raise exceptions.DeviceIdMissingError("no device identifier")
service = config.main_service(protocol=protocol)
implementation = {Protocol.DMAP: DmapAppleTV, Protocol.MRP: MrpAppleTV}.get(
service.protocol
)
if not implementation:
raise exceptions.UnsupportedProtocolError(str(service.protocol))
# If no session is given, create a default one
if session is None:
session = await net.create_session(loop=loop)
# AirPlay stream API is the same for both DMAP and MRP
airplay = AirPlayStreamAPI(config, loop)
atv = implementation(loop, session, config, airplay)
await atv.connect()
return atv
async def pair(
config: conf.AppleTV,
protocol: Protocol,
loop: asyncio.AbstractEventLoop,
session: aiohttp.ClientSession = None,
**kwargs
):
"""Pair a protocol for an Apple TV."""
service = config.get_service(protocol)
if not service:
raise exceptions.NoServiceError(
"no service available for protocol " + str(protocol)
)
handler = {
Protocol.DMAP: DmapPairingHandler,
Protocol.MRP: MrpPairingHandler,
Protocol.AirPlay: AirPlayPairingHandler,
}.get(protocol)
if handler is None:
raise exceptions.UnsupportedProtocolError(str(protocol))
if session is None:
session = await net.create_session(loop)
return handler(config, session, loop, **kwargs)
Sub-modules
pyatv.conf
-
Configuration used when connecting to a device …
pyatv.const
-
Constants used in the public API.
pyatv.convert
-
Various types of extraction and conversion functions.
pyatv.exceptions
-
Local exceptions used by library.
pyatv.helpers
-
Various helper methods.
pyatv.interface
-
Public interface exposed by library …
Functions
async def connect(config, loop, protocol=None, session=None)
-
Connect to a device based on a configuration.
Expand source code
async def connect( config: conf.AppleTV, loop: asyncio.AbstractEventLoop, protocol: Protocol = None, session: aiohttp.ClientSession = None, ) -> interface.AppleTV: """Connect to a device based on a configuration.""" if config.identifier is None: raise exceptions.DeviceIdMissingError("no device identifier") service = config.main_service(protocol=protocol) implementation = {Protocol.DMAP: DmapAppleTV, Protocol.MRP: MrpAppleTV}.get( service.protocol ) if not implementation: raise exceptions.UnsupportedProtocolError(str(service.protocol)) # If no session is given, create a default one if session is None: session = await net.create_session(loop=loop) # AirPlay stream API is the same for both DMAP and MRP airplay = AirPlayStreamAPI(config, loop) atv = implementation(loop, session, config, airplay) await atv.connect() return atv
async def pair(config, protocol, loop, session=None, **kwargs)
-
Pair a protocol for an Apple TV.
Expand source code
async def pair( config: conf.AppleTV, protocol: Protocol, loop: asyncio.AbstractEventLoop, session: aiohttp.ClientSession = None, **kwargs ): """Pair a protocol for an Apple TV.""" service = config.get_service(protocol) if not service: raise exceptions.NoServiceError( "no service available for protocol " + str(protocol) ) handler = { Protocol.DMAP: DmapPairingHandler, Protocol.MRP: MrpPairingHandler, Protocol.AirPlay: AirPlayPairingHandler, }.get(protocol) if handler is None: raise exceptions.UnsupportedProtocolError(str(protocol)) if session is None: session = await net.create_session(loop) return handler(config, session, loop, **kwargs)
async def scan(loop, timeout=5, identifier=None, protocol=None, hosts=None)
-
Scan for Apple TVs on network and return their configurations.
Expand source code
async def scan( loop: asyncio.AbstractEventLoop, timeout: int = 5, identifier: str = None, protocol: Protocol = None, hosts: List[str] = None, ) -> List[conf.AppleTV]: """Scan for Apple TVs on network and return their configurations.""" def _should_include(atv): if not atv.ready: return False if identifier and identifier not in atv.all_identifiers: return False if protocol and atv.get_service(protocol) is None: return False return True scanner: BaseScanner if hosts: scanner = UnicastMdnsScanner(hosts, loop) else: scanner = ZeroconfScanner(loop) devices = (await scanner.discover(timeout)).values() return [device for device in devices if _should_include(device)]