#!/usr/bin/env python3 """Tool modelled to be used for scripting.""" import asyncio import datetime from enum import Enum import json import logging import sys import traceback from typing import List, Optional from pyatv import connect, const, scan from pyatv.const import FeatureName, FeatureState, Protocol from pyatv.interface import ( App, AppleTV, AudioListener, DeviceListener, KeyboardListener, OutputDevice, Playing, PowerListener, PushListener, RemoteControl, Storage, retrieve_commands, ) from pyatv.scripts import ( TransformOutput, TransformProtocol, VerifyScanHosts, create_common_parser, get_storage, log_current_version, ) _LOGGER = logging.getLogger(__name__) class PushPrinter(PushListener): """Listen for push updates and print changes.""" def __init__(self, formatter, atv: AppleTV): """Initialize a new PushPrinter.""" self.formatter = formatter self.atv = atv def playstatus_update(self, updater, playstatus: Playing) -> None: """Inform about changes to what is currently playing.""" app = ( self.atv.metadata.app if not self.atv.features.in_state(FeatureState.Unavailable, FeatureName.App) else None ) print( self.formatter(output_playing(playstatus, app)), flush=True, ) def playstatus_error(self, updater, exception: Exception) -> None: """Inform about an error when updating play status.""" print(self.formatter(output(False, exception=exception)), flush=True) class PowerPrinter(PowerListener): """Listen for power updates and print changes.""" def __init__(self, formatter): """Initialize a new PowerPrinter.""" self.formatter = formatter def powerstate_update( self, old_state: const.PowerState, new_state: const.PowerState ): """Device power state was updated.""" print( self.formatter( output(True, values={"power_state": new_state.name.lower()}) ), flush=True, ) class AudioPrinter(AudioListener): """Listen for audio updates and print changes.""" def __init__(self, formatter): """Initialize a new AudioPrinter.""" self.formatter = formatter def volume_update(self, old_level: float, new_level: float): """Device volume level was updated.""" print( self.formatter(output(True, values={"volume": new_level})), flush=True, ) def outputdevices_update( self, old_devices: List[OutputDevice], new_devices: List[OutputDevice] ): """Output devices were updated.""" print( self.formatter( output( True, values={ "output_devices": [ {"name": device.name, "identifier": device.identifier} for device in new_devices ] }, ) ), flush=True, ) def volume_device_update( self, output_device: OutputDevice, old_level: float, new_level: float ) -> None: """State of output device volume changed.""" print( self.formatter( output( True, values={ "output_device_id": output_device.identifier, "old_level": old_level, "new_level": new_level, }, ) ), flush=True, ) class KeyboardPrinter(KeyboardListener): """Listen for keyboard updates and print changes.""" def __init__(self, formatter): """Initialize a new KeyboardPrinter.""" self.formatter = formatter def focusstate_update( self, old_state: const.KeyboardFocusState, new_state: const.KeyboardFocusState ): """Keyboard focus state was updated.""" print( self.formatter( output(True, values={"focus_state": new_state.name.lower()}) ), flush=True, ) class DevicePrinter(DeviceListener): """Listen for generic device updates and print them.""" def __init__(self, formatter, abort_sem): """Initialize a new DeviceListener.""" self.formatter = formatter self.abort_sem = abort_sem def connection_lost(self, exception): """Call when unexpectedly being disconnected from device.""" print( self.formatter( output(False, exception=exception, values={"connection": "lost"}) ), flush=True, ) self.abort_sem.release() def connection_closed(self): """Call when connection was (intentionally) closed.""" print(self.formatter(output(True, values={"connection": "closed"})), flush=True) self.abort_sem.release() async def wait_for_input(loop, abort_sem): """Wait for user input (enter) or abort signal.""" reader = asyncio.StreamReader(loop=loop) reader_protocol = asyncio.StreamReaderProtocol(reader) await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin) reader_readline = asyncio.create_task(reader.readline()) abort_sem_acquire = asyncio.create_task(abort_sem.acquire()) await asyncio.wait( [reader_readline, abort_sem_acquire], return_when=asyncio.FIRST_COMPLETED ) def output(success: bool, error=None, exception=None, values=None): """Produce output in intermediate format before conversion.""" now = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat() result = {"result": "success" if success else "failure", "datetime": str(now)} if error: result["error"] = error if exception: result["exception"] = str(exception) result["stacktrace"] = "".join( traceback.format_exception( type(exception), exception, exception.__traceback__ ) ) if values: result.update(**values) return result def output_playing(playing: Playing, app: Optional[App]): """Produce output for what is currently playing.""" def _convert(field): if isinstance(field, Enum): return field.name.lower() return field if field else None commands = retrieve_commands(Playing) values = {k: _convert(getattr(playing, k)) for k in commands} if app: values["app"] = app.name values["app_id"] = app.identifier else: values["app"] = None values["app_id"] = None return output(True, values=values) async def _scan_devices(loop, storage: Storage, hosts): atvs = [] for atv in await scan(loop, hosts=hosts, storage=storage): services = [] for service in atv.services: services.append( {"protocol": service.protocol.name.lower(), "port": service.port} ) atvs.append( { "name": atv.name, "address": str(atv.address), "identifier": atv.identifier, "all_identifiers": atv.all_identifiers, "device_info": { "mac": atv.device_info.mac, "model": atv.device_info.model.name, "model_str": atv.device_info.model_str, "operating_system": atv.device_info.operating_system.name, "version": atv.device_info.version, }, "services": services, } ) return output(True, values={"devices": atvs}) async def _autodiscover_device(args, storage: Storage, loop: asyncio.AbstractEventLoop): options = {"identifier": args.id, "protocol": args.protocol} if args.scan_hosts: options["hosts"] = args.scan_hosts atvs = await scan(loop, storage=storage, **options) if not atvs: return None apple_tv = atvs[0] def _set_credentials(protocol, field): service = apple_tv.get_service(protocol) if service: value = service.credentials or getattr(args, field) service.credentials = value for proto in Protocol: _set_credentials(proto, f"{proto.name.lower()}_credentials") return apple_tv async def _handle_command( args, abort_sem, storage: Storage, loop: asyncio.AbstractEventLoop ): if args.command == "scan": return await _scan_devices(loop, storage, args.scan_hosts) config = await _autodiscover_device(args, storage, loop) if not config: return output(False, "device_not_found") atv = await connect(config, loop, storage=storage) try: return await _run_command(atv, args, abort_sem, loop) finally: atv.close() async def _run_command(atv, args, abort_sem, loop): if args.command == "playing": return output_playing( await atv.metadata.playing(), ( atv.metadata.app if atv.features.in_state(FeatureState.Available, FeatureName.App) else None ), ) if args.command == "push_updates": power_listener = PowerPrinter(args.output) audio_listener = AudioPrinter(args.output) keyboard_listener = KeyboardPrinter(args.output) device_listener = DevicePrinter(args.output, abort_sem) push_listener = PushPrinter(args.output, atv) atv.power.listener = power_listener atv.audio.listener = audio_listener atv.keyboard.listener = keyboard_listener atv.listener = device_listener atv.push_updater.listener = push_listener atv.push_updater.start() print( args.output( output(True, values={"power_state": atv.power.power_state.name.lower()}) ), flush=True, ) audio_listener.outputdevices_update([], atv.audio.output_devices) await wait_for_input(loop, abort_sem) return output(True, values={"push_updates": "finished"}) if args.command in retrieve_commands(RemoteControl): await getattr(atv.remote_control, args.command)() return output(True, values={"command": args.command}) return output(False, "unsupported_command") async def appstart(loop): """Start the asyncio event loop and runs the application.""" parser = create_common_parser() parser.add_argument("command", help="command to run") parser.add_argument("-i", "--id", help="device identifier", dest="id", default=None) parser.add_argument( "--protocol", action=TransformProtocol, help="protocol to use (values: dmap, mrp)", dest="protocol", default=None, ) parser.add_argument( "-s", "--scan-hosts", help="scan specific hosts", dest="scan_hosts", default=None, action=VerifyScanHosts, ) parser.add_argument( "--output", help="output format (values: json)", dest="output", default=json.dumps, action=TransformOutput, ) parser.add_argument( "--debug", help="save debug log to atvscript.log", action="store_true", dest="debug", ) creds = parser.add_argument_group("credentials") for prot in Protocol: creds.add_argument( f"--{prot.name.lower()}-credentials", help=f"credentials for {prot.name}", dest=f"{prot.name.lower()}_credentials", default=None, ) args = parser.parse_args() abort_sem = asyncio.Semaphore(0) if args.debug: # Only allow saving to file to make sure output always has the specified format logging.basicConfig( filename="atvscript.log", level=logging.DEBUG, format="%(asctime)s %(levelname)s [%(name)s]: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) log_current_version() def _handle_exception(_, context): kwargs = {"error": context["message"]} if "exception" in context: kwargs["exception"] = context["exception"] print(args.output(output(False, **kwargs))) abort_sem.release() loop.set_exception_handler(_handle_exception) _LOGGER.debug("Started atvscript") storage = get_storage(args, loop) await storage.load() try: print( args.output(await _handle_command(args, abort_sem, storage, loop)), flush=True, ) except Exception as ex: print(args.output(output(False, exception=ex)), flush=True) return 0 def main(): """Application start here.""" loop = asyncio.get_event_loop() try: return loop.run_until_complete(appstart(loop)) except KeyboardInterrupt: return 0 if __name__ == "__main__": sys.exit(main())