# -*- encoding=utf8 -*- import redis from redis import ConnectionPool import json import time import socket import uuid import platform import os import sys from typing import Optional, Dict, Any, Tuple CONFIG_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) if CONFIG_ROOT not in sys.path: sys.path.insert(0, CONFIG_ROOT) from config_loader import load_config as load_autool_config def _load_worker_config() -> Dict[str, Any]: return load_autool_config() def _resolve_device_type(payload: Dict[str, Any]) -> str: return "emulator" if bool(payload.get("IS_EMULATOR", True)) else "physical" WORKER_CONFIG = _load_worker_config() CONTROL_REDIS_HOST = str(WORKER_CONFIG["CONTROL_REDIS_HOST"]).strip() CONTROL_REDIS_PORT = int(WORKER_CONFIG["CONTROL_REDIS_PORT"]) CONTROL_REDIS_DB = int(WORKER_CONFIG["CONTROL_REDIS_DB"]) CONTROL_CHANNEL_NAMESPACE = str(WORKER_CONFIG.get("CONTROL_CHANNEL_NAMESPACE", "")).strip() DEFAULT_DEVICE_TYPE = _resolve_device_type(WORKER_CONFIG) def _channel_name(name: str) -> str: return f"{CONTROL_CHANNEL_NAMESPACE}:{name}" if CONTROL_CHANNEL_NAMESPACE else name class TaskWorker: _connection_pool: Optional[ConnectionPool] = None _connection_pool_config: Optional[Tuple[str, int, int, int]] = None def __init__( self, redis_host=CONTROL_REDIS_HOST, redis_port=CONTROL_REDIS_PORT, redis_db=CONTROL_REDIS_DB, max_connections=10, ): pool_config = (redis_host, redis_port, redis_db, max_connections) if TaskWorker._connection_pool is None or TaskWorker._connection_pool_config != pool_config: TaskWorker._connection_pool = ConnectionPool( host=redis_host, port=redis_port, db=redis_db, decode_responses=True, max_connections=max_connections, socket_timeout=30, socket_connect_timeout=10, retry_on_timeout=True ) TaskWorker._connection_pool_config = pool_config self.redis = redis.Redis(connection_pool=TaskWorker._connection_pool) self.current_task: Optional[Dict[str, Any]] = None self.worker_id: Optional[str] = None self.ip_address: Optional[str] = None self.mac_address: Optional[str] = None self.hostname: Optional[str] = None self.platform: Optional[str] = None self.device_type: Optional[str] = None self.DEFAULT_TIMEOUT = 60 self.WAIT_INTERVAL = 30 def _get_ip_address(self): """获取本机IP地址(优先获取192.168.2.开头的IP)""" try: hostname = socket.gethostname() ip_addresses = socket.gethostbyname_ex(hostname)[2] for ip in ip_addresses: if ip.startswith('192.168.2.'): return ip for ip in ip_addresses: if ip.startswith('192.168.'): return ip if ip_addresses: return ip_addresses[0] return '127.0.0.1' except Exception as e: print(f"获取IP地址失败: {e}") return '127.0.0.1' def _get_mac_address(self) -> str: """获取本机Mac地址,优先匹配真实物理网卡,保持与add_workers.py逻辑一致""" if platform.system() == "Windows": try: import subprocess import re result = subprocess.run(["getmac", "/fo", "csv", "/nh"], capture_output=True, text=True, timeout=10) output = result.stdout.strip() best_mac = None for line in output.splitlines(): line = line.strip() if not line: continue parts = [p.strip().strip('"') for p in line.split(",")] if len(parts) < 2: continue mac = parts[0].strip() transport = parts[1].strip() if len(parts) > 1 else "" # 跳过无效 MAC if not re.match(r"([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}", mac): continue # 跳过已断开的连接 if "已断开" in transport or "Disconnected" in transport.lower() or "Media disconnected" in transport.lower(): continue mac = mac.upper().replace("-", ":") # 优先选取硬件以太网适配器 transport_lower = transport.lower() if any(kw in transport_lower for kw in ["ethernet", "以太网", "realtek", "intel"]): best_mac = mac break if best_mac is None: best_mac = mac if best_mac: return best_mac except Exception as e: print(f"通过getmac获取MAC地址失败,尝试降级方案: {e}") # 降级方案(非Windows或getmac失败) try: mac = uuid.getnode() mac_address = ':'.join(['{:02x}'.format((mac >> elements) & 0xff) for elements in range(0, 8*6, 8)][::-1]) return mac_address.upper() except Exception as e: print(f"获取MAC地址失败: {e}") return '00:00:00:00:00:00' def _send_request(self, publish_channel: str, response_channel: str, request_data: Dict[str, Any], timeout: Optional[int] = None) -> Optional[Dict[str, Any]]: """通用的请求发送方法,避免代码重复 Args: publish_channel: 发布请求的频道 response_channel: 订阅响应的频道 request_data: 请求数据 timeout: 超时时间(秒),默认使用self.DEFAULT_TIMEOUT Returns: 响应数据字典,超时或失败返回None """ if timeout is None: timeout = self.DEFAULT_TIMEOUT pubsub = self.redis.pubsub() try: # 先订阅,再发布,避免竞态条件 pubsub.subscribe(response_channel) # 发布请求 self.redis.publish(publish_channel, json.dumps(request_data)) # 等待响应(带超时) start_time = time.time() while time.time() - start_time < timeout: message = pubsub.get_message(timeout=1) if message and message['type'] == 'message': return json.loads(message['data']) return None # 超时 finally: pubsub.unsubscribe(response_channel) pubsub.close() def init(self) -> Optional[Dict[str, Any]]: """初始化函数:上报MAC+IP并领取第一个任务 Returns: dict: 包含任务信息的字典 如果没有任务,会阻塞等待直到有任务 """ try: self.ip_address = self._get_ip_address() self.mac_address = self._get_mac_address() self.hostname = socket.gethostname() self.platform = platform.system() self.device_type = DEFAULT_DEVICE_TYPE self.worker_id = f"{self.ip_address}_{self.mac_address}" print(f"Worker信息:") print(f" Worker ID: {self.worker_id}") print(f" IP地址: {self.ip_address}") print(f" MAC地址: {self.mac_address}") print(f" 主机名: {self.hostname}") print(f" 平台: {self.platform}") print(f" 设备类型: {self.device_type}") init_request = { 'worker_id': self.worker_id, 'ip_address': self.ip_address, 'mac_address': self.mac_address, 'hostname': self.hostname, 'platform': self.platform, 'device_type': self.device_type, } while True: print("等待分发器响应...") response = self._send_request( publish_channel=_channel_name("worker:init"), response_channel=_channel_name(f"worker:init:response:{self.worker_id}"), request_data=init_request ) if response is None: print(f"初始化超时,等待 {self.WAIT_INTERVAL} 秒后重试...") time.sleep(self.WAIT_INTERVAL) continue task = response.get('task') if task: self.current_task = task print(f"初始化成功,领取任务: {task['app_name']} ({task['package_name']})") return task else: print("暂无任务,等待 {self.WAIT_INTERVAL} 秒后重试...") time.sleep(self.WAIT_INTERVAL) except Exception as e: print(f"初始化失败: {e}") import traceback traceback.print_exc() return None def report(self, report_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """上报前一个任务的完成状态,并获取下一个任务 Args: report_data: 上报数据字典,结构如下: { "status": "success|failed|stop", # 必填 "error": { # 失败时必填 "type": "DOWNLOAD_ERROR/5", "category": "DOWNLOAD_ERROR", "code": 5, "reason": "错误原因描述", "details": "详细错误信息(可选)", "crashed_source": "google_play" # APP_CRASH时必填,闪退的下载源 }, "metrics": { # 可选 "login_count": 0, "register_count": 0, "guiagent_message": "GuiAgent消息", "scenario_triggered": true }, "statistics": { # 可选 "round": 1, "exit_code": 0, "error_reason": "...", "droidbot_steps": 100, "guiagent_steps": 50, "total_steps": 150, "duration_seconds": 120.5, "is_retry": false, "download_source": "google" } } Returns: dict: 包含下一个任务信息的字典 如果没有任务,会阻塞等待直到有任务(stop状态除外) """ try: if not self.current_task: print("警告: 没有当前任务,无法上报") return None previous_task_key = self.current_task['task_key'] status = report_data.get('status', 'failed') print(f"上报任务: {previous_task_key} = {status}") error_info = report_data.get('error', {}) if error_info: error_type = error_info.get('type') if not error_type: category = error_info.get('category') code = error_info.get('code') error_type = f"{category}/{code}" if category is not None and code is not None else 'UNKNOWN' error_reason = error_info.get('reason', '') print(f"错误类型: {error_type}") if error_reason: print(f"错误原因: {error_reason}") metrics = report_data.get('metrics', {}) if metrics: login_count = metrics.get('login_count', 0) register_count = metrics.get('register_count', 0) if login_count > 0 or register_count > 0: print(f"指标: 登录场景 {login_count} 次, 注册场景 {register_count} 次") statistics = report_data.get('statistics', {}) if statistics: duration = statistics.get('duration_seconds', 0) total_steps = statistics.get('total_steps', 0) print(f"统计: 耗时 {duration}s, 总步数 {total_steps}") report_request = { 'worker_id': self.worker_id, 'previous_task_key': previous_task_key, 'report_data': report_data } if status == 'stop': print("发送停止请求...") response = self._send_request( publish_channel=_channel_name("worker:report"), response_channel=_channel_name(f"worker:report:response:{self.worker_id}"), request_data=report_request ) self.current_task = None return None while True: print("等待分发器响应...") response = self._send_request( publish_channel=_channel_name("worker:report"), response_channel=_channel_name(f"worker:report:response:{self.worker_id}"), request_data=report_request ) if response is None: print(f"上报超时,等待 {self.WAIT_INTERVAL} 秒后重试...") time.sleep(self.WAIT_INTERVAL) continue next_task = response.get('task') if next_task: self.current_task = next_task print(f"上报成功,领取新任务: {next_task['app_name']} ({next_task['package_name']})") return next_task else: print("当前无新任务,结束本轮上报") self.current_task = None return None except Exception as e: print(f"上报失败: {e}") import traceback traceback.print_exc() return None def event(self, event_data: Dict[str, Any]) -> bool: """发送监控事件,不阻塞主流程。""" try: if not self.worker_id: return False payload = dict(event_data or {}) payload.setdefault("worker_id", self.worker_id) payload.setdefault("event_time", time.time()) if "task_key" not in payload and self.current_task: payload["task_key"] = self.current_task.get("task_key") self.redis.publish(_channel_name("worker:event"), json.dumps(payload)) return True except Exception as e: print(f"发送监控事件失败: {e}") return False def retry(self) -> bool: """重试函数:告知该任务需重新计时 Returns: bool: 是否成功更新 """ try: if not self.current_task: print("警告: 没有当前任务,无法发送重试请求") return False # 构建重试请求 retry_request = { 'worker_id': self.worker_id, 'current_task_key': self.current_task['task_key'] } # 使用通用方法发送请求(5秒超时) response = self._send_request( publish_channel=_channel_name("worker:retry"), response_channel=_channel_name(f"worker:retry:response:{self.worker_id}"), request_data=retry_request, timeout=5 ) if response is None: print("重试请求超时") return False success = response.get('success', False) if success: print(f"重试成功,任务 {self.current_task['task_key']} 已重新计时") else: print("重试失败") return success except Exception as e: print(f"重试失败: {e}") return False def main(): print("=" * 60) print("Redis任务分发Worker(简化接口版)") print("=" * 60) worker = TaskWorker() try: # 1. 初始化:注册Worker并领取第一个任务 print("\n[1] 初始化Worker...") task = worker.init() if not task: print("没有任务需要处理,程序退出") return # 2. 处理任务循环 print("\n[2] 开始处理任务...") print("按 Ctrl+C 停止Worker\n") while task: try: # 获取任务信息 app_name = task['app_name'] package_name = task['package_name'] task_key = task['task_key'] print(f"\n开始处理任务:") print(f" 应用名称: {app_name}") print(f" 包名: {package_name}") print(f" 任务键: {task_key}") # 这里可以添加自定义的任务处理逻辑 # 例如:调用外部脚本、执行测试等 # 模拟任务处理 print("正在处理任务...") time.sleep(2) # 定期发送心跳(模拟) print("发送心跳...") worker.retry() # 继续处理 time.sleep(10) # 上报任务完成状态 status = 'success' # 或 'failed' print(f"任务完成,状态: {status}") # 3. 上报任务状态并获取下一个任务 report_data = { "status": status, "error": None, "metrics": { "login_count": 0, "register_count": 0 } } task = worker.report(report_data) except KeyboardInterrupt: print("\n检测到中断信号,正在退出...") break except Exception as e: print(f"处理任务时出错: {e}") import traceback traceback.print_exc() # 上报失败状态 task = worker.report({ "status": "failed", "error": {"type": "TASK_ERROR", "reason": str(e)} }) print("\n所有任务已完成或无更多任务") except KeyboardInterrupt: print("\n检测到中断信号,正在退出...") except Exception as e: print(f"\n程序发生错误: {e}") import traceback traceback.print_exc() if __name__ == "__main__": main()