diff --git a/examples/json-api.py b/examples/json-api.py new file mode 100644 index 000000000..48f00e6e8 --- /dev/null +++ b/examples/json-api.py @@ -0,0 +1,405 @@ +"""Simple json api client implemented in pyatv""" + +import os +import datetime +import traceback +from enum import Enum +from ipaddress import ip_address + +import logging +import asyncio +from aiohttp import WSMsgType, web + +import pyatv +from pyatv.interface import ( + App, + Apps, + Audio, + DeviceListener, + Playing, + Power, + PowerListener, + PushListener, + RemoteControl, + Stream, + retrieve_commands, +) + + +DEVICES = """ + +
DEVICES
+""" + + +STATE = """ + +
Connecting...
+
+""" + + +class PushPrinter(DeviceListener, PushListener, PowerListener): + """Listener for device and push updates events.""" + + def __init__(self, app, identifier): + """Initialize a new PushPrinter.""" + self.app = app + self.identifier = identifier + + def connection_lost(self, exception: Exception) -> None: + """Call when connection was lost.""" + self._remove() + payload = output(False, exception=exception, values={"connection": "lost"}) + self._send_json(payload) + + def connection_closed(self) -> None: + """Call when connection was closed.""" + self._remove() + payload = output(True, values={"connection": "closed"}) + self._send_json(payload) + + def _remove(self): + self.app["atv"].pop(self.identifier) + self.app["listeners"].remove(self) + + def _send_json(self, payload): + clients = self.app["clients"].get(self.identifier, []) + for client in clients: + asyncio.ensure_future(client.send_json(payload)) + + def playstatus_update(self, updater, playstatus: Playing) -> None: + """Call when play status was updated.""" + atv = self.app["atv"][self.identifier] + payload = output_playing(playstatus, atv.metadata.app) + self._send_json(payload) + + def playstatus_error(self, updater, exception: Exception) -> None: + """Call when an error occurred.""" + payload = output(False, exception=exception) + self._send_json(payload) + + def powerstate_update(self, old_state, new_state): + """Call when power state was updated.""" + payload = output(True, values={"power_state": new_state.name.lower()}) + self._send_json(payload) + + +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: 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) + + +def web_command(method): + """Decorate a web request handler.""" + + async def _handler(request): + device_id = request.match_info["id"] + atv = request.app["atv"].get(device_id) + if not atv: + return web.json_response( + output( + False, + error=f"Not connected to {device_id}", + values={"connection": "empty"} + ) + ) + return await method(request, atv) + + return _handler + + +def add_credentials(config, query): + """Add credentials to pyatv device configuration.""" + for service in config.services: + proto_name = service.protocol.name.lower() + if proto_name in query: + config.set_credentials(service.protocol, query.get(proto_name)) + + for service in config.services: + if service.credentials: + return True + + return False + + +routes = web.RouteTableDef() + + +@routes.get("/version") +async def version(request): + """Handle request to receive version pyatv.""" + return web.json_response( + output(True, values={"version": pyatv.const.__version__}) + ) + + +@routes.get("/devices") +async def devices(request): + """List devices connect.""" + devices = [] + for device in request.app["atv"]: + devices.append( + f"{device}" + ) + if devices: + devices = str("
".join(devices)) + else: + devices = str("Empty devices list") + return web.Response( + text=DEVICES.replace("DEVICES", devices), + content_type="text/html", + ) + + +@routes.get("/state/{id}") +async def state(request): + """Handle request to receive push updates.""" + return web.Response( + text=STATE.replace("DEVICE_ID", request.match_info["id"]), + content_type="text/html", + ) + + +@routes.get("/scan/") +async def scan(request): + """Handle request to scan for devices.""" + + def _convert(hosts): + if hosts: + ip_split = hosts.split(",") + return [ip_address(ip) for ip in ip_split] + return None + + hosts = _convert(request.query.get("hosts")) + atvs = [] + for atv in await pyatv.scan(loop=asyncio.get_event_loop(), hosts=hosts): + 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, + "services": services, + } + ) + return web.json_response(output(True, values={"devices": atvs})) + +@routes.get("/connect/{id}") +async def connect(request): + """Handle request to connect to a device.""" + loop = asyncio.get_event_loop() + device_id = request.match_info["id"] + if device_id in request.app["atv"]: + return web.json_response( + output(True, values={"connection": "connected"}) + ) + + options = {} + if ip_address(device_id): + options["hosts"] = [device_id] + else: + options["identifier"] = device_id + results = await pyatv.scan(loop=loop, **options) + if not results: + return web.json_response(output(False, error="Device not found")) + + if not add_credentials(results[0], request.query): + return web.json_response( + output(False, error="Failed to connect device, empty Credentials") + ) + + try: + atv = await pyatv.connect(results[0], loop=loop) + except Exception as ex: + return web.json_response( + output(False, error="Failed to connect device", exception=ex) + ) + + push_listener = PushPrinter(request.app, device_id) + + atv.power.listener = push_listener + atv.listener = push_listener + atv.push_updater.listener = push_listener + atv.push_updater.start() + request.app["listeners"].append(push_listener) + + request.app["atv"][device_id] = atv + return web.json_response(output(True, values={"connection": "connected"})) + + +@routes.get("/command/{id}/{command}") +@web_command +async def command(request, atv): + """Handle remote command request.""" + + def _command(command): + ctrl = retrieve_commands(RemoteControl) + power = retrieve_commands(Power) + stream = retrieve_commands(Stream) + apps = retrieve_commands(Apps) + audio = retrieve_commands(Audio) + if command in audio: + return atv.audio + if command in ctrl: + return atv.remote_control + if command in power: + return atv.power + if command in stream: + return atv.stream + if command in apps: + return atv.apps + return None + + command = request.match_info["command"] + + try: + object = _command(command=command) + if object: + await getattr(object, command)() + return web.json_response(output(True, values={"command": command})) + except Exception as ex: + return web.json_response( + output(False, error="failed_command", exception=ex) + ) + + return web.json_response(output(False, error="unsupported_command")) + + +@routes.get("/playing/{id}") +@web_command +async def playing(request, atv): + """Handle request for current play status.""" + try: + playstatus = await atv.metadata.playing() + except Exception as ex: + return web.json_response( + output(False, error="Remote control command failed", exception=ex) + ) + return web.json_response(output_playing(playstatus, atv.metadata.app)) + + +@routes.get("/close/{id}") +@web_command +async def close_connection(request, atv): + """Handle request to close a connection.""" + atv.close() + return web.json_response(output(True, values={"connection": "closed"})) + + +@routes.get("/ws/{id}") +@web_command +async def websocket_handler(request, atv): + """Handle incoming websocket requests.""" + device_id = request.match_info["id"] + + ws = web.WebSocketResponse() + await ws.prepare(request) + request.app["clients"].setdefault(device_id, []).append(ws) + + playstatus = await atv.metadata.playing() + await ws.send_json(output_playing(playstatus, atv.metadata.app)) + + async for msg in ws: + if msg.type == WSMsgType.TEXT: + # Handle custom commands from client here + if msg.data == "close": + await ws.close() + elif msg.type == WSMsgType.ERROR: + print(f"Connection closed with exception: {ws.exception()}") + + request.app["clients"][device_id].remove(ws) + + return ws + + +async def on_shutdown(app: web.Application) -> None: + """Call when application is shutting down.""" + for atv in app["atv"].values(): + atv.close() + + +def main(): + host = os.environ.get("HOST", "0.0.0.0") + port = os.environ.get("PORT", 8080) + + access_log = logging.getLogger('aiohttp.access') + access_log.setLevel(logging.INFO) + access_log.addHandler(logging.StreamHandler()) + access_log_format = '%a %t "%r" %s %b "%{User-Agent}i" %Tfsec' + + app = web.Application() + app["atv"] = {} + app["listeners"] = [] + app["clients"] = {} + + app.add_routes(routes) + app.on_shutdown.append(on_shutdown) + web.run_app( + app, host=host, port=port, + access_log=access_log, + access_log_format=access_log_format + ) + + +if __name__ == "__main__": + main() \ No newline at end of file