Merge branch 'dev'——设备电量监控(大屏+监控页显示、低电量 webhook 告警)、选择设备与所有通知显示设备名、条件判断支持文本比对
This commit is contained in:
@@ -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,条件判断用)
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"""设备电量采集 + 低电量告警(大屏/监控展示 + webhook 通知)。
|
||||
|
||||
设计要点(都是被现有代码/实测约束逼出来的,改之前先读一遍):
|
||||
|
||||
- **只读**:`adb -s <serial> 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()
|
||||
@@ -39,13 +39,59 @@ def init_app(app):
|
||||
并后台刷新一次设备型号(首次启动/设备更换后型号可能变化)。"""
|
||||
global _app
|
||||
_app = app
|
||||
refresh_names() # 同步刷一次名称快照,让通知从第一条起就有名字
|
||||
try:
|
||||
t = threading.Thread(target=_refresh_models_bg, daemon=True)
|
||||
t.start()
|
||||
t2 = threading.Thread(target=_names_loop, name="device-names", daemon=True)
|
||||
t2.start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ================== 名称内存快照(给通知用) ==================
|
||||
# 为什么单独存一份:webhook 通知里要显示"是哪台设备"(A08)而不是地址(IP),
|
||||
# 但 `notifier.notify()` 有一条硬红线——**零 DB 访问**(不能为了取个名字去查库,
|
||||
# 更不能在业务线程里阻塞)。所以由这里维护一份 serial→名称 的内存快照:
|
||||
# 启动刷一次、池子有变动时刷一次、再兜底每 60s 刷一次(覆盖整库恢复等外部改动)。
|
||||
_names = {}
|
||||
_names_lock = threading.Lock()
|
||||
_NAMES_INTERVAL = 60
|
||||
|
||||
|
||||
def refresh_names():
|
||||
"""把设备池的 serial→名称刷进内存快照(只查库,不碰 adb)。返回条数。"""
|
||||
try:
|
||||
rows = list_devices()
|
||||
except Exception as e:
|
||||
_log.debug(f"刷新设备名快照失败: {e}")
|
||||
return 0
|
||||
m = {}
|
||||
for d in rows:
|
||||
s = d.get("serial") or ""
|
||||
if s:
|
||||
m[s] = d.get("name") or ""
|
||||
with _names_lock:
|
||||
_names.clear()
|
||||
_names.update(m)
|
||||
return len(m)
|
||||
|
||||
|
||||
def name_of(serial):
|
||||
"""serial 对应的设备名称(**纯内存,可在通知路径上调用**)。没有则返回 ""。"""
|
||||
if not serial:
|
||||
return ""
|
||||
with _names_lock:
|
||||
return _names.get(str(serial), "")
|
||||
|
||||
|
||||
def _names_loop():
|
||||
"""兜底定时刷新名称快照(覆盖整库恢复这类进程外改动)。"""
|
||||
while True:
|
||||
time.sleep(_NAMES_INTERVAL)
|
||||
refresh_names()
|
||||
|
||||
|
||||
def _refresh_models_bg():
|
||||
"""后台批量采集在线设备型号(启动时/手动触发)。失败静默,不影响启动。"""
|
||||
time.sleep(3) # 等服务起来再查
|
||||
@@ -183,6 +229,7 @@ def _move_device_row(old, new_serial, fingerprint=""):
|
||||
if fingerprint:
|
||||
old.fingerprint = fingerprint
|
||||
db.session.commit()
|
||||
refresh_names() # 键变了(serial → 名称的映射也跟着变)
|
||||
if _move_hook is not None:
|
||||
try:
|
||||
_move_hook(old_serial, new_serial)
|
||||
@@ -255,11 +302,13 @@ def add_device(serial, name="", note="", enabled=True, fingerprint=""):
|
||||
if fingerprint:
|
||||
d.fingerprint = fingerprint
|
||||
db.session.commit()
|
||||
refresh_names() # 名字可能改了:通知里要立刻用新名字
|
||||
return False
|
||||
db.session.add(Device(serial=serial, name=name or "", note=note or "",
|
||||
enabled=enabled, fingerprint=fingerprint or "",
|
||||
created_at=time.strftime("%Y-%m-%d %H:%M")))
|
||||
db.session.commit()
|
||||
refresh_names()
|
||||
_log.info(f"设备池新增设备: {serial}")
|
||||
return True
|
||||
|
||||
@@ -274,6 +323,7 @@ def rename_device(serial, name):
|
||||
old = d.name or ""
|
||||
d.name = name
|
||||
db.session.commit()
|
||||
refresh_names()
|
||||
_log.info(f"设备池重命名: {old or serial} → {name}({serial})")
|
||||
return True
|
||||
|
||||
@@ -301,6 +351,7 @@ def remove_device(serial):
|
||||
return False
|
||||
db.session.delete(d)
|
||||
db.session.commit()
|
||||
refresh_names()
|
||||
_log.info(f"设备池删除设备: {serial}")
|
||||
return True
|
||||
|
||||
|
||||
@@ -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": "巡检",
|
||||
@@ -356,6 +357,53 @@ def _label(k):
|
||||
return _FIELD_LABELS.get(k, k)
|
||||
|
||||
|
||||
# ================== 设备名解析(让通知显示名字而不是 IP) ==================
|
||||
# 由装配层注册(web_server.py):`notifier.set_device_name_resolver(device_pool.name_of)`。
|
||||
# **刻意做成"纯内存回调"**:notify() 的红线是零 DB——不能为了取个名字去查库。
|
||||
# 快照由 device_pool 维护(启动/池子变动/每 60s 兜底刷新)。
|
||||
_device_name_fn = None
|
||||
|
||||
|
||||
def set_device_name_resolver(fn):
|
||||
"""注册 serial → 设备名 的解析器。必须是**纯内存**实现(不得查库/发请求)。"""
|
||||
global _device_name_fn
|
||||
_device_name_fn = fn
|
||||
|
||||
|
||||
def _dev_name(serial):
|
||||
"""尽力把地址解析成设备名;解析不到返回 ""(调用方保留原值)。"""
|
||||
if not serial or _device_name_fn is None:
|
||||
return ""
|
||||
try:
|
||||
return str(_device_name_fn(str(serial)) or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def fill_device_names(fields):
|
||||
"""把 fields 里的设备地址补成设备名(**原地改**,返回同一个 dict)。
|
||||
|
||||
两类字段:
|
||||
· `serial` → 补 `device_name`(原来没填才补)——标题主体、字段表、
|
||||
聚合样本认的都是 `device_name`,补上这一处就全带名字了;
|
||||
· `serials` / `devices`(多设备事件,如设备断联)→ 逐项换成名字,
|
||||
通知里不该出现一串 IP。
|
||||
查不到名字(设备没命名 / 已不在池里)就**保留原值**,绝不把内容弄丢。
|
||||
"""
|
||||
if not isinstance(fields, dict):
|
||||
return fields
|
||||
s = fields.get("serial")
|
||||
if s and not fields.get("device_name"):
|
||||
n = _dev_name(s)
|
||||
if n:
|
||||
fields["device_name"] = n
|
||||
for k in ("serials", "devices"):
|
||||
v = fields.get(k)
|
||||
if isinstance(v, (list, tuple)) and v:
|
||||
fields[k] = [_dev_name(x) or x for x in v]
|
||||
return fields
|
||||
|
||||
|
||||
def _subject(ev, fields):
|
||||
"""标题主体:**用户自己写的 title 优先**,其次任务名,再次设备名/serial。"""
|
||||
if fields.get("title"):
|
||||
@@ -373,6 +421,7 @@ def _subject(ev, fields):
|
||||
def build_message(ev, fields, hook=None):
|
||||
"""把事件渲染成统一消息体(title/summary/fields/level/markdown)。"""
|
||||
fields = dict(fields or {})
|
||||
fill_device_names(fields) # 同 notify():预览/测试发送也要显示设备名
|
||||
merged = fields.pop("_merged", None)
|
||||
level = _level_of(ev.key, fields)
|
||||
subj = _subject(ev, fields)
|
||||
@@ -1007,6 +1056,7 @@ def notify(event, **fields):
|
||||
for p in h["events"])]
|
||||
if not hooks:
|
||||
return
|
||||
fill_device_names(fields) # 地址 → 设备名(纯内存快照,不查库)
|
||||
for h in hooks:
|
||||
keys = tuple(str(fields.get(k, "")) for k in (ev.agg_key or ()))
|
||||
# 聚合窗口:事件自己声明「立即发」(agg_window=0,低频高危事件,如批次结束 /
|
||||
|
||||
@@ -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", "应用安装开始", "安装",
|
||||
|
||||
@@ -219,6 +219,21 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None,
|
||||
if isinstance(else_, list) and else_:
|
||||
validate_steps(else_, depth + 1, f"{here}.params.else",
|
||||
errors, warnings, types, counter)
|
||||
# 文本比对(可选):运算符必须认得,且配了运算符就得有值——
|
||||
# 执行器对认不出的运算符是"一律不命中",写错了会静默走 else,
|
||||
# 所以在这里拦住,别让 AI 草稿悄悄变成"永远不命中"
|
||||
cmp_op = (params.get("cmp_op") or "").strip()
|
||||
if cmp_op:
|
||||
from tasks.generic.task import CMP_OPS
|
||||
if cmp_op not in CMP_OPS:
|
||||
errors.append(f"{here}.params.cmp_op: 不支持的比对运算符 {cmp_op!r}"
|
||||
f"(可用:{'、'.join(CMP_OPS)})")
|
||||
elif not str(params.get("cmp_value") or "").strip():
|
||||
errors.append(f"{here}.params.cmp_value: 选了文本比对"
|
||||
f"({cmp_op})就必须填比对的值,否则永远不命中")
|
||||
elif (params.get("selector_type") or "xpath") == "screen":
|
||||
warnings.append(f"{here}: 屏幕状态没有文本可比,"
|
||||
"cmp_op/cmp_value 会被忽略")
|
||||
|
||||
if stype == "swipe":
|
||||
_check_direction(params, here, errors)
|
||||
|
||||
@@ -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", {}),
|
||||
|
||||
+12
-2
@@ -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
|
||||
@@ -567,7 +577,7 @@
|
||||
| 接口 | 请求 | 响应要点 |
|
||||
|------|------|---------|
|
||||
| `GET /api/uiauto/status` | — | uiautodev(:20242)是否在跑,前端据此禁/启用"抓取元素" |
|
||||
| `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全) |
|
||||
| `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全)。每项 `{serial, name, model, product, status}`;**`name` 是平台给设备起的名字**(`cs1`/`A08`),池外设备退回 `serial`——uiautodev 自带的 name 是设备 codename(实测一柜子全是 `earth`),不能用来认设备 |
|
||||
| `GET /api/uiauto/elements?serial=` | — | 扁平元素列表(走 uiautodev),每项带 `suggested`、`bounds`、`depth` |
|
||||
| `GET /api/uiauto/snapshot?serial=` | — | **一次取齐**:截图 + 元素树(原生 u2 同一连接背靠背)→ `{image,width,height,elements,unstable,screen_state,cost_ms}`;抓取弹窗与 MCP 的 `de_snapshot` 都用它,失败返回 **502** |
|
||||
| `GET /api/uiauto/screenshot?serial=` | — | JPEG |
|
||||
|
||||
+11
-8
@@ -26,6 +26,7 @@
|
||||
┌───────────────────────────▼──────────────────────────────────────────┐
|
||||
│ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │
|
||||
│ system_backup(备份) apk_manager(应用) device_discovery │
|
||||
│ device_battery(电量采集 + 低电量告警) │
|
||||
└───────────────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼──────────────────────────────────────────┐
|
||||
@@ -79,15 +80,14 @@
|
||||
|------|------|---------|
|
||||
| **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`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) |
|
||||
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
|
||||
|
||||
| 阶段 | 位置 | 做了什么 |
|
||||
|------|------|---------|
|
||||
| **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`。
|
||||
|
||||
@@ -104,7 +104,9 @@
|
||||
| **巡检调度器** `BackgroundScheduler` | `web_server.py` | 经验库 AI 巡检 | 每日 03:47 |
|
||||
| `worker-watchdog` | `start_watchdog()` | `running/connecting` 心跳超时 → 标 `error` | 30s 检查 / 120s 阈值 |
|
||||
| `device-discovery` | `device_discovery.init_app` | 网段扫描 + 断联重连 | 首轮延迟 15s,之后 interval(默认 60s) |
|
||||
| `device-names` | `device_pool.init_app` | 刷 `serial → 名称` 内存快照(通知显示设备名用;只查库) | 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`:一个内联 `<style>` + 7 个 Tab 面板 + 7 个模态框容器 + 11 个 `<script src>`
|
||||
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 10 个模态框容器 + 15 个 `<script src>`
|
||||
- 独立页面:`login.html`(登录)、`wall.html`(监控大屏,**完全自包含**,自带 CSS/JS,不加载 `static/admin/*.js`)
|
||||
- 服务端内联页:`GET /locate`(设备端定位大字页,免登录)
|
||||
- 响应头强制 `no-store`,避免后台改版后浏览器拿旧页面
|
||||
@@ -282,7 +285,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
|
||||
| 用户 | `users` | `admin` | — |
|
||||
| 工具 | `tools` | `admin` | `clipboard` / `adb` / `ts` / `apks` / `appver` / `devapps` / `devpool` / `groups` |
|
||||
| AI 控制台 | `agent` | `admin` | — |
|
||||
| 系统 | `system` | `admin` | `backup` 数据备份 / `restore` 导入恢复 |
|
||||
| 系统 | `system` | `admin` | `backup` 数据备份 / `restore` 导入恢复 / `notify` 通知 Webhook |
|
||||
|
||||
子分栏会记住上次选中位置(`_activeSubs`);`devpool` 子分栏自带 10s 轮询,切走即停。
|
||||
|
||||
@@ -293,10 +296,10 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
|
||||
| `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 |
|
||||
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
|
||||
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
|
||||
| `monitor.js` | 监控页:设备表、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
|
||||
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例 |
|
||||
| `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
|
||||
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
|
||||
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
|
||||
| `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏 | |
|
||||
| `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏、**电量监控配置**(阈值/间隔/立即采集) | 电量表单只回填一次,10s 轮询不冲掉用户正在输的值 |
|
||||
| `apps.js` | 应用管理:APK 上传/安装/删除、设备已装应用 | |
|
||||
| `admin.js` | 分组、日志、用户 + **全局初始化入口**(末尾 `initCsrf(); loadMe(); showTab('monitor')`) | |
|
||||
| `agent.js` | AI 控制台·聊天:会话、SSE 流、Markdown / 推理链 / token 渲染、实时画面、经验库 / 动作库 | |
|
||||
|
||||
@@ -278,6 +278,7 @@ UTF-8 等价于字节序)。
|
||||
| `deployment_claimed_at` | 标签写入时间 | 同上 |
|
||||
| `notify_webhooks` | **通知 / Webhook 全部配置**(JSON:`{version, settings, webhooks[]}`,见 [NOTIFY.md](NOTIFY.md) §4)。**不建表**——新增/删除 webhook 都只改这一个键 | 系统 → 通知 页 |
|
||||
| `step_defaults` | **步骤默认值**(JSON:`{步骤类型: {字段: 值}}`,见 [TASK_DEV.md](TASK_DEV.md) §3.1)。新建步骤时预填用。**只存与出厂值不同的字段**,这样以后调出厂默认能跟着走 | 任务 → 动作配置 页 |
|
||||
| `device_battery` | **电量监控配置**(JSON:`{enabled, low, critical, skip_charging, interval}`,见 [NOTIFY.md](NOTIFY.md) §3.1)。**只存与出厂值不同的字段**(出厂:`low=20 critical=10 skip_charging=true interval=60`)。**电量本身不落库**——只放采集线程的内存缓存(重启重新采一轮),所以没有对应表、不动备份覆盖清单 | 工具 → 设备发现 → 电量监控 |
|
||||
|
||||
> ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。
|
||||
> **同理 `notify_webhooks` 里的 webhook URL 本身就是凭据**(企业微信 `?key=`、钉钉 `?access_token=`、
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
| 1 | **绝不 `adb kill-server`** | 会断掉所有设备的 adb transport,运行中任务全废 | `core/adb_helper.py` 只 connect 不 kill;Web 层硬拦截 `kill-server`/`disconnect` 字符串 |
|
||||
| 2 | **绝不对 `IP:5555` 设备 `adb disconnect`** | 该地址的 adb transport 是共享的 | `adb_disconnect` **全项目零调用方**;`STFDevice.release()` 空实现 |
|
||||
| 3 | **空闲设备扫描不主动 connect/disconnect** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测 |
|
||||
| 3b | (3 的边界)**只读 `adb -s <serial> shell …` 是允许的**,它不建立/断开 transport | 设备已在 `adb devices` 里就说明 transport 现成,读一下不扰动任何人 | 电量采集 `core/device_battery.py` 就是这么读**包括空闲设备在内**的全部在线设备(`dumpsys battery`,实测 0.2~0.35s/台);**别"顺手"给它加 connect** |
|
||||
| 4 | **adb key 保持历史 key 不变** | 设备信任该 key,换 key 全部 `unauthorized` | 部署沿用 `~/.android/adbkey` |
|
||||
| 5 | **生产(220)默认只读** | 生产事故成本高 | 任何写操作(pull/重启/改文件)都需负责人确认 |
|
||||
| 6 | **新增持久化表必须进备份覆盖清单** | 漏登记 = 等于没备份 | `SUMMARY_TABLES` 已由模型元数据自动派生(加了 ORM 表就进清单);**手工要做的只有补 `TABLE_LABELS` 中文标签**,详见 [DEPLOY.md](DEPLOY.md) §5.2 |
|
||||
|
||||
+43
-1
@@ -54,7 +54,7 @@ daemon 线程。所以:
|
||||
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
|
||||
| 业务·任务自己发 | `task.notify.custom`(步骤「发通知」)/ `task.patrol.hit`(任务「公共巡检」命中)——**标题正文由任务自己写**,见下 |
|
||||
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
|
||||
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` |
|
||||
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` · `device.battery.low` · `.battery.recovered` |
|
||||
| 安装 | `apk.install.started` · `.finished` |
|
||||
| 系统 | `system.backup.exported` · `.imported` · `.restored` · `.restore_failed` · `service.started` · `.stopping` · `user.login` |
|
||||
| AI | `ai.audit.finished` · `.failed` · `.skipped` |
|
||||
@@ -106,6 +106,31 @@ daemon 线程。所以:
|
||||
- 谁能收到仍然只看 webhook 的**事件订阅**:想让某个群收任务自定义消息,就在那条
|
||||
webhook 上勾 `task.notify.custom` / `task.patrol.hit`。
|
||||
|
||||
### 3.1 设备电量告警(`device.battery.low` / `.battery.recovered`)
|
||||
|
||||
采集与判定都在 `core/device_battery.py`(后台线程 `device-battery`,默认 60s 一轮,
|
||||
`adb -s <serial> shell dumpsys battery` **只读**查询)。阈值配置在
|
||||
**「工具 → 设备发现 → 电量监控」**,存在 `app_meta.device_battery`(见 [DATA_MODEL.md](DATA_MODEL.md) §5)。
|
||||
|
||||
阈值语义(**这是唯一判定处,前端只读后端算好的 `tier` 上色**):
|
||||
|
||||
| 档位 | 条件 | 通知 |
|
||||
|---|---|---|
|
||||
| `0` 正常 | `电量 >= 低电量阈值 + 5`(迟滞)或 充电中 | 从低/严重回到 0 → 发 `device.battery.recovered` |
|
||||
| `1` 低 | `严重阈值 < 电量 <= 低电量阈值` | **掉到这一档才发** `device.battery.low`(`warning`) |
|
||||
| `2` 严重 | `电量 <= 严重阈值` | 掉到这一档再发一次(`error`),阈值字段是严重阈值 |
|
||||
|
||||
- **迟滞 +5**:回升要到 `低电量阈值 + 5` 才算恢复。没有它,电量在阈值上下浮动
|
||||
(充电器接触不良)会反复告警。
|
||||
- **"充电中"看的是 `status:` 行**(`2` 充电中 / `5` 已充满),不是"插着电"。
|
||||
所以**"插着却没充电"**(劣质线、温控停充、满电停充 `status:4`)**仍然会告警**——
|
||||
那正是最该让人知道的情况。想连充电中一起报,把「充电中不告警」勾掉即可。
|
||||
- **重复提醒**:同一档位持续不恢复时,每 **6 小时**再提醒一次(`core/device_battery.RE_NOTIFY_S`)。
|
||||
- 只对**「设备池 ∩ 在线」**的设备告警:adb 里能看到但不归平台管的设备不吵人。
|
||||
- 一周内改了阈值**不用重启**:采集线程每轮重读配置,档位下一轮就按新阈值算。
|
||||
|
||||
排查:`logs/core.log` 搜 `core.battery`(每轮一行「电量采集完成: N/M 台」)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置
|
||||
@@ -222,6 +247,23 @@ Bark `/…/<key>`、Slack `/services/T…/B…/X…`)→ 接口回显、发送
|
||||
- 事件的 `fields` 是前端字段表与模板占位符的白名单,只写真正有用的
|
||||
- 高频事件(每台设备/每次尝试都会发生的)把 `agg_window` 设大一点或 `recommend=False`
|
||||
|
||||
**设备一律只传 `serial`,名字自动补**(不用每个调用点自己去查名字):
|
||||
|
||||
群消息里没人想看 `192.168.20.110:5555`,要看「A09」。这件事在通知链路里
|
||||
**统一做掉**,调用点照旧只传 `serial` 就行:
|
||||
|
||||
| 环节 | 做什么 |
|
||||
|---|---|
|
||||
| `core/device_pool.py` | 维护一份 `serial → 名称` 的**内存快照**:启动刷一次、池子增删改名/迁址时刷一次、每 60s 兜底刷一次(覆盖整库恢复这类进程外改动) |
|
||||
| `web_server.py` | `notifier.set_device_name_resolver(device_pool.name_of)` 把快照接给通知 |
|
||||
| `core/notifier.py` | `fill_device_names()` 在 `notify()` 与 `build_message()` 里把 `serial` 补成 `device_name`、把 `serials`/`devices` 列表逐项换成名字 |
|
||||
|
||||
为什么绕这一圈:`notify()` 的红线是**零 DB**,不能为了取个名字去查库(那等于在业务
|
||||
线程里加一次阻塞查询)。所以名字由 `device_pool` 预先算好放内存,`notifier` 只读。
|
||||
|
||||
降级规则(**绝不把内容弄丢**):调用点自己传了 `device_name` 就用它的;查不到名字
|
||||
(设备没命名 / 已不在池里)就保留原来的地址。解析器缺失或抛异常都只是降级,不影响发送。
|
||||
|
||||
---
|
||||
|
||||
## 8. 排障
|
||||
|
||||
+39
-2
@@ -131,7 +131,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) |
|
||||
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
|
||||
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
|
||||
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App** |
|
||||
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("") | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**;填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含) |
|
||||
| 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 |
|
||||
| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) |
|
||||
| 21 | `gesture` | 录制手势 | `points`(录出来的) | `speed`(1.0) | — | **纯录制回放**:把录下的轨迹点列 `[[x,y,t_ms],…]` 按原路径与时长交给设备插值,不做弧线/抖动/手速加工。与「滑动」是两套东西,见 §3.2 |
|
||||
@@ -236,6 +236,43 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
- OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定)
|
||||
- 命中长文本时点击位置按关键词在文本中的比例估算(避免点到整块中心偏离)
|
||||
|
||||
#### 文本比对(`cmp_op` / `cmp_value`,可选)——"这台登录的是不是那个号"
|
||||
|
||||
光判断"元素在不在"不够用。填了 `cmp_op` 就变成:**先按选择器取到元素的文本,再拿它和
|
||||
`cmp_value` 比,比对结果才是命中与否**。
|
||||
|
||||
`cmp_op` 取值:`等于` / `不等于` / `包含` / `不包含`(`==`/`!=`/`contains` 这类别名也认)。
|
||||
不填 = 老行为(只看元素在不在)。
|
||||
|
||||
例子(抓元素拿到的是 `android.widget.TextView text:抖音号:35377983067 id:com.ss.android.ugc.aweme:id/506`):
|
||||
|
||||
```json
|
||||
{"type": "if_el", "params": {
|
||||
"selector_type": "resourceId", "selector_value": "com.ss.android.ugc.aweme:id/506",
|
||||
"cmp_op": "包含", "cmp_value": "35377983067", "timeout": 3,
|
||||
"then": [{"type": "stop_self", "params": {"reason": "登录的不是目标抖音号"}}],
|
||||
"else": []}}
|
||||
```
|
||||
|
||||
要判断"**不是**这个号"就用 `不等于`(`cmp_op=不等于` 时"界面上是别的号"会命中,正好用来抓
|
||||
"号被换/掉登录")。
|
||||
|
||||
**两条必须知道的规则**(都为了不误判):
|
||||
|
||||
| 情况 | 结果 | 为什么 |
|
||||
|---|---|---|
|
||||
| 元素**没找到** | 一律**未命中**(走 `else`),日志写"未找到目标,无法比对" | "没读到"绝不能被当成"和我设的不一样"——否则界面还没加载出来就误判成"账号被换了" |
|
||||
| 元素在、但文本为空 | 按**空文本**参与比对(空 `不等于` 任何非空值 → 命中) | 用于抓"栏位是空的"(如未登录)。日志会打出实际读到的值,好排查 |
|
||||
|
||||
其它细节:
|
||||
|
||||
- 支持比对的类型:所有**元素类**选择器(xpath / text / resourceId / description / className…)、
|
||||
**`foreground`**(比当前前台包名)、**`ocr`**(比 OCR 命中的那段文字,如 `抖音号:35377983067`)。
|
||||
**`screen`(亮/熄)没有文本可比**,设了比对会被忽略并告警。
|
||||
- 比对**不影响「选择器健康」统计**:统计用的仍是"元素在不在"——"找到了但值不对"是条件按预期
|
||||
走了 `else`,不是选择器失效,不该攒出「连续未命中」告警。
|
||||
- 读元素文本是**只读**操作(`get_text`,2 秒超时),不会点击或改动任何状态。
|
||||
|
||||
### 4.3 公共参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
@@ -412,7 +449,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
|------|------|
|
||||
| 操作库拖拽 | 左侧分类库(interact / screen / flow),拖到画布;支持跨层级嵌套(循环套循环)、拖拽排序 |
|
||||
| 参数表单 | 按步骤类型渲染对应字段(选择器行、时长、概率、循环模式等) |
|
||||
| 条件分支 | `if_el` 的 then/else 两个分支区;OCR 模式下多出"命中后点击"复选框 |
|
||||
| 条件分支 | `if_el` 的 then/else 两个分支区;OCR 模式下多出"命中后点击"复选框;元素/OCR/前台App 条件还多一块**文本比对**(运算符下拉 + 比对的值,见 §4.2) |
|
||||
| 多选与打包 | ☑ 多选 → 打包成自定义动作 |
|
||||
| 元素抓取 | 「抓取元素」打开抓取弹窗(选设备 → 截图 + 元素树 → 点选回填),见 §5.5 |
|
||||
| 测试 | 「测试此步骤」真机试执行 |
|
||||
|
||||
+38
-12
@@ -19,7 +19,7 @@ var STEP_LIB=[
|
||||
{type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
|
||||
{type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
|
||||
{type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
|
||||
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}},
|
||||
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,cmp_op:'',cmp_value:'',then:[],else:[]}},
|
||||
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
|
||||
{type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}},
|
||||
{type:'gesture',label:'录制手势',icon:'⏺',cat:'interact',params:{points:[],speed:1.0}}
|
||||
@@ -425,15 +425,29 @@ var _stepEditor={
|
||||
}else if(step.type==='if_el'){
|
||||
// 屏幕状态 / 前台App 是"直接读设备当前状态",没有等待超时、也没有选择器可抓
|
||||
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground');
|
||||
// 文本比对:屏幕状态没有文本可比(亮/熄),只有元素/OCR/前台 App 才给这块
|
||||
var cmpRow=dyn?'':'<div class="form-row"><div class="form-group"><label>文本比对</label>'+
|
||||
'<select class="form-control" data-key="cmp_op">'+
|
||||
[['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']]
|
||||
.map(function(o){return '<option value="'+o[0]+'"'+((p.cmp_op||'')===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
|
||||
'</select></div>'+
|
||||
'<div class="form-group" style="grid-column:span 2"><label>比对的值</label>'+
|
||||
'<input type="text" class="form-control" data-key="cmp_value" value="'+esc(p.cmp_value||'')+
|
||||
'" placeholder="如 35377983067"></div></div>';
|
||||
h+=_selRowHtml(p,
|
||||
(dyn?'':'<div class="form-group"><label>查找超时(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||3)+'" min="0.5" max="30" step="0.5"></div>'+
|
||||
'<div class="form-group form-check" data-ocr-field style="display:'+(p.selector_type==='ocr'?'block':'none')+'">'+
|
||||
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>')+
|
||||
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>'+
|
||||
cmpRow)+
|
||||
'<div class="help">'+(dyn
|
||||
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。<b>屏幕状态</b>读系统 mWakefulness(亮/熄),'
|
||||
+'<b>前台App是</b>判断当前前台是不是某个包名——都只看当前状态,不等待。'
|
||||
: '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'
|
||||
+'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)')+'</div>', true);
|
||||
+'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)'
|
||||
+'<br><b>文本比对</b>(可选):填了就把找到的那个元素的<b>文本</b>和"比对的值"比,'
|
||||
+'比中了才算命中。例:元素文本是 <code>抖音号:35377983067</code>,'
|
||||
+'想判断"这台登录的是不是这个号",选<b>包含</b>、值填 <code>35377983067</code> 即可(要整段一模一样才用"等于")。'
|
||||
+'<br>⚠ 元素<b>没找到</b>时一律算未命中(走"未找到时"分支)——"没读到"不会被当成"和我设的不一样"。')+'</div>', true);
|
||||
}
|
||||
// 共享:触发概率(所有步骤通用,0~100)
|
||||
h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
|
||||
@@ -592,10 +606,7 @@ var _stepEditor={
|
||||
if(!list)return;
|
||||
if(!r||!r.ok){list.innerHTML='<div class="se-empty">'+esc((r&&r.error)||'获取设备失败')+'</div>';return;}
|
||||
list.innerHTML=(r.devices||[]).map(function(d){
|
||||
return '<div class="el-device-card" onclick="_stepEditor._runTestStep(\''+esc(d.serial)+'\')">'+
|
||||
'<div class="edc-icon">📱</div>'+
|
||||
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
|
||||
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div></div>';
|
||||
return self._devCard(d, "_stepEditor._runTestStep('"+esc(d.serial)+"')");
|
||||
}).join('')||'<div class="se-empty">无可用设备</div>';
|
||||
});
|
||||
},
|
||||
@@ -847,6 +858,13 @@ var _stepEditor={
|
||||
if(!s.params.selector_value){
|
||||
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
|
||||
}
|
||||
if(s.params.cmp_op&&!String(s.params.cmp_value||'').trim()){
|
||||
warnings.push(name+' "'+label+'": 选了"文本比对('+s.params.cmp_op+')"但没填比对的值,'
|
||||
+'执行时永远不命中(相当于每次都走"未找到时"分支)。');
|
||||
}
|
||||
if(s.params.cmp_op&&s.params.selector_type==='screen'){
|
||||
warnings.push(name+' "'+label+'": 屏幕状态没有文本可比,文本比对的设置会被忽略。');
|
||||
}
|
||||
}
|
||||
_childArrs(s).forEach(function(a){
|
||||
if(a.length)check(a,name+' 子步骤');
|
||||
@@ -918,8 +936,20 @@ var _stepEditor={
|
||||
// 直接弹设备选择页(独立模态框,不影响任务编辑窗口)
|
||||
this._showDevicePicker();
|
||||
},
|
||||
// 设备选择卡片(「抓取元素」与「测试此步骤」两处共用):
|
||||
// **设备名优先**,型号与地址降为副标题——同型号好几台时只有平台起的名字
|
||||
// (A08)能认出"是哪一台",光看 IP 选错设备是常事。
|
||||
_devCard:function(d,onclick){
|
||||
var sub=[d.model||d.product||'', d.serial||''].filter(Boolean).join(' · ');
|
||||
return '<div class="el-device-card" onclick="'+onclick+'">'+
|
||||
'<div class="edc-icon">📱</div>'+
|
||||
'<div class="edc-info"><div class="edc-name">'+esc(d.name||d.serial)+'</div>'+
|
||||
'<div class="edc-serial">'+esc(sub)+'</div></div>'+
|
||||
(d.status?'<span class="edc-status">'+esc(d.status)+'</span>':'')+'</div>';
|
||||
},
|
||||
// 步骤1:设备选择页
|
||||
_showDevicePicker:function(){
|
||||
var self=this;
|
||||
document.getElementById('el-picker-title').textContent='抓取元素 - 选择设备';
|
||||
document.getElementById('el-picker-body').innerHTML='<div class="el-device-grid"><div class="egd-title">选择要抓取元素的设备</div><div id="el-device-list">加载中...</div></div>';
|
||||
document.getElementById('el-picker-footer').innerHTML='<button class="btn" onclick="closeElPicker()">取消</button>';
|
||||
@@ -930,11 +960,7 @@ var _stepEditor={
|
||||
var devices=res.devices||[];
|
||||
if(!devices.length){list.innerHTML='<div class="se-empty">uiauto2 未发现已连接设备</div>';return;}
|
||||
list.innerHTML=devices.map(function(d){
|
||||
return '<div class="el-device-card" onclick="_stepEditor._showElPicker(\''+esc(d.serial)+'\')">'+
|
||||
'<div class="edc-icon">📱</div>'+
|
||||
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
|
||||
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div>'+
|
||||
'<span class="edc-status">'+esc(d.status||'device')+'</span></div>';
|
||||
return self._devCard(d, "_stepEditor._showElPicker('"+esc(d.serial)+"')");
|
||||
}).join('');
|
||||
}).catch(function(e){
|
||||
document.getElementById('el-device-list').innerHTML='<div class="se-empty">请求失败: '+esc(e)+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';
|
||||
|
||||
+20
-1
@@ -13,6 +13,9 @@ function _sortValue(dev,key){
|
||||
// 按状态优先级排序:running>connecting>idle>done>error>released
|
||||
const order={running:0,connecting:1,idle:2,done:3,released:4,error:5,failed:6};
|
||||
v=order[dev.worker_status]||99;
|
||||
}else if(key==='battery'){
|
||||
// 没采到电量的排最后(用 -1 而不是 0,0% 是真实电量)
|
||||
v=dev.battery?dev.battery.level:-1;
|
||||
}else{
|
||||
v=dev[key]||'';
|
||||
}
|
||||
@@ -325,6 +328,22 @@ function renderDevices(){
|
||||
applyListPager('devices');
|
||||
initListSearch('devices','search-devices');
|
||||
}
|
||||
// 电量单元格:告警档位(tier 0 正常/1 低/2 严重)由后端算好,前端只上色——
|
||||
// 阈值只在「工具 → 设备发现 → 电量监控」一处定义,两边不重复判
|
||||
function _battCell(dev){
|
||||
const b=dev.battery;
|
||||
if(!b)return '<span class="text-muted">-</span>';
|
||||
const t=b.tier||0;
|
||||
const cls=t===2?'label-danger':(t===1?'label-warning':'label-success');
|
||||
const mins=b.at?Math.floor((Date.now()/1000-b.at)/60):-1;
|
||||
const ageTxt=(mins<0?'':(mins<1?'刚刚':mins+' 分钟前'));
|
||||
const chg=b.charging?' ⚡':'';
|
||||
const tip=(t===2?'电量严重不足':(t===1?'电量低':'电量正常'))
|
||||
+(b.charging?'(充电中)':'')+(ageTxt?'('+ageTxt+'采集)':'')
|
||||
+(dev.present?'':'(设备已离线,显示最后一次读数)');
|
||||
const stale=dev.present?'':'<div style="font-size:11px;color:#94a3b8">'+ageTxt+'</div>';
|
||||
return '<span class="label '+cls+'" title="'+tip+'">'+b.level+'%'+chg+'</span>'+stale;
|
||||
}
|
||||
function renderDeviceRow(dev){
|
||||
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
|
||||
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
|
||||
@@ -366,7 +385,7 @@ function renderDeviceRow(dev){
|
||||
const checked=_selectedSerials.has(dev.serial)?'checked':'';
|
||||
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
||||
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
|
||||
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
|
||||
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+_battCell(dev)+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
|
||||
}
|
||||
|
||||
async function loadJobsForMonitor(){
|
||||
|
||||
@@ -43,6 +43,7 @@ async function loadToolsDevices(force){
|
||||
|
||||
// ================== 设备自动发现(扫描 → 待连接池 → 确认) ==================
|
||||
function loadDiscovery(){
|
||||
loadBattery(); // 电量监控与发现同住一个子面板,跟着它的 10s 轮询一起刷新状态行
|
||||
apiGet('/api/devices/discovery').then(r=>{
|
||||
if(!r||!r.ok)return;
|
||||
const status=document.getElementById('discovery-status');
|
||||
@@ -116,6 +117,62 @@ function loadDiscovery(){
|
||||
}
|
||||
});
|
||||
}
|
||||
// ================== 设备电量监控(定时采集 + 低电量告警) ==================
|
||||
// 电量本身随 /api/status 下发(监控页电量列 / 大屏卡片),这里只管配置与采集器状态。
|
||||
let _batteryLoaded=false; // 表单只回填一次:10s 轮询不能把用户正在输的值冲掉
|
||||
|
||||
function loadBattery(){
|
||||
apiGet('/api/devices/battery').then(r=>{
|
||||
if(!r||!r.ok)return;
|
||||
const en=document.getElementById('battery-enabled');
|
||||
if(!en)return; // 面板不在页面上
|
||||
const s=r.settings||{};
|
||||
if(!_batteryLoaded){
|
||||
en.checked=!!s.enabled;
|
||||
document.getElementById('battery-low').value=s.low;
|
||||
document.getElementById('battery-critical').value=s.critical;
|
||||
document.getElementById('battery-skip-charging').checked=!!s.skip_charging;
|
||||
document.getElementById('battery-interval').value=s.interval;
|
||||
_batteryLoaded=true;
|
||||
}
|
||||
const st=document.getElementById('battery-status');
|
||||
if(r.scanning)st.textContent='采集中...';
|
||||
else if(r.last_scan)st.textContent='上次采集 '+r.last_scan[0]+' · 成功 '+r.last_scan[1]+'/'+r.last_scan[2]+' 台';
|
||||
else st.textContent='尚未采集';
|
||||
});
|
||||
}
|
||||
|
||||
function saveBatterySettings(){
|
||||
const body={
|
||||
enabled:document.getElementById('battery-enabled').checked,
|
||||
low:document.getElementById('battery-low').value,
|
||||
critical:document.getElementById('battery-critical').value,
|
||||
skip_charging:document.getElementById('battery-skip-charging').checked,
|
||||
interval:document.getElementById('battery-interval').value,
|
||||
};
|
||||
apiPost('/api/devices/battery/settings',body).then(r=>{
|
||||
if(!r||!r.ok){showToast('保存失败: '+((r&&r.error)||''),'error');return;}
|
||||
showToast(r.msg||'已保存','success');
|
||||
// 回填后端钳制后的真实值(填 999 会被钳到 100,严重阈值高于低电量会被压回来)
|
||||
const s=r.settings;
|
||||
if(s){
|
||||
document.getElementById('battery-low').value=s.low;
|
||||
document.getElementById('battery-critical').value=s.critical;
|
||||
document.getElementById('battery-interval').value=s.interval;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scanBatteryNow(){
|
||||
apiPost('/api/devices/battery/scan',{}).then(r=>{
|
||||
if(r&&r.ok){
|
||||
showToast(r.msg||'采集已启动','success');
|
||||
setTimeout(loadBattery,3000);
|
||||
if(typeof loadMonitor==='function')setTimeout(loadMonitor,3000); // 监控页设备表跟着更新
|
||||
}else showToast('采集失败: '+((r&&r.error)||''),'error');
|
||||
});
|
||||
}
|
||||
|
||||
function reconnectPoolDev(serial){
|
||||
apiPost('/api/devices/discovery/reconnect',{serial}).then(r=>{
|
||||
if(r&&r.ok){
|
||||
|
||||
+103
-3
@@ -113,7 +113,7 @@ STEP_TYPES = [
|
||||
"params": {"children": []}},
|
||||
{"type": "if_el", "label": "条件判断", "icon": "❓",
|
||||
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
|
||||
"then": [], "else": []}},
|
||||
"cmp_op": "", "cmp_value": "", "then": [], "else": []}},
|
||||
{"type": "notify", "label": "发通知", "icon": "🔔",
|
||||
"params": {"title": "", "message": "", "level": "info"}},
|
||||
{"type": "stop_self", "label": "停止本设备", "icon": "⛔",
|
||||
@@ -129,6 +129,43 @@ DEFAULT_PARAMS = {
|
||||
# 缺 steps 的任务执行时会明确报错(见 run_task),不会静默空跑。
|
||||
}
|
||||
|
||||
# 「条件判断」文本比对时读元素文本的超时(秒):只读一次属性,正常几十毫秒
|
||||
_TEXT_READ_TIMEOUT = 2
|
||||
|
||||
# 「条件判断」的文本比对运算符:中文为主(界面上就是这个),英文/符号也认,
|
||||
# 方便 AI 建任务或手写 JSON 时不用猜用哪个词。
|
||||
_CMP_ALIASES = {
|
||||
"等于": ("等于", "==", "=", "eq", "equals", "equal"),
|
||||
"不等于": ("不等于", "!=", "<>", "ne", "not_equal", "notequal"),
|
||||
"包含": ("包含", "含", "contains", "in", "has"),
|
||||
"不包含": ("不包含", "不含", "not_contains", "notcontains", "excludes"),
|
||||
}
|
||||
# 界面上给的运算符(顺序即下拉框顺序);上面那张表另收了英文/符号别名。
|
||||
# AI 草稿校验(core/task_draft.py)用它拦"运算符拼错 -> 永远不命中"。
|
||||
CMP_OPS = tuple(_CMP_ALIASES)
|
||||
|
||||
|
||||
def _text_matches(got, op, want):
|
||||
"""元素文本比对:got(采集到的)对 want(用户设的)做 op 判断。
|
||||
|
||||
op 见 `_CMP_ALIASES`(等于/不等于/包含/不包含)。认不出的 op 一律返回 False
|
||||
——条件判断"看不懂就算不命中",比猜一个更容易排查。
|
||||
"""
|
||||
got = (got or "").strip()
|
||||
want = (want or "").strip()
|
||||
key = (op or "").strip().lower()
|
||||
for canon, aliases in _CMP_ALIASES.items():
|
||||
if key in [a.lower() for a in aliases]:
|
||||
if canon == "等于":
|
||||
return got == want
|
||||
if canon == "不等于":
|
||||
return got != want
|
||||
if canon == "包含":
|
||||
# 想匹配空串等于"永远命中",那是误配,当不命中处理
|
||||
return bool(want) and want in got
|
||||
return bool(want) and want not in got
|
||||
return False
|
||||
|
||||
|
||||
class GenericStepsWorker(BaseWorker):
|
||||
"""通用步骤 worker:按 params.steps 顺序执行步骤链。
|
||||
@@ -576,15 +613,24 @@ class GenericStepsWorker(BaseWorker):
|
||||
selector_type=ocr 时用屏幕 OCR 匹配文字(图片/画布/WebView 里的文字也能找到),
|
||||
命中且 ocr_click=True 时自动点击该文字中心。
|
||||
分支里可放任意步骤(含循环/嵌套条件判断),实现"有弹窗就关掉、没弹窗就继续"这类逻辑。
|
||||
|
||||
**文本比对(cmp_op / cmp_value,可选)**:光"元素在不在"不够用——常见需求是
|
||||
"这台机器上登录的抖音号是不是我配的那个"。填了 cmp_op 就变成:
|
||||
先按选择器**取到元素的文本**,再拿它和 cmp_value 比(等于/不等于/包含/不包含),
|
||||
比对结果才是命中与否。例:`resourceId=com.ss.android.ugc.aweme:id/506` 的文本是
|
||||
`抖音号:35377983067`,cmp_op=包含、cmp_value=35377983067 就是"这台上登录的是它"。
|
||||
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
|
||||
"""
|
||||
sel_type = params.get("selector_type", "xpath")
|
||||
sel_val = params.get("selector_value", "")
|
||||
timeout = float(params.get("timeout", 3))
|
||||
cmp_op = str(params.get("cmp_op") or "").strip()
|
||||
cmp_val = str(params.get("cmp_value") or "")
|
||||
if not sel_val:
|
||||
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
|
||||
return None
|
||||
found = False
|
||||
got = None # 比对用的"采集到的值":元素文本 / OCR 命中文本 / 前台包名
|
||||
# 非元素类条件(屏幕状态/前台 App):不参与"选择器健康"统计——它们不是选择器,
|
||||
# 连续未命中不该被记成"选择器失效"
|
||||
dynamic = sel_type in ("screen", "foreground")
|
||||
@@ -599,6 +645,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
f"(判断{'熄屏' if want_off else '亮屏'})")
|
||||
elif sel_type == "foreground":
|
||||
cur = current_package(d)
|
||||
got = cur or ""
|
||||
found = cur == str(sel_val).strip()
|
||||
detail = f"前台='{cur or '未知'}'(期望 {sel_val})"
|
||||
elif sel_type == "ocr":
|
||||
@@ -614,6 +661,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
_log.warning(f"[{self.serial}] OCR 截图失败")
|
||||
else:
|
||||
found, center, matched = find_on_screen(img, sel_val)
|
||||
got = matched or "" if found else ""
|
||||
if found:
|
||||
if params.get("ocr_click") and center:
|
||||
d.click(*center)
|
||||
@@ -634,16 +682,68 @@ class GenericStepsWorker(BaseWorker):
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] 条件判断异常: {e}")
|
||||
found = False
|
||||
|
||||
# 元素"在不在"的原始结果:选择器健康统计要用它、**不能**用比对结果——
|
||||
# "找到了但文本不对"是条件按预期走了 else 分支,不是选择器失效。
|
||||
existed = found
|
||||
|
||||
# ---- 文本比对(可选)----
|
||||
if cmp_op and sel_type != "screen":
|
||||
if not found:
|
||||
# 元素都没找到就没什么可比的:算未命中(走 else 分支)。
|
||||
# 这条规则很重要——"没读到"永远不能被当成"和我设的不一样",
|
||||
# 否则界面还没加载出来时会误判成"账号被别人换了"。
|
||||
nofind = f"未找到目标,无法比对({cmp_op} {cmp_val})"
|
||||
detail = f"{detail};{nofind}" if detail else nofind
|
||||
else:
|
||||
if got is None:
|
||||
got = self._read_element_text(d, sel_type, sel_val)
|
||||
hit = _text_matches(got, cmp_op, cmp_val)
|
||||
detail = f"采集到 '{got}' {cmp_op} '{cmp_val}' → {'符合' if hit else '不符合'}"
|
||||
found = hit
|
||||
elif cmp_op and sel_type == "screen":
|
||||
_log.warning(f"[{self.serial}] 屏幕状态没有文本可比,已忽略文本比对设置")
|
||||
|
||||
if dynamic:
|
||||
_log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}")
|
||||
else:
|
||||
self._track_selector_health(sel_val, found)
|
||||
self._track_selector_health(sel_val, existed)
|
||||
branch = params.get("then" if found else "else") or []
|
||||
self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)")
|
||||
if cmp_op:
|
||||
cmp_txt = "比对命中" if found else "比对未命中"
|
||||
else:
|
||||
cmp_txt = "命中" if found else "未命中"
|
||||
self.set_action(f"条件{cmp_txt} → 执行{'then' if found else 'else'}分支({len(branch)}步)")
|
||||
_log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {detail or sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步")
|
||||
self._exec_steps(d, branch, depth + 1)
|
||||
return found
|
||||
|
||||
def _read_element_text(self, d, sel_type, sel_val):
|
||||
"""取选中元素的文本(文本比对用)。读不到返回 ""。
|
||||
|
||||
只读操作,不改任何状态;失败静默返回空串,由调用方按"空文本"去比对
|
||||
(日志里会打出实际读到的值,排查时能看出是空还是不对)。
|
||||
|
||||
注意 u2 两种元素的 exists 语义不同,别"统一"掉:
|
||||
· `d.xpath(...)` → XPathSelector.exists 是**纯 bool 属性**
|
||||
· `d(text=...)` → UiObject.exists 是**可调用的 Exists 对象**
|
||||
(`bool()` 即时判断、`exists(timeout=t)` 带等待)——两种写法都对
|
||||
"""
|
||||
try:
|
||||
if sel_type == "xpath":
|
||||
el = d.xpath(sel_val)
|
||||
if not el.exists:
|
||||
return ""
|
||||
return (el.get_text() or "").strip()
|
||||
el = d(**{sel_type: sel_val})
|
||||
if not el.exists:
|
||||
return ""
|
||||
# 限时读:元素在"检查"与"读"之间消失时,别挂满默认等待(默认可能是 10s)
|
||||
return (el.get_text(timeout=_TEXT_READ_TIMEOUT) or "").strip()
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] 读取元素文本失败(按空文本比对): {e}")
|
||||
return ""
|
||||
|
||||
def _exec_swipe(self, d, params, depth=0):
|
||||
"""滑动一次(默认拟人:弧线 + 抖动 + 本设备手速,见 core/humanize.py)。
|
||||
|
||||
|
||||
@@ -309,7 +309,8 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
.el-device-card:hover{border-color:var(--primary);box-shadow:var(--shadow-md);transform:translateY(-1px)}
|
||||
.el-device-card .edc-icon{width:36px;height:36px;background:#f1efe8;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
|
||||
.el-device-card .edc-info{flex:1}
|
||||
.el-device-card .edc-serial{font-size:13px;font-weight:600;color:var(--text);font-family:var(--mono)}
|
||||
.el-device-card .edc-name{font-size:13.5px;font-weight:700;color:var(--text)}
|
||||
.el-device-card .edc-serial{font-size:12px;color:var(--text-light);margin-top:2px;font-family:var(--mono)}
|
||||
.el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px}
|
||||
.el-device-card .edc-status{font-size:11px;padding:2px 10px;border-radius:99px;background:var(--success-soft);color:#15803d;flex-shrink:0;font-weight:600}
|
||||
.el-picker-main{display:flex;flex:1;overflow:hidden}
|
||||
@@ -553,6 +554,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th>
|
||||
<th data-sort="serial" onclick="_lpSort('devices','serial')">设备</th>
|
||||
<th data-sort="model" onclick="_lpSort('devices','model')">型号</th>
|
||||
<th data-sort="battery" onclick="_lpSort('devices','battery')" title="后台每分钟采集一次(dumpsys battery);颜色按告警档位上色,阈值在「工具 → 设备发现 → 电量监控」里配">电量</th>
|
||||
<th data-sort="worker_status" onclick="_lpSort('devices','worker_status')">任务状态</th>
|
||||
<th data-sort="foreground_app" onclick="_lpSort('devices','foreground_app')">前台App</th>
|
||||
<th data-sort="progress" onclick="_lpSort('devices','progress')">进度</th>
|
||||
@@ -1205,6 +1207,37 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 电量监控:定时采集设备电量(大屏 + 监控页设备列表展示),低于阈值推 webhook 通知 -->
|
||||
<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="battery-enabled" onchange="saveBatterySettings()"> 电量监控
|
||||
</label>
|
||||
<span class="text-muted" style="margin-left:8px">低电量</span>
|
||||
<input type="number" id="battery-low" style="width:70px" min="1" max="100"
|
||||
title="电量低于这个值就告警">
|
||||
<span class="text-muted">% · 严重</span>
|
||||
<input type="number" id="battery-critical" style="width:70px" min="1" max="100"
|
||||
title="低于这个值时通知等级升到「严重」(error)。不能高于低电量阈值。">
|
||||
<span class="text-muted">%</span>
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;margin-left:10px"
|
||||
title="充电中(status=充电中/已充满)不告警——插着充电器说明正在补电。注意「插着却没充电」(劣质线/温控/满电停充)仍会告警,那正是最该知道的情况。">
|
||||
<input type="checkbox" id="battery-skip-charging"> 充电中不告警
|
||||
</label>
|
||||
<span class="text-muted" style="margin-left:8px">采集间隔</span>
|
||||
<input type="number" id="battery-interval" style="width:70px" min="30" max="3600">
|
||||
<span class="text-muted">秒</span>
|
||||
<button class="btn" onclick="saveBatterySettings()">保存设置</button>
|
||||
<button class="btn" onclick="scanBatteryNow()">立即采集</button>
|
||||
<span id="battery-status" class="text-muted"></span>
|
||||
</div>
|
||||
<div class="help">定时用 <code>dumpsys battery</code>(只读)采集池内在线设备的电量,显示在
|
||||
<b>「监控 → 设备列表」的电量列</b>与<b>大屏卡片</b>上。<br>
|
||||
低于阈值时推 webhook 通知(事件 <code>device.battery.low</code>,要在「系统 → 通知」里勾选订阅才发):
|
||||
<b>掉到低电量报一次、掉到严重再报一次、恢复再报一次</b>,不做重复刷屏。<br>
|
||||
电量只放内存、<b>不落库</b>(重启后重新采一轮),所以不占数据库、不影响备份。
|
||||
设备离线时显示最后一次读数(置灰),不会变成空白。</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<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()">
|
||||
|
||||
@@ -73,6 +73,17 @@ main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;paddi
|
||||
.card .info .name{font-size:13.5px;font-weight:700;color:#f1f4f8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .info .ip{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .info .model{font-size:10.5px;color:var(--faint);margin-top:2px}
|
||||
/* 型号 + 电量同一行(省一行高度,卡片本来就矮) */
|
||||
.card .info .meta{display:flex;align-items:center;gap:8px;margin-top:2px;white-space:nowrap;overflow:hidden}
|
||||
.card .info .meta .model{margin-top:0;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .info .batt{margin-left:auto;display:inline-flex;align-items:center;gap:2px;font-size:11px;color:var(--faint);flex:0 0 auto}
|
||||
.card .info .batt .lv{font-family:var(--mono);font-weight:700}
|
||||
.card .info .batt.ok .lv{color:var(--green)}
|
||||
.card .info .batt.low .lv{color:var(--amber)}
|
||||
.card .info .batt.crit .lv{color:var(--red)}
|
||||
.card .info .batt.crit{animation:pulse 1.6s infinite}
|
||||
.card .info .batt.stale{opacity:.4}
|
||||
.card .info .batt .chg{color:var(--teal)}
|
||||
.card .info .action{font-size:11.5px;color:var(--muted);margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:16px}
|
||||
.card .info .action b{color:var(--amber);font-weight:600}
|
||||
.card .info .prog{margin-top:7px;height:4px;background:#1d242e;border-radius:99px;overflow:hidden}
|
||||
@@ -202,8 +213,8 @@ async function refreshStatus(){
|
||||
const r=await apiGet('/api/status');
|
||||
if(!r||!r.ok)return;
|
||||
const devs=(r.devices||[]).sort((a,b)=>a.serial.localeCompare(b.serial));
|
||||
const changed=JSON.stringify(devs.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model]))!==
|
||||
JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model]));
|
||||
const changed=JSON.stringify(devs.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model,d.battery]))!==
|
||||
JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model,d.battery]));
|
||||
_devices=devs;
|
||||
renderStats(devs);
|
||||
if(changed)renderGrid(visibleDevs(devs));
|
||||
@@ -249,6 +260,22 @@ function fmtProgress(p){
|
||||
}
|
||||
function shortIp(serial){return String(serial||'').replace(/:5555$/,'');}
|
||||
|
||||
// 电量:告警档位(tier 0 正常/1 低/2 严重)由后端算好,前端只上色——
|
||||
// 阈值只在「工具 → 设备发现 → 电量监控」一处定义,前端不重复判,免得两边不一致
|
||||
function battHtml(d){
|
||||
const b=d.battery;
|
||||
if(!b)return '<span class="batt">🔋<span class="lv">—</span></span>';
|
||||
const t=b.tier||0;
|
||||
const cls=t===2?'crit':(t===1?'low':'ok');
|
||||
const chg=b.charging?'<span class="chg" title="充电中">⚡</span>':'';
|
||||
const mins=b.at?Math.floor((Date.now()/1000-b.at)/60):-1;
|
||||
const minsTxt=(mins<0?'未知':(mins<1?'刚刚':mins+' 分钟前'));
|
||||
const tip=(t===2?'电量严重不足':(t===1?'电量低':'电量正常'))
|
||||
+'('+minsTxt+'采集'+(d.present?'':',设备已离线,显示最后一次读数')+')';
|
||||
return '<span class="batt '+cls+(d.present?'':' stale')+'" title="'+tip+'">🔋'
|
||||
+'<span class="lv">'+b.level+'%</span>'+chg+'</span>';
|
||||
}
|
||||
|
||||
function cardHtml(d, i){
|
||||
const cls=cardCls(d);
|
||||
const serial=esc(d.serial);
|
||||
@@ -268,7 +295,8 @@ function cardHtml(d, i){
|
||||
'<div class="info">'+
|
||||
'<div class="name">'+name+'</div>'+
|
||||
'<div class="ip">'+ip+'</div>'+
|
||||
'<div class="model">'+esc(d.model||'')+'</div>'+
|
||||
'<div class="meta"><span class="model">'+esc(d.model||'')+'</span>'
|
||||
+'<span class="batt-wrap">'+battHtml(d)+'</span></div>'+
|
||||
action+fmtProgress(d.progress)+
|
||||
'<div class="hint">点击进入设备操作</div>'+
|
||||
'</div>'+
|
||||
@@ -306,6 +334,9 @@ function renderGrid(devs){
|
||||
card.querySelector('.name').textContent=d.device_name||d.model||'设备';
|
||||
card.querySelector('.ip').textContent=shortIp(serial);
|
||||
card.querySelector('.model').textContent=d.model||'';
|
||||
// 电量(与型号同一行,见 .meta)
|
||||
const bw=card.querySelector('.batt-wrap');
|
||||
if(bw)bw.innerHTML=battHtml(d);
|
||||
const action=d.current_action
|
||||
?(d.task_job?'<b>'+esc(d.task_job)+'</b> · ':'')+esc(d.current_action)
|
||||
:(d.task_job?'<b>'+esc(d.task_job)+'</b>':'');
|
||||
|
||||
+35
-1
@@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from core import device_pool, device_discovery
|
||||
from core import device_battery, device_pool, device_discovery
|
||||
from core.adb_helper import adb_connect, adb_connect_light
|
||||
from core.logger import get_logger
|
||||
from web.auth import perm_required, PERM_DEVICES
|
||||
@@ -411,6 +411,40 @@ def api_devices_discovery_settings():
|
||||
return jsonify({"ok": True, "msg": msg})
|
||||
|
||||
|
||||
# ================== API:设备电量(采集/阈值/告警) ==================
|
||||
# 电量本身通过 /api/status 随设备列表一起下发(大屏/监控同一份数据);
|
||||
# 这里是它的配置与采集器状态。低电量告警走通知子系统(事件 device.battery.low)。
|
||||
@bp.route("/api/devices/battery", methods=["GET"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_battery():
|
||||
"""电量采集器状态 + 当前配置(面板显示"上次采集 12:00 · 14/14 台")。"""
|
||||
try:
|
||||
return jsonify({"ok": True, **device_battery.get_status()})
|
||||
except Exception as e:
|
||||
_log.warning(f"电量状态获取失败: {e}")
|
||||
return jsonify({"ok": False, "error": str(e)[:120]}), 503
|
||||
|
||||
|
||||
@bp.route("/api/devices/battery/settings", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_battery_settings():
|
||||
"""保存电量监控配置:{enabled?, low?, critical?, skip_charging?, interval?}。"""
|
||||
data = request.json or {}
|
||||
ok, msg, saved = device_battery.save_settings(data)
|
||||
if not ok:
|
||||
return jsonify({"ok": False, "error": msg}), 400
|
||||
return jsonify({"ok": True, "msg": msg, "settings": saved})
|
||||
|
||||
|
||||
@bp.route("/api/devices/battery/scan", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_battery_scan():
|
||||
"""立即采集一次(后台执行,约 1-3 秒;看「上次采集」或大屏刷新确认)。"""
|
||||
if not device_battery.scan_once():
|
||||
return jsonify({"ok": False, "error": "上一轮采集还没跑完,稍等一下"}), 409
|
||||
return jsonify({"ok": True, "msg": "采集已启动(约 1-3 秒)"})
|
||||
|
||||
|
||||
# ================== API:Tailscale 管理(仅管理员) ==================
|
||||
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
|
||||
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
|
||||
|
||||
+6
-1
@@ -6,8 +6,13 @@ import subprocess
|
||||
import shlex
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from flask import (Blueprint, jsonify, request, Response, render_template)
|
||||
from flask import (Blueprint, jsonify, request, Response, render_template,
|
||||
render_template_string)
|
||||
from flask_login import login_required
|
||||
# /locate 定位页要把 serial 拼进 HTML,而 render_template_string 的模板名是
|
||||
# "<template>"、**不会自动转义**(serial 来自 query,不转义就是一个反射 XSS),
|
||||
# 所以这里显式转义。blueprint 拆分时漏掉了这两个 import,/locate 一直 500。
|
||||
from markupsafe import escape as _esc
|
||||
|
||||
from core import device_pool
|
||||
from core.adb_helper import (screenshot, list_installed_apps, adb_connect,
|
||||
|
||||
@@ -459,6 +459,16 @@ def api_uiauto_devices():
|
||||
})
|
||||
seen.add(serial)
|
||||
added += 1
|
||||
# 设备名用**平台起的名字**(A08 / cs1):uiautodev 那份 name 是设备自己的
|
||||
# codename(实测一柜子机器全叫 "earth"),同型号好几台时等于没有——
|
||||
# 而抓取元素/试执行必须选对设备,所以池外设备宁可退回地址(地址至少唯一)。
|
||||
try:
|
||||
names = {d["serial"]: (d.get("name") or "") for d in device_pool.list_devices()}
|
||||
except Exception:
|
||||
names = {}
|
||||
for d in data:
|
||||
s = d.get("serial") or ""
|
||||
d["name"] = names.get(s) or s
|
||||
if added:
|
||||
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
|
||||
return jsonify({"ok": True, "devices": data})
|
||||
|
||||
@@ -98,10 +98,16 @@ device_pool.init_app(app)
|
||||
# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池)
|
||||
from core import device_discovery
|
||||
device_discovery.init_app(app)
|
||||
# 设备电量采集:定时 dumpsys battery(只读)→ 内存缓存 → /api/status 展示 + 低电量告警
|
||||
from core import device_battery
|
||||
device_battery.init_app(app)
|
||||
mgr = TaskManager(app=app)
|
||||
# 设备换地址(认领/迁址)后同步分组与任务的引用:它们的"内存副本"在 TaskManager 里
|
||||
# (调度用内存对象),device_pool 只改库不改内存不生效,所以在此注册回调打通
|
||||
device_pool.set_move_hook(mgr.sync_device_serial)
|
||||
# 通知里要显示设备名(A08)而不是地址(IP):notifier 只读 device_pool 的**内存**名称
|
||||
# 快照,不查库(notify 的红线是零 DB)。快照在 device_pool.init_app 里已刷过一次。
|
||||
_notifier.set_device_name_resolver(device_pool.name_of)
|
||||
apk_mgr = ApkManager(app=app)
|
||||
|
||||
# Web 层按模块拆分(web/ 蓝图包):认证/监控/任务/管理/工具/设备池/APK/Tailscale
|
||||
@@ -352,4 +358,5 @@ if __name__ == "__main__":
|
||||
pass
|
||||
mgr.shutdown()
|
||||
device_discovery.shutdown()
|
||||
device_battery.shutdown()
|
||||
_stop_uiauto()
|
||||
|
||||
Reference in New Issue
Block a user