Merge branch 'feat/task-patrol'——任务级公共巡检(含与拟人滑动的合并:import、能力表两处冲突已解)
This commit is contained in:
+18
-7
@@ -310,10 +310,15 @@ _LEVEL_BY_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def _level_of(event_key):
|
||||
for hints, lv in _LEVEL_BY_HINT:
|
||||
def _level_of(event_key, fields=None):
|
||||
"""消息级别:**事件自己带的 `level` 优先**(步骤「发通知」/巡检都能自己选),
|
||||
否则按事件 key 猜(failed→error、success→success…),猜不到算 info。"""
|
||||
lv = (fields or {}).get("level")
|
||||
if lv in ("info", "success", "warning", "error"):
|
||||
return lv
|
||||
for hints, lv2 in _LEVEL_BY_HINT:
|
||||
if any(h in event_key for h in hints):
|
||||
return lv
|
||||
return lv2
|
||||
return "info"
|
||||
|
||||
|
||||
@@ -331,6 +336,8 @@ _FIELD_LABELS = {
|
||||
"timeout_s": "超时(秒)", "task_job": "原任务", "model": "型号",
|
||||
"apk_name": "应用", "apk_id": "应用ID", "package_name": "包名",
|
||||
"success": "成功", "failed": "失败", "skipped": "跳过", "total": "总数",
|
||||
"title": "标题", "message": "内容", "patrol_name": "巡检",
|
||||
"check_label": "检查", "action_label": "动作", "detail": "结果",
|
||||
"stopped": "停止", "failed_devices": "失败设备",
|
||||
"failed_items": "失败明细", "filename": "文件", "size": "大小(字节)",
|
||||
"tables": "表数", "include_apk": "含APK", "user": "操作人", "operator": "操作人",
|
||||
@@ -350,7 +357,9 @@ def _label(k):
|
||||
|
||||
|
||||
def _subject(ev, fields):
|
||||
"""标题主体:优先任务名,其次设备名/serial。"""
|
||||
"""标题主体:**用户自己写的 title 优先**,其次任务名,再次设备名/serial。"""
|
||||
if fields.get("title"):
|
||||
return str(fields["title"])
|
||||
for k in ("job_name", "apk_name", "hook_name"):
|
||||
if fields.get(k):
|
||||
return str(fields[k])
|
||||
@@ -365,9 +374,11 @@ def build_message(ev, fields, hook=None):
|
||||
"""把事件渲染成统一消息体(title/summary/fields/level/markdown)。"""
|
||||
fields = dict(fields or {})
|
||||
merged = fields.pop("_merged", None)
|
||||
level = _level_of(ev.key)
|
||||
level = _level_of(ev.key, fields)
|
||||
subj = _subject(ev, fields)
|
||||
title = ev.label + (f":{subj}" if subj else "")
|
||||
# 「发通知」步骤自带标题时**直接用它**:用户写的是"抖音号1 掉线了",
|
||||
# 前面再挂一个"任务自定义通知:"纯属噪音(其它事件照旧带事件名)
|
||||
title = str(fields["title"]) if fields.get("title") else ev.label + (f":{subj}" if subj else "")
|
||||
if merged and merged.get("merged_count", 0) > 1:
|
||||
title = f"{ev.label}:{subj}" if subj else ev.label
|
||||
title += f"({merged['merged_count']} 次)"
|
||||
@@ -382,7 +393,7 @@ def build_message(ev, fields, hook=None):
|
||||
continue
|
||||
# 标题里已经写出来的主体(任务名/设备名)不再重复占一行:
|
||||
# "任务批次结束:抖音养号" 下面再来一行 "任务:抖音养号" 是纯噪音
|
||||
if k in ("job_name", "device_name", "apk_name", "hook_name") \
|
||||
if k in ("job_name", "device_name", "apk_name", "hook_name", "title") \
|
||||
and str(v) == str(subj):
|
||||
continue
|
||||
# 设备名优先:两个都带时只显示名字——IP 是给日志看的,通知里没人想读地址
|
||||
|
||||
@@ -96,6 +96,17 @@ EVENTS = [
|
||||
"单次 attempt 抛异常"),
|
||||
|
||||
# ---------------- 业务(任务内容) ----------------
|
||||
_e("task.patrol.hit", "任务巡检命中", "业务",
|
||||
["title", "message", "patrol_name", "check_label", "detail", "action_label",
|
||||
"job_id", "job_name", "serial", "device_name", "model"],
|
||||
"任务里配置的「公共巡检」命中(熄屏/元素/前台…),已执行动作——"
|
||||
"标题正文可在巡检项里自己写,支持 {device}/{job}/{time}/{app}/{screen}",
|
||||
agg_window=0, recommend=True),
|
||||
_e("task.notify.custom", "任务自定义通知", "业务",
|
||||
["title", "message", "job_id", "job_name", "serial", "device_name", "model"],
|
||||
"任务里的「发通知」步骤被触发——标题与正文由任务自己写,支持 "
|
||||
"{device}/{serial}/{job}/{time}/{app}/{screen} 占位符", agg_window=0,
|
||||
recommend=True),
|
||||
_e("task.selector.invalid", "选择器连续失效", "业务",
|
||||
["serial", "device_name", "selector", "miss_count"],
|
||||
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"""任务内**公共巡检**:任务运行期间按间隔穿插检查设备状态,命中就动作 + 通知。
|
||||
|
||||
和步骤编辑器的关系(重要,别混):
|
||||
|
||||
| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) |
|
||||
|---|---|---|
|
||||
| 配置位置 | 步骤画布,拖出来的 | 任务编辑器里单独一块 |
|
||||
| 执行方式 | 顺序执行 | **穿插**:每执行完一步(以及长等待的每个分片)看一眼"哪个巡检到点了" |
|
||||
| 适合什么 | 一段固定流程 | 全程都要盯的守护条件(熄屏就点亮、掉到桌面就通知) |
|
||||
|
||||
为什么用穿插而不是另起线程:不需要并发模型,不会和主流程抢屏幕(否则巡检点屏幕、
|
||||
主流程也在点,动作会互相打断)。代价是巡检精度受步长影响——某一步卡 30 秒,
|
||||
巡检最多晚 30 秒;这也是刻意的取舍(见 doc/TASK_DEV.md §4.5)。
|
||||
|
||||
配置结构(`params.watchers` 数组,每项):
|
||||
|
||||
```json
|
||||
{"name": "熄屏点亮", "enabled": true, "interval": 60,
|
||||
"check": "screen_off", // 见 CHECKS
|
||||
"selector_type": "xpath", "selector_value": "", // 元素类检查才用
|
||||
"action": "screen_on", // 见 ACTIONS
|
||||
"notify": true, "title": "{device} 熄屏了",
|
||||
"message": "任务 {job} 在 {time} 发现 {screen},已点亮",
|
||||
"cooldown": 300} // 命中后多久内不再重复动作/通知(防刷屏)
|
||||
```
|
||||
"""
|
||||
from core.logger import get_logger
|
||||
from core.u2_helper import screen_is_on, current_package
|
||||
|
||||
_log = get_logger("task.generic")
|
||||
|
||||
# 检查项:need 说明该项还要填什么(selector=元素选择器 / package=包名)
|
||||
CHECKS = {
|
||||
"screen_off": {"label": "屏幕熄灭", "need": ""},
|
||||
"screen_on": {"label": "屏幕亮着", "need": ""},
|
||||
"element_exists": {"label": "元素存在", "need": "selector"},
|
||||
"element_missing": {"label": "元素不存在", "need": "selector"},
|
||||
"foreground_is": {"label": "前台是该App", "need": "package"},
|
||||
"foreground_not": {"label": "前台不是该App", "need": "package"},
|
||||
}
|
||||
|
||||
ACTIONS = {
|
||||
"none": {"label": "只发通知,不动设备"},
|
||||
"screen_on": {"label": "点亮屏幕"},
|
||||
"screen_off": {"label": "熄灭屏幕"},
|
||||
"stop_self": {"label": "停止本设备任务"},
|
||||
}
|
||||
|
||||
# 通知正文里可用的占位符(前端提示、文档、渲染三处共用一份,避免漂移)
|
||||
PLACEHOLDERS = ("device", "serial", "job", "time", "app", "screen")
|
||||
|
||||
|
||||
def _element_exists(d, sel_type, sel_val):
|
||||
"""元素是否存在(不等超时,立即判定)。一次 UI dump 的代价,慢设备上可能要几秒。"""
|
||||
if sel_type == "xpath":
|
||||
return bool(d.xpath(sel_val).exists())
|
||||
return bool(d(**{sel_type: sel_val}).exists())
|
||||
|
||||
|
||||
def evaluate(d, spec):
|
||||
"""跑一次检查,返回 (是否命中, 一句人话说明)。异常由调用方兜。"""
|
||||
check = str(spec.get("check") or "")
|
||||
val = str(spec.get("selector_value") or "").strip()
|
||||
|
||||
if check in ("screen_off", "screen_on"):
|
||||
state = screen_is_on(d)
|
||||
if state is None:
|
||||
return False, "屏幕状态读不到"
|
||||
hit = (not state) if check == "screen_off" else state
|
||||
return hit, f"屏幕{'亮着' if state else '熄灭'}"
|
||||
|
||||
if check in ("element_exists", "element_missing"):
|
||||
if not val:
|
||||
return False, "没填选择器"
|
||||
found = _element_exists(d, spec.get("selector_type") or "xpath", val)
|
||||
return (found if check == "element_exists" else not found), \
|
||||
f"元素{'存在' if found else '不存在'}"
|
||||
|
||||
if check in ("foreground_is", "foreground_not"):
|
||||
if not val:
|
||||
return False, "没填包名"
|
||||
cur = current_package(d)
|
||||
return (cur == val) if check == "foreground_is" else (cur != val), \
|
||||
f"前台={cur or '未知'}"
|
||||
|
||||
return False, f"未知检查项 {check}"
|
||||
|
||||
|
||||
def act(d, spec, worker=None):
|
||||
"""执行命中后的动作,返回一句人话(没动作返回空串)。"""
|
||||
action = str(spec.get("action") or "none")
|
||||
if action == "screen_on":
|
||||
d.unlock() # 与 screen_on 步骤同语义:唤醒 + 解锁
|
||||
return "已点亮屏幕"
|
||||
if action == "screen_off":
|
||||
d.screen_off()
|
||||
return "已熄灭屏幕"
|
||||
if action == "stop_self":
|
||||
if worker is not None:
|
||||
worker.stop() # 置停止位,主流程下一步就退出
|
||||
return "已停止本设备任务"
|
||||
return ""
|
||||
|
||||
|
||||
def check_label(check):
|
||||
return (CHECKS.get(check) or {}).get("label", check)
|
||||
|
||||
|
||||
def action_label(action):
|
||||
return (ACTIONS.get(action) or {}).get("label", action)
|
||||
@@ -909,6 +909,18 @@ class TaskManager:
|
||||
job_name=job.name, attempt=attempt,
|
||||
phase="after")
|
||||
return
|
||||
if worker is not None and worker.stopped():
|
||||
# 任务**自己**停的(步骤里的「停止本设备」):不是失败,也不重试。
|
||||
# 判据是"worker 置了停止位但 _stop_requested 里没有"——上面那个
|
||||
# 分支已经排除了用户/调度停止的情况。
|
||||
_log.info(f"{serial} 任务内主动停止本设备 (job={job.name})")
|
||||
outcome = "stopped"
|
||||
notifier.notify("task.device.stopped", serial=serial,
|
||||
device_name=dname,
|
||||
model=_dev_model(serial, tracker),
|
||||
job_id=job.id, job_name=job.name,
|
||||
attempt=attempt, phase="self")
|
||||
return
|
||||
_log.warning(f"{serial} 任务未成功(status={st})")
|
||||
except DeviceOfflineError as e:
|
||||
# 设备掉线,立即放弃,不重试
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"""
|
||||
import time
|
||||
import random
|
||||
import re
|
||||
|
||||
from core.logger import get_logger
|
||||
|
||||
@@ -65,6 +66,34 @@ def random_sleep(min_s, max_s):
|
||||
time.sleep(random.uniform(min_s, max_s))
|
||||
|
||||
|
||||
def screen_is_on(d):
|
||||
"""屏幕是否亮着。返回 True/False,取不到返回 None(三态,别当 False 用)。
|
||||
|
||||
为什么不用 `d.info`:它在部分设备上一次要**十几秒**(见 doc/backlog/TODO.md);
|
||||
`dumpsys power` 经 atx-agent 跑只要 ~0.3s,且 `mWakefulness` 字段全机型都有
|
||||
(MIUI 的 `dumpsys display` 反而没有 mScreenState)。
|
||||
"""
|
||||
try:
|
||||
out = d.shell("dumpsys power")
|
||||
out = getattr(out, "output", None) or str(out)
|
||||
m = re.search(r"mWakefulness=(\w+)", out)
|
||||
if m:
|
||||
return m.group(1).lower() != "asleep"
|
||||
except Exception as e:
|
||||
_log.warning(f"读取屏幕状态失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def current_package(d):
|
||||
"""当前前台 App 包名;取不到返回空串。"""
|
||||
try:
|
||||
cur = d.app_current() or {}
|
||||
return cur.get("package") or ""
|
||||
except Exception as e:
|
||||
_log.warning(f"读取前台 App 失败: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def safe_click(el, timeout=1):
|
||||
"""安全点击:元素存在才点,不抛异常。返回是否点击成功。"""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user