"""Command line entry point. screencast-server run the daemon the shell plugin starts screencast-server devices list what is on the network screencast-server cast "" start casting the screen screencast-server stop | status screencast-server pair "" AirPlay PIN pairing screencast-server set fps=30 bitrate=8000 """ from __future__ import annotations import argparse import asyncio import json import os import sys from . import VERSION from .server import Daemon from .util import log, runtime_dir, setup_logging async def _call(msg: dict, *, wait_state: bool = True, timeout: float = 200.0) -> dict: """Send one command to the running daemon and return its reply.""" path = runtime_dir() / "ctl.sock" try: reader, writer = await asyncio.open_unix_connection(str(path)) except (FileNotFoundError, ConnectionRefusedError): print("screencast-server is not running (start it with: screencast-server run)", file=sys.stderr) raise SystemExit(4) state: dict = {} writer.write((json.dumps(msg) + "\n").encode()) await writer.drain() deadline = asyncio.get_running_loop().time() + timeout try: while True: remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: break line = await asyncio.wait_for(reader.readline(), remaining) if not line: break data = json.loads(line) if data.get("type") == "state": state = data if msg.get("cmd") == "get" and wait_state: break elif data.get("type") == "reply": data["state"] = state return data except asyncio.TimeoutError: pass finally: writer.close() return {"ok": True, "state": state} def _print_devices(state: dict) -> None: devices = state.get("devices") or [] if not devices: print("no receivers found yet") return width = max(len(d["name"]) for d in devices) for dev in devices: extra = f" · {dev['app']}" if dev.get("app") else "" print(f"{dev['name']:<{width}} {dev['kindLabel']:<12} {dev['host']:<15} {dev['id']}{extra}") def _print_status(state: dict) -> None: session = state.get("session") or {} if session.get("state") in (None, "", "idle"): print("idle") else: print( f"{session['state']}: {session.get('deviceName', '')} " f"({session.get('width')}x{session.get('height')} {session.get('encoder')}/" f"{session.get('container')}, {session.get('clients')} client(s))" ) if session.get("url"): print(f" {session['url']}") if session.get("error"): print(f"error: {session['error']}") elif state.get("error"): print(f"error: {state['error']}") def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="screencast-server", description=__doc__) parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("--version", action="version", version=VERSION) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("run", help="run the daemon") sub.add_parser("devices", help="list receivers") sub.add_parser("status", help="what is being cast") sub.add_parser("stop", help="stop casting") sub.add_parser("rescan", help="scan the network again") sub.add_parser("open-firewall", help="let receivers reach the stream port (asks for a password)") p_cast = sub.add_parser("cast", help="cast the screen to a receiver") p_cast.add_argument("device", help="device id, or part of its name") p_vol = sub.add_parser("volume", help="set the receiver volume (0-100)") p_vol.add_argument("percent", type=float) p_pair = sub.add_parser("pair", help="pair with an AirPlay receiver") p_pair.add_argument("device") p_set = sub.add_parser("set", help="change settings, e.g. fps=30 audio=false") p_set.add_argument("assignments", nargs="+") args = parser.parse_args(argv) setup_logging(args.verbose) if args.command == "run": try: code = asyncio.run(Daemon().run()) except KeyboardInterrupt: code = 0 # Same reason as the timer in Daemon.shutdown: library threads would # otherwise hold the process open after everything is closed. sys.stdout.flush() sys.stderr.flush() os._exit(code) if args.command == "devices": state = asyncio.run(_call({"cmd": "rescan"}, timeout=8)).get("state") or {} _print_devices(state) return 0 if args.command == "status": _print_status(asyncio.run(_call({"cmd": "get"}, timeout=8)).get("state") or {}) return 0 if args.command == "stop": asyncio.run(_call({"cmd": "stop"}, timeout=20)) return 0 if args.command == "rescan": _print_devices(asyncio.run(_call({"cmd": "rescan"}, timeout=10)).get("state") or {}) return 0 if args.command == "open-firewall": reply = asyncio.run(_call({"cmd": "openFirewall"}, timeout=200)) if reply.get("ok"): print(reply.get("note") or "the stream port is open") return 0 print(reply.get("error") or "could not open the port", file=sys.stderr) return 1 if args.command == "cast": state = asyncio.run(_cast(args.device)) _print_status(state) return 0 if (state.get("session") or {}).get("state") == "streaming" else 1 if args.command == "volume": reply = asyncio.run(_call({"cmd": "volume", "value": args.percent / 100.0}, timeout=15)) return 0 if reply.get("ok") else 1 if args.command == "pair": return _pair(args.device) if args.command == "set": patch: dict = {} for item in args.assignments: if "=" not in item: print(f"expected key=value, got “{item}”", file=sys.stderr) return 2 key, value = item.split("=", 1) if value.lower() in ("true", "false"): patch[key] = value.lower() == "true" else: try: patch[key] = int(value) except ValueError: patch[key] = value reply = asyncio.run(_call({"cmd": "set", "settings": patch}, timeout=15)) print(json.dumps((reply.get("state") or {}).get("settings", {}), indent=2)) return 0 return 2 async def _cast(device: str) -> dict: """Start the cast, then follow the state pushes until it settles.""" path = runtime_dir() / "ctl.sock" try: reader, writer = await asyncio.open_unix_connection(str(path)) except (FileNotFoundError, ConnectionRefusedError): print("screencast-server is not running", file=sys.stderr) raise SystemExit(4) writer.write((json.dumps({"cmd": "cast", "device": device}) + "\n").encode()) await writer.drain() state: dict = {} # The daemon pushes its state on connect, before it has seen the command: # that first one says "idle" and must not end the wait. first = True deadline = asyncio.get_running_loop().time() + 300 try: while True: remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: break line = await asyncio.wait_for(reader.readline(), remaining) if not line: break data = json.loads(line) if data.get("type") == "reply" and not data.get("ok"): print(f"error: {data.get('error')}", file=sys.stderr) break if data.get("type") != "state": continue state = data if first: first = False continue session_state = (data.get("session") or {}).get("state", "") if session_state in ("streaming", "error", "idle") and state.get("busy", "") == "": break except asyncio.TimeoutError: pass finally: writer.close() return state def _pair(device: str) -> int: reply = asyncio.run(_call({"cmd": "pair", "device": device}, timeout=30)) if not reply.get("ok"): print(f"error: {reply.get('error')}", file=sys.stderr) return 1 pin = input("PIN shown on the receiver: ").strip() reply = asyncio.run(_call({"cmd": "pairPin", "pin": pin}, timeout=30)) if not reply.get("ok"): print(f"error: {reply.get('error') or 'pairing failed'}", file=sys.stderr) return 1 print("paired") return 0 if __name__ == "__main__": try: sys.exit(main()) except BrokenPipeError: log.debug("stdout closed")