refactor(core): 组装 Device 改用 recipe 方案

原来组装 Device 的代码放在每个 Impl 文件下实现,通过
@register_impl 装饰器注册组装函数,然后通过统一接口
组装。现在将所有组装代码移动到了 Host 实现下,Impl 实现
只需要实现自身。
This commit is contained in:
XcantloadX 2025-06-24 14:34:55 +08:00
parent 07186787b4
commit f01e0224cb
19 changed files with 271 additions and 301 deletions

View File

@ -76,6 +76,9 @@ kaa 的开发主要用到了以下开源项目:
](https://github.com/AllenHeartcore/GkmasObjectManager):用于提取游戏图像资源,以 GPLv3 协议开源。
* [gakumasu-diff](https://github.com/vertesan/gakumasu-diff):游戏数据。
kaa 的开发还参考了以下开源项目:
* [EmulatorExtras](https://github.com/MaaXYZ/EmulatorExtras)MuMu 与雷电模拟器的截图与控制接口定义。
* [blue_archive_auto_script](https://github.com/pur1fying/blue_archive_auto_script)MuMu 与雷电模拟器的截图与控制接口的 Python 实现,以及各模拟器的控制实现。
## 免责声明
**请在使用本项目前仔细阅读以下内容。使用本脚本将带来包括但不限于账号被封禁的风险。**

View File

@ -46,12 +46,10 @@ from kotonebot.backend.color import (
from kotonebot.backend.ocr import (
Ocr, OcrResult, OcrResultList, jp, en, StringMatchFunction
)
from kotonebot.client.registration import AdbBasedImpl, create_device
from kotonebot.config.manager import load_config, save_config
from kotonebot.config.base_config import UserConfig
from kotonebot.backend.core import Image, HintBox
from kotonebot.errors import KotonebotWarning
from kotonebot.client import DeviceImpl
from kotonebot.backend.preprocessor import PreprocessorProtocol
from kotonebot.primitives import Rect

View File

@ -1,11 +1,10 @@
from .device import Device
from .registration import create_device, DeviceImpl
from .registration import DeviceImpl
# 确保所有实现都被注册
from . import implements # noqa: F401
__all__ = [
'Device',
'create_device',
'DeviceImpl',
]

View File

@ -0,0 +1,91 @@
from abc import ABC
from typing import Literal, TypeVar
from typing_extensions import assert_never
from adbutils import adb
from adbutils._device import AdbDevice
from kotonebot import logging
from kotonebot.client.device import AndroidDevice
from .protocol import Instance, AdbHostConfig, Device
logger = logging.getLogger(__name__)
AdbRecipes = Literal['adb', 'adb_raw', 'uiautomator2']
def connect_adb(
ip: str,
port: int,
connect: bool = True,
disconnect: bool = True,
timeout: float = 180,
device_serial: str | None = None
) -> AdbDevice:
"""
创建 ADB 连接
"""
if disconnect:
logger.debug('adb disconnect %s:%d', ip, port)
adb.disconnect(f'{ip}:{port}')
if connect:
logger.debug('adb connect %s:%d', ip, port)
result = adb.connect(f'{ip}:{port}')
if 'cannot connect to' in result:
raise ValueError(result)
serial = device_serial or f'{ip}:{port}'
logger.debug('adb wait for %s', serial)
adb.wait_for(serial, timeout=timeout)
devices = adb.device_list()
logger.debug('adb device_list: %s', devices)
d = [d for d in devices if d.serial == serial]
if len(d) == 0:
raise ValueError(f"Device {serial} not found")
d = d[0]
return d
class CommonAdbCreateDeviceMixin(ABC):
"""
通用 ADB 创建设备的 Mixin
Mixin 定义了创建 ADB 设备的通用接口
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# 下面的属性只是为了让类型检查通过,无实际实现
self.adb_ip: str
self.adb_port: int
self.adb_name: str
def create_device(self, recipe: AdbRecipes, config: AdbHostConfig) -> Device:
"""
创建 ADB 设备
"""
connection = connect_adb(
self.adb_ip,
self.adb_port,
connect=True,
disconnect=True,
timeout=config.timeout,
device_serial=self.adb_name
)
d = AndroidDevice(connection)
match recipe:
case 'adb':
from kotonebot.client.implements.adb import AdbImpl
impl = AdbImpl(connection)
d._screenshot = impl
d._touch = impl
d.commands = impl
case 'adb_raw':
from kotonebot.client.implements.adb_raw import AdbRawImpl
impl = AdbRawImpl(connection)
d._screenshot = impl
d._touch = impl
d.commands = impl
case 'uiautomator2':
from kotonebot.client.implements.uiautomator2 import UiAutomator2Impl
from kotonebot.client.implements.adb import AdbImpl
impl = UiAutomator2Impl(d)
d._screenshot = impl
d._touch = impl
d.commands = AdbImpl(connection)
case _:
assert_never(f'Unsupported ADB recipe: {recipe}')
return d

View File

@ -1,22 +1,21 @@
import os
import subprocess
from psutil import process_iter
from .protocol import Instance, AdbHostConfig
from typing import ParamSpec, TypeVar, cast
from .protocol import Instance, AdbHostConfig, HostProtocol
from typing import ParamSpec, TypeVar
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import DeviceImpl
from kotonebot.client.device import Device
from kotonebot.client.registration import AdbBasedImpl, create_device
from kotonebot.client.implements.adb import AdbImplConfig
from kotonebot.client import Device
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
logger = logging.getLogger(__name__)
CustomRecipes = AdbRecipes
P = ParamSpec('P')
T = TypeVar('T')
class CustomInstance(Instance[AdbHostConfig]):
class CustomInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
def __init__(self, exe_path: str | None, emulator_args: str = "", *args, **kwargs):
super().__init__(*args, **kwargs)
self.exe_path: str | None = exe_path
@ -69,24 +68,12 @@ class CustomInstance(Instance[AdbHostConfig]):
pass
@override
def create_device(self, impl: DeviceImpl, host_config: AdbHostConfig) -> Device:
def create_device(self, impl: CustomRecipes, host_config: AdbHostConfig) -> Device:
"""为自定义实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
# 为 ADB 相关的实现创建配置
if impl in ['adb', 'adb_raw', 'uiautomator2']:
config = AdbImplConfig(
addr=f'{self.adb_ip}:{self.adb_port}',
connect=True,
disconnect=True,
device_serial=self.adb_name,
timeout=host_config.timeout
)
impl = cast(AdbBasedImpl, impl) # make pylance happy
return create_device(impl, config)
else:
raise ValueError(f'Unsupported device implementation for Custom: {impl}')
return super().create_device(impl, host_config)
def __repr__(self) -> str:
return f'CustomInstance(#{self.id}# at "{self.exe_path}" with {self.adb_ip}:{self.adb_port})'
@ -99,6 +86,25 @@ def _type_check(ins: Instance) -> CustomInstance:
def create(exe_path: str | None, adb_ip: str, adb_port: int, adb_name: str | None, emulator_args: str = "") -> CustomInstance:
return CustomInstance(exe_path, emulator_args=emulator_args, id='custom', name='Custom', adb_ip=adb_ip, adb_port=adb_port, adb_name=adb_name)
class CustomHost(HostProtocol[CustomRecipes]):
@staticmethod
def installed() -> bool:
# Custom instances don't have a specific installation requirement
return True
@staticmethod
def list() -> list[Instance]:
# Custom instances are created manually, not discovered
return []
@staticmethod
def query(*, id: str) -> Instance | None:
# Custom instances are created manually, not discovered
return None
@staticmethod
def recipes() -> 'list[CustomRecipes]':
return ['adb', 'adb_raw', 'uiautomator2']
if __name__ == '__main__':
ins = create(r'C:\Program Files\BlueStacks_nxt\HD-Player.exe', '127.0.0.1', 5555, '**emulator-name**')

View File

@ -1,18 +1,17 @@
import os
import subprocess
from typing import cast
from typing import Literal
from functools import lru_cache
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import DeviceImpl
from kotonebot.client.device import Device
from kotonebot.client.registration import AdbBasedImpl, create_device
from kotonebot.client.implements.adb import AdbImplConfig
from kotonebot.client import Device
from kotonebot.util import Countdown, Interval
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
logger = logging.getLogger(__name__)
LeidianRecipes = AdbRecipes
if os.name == 'nt':
from ...interop.win.reg import read_reg
@ -21,7 +20,7 @@ else:
"""Stub for read_reg on non-Windows platforms."""
return default
class LeidianInstance(Instance[AdbHostConfig]):
class LeidianInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
@copy_type(Instance.__init__)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -64,35 +63,23 @@ class LeidianInstance(Instance[AdbHostConfig]):
it = Interval(5)
while not cd.expired() and not self.running():
it.wait()
self.refresh()
if not self.running():
raise TimeoutError(f'Leidian instance "{self.name}" is not available.')
@override
def running(self) -> bool:
result = LeidianHost._invoke_manager(['isrunning', '--index', str(self.index)])
return result.strip() == 'running'
return self.is_running
@override
def create_device(self, impl: DeviceImpl, host_config: AdbHostConfig) -> Device:
def create_device(self, impl: LeidianRecipes, host_config: AdbHostConfig) -> Device:
"""为雷电模拟器实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
# 为 ADB 相关的实现创建配置
if impl in ['adb', 'adb_raw', 'uiautomator2']:
config = AdbImplConfig(
addr=f'{self.adb_ip}:{self.adb_port}',
connect=False, # 雷电模拟器不需要 adb connect
disconnect=False,
device_serial=self.adb_name,
timeout=host_config.timeout
)
impl = cast(AdbBasedImpl, impl) # make pylance happy
return create_device(impl, config)
else:
raise ValueError(f'Unsupported device implementation for Leidian: {impl}')
return super().create_device(impl, host_config)
class LeidianHost(HostProtocol):
class LeidianHost(HostProtocol[LeidianRecipes]):
@staticmethod
@lru_cache(maxsize=1)
def _read_install_path() -> str | None:
@ -197,6 +184,10 @@ class LeidianHost(HostProtocol):
return instance
return None
@staticmethod
def recipes() -> 'list[LeidianRecipes]':
return ['adb', 'adb_raw', 'uiautomator2']
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
print(LeidianHost._read_install_path())

View File

@ -2,19 +2,17 @@ import os
import json
import subprocess
from functools import lru_cache
from typing import Any, cast
from typing import Any, Literal
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import DeviceImpl, Device
from kotonebot.client import Device
from kotonebot.client.device import AndroidDevice
from kotonebot.client.registration import AdbBasedImpl, create_device
from kotonebot.client.implements.adb import AdbImpl, AdbImplConfig, _create_adb_device_base
from kotonebot.client.implements.adb import AdbImpl
from kotonebot.client.implements.nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
from kotonebot.util import Countdown, Interval
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
logger = logging.getLogger(__name__)
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin, connect_adb
if os.name == 'nt':
from ...interop.win.reg import read_reg
@ -23,7 +21,10 @@ else:
"""Stub for read_reg on non-Windows platforms."""
return default
class Mumu12Instance(Instance[AdbHostConfig]):
logger = logging.getLogger(__name__)
MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
@copy_type(Instance.__init__)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -75,45 +76,35 @@ class Mumu12Instance(Instance[AdbHostConfig]):
return self.is_android_started
@override
def create_device(self, impl: DeviceImpl, host_config: AdbHostConfig) -> Device:
def create_device(self, impl: MuMu12Recipes, host_config: AdbHostConfig) -> Device:
"""为MuMu12模拟器实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
# 新增对 nemu_ipc 的支持
if impl == 'nemu_ipc':
# NemuImpl
nemu_path = Mumu12Host._read_install_path()
if not nemu_path:
raise RuntimeError("无法找到 MuMu12 的安装路径。")
nemu_config = NemuIpcImplConfig(mumu_root_folder=nemu_path, instance_id=int(self.id))
nemu_config = NemuIpcImplConfig(nemu_folder=nemu_path, instance_id=int(self.id))
nemu_impl = NemuIpcImpl(nemu_config)
# AdbImpl
adb_impl = AdbImpl(connect_adb(
self.adb_ip,
self.adb_port,
timeout=host_config.timeout,
device_serial=self.adb_name
))
device = AndroidDevice()
nemu_impl = NemuIpcImpl(device, nemu_config)
device._screenshot = nemu_impl
device._touch = nemu_impl
# 组装命令部分 (Adb)
adb_config = AdbImplConfig(addr=f'{self.adb_ip}:{self.adb_port}', timeout=host_config.timeout)
# adb_impl = AdbImpl(device, adb_config)
_d = _create_adb_device_base(adb_config, AdbImpl)
device.commands = _d.commands
device.commands = adb_impl
return device
# 为 ADB 相关的实现创建配置
if impl in ['adb', 'adb_raw', 'uiautomator2']:
config = AdbImplConfig(
addr=f'{self.adb_ip}:{self.adb_port}',
connect=True,
disconnect=True,
device_serial=self.adb_name,
timeout=host_config.timeout
)
impl = cast(AdbBasedImpl, impl) # make pylance happy
return create_device(impl, config)
else:
raise ValueError(f'Unsupported device implementation for MuMu12: {impl}')
return super().create_device(impl, host_config)
class Mumu12Host(HostProtocol):
class Mumu12Host(HostProtocol[MuMu12Recipes]):
@staticmethod
@lru_cache(maxsize=1)
def _read_install_path() -> str | None:
@ -211,6 +202,10 @@ class Mumu12Host(HostProtocol):
return instance
return None
@staticmethod
def recipes() -> 'list[MuMu12Recipes]':
return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
print(Mumu12Host._read_install_path())

View File

@ -195,7 +195,8 @@ class Instance(Generic[T_HostConfig], ABC):
def __repr__(self) -> str:
return f'{self.__class__.__name__}(name="{self.name}", id="{self.id}", adb="{self.adb_ip}:{self.adb_port}"({self.adb_name}))'
class HostProtocol(Protocol):
Recipe = TypeVar('Recipe', bound=str)
class HostProtocol(Generic[Recipe], Protocol):
@staticmethod
def installed() -> bool: ...
@ -205,6 +206,8 @@ class HostProtocol(Protocol):
@staticmethod
def query(*, id: str) -> Instance | None: ...
@staticmethod
def recipes() -> 'list[Recipe]': ...
if __name__ == '__main__':
pass

View File

@ -0,0 +1,55 @@
from abc import ABC
from typing import Literal
from typing_extensions import assert_never
from kotonebot import logging
from kotonebot.client.device import WindowsDevice
from .protocol import Device, WindowsHostConfig, RemoteWindowsHostConfig
logger = logging.getLogger(__name__)
WindowsRecipes = Literal['windows', 'remote_windows']
# Windows 相关的配置类型联合
WindowsHostConfigs = WindowsHostConfig | RemoteWindowsHostConfig
class CommonWindowsCreateDeviceMixin(ABC):
"""
通用 Windows 创建设备的 Mixin
Mixin 定义了创建 Windows 设备的通用接口
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def create_device(self, recipe: WindowsRecipes, config: WindowsHostConfigs) -> Device:
"""
创建 Windows 设备
"""
match recipe:
case 'windows':
if not isinstance(config, WindowsHostConfig):
raise ValueError(f"Expected WindowsHostConfig for 'windows' recipe, got {type(config)}")
from kotonebot.client.implements.windows import WindowsImpl
d = WindowsDevice()
impl = WindowsImpl(
device=d,
window_title=config.window_title,
ahk_exe_path=config.ahk_exe_path
)
d._screenshot = impl
d._touch = impl
return d
case 'remote_windows':
if not isinstance(config, RemoteWindowsHostConfig):
raise ValueError(f"Expected RemoteWindowsHostConfig for 'remote_windows' recipe, got {type(config)}")
from kotonebot.client.implements.remote_windows import RemoteWindowsImpl
d = WindowsDevice()
impl = RemoteWindowsImpl(
device=d,
host=config.host,
port=config.port
)
d._screenshot = impl
d._touch = impl
return d
case _:
assert_never(f'Unsupported Windows recipe: {recipe}')

View File

@ -9,7 +9,7 @@ from adbutils._device import AdbDevice as AdbUtilsDevice
from ..device import AndroidDevice
from ..protocol import AndroidCommandable, Touchable, Screenshotable
from ..registration import register_impl, ImplConfig
from ..registration import ImplConfig
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@ -83,46 +83,3 @@ class AdbImpl(AndroidCommandable, Touchable, Screenshotable):
if duration is not None:
logger.warning("Swipe duration is not supported with AdbDevice. Ignoring duration.")
self.adb.shell(f"input touchscreen swipe {x1} {y1} {x2} {y2}")
def _create_adb_device_base(config: AdbImplConfig, impl_class: type) -> AndroidDevice:
"""
通用的 ADB 设备创建工厂函数
其他任意基于 ADB Impl 可以直接复用这个函数
:param config: ADB 实现配置
:param impl_class: 实现类或工厂函数构造函数接收 adb_connection 参数
"""
from adbutils import adb
if config.disconnect:
logger.debug('adb disconnect %s', config.addr)
adb.disconnect(config.addr)
if config.connect:
logger.debug('adb connect %s', config.addr)
result = adb.connect(config.addr)
if 'cannot connect to' in result:
raise ValueError(result)
serial = config.device_serial or config.addr
logger.debug('adb wait for %s', serial)
adb.wait_for(serial, timeout=config.timeout)
devices = adb.device_list()
logger.debug('adb device_list: %s', devices)
d = [d for d in devices if d.serial == serial]
if len(d) == 0:
raise ValueError(f"Device {config.addr} not found")
d = d[0]
device = AndroidDevice(d)
impl = impl_class(d)
device._touch = impl
device._screenshot = impl
device.commands = impl
return device
@register_impl('adb', config_model=AdbImplConfig)
def create_adb_device(config: AdbImplConfig) -> AndroidDevice:
"""AdbImpl 工厂函数"""
return _create_adb_device_base(config, AdbImpl)

View File

@ -11,9 +11,8 @@ import numpy as np
from cv2.typing import MatLike
from adbutils._utils import adb_path
from .adb import AdbImpl, AdbImplConfig, _create_adb_device_base
from .adb import AdbImpl
from adbutils._device import AdbDevice as AdbUtilsDevice
from ..registration import register_impl
from kotonebot import logging
logger = logging.getLogger(__name__)
@ -158,9 +157,3 @@ class AdbRawImpl(AdbImpl):
data = self.__data
self.__data = None
return data
@register_impl('adb_raw', config_model=AdbImplConfig)
def create_adb_raw_device(config: AdbImplConfig):
"""AdbRawImpl 工厂函数"""
return _create_adb_device_base(config, AdbRawImpl)

View File

@ -11,8 +11,9 @@ from cv2.typing import MatLike
from ...device import AndroidDevice, Device
from ...protocol import Touchable, Screenshotable
from ...registration import register_impl, ImplConfig
from ...registration import ImplConfig
from .external_renderer_ipc import ExternalRendererIpc
from kotonebot.errors import KotonebotError
logger = logging.getLogger(__name__)
@ -22,16 +23,23 @@ class NemuIpcIncompatible(Exception):
pass
class NemuIpcError(Exception):
class NemuIpcError(KotonebotError):
"""调用 IPC 过程中发生错误"""
pass
@dataclass
class NemuIpcImplConfig(ImplConfig):
"""nemu_ipc 设备实现的配置模型mumu_root_folder 为 MuMu 根目录"""
mumu_root_folder: str
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):
@ -39,23 +47,22 @@ class NemuIpcImpl(Touchable, Screenshotable):
利用 MuMu12 提供的 external_renderer_ipc.dll 进行截图与触摸控制
"""
def __init__(self, device: Device, config: NemuIpcImplConfig):
self.device = device
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 = 0
self.display_id: int = config.display_id
"""
显示器 ID`0` 表示主显示器
如果没有启用后台保活功能一般为主显示器
"""
self.nemu_folder = config.mumu_root_folder
self.nemu_folder = config.nemu_folder
# --------------------------- DLL 封装 ---------------------------
self._ipc = ExternalRendererIpc(config.mumu_root_folder)
self._ipc = ExternalRendererIpc(config.nemu_folder)
logger.info("ExternalRendererIpc initialized and DLL loaded")
@property
@ -223,15 +230,3 @@ class NemuIpcImpl(Touchable, Screenshotable):
# 最终抬起
self._ipc.input_touch_up(self._connect_id, self.display_id)
sleep(0.01)
# ------------------------------------------------------------------
# 工厂方法
# ------------------------------------------------------------------
@register_impl("nemu_ipc", config_model=NemuIpcImplConfig)
def create_nemu_ipc_device(config: NemuIpcImplConfig):
"""创建一个 AndroidDevice并挂载 NemuIpcImpl。"""
device = AndroidDevice()
impl = NemuIpcImpl(device, config)
device._touch = impl
device._screenshot = impl
return device

View File

@ -23,8 +23,8 @@ from cv2.typing import MatLike
from kotonebot import logging
from ..device import Device, WindowsDevice
from ..protocol import Touchable, Screenshotable
from ..registration import register_impl, ImplConfig
from .windows import WindowsImpl, WindowsImplConfig, create_windows_device
from ..registration import ImplConfig
from .windows import WindowsImpl, WindowsImplConfig
logger = logging.getLogger(__name__)
@ -63,7 +63,11 @@ class RemoteWindowsServer:
self.port = port
self.server = None
self.device = WindowsDevice()
self.impl = create_windows_device(windows_impl_config)
self.impl = WindowsImpl(
WindowsDevice(),
ahk_exe_path=windows_impl_config.ahk_exe_path,
window_title=windows_impl_config.window_title
)
self.device._screenshot = self.impl
self.device._touch = self.impl
@ -187,13 +191,3 @@ class RemoteWindowsImpl(Touchable, Screenshotable):
"""Swipe from (x1, y1) to (x2, y2) on the remote server."""
if not self.proxy.swipe(x1, y1, x2, y2, duration):
raise RuntimeError(f"Failed to swipe from ({x1}, {y1}) to ({x2}, {y2})")
# 编写并注册创建函数
@register_impl('remote_windows', config_model=RemoteWindowsImplConfig)
def create_remote_windows_device(config: RemoteWindowsImplConfig) -> Device:
device = WindowsDevice()
remote_impl = RemoteWindowsImpl(device, config.host, config.port)
device._touch = remote_impl
device._screenshot = remote_impl
return device

View File

@ -8,8 +8,6 @@ from cv2.typing import MatLike
from kotonebot import logging
from ..device import Device
from ..protocol import Screenshotable, Commandable, Touchable
from ..registration import register_impl
from .adb import AdbImplConfig, _create_adb_device_base
logger = logging.getLogger(__name__)
@ -85,9 +83,3 @@ class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
滑动屏幕
"""
self.u2_client.swipe(x1, y1, x2, y2, duration=duration or 0.1)
@register_impl('uiautomator2', config_model=AdbImplConfig)
def create_uiautomator2_device(config: AdbImplConfig) -> Device:
"""UiAutomator2Impl 工厂函数"""
return _create_adb_device_base(config, UiAutomator2Impl)

View File

@ -13,7 +13,7 @@ from cv2.typing import MatLike
from ..device import Device, WindowsDevice
from ..protocol import Commandable, Touchable, Screenshotable
from ..registration import register_impl, ImplConfig
from ..registration import ImplConfig
# 1. 定义配置模型
@dataclass
@ -167,21 +167,6 @@ class WindowsImpl(Touchable, Screenshotable):
# TODO: 这个 speed 的单位是什么?
self.ahk.mouse_drag(x2, y2, from_position=(x1, y1), coord_mode='Client', speed=10)
# 3. 编写并注册创建函数
@register_impl('windows', config_model=WindowsImplConfig)
def create_windows_device(config: WindowsImplConfig) -> Device:
device = WindowsDevice()
impl = WindowsImpl(
device,
window_title=config.window_title,
ahk_exe_path=config.ahk_exe_path
)
device._touch = impl
device._screenshot = impl
return device
if __name__ == '__main__':
from ..device import Device
device = Device()

View File

@ -22,73 +22,3 @@ class ImplRegistrationError(KotonebotError):
class ImplConfig:
"""所有设备实现配置模型的名义上的基类,便于类型约束。"""
pass
T_Config = TypeVar("T_Config", bound=ImplConfig)
# 定义两种创建者函数类型
CreatorWithConfig = Callable[[Any], Device]
CreatorWithoutConfig = Callable[[], Device]
# --- 底层 API: 公开的注册表 ---
# 注册表结构: {'impl_name': (创建函数, 配置模型类 或 None)}
DEVICE_CREATORS: Dict[str, tuple[Callable[..., Device], Type[ImplConfig] | None]] = {}
def register_impl(name: str, config_model: Type[ImplConfig] | None = None) -> Callable[..., Any]:
"""
一个统一的装饰器用于向 DEVICE_CREATORS 注册表中注册一个设备实现
:param name: 实现的名称 (e.g., 'windows', 'adb')
:param config_model: (可选) 与该实现关联的 dataclass 配置模型
"""
def decorator(creator_func: Callable[..., Device]) -> Callable[..., Device]:
# if name in DEVICE_CREATORS:
# raise ImplRegistrationError(f"实现 '{name}' 已被注册。")
DEVICE_CREATORS[name] = (creator_func, config_model)
return creator_func
return decorator
# --- 高层 API: 带 overload 的便利函数 ---
# 为需要配置的已知 impl 提供 overload
@overload
def create_device(impl_name: Literal['windows'], config: 'WindowsImplConfig') -> Device: ...
@overload
def create_device(impl_name: Literal['remote_windows'], config: 'RemoteWindowsImplConfig') -> Device: ...
@overload
def create_device(impl_name: AdbBasedImpl, config: 'AdbImplConfig') -> Device: ...
# 新增 nemu_ipc overload
@overload
def create_device(impl_name: Literal['nemu_ipc'], config: 'NemuIpcImplConfig') -> Device: ...
# 函数的实际实现
def create_device(impl_name: DeviceImpl, config: ImplConfig | None = None) -> Device:
"""
根据名称和可选的配置对象统一创建设备
"""
creator_tuple = DEVICE_CREATORS.get(impl_name)
if not creator_tuple:
raise ImplRegistrationError(f"未找到名为 '{impl_name}' 的实现。")
creator_func, registered_config_model = creator_tuple
# 情况 A: 实现需要配置
if registered_config_model is not None:
creator_with_config = cast(CreatorWithConfig, creator_func)
if config is None:
raise ValueError(f"实现 '{impl_name}' 需要一个配置对象,但传入的是 None。")
if not isinstance(config, registered_config_model):
raise TypeError(f"'{impl_name}' 传入的配置类型错误,应为 '{registered_config_model.__name__}',实际为 '{type(config).__name__}'")
return creator_with_config(config)
# 情况 B: 实现无需配置
else:
creator_without_config = cast(CreatorWithoutConfig, creator_func)
if config is not None:
print(f"提示:实现 '{impl_name}' 无需配置,但你提供了一个配置对象,它将被忽略。")
return creator_without_config()

View File

@ -3,10 +3,10 @@ from typing import Generic, TypeVar, Literal
from pydantic import BaseModel, ConfigDict
from kotonebot.client import DeviceImpl
T = TypeVar('T')
BackendType = Literal['custom', 'mumu12', 'leidian', 'dmm']
DeviceRecipes = Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
@ -27,7 +27,7 @@ class BackendConfig(ConfigBaseModel):
雷电模拟器需要设置正确的模拟器名否则 自动启动模拟器 功能将无法正常工作
其他功能不受影响
"""
screenshot_impl: DeviceImpl = 'adb'
screenshot_impl: DeviceRecipes = 'adb'
"""
截图方法暂时推荐使用adb截图方式

View File

@ -1,18 +1,15 @@
from importlib import resources
from typing_extensions import override
from kotonebot.client import Device, DeviceImpl
from kotonebot.client.registration import create_device
from kotonebot.client.implements.windows import WindowsImplConfig
from kotonebot.client.implements.remote_windows import RemoteWindowsImplConfig
from kotonebot.client import Device
from kotonebot.client.host import HostProtocol, Instance
from kotonebot.client.host.protocol import WindowsHostConfig, RemoteWindowsHostConfig
from kotonebot.client.host.windows_common import WindowsRecipes, WindowsHostConfigs, CommonWindowsCreateDeviceMixin
DmmHostConfigs = WindowsHostConfig | RemoteWindowsHostConfig
DmmHostConfigs = WindowsHostConfigs
DmmRecipes = WindowsRecipes
# TODO: 可能应该把 start_game 和 end_game 里对启停的操作移动到这里来
class DmmInstance(Instance[DmmHostConfigs]):
class DmmInstance(CommonWindowsCreateDeviceMixin, Instance[DmmHostConfigs]):
def __init__(self):
super().__init__('dmm', 'gakumas')
@ -37,31 +34,11 @@ class DmmInstance(Instance[DmmHostConfigs]):
raise NotImplementedError()
@override
def create_device(self, impl: DeviceImpl, host_config: DmmHostConfigs) -> Device:
if impl == 'windows':
assert isinstance(host_config, WindowsHostConfig)
win_config = WindowsImplConfig(
window_title=host_config.window_title,
ahk_exe_path=host_config.ahk_exe_path
)
return create_device(impl, win_config)
def create_device(self, impl: DmmRecipes, host_config: DmmHostConfigs) -> Device:
"""为 DMM 实例创建 Device。"""
return super().create_device(impl, host_config)
elif impl == 'remote_windows':
assert isinstance(host_config, RemoteWindowsHostConfig)
config = RemoteWindowsImplConfig(
windows_impl_config=WindowsImplConfig(
window_title=host_config.windows_host_config.window_title,
ahk_exe_path=host_config.windows_host_config.ahk_exe_path
),
host=host_config.host,
port=host_config.port
)
return create_device(impl, config)
else:
raise ValueError(f'Unsupported impl for DMM: {impl}')
class DmmHost(HostProtocol):
class DmmHost(HostProtocol[DmmRecipes]):
instance = DmmInstance()
"""DmmInstance 单例。"""
@ -77,3 +54,7 @@ class DmmHost(HostProtocol):
@staticmethod
def query(*, id: str) -> Instance | None:
raise NotImplementedError()
@staticmethod
def recipes() -> 'list[DmmRecipes]':
return ['windows', 'remote_windows']

View File

@ -1,5 +1,6 @@
import io
import os
from typing import Any, cast
import zipfile
import logging
import traceback
@ -9,8 +10,6 @@ from typing_extensions import override
import cv2
from kotonebot.client.implements.nemu_ipc import NemuIpcImplConfig
from ...client import Device
from kotonebot.ui import user
from kotonebot import KotoneBot
@ -234,7 +233,10 @@ class Kaa(KotoneBot):
elif isinstance(self.backend_instance, (CustomInstance, Mumu12Instance, LeidianInstance)):
if impl_name in ['adb', 'adb_raw', 'uiautomator2'] or (impl_name == 'nemu_ipc' and isinstance(self.backend_instance, Mumu12Instance)):
host_conf = AdbHostConfig(timeout=180)
return self.backend_instance.create_device(impl_name, host_conf)
return self.backend_instance.create_device(
cast(Any, impl_name), # :(
host_conf
)
else:
raise ValueError(f"{user_config.backend.type} backend does not support implementation '{impl_name}'")