Compare commits
2
Commits
c111cb9a5e
...
5354fe6379
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5354fe6379 | ||
|
|
52c4d3aab4 |
@@ -0,0 +1,162 @@
|
|||||||
|
"""设备池(STF 替代数据源):SQLite devices 表 = 设备清单,本地 adb = 在线状态。
|
||||||
|
|
||||||
|
阶段 0:纯新增模块,不改现有行为。task_manager / web_server 的 STF 调用
|
||||||
|
在阶段 1 逐个切换到本模块,阶段 3 摘除 STF 后删除 seed 相关代码。
|
||||||
|
|
||||||
|
设计:
|
||||||
|
- list_configured() — devices 表里 enabled 的设备(清单,管理页维护)
|
||||||
|
- list_online() — 本机 `adb devices` 里 state=device 的设备(实时)
|
||||||
|
- list_ready() — configured ∩ online(调度用,取代 STF list_free_devices)
|
||||||
|
- CRUD — add/update/remove/set_enabled(设备池管理页用)
|
||||||
|
- 首次启动自动从 STF 导入现有设备(一次性,app_meta 标记防重复)
|
||||||
|
|
||||||
|
红线:全模块不 connect / 不 kill-server / 不 disconnect,遵守既有技术约束。
|
||||||
|
后台线程(task_manager 等)调用时由本模块自行推 app context,调用方无需关心。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from core.adb_helper import _adb
|
||||||
|
from core.logger import get_logger
|
||||||
|
from core.models import db, Device
|
||||||
|
|
||||||
|
_log = get_logger("core.device_pool")
|
||||||
|
|
||||||
|
_app = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
|
"""web_server 启动时调用:绑定 Flask app,并做首次设备导入。"""
|
||||||
|
global _app
|
||||||
|
_app = app
|
||||||
|
try:
|
||||||
|
_seed_from_stf_if_empty()
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"设备池初始化异常(不影响启动): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
"""后台线程访问 db 需自行推 app context(与 task_manager._db 同思路)。"""
|
||||||
|
if _app is None:
|
||||||
|
raise RuntimeError("device_pool 未关联 Flask app(web_server 启动时调用 init_app)")
|
||||||
|
return _app.app_context()
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 清单 ==================
|
||||||
|
def list_configured(enabled_only=True):
|
||||||
|
"""devices 表里的设备清单(serial 列表)。"""
|
||||||
|
with _ctx():
|
||||||
|
q = Device.query
|
||||||
|
if enabled_only:
|
||||||
|
q = q.filter_by(enabled=True)
|
||||||
|
return [d.serial for d in q.order_by(Device.serial).all()]
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices():
|
||||||
|
"""全量设备行(设备池管理页用)。"""
|
||||||
|
with _ctx():
|
||||||
|
return [d.to_dict() for d in Device.query.order_by(Device.serial).all()]
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 在线状态 ==================
|
||||||
|
def list_online():
|
||||||
|
"""本机 adb devices 中 state=device 的设备(实时)。"""
|
||||||
|
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])
|
||||||
|
return serials
|
||||||
|
|
||||||
|
|
||||||
|
def is_online(serial):
|
||||||
|
"""设备当前是否在线(本机 adb 可达且状态正常)。"""
|
||||||
|
return serial in list_online()
|
||||||
|
|
||||||
|
|
||||||
|
def list_ready():
|
||||||
|
"""可调度设备:清单 ∩ 在线(取代 STF list_free_devices)。"""
|
||||||
|
online = set(list_online())
|
||||||
|
return [s for s in list_configured() if s in online]
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 管理(CRUD) ==================
|
||||||
|
def add_device(serial, name="", note="", enabled=True):
|
||||||
|
"""添加/更新设备(upsert)。返回 True 新增 / False 已存在并更新。"""
|
||||||
|
serial = (serial or "").strip()
|
||||||
|
if not serial:
|
||||||
|
return False
|
||||||
|
with _ctx():
|
||||||
|
d = Device.query.get(serial)
|
||||||
|
if d:
|
||||||
|
d.name, d.note, d.enabled = name or "", note or "", enabled
|
||||||
|
db.session.commit()
|
||||||
|
return False
|
||||||
|
db.session.add(Device(serial=serial, name=name or "", note=note or "",
|
||||||
|
enabled=enabled,
|
||||||
|
created_at=time.strftime("%Y-%m-%d %H:%M")))
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"设备池新增设备: {serial}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def remove_device(serial):
|
||||||
|
"""删除设备。返回是否删除成功。"""
|
||||||
|
with _ctx():
|
||||||
|
d = Device.query.get(serial)
|
||||||
|
if not d:
|
||||||
|
return False
|
||||||
|
db.session.delete(d)
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"设备池删除设备: {serial}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_enabled(serial, enabled):
|
||||||
|
"""启用/停用设备(停用后不参与调度)。返回是否成功。"""
|
||||||
|
with _ctx():
|
||||||
|
d = Device.query.get(serial)
|
||||||
|
if not d:
|
||||||
|
return False
|
||||||
|
d.enabled = enabled
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 首次导入(阶段 3 摘除 STF 后删除) ==================
|
||||||
|
def _seed_from_stf_if_empty():
|
||||||
|
"""首次启动把 STF 现有设备导入本地清单。
|
||||||
|
|
||||||
|
条件:device 表为空 且 未导入过(app_meta 标记)。STF 不可达时跳过,
|
||||||
|
不写标记,下次启动重试。摘除 STF 后此函数随 stf_client 一起删除。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from core.stf_client import STFClient
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
with _ctx():
|
||||||
|
if Device.query.count() > 0:
|
||||||
|
return
|
||||||
|
seeded = db.session.execute(text(
|
||||||
|
"SELECT value FROM app_meta WHERE key='devices_seeded_from_stf'")).scalar()
|
||||||
|
if seeded:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# 导入全部 serial(不限于 present):STF 池 = 配置的舰队,
|
||||||
|
# present=false 的是掉线/陈旧记录,导入后显示离线,管理页可删
|
||||||
|
serials = [d["serial"] for d in STFClient().list_all_devices()]
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"从 STF 导入设备清单失败(可稍后手动添加): {e}")
|
||||||
|
return
|
||||||
|
if not serials:
|
||||||
|
_log.warning("STF 无在线设备,跳过设备池首次导入")
|
||||||
|
return
|
||||||
|
now = time.strftime("%Y-%m-%d %H:%M")
|
||||||
|
for s in serials:
|
||||||
|
db.session.add(Device(serial=s, created_at=now))
|
||||||
|
db.session.execute(text(
|
||||||
|
"INSERT OR REPLACE INTO app_meta(key,value) VALUES('devices_seeded_from_stf','1')"))
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"设备池首次导入 {len(serials)} 台设备(来源 STF)")
|
||||||
+14
-24
@@ -18,6 +18,7 @@ import uiautomator2 as u2
|
|||||||
|
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
from core.stf_client import DeviceOfflineError, STFError
|
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_disconnect
|
||||||
|
|
||||||
_log = get_logger("core.worker")
|
_log = get_logger("core.worker")
|
||||||
@@ -32,13 +33,15 @@ _U2_INFO_TIMEOUT = 10
|
|||||||
|
|
||||||
|
|
||||||
class STFDevice:
|
class STFDevice:
|
||||||
"""单设备生命周期:acquire(占用+连接)→ release(断开+释放)。
|
"""单设备生命周期:acquire(直连)→ release(无操作)。
|
||||||
|
|
||||||
用 try/finally 保证设备一定被释放,即使养号中途报错。
|
阶段 1:已摘除 STF occupy/release——单实例互斥由 TaskManager._running 保证,
|
||||||
|
设备健康由 device_pool(本地 adb 在线状态)判断。保留 STF 引用仅为
|
||||||
|
非 IP:port(USB 序列号)设备的 remoteConnect 桥接,阶段 2 换成 220 adb server。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, stf_client, serial=None):
|
def __init__(self, stf_client=None, serial=None):
|
||||||
self.stf = stf_client
|
self.stf = stf_client # 仅 USB 桥接用(阶段 2 移除)
|
||||||
self.serial = serial
|
self.serial = serial
|
||||||
self.remote_adb_url = None
|
self.remote_adb_url = None
|
||||||
|
|
||||||
@@ -46,22 +49,15 @@ class STFDevice:
|
|||||||
self.serial = serial or self.serial or self._pick_free()
|
self.serial = serial or self.serial or self._pick_free()
|
||||||
_log.info(f"[{self.serial}] 选定设备")
|
_log.info(f"[{self.serial}] 选定设备")
|
||||||
|
|
||||||
ok, msg = self.stf.occupy(self.serial)
|
|
||||||
if not ok:
|
|
||||||
raise STFError(f"占用设备失败: {msg}", "conflict")
|
|
||||||
_log.info(f"[{self.serial}] STF 占用成功")
|
|
||||||
|
|
||||||
# 直连优先:serial 本身是 IP:5555(如 Tailscale 网络设备)时直接 adb connect。
|
# 直连优先:serial 本身是 IP:5555(如 Tailscale 网络设备)时直接 adb connect。
|
||||||
# 实测(本机 100.100.10.2,设备 100.100.10.x):
|
# 只 connect、绝不 disconnect/kill-server(红线),不影响 STF provider 的连接。
|
||||||
# - 本机在 tailnet 内时直连可靠且快(<1s),且只 connect、绝不 disconnect/kill-server,
|
# 仅当 serial 不是 IP:port(如 USB 序列号)时才走 STF 桥接(阶段 2 替换)。
|
||||||
# 不会影响 STF provider 共享的 adb 连接(STF 端设备保持 present/ready)。
|
|
||||||
# - STF remoteConnect 桥接在此环境反而有 adb key 认证问题(隧道显示 unauthorized,
|
|
||||||
# 即使设备已授权本机 key),直连才是可靠路径。
|
|
||||||
# 仅当 serial 不是 IP:port(如 USB 序列号)时才走 STF 桥接。
|
|
||||||
if ":" in self.serial:
|
if ":" in self.serial:
|
||||||
self.remote_adb_url = self.serial
|
self.remote_adb_url = self.serial
|
||||||
_log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}")
|
_log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}")
|
||||||
else:
|
else:
|
||||||
|
if self.stf is None:
|
||||||
|
raise STFError(f"USB 设备 {self.serial} 暂不支持(未启用 STF 桥接)", "offline")
|
||||||
self.remote_adb_url = self.stf.remote_connect(self.serial)
|
self.remote_adb_url = self.stf.remote_connect(self.serial)
|
||||||
_log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}")
|
_log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}")
|
||||||
|
|
||||||
@@ -70,27 +66,21 @@ class STFDevice:
|
|||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
def _pick_free(self):
|
def _pick_free(self):
|
||||||
free = self.stf.list_free_devices()
|
free = device_pool.list_ready()
|
||||||
if not free:
|
if not free:
|
||||||
raise STFError("没有可用的在线空闲设备", "offline")
|
raise STFError("没有可用的在线空闲设备", "offline")
|
||||||
return free[0]["serial"]
|
return free[0]
|
||||||
|
|
||||||
def release(self):
|
def release(self):
|
||||||
if not self.serial:
|
if not self.serial:
|
||||||
return
|
return
|
||||||
# 直连模式(remote_adb_url == serial):不 disconnect
|
# 直连模式(remote_adb_url == serial):不 disconnect(红线,STF 共享 transport)
|
||||||
# STF provider 共享该 IP:5555 的 adb transport,disconnect 会让 STF 误判离线并重连
|
|
||||||
if self.remote_adb_url and self.remote_adb_url != self.serial:
|
if self.remote_adb_url and self.remote_adb_url != self.serial:
|
||||||
adb_disconnect(self.remote_adb_url)
|
adb_disconnect(self.remote_adb_url)
|
||||||
try:
|
try:
|
||||||
self.stf.remote_disconnect(self.serial)
|
self.stf.remote_disconnect(self.serial)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
ok, msg = self.stf.release(self.serial)
|
|
||||||
if ok:
|
|
||||||
_log.info(f"[{self.serial}] 已释放")
|
|
||||||
else:
|
|
||||||
_log.warning(f"[{self.serial}] STF 释放失败: {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
# ================== 全局 worker 状态注册表(供 web_server 读取) ==================
|
# ================== 全局 worker 状态注册表(供 web_server 读取) ==================
|
||||||
|
|||||||
@@ -216,11 +216,38 @@ class ApkFile(db.Model):
|
|||||||
return f"<ApkFile {self.display_name}>"
|
return f"<ApkFile {self.display_name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Device(db.Model):
|
||||||
|
"""设备池(本地设备清单,替代 STF 池作为调度数据源)。
|
||||||
|
|
||||||
|
serial 即 adb 序列号(IP:5555 或 USB 序列号);enabled=False 不参与调度。
|
||||||
|
"""
|
||||||
|
serial = db.Column(db.String(120), primary_key=True)
|
||||||
|
name = db.Column(db.String(80), default="") # 备注名(可选)
|
||||||
|
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
|
||||||
|
note = db.Column(db.Text, default="") # 备注
|
||||||
|
created_at = db.Column(db.String(20), default="") # 添加时间
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"serial": self.serial, "name": self.name or "",
|
||||||
|
"enabled": bool(self.enabled), "note": self.note or "",
|
||||||
|
"created_at": self.created_at or ""}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Device {self.serial}>"
|
||||||
|
|
||||||
|
|
||||||
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
||||||
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
||||||
SCHEMA_MIGRATIONS = [
|
SCHEMA_MIGRATIONS = [
|
||||||
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)",
|
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)",
|
||||||
"ALTER TABLE user ADD COLUMN perms TEXT DEFAULT '[]'"),
|
"ALTER TABLE user ADD COLUMN perms TEXT DEFAULT '[]'"),
|
||||||
|
(2, "设备池:device 表(本地设备清单,替代 STF 池)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS device ("
|
||||||
|
"serial VARCHAR(120) PRIMARY KEY,"
|
||||||
|
"name VARCHAR(80) DEFAULT '',"
|
||||||
|
"enabled BOOLEAN DEFAULT 1,"
|
||||||
|
"note TEXT DEFAULT '',"
|
||||||
|
"created_at VARCHAR(20) DEFAULT '')"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+67
-142
@@ -30,6 +30,7 @@ from apscheduler.triggers.cron import CronTrigger
|
|||||||
from config import DATA_DIR
|
from config import DATA_DIR
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
|
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
|
||||||
|
from core import device_pool
|
||||||
from .stf_client import STFClient, DeviceOfflineError
|
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, adb_connect_light, adb_disconnect
|
||||||
from .device_worker import (
|
from .device_worker import (
|
||||||
@@ -41,8 +42,8 @@ from tasks import list_task_types, get_task_class
|
|||||||
|
|
||||||
_log = get_logger("core.tm")
|
_log = get_logger("core.tm")
|
||||||
|
|
||||||
# 错峰启动间隔(秒):批量触发时设备逐个开始占用/连接,避免 adb 全局锁串行堆积
|
# 错峰启动间隔(秒):批量触发时设备逐个开始连接,避免 adb 全局锁串行堆积。
|
||||||
# 和 STF occupy 并发风暴。100 台 × 0.2s = 20s 全部开始启动。
|
# 100 台 × 0.2s = 20s 全部开始启动。
|
||||||
_START_STAGGER_SEC = 0.2
|
_START_STAGGER_SEC = 0.2
|
||||||
|
|
||||||
|
|
||||||
@@ -143,7 +144,10 @@ class TaskJob:
|
|||||||
d.get("params"), d.get("schedule"), d.get("retry"), d.get("enabled", True))
|
d.get("params"), d.get("schedule"), d.get("retry"), d.get("enabled", True))
|
||||||
|
|
||||||
def resolve_serials(self, manager):
|
def resolve_serials(self, manager):
|
||||||
"""根据 target 解析出实际要跑的 serial 列表。"""
|
"""根据 target 解析出实际要跑的 serial 列表。
|
||||||
|
|
||||||
|
数据源:core.device_pool(本地清单 + adb 在线状态),不再查 STF。
|
||||||
|
"""
|
||||||
mode = self.target.get("mode", "all")
|
mode = self.target.get("mode", "all")
|
||||||
if mode == "serial":
|
if mode == "serial":
|
||||||
serials = [self.target["serial"]]
|
serials = [self.target["serial"]]
|
||||||
@@ -151,37 +155,31 @@ class TaskJob:
|
|||||||
g = manager.groups.get(self.target.get("group_name"))
|
g = manager.groups.get(self.target.get("group_name"))
|
||||||
serials = list(g.serials) if g else []
|
serials = list(g.serials) if g else []
|
||||||
else:
|
else:
|
||||||
# all:默认返回所有空闲设备(天然只含在线设备);
|
# all:默认返回池内在线设备(单实例互斥由 _run_with_retry 的 _running 保证);
|
||||||
# 抢占模式返回全部在线就绪设备(含被占用,执行时抢占)
|
# 抢占模式返回全部在线设备(含运行中,执行时抢占)
|
||||||
if self.params.get("preempt"):
|
if self.params.get("preempt"):
|
||||||
try:
|
try:
|
||||||
return [d["serial"] for d in manager.stf.list_all_devices()
|
return device_pool.list_online()
|
||||||
if d.get("present") and d.get("ready")]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
return [d["serial"] for d in manager.stf.list_free_devices()]
|
return device_pool.list_ready()
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
# 离线自动跳过(serial/group 模式):跳过 STF 池里不在线(present=False)或
|
# 离线自动跳过(serial/group 模式):跳过本机 adb 不可达的设备,
|
||||||
# 未就绪(ready=False,provider 刚接入还在初始化)的设备,
|
# 避免反复尝试连接后报"设备离线"。默认开启,可在任务编辑器取消勾选。
|
||||||
# 避免对离线/未就绪设备反复尝试占用后报"设备离线"。默认开启,可在任务编辑器取消勾选。
|
|
||||||
if serials and self.params.get("skip_offline", True):
|
if serials and self.params.get("skip_offline", True):
|
||||||
try:
|
try:
|
||||||
devs = manager.stf.list_all_devices()
|
online = set(device_pool.list_online())
|
||||||
except Exception:
|
except Exception:
|
||||||
devs = None # STF 查询失败时不过滤,维持原行为
|
online = None # 查询失败时不过滤,维持原行为
|
||||||
if devs is not None:
|
if online is not None:
|
||||||
present = {d["serial"] for d in devs if d.get("present")}
|
|
||||||
ready = {d["serial"] for d in devs if d.get("present") and d.get("ready")}
|
|
||||||
kept, skipped = [], []
|
kept, skipped = [], []
|
||||||
for s in serials:
|
for s in serials:
|
||||||
if s not in present:
|
if s in online:
|
||||||
skipped.append((s, "离线"))
|
|
||||||
elif s not in ready:
|
|
||||||
skipped.append((s, "未就绪(provider初始化中)"))
|
|
||||||
else:
|
|
||||||
kept.append(s)
|
kept.append(s)
|
||||||
|
else:
|
||||||
|
skipped.append((s, "离线(未连接)"))
|
||||||
if skipped:
|
if skipped:
|
||||||
detail = ", ".join(f"{s}({why})" for s, why in skipped)
|
detail = ", ".join(f"{s}({why})" for s, why in skipped)
|
||||||
_log.info(f"任务 {self.name} 跳过 {len(skipped)} 台设备: {detail}")
|
_log.info(f"任务 {self.name} 跳过 {len(skipped)} 台设备: {detail}")
|
||||||
@@ -206,8 +204,7 @@ class _ForegroundScanner:
|
|||||||
dumpsys window 只读取窗口状态,不执行任何操作。
|
dumpsys window 只读取窗口状态,不执行任何操作。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, stf):
|
def __init__(self):
|
||||||
self.stf = stf
|
|
||||||
self._cache = {} # serial -> app_name
|
self._cache = {} # serial -> app_name
|
||||||
self._cache_lock = threading.Lock()
|
self._cache_lock = threading.Lock()
|
||||||
self._scanning = threading.Event() # 标记是否正在扫描
|
self._scanning = threading.Event() # 标记是否正在扫描
|
||||||
@@ -245,61 +242,40 @@ class _ForegroundScanner:
|
|||||||
def _scan_all(self):
|
def _scan_all(self):
|
||||||
"""扫描所有在线设备(后台线程执行)。
|
"""扫描所有在线设备(后台线程执行)。
|
||||||
|
|
||||||
按设备归属分四类处理:
|
按设备归属分两类处理:
|
||||||
1. worker 运行中:用已有 remote_adb_url 直接查询(无额外开销)
|
1. worker 运行中:用已有 remote_adb_url 直接查询(无额外开销)
|
||||||
2. 自己账户占用但无 worker:调用 STF remoteConnect 获取隧道查询
|
2. 空闲设备:返回"空闲"——不主动连接。IP:5555 的 adb transport
|
||||||
(不 occupy/release,不打扰设备 UI)
|
与 STF provider 共享,外部 connect/disconnect 会让 STF 误判
|
||||||
3. 完全空闲设备(using=False):尝试轻量 adb connect serial
|
设备离线并触发重连(迁移期仍保留此约束,摘除 STF 后可放开)
|
||||||
(单次尝试,不 kill-server,不影响其他 worker)
|
|
||||||
4. 被他人占用:标记 "(他人占用)"
|
|
||||||
"""
|
"""
|
||||||
self._scanning.set()
|
self._scanning.set()
|
||||||
_log.info("前台 App 扫描已启动")
|
_log.info("前台 App 扫描已启动")
|
||||||
try:
|
try:
|
||||||
all_devices = self.stf.list_all_devices()
|
online = set(device_pool.list_online())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.error("前台 App 扫描: 获取设备列表失败: %s", e)
|
_log.error("前台 App 扫描: 获取在线设备失败: %s", e)
|
||||||
self._scanning.clear()
|
self._scanning.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
# 自己账户已占用的设备列表(用于区分"自己占用"vs"他人占用")
|
|
||||||
try:
|
|
||||||
my_serials = {d["serial"] for d in self.stf.list_my_devices()}
|
|
||||||
except Exception:
|
|
||||||
my_serials = set()
|
|
||||||
|
|
||||||
worker_status = {w["serial"]: w for w in get_all_worker_status()}
|
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(worker 运行中,已有 adb 连接)
|
||||||
my_owned = [] # 自己账户占用但无 running worker,需 remoteConnect 获取隧道
|
free_serials = [] # 空闲设备
|
||||||
free_serials = [] # 完全空闲设备,尝试轻量 adb connect
|
|
||||||
skip_serials = [] # 被他人占用
|
|
||||||
|
|
||||||
for dev in all_devices:
|
for serial in online:
|
||||||
if not dev.get("present"):
|
|
||||||
continue
|
|
||||||
serial = dev.get("serial", "")
|
|
||||||
if not serial:
|
|
||||||
continue
|
|
||||||
w = worker_status.get(serial, {})
|
w = worker_status.get(serial, {})
|
||||||
url = w.get("remote_adb_url")
|
url = w.get("remote_adb_url")
|
||||||
if url and w.get("status") in ("running", "connecting"):
|
if url and w.get("status") in ("running", "connecting"):
|
||||||
# 1. 设备正在执行任务,已有 adb 连接,直接查询
|
# 1. 设备正在执行任务,已有 adb 连接,直接查询
|
||||||
have_conn[serial] = url
|
have_conn[serial] = url
|
||||||
elif serial in my_serials:
|
|
||||||
# 2. 自己账户占用但无 worker,可安全 remoteConnect(不打扰设备)
|
|
||||||
my_owned.append(serial)
|
|
||||||
elif dev.get("using"):
|
|
||||||
# 4. 被他人占用
|
|
||||||
skip_serials.append(serial)
|
|
||||||
else:
|
else:
|
||||||
# 3. 完全空闲,尝试轻量 adb connect(不 kill-server)
|
# 2. 空闲设备
|
||||||
free_serials.append(serial)
|
free_serials.append(serial)
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
_log.info("前台 App 扫描分类: 运行中=%d, 自己占用=%d, 空闲=%d, 他人占用=%d",
|
_log.info("前台 App 扫描分类: 运行中=%d, 空闲=%d",
|
||||||
len(have_conn), len(my_owned), len(free_serials), len(skip_serials))
|
len(have_conn), len(free_serials))
|
||||||
|
|
||||||
# 1. worker 运行中设备:用已有 remote_adb_url 查询(并发 10)
|
# 1. worker 运行中设备:用已有 remote_adb_url 查询(并发 10)
|
||||||
if have_conn:
|
if have_conn:
|
||||||
@@ -313,33 +289,9 @@ class _ForegroundScanner:
|
|||||||
except Exception:
|
except Exception:
|
||||||
results[s] = None
|
results[s] = None
|
||||||
|
|
||||||
# 2. 自己占用设备:remoteConnect 获取隧道 → 查询 → 断开(并发 5,不打扰设备)
|
# 2. 空闲设备:不打扰,直接返回"空闲"
|
||||||
if my_owned:
|
for s in free_serials:
|
||||||
with ThreadPoolExecutor(max_workers=min(5, len(my_owned))) as pool:
|
results[s] = "空闲"
|
||||||
futures = {pool.submit(self._scan_my_owned, s): s
|
|
||||||
for s in my_owned}
|
|
||||||
for fut in as_completed(futures, timeout=30):
|
|
||||||
s = futures[fut]
|
|
||||||
try:
|
|
||||||
results[s] = fut.result()
|
|
||||||
except Exception:
|
|
||||||
results[s] = None
|
|
||||||
|
|
||||||
# 3. 空闲设备:轻量 adb connect serial → 查询 → disconnect(并发 10)
|
|
||||||
if free_serials:
|
|
||||||
with ThreadPoolExecutor(max_workers=min(10, len(free_serials))) as pool:
|
|
||||||
futures = {pool.submit(self._scan_free, s): s
|
|
||||||
for s in free_serials}
|
|
||||||
for fut in as_completed(futures, timeout=20):
|
|
||||||
s = futures[fut]
|
|
||||||
try:
|
|
||||||
results[s] = fut.result()
|
|
||||||
except Exception:
|
|
||||||
results[s] = None
|
|
||||||
|
|
||||||
# 4. 被别人占用的设备
|
|
||||||
for s in skip_serials:
|
|
||||||
results[s] = "(他人占用)"
|
|
||||||
|
|
||||||
# 更新缓存
|
# 更新缓存
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
@@ -351,37 +303,6 @@ class _ForegroundScanner:
|
|||||||
self._scanning.clear()
|
self._scanning.clear()
|
||||||
_log.info("前台 App 扫描完成: %d 台设备", len(results))
|
_log.info("前台 App 扫描完成: %d 台设备", len(results))
|
||||||
|
|
||||||
def _scan_my_owned(self, serial):
|
|
||||||
"""自己账户占用的设备:通过 STF remoteConnect 获取隧道查询。
|
|
||||||
|
|
||||||
不调用 occupy/release,只建立/断开 ADB 隧道,不打扰设备 UI。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
url = self.stf.remote_connect(serial)
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
if not adb_connect_light(url):
|
|
||||||
return None
|
|
||||||
return get_foreground_app(url)
|
|
||||||
finally:
|
|
||||||
adb_disconnect(url)
|
|
||||||
self.stf.remote_disconnect(serial)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _scan_free(self, serial):
|
|
||||||
"""空闲设备:不扫描前台 App,直接返回"空闲"。
|
|
||||||
|
|
||||||
原因:STF provider 内部通过 IP:5555 维持 adb 连接监控设备。
|
|
||||||
外部 adb connect/disconnect IP:5555 会让 adb server 断开该地址的
|
|
||||||
transport,连带 STF provider 的连接一起断,STF 误判设备 offline
|
|
||||||
并触发重连——表现就是"一扫描前台 App 设备就离线、需要重连"。
|
|
||||||
因此空闲设备绝不主动 adb connect/disconnect,只对运行中设备
|
|
||||||
(走 STF 隧道端口,不动 5555)和自占设备(remoteConnect 隧道)扫描。
|
|
||||||
"""
|
|
||||||
return "空闲"
|
|
||||||
|
|
||||||
|
|
||||||
# ================== 任务管理器 ==================
|
# ================== 任务管理器 ==================
|
||||||
class TaskManager:
|
class TaskManager:
|
||||||
@@ -396,7 +317,7 @@ class TaskManager:
|
|||||||
self._running = {} # serial -> {"worker", "job_id", "started_at", "attempt"}
|
self._running = {} # serial -> {"worker", "job_id", "started_at", "attempt"}
|
||||||
self._stop_requested = set() # serial 集合:用户请求停止,阻止后续重试
|
self._stop_requested = set() # serial 集合:用户请求停止,阻止后续重试
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._fg_scanner = _ForegroundScanner(self.stf)
|
self._fg_scanner = _ForegroundScanner()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def _db(self):
|
def _db(self):
|
||||||
@@ -837,15 +758,15 @@ class TaskManager:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return {s: dict(v) for s, v in self._running.items()}
|
return {s: dict(v) for s, v in self._running.items()}
|
||||||
|
|
||||||
# ---- 状态查询(带缓存,避免 STF 请求阻塞前端)----
|
# ---- 状态查询(带缓存,避免设备列表查询阻塞前端)----
|
||||||
_status_cache = None # (timestamp, data, error)
|
_status_cache = None # (timestamp, data, error)
|
||||||
_status_cache_lock = threading.Lock()
|
_status_cache_lock = threading.Lock()
|
||||||
_STATUS_CACHE_TTL = 5.0 # 缓存 5 秒,前端 5 秒刷新刚好命中
|
_STATUS_CACHE_TTL = 5.0 # 缓存 5 秒,前端 5 秒刷新刚好命中
|
||||||
|
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
"""综合状态:STF 设备池 + 本地 worker + 运行中的任务。
|
"""综合状态:设备池(清单+在线)+ 本地 worker + 运行中的任务。
|
||||||
|
|
||||||
带 5 秒缓存:STF 请求慢时避免每次 /api/status 都打 STF 阻塞 Flask。
|
带 5 秒缓存:避免每次 /api/status 都查库/adb 阻塞 Flask。
|
||||||
worker 状态实时读(内存,无 IO),不受缓存影响。
|
worker 状态实时读(内存,无 IO),不受缓存影响。
|
||||||
"""
|
"""
|
||||||
with self._status_cache_lock:
|
with self._status_cache_lock:
|
||||||
@@ -856,46 +777,53 @@ class TaskManager:
|
|||||||
return None, err
|
return None, err
|
||||||
# 用缓存的设备列表 + 实时 worker 状态重新组装
|
# 用缓存的设备列表 + 实时 worker 状态重新组装
|
||||||
return self._merge_status(cached), None
|
return self._merge_status(cached), None
|
||||||
# 缓存过期或不存在,重新拉 STF
|
# 缓存过期或不存在,重新拉设备池
|
||||||
try:
|
try:
|
||||||
all_devices = self.stf.list_all_devices()
|
configured = device_pool.list_configured()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with self._status_cache_lock:
|
with self._status_cache_lock:
|
||||||
self._status_cache = (time.time(), None, str(e))
|
self._status_cache = (time.time(), None, str(e))
|
||||||
return None, f"获取设备列表失败: {e}"
|
return None, f"获取设备列表失败: {e}"
|
||||||
with self._status_cache_lock:
|
with self._status_cache_lock:
|
||||||
self._status_cache = (time.time(), all_devices, None)
|
self._status_cache = (time.time(), configured, None)
|
||||||
return self._merge_status(all_devices), None
|
return self._merge_status(configured), None
|
||||||
|
|
||||||
def _merge_status(self, all_devices):
|
def _merge_status(self, configured_serials):
|
||||||
"""用 STF 设备列表 + 实时 worker 状态 + 前台 App 组装返回结果。"""
|
"""用设备池清单 + 实时 worker 状态 + 前台 App 组装返回结果。"""
|
||||||
worker_status = {w["serial"]: w for w in get_all_worker_status()}
|
worker_status = {w["serial"]: w for w in get_all_worker_status()}
|
||||||
running = self.get_running()
|
running = self.get_running()
|
||||||
|
try:
|
||||||
|
online = set(device_pool.list_online())
|
||||||
|
except Exception:
|
||||||
|
online = set()
|
||||||
|
try:
|
||||||
|
names = {d["serial"]: d["name"] for d in device_pool.list_devices()}
|
||||||
|
except Exception:
|
||||||
|
names = {}
|
||||||
|
|
||||||
# 清理陈旧状态:serial 已不在 STF 设备池、且没有在跑 worker 的条目,
|
# 清理陈旧状态:serial 已不在设备池、且没有在跑 worker 的条目,
|
||||||
# 避免设备被删除后其失败记录仍残留在"异常汇总"里
|
# 避免设备被删除后其失败记录仍残留在"异常汇总"里
|
||||||
present = {dev.get("serial", "") for dev in all_devices}
|
configured = set(configured_serials)
|
||||||
for serial, w in list(worker_status.items()):
|
for serial, w in list(worker_status.items()):
|
||||||
if serial in present or serial in running:
|
if serial in configured or serial in running:
|
||||||
continue
|
continue
|
||||||
if w.get("status") in ("running", "connecting"):
|
if w.get("status") in ("running", "connecting"):
|
||||||
continue
|
continue
|
||||||
_remove_worker(serial)
|
_remove_worker(serial)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for dev in all_devices:
|
for serial in configured_serials:
|
||||||
serial = dev.get("serial", "")
|
is_online = serial in online
|
||||||
owner = dev.get("owner")
|
|
||||||
w = worker_status.get(serial, {})
|
w = worker_status.get(serial, {})
|
||||||
r = running.get(serial, {})
|
r = running.get(serial, {})
|
||||||
result.append({
|
result.append({
|
||||||
"serial": serial,
|
"serial": serial,
|
||||||
"model": dev.get("model") or w.get("model", "") or dev.get("product", ""),
|
"model": w.get("model", ""),
|
||||||
"device_name": dev.get("name") or "",
|
"device_name": names.get(serial, ""),
|
||||||
"present": dev.get("present", False),
|
"present": is_online,
|
||||||
"ready": dev.get("ready", False),
|
"ready": is_online, # 阶段 1:ready 概念并入在线状态
|
||||||
"stf_occupied": dev.get("using", False),
|
"stf_occupied": False, # 阶段 1:已无 STF 占用(阶段 3 删字段)
|
||||||
"owner": owner.get("name", "") if owner else "",
|
"owner": "",
|
||||||
"worker_status": w.get("status", "idle"),
|
"worker_status": w.get("status", "idle"),
|
||||||
"foreground_app": self._fg_scanner.get(serial),
|
"foreground_app": self._fg_scanner.get(serial),
|
||||||
# 通用进度字段(任意 app 通用,前端统一解析展示)
|
# 通用进度字段(任意 app 通用,前端统一解析展示)
|
||||||
@@ -912,14 +840,11 @@ class TaskManager:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def list_all_serials(self):
|
def list_all_serials(self):
|
||||||
"""返回 STF 上所有在线设备的 serial 列表(供分组表单勾选用)。
|
"""返回设备池在线设备的 serial 列表(供分组表单勾选用)。"""
|
||||||
|
try:
|
||||||
复用 get_status 缓存,避免开页面时阻塞。
|
return device_pool.list_ready()
|
||||||
"""
|
except Exception:
|
||||||
devices, err = self.get_status()
|
|
||||||
if err or not devices:
|
|
||||||
return []
|
return []
|
||||||
return [d["serial"] for d in devices if d.get("present")]
|
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.stop_all()
|
self.stop_all()
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# 摘除 STF 迁移计划
|
||||||
|
|
||||||
|
背景:全舰队设备为 Tailscale IP:5555 直连(adb key 沿用 STF 的),单实例部署。
|
||||||
|
STF 当前仅提供:occupy/release 互斥、present+ready 健康信号、设备池清单(与
|
||||||
|
220 的 connect_devices.sh 双维护)、remoteConnect 桥接(IP:port 已禁用)、网页看屏。
|
||||||
|
任务本体跑 uiautomator2(atx-agent),与 STF agent(jp.co.cyberagent.stf)无关。
|
||||||
|
|
||||||
|
摘除收益:告别 agent 安装死锁(MIUI 弹窗)、STF 容器重启风险、occupy 冲突悬空;
|
||||||
|
调度直接基于 adb 真实现状。风险点:多实例互斥(当前单实例无此需求,见阶段 3)。
|
||||||
|
|
||||||
|
## 已确认的决策(2026-08-17)
|
||||||
|
|
||||||
|
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 容器只做看屏(不推荐,依赖没摘干净)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移难度与程度评估(2026-08-17)
|
||||||
|
|
||||||
|
### 代码量
|
||||||
|
|
||||||
|
| 项 | 量级 |
|
||||||
|
|---|---|
|
||||||
|
| 删除 `core/stf_client.py` 及引用 | ~250 行 |
|
||||||
|
| 新增 `core/device_pool.py`(清单+在线状态) | ~150 行 |
|
||||||
|
| 新增 devices 表 CRUD(复用现有 SQLite 基建) | ~120 行 |
|
||||||
|
| 修改 `task_manager`(resolve_serials/调度) | ~120 行 |
|
||||||
|
| 重写 `device_worker.STFDevice`(去 occupy/release) | ~80 行 |
|
||||||
|
| 修改 `web_server` 约 8 处调用点 | ~60 行 |
|
||||||
|
| 改造 `stf_device_mgmt`(SSH 脚本 → SQLite 管理) | ~100 行 |
|
||||||
|
| 前端 6 个文件文案/组件 | ~80 行 |
|
||||||
|
|
||||||
|
净效果:**-600 行 / +400 行 / 改 ~300 行**,集中在 task_manager 与 device_worker 两个核心文件。
|
||||||
|
|
||||||
|
### 分项难度(单人)
|
||||||
|
|
||||||
|
| 模块 | 难度 | 估算 | 关键点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 设备池 SQLite + device_pool | 低 | 0.5-1 天 | 表结构 + CRUD + 管理页改数据源 |
|
||||||
|
| 调度替换 | 中 | 1-2 天 | 内存锁已有(`_running[serial]`),换数据源 + 对照验证 |
|
||||||
|
| web_server 调用点 | 低 | 0.5 天 | 8 处机械替换 |
|
||||||
|
| USB 支持 | 中 | 1 天 | 220 容器发端口 + adb_helper 支持 `-H/-P` + u2 远程 server;**需一台真机验证** |
|
||||||
|
| 网页看屏(ws-scrcpy) | 中 | 0.5-1 天 | 独立 Node 服务 + 内嵌页 + 认证联动 |
|
||||||
|
| 界面清理 | 低 | 0.5 天 | 文案/按钮/列 |
|
||||||
|
| 回归验证 | 中 | 1 天 | 调度/抢占/重试/离线跳过/亮屏/剪贴板/终端/看屏 |
|
||||||
|
|
||||||
|
**总计约 4-6 人日**,分 4 个 commit 阶段,每阶段可独立回滚。
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
- **中**:USB 真机方案当前无设备可验,落地时需借一台 USB 设备
|
||||||
|
- **低**:离线跳过判定语义变化(STF 状态 → adb 状态),个别边界行为可能微变,需对照
|
||||||
|
- **低**:唯一动 220 的地方 = adb 容器发布 5037 端口(一次性)
|
||||||
|
- **低**:ws-scrcpy 是新增 Node 依赖,需评估其维护性与内存占用(可先试用再定)
|
||||||
|
|
||||||
|
### 实施顺序(调整后)
|
||||||
|
|
||||||
|
- **阶段 0**:SQLite 设备表 + `core/device_pool.py`(纯新增,STF 照常跑)
|
||||||
|
- **阶段 1**:调度/生命周期替换(STF 保留运行对照验证)
|
||||||
|
- **阶段 2**:USB 远程 adb server 打通 + ws-scrcpy 看屏部署
|
||||||
|
- **阶段 3**:界面清理 + 220 停 STF 容器(`docker stop` 不删,可回滚)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 0:新建设备池模块(纯新增,不改行为)
|
||||||
|
|
||||||
|
新建 `core/device_pool.py` + devices 表(data/users.db):
|
||||||
|
|
||||||
|
- devices 表:serial(主键)、name、enabled、note、created_at;管理页增删改
|
||||||
|
- `list_configured()` — 读 SQLite devices 表(取代 SSH 读 220 脚本)
|
||||||
|
- `list_online()` — 本机 `adb devices` 中 state=device 的设备(实时)
|
||||||
|
- `is_online(serial)` — 在 list_online 中;可选 `adb -s get-state` 兜底(带超时)
|
||||||
|
- `list_ready()` — list_configured 与 list_online 的交集(取代 STF list_free_devices)
|
||||||
|
- 全模块不 connect/不 kill-server/disconnect,遵守既有红线
|
||||||
|
- 迁移脚本:现有 8 台设备从 STF 池导入 devices 表
|
||||||
|
|
||||||
|
产出:`core/device_pool.py` + `doc/ARCHITECTURE.md` 设备章节初稿 + 数据迁移脚本。
|
||||||
|
|
||||||
|
## 阶段 1:替换调度与生命周期(STF 保留运行,可对照验证)
|
||||||
|
|
||||||
|
| 位置 | 现状 | 改为 |
|
||||||
|
|---|---|---|
|
||||||
|
| `task_manager.resolve_serials` (145-183) | `stf.list_free_devices()` / `list_all_devices()` | `device_pool.list_ready()`;preempt 模式 = 全部 list_online |
|
||||||
|
| `resolve_serials` skip_offline 过滤 (166-183) | STF present/ready 判定 | `device_pool.is_online()`;跳过原因文案"离线/未连接" |
|
||||||
|
| `device_worker.STFDevice.acquire` (45-70) | `stf.occupy()` + 直连/桥接 | 去掉 occupy(互斥由 `_running[serial]` 负责);**USB 桥接保留到阶段 2**(避免迁移期 USB 断档,阶段 2 换成 220 adb server);保留 adb_connect 重试 + 2s 等待 |
|
||||||
|
| `STFDevice.release` (78-93) | `stf.release()` | 置空(仅保留 USB 隧道断开) |
|
||||||
|
| `STFDevice._pick_free` (72-76) | `list_free_devices` | `device_pool.list_ready()` |
|
||||||
|
| `_ForegroundScanner` (234-386) | `list_all_devices` / `list_my_devices` / remote_connect | `device_pool.list_online()`;桥接分支删除 |
|
||||||
|
| `web_server.api_device_screen_all` (646) | STF present 列表 | `device_pool.list_online()` |
|
||||||
|
| `web_server` 设备列表 (924 / 1011 / 1079) | STF 池合并 | SQLite devices 表 + 本地 adb(`_merged_device_list` 改数据源,保留 stf_not_ready 状态的等价物"未连接") |
|
||||||
|
| `web_server.api_release` (740) / 启动清理 (1581) | release_all_mine | 删除或改为清理本实例 `_running` 状态(重启本就清零) |
|
||||||
|
|
||||||
|
验证清单(阶段 1 完成后全跑一遍):
|
||||||
|
- [ ] 任务调度(serial/group/all 三种模式)+ 抢占/归还 + 重试 + 离线跳过
|
||||||
|
- [ ] 一键亮屏/息屏、剪贴板注入、应用版本查询、维护终端设备列表
|
||||||
|
- [ ] 前台 App 扫描
|
||||||
|
- [ ] 对照 STF 状态确认无行为差异
|
||||||
|
|
||||||
|
## 阶段 2:USB 打通 + 网页看屏(阶段 1 稳定运行 ≥ 3 天后)
|
||||||
|
|
||||||
|
- **USB 远程 adb server**:
|
||||||
|
- 220 侧一次性改动:adb 容器发布 5037 端口(`docker run -p 5037:5037` 或 iptables),
|
||||||
|
Tailscale 可达
|
||||||
|
- `core/adb_helper._adb` 支持 `-H <220-tailscale-ip> -P 5037`(仅 USB 序列号设备)
|
||||||
|
- u2 连接:USB 设备走 `adbutils.Adb(host=220_ip, port=5037)` → `u2.Device(dev)`
|
||||||
|
(已验证 u2 3.7.0 底层 adbutils 支持远程 adb server)
|
||||||
|
- 本机 USB 设备照旧走本地 adb,零改动
|
||||||
|
- 需要一台真机 USB 设备验证
|
||||||
|
- **网页看屏(ws-scrcpy)**:
|
||||||
|
- 平台机部署 ws-scrcpy(Node 服务,WebRTC 低延迟流 + 触控),本机 adb 为数据源
|
||||||
|
- 管理员页面新增「远程看屏」入口,iframe 内嵌;登录态复用(ws-scrcpy 自身鉴权
|
||||||
|
走 token 或仅内网/Tailscale 暴露)
|
||||||
|
- 备选方案(若 ws-scrcpy 试用不达标):自建 minicap JPEG 流 + u2 触控注入(2-3 天)
|
||||||
|
|
||||||
|
## 阶段 3:界面清理 + STF 停用(阶段 2 稳定后)
|
||||||
|
|
||||||
|
- `templates/admin/monitor.html`:删「一键重启 STF 容器」区块 (460-468)、设备表 STF 占用列 (328);
|
||||||
|
「STF 设备管理」子分栏改名为「设备池管理」(增删改走 SQLite,SSH 部分退役)
|
||||||
|
- 维护终端:删「卸载/检查 STF agent」按钮(agent 已无用);如需补「重启 u2 atx-agent」
|
||||||
|
(u2 断开重连会自动重推 atx-agent,一般无需手动)
|
||||||
|
- 文案清理:tools.js / apps.js / editor.js 中"STF 池"→"设备池",`api/adb/devices` 的
|
||||||
|
source 字段 `stf` → `pool`
|
||||||
|
- 删除 `core/stf_client.py`、`STFClient` 全部引用;`config.py` 中 STF_URL/STF_TOKEN 标注废弃
|
||||||
|
- 220 侧:**停 STF 容器**(`docker stop stf`,不删除);connect_devices.sh cron 退役,
|
||||||
|
补连职责由平台侧定时器(对池内设备 adb connect 检查)接管
|
||||||
|
- 更新 `doc/ARCHITECTURE.md` / `doc/DEPLOY.md` 相关章节
|
||||||
|
|
||||||
|
## 阶段 4(可选,未来多实例/多机时)
|
||||||
|
|
||||||
|
跨实例互斥:SQLite 行锁替代 occupy——
|
||||||
|
```sql
|
||||||
|
UPDATE device_locks SET owner=?, ts=? WHERE serial=? AND (owner IS NULL OR owner=?)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回滚方式
|
||||||
|
|
||||||
|
- 每个阶段一个 commit,问题可整体 `git revert`;STF 阶段 3 前一直运行,随时回切
|
||||||
|
- 阶段 3 的 STF 容器是 `docker stop` 而非删除,回滚 = `git revert` + `docker start stf`
|
||||||
|
- 每个阶段 commit 后在 dev 观察 ≥3 天再进下一阶段
|
||||||
@@ -167,7 +167,7 @@ async function openInstallModal(apkId){
|
|||||||
'<div id="install-dev-list" class="device-list">'+
|
'<div id="install-dev-list" class="device-list">'+
|
||||||
online.map(d=>{
|
online.map(d=>{
|
||||||
const isUsb=d.source==='usb';
|
const isUsb=d.source==='usb';
|
||||||
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='stf'?'STF':'adb'))+'</span>';
|
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>';
|
||||||
// serial 唯一,显示 IP + 型号便于区分同型号设备
|
// serial 唯一,显示 IP + 型号便于区分同型号设备
|
||||||
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
|
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
|
||||||
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
|
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
|
||||||
|
|||||||
+36
-30
@@ -68,6 +68,10 @@ login_manager.login_view = "login"
|
|||||||
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
||||||
init_db(app)
|
init_db(app)
|
||||||
stf = STFClient()
|
stf = STFClient()
|
||||||
|
# 设备池(本地清单 + adb 在线状态):阶段 0 起作为 STF 的替代数据源,
|
||||||
|
# 首次启动自动从 STF 导入现有设备;阶段 3 摘除 STF 后此模块独立运行
|
||||||
|
from core import device_pool
|
||||||
|
device_pool.init_app(app)
|
||||||
mgr = TaskManager(stf, app=app)
|
mgr = TaskManager(stf, app=app)
|
||||||
apk_mgr = ApkManager(stf, app=app)
|
apk_mgr = ApkManager(stf, app=app)
|
||||||
|
|
||||||
@@ -643,7 +647,7 @@ def api_device_screen_all():
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
mode = data.get("mode", "on")
|
mode = data.get("mode", "on")
|
||||||
try:
|
try:
|
||||||
serials = [d["serial"] for d in stf.list_all_devices() if d.get("present")]
|
serials = device_pool.list_online()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502
|
return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502
|
||||||
if not serials:
|
if not serials:
|
||||||
@@ -737,7 +741,12 @@ def api_stop_all():
|
|||||||
@app.route("/api/release", methods=["POST"])
|
@app.route("/api/release", methods=["POST"])
|
||||||
@perm_required(PERM_DEVICES)
|
@perm_required(PERM_DEVICES)
|
||||||
def api_release():
|
def api_release():
|
||||||
|
# 阶段 1:调度已不占 STF,此处仅清理迁移期遗留的 STF 占用记录
|
||||||
|
# (阶段 3 摘除 STF 后此接口改为清理本实例 _running/worker 状态)
|
||||||
|
try:
|
||||||
released, failed = stf.release_all_mine()
|
released, failed = stf.release_all_mine()
|
||||||
|
except Exception:
|
||||||
|
released, failed = [], []
|
||||||
return jsonify({"ok": True, "released": released, "failed": failed})
|
return jsonify({"ok": True, "released": released, "failed": failed})
|
||||||
|
|
||||||
|
|
||||||
@@ -912,32 +921,30 @@ def api_steps_test():
|
|||||||
def api_uiauto_devices():
|
def api_uiauto_devices():
|
||||||
"""获取可选设备列表(抓取元素用)。
|
"""获取可选设备列表(抓取元素用)。
|
||||||
|
|
||||||
uiautodev 只认识本地 adb 已连接的设备;平台设备池(STF)里在线但未
|
uiautodev 只认识本地 adb 已连接的设备;设备池里在线但未本地连接的设备
|
||||||
本地连接的设备(如 100.100.10.10)在这里补全,并先做一次轻量 adb
|
在这里补全,并先做一次轻量 adb connect(单次尝试,绝不 kill-server),
|
||||||
connect(单次尝试,绝不 kill-server),让它们可被 uiautodev 抓取。
|
让它们可被 uiautodev 抓取。
|
||||||
"""
|
"""
|
||||||
ok, data = uiauto_helper.list_devices()
|
ok, data = uiauto_helper.list_devices()
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"ok": False, "error": data}), 503
|
return jsonify({"ok": False, "error": data}), 503
|
||||||
seen = {d.get("serial") for d in data}
|
seen = {d.get("serial") for d in data}
|
||||||
try:
|
try:
|
||||||
pool = stf.list_all_devices()
|
online = set(device_pool.list_online())
|
||||||
|
pool = [s for s in device_pool.list_configured() if s in online]
|
||||||
except Exception:
|
except Exception:
|
||||||
pool = []
|
pool = []
|
||||||
added = 0
|
added = 0
|
||||||
for d in pool:
|
for serial in pool:
|
||||||
serial = d.get("serial", "")
|
|
||||||
if not serial or serial in seen:
|
if not serial or serial in seen:
|
||||||
continue
|
continue
|
||||||
if not (d.get("present") and d.get("ready")):
|
|
||||||
continue
|
|
||||||
# 轻量连接(单次尝试,不重试不 kill-server);连不上也照样列出,
|
# 轻量连接(单次尝试,不重试不 kill-server);连不上也照样列出,
|
||||||
# 前端抓取时会报明确错误,不影响其他设备的连接
|
# 前端抓取时会报明确错误,不影响其他设备的连接
|
||||||
adb_connect_light(serial)
|
adb_connect_light(serial)
|
||||||
data.append({
|
data.append({
|
||||||
"serial": serial,
|
"serial": serial,
|
||||||
"model": d.get("model") or d.get("product") or "",
|
"model": "",
|
||||||
"product": d.get("product") or "",
|
"product": "",
|
||||||
"name": serial,
|
"name": serial,
|
||||||
"status": "device",
|
"status": "device",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -945,7 +952,7 @@ def api_uiauto_devices():
|
|||||||
seen.add(serial)
|
seen.add(serial)
|
||||||
added += 1
|
added += 1
|
||||||
if added:
|
if added:
|
||||||
_log.info(f"抓取元素设备列表补全 {added} 台 STF 在线设备: {list(seen)}")
|
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
|
||||||
return jsonify({"ok": True, "devices": data})
|
return jsonify({"ok": True, "devices": data})
|
||||||
|
|
||||||
|
|
||||||
@@ -1002,18 +1009,16 @@ def api_apks_delete(apk_id):
|
|||||||
@app.route("/api/apks/install/devices")
|
@app.route("/api/apks/install/devices")
|
||||||
@perm_required(PERM_APKS)
|
@perm_required(PERM_APKS)
|
||||||
def api_apks_install_devices():
|
def api_apks_install_devices():
|
||||||
"""可安装设备列表(应用管理):STF 在线设备池 + 本机 adb 设备(含 USB 有线连接)。
|
"""可安装设备列表(应用管理):设备池在线设备 + 本机 adb 设备(含 USB 有线连接)。
|
||||||
|
|
||||||
source: stf=STF 池 / usb=本机有线 adb / adb=本机网络 adb。
|
source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。
|
||||||
"""
|
"""
|
||||||
devices, seen = [], set()
|
devices, seen = [], set()
|
||||||
try:
|
try:
|
||||||
for d in stf.list_all_devices():
|
online = set(device_pool.list_online())
|
||||||
serial = d.get("serial", "")
|
for serial in device_pool.list_configured():
|
||||||
if serial and d.get("present") and d.get("ready"):
|
if serial in online:
|
||||||
devices.append({"serial": serial,
|
devices.append({"serial": serial, "model": "", "source": "pool"})
|
||||||
"model": d.get("model") or d.get("manufacturer") or "",
|
|
||||||
"source": "stf"})
|
|
||||||
seen.add(serial)
|
seen.add(serial)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -1062,12 +1067,11 @@ def _blocked_adb_cmd(cmd):
|
|||||||
|
|
||||||
|
|
||||||
def _merged_device_list():
|
def _merged_device_list():
|
||||||
"""本地 adb(含 USB)+ STF 在线设备池的合并列表([{serial, state}])。
|
"""本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。
|
||||||
|
|
||||||
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
|
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
|
||||||
STF 里 adb 可达但 agent 未就绪的设备(手机端 agent 启动失败)也列入,
|
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接;
|
||||||
标记 stf_not_ready——这种设备恰恰需要用维护终端卸载/重装 agent 修复,
|
stf_not_ready=STF agent 未就绪(迁移期保留,阶段 3 摘除 STF 后删除)。
|
||||||
不列出来就永远无法修复(死循环)。
|
|
||||||
"""
|
"""
|
||||||
states = {}
|
states = {}
|
||||||
out = _adb("devices")
|
out = _adb("devices")
|
||||||
@@ -1075,15 +1079,17 @@ def _merged_device_list():
|
|||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
|
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
|
||||||
states[parts[0]] = parts[1]
|
states[parts[0]] = parts[1]
|
||||||
|
try:
|
||||||
|
for serial in device_pool.list_configured():
|
||||||
|
if serial not in states:
|
||||||
|
states[serial] = "pool"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 迁移期:STF 里 adb 可达但 agent 未就绪的设备仍列出,供维护终端修复 agent
|
||||||
try:
|
try:
|
||||||
for d in stf.list_all_devices():
|
for d in stf.list_all_devices():
|
||||||
serial = d.get("serial", "")
|
serial = d.get("serial", "")
|
||||||
if not serial or serial in states:
|
if serial and d.get("present") and not d.get("ready") and serial not in states:
|
||||||
continue
|
|
||||||
if d.get("present") and d.get("ready"):
|
|
||||||
states[serial] = "stf"
|
|
||||||
elif d.get("present") and not d.get("ready"):
|
|
||||||
# adb 可达但 STF agent 未就绪(启动失败/未安装):列入供修复
|
|
||||||
states[serial] = "stf_not_ready"
|
states[serial] = "stf_not_ready"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user