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

416 lines
17 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MuMu模拟器管理模块
"""
import subprocess
import os
import time
import configparser
import shutil
import re
import requests
import logging
import json
import sys
# 注意:日志配置现在由统一的 logging_config 模块管理
# 配置日志
# logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class MuMuManager:
def __init__(self, config, vm_index=None):
self.config = config
# 优先从 mumu 子配置读取,兼容旧的扁平键名
mumu = config.get('mumu', {}) if isinstance(config, dict) else {}
self.vm_index = (
vm_index
or mumu.get('default_vm_index')
or config.get('DEFAULT_VM_INDEX', 2)
)
self.mumu_manager_path = (
mumu.get('manager_path')
or config.get('MUMU_MANAGER_PATH')
)
self.clean_backup_path = (
mumu.get('clean_backup')
or config.get('CLEAN_BACKUP_PATH')
or config.get('shared_paths', {}).get('clean_backup')
)
self.local_import_dir = (
mumu.get('local_import_dir')
or config.get('LOCAL_IMPORT_DIR')
)
self.max_vm_index = (
mumu.get('max_vm_index')
or config.get('MAX_VM_INDEX', 10)
)
self.net_bridge_card = (
mumu.get('bridge_card')
or config.get('NET_BRIDGE_CARD', 'Realtek PCIe GbE Family Controller')
)
# 从 MuMuManager.exe 路径推导工作目录,回退到 C:\
if self.mumu_manager_path and os.path.exists(os.path.dirname(self.mumu_manager_path)):
self._cwd = os.path.dirname(self.mumu_manager_path)
else:
self._cwd = 'C:\\'
@property
def device_type(self) -> str:
return 'emulator'
def get_active_network_adapter(self):
"""获取第一个有网的网卡名称"""
try:
output = subprocess.check_output(
['wmic', 'nic', 'where', 'NetConnectionStatus=2', 'get', 'Name', '/value'],
encoding='utf-8', errors='ignore', timeout=10
)
for line in output.strip().split('\n'):
if line.startswith('Name='):
adapter_name = line.split('=', 1)[1].strip()
if adapter_name:
logger.info(f"检测到活动网卡: {adapter_name}")
return adapter_name
except Exception as e:
logger.warning(f"获取网卡信息失败: {e}")
# 如果获取失败,返回默认值
default_adapter = self.config.get('NET_BRIDGE_CARD', 'Realtek PCIe GbE Family Controller')
logger.warning(f"无法获取活动网卡,使用默认值: {default_adapter}")
return default_adapter
def run_cmd(self, command, use_index=True):
"""执行 MuMuManager 命令"""
# 路径需要用引号包裹防止Windows路径中的空格导致命令解析错误
cmd = f'"{self.mumu_manager_path}" {command}' + (f" -v {self.vm_index}" if use_index else "")
logger.info(f"执行: {cmd}")
# 使用 utf-8 编码并忽略无法解码的字符,防止在 Windows GBK 环境下崩溃
res = subprocess.run(cmd, shell=True, capture_output=True, encoding='utf-8', errors='ignore', cwd=self._cwd, timeout=60)
if res.returncode != 0: logger.error(f"命令失败: {res.stdout.strip()}\n {res.stderr.strip()}")
return res.returncode == 0
def force_cleanup(self):
"""清理残留进程"""
logger.info("清理 MuMu 进程...")
for proc in ['MuMuPlayer.exe', 'MuMuNxDevice.exe', 'MuMuNxMain.exe']:
# 统一使用 encoding 和 errors 参数
subprocess.run(f'taskkill /f /im {proc}', shell=True, capture_output=True,
encoding='utf-8', errors='ignore')
time.sleep(10)
def get_player_state(self) -> str:
"""
获取模拟器播放器状态
:return: 状态字符串
- 'starting_rom': 模拟器正在启动
- 'start_finished': 模拟器运行正常
- '': 空字符串表示模拟器卡死或未响应
- 'FROZEN': 检测到错误输出(如 result=-3表示卡死
- None: 无法获取状态(命令执行失败或超时)
"""
try:
cmd = f'"{self.mumu_manager_path}" api player_state -v {self.vm_index}'
logger.info(f"检查模拟器状态: {cmd}")
result = subprocess.run(
cmd, shell=True, capture_output=True,
encoding='utf-8', errors='ignore',
cwd=self._cwd, timeout=30
)
output = result.stdout.strip()
logger.debug(f"模拟器状态输出: {output}")
# 检测错误输出,如 "check player state fail: result=-3"
if 'fail' in output.lower() or 'result=-' in output:
logger.warning(f"模拟器状态检测失败: {output}")
return 'FROZEN' # 返回特殊标记表示卡死
# 解析 state=xxx 格式的输出
# 例如: "check player state: state=start_finished"
match = re.search(r'state=([\w]*)', output)
if match:
state = match.group(1)
logger.info(f"模拟器状态: {state if state else '(空/卡死)'}")
return state
logger.warning(f"无法解析模拟器状态输出: {output}")
return None
except subprocess.TimeoutExpired:
logger.error("获取模拟器状态超时,视为卡死")
return 'FROZEN' # 超时也视为卡死
except Exception as e:
logger.error(f"获取模拟器状态失败: {e}")
return None
def is_player_frozen(self) -> bool:
"""
检查模拟器是否处于卡死状态
:return: True 表示卡死False 表示正常或正在启动
以下情况判定为卡死:
- state='' (空字符串)
- state='FROZEN' (检测到错误输出或超时)
- state=None (命令执行失败)
"""
state = self.get_player_state()
# 空字符串、FROZEN 标记、None 都视为卡死
frozen = state in ('', 'FROZEN', None)
if frozen:
logger.warning(f"判定模拟器卡死 (state={repr(state)})")
return frozen
def launch_player(self) -> bool:
"""
启动模拟器播放器(不执行 shutdown
:return: 成功返回 True失败返回 False
"""
logger.info("启动模拟器...")
cmd = f'"{self.mumu_manager_path}" control -v {self.vm_index} launch'
logger.info(f"执行: {cmd}")
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
encoding='utf-8',
errors='ignore',
cwd=self._cwd,
timeout=60,
)
if result.returncode == 0:
# 等待启动完成并获取 IP
for _ in range(10):
time.sleep(15)
ip = self.get_adb_ip()
if ip:
logger.info(f"获取到模拟器 IP: {ip},尝试 ADB 连接...")
# 执行 adb connect 连接设备
try:
connect_result = subprocess.run(
['adb', 'connect', ip],
capture_output=True,
encoding='utf-8',
errors='ignore',
timeout=10
)
logger.info(f"ADB connect 输出: {connect_result.stdout.strip()}")
# 验证连接是否成功
verify_result = subprocess.run(
['adb', '-s', ip, 'shell', 'getprop', 'ro.build.version.sdk'],
capture_output=True,
encoding='utf-8',
errors='ignore',
timeout=10
)
if verify_result.returncode == 0 and verify_result.stdout.strip():
logger.info(f"模拟器启动成功ADB 连接验证通过: {ip} (SDK: {verify_result.stdout.strip()})")
return True
else:
logger.warning(f"ADB 连接验证失败,继续等待...")
except subprocess.TimeoutExpired:
logger.warning(f"ADB 连接超时,继续等待...")
except Exception as e:
logger.warning(f"ADB 连接异常: {e},继续等待...")
else:
logger.error(f"命令失败: {result.stdout.strip()}\n {result.stderr.strip()}")
logger.error("模拟器启动失败")
return False
def recover_from_frozen(self) -> bool:
"""
从卡死状态恢复模拟器
使用 force_cleanup() 强杀进程后重新启动
:return: 成功返回 True失败返回 False
"""
logger.warning("检测到模拟器卡死,执行强制恢复...")
self.force_cleanup()
return self.launch_player()
def get_adb_ip(self):
"""获取 ADB IP"""
# 明确指定编码为 utf-8 并忽略错误,解决 Windows 下的 UnicodeDecodeError
output = subprocess.check_output([self.mumu_manager_path, "info", "-v", str(self.vm_index)],
encoding='utf-8', errors='ignore', timeout=30)
match = re.search(r'"adb_host_ip":\s*"([^"]+)"', output)
return match.group(1) if match else None
def prepare_import_file(self):
"""同步备份文件到本地目录"""
if not self.local_import_dir:
# 如果没配置,回退到桌面
self.local_import_dir = os.path.join(os.path.expanduser("~"), "Desktop")
if not os.path.exists(self.local_import_dir):
os.makedirs(self.local_import_dir, exist_ok=True)
target = os.path.join(self.local_import_dir, os.path.basename(self.clean_backup_path))
# 检查源文件是否存在
if not os.path.exists(self.clean_backup_path):
error_msg = f"找不到源备份文件: {self.clean_backup_path}"
logger.error(error_msg)
raise FileNotFoundError(error_msg)
if os.path.exists(target) and os.path.getsize(target) == os.path.getsize(self.clean_backup_path):
logger.info(f"备份文件已存在且大小一致,跳过拷贝: {target}")
return target
logger.info(f"开始拷贝备份文件: {self.clean_backup_path} -> {target}")
try:
shutil.copy2(self.clean_backup_path, target)
except Exception as e:
error_msg = f"拷贝备份文件失败: {e}"
logger.error(error_msg)
raise
return target
def check_status(self) -> bool:
"""
检查模拟器是否正常运行,并验证 ADB 连接是否可用
:return: 如果模拟器运行正常且 ADB 连接可用返回True否则返回False
"""
try:
# 1. 检查模拟器进程是否存在
ip = self.get_adb_ip()
if not ip:
logger.warning("无法获取模拟器ADB IP模拟器可能未运行")
return False
# 2. 验证 ADB 实际连接(关键修复)
logger.debug(f"检测到模拟器 IP: {ip},验证 ADB 连接...")
try:
# 尝试连接设备
result = subprocess.run(
['adb', 'connect', ip],
capture_output=True,
encoding='utf-8',
errors='ignore',
timeout=10
)
# 执行测试命令验证设备确实可用
result = subprocess.run(
['adb', '-s', ip, 'shell', 'getprop', 'ro.build.version.sdk'],
capture_output=True,
encoding='utf-8',
errors='ignore',
timeout=5
)
if result.returncode == 0 and result.stdout.strip():
logger.debug(f"模拟器 ADB 连接验证成功: {ip} (SDK: {result.stdout.strip()})")
return True
else:
logger.warning(f"ADB 连接验证失败: {ip},设备未响应")
return False
except subprocess.TimeoutExpired:
logger.warning(f"ADB 连接验证超时: {ip}")
return False
except Exception as adb_err:
logger.warning(f"ADB 连接验证异常: {ip} - {adb_err}")
return False
except Exception as e:
logger.warning(f"检查模拟器状态失败: {e}")
return False
def ensure_running(self) -> tuple:
"""
确保模拟器正在运行,如果未运行则尝试启动
:return: (success, restarted) - success表示模拟器是否正常运行restarted表示是否执行了重启
"""
if self.check_status():
return (True, False) # 正常运行,未重启
logger.warning("模拟器未运行或 ADB 连接失败,检查模拟器状态...")
# 检测是否卡死,选择恢复策略
if self.is_player_frozen():
# 模拟器卡死:使用 force_cleanup + launch_player
logger.warning("检测到模拟器卡死状态 (state=空),执行强制恢复...")
success = self.recover_from_frozen()
else:
# 模拟器未卡死:使用常规重启
logger.info("模拟器状态正常,执行常规重启...")
success = self.restart()
return (success, True) # 返回重启结果和重启标志
def restart(self):
"""重启模拟器"""
logger.info("正在重启模拟器...")
cmd = f'"{self.mumu_manager_path}" control --vmindex {self.vm_index} restart'
logger.info(f"执行: {cmd}")
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
encoding='utf-8',
errors='ignore',
cwd=self._cwd,
timeout=120,
)
if result.returncode != 0:
logger.error(f"模拟器重启命令失败: {result.stdout.strip()}\n {result.stderr.strip()}")
return False
for _ in range(18):
time.sleep(10)
if self.check_status():
ip = self.get_adb_ip()
logger.info(f"模拟器重启成功ADB IP: {ip}")
return True
logger.error("模拟器重启失败:等待超时")
return False
def recover(self):
"""执行恢复流程"""
import_path = self.prepare_import_file()
for attempt in range(1, 11):
logger.info(f"尝试恢复 #{attempt}")
# self.force_cleanup() # 注释强杀,避免文件系统损坏
# 1. 重置环境
self.run_cmd('api shutdown_player')
time.sleep(15)
self.run_cmd('delete')
time.sleep(15)
for i in range(2, self.max_vm_index + 1):
self.run_cmd(f'delete -v {i}')
time.sleep(15)
# 2. 导入与配置
if not self.run_cmd(f'import -p "{import_path}"'): continue
time.sleep(15)
net_adapter = self.get_active_network_adapter()
self.run_cmd(f'setting -k "net_bridge_card" -val "{net_adapter}"')
time.sleep(15)
# 3. 启动并校验
if self.run_cmd('api launch_player'):
for _ in range(10):
time.sleep(15)
ip = self.get_adb_ip()
if ip:
logger.info("恢复成功!")
self.run_cmd('api shutdown_player')
return True
logger.warning(f"{attempt} 次失败")
return False
if __name__ == '__main__':
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from config_loader import load_config
config = load_config()
mumu_controller = MuMuManager(config)
net_adapter = mumu_controller.get_active_network_adapter()
print(net_adapter)