kotones-auto-assistant/kotonebot/client/implements/nemu_ipc/nemu_ipc.py

232 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import ctypes
import logging
from dataclasses import dataclass
from time import sleep
from typing_extensions import override
import cv2
import numpy as np
from cv2.typing import MatLike
from ...device import AndroidDevice, Device
from ...protocol import Touchable, Screenshotable
from ...registration import ImplConfig
from .external_renderer_ipc import ExternalRendererIpc
from kotonebot.errors import KotonebotError
logger = logging.getLogger(__name__)
class NemuIpcIncompatible(Exception):
"""MuMu12 版本过低或 dll 不兼容"""
pass
class NemuIpcError(KotonebotError):
"""调用 IPC 过程中发生错误"""
pass
@dataclass
class NemuIpcImplConfig(ImplConfig):
r"""nemu_ipc 能力的配置模型。
参数说明:
nemu_folder: MuMu12 根目录(如 F:\Apps\Netease\MuMuPlayer-12.0)。
instance_id: 模拟器实例 ID。
display_id: 目标显示器 ID默认为 0主显示器
"""
nemu_folder: str
instance_id: int
display_id: int = 0
class NemuIpcImpl(Touchable, Screenshotable):
"""
利用 MuMu12 提供的 external_renderer_ipc.dll 进行截图与触摸控制。
"""
def __init__(self, config: NemuIpcImplConfig):
self.config = config
self.__width: int = 0
self.__height: int = 0
self.__connected: bool = False
self._connect_id: int = 0
self.display_id: int = config.display_id
"""
显示器 ID。`0` 表示主显示器。
如果没有启用「后台保活」功能,一般为主显示器。
"""
self.nemu_folder = config.nemu_folder
# --------------------------- DLL 封装 ---------------------------
self._ipc = ExternalRendererIpc(config.nemu_folder)
logger.info("ExternalRendererIpc initialized and DLL loaded")
@property
def width(self) -> int:
"""
屏幕宽度。
若为 0表示未连接或未获取到分辨率。
"""
return self.__width
@property
def height(self) -> int:
"""
屏幕高度。
若为 0表示未连接或未获取到分辨率。
"""
return self.__height
@property
def connected(self) -> bool:
"""是否已连接。"""
return self.__connected
# ------------------------------------------------------------------
# 基础控制
# ------------------------------------------------------------------
def _ensure_connected(self) -> None:
if not self.__connected:
self.connect()
def connect(self) -> None:
"""连接模拟器。"""
if self.__connected:
return
connect_id = self._ipc.connect(self.nemu_folder, self.config.instance_id)
if connect_id == 0:
raise NemuIpcError("nemu_connect failed, please check if the emulator is running and the instance ID is correct.")
self._connect_id = connect_id
self.__connected = True
logger.debug("NemuIpc connected, connect_id=%d", connect_id)
def disconnect(self) -> None:
"""断开连接。"""
if not self.__connected:
return
self._ipc.disconnect(self._connect_id)
self.__connected = False
self._connect_id = 0
logger.debug("NemuIpc disconnected.")
# ------------------------------------------------------------------
# Screenshotable 接口实现
# ------------------------------------------------------------------
@property
def screen_size(self) -> tuple[int, int]:
"""获取屏幕分辨率。"""
if self.__width == 0 or self.__height == 0:
self._query_resolution()
if self.__width == 0 or self.__height == 0:
raise NemuIpcError("Screen resolution not obtained, please connect to the emulator first.")
return self.__width, self.__height
@override
def detect_orientation(self):
if self.__width > self.__height:
return "landscape"
if self.__height > self.__width:
return "portrait"
return None
@override
def screenshot(self) -> MatLike:
self._ensure_connected()
# 确保分辨率已知
_ = self.screen_size
length = self.__width * self.__height * 4 # RGBA
buf_type = ctypes.c_ubyte * length
buffer = buf_type()
w_ptr = ctypes.pointer(ctypes.c_int(self.__width))
h_ptr = ctypes.pointer(ctypes.c_int(self.__height))
ret = self._ipc.capture_display(
self._connect_id,
self.display_id,
length,
ctypes.cast(w_ptr, ctypes.c_void_p),
ctypes.cast(h_ptr, ctypes.c_void_p),
ctypes.cast(buffer, ctypes.c_void_p),
)
if ret != 0:
raise NemuIpcError(f"nemu_capture_display screenshot failed, error code={ret}")
# 读入并转换数据
img = np.ctypeslib.as_array(buffer).reshape((self.__height, self.__width, 4))
# RGBA -> BGR
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
cv2.flip(img, 0, dst=img)
return img
# --------------------------- 内部工具 -----------------------------
def _query_resolution(self) -> None:
"""调用 capture 接口并返回宽高信息,不取像素数据。"""
self._ensure_connected()
w_ptr = ctypes.pointer(ctypes.c_int(0))
h_ptr = ctypes.pointer(ctypes.c_int(0))
ret = self._ipc.capture_display(
self._connect_id,
self.display_id,
0,
ctypes.cast(w_ptr, ctypes.c_void_p),
ctypes.cast(h_ptr, ctypes.c_void_p),
ctypes.c_void_p(),
)
if ret != 0:
raise NemuIpcError(f"nemu_capture_display 查询分辨率失败,错误码={ret}")
self.__width = w_ptr.contents.value
self.__height = h_ptr.contents.value
logger.debug("Parsed resolution %dx%d", self.__width, self.__height)
# ------------------------------------------------------------------
# Touchable 接口实现
# ------------------------------------------------------------------
@override
def click(self, x: int, y: int) -> None:
self._ensure_connected()
self._ipc.input_touch_down(self._connect_id, self.display_id, x, y)
sleep(0.01)
self._ipc.input_touch_up(self._connect_id, self.display_id)
@override
def swipe(
self,
x1: int,
y1: int,
x2: int,
y2: int,
duration: float | None = None,
) -> None:
self._ensure_connected()
duration = duration or 0.3
steps = max(int(duration / 0.01), 2)
xs = np.linspace(x1, x2, steps, dtype=int)
ys = np.linspace(y1, y2, steps, dtype=int)
# 按下第一点
self._ipc.input_touch_down(self._connect_id, self.display_id, xs[0], ys[0])
sleep(0.01)
# 中间移动
for px, py in zip(xs[1:-1], ys[1:-1]):
self._ipc.input_touch_down(self._connect_id, self.display_id, px, py)
sleep(0.01)
# 最终抬起
self._ipc.input_touch_up(self._connect_id, self.display_id)
sleep(0.01)