Files
auto_control/web_server.py
T

228 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备自动化管理后台入口:Flask app 装配 + 启动。
路由按功能域拆分到 web/ 蓝图包(auth/monitor/tasks/admin/tools/devices/apks/tailscale),
本文件只负责:app 创建、数据库初始化、共享对象装配、蓝图注册、uiautodev 生命周期、启动。
"""
import os
import time
import atexit
import secrets
import socket
import subprocess
import sys
import threading
from flask import Flask
from flask_login import LoginManager
from core.task_manager import TaskManager
from core.apk_manager import ApkManager
from core.logger import get_logger
from core.models import db, init_db, User
from core import uiauto_helper
from config import WEB_HOST, WEB_PORT
_log = get_logger("web")
app = Flask(__name__)
# 会话密钥:优先 .env 的 WEB_SECRET_KEY;未配置则随机生成(重启后登录态失效,生产务必配置固定值)
_web_secret = os.environ.get("WEB_SECRET_KEY") or secrets.token_hex(32)
if not os.environ.get("WEB_SECRET_KEY"):
_log.warning("未配置 WEB_SECRET_KEY,已随机生成会话密钥(重启后登录态失效)")
app.config["SECRET_KEY"] = _web_secret
# debug=False 时模板默认不自动重载,开发期改 HTML 需重启才生效;这里显式开启
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
login_manager = LoginManager(app)
login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
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)
# Web 层按模块拆分(web/ 蓝图包):认证/监控/任务/管理/工具/设备池/APK/Tailscale
from web import context, register_blueprints
from web.auth import _csrf_protect, _csrf_token
context.init(mgr, apk_mgr, device_pool)
register_blueprints(app)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# ================== API:健康检查(无需登录,供探活) ==================
_UIAUTO_PID_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "uiauto.pid")
_uiauto_proc = None
def _kill_stale_uiauto():
"""清理上次 web_server 残留的 uiautodev 进程(读取 PID 文件)。"""
if not os.path.exists(_UIAUTO_PID_FILE):
return
try:
with open(_UIAUTO_PID_FILE) as f:
pid = int(f.read().strip())
if pid > 0 and pid != os.getpid():
try:
os.kill(pid, 0) # 探测进程是否存活
os.kill(pid, 15) # 终止残留
_log.warning(f"已清理残留的 uiautodev 进程 (PID={pid})")
except ProcessLookupError:
pass
except PermissionError:
_log.warning(f"无权限清理残留 uiautodev (PID={pid}),忽略")
except Exception:
pass
finally:
try:
os.remove(_UIAUTO_PID_FILE)
except OSError:
pass
def _ensure_uiauto_running():
"""确保 uiautodev 本地服务在运行(元素抓取功能依赖,端口 20242)。
已在运行则跳过;未运行则用子进程启动 `uiautodev server --no-browser`。
启动失败只记日志,不影响主服务。
"""
global _uiauto_proc
_kill_stale_uiauto()
if uiauto_helper.is_running():
_log.info("uiauto2 服务已在运行 (端口 20242)")
return
try:
_log.info("正在自动启动 uiautodev 服务 (端口 20242)...")
kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
_uiauto_proc = subprocess.Popen(
[sys.executable, "-m", "uiautodev", "server", "--no-browser"],
**kwargs,
)
_log.info(f"uiautodev 服务已启动 (PID={_uiauto_proc.pid})")
try:
with open(_UIAUTO_PID_FILE, "w") as f:
f.write(str(_uiauto_proc.pid))
except OSError:
pass
atexit.register(_stop_uiauto)
except FileNotFoundError:
_log.warning("uiautodev 未安装,元素抓取功能不可用。请运行: pip install uiautodev")
except Exception as e:
_log.warning(f"自动启动 uiautodev 失败: {e}(元素抓取功能不可用)")
def _stop_uiauto():
"""停止自动启动的 uiautodev 子进程。"""
global _uiauto_proc
try:
if os.path.exists(_UIAUTO_PID_FILE):
os.remove(_UIAUTO_PID_FILE)
except OSError:
pass
if not _uiauto_proc:
return
try:
_uiauto_proc.terminate()
_uiauto_proc.wait(timeout=5)
_log.info("uiautodev 服务已停止")
except Exception:
try:
_uiauto_proc.kill()
except Exception:
pass
_uiauto_proc = None
def _preconnect_pool_devices():
"""容器/服务重启后 adb server 重启,设备全部掉线,逐个重连要几分钟;
启动时后台并发 connect 设备池里的网络设备(IP:5555),把恢复时间降到秒级。
USB 设备无需 connect(adb server 自动重枚举),只连网络设备。
预连接幂等:已连接的设备返回 already connected,无副作用。
"""
def _run():
try:
from core import device_pool
from core.adb_helper import _adb
serials = [s for s in device_pool.list_configured() if ":" in s]
if not serials:
return
_log.info(f"预连接设备池 {len(serials)} 台网络设备(重启后加速恢复)...")
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
list(ex.map(lambda s: _adb("connect", s), serials))
out = _adb("devices")
online = sum(1 for ln in (out or "").splitlines()[1:]
if len(ln.split()) >= 2 and ln.split()[1] == "device")
_log.info(f"设备池预连接完成: {online} 台在线")
except Exception as e:
_log.warning(f"设备池预连接失败: {e}")
threading.Thread(target=_run, daemon=True).start()
def _run_server(host, port):
"""启动 Flask,自动处理端口冲突和权限问题。
常见失败原因:
WinError 10013 — 端口在 Windows 动态端口范围内被出站连接占用,
或非管理员绑定 0.0.0.0。前者换端口,后者降级 127.0.0.1。
WinError 10048 — 端口已被其他进程监听。换端口重试。
策略:原端口失败 → 试 127.0.0.1:原端口 → 试 127.0.0.1:原端口+1..+5
"""
import socket
# 先探测原端口是否可用(避免 flask 报错打日志难看)
candidates = [(host, port)]
if host == "0.0.0.0":
candidates.append(("127.0.0.1", port))
for i in range(1, 6):
candidates.append(("127.0.0.1", port + i))
for h, p in candidates:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((h, p))
# 探测成功,启动 flask
if (h, p) != (host, port):
_log.warning(f"原端口 {host}:{port} 不可用,改用 {h}:{p}")
_log.info(f"启动服务: http://localhost:{p}/")
app.run(host=h, port=p, debug=False, threaded=True)
return
except OSError as e:
_log.warning(f"绑定 {h}:{p} 失败: {e}")
# 全部失败
raise OSError(f"无法绑定任何候选端口({host}:{port} 及 127.0.0.1:{port}~{port+5})")
if __name__ == "__main__":
from config import WEB_HOST, WEB_PORT
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
_ensure_uiauto_running()
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
_preconnect_pool_devices()
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
try:
_run_server(WEB_HOST, WEB_PORT)
finally:
mgr.shutdown()
device_discovery.shutdown()
_stop_uiauto()