diff --git a/config.py b/config.py index d3a132b..5cba4be 100644 --- a/config.py +++ b/config.py @@ -80,6 +80,13 @@ STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "") STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "") STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "") +# ================== USB 设备远程 adb server(阶段 2) ================== +# USB 设备插在 220(STF 主机)上,本机 adb 看不到;220 的 adb 容器是 host 网络 +# 模式,5037 已监听所有网卡(含 Tailscale),平台用 adb -H/-P 直接驱动。 +# 平台自身 USB(serial 无冒号且在本机 adb 里)不受影响,仍走本地。 +USB_ADB_HOST = _env("USB_ADB_HOST", "100.100.10.1") # 220 的 Tailscale IP +USB_ADB_PORT = int(_env("USB_ADB_PORT", "5037")) + # ================== Tailscale 管理(工具页) ================== # 调用官方 API v2 管理 tailnet 设备。配置从 .env 读取: # TAILSCALE_API_KEY:后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret) diff --git a/core/adb_helper.py b/core/adb_helper.py index 6ff867f..282a970 100644 --- a/core/adb_helper.py +++ b/core/adb_helper.py @@ -31,6 +31,26 @@ def _adb(*args): except subprocess.TimeoutExpired: _log.warning(f"adb {' '.join(args)} 超时 {_ADB_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() @@ -77,6 +97,41 @@ def get_foreground_app(remote_adb_url, timeout=3): 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。 diff --git a/core/device_pool.py b/core/device_pool.py index 52b89b9..f13c210 100644 --- a/core/device_pool.py +++ b/core/device_pool.py @@ -17,7 +17,8 @@ import time from sqlalchemy import text -from core.adb_helper import _adb +from config import USB_ADB_HOST, USB_ADB_PORT +from core.adb_helper import _adb, _adb_remote from core.logger import get_logger from core.models import db, Device @@ -61,13 +62,31 @@ def list_devices(): # ================== 在线状态 ================== def list_online(): - """本机 adb devices 中 state=device 的设备(实时)。""" + """本机 adb 中 state=device 的设备 + 池内 USB 设备(经 220 远程 adb server)。 + + 池内无 USB 设备(serial 无冒号)时不查远程 server,零额外开销。 + """ out = _adb("devices") or "" serials = [] for line in out.splitlines()[1:]: parts = line.split() if len(parts) >= 2 and parts[0] and parts[1] == "device": serials.append(parts[0]) + # 池内有 USB 设备时,补查远程 adb server(220,host 网络 5037 全接口监听) + try: + usb = [s for s in list_configured() if ":" not in s] + except Exception: + usb = [] + if usb: + try: + out = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "devices") or "" + for line in out.splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0] and parts[1] == "device": + if parts[0] not in serials: + serials.append(parts[0]) + except Exception: + pass return serials diff --git a/core/device_worker.py b/core/device_worker.py index f48ad38..8e4e4f5 100644 --- a/core/device_worker.py +++ b/core/device_worker.py @@ -1,9 +1,9 @@ """设备生命周期 + 通用 Worker 基类 + 全局状态注册表 + 心跳看门狗。 - STFDevice — 单设备生命周期:acquire(占用+adb连接)→ release(断开+释放) - BaseWorker — 通用 worker 基类:封装 STF 生命周期 + u2 连接 + 状态上报 + stop + STFDevice — 单设备生命周期:acquire(直连/远程 server)→ release + BaseWorker — 通用 worker 基类:封装设备生命周期 + u2 连接 + 状态上报 + stop + 生命周期钩子(setup/teardown/on_error) + 心跳上报 - 子类只需实现 run_task(d),可选实现钩子,不用管 STF/adb/状态/异常 + 子类只需实现 run_task(d),可选实现钩子,不用管 adb/状态/异常 _WORKERS — 全局状态注册表,供 web_server 读取实时状态 _watchdog — 心跳看门狗线程,检测 worker 卡死 @@ -16,10 +16,11 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeou import uiautomator2 as u2 +from config import USB_ADB_HOST, USB_ADB_PORT from core.logger import get_logger from core.stf_client import DeviceOfflineError, STFError from core import device_pool -from .adb_helper import adb_connect, adb_disconnect +from .adb_helper import adb_connect, _adb_remote _log = get_logger("core.worker") @@ -35,15 +36,15 @@ _U2_INFO_TIMEOUT = 10 class STFDevice: """单设备生命周期:acquire(直连)→ release(无操作)。 - 阶段 1:已摘除 STF occupy/release——单实例互斥由 TaskManager._running 保证, - 设备健康由 device_pool(本地 adb 在线状态)判断。保留 STF 引用仅为 - 非 IP:port(USB 序列号)设备的 remoteConnect 桥接,阶段 2 换成 220 adb server。 + 阶段 2:已摘除 STF occupy/release 与 remoteConnect 桥接—— + 单实例互斥由 TaskManager._running 保证;非 IP:port(USB 序列号)设备 + 通过 220 的远程 adb server(-H/-P)驱动,不再依赖 STF。 """ - def __init__(self, stf_client=None, serial=None): - self.stf = stf_client # 仅 USB 桥接用(阶段 2 移除) + def __init__(self, serial=None): self.serial = serial - self.remote_adb_url = None + self.remote_adb_url = None # IP:5555 设备:设备地址 + self.usb_server = None # USB 设备:远程 adb server 地址(host:port) def acquire(self, serial=None): self.serial = serial or self.serial or self._pick_free() @@ -51,18 +52,21 @@ class STFDevice: # 直连优先:serial 本身是 IP:5555(如 Tailscale 网络设备)时直接 adb connect。 # 只 connect、绝不 disconnect/kill-server(红线),不影响 STF provider 的连接。 - # 仅当 serial 不是 IP:port(如 USB 序列号)时才走 STF 桥接(阶段 2 替换)。 if ":" in self.serial: self.remote_adb_url = self.serial _log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}") + if not adb_connect(self.remote_adb_url): + raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline") else: - if self.stf is None: - raise STFError(f"USB 设备 {self.serial} 暂不支持(未启用 STF 桥接)", "offline") - self.remote_adb_url = self.stf.remote_connect(self.serial) - _log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}") - - if not adb_connect(self.remote_adb_url): - raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline") + # USB 设备:插在 220 上,本机 adb 看不到,经远程 adb server 驱动。 + # 无需 adb connect——设备已在远程 server 上,直接校验可见性。 + self.usb_server = f"{USB_ADB_HOST}:{USB_ADB_PORT}" + _log.info(f"[{self.serial}] USB 设备,远程 adb server: {self.usb_server}") + out = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "devices") or "" + seen = {line.split()[0] for line in out.splitlines()[1:] + if len(line.split()) >= 2} + if self.serial not in seen: + raise STFError(f"USB 设备 {self.serial} 不在远程 adb server", "offline") time.sleep(2) def _pick_free(self): @@ -72,15 +76,8 @@ class STFDevice: return free[0] def release(self): - if not self.serial: - return - # 直连模式(remote_adb_url == serial):不 disconnect(红线,STF 共享 transport) - if self.remote_adb_url and self.remote_adb_url != self.serial: - adb_disconnect(self.remote_adb_url) - try: - self.stf.remote_disconnect(self.serial) - except Exception: - pass + # 直连模式:不 disconnect(红线,STF 共享 transport);USB:无连接可断 + pass # ================== 全局 worker 状态注册表(供 web_server 读取) ================== @@ -349,15 +346,20 @@ class BaseWorker(threading.Thread): # ================== 基类主循环(不要重写) ================== def run(self): """基类主循环:不要重写。子类实现 run_task + 可选钩子。""" - device = STFDevice(self.stf, serial=self.serial) + device = STFDevice(serial=self.serial) _update_status(self.serial, status="connecting", stf_occupied=False, - last_error="", remote_adb_url="", model="", + last_error="", remote_adb_url="", usb_server="", model="", current_action="") try: device.acquire() remote = device.remote_adb_url - # u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护 - self.d = self._u2_connect_with_timeout(remote) + usb_server = device.usb_server + # u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护; + # USB 设备(无 IP:5555)经远程 adb server 建 u2 连接 + if usb_server: + self.d = self._u2_connect_usb_with_timeout(usb_server) + else: + self.d = self._u2_connect_with_timeout(remote) if self.d is None: raise STFError(f"u2.connect 超时 {_U2_CONNECT_TIMEOUT}s,atx-agent 无响应", "offline") _log.info(f"[{self.serial}] u2 连接成功") @@ -369,7 +371,8 @@ class BaseWorker(threading.Thread): except Exception: pass _update_status(self.serial, status="running", stf_occupied=True, - remote_adb_url=remote, model=model) + remote_adb_url=remote or "", usb_server=usb_server or "", + model=model) # setup 钩子 try: @@ -435,6 +438,28 @@ class BaseWorker(threading.Thread): except FuturesTimeout: _log.error(f"[{self.serial}] u2.connect 超时 {_U2_CONNECT_TIMEOUT}s") return None + + def _u2_connect_usb_with_timeout(self, server): + """USB 设备:经远程 adb server(220)建 u2 连接,带超时保护。 + + u2 3.7 底层 adbutils 支持远程 adb server:AdbClient(host, port).device(serial), + 再交给 u2.connect(AdbDevice)(与本地连接同语义,无需 adb connect)。 + """ + host, _, port = server.partition(":") + with ThreadPoolExecutor(max_workers=1) as pool: + def _connect(): + from adbutils import AdbClient + dev = AdbClient(host=host, port=int(port)).device(self.serial) + return u2.connect(dev) + fut = pool.submit(_connect) + try: + return fut.result(timeout=_U2_CONNECT_TIMEOUT) + except FuturesTimeout: + _log.error(f"[{self.serial}] u2 远程连接超时 {_U2_CONNECT_TIMEOUT}s") + return None + except Exception as e: + _log.error(f"[{self.serial}] u2 远程连接失败: {e}") + return None except Exception as e: _log.error(f"[{self.serial}] u2.connect 异常: {e}") return None diff --git a/core/task_manager.py b/core/task_manager.py index bf59e13..3398bc4 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -32,7 +32,7 @@ from core.logger import get_logger from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow from core import device_pool from .stf_client import STFClient, DeviceOfflineError -from .adb_helper import get_foreground_app, adb_connect_light, adb_disconnect +from .adb_helper import get_foreground_app, get_foreground_app_remote from .device_worker import ( get_all_worker_status, _update_status, _remove_worker, _WORKERS, _WORKERS_LOCK, @@ -260,22 +260,26 @@ class _ForegroundScanner: worker_status = {w["serial"]: w for w in get_all_worker_status()} # 分类设备 - have_conn = {} # serial -> remote_adb_url(worker 运行中,已有 adb 连接) + have_conn = {} # serial -> remote_adb_url(IP:5555 运行中设备) + usb_conn = {} # serial -> usb_server(USB 运行中设备,经远程 adb server) free_serials = [] # 空闲设备 for serial in online: w = worker_status.get(serial, {}) - url = w.get("remote_adb_url") - if url and w.get("status") in ("running", "connecting"): - # 1. 设备正在执行任务,已有 adb 连接,直接查询 - have_conn[serial] = url + if w.get("status") not in ("running", "connecting"): + free_serials.append(serial) + elif w.get("remote_adb_url"): + # 1. IP:5555 设备正在执行任务,已有 adb 连接,直接查询 + have_conn[serial] = w["remote_adb_url"] + elif w.get("usb_server"): + # 1b. USB 设备运行中:经远程 adb server 查询 + usb_conn[serial] = w["usb_server"] else: - # 2. 空闲设备 free_serials.append(serial) results = {} - _log.info("前台 App 扫描分类: 运行中=%d, 空闲=%d", - len(have_conn), len(free_serials)) + _log.info("前台 App 扫描分类: 运行中=%d, USB运行中=%d, 空闲=%d", + len(have_conn), len(usb_conn), len(free_serials)) # 1. worker 运行中设备:用已有 remote_adb_url 查询(并发 10) if have_conn: @@ -289,6 +293,18 @@ class _ForegroundScanner: except Exception: results[s] = None + # 1b. USB 运行中设备:经远程 adb server 查询(并发 5) + if usb_conn: + with ThreadPoolExecutor(max_workers=min(5, len(usb_conn))) as pool: + futures = {pool.submit(self._scan_usb_conn, s): s + for s, server in usb_conn.items()} + for fut in as_completed(futures, timeout=15): + s = futures[fut] + try: + results[s] = fut.result() + except Exception: + results[s] = None + # 2. 空闲设备:不打扰,直接返回"空闲" for s in free_serials: results[s] = "空闲" @@ -303,6 +319,18 @@ class _ForegroundScanner: self._scanning.clear() _log.info("前台 App 扫描完成: %d 台设备", len(results)) + def _scan_usb_conn(self, serial): + """USB 运行中设备:经远程 adb server(220)查询前台 App。""" + try: + with _WORKERS_LOCK: + server = _WORKERS.get(serial, {}).get("usb_server", "") + if not server: + return None + host, _, port = server.partition(":") + return get_foreground_app_remote(host, int(port), serial) + except Exception: + return None + # ================== 任务管理器 ================== class TaskManager: diff --git a/doc/STF_REMOVAL.md b/doc/STF_REMOVAL.md index 0c3f446..dfbc2d2 100644 --- a/doc/STF_REMOVAL.md +++ b/doc/STF_REMOVAL.md @@ -13,13 +13,13 @@ STF 当前仅提供:occupy/release 互斥、present+ready 健康信号、设 1. **设备清单放本地 SQLite**(data/users.db 新增 devices 表),220 的 connect_devices.sh 退役(其"每 5 分钟补连"职责由平台定时器 + SSH 兜底接管,或直接依赖 adb 重试) 2. **USB 有线设备保留支持**——当前舰队 0 台 USB(全 IP:5555),但能力要保留。 - 方案:220 的 adb 容器发布 5037 端口(改 docker 运行参数,唯一动 220 的地方), - 平台通过 Tailscale 用 `adb -H <220> -P 5037` 和 `adbutils.Adb(host=220)`(u2 底层) - 驱动远端 USB 设备;本机 USB 设备照旧走本地 adb。已验证 uiautomator2 3.7.0 底层 - adbutils 支持远程 adb server -3. **网页远程看屏**——引入 **ws-scrcpy**(Node 服务,WebRTC 低延迟流 + 触控), - 与平台同机部署、内嵌管理员页面 iframe;本机 adb 即数据源,无需 STF。 - 备选:自建 minicap JPEG 流 + u2 注入(2-3 天),或保留 STF 容器只做看屏(不推荐,依赖没摘干净) + 实施中发现 **220 的 adb 容器是 host 网络模式、5037 已监听所有网卡(含 Tailscale + 100.100.10.1),无需改动 220 任何配置**。平台用 `adb -H 100.100.10.1 -P 5037` + 和 `adbutils.AdbClient(host=..., port=5037)`(u2 底层)驱动远端 USB 设备; + 本机 USB 设备照旧走本地 adb。已验证(阶段 2 完成) +3. **网页远程看屏**——原计划 ws-scrcpy **不在 npm 分发**(仅 GitHub,国内下载不可靠), + 改**自建**:MJPEG 流(u2/atx-agent minicap 截图,~5fps,实测 0.19s/帧)+ u2 触控 + (tap/swipe/key/text),管理页新子分栏,零新依赖(阶段 2 完成) --- diff --git a/static/admin/base.js b/static/admin/base.js index cdd96f7..e72fe50 100644 --- a/static/admin/base.js +++ b/static/admin/base.js @@ -110,6 +110,7 @@ function showSubTab(tabId, name){ const tab = document.getElementById('tab-'+tabId); tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name)); tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name)); + if(name==='screen' && typeof loadScreenDevices==='function') loadScreenDevices(); } // ================== 模态框 ================== diff --git a/static/admin/tools.js b/static/admin/tools.js index 2c59ec0..09a4f67 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -425,3 +425,124 @@ function copyTsKey(){ if(s)s.textContent='已复制'; } + +// ================== Tab: 工具 - 远程看屏(MJPEG 流 + u2 触控) ================== +let _screenViewOn = false; + +async function loadScreenDevices(force){ + const sel=document.getElementById('screen-device'); + if(!sel)return; + const list=await _fetchAdbDevices(force); + if(list===null)return; + const cur=sel.value; + const stateTxt={device:'在线',offline:'离线',pool:'设备池(未连接)',stf_not_ready:'STF未就绪'}; + sel.innerHTML=list.map(d=> + '' + ).join(''); + if(cur)sel.value=cur; +} + +function _screenSerial(){ + const sel=document.getElementById('screen-device'); + return sel?sel.value:''; +} + +function startScreenView(){ + const serial=_screenSerial(); + if(!serial){showToast('请先选择设备','error');return;} + stopScreenView(); + const img=document.getElementById('screen-img'); + const loading=document.getElementById('screen-loading'); + loading.style.display='flex'; + document.getElementById('screen-status').textContent=''; + img.src='/api/screen/stream?serial='+encodeURIComponent(serial); + img.onload=()=>{loading.style.display='none';_screenViewOn=true;document.getElementById('screen-status').textContent='观看中';}; + img.onerror=()=>{loading.style.display='none';_screenViewOn=false;showToast('画面流中断(设备离线或已停止)','error');}; +} + +function stopScreenView(){ + const img=document.getElementById('screen-img'); + if(img)img.src=''; + _screenViewOn=false; + const loading=document.getElementById('screen-loading'); + if(loading)loading.style.display='none'; + const st=document.getElementById('screen-status'); + if(st)st.textContent=''; +} + +// 显示尺寸 -> 设备原生分辨率(img.naturalWidth/Height 即设备分辨率) +function _screenCoords(ev){ + const img=document.getElementById('screen-img'); + if(!img.naturalWidth)return [null,null]; + const r=img.getBoundingClientRect(); + return [ + Math.round((ev.clientX-r.left)/r.width*img.naturalWidth), + Math.round((ev.clientY-r.top)/r.height*img.naturalHeight), + ]; +} + +function _screenTap(x,y){ + const serial=_screenSerial(); + if(!serial||x==null||y==null)return; + apiPost('/api/screen/tap',{serial,x,y}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');}); +} + +function _screenSwipe(x1,y1,x2,y2){ + const serial=_screenSerial(); + if(!serial||x1==null)return; + apiPost('/api/screen/swipe',{serial,x1,y1,x2,y2}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');}); +} + +function screenKey(key){ + const serial=_screenSerial(); + if(!serial){showToast('请先选择设备','error');return;} + apiPost('/api/screen/key',{serial,key}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');}); +} + +function screenSendText(){ + const serial=_screenSerial(); + const inp=document.getElementById('screen-text'); + const text=(inp.value||'').trim(); + if(!serial){showToast('请先选择设备','error');return;} + if(!text){showToast('请输入文字','error');return;} + apiPost('/api/screen/text',{serial,text}).then(r=>{ + if(r&&!r.ok)showToast(r.error,'error'); + else inp.value=''; + }); +} + +// 鼠标 + 触控:拖拽=滑动,点按=轻触 +(function(){ + const img=document.getElementById('screen-img'); + if(!img)return; + let dragging=false, sx=0, sy=0; + + function onDown(ev){ + if(!_screenViewOn)return; + ev.preventDefault(); + dragging=true; + const [x,y]=_screenCoords(ev); + sx=x; sy=y; + } + function onUp(ev){ + if(!dragging)return; + dragging=false; + const [x,y]=_screenCoords(ev); + if(x==null)return; + if(Math.abs(x-sx)<8 && Math.abs(y-sy)<8){ + _screenTap(x,y); // 位移小 → 点击 + }else{ + _screenSwipe(sx,sy,x,y); // 有位移 → 滑动 + } + } + + img.addEventListener('mousedown',onDown); + img.addEventListener('mousemove',ev=>{if(dragging)ev.preventDefault();}); + window.addEventListener('mouseup',onUp); + img.addEventListener('touchstart',onDown,{passive:false}); + img.addEventListener('touchend',ev=>{ + if(!dragging)return; + const t=ev.changedTouches[0]; + onUp({clientX:t.clientX,clientY:t.clientY,preventDefault(){}}); + },{passive:false}); +})(); diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 6342078..aba83d6 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -482,6 +482,7 @@ select.form-control{cursor:pointer} + @@ -665,6 +666,42 @@ select.form-control{cursor:pointer} 脚本的 cron 每 5 分钟自动补连掉线设备。adb 操作发生在 220(STF provider 侧),不影响本机任务直连。 + + +