Files
auto_control/core/adb_helper.py
T

385 lines
15 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.
"""adb 命令工具封装。
并发关键:adb_connect 绝不 kill-server(会断开其他 worker 正在用的设备),
用全局锁串行化连接阶段,connect 本身很快,串行不影响整体并发。
"""
import re
import time
import subprocess
import threading
from config import ADB_PATH
from core.logger import get_logger
_log = get_logger("core.adb")
_ADB_LOCK = threading.Lock()
# adb 命令超时(秒)。adb connect 到不可达主机(如 Tailscale IP 不通)会长时间挂起,
# 没有超时会让 worker 永久卡在 connecting。connect/disconnect 正常都应瞬时完成。
_ADB_TIMEOUT = 30
def _adb(*args, timeout=_ADB_TIMEOUT):
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
遇到非 GBK 字节会崩溃)。
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
timeout 可覆盖(扫描验证等非关键路径用短超时快速失败)。
"""
try:
r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
_log.warning(f"adb {' '.join(args)} 超时 {timeout}s")
return ""
return _decode(r)
def _adb_remote(host, port, *args):
"""在远程 adb server(如 220)上执行 adb 命令,返回文本。
USB 设备插在 220,本机 adb server 看不到,必须 -H/-P 指向 220 的 adb server
(host 网络模式,5037 全接口监听,Tailscale 可达)。超时语义同 _adb。
"""
try:
r = subprocess.run([ADB_PATH, "-H", host, "-P", str(port), *args],
capture_output=True, timeout=_ADB_TIMEOUT)
except subprocess.TimeoutExpired:
_log.warning(f"adb -H {host} -P {port} {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
return ""
return _decode(r)
def _decode(r):
"""subprocess 结果解码(Windows GBK 兼容)。"""
out = (r.stdout or b"") + (r.stderr or b"")
try:
return out.decode("utf-8", errors="replace").strip()
except Exception:
return out.decode("gbk", errors="replace").strip()
def get_foreground_app(remote_adb_url, timeout=3):
"""获取设备当前前台 App 包名。
通过 adb shell dumpsys window 解析 mCurrentFocus。
remote_adb_url: adb 连接地址(如 192.168.20.17:12345),设备需已 adb connect。
返回包名字符串(如 com.ss.android.ugc.aweme),失败/无设备返回 None。
"""
if not remote_adb_url:
return None
try:
r = subprocess.run(
[ADB_PATH, "-s", remote_adb_url, "shell",
"dumpsys window | grep mCurrentFocus"],
capture_output=True, timeout=timeout
)
out = (r.stdout or b"").decode("utf-8", errors="replace").strip()
if not out:
return None
low = out.lower()
if "device offline" in low or "device not found" in low or "error:" in low:
return None
# 解析 mCurrentFocus=Window{abc u0 com.xx/com.xx.MainActivity}
for line in out.split("\n"):
if "mCurrentFocus" not in line:
continue
# 提取 u0 后面的 token(包名/Activity 或包名)
m = re.search(r'\bu0\s+(\S+)', line)
if m:
token = m.group(1).rstrip("}")
if "/" in token:
return token.split("/")[0]
return token or None
return None
except subprocess.TimeoutExpired:
return None
except Exception:
return None
def get_foreground_app_remote(host, port, serial, timeout=3):
"""远程 adb server(220)上设备的前台 App(USB 设备用)。
与 get_foreground_app 同逻辑,只是 adb 指向远程 server(-H/-P)。
"""
if not serial:
return None
try:
r = subprocess.run(
[ADB_PATH, "-H", host, "-P", str(port), "-s", serial, "shell",
"dumpsys window | grep mCurrentFocus"],
capture_output=True, timeout=timeout
)
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace").strip()
if not out:
return None
low = out.lower()
if "device offline" in low or "device not found" in low or "error:" in low:
return None
for line in out.split("\n"):
if "mCurrentFocus" not in line:
continue
m = re.search(r'\bu0\s+(\S+)', line)
if m:
token = m.group(1).rstrip("}")
if "/" in token:
return token.split("/")[0]
return token or None
return None
except subprocess.TimeoutExpired:
return None
except Exception:
return None
def adb_connect(remote_adb_url, retries=5):
"""adb connect(带重试)。返回 True/False。
首次常因认证握手未就绪报 failed to authenticate,第2次重试通常 already connected。
STF remoteConnect 启用后端口监听可能需要几秒,所以重试间隔递增。
**绝不 kill-server**:kill-server 会断开所有设备的 adb transport,
导致 STF provider 对全部设备误判离线并触发重连,影响所有运行中的任务。
连接失败就返回 False,由调用方处理(标记任务失败,不影响其他设备)。
"""
with _ADB_LOCK:
return _try_connect(remote_adb_url, retries)
def _try_connect(remote_adb_url, retries):
"""尝试连接,不 kill-server。返回 True/False。"""
for attempt in range(1, retries + 1):
out = _adb("connect", remote_adb_url)
_log.info(f"{remote_adb_url} 第 {attempt} 次尝试: {out}")
if "connected" in out.lower() and "failed" not in out.lower():
return True
time.sleep(1 + attempt)
return False
def adb_connect_light(remote_adb_url, timeout=5):
"""轻量 adb connect(扫描/验证专用):单次尝试,不重试,绝不 kill-server。
与 adb_connect 的区别:
- 不重试:失败立即返回 False
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
- 短超时(默认 5s):验证场景(设备发现)连不上的主机快速放弃,
避免每台卡 30s 拖慢整轮发现并长时间占用全局 adb 锁
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
"""
with _ADB_LOCK:
out = _adb("connect", remote_adb_url, timeout=timeout)
if "connected" in out.lower() and "failed" not in out.lower():
return True
return False
def adb_disconnect(remote_adb_url):
"""adb disconnect。"""
try:
_adb("disconnect", remote_adb_url)
except Exception:
pass
def screenshot(serial, timeout=5):
"""获取设备当前画面截图(PNG bytes)。
用 `adb exec-out -s <serial> shell screencap -p`,只读操作,
不抢占 u2 的 atx-agent 通道,任务运行中调用安全。
返回 (ok, png_bytes_or_errmsg)。
"""
if not serial:
return False, "缺少 serial"
# 先尝试连一下(任务运行中设备已 connect,这里是幂等的)
if not adb_connect_light(serial):
return False, "adb connect 失败"
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "exec-out", "screencap", "-p"],
capture_output=True, timeout=timeout,
)
# PNG 文件头:\x89PNG
if r.stdout and r.stdout[:4] == b"\x89PNG":
return True, r.stdout
# 失败时 stdout 可能为空,看 stderr
err = (r.stderr or b"").decode("utf-8", errors="replace").strip()
return False, err or "截图失败(设备可能离线)"
except subprocess.TimeoutExpired:
return False, "截图超时"
except Exception as e:
return False, str(e)
# 常见应用名映射:快速识别大/重要应用,避免拉取 APK 解析(慢)
COMMON_APPS = {
"com.ss.android.ugc.aweme": "抖音",
"com.ss.android.ugc.aweme.lite": "抖音极速版",
"com.smile.gifmaker": "快手",
"com.kuaishou.nebula": "快手极速版",
"com.tencent.mm": "微信",
"com.tencent.mobileqq": "QQ",
"com.tencent.qqlive": "腾讯视频",
"com.eg.android.AlipayGphone": "支付宝",
"com.taobao.taobao": "淘宝",
"com.tmall.wireless": "天猫",
"com.jingdong.app.mall": "京东",
"com.xunmeng.pinduoduo": "拼多多",
"com.ss.android.article.news": "今日头条",
"com.zhihu.android": "知乎",
"com.miui.home": "桌面",
"com.android.settings": "设置",
"com.android.systemui": "系统",
"com.github.uiautomator": "ATX",
"com.github.uiautomator.test": "ATX测试",
"jp.co.cyberagent.stf": "STF服务",
"moe.shizuku.privileged.api": "Shizuku",
"moe.shizuku.api": "Shizuku",
"io.tailscale.android": "Tailscale",
"com.immomo.momo": "陌陌",
"com.xingin.xhs": "小红书",
}
# 解析应用名时,超过该大小的 APK 不拉取(太慢),用映射或包名兜底
_LABEL_PARSE_MAX_BYTES = 5 * 1024 * 1024 # 5MB
# 应用名缓存:(serial, package) -> label
_APP_LABEL_CACHE = {}
def list_installed_apps(serial, timeout=40):
"""获取设备上已安装应用列表:应用名 + 包名 + versionCode + versionName + APK路径。
- `pm list packages --show-versioncode -f` 取包名/版本码/路径(快)
- `pm dump packages` 取人性化 versionName(实测 ~0.8s)
- 应用名 label:常见应用走映射;用户安装且较小(<=10MB)的未知应用流式拉 APK 用
pyaxmlparser 解析(带缓存),其余用包名兜底。
返回 (ok, apps) 或 (False, 错误消息)。
"""
if not serial:
return False, "缺少 serial"
try:
# 1. 包名 + versionCode + APK 路径
r1 = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "list", "packages",
"--show-versioncode", "-f"],
capture_output=True, timeout=timeout)
apps = {}
for line in (r1.stdout or b"").decode("utf-8", errors="replace").splitlines():
line = line.strip()
m = re.match(r'package:(.+)=(\S+) versionCode:(\d+)', line)
if m:
apps[m.group(2)] = {"package": m.group(2), "path": m.group(1),
"version_code": int(m.group(3)), "version_name": "",
"label": COMMON_APPS.get(m.group(2), "")}
else:
m2 = re.match(r'package:(.+)=(\S+)', line)
if m2:
apps[m2.group(2)] = {"package": m2.group(2), "path": m2.group(1),
"version_code": 0, "version_name": "",
"label": COMMON_APPS.get(m2.group(2), "")}
# 2. 人性化版本号 versionName
try:
r2 = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "dump", "packages"],
capture_output=True, timeout=timeout)
out2 = (r2.stdout or b"").decode("utf-8", errors="replace")
for m in re.finditer(r'Package \[([^\]]+)\]', out2):
pkg = m.group(1)
if pkg not in apps:
continue
nxt = out2.find("Package [", m.end())
seg = out2[m.end():nxt if nxt != -1 else len(out2)]
vm = re.search(r'versionName=(\S+)', seg)
if vm:
apps[pkg]["version_name"] = vm.group(1)
except Exception:
pass # versionName 拿不到不阻塞
# 3. 应用名 label:用户安装且未映射的小 APK,流式拉取解析真实名
_resolve_app_labels(serial, apps, timeout)
return True, sorted(apps.values(), key=lambda a: a["package"])
except subprocess.TimeoutExpired:
return False, "查询超时"
except Exception as e:
return False, str(e)
def _resolve_app_labels(serial, apps, timeout):
"""并行解析用户安装小应用的显示名 label(带缓存)。失败/超时静默跳过。"""
import io as _io
from concurrent.futures import ThreadPoolExecutor
targets = [(pkg, a["path"]) for pkg, a in apps.items()
if a["path"].startswith("/data/") and not a["label"]
and (serial, pkg) not in _APP_LABEL_CACHE]
if not targets:
return
# 用 ls -l 一次拿所有候选 APK 大小,跳过过大的(流式拉大 APK 太慢)
sizes = {}
try:
rsz = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "ls", "-l"] + [p for _, p in targets],
capture_output=True, timeout=min(timeout, 15))
for line in (rsz.stdout or b"").decode("utf-8", errors="replace").splitlines():
parts = line.split()
# ls -l(toybox): 权限 链接 属主 属组 大小 日期 时间 路径 = 8 字段
if len(parts) >= 6:
try:
sizes[parts[-1]] = int(parts[4])
except ValueError:
pass
except Exception:
pass
small = [(pkg, path) for pkg, path in targets if sizes.get(path, 0) <= _LABEL_PARSE_MAX_BYTES]
def _fetch(pkg_path):
pkg, path = pkg_path
label = ""
try:
rp = subprocess.run(
[ADB_PATH, "-s", serial, "exec-out", "cat", path],
capture_output=True, timeout=min(timeout, 8))
from pyaxmlparser import APK as _APKParser
label = (_APKParser(_io.BytesIO(rp.stdout)).get_app_name() or "").strip()
except Exception:
label = ""
_APP_LABEL_CACHE[(serial, pkg)] = label
return pkg, label
if small:
try:
with ThreadPoolExecutor(max_workers=8) as pool:
for pkg, label in pool.map(_fetch, small):
if pkg in apps:
apps[pkg]["label"] = label
except Exception:
pass # label 解析失败不影响主流程
def identify_device(serial, duration=3):
"""让设备响铃以供识别(直连 serial,不经过 STF)。
播放铃声 duration 秒后自动停止。
不 disconnect(避免断开 STF agent 共享的 adb 连接)。
返回 (ok, msg)。
"""
import subprocess
if not adb_connect_light(serial):
return False, "adb connect 失败"
try:
# 异步启动铃声播放(cmd audio play-ringtone 会阻塞,用 Popen)
p = subprocess.Popen(
[ADB_PATH, "-s", serial, "shell", "cmd", "audio", "play-ringtone"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
time.sleep(duration)
p.terminate()
# 停止铃声
subprocess.run(
[ADB_PATH, "-s", serial, "shell", "cmd", "audio", "stop-ringtone"],
capture_output=True, timeout=5,
)
_log.info(f"[{serial}] 识别响铃 {duration}s 完成")
return True, f"已响铃 {duration} 秒"
except Exception as e:
_log.warning(f"[{serial}] 识别响铃失败: {e}")
return False, str(e)