Files
iOSAI/Module/DeviceInfo.py
2025-09-17 22:23:57 +08:00

409 lines
17 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.

# -*- coding: utf-8 -*-
import os
import signal
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import wda
import threading
import subprocess
from pathlib import Path
from typing import List, Dict, Optional
from tidevice import Usbmux, ConnectionType
from tidevice._device import BaseDevice
from Entity.DeviceModel import DeviceModel
from Entity.Variables import WdaAppBundleId
from Module.FlaskSubprocessManager import FlaskSubprocessManager
from Utils.LogManager import LogManager
from Utils.SubprocessKit import check_output as sp_check_output, popen as sp_popen
class Deviceinfo(object):
"""设备生命周期管理:以 deviceModelList 为唯一真理源"""
def __init__(self):
...
# ✅ 新增:连接线程池(最大 6 并发)
self._connect_pool = ThreadPoolExecutor(max_workers=6)
...
if os.name == "nt":
self._si = subprocess.STARTUPINFO()
self._si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
self._si.wShowWindow = subprocess.SW_HIDE # 0
else:
self._si = None
self.deviceIndex = 0
self.screenProxy = 9110
self.pidList: List[Dict] = [] # 仅记录 iproxy 进程
self.manager = FlaskSubprocessManager.get_instance()
self.deviceModelList: List[DeviceModel] = [] # 根基,不动
self.maxDeviceCount = 6
self._lock = threading.Lock()
self._model_index: Dict[str, DeviceModel] = {} # udid -> model
# ✅ 1. 失踪时间戳记录(替代原来的 miss_count
self._last_seen: Dict[str, float] = {}
self._port_pool: List[int] = []
self._port_in_use: set[int] = set()
# region iproxy 初始化(原逻辑不变)
try:
self.iproxy_path = self._iproxy_path()
self.iproxy_dir = self.iproxy_path.parent
os.environ["PATH"] = str(self.iproxy_dir) + os.pathsep + os.environ.get("PATH", "")
try:
os.add_dll_directory(str(self.iproxy_dir))
except Exception:
pass
self._creationflags = 0x08000000 if os.name == "nt" else 0
self._popen_kwargs = dict(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(self.iproxy_dir),
shell=False,
text=True,
creationflags=0x08000000 if os.name == "nt" else 0, # CREATE_NO_WINDOW
encoding="utf-8",
bufsize=1,
)
def _spawn_iproxy(udid: str, local_port: int, remote_port: int = 9100) -> subprocess.Popen:
args = [str(self.iproxy_path), "-u", udid, str(local_port), str(remote_port)]
p = subprocess.Popen(args, **self._popen_kwargs)
def _pipe_to_log(name: str, stream):
try:
for line in iter(stream.readline, ''):
s = line.strip()
if s:
LogManager.info(f"[iproxy {name}] {s}", udid)
except Exception:
pass
threading.Thread(target=_pipe_to_log, args=("STDOUT", p.stdout), daemon=True).start()
threading.Thread(target=_pipe_to_log, args=("STDERR", p.stderr), daemon=True).start()
return p
self._spawn_iproxy = _spawn_iproxy
LogManager.info(f"iproxy 启动器已就绪,目录: {self.iproxy_dir}")
except Exception as e:
self.iproxy_path = None
self.iproxy_dir = None
self._spawn_iproxy = None
LogManager.error(f"初始化 iproxy 失败:{e}")
# endregion
# ------------------------------------------------------------------
# 主监听循环 → 只负责“发现”和“提交任务”
# ------------------------------------------------------------------
def startDeviceListener(self):
MISS_WINDOW = 5.0
while True:
try:
lists = Usbmux().device_list()
except Exception as e:
LogManager.warning(f"usbmuxd 连接失败: {e}2 秒后重试")
time.sleep(2)
continue
now_udids = {d.udid for d in lists if d.conn_type == ConnectionType.USB}
usb_sn_set = self._usb_enumerate_sn()
# 1. 失踪判定(同旧逻辑)
need_remove = []
with self._lock:
for udid in list(self._model_index.keys()):
if udid not in now_udids:
last = self._last_seen.get(udid, time.time())
if time.time() - last > MISS_WINDOW and udid not in usb_sn_set:
need_remove.append(udid)
else:
self._last_seen[udid] = time.time()
for udid in need_remove:
self._remove_model(udid)
# 2. 发现新设备 → 并发连接
with self._lock:
new_udids = [d.udid for d in lists
if d.conn_type == ConnectionType.USB and
d.udid not in self._model_index and
len(self.deviceModelList) < self.maxDeviceCount]
if new_udids:
futures = {self._connect_pool.submit(self._connect_device_task, udid): udid
for udid in new_udids}
for f in as_completed(futures, timeout=10):
udid = futures[f]
try:
f.result(timeout=8) # 单台 8 s 硬截止
except Exception as e:
LogManager.error(f"连接任务超时/失败: {e}", udid)
time.sleep(1)
# ------------------------------------------------------------------
# ✅ 3. USB 层枚举 SN跨平台
# ------------------------------------------------------------------
def _usb_enumerate_sn(self) -> set[str]:
try:
out = sp_check_output(["idevice_id", "-l"], text=True, timeout=3)
return {line.strip() for line in out.splitlines() if line.strip()}
except Exception:
return set()
# ===================== 以下代码与原文件完全一致 =====================
def _wda_health_checker(self):
while True:
time.sleep(1)
with self._lock:
online = [m for m in self.deviceModelList if m.ready]
for model in online:
udid = model.deviceId
if not self._wda_ok(udid):
LogManager.warning(f"WDA 异常,重启通道:{udid}", udid)
with self._lock:
self._remove_model(udid)
self.connectDevice(udid)
def _wda_ok(self, udid: str) -> bool:
try:
c = wda.USBClient(udid, 8100)
st = c.status()
if st.get("state") != "success":
return False
return True
except Exception as e:
LogManager.error(f"WDA health-check 异常:{e}", udid)
return False
# -------------------- 增删改查唯一入口(未改动) --------------------
def _has_model(self, udid: str) -> bool:
return udid in self._model_index
def _add_model(self, model: DeviceModel):
if model.deviceId in self._model_index:
return
model.ready = True
self.deviceModelList.append(model)
self._model_index[model.deviceId] = model
try:
self.manager.send(model.toDict())
except Exception as e:
LogManager.warning(f"{model.deviceId} 发送上线事件失败:{e}")
LogManager.method_info(f"{model.deviceId} 加入设备成功,当前在线数:{len(self.deviceModelList)}", method="device_count")
def _remove_model(self, udid: str):
print(f"【删】进入删除方法 udid={udid}")
LogManager.method_info(f"【删】进入删除方法 udid={udid}", method="device_count")
with self._lock:
print(f"【删】拿到锁 udid={udid}")
LogManager.method_info(f"【删】拿到锁 udid={udid}", method="device_count")
model = self._model_index.pop(udid, None)
if not model:
print(f"【删】模型已空,直接返回 udid={udid}")
LogManager.method_info(f"【删】模型已空,直接返回 udid={udid}", method="device_count")
return
if model.deleting:
print(f"【删】正在删除中,幂等返回 udid={udid}")
LogManager.method_info(method="device_count", text=f"【删】正在删除中,幂等返回 udid={udid}")
return
model.deleting = True
model.type = 2
print(f"【删】标记 deleting=True udid={udid}")
LogManager.method_info("【删】标记 deleting=True udid={udid}", "device_count")
before = len(self.deviceModelList)
self.deviceModelList = [m for m in self.deviceModelList if m.deviceId != udid]
after = len(self.deviceModelList)
print(f"【删】列表过滤 before={before} → after={after} udid={udid}")
LogManager.method_info(f"【删】列表过滤 before={before} → after={after} udid={udid}", "device_count")
self._port_in_use.discard(model.screenPort)
self._port_pool.append(model.screenPort)
print(f"【删】回收端口 port={model.screenPort} udid={udid}")
LogManager.method_info(f"【删】回收端口 port={model.screenPort} udid={udid}", method="device_count")
to_kill = [item for item in self.pidList if item.get("id") == udid]
self.pidList = [item for item in self.pidList if item.get("id") != udid]
print(f"【删】待杀进程数 count={len(to_kill)} udid={udid}")
LogManager.method_info(f"【删】待杀进程数 count={len(to_kill)} udid={udid}", method="device_count")
for idx, item in enumerate(to_kill, 1):
print(f"【删】杀进程 {idx}/{len(to_kill)} pid={item.get('target').pid} udid={udid}")
LogManager.method_info(f"【删】杀进程 {idx}/{len(to_kill)} pid={item.get('target').pid} udid={udid}", method="device_count")
self._terminate_proc(item.get("target"))
print(f"【删】进程清理完成 udid={udid}")
LogManager.method_info(f"【删】进程清理完成 udid={udid}", method="device_count")
retry = 3
while retry:
try:
self.manager.send(model.toDict())
print(f"【删】下线事件已发送 udid={udid}")
LogManager.method_info(f"【删】下线事件已发送 udid={udid}", method="device_count")
break
except Exception as e:
retry -= 1
print(f"【删】发送事件失败 retry={retry} err={e} udid={udid}")
LogManager.method_error(f"【删】发送事件失败 retry={retry} err={e} udid={udid}", method="device_count")
time.sleep(0.2)
else:
print(f"【删】发送事件彻底失败,主动退出 udid={udid}")
LogManager.method_error(f"【删】发送事件彻底失败,主动退出 udid={udid}", method="device_count")
print(f"【删】===== 设备 {udid} 删除全流程结束 =====")
LogManager.method_info(f"【删】===== 设备 {udid} 删除全流程结束 =====", method="device_count")
print(len(self.deviceModelList))
LogManager.method_info(f"当前剩余设备数量:{len(self.deviceModelList)}", method="device_count")
# -------------------- 端口分配与回收(未改动) --------------------
def _alloc_port(self) -> int:
if self._port_pool:
port = self._port_pool.pop()
else:
self.screenProxy += 1
port = self.screenProxy
self._port_in_use.add(port)
return port
def _free_port(self, port: int):
if port in self._port_in_use:
self._port_in_use.remove(port)
self._port_pool.append(port)
# ------------------------------------------------------------------
# 线程池里真正干活的地方(原 connectDevice 逻辑搬过来)
# ------------------------------------------------------------------
def _connect_device_task(self, udid: str):
if not self.is_device_trusted(udid):
LogManager.warning("设备未信任,跳过 WDA 启动", udid)
return
try:
d = wda.USBClient(udid, 8100)
except Exception as e:
LogManager.error(f"启动 WDA 失败: {e}", udid)
return
width, height, scale = 0, 0, 1.0
try:
size = d.window_size()
width, height = size.width, size.height
scale = d.scale
except Exception as e:
LogManager.warning(f"读取屏幕信息失败:{e}", udid)
port = self._alloc_port()
model = DeviceModel(udid, port, width, height, scale, type=1)
# 先做完所有 IO再抢锁写内存
try:
d.app_start(WdaAppBundleId)
d.home()
except Exception as e:
LogManager.warning(f"启动/切回桌面失败:{e}", udid)
time.sleep(2) # 原逻辑保留
target = self.relayDeviceScreenPort(udid, port)
# 毫秒级临界区
with self._lock:
if udid in self._model_index: # 并发防重
return
self._add_model(model)
if target:
self.pidList.append({"target": target, "id": udid})
# ------------------------------------------------------------------
# 原函数保留(改名即可)
# ------------------------------------------------------------------
def connectDevice(self, udid: str):
"""对外保留接口,实际走线程池"""
self._connect_pool.submit(self._connect_device_task, udid)
# -------------------- 工具方法(未改动) --------------------
def is_device_trusted(self, udid: str) -> bool:
try:
d = BaseDevice(udid)
d.get_value("DeviceName")
return True
except Exception:
return False
def relayDeviceScreenPort(self, udid: str, port: int) -> Optional[subprocess.Popen]:
if not self._spawn_iproxy:
LogManager.error("iproxy 启动器未就绪", udid)
return None
while self._port_in_use and self._is_port_open(port):
pid = self._get_pid_by_port(port)
if pid and pid != os.getpid():
LogManager.warning(f"端口 {port} 仍被 PID {pid} 占用,尝试释放", udid)
self._kill_pid_gracefully(pid)
else:
break
try:
p = self._spawn_iproxy(udid, port, 9100)
self._port_in_use.add(port)
LogManager.info(f"启动 iproxy 成功,本地 {port} -> 设备 9100", udid)
return p
except Exception as e:
LogManager.error(f"启动 iproxy 失败:{e}", udid)
return None
def _is_port_open(self, port: int) -> bool:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(("127.0.0.1", port)) == 0
def _get_pid_by_port(self, port: int) -> Optional[int]:
try:
if os.name == "nt":
out = sp_check_output(["netstat", "-ano", "-p", "tcp"], text=True)
for line in out.splitlines():
if f"127.0.0.1:{port}" in line and "LISTENING" in line:
return int(line.strip().split()[-1])
else:
out = sp_check_output(["lsof", "-t", f"-iTCP:{port}", "-sTCP:LISTEN"], text=True)
return int(out.strip().split()[0])
except Exception:
return None
def _kill_pid_gracefully(self, pid: int):
try:
os.kill(pid, signal.SIGTERM)
time.sleep(1)
os.kill(pid, signal.SIGKILL)
except Exception:
pass
def _terminate_proc(self, p: Optional[subprocess.Popen]):
if not p or p.poll() is not None:
return
try:
p.terminate()
p.wait(timeout=3)
except Exception:
try:
if os.name == "posix":
os.killpg(os.getpgid(p.pid), signal.SIGKILL)
else:
p.kill()
p.wait(timeout=2)
except Exception:
pass
def _base_dir(self) -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parents[1]
def _iproxy_path(self) -> Path:
exe = "iproxy.exe" if os.name == "nt" else "iproxy"
base = self._base_dir()
candidates = [base / "resources" / "iproxy" / exe]
for p in candidates:
if p.exists():
return p
raise FileNotFoundError(f"iproxy not found, tried: {[str(c) for c in candidates]}")