diff --git a/core/device_pool.py b/core/device_pool.py new file mode 100644 index 0000000..52b89b9 --- /dev/null +++ b/core/device_pool.py @@ -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)") diff --git a/core/models.py b/core/models.py index c0bcce5..82eca54 100644 --- a/core/models.py +++ b/core/models.py @@ -216,11 +216,38 @@ class ApkFile(db.Model): return f"" +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"" + + # 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL) # 版本号单调递增,只执行比当前 schema_version 新的迁移。 SCHEMA_MIGRATIONS = [ (1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)", "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 '')"), ] diff --git a/doc/STF_REMOVAL.md b/doc/STF_REMOVAL.md new file mode 100644 index 0000000..1c61517 --- /dev/null +++ b/doc/STF_REMOVAL.md @@ -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 与桥接分支(IP:port 直连保留,USB 序列号仅提示不支持);保留 adb_connect 重试 + 2s 等待 | +| `STFDevice.release` (78-93) | `stf.release()` | 置空(互斥由 `_running[serial]` 负责,本来就是内存锁) | +| `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 天再进下一阶段 diff --git a/web_server.py b/web_server.py index 08cca3c..de91da0 100644 --- a/web_server.py +++ b/web_server.py @@ -68,6 +68,10 @@ login_manager.login_view = "login" # 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) init_db(app) stf = STFClient() +# 设备池(本地清单 + adb 在线状态):阶段 0 起作为 STF 的替代数据源, +# 首次启动自动从 STF 导入现有设备;阶段 3 摘除 STF 后此模块独立运行 +from core import device_pool +device_pool.init_app(app) mgr = TaskManager(stf, app=app) apk_mgr = ApkManager(stf, app=app)