Skip to content

Commit 54bf3b9

Browse files
codeskyblueCopilot
andauthored
add pipe_duplex for socket <-> websocket (#65)
* add pipe_duplex for socket <-> websocket * add RWSocketDuplex * add new api: /ws/android/scrcpy3/ * Update uiautodev/remote/pipe.py Co-authored-by: Copilot <[email protected]> * Apply suggestion from @Copilot Co-authored-by: Copilot <[email protected]> * Apply suggestion from @Copilot Co-authored-by: Copilot <[email protected]> * remove useless code --------- Co-authored-by: Copilot <[email protected]>
1 parent 2a8668f commit 54bf3b9

File tree

6 files changed

+238
-3
lines changed

6 files changed

+238
-3
lines changed

uiautodev/app.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,27 @@ def mock_auth_me():
148148
# 401 {"detail":"Authentication required"}
149149
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
150150

151+
@app.websocket('/ws/android/scrcpy3/{serial}')
152+
async def handle_android_scrcpy3_ws(websocket: WebSocket, serial: str):
153+
await websocket.accept()
154+
try:
155+
logger.info(f"WebSocket serial: {serial}")
156+
device = adbutils.device(serial)
157+
from uiautodev.remote.scrcpy3 import ScrcpyServer3
158+
scrcpy = ScrcpyServer3(device)
159+
try:
160+
await scrcpy.stream_to_websocket(websocket)
161+
finally:
162+
scrcpy.close()
163+
except WebSocketDisconnect:
164+
logger.info(f"WebSocket disconnected by client.")
165+
except Exception as e:
166+
logger.exception(f"WebSocket error for serial={serial}: {e}")
167+
reason = str(e).replace("\n", " ")
168+
await websocket.close(code=1000, reason=reason)
169+
finally:
170+
logger.info(f"WebSocket closed for serial={serial}")
171+
151172
@app.websocket("/ws/android/scrcpy/{serial}")
152173
async def handle_android_ws(websocket: WebSocket, serial: str):
153174
"""

uiautodev/command_proxy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def tap(driver: BaseDriver, params: TapRequest):
6868
wsize = driver.window_size()
6969
x = int(wsize[0] * params.x)
7070
y = int(wsize[1] * params.y)
71-
driver.tap(x, y)
71+
driver.tap(int(x), int(y))
7272

7373

7474
@register(Command.APP_INSTALL)

uiautodev/remote/pipe.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import asyncio
2+
import socket
3+
from typing import Optional, Protocol
4+
from starlette.websockets import WebSocket, WebSocketDisconnect
5+
6+
7+
class AsyncDuplex(Protocol):
8+
async def read(self, n: int = -1) -> bytes: ...
9+
async def write(self, data: bytes): ...
10+
async def close(self): ...
11+
12+
13+
async def pipe_duplex(a: AsyncDuplex, b: AsyncDuplex, label_a="A", label_b="B"):
14+
"""双向管道:a <-> b"""
15+
task_ab = asyncio.create_task(_pipe_oneway(a, b, f"{label_a}->{label_b}"))
16+
task_ba = asyncio.create_task(_pipe_oneway(b, a, f"{label_b}->{label_a}"))
17+
done, pending = await asyncio.wait(
18+
[task_ab, task_ba],
19+
return_when=asyncio.FIRST_COMPLETED,
20+
)
21+
for t in pending:
22+
t.cancel()
23+
if pending:
24+
await asyncio.gather(*pending, return_exceptions=True)
25+
26+
27+
async def _pipe_oneway(src: AsyncDuplex, dst: AsyncDuplex, name: str):
28+
try:
29+
while True:
30+
data = await src.read(4096)
31+
if not data:
32+
break
33+
await dst.write(data)
34+
except asyncio.CancelledError:
35+
pass
36+
except Exception as e:
37+
print(f"[{name}] error:", e)
38+
finally:
39+
await dst.close()
40+
41+
class RWSocketDuplex:
42+
def __init__(self, rsock: socket.socket, wsock: socket.socket, loop=None):
43+
self.rsock = rsock
44+
self.wsock = wsock
45+
self._same = rsock is wsock
46+
self.loop = loop or asyncio.get_running_loop()
47+
self._closed = False
48+
49+
self.rsock.setblocking(False)
50+
if not self._same:
51+
self.wsock.setblocking(False)
52+
53+
async def read(self, n: int = 4096) -> bytes:
54+
if self._closed:
55+
return b''
56+
try:
57+
data = await self.loop.sock_recv(self.rsock, n)
58+
if not data:
59+
await self.close()
60+
return b''
61+
return data
62+
except (ConnectionResetError, OSError):
63+
await self.close()
64+
return b''
65+
66+
async def write(self, data: bytes):
67+
if not data or self._closed:
68+
return
69+
try:
70+
await self.loop.sock_sendall(self.wsock, data)
71+
except (ConnectionResetError, OSError):
72+
await self.close()
73+
74+
async def close(self):
75+
if self._closed:
76+
return
77+
self._closed = True
78+
try:
79+
self.rsock.close()
80+
except Exception:
81+
pass
82+
if not self._same:
83+
try:
84+
self.wsock.close()
85+
except Exception:
86+
pass
87+
88+
def is_closed(self):
89+
return self._closed
90+
91+
class SocketDuplex(RWSocketDuplex):
92+
"""封装 socket.socket 为 AsyncDuplex 接口"""
93+
def __init__(self, sock: socket.socket, loop: Optional[asyncio.AbstractEventLoop] = None):
94+
super().__init__(sock, sock, loop)
95+
96+
97+
class WebSocketDuplex:
98+
"""将 starlette.websockets.WebSocket 封装为 AsyncDuplex"""
99+
def __init__(self, ws: WebSocket):
100+
self.ws = ws
101+
self._closed = False
102+
103+
async def read(self, n: int = -1) -> bytes:
104+
"""读取二进制消息,如果是文本则自动转 bytes"""
105+
if self._closed:
106+
return b''
107+
try:
108+
msg = await self.ws.receive()
109+
except WebSocketDisconnect:
110+
self._closed = True
111+
return b''
112+
except Exception:
113+
self._closed = True
114+
return b''
115+
116+
if msg["type"] == "websocket.disconnect":
117+
self._closed = True
118+
return b''
119+
elif msg["type"] == "websocket.receive":
120+
data = msg.get("bytes")
121+
if data is not None:
122+
return data
123+
text = msg.get("text")
124+
return text.encode("utf-8") if text else b''
125+
return b''
126+
127+
async def write(self, data: bytes):
128+
if self._closed:
129+
return
130+
try:
131+
await self.ws.send_bytes(data)
132+
except Exception:
133+
self._closed = True
134+
135+
async def close(self):
136+
if not self._closed:
137+
self._closed = True
138+
try:
139+
await self.ws.close()
140+
except Exception:
141+
pass

uiautodev/remote/scrcpy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def _setup_connection(self):
5252
self._video_conn = self._connect_scrcpy(self.device)
5353
self._control_conn = self._connect_scrcpy(self.device)
5454
self._parse_scrcpy_info(self._video_conn)
55+
5556
self.controller = ScrcpyTouchController(self._control_conn)
5657

5758
@retry.retry(exceptions=AdbError, tries=20, delay=0.1)

uiautodev/remote/scrcpy3.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import logging
2+
from pathlib import Path
3+
import socket
4+
from adbutils import AdbConnection, AdbDevice, AdbError, Network
5+
from fastapi import WebSocket
6+
from retry import retry
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class ScrcpyServer3:
12+
VERSION = "3.3.3"
13+
14+
def __init__(self, device: AdbDevice):
15+
self._device = device
16+
self._shell_conn: AdbConnection
17+
self._video_sock: socket.socket
18+
self._control_sock: socket.socket
19+
20+
self._shell_conn = self._start_scrcpy3()
21+
self._video_sock = self._connect_scrcpy(dummy_byte=True)
22+
self._control_sock = self._connect_scrcpy()
23+
24+
def _start_scrcpy3(self):
25+
device = self._device
26+
jar_path = Path(__file__).parent.joinpath(f'../binaries/scrcpy-server-v{self.VERSION}.jar')
27+
device.sync.push(jar_path, '/data/local/tmp/scrcpy_server.jar', check=True)
28+
logger.info(f'{jar_path.name} pushed to device')
29+
30+
# 构建启动 scrcpy 服务器的命令
31+
cmds = [
32+
'CLASSPATH=/data/local/tmp/scrcpy_server.jar',
33+
'app_process', '/',
34+
f'com.genymobile.scrcpy.Server', self.VERSION,
35+
'log_level=info', 'max_size=1024', 'max_fps=30',
36+
'video_bit_rate=8000000', 'tunnel_forward=true',
37+
'send_frame_meta=true',
38+
f'control=true',
39+
'audio=false', 'show_touches=false', 'stay_awake=false',
40+
'power_off_on_close=false', 'clipboard_autosync=false'
41+
]
42+
conn = device.shell(cmds, stream=True)
43+
logger.debug("scrcpy output: %s", conn.conn.recv(100))
44+
return conn
45+
46+
@retry(exceptions=AdbError, tries=20, delay=0.1)
47+
def _connect_scrcpy(self, dummy_byte: bool = False) -> socket.socket:
48+
sock = self._device.create_connection(Network.LOCAL_ABSTRACT, 'scrcpy')
49+
if dummy_byte:
50+
received = sock.recv(1)
51+
if not received or received != b"\x00":
52+
raise ConnectionError("Did not receive Dummy Byte!")
53+
logger.debug('Received Dummy Byte!')
54+
return sock
55+
56+
def stream_to_websocket(self, ws: WebSocket):
57+
from .pipe import RWSocketDuplex, WebSocketDuplex, AsyncDuplex, pipe_duplex
58+
socket_duplex = RWSocketDuplex(self._video_sock, self._control_sock)
59+
websocket_duplex = WebSocketDuplex(ws)
60+
return pipe_duplex(socket_duplex, websocket_duplex)
61+
62+
def close(self):
63+
self._safe_close_sock(self._control_sock)
64+
self._safe_close_sock(self._video_sock)
65+
self._shell_conn.close()
66+
67+
def _safe_close_sock(self, sock: socket.socket):
68+
try:
69+
sock.close()
70+
except:
71+
pass
72+

uiautodev/router/android.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# prefix for /api/android/{serial}/shell
22

33
import logging
4-
from typing import Dict, Optional
4+
from typing import Dict, Optional, Union
55

66
from fastapi import APIRouter, Request, Response
77
from pydantic import BaseModel
@@ -18,7 +18,7 @@ class AndroidShellPayload(BaseModel):
1818
command: str
1919

2020
@router.post("/{serial}/shell")
21-
def shell(serial: str, payload: AndroidShellPayload) -> ShellResponse:
21+
def shell(serial: str, payload: AndroidShellPayload):
2222
"""Run a shell command on an Android device"""
2323
try:
2424
driver = ADBAndroidDriver(serial)

0 commit comments

Comments
 (0)