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 ADDED
@@ -0,0 +1,3 @@
1
+ """Control a PC's media with the iOS Apple TV Remote app, via pyatv."""
2
+
3
+ __version__ = "0.1.1"
atv_remote/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from atv_remote.cli import main
2
+
3
+ main()
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
+ [![CI](https://github.com/aryakvn/windows-apple-remote/actions/workflows/ci.yml/badge.svg)](https://github.com/aryakvn/windows-apple-remote/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/windows-apple-remote)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ atv-remote = atv_remote.cli:main
@@ -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.