feat(task): 为 DMM 控制适配 HostProtocol

This commit is contained in:
XcantloadX 2025-05-21 22:54:26 +08:00
parent 548ba04071
commit 68bf47d89e
10 changed files with 282 additions and 131 deletions

View File

@ -9,8 +9,6 @@ from dataclasses import dataclass, field
from typing import Any, Literal, Callable, Generic, TypeVar, ParamSpec
from kotonebot.client import Device
from kotonebot.client.host import Mumu12Host, LeidianHost
from kotonebot.ui import user
from kotonebot.client.host.protocol import Instance
from kotonebot.backend.context import init_context, vars
from kotonebot.backend.context import task_registry, action_registry, Task, Action
@ -132,80 +130,34 @@ class KotoneBot:
logger.debug(f'Loading sub-module: {name}')
try:
importlib.import_module(name)
except Exception as e:
except Exception:
logger.error(f'Failed to load sub-module: {name}')
logger.exception(f'Error: ')
logger.exception('Error: ')
logger.info('Tasks and actions initialized.')
logger.info(f'{len(task_registry)} task(s) and {len(action_registry)} action(s) loaded.')
def check_backend(self) -> Device:
from kotonebot.client.host import create_custom
from kotonebot.config.manager import load_config
# HACK: 硬编码
config = load_config(self.config_path, type=self.config_type)
config = config.user_configs[0]
logger.info('Checking backend...')
if config.backend.type == 'custom':
exe = config.backend.emulator_path
if exe is None:
user.error('「检查并启动模拟器」已开启但未配置「模拟器 exe 文件路径」。')
raise ValueError('Emulator executable path is not set.')
if not os.path.exists(exe):
user.error('「模拟器 exe 文件路径」对应的文件不存在!请检查路径是否正确。')
raise FileNotFoundError(f'Emulator executable not found: {exe}')
self.backend_instance = create_custom(
adb_ip=config.backend.adb_ip,
adb_port=config.backend.adb_port,
adb_name=config.backend.adb_emulator_name,
exe_path=exe,
emulator_args=config.backend.emulator_args
)
if config.backend.check_emulator:
if not self.backend_instance.running():
logger.info('Starting custom backend...')
self.backend_instance.start()
logger.info('Waiting for custom backend to be available...')
self.backend_instance.wait_available()
else:
logger.info('Custom backend "%s" already running.', self.backend_instance)
elif config.backend.type == 'mumu12':
if config.backend.instance_id is None:
raise ValueError('MuMu12 instance ID is not set.')
self.backend_instance = Mumu12Host.query(id=config.backend.instance_id)
if self.backend_instance is None:
raise ValueError(f'MuMu12 instance not found: {config.backend.instance_id}')
if not self.backend_instance.running():
logger.info('Starting MuMu12 backend...')
self.backend_instance.start()
logger.info('Waiting for MuMu12 backend to be available...')
self.backend_instance.wait_available()
else:
logger.info('MuMu12 backend "%s" already running.', self.backend_instance)
elif config.backend.type == 'leidian':
if config.backend.instance_id is None:
raise ValueError('Leidian instance ID is not set.')
self.backend_instance = LeidianHost.query(id=config.backend.instance_id)
if self.backend_instance is None:
raise ValueError(f'Leidian instance not found: {config.backend.instance_id}')
if not self.backend_instance.running():
logger.info('Starting Leidian backend...')
self.backend_instance.start()
logger.info('Waiting for Leidian backend to be available...')
self.backend_instance.wait_available()
else:
logger.info('Leidian backend "%s" already running.', self.backend_instance)
else:
raise ValueError(f'Unsupported backend type: {config.backend.type}')
assert self.backend_instance is not None, 'Backend instance is not set.'
return self.backend_instance.create_device(config.backend.screenshot_impl)
def _on_create_device(self) -> Device:
"""
抽象方法用于创建 Device `run()` 方法执行前会被调用
所有子类都需要重写该方法
"""
raise NotImplementedError('Implement `_create_device` before using Kotonebot.')
def _on_after_init_context(self):
"""
抽象方法 init_context() 被调用后立即执行
"""
pass
def run(self, tasks: list[Task], *, by_priority: bool = True):
"""
按优先级顺序运行所有任务
"""
d = self.check_backend()
d = self._on_create_device()
init_context(config_path=self.config_path, config_type=self.config_type, target_device=d)
self._on_after_init_context()
vars.interrupted.clear()
if by_priority:

View File

@ -174,11 +174,30 @@ def is_manual_screenshot_mode() -> bool:
class ContextGlobalVars:
def __init__(self):
self.auto_collect: bool = False
"""遇到未知P饮料/卡片时,是否自动截图并收集"""
self.__vars = dict[str, Any]()
self.interrupted: Event = Event()
"""用户请求中断事件"""
def __getitem__(self, key: str) -> Any:
return self.__vars[key]
def __setitem__(self, key: str, value: Any) -> None:
self.__vars[key] = value
def __delitem__(self, key: str) -> None:
del self.__vars[key]
def __contains__(self, key: str) -> bool:
return key in self.__vars
def get(self, key: str, default: Any = None) -> Any:
return self.__vars.get(key, default)
def set(self, key: str, value: Any) -> None:
self.__vars[key] = value
def clear(self):
self.__vars.clear()
class ContextStackVars:
stack: list['ContextStackVars'] = []

View File

@ -81,4 +81,6 @@ def create_device(
remote_impl = RemoteWindowsImpl(device, host, port)
device._touch = remote_impl
device._screenshot = remote_impl
else:
raise ValueError(f"Unsupported device implementation: {impl}")
return device

View File

@ -81,7 +81,7 @@ class UserConfig(ConfigBaseModel, Generic[T]):
class RootConfig(ConfigBaseModel, Generic[T]):
version: int = 4
version: int = 5
"""配置版本。"""
user_configs: list[UserConfig[T]] = []
"""用户配置。"""

View File

@ -523,6 +523,11 @@ def upgrade_config() -> str | None:
user_config, msg = upgrade_v3_to_v4(user_config['options'])
messages.append(msg)
version = 4
case 4:
logger.info('Upgrading config: v4 -> v5')
user_config, msg = upgrade_v4_to_v5(user_config, user_config['options'])
messages.append(msg)
version = 5
case _:
logger.info('No config upgrade needed.')
return version
@ -929,5 +934,16 @@ def upgrade_v3_to_v4(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
logger.info('Corrected game package name to com.bandainamcoent.idolmaster_gakuen')
return options, ''
def upgrade_v4_to_v5(user_config: dict[str, Any], options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v4 -> v5 变更
windows windows_remote 截图方式的 type 设置为 dmm
"""
shutil.copy('config.json', 'config.v4.json')
if user_config['backend']['screenshot_impl'] in ['windows', 'remote_windows']:
logger.info('Set backend type to dmm.')
user_config['backend']['type'] = 'dmm'
return options, ''
if __name__ == '__main__':
print(PurchaseConfig.model_fields['money_refresh_on'].description)

View File

@ -0,0 +1,8 @@
from kotonebot.backend.context import vars
from kotonebot.client.host import Instance
def _set_instance(new_instance: Instance) -> None:
vars.set('instance', new_instance)
def instance() -> Instance:
return vars.get('instance')

View File

@ -0,0 +1,53 @@
from typing_extensions import override
from kotonebot.client import Device, create_device
from kotonebot.client import DeviceImpl, Device
from kotonebot.client.host import HostProtocol, Instance
# TODO: 可能应该把 start_game 和 end_game 里对启停的操作移动到这里来
class DmmInstance(Instance):
def __init__(self):
super().__init__('dmm', 'gakumas')
@override
def refresh(self):
raise NotImplementedError()
@override
def start(self):
raise NotImplementedError()
@override
def stop(self):
raise NotImplementedError()
@override
def running(self) -> bool:
raise NotImplementedError()
@override
def wait_available(self, timeout: float = 180):
raise NotImplementedError()
@override
def create_device(self, impl: DeviceImpl, *, timeout: float = 180) -> Device:
if impl not in ['windows', 'remote_windows']:
raise ValueError(f'Unsupported device implementation: {impl}')
return create_device('', impl, timeout=timeout)
class DmmHost(HostProtocol):
instance = DmmInstance()
"""DmmInstance 单例。"""
@staticmethod
def installed() -> bool:
# TODO: 应该检查 DMM 和 gamkumas 的安装情况
raise NotImplementedError()
@staticmethod
def list() -> list[Instance]:
raise NotImplementedError()
@staticmethod
def query(*, id: str) -> Instance | None:
raise NotImplementedError()

View File

@ -437,7 +437,24 @@ class KotoneBotUI:
has_mumu12 = Mumu12Host.installed()
has_leidian = LeidianHost.installed()
current_tab = 0
with gr.Tabs(selected=self.current_config.backend.type):
def _update_emulator_tab_options(impl_value: str, selected_index: int):
nonlocal current_tab
current_tab = selected_index
if selected_index == 3: # DMM
choices = ['windows', 'remote_windows']
else: # Mumu, Leidian, Custom
choices = ['adb', 'adb_raw', 'uiautomator2']
if not impl_value in choices:
new_value = choices[0]
else:
new_value = impl_value
return gr.Dropdown(choices=choices, value=new_value)
with gr.Tabs(selected=self.current_config.backend.type) as emulator_tabs:
with gr.Tab("MuMu 12", interactive=has_mumu12, id="mumu12") as tab_mumu12:
gr.Markdown("已选中 MuMu 12 模拟器")
if has_mumu12:
@ -512,59 +529,74 @@ class KotoneBotUI:
inputs=[check_emulator],
outputs=[check_emulator_group]
)
screenshot_impl = gr.Dropdown(
choices=['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows'],
value=self.current_config.backend.screenshot_impl,
label="截图方法",
info=BackendConfig.model_fields['screenshot_impl'].description,
interactive=True
)
keep_screenshots = gr.Checkbox(
label="保留截图数据",
value=self.current_config.keep_screenshots,
info=UserConfig.model_fields['keep_screenshots'].description,
interactive=True
)
def set_current_tab(value: int) -> None:
nonlocal current_tab
current_tab = value
tab_mumu12.select(fn=partial(set_current_tab, 0))
tab_leidian.select(fn=partial(set_current_tab, 1))
tab_custom.select(fn=partial(set_current_tab, 2))
def set_config(_: BaseConfig, data: dict[ConfigKey, Any]) -> None:
if current_tab == 0:
self.current_config.backend.type = 'mumu12'
self.current_config.backend.instance_id = data['_mumu_index']
elif current_tab == 1:
self.current_config.backend.type = 'leidian'
self.current_config.backend.instance_id = data['_leidian_index']
else:
self.current_config.backend.type = 'custom'
self.current_config.backend.instance_id = None
self.current_config.backend.adb_ip = data['adb_ip']
self.current_config.backend.adb_port = data['adb_port']
self.current_config.backend.adb_emulator_name = data['adb_emulator_name']
self.current_config.backend.screenshot_impl = data['screenshot_method']
self.current_config.keep_screenshots = data['keep_screenshots']
self.current_config.backend.check_emulator = data['check_emulator']
self.current_config.backend.emulator_path = data['emulator_path']
self.current_config.backend.emulator_args = data['emulator_args']
return set_config, {
'adb_ip': adb_ip,
'adb_port': adb_port,
'screenshot_method': screenshot_impl,
'keep_screenshots': keep_screenshots,
'check_emulator': check_emulator,
'emulator_path': emulator_path,
'adb_emulator_name': adb_emulator_name,
'emulator_args': emulator_args,
'_mumu_index': mumu_instance,
'_leidian_index': leidian_instance
}
with gr.Tab("DMM", id="dmm") as tab_dmm:
gr.Markdown("已选中 DMM")
type_in_config = self.current_config.backend.type
if type_in_config in ['dmm']:
choices = ['windows', 'remote_windows']
elif type_in_config in ['mumu12', 'leidian', 'custom']:
choices = ['adb', 'adb_raw', 'uiautomator2']
else:
raise ValueError(f'Unsupported backend type: {type_in_config}')
screenshot_impl = gr.Dropdown(
choices=choices,
value=self.current_config.backend.screenshot_impl,
label="截图方法",
info=BackendConfig.model_fields['screenshot_impl'].description,
interactive=True
)
keep_screenshots = gr.Checkbox(
label="保留截图数据",
value=self.current_config.keep_screenshots,
info=UserConfig.model_fields['keep_screenshots'].description,
interactive=True
)
tab_mumu12.select(fn=partial(_update_emulator_tab_options, selected_index=0), inputs=[screenshot_impl], outputs=[screenshot_impl])
tab_leidian.select(fn=partial(_update_emulator_tab_options, selected_index=1), inputs=[screenshot_impl], outputs=[screenshot_impl])
tab_custom.select(fn=partial(_update_emulator_tab_options, selected_index=2), inputs=[screenshot_impl], outputs=[screenshot_impl])
tab_dmm.select(fn=partial(_update_emulator_tab_options, selected_index=3), inputs=[screenshot_impl], outputs=[screenshot_impl])
def set_config(_: BaseConfig, data: dict[ConfigKey, Any]) -> None:
# current_tab is updated by _update_emulator_tab_options
if current_tab == 0: # Mumu
self.current_config.backend.type = 'mumu12'
self.current_config.backend.instance_id = data['_mumu_index']
elif current_tab == 1: # Leidian
self.current_config.backend.type = 'leidian'
self.current_config.backend.instance_id = data['_leidian_index']
elif current_tab == 2: # Custom
self.current_config.backend.type = 'custom'
self.current_config.backend.instance_id = None
self.current_config.backend.adb_ip = data['adb_ip']
self.current_config.backend.adb_port = data['adb_port']
self.current_config.backend.adb_emulator_name = data['adb_emulator_name']
self.current_config.backend.check_emulator = data['check_emulator']
self.current_config.backend.emulator_path = data['emulator_path']
self.current_config.backend.emulator_args = data['emulator_args']
elif current_tab == 3: # DMM
self.current_config.backend.type = 'dmm'
self.current_config.backend.instance_id = None # DMM doesn't use instance_id here
# Common settings for all backend types
self.current_config.backend.screenshot_impl = data['screenshot_method']
self.current_config.keep_screenshots = data['keep_screenshots'] # This is a UserConfig field
return set_config, {
'adb_ip': adb_ip,
'adb_port': adb_port,
'screenshot_method': screenshot_impl, # screenshot_impl is the component
'keep_screenshots': keep_screenshots,
'check_emulator': check_emulator,
'emulator_path': emulator_path,
'adb_emulator_name': adb_emulator_name,
'emulator_args': emulator_args,
'_mumu_index': mumu_instance,
'_leidian_index': leidian_instance
}
def _create_purchase_settings(self) -> ConfigBuilderReturnValue:
with gr.Column():

View File

@ -7,9 +7,15 @@ import zipfile
from datetime import datetime
import cv2
from typing_extensions import override
from .dmm_host import DmmHost
from ...client import Device
from kotonebot.ui import user
from kotonebot import KotoneBot
from ..kaa_context import _set_instance
from ..common import BaseConfig, upgrade_config
from kotonebot.client.host import Mumu12Host, LeidianHost
# 初始化日志
log_formatter = logging.Formatter('[%(asctime)s][%(levelname)s][%(name)s] %(message)s')
@ -90,4 +96,74 @@ class Kaa(KotoneBot):
return path
except Exception as e:
logger.exception(f'Failed to save error report:')
return ''
return ''
@override
def _on_after_init_context(self):
if self.backend_instance is None:
raise ValueError('Backend instance is not set.')
_set_instance(self.backend_instance)
@override
def _on_create_device(self) -> Device:
from kotonebot.client.host import create_custom
from kotonebot.config.manager import load_config
# HACK: 硬编码
config = load_config(self.config_path, type=self.config_type)
config = config.user_configs[0]
logger.info('Checking backend...')
if config.backend.type == 'custom':
exe = config.backend.emulator_path
if exe is None:
user.error('「检查并启动模拟器」已开启但未配置「模拟器 exe 文件路径」。')
raise ValueError('Emulator executable path is not set.')
if not os.path.exists(exe):
user.error('「模拟器 exe 文件路径」对应的文件不存在!请检查路径是否正确。')
raise FileNotFoundError(f'Emulator executable not found: {exe}')
self.backend_instance = create_custom(
adb_ip=config.backend.adb_ip,
adb_port=config.backend.adb_port,
adb_name=config.backend.adb_emulator_name,
exe_path=exe,
emulator_args=config.backend.emulator_args
)
if config.backend.check_emulator:
if not self.backend_instance.running():
logger.info('Starting custom backend...')
self.backend_instance.start()
logger.info('Waiting for custom backend to be available...')
self.backend_instance.wait_available()
else:
logger.info('Custom backend "%s" already running.', self.backend_instance)
elif config.backend.type == 'mumu12':
if config.backend.instance_id is None:
raise ValueError('MuMu12 instance ID is not set.')
self.backend_instance = Mumu12Host.query(id=config.backend.instance_id)
if self.backend_instance is None:
raise ValueError(f'MuMu12 instance not found: {config.backend.instance_id}')
if not self.backend_instance.running():
logger.info('Starting MuMu12 backend...')
self.backend_instance.start()
logger.info('Waiting for MuMu12 backend to be available...')
self.backend_instance.wait_available()
else:
logger.info('MuMu12 backend "%s" already running.', self.backend_instance)
elif config.backend.type == 'leidian':
if config.backend.instance_id is None:
raise ValueError('Leidian instance ID is not set.')
self.backend_instance = LeidianHost.query(id=config.backend.instance_id)
if self.backend_instance is None:
raise ValueError(f'Leidian instance not found: {config.backend.instance_id}')
if not self.backend_instance.running():
logger.info('Starting Leidian backend...')
self.backend_instance.start()
logger.info('Waiting for Leidian backend to be available...')
self.backend_instance.wait_available()
else:
logger.info('Leidian backend "%s" already running.', self.backend_instance)
elif config.backend.type == 'dmm':
self.backend_instance = DmmHost.instance
else:
raise ValueError(f'Unsupported backend type: {config.backend.type}')
assert self.backend_instance is not None, 'Backend instance is not set.'
return self.backend_instance.create_device(config.backend.screenshot_impl)

View File

@ -6,6 +6,7 @@ import _thread
import threading
from kotonebot.ui import user
from ..kaa_context import instance
from kotonebot.kaa.common import Priority, conf
from kotonebot import task, action, config, device
@ -56,15 +57,7 @@ def end_game():
# 关闭模拟器
if conf().end_game.kill_emulator:
emulator_path = config.current.backend.emulator_path
if emulator_path is None:
logger.warning("Emulator path is not set. Skipping")
user.info("「关闭模拟器」功能需要配置「模拟器 exe 文件路径」。")
else:
exe_name = os.path.basename(emulator_path)
os.system(f"taskkill /f /im {exe_name}")
logger.info("Emulator closed")
# TODO: 实现关闭模拟器
instance().stop()
# 关机
if conf().end_game.shutdown: