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.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())