feat: 设备自动发现——定时扫描局域网+Tailscale 网段开放 adb 5555 的设备进入待连接池,用户确认后才加入正式设备池(不自动连接);socket 并发探测+adb 短超时验证(state=device 过滤 unauthorized),配置存 app_meta 可前端调整;配套:adb_connect_light 验证短超时、DISABLE_SCHEDULER 回归开关
This commit is contained in:
@@ -82,3 +82,10 @@ USB_ADB_PORT = int(_env("USB_ADB_PORT", "5037"))
|
|||||||
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
|
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
|
||||||
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
|
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
|
||||||
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "")
|
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"))
|
||||||
|
|||||||
+9
-6
@@ -19,17 +19,18 @@ _ADB_LOCK = threading.Lock()
|
|||||||
_ADB_TIMEOUT = 30
|
_ADB_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
def _adb(*args):
|
def _adb(*args, timeout=_ADB_TIMEOUT):
|
||||||
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
|
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
|
||||||
|
|
||||||
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
|
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
|
||||||
遇到非 GBK 字节会崩溃)。
|
遇到非 GBK 字节会崩溃)。
|
||||||
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
|
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
|
||||||
|
timeout 可覆盖(扫描验证等非关键路径用短超时快速失败)。
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
except subprocess.TimeoutExpired:
|
||||||
_log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
|
_log.warning(f"adb {' '.join(args)} 超时 {timeout}s")
|
||||||
return ""
|
return ""
|
||||||
return _decode(r)
|
return _decode(r)
|
||||||
|
|
||||||
@@ -157,16 +158,18 @@ def _try_connect(remote_adb_url, retries):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def adb_connect_light(remote_adb_url):
|
def adb_connect_light(remote_adb_url, timeout=5):
|
||||||
"""轻量 adb connect(扫描专用):单次尝试,不重试,绝不 kill-server。
|
"""轻量 adb connect(扫描/验证专用):单次尝试,不重试,绝不 kill-server。
|
||||||
|
|
||||||
与 adb_connect 的区别:
|
与 adb_connect 的区别:
|
||||||
- 不重试:失败立即返回 False
|
- 不重试:失败立即返回 False
|
||||||
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
|
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
|
||||||
|
- 短超时(默认 5s):验证场景(设备发现)连不上的主机快速放弃,
|
||||||
|
避免每台卡 30s 拖慢整轮发现并长时间占用全局 adb 锁
|
||||||
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
|
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
|
||||||
"""
|
"""
|
||||||
with _ADB_LOCK:
|
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():
|
if "connected" in out.lower() and "failed" not in out.lower():
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -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, "已忽略"
|
||||||
@@ -238,6 +238,21 @@ class Device(db.Model):
|
|||||||
return f"<Device {self.serial}>"
|
return f"<Device {self.serial}>"
|
||||||
|
|
||||||
|
|
||||||
|
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 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
||||||
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
||||||
SCHEMA_MIGRATIONS = [
|
SCHEMA_MIGRATIONS = [
|
||||||
@@ -252,6 +267,12 @@ SCHEMA_MIGRATIONS = [
|
|||||||
"created_at VARCHAR(20) DEFAULT '')"),
|
"created_at VARCHAR(20) DEFAULT '')"),
|
||||||
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
|
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
|
||||||
"ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"),
|
"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 '')"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,11 @@ class TaskManager:
|
|||||||
def __init__(self, app=None):
|
def __init__(self, app=None):
|
||||||
self.app = app # Flask app,用于 db context
|
self.app = app # Flask app,用于 db context
|
||||||
self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||||
|
if os.environ.get("DISABLE_SCHEDULER"):
|
||||||
|
# 测试/回归模式:不启动 cron 调度器,避免 test_client 加载任务后
|
||||||
|
# 按 cron 真的跑任务(干扰回归、占用设备)。add_job 仍可调用不报错。
|
||||||
|
_log.info("DISABLE_SCHEDULER=1:跳过 cron 调度器(测试/回归模式)")
|
||||||
|
else:
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
start_watchdog() # 启动心跳看门狗
|
start_watchdog() # 启动心跳看门狗
|
||||||
self.groups = {} # name -> DeviceGroup(内存业务对象)
|
self.groups = {} # name -> DeviceGroup(内存业务对象)
|
||||||
@@ -881,4 +886,7 @@ class TaskManager:
|
|||||||
self.stop_all()
|
self.stop_all()
|
||||||
self._fg_scanner.stop()
|
self._fg_scanner.stop()
|
||||||
stop_watchdog()
|
stop_watchdog()
|
||||||
|
try:
|
||||||
self.scheduler.shutdown(wait=False)
|
self.scheduler.shutdown(wait=False)
|
||||||
|
except Exception:
|
||||||
|
pass # DISABLE_SCHEDULER 模式下 scheduler 未 start,shutdown 会抛异常
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import signal
|
|||||||
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
|
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
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 相关接口可能慢(连接/查询设备),给足时间
|
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间
|
||||||
signal.alarm(300)
|
signal.alarm(300)
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -94,6 +94,7 @@ function showTab(name){
|
|||||||
// 停止所有定时器
|
// 停止所有定时器
|
||||||
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||||||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||||
|
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||||||
// 按需加载数据
|
// 按需加载数据
|
||||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||||||
@@ -106,13 +107,21 @@ function showTab(name){
|
|||||||
// ================== 页内子分栏(任务/工具 通用) ==================
|
// ================== 页内子分栏(任务/工具 通用) ==================
|
||||||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||||||
let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
|
let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
|
||||||
|
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
|
||||||
|
|
||||||
function showSubTab(tabId, name){
|
function showSubTab(tabId, name){
|
||||||
_activeSubs[tabId] = name;
|
_activeSubs[tabId] = name;
|
||||||
const tab = document.getElementById('tab-'+tabId);
|
const tab = document.getElementById('tab-'+tabId);
|
||||||
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
|
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));
|
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;}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 模态框 ==================
|
// ================== 模态框 ==================
|
||||||
|
|||||||
@@ -39,6 +39,87 @@ async function loadToolsDevices(force){
|
|||||||
status.textContent = '共 '+_clipDevices.length+' 台设备';
|
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='<tr><td colspan="6" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
|
||||||
|
}else{
|
||||||
|
tb.innerHTML=rows.map(x=>{
|
||||||
|
const src=x.source==='tailscale'
|
||||||
|
?'<span class="label label-info">Tailscale</span>'
|
||||||
|
:'<span class="label label-warning">局域网</span>';
|
||||||
|
const onl=x.online
|
||||||
|
?'<span class="label label-success">在线</span>'
|
||||||
|
:'<span class="label label-warning">离线</span>';
|
||||||
|
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
|
||||||
|
+'<td>'+src+'</td><td>'+esc(x.first_seen)+'</td><td>'+esc(x.last_seen)+'</td><td>'+onl+'</td>'
|
||||||
|
+'<td><button class="btn btn-primary btn-xs" onclick="confirmDiscoveryDev(''+esc(x.serial)+'')">确认添加</button> '
|
||||||
|
+'<button class="btn btn-xs" onclick="ignoreDiscoveryDev(''+esc(x.serial)+'')">忽略</button></td></tr>';
|
||||||
|
}).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){
|
function toggleClipDevice(cb){
|
||||||
if(cb.checked)_clipSelected.add(cb.value);
|
if(cb.checked)_clipSelected.add(cb.value);
|
||||||
else _clipSelected.delete(cb.value);
|
else _clipSelected.delete(cb.value);
|
||||||
|
|||||||
@@ -649,6 +649,28 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
|
|
||||||
<!-- 子分栏 6:设备池管理(本地 SQLite 清单,任务调度的数据源) -->
|
<!-- 子分栏 6:设备池管理(本地 SQLite 清单,任务调度的数据源) -->
|
||||||
<div class="sub-panel" id="tools-sub-devpool">
|
<div class="sub-panel" id="tools-sub-devpool">
|
||||||
|
<!-- 自动发现:扫描网段 → 待连接池,确认后才加入正式池 -->
|
||||||
|
<div style="border-bottom:1px solid var(--border);margin-bottom:10px;padding-bottom:10px">
|
||||||
|
<div class="toolbar" style="flex-wrap:wrap">
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:4px">
|
||||||
|
<input type="checkbox" id="discovery-enabled" onchange="toggleDiscovery(this.checked)"> 定时扫描
|
||||||
|
</label>
|
||||||
|
<span class="text-muted" style="margin-left:8px">周期</span>
|
||||||
|
<input type="number" id="discovery-interval" style="width:70px" min="10">
|
||||||
|
<span class="text-muted">秒 · 网段</span>
|
||||||
|
<input type="text" id="discovery-subnets" class="form-control" style="width:300px;font-family:monospace"
|
||||||
|
placeholder="192.168.20.0/24, 100.100.10.0/24" title="逗号分隔的 CIDR 网段">
|
||||||
|
<button class="btn" onclick="saveDiscoverySettings()">保存设置</button>
|
||||||
|
<button class="btn btn-primary" onclick="runDiscoveryScan()">立即扫描</button>
|
||||||
|
<span id="discovery-status" class="text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table table-hover table-sm" style="margin-top:8px">
|
||||||
|
<thead><tr><th>serial</th><th>来源</th><th>首次发现</th><th>最近发现</th><th>在线</th><th>操作</th></tr></thead>
|
||||||
|
<tbody id="tb-discovery"><tr><td colspan="6" class="empty">尚未扫描</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="help">扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入<b>待连接池</b>;
|
||||||
|
<b>确认后才加入正式设备池</b>(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。</div>
|
||||||
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
|
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
|
||||||
placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()">
|
placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()">
|
||||||
|
|||||||
+85
-1
@@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
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.adb_helper import adb_connect
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
from web.auth import perm_required, PERM_DEVICES
|
from web.auth import perm_required, PERM_DEVICES
|
||||||
@@ -113,6 +113,90 @@ def api_devices_pool_refresh_models():
|
|||||||
return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"})
|
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 管理(仅管理员) ==================
|
# ================== API:Tailscale 管理(仅管理员) ==================
|
||||||
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
|
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
|
||||||
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
|
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ init_db(app)
|
|||||||
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
|
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
|
||||||
from core import device_pool
|
from core import device_pool
|
||||||
device_pool.init_app(app)
|
device_pool.init_app(app)
|
||||||
|
# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池)
|
||||||
|
from core import device_discovery
|
||||||
|
device_discovery.init_app(app)
|
||||||
mgr = TaskManager(app=app)
|
mgr = TaskManager(app=app)
|
||||||
apk_mgr = ApkManager(app=app)
|
apk_mgr = ApkManager(app=app)
|
||||||
|
|
||||||
@@ -220,4 +223,5 @@ if __name__ == "__main__":
|
|||||||
_run_server(WEB_HOST, WEB_PORT)
|
_run_server(WEB_HOST, WEB_PORT)
|
||||||
finally:
|
finally:
|
||||||
mgr.shutdown()
|
mgr.shutdown()
|
||||||
|
device_discovery.shutdown()
|
||||||
_stop_uiauto()
|
_stop_uiauto()
|
||||||
|
|||||||
Reference in New Issue
Block a user