"""设备自动发现:扫描网段中开放 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 import notifier from core.models import db, Device, PendingDevice _log = get_logger("core.disc") # 扫描配置键(app_meta) _K_ENABLED = "discovery_enabled" _K_SUBNETS = "discovery_subnets" _K_INTERVAL = "discovery_interval" _K_PORT = "discovery_port" # 指纹匹配时是否自动认领(默认关:认领会改写分组/任务引用,默认交人工确认) _K_AUTO_CLAIM = "discovery_auto_claim" # 单网段主机数上限(防误配 /8 之类),超过截断并告警 _MAX_HOSTS_PER_SUBNET = 1024 # socket 探测并发数与单 IP 超时 _PROBE_WORKERS = 64 _PROBE_TIMEOUT = 0.4 _app = None _scan_lock = threading.Lock() # 定时/手动扫描互斥 # 上一轮"池内离线"集合:通知只报**状态沿**(这一轮新变成离线的),不每轮刷屏 _last_offline = set() _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(): from core.db_config import meta_get def _get(key, default): v = meta_get(key) 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), "auto_claim": _get(_K_AUTO_CLAIM, "0") == "1", # 默认关 } def save_settings(enabled=None, subnets=None, interval=None, port=None, auto_claim=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(): from core.db_config import meta_set _put = meta_set # 方言中立 upsert(app_meta.key 在 MySQL 里是保留字) 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))) if auto_claim is not None: _put(_K_AUTO_CLAIM, "1" if auto_claim else "0") db.session.commit() return True, "已保存" # ================== 网段展开与端口探测 ================== def _local_ips(): """本机自身 IP 集合(尽力而为):扫描时排除,避免探测到自己的 5555。 优先 `hostname -I`(Linux/macOS 支持,一行空格分隔多 IP);不支持/失败的 平台退回 socket.getaddrinfo 枚举。关键:外部命令必须用 bytes 收——Windows 上 git-bash 的 coreutils hostname 不支持 -I,会把 GBK 报错写进 stderr, text=True 在 subprocess 后台读线程里 utf-8 严格解码会直接炸线程(主线程 try/except 接不住异步线程异常)。 """ import subprocess ips = set() try: r = subprocess.run(["hostname", "-I"], capture_output=True, timeout=3) if r.returncode == 0: ips.update(p for p in r.stdout.decode("utf-8", errors="ignore").split() if p) except Exception: pass if not ips: # 兜底:主机名解析出的接口 IPv4 try: for info in socket.getaddrinfo(socket.gethostname(), None): ip = info[4][0] if ":" not in ip: ips.add(ip) except Exception: pass return ips def _expand_subnets(subnets, max_hosts=_MAX_HOSTS_PER_SUBNET): """CIDR 列表 → IP 列表。排除 220 自身(USB_ADB_HOST)与本机 IP; 非法网段跳过记日志;单网段超过 max_hosts 截断并告警。""" self_ips = {USB_ADB_HOST} | _local_ips() 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 _auto_claim(fps): """指纹命中池中已有设备 → 自动认领:迁移记录到新地址并同步分组/任务引用。 仅在设置 discovery_auto_claim 打开时由扫描线程调用(默认关)。 返回 [(old_serial, new_serial, name), ...]。 """ from core import device_pool done = [] for serial, fp in (fps or {}).items(): if not fp: continue try: info = device_pool.find_by_fingerprint(fp) if not info or info.get("serial") == serial: continue # 没匹配到,或本来就是这条(无需迁移) old, name = device_pool.claim_device(serial, fp) if not old: continue PendingDevice.query.filter_by(serial=serial).delete() db.session.commit() done.append((old, serial, name or info.get("name") or "")) _log.info(f"自动认领: 『{name or info.get('name') or old}』{old} → {serial}") except Exception as e: db.session.rollback() _log.warning(f"自动认领 {serial} 失败: {e}") return done # ================== 扫描 ================== 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(顺带刷新指纹) # 指纹用于认出"这台其实是设备池里某台设备换了 IP",见 list_pending 的 match now = _fmt() existing = {p.serial for p in PendingDevice.query.all()} added = 0 added_serials = [] from core import device_pool fps = {} for serial in verified: source = "tailscale" if serial.split(":")[0].startswith("100.") else "lan" try: fp = device_pool.read_fingerprint(serial, timeout=4) except Exception: fp = "" fps[serial] = fp if serial in existing: PendingDevice.query.filter_by(serial=serial).update( {"last_seen": now, "fingerprint": fp}) else: db.session.add(PendingDevice(serial=serial, source=source, first_seen=now, last_seen=now, fingerprint=fp)) added += 1 added_serials.append(serial) db.session.commit() # 5.5 自动认领(可选,默认关):指纹命中池中已有设备 → 直接把记录迁到新地址。 # 默认关是因为认领会改写分组/任务引用(数据结构变动),交人工点一下更稳妥; # 打开后零点击完成,见 doc/API.md §6。 claimed = _auto_claim(fps) if settings.get("auto_claim") else [] # 6. 正式池断联设备自动重连:adb connect 会因 WiFi 波动/设备重启/ # adb 服务重启而断开——扫描线程每轮顺带重试(幂等轻量,内部 # 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给 # 「未授权新设备」的,正式池设备断联不进 pending,而是自动重连。 back = _reconnect_offline(configured) # 7. 离线集合(供"状态沿"通知用:只报**这一轮新变成离线**的,不每轮刷屏) offline_now = {s for s in configured if not device_pool.is_online(s)} _log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台," f"新增待连接 {added} 台" + (f",自动重连恢复 {len(back)} 台 {back}" if back else "") + (f",自动认领 {len(claimed)} 台 {[c[0] + '→' + c[1] for c in claimed]}" if claimed else "")) # 通知统一放在 `with _ctx():` 之后(别在 DB 事务期间做额外的事) global _last_offline try: newly_offline = sorted(offline_now - _last_offline) _last_offline = set(offline_now) if newly_offline: notifier.notify("device.offline", serials=newly_offline, count=len(newly_offline), devices=newly_offline[:10]) if back: notifier.notify("device.online", serials=list(back), count=len(back)) if added_serials: notifier.notify("device.discovered", serials=added_serials[:10], count=len(added_serials)) if claimed: notifier.notify("device.claimed", pairs=[f"{c[0]}→{c[1]}" for c in claimed][:10], count=len(claimed)) except Exception as e: _log.warning(f"发送设备上下线通知失败(不影响扫描): {e}") _last_scan = (now, len(open_ips), len(verified), added) _last_error = "" return True, {"found": len(open_ips), "verified": len(verified), "new": added, "claimed": len(claimed)} 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 _reconnect_offline(configured): """对正式池中断联的网络设备逐个 adb 重连,返回恢复的 serial 列表。 只重连网络设备(IP:5555;USB 设备插着就在,无需 connect)。 幂等轻量:内部 adb 全局锁串行,失败静默(下轮扫描再试)。 """ online_now = _parse_adb_devices(_adb("devices")) targets = [s for s in (configured or []) if ":" in s and s not in online_now] if not targets: return [] for serial in targets: adb_connect_light(serial) online_after = _parse_adb_devices(_adb("devices")) return [s for s in targets if s in online_after] def list_pool_offline(): """正式设备池中断联的设备(serial + 型号 + 备注名),面板展示用。 断联设备仍是正式池成员(不删除、不进 pending)——pending 是给未授权 新设备的;它们由扫描线程每轮自动重连,也可前端手动立即重连。 """ online = _parse_adb_devices(_adb("devices")) with _ctx(): rows = [d.to_dict() for d in Device.query.filter_by(enabled=True) .order_by(Device.serial).all()] return [{"serial": r["serial"], "model": r.get("model") or "", "name": r.get("name") or ""} for r in rows if r["serial"] not in online] # ================== 定时扫描线程 ================== 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"], "auto_claim": settings["auto_claim"], "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(): """待连接列表:只返回当前在线的设备(离线候选不可确认,不展示)。 后端直接过滤(2026-09-04):不依赖前端 JS 版本,任何客户端都拿不到 离线条目;设备恢复在线后扫描自动更新 last_seen 并重新出现在列表。 """ online = _parse_adb_devices(_adb("devices")) with _ctx(): rows = [p.to_dict() for p in PendingDevice.query.order_by( PendingDevice.first_seen.desc()).all()] out = [] for r in rows: if r["serial"] not in online: continue r["online"] = True # 指纹匹配:这台其实就是设备池里某台设备换了地址(前端据此提示"认领") r["match"] = None if r.get("fingerprint"): try: from core import device_pool m = device_pool.find_by_fingerprint(r["fingerprint"]) if m and m["serial"] != r["serial"]: r["match"] = {"serial": m["serial"], "name": m.get("name") or ""} except Exception: pass out.append(r) return out def confirm_pending(serial, name="", fingerprint=""): """确认连接:pending 行 → 正式设备池(add_device upsert)→ 删 pending。 先按指纹尝试**认领**:同一台物理设备换了地址时,把池中旧记录迁到新 serial, 并同步分组/任务里的引用(名称等信息全部保留),而不是新增一条。 返回 (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 fp = (fingerprint or row.fingerprint or "").strip() claimed_old, claimed_name = device_pool.claim_device(serial, fp) if claimed_old: device_pool.add_device(serial, name=claimed_name or name, fingerprint=fp) PendingDevice.query.filter_by(serial=serial).delete() db.session.commit() return True, (f"已认领为『{claimed_name or claimed_old}』" f"(原地址 {claimed_old},分组/任务的引用已同步)"), False is_new = device_pool.add_device(serial, name=name or "", fingerprint=fp) 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, "已忽略"