314 lines
10 KiB
Python
314 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import functools
|
|
import socket
|
|
import threading
|
|
import time
|
|
from functools import wraps
|
|
from typing import Any, Callable, Optional
|
|
|
|
import numpy as np
|
|
from adbutils import AdbConnection, Network
|
|
|
|
from mower.utils import config
|
|
from mower.utils import typealias as tp
|
|
from mower.utils.csleep import MowerExit
|
|
from mower.utils.device.exception import ADBError, SimulatorError
|
|
from mower.utils.device.method.adb import ADB
|
|
from mower.utils.device.method.adb.const import KeyCode
|
|
from mower.utils.log import logger
|
|
from mower.utils.path import get_path
|
|
from mower.utils.vector import sm, va
|
|
|
|
from . import const
|
|
from .control import ControlSender
|
|
|
|
SCR_PATH = "/data/local/tmp/scrcpy-server.jar"
|
|
SCR_VER = "3.0"
|
|
|
|
|
|
swipe_update_result = None
|
|
|
|
|
|
def recog_start(func: Callable[[tp.Image], Any] = lambda _: None):
|
|
time.sleep(0.1)
|
|
config.recog.update()
|
|
global swipe_update_result
|
|
swipe_update_result = func(config.recog.img)
|
|
|
|
|
|
def retry_scrcpy(func):
|
|
@wraps(func)
|
|
def retry_wrapper(self, *args, **kwargs):
|
|
for try_count in range(2):
|
|
try:
|
|
return func(self, *args, **kwargs)
|
|
except MowerExit:
|
|
raise
|
|
except (
|
|
ConnectionResetError,
|
|
BrokenPipeError,
|
|
ConnectionAbortedError,
|
|
) as e:
|
|
logger.exception(e)
|
|
self.stop()
|
|
time.sleep(1)
|
|
self.start()
|
|
try_count += 1
|
|
# After scrcpy retries fail, escalate to adb error
|
|
raise ADBError("Scrcpy failed after 2 attempts.")
|
|
|
|
return retry_wrapper
|
|
|
|
|
|
class Client:
|
|
def __init__(
|
|
self,
|
|
adb: ADB,
|
|
max_width: int = 0,
|
|
bitrate: int = 8000000,
|
|
max_fps: int = 0,
|
|
flip: bool = False,
|
|
block_frame: bool = False,
|
|
stay_awake: bool = False,
|
|
lock_screen_orientation: int = const.LOCK_SCREEN_ORIENTATION_UNLOCKED,
|
|
displayid: Optional[int] = None,
|
|
connection_timeout: int = 3000,
|
|
):
|
|
"""
|
|
Create a scrcpy client, this client won't be started until you call the start function
|
|
Args:
|
|
client: ADB client
|
|
max_width: frame width that will be broadcast from android server
|
|
bitrate: bitrate
|
|
max_fps: maximum fps, 0 means not limited (supported after android 10)
|
|
flip: flip the video
|
|
block_frame: only return nonempty frames, may block cv2 render thread
|
|
stay_awake: keep Android device awake
|
|
lock_screen_orientation: lock screen orientation, LOCK_SCREEN_ORIENTATION_*
|
|
connection_timeout: timeout for connection, unit is ms
|
|
"""
|
|
|
|
# User accessible
|
|
self.adb = adb
|
|
self.last_frame: Optional[np.ndarray] = None
|
|
self.resolution = (1920, 1080)
|
|
self.device_name: Optional[str] = None
|
|
self.control = ControlSender(self)
|
|
|
|
# Params
|
|
self.flip = flip
|
|
self.max_width = max_width
|
|
self.bitrate = bitrate
|
|
self.max_fps = max_fps
|
|
self.block_frame = block_frame
|
|
self.stay_awake = stay_awake
|
|
self.lock_screen_orientation = lock_screen_orientation
|
|
self.connection_timeout = connection_timeout
|
|
self.displayid = displayid
|
|
|
|
# Need to destroy
|
|
self.__server_stream: Optional[AdbConnection] = None
|
|
self.control_socket: Optional[socket.socket] = None
|
|
self.control_socket_lock = threading.Lock()
|
|
|
|
self.start()
|
|
|
|
def __del__(self) -> None:
|
|
self.stop()
|
|
|
|
def __start_server(self) -> None:
|
|
"""
|
|
Start server and get the connection
|
|
"""
|
|
cmdline = f"CLASSPATH={SCR_PATH} app_process / com.genymobile.scrcpy.Server {SCR_VER} video=false audio=false control=true tunnel_forward=true"
|
|
if self.displayid is not None:
|
|
cmdline += f" display_id={self.displayid}"
|
|
self.__server_stream: AdbConnection = self.adb.adb_shell(cmdline, True)
|
|
# Wait for server to start
|
|
self.__server_stream.conn.settimeout(3)
|
|
logger.info("Create server stream")
|
|
response = self.__server_stream.read(10)
|
|
logger.debug(response)
|
|
if b"[server]" not in response:
|
|
raise ConnectionError(
|
|
"Failed to start scrcpy-server: " + response.decode("utf-8", "ignore")
|
|
)
|
|
|
|
def __deploy_server(self) -> None:
|
|
"""
|
|
Deploy server to android device
|
|
"""
|
|
server_file_path = get_path(f"@install/mower/vendor/scrcpy-server-v{SCR_VER}")
|
|
self.adb.adb_push(str(server_file_path), SCR_PATH)
|
|
self.__start_server()
|
|
|
|
def __init_server_connection(self) -> None:
|
|
try:
|
|
self.control_socket = self.adb.adb.create_connection(
|
|
Network.LOCAL_ABSTRACT, "scrcpy"
|
|
)
|
|
self.control_socket.settimeout(3)
|
|
except socket.timeout:
|
|
raise ConnectionError("Failed to connect scrcpy-server")
|
|
|
|
dummy_byte = self.control_socket.recv(1)
|
|
if not len(dummy_byte) or dummy_byte != b"\x00":
|
|
raise ConnectionError("Did not receive Dummy Byte!")
|
|
|
|
self.device_name = self.control_socket.recv(64).decode("utf-8")
|
|
self.device_name = self.device_name.rstrip("\x00")
|
|
if not len(self.device_name):
|
|
raise ConnectionError("Did not receive Device Name!")
|
|
logger.info(f"scrcpy连接设备:{self.device_name}")
|
|
|
|
def start(self) -> None:
|
|
"""
|
|
Start listening video stream
|
|
"""
|
|
try_count = 0
|
|
while try_count < 3:
|
|
try:
|
|
self.__deploy_server()
|
|
time.sleep(0.5)
|
|
self.__init_server_connection()
|
|
break
|
|
except ConnectionError as e:
|
|
logger.exception(f"Failed to connect scrcpy-server: {e}")
|
|
self.stop()
|
|
logger.warning("Try again in 10 seconds...")
|
|
time.sleep(10)
|
|
try_count += 1
|
|
else:
|
|
raise RuntimeError("Failed to connect scrcpy-server.")
|
|
|
|
def stop(self) -> None:
|
|
"""
|
|
Stop listening (both threaded and blocked)
|
|
"""
|
|
if self.__server_stream is not None:
|
|
self.__server_stream.close()
|
|
self.__server_stream = None
|
|
if self.control_socket is not None:
|
|
self.control_socket.close()
|
|
self.control_socket = None
|
|
|
|
def stable(f):
|
|
@functools.wraps(f)
|
|
def inner(self: Client, *args, **kwargs):
|
|
try_count = 0
|
|
while try_count < 3:
|
|
try:
|
|
return f(self, *args, **kwargs)
|
|
except (
|
|
ConnectionResetError,
|
|
BrokenPipeError,
|
|
ConnectionAbortedError,
|
|
) as e:
|
|
logger.debug_exception(e)
|
|
self.stop()
|
|
time.sleep(1)
|
|
self.start()
|
|
try_count += 1
|
|
except SimulatorError as e:
|
|
logger.exception(e)
|
|
else:
|
|
raise ADBError("Failed to start scrcpy-server.")
|
|
|
|
return inner
|
|
|
|
@retry_scrcpy
|
|
def tap(self, x: int, y: int) -> None:
|
|
self.control.tap(x, y)
|
|
|
|
@retry_scrcpy
|
|
def back(self):
|
|
self.control.send_keyevent(KeyCode.KEYCODE_BACK)
|
|
|
|
@retry_scrcpy
|
|
def swipe(
|
|
self,
|
|
x0: int,
|
|
y0: int,
|
|
x1: int,
|
|
y1: int,
|
|
duration: float = 1,
|
|
fall: bool = True,
|
|
lift: bool = True,
|
|
update: bool = False,
|
|
interval: float = 0,
|
|
func: Callable[[tp.Image], Any] = lambda _: None,
|
|
):
|
|
"""滑动
|
|
|
|
Args:
|
|
x0 (int): 起点横坐标
|
|
y0 (int): 起点纵坐标
|
|
x1 (int): 终点横坐标
|
|
y1 (int): 终点纵坐标
|
|
duration (float, optional): 拖动时长. Defaults to 1.
|
|
fall (bool, optional): 按下. Defaults to True.
|
|
lift (bool, optional): 抬起. Defaults to True.
|
|
update (bool, optional): 滑动前截图. Defaults to False.
|
|
interval (float, optional): 拖动后的等待时间. Defaults to 0.
|
|
func (Callable[[tp.Image], Any], optional): 处理截图的函数. Defaults to lambda _: None.
|
|
"""
|
|
frame_time = 1 / 30
|
|
|
|
start_time = time.perf_counter()
|
|
fall and self.control.touch(x0, y0, const.ACTION_DOWN)
|
|
|
|
if update and config.recog:
|
|
thread = threading.Thread(target=recog_start, args=(func,))
|
|
thread.start()
|
|
|
|
if (down_time := time.perf_counter()) < start_time + frame_time:
|
|
time.sleep(start_time + frame_time - down_time)
|
|
down_time = start_time + frame_time
|
|
|
|
while (progress := (time.perf_counter() - down_time) / duration) <= 1:
|
|
x, y = va(sm(1 - progress, (x0, y0)), sm(progress, (x1, y1)))
|
|
start_time = time.perf_counter()
|
|
self.control.touch(x, y, const.ACTION_MOVE)
|
|
if (move_time := time.perf_counter() - start_time) < frame_time:
|
|
time.sleep(frame_time - move_time)
|
|
self.control.touch(x1, y1, const.ACTION_MOVE)
|
|
|
|
lift and self.control.touch(x1, y1, const.ACTION_UP)
|
|
|
|
if update and config.recog:
|
|
start_time = time.perf_counter()
|
|
thread.join()
|
|
if (stop_time := time.perf_counter()) < start_time + interval:
|
|
time.sleep(start_time + interval - stop_time)
|
|
return swipe_update_result
|
|
|
|
if interval > 0:
|
|
time.sleep(interval)
|
|
|
|
@retry_scrcpy
|
|
def swipe_ext(
|
|
self,
|
|
points: list[tp.Coordinate],
|
|
durations: list[int],
|
|
update: bool = False,
|
|
interval: float = 0,
|
|
func: Callable[[tp.Image], Any] = lambda _: None,
|
|
):
|
|
total = len(durations)
|
|
for idx, (S, E, D) in enumerate(zip(points[:-1], points[1:], durations)):
|
|
first = idx == 0
|
|
last = idx == total - 1
|
|
result = self.swipe(
|
|
x0=S[0],
|
|
y0=S[1],
|
|
x1=E[0],
|
|
y1=E[1],
|
|
duration=D / 1000,
|
|
fall=first,
|
|
lift=last,
|
|
update=last and update,
|
|
interval=interval if last else 0,
|
|
func=func,
|
|
)
|
|
return result
|