windows-apple-remote 0.1.1__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.
- atv_remote/__init__.py +3 -0
- atv_remote/__main__.py +3 -0
- atv_remote/cli.py +91 -0
- atv_remote/keys.py +28 -0
- atv_remote/server.py +407 -0
- windows_apple_remote-0.1.1.dist-info/METADATA +118 -0
- windows_apple_remote-0.1.1.dist-info/RECORD +10 -0
- windows_apple_remote-0.1.1.dist-info/WHEEL +4 -0
- windows_apple_remote-0.1.1.dist-info/entry_points.txt +2 -0
- windows_apple_remote-0.1.1.dist-info/licenses/LICENSE +21 -0
atv_remote/__init__.py
ADDED
atv_remote/__main__.py
ADDED
atv_remote/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Command line entry point: advertise this PC as an Apple TV and serve remotes."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from zeroconf import ServiceInfo
|
|
12
|
+
from zeroconf.asyncio import AsyncZeroconf
|
|
13
|
+
|
|
14
|
+
from atv_remote import keys
|
|
15
|
+
from atv_remote.server import Identity, RemoteServer
|
|
16
|
+
|
|
17
|
+
SERVICE_TYPE = "_companion-link._tcp.local."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_state_path():
|
|
21
|
+
base = os.environ.get("APPDATA") or Path.home() / ".config"
|
|
22
|
+
return Path(base) / "atv-remote" / "state.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def local_ip():
|
|
26
|
+
"""IP of the interface that routes to the mDNS multicast group (no packet is sent)."""
|
|
27
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
28
|
+
sock.connect(("224.0.0.251", 5353))
|
|
29
|
+
return sock.getsockname()[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def show_pin(pin):
|
|
33
|
+
print(f"\n Pairing PIN: {pin}\n Enter it on your iPhone/iPad.\n", flush=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def serve(args):
|
|
37
|
+
identity = Identity(args.state)
|
|
38
|
+
loop = asyncio.get_running_loop()
|
|
39
|
+
server = await loop.create_server(
|
|
40
|
+
lambda: RemoteServer(identity, args.name, keys.press, show_pin), "0.0.0.0", args.port
|
|
41
|
+
)
|
|
42
|
+
port = server.sockets[0].getsockname()[1]
|
|
43
|
+
ip = args.address or local_ip()
|
|
44
|
+
|
|
45
|
+
zeroconf = AsyncZeroconf(interfaces=[ip])
|
|
46
|
+
info = ServiceInfo(
|
|
47
|
+
SERVICE_TYPE,
|
|
48
|
+
f"{args.name}.{SERVICE_TYPE}",
|
|
49
|
+
addresses=[socket.inet_aton(ip)],
|
|
50
|
+
port=port,
|
|
51
|
+
properties=identity.txt_record(),
|
|
52
|
+
)
|
|
53
|
+
await zeroconf.async_register_service(info)
|
|
54
|
+
print(f"'{args.name}' is live on {ip}:{port}.")
|
|
55
|
+
print("Open Apple TV Remote (Control Center) on your iPhone and pick it. Ctrl+C to quit.")
|
|
56
|
+
try:
|
|
57
|
+
await asyncio.Event().wait()
|
|
58
|
+
finally:
|
|
59
|
+
await zeroconf.async_unregister_service(info)
|
|
60
|
+
await zeroconf.async_close()
|
|
61
|
+
server.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv=None):
|
|
65
|
+
parser = argparse.ArgumentParser(
|
|
66
|
+
prog="atv-remote", description="Control this PC's media with the iOS Apple TV Remote."
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("--name", default=socket.gethostname(), help="name shown on the iPhone")
|
|
69
|
+
parser.add_argument("--address", help="IP to advertise (default: auto-detect)")
|
|
70
|
+
parser.add_argument("--port", type=int, default=0, help="TCP port (default: random)")
|
|
71
|
+
parser.add_argument("--state", type=Path, default=default_state_path(), help="identity/pairings file")
|
|
72
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
|
73
|
+
args = parser.parse_args(argv)
|
|
74
|
+
|
|
75
|
+
if not keys.SUPPORTED:
|
|
76
|
+
parser.exit(1, "atv-remote supports Windows only for now.\n")
|
|
77
|
+
|
|
78
|
+
logging.basicConfig(
|
|
79
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
80
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
81
|
+
)
|
|
82
|
+
if not args.verbose:
|
|
83
|
+
logging.getLogger("pyatv").setLevel(logging.WARNING)
|
|
84
|
+
try:
|
|
85
|
+
asyncio.run(serve(args))
|
|
86
|
+
except KeyboardInterrupt:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
sys.exit(main())
|
atv_remote/keys.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Turn remote actions into OS key presses. Windows only for now."""
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
# Windows virtual-key codes
|
|
7
|
+
VIRTUAL_KEYS = {
|
|
8
|
+
"play_pause": 0xB3,
|
|
9
|
+
"next": 0xB0,
|
|
10
|
+
"previous": 0xB1,
|
|
11
|
+
"volume_up": 0xAF,
|
|
12
|
+
"volume_down": 0xAE,
|
|
13
|
+
"back": 0x1B, # Escape
|
|
14
|
+
}
|
|
15
|
+
KEYEVENTF_EXTENDEDKEY = 0x1
|
|
16
|
+
KEYEVENTF_KEYUP = 0x2
|
|
17
|
+
NOT_EXTENDED = {0x1B}
|
|
18
|
+
|
|
19
|
+
SUPPORTED = sys.platform == "win32"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def press(action):
|
|
23
|
+
"""Tap the key mapped to `action`."""
|
|
24
|
+
vk = VIRTUAL_KEYS[action]
|
|
25
|
+
flags = 0 if vk in NOT_EXTENDED else KEYEVENTF_EXTENDEDKEY
|
|
26
|
+
user32 = ctypes.windll.user32
|
|
27
|
+
user32.keybd_event(vk, 0, flags, 0)
|
|
28
|
+
user32.keybd_event(vk, 0, flags | KEYEVENTF_KEYUP, 0)
|
atv_remote/server.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Companion-protocol server that makes this PC show up in the Apple TV Remote app.
|
|
2
|
+
|
|
3
|
+
Pairing/crypto comes from pyatv's CompanionServerAuth. On top of it we add what a
|
|
4
|
+
real Apple TV does and pyatv's test server skips: a per-install key, a random PIN
|
|
5
|
+
per pairing, and rejecting devices that never paired.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import binascii
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import secrets
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from cryptography.exceptions import InvalidSignature, InvalidTag
|
|
18
|
+
from cryptography.hazmat.primitives import serialization
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
|
|
21
|
+
from pyatv.auth.hap_srp import hkdf_expand
|
|
22
|
+
from pyatv.auth.hap_tlv8 import ErrorCode, TlvValue, read_tlv, write_tlv
|
|
23
|
+
from pyatv.const import TouchAction
|
|
24
|
+
from pyatv.protocols.companion import MediaControlFlags
|
|
25
|
+
from pyatv.protocols.companion.api import HidCommand, MediaControlCommand
|
|
26
|
+
from pyatv.protocols.companion.connection import FrameType
|
|
27
|
+
from pyatv.protocols.companion.server_auth import (
|
|
28
|
+
CompanionServerAuth,
|
|
29
|
+
generate_keys,
|
|
30
|
+
new_server_session,
|
|
31
|
+
)
|
|
32
|
+
from pyatv.support import chacha20, opack
|
|
33
|
+
|
|
34
|
+
_LOGGER = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
AUTH_FRAMES = {FrameType.PS_Start, FrameType.PS_Next, FrameType.PV_Start, FrameType.PV_Next}
|
|
37
|
+
OPACK_FRAMES = AUTH_FRAMES | {FrameType.U_OPACK, FrameType.E_OPACK, FrameType.P_OPACK}
|
|
38
|
+
RESPONSE = 3 # "_t" value of a response
|
|
39
|
+
# Device facts taken from servers known to work with the iOS remote (see
|
|
40
|
+
# thiccaxe/CompanionGames); tvremoted drops the session when these look wrong.
|
|
41
|
+
MODEL = "AppleTV5,3"
|
|
42
|
+
SOURCE_VERSION = "715.2"
|
|
43
|
+
RP_FLAGS = "0xB6782"
|
|
44
|
+
SERVICE_TYPES = [
|
|
45
|
+
"com.apple.tvremoteservices", # the service the remote opens a session with
|
|
46
|
+
"com.apple.bluetooth.remote",
|
|
47
|
+
"com.apple.siri.wakeup",
|
|
48
|
+
"com.apple.home.messaging",
|
|
49
|
+
"com.apple.workflow.remotewidgets",
|
|
50
|
+
"com.apple.devicediscoveryui.rapportwake",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# The remote is used as a media controller: directions and taps map to media keys,
|
|
54
|
+
# the same as touchpad swipes.
|
|
55
|
+
HID_ACTIONS = {
|
|
56
|
+
HidCommand.Up: "volume_up",
|
|
57
|
+
HidCommand.Down: "volume_down",
|
|
58
|
+
HidCommand.Left: "previous",
|
|
59
|
+
HidCommand.Right: "next",
|
|
60
|
+
HidCommand.Select: "play_pause", # a tap on the touch area arrives as Select
|
|
61
|
+
HidCommand.Menu: "back",
|
|
62
|
+
HidCommand.PlayPause: "play_pause",
|
|
63
|
+
HidCommand.VolumeUp: "volume_up",
|
|
64
|
+
HidCommand.VolumeDown: "volume_down",
|
|
65
|
+
}
|
|
66
|
+
# ponytail: Play and Pause both toggle; Windows has one play/pause key and we don't track player state
|
|
67
|
+
MCC_ACTIONS = {
|
|
68
|
+
MediaControlCommand.Play: "play_pause",
|
|
69
|
+
MediaControlCommand.Pause: "play_pause",
|
|
70
|
+
MediaControlCommand.NextTrack: "next",
|
|
71
|
+
MediaControlCommand.PreviousTrack: "previous",
|
|
72
|
+
}
|
|
73
|
+
MEDIA_FLAGS = (
|
|
74
|
+
MediaControlFlags.Play
|
|
75
|
+
| MediaControlFlags.Pause
|
|
76
|
+
| MediaControlFlags.NextTrack
|
|
77
|
+
| MediaControlFlags.PreviousTrack
|
|
78
|
+
| MediaControlFlags.Volume # lets the iPhone's volume buttons control the PC
|
|
79
|
+
)
|
|
80
|
+
SWIPE_FRACTION = 0.15 # swipe must cover this share of the touchpad to count
|
|
81
|
+
VOLUME_STEP_FRACTION = 0.1 # each extra 10% of vertical swipe = one more volume step
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _RefTable(list):
|
|
85
|
+
"""OPACK back-reference table matching Apple's encoder (and pyatv's own pack()).
|
|
86
|
+
|
|
87
|
+
pyatv's decoder also tables 1-byte objects ('' and b'') and dedupes by ==, so a
|
|
88
|
+
message containing '' resolves later references one slot off (seen with iOS 27's
|
|
89
|
+
_systemInfo). Remove when pyatv's unpack is fixed.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __contains__(self, value):
|
|
93
|
+
return any(type(item) is type(value) and item == value for item in self)
|
|
94
|
+
|
|
95
|
+
def append(self, value):
|
|
96
|
+
if value not in ("", b""):
|
|
97
|
+
super().append(value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def unpack_opack(data):
|
|
101
|
+
return opack._unpack(data, _RefTable())[0] # pylint: disable=protected-access
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Identity:
|
|
105
|
+
"""Persistent server identity and paired devices, stored as JSON."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, path):
|
|
108
|
+
self.path = Path(path)
|
|
109
|
+
data = json.loads(self.path.read_text()) if self.path.exists() else {}
|
|
110
|
+
self.id = data.get("id") or str(uuid.uuid4()).upper()
|
|
111
|
+
self.seed = bytes.fromhex(data["seed"]) if "seed" in data else secrets.token_bytes(32)
|
|
112
|
+
self.clients = data.get("clients", {}) # pairing id -> Ed25519 public key (hex)
|
|
113
|
+
self.save()
|
|
114
|
+
|
|
115
|
+
def save(self):
|
|
116
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
data = {"id": self.id, "seed": self.seed.hex(), "clients": self.clients}
|
|
118
|
+
self.path.write_text(json.dumps(data, indent=2))
|
|
119
|
+
|
|
120
|
+
def txt_record(self):
|
|
121
|
+
"""mDNS TXT properties of an Apple TV, with stable ids derived from our seed."""
|
|
122
|
+
digest = hashlib.sha256(self.seed).hexdigest()
|
|
123
|
+
mac = ":".join(digest[i : i + 2] for i in range(48, 60, 2)).upper()
|
|
124
|
+
return {
|
|
125
|
+
"rpMac": "2",
|
|
126
|
+
"rpFl": RP_FLAGS,
|
|
127
|
+
"rpMd": MODEL,
|
|
128
|
+
"rpVr": SOURCE_VERSION,
|
|
129
|
+
"rpHN": digest[0:12],
|
|
130
|
+
"rpHA": digest[12:24],
|
|
131
|
+
"rpAD": digest[24:36],
|
|
132
|
+
"rpHI": digest[36:48],
|
|
133
|
+
"rpBA": mac,
|
|
134
|
+
"rpMRtID": self.id,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def _uuid(self, label):
|
|
138
|
+
return str(uuid.UUID(hashlib.sha256(self.seed + label).hexdigest()[:32])).upper()
|
|
139
|
+
|
|
140
|
+
def system_info(self, name, port):
|
|
141
|
+
"""Our answer to the client's _systemInfo, mirroring the fields an Apple TV sends."""
|
|
142
|
+
txt = self.txt_record()
|
|
143
|
+
return {
|
|
144
|
+
"name": name,
|
|
145
|
+
"model": MODEL,
|
|
146
|
+
"_sv": SOURCE_VERSION,
|
|
147
|
+
"_i": txt["rpHI"],
|
|
148
|
+
"_idsID": self._uuid(b"ids"),
|
|
149
|
+
"_pubID": txt["rpBA"],
|
|
150
|
+
"_mrID": self.id,
|
|
151
|
+
"_mRtID": self.id,
|
|
152
|
+
"_lP": port,
|
|
153
|
+
"_stA": SERVICE_TYPES,
|
|
154
|
+
"_sf": 65536,
|
|
155
|
+
"_bf": 1920,
|
|
156
|
+
"_cf": 512,
|
|
157
|
+
"_clFl": 128,
|
|
158
|
+
"_msSt": 1,
|
|
159
|
+
"_msRo": 1,
|
|
160
|
+
"_dCapF": 1,
|
|
161
|
+
"_siriInfo": {
|
|
162
|
+
"collectorElectionVersion": 1.0,
|
|
163
|
+
"peerData": {
|
|
164
|
+
"productType": MODEL,
|
|
165
|
+
"userInterfaceIdiom": "ZEUS", # Apple TV
|
|
166
|
+
"userAssignedDeviceName": name,
|
|
167
|
+
"assistantIdentifier": self._uuid(b"assistant"),
|
|
168
|
+
"sharedUserIdentifier": self._uuid(b"user"),
|
|
169
|
+
"isLocationSharingDevice": False,
|
|
170
|
+
"isSiriCloudSyncEnabled": False,
|
|
171
|
+
},
|
|
172
|
+
"audio-session-coordination.system-info": {
|
|
173
|
+
"isSupportedAndEnabled": False,
|
|
174
|
+
"mediaRemoteGroupIdentifier": self._uuid(b"group"),
|
|
175
|
+
"mediaRemoteRouteIdentifier": self.id,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class RemoteServer(CompanionServerAuth, asyncio.Protocol):
|
|
182
|
+
"""One connection from an iOS device."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, identity, name, on_action, on_pin):
|
|
185
|
+
super().__init__(name, unique_id=identity.id)
|
|
186
|
+
self.keys = generate_keys(identity.seed)
|
|
187
|
+
self.identity = identity
|
|
188
|
+
self.on_action = on_action
|
|
189
|
+
self.on_pin = on_pin
|
|
190
|
+
self.transport = None
|
|
191
|
+
self.buffer = b""
|
|
192
|
+
self.chacha = None
|
|
193
|
+
self._client_verify_pub = None
|
|
194
|
+
self._touchpad = (1000.0, 1000.0)
|
|
195
|
+
self._touch_start = None
|
|
196
|
+
self._volume = 0.5 # what we last told the iPhone; SetVolume moves relative to it
|
|
197
|
+
|
|
198
|
+
# asyncio.Protocol
|
|
199
|
+
|
|
200
|
+
def connection_made(self, transport):
|
|
201
|
+
self.transport = transport
|
|
202
|
+
_LOGGER.info("Device connected: %s", transport.get_extra_info("peername"))
|
|
203
|
+
|
|
204
|
+
def connection_lost(self, exc):
|
|
205
|
+
_LOGGER.info("Device disconnected")
|
|
206
|
+
self.transport = None
|
|
207
|
+
|
|
208
|
+
def data_received(self, data):
|
|
209
|
+
self.buffer += data
|
|
210
|
+
while len(self.buffer) >= 4:
|
|
211
|
+
end = 4 + int.from_bytes(self.buffer[1:4], "big")
|
|
212
|
+
if len(self.buffer) < end:
|
|
213
|
+
return
|
|
214
|
+
header, payload = self.buffer[:4], self.buffer[4:end]
|
|
215
|
+
self.buffer = self.buffer[end:]
|
|
216
|
+
try:
|
|
217
|
+
self._handle_frame(header, payload)
|
|
218
|
+
except Exception:
|
|
219
|
+
# A broken frame desyncs the cipher counters; drop the connection.
|
|
220
|
+
_LOGGER.exception("Bad frame (type %d), closing connection", header[0])
|
|
221
|
+
self.transport.close()
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
def _handle_frame(self, header, payload):
|
|
225
|
+
frame_type = FrameType(header[0])
|
|
226
|
+
if self.chacha and payload:
|
|
227
|
+
payload = self.chacha.decrypt(payload, aad=header)
|
|
228
|
+
if frame_type not in OPACK_FRAMES:
|
|
229
|
+
_LOGGER.debug("Ignoring %s frame: %s", frame_type, payload.hex())
|
|
230
|
+
return
|
|
231
|
+
message = unpack_opack(payload) if payload else {}
|
|
232
|
+
if frame_type in AUTH_FRAMES:
|
|
233
|
+
self.handle_auth_frame(frame_type, message)
|
|
234
|
+
elif frame_type == FrameType.E_OPACK and self.chacha:
|
|
235
|
+
_LOGGER.debug("Received: %s", message)
|
|
236
|
+
self._handle_message(message)
|
|
237
|
+
else:
|
|
238
|
+
_LOGGER.debug("Ignoring %s frame", frame_type)
|
|
239
|
+
|
|
240
|
+
# CompanionServerAuth hooks
|
|
241
|
+
|
|
242
|
+
def send_to_client(self, frame_type, data):
|
|
243
|
+
payload = opack.pack(data)
|
|
244
|
+
length = len(payload) + (16 if self.chacha else 0)
|
|
245
|
+
header = bytes([frame_type.value]) + length.to_bytes(3, "big")
|
|
246
|
+
if self.chacha:
|
|
247
|
+
payload = self.chacha.encrypt(payload, aad=header)
|
|
248
|
+
self.transport.write(header + payload)
|
|
249
|
+
|
|
250
|
+
def enable_encryption(self, output_key, input_key):
|
|
251
|
+
self.chacha = chacha20.Chacha20Cipher(output_key, input_key, nonce_length=12)
|
|
252
|
+
|
|
253
|
+
def _m1_setup(self, pairing_data):
|
|
254
|
+
pin = f"{secrets.randbelow(10000):04d}"
|
|
255
|
+
self.session, self.salt = new_server_session(self.keys, pin)
|
|
256
|
+
self.on_pin(pin)
|
|
257
|
+
super()._m1_setup(pairing_data)
|
|
258
|
+
|
|
259
|
+
def _m5_setup(self, pairing_data):
|
|
260
|
+
srp_key = binascii.unhexlify(self.session.key)
|
|
261
|
+
session_key = hkdf_expand("Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info", srp_key)
|
|
262
|
+
cipher = chacha20.Chacha20Cipher(session_key, session_key)
|
|
263
|
+
# Fails with InvalidTag unless the client knew the PIN.
|
|
264
|
+
tlv = read_tlv(cipher.decrypt(pairing_data[TlvValue.EncryptedData], nonce=b"PS-Msg05"))
|
|
265
|
+
client_id, client_ltpk = tlv[TlvValue.Identifier], tlv[TlvValue.PublicKey]
|
|
266
|
+
ios_x = hkdf_expand(
|
|
267
|
+
"Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info", srp_key
|
|
268
|
+
)
|
|
269
|
+
Ed25519PublicKey.from_public_bytes(client_ltpk).verify(
|
|
270
|
+
tlv[TlvValue.Signature], ios_x + client_id + client_ltpk
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
acc_x = hkdf_expand("Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info", srp_key)
|
|
274
|
+
info = {"name": self.device_name, "model": MODEL}
|
|
275
|
+
reply = write_tlv(
|
|
276
|
+
{
|
|
277
|
+
TlvValue.Identifier: self.unique_id,
|
|
278
|
+
TlvValue.PublicKey: self.keys.auth_pub,
|
|
279
|
+
TlvValue.Signature: self.keys.sign.sign(acc_x + self.unique_id + self.keys.auth_pub),
|
|
280
|
+
TlvValue.Name: opack.pack(info),
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
encrypted = cipher.encrypt(reply, nonce=b"PS-Msg06")
|
|
284
|
+
self.send_to_client(
|
|
285
|
+
FrameType.PS_Next,
|
|
286
|
+
{"_pd": write_tlv({TlvValue.SeqNo: b"\x06", TlvValue.EncryptedData: encrypted})},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
self.identity.clients[client_id.decode()] = client_ltpk.hex()
|
|
290
|
+
self.identity.save()
|
|
291
|
+
_LOGGER.info("Paired with %s", client_id.decode())
|
|
292
|
+
print("Paired. The device is remembered for next time.")
|
|
293
|
+
# iOS keeps using this connection, encrypted with keys from the pairing secret.
|
|
294
|
+
self.enable_encryption(
|
|
295
|
+
hkdf_expand("", "ServerEncrypt-main", srp_key),
|
|
296
|
+
hkdf_expand("", "ClientEncrypt-main", srp_key),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def _m1_verify(self, pairing_data):
|
|
300
|
+
self._client_verify_pub = pairing_data[TlvValue.PublicKey]
|
|
301
|
+
super()._m1_verify(pairing_data)
|
|
302
|
+
|
|
303
|
+
def _m3_verify(self, pairing_data):
|
|
304
|
+
# pyatv's server skips this check; without it any device could connect unpaired.
|
|
305
|
+
shared = self.keys.verify.exchange(X25519PublicKey.from_public_bytes(self._client_verify_pub))
|
|
306
|
+
session_key = hkdf_expand("Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info", shared)
|
|
307
|
+
server_pub = self.keys.verify_pub.public_bytes(
|
|
308
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
309
|
+
)
|
|
310
|
+
try:
|
|
311
|
+
cipher = chacha20.Chacha20Cipher(session_key, session_key)
|
|
312
|
+
tlv = read_tlv(cipher.decrypt(pairing_data[TlvValue.EncryptedData], nonce=b"PV-Msg03"))
|
|
313
|
+
client_id = tlv[TlvValue.Identifier]
|
|
314
|
+
ltpk = bytes.fromhex(self.identity.clients[client_id.decode()])
|
|
315
|
+
Ed25519PublicKey.from_public_bytes(ltpk).verify(
|
|
316
|
+
tlv[TlvValue.Signature], self._client_verify_pub + client_id + server_pub
|
|
317
|
+
)
|
|
318
|
+
except (KeyError, InvalidSignature, InvalidTag):
|
|
319
|
+
_LOGGER.info("Unpaired device, asking it to pair")
|
|
320
|
+
error = {TlvValue.SeqNo: b"\x04", TlvValue.Error: bytes([ErrorCode.Authentication])}
|
|
321
|
+
self.send_to_client(FrameType.PV_Next, {"_pd": write_tlv(error)})
|
|
322
|
+
return
|
|
323
|
+
_LOGGER.info("Verified %s", client_id.decode())
|
|
324
|
+
super()._m3_verify(pairing_data)
|
|
325
|
+
|
|
326
|
+
# Remote commands
|
|
327
|
+
|
|
328
|
+
def _handle_message(self, message):
|
|
329
|
+
ident, content = message.get("_i"), message.get("_c", {})
|
|
330
|
+
reply = {}
|
|
331
|
+
|
|
332
|
+
if ident == "_systemInfo":
|
|
333
|
+
port = self.transport.get_extra_info("sockname")[1]
|
|
334
|
+
reply = self.identity.system_info(self.device_name, port)
|
|
335
|
+
elif ident == "_hidC" and content.get("_hBtS") == 2: # 2 = button released
|
|
336
|
+
self._act(HID_ACTIONS.get(_enum(HidCommand, content.get("_hidC"))))
|
|
337
|
+
elif ident == "_mcc":
|
|
338
|
+
command = _enum(MediaControlCommand, content.get("_mcc"))
|
|
339
|
+
if command == MediaControlCommand.GetVolume:
|
|
340
|
+
reply = {"_vol": self._volume}
|
|
341
|
+
elif command == MediaControlCommand.SetVolume:
|
|
342
|
+
# ponytail: absolute volume becomes one key step up/down; reading and setting
|
|
343
|
+
# the real Windows volume needs the COM audio API (pycaw) if this feels off.
|
|
344
|
+
new = content.get("_vol", self._volume)
|
|
345
|
+
if new != self._volume:
|
|
346
|
+
self._act("volume_up" if new > self._volume else "volume_down")
|
|
347
|
+
self._volume = new
|
|
348
|
+
else:
|
|
349
|
+
self._act(MCC_ACTIONS.get(command))
|
|
350
|
+
elif ident == "_hidT":
|
|
351
|
+
self._touch(content)
|
|
352
|
+
elif ident == "_touchStart":
|
|
353
|
+
self._touchpad = (content.get("_width", 1000.0), content.get("_height", 1000.0))
|
|
354
|
+
reply = {"_i": 1} # touch session id; the remote gives up without one
|
|
355
|
+
elif ident == "FetchMediaControlStatus":
|
|
356
|
+
reply = {"MediaControlFlags": int(MEDIA_FLAGS)}
|
|
357
|
+
elif ident == "_sessionStart":
|
|
358
|
+
reply = {"_sid": secrets.randbits(32)}
|
|
359
|
+
elif ident == "TVRCSessionStart":
|
|
360
|
+
reply = content
|
|
361
|
+
elif ident == "FetchAttentionState":
|
|
362
|
+
reply = {"state": 3} # awake
|
|
363
|
+
elif ident == "_interest" and "_iMC" in content.get("_regEvents", []):
|
|
364
|
+
self._send("_iMC", message.get("_x", 0), 1, {"_mcF": int(MEDIA_FLAGS)})
|
|
365
|
+
|
|
366
|
+
# Answer everything, even events and requests we don't know: tvremoted ends the
|
|
367
|
+
# session on "No request handler" errors, and extra acks are ignored.
|
|
368
|
+
self._reply(message, _c=reply)
|
|
369
|
+
|
|
370
|
+
def _touch(self, content):
|
|
371
|
+
"""Swipe left/right: previous/next track. Swipe up/down: volume, longer = more."""
|
|
372
|
+
phase, point = content.get("_tPh"), (content.get("_cx", 0), content.get("_cy", 0))
|
|
373
|
+
if phase == TouchAction.Press.value:
|
|
374
|
+
self._touch_start = point
|
|
375
|
+
elif phase == TouchAction.Release.value and self._touch_start:
|
|
376
|
+
dx, dy = point[0] - self._touch_start[0], point[1] - self._touch_start[1]
|
|
377
|
+
self._touch_start = None
|
|
378
|
+
width, height = self._touchpad
|
|
379
|
+
if abs(dx) >= abs(dy) and abs(dx) > width * SWIPE_FRACTION:
|
|
380
|
+
self._act("next" if dx > 0 else "previous")
|
|
381
|
+
elif abs(dy) > abs(dx) and abs(dy) > height * SWIPE_FRACTION:
|
|
382
|
+
steps = max(1, int(abs(dy) / (height * VOLUME_STEP_FRACTION)))
|
|
383
|
+
for _ in range(steps):
|
|
384
|
+
self._act("volume_down" if dy > 0 else "volume_up") # y grows downwards
|
|
385
|
+
|
|
386
|
+
def _act(self, action):
|
|
387
|
+
if action:
|
|
388
|
+
_LOGGER.info("Action: %s", action)
|
|
389
|
+
self.on_action(action)
|
|
390
|
+
|
|
391
|
+
def _send(self, ident, xid, msg_type, content):
|
|
392
|
+
self._write({"_i": ident, "_x": xid, "_t": msg_type, "_c": content})
|
|
393
|
+
|
|
394
|
+
def _reply(self, request, **fields):
|
|
395
|
+
# Responses carry no "_i"; the client matches them by "_x".
|
|
396
|
+
self._write({"_x": request.get("_x", 0), "_t": RESPONSE, **fields})
|
|
397
|
+
|
|
398
|
+
def _write(self, message):
|
|
399
|
+
_LOGGER.debug("Sending: %s", message)
|
|
400
|
+
self.send_to_client(FrameType.E_OPACK, message)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _enum(enum_type, value):
|
|
404
|
+
try:
|
|
405
|
+
return enum_type(value)
|
|
406
|
+
except ValueError:
|
|
407
|
+
return None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: windows-apple-remote
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Control your PC's media with the iOS Apple TV Remote app (powered by pyatv).
|
|
5
|
+
Project-URL: Homepage, https://github.com/aryakvn/windows-apple-remote
|
|
6
|
+
Project-URL: Issues, https://github.com/aryakvn/windows-apple-remote/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/aryakvn/windows-apple-remote/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Arya Kavian <aryakvn@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: apple tv,companion,media keys,pyatv,remote
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: cryptography>=42
|
|
18
|
+
Requires-Dist: pyatv<0.19,>=0.18
|
|
19
|
+
Requires-Dist: zeroconf>=0.131
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# windows-apple-remote
|
|
26
|
+
|
|
27
|
+
[](https://github.com/aryakvn/windows-apple-remote/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/windows-apple-remote/)
|
|
29
|
+
|
|
30
|
+
Package: [`windows-apple-remote`](https://pypi.org/project/windows-apple-remote/) · Source:
|
|
31
|
+
[github.com/aryakvn/windows-apple-remote](https://github.com/aryakvn/windows-apple-remote)
|
|
32
|
+
|
|
33
|
+
Control your Windows PC's media with the **Apple TV Remote** on your iPhone or iPad
|
|
34
|
+
(Control Center → Apple TV Remote). The PC advertises itself as an Apple TV over the
|
|
35
|
+
Companion protocol using [pyatv](https://pyatv.dev), and turns remote buttons into
|
|
36
|
+
media keys.
|
|
37
|
+
|
|
38
|
+
| Remote (touch area) | PC |
|
|
39
|
+
|---------------------------------------|--------------------------------------|
|
|
40
|
+
| Tap | Play/Pause |
|
|
41
|
+
| Swipe right / left | Next / Previous track |
|
|
42
|
+
| Swipe up / down (longer = more) | Volume Up / Down |
|
|
43
|
+
| iPhone volume buttons | Volume Up / Down |
|
|
44
|
+
| Back (Menu) | Escape |
|
|
45
|
+
|
|
46
|
+
## Install & run
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pip install windows-apple-remote
|
|
50
|
+
atv-remote
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or the latest code from GitHub:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
pip install git+https://github.com/aryakvn/windows-apple-remote.git
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then on the iPhone (same Wi-Fi network): Control Center → Apple TV Remote → pick your
|
|
60
|
+
PC's name. The first time, a 4-digit PIN is printed in the terminal; type it on the
|
|
61
|
+
phone. The phone is remembered afterwards.
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
atv-remote --name "Living Room PC" # name shown on the phone (default: hostname)
|
|
67
|
+
atv-remote --address 192.168.1.20 # IP to advertise if auto-detect picks the wrong adapter
|
|
68
|
+
atv-remote --port 49200 # fixed TCP port (useful for firewall rules)
|
|
69
|
+
atv-remote -v # debug logging
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Notes
|
|
73
|
+
|
|
74
|
+
- **Windows Firewall:** allow Python on private networks when prompted, or the phone
|
|
75
|
+
can't connect. mDNS (UDP 5353) must be allowed too.
|
|
76
|
+
- **Forget all paired devices:** delete `%APPDATA%\atv-remote\state.json`. It also
|
|
77
|
+
holds this PC's private identity key, so keep it private.
|
|
78
|
+
- Media and volume keys are global; Back (Escape) goes to the focused window.
|
|
79
|
+
- Windows has a single Play/Pause key, so the remote's separate Play and Pause both toggle.
|
|
80
|
+
- Now-playing info and the volume slider are not supported (they need AirPlay/MRP).
|
|
81
|
+
- macOS support is planned. HomeKit is not supported.
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
git clone https://github.com/aryakvn/windows-apple-remote.git
|
|
87
|
+
cd windows-apple-remote
|
|
88
|
+
python -m venv .venv
|
|
89
|
+
.venv\Scripts\activate
|
|
90
|
+
pip install -e ".[test]"
|
|
91
|
+
pytest
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Use `pip install -e` (editable). A plain `pip install .` copies the code into the venv
|
|
95
|
+
and later edits stop taking effect. Stop a running `atv-remote` before reinstalling;
|
|
96
|
+
Windows locks its executable.
|
|
97
|
+
|
|
98
|
+
The tests use pyatv's own client to pair with the server and press buttons end to end.
|
|
99
|
+
Bugs and ideas: [issues](https://github.com/aryakvn/windows-apple-remote/issues).
|
|
100
|
+
|
|
101
|
+
## Releasing to PyPI
|
|
102
|
+
|
|
103
|
+
Publishing uses [trusted publishing](https://docs.pypi.org/trusted-publishers/), so no
|
|
104
|
+
API token is stored in GitHub. One-time setup:
|
|
105
|
+
|
|
106
|
+
1. On [pypi.org](https://pypi.org/manage/account/publishing/) add a *pending publisher*:
|
|
107
|
+
project `windows-apple-remote`, owner `aryakvn`, repository `windows-apple-remote`,
|
|
108
|
+
workflow `publish.yml`, environment `pypi`.
|
|
109
|
+
2. Optional dry run: do the same on [test.pypi.org](https://test.pypi.org/manage/account/publishing/)
|
|
110
|
+
with environment `testpypi`.
|
|
111
|
+
|
|
112
|
+
Each release:
|
|
113
|
+
|
|
114
|
+
1. Bump `__version__` in `src/atv_remote/__init__.py` and move the `Unreleased`
|
|
115
|
+
entries in `CHANGELOG.md` under the new version.
|
|
116
|
+
2. Optional: run the **Publish** workflow by hand (Actions tab) to upload to TestPyPI.
|
|
117
|
+
3. Publish a GitHub release tagged `vX.Y.Z` (matching `__version__`). The workflow runs
|
|
118
|
+
the tests, checks the tag against the version, builds, and uploads to PyPI.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
atv_remote/__init__.py,sha256=ygQFQfVXBOOoBmvbH2DFl-8By-O94P7-htzaUMDx4Go,95
|
|
2
|
+
atv_remote/__main__.py,sha256=5SbiqB0hcbnY14mPChm5GE56Ue-igFdA4P6c4nJBDlI,40
|
|
3
|
+
atv_remote/cli.py,sha256=NWwJyGfLRgMk1-Ly9mk8z1vgpUtY3p3Zf7Bp2ksH01Y,2962
|
|
4
|
+
atv_remote/keys.py,sha256=j7oPRU0QTJKXz7R8ldnSgsdyQ5F3sQdzGQfEsrSAMuc,680
|
|
5
|
+
atv_remote/server.py,sha256=pNq6dcKyNpsGens_Uvd3LydsfyCL2dSZQOHVeJ23D_U,17151
|
|
6
|
+
windows_apple_remote-0.1.1.dist-info/METADATA,sha256=4SzwiXewhkPkAjVkCdn3szZIw_YaAiHFZFMNVZvXum8,5115
|
|
7
|
+
windows_apple_remote-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
windows_apple_remote-0.1.1.dist-info/entry_points.txt,sha256=S_aPz633ys75NJI53_B7uLfZOGfMgNr-IngNVcY3Ub4,51
|
|
9
|
+
windows_apple_remote-0.1.1.dist-info/licenses/LICENSE,sha256=xwXpOXqqALtmBkr8UnzywZI7EhtG81A_PIDT9wb3TkY,1068
|
|
10
|
+
windows_apple_remote-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arya Kavian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|