From eb65dc588d6427f5c475f366da0cf44eb1096f4a Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Wed, 17 Dec 2025 15:47:04 +0800 Subject: [PATCH 01/15] =?UTF-8?q?tools:=20=E5=A2=9E=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=8E=8B=E5=8A=9B=E6=B5=8B=E8=AF=95=E7=9A=84=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E6=97=A8=E5=9C=A8=E6=B5=8B=E8=AF=95=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=89=A7=E8=A1=8Cmaamcp=E7=9A=84=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=EF=BC=8C=E4=B8=BA=E5=90=8E=E9=9D=A2=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=BC=98=E5=8C=96=E5=81=9A=E5=87=86=E5=A4=87=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_performance_stress.py | 521 +++++++++++++++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 tests/test_performance_stress.py diff --git a/tests/test_performance_stress.py b/tests/test_performance_stress.py new file mode 100644 index 0000000..3cfa082 --- /dev/null +++ b/tests/test_performance_stress.py @@ -0,0 +1,521 @@ +"""压力测试 - 测量关键函数在高负载下的性能表现""" + +import time +import pytest +from typing import Callable, Any, Optional, List, Dict + +from maa_mcp.vision import ocr, screencap +from maa_mcp.control import click, swipe, input_text, click_key, scroll, double_click +from maa_mcp.core import controller_info_registry, ControllerType +from maa_mcp.adb import find_adb_device_list, connect_adb_device +from maa_mcp.win32 import find_window_list, connect_window + +# 尝试不同的导入方式,确保在直接运行和作为模块运行时都能工作 +try: + from tests.test_performance import PerformanceTimer, PerformanceBenchmarker +except ImportError: + import sys + import os + + sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) + from test_performance import PerformanceTimer, PerformanceBenchmarker + + +class StressTestConfig: + """压力测试配置类""" + + def __init__(self): + self.iterations = 10 # 默认执行1000次 + self.warmup_iterations = 10 # 预热迭代次数 + self.timeout = 30.0 # 单个测试超时时间(秒) + + +class MockController: + """模拟控制器类,用于模拟设备/窗口控制器的基本功能""" + + def __init__(self): + self.controller_id = "mock_controller" + + def post_screencap(self): + """模拟截图操作""" + time.sleep(0.001) # 模拟截图延迟 + return self + + def wait(self): + """模拟等待操作""" + return self + + def get(self): + """模拟获取结果""" + return b"mock_image_data" # 返回模拟的图片数据 + + def post_touch_down(self, x, y, contact=0): + """模拟触摸按下操作""" + time.sleep(0.001) + return self + + def post_touch_up(self, contact=0): + """模拟触摸抬起操作""" + time.sleep(0.001) + return self + + def post_swipe(self, start_x, start_y, end_x, end_y, duration): + """模拟滑动操作""" + time.sleep(duration / 1000.0 * 0.1) # 模拟滑动延迟,实际时间的1/10 + return self + + def post_input_text(self, text): + """模拟输入文本操作""" + time.sleep(len(text) * 0.0005) # 模拟输入每个字符的延迟 + return self + + def post_key_down(self, key): + """模拟按键按下操作""" + time.sleep(0.001) + return self + + def post_key_up(self, key): + """模拟按键抬起操作""" + time.sleep(0.001) + return self + + def post_scroll(self, x, y): + """模拟滚动操作""" + time.sleep(0.001) + return self + + @property + def succeeded(self): + """模拟操作成功状态""" + return True + + +class TestStressPerformance: + """压力测试类 - 测试关键函数在高负载下的性能""" + + def setup_class(self): + """测试类初始化,获取实际控制器""" + self.config = StressTestConfig() + self.benchmarker = PerformanceBenchmarker() + self.controller_id = None + self.device_name = None + self.window_name = None + + # 尝试获取ADB设备 + try: + device_list = find_adb_device_list.fn() + if device_list: + self.device_name = device_list[0] # 使用第一个设备 + self.controller_id = connect_adb_device.fn(self.device_name) + print( + f" 使用ADB设备: {self.device_name}, 控制器ID: {self.controller_id}" + ) + except Exception as e: + print(f" 获取ADB设备失败: {e}") + + # 如果没有ADB设备,尝试获取Windows窗口 + if not self.controller_id: + try: + window_list = find_window_list.fn() + if window_list: + self.window_name = window_list[0] # 使用第一个窗口 + self.controller_id = connect_window.fn(self.window_name) + print( + f" 使用Windows窗口: {self.window_name}, 控制器ID: {self.controller_id}" + ) + except Exception as e: + print(f" 获取Windows窗口失败: {e}") + + # 如果仍然没有控制器,将使用模拟数据 + if not self.controller_id: + print(" 未找到实际设备或窗口,部分测试将使用模拟数据") + + def test_stress_find_adb_device_list(self): + """压力测试 - find_adb_device_list 函数""" + print(f"\n=== 压力测试: find_adb_device_list ({self.config.iterations}次) ===") + + # 预热 + for _ in range(self.config.warmup_iterations): + find_adb_device_list.fn() + + # 执行压力测试 + results = self.benchmarker.run_multiple( + find_adb_device_list.fn, + iterations=self.config.iterations, + print_stats=False, + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "find_adb_device_list") + + def test_stress_find_window_list(self): + """压力测试 - find_window_list 函数""" + print(f"\n=== 压力测试: find_window_list ({self.config.iterations}次) ===") + + # 预热 + for _ in range(self.config.warmup_iterations): + find_window_list.fn() + + # 执行压力测试 + results = self.benchmarker.run_multiple( + find_window_list.fn, + iterations=self.config.iterations, + print_stats=False, + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "find_window_list") + + def test_stress_ocr(self): + """压力测试 - OCR 函数""" + print(f"\n=== 压力测试: ocr ({self.config.iterations}次) ===") + + if not self.controller_id: + print(" 没有有效的控制器ID,无法执行OCR测试") + return + + # 预热 + for _ in range(self.config.warmup_iterations): + ocr.fn(self.controller_id) + + # 执行压力测试 + results = self.benchmarker.run_multiple( + ocr.fn, + iterations=self.config.iterations, + print_stats=False, + controller_id=self.controller_id, + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "ocr") + + def test_stress_screencap(self): + """压力测试 - 截图函数""" + print(f"\n=== 压力测试: screencap ({self.config.iterations}次) ===") + + if not self.controller_id: + print(" 没有有效的控制器ID,无法执行截图测试") + return + + # 预热 + for _ in range(self.config.warmup_iterations): + screencap.fn(self.controller_id) + + # 执行压力测试 + results = self.benchmarker.run_multiple( + screencap.fn, + iterations=self.config.iterations, + print_stats=False, + controller_id=self.controller_id, + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "screencap") + + def test_stress_click(self): + """压力测试 - 点击函数""" + print(f"\n=== 压力测试: click ({self.config.iterations}次) ===") + + if not self.controller_id: + print(" 没有有效的控制器ID,无法执行点击测试") + return + + # 预热 + for _ in range(self.config.warmup_iterations): + click.fn(self.controller_id, 100, 100) + + # 执行压力测试 + results = self.benchmarker.run_multiple( + click.fn, + iterations=self.config.iterations, + print_stats=False, + controller_id=self.controller_id, + x=100, + y=100, + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "click") + + def test_stress_swipe(self): + """压力测试 - 滑动函数""" + print(f"\n=== 压力测试: swipe ({self.config.iterations}次) ===") + + if not self.controller_id: + print(" 没有有效的控制器ID,无法执行滑动测试") + return + + # 预热 + for _ in range(self.config.warmup_iterations): + swipe.fn(self.controller_id, 100, 100, 200, 200, 500) + + # 执行压力测试 + results = self.benchmarker.run_multiple( + swipe.fn, + iterations=self.config.iterations, + print_stats=False, + controller_id=self.controller_id, + start_x=100, + start_y=100, + end_x=200, + end_y=200, + duration=500, + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "swipe") + + def test_stress_input_text(self): + """压力测试 - 输入文本函数""" + print(f"\n=== 压力测试: input_text ({self.config.iterations}次) ===") + + if not self.controller_id: + print(" 没有有效的控制器ID,无法执行输入文本测试") + return + + # 预热 + for _ in range(self.config.warmup_iterations): + input_text.fn(self.controller_id, "test") + + # 执行压力测试 + results = self.benchmarker.run_multiple( + input_text.fn, + iterations=self.config.iterations, + print_stats=False, + controller_id=self.controller_id, + text="test", + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "input_text") + + def test_stress_click_key(self): + """压力测试 - 按键点击函数""" + print(f"\n=== 压力测试: click_key ({self.config.iterations}次) ===") + + if not self.controller_id: + print(" 没有有效的控制器ID,无法执行按键点击测试") + return + + # 预热 + for _ in range(self.config.warmup_iterations): + click_key.fn(self.controller_id, 13) # 13 是回车键的虚拟键码 + + # 执行压力测试 + results = self.benchmarker.run_multiple( + click_key.fn, + iterations=self.config.iterations, + print_stats=False, + controller_id=self.controller_id, + key=13, # 13 是回车键的虚拟键码 + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "click_key") + + def test_stress_scroll(self): + """压力测试 - 滚动函数""" + print(f"\n=== 压力测试: scroll ({self.config.iterations}次) ===") + + if not self.controller_id: + print(" 没有有效的控制器ID,无法执行滚动测试") + return + + # 检查是否为 ADB 控制器 + info = controller_info_registry.get(self.controller_id) + if info and info.controller_type == ControllerType.ADB: + print(" 当前控制器为 ADB,跳过 scroll 压力测试 (仅支持 Windows)") + return + + # 预热 + for _ in range(self.config.warmup_iterations): + scroll.fn(self.controller_id, 0, -120) + + # 执行压力测试 + results = self.benchmarker.run_multiple( + scroll.fn, + iterations=self.config.iterations, + print_stats=False, + controller_id=self.controller_id, + x=0, + y=-120, + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "scroll") + + def test_stress_double_click(self): + """压力测试 - 双击函数""" + print(f"\n=== 压力测试: double_click ({self.config.iterations}次) ===") + + if not self.controller_id: + print(" 没有有效的控制器ID,无法执行双击测试") + return + + # 预热 + for _ in range(self.config.warmup_iterations): + double_click.fn(self.controller_id, 100, 100) + + # 执行压力测试 + results = self.benchmarker.run_multiple( + double_click.fn, + iterations=self.config.iterations, + print_stats=False, + controller_id=self.controller_id, + x=100, + y=100, + ) + + # 打印详细统计信息 + self._print_stress_test_stats(results, "double_click") + + def _print_stress_test_stats(self, results: List, function_name: str): + """打印压力测试的详细统计信息""" + if not results: + print(f" 未获取到测试结果") + return + + # 筛选成功的测试结果 + success_results = [r for r in results if r.success] + if not success_results: + print(f" 所有测试都失败了") + return + + # 计算统计数据 + execution_times = [r.execution_time for r in success_results] + avg_time = sum(execution_times) / len(execution_times) + min_time = min(execution_times) + max_time = max(execution_times) + median_time = sorted(execution_times)[len(execution_times) // 2] + + # 计算每秒处理次数(TPS) + tps = len(success_results) / sum(execution_times) + + print(f"\n[压力测试统计] {function_name}") + print(f" 总执行次数: {len(results)}") + print(f" 成功次数: {len(success_results)}") + print(f" 平均时间: {avg_time * 1000:.3f} 毫秒") + print(f" 最小时间: {min_time * 1000:.3f} 毫秒") + print(f" 最大时间: {max_time * 1000:.3f} 毫秒") + print(f" 中位数时间: {median_time * 1000:.3f} 毫秒") + print(f" 每秒处理次数 (TPS): {tps:.2f}") + print(f" 总耗时: {sum(execution_times) * 1000:.2f} 毫秒") + + +# 性能测试接口 - 为关键函数添加性能测试装饰器 +class PerformanceTestInterface: + """性能测试接口类 - 提供性能测试的统一接口""" + + @staticmethod + def measure_function_performance( + func: Callable, iterations: int = 1000, *args, **kwargs + ) -> Dict[str, Any]: + """测量函数在指定次数迭代下的性能 + + Args: + func: 要测试的函数 + iterations: 迭代次数 + *args: 函数参数 + **kwargs: 函数关键字参数 + + Returns: + 包含性能统计数据的字典 + """ + benchmarker = PerformanceBenchmarker() + + # 预热 + for _ in range(10): + func(*args, **kwargs) + + # 执行测试 + results = benchmarker.run_multiple(func, iterations, *args, **kwargs) + + # 计算统计数据 + success_results = [r for r in results if r.success] + if not success_results: + return { + "function_name": func.__name__, + "iterations": iterations, + "success": False, + "message": "所有测试都失败了", + } + + success_times = [r.execution_time for r in success_results] + avg_time = sum(success_times) / len(success_times) + min_time = min(success_times) + max_time = max(success_times) + median_time = sorted(success_times)[len(success_times) // 2] + tps = len(success_results) / sum(success_times) + + return { + "function_name": func.__name__, + "iterations": iterations, + "success": True, + "total_executions": len(results), + "successful_executions": len(success_results), + "average_time": avg_time, + "minimum_time": min_time, + "maximum_time": max_time, + "median_time": median_time, + "tps": tps, + "total_time": sum(success_times), + } + + @staticmethod + def compare_function_performances( + functions: List[Callable], iterations: int = 1000 + ): + """比较多个函数的性能""" + results = [] + + for func in functions: + result = PerformanceTestInterface.measure_function_performance( + func, iterations + ) + if result: + results.append(result) + + # 按平均时间排序 + results.sort(key=lambda x: x["average_time"]) + + return results + + +# 压力测试示例脚本(可直接运行) +if __name__ == "__main__": + """压力测试示例 - 展示如何使用压力测试模块""" + + print("MaaMCP 压力测试示例") + print("=" * 60) + + # 创建压力测试配置 + config = StressTestConfig() + config.iterations = 1000 # 使用1000次迭代进行完整的压力测试 + + # 创建测试实例 + test = TestStressPerformance() + test.setup_class() + + # 运行部分压力测试 + print("\n1. 运行 find_adb_device_list 压力测试:") + test.test_stress_find_adb_device_list() + + print("\n2. 运行 OCR 压力测试:") + test.test_stress_ocr() + + print("\n3. 运行 click 压力测试:") + test.test_stress_click() + + print("\n4. 运行 input_text 压力测试:") + test.test_stress_input_text() + + print("\n5. 运行 scroll 压力测试:") + test.test_stress_scroll() + + print("\n6. 运行 double_click 压力测试:") + test.test_stress_double_click() + + print("\n" + "=" * 60) + print("压力测试示例执行完成!") + print("要运行完整的1000次迭代测试,请使用 pytest 执行:") + print("pytest tests/test_performance_stress.py -v") From c0f041ffb1dc0e80b2fd0a44d8a73e636f7001d2 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Wed, 17 Dec 2025 15:59:47 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=BC=95=E7=94=A8=E9=94=99=E8=AF=AF=E7=9A=84=E6=83=85?= =?UTF-8?q?=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_performance_stress.py | 171 +++++++++++++++++++++++++++++-- 1 file changed, 162 insertions(+), 9 deletions(-) diff --git a/tests/test_performance_stress.py b/tests/test_performance_stress.py index 3cfa082..39f1b22 100644 --- a/tests/test_performance_stress.py +++ b/tests/test_performance_stress.py @@ -10,15 +10,168 @@ from maa_mcp.adb import find_adb_device_list, connect_adb_device from maa_mcp.win32 import find_window_list, connect_window -# 尝试不同的导入方式,确保在直接运行和作为模块运行时都能工作 -try: - from tests.test_performance import PerformanceTimer, PerformanceBenchmarker -except ImportError: - import sys - import os - - sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) - from test_performance import PerformanceTimer, PerformanceBenchmarker + +class PerformanceTimer: + """性能计时器,用于测量函数执行时间""" + + def __init__(self): + self.start_time: Optional[float] = None + self.end_time: Optional[float] = None + self.elapsed_time: Optional[float] = None + + def start(self): + """开始计时""" + self.start_time = time.perf_counter() + self.end_time = None + self.elapsed_time = None + + def stop(self): + """停止计时""" + if self.start_time is not None: + self.end_time = time.perf_counter() + self.elapsed_time = self.end_time - self.start_time + + def __enter__(self): + """上下文管理器入口""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """上下文管理器出口""" + self.stop() + + +class PerformanceTestResult: + """性能测试结果类""" + + def __init__( + self, + function_name: str, + execution_time: float, + success: bool, + result: Any = None, + ): + self.function_name = function_name + self.execution_time = execution_time + self.success = success + self.result = result + + def __str__(self): + status = "成功" if self.success else "失败" + return f"{self.function_name} - {status}, 耗时: {self.execution_time:.4f}秒" + + +class PerformanceBenchmarker: + """性能基准测试工具,用于批量测试函数性能""" + + def __init__(self): + self.results: List[PerformanceTestResult] = [] + + def benchmark(self, func: Callable, *args, **kwargs) -> PerformanceTestResult: + """执行单次性能测试""" + timer = PerformanceTimer() + success = False + result = None + + try: + timer.start() + result = func(*args, **kwargs) + timer.stop() + success = True + except Exception as e: + timer.stop() + print(f"[Error] {func.__name__} 执行失败: {e}") + + test_result = PerformanceTestResult( + function_name=func.__name__, + execution_time=timer.elapsed_time or 0, + success=success, + result=result, + ) + + self.results.append(test_result) + return test_result + + def run_multiple( + self, + func: Callable, + iterations: int = 5, + print_stats: bool = True, + *args, + **kwargs, + ) -> List[PerformanceTestResult]: + """多次执行性能测试,获取平均时间""" + test_results = [] + + total_start_time = time.perf_counter() + last_print_time = 0 + + for i in range(iterations): + # 使用\r实现同一行滚动显示进度,限制刷新频率避免拖慢速度 + current_time = time.perf_counter() + if iterations > 1 and print_stats: + # 每0.1秒或最后一次才刷新 + if current_time - last_print_time > 0.1 or i == iterations - 1: + print( + f"\r[Iteration {i+1}/{iterations}] - 进行中...", + end="", + flush=True, + ) + last_print_time = current_time + + result = self.benchmark(func, *args, **kwargs) + test_results.append(result) + + total_end_time = time.perf_counter() + total_wall_time = total_end_time - total_start_time + + # 完成后换行 + if iterations > 1 and print_stats: + print() + + # 计算统计信息 + if test_results and print_stats: + success_times = [r.execution_time for r in test_results if r.success] + if success_times: + avg_time = sum(success_times) / len(success_times) + max_time = max(success_times) + min_time = min(success_times) + total_execution_time = sum(success_times) + + print(f"\n[Statistics] {func.__name__}") + print(f" 平均时间: {avg_time:.4f} 秒") + print(f" 最大时间: {max_time:.4f} 秒") + print(f" 最小时间: {min_time:.4f} 秒") + print(f" 成功率: {len(success_times)}/{iterations}") + print(f" 总执行耗时 (Sum): {total_execution_time:.4f} 秒") + print(f" 总墙钟耗时 (Wall): {total_wall_time:.4f} 秒") + if total_wall_time > total_execution_time * 1.1: + print(f" 注意: 墙钟时间显著大于执行时间,可能存在系统开销或IO等待") + + return test_results + + def print_summary(self): + """打印所有测试结果摘要""" + print("\n" + "=" * 50) + print("性能测试结果摘要") + print("=" * 50) + + for result in self.results: + print(result) + + # 统计总览 + total_tests = len(self.results) + successful_tests = sum(1 for r in self.results if r.success) + avg_time_all = ( + sum(r.execution_time for r in self.results if r.success) / successful_tests + if successful_tests + else 0 + ) + + print(f"\n总览: {successful_tests}/{total_tests} 个测试成功") + if successful_tests: + print(f"平均执行时间: {avg_time_all:.4f} 秒") + print("=" * 50) class StressTestConfig: From 8d66dabf4eca173f5679f6ca72107dc570f836b9 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Wed, 17 Dec 2025 18:21:24 +0800 Subject: [PATCH 03/15] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=9A=84=E5=A4=9A=E7=BA=BF=E7=A8=8Bmain=E5=87=BD?= =?UTF-8?q?=E6=95=B0=EF=BC=8C=E4=BD=86=E6=98=AF=E5=A5=BD=E5=83=8F=E8=BF=98?= =?UTF-8?q?=E6=9C=89=E9=97=AE=E9=A2=98=EF=BC=8C=E5=85=88=E6=9A=82=E6=97=B6?= =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maa_mcp/adb.py | 12 +- maa_mcp/core.py | 2 + maa_mcp/pipeline_server.py | 721 +++++++++++++++++++++++++++++++++++++ maa_mcp/win32.py | 10 +- pyproject.toml | 1 + verify_server.py | 86 +++++ 6 files changed, 829 insertions(+), 3 deletions(-) create mode 100644 maa_mcp/pipeline_server.py create mode 100644 verify_server.py diff --git a/maa_mcp/adb.py b/maa_mcp/adb.py index 171b011..7fbe35d 100644 --- a/maa_mcp/adb.py +++ b/maa_mcp/adb.py @@ -68,8 +68,16 @@ def connect_adb_device(device_name: str) -> Optional[str]: if not adb_controller.post_connection().wait().succeeded: return None controller_id = object_registry.register(adb_controller) + + connection_params = { + "adb_path": device.adb_path, + "address": device.address, + "screencap_methods": device.screencap_methods, + "input_methods": device.input_methods, + "config": device.config, + } + controller_info_registry[controller_id] = ControllerInfo( - controller_type=ControllerType.ADB + controller_type=ControllerType.ADB, connection_params=connection_params ) return controller_id - diff --git a/maa_mcp/core.py b/maa_mcp/core.py index 1cedfbd..6acde5f 100644 --- a/maa_mcp/core.py +++ b/maa_mcp/core.py @@ -30,6 +30,8 @@ class ControllerInfo: """控制器信息,用于记录控制器类型和配置""" controller_type: ControllerType + # 连接参数,用于在子进程中重建控制器 + connection_params: dict # Win32 专用:键盘输入方式 keyboard_method: Optional[str] = None diff --git a/maa_mcp/pipeline_server.py b/maa_mcp/pipeline_server.py new file mode 100644 index 0000000..fb9db50 --- /dev/null +++ b/maa_mcp/pipeline_server.py @@ -0,0 +1,721 @@ +# pipeline_server.py +""" +多进程流水线 MCP 服务器 +====================== +真正可运行的 MCP 服务器入口,支持多进程后台监控。 + +使用方法: +1. 作为 MCP 服务器运行 (替代 __main__.py): + python maa_mcp/pipeline_server.py + +2. 运行测试: + python maa_mcp/pipeline_server.py --test +""" + +import os +import sys +import time +import json +import logging +import argparse +from multiprocessing import Process, Queue, Event, Manager +from queue import Empty +from typing import Optional, List, Dict, Any +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +# 导入 MaaFramework 相关 +try: + from maa.controller import AdbController, Win32Controller + from maa.resource import Resource + from maa.tasker import Tasker + from maa.pipeline import JRecognitionType, JOCR + from maa.define import MaaWin32ScreencapMethodEnum, MaaWin32InputMethodEnum +except ImportError: + pass + +# 导入 MCP Core 和 Registry +from maa_mcp.core import mcp, controller_info_registry, ControllerType, ControllerInfo +from maa_mcp.paths import get_resource_dir, get_screenshots_dir + +# 导入功能模块以注册基础工具 +import maa_mcp.adb +import maa_mcp.win32 +import maa_mcp.vision +import maa_mcp.control +import maa_mcp.utils +import maa_mcp.resource + +# ==================== 日志配置 ==================== + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("PipelineServer") + +# ==================== Win32 映射配置 ==================== + +_SCREENCAP_METHOD_MAP = { + "FramePool": MaaWin32ScreencapMethodEnum.FramePool, + "PrintWindow": MaaWin32ScreencapMethodEnum.PrintWindow, + "GDI": MaaWin32ScreencapMethodEnum.GDI, + "DXGI_DesktopDup_Window": MaaWin32ScreencapMethodEnum.DXGI_DesktopDup_Window, + "ScreenDC": MaaWin32ScreencapMethodEnum.ScreenDC, + "DXGI_DesktopDup": MaaWin32ScreencapMethodEnum.DXGI_DesktopDup, +} + +_MOUSE_METHOD_MAP = { + "PostMessage": MaaWin32InputMethodEnum.PostMessage, + "PostMessageWithCursorPos": MaaWin32InputMethodEnum.PostMessageWithCursorPos, + "Seize": MaaWin32InputMethodEnum.Seize, +} + +_KEYBOARD_METHOD_MAP = { + "PostMessage": MaaWin32InputMethodEnum.PostMessage, + "Seize": MaaWin32InputMethodEnum.Seize, +} + +# ==================== 配置 ==================== + + +@dataclass +class PipelineConfig: + """流水线配置""" + + screenshot_fps: float = 2.0 # 截图帧率 + message_queue_size: int = 100 # 消息队列大小 + similarity_threshold: int = 5 # 图像相似度阈值 + enable_dedup: bool = True # 启用消息去重 + + +# ==================== MAA 工具接口 ==================== + + +class IMaaTool: + """MAA 工具接口""" + + def screencap(self, controller_id: str) -> Optional[str]: ... + def ocr(self, controller_id: str) -> List[Dict]: ... + def click(self, controller_id: str, x: int, y: int, duration: int = 50) -> bool: ... + def input_text(self, controller_id: str, text: str) -> bool: ... + + +# ==================== 真实 MAA 工具 ==================== + + +class RealMAATool(IMaaTool): + """ + 真实 MAA 工具实现 + 在子进程中重新连接设备并执行操作 + """ + + def __init__(self, controller_type: ControllerType, params: dict): + self.logger = logging.getLogger("RealMAA") + self.controller = None + self.tasker = None + self.resource = None + + self.logger.info(f"初始化真实 MAA 工具: {controller_type}, 参数: {params}") + + try: + if controller_type == ControllerType.ADB: + self.controller = AdbController( + adb_path=params.get("adb_path"), + address=params.get("address"), + screencap_methods=params.get("screencap_methods", 0), + input_methods=params.get("input_methods", 0), + config=params.get("config", "{}"), + ) + + elif controller_type == ControllerType.WIN32: + hwnd = params.get("hwnd") + screencap = _SCREENCAP_METHOD_MAP.get( + params.get("screencap_method"), + MaaWin32ScreencapMethodEnum.FramePool, + ) + mouse = _MOUSE_METHOD_MAP.get( + params.get("mouse_method"), MaaWin32InputMethodEnum.PostMessage + ) + keyboard = _KEYBOARD_METHOD_MAP.get( + params.get("keyboard_method"), MaaWin32InputMethodEnum.PostMessage + ) + + self.controller = Win32Controller( + hwnd=hwnd, + screencap_method=screencap, + mouse_method=mouse, + keyboard_method=keyboard, + ) + + if self.controller: + self.controller.post_connection().wait() + + # 初始化资源 + self.resource = Resource() + res_path = get_resource_dir() + self.resource.post_bundle(str(res_path)).wait() + + # 初始化 Tasker + self.tasker = Tasker() + self.tasker.bind(self.resource, self.controller) + + except Exception as e: + self.logger.error(f"MAA 初始化失败: {e}") + import traceback + + traceback.print_exc() + + def screencap(self, controller_id: str) -> Optional[str]: + if not self.controller: + return None + try: + image = self.controller.post_screencap().wait().get() + if image is None: + return None + + import cv2 + + temp_dir = get_screenshots_dir() + temp_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filepath = temp_dir / f"pipeline_{timestamp}.png" + cv2.imwrite(str(filepath), image) + return str(filepath) + except Exception as e: + self.logger.error(f"截图失败: {e}") + return None + + def ocr(self, controller_id: str) -> List[Dict]: + if not self.tasker: + return [] + try: + # 获取截图用于 OCR + image = self.controller.post_screencap().wait().get() + if image is None: + return [] + + info = ( + self.tasker.post_recognition(JRecognitionType.OCR, JOCR(), image) + .wait() + .get() + ) + if not info or not info.nodes: + return [] + + results = [] + for result in info.nodes[0].recognition.all_results: + # 转换 OCR 结果为简单字典 + results.append( + { + "text": result.text, + "x": result.rect.x, + "y": result.rect.y, + "w": result.rect.width, + "h": result.rect.height, + # 如果有 score 字段则添加 + "score": getattr(result, "score", 0.99), + } + ) + return results + except Exception as e: + self.logger.error(f"OCR 失败: {e}") + return [] + + def click(self, controller_id: str, x: int, y: int, duration: int = 50) -> bool: + if not self.controller: + return False + try: + self.controller.post_click(x, y).wait() + return True + except Exception as e: + self.logger.error(f"点击失败: {e}") + return False + + def input_text(self, controller_id: str, text: str) -> bool: + if not self.controller: + return False + try: + self.controller.post_input_text(text).wait() + return True + except Exception as e: + self.logger.error(f"输入失败: {e}") + return False + + +# ==================== 模拟 MAA 工具 ==================== + + +class MockMAATool(IMaaTool): + """ + 模拟 MAA 工具(用于测试) + """ + + def __init__(self): + self.logger = logging.getLogger("MockMAA") + self._frame_count = 0 + self._message_templates = [ + "你好", + "在吗?", + "今天天气真好", + "有什么新消息吗", + "帮我查一下", + "谢谢", + "好的", + "收到", + ] + + def screencap(self, controller_id: str) -> Optional[str]: + self._frame_count += 1 + temp_dir = Path("./temp_screenshots") + temp_dir.mkdir(exist_ok=True) + filepath = temp_dir / f"frame_{self._frame_count}.png" + filepath.write_text(f"mock_frame_{self._frame_count}") + return str(filepath) + + def ocr(self, controller_id: str) -> List[Dict]: + import random + + results = [] + results.append({"text": "微信", "x": 540, "y": 50, "score": 0.99}) + results.append({"text": "发送", "x": 950, "y": 1800, "score": 0.98}) + if random.random() < 0.3: + msg = random.choice(self._message_templates) + results.append( + { + "text": f"{msg}_{int(time.time()) % 1000}", + "x": 200, + "y": random.randint(300, 1500), + "score": 0.95, + } + ) + return results + + def click(self, controller_id: str, x: int, y: int, duration: int = 50) -> bool: + msg = f"点击: ({x}, {y})" + self.logger.info(msg) + # print(f"[MockMAA] {msg}") + time.sleep(duration / 1000) + return True + + def input_text(self, controller_id: str, text: str) -> bool: + msg = f"输入: {text}" + self.logger.info(msg) + # print(f"[MockMAA] {msg}") + time.sleep(0.1) + return True + + +# ==================== 流水线状态管理 ==================== + + +class PipelineState: + """流水线全局状态(单例,线程版)""" + + _instance = None + _lock = Lock() # 类属性:全局共享锁 + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._initialized = True + self.is_running = False + self.stop_event = Event() + self.pipeline_thread: Optional[threading.Thread] = None + self.message_queue = Queue(maxsize=100) + self.stats_dict = {} + self.last_screen_state = {} + self.controller_id: Optional[str] = None + self.reset() + + def reset(self): + with PipelineState._lock: + self.is_running = False + self.stop_event.clear() + temp_queue = Queue(maxsize=100) + while not self.message_queue.empty(): + try: + temp_queue.put_nowait(self.message_queue.get_nowait()) + except Empty: + break + self.message_queue = temp_queue # 清空队列 + self.stats_dict = { + "frame_count": 0, + "ocr_count": 0, + "new_message_count": 0, + "start_time": 0, + "last_update": 0, + } + self.last_screen_state = {} + + +# 懒加载全局状态 +pipeline_state = None + + +def get_pipeline_state() -> PipelineState: + global pipeline_state + if pipeline_state is None: + pipeline_state = PipelineState() + return pipeline_state + + +# ==================== 流水线核心逻辑 ==================== + + +def run_pipeline_loop( + controller_id: str, + controller_type: Optional[str], + shared_controller, # 新增:共享 controller + shared_tasker, # 新增:共享 tasker + config_dict: Dict, + stop_event: Event, + message_queue: Queue, + stats_dict: Dict, + last_screen_state: Dict, +): + """流水线主循环(线程版,共享 MAA 实例)""" + from maa_mcp.core import PipelineState # 访问 lock + + pipeline_state = get_pipeline_state() + thread_logger = logging.getLogger("PipelineLoop") + thread_logger.info(f"流水线线程启动,控制器: {controller_id}") + + # 使用共享实例,无需重建 + if controller_id == "test_device": + thread_logger.info("使用 MockMAA 工具") + maa_tool = MockMAATool() + else: + thread_logger.info("使用共享 RealMAA 组件") + + # maa_tool 封装共享调用 + class SharedMAATool: + def __init__(self, controller, tasker): + self.controller = controller + self.tasker = tasker + self.lock = Lock() # 每个调用加锁 + + def ocr(self, cid): + with self.lock: + try: + image = self.controller.post_screencap().wait().get() + if image is None: + return [] + info = ( + self.tasker.post_recognition( + JRecognitionType.OCR, JOCR(), image + ) + .wait() + .get() + ) + if not info or not info.nodes: + return [] + results = [] + for result in info.nodes[0].recognition.all_results: + results.append( + { + "text": result.text, + "x": result.rect.x, + "y": result.rect.y, + "w": result.rect.width, + "h": result.rect.height, + "score": getattr(result, "score", 0.99), + } + ) + return results + except Exception as e: + thread_logger.error(f"OCR 失败: {e}") + return [] + + maa_tool = SharedMAATool(shared_controller, shared_tasker) + + fps = config_dict.get("fps", 2.0) + enable_dedup = config_dict.get("enable_dedup", True) + last_texts = set() + frame_count = 0 + interval = 1.0 / fps + + while not stop_event.is_set(): + try: + loop_start = time.time() + frame_count += 1 + + ocr_results = maa_tool.ocr(controller_id) + if not ocr_results: + time.sleep(interval) + continue + + # 提取文本等逻辑不变 + current_texts = set() + text_details = {} + for item in ocr_results: + text = item.get("text", "") + if text: + current_texts.add(text) + text_details[text] = item + + if enable_dedup: + new_texts = current_texts - last_texts + else: + new_texts = current_texts + + ui_elements = {"微信", "发送", "输入", "语音", "表情", "更多"} + new_texts = {t for t in new_texts if not any(ui in t for ui in ui_elements)} + + for text in new_texts: + item = text_details.get(text, {}) + message_data = { + "text": text, + "x": item.get("x", 0), + "y": item.get("y", 0), + "score": item.get("score", 0), + "timestamp": time.time(), + "frame_id": frame_count, + } + try: + message_queue.put_nowait(message_data) + with pipeline_state._lock: + stats_dict["new_message_count"] = ( + stats_dict.get("new_message_count", 0) + 1 + ) + thread_logger.info(f"🆕 新消息: {text}") + except: + pass + + last_texts = current_texts + with pipeline_state._lock: + stats_dict["frame_count"] = frame_count + stats_dict["ocr_count"] = stats_dict.get("ocr_count", 0) + 1 + stats_dict["last_update"] = time.time() + last_screen_state["texts"] = list(current_texts) + last_screen_state["timestamp"] = time.time() + + elapsed = time.time() - loop_start + sleep_time = max(0, interval - elapsed) + if sleep_time > 0: + time.sleep(sleep_time) + + except Exception as e: + thread_logger.error(f"流水线异常: {e}") + time.sleep(1) + + thread_logger.info("流水线线程已停止") + + +# ==================== MCP 工具实现 ==================== + + +def _start_pipeline_impl(controller_id: str, fps: float = 2.0) -> str: + """启动流水线实现""" + try: + pipeline_state = get_pipeline_state() + if pipeline_state.is_running: + return "⚠️ 流水线已经在运行中" + + # 获取控制器信息 + ctype_str = None + cparams = None + + if controller_id != "test_device": + info = controller_info_registry.get(controller_id) + if not info: + return f"❌ 未找到控制器: {controller_id},请先连接设备" + + ctype_str = info.controller_type.name # "ADB" or "WIN32" + cparams = info.connection_params + if not cparams: + return f"❌ 控制器 {controller_id} 缺少连接参数,无法在后台进程重建" + + pipeline_state.reset() + pipeline_state.controller_id = controller_id + pipeline_state.stats_dict["start_time"] = time.time() + + logger.info(f"正在启动流水线进程, controller_id={controller_id}") + + pipeline_state.pipeline_process = Process( + target=run_pipeline_loop, + args=( + controller_id, + ctype_str, + cparams, + {"fps": fps, "enable_dedup": True}, + pipeline_state.stop_event, + pipeline_state.message_queue, + pipeline_state.stats_dict, + pipeline_state.last_screen_state, + ), + daemon=True, + ) + pipeline_state.pipeline_process.start() + pipeline_state.is_running = True + + return f"✅ 流水线已启动 (PID: {pipeline_state.pipeline_process.pid})" + except Exception as e: + logger.exception("启动流水线失败") + return f"❌ 启动流水线失败: {str(e)}" + + +def _stop_pipeline_impl() -> str: + """停止流水线实现""" + pipeline_state = get_pipeline_state() + if not pipeline_state.is_running: + return "⚠️ 流水线未在运行" + + pipeline_state.stop_event.set() + if pipeline_state.pipeline_process: + pipeline_state.pipeline_process.join(timeout=5) + if pipeline_state.pipeline_process.is_alive(): + pipeline_state.pipeline_process.terminate() + + pipeline_state.is_running = False + return "✅ 流水线已停止" + + +def _get_new_messages_impl(max_count: int = 10) -> List[Dict[str, Any]]: + """获取消息实现""" + pipeline_state = get_pipeline_state() + messages = [] + for _ in range(max_count): + try: + messages.append(pipeline_state.message_queue.get_nowait()) + except Empty: + break + return messages + + +def _get_pipeline_status_impl() -> Dict[str, Any]: + """获取状态实现(线程版)""" + pipeline_state = get_pipeline_state() + with PipelineState._lock: + stats = dict(pipeline_state.stats_dict) + start_time = stats.get("start_time", 0) + uptime = time.time() - start_time if start_time > 0 else 0 + return { + "is_running": pipeline_state.is_running, + "controller_id": pipeline_state.controller_id, + "uptime": round(uptime, 1), + "frame_count": stats.get("frame_count", 0), + "new_messages": stats.get("new_message_count", 0), + "pending": pipeline_state.message_queue.qsize(), + } + + +def _pipeline_send_reply_impl(text: str) -> bool: + """发送回复实现""" + pipeline_state = get_pipeline_state() + if not pipeline_state.controller_id: + return False + + cid = pipeline_state.controller_id + + if cid == "test_device": + tool = MockMAATool() + tool.click(cid, 540, 1700) + tool.input_text(cid, text) + tool.click(cid, 950, 1800) + return True + + try: + from maa_mcp.control import click, input_text + + click(cid, 540, 1700) + time.sleep(0.3) + input_text(cid, text) + time.sleep(0.2) + click(cid, 950, 1800) + return True + except Exception as e: + logger.error(f"发送回复失败: {e}") + return False + + +# ==================== MCP 工具注册 ==================== + + +@mcp.tool() +def start_pipeline(controller_id: str, fps: float = 2.0) -> str: + """ + 启动后台监控流水线。 + + Args: + controller_id: 设备控制器ID (需先连接设备) + fps: 截图帧率(默认2.0) + """ + return _start_pipeline_impl(controller_id, fps) + + +@mcp.tool() +def stop_pipeline() -> str: + """停止后台监控流水线。""" + return _stop_pipeline_impl() + + +@mcp.tool() +def get_new_messages(max_count: int = 10) -> List[Dict[str, Any]]: + """获取新检测到的消息(非阻塞)。""" + return _get_new_messages_impl(max_count) + + +@mcp.tool() +def get_pipeline_status() -> Dict[str, Any]: + """获取流水线运行状态。""" + return _get_pipeline_status_impl() + + +@mcp.tool() +def pipeline_send_reply(text: str) -> bool: + """ + (流水线专用) 发送回复消息。 + 使用当前流水线绑定的控制器发送消息。 + """ + return _pipeline_send_reply_impl(text) + + +# ==================== 测试与主入口 ==================== + + +def run_test(): + """运行本地测试""" + print("=" * 60) + print("🧪 流水线本地测试 (使用 MockMAA)") + print("=" * 60) + + _start_pipeline_impl("test_device", fps=2.0) + + print("运行中 (10s)...") + for _ in range(10): + time.sleep(1) + msgs = _get_new_messages_impl() + if msgs: + for m in msgs: + print(f"📩 [{m['timestamp']}] {m['text']}") + + print("发送回复测试...") + _pipeline_send_reply_impl("Test Reply") + + _stop_pipeline_impl() + print("测试完成") + + +def main(): + parser = argparse.ArgumentParser(description="MaaMCP Pipeline Server") + parser.add_argument("--test", action="store_true", help="运行本地测试") + args = parser.parse_args() + + if args.test: + run_test() + else: + # 启动 MCP 服务器 + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/maa_mcp/win32.py b/maa_mcp/win32.py index d1fbbed..ed600df 100644 --- a/maa_mcp/win32.py +++ b/maa_mcp/win32.py @@ -125,9 +125,17 @@ def connect_window( if not window_controller.post_connection().wait().succeeded: return None controller_id = object_registry.register(window_controller) + + connection_params = { + "hwnd": window.hwnd, + "screencap_method": screencap_method, + "mouse_method": mouse_method, + "keyboard_method": keyboard_method, + } + controller_info_registry[controller_id] = ControllerInfo( controller_type=ControllerType.WIN32, + connection_params=connection_params, keyboard_method=keyboard_method, ) return controller_id - diff --git a/pyproject.toml b/pyproject.toml index d13dea5..dce5bcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ Issues = "https://github.com/MistEO/MaaMCP/issues" [project.scripts] maa-mcp = "maa_mcp.__main__:main" +maa-mcp-server = "maa_mcp.pipeline_server:main" [tool.hatch.version] source = "vcs" diff --git a/verify_server.py b/verify_server.py new file mode 100644 index 0000000..6b67f63 --- /dev/null +++ b/verify_server.py @@ -0,0 +1,86 @@ +import asyncio +import sys +import os +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + + +async def run(): + # 获取当前 python 解释器路径 + python_executable = sys.executable + script_path = os.path.join( + os.path.dirname(__file__), "maa_mcp", "pipeline_server.py" + ) + project_root = os.path.dirname(__file__) + + print(f"🔌 正在连接到服务器: {script_path}") + + # 设置环境变量,确保 Python 路径正确,且输出不缓冲 + env = os.environ.copy() + env["PYTHONPATH"] = project_root + env["PYTHONUNBUFFERED"] = "1" + + server_params = StdioServerParameters( + command=python_executable, args=[script_path], env=env + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # 1. 初始化 + print("🚀 发送初始化请求...") + await session.initialize() + print("✅ 初始化成功!") + + # 2. 列出工具 + print("\n🛠️ 获取工具列表...") + tools = await session.list_tools() + print(f"✅ 成功获取 {len(tools.tools)} 个工具:") + for tool in tools.tools: + print( + f" - {tool.name}: {tool.description.splitlines()[0] if tool.description else 'No description'}" + ) + + # 3. 测试 start_pipeline 工具 + print("\n🧪 测试 start_pipeline 工具...") + try: + # 使用测试设备 ID + result = await session.call_tool( + "start_pipeline", + arguments={"controller_id": "test_device", "fps": 2.0}, + ) + print(f"✅ 调用成功,返回结果:\n{result.content[0].text}") + except Exception as e: + print(f"❌ 调用失败: {e}") + + # 4. 等待几秒 + print("\n⏳ 等待 3 秒...") + await asyncio.sleep(3) + + # 5. 获取新消息 + print("\n📩 获取新消息...") + try: + result = await session.call_tool("get_new_messages", arguments={}) + # get_new_messages 返回的是 list,mcp 协议层会包装成 TextContent + # FastMCP 可能会将其序列化为 JSON 字符串 + print(f"✅ 消息内容:\n{result.content[0].text}") + except Exception as e: + print(f"❌ 获取消息失败: {e}") + + # 6. 停止流水线 + print("\n🛑 停止流水线...") + try: + result = await session.call_tool("stop_pipeline", arguments={}) + print(f"✅ 停止结果: {result.content[0].text}") + except Exception as e: + print(f"❌ 停止失败: {e}") + + print("\n✨ 验证完成!服务器运行正常。") + + +if __name__ == "__main__": + try: + asyncio.run(run()) + except KeyboardInterrupt: + print("\n用户取消") + except Exception as e: + print(f"\n❌ 发生错误: {e}") From ae74b3716336afccd2222d8caba1464921c160b0 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Thu, 18 Dec 2025 18:42:54 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=E5=A4=9A=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=E8=BF=90=E8=A1=8C=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E6=97=A8=E5=9C=A8=E5=90=8E=E5=8F=B0=E5=A4=9A=E5=BC=80?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E7=BA=BF=E7=A8=8B=E5=A4=84=E7=90=86=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E6=93=8D=E4=BD=9C=EF=BC=8C=E7=84=B6=E5=90=8E=E4=B8=BB?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E5=B0=B1=E4=B8=8D=E7=94=A8ai=E6=9D=A5?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E6=88=AA=E5=9B=BE=E4=BA=86=EF=BC=8C=E5=87=8F?= =?UTF-8?q?=E5=B0=91=E4=BA=86=E9=83=A8=E5=88=86=E6=B5=81=E7=A8=8B=E7=9A=84?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maa_mcp/core.py | 54 ++- maa_mcp/paths.py | 11 + maa_mcp/pipeline/__init__.py | 27 ++ maa_mcp/pipeline/config.py | 103 ++++ maa_mcp/pipeline/logging_config.py | 108 +++++ maa_mcp/pipeline/state.py | 127 +++++ maa_mcp/pipeline_server.py | 730 +++++++---------------------- maa_mcp/vision.py | 85 ++-- pyproject.toml | 1 + verify_server.py | 86 ---- 10 files changed, 646 insertions(+), 686 deletions(-) create mode 100644 maa_mcp/pipeline/__init__.py create mode 100644 maa_mcp/pipeline/config.py create mode 100644 maa_mcp/pipeline/logging_config.py create mode 100644 maa_mcp/pipeline/state.py delete mode 100644 verify_server.py diff --git a/maa_mcp/core.py b/maa_mcp/core.py index 6acde5f..3fd70cd 100644 --- a/maa_mcp/core.py +++ b/maa_mcp/core.py @@ -57,23 +57,67 @@ class ControllerInfo: - 每个设备/窗口拥有独立的控制器 ID(controller_id) - 通过在操作时指定不同的 controller_id 实现多设备协同自动化 - 标准工作流程: - 1. 设备/窗口发现与连接 + ⭐ 双模式运行支持: + - 串行模式(流程 1 + 2):传统的同步执行方式,一个指令完成后再执行下一个 + - 流水线模式(流程 1 + 3):多线程异步执行方式,后台持续采集屏幕信息,主线程专注于决策和操作 + + ======================== + 标准工作流程 + ======================== + + 1. 设备/窗口发现与连接(必选,两种模式通用) - 调用 find_adb_device_list() 扫描可用的 ADB 设备 - 调用 find_window_list() 扫描可用的 Windows 窗口 - 若发现多个设备/窗口,需向用户展示列表并等待用户选择需要操作的目标 - 使用 connect_adb_device(device_name) 或 connect_window(window_name) 建立连接 - 可连接多个设备/窗口,每个连接返回独立的控制器 ID - 2. 自动化执行循环 + 2. 串行自动化执行循环(流程 1 之后选择此流程进入串行模式) - 调用 ocr(controller_id) 对指定设备进行屏幕截图和 OCR 识别 - - 首次使用时,如果 OCR 模型文件不存在,ocr() 会返回提示信息,需要调用 check_and_download_ocr() 下载资源 + - 首次使用时,如果 OCR 模型文件不存在, ocr() 会返回提示信息,需要调用 check_and_download_ocr() 下载资源 - 下载完成后即可正常使用 OCR 功能,后续调用无需再次下载 - 根据识别结果调用 click()、double_click()、scroll()、swipe() 等执行相应操作 - 所有操作通过 controller_id 指定目标设备/窗口 - 可在多个设备间切换操作,实现协同自动化 + - 特点:每次操作需等待 OCR 完成,适合简单任务或对实时性要求不高的场景 + + 3. 流水线自动化执行(流程 1 之后选择此流程进入多线程流水线模式) + ⭐ 适用场景:需要高频屏幕监控、实时响应的自动化任务 + + 3.1 启动流水线 + - 调用 start_pipeline(controller_id) 启动指定控制器的流水线 + - 流水线会在后台启动独立线程,按固定频率自动截图并缓存图片路径 + - 截图路径会自动推送到消息队列中 + - 启动流水线后,等待约 1 秒让流水线进行初始缓存 + + 3.2 获取流水线状态和截图 + - 调用 get_pipeline_status() 检测流水线运行状态和待处理消息数量 + - 如果有新消息,调用 get_new_messages() 获取最新的截图路径 + - 消息包含 type(固定为 "screenshot")、image_path(截图文件路径)、timestamp、frame_id + + 3.3 分析截图并执行操作 + - 读取 image_path 中的图片内容,进行视觉分析 + - 根据图片内容判断是否需要执行 OCR(调用 ocr 工具获取文字信息) + - 根据分析结果调用 click()、double_click()、scroll()、swipe() 等执行相应操作 + - 所有操作通过 controller_id 指定目标设备/窗口 + - 可在多个设备间切换操作,实现协同自动化 + - 操作完成后继续循环 3.2 和 3.3,直到任务完成 + + 3.4 停止流水线 + - 任务完成后,调用 stop_pipeline() 停止流水线 + - 释放后台线程资源 + + 流水线模式优势: + - 后台持续截图,大模型可直接查看完整画面进行决策 + - 大模型可根据图片内容自行决定是否需要 OCR、具体 OCR 哪个区域 + - 支持高频屏幕监控,不错过任何界面变化 + - 适合需要快速响应的实时自动化任务 + - 消息队列机制,支持异步处理和历史数据查询 + + ======================== + 屏幕识别策略(重要) + ======================== - 屏幕识别策略(重要): - 优先使用 OCR:始终优先调用 ocr() 进行文字识别,OCR 返回结构化文本数据,token 消耗极低 - 按需使用截图:仅当以下情况时,才调用 screencap() 获取截图,再通过 read_file 读取图片进行视觉识别: 1. OCR 结果不足以做出决策(如需要识别图标、图像、颜色、布局等非文字信息) diff --git a/maa_mcp/paths.py b/maa_mcp/paths.py index 275ca83..96c0d8a 100644 --- a/maa_mcp/paths.py +++ b/maa_mcp/paths.py @@ -67,6 +67,16 @@ def get_screenshots_dir() -> Path: return get_data_dir() / "screenshots" +def get_logs_dir() -> Path: + """ + 获取日志目录路径 + + Returns: + 日志目录路径 (data_dir/logs) + """ + return get_data_dir() / "logs" + + def ensure_dirs() -> None: """ 确保所有必要的目录存在 @@ -76,6 +86,7 @@ def ensure_dirs() -> None: get_model_dir(), get_ocr_dir(), get_screenshots_dir(), + get_logs_dir(), ] for d in dirs: d.mkdir(parents=True, exist_ok=True) diff --git a/maa_mcp/pipeline/__init__.py b/maa_mcp/pipeline/__init__.py new file mode 100644 index 0000000..0bc7d27 --- /dev/null +++ b/maa_mcp/pipeline/__init__.py @@ -0,0 +1,27 @@ +# maa_mcp/pipeline/__init__.py +""" +Pipeline 模块 +============= +流水线服务器的核心组件。 + +包含: +- logging_config: 日志配置 +- config: 流水线配置和常量 +- state: 流水线状态管理 +""" + +from .logging_config import setup_logger, get_logger +from .config import PipelineConfig, UI_ELEMENTS_FILTER +from .state import PipelineState, get_pipeline_state + +__all__ = [ + # 日志 + "setup_logger", + "get_logger", + # 配置 + "PipelineConfig", + "UI_ELEMENTS_FILTER", + # 状态管理 + "PipelineState", + "get_pipeline_state", +] diff --git a/maa_mcp/pipeline/config.py b/maa_mcp/pipeline/config.py new file mode 100644 index 0000000..3e4f7b4 --- /dev/null +++ b/maa_mcp/pipeline/config.py @@ -0,0 +1,103 @@ +# maa_mcp/pipeline/config.py +""" +配置模块 +======== +流水线相关的配置类和常量映射。 +""" + +from dataclasses import dataclass +from typing import Dict, Any + +# 导入 MaaFramework 枚举(可选) +try: + from maa.define import MaaWin32ScreencapMethodEnum, MaaWin32InputMethodEnum + + _MAA_AVAILABLE = True +except ImportError: + _MAA_AVAILABLE = False + MaaWin32ScreencapMethodEnum = None + MaaWin32InputMethodEnum = None + + +@dataclass +class PipelineConfig: + """流水线配置""" + + screenshot_fps: float = 2.0 # 截图帧率 + message_queue_size: int = 100 # 消息队列大小 + similarity_threshold: int = 5 # 图像相似度阈值 + enable_dedup: bool = True # 启用消息去重 + + +class Win32MethodMaps: + """Win32 方法映射配置""" + + @staticmethod + def get_screencap_map() -> Dict[str, Any]: + """获取截图方法映射""" + if not _MAA_AVAILABLE: + return {} + return { + "FramePool": MaaWin32ScreencapMethodEnum.FramePool, + "PrintWindow": MaaWin32ScreencapMethodEnum.PrintWindow, + "GDI": MaaWin32ScreencapMethodEnum.GDI, + "DXGI_DesktopDup_Window": MaaWin32ScreencapMethodEnum.DXGI_DesktopDup_Window, + "ScreenDC": MaaWin32ScreencapMethodEnum.ScreenDC, + "DXGI_DesktopDup": MaaWin32ScreencapMethodEnum.DXGI_DesktopDup, + } + + @staticmethod + def get_mouse_map() -> Dict[str, Any]: + """获取鼠标方法映射""" + if not _MAA_AVAILABLE: + return {} + return { + "PostMessage": MaaWin32InputMethodEnum.PostMessage, + "PostMessageWithCursorPos": MaaWin32InputMethodEnum.PostMessageWithCursorPos, + "Seize": MaaWin32InputMethodEnum.Seize, + } + + @staticmethod + def get_keyboard_map() -> Dict[str, Any]: + """获取键盘方法映射""" + if not _MAA_AVAILABLE: + return {} + return { + "PostMessage": MaaWin32InputMethodEnum.PostMessage, + "Seize": MaaWin32InputMethodEnum.Seize, + } + + @classmethod + def get_screencap_method(cls, method_name: str, default=None): + """获取截图方法枚举值""" + screencap_map = cls.get_screencap_map() + if default is None and _MAA_AVAILABLE: + default = MaaWin32ScreencapMethodEnum.FramePool + return screencap_map.get(method_name, default) + + @classmethod + def get_mouse_method(cls, method_name: str, default=None): + """获取鼠标方法枚举值""" + mouse_map = cls.get_mouse_map() + if default is None and _MAA_AVAILABLE: + default = MaaWin32InputMethodEnum.PostMessage + return mouse_map.get(method_name, default) + + @classmethod + def get_keyboard_method(cls, method_name: str, default=None): + """获取键盘方法枚举值""" + keyboard_map = cls.get_keyboard_map() + if default is None and _MAA_AVAILABLE: + default = MaaWin32InputMethodEnum.PostMessage + return keyboard_map.get(method_name, default) + + +# UI 元素过滤列表(用于消息去重时过滤 UI 文本) +UI_ELEMENTS_FILTER = {"微信", "发送", "输入", "语音", "表情", "更多"} + + +__all__ = [ + "PipelineConfig", + "Win32MethodMaps", + "UI_ELEMENTS_FILTER", +] diff --git a/maa_mcp/pipeline/logging_config.py b/maa_mcp/pipeline/logging_config.py new file mode 100644 index 0000000..cd3f72e --- /dev/null +++ b/maa_mcp/pipeline/logging_config.py @@ -0,0 +1,108 @@ +# maa_mcp/pipeline/logging_config.py +""" +日志配置模块 +============ +使用 loguru 配置日志输出到控制台和文件。 +""" + +import sys +from pathlib import Path +from typing import Optional + +from loguru import logger + +from maa_mcp.paths import get_logs_dir + + +# 标记是否已初始化 +_initialized = False + + +def setup_logger( + console_level: str = "INFO", + file_level: str = "DEBUG", + error_retention: str = "30 days", + log_retention: str = "7 days", +) -> None: + """ + 配置 loguru 日志系统。 + + Args: + console_level: 控制台日志级别 + file_level: 文件日志级别 + error_retention: 错误日志保留时间 + log_retention: 普通日志保留时间 + """ + global _initialized + + if _initialized: + return + + # 获取日志目录 + logs_dir = get_logs_dir() + logs_dir.mkdir(parents=True, exist_ok=True) + + # 移除默认 handler + logger.remove() + + # 添加控制台输出 + logger.add( + sys.stderr, + format="{time:HH:mm:ss} | {level: <8} | {extra[module]}:{function}:{line} - {message}", + level=console_level, + filter=lambda record: "module" in record["extra"], + ) + + # 为没有 module 的日志添加默认格式 + logger.add( + sys.stderr, + format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level=console_level, + filter=lambda record: "module" not in record["extra"], + ) + + # 添加文件输出 - 按日期轮转 + logger.add( + logs_dir / "pipeline_{time:YYYY-MM-DD}.log", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}", + level=file_level, + rotation="00:00", # 每天午夜轮转 + retention=log_retention, + compression="zip", # 压缩旧日志 + encoding="utf-8", + ) + + # 添加错误日志单独文件 + logger.add( + logs_dir / "pipeline_error_{time:YYYY-MM-DD}.log", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}", + level="ERROR", + rotation="00:00", + retention=error_retention, + compression="zip", + encoding="utf-8", + ) + + _initialized = True + logger.bind(module="Logger").info(f"日志系统初始化完成,日志目录: {logs_dir}") + + +def get_logger(module: str = "Pipeline"): + """ + 获取带模块标识的 logger。 + + Args: + module: 模块名称标识 + + Returns: + 绑定了模块名的 logger 实例 + """ + # 确保已初始化 + if not _initialized: + setup_logger() + + return logger.bind(module=module) + + +# 模块级别的便捷导出 +__all__ = ["setup_logger", "get_logger", "logger"] diff --git a/maa_mcp/pipeline/state.py b/maa_mcp/pipeline/state.py new file mode 100644 index 0000000..58ebce2 --- /dev/null +++ b/maa_mcp/pipeline/state.py @@ -0,0 +1,127 @@ +# maa_mcp/pipeline/state.py +""" +状态管理模块 +============ +流水线状态管理类。 +""" + +import threading +from threading import Lock, Event +from queue import Queue, Empty +from typing import Optional, Dict, Any + + +class PipelineState: + """ + 流水线全局状态(单例,线程安全) + + 管理流水线的运行状态、消息队列和统计信息。 + """ + + _instance = None + _lock = Lock() # 类属性:全局共享锁 + + def __new__(cls): + if cls._instance is None: + with cls._lock: + # 双重检查锁定 + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._initialized = True + self.is_running = False + self.stop_event = Event() + self.pipeline_thread: Optional[threading.Thread] = None + self.message_queue = Queue(maxsize=100) + self.stats_dict: Dict[str, Any] = {} + self.last_screen_state: Dict[str, Any] = {} + self.controller_id: Optional[str] = None + self.reset() + + def reset(self): + """重置流水线状态""" + with PipelineState._lock: + self.is_running = False + self.stop_event.clear() + # 清空队列 + while not self.message_queue.empty(): + try: + self.message_queue.get_nowait() + except Empty: + break + self.stats_dict = { + "frame_count": 0, + "ocr_count": 0, + "new_message_count": 0, + "start_time": 0, + "last_update": 0, + } + self.last_screen_state = {} + self.controller_id = None + + def start(self, controller_id: str): + """标记流水线启动""" + with PipelineState._lock: + self.is_running = True + self.controller_id = controller_id + + def stop(self): + """标记流水线停止""" + with PipelineState._lock: + self.is_running = False + self.stop_event.set() + + def update_stats(self, **kwargs): + """更新统计信息""" + with PipelineState._lock: + for key, value in kwargs.items(): + self.stats_dict[key] = value + + def increment_stat(self, key: str, amount: int = 1): + """增加统计计数""" + with PipelineState._lock: + self.stats_dict[key] = self.stats_dict.get(key, 0) + amount + + def get_stats(self) -> Dict[str, Any]: + """获取统计信息副本""" + with PipelineState._lock: + return dict(self.stats_dict) + + def update_screen_state(self, texts: list, timestamp: float): + """更新屏幕状态""" + with PipelineState._lock: + self.last_screen_state["texts"] = texts + self.last_screen_state["timestamp"] = timestamp + + def get_screen_state(self) -> Dict[str, Any]: + """获取屏幕状态副本""" + with PipelineState._lock: + return dict(self.last_screen_state) + + +# 全局状态实例(懒加载) +_pipeline_state: Optional[PipelineState] = None + + +def get_pipeline_state() -> PipelineState: + """ + 获取流水线状态单例实例 + + Returns: + PipelineState 实例 + """ + global _pipeline_state + if _pipeline_state is None: + _pipeline_state = PipelineState() + return _pipeline_state + + +__all__ = [ + "PipelineState", + "get_pipeline_state", +] diff --git a/maa_mcp/pipeline_server.py b/maa_mcp/pipeline_server.py index fb9db50..a965ecf 100644 --- a/maa_mcp/pipeline_server.py +++ b/maa_mcp/pipeline_server.py @@ -1,43 +1,21 @@ # pipeline_server.py """ -多进程流水线 MCP 服务器 +多线程流水线 MCP 服务器 ====================== -真正可运行的 MCP 服务器入口,支持多进程后台监控。 +真正可运行的 MCP 服务器入口,支持多线程后台监控。 使用方法: -1. 作为 MCP 服务器运行 (替代 __main__.py): +作为 MCP 服务器运行 (替代 __main__.py): python maa_mcp/pipeline_server.py - -2. 运行测试: - python maa_mcp/pipeline_server.py --test """ -import os -import sys import time -import json -import logging -import argparse -from multiprocessing import Process, Queue, Event, Manager -from queue import Empty -from typing import Optional, List, Dict, Any -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -# 导入 MaaFramework 相关 -try: - from maa.controller import AdbController, Win32Controller - from maa.resource import Resource - from maa.tasker import Tasker - from maa.pipeline import JRecognitionType, JOCR - from maa.define import MaaWin32ScreencapMethodEnum, MaaWin32InputMethodEnum -except ImportError: - pass +from threading import Thread, Event +from queue import Queue, Empty +from typing import List, Dict, Any # 导入 MCP Core 和 Registry -from maa_mcp.core import mcp, controller_info_registry, ControllerType, ControllerInfo -from maa_mcp.paths import get_resource_dir, get_screenshots_dir +from maa_mcp.core import mcp, controller_info_registry, object_registry # 导入功能模块以注册基础工具 import maa_mcp.adb @@ -47,325 +25,21 @@ import maa_mcp.utils import maa_mcp.resource -# ==================== 日志配置 ==================== - -logging.basicConfig( - level=logging.INFO, - format="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", - datefmt="%H:%M:%S", +# 导入 Pipeline 子模块 +from maa_mcp.pipeline import ( + setup_logger, + get_logger, + PipelineState, + get_pipeline_state, ) -logger = logging.getLogger("PipelineServer") - -# ==================== Win32 映射配置 ==================== - -_SCREENCAP_METHOD_MAP = { - "FramePool": MaaWin32ScreencapMethodEnum.FramePool, - "PrintWindow": MaaWin32ScreencapMethodEnum.PrintWindow, - "GDI": MaaWin32ScreencapMethodEnum.GDI, - "DXGI_DesktopDup_Window": MaaWin32ScreencapMethodEnum.DXGI_DesktopDup_Window, - "ScreenDC": MaaWin32ScreencapMethodEnum.ScreenDC, - "DXGI_DesktopDup": MaaWin32ScreencapMethodEnum.DXGI_DesktopDup, -} - -_MOUSE_METHOD_MAP = { - "PostMessage": MaaWin32InputMethodEnum.PostMessage, - "PostMessageWithCursorPos": MaaWin32InputMethodEnum.PostMessageWithCursorPos, - "Seize": MaaWin32InputMethodEnum.Seize, -} - -_KEYBOARD_METHOD_MAP = { - "PostMessage": MaaWin32InputMethodEnum.PostMessage, - "Seize": MaaWin32InputMethodEnum.Seize, -} - -# ==================== 配置 ==================== - - -@dataclass -class PipelineConfig: - """流水线配置""" - - screenshot_fps: float = 2.0 # 截图帧率 - message_queue_size: int = 100 # 消息队列大小 - similarity_threshold: int = 5 # 图像相似度阈值 - enable_dedup: bool = True # 启用消息去重 - - -# ==================== MAA 工具接口 ==================== - - -class IMaaTool: - """MAA 工具接口""" - - def screencap(self, controller_id: str) -> Optional[str]: ... - def ocr(self, controller_id: str) -> List[Dict]: ... - def click(self, controller_id: str, x: int, y: int, duration: int = 50) -> bool: ... - def input_text(self, controller_id: str, text: str) -> bool: ... - - -# ==================== 真实 MAA 工具 ==================== - - -class RealMAATool(IMaaTool): - """ - 真实 MAA 工具实现 - 在子进程中重新连接设备并执行操作 - """ - - def __init__(self, controller_type: ControllerType, params: dict): - self.logger = logging.getLogger("RealMAA") - self.controller = None - self.tasker = None - self.resource = None - - self.logger.info(f"初始化真实 MAA 工具: {controller_type}, 参数: {params}") - - try: - if controller_type == ControllerType.ADB: - self.controller = AdbController( - adb_path=params.get("adb_path"), - address=params.get("address"), - screencap_methods=params.get("screencap_methods", 0), - input_methods=params.get("input_methods", 0), - config=params.get("config", "{}"), - ) - - elif controller_type == ControllerType.WIN32: - hwnd = params.get("hwnd") - screencap = _SCREENCAP_METHOD_MAP.get( - params.get("screencap_method"), - MaaWin32ScreencapMethodEnum.FramePool, - ) - mouse = _MOUSE_METHOD_MAP.get( - params.get("mouse_method"), MaaWin32InputMethodEnum.PostMessage - ) - keyboard = _KEYBOARD_METHOD_MAP.get( - params.get("keyboard_method"), MaaWin32InputMethodEnum.PostMessage - ) - - self.controller = Win32Controller( - hwnd=hwnd, - screencap_method=screencap, - mouse_method=mouse, - keyboard_method=keyboard, - ) - - if self.controller: - self.controller.post_connection().wait() - - # 初始化资源 - self.resource = Resource() - res_path = get_resource_dir() - self.resource.post_bundle(str(res_path)).wait() - - # 初始化 Tasker - self.tasker = Tasker() - self.tasker.bind(self.resource, self.controller) - - except Exception as e: - self.logger.error(f"MAA 初始化失败: {e}") - import traceback - - traceback.print_exc() - - def screencap(self, controller_id: str) -> Optional[str]: - if not self.controller: - return None - try: - image = self.controller.post_screencap().wait().get() - if image is None: - return None - - import cv2 - - temp_dir = get_screenshots_dir() - temp_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - filepath = temp_dir / f"pipeline_{timestamp}.png" - cv2.imwrite(str(filepath), image) - return str(filepath) - except Exception as e: - self.logger.error(f"截图失败: {e}") - return None - - def ocr(self, controller_id: str) -> List[Dict]: - if not self.tasker: - return [] - try: - # 获取截图用于 OCR - image = self.controller.post_screencap().wait().get() - if image is None: - return [] - - info = ( - self.tasker.post_recognition(JRecognitionType.OCR, JOCR(), image) - .wait() - .get() - ) - if not info or not info.nodes: - return [] - - results = [] - for result in info.nodes[0].recognition.all_results: - # 转换 OCR 结果为简单字典 - results.append( - { - "text": result.text, - "x": result.rect.x, - "y": result.rect.y, - "w": result.rect.width, - "h": result.rect.height, - # 如果有 score 字段则添加 - "score": getattr(result, "score", 0.99), - } - ) - return results - except Exception as e: - self.logger.error(f"OCR 失败: {e}") - return [] - - def click(self, controller_id: str, x: int, y: int, duration: int = 50) -> bool: - if not self.controller: - return False - try: - self.controller.post_click(x, y).wait() - return True - except Exception as e: - self.logger.error(f"点击失败: {e}") - return False - - def input_text(self, controller_id: str, text: str) -> bool: - if not self.controller: - return False - try: - self.controller.post_input_text(text).wait() - return True - except Exception as e: - self.logger.error(f"输入失败: {e}") - return False - - -# ==================== 模拟 MAA 工具 ==================== - -class MockMAATool(IMaaTool): - """ - 模拟 MAA 工具(用于测试) - """ - - def __init__(self): - self.logger = logging.getLogger("MockMAA") - self._frame_count = 0 - self._message_templates = [ - "你好", - "在吗?", - "今天天气真好", - "有什么新消息吗", - "帮我查一下", - "谢谢", - "好的", - "收到", - ] - - def screencap(self, controller_id: str) -> Optional[str]: - self._frame_count += 1 - temp_dir = Path("./temp_screenshots") - temp_dir.mkdir(exist_ok=True) - filepath = temp_dir / f"frame_{self._frame_count}.png" - filepath.write_text(f"mock_frame_{self._frame_count}") - return str(filepath) - - def ocr(self, controller_id: str) -> List[Dict]: - import random - - results = [] - results.append({"text": "微信", "x": 540, "y": 50, "score": 0.99}) - results.append({"text": "发送", "x": 950, "y": 1800, "score": 0.98}) - if random.random() < 0.3: - msg = random.choice(self._message_templates) - results.append( - { - "text": f"{msg}_{int(time.time()) % 1000}", - "x": 200, - "y": random.randint(300, 1500), - "score": 0.95, - } - ) - return results - - def click(self, controller_id: str, x: int, y: int, duration: int = 50) -> bool: - msg = f"点击: ({x}, {y})" - self.logger.info(msg) - # print(f"[MockMAA] {msg}") - time.sleep(duration / 1000) - return True - - def input_text(self, controller_id: str, text: str) -> bool: - msg = f"输入: {text}" - self.logger.info(msg) - # print(f"[MockMAA] {msg}") - time.sleep(0.1) - return True - - -# ==================== 流水线状态管理 ==================== - - -class PipelineState: - """流水线全局状态(单例,线程版)""" - - _instance = None - _lock = Lock() # 类属性:全局共享锁 - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self): - if self._initialized: - return - self._initialized = True - self.is_running = False - self.stop_event = Event() - self.pipeline_thread: Optional[threading.Thread] = None - self.message_queue = Queue(maxsize=100) - self.stats_dict = {} - self.last_screen_state = {} - self.controller_id: Optional[str] = None - self.reset() - - def reset(self): - with PipelineState._lock: - self.is_running = False - self.stop_event.clear() - temp_queue = Queue(maxsize=100) - while not self.message_queue.empty(): - try: - temp_queue.put_nowait(self.message_queue.get_nowait()) - except Empty: - break - self.message_queue = temp_queue # 清空队列 - self.stats_dict = { - "frame_count": 0, - "ocr_count": 0, - "new_message_count": 0, - "start_time": 0, - "last_update": 0, - } - self.last_screen_state = {} - - -# 懒加载全局状态 -pipeline_state = None +# 导入现有的工具实现函数(内部函数,可直接调用) +from maa_mcp.vision import _screencap as mcp_screencap +# ==================== 初始化日志 ==================== -def get_pipeline_state() -> PipelineState: - global pipeline_state - if pipeline_state is None: - pipeline_state = PipelineState() - return pipeline_state +setup_logger() +logger = get_logger("PipelineServer") # ==================== 流水线核心逻辑 ==================== @@ -373,130 +47,65 @@ def get_pipeline_state() -> PipelineState: def run_pipeline_loop( controller_id: str, - controller_type: Optional[str], - shared_controller, # 新增:共享 controller - shared_tasker, # 新增:共享 tasker config_dict: Dict, stop_event: Event, message_queue: Queue, - stats_dict: Dict, - last_screen_state: Dict, ): - """流水线主循环(线程版,共享 MAA 实例)""" - from maa_mcp.core import PipelineState # 访问 lock - - pipeline_state = get_pipeline_state() - thread_logger = logging.getLogger("PipelineLoop") + """ + 流水线主循环(多线程版) + + 不再执行 OCR,而是直接截图并缓存图片路径到队列中。 + 大模型获取图片路径后可自行决定是否需要 OCR 以及具体操作。 + + Args: + controller_id: 控制器 ID + config_dict: 配置字典 + stop_event: 停止事件 + message_queue: 消息队列(存放截图路径) + """ + thread_logger = get_logger("PipelineLoop") + + thread_logger.debug(f"[初始化] 流水线线程启动") + thread_logger.debug(f"[初始化] controller_id={controller_id}") thread_logger.info(f"流水线线程启动,控制器: {controller_id}") - # 使用共享实例,无需重建 - if controller_id == "test_device": - thread_logger.info("使用 MockMAA 工具") - maa_tool = MockMAATool() - else: - thread_logger.info("使用共享 RealMAA 组件") - - # maa_tool 封装共享调用 - class SharedMAATool: - def __init__(self, controller, tasker): - self.controller = controller - self.tasker = tasker - self.lock = Lock() # 每个调用加锁 - - def ocr(self, cid): - with self.lock: - try: - image = self.controller.post_screencap().wait().get() - if image is None: - return [] - info = ( - self.tasker.post_recognition( - JRecognitionType.OCR, JOCR(), image - ) - .wait() - .get() - ) - if not info or not info.nodes: - return [] - results = [] - for result in info.nodes[0].recognition.all_results: - results.append( - { - "text": result.text, - "x": result.rect.x, - "y": result.rect.y, - "w": result.rect.width, - "h": result.rect.height, - "score": getattr(result, "score", 0.99), - } - ) - return results - except Exception as e: - thread_logger.error(f"OCR 失败: {e}") - return [] - - maa_tool = SharedMAATool(shared_controller, shared_tasker) - fps = config_dict.get("fps", 2.0) - enable_dedup = config_dict.get("enable_dedup", True) - last_texts = set() frame_count = 0 interval = 1.0 / fps + + thread_logger.debug(f"[初始化] fps={fps}, interval={interval}s") + thread_logger.info("流水线初始化完成,开始主循环(截图模式)") while not stop_event.is_set(): try: loop_start = time.time() frame_count += 1 - - ocr_results = maa_tool.ocr(controller_id) - if not ocr_results: + + thread_logger.debug(f"[Frame {frame_count}] 开始截图...") + + # 直接调用 vision.py 中的 screencap 函数,获取截图路径 + screenshot_path = mcp_screencap(controller_id) + + # 处理截图返回值 + if screenshot_path is None: + thread_logger.debug(f"[Frame {frame_count}] 截图失败: None") time.sleep(interval) continue - - # 提取文本等逻辑不变 - current_texts = set() - text_details = {} - for item in ocr_results: - text = item.get("text", "") - if text: - current_texts.add(text) - text_details[text] = item - - if enable_dedup: - new_texts = current_texts - last_texts - else: - new_texts = current_texts - - ui_elements = {"微信", "发送", "输入", "语音", "表情", "更多"} - new_texts = {t for t in new_texts if not any(ui in t for ui in ui_elements)} - - for text in new_texts: - item = text_details.get(text, {}) - message_data = { - "text": text, - "x": item.get("x", 0), - "y": item.get("y", 0), - "score": item.get("score", 0), - "timestamp": time.time(), - "frame_id": frame_count, - } - try: - message_queue.put_nowait(message_data) - with pipeline_state._lock: - stats_dict["new_message_count"] = ( - stats_dict.get("new_message_count", 0) + 1 - ) - thread_logger.info(f"🆕 新消息: {text}") - except: - pass - - last_texts = current_texts - with pipeline_state._lock: - stats_dict["frame_count"] = frame_count - stats_dict["ocr_count"] = stats_dict.get("ocr_count", 0) + 1 - stats_dict["last_update"] = time.time() - last_screen_state["texts"] = list(current_texts) - last_screen_state["timestamp"] = time.time() + + thread_logger.debug(f"[Frame {frame_count}] 截图成功: {screenshot_path}") + + # 将截图路径放入消息队列 + message_data = { + "type": "screenshot", + "image_path": screenshot_path, + "timestamp": time.time(), + "frame_id": frame_count, + } + try: + message_queue.put_nowait(message_data) + thread_logger.info(f"📷 新截图: {screenshot_path}") + except: + thread_logger.warning(f"[Frame {frame_count}] 消息队列已满,丢弃截图") elapsed = time.time() - loop_start sleep_time = max(0, interval - elapsed) @@ -505,6 +114,8 @@ def ocr(self, cid): except Exception as e: thread_logger.error(f"流水线异常: {e}") + import traceback + thread_logger.debug(f"堆栈: {traceback.format_exc()}") time.sleep(1) thread_logger.info("流水线线程已停止") @@ -516,48 +127,47 @@ def ocr(self, cid): def _start_pipeline_impl(controller_id: str, fps: float = 2.0) -> str: """启动流水线实现""" try: + logger.debug(f"[启动] 收到启动流水线请求: controller_id={controller_id}, fps={fps}") + pipeline_state = get_pipeline_state() + if pipeline_state.is_running: return "⚠️ 流水线已经在运行中" - # 获取控制器信息 - ctype_str = None - cparams = None - - if controller_id != "test_device": - info = controller_info_registry.get(controller_id) - if not info: - return f"❌ 未找到控制器: {controller_id},请先连接设备" + # 获取控制器信息,验证 controller_id 是否有效 + info = controller_info_registry.get(controller_id) + if not info: + return f"❌ 未找到控制器: {controller_id},请先连接设备" - ctype_str = info.controller_type.name # "ADB" or "WIN32" - cparams = info.connection_params - if not cparams: - return f"❌ 控制器 {controller_id} 缺少连接参数,无法在后台进程重建" + # 验证 controller 对象存在 + if object_registry.get(controller_id) is None: + return f"❌ 未找到控制器对象: {controller_id}" pipeline_state.reset() pipeline_state.controller_id = controller_id pipeline_state.stats_dict["start_time"] = time.time() - logger.info(f"正在启动流水线进程, controller_id={controller_id}") + logger.info(f"正在启动流水线线程, controller_id={controller_id}") - pipeline_state.pipeline_process = Process( + # 启动流水线线程,只需要传递 controller_id + pipeline_state.pipeline_thread = Thread( target=run_pipeline_loop, args=( controller_id, - ctype_str, - cparams, {"fps": fps, "enable_dedup": True}, pipeline_state.stop_event, pipeline_state.message_queue, - pipeline_state.stats_dict, - pipeline_state.last_screen_state, ), daemon=True, + name=f"PipelineThread-{controller_id}", ) - pipeline_state.pipeline_process.start() + + pipeline_state.pipeline_thread.start() pipeline_state.is_running = True + + logger.info(f"流水线已启动, Thread={pipeline_state.pipeline_thread.name}") - return f"✅ 流水线已启动 (PID: {pipeline_state.pipeline_process.pid})" + return f"✅ 流水线已启动 (Thread: {pipeline_state.pipeline_thread.name})" except Exception as e: logger.exception("启动流水线失败") return f"❌ 启动流水线失败: {str(e)}" @@ -570,10 +180,10 @@ def _stop_pipeline_impl() -> str: return "⚠️ 流水线未在运行" pipeline_state.stop_event.set() - if pipeline_state.pipeline_process: - pipeline_state.pipeline_process.join(timeout=5) - if pipeline_state.pipeline_process.is_alive(): - pipeline_state.pipeline_process.terminate() + if pipeline_state.pipeline_thread: + pipeline_state.pipeline_thread.join(timeout=5) + if pipeline_state.pipeline_thread.is_alive(): + logger.warning("流水线线程未能在5秒内停止") pipeline_state.is_running = False return "✅ 流水线已停止" @@ -592,129 +202,125 @@ def _get_new_messages_impl(max_count: int = 10) -> List[Dict[str, Any]]: def _get_pipeline_status_impl() -> Dict[str, Any]: - """获取状态实现(线程版)""" + """获取状态实现""" pipeline_state = get_pipeline_state() - with PipelineState._lock: - stats = dict(pipeline_state.stats_dict) + stats = pipeline_state.get_stats() start_time = stats.get("start_time", 0) uptime = time.time() - start_time if start_time > 0 else 0 return { "is_running": pipeline_state.is_running, "controller_id": pipeline_state.controller_id, "uptime": round(uptime, 1), - "frame_count": stats.get("frame_count", 0), - "new_messages": stats.get("new_message_count", 0), "pending": pipeline_state.message_queue.qsize(), } -def _pipeline_send_reply_impl(text: str) -> bool: - """发送回复实现""" - pipeline_state = get_pipeline_state() - if not pipeline_state.controller_id: - return False +# ==================== MCP 工具注册 ==================== - cid = pipeline_state.controller_id - if cid == "test_device": - tool = MockMAATool() - tool.click(cid, 540, 1700) - tool.input_text(cid, text) - tool.click(cid, 950, 1800) - return True +@mcp.tool( + name="start_pipeline", + description=""" + 启动后台监控流水线,持续对设备屏幕进行截图并缓存图片路径。 - try: - from maa_mcp.control import click, input_text - - click(cid, 540, 1700) - time.sleep(0.3) - input_text(cid, text) - time.sleep(0.2) - click(cid, 950, 1800) - return True - except Exception as e: - logger.error(f"发送回复失败: {e}") - return False + 参数: + - controller_id: 控制器 ID,由 connect_adb_device() 或 connect_window() 返回 + - fps: 截图帧率(默认 2.0),控制每秒截图次数 + 返回值: + - 成功:返回包含 "✅" 的成功信息 + - 失败:返回包含 "❌" 的错误信息 -# ==================== MCP 工具注册 ==================== + 说明: + 流水线启动后会在后台线程持续运行,定期截图并将图片路径放入消息队列。 + 可通过 get_new_messages() 获取截图路径,然后读取图片内容进行分析。 + 大模型可根据图片内容自行决定是否需要 OCR、具体 OCR 哪个区域、点击哪里等操作。 + 同一时间只能运行一个流水线实例。 + """, +) +def start_pipeline(controller_id: str, fps: float = 2.0) -> str: + return _start_pipeline_impl(controller_id, fps) -@mcp.tool() -def start_pipeline(controller_id: str, fps: float = 2.0) -> str: - """ - 启动后台监控流水线。 +@mcp.tool( + name="stop_pipeline", + description=""" + 停止当前运行的后台监控流水线。 - Args: - controller_id: 设备控制器ID (需先连接设备) - fps: 截图帧率(默认2.0) - """ - return _start_pipeline_impl(controller_id, fps) + 参数: + 无 + 返回值: + - 成功:返回包含 "✅" 的成功信息 + - 未运行:返回包含 "⚠️" 的提示信息 -@mcp.tool() + 说明: + 停止流水线后,后台线程将结束运行,消息队列中的未读消息仍可通过 get_new_messages() 获取。 + """, +) def stop_pipeline() -> str: - """停止后台监控流水线。""" return _stop_pipeline_impl() -@mcp.tool() +@mcp.tool( + name="get_new_messages", + description=""" + 获取流水线缓存的新截图路径(非阻塞)。 + + 参数: + - max_count: 最大获取数量(默认 10),控制单次调用返回的截图数量上限 + + 返回值: + - 成功:返回消息列表,每条消息包含以下字段: + - type: 消息类型,固定为 "screenshot" + - image_path: 截图文件的绝对路径,可通过读取该路径获取图片内容 + - timestamp: 截图时间戳 + - frame_id: 帧序号 + - 无新消息:返回空列表 [] + + 说明: + 此方法为非阻塞调用,立即返回当前队列中的截图路径。 + 获取后的消息会从队列中移除,不会重复返回。 + + 建议用法: + 1. 获取 image_path 后,读取图片内容进行视觉分析 + 2. 根据图片内容判断是否需要执行 OCR(调用 ocr 工具) + 3. 根据分析结果决定具体的点击位置或其他操作 + """, +) def get_new_messages(max_count: int = 10) -> List[Dict[str, Any]]: - """获取新检测到的消息(非阻塞)。""" return _get_new_messages_impl(max_count) -@mcp.tool() -def get_pipeline_status() -> Dict[str, Any]: - """获取流水线运行状态。""" - return _get_pipeline_status_impl() - - -@mcp.tool() -def pipeline_send_reply(text: str) -> bool: - """ - (流水线专用) 发送回复消息。 - 使用当前流水线绑定的控制器发送消息。 - """ - return _pipeline_send_reply_impl(text) - +@mcp.tool( + name="get_pipeline_status", + description=""" + 获取流水线的当前运行状态。 -# ==================== 测试与主入口 ==================== + 参数: + 无 + 返回值: + 返回状态字典,包含以下字段: + - is_running: 是否正在运行(布尔值) + - controller_id: 当前绑定的控制器 ID(字符串或 None) + - uptime: 运行时长(秒,浮点数) + - pending: 待处理消息数量(整数) -def run_test(): - """运行本地测试""" - print("=" * 60) - print("🧪 流水线本地测试 (使用 MockMAA)") - print("=" * 60) - - _start_pipeline_impl("test_device", fps=2.0) - - print("运行中 (10s)...") - for _ in range(10): - time.sleep(1) - msgs = _get_new_messages_impl() - if msgs: - for m in msgs: - print(f"📩 [{m['timestamp']}] {m['text']}") + 说明: + 可用于检查流水线是否正常运行,以及监控消息队列的积压情况。 + """, +) +def get_pipeline_status() -> Dict[str, Any]: + return _get_pipeline_status_impl() - print("发送回复测试...") - _pipeline_send_reply_impl("Test Reply") - _stop_pipeline_impl() - print("测试完成") +# ==================== 主入口 ==================== def main(): - parser = argparse.ArgumentParser(description="MaaMCP Pipeline Server") - parser.add_argument("--test", action="store_true", help="运行本地测试") - args = parser.parse_args() - - if args.test: - run_test() - else: - # 启动 MCP 服务器 - mcp.run() + # 启动 MCP 服务器 + mcp.run() if __name__ == "__main__": diff --git a/maa_mcp/vision.py b/maa_mcp/vision.py index a185e31..efbba8f 100644 --- a/maa_mcp/vision.py +++ b/maa_mcp/vision.py @@ -14,6 +14,56 @@ from maa_mcp.paths import get_screenshots_dir +def _screencap(controller_id: str) -> Optional[str]: + controller: Controller | None = object_registry.get(controller_id) + if not controller: + return None + image = controller.post_screencap().wait().get() + if image is None: + return None + + # 保存截图到跨平台用户数据目录,返回路径供大模型按需读取 + screenshots_dir = get_screenshots_dir() + screenshots_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filepath = screenshots_dir / f"screenshot_{timestamp}.png" + success = cv2.imwrite(str(filepath), image) + if not success: + return None + # 记录当前会话保存的截图文件路径,用于退出时清理 + _saved_screenshots.append(filepath) + return str(filepath.absolute()) + +def _ocr_impl(controller_id: str) -> Optional[Union[list, str]]: + """ + OCR 核心实现(可被其他模块复用) + + 参数: + - controller_id: 控制器 ID + + 返回值: + - 成功:返回识别结果列表 + - OCR 资源不存在:返回字符串提示信息 + - 失败:返回 None + """ + # 先检查 OCR 资源是否存在,不存在则返回提示信息让 AI 主动调用下载 + if not check_ocr_files_exist(): + return "OCR 模型文件不存在,请先调用 check_and_download_ocr() 下载 OCR 资源后重试" + + controller: Controller | None = object_registry.get(controller_id) + tasker = get_or_create_tasker(controller_id) + if not controller or not tasker: + return None + + image = controller.post_screencap().wait().get() + info: TaskDetail | None = ( + tasker.post_recognition(JRecognitionType.OCR, JOCR(), image).wait().get() + ) + if not info: + return None + return info.nodes[0].recognition.all_results + + @mcp.tool( name="ocr", description=""" @@ -34,22 +84,7 @@ """, ) def ocr(controller_id: str) -> Optional[Union[list, str]]: - # 先检查 OCR 资源是否存在,不存在则返回提示信息让 AI 主动调用下载 - if not check_ocr_files_exist(): - return "OCR 模型文件不存在,请先调用 check_and_download_ocr() 下载 OCR 资源后重试" - - controller: Controller | None = object_registry.get(controller_id) - tasker = get_or_create_tasker(controller_id) - if not controller or not tasker: - return None - - image = controller.post_screencap().wait().get() - info: TaskDetail | None = ( - tasker.post_recognition(JRecognitionType.OCR, JOCR(), image).wait().get() - ) - if not info: - return None - return info.nodes[0].recognition.all_results + return _ocr_impl(controller_id) @mcp.tool( @@ -64,20 +99,4 @@ def ocr(controller_id: str) -> Optional[Union[list, str]]: """, ) def screencap(controller_id: str) -> Optional[str]: - controller = object_registry.get(controller_id) - if not controller: - return None - image = controller.post_screencap().wait().get() - if image is None: - return None - # 保存截图到跨平台用户数据目录,返回路径供大模型按需读取 - screenshots_dir = get_screenshots_dir() - screenshots_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - filepath = screenshots_dir / f"screenshot_{timestamp}.png" - success = cv2.imwrite(str(filepath), image) - if not success: - return None - # 记录当前会话保存的截图文件路径,用于退出时清理 - _saved_screenshots.append(filepath) - return str(filepath.absolute()) + return _screencap(controller_id) diff --git a/pyproject.toml b/pyproject.toml index dce5bcb..02f362d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "fastmcp>=2.0.0", "opencv-python>=4.0.0", "platformdirs>=4.0.0", + "loguru>=0.7.0", ] [project.urls] diff --git a/verify_server.py b/verify_server.py deleted file mode 100644 index 6b67f63..0000000 --- a/verify_server.py +++ /dev/null @@ -1,86 +0,0 @@ -import asyncio -import sys -import os -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - - -async def run(): - # 获取当前 python 解释器路径 - python_executable = sys.executable - script_path = os.path.join( - os.path.dirname(__file__), "maa_mcp", "pipeline_server.py" - ) - project_root = os.path.dirname(__file__) - - print(f"🔌 正在连接到服务器: {script_path}") - - # 设置环境变量,确保 Python 路径正确,且输出不缓冲 - env = os.environ.copy() - env["PYTHONPATH"] = project_root - env["PYTHONUNBUFFERED"] = "1" - - server_params = StdioServerParameters( - command=python_executable, args=[script_path], env=env - ) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # 1. 初始化 - print("🚀 发送初始化请求...") - await session.initialize() - print("✅ 初始化成功!") - - # 2. 列出工具 - print("\n🛠️ 获取工具列表...") - tools = await session.list_tools() - print(f"✅ 成功获取 {len(tools.tools)} 个工具:") - for tool in tools.tools: - print( - f" - {tool.name}: {tool.description.splitlines()[0] if tool.description else 'No description'}" - ) - - # 3. 测试 start_pipeline 工具 - print("\n🧪 测试 start_pipeline 工具...") - try: - # 使用测试设备 ID - result = await session.call_tool( - "start_pipeline", - arguments={"controller_id": "test_device", "fps": 2.0}, - ) - print(f"✅ 调用成功,返回结果:\n{result.content[0].text}") - except Exception as e: - print(f"❌ 调用失败: {e}") - - # 4. 等待几秒 - print("\n⏳ 等待 3 秒...") - await asyncio.sleep(3) - - # 5. 获取新消息 - print("\n📩 获取新消息...") - try: - result = await session.call_tool("get_new_messages", arguments={}) - # get_new_messages 返回的是 list,mcp 协议层会包装成 TextContent - # FastMCP 可能会将其序列化为 JSON 字符串 - print(f"✅ 消息内容:\n{result.content[0].text}") - except Exception as e: - print(f"❌ 获取消息失败: {e}") - - # 6. 停止流水线 - print("\n🛑 停止流水线...") - try: - result = await session.call_tool("stop_pipeline", arguments={}) - print(f"✅ 停止结果: {result.content[0].text}") - except Exception as e: - print(f"❌ 停止失败: {e}") - - print("\n✨ 验证完成!服务器运行正常。") - - -if __name__ == "__main__": - try: - asyncio.run(run()) - except KeyboardInterrupt: - print("\n用户取消") - except Exception as e: - print(f"\n❌ 发生错误: {e}") From 5023734263cc9a5f81bab270eefa9422312f21b7 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Fri, 19 Dec 2025 11:36:33 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix:=20=E4=BF=AE=E6=8E=89=E4=B8=80?= =?UTF-8?q?=E4=B8=AAmerge=E8=BF=87=E7=A8=8B=E4=B8=AD=E6=9C=AA=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=AF=BC=E8=87=B4=E7=9A=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 44a5b76..b36534e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,11 +45,8 @@ dependencies = [ "fastmcp>=2.0.0", "opencv-python>=4.0.0", "platformdirs>=4.0.0", -<<<<<<< HEAD "loguru>=0.7.0", -======= "lzstring>=1.0.4", ->>>>>>> upstream/main ] [project.urls] From b6fc45e0ae4dba1da150bbec1658161c87c9c004 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Fri, 19 Dec 2025 11:49:06 +0800 Subject: [PATCH 06/15] =?UTF-8?q?chore:=E7=BB=9F=E4=B8=80=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=99=A8=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maa_mcp/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/maa_mcp/core.py b/maa_mcp/core.py index 01bd076..20dcac3 100644 --- a/maa_mcp/core.py +++ b/maa_mcp/core.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from enum import Enum, auto from pathlib import Path -from typing import Optional +from typing import Any, Dict, Optional from maa.toolkit import Toolkit @@ -31,7 +31,7 @@ class ControllerInfo: controller_type: ControllerType # 连接参数,用于在子进程中重建控制器 - connection_params: dict + connection_params: Dict[str, Any] # Win32 专用:键盘输入方式 keyboard_method: Optional[str] = None From fc11984db55d72b5b0881554e2962d63d3bb3962 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Fri, 19 Dec 2025 11:49:35 +0800 Subject: [PATCH 07/15] =?UTF-8?q?doc:=20=E5=A2=9E=E5=8A=A0=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E7=BA=BF=E8=BF=90=E8=A1=8C=E6=B5=81=E7=A8=8B=E7=9A=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++-- README_EN.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d76e67a..97c3421 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ MaaMCP 是一个 MCP 服务器,将 MaaFramework 的强大自动化能力通过 - 🖥️ **Windows 自动化** - 控制 Windows 桌面应用程序 - 🎯 **后台操作** - Windows 上的截图与控制均在后台运行,不占用鼠标键盘,您可以继续使用电脑做其他事情 - 🔗 **多设备协同** - 同时控制多个设备/窗口,实现跨设备自动化 +- ⚡ **双模式运行** - 串行模式(同步执行)和流水线模式(后台持续截图),适应不同场景需求 - 👁️ **智能识别** - 使用 OCR 识别屏幕文字内容 - 🎯 **精准操作** - 执行点击、滑动、文本输入、按键等操作 - 📸 **屏幕截图** - 获取实时屏幕截图进行视觉分析 @@ -64,6 +65,13 @@ Talk is cheap, 请看: **[🎞️ Bilibili 视频演示](https://www.bilibili.co - 支持组合键:Ctrl+C、Ctrl+V、Alt+Tab 等 - `scroll` - 鼠标滚轮(仅 Windows) +### ⚡ 流水线模式(多线程后台监控) + +- `start_pipeline` - 启动后台监控流水线,持续截图并缓存图片路径 +- `stop_pipeline` - 停止流水线 +- `get_new_messages` - 获取流水线缓存的新截图路径 +- `get_pipeline_status` - 获取流水线运行状态 + ### 📝 Pipeline 生成与运行 - `get_pipeline_protocol` - 获取 Pipeline 协议文档 @@ -151,18 +159,74 @@ MaaMCP 会自动: ## 工作流程 -MaaMCP 遵循简洁的操作流程,支持多设备/多窗口协同工作: +MaaMCP 遵循简洁的操作流程,支持多设备/多窗口协同工作,并提供两种运行模式: ```mermaid graph LR A[扫描设备] --> B[建立连接] - B --> C[执行自动化操作] + B --> C1[串行模式] + B --> C2[流水线模式] + C1 --> D[执行自动化操作] + C2 --> D ``` 1. **扫描** - 使用 `find_adb_device_list` 或 `find_window_list` 2. **连接** - 使用 `connect_adb_device` 或 `connect_window`(可连接多个设备/窗口,获得多个控制器 ID) 3. **操作** - 通过指定不同的控制器 ID,对多个设备/窗口执行 OCR、点击、滑动等自动化操作 +### 双模式运行 + +MaaMCP 支持两种运行模式,可根据任务需求灵活选择: + +#### 串行模式(默认) + +传统的同步执行方式,一个指令完成后再执行下一个: + +``` +OCR识别 → 分析结果 → 执行操作 → OCR识别 → ... +``` + +**适用场景**:简单任务、对实时性要求不高的场景 + +#### 流水线模式(多线程后台监控) + +多线程异步执行方式,后台持续采集屏幕信息,主线程专注于决策和操作: + +```mermaid +graph LR + subgraph 后台线程 + S1[持续截图] --> S2[缓存图片路径] + S2 --> S3[推送到消息队列] + S3 --> S1 + end + subgraph 主线程 + M1[获取截图路径] --> M2[视觉分析] + M2 --> M3[决定是否OCR] + M3 --> M4[执行操作] + M4 --> M1 + end +``` + +**工作流程**: + +1. **启动流水线** - 调用 `start_pipeline(controller_id)` 启动后台监控 +2. **获取截图** - 调用 `get_pipeline_status()` 检查状态,`get_new_messages()` 获取截图路径 +3. **分析执行** - 读取图片进行视觉分析,根据需要调用 OCR,执行点击等操作 +4. **停止流水线** - 任务完成后调用 `stop_pipeline()` 释放资源 + +**优势**: +- 后台持续截图,AI 可直接查看完整画面进行决策 +- AI 可根据图片内容自行决定是否需要 OCR、具体 OCR 哪个区域 +- 支持高频屏幕监控,不错过任何界面变化 +- 适合需要快速响应的实时自动化任务 +- 消息队列机制,支持异步处理 + +**使用示例**: + +```text +请用 MaaMCP 工具连接我的设备,使用流水线模式监控屏幕,当出现特定弹窗时自动点击确认。 +``` + ## Pipeline 生成功能 MaaMCP 支持让 AI 将执行过的操作转换为 [MaaFramework Pipeline](https://maafw.xyz/docs/3.1-PipelineProtocol) JSON 格式,实现**一次操作,无限复用**。 diff --git a/README_EN.md b/README_EN.md index b00a3ab..48422bf 100644 --- a/README_EN.md +++ b/README_EN.md @@ -30,6 +30,7 @@ MaaMCP is a Model Context Protocol server that exposes MaaFramework's powerful a - 🖥️ **Windows Automation** - Control Windows desktop applications - 🎯 **Background Operation** - Screenshots and controls on Windows run in the background without occupying your mouse or keyboard, allowing you to continue using your computer for other tasks - 🔗 **Multi-Device Coordination** - Control multiple devices/windows simultaneously for cross-device automation +- ⚡ **Dual-Mode Operation** - Serial mode (synchronous execution) and Pipeline mode (background continuous screenshots), adapting to different scenarios - 👁️ **Smart Recognition** - Use OCR to recognize on-screen text - 🎯 **Precise Operations** - Execute clicks, swipes, text input, key presses, and more - 📸 **Screenshots** - Capture real-time screenshots for visual analysis @@ -64,6 +65,13 @@ Talk is cheap, see: **[🎞️ Bilibili Video Demo](https://www.bilibili.com/vid - Supports key combinations: Ctrl+C, Ctrl+V, Alt+Tab, etc. - `scroll` - Mouse wheel (Windows only) +### ⚡ Pipeline Mode (Multi-threaded Background Monitoring) + +- `start_pipeline` - Start background monitoring pipeline, continuously screenshots and caches image paths +- `stop_pipeline` - Stop pipeline +- `get_new_messages` - Get new screenshot paths cached by pipeline +- `get_pipeline_status` - Get pipeline running status + ### 📝 Pipeline Generation & Execution - `get_pipeline_protocol` - Get Pipeline protocol documentation @@ -151,18 +159,74 @@ MaaMCP will automatically: ## Workflow -MaaMCP follows a streamlined operational workflow with multi-device/window coordination support: +MaaMCP follows a streamlined operational workflow with multi-device/window coordination support and two operation modes: ```mermaid graph LR A[Scan Devices] --> B[Establish Connection] - B --> C[Execute Automation] + B --> C1[Serial Mode] + B --> C2[Pipeline Mode] + C1 --> D[Execute Automation] + C2 --> D ``` 1. **Scan** - Use `find_adb_device_list` or `find_window_list` 2. **Connect** - Use `connect_adb_device` or `connect_window` (can connect multiple devices/windows, each gets a unique controller ID) 3. **Operate** - Execute OCR, click, swipe, etc. on multiple devices/windows by specifying different controller IDs (OCR resources auto-download on first use) +### Dual-Mode Operation + +MaaMCP supports two operation modes, allowing flexible selection based on task requirements: + +#### Serial Mode (Default) + +Traditional synchronous execution, completing one instruction before executing the next: + +``` +OCR Recognition → Analyze Results → Execute Operation → OCR Recognition → ... +``` + +**Suitable for**: Simple tasks, scenarios with low real-time requirements + +#### Pipeline Mode (Multi-threaded Background Monitoring) + +Multi-threaded asynchronous execution, background thread continuously captures screen information while main thread focuses on decision-making and operations: + +```mermaid +graph LR + subgraph Background Thread + S1[Continuous Screenshots] --> S2[Cache Image Paths] + S2 --> S3[Push to Message Queue] + S3 --> S1 + end + subgraph Main Thread + M1[Get Screenshot Path] --> M2[Visual Analysis] + M2 --> M3[Decide if OCR Needed] + M3 --> M4[Execute Operation] + M4 --> M1 + end +``` + +**Workflow**: + +1. **Start Pipeline** - Call `start_pipeline(controller_id)` to start background monitoring +2. **Get Screenshots** - Call `get_pipeline_status()` to check status, `get_new_messages()` to get screenshot paths +3. **Analyze & Execute** - Read images for visual analysis, call OCR as needed, execute clicks and other operations +4. **Stop Pipeline** - Call `stop_pipeline()` to release resources when task is complete + +**Advantages**: +- Continuous background screenshots, AI can directly view complete screen for decision-making +- AI can decide whether OCR is needed and which specific regions to OCR based on image content +- Supports high-frequency screen monitoring, never misses interface changes +- Suitable for real-time automation tasks requiring fast response +- Message queue mechanism supports asynchronous processing + +**Usage Example**: + +```text +Please use MaaMCP tools to connect to my device, use pipeline mode to monitor the screen, and automatically click confirm when a specific popup appears. +``` + ## Pipeline Generation MaaMCP supports AI converting executed operations into [MaaFramework Pipeline](https://maafw.xyz/docs/3.1-PipelineProtocol) JSON format, enabling **operate once, reuse infinitely**. From 6261682e3336c68701f61ab8ae143cbee86a7362 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Sat, 20 Dec 2025 12:37:43 +0800 Subject: [PATCH 08/15] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E6=95=B4?= =?UTF-8?q?=E4=B8=AA=E7=BB=93=E6=9E=84=EF=BC=8C=E5=8E=BB=E6=8E=89logger?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=88=B0=E6=8E=A7=E5=88=B6=E5=8F=B0=E3=80=82?= =?UTF-8?q?=E5=8E=BB=E6=8E=89config=E7=B1=BB=E7=9A=84=E9=94=AE=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E5=AE=9E=E9=99=85=E4=B8=8A=E7=94=A8=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=EF=BC=8C=E5=85=B6=E4=BD=99=E5=85=A8=E5=B1=80=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=90=88=E5=B9=B6=E5=88=B0=20pipeline=5Fserv?= =?UTF-8?q?er=E7=9B=B4=E6=8E=A5=E4=BD=BF=E7=94=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加可执行命令 maa_mcp_server 给这个命令放到mcp里面才能运流水线模式 --- maa_mcp/pipeline/__init__.py | 5 -- maa_mcp/pipeline/config.py | 103 ----------------------------- maa_mcp/pipeline/logging_config.py | 41 ++++-------- maa_mcp/pipeline_server.py | 44 ++++++++---- pyproject.toml | 2 + 5 files changed, 48 insertions(+), 147 deletions(-) delete mode 100644 maa_mcp/pipeline/config.py diff --git a/maa_mcp/pipeline/__init__.py b/maa_mcp/pipeline/__init__.py index 0bc7d27..5f2aaa4 100644 --- a/maa_mcp/pipeline/__init__.py +++ b/maa_mcp/pipeline/__init__.py @@ -6,21 +6,16 @@ 包含: - logging_config: 日志配置 -- config: 流水线配置和常量 - state: 流水线状态管理 """ from .logging_config import setup_logger, get_logger -from .config import PipelineConfig, UI_ELEMENTS_FILTER from .state import PipelineState, get_pipeline_state __all__ = [ # 日志 "setup_logger", "get_logger", - # 配置 - "PipelineConfig", - "UI_ELEMENTS_FILTER", # 状态管理 "PipelineState", "get_pipeline_state", diff --git a/maa_mcp/pipeline/config.py b/maa_mcp/pipeline/config.py deleted file mode 100644 index 3e4f7b4..0000000 --- a/maa_mcp/pipeline/config.py +++ /dev/null @@ -1,103 +0,0 @@ -# maa_mcp/pipeline/config.py -""" -配置模块 -======== -流水线相关的配置类和常量映射。 -""" - -from dataclasses import dataclass -from typing import Dict, Any - -# 导入 MaaFramework 枚举(可选) -try: - from maa.define import MaaWin32ScreencapMethodEnum, MaaWin32InputMethodEnum - - _MAA_AVAILABLE = True -except ImportError: - _MAA_AVAILABLE = False - MaaWin32ScreencapMethodEnum = None - MaaWin32InputMethodEnum = None - - -@dataclass -class PipelineConfig: - """流水线配置""" - - screenshot_fps: float = 2.0 # 截图帧率 - message_queue_size: int = 100 # 消息队列大小 - similarity_threshold: int = 5 # 图像相似度阈值 - enable_dedup: bool = True # 启用消息去重 - - -class Win32MethodMaps: - """Win32 方法映射配置""" - - @staticmethod - def get_screencap_map() -> Dict[str, Any]: - """获取截图方法映射""" - if not _MAA_AVAILABLE: - return {} - return { - "FramePool": MaaWin32ScreencapMethodEnum.FramePool, - "PrintWindow": MaaWin32ScreencapMethodEnum.PrintWindow, - "GDI": MaaWin32ScreencapMethodEnum.GDI, - "DXGI_DesktopDup_Window": MaaWin32ScreencapMethodEnum.DXGI_DesktopDup_Window, - "ScreenDC": MaaWin32ScreencapMethodEnum.ScreenDC, - "DXGI_DesktopDup": MaaWin32ScreencapMethodEnum.DXGI_DesktopDup, - } - - @staticmethod - def get_mouse_map() -> Dict[str, Any]: - """获取鼠标方法映射""" - if not _MAA_AVAILABLE: - return {} - return { - "PostMessage": MaaWin32InputMethodEnum.PostMessage, - "PostMessageWithCursorPos": MaaWin32InputMethodEnum.PostMessageWithCursorPos, - "Seize": MaaWin32InputMethodEnum.Seize, - } - - @staticmethod - def get_keyboard_map() -> Dict[str, Any]: - """获取键盘方法映射""" - if not _MAA_AVAILABLE: - return {} - return { - "PostMessage": MaaWin32InputMethodEnum.PostMessage, - "Seize": MaaWin32InputMethodEnum.Seize, - } - - @classmethod - def get_screencap_method(cls, method_name: str, default=None): - """获取截图方法枚举值""" - screencap_map = cls.get_screencap_map() - if default is None and _MAA_AVAILABLE: - default = MaaWin32ScreencapMethodEnum.FramePool - return screencap_map.get(method_name, default) - - @classmethod - def get_mouse_method(cls, method_name: str, default=None): - """获取鼠标方法枚举值""" - mouse_map = cls.get_mouse_map() - if default is None and _MAA_AVAILABLE: - default = MaaWin32InputMethodEnum.PostMessage - return mouse_map.get(method_name, default) - - @classmethod - def get_keyboard_method(cls, method_name: str, default=None): - """获取键盘方法枚举值""" - keyboard_map = cls.get_keyboard_map() - if default is None and _MAA_AVAILABLE: - default = MaaWin32InputMethodEnum.PostMessage - return keyboard_map.get(method_name, default) - - -# UI 元素过滤列表(用于消息去重时过滤 UI 文本) -UI_ELEMENTS_FILTER = {"微信", "发送", "输入", "语音", "表情", "更多"} - - -__all__ = [ - "PipelineConfig", - "Win32MethodMaps", - "UI_ELEMENTS_FILTER", -] diff --git a/maa_mcp/pipeline/logging_config.py b/maa_mcp/pipeline/logging_config.py index cd3f72e..16fe777 100644 --- a/maa_mcp/pipeline/logging_config.py +++ b/maa_mcp/pipeline/logging_config.py @@ -26,41 +26,28 @@ def setup_logger( ) -> None: """ 配置 loguru 日志系统。 - + + 注意:默认只输出日志到文件,不输出到控制台。 + 如果需要临时启用控制台输出,可以取消注释函数内部的控制台输出配置代码。 + Args: - console_level: 控制台日志级别 + console_level: 控制台日志级别(当前未使用) file_level: 文件日志级别 error_retention: 错误日志保留时间 log_retention: 普通日志保留时间 """ global _initialized - + if _initialized: return - + # 获取日志目录 logs_dir = get_logs_dir() logs_dir.mkdir(parents=True, exist_ok=True) - + # 移除默认 handler logger.remove() - - # 添加控制台输出 - logger.add( - sys.stderr, - format="{time:HH:mm:ss} | {level: <8} | {extra[module]}:{function}:{line} - {message}", - level=console_level, - filter=lambda record: "module" in record["extra"], - ) - - # 为没有 module 的日志添加默认格式 - logger.add( - sys.stderr, - format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", - level=console_level, - filter=lambda record: "module" not in record["extra"], - ) - + # 添加文件输出 - 按日期轮转 logger.add( logs_dir / "pipeline_{time:YYYY-MM-DD}.log", @@ -71,7 +58,7 @@ def setup_logger( compression="zip", # 压缩旧日志 encoding="utf-8", ) - + # 添加错误日志单独文件 logger.add( logs_dir / "pipeline_error_{time:YYYY-MM-DD}.log", @@ -82,7 +69,7 @@ def setup_logger( compression="zip", encoding="utf-8", ) - + _initialized = True logger.bind(module="Logger").info(f"日志系统初始化完成,日志目录: {logs_dir}") @@ -90,17 +77,17 @@ def setup_logger( def get_logger(module: str = "Pipeline"): """ 获取带模块标识的 logger。 - + Args: module: 模块名称标识 - + Returns: 绑定了模块名的 logger 实例 """ # 确保已初始化 if not _initialized: setup_logger() - + return logger.bind(module=module) diff --git a/maa_mcp/pipeline_server.py b/maa_mcp/pipeline_server.py index a965ecf..beae408 100644 --- a/maa_mcp/pipeline_server.py +++ b/maa_mcp/pipeline_server.py @@ -26,6 +26,8 @@ import maa_mcp.resource # 导入 Pipeline 子模块 +from dataclasses import dataclass + from maa_mcp.pipeline import ( setup_logger, get_logger, @@ -33,6 +35,21 @@ get_pipeline_state, ) + +# 流水线配置类 +@dataclass +class PipelineConfig: + """流水线配置""" + + screenshot_fps: float = 2.0 # 截图帧率 + message_queue_size: int = 100 # 消息队列大小 + similarity_threshold: int = 5 # 图像相似度阈值 + enable_dedup: bool = True # 启用消息去重 + + +# UI 元素过滤列表(用于消息去重时过滤 UI 文本) +UI_ELEMENTS_FILTER = {"微信", "发送", "输入", "语音", "表情", "更多"} + # 导入现有的工具实现函数(内部函数,可直接调用) from maa_mcp.vision import _screencap as mcp_screencap @@ -53,10 +70,10 @@ def run_pipeline_loop( ): """ 流水线主循环(多线程版) - + 不再执行 OCR,而是直接截图并缓存图片路径到队列中。 大模型获取图片路径后可自行决定是否需要 OCR 以及具体操作。 - + Args: controller_id: 控制器 ID config_dict: 配置字典 @@ -64,7 +81,7 @@ def run_pipeline_loop( message_queue: 消息队列(存放截图路径) """ thread_logger = get_logger("PipelineLoop") - + thread_logger.debug(f"[初始化] 流水线线程启动") thread_logger.debug(f"[初始化] controller_id={controller_id}") thread_logger.info(f"流水线线程启动,控制器: {controller_id}") @@ -72,7 +89,7 @@ def run_pipeline_loop( fps = config_dict.get("fps", 2.0) frame_count = 0 interval = 1.0 / fps - + thread_logger.debug(f"[初始化] fps={fps}, interval={interval}s") thread_logger.info("流水线初始化完成,开始主循环(截图模式)") @@ -80,18 +97,18 @@ def run_pipeline_loop( try: loop_start = time.time() frame_count += 1 - + thread_logger.debug(f"[Frame {frame_count}] 开始截图...") # 直接调用 vision.py 中的 screencap 函数,获取截图路径 screenshot_path = mcp_screencap(controller_id) - + # 处理截图返回值 if screenshot_path is None: thread_logger.debug(f"[Frame {frame_count}] 截图失败: None") time.sleep(interval) continue - + thread_logger.debug(f"[Frame {frame_count}] 截图成功: {screenshot_path}") # 将截图路径放入消息队列 @@ -115,6 +132,7 @@ def run_pipeline_loop( except Exception as e: thread_logger.error(f"流水线异常: {e}") import traceback + thread_logger.debug(f"堆栈: {traceback.format_exc()}") time.sleep(1) @@ -127,10 +145,12 @@ def run_pipeline_loop( def _start_pipeline_impl(controller_id: str, fps: float = 2.0) -> str: """启动流水线实现""" try: - logger.debug(f"[启动] 收到启动流水线请求: controller_id={controller_id}, fps={fps}") - + logger.debug( + f"[启动] 收到启动流水线请求: controller_id={controller_id}, fps={fps}" + ) + pipeline_state = get_pipeline_state() - + if pipeline_state.is_running: return "⚠️ 流水线已经在运行中" @@ -161,10 +181,10 @@ def _start_pipeline_impl(controller_id: str, fps: float = 2.0) -> str: daemon=True, name=f"PipelineThread-{controller_id}", ) - + pipeline_state.pipeline_thread.start() pipeline_state.is_running = True - + logger.info(f"流水线已启动, Thread={pipeline_state.pipeline_thread.name}") return f"✅ 流水线已启动 (Thread: {pipeline_state.pipeline_thread.name})" diff --git a/pyproject.toml b/pyproject.toml index b36534e..c852f66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,9 @@ Issues = "https://github.com/MistEO/MaaMCP/issues" [project.scripts] maa-mcp = "maa_mcp.__main__:main" +maa_mcp = "maa_mcp.__main__:main" maa-mcp-server = "maa_mcp.pipeline_server:main" +maa_mcp_server = "maa_mcp.pipeline_server:main" [tool.hatch.version] source = "vcs" From eab8a0829ff41e64eccf139c0bd878123af04ff8 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Tue, 23 Dec 2025 14:53:55 +0800 Subject: [PATCH 09/15] =?UTF-8?q?docs:=20readme=E6=96=87=E6=A1=A3=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=A4=A7=E6=A8=A1=E5=9E=8B=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E5=92=8C=E6=80=A7=E8=83=BD=E5=BB=BA=E8=AE=AE=EF=BC=88=E4=BD=BF?= =?UTF-8?q?=E7=94=A8flash=E7=B1=BB=E7=9A=84=E5=BF=AB=E9=80=9F=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 ++++++++++++++++++++++ README_EN.md | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/README.md b/README.md index c48724d..cd23017 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,28 @@ MaaMCP 会自动: 3. 自动下载并加载 OCR 资源 4. 执行识别和操作任务 +## 大模型提示词 + +如果你希望 AI 能够快速、高效地完成自动化任务,而不希望看到运行过程中的详细解释,可以将以下内容添加到你的提示词(Prompt)中: + +``` +# Role: UI Automation Agent + +## Workflow Optimization Rules +1. **Minimize Round-Trips**: 你的目标是以最少的交互次数完成任务。 +2. **Critical Pattern**: 当涉及到表单/聊天输入时,必须遵循 **[Click Focus -> Input Text -> Send Key]** 的原子化操作序列。 + - 🚫 错误做法:先 Click,等待结果;再 Input,等待结果;再 Press Enter。 + - ✅ 正确做法:在 `click` 之后,无需等待返回,直接在同一个 `tool_calls` 列表中根据逻辑推断追加 `input_text` 和 `click_key`。 + +## Communication Style +- **NO YAPPING**: 不要复述用户的指令,不要解释你的步骤。 +- **Direct Execution**: 接收指令 -> (内部思考) -> 直接输出 JSON 工具调用。 +``` + +### 性能建议 + +为了获得最快的运行速度,建议使用 **Flash 版本**的大语言模型(如 Claude 3 Flash),这些模型在保持较高智能水平的同时,能够显著提升响应速度。 + ## 工作流程 MaaMCP 遵循简洁的操作流程,支持多设备/多窗口协同工作: diff --git a/README_EN.md b/README_EN.md index 48798b2..35ec768 100644 --- a/README_EN.md +++ b/README_EN.md @@ -149,6 +149,28 @@ MaaMCP will automatically: 3. Auto-download and load OCR resources (on first use) 4. Execute recognition and operation tasks +## Prompt words + +If you want AI to complete automation tasks quickly and efficiently without seeing detailed explanations during the running process, you can add the following content to your prompt: + +``` +# Role: UI Automation Agent + +## Workflow Optimization Rules +1. **Minimize Round-Trips**: Your goal is to complete tasks with the fewest interactions. +2. **Critical Pattern**: When it comes to form/chat input, you must follow the **[Click Focus -> Input Text -> Send Key]** atomic operation sequence. + - 🚫 Wrong way: Click first, wait for results; then Input, wait for results; then Press Enter. + - ✅ Correct way: After `click`, without waiting for a return, directly append `input_text` and `click_key` in the same `tool_calls` list based on logical inference. + +## Communication Style +- **NO YAPPING**: Don't repeat user instructions, don't explain your steps. +- **Direct Execution**: Receive instruction -> (internal thinking) -> directly output JSON tool calls. +``` + +### Performance Recommendations + +For the fastest running speed, it is recommended to use **Flash version** of large language models (such as Claude 3 Flash), which can significantly improve response speed while maintaining high intelligence levels. + ## Workflow MaaMCP follows a streamlined operational workflow with multi-device/window coordination support: From 2629b16f46b22b3a751b8417060898da403745af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=B1=E5=B1=B1=E5=AD=90?= <46012438+KhazixW2@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:58:24 +0800 Subject: [PATCH 10/15] Update README_EN.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README_EN.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README_EN.md b/README_EN.md index 35ec768..a0428a4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -164,6 +164,7 @@ If you want AI to complete automation tasks quickly and efficiently without seei ## Communication Style - **NO YAPPING**: Don't repeat user instructions, don't explain your steps. +- **Direct Execution**: Receive instructions -> (internal thinking) -> directly output JSON tool calls. - **Direct Execution**: Receive instruction -> (internal thinking) -> directly output JSON tool calls. ``` From 6d734b3edb0548e671276082a3355b321ad835a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=B1=E5=B1=B1=E5=AD=90?= <46012438+KhazixW2@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:58:34 +0800 Subject: [PATCH 11/15] Update README_EN.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README_EN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_EN.md b/README_EN.md index a0428a4..54bef2f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -170,7 +170,7 @@ If you want AI to complete automation tasks quickly and efficiently without seei ### Performance Recommendations -For the fastest running speed, it is recommended to use **Flash version** of large language models (such as Claude 3 Flash), which can significantly improve response speed while maintaining high intelligence levels. +For the fastest running speed, it is recommended to use the **Flash version** of a large language model (such as Claude 3 Flash), which can significantly improve response speed while maintaining high intelligence levels. ## Workflow From 6e2af563aea77a93586da4239b6c1bc9c4136f9f Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Mon, 23 Mar 2026 00:36:00 +0800 Subject: [PATCH 12/15] =?UTF-8?q?perf:=20swipe=E6=8F=8F=E8=BF=B0=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=EF=BC=8C=E5=9C=A8=E9=BB=98=E8=AE=A4=E6=83=85=E5=86=B5?= =?UTF-8?q?=E4=B8=8B=E4=BD=BF=E7=94=A8slow=5Fduration=E6=9D=A5=E6=BB=91?= =?UTF-8?q?=E5=8A=A8=EF=BC=8C=E9=81=BF=E5=85=8D=E4=B8=80=E6=AC=A1=E6=BB=91?= =?UTF-8?q?=E5=8A=A8=E8=BF=87=E8=BF=9C=E5=AF=BC=E8=87=B4=E6=BB=91=E5=8A=A8?= =?UTF-8?q?=E9=9D=9E=E9=A2=84=E6=9C=9F=E8=A1=8C=E4=B8=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 7 +++++-- maa_mcp/control.py | 21 +++++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c330115..5c2db52 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,10 @@ "mcp__maa-mcp__ocr", "mcp__maa-mcp__click", "mcp__maa-mcp__wait", - "mcp__maa-mcp__click_key" + "mcp__maa-mcp__click_key", + "mcp__maa-mcp__connect_adb_device", + "mcp__maa-mcp__swipe", + "mcp__maa-mcp__find_adb_device_list" ], "defaultMode": "bypassPermissions" }, @@ -24,4 +27,4 @@ "cwd": "${workspaceFolder}" } } -} +} \ No newline at end of file diff --git a/maa_mcp/control.py b/maa_mcp/control.py index e4425f2..a393f04 100644 --- a/maa_mcp/control.py +++ b/maa_mcp/control.py @@ -109,7 +109,14 @@ def double_click( - 失败:返回 False 说明: - 坐标系统以屏幕左上角为原点 (0, 0)。duration 参数控制滑动速度,数值越大滑动越慢。 + - 坐标系统以屏幕左上角为原点 (0, 0) + - duration 参数控制滑动速度,数值越大滑动越慢 + - 起点、终点坐标由 AI 根据当前 OCR 识别结果和场景自行计算决定 + + 预设 duration 值(建议直接使用),正常情况下默认用slow速度滑动: + + - slow(慢速): duration=3000,适合长距离拖拽、滚动翻页 + - fast(快速): duration=1500,适合短距离精确滑动、点击式滑动 """, ) def swipe( @@ -199,7 +206,9 @@ def click_key(controller_id: str, key: int, duration: int = 50) -> bool: return controller.post_key_up(key).wait().succeeded -@mcp.tool(name="keyboard_shortcut", description=""" +@mcp.tool( + name="keyboard_shortcut", + description=""" 在设备屏幕上执行键盘快捷键操作。 参数: @@ -220,7 +229,8 @@ def click_key(controller_id: str, key: int, duration: int = 50) -> bool: - Left Windows: 91 (0x5B) 注意:该方法仅对 Windows 窗口控制器,且在 Seize 控制方式下有效,其他控制方式不支持。 -""") +""", +) def keyboard_shortcut( controller_id: str, modifiers: list[int], primary_key: int, duration: int = 50 ) -> Union[bool, str]: @@ -233,7 +243,10 @@ def keyboard_shortcut( if info: if info.controller_type == ControllerType.ADB: return "keyboard_shortcut 不支持 ADB 控制器,该方法仅适用于 Windows 窗口控制器。请使用 click_key 进行单个按键操作。" - if info.controller_type == ControllerType.WIN32 and info.keyboard_method != "Seize": + if ( + info.controller_type == ControllerType.WIN32 + and info.keyboard_method != "Seize" + ): return f"keyboard_shortcut 仅支持 Seize 键盘模式,当前为 {info.keyboard_method}。可对同一窗口调用 connect_window(keyboard_method='Seize') 获取新 controller_id,原 controller_id 仍可用于其他操作。" for modifier in modifiers: From c6e9b3837cc859e8c129959940ddd5e35de964f7 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Mon, 23 Mar 2026 12:57:07 +0800 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95ci=E5=8F=AF=E8=83=BD=E5=87=BA=E7=8E=B0=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- tests/test_performance_stress.py | 67 +++++++++++++++++++------------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5c2db52..b1ea21b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,8 @@ "mcp__maa-mcp__click_key", "mcp__maa-mcp__connect_adb_device", "mcp__maa-mcp__swipe", - "mcp__maa-mcp__find_adb_device_list" + "mcp__maa-mcp__find_adb_device_list", + "mcp__maa-mcp__test_sweep" ], "defaultMode": "bypassPermissions" }, diff --git a/tests/test_performance_stress.py b/tests/test_performance_stress.py index 39f1b22..c139230 100644 --- a/tests/test_performance_stress.py +++ b/tests/test_performance_stress.py @@ -11,6 +11,15 @@ from maa_mcp.win32 import find_window_list, connect_window +def _call_tool(func, *args, **kwargs): + """兼容模式:调用工具函数,自动处理 FunctionTool 和普通函数""" + # 如果 func 有 .fn 属性,说明是 FunctionTool,使用 .fn() 调用 + if hasattr(func, 'fn'): + return func.fn(*args, **kwargs) + # 否则直接调用 + return func(*args, **kwargs) + + class PerformanceTimer: """性能计时器,用于测量函数执行时间""" @@ -75,15 +84,19 @@ def benchmark(self, func: Callable, *args, **kwargs) -> PerformanceTestResult: try: timer.start() - result = func(*args, **kwargs) + # 兼容 FunctionTool 和普通函数 + result = _call_tool(func, *args, **kwargs) timer.stop() success = True except Exception as e: timer.stop() - print(f"[Error] {func.__name__} 执行失败: {e}") + print(f"[Error] {getattr(func, '__name__', str(func))} 执行失败: {e}") + + # 获取函数名:优先使用 __name__,FunctionTool 使用 name 属性 + func_name = getattr(func, '__name__', None) or getattr(func, 'name', str(func)) test_result = PerformanceTestResult( - function_name=func.__name__, + function_name=func_name, execution_time=timer.elapsed_time or 0, success=success, result=result, @@ -256,10 +269,10 @@ def setup_class(self): # 尝试获取ADB设备 try: - device_list = find_adb_device_list.fn() + device_list = _call_tool(find_adb_device_list) if device_list: self.device_name = device_list[0] # 使用第一个设备 - self.controller_id = connect_adb_device.fn(self.device_name) + self.controller_id = _call_tool(connect_adb_device, self.device_name) print( f" 使用ADB设备: {self.device_name}, 控制器ID: {self.controller_id}" ) @@ -269,10 +282,10 @@ def setup_class(self): # 如果没有ADB设备,尝试获取Windows窗口 if not self.controller_id: try: - window_list = find_window_list.fn() + window_list = _call_tool(find_window_list) if window_list: self.window_name = window_list[0] # 使用第一个窗口 - self.controller_id = connect_window.fn(self.window_name) + self.controller_id = _call_tool(connect_window, self.window_name) print( f" 使用Windows窗口: {self.window_name}, 控制器ID: {self.controller_id}" ) @@ -289,11 +302,11 @@ def test_stress_find_adb_device_list(self): # 预热 for _ in range(self.config.warmup_iterations): - find_adb_device_list.fn() + _call_tool(find_adb_device_list) # 执行压力测试 results = self.benchmarker.run_multiple( - find_adb_device_list.fn, + find_adb_device_list, iterations=self.config.iterations, print_stats=False, ) @@ -307,11 +320,11 @@ def test_stress_find_window_list(self): # 预热 for _ in range(self.config.warmup_iterations): - find_window_list.fn() + _call_tool(find_window_list) # 执行压力测试 results = self.benchmarker.run_multiple( - find_window_list.fn, + find_window_list, iterations=self.config.iterations, print_stats=False, ) @@ -329,11 +342,11 @@ def test_stress_ocr(self): # 预热 for _ in range(self.config.warmup_iterations): - ocr.fn(self.controller_id) + _call_tool(ocr, self.controller_id) # 执行压力测试 results = self.benchmarker.run_multiple( - ocr.fn, + ocr, iterations=self.config.iterations, print_stats=False, controller_id=self.controller_id, @@ -352,11 +365,11 @@ def test_stress_screencap(self): # 预热 for _ in range(self.config.warmup_iterations): - screencap.fn(self.controller_id) + _call_tool(screencap, self.controller_id) # 执行压力测试 results = self.benchmarker.run_multiple( - screencap.fn, + screencap, iterations=self.config.iterations, print_stats=False, controller_id=self.controller_id, @@ -375,11 +388,11 @@ def test_stress_click(self): # 预热 for _ in range(self.config.warmup_iterations): - click.fn(self.controller_id, 100, 100) + _call_tool(click, self.controller_id, 100, 100) # 执行压力测试 results = self.benchmarker.run_multiple( - click.fn, + click, iterations=self.config.iterations, print_stats=False, controller_id=self.controller_id, @@ -400,11 +413,11 @@ def test_stress_swipe(self): # 预热 for _ in range(self.config.warmup_iterations): - swipe.fn(self.controller_id, 100, 100, 200, 200, 500) + _call_tool(swipe, self.controller_id, 100, 100, 200, 200, 500) # 执行压力测试 results = self.benchmarker.run_multiple( - swipe.fn, + swipe, iterations=self.config.iterations, print_stats=False, controller_id=self.controller_id, @@ -428,11 +441,11 @@ def test_stress_input_text(self): # 预热 for _ in range(self.config.warmup_iterations): - input_text.fn(self.controller_id, "test") + _call_tool(input_text, self.controller_id, "test") # 执行压力测试 results = self.benchmarker.run_multiple( - input_text.fn, + input_text, iterations=self.config.iterations, print_stats=False, controller_id=self.controller_id, @@ -452,11 +465,11 @@ def test_stress_click_key(self): # 预热 for _ in range(self.config.warmup_iterations): - click_key.fn(self.controller_id, 13) # 13 是回车键的虚拟键码 + _call_tool(click_key, self.controller_id, 13) # 13 是回车键的虚拟键码 # 执行压力测试 results = self.benchmarker.run_multiple( - click_key.fn, + click_key, iterations=self.config.iterations, print_stats=False, controller_id=self.controller_id, @@ -482,11 +495,11 @@ def test_stress_scroll(self): # 预热 for _ in range(self.config.warmup_iterations): - scroll.fn(self.controller_id, 0, -120) + _call_tool(scroll, self.controller_id, 0, -120) # 执行压力测试 results = self.benchmarker.run_multiple( - scroll.fn, + scroll, iterations=self.config.iterations, print_stats=False, controller_id=self.controller_id, @@ -507,11 +520,11 @@ def test_stress_double_click(self): # 预热 for _ in range(self.config.warmup_iterations): - double_click.fn(self.controller_id, 100, 100) + _call_tool(double_click, self.controller_id, 100, 100) # 执行压力测试 results = self.benchmarker.run_multiple( - double_click.fn, + double_click, iterations=self.config.iterations, print_stats=False, controller_id=self.controller_id, From 639e4c6b11439b0667ca91eace9b79c47c923246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=B1=E5=B1=B1=E5=AD=90?= <46012438+KhazixW2@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:09:59 +0800 Subject: [PATCH 14/15] Update README_EN.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README_EN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_EN.md b/README_EN.md index 48422bf..d6afbb5 100644 --- a/README_EN.md +++ b/README_EN.md @@ -67,7 +67,7 @@ Talk is cheap, see: **[🎞️ Bilibili Video Demo](https://www.bilibili.com/vid ### ⚡ Pipeline Mode (Multi-threaded Background Monitoring) -- `start_pipeline` - Start background monitoring pipeline, continuously screenshots and caches image paths +- `start_pipeline` - Start background monitoring pipeline, continuously captures screenshots and caches image paths - `stop_pipeline` - Stop pipeline - `get_new_messages` - Get new screenshot paths cached by pipeline - `get_pipeline_status` - Get pipeline running status From 773c2498e4c6f4adc6f96ace19e96572353cdb54 Mon Sep 17 00:00:00 2001 From: KhazixW2 <3196497671@qq.com> Date: Mon, 23 Mar 2026 14:50:04 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20PR=20#28=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 logging_config.py 中未使用的 console_level 参数 - 将 pipeline_server.py 中的裸 except 改为显式捕获 queue.Full - 添加对 _ocr_impl 返回错误字符串的处理,避免错误信息以 type:"ocr" 入队 - 测试无设备时改用 pytest.skip() 替代静默返回 - 删除未使用的 MockController 类 - 修复 StressTestConfig 注释与默认值不一致问题,移除未使用的 timeout 字段 - 为压力测试添加轻量级断言(成功率 >= 80%),避免无断言的假通过 --- maa_mcp/pipeline/logging_config.py | 2 - maa_mcp/pipeline_server.py | 14 ++- tests/test_performance_stress.py | 149 +++++++++++++---------------- 3 files changed, 77 insertions(+), 88 deletions(-) diff --git a/maa_mcp/pipeline/logging_config.py b/maa_mcp/pipeline/logging_config.py index 16fe777..5bce7bb 100644 --- a/maa_mcp/pipeline/logging_config.py +++ b/maa_mcp/pipeline/logging_config.py @@ -19,7 +19,6 @@ def setup_logger( - console_level: str = "INFO", file_level: str = "DEBUG", error_retention: str = "30 days", log_retention: str = "7 days", @@ -31,7 +30,6 @@ def setup_logger( 如果需要临时启用控制台输出,可以取消注释函数内部的控制台输出配置代码。 Args: - console_level: 控制台日志级别(当前未使用) file_level: 文件日志级别 error_retention: 错误日志保留时间 log_retention: 普通日志保留时间 diff --git a/maa_mcp/pipeline_server.py b/maa_mcp/pipeline_server.py index 9af1f27..6f9629c 100644 --- a/maa_mcp/pipeline_server.py +++ b/maa_mcp/pipeline_server.py @@ -11,7 +11,7 @@ import time from threading import Thread, Event -from queue import Queue, Empty +from queue import Queue, Empty, Full from typing import List, Dict, Any # 导入 MCP Core 和 Registry @@ -109,7 +109,13 @@ def run_pipeline_loop( time.sleep(interval) continue - thread_logger.debug(f"[Frame {frame_count}] OCR 成功,结果条数: {len(ocr_results) if isinstance(ocr_results, list) else 0}") + # 检查是否为错误信息(字符串) + if isinstance(ocr_results, str): + thread_logger.warning(f"[Frame {frame_count}] OCR 错误: {ocr_results}") + time.sleep(interval) + continue + + thread_logger.debug(f"[Frame {frame_count}] OCR 成功,结果条数: {len(ocr_results)}") # 将 OCR 结果放入消息队列 message_data = { @@ -120,8 +126,8 @@ def run_pipeline_loop( } try: message_queue.put_nowait(message_data) - thread_logger.info(f"📷 OCR 结果: {len(ocr_results) if isinstance(ocr_results, list) else 0} 条") - except: + thread_logger.info(f"📷 OCR 结果: {len(ocr_results)} 条") + except Full: thread_logger.warning(f"[Frame {frame_count}] 消息队列已满,丢弃 OCR 结果") elapsed = time.time() - loop_start diff --git a/tests/test_performance_stress.py b/tests/test_performance_stress.py index c139230..0679074 100644 --- a/tests/test_performance_stress.py +++ b/tests/test_performance_stress.py @@ -191,69 +191,8 @@ class StressTestConfig: """压力测试配置类""" def __init__(self): - self.iterations = 10 # 默认执行1000次 + self.iterations = 10 # 默认执行次数 self.warmup_iterations = 10 # 预热迭代次数 - self.timeout = 30.0 # 单个测试超时时间(秒) - - -class MockController: - """模拟控制器类,用于模拟设备/窗口控制器的基本功能""" - - def __init__(self): - self.controller_id = "mock_controller" - - def post_screencap(self): - """模拟截图操作""" - time.sleep(0.001) # 模拟截图延迟 - return self - - def wait(self): - """模拟等待操作""" - return self - - def get(self): - """模拟获取结果""" - return b"mock_image_data" # 返回模拟的图片数据 - - def post_touch_down(self, x, y, contact=0): - """模拟触摸按下操作""" - time.sleep(0.001) - return self - - def post_touch_up(self, contact=0): - """模拟触摸抬起操作""" - time.sleep(0.001) - return self - - def post_swipe(self, start_x, start_y, end_x, end_y, duration): - """模拟滑动操作""" - time.sleep(duration / 1000.0 * 0.1) # 模拟滑动延迟,实际时间的1/10 - return self - - def post_input_text(self, text): - """模拟输入文本操作""" - time.sleep(len(text) * 0.0005) # 模拟输入每个字符的延迟 - return self - - def post_key_down(self, key): - """模拟按键按下操作""" - time.sleep(0.001) - return self - - def post_key_up(self, key): - """模拟按键抬起操作""" - time.sleep(0.001) - return self - - def post_scroll(self, x, y): - """模拟滚动操作""" - time.sleep(0.001) - return self - - @property - def succeeded(self): - """模拟操作成功状态""" - return True class TestStressPerformance: @@ -292,9 +231,9 @@ def setup_class(self): except Exception as e: print(f" 获取Windows窗口失败: {e}") - # 如果仍然没有控制器,将使用模拟数据 + # 如果仍然没有控制器,跳过测试类 if not self.controller_id: - print(" 未找到实际设备或窗口,部分测试将使用模拟数据") + pytest.skip("未检测到可用的真实控制器设备/窗口,跳过压力性能测试") def test_stress_find_adb_device_list(self): """压力测试 - find_adb_device_list 函数""" @@ -337,8 +276,7 @@ def test_stress_ocr(self): print(f"\n=== 压力测试: ocr ({self.config.iterations}次) ===") if not self.controller_id: - print(" 没有有效的控制器ID,无法执行OCR测试") - return + pytest.skip("未检测到可用控制器") # 预热 for _ in range(self.config.warmup_iterations): @@ -355,13 +293,25 @@ def test_stress_ocr(self): # 打印详细统计信息 self._print_stress_test_stats(results, "ocr") + # 轻量级断言 + assert results, "OCR 压力测试没有产生任何结果" + successes = [r for r in results if r.success] + success_ratio = len(successes) / len(results) + assert success_ratio >= 0.8, f"OCR 成功率过低: {success_ratio:.2%}" + + durations_ms = [r.execution_time * 1000 for r in successes] + if durations_ms: + avg_duration = sum(durations_ms) / len(durations_ms) + max_duration = max(durations_ms) + assert max_duration < 5000, f"OCR 单次调用耗时过长: {max_duration:.1f} ms" + assert avg_duration < 3000, f"OCR 平均耗时过长: {avg_duration:.1f} ms" + def test_stress_screencap(self): """压力测试 - 截图函数""" print(f"\n=== 压力测试: screencap ({self.config.iterations}次) ===") if not self.controller_id: - print(" 没有有效的控制器ID,无法执行截图测试") - return + pytest.skip("未检测到可用控制器") # 预热 for _ in range(self.config.warmup_iterations): @@ -378,13 +328,18 @@ def test_stress_screencap(self): # 打印详细统计信息 self._print_stress_test_stats(results, "screencap") + # 轻量级断言 + assert results, "截图压力测试没有产生任何结果" + successes = [r for r in results if r.success] + success_ratio = len(successes) / len(results) + assert success_ratio >= 0.8, f"截图成功率过低: {success_ratio:.2%}" + def test_stress_click(self): """压力测试 - 点击函数""" print(f"\n=== 压力测试: click ({self.config.iterations}次) ===") if not self.controller_id: - print(" 没有有效的控制器ID,无法执行点击测试") - return + pytest.skip("未检测到可用控制器") # 预热 for _ in range(self.config.warmup_iterations): @@ -403,13 +358,18 @@ def test_stress_click(self): # 打印详细统计信息 self._print_stress_test_stats(results, "click") + # 轻量级断言 + assert results, "点击压力测试没有产生任何结果" + successes = [r for r in results if r.success] + success_ratio = len(successes) / len(results) + assert success_ratio >= 0.8, f"点击成功率过低: {success_ratio:.2%}" + def test_stress_swipe(self): """压力测试 - 滑动函数""" print(f"\n=== 压力测试: swipe ({self.config.iterations}次) ===") if not self.controller_id: - print(" 没有有效的控制器ID,无法执行滑动测试") - return + pytest.skip("未检测到可用控制器") # 预热 for _ in range(self.config.warmup_iterations): @@ -431,13 +391,18 @@ def test_stress_swipe(self): # 打印详细统计信息 self._print_stress_test_stats(results, "swipe") + # 轻量级断言 + assert results, "滑动压力测试没有产生任何结果" + successes = [r for r in results if r.success] + success_ratio = len(successes) / len(results) + assert success_ratio >= 0.8, f"滑动成功率过低: {success_ratio:.2%}" + def test_stress_input_text(self): """压力测试 - 输入文本函数""" print(f"\n=== 压力测试: input_text ({self.config.iterations}次) ===") if not self.controller_id: - print(" 没有有效的控制器ID,无法执行输入文本测试") - return + pytest.skip("未检测到可用控制器") # 预热 for _ in range(self.config.warmup_iterations): @@ -455,13 +420,18 @@ def test_stress_input_text(self): # 打印详细统计信息 self._print_stress_test_stats(results, "input_text") + # 轻量级断言 + assert results, "输入文本压力测试没有产生任何结果" + successes = [r for r in results if r.success] + success_ratio = len(successes) / len(results) + assert success_ratio >= 0.8, f"输入文本成功率过低: {success_ratio:.2%}" + def test_stress_click_key(self): """压力测试 - 按键点击函数""" print(f"\n=== 压力测试: click_key ({self.config.iterations}次) ===") if not self.controller_id: - print(" 没有有效的控制器ID,无法执行按键点击测试") - return + pytest.skip("未检测到可用控制器") # 预热 for _ in range(self.config.warmup_iterations): @@ -479,19 +449,23 @@ def test_stress_click_key(self): # 打印详细统计信息 self._print_stress_test_stats(results, "click_key") + # 轻量级断言 + assert results, "按键点击压力测试没有产生任何结果" + successes = [r for r in results if r.success] + success_ratio = len(successes) / len(results) + assert success_ratio >= 0.8, f"按键点击成功率过低: {success_ratio:.2%}" + def test_stress_scroll(self): """压力测试 - 滚动函数""" print(f"\n=== 压力测试: scroll ({self.config.iterations}次) ===") if not self.controller_id: - print(" 没有有效的控制器ID,无法执行滚动测试") - return + pytest.skip("未检测到可用控制器") # 检查是否为 ADB 控制器 info = controller_info_registry.get(self.controller_id) if info and info.controller_type == ControllerType.ADB: - print(" 当前控制器为 ADB,跳过 scroll 压力测试 (仅支持 Windows)") - return + pytest.skip("当前控制器为 ADB,跳过 scroll 压力测试 (仅支持 Windows)") # 预热 for _ in range(self.config.warmup_iterations): @@ -510,13 +484,18 @@ def test_stress_scroll(self): # 打印详细统计信息 self._print_stress_test_stats(results, "scroll") + # 轻量级断言 + assert results, "滚动压力测试没有产生任何结果" + successes = [r for r in results if r.success] + success_ratio = len(successes) / len(results) + assert success_ratio >= 0.8, f"滚动成功率过低: {success_ratio:.2%}" + def test_stress_double_click(self): """压力测试 - 双击函数""" print(f"\n=== 压力测试: double_click ({self.config.iterations}次) ===") if not self.controller_id: - print(" 没有有效的控制器ID,无法执行双击测试") - return + pytest.skip("未检测到可用控制器") # 预热 for _ in range(self.config.warmup_iterations): @@ -535,6 +514,12 @@ def test_stress_double_click(self): # 打印详细统计信息 self._print_stress_test_stats(results, "double_click") + # 轻量级断言 + assert results, "双击压力测试没有产生任何结果" + successes = [r for r in results if r.success] + success_ratio = len(successes) / len(results) + assert success_ratio >= 0.8, f"双击成功率过低: {success_ratio:.2%}" + def _print_stress_test_stats(self, results: List, function_name: str): """打印压力测试的详细统计信息""" if not results: