homebridge-agent-atv 0.1.1 → 0.1.2
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.
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-agent-atv",
|
|
3
3
|
"displayName": "Agent ATV",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"description": "Control an Apple TV from AI agents: an authenticated HTTP API (pyatv bridge), keyboard input, wait conditions, optional screen reading (focused element, screenshots), and a minimal HomeKit TV accessory.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "AlexDemzz",
|
|
@@ -17,7 +17,7 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
|
17
17
|
|
|
18
18
|
from agent_atv_bridge.errors import BridgeError
|
|
19
19
|
|
|
20
|
-
from . import image
|
|
20
|
+
from . import image, inbound_queue
|
|
21
21
|
|
|
22
22
|
_LOGGER = logging.getLogger("agent_atv_screen.pmd3")
|
|
23
23
|
|
|
@@ -245,6 +245,7 @@ class Pmd3Backend:
|
|
|
245
245
|
self._ax_lock = asyncio.Lock()
|
|
246
246
|
reader = self._pmd3.dtx_reader
|
|
247
247
|
reader.MAX_BUFFERED_SIZE = max(reader.MAX_BUFFERED_SIZE, DTX_MAX_BUFFERED)
|
|
248
|
+
inbound_queue.install(self._pmd3.userspace_tunnel)
|
|
248
249
|
# The stock no-root provider always starts with usbmux, which an Apple TV (Wi-Fi only) never reaches.
|
|
249
250
|
self._pmd3.userspace_tunnel._create_no_root_tunnel_provider = self._provider
|
|
250
251
|
self._pmd3.tunnel_service.input = self._read_pin
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Queues the userspace tunnel's inbound packets instead of dropping them (DESIGN.md §6.1 Tunnel).
|
|
2
|
+
|
|
3
|
+
Without root, Linux caps the tunnel's socketpair buffer far below a screenshot burst, and
|
|
4
|
+
UserspaceTun.write drops whatever does not fit, which stalls the capture on retransmissions. This
|
|
5
|
+
replaces write() with one that queues what does not fit and sends it, in order, once the socket is
|
|
6
|
+
writable again.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import collections
|
|
13
|
+
import errno
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any, Deque, Optional
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger("agent_atv_screen.pmd3")
|
|
18
|
+
|
|
19
|
+
# Bounds the memory a stalled stack can pin; past it TCP recovers the drops.
|
|
20
|
+
MAX_QUEUED = 512
|
|
21
|
+
_QUEUE = "_agent_atv_queue"
|
|
22
|
+
_DROPPING = "_agent_atv_dropping"
|
|
23
|
+
_FULL = (errno.EAGAIN, errno.EWOULDBLOCK, errno.ENOBUFS)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def install(userspace_tunnel: Any) -> None:
|
|
27
|
+
tun_class = userspace_tunnel.UserspaceTun
|
|
28
|
+
if getattr(tun_class.write, "queues_inbound", False):
|
|
29
|
+
return
|
|
30
|
+
loopback, ethernet = userspace_tunnel.LOOPBACK_HEADER, userspace_tunnel._ETH_HDR_TO_STACK
|
|
31
|
+
stock_close = tun_class.close
|
|
32
|
+
|
|
33
|
+
def write(self: Any, data: bytes) -> None:
|
|
34
|
+
ipv6 = data[len(loopback) :] if data[: len(loopback)] == loopback else data
|
|
35
|
+
_send(self, ethernet + ipv6)
|
|
36
|
+
|
|
37
|
+
async def close(self: Any) -> None:
|
|
38
|
+
# The socketpair closes under the writer otherwise, and its descriptor gets reused.
|
|
39
|
+
# The stock close() sets _closed before it awaits, so no write() can queue again.
|
|
40
|
+
_discard(self)
|
|
41
|
+
await stock_close(self)
|
|
42
|
+
|
|
43
|
+
write.queues_inbound = True # type: ignore[attr-defined]
|
|
44
|
+
tun_class.write = write
|
|
45
|
+
tun_class.close = close
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _send(tun: Any, frame: bytes) -> None:
|
|
49
|
+
queue: Optional[Deque[bytes]] = getattr(tun, _QUEUE, None)
|
|
50
|
+
if queue:
|
|
51
|
+
if len(queue) < MAX_QUEUED:
|
|
52
|
+
queue.append(frame)
|
|
53
|
+
elif not getattr(tun, _DROPPING, False):
|
|
54
|
+
setattr(tun, _DROPPING, True)
|
|
55
|
+
_LOGGER.debug("The tunnel's inbound queue is full (%d packets); dropping until it drains", MAX_QUEUED)
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
tun._peer.send(frame)
|
|
59
|
+
return
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
if exc.errno not in _FULL:
|
|
62
|
+
_failed(tun, exc)
|
|
63
|
+
return
|
|
64
|
+
if tun._closed:
|
|
65
|
+
return
|
|
66
|
+
setattr(tun, _QUEUE, collections.deque((frame,)))
|
|
67
|
+
asyncio.get_running_loop().add_writer(tun._peer.fileno(), _flush, tun)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _flush(tun: Any) -> None:
|
|
71
|
+
queue: Deque[bytes] = getattr(tun, _QUEUE)
|
|
72
|
+
while queue:
|
|
73
|
+
try:
|
|
74
|
+
tun._peer.send(queue[0])
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
if exc.errno in _FULL:
|
|
77
|
+
return
|
|
78
|
+
_failed(tun, exc)
|
|
79
|
+
queue.popleft()
|
|
80
|
+
_discard(tun)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _failed(tun: Any, exc: OSError) -> None:
|
|
84
|
+
# Like the stock write(): any other error (e.g. EMSGSIZE) drops the packet, logged once per tunnel.
|
|
85
|
+
if not tun._send_error_logged:
|
|
86
|
+
tun._send_error_logged = True
|
|
87
|
+
_LOGGER.debug("The userspace tunnel dropped a packet from the Apple TV: %s", exc)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _discard(tun: Any) -> None:
|
|
91
|
+
if getattr(tun, _QUEUE, None) is None:
|
|
92
|
+
return
|
|
93
|
+
setattr(tun, _QUEUE, None)
|
|
94
|
+
setattr(tun, _DROPPING, False)
|
|
95
|
+
if tun._peer.fileno() >= 0:
|
|
96
|
+
asyncio.get_running_loop().remove_writer(tun._peer.fileno())
|