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: