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.
- ps5_remoteplay/__init__.py +39 -0
- ps5_remoteplay/__main__.py +168 -0
- ps5_remoteplay/const.py +14 -0
- ps5_remoteplay/credentials.py +50 -0
- ps5_remoteplay/crypto.py +119 -0
- ps5_remoteplay/discovery.py +144 -0
- ps5_remoteplay/errors.py +48 -0
- ps5_remoteplay/http.py +102 -0
- ps5_remoteplay/keys.py +269 -0
- ps5_remoteplay/oauth.py +72 -0
- ps5_remoteplay/registration.py +85 -0
- ps5_remoteplay/session.py +193 -0
- ps5_remoteplay/wake.py +22 -0
- ps5_remoteplay-0.1.0.dist-info/METADATA +68 -0
- ps5_remoteplay-0.1.0.dist-info/RECORD +18 -0
- ps5_remoteplay-0.1.0.dist-info/WHEEL +4 -0
- ps5_remoteplay-0.1.0.dist-info/entry_points.txt +2 -0
- ps5_remoteplay-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -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()
|
ps5_remoteplay/const.py
ADDED
|
@@ -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
|
+
)
|
ps5_remoteplay/crypto.py
ADDED
|
@@ -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)
|
ps5_remoteplay/errors.py
ADDED
|
@@ -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
|