ps5-remoteplay 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ from .credentials import Credentials
2
+ from .discovery import DeviceInfo, DeviceStatus, discover, get_device, wait_for_status
3
+ from .errors import (
4
+ DeviceNotFound,
5
+ LoginFailed,
6
+ OAuthError,
7
+ PasscodeMismatch,
8
+ PasscodeRequired,
9
+ ProtocolError,
10
+ PS5Error,
11
+ RemotePlayHttpError,
12
+ )
13
+ from .oauth import account_id_from_redirect, login_url
14
+ from .registration import register
15
+ from .session import RemotePlaySession, standby
16
+ from .wake import wake
17
+
18
+ __all__ = [
19
+ "Credentials",
20
+ "DeviceInfo",
21
+ "DeviceNotFound",
22
+ "DeviceStatus",
23
+ "LoginFailed",
24
+ "OAuthError",
25
+ "PS5Error",
26
+ "PasscodeMismatch",
27
+ "PasscodeRequired",
28
+ "ProtocolError",
29
+ "RemotePlayHttpError",
30
+ "RemotePlaySession",
31
+ "account_id_from_redirect",
32
+ "discover",
33
+ "get_device",
34
+ "login_url",
35
+ "register",
36
+ "standby",
37
+ "wait_for_status",
38
+ "wake",
39
+ ]
@@ -0,0 +1,168 @@
1
+ import argparse
2
+ import asyncio
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ import aiohttp
11
+
12
+ from . import (
13
+ Credentials,
14
+ DeviceInfo,
15
+ DeviceNotFound,
16
+ DeviceStatus,
17
+ PS5Error,
18
+ account_id_from_redirect,
19
+ discover,
20
+ get_device,
21
+ login_url,
22
+ register,
23
+ standby,
24
+ wait_for_status,
25
+ wake,
26
+ )
27
+
28
+ EXIT_AWAKE = 0
29
+ EXIT_STANDBY = 1
30
+ EXIT_NOT_FOUND = 2
31
+ EXIT_ERROR = 3
32
+
33
+ DEFAULT_CREDENTIALS = Path.home() / ".config" / "ps5-remoteplay" / "credentials.json"
34
+
35
+
36
+ def _load_store(path: Path) -> dict:
37
+ try:
38
+ return json.loads(path.read_text())
39
+ except FileNotFoundError:
40
+ return {}
41
+
42
+
43
+ def _save_store(path: Path, store: dict) -> None:
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
46
+ with os.fdopen(fd, "w") as f:
47
+ json.dump(store, f, indent=2)
48
+
49
+
50
+ def _credentials_for(args: argparse.Namespace, device: DeviceInfo) -> Credentials:
51
+ entry = _load_store(args.credentials).get(device.id)
52
+ if not entry:
53
+ raise PS5Error(f"No credentials for {device.name} ({device.id}) in {args.credentials}; run 'pair' first")
54
+ return Credentials.from_dict(entry)
55
+
56
+
57
+ def _describe(device: DeviceInfo) -> str:
58
+ return f"{device.name} ({device.id}) at {device.host}: {device.status}"
59
+
60
+
61
+ async def _status(args: argparse.Namespace) -> int:
62
+ if args.host:
63
+ device = await get_device(args.host, timeout=args.timeout)
64
+ print(_describe(device))
65
+ return EXIT_AWAKE if device.status == DeviceStatus.AWAKE else EXIT_STANDBY
66
+
67
+ devices = await discover(timeout=args.timeout)
68
+ for device in devices:
69
+ print(_describe(device))
70
+ return EXIT_AWAKE if devices else EXIT_NOT_FOUND
71
+
72
+
73
+ async def _wake(args: argparse.Namespace) -> int:
74
+ device = await get_device(args.host)
75
+ if device.status == DeviceStatus.AWAKE:
76
+ print(f"{device.name} is already awake")
77
+ return EXIT_AWAKE
78
+
79
+ await wake(args.host, _credentials_for(args, device))
80
+ print(f"Sent WAKEUP to {device.name}")
81
+ if not args.no_wait:
82
+ await wait_for_status(args.host, DeviceStatus.AWAKE, timeout=args.timeout)
83
+ print(f"{device.name} is awake")
84
+ return EXIT_AWAKE
85
+
86
+
87
+ async def _pair(args: argparse.Namespace) -> int:
88
+ device = await get_device(args.host)
89
+ if device.status != DeviceStatus.AWAKE:
90
+ raise PS5Error("The PS5 must be awake to pair; turn it on first")
91
+
92
+ url = login_url()
93
+ print("1. Sign in to PSN in your browser. When the page shows \"redirect\", copy the full URL from the address bar.")
94
+ print(f" {url}")
95
+ if not args.no_browser:
96
+ webbrowser.open(url)
97
+ redirect = await asyncio.to_thread(input, "Redirect URL> ")
98
+ account_id = await account_id_from_redirect(redirect)
99
+
100
+ print("2. On the PS5, open Settings > System > Remote Play > Link Device.")
101
+ pin = await asyncio.to_thread(input, "PIN shown on the PS5> ")
102
+ credentials = await register(args.host, account_id, pin)
103
+
104
+ store = _load_store(args.credentials)
105
+ store[device.id] = credentials.to_dict()
106
+ _save_store(args.credentials, store)
107
+ print(f"Paired with {device.name}; credentials saved to {args.credentials}")
108
+ return EXIT_AWAKE
109
+
110
+
111
+ async def _standby(args: argparse.Namespace) -> int:
112
+ device = await get_device(args.host)
113
+ changed = await standby(args.host, _credentials_for(args, device), passcode=args.passcode)
114
+ print(f"{device.name} {'is going into standby' if changed else 'is already in standby'}")
115
+ return EXIT_STANDBY
116
+
117
+
118
+ def _parser() -> argparse.ArgumentParser:
119
+ common = argparse.ArgumentParser(add_help=False)
120
+ common.add_argument("--debug", action="store_true", help="log protocol details")
121
+ common.add_argument(
122
+ "--credentials", type=Path, default=DEFAULT_CREDENTIALS,
123
+ help=f"credentials file (default {DEFAULT_CREDENTIALS}); playactor's file also works",
124
+ )
125
+
126
+ parser = argparse.ArgumentParser(prog="ps5-remoteplay")
127
+ commands = parser.add_subparsers(dest="command", required=True)
128
+
129
+ status = commands.add_parser("status", parents=[common], help="show status; exit 0 awake, 1 standby, 2 not found")
130
+ status.add_argument("--host", help="console IP; omit to broadcast")
131
+ status.add_argument("--timeout", type=float, default=3.0)
132
+ status.set_defaults(handler=_status)
133
+
134
+ wake_cmd = commands.add_parser("wake", parents=[common], help="wake a paired console")
135
+ wake_cmd.add_argument("--host", required=True)
136
+ wake_cmd.add_argument("--no-wait", action="store_true")
137
+ wake_cmd.add_argument("--timeout", type=float, default=30.0)
138
+ wake_cmd.set_defaults(handler=_wake)
139
+
140
+ pair = commands.add_parser("pair", parents=[common], help="pair via PSN login and the Link Device PIN")
141
+ pair.add_argument("--host", required=True)
142
+ pair.add_argument("--no-browser", action="store_true")
143
+ pair.set_defaults(handler=_pair)
144
+
145
+ standby_cmd = commands.add_parser("standby", parents=[common], help="put a paired console into standby")
146
+ standby_cmd.add_argument("--host", required=True)
147
+ standby_cmd.add_argument("--passcode", help="4-digit PS5 user passcode, if set")
148
+ standby_cmd.set_defaults(handler=_standby)
149
+ return parser
150
+
151
+
152
+ def main() -> None:
153
+ args = _parser().parse_args()
154
+ logging.basicConfig(level=logging.DEBUG if args.debug else logging.WARNING)
155
+ try:
156
+ sys.exit(asyncio.run(args.handler(args)))
157
+ except DeviceNotFound as e:
158
+ print(f"Error: {e}", file=sys.stderr)
159
+ sys.exit(EXIT_NOT_FOUND)
160
+ except (PS5Error, ValueError, OSError, aiohttp.ClientError) as e:
161
+ print(f"Error: {e or type(e).__name__}", file=sys.stderr)
162
+ sys.exit(EXIT_ERROR)
163
+ except KeyboardInterrupt:
164
+ sys.exit(130)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
@@ -0,0 +1,14 @@
1
+ DISCOVERY_PORT = 9302
2
+ REMOTEPLAY_PORT = 9295
3
+ DISCOVERY_VERSION = "00030010"
4
+ RP_VERSION = "1.0"
5
+ USER_AGENT = "remoteplay Windows"
6
+
7
+ CLIENT_TYPE = "dabfa2ec873de5839bee8d3f4c0239c4282c07c25c6077a2931afcf0adc0d34f"
8
+
9
+ # Values used by the official Remote Play Windows client
10
+ OAUTH_CLIENT_ID = "ba495a24-818c-472b-b12d-ff231c1b5745"
11
+ OAUTH_CLIENT_SECRET = "mvaiZkRsAsI1IBkY"
12
+ OAUTH_REDIRECT_URI = "https://remoteplay.dl.playstation.net/remoteplay/redirect"
13
+ OAUTH_AUTHORIZE_URL = "https://auth.api.sonyentertainmentnetwork.com/2.0/oauth/authorize"
14
+ OAUTH_TOKEN_URL = "https://auth.api.sonyentertainmentnetwork.com/2.0/oauth/token"
@@ -0,0 +1,50 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+
5
+ @dataclass
6
+ class Credentials:
7
+ """Pairing result for one console.
8
+
9
+ `to_dict`/`from_dict` use playactor's credentials.json entry shape, so
10
+ entries can be moved between the two tools.
11
+ """
12
+
13
+ account_id: str
14
+ user_credential: str
15
+ registration: dict[str, str] = field(default_factory=dict)
16
+
17
+ @property
18
+ def regist_key(self) -> str:
19
+ return self.registration["PS5-RegistKey"]
20
+
21
+ @property
22
+ def rp_key(self) -> bytes:
23
+ return bytes.fromhex(self.registration["RP-Key"])
24
+
25
+ def wake_fields(self) -> dict[str, str]:
26
+ return {
27
+ "app-type": "r",
28
+ "auth-type": "R",
29
+ "client-type": "vr",
30
+ "model": "w",
31
+ "user-credential": self.user_credential,
32
+ }
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return {
36
+ **self.wake_fields(),
37
+ "accountId": self.account_id,
38
+ "registration": dict(self.registration),
39
+ }
40
+
41
+ @classmethod
42
+ def from_dict(cls, data: dict[str, Any]) -> "Credentials":
43
+ registration = data.get("registration") or {}
44
+ if not registration.get("PS5-RegistKey") or not registration.get("RP-Key"):
45
+ raise ValueError("Credentials are missing PS5-RegistKey or RP-Key; pair again")
46
+ return cls(
47
+ account_id=data["accountId"],
48
+ user_credential=data["user-credential"],
49
+ registration=dict(registration),
50
+ )
@@ -0,0 +1,119 @@
1
+ import hashlib
2
+ import hmac
3
+ import struct
4
+
5
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
6
+
7
+ try:
8
+ from cryptography.hazmat.decrepit.ciphers.modes import CFB
9
+ except ImportError:
10
+ from cryptography.hazmat.primitives.ciphers.modes import CFB
11
+
12
+ from .keys import AERO_KEYS, AUTH_NONCE_KEYS, AUTH_SEED_KEYS, INIT_KEYS
13
+
14
+ NONCE_LENGTH = 16
15
+ PREFACE_LENGTH = 480
16
+ HEADER_LENGTH = 8
17
+
18
+ _HMAC_KEY = bytes.fromhex("464687b349ca8ce859c5270f5d7a69d6")
19
+
20
+
21
+ def generate_iv(nonce: bytes, counter: int) -> bytes:
22
+ message = nonce + struct.pack(">Q", counter)
23
+ return hmac.new(_HMAC_KEY, message, hashlib.sha256).digest()[:NONCE_LENGTH]
24
+
25
+
26
+ def generate_seed(pin: int, offset: int) -> bytes:
27
+ seed = bytearray(INIT_KEYS[(i * 0x20 + offset) % len(INIT_KEYS)] for i in range(16))
28
+ for i, b in enumerate(struct.pack(">I", pin & 0xFFFFFFFF)):
29
+ seed[12 + i] ^= b
30
+ return bytes(seed)
31
+
32
+
33
+ def generate_aeropause(nonce: bytes, preface: bytes) -> bytes:
34
+ offset = preface[0] >> 3
35
+ return bytes(
36
+ ((nonce[i] ^ AERO_KEYS[i * 0x20 + offset]) + 0xD3 + i) & 0xFF
37
+ for i in range(16)
38
+ )
39
+
40
+
41
+ def transform_server_nonce(server_nonce: bytes) -> bytes:
42
+ offset = (server_nonce[0] >> 3) * 0x70
43
+ return bytes(
44
+ ((server_nonce[i] - 0x2D - i) ^ AUTH_NONCE_KEYS[offset + i]) & 0xFF
45
+ for i in range(16)
46
+ )
47
+
48
+
49
+ def generate_auth_seed(auth_key: bytes, server_nonce: bytes) -> bytes:
50
+ offset = (server_nonce[7] >> 3) * 0x70
51
+ return bytes(
52
+ (((auth_key[i] + 0x18 + i) ^ server_nonce[i]) ^ AUTH_SEED_KEYS[offset + i]) & 0xFF
53
+ for i in range(16)
54
+ )
55
+
56
+
57
+ def aes_cfb(key: bytes, iv: bytes, data: bytes, *, decrypt: bool = False) -> bytes:
58
+ cipher = Cipher(algorithms.AES(key), CFB(iv))
59
+ ctx = cipher.decryptor() if decrypt else cipher.encryptor()
60
+ return ctx.update(data) + ctx.finalize()
61
+
62
+
63
+ class RegistrationCrypto:
64
+ def __init__(self, pin: str, nonce: bytes) -> None:
65
+ if not pin.isdigit():
66
+ raise ValueError("PIN must be numeric")
67
+ if len(nonce) != NONCE_LENGTH:
68
+ raise ValueError("nonce must be 16 bytes")
69
+
70
+ preface = bytearray(b"A" * PREFACE_LENGTH)
71
+ self._iv = generate_iv(nonce, 0)
72
+ self._key = generate_seed(int(pin), preface[0x18D] & 0x1F)
73
+
74
+ aeropause = generate_aeropause(nonce, preface)
75
+ preface[0xC7:0xCF] = aeropause[8:16]
76
+ preface[0x191:0x199] = aeropause[0:8]
77
+ self.preface = bytes(preface)
78
+
79
+ def encrypt_record(self, record: dict[str, str]) -> bytes:
80
+ text = "".join(f"{k}: {v}\r\n" for k, v in record.items())
81
+ return self.preface + aes_cfb(self._key, self._iv, text.encode())
82
+
83
+ def decrypt(self, data: bytes) -> bytes:
84
+ return aes_cfb(self._key, self._iv, data, decrypt=True)
85
+
86
+
87
+ class SessionCipher:
88
+ """Per-message AES-CFB with independent encrypt/decrypt counters.
89
+
90
+ Header-only frames are sent in the clear and do not consume a counter.
91
+ """
92
+
93
+ def __init__(self, rp_key: bytes, server_nonce: bytes) -> None:
94
+ if len(rp_key) != NONCE_LENGTH or len(server_nonce) != NONCE_LENGTH:
95
+ raise ValueError("RP-Key and server nonce must be 16 bytes")
96
+ self._key = generate_auth_seed(rp_key, server_nonce)
97
+ self._nonce = transform_server_nonce(server_nonce)
98
+ self._encrypt_counter = 0
99
+ self._decrypt_counter = 0
100
+
101
+ def encrypt(self, data: bytes) -> bytes:
102
+ iv = generate_iv(self._nonce, self._encrypt_counter)
103
+ self._encrypt_counter += 1
104
+ return aes_cfb(self._key, iv, data)
105
+
106
+ def decrypt(self, data: bytes) -> bytes:
107
+ iv = generate_iv(self._nonce, self._decrypt_counter)
108
+ self._decrypt_counter += 1
109
+ return aes_cfb(self._key, iv, data, decrypt=True)
110
+
111
+ def encode_frame(self, frame: bytes) -> bytes:
112
+ if len(frame) == HEADER_LENGTH:
113
+ return frame
114
+ return frame[:HEADER_LENGTH] + self.encrypt(frame[HEADER_LENGTH:])
115
+
116
+ def decode_payload(self, payload: bytes) -> bytes:
117
+ if not payload:
118
+ return payload
119
+ return self.decrypt(payload)
@@ -0,0 +1,144 @@
1
+ import asyncio
2
+ import logging
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+ from .const import DISCOVERY_PORT, DISCOVERY_VERSION
7
+ from .errors import DeviceNotFound
8
+
9
+ _LOGGER = logging.getLogger(__name__)
10
+
11
+ _STATUS_CODE_STANDBY = "620"
12
+
13
+
14
+ class DeviceStatus(StrEnum):
15
+ AWAKE = "AWAKE"
16
+ STANDBY = "STANDBY"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class DeviceInfo:
21
+ host: str
22
+ id: str
23
+ name: str
24
+ type: str
25
+ status: DeviceStatus
26
+ system_version: str
27
+ host_request_port: int | None
28
+ data: dict[str, str]
29
+
30
+
31
+ def format_message(message_type: str, data: dict[str, str] | None = None) -> bytes:
32
+ fields = "".join(f"{k}:{v}\n" for k, v in (data or {}).items())
33
+ return (
34
+ f"{message_type} * HTTP/1.1\n{fields}device-discovery-protocol-version:{DISCOVERY_VERSION}\n"
35
+ ).encode()
36
+
37
+
38
+ def parse_message(raw: bytes) -> dict[str, str] | None:
39
+ """Parse a discovery datagram; returns None for anything that isn't a device reply."""
40
+ lines = raw.decode("utf-8", errors="replace").replace("\r", "").split("\n")
41
+ parts = lines[0].split(" ")
42
+ if not lines[0].startswith("HTTP") or len(parts) < 2:
43
+ return None
44
+
45
+ result = {"status_code": parts[1]}
46
+ for line in lines[1:]:
47
+ if ":" not in line:
48
+ continue
49
+ key, value = line.split(":", 1)
50
+ value = value.lstrip(" ")
51
+ if value:
52
+ result[key.lower()] = value
53
+ return result
54
+
55
+
56
+ def _device_from(host: str, parsed: dict[str, str]) -> DeviceInfo | None:
57
+ device_id = parsed.get("host-id")
58
+ if not device_id:
59
+ return None
60
+ port = parsed.get("host-request-port", "")
61
+ return DeviceInfo(
62
+ host=host,
63
+ id=device_id,
64
+ name=parsed.get("host-name", ""),
65
+ type=parsed.get("host-type", ""),
66
+ status=DeviceStatus.STANDBY if parsed["status_code"] == _STATUS_CODE_STANDBY else DeviceStatus.AWAKE,
67
+ system_version=parsed.get("system-version", ""),
68
+ host_request_port=int(port) if port.isdigit() else None,
69
+ data=parsed,
70
+ )
71
+
72
+
73
+ class _DiscoveryProtocol(asyncio.DatagramProtocol):
74
+ def __init__(self) -> None:
75
+ self.devices: asyncio.Queue[DeviceInfo] = asyncio.Queue()
76
+
77
+ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
78
+ parsed = parse_message(data)
79
+ device = parsed and _device_from(addr[0], parsed)
80
+ if device:
81
+ _LOGGER.debug("Discovered %s at %s: %s", device.id, device.host, device.status)
82
+ self.devices.put_nowait(device)
83
+
84
+
85
+ async def _search(
86
+ host: str | None, timeout: float, resend_interval: float, *, first_only: bool
87
+ ) -> list[DeviceInfo]:
88
+ loop = asyncio.get_running_loop()
89
+ transport, protocol = await loop.create_datagram_endpoint(
90
+ _DiscoveryProtocol,
91
+ local_addr=("0.0.0.0", 0),
92
+ allow_broadcast=host is None,
93
+ )
94
+ target = (host or "255.255.255.255", DISCOVERY_PORT)
95
+ message = format_message("SRCH")
96
+ found: dict[str, DeviceInfo] = {}
97
+ deadline = loop.time() + timeout
98
+ try:
99
+ while loop.time() < deadline:
100
+ transport.sendto(message, target)
101
+ resend_at = min(loop.time() + resend_interval, deadline)
102
+ while (wait := resend_at - loop.time()) > 0:
103
+ try:
104
+ device = await asyncio.wait_for(protocol.devices.get(), wait)
105
+ except TimeoutError:
106
+ break
107
+ if host and device.host != host:
108
+ continue
109
+ found[device.id] = device
110
+ if first_only:
111
+ return [device]
112
+ finally:
113
+ transport.close()
114
+ return list(found.values())
115
+
116
+
117
+ async def discover(timeout: float = 5.0, resend_interval: float = 1.0) -> list[DeviceInfo]:
118
+ """Broadcast a search and return every PS5 that answered."""
119
+ return await _search(None, timeout, resend_interval, first_only=False)
120
+
121
+
122
+ async def get_device(host: str, timeout: float = 3.0, resend_interval: float = 1.0) -> DeviceInfo:
123
+ """Query one console directly. Raises DeviceNotFound if it doesn't answer."""
124
+ devices = await _search(host, timeout, resend_interval, first_only=True)
125
+ if not devices:
126
+ raise DeviceNotFound(f"No PS5 answered at {host}")
127
+ return devices[0]
128
+
129
+
130
+ async def wait_for_status(
131
+ host: str,
132
+ status: DeviceStatus,
133
+ timeout: float = 30.0,
134
+ poll_interval: float = 2.0,
135
+ ) -> DeviceInfo:
136
+ async with asyncio.timeout(timeout):
137
+ while True:
138
+ try:
139
+ device = await get_device(host, timeout=poll_interval)
140
+ if device.status == status:
141
+ return device
142
+ except DeviceNotFound:
143
+ pass
144
+ await asyncio.sleep(poll_interval)
@@ -0,0 +1,48 @@
1
+ class PS5Error(Exception):
2
+ pass
3
+
4
+
5
+ class DeviceNotFound(PS5Error):
6
+ pass
7
+
8
+
9
+ class OAuthError(PS5Error):
10
+ pass
11
+
12
+
13
+ _REASONS = {
14
+ "80108b09": "Registration failed, probably invalid PIN",
15
+ "80108b02": "Invalid PSN ID",
16
+ "80108b10": "Remote is already in use",
17
+ "80108b15": "Remote Play on Console crashed",
18
+ "80108b11": "RP-Version mismatch",
19
+ }
20
+
21
+
22
+ class RemotePlayHttpError(PS5Error):
23
+ def __init__(self, status: int, reason_code: str | None = None) -> None:
24
+ self.status = status
25
+ self.reason_code = reason_code
26
+ self.reason = _REASONS.get((reason_code or "").lower())
27
+ message = f"Remote Play request failed with HTTP {status}"
28
+ if reason_code:
29
+ message += f": {self.reason or 'Other error'} ({reason_code})"
30
+ super().__init__(message)
31
+
32
+
33
+ class ProtocolError(PS5Error):
34
+ pass
35
+
36
+
37
+ class PasscodeRequired(PS5Error):
38
+ pass
39
+
40
+
41
+ class PasscodeMismatch(PS5Error):
42
+ pass
43
+
44
+
45
+ class LoginFailed(PS5Error):
46
+ def __init__(self, result: int) -> None:
47
+ self.result = result
48
+ super().__init__(f"Remote Play login failed with result {result}")
ps5_remoteplay/http.py ADDED
@@ -0,0 +1,102 @@
1
+ """Minimal HTTP/1.1 over asyncio streams.
2
+
3
+ The Remote Play ctrl request hands its TCP connection over to the binary
4
+ frame protocol, so the response must be read without consuming anything past
5
+ the headers. A general-purpose client can't guarantee that.
6
+ """
7
+
8
+ import asyncio
9
+ from dataclasses import dataclass
10
+
11
+ from .const import REMOTEPLAY_PORT, USER_AGENT
12
+ from .errors import ProtocolError, RemotePlayHttpError
13
+
14
+ _MAX_HEADER_BYTES = 64 * 1024
15
+
16
+
17
+ @dataclass
18
+ class Response:
19
+ status: int
20
+ reason: str
21
+ headers: dict[str, str]
22
+ body: bytes
23
+
24
+
25
+ def build_request(
26
+ method: str,
27
+ host: str,
28
+ port: int,
29
+ path: str,
30
+ headers: dict[str, str],
31
+ body: bytes = b"",
32
+ ) -> bytes:
33
+ # Content-Length: 0 is required even on GETs; the console returns 403 without it
34
+ lines = [
35
+ f"{method} {path} HTTP/1.1",
36
+ f"Host: {host}:{port}",
37
+ f"User-Agent: {USER_AGENT}",
38
+ *(f"{k}: {v}" for k, v in headers.items()),
39
+ f"Content-Length: {len(body)}",
40
+ ]
41
+ return ("\r\n".join(lines) + "\r\n\r\n").encode() + body
42
+
43
+
44
+ async def read_response(reader: asyncio.StreamReader, *, read_body: bool = True) -> Response:
45
+ try:
46
+ head = await reader.readuntil(b"\r\n\r\n")
47
+ except asyncio.IncompleteReadError as e:
48
+ raise ProtocolError("Connection closed before HTTP response headers") from e
49
+ except asyncio.LimitOverrunError as e:
50
+ raise ProtocolError("HTTP response headers too large") from e
51
+
52
+ if len(head) > _MAX_HEADER_BYTES:
53
+ raise ProtocolError("HTTP response headers too large")
54
+
55
+ status_line, *header_lines = head.decode("latin-1").split("\r\n")
56
+ parts = status_line.split(" ", 2)
57
+ if len(parts) < 2 or not parts[0].startswith("HTTP/") or not parts[1].isdigit():
58
+ raise ProtocolError(f"Invalid HTTP status line: {status_line!r}")
59
+
60
+ headers: dict[str, str] = {}
61
+ for line in header_lines:
62
+ if ":" in line:
63
+ key, value = line.split(":", 1)
64
+ headers[key.strip().lower()] = value.strip()
65
+
66
+ body = b""
67
+ if read_body:
68
+ if "chunked" in headers.get("transfer-encoding", "").lower():
69
+ raise ProtocolError("Chunked HTTP responses are not supported")
70
+ if "content-length" in headers:
71
+ body = await reader.readexactly(int(headers["content-length"]))
72
+ else:
73
+ body = await reader.read()
74
+
75
+ return Response(int(parts[1]), parts[2] if len(parts) > 2 else "", headers, body)
76
+
77
+
78
+ def raise_for_status(response: Response) -> None:
79
+ if response.status >= 300:
80
+ raise RemotePlayHttpError(response.status, response.headers.get("rp-application-reason"))
81
+
82
+
83
+ async def request(
84
+ host: str,
85
+ method: str,
86
+ path: str,
87
+ headers: dict[str, str],
88
+ body: bytes = b"",
89
+ *,
90
+ port: int = REMOTEPLAY_PORT,
91
+ timeout: float = 15.0,
92
+ ) -> Response:
93
+ async with asyncio.timeout(timeout):
94
+ reader, writer = await asyncio.open_connection(host, port)
95
+ try:
96
+ writer.write(build_request(method, host, port, path, headers, body))
97
+ await writer.drain()
98
+ response = await read_response(reader)
99
+ finally:
100
+ writer.close()
101
+ raise_for_status(response)
102
+ return response