From 4efe17c1c957105cc5a0d3339af872b19aa6d9b1 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 30 Aug 2026 10:57:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E5=A4=87=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E2=80=94=E2=80=94=E5=AE=9A=E6=97=B6=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E5=B1=80=E5=9F=9F=E7=BD=91+Tailscale=20=E7=BD=91?= =?UTF-8?q?=E6=AE=B5=E5=BC=80=E6=94=BE=20adb=205555=20=E7=9A=84=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E8=BF=9B=E5=85=A5=E5=BE=85=E8=BF=9E=E6=8E=A5=E6=B1=A0?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E6=88=B7=E7=A1=AE=E8=AE=A4=E5=90=8E=E6=89=8D?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=E6=AD=A3=E5=BC=8F=E8=AE=BE=E5=A4=87=E6=B1=A0?= =?UTF-8?q?=EF=BC=88=E4=B8=8D=E8=87=AA=E5=8A=A8=E8=BF=9E=E6=8E=A5=EF=BC=89?= =?UTF-8?q?=EF=BC=9Bsocket=20=E5=B9=B6=E5=8F=91=E6=8E=A2=E6=B5=8B+adb=20?= =?UTF-8?q?=E7=9F=AD=E8=B6=85=E6=97=B6=E9=AA=8C=E8=AF=81=EF=BC=88state=3Dd?= =?UTF-8?q?evice=20=E8=BF=87=E6=BB=A4=20unauthorized=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=AD=98=20app=5Fmeta=20=E5=8F=AF=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E8=B0=83=E6=95=B4=EF=BC=9B=E9=85=8D=E5=A5=97=EF=BC=9A?= =?UTF-8?q?adb=5Fconnect=5Flight=20=E9=AA=8C=E8=AF=81=E7=9F=AD=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E3=80=81DISABLE=5FSCHEDULER=20=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 7 + core/adb_helper.py | 15 +- core/device_discovery.py | 338 +++++++++++++++++++++++++++++++++++ core/models.py | 21 +++ core/task_manager.py | 12 +- scripts/regression_test.py | 4 + static/admin/base.js | 11 +- static/admin/tools.js | 81 +++++++++ templates/admin/monitor.html | 22 +++ web/devices_api.py | 86 ++++++++- web_server.py | 4 + 11 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 core/device_discovery.py diff --git a/config.py b/config.py index cfd21ff..faa6491 100644 --- a/config.py +++ b/config.py @@ -82,3 +82,10 @@ USB_ADB_PORT = int(_env("USB_ADB_PORT", "5037")) # 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。 TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "") + +# ================== 设备自动发现 ================== +# 定时扫描网段中开放 adb 5555 的设备 → 待连接池(用户确认后才加入正式池)。 +# 网段默认:局域网 + Tailscale;可在工具页设备池面板修改(存 app_meta)。 +DISCOVERY_PORT = int(_env("DISCOVERY_PORT", "5555")) +DISCOVERY_SUBNETS = ["192.168.20.0/24", "100.100.10.0/24"] +DISCOVERY_INTERVAL = int(_env("DISCOVERY_INTERVAL", "60")) diff --git a/core/adb_helper.py b/core/adb_helper.py index 282a970..5cfc03e 100644 --- a/core/adb_helper.py +++ b/core/adb_helper.py @@ -19,17 +19,18 @@ _ADB_LOCK = threading.Lock() _ADB_TIMEOUT = 30 -def _adb(*args): +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=_ADB_TIMEOUT) + r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=timeout) except subprocess.TimeoutExpired: - _log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s") + _log.warning(f"adb {' '.join(args)} 超时 {timeout}s") return "" return _decode(r) @@ -157,16 +158,18 @@ def _try_connect(remote_adb_url, retries): return False -def adb_connect_light(remote_adb_url): - """轻量 adb connect(扫描专用):单次尝试,不重试,绝不 kill-server。 +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) + out = _adb("connect", remote_adb_url, timeout=timeout) if "connected" in out.lower() and "failed" not in out.lower(): return True return False diff --git a/core/device_discovery.py b/core/device_discovery.py new file mode 100644 index 0000000..9a6e2d3 --- /dev/null +++ b/core/device_discovery.py @@ -0,0 +1,338 @@ +"""设备自动发现:扫描网段中开放 adb 5555 的设备 → 待连接池(pending)。 + +流程:socket 并发探测 5555(标准库,不碰 adb)→ 剔除已在设备池的 serial → +串行 adb 验证(adb_connect_light,内部全局锁)→ 用 `adb devices` 的 +state=="device" 过滤(排除 unauthorized/offline,connect 输出不可信)→ +写入 pending_device 表。**扫描只验证、不自动连接入池**——用户在前端确认后 +才调 device_pool.add_device + adb_connect(见 web/devices_api.py)。 + +安全红线:绝不 kill-server / 绝不 disconnect(与全项目一致)。 + +配置存 app_meta KV 表(discovery_enabled/subnets/interval/port),定时线程 +每轮重读,开关/周期/网段改动即时生效无需重启。 +""" +import ipaddress +import json +import socket +import threading +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime + +from config import (USB_ADB_HOST, + DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL) +from core.adb_helper import _adb, adb_connect_light +from core.logger import get_logger +from core.models import db, PendingDevice + +_log = get_logger("core.disc") + +# 扫描配置键(app_meta) +_K_ENABLED = "discovery_enabled" +_K_SUBNETS = "discovery_subnets" +_K_INTERVAL = "discovery_interval" +_K_PORT = "discovery_port" + +# 单网段主机数上限(防误配 /8 之类),超过截断并告警 +_MAX_HOSTS_PER_SUBNET = 1024 +# socket 探测并发数与单 IP 超时 +_PROBE_WORKERS = 64 +_PROBE_TIMEOUT = 0.4 + +_app = None +_scan_lock = threading.Lock() # 定时/手动扫描互斥 +_stop_event = threading.Event() # shutdown 用 +_scanning = False # 状态快照(API/前端) +_last_scan = None # (时间串, 开放数, 可连数, 新增数) +_last_error = "" + + +def _ctx(): + """后台线程访问 db 时自行推 app context(仿 device_pool._ctx)。""" + return _app.app_context() if _app else None + + +def _fmt(ts=None): + return (ts or datetime.now()).strftime("%Y-%m-%d %H:%M") + + +# ================== 配置读写 ================== +def get_settings(): + """读发现配置(app_meta,缺键用 config 默认值补齐)。""" + with _ctx(): + def _get(key, default): + v = db.session.execute( + db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar() + return v if v is not None else default + try: + subnets = json.loads(_get(_K_SUBNETS, "[]")) or DISCOVERY_SUBNETS + except Exception: + subnets = DISCOVERY_SUBNETS + return { + "enabled": _get(_K_ENABLED, "1") == "1", # 默认开启 + "subnets": subnets, + "interval": int(_get(_K_INTERVAL, str(DISCOVERY_INTERVAL)) or DISCOVERY_INTERVAL), + "port": int(_get(_K_PORT, str(DISCOVERY_PORT)) or DISCOVERY_PORT), + } + + +def save_settings(enabled=None, subnets=None, interval=None, port=None): + """写发现配置(部分字段更新)。subnets 逐项校验 CIDR,非法返回 (False, 原因)。""" + if subnets is not None: + clean = [] + for s in subnets: + s = (s or "").strip() + if not s: + continue + try: + ipaddress.ip_network(s, strict=False) + except ValueError: + return False, f"网段格式错误: {s}" + clean.append(s) + subnets = clean + try: + if interval is not None and (int(interval) < 10 or int(interval) > 3600): + return False, "扫描周期需在 10-3600 秒之间" + if port is not None and (int(port) < 1 or int(port) > 65535): + return False, "端口不合法" + except (TypeError, ValueError): + return False, "参数不合法" + with _ctx(): + def _put(key, value): + db.session.execute( + db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"), + {"k": key, "v": str(value)}) + if enabled is not None: + _put(_K_ENABLED, "1" if enabled else "0") + if subnets is not None: + _put(_K_SUBNETS, json.dumps(subnets)) + if interval is not None: + _put(_K_INTERVAL, str(int(interval))) + if port is not None: + _put(_K_PORT, str(int(port))) + db.session.commit() + return True, "已保存" + + +# ================== 网段展开与端口探测 ================== +def _expand_subnets(subnets, max_hosts=_MAX_HOSTS_PER_SUBNET): + """CIDR 列表 → IP 列表。排除 220 自身(USB_ADB_HOST)与本机 IP; + 非法网段跳过记日志;单网段超过 max_hosts 截断并告警。""" + self_ips = {USB_ADB_HOST} + try: # 本机自身 IP(接口枚举,尽力而为;无 hostname -I 的平台跳过) + import subprocess + out = subprocess.run(["hostname", "-I"], capture_output=True, text=True, + timeout=3).stdout or "" + self_ips.update(p for p in out.split() if p) + except Exception: + pass + ips = [] + for cidr in subnets: + try: + net = ipaddress.ip_network((cidr or "").strip(), strict=False) + except ValueError: + _log.warning(f"发现: 跳过非法网段 {cidr}") + continue + hosts = [str(h) for h in net.hosts() + if str(h) not in self_ips] + if len(hosts) > max_hosts: + _log.warning(f"发现: 网段 {cidr} 主机数 {len(hosts)} 超过上限,截断前 {max_hosts}") + hosts = hosts[:max_hosts] + ips.extend(hosts) + return ips + + +def _probe_port(ip, port, timeout=_PROBE_TIMEOUT): + """单 IP 端口探测(标准库 socket,超时/拒绝/不可达一律 False)。""" + try: + with socket.create_connection((ip, port), timeout=timeout): + return True + except Exception: + return False + + +def _probe_open(ips, port, workers=_PROBE_WORKERS): + """并发探测,返回开放端口的主机列表。""" + if not ips: + return [] + with ThreadPoolExecutor(max_workers=workers) as ex: + results = list(ex.map(lambda ip: (ip, _probe_port(ip, port)), ips)) + return [ip for ip, ok in results if ok] + + +# ================== adb devices 解析 ================== +def _parse_adb_devices(out): + """解析 `adb devices` 输出 → state=="device" 的 serial 集合。 + + 只看 connect 输出不可靠(未授权设备也返回 connected),必须用 state 过滤。 + """ + devices = set() + for line in (out or "").splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): + if parts[1] == "device": + devices.add(parts[0]) + return devices + + +# ================== 扫描 ================== +def scan_once(manual=False): + """执行一轮扫描。返回 (ok, result_dict);后台线程/API 调用。""" + global _scanning, _last_scan, _last_error + if not _scan_lock.acquire(blocking=False): + return False, {"error": "扫描进行中"} + _scanning = True + try: + settings = get_settings() + port = settings["port"] + subnets = settings["subnets"] + if not subnets: + _last_error = "未配置扫描网段" + return True, {"found": 0, "verified": 0, "new": 0, "error": _last_error} + + # 1. socket 并发探测(不碰 adb,把几百台缩小到开放端口量级) + open_ips = _probe_open(_expand_subnets(subnets), port) + candidates = {f"{ip}:{port}" for ip in open_ips} + + with _ctx(): + # 2. 剔除已在正式设备池的 serial + configured = set(device_pool_list_configured()) + candidates -= configured + + # 3. 串行 adb 验证:已连接的直接跳过(零成本),其余 adb_connect_light + already = _parse_adb_devices(_adb("devices")) + for serial in candidates - already: + adb_connect_light(serial) # 内部全局锁串行;失败无妨,下面按 state 过滤 + + # 4. state==device 过滤(排除 unauthorized/offline) + verified = _parse_adb_devices(_adb("devices")) & candidates + + # 5. 写 pending:新 → 插入;已有 → 更新 last_seen + now = _fmt() + existing = {p.serial for p in PendingDevice.query.all()} + added = 0 + for serial in verified: + source = "tailscale" if serial.split(":")[0].startswith("100.") else "lan" + if serial in existing: + PendingDevice.query.filter_by(serial=serial).update( + {"last_seen": now}) + else: + db.session.add(PendingDevice(serial=serial, source=source, + first_seen=now, last_seen=now)) + added += 1 + db.session.commit() + _log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台," + f"新增待连接 {added} 台") + _last_scan = (now, len(open_ips), len(verified), added) + _last_error = "" + return True, {"found": len(open_ips), "verified": len(verified), "new": added} + except Exception as e: + _log.warning(f"发现扫描异常: {e}") + _last_error = str(e)[:200] + return False, {"error": _last_error} + finally: + _scanning = False + _scan_lock.release() + + +def device_pool_list_configured(): + """设备池已配置 serial(延迟 import 避免循环依赖)。""" + from core import device_pool + return device_pool.list_configured() + + +# ================== 定时扫描线程 ================== +def _discovery_loop(): + """定时扫描 daemon 线程。每轮重读配置(开关/周期即时生效)。 + + 启动先 sleep 15s 避让 web_server 预连接线程(两者都抢 adb 全局锁)。 + 等待用 30s 切片(_stop_event.wait),关停/改周期 ≤30s 生效。 + """ + try: + _stop_event.wait(15) + while not _stop_event.is_set(): + try: + settings = get_settings() + if settings["enabled"]: + scan_once() + except Exception as e: + _log.warning(f"发现定时扫描异常: {e}") + wait_s = max(10, settings.get("interval", 60)) + waited = 0 + while waited < wait_s and not _stop_event.is_set(): + _stop_event.wait(min(30, wait_s - waited)) + waited += min(30, wait_s - waited) + except Exception: + pass + + +def init_app(app): + """web_server 启动时调用:绑 app + 起定时扫描 daemon 线程。""" + global _app + _app = app + _stop_event.clear() + t = threading.Thread(target=_discovery_loop, name="device-discovery", daemon=True) + t.start() + _log.info("设备自动发现线程已启动(默认 60s 扫描一次)") + + +def shutdown(): + """优雅退出:置 stop_event,等待中的循环在切片边界退出。""" + _stop_event.set() + + +# ================== 对外查询与确认 ================== +def get_status(): + """API 状态快照:配置 + 扫描状态 + 最近一轮结果 + 待连接数量。""" + settings = get_settings() + with _ctx(): + pending = PendingDevice.query.count() + return { + "enabled": settings["enabled"], + "subnets": settings["subnets"], + "interval": settings["interval"], + "port": settings["port"], + "scanning": _scanning, + "last_scan": _last_scan[0] if _last_scan else "", + "last_result": ({"found": _last_scan[1], "verified": _last_scan[2], + "new": _last_scan[3]} if _last_scan else None), + "last_error": _last_error, + "pending_count": pending, + } + + +def list_pending(): + """待连接列表(含当前在线状态:本机 adb 一次解析)。""" + online = _parse_adb_devices(_adb("devices")) + with _ctx(): + rows = [p.to_dict() for p in PendingDevice.query.order_by( + PendingDevice.first_seen.desc()).all()] + for r in rows: + r["online"] = r["serial"] in online + return rows + + +def confirm_pending(serial, name=""): + """确认连接:pending 行 → 正式设备池(add_device upsert)→ 删 pending。 + + 返回 (ok, msg, is_new)。adb_connect + 采型号由 API 层后台线程做。 + """ + with _ctx(): + row = PendingDevice.query.get(serial) + if not row: + return False, "设备不在待连接列表", False + from core import device_pool + is_new = device_pool.add_device(serial, name=name or "") + PendingDevice.query.filter_by(serial=serial).delete() + db.session.commit() + return True, "已加入设备池" + ("" if is_new else "(已存在,信息已更新)"), is_new + + +def ignore_pending(serial): + """忽略:删除 pending 行(下轮扫描可能再次发现)。""" + with _ctx(): + row = PendingDevice.query.get(serial) + if not row: + return False, "设备不在待连接列表" + PendingDevice.query.filter_by(serial=serial).delete() + db.session.commit() + return True, "已忽略" diff --git a/core/models.py b/core/models.py index d0d5cce..d880a7d 100644 --- a/core/models.py +++ b/core/models.py @@ -238,6 +238,21 @@ class Device(db.Model): return f"" +class PendingDevice(db.Model): + """自动发现待连接池:扫描验证通过的设备,等待用户确认后才入正式池。 + + 与 Device 表的区别:只代表"被扫描到、可连接",不参与任务调度。 + """ + serial = db.Column(db.String(120), primary_key=True) # 如 192.168.20.5:5555 + source = db.Column(db.String(20), default="") # lan / tailscale + first_seen = db.Column(db.String(20), default="") # 首次发现时间 + last_seen = db.Column(db.String(20), default="") # 最近一次扫描仍可见的时间 + + def to_dict(self): + return {"serial": self.serial, "source": self.source or "", + "first_seen": self.first_seen or "", "last_seen": self.last_seen or ""} + + # 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL) # 版本号单调递增,只执行比当前 schema_version 新的迁移。 SCHEMA_MIGRATIONS = [ @@ -252,6 +267,12 @@ SCHEMA_MIGRATIONS = [ "created_at VARCHAR(20) DEFAULT '')"), (3, "设备池:device 表新增 model 列(型号,在线时自动采集)", "ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"), + (4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)", + "CREATE TABLE IF NOT EXISTS pending_device (" + "serial VARCHAR(120) PRIMARY KEY," + "source VARCHAR(20) DEFAULT ''," + "first_seen VARCHAR(20) DEFAULT ''," + "last_seen VARCHAR(20) DEFAULT '')"), ] diff --git a/core/task_manager.py b/core/task_manager.py index 76072fa..7546465 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -337,7 +337,12 @@ class TaskManager: def __init__(self, app=None): self.app = app # Flask app,用于 db context self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai") - self.scheduler.start() + if os.environ.get("DISABLE_SCHEDULER"): + # 测试/回归模式:不启动 cron 调度器,避免 test_client 加载任务后 + # 按 cron 真的跑任务(干扰回归、占用设备)。add_job 仍可调用不报错。 + _log.info("DISABLE_SCHEDULER=1:跳过 cron 调度器(测试/回归模式)") + else: + self.scheduler.start() start_watchdog() # 启动心跳看门狗 self.groups = {} # name -> DeviceGroup(内存业务对象) self.jobs = {} # id -> TaskJob(内存业务对象) @@ -881,4 +886,7 @@ class TaskManager: self.stop_all() self._fg_scanner.stop() stop_watchdog() - self.scheduler.shutdown(wait=False) + try: + self.scheduler.shutdown(wait=False) + except Exception: + pass # DISABLE_SCHEDULER 模式下 scheduler 未 start,shutdown 会抛异常 diff --git a/scripts/regression_test.py b/scripts/regression_test.py index 555bc33..5b1ff19 100644 --- a/scripts/regression_test.py +++ b/scripts/regression_test.py @@ -21,6 +21,10 @@ import signal # 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# 测试模式:禁用 cron 调度器(否则 test_client 会按 DB 里的任务 cron 真跑任务, +# 干扰回归/占用设备——实测 ocr关键字任务每 30 分钟触发导致回归超时) +os.environ["DISABLE_SCHEDULER"] = "1" + # 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间 signal.alarm(300) diff --git a/static/admin/base.js b/static/admin/base.js index 44bd0b1..42a9c9e 100644 --- a/static/admin/base.js +++ b/static/admin/base.js @@ -94,6 +94,7 @@ function showTab(name){ // 停止所有定时器 if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;} if(_logTimer){clearInterval(_logTimer);_logTimer=null;} + if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;} // 按需加载数据 if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();} @@ -106,13 +107,21 @@ function showTab(name){ // ================== 页内子分栏(任务/工具 通用) ================== // 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位 let _activeSubs = {tasks: 'plan', tools: 'clipboard'}; +let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时) function showSubTab(tabId, name){ _activeSubs[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==='devpool' && typeof loadDevPool==='function') loadDevPool(); + if(name==='devpool' && typeof loadDevPool==='function'){ + loadDevPool(); + if(typeof loadDiscovery==='function'){ + loadDiscovery(); + if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;} + _discoveryTimer=setInterval(loadDiscovery,10000); // 10s 轮询:扫完自动出新结果 + } + } else if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;} } // ================== 模态框 ================== diff --git a/static/admin/tools.js b/static/admin/tools.js index f262ee1..4aeb5b8 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -39,6 +39,87 @@ async function loadToolsDevices(force){ status.textContent = '共 '+_clipDevices.length+' 台设备'; } +// ================== 设备自动发现(扫描 → 待连接池 → 确认) ================== +function loadDiscovery(){ + apiGet('/api/devices/discovery').then(r=>{ + if(!r||!r.ok)return; + const status=document.getElementById('discovery-status'); + const enabled=document.getElementById('discovery-enabled'); + const interval=document.getElementById('discovery-interval'); + const subnets=document.getElementById('discovery-subnets'); + if(!enabled)return; // 面板未渲染(离开子分栏) + enabled.checked=!!r.enabled; + interval.value=r.interval||60; + subnets.value=(r.subnets||[]).join(', '); + // 状态行 + if(r.scanning){status.textContent='扫描中...';} + else if(r.last_scan){ + let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 ' + +(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0); + if(r.last_error)txt+=' · ⚠ '+esc(r.last_error); + status.textContent=txt; + }else{status.textContent='尚未扫描';} + // 待连接表格 + const rows=(r.pending||[]); + const tb=document.getElementById('tb-discovery'); + if(!tb)return; + if(!rows.length){ + tb.innerHTML='待连接池为空(扫描到的设备会出现在这里)'; + }else{ + tb.innerHTML=rows.map(x=>{ + const src=x.source==='tailscale' + ?'Tailscale' + :'局域网'; + const onl=x.online + ?'在线' + :'离线'; + return ''+esc(x.serial)+'' + +''+src+''+esc(x.first_seen)+''+esc(x.last_seen)+''+onl+'' + +' ' + +''; + }).join(''); + } + }); +} +function toggleDiscovery(checked){ + apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{ + if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success'); + else showToast('设置失败: '+((r&&r.error)||''),'error'); + }); +} +function saveDiscoverySettings(){ + const subnets=(document.getElementById('discovery-subnets').value||'') + .split(',').map(s=>s.trim()).filter(Boolean); + const interval=parseInt(document.getElementById('discovery-interval').value)||60; + apiPost('/api/devices/discovery/settings',{subnets,interval}).then(r=>{ + if(r&&r.ok)showToast('设置已保存','success'); + else showToast('保存失败: '+((r&&r.error)||''),'error'); + }); +} +function runDiscoveryScan(){ + apiPost('/api/devices/discovery/scan',{}).then(r=>{ + if(r&&r.ok){showToast(r.msg||'扫描已启动','success');setTimeout(loadDiscovery,3000);} + else showToast('扫描失败: '+((r&&r.error)||''),'error'); + }); +} +function confirmDiscoveryDev(serial){ + const name=prompt('备注名(可选,留空直接添加)',''); + if(name===null)return; + apiPost('/api/devices/discovery/confirm',{serial,name}).then(r=>{ + if(r&&r.ok){ + showToast('已加入设备池'+(r.is_new?'':'(已存在,已更新)'),'success'); + loadDevPool();loadDiscovery(); + }else showToast('确认失败: '+((r&&r.error)||''),'error'); + }); +} +function ignoreDiscoveryDev(serial){ + if(!confirm('忽略 '+serial+'?下轮扫描可能再次发现'))return; + apiPost('/api/devices/discovery/ignore',{serial}).then(r=>{ + if(r&&r.ok){showToast('已忽略','success');loadDiscovery();} + else showToast('操作失败: '+((r&&r.error)||''),'error'); + }); +} + function toggleClipDevice(cb){ if(cb.checked)_clipSelected.add(cb.value); else _clipSelected.delete(cb.value); diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 5b7e4e8..db383fc 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -649,6 +649,28 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
+ +
+
+ + 周期 + + 秒 · 网段 + + + + +
+ + + +
serial来源首次发现最近发现在线操作
尚未扫描
+
扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入待连接池; + 确认后才加入正式设备池(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。
+
diff --git a/web/devices_api.py b/web/devices_api.py index 3f24a30..be65aa0 100644 --- a/web/devices_api.py +++ b/web/devices_api.py @@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Blueprint, jsonify, request -from core import device_pool +from core import device_pool, device_discovery from core.adb_helper import adb_connect from core.logger import get_logger from web.auth import perm_required, PERM_DEVICES @@ -113,6 +113,90 @@ def api_devices_pool_refresh_models(): return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"}) +# ================== API:设备自动发现(扫描 → 待连接池 → 确认) ================== +# 扫描只把设备放进待连接池(pending),用户确认后才加入正式设备池并连接。 +@bp.route("/api/devices/discovery", methods=["GET"]) +@perm_required(PERM_DEVICES) +def api_devices_discovery(): + """发现状态 + 待连接列表(前端 10s 轮询一次请求)。""" + try: + status = device_discovery.get_status() + pending = device_discovery.list_pending() + except Exception as e: + _log.warning(f"discovery 状态获取失败: {e}") + return jsonify({"ok": False, "error": str(e)[:120]}), 503 + status["pending"] = pending + return jsonify({"ok": True, **status}) + + +@bp.route("/api/devices/discovery/scan", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_discovery_scan(): + """手动触发一轮扫描(后台执行,约 5-30 秒)。""" + ok, result = device_discovery.scan_once(manual=True) + if not ok: + return jsonify({"ok": False, "error": result.get("error", "扫描失败")}), 409 + if "error" in result: + return jsonify({"ok": False, "error": result["error"]}), 400 + return jsonify({"ok": True, "msg": "扫描已启动(后台执行,约 5-30 秒)", + "result": result}) + + +@bp.route("/api/devices/discovery/confirm", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_discovery_confirm(): + """确认连接:待连接 → 正式设备池 + 后台 adb connect + 采型号。""" + data = request.json or {} + serial = (data.get("serial") or "").strip() + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + ok, msg, is_new = device_discovery.confirm_pending( + serial, name=(data.get("name") or "").strip()) + if not ok: + return jsonify({"ok": False, "error": msg}), 404 + if ":" in serial: + def _connect(): + try: + adb_connect(serial) + except Exception: + pass + try: + device_pool.refresh_model(serial) + except Exception: + pass + threading.Thread(target=_connect, daemon=True).start() + _log.info(f"设备池管理: 发现确认 {serial}") + return jsonify({"ok": True, "msg": msg, "is_new": is_new}) + + +@bp.route("/api/devices/discovery/ignore", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_discovery_ignore(): + """忽略:从待连接池删除(下轮扫描可能再次发现)。""" + serial = (request.json or {}).get("serial", "").strip() + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + ok, msg = device_discovery.ignore_pending(serial) + if not ok: + return jsonify({"ok": False, "error": msg}), 404 + return jsonify({"ok": True, "msg": msg}) + + +@bp.route("/api/devices/discovery/settings", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_discovery_settings(): + """保存发现配置(部分字段更新):{enabled?, subnets?, interval?, port?}。""" + data = request.json or {} + ok, msg = device_discovery.save_settings( + enabled=data.get("enabled"), + subnets=data.get("subnets"), + interval=data.get("interval"), + port=data.get("port")) + if not ok: + return jsonify({"ok": False, "error": msg}), 400 + return jsonify({"ok": True, "msg": msg}) + + # ================== API:Tailscale 管理(仅管理员) ================== # 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。 # 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。 diff --git a/web_server.py b/web_server.py index 62fedc0..ff88caa 100644 --- a/web_server.py +++ b/web_server.py @@ -45,6 +45,9 @@ init_db(app) # 设备池(本地清单 + adb 在线状态):任务调度的数据源 from core import device_pool device_pool.init_app(app) +# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池) +from core import device_discovery +device_discovery.init_app(app) mgr = TaskManager(app=app) apk_mgr = ApkManager(app=app) @@ -220,4 +223,5 @@ if __name__ == "__main__": _run_server(WEB_HOST, WEB_PORT) finally: mgr.shutdown() + device_discovery.shutdown() _stop_uiauto()