autool/utils_android/TaskWorker/worker_integrated.py
2026-06-17 19:44:18 +08:00

509 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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()