diff --git a/README.md b/README.md index ebde1a0..957c544 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ |------|------| | **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) | | **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 | +| **电量监控** | 后台每分钟 `dumpsys battery`(只读)采一次电量,**大屏卡片**与**监控页设备列表**都显示(按档位着色 + ⚡ 充电中);低于阈值推 webhook 告警(**充电中不报、"插着却没充电"照报**,掉档/恢复各报一次,不刷屏)。电量只放内存不落库(见 [NOTIFY.md](doc/NOTIFY.md) §3.1) | | **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) | | **步骤编辑器** | 可视化拖拽编排 21 种步骤(含循环/条件/OCR/通知/录制回放),可打包成"自定义动作"复用 | | **拟人化操作** | 滑动默认走弧线轨迹、位置/幅度/时长每次抖动,且**每台设备有自己的手速与习惯**(同设备风格稳定、设备之间明显不同)——批量跑时不像同步机器人(见 [TASK_DEV.md](doc/TASK_DEV.md) §8.4) | @@ -136,6 +137,7 @@ auto_control/ │ ├── task_manager.py # 调度器:分组/任务/APScheduler/重试/运行控制 │ ├── adb_helper.py # adb 命令封装(全局锁,红线:绝不 kill-server) │ ├── device_discovery.py # 网段扫描发现设备 → 待连接池 +│ ├── device_battery.py # 设备电量采集(dumpsys 只读)+ 低电量告警 │ ├── u2_helper.py # uiautomator2 通用辅助(等首页/安全点击等) │ ├── uiauto_helper.py # uiautodev 客户端(元素树 + XPath 建议) │ ├── ocr.py # 屏幕 OCR(RapidOCR,条件判断用) diff --git a/core/device_battery.py b/core/device_battery.py new file mode 100644 index 0000000..19a6096 --- /dev/null +++ b/core/device_battery.py @@ -0,0 +1,404 @@ +"""设备电量采集 + 低电量告警(大屏/监控展示 + webhook 通知)。 + +设计要点(都是被现有代码/实测约束逼出来的,改之前先读一遍): + + - **只读**:`adb -s shell dumpsys battery`(实测 0.2~0.35s/台)。 + 全模块不 connect / 不 disconnect / 不 kill-server(技术红线,见 doc/DEVELOPMENT.md)。 + 能查到的前提是设备已在 adb server 里(= device_pool.list_online()), + 所以连"空闲设备"也能查——**不需要 connect**,这是本模块敢每轮扫全量的原因。 + - **后台独立线程**采集(照 core/device_discovery.py 的 daemon 循环):`/api/status` + 只读内存缓存。绝不能把 dumpsys 放进 /api/status——前端 5 秒轮询一次, + 14 台设备同步查会把 Flask 拖死(`get_status` 的 5s 缓存注释也写了这条)。 + - **不落库**:电量是易变值,重启重新采一轮即可 → 不建表、**不动备份覆盖清单**。 + - 告警走 notifier(`device.battery.low` / `device.battery.recovered`), + **按档位做状态沿检测**(正常/低/严重),掉档才报,绝不满屏刷。 + - 配置存 `app_meta.device_battery` 一个键(照 core/step_defaults.py 的做法: + 出厂默认 + 字段白名单 + 只落"与出厂值不同"的字段)。 +""" +import json +import re +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +from config import USB_ADB_HOST, USB_ADB_PORT +from core import notifier, device_pool +from core.adb_helper import _adb, _adb_remote +from core.logger import get_logger +from core.models import db + +_log = get_logger("core.battery") + +# 出厂默认(前端「工具 → 设备发现 → 电量监控」的初始内容,也是没配过时的回退) +FACTORY = { + "enabled": True, + "low": 20, # 低电量阈值(%) + "critical": 10, # 严重低电量阈值(%)——通知等级升到 error + "skip_charging": True, # 充电中不告警(插着充电器说明正在补电) + "interval": 60, # 采集间隔(秒) +} + +# 字段规格:("num", 最小, 最大) / ("bool",)。白名单之外的一律丢弃。 +SPEC = { + "enabled": ("bool",), + "low": ("num", 1, 100), + "critical": ("num", 1, 100), + "skip_charging": ("bool",), + "interval": ("num", 30, 3600), +} + +META_KEY = "device_battery" + +# 恢复迟滞:掉到阈值下之后,要回升到 low+HYSTERESIS 才算"恢复"。 +# 没有它的话,电量在阈值上下浮动(充电器接触不良)会反复告警。 +HYSTERESIS = 5 +# 持续低电量的重复提醒间隔(秒)。0 = 只提醒一次(等恢复后才重新武装)。 +RE_NOTIFY_S = 6 * 3600 +# 单台设备读取超时(秒)。正常 0.2~0.35s,8s 已经非常宽松。 +_READ_TIMEOUT = 8 + +_app = None +_cache = {} # serial -> {"level": int, "charging": bool, "at": ts} +_tiers = {} # serial -> {"tier": int, "at": ts} 告警档位状态(0 正常/1 低/2 严重) +_lock = threading.Lock() +_stop_event = threading.Event() +_scanning = False +_last_scan = None # (时间串, 采集成功数, 在线数) + + +def _ctx(): + """后台线程访问 db 时自行推 app context(仿 device_pool._ctx)。""" + return _app.app_context() if _app else None + + +# ================== 配置读写 ================== +def factory(): + """出厂默认的拷贝(别让调用方改到常量)。""" + return dict(FACTORY) + + +def _clean(raw): + """按 SPEC 校验清洗;非法字段**丢弃**(不让脏值进库)。""" + if not isinstance(raw, dict): + return {} + out = {} + for k, rule in SPEC.items(): + if k not in raw: + continue + v = raw[k] + try: + if rule[0] == "bool": + out[k] = bool(v) + elif rule[0] == "num": + out[k] = int(round(max(rule[1], min(rule[2], float(v))))) + except (TypeError, ValueError): + continue + return out + + +def get_settings(): + """当前生效配置(读库;模块未初始化时回退出厂值,别让调用方炸)。""" + ctx = _ctx() + if ctx is None: + return factory() + try: + from core.db_config import meta_get + with ctx: + raw = meta_get(META_KEY) or "" + if not raw: + return factory() + return _merge(json.loads(raw)) + except Exception: + return factory() + + +def _merge(user): + """出厂默认 + 用户覆盖(逐字段合并 + 钳制)。""" + vals = dict(FACTORY) + vals.update(_clean(user)) + # 严重阈值不能高于低电量阈值,否则"严重"永远先触发,语义反了 + if vals["critical"] > vals["low"]: + vals["critical"] = vals["low"] + return vals + + +def save_settings(payload): + """保存配置。返回 (ok, msg, saved)。 + + 与出厂默认**相同的字段不入库**(照 step_defaults):以后调出厂值时, + 没配过的字段能跟着更新,库里只留"用户真的改过"的部分。 + """ + vals = _merge(payload) + diff = {k: v for k, v in vals.items() if FACTORY.get(k) != v} + ctx = _ctx() + if ctx is None: + return False, "电量模块未初始化", {} + try: + from core.db_config import meta_set + with ctx: + meta_set(META_KEY, json.dumps(diff, ensure_ascii=False)) + db.session.commit() + except Exception as e: + return False, f"保存失败: {e}", {} + _log.info(f"电量监控配置已保存: {vals}") + return True, "已保存", vals + + +# ================== 单台读取与解析 ================== +_BAT_LEVEL_RE = re.compile(r"^\s*level:\s*(\d+)", re.M) +_BAT_SCALE_RE = re.compile(r"^\s*scale:\s*(\d+)", re.M) +_BAT_STATUS_RE = re.compile(r"^\s*status:\s*(\d+)", re.M) +# 部分 ROM 没有 status 行时的兜底:插着电就算充电中 +_POWERED_RE = re.compile(r"^\s*(?:AC|USB|Wireless) powered:\s*true", re.M) + + +def _parse_battery(text): + """解析 `dumpsys battery` 输出,返回 (level, charging) 或 None。 + + status: 2=充电中 5=已充满 → charging;3=放电 4=未充电(插着但不充, + 例如劣质线/温控/满电停充)→ 不算充电中。所以"插着却掉电"仍然会告警, + 这正是最该让人知道的情况。 + """ + if not text or "level:" not in text: + return None + m = _BAT_LEVEL_RE.search(text) + if not m: + return None + level = int(m.group(1)) + ms = _BAT_SCALE_RE.search(text) + scale = int(ms.group(1)) if ms else 100 + if scale > 0 and scale != 100: + level = int(round(level * 100.0 / scale)) + st = _BAT_STATUS_RE.search(text) + if st: + charging = int(st.group(1)) in (2, 5) + else: + charging = bool(_POWERED_RE.search(text)) + return max(0, min(100, level)), charging + + +def _read_battery(serial): + """读一台设备的电量,返回 (level, charging) 或 None。 + + 与 device_pool.refresh_model 同思路:IP:5555 设备走本机 adb server; + USB 设备本机看不到,再走 220 远程 adb server 兜底。 + """ + out = "" + try: + out = _adb("-s", serial, "shell", "dumpsys", "battery", timeout=_READ_TIMEOUT) + except Exception: + out = "" + if not out and ":" not in serial: + try: + out = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "-s", serial, + "shell", "dumpsys", "battery") + except Exception: + out = "" + return _parse_battery(out) + + +# ================== 告警(档位状态沿) ================== +def _tier_of(level, charging, spec, prev): + """电量档位:0 正常 / 1 低 / 2 严重。 + + 充电中(且配了「充电中不告警」)直接算 0——正在补电就不用管它。 + 回升要越过 low+HYSTERESIS 才回 0,避免在阈值上下反复告警。 + """ + if spec["skip_charging"] and charging: + return 0 + if level <= spec["critical"]: + return 2 + if level <= spec["low"]: + return 1 + if level >= spec["low"] + HYSTERESIS: + return 0 + return prev # 迟滞带内:维持原档位(不报也不恢复) + + +def _check_alerts(serial, level, charging, spec, dname, model): + """比上一轮档位,决定是否发通知。只在档位变化/到重复提醒点时发。""" + now = time.time() + with _lock: + st = _tiers.setdefault(serial, {"tier": 0, "at": 0.0}) + prev = st["tier"] + tier = _tier_of(level, charging, spec, prev) + # 掉档(正常→低→严重)立刻报;同一档位停留太久到重复提醒间隔再报一次 + fire = tier > prev or (tier > 0 and now - st["at"] >= RE_NOTIFY_S) + if fire: + st["tier"], st["at"] = tier, now + recovered = prev > 0 and tier == 0 + if recovered: + st["tier"], st["at"] = 0, now + if fire: + notifier.notify( + "device.battery.low", serial=serial, device_name=dname, model=model, + battery=level, charging=charging, + threshold=spec["critical"] if tier == 2 else spec["low"], + level="error" if tier == 2 else "warning") + if recovered: + notifier.notify( + "device.battery.recovered", serial=serial, device_name=dname, + model=model, battery=level, charging=charging, threshold=spec["low"]) + + +# ================== 一轮全量采集 ================== +def _scan_all(): + """采集池内所有在线设备的电量(后台线程执行)。 + + 读失败的设备(离线/超时)**保持上一次的读数不动**——大屏上显示的是 + "最后一次知道的电量",比直接变成空白有用。 + """ + global _scanning, _last_scan + if _scanning: + return + _scanning = True + try: + spec = get_settings() + try: + online = list(device_pool.list_online()) + except Exception as e: + _log.warning(f"电量采集: 获取在线设备失败: {e}") + return + if not online: + _last_scan = (time.strftime("%H:%M:%S"), 0, 0) + return + # 设备名/型号:通知里要显示「A08(Redmi K40)」而不是一串 IP + names, models, known = {}, {}, set() + try: + for d in device_pool.list_devices(): + s = d.get("serial") or "" + if not s: + continue + known.add(s) + names[s] = d.get("name") or "" + models[s] = d.get("model") or "" + except Exception: + pass + + # 只采集「设备池 ∩ 在线」:平台管的是设备池(与 list_ready 同口径), + # 池外的设备不上大屏、也不告警——adb 里能看到但不归平台管的设备不该吵人。 + # known 为空 = 设备表没读到(不能因此整轮不采),退回按在线设备采。 + targets = [s for s in online if s in known] if known else list(online) + if not targets: + _last_scan = (time.strftime("%H:%M:%S"), 0, 0) + return + + results = {} + try: + with ThreadPoolExecutor(max_workers=min(10, len(targets))) as pool: + futures = {pool.submit(_read_battery, s): s for s in targets} + for fut in as_completed(futures, timeout=_READ_TIMEOUT + 15): + s = futures[fut] + try: + results[s] = fut.result() + except Exception: + results[s] = None + except Exception as e: + _log.warning(f"电量采集: 部分设备读取超时(保留已取到的): {e}") + + now = time.time() + with _lock: + for s, r in results.items(): + if r: + _cache[s] = {"level": r[0], "charging": r[1], "at": now} + # 设备已从池里删除的,连缓存一起清掉(避免大屏上冒出幽灵设备)。 + # ⚠️ 只在真的读到设备清单时才清理:list_devices 失败时 known 是空的, + # 照清下去会把整份缓存抹掉、大屏全体变「—」。 + if known: + for s in [k for k in _cache if k not in known]: + _cache.pop(s, None) + _tiers.pop(s, None) + + for s, r in results.items(): + if r: + _check_alerts(s, r[0], r[1], spec, names.get(s, ""), models.get(s, "")) + + ok = sum(1 for r in results.values() if r) + _last_scan = (time.strftime("%H:%M:%S"), ok, len(targets)) + _log.info(f"电量采集完成: {ok}/{len(targets)} 台(池内在线;adb 可见 {len(online)} 台)") + except Exception as e: + _log.warning(f"电量采集异常: {e}") + finally: + _scanning = False + + +def scan_once(): + """手动触发一轮采集(后台线程执行,不阻塞调用者)。返回是否已启动。 + + 不看 enabled 开关:用户点了「立即采集」就应该采,开关只管定时循环。 + """ + if _scanning: + return False + threading.Thread(target=_scan_all, name="battery-scan-once", daemon=True).start() + return True + + +# ================== 对外读取 ================== +def get(serial): + """设备电量快照(供 /api/status 组装)或 None。 + + 结构 {"level": int, "charging": bool, "at": ts, "tier": 0|1|2}。 + **带上 tier 是刻意的**:前端只按档位上色,不在 JS 里重算阈值—— + 否则「工具 → 设备发现」改了阈值,大屏/监控页的判定就和告警对不上了。 + """ + with _lock: + v = _cache.get(serial) + if not v: + return None + out = dict(v) + st = _tiers.get(serial) + out["tier"] = st["tier"] if st else 0 + return out + + +def get_status(): + """采集器状态(面板显示用)。""" + with _lock: + cached = len(_cache) + return { + "settings": get_settings(), + "scanning": _scanning, + "last_scan": _last_scan, + "cached": cached, + } + + +# ================== 定时采集线程 ================== +def _battery_loop(): + """定时采集 daemon 线程。每轮重读配置(开关/间隔即时生效)。 + + 启动先等 20s:让开 web_server 的预连接与型号采集(都抢 adb)。 + 等待用 15s 切片,关停/改间隔 ≤15s 生效。 + """ + try: + _stop_event.wait(20) + while not _stop_event.is_set(): + interval = 60 + try: + spec = get_settings() + interval = max(30, int(spec.get("interval", 60))) + if spec.get("enabled"): + _scan_all() + except Exception as e: + _log.warning(f"电量定时采集异常: {e}") + waited = 0 + while waited < interval and not _stop_event.is_set(): + _stop_event.wait(min(15, interval - waited)) + waited += min(15, interval - waited) + except Exception: + pass + + +def init_app(app): + """web_server 启动时调用:绑 app + 起定时采集 daemon 线程。""" + global _app + _app = app + _stop_event.clear() + t = threading.Thread(target=_battery_loop, name="device-battery", daemon=True) + t.start() + _log.info("设备电量采集线程已启动(默认 60s 一次)") + + +def shutdown(): + """优雅退出:置 stop_event,等待中的循环在切片边界退出。""" + _stop_event.set() diff --git a/core/notifier.py b/core/notifier.py index f49b5cf..d59c85c 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -334,6 +334,7 @@ _FIELD_LABELS = { "error_msg": "错误", "count": "数量", "serials": "设备", "devices": "设备", "pairs": "认领", "target": "目标", "selector": "选择器", "miss_count": "连续未命中", "timeout_s": "超时(秒)", "task_job": "原任务", "model": "型号", + "battery": "电量(%)", "threshold": "告警阈值(%)", "charging": "充电中", "apk_name": "应用", "apk_id": "应用ID", "package_name": "包名", "success": "成功", "failed": "失败", "skipped": "跳过", "total": "总数", "title": "标题", "message": "内容", "patrol_name": "巡检", diff --git a/core/notify_events.py b/core/notify_events.py index 5836231..91f8f03 100644 --- a/core/notify_events.py +++ b/core/notify_events.py @@ -128,6 +128,16 @@ EVENTS = [ _e("device.heartbeat_timeout", "心跳超时", "设备", ["serial", "device_name", "timeout_s", "task_job", "model"], "设备卡死(长时间没心跳),任务可能已中断", agg_window=0, recommend=True), + _e("device.battery.low", "设备电量低", "设备", + ["serial", "device_name", "model", "battery", "charging", "threshold"], + "设备电量掉到阈值以下(默认 20%,可在「工具 → 设备发现 → 电量监控」改)。" + "充电中的设备不报——插着充电器说明正在补电;" + "「插着却没充电」(劣质线/温控/满电停充)仍会报,那正是最该知道的情况", + agg_window=0, agg_key=_BY_SERIAL, recommend=True), + _e("device.battery.recovered", "设备电量已恢复", "设备", + ["serial", "device_name", "model", "battery", "charging", "threshold"], + "低电量设备重新充上电,或电量回升到阈值以上(含恢复迟滞)", agg_window=0, + agg_key=_BY_SERIAL), # ---------------- 应用安装 ---------------- _e("apk.install.started", "应用安装开始", "安装", diff --git a/core/task_manager.py b/core/task_manager.py index c7559ea..269dfd2 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -32,6 +32,7 @@ from config import DATA_DIR from core.logger import get_logger from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow from core import device_pool +from core import device_battery from core import notifier from .adb_helper import get_foreground_app, get_foreground_app_remote from .device_worker import ( @@ -1122,6 +1123,9 @@ class TaskManager: "owner": "", "worker_status": w.get("status", "idle"), "foreground_app": self._fg_scanner.get(serial), + # 电量(后台线程采的缓存,这里只读内存不查 adb): + # {"level":85,"charging":false,"at":ts} 或 None(还没采到) + "battery": device_battery.get(serial), # 通用进度字段(任意 app 通用,前端统一解析展示) # 结构:{"done": int, "total": int, "unit": str, "action_counts": dict} "progress": w.get("progress", {}), diff --git a/doc/API.md b/doc/API.md index 3611d92..5574608 100644 --- a/doc/API.md +++ b/doc/API.md @@ -169,6 +169,9 @@ | POST | `/api/devices/discovery/ignore` | D | 忽略待连接设备 | | POST | `/api/devices/discovery/reconnect` | D | 立即重连指定断联设备 | | POST | `/api/devices/discovery/settings` | D | 保存发现配置 | +| GET | `/api/devices/battery` | D | 电量采集器状态 + 生效配置(`{settings, scanning, last_scan:[时间,成功,目标数], cached}`) | +| POST | `/api/devices/battery/settings` | D | 保存电量监控配置(越界钳制,返回钳制后的生效值) | +| POST | `/api/devices/battery/scan` | D | 立即采集一次(后台执行;上一轮没跑完返回 409) | ### 2.6 apks(`web/apks_api.py`) @@ -321,12 +324,19 @@ "serial": "192.168.20.206:5555", "model": "22120RN86C", "device_name": "", "present": true, "ready": true, "worker_status": "running", "task_job": "测试抖音评论", "attempt": 1, "max_attempts": 1, "current_action": "点击搜索", "progress": {"done": 5, "total": 0, "unit": "操作", "elapsed": 42}, - "foreground_app": "抖音", "last_error": "", "last_warning": "", "end_time": 0 + "foreground_app": "抖音", "last_error": "", "last_warning": "", "end_time": 0, + "battery": {"level": 85, "charging": true, "at": 1700000000.0, "tier": 0} }]} ``` `worker_status`:`idle` / `connecting` / `running` / `done` / `error` / `released` / `failed`。 +`battery`:**后台线程采的缓存值**(默认 60s 一轮,`dumpsys battery` 只读),没采到时为 `null`。 +`tier` 是**告警档位**(`0` 正常 / `1` 低电量 / `2` 严重),由后端按配置阈值算好—— +前端只按 `tier` 上色,**不在 JS 里重算阈值**(阈值只在「工具 → 设备发现 → 电量监控」一处定义)。 +设备离线时保留最后一次读数(`at` 是采集时刻),页面置灰显示。详见 [NOTIFY.md](NOTIFY.md) §3 的 +`device.battery.low`。 + ### GET /api/summary ```json diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 645de38..20f3f12 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -26,6 +26,7 @@ ┌───────────────────────────▼──────────────────────────────────────────┐ │ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │ │ system_backup(备份) apk_manager(应用) device_discovery │ +│ device_battery(电量采集 + 低电量告警) │ └───────────────────────────┬──────────────────────────────────────────┘ │ ┌───────────────────────────▼──────────────────────────────────────────┐ @@ -79,7 +80,7 @@ |------|------|---------| | **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` | | **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 | -| **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` | +| **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` | ### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动 @@ -87,7 +88,7 @@ |------|------|---------| | **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) | | **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`,自建 app context);失败仅 warning | -| **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + 停 uiautodev | +| **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + `device_battery.shutdown()` + 停 uiautodev | > ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。 @@ -105,6 +106,7 @@ | `worker-watchdog` | `start_watchdog()` | `running/connecting` 心跳超时 → 标 `error` | 30s 检查 / 120s 阈值 | | `device-discovery` | `device_discovery.init_app` | 网段扫描 + 断联重连 | 首轮延迟 15s,之后 interval(默认 60s) | | `_refresh_models_bg` | `device_pool.init_app` | 启动后采集全部在线设备型号 | 一次性(3s 后) | +| `device-battery` | `device_battery.init_app` | 采设备电量(`dumpsys battery` 只读)+ 低电量告警 | 首轮延迟 20s,之后 interval(默认 60s) | | `BaseWorker` × N | `TaskManager._run_with_retry` | 单设备任务执行 | 任务期 | | `_run_with_retry` × N | 同上 | 单设备重试循环 | 任务期 | | `fg-scan-once` | `_ForegroundScanner.scan_once` | 前台 App 扫描 | 手动触发,`Event` 防重入 | @@ -127,6 +129,7 @@ | `_engine_lock` | `core/ocr.py` | OCR 引擎懒加载 + 推理串行 | 推理 0.2~0.5s,锁开销可忽略 | | `_scan_lock` / `_stop_event` | `core/device_discovery.py` | 定时/手动扫描互斥 | `acquire(blocking=False)` | | `_status_cache_lock` | `core/task_manager.py` | 状态缓存(TTL 5s) | 避免 `/api/status` 每次都查库 + adb | +| `device_battery._lock` | `core/device_battery.py` | 电量缓存 `_cache` + 告警档位 `_tiers` | 采集线程 / `/api/status` / 告警状态机共用 | **无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context" + 数据库自身的并发控制(MySQL 下是 InnoDB 行锁 + READ COMMITTED,回退 SQLite 时是 WAL + `busy_timeout=5000`)。 @@ -267,7 +270,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set ### 6.1 单页应用 -- 主页面 `templates/admin/monitor.html`:一个内联 `