Files
auto_control/web/monitor.py
T
butubb 8fdde978ea fix(设备名): 选择设备与所有通知都显示名称而不是 IP
用户反馈:① 元素抓取的选择设备列表显示的是 IP;② 所有 webhook 通知里都是 IP;
要都显示设备名。

- 通知链路统一补名字(调用点不用动):`core/notifier.py`
  · 新增 `set_device_name_resolver()` + `fill_device_names()`,在 `notify()` 与
    `build_message()`(预览/测试发送也走它)里把 `serial` 补成 `device_name`、
    把 `serials`/`devices` 列表逐项换成名字。
  · 补一处就全带名字了——标题主体、字段表、聚合样本认的都是 `device_name`。
  · **做成"纯内存回调"是刻意的**:notify() 的硬红线是零 DB,不能为了取个名字去查库
    (那等于在业务线程里加一次阻塞查询)。
  · 降级规则:调用点自己传了 device_name 就用它的;查不到名字(没命名/不在池里)
    保留原地址;解析器缺失或抛异常都只是降级,绝不影响发送。
- `core/device_pool.py`:维护 `serial → 名称` 内存快照——`init_app` 同步刷一次、
  增删改名/迁址后各刷一次(改名立刻生效)、`device-names` 线程每 60s 兜底刷一次
  (覆盖整库恢复这类进程外改动)。`name_of()` 只读内存,可在通知路径上安全调用。
- `web_server.py`:装配层接上 `notifier.set_device_name_resolver(device_pool.name_of)`。
- 元素抓取/测试此步骤的设备列表:`GET /api/uiauto/devices` 的 `name` 改用**平台名**,
  前端 `editor.js` 新增共用的 `_devCard()`——名字做主标题(粗体),
  `型号 · 地址` 作副标题。
  · 池外设备**退回地址而不是 uiautodev 的 name**:实测那份 name 是设备 codename
    (一柜子机器全叫 "earth"),拿它认设备等于没名字,地址至少唯一。
  · 「测试此步骤」的设备列表原来连状态角标都没有,一并统一成同一个卡片。
- 顺带修一个**既有 bug(不是本次需求)**:`/locate` 设备端定位页从 ce47a5b 那次
  web 蓝图拆分起就一直 500——拆分时漏掉了 `render_template_string` 与
  `markupsafe.escape as _esc` 两个 import。后者不只是缺个名字:定位页把 query 里的
  serial 拼进 HTML,而 `render_template_string` 的模板名是 "<template>"、
  **不会自动转义**,所以那还是个反射 XSS。已显式转义(已用 `<img onerror>` 验证)。

自测:device_pool 快照/改名即时生效、fill_device_names 全分支(含列表、
未命名保留地址、不在池保留地址、解析器缺失/抛异常降级)、真消息渲染断言
**通篇不含 IP**;GET 路由冒烟 54 个 0 个 500;前端语法 + 卡片渲染截图核对。
2026-09-24 08:55:10 +08:00

715 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""监控域 API:状态/运行控制/设备操作/远程看屏。"""
import time
import threading
import re
import subprocess
import shlex
from concurrent.futures import ThreadPoolExecutor, as_completed
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,
adb_connect_light, _ADB_LOCK, _adb)
from core.device_worker import (get_all_worker_status, clear_worker_error,
clear_all_worker_errors)
from web import context
from web.auth import perm_required, PERM_DEVICES
from web.common import _merged_device_list, _device_screen_state
from config import ADB_PATH
from core.logger import get_logger
_log = get_logger("web")
bp = Blueprint("monitor", __name__)
# 远程看屏参数
_SCREEN_JPEG_QUALITY = 65
_SCREEN_FRAME_GAP = 0.05
@bp.route("/api/health")
def api_health():
"""轻量健康检查:返回进程/设备/任务摘要,不暴露敏感信息,供运维探活。"""
try:
devices, err = context.mgr.get_status()
devs = devices or []
return jsonify({
"ok": True,
"status": "up",
"time": time.time(),
"device_total": len(devs),
"device_online": sum(1 for d in devs if d.get("present")),
"device_running": sum(1 for d in devs
if d.get("worker_status") in ("running", "connecting")),
"device_error": sum(1 for d in devs
if d.get("worker_status") in ("error", "failed")),
"jobs": len(context.mgr.jobs),
})
except Exception as e:
return jsonify({"ok": False, "status": "down", "error": str(e)}), 500
# ================== API:失败任务汇总 ==================
@bp.route("/api/summary")
@login_required
def api_summary():
"""失败/异常任务汇总:各状态计数 + 异常设备列表(last_error / 重试次数)。
供监控页"异常汇总"面板使用,便于长期运行观察设备健康度。
"""
try:
sts = get_all_worker_status()
# 已删除/不在当前 STF 设备池的设备,其陈旧失败记录不应展示。
# 复用 get_status 缓存(顺带触发上面的状态清理),STF 异常时不隐藏任何记录。
try:
devices, derr = context.mgr.get_status()
if not derr:
present = {d.get("serial") for d in devices}
sts = [s for s in sts if s.get("serial") in present]
except Exception:
pass
counts = {"total": 0, "running": 0, "done": 0,
"error": 0, "failed": 0, "idle": 0}
errors = []
for s in sts:
st = s.get("status", "idle")
counts["total"] += 1
if st in counts:
counts[st] += 1
if st in ("error", "failed") and s.get("last_error"):
errors.append({
"serial": s.get("serial"),
"model": s.get("model", ""),
"status": st,
"last_error": s.get("last_error", ""),
"task": s.get("task_job", ""),
"attempt": s.get("attempt", 0),
"updated": s.get("last_heartbeat", 0),
})
errors.sort(key=lambda x: x.get("updated", 0), reverse=True)
return jsonify({"ok": True, "counts": counts, "errors": errors[:50]})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# ================== API:状态(监控大屏用)==================
@bp.route("/api/status")
@login_required
def api_status():
devices, err = context.mgr.get_status()
if err:
return jsonify({"ok": False, "error": err}), 500
# 按**名称**排(A01/A02/…)——之前是 adb 返回的顺序,看着像按 IP 排,很别扭。
# 没名字的排在最后、彼此按地址排(历史数据里可能有没命名的设备)。
devices.sort(key=lambda d: (
1 if not (d.get("device_name") or "").strip() else 0,
(d.get("device_name") or "").strip() or d.get("serial", "")))
return jsonify({
"ok": True, "devices": devices, "server_time": time.time(),
"fg_scanning": context.mgr._fg_scanner.is_scanning,
"fg_last_scan": context.mgr._fg_scanner.last_scan_time,
})
@bp.route("/api/scan_foreground", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_scan_foreground():
"""手动触发前台 App 扫描(不打扰设备)。"""
started = context.mgr._fg_scanner.scan_once()
if started:
return jsonify({"ok": True, "msg": "扫描已启动"})
return jsonify({"ok": False, "error": "已有扫描在进行中"})
@bp.route("/api/stop_device", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_stop_device():
serial = (request.json or {}).get("serial", "")
if context.mgr.stop_device(serial):
return jsonify({"ok": True, "msg": f"已发送停止信号给 {serial}"})
return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400
@bp.route("/locate")
def locate_page():
"""设备端定位页(免登录):点亮后浏览器打开,大字显示设备 IP/序列号。
只展示 serial 文本,无任何平台数据,免登录可接受。
"""
serial = request.args.get("serial", "")
return render_template_string(
"""<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备定位</title><style>
body{margin:0;background:#000;color:#fff;display:flex;flex-direction:column;
align-items:center;justify-content:center;height:100vh;font-family:monospace}
.serial{font-size:min(9vw,72px);font-weight:700;color:#ffd700;word-break:break-all;padding:0 20px;text-align:center}
.ip{font-size:min(5vw,36px);color:#7dd3fc;margin-top:24px;word-break:break-all;padding:0 20px;text-align:center}
.hint{font-size:min(3vw,16px);color:#64748b;margin-top:40px}
</style></head><body>
<div class="serial">{{ serial }}</div>
<div class="ip">{{ ip }}</div>
<div class="hint">定位完成 · 按返回键退出</div>
</body></html>""",
serial=_esc(serial), ip=_esc(serial.split(":")[0]))
# 常见浏览器包名(结束定位时 force-stop 用)
_BROWSER_PKGS = {"com.android.browser", "com.miui.browser", "com.android.chrome",
"com.brave.browser", "com.opera.browser", "org.mozilla.firefox",
"com.UCMobile", "com.tencent.mtt", "com.baidu.browser.apps"}
# 定位时启动的浏览器:serial -> 包名(结束定位时无论前后台都关闭它)
_locate_browsers = {}
_locate_lock = threading.Lock()
def _current_focus_pkg(serial):
"""取设备当前前台包名(dumpsys window 解析,失败返回空串)。"""
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"],
capture_output=True, timeout=20)
out = (r.stdout or b"").decode("utf-8", errors="replace")
m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out)
return m.group(1) if m else ""
except Exception:
return ""
@bp.route("/api/device/locate/stop", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_locate_stop():
"""结束定位:关闭定位启动的浏览器(无论当前是否前台)。
优先 force-stop 定位时记录下的浏览器包名(切到后台也能关掉);
无记录时退回:前台是浏览器 → force-stop;否则按返回键(不误杀任务应用)。
"""
serial = str((request.json or {}).get("serial", "")).strip()
if not serial:
return jsonify({"ok": False, "error": "缺少设备 serial"}), 400
try:
if ":" in serial:
adb_connect(serial)
# 1. 优先关闭定位时启动的浏览器(无论前后台)
with _locate_lock:
started = _locate_browsers.pop(serial, None)
if started:
subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", started],
capture_output=True, timeout=20)
msg = f"已关闭定位浏览器 {started}"
else:
# 2. 退回:前台是浏览器则关闭,否则返回键轻量退出
pkg = _current_focus_pkg(serial)
if pkg in _BROWSER_PKGS:
subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", pkg],
capture_output=True, timeout=20)
msg = f"已关闭浏览器 {pkg}"
else:
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "4"],
capture_output=True, timeout=15) # BACK 轻量退出
msg = "已按返回键退出" + (f"(前台 {pkg})" if pkg else "")
_log.info(f"结束定位 {serial}: {msg}")
return jsonify({"ok": True, "msg": msg})
except Exception as e:
return jsonify({"ok": False, "error": f"结束定位失败: {e}"}), 502
@bp.route("/api/device/screen_all", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_screen_all():
"""批量亮屏/息屏(并发)。mode: on=亮屏解锁 / off=息屏。
请求可带 serials 指定设备(前端勾选);不带则作用于全部在线设备。
息屏会中断运行中的任务(u2 无法操作),前端已有确认提示。
"""
data = request.json or {}
mode = data.get("mode", "on")
try:
online = set(device_pool.list_online())
except Exception as e:
return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502
serials = data.get("serials") or []
if serials:
# 指定设备:过滤掉不在线的(离线设备操作无意义,直接跳过)
serials = [s for s in serials if s in online]
else:
serials = list(online)
if not serials:
return jsonify({"ok": False, "error": "无在线设备"}), 400
def _do(serial):
try:
if ":" in serial:
adb_connect(serial)
if mode == "off":
# KEYCODE_SLEEP(223) 是**单向**熄屏;不能用 KEYCODE_POWER(26)——
# 那是电源键**开关**,对已经息屏的设备会把屏幕点亮(2026-09-14 用户反馈:
# "一键息屏"把睡着的设备唤醒了,看着像按钮反了)
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "223"],
capture_output=True, timeout=15)
else:
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"],
capture_output=True, timeout=15) # WAKEUP
subprocess.run([ADB_PATH, "-s", serial, "shell", "wm", "dismiss-keyguard"],
capture_output=True, timeout=15)
return True
except Exception:
return False
with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool:
results = list(pool.map(_do, serials))
ok_n = sum(1 for x in results if x)
_log.info(f"批量{'息屏' if mode == 'off' else '亮屏'}: 成功 {ok_n}/{len(serials)}")
return jsonify({"ok": True, "success": ok_n, "total": len(serials)})
@bp.route("/api/device/locate", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_locate():
"""定位设备:点亮屏幕并解除锁屏,可选打开大字定位页(查找设备用)。
请求 {"serial": "...", "show": true}:show=true 时额外用浏览器打开
平台 /locate 定位页,全屏大字显示设备 IP(更醒目,但会切换前台,
任务运行中慎用)。点亮组合:WAKEUP → dismiss-keyguard → MENU 兜底。
绝不 disconnect,红线。
"""
data = request.json or {}
serial = str(data.get("serial", "")).strip()
if not serial:
return jsonify({"ok": False, "error": "缺少设备 serial"}), 400
try:
if ":" in serial:
adb_connect(serial)
for cmd in (["shell", "input", "keyevent", "224"],
["shell", "wm", "dismiss-keyguard"],
["shell", "input", "keyevent", "82"]):
try:
subprocess.run([ADB_PATH, "-s", serial, *cmd],
capture_output=True, timeout=15)
except Exception:
pass # 单步失败不阻塞,继续下一步
msgs = ["屏幕已点亮"]
if data.get("show"):
# 用设备浏览器打开定位页(设备走 tailnet 访问本机 100.100.10.2:18050)
locate_url = f"http://100.100.10.2:18050/locate?serial={urllib.parse.quote(serial)}"
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "start",
"-a", "android.intent.action.VIEW", "-d", locate_url],
capture_output=True, timeout=20)
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if "error" in out.lower() or "exception" in out.lower():
msgs.append(f"打开定位页失败: {out.strip()[:80]}")
else:
msgs.append("已打开大字定位页(按返回键退出)")
# 记录启动的浏览器包名:结束定位时无论前后台都关闭它。
# 优先从 am start 输出解析 pkg=(浏览器可能尚未到前台,前台检测会扑空)
pkg = ""
m2 = re.search(r"pkg=([\w.]+)", out)
if m2 and m2.group(1) in _BROWSER_PKGS:
pkg = m2.group(1)
else:
time.sleep(1.5) # 等浏览器切到前台再查
pkg = _current_focus_pkg(serial)
if pkg in _BROWSER_PKGS:
with _locate_lock:
_locate_browsers[serial] = pkg
_log.info(f"定位设备 {serial}: {'; '.join(msgs)}")
return jsonify({"ok": True, "msg": f"{serial} {';'.join(msgs)}"})
except Exception as e:
return jsonify({"ok": False, "error": f"定位失败: {e}"}), 502
@bp.route("/api/stop_all", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_stop_all():
stopped = context.mgr.stop_all()
return jsonify({"ok": True, "stopped": stopped})
@bp.route("/api/device/clear_error", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_clear_error():
"""清除单台设备的异常状态(error/failed → idle),供设备列表"清除异常"按钮使用。"""
serial = (request.json or {}).get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
if serial in context.mgr.get_running():
return jsonify({"ok": False, "error": "设备正在运行或等待重试,无法清除"}), 400
if not clear_worker_error(serial):
return jsonify({"ok": False, "error": "设备正在运行任务,无法清除"}), 400
_log.info(f"清除设备 {serial} 的异常状态")
return jsonify({"ok": True, "msg": f"已清除 {serial} 的异常状态"})
@bp.route("/api/device/clear_all_errors", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_clear_all_errors():
"""一键清除所有异常/失败设备(跳过正在运行/等待重试的)。"""
running = set(context.mgr.get_running())
cleared = clear_all_worker_errors(exclude=running)
_log.info(f"一键清除异常:共清除 {cleared} 台")
return jsonify({"ok": True, "cleared": cleared,
"msg": f"已清除 {cleared} 台设备的异常状态"})
@bp.route("/api/device/screenshot")
@login_required
def api_device_screenshot():
"""获取设备当前画面截图(PNG)。
用 adb exec-out screencap -p,只读操作,不抢占 u2 的 atx-agent 通道,
任务运行中调用安全。直接返回 image/png,前端用 <img> 加载。
?serial=xxx 设备 serial
?t=123 时间戳,避免浏览器缓存(前端自动加)
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, data = screenshot(serial)
if ok:
return Response(data, mimetype="image/png",
headers={"Cache-Control": "no-store"})
return jsonify({"ok": False, "error": data}), 500
@bp.route("/api/devices/<serial>/apps")
@login_required
def api_device_apps(serial):
"""获取指定设备上已安装的应用列表(包名 + versionCode + versionName + 路径)。"""
ok, data = list_installed_apps(serial)
if ok:
return jsonify({"ok": True, "apps": data})
return jsonify({"ok": False, "error": data}), 500
# ================== 自定义动作(步骤打包) ==================
@bp.route("/api/screen/stream")
@perm_required(PERM_DEVICES)
def api_screen_stream():
"""远程看屏:MJPEG 实时画面流(multipart/x-mixed-replace)。
?serial=xxx 设备 serial
浏览器 <img> 直接渲染;客户端断开(GeneratorExit)自动停止,不占资源。
设备离线/atx-agent 无响应时流自然结束,前端提示重新连接。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
# 参数:q=JPEG 质量(大屏弹窗用 85 更清晰);fps=目标帧率(0=默认节流)
try:
quality = max(30, min(95, int(request.args.get("q", _SCREEN_JPEG_QUALITY))))
fps = float(request.args.get("fps", 0) or 0)
except ValueError:
quality, fps = _SCREEN_JPEG_QUALITY, 0
gap = (1.0 / fps) if fps > 0 else _SCREEN_FRAME_GAP
def generate():
import io
import uiautomator2 as u2
try:
d = u2.connect(serial)
except Exception as e:
_log.warning(f"远程看屏 {serial} u2 连接失败: {e}")
return
while True:
try:
img = d.screenshot()
if img is None:
break
if img.mode != "RGB":
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, "JPEG", quality=quality)
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"
+ buf.getvalue() + b"\r\n")
except GeneratorExit:
break
except Exception as e:
_log.debug(f"远程看屏 {serial} 流中断: {e}")
break
time.sleep(gap)
return Response(generate(),
mimetype="multipart/x-mixed-replace; boundary=frame")
_screen_u2_cache = {} # serial -> (ts, device):大屏高频触控复用连接(60s TTL)
def _screen_get_device(serial):
"""远程看屏用的 u2 连接(60s 缓存复用,避免每次 tap/swipe 都重建连接)。
大屏高频操作(点击/滑动/按键)每次 u2.connect 有 1-3s 握手开销(atx-agent
探测),体验卡顿。缓存 60s 内复用;连接失败(设备离线/重启)时清缓存重建,
由调用方把异常转 503。
"""
import time
import uiautomator2 as u2
now = time.time()
cached = _screen_u2_cache.get(serial)
if cached and now - cached[0] < 60:
return cached[1]
d = u2.connect(serial)
_screen_u2_cache[serial] = (now, d)
if len(_screen_u2_cache) > 30: # 惰性清理过期项
for k in list(_screen_u2_cache):
if now - _screen_u2_cache[k][0] >= 60:
del _screen_u2_cache[k]
return d
def _screen_invalidate(serial):
"""u2 调用失败时清掉缓存(设备离线/atx-agent 重启),下次请求重新连接。"""
_screen_u2_cache.pop(serial, None)
@bp.route("/api/screen/thumb")
@perm_required(PERM_DEVICES)
def api_screen_thumb():
"""大屏缩略图:单张 JPEG(360px 宽,质量 55),供监控大屏轮询。
一次性请求(非流),与任务并发安全(与任务截图同走 u2 minicap)。
大屏按需轮询:每设备约 2.5s 一帧,20 台 ≈ 0.2 核 CPU + 100KB/s。
响应头 X-Screen-State: on/off/unknown —— 大屏据此显示「亮屏中/熄屏中」。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
import io
import uiautomator2 as u2
try:
d = u2.connect(serial)
img = d.screenshot()
if img is None:
return jsonify({"ok": False, "error": "截图失败"}), 503
if img.width > 360:
ratio = 360 / img.width
img = img.resize((360, int(img.height * ratio)))
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=55)
return Response(buf.getvalue(), mimetype="image/jpeg",
headers={"Cache-Control": "no-store",
"X-Screen-State": _device_screen_state(serial)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 503
def _snap_to_clickable(d, x, y):
"""坐标吸附:找包含 (x,y) 的最小可点击元素,返回其 bounds 中心。
AI/触控给的坐标常偏离目标 20-50px(模型视觉定位误差)。dump 当前 UI 树后
遍历 clickable 节点:点击点落在哪个可点元素内就点它的中心——偏了也点得准;
取包含元素中面积最小者(最具体的那个)。点空白处(收键盘等)无包含元素则
原坐标返回。dump 失败也不阻塞,直接原坐标。返回 (cx, cy, snapped, label)。
"""
try:
import xml.etree.ElementTree as ET
xml_str = d.dump_hierarchy()
best = None # (area, cx, cy, label)
for node in ET.fromstring(xml_str).iter("node"):
if node.get("clickable") != "true":
continue
if node.get("enabled") == "false":
continue
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if not m:
continue
x1, y1, x2, y2 = map(int, m.groups())
if x1 <= x <= x2 and y1 <= y <= y2:
area = (x2 - x1) * (y2 - y1)
if best is None or area < best[0]:
best = (area, (x1 + x2) // 2, (y1 + y2) // 2,
(node.get("text") or node.get("content-desc") or "")[:40])
if best:
return best[1], best[2], True, best[3]
except Exception:
pass
return x, y, False, ""
@bp.route("/api/screen/tap", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_tap():
"""点击:{serial, x, y, snap?}(设备原生分辨率坐标)。
snap=1 时先吸附:点落在可点击元素内则改点元素中心(MCP/AI 场景用,粗略
坐标也能点准);大屏精确触控不带 snap 保持原行为。
返回 {ok, snapped, x, y, label}。
"""
data = request.json or {}
serial, x, y = data.get("serial", ""), data.get("x"), data.get("y")
snap = int(data.get("snap", 0) or 0) == 1
if not serial or x is None or y is None:
return jsonify({"ok": False, "error": "缺少 serial/x/y"}), 400
try:
d = _screen_get_device(serial)
sx, sy = int(x), int(y)
label = ""
snapped = False
if snap:
sx, sy, snapped, label = _snap_to_clickable(d, sx, sy)
d.click(sx, sy)
return jsonify({"ok": True, "snapped": snapped, "x": sx, "y": sy,
"label": label})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503
@bp.route("/api/screen/tap_text", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_tap_text():
"""按屏幕文字点击:{serial, text}——找到显示该文字的位置并点中心(子串匹配)。
两步:① UI 树 textContains/descriptionContains 命中 → 点元素中心(原生控件);
② 未命中 → 截图 OCR 找文字中心(WebView/图片/画布渲染的文字)。
返回 {ok, found, method: ui|ocr, matched, x, y};found=false 表示屏幕确实
没有该文字(业务结果非设备错误);OCR 不可用且 UI 未命中时也返回 found=false。
"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
text = (data.get("text") or "").strip()
if not serial or not text:
return jsonify({"ok": False, "error": "缺少 serial/text"}), 400
if len(text) > 100:
return jsonify({"ok": False, "error": "文字过长(≤100 字符)"}), 400
try:
d = _screen_get_device(serial)
# ① UI 树:text / content-desc 子串匹配(原生控件最快最准)
for kw in ({"textContains": text}, {"descriptionContains": text}):
try:
el = d(**kw)
if el.exists(timeout=1.5):
# u2 版本差异:3.x 部分版本 bounds 是方法(el.bounds())
b = el.bounds() if callable(el.bounds) else el.bounds
el.click()
return jsonify({"ok": True, "found": True, "method": "ui",
"matched": text,
"x": (b[0] + b[2]) // 2, "y": (b[1] + b[3]) // 2})
except Exception:
continue
# ② OCR 兜底:截图找文字中心(UI 树没有的渲染文字)
img = d.screenshot()
if img is not None:
from core.ocr import find_on_screen
found, center, matched = find_on_screen(img, text)
if found:
d.click(*center)
return jsonify({"ok": True, "found": True, "method": "ocr",
"matched": matched,
"x": center[0], "y": center[1]})
return jsonify({"ok": True, "found": False, "method": "",
"matched": "", "x": 0, "y": 0})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"文字点击失败: {e}"}), 503
@bp.route("/api/screen/swipe", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_swipe():
"""滑动:{serial, x1, y1, x2, y2, duration?}(设备原生分辨率坐标)。"""
data = request.json or {}
serial = data.get("serial", "")
x1, y1, x2, y2 = (data.get(k) for k in ("x1", "y1", "x2", "y2"))
if not serial or None in (x1, y1, x2, y2):
return jsonify({"ok": False, "error": "缺少 serial/x1/y1/x2/y2"}), 400
try:
_screen_get_device(serial).swipe(int(x1), int(y1), int(x2), int(y2),
duration=float(data.get("duration", 0.2)))
return jsonify({"ok": True})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503
_SCREEN_KEYS = {"back", "home", "recent", "menu", "power", "volume_up",
"volume_down", "enter", "delete", "search", "camera"}
@bp.route("/api/screen/key", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_key():
"""按键:{serial, key}(back/home/recent/menu/power 等,见 _SCREEN_KEYS)。"""
data = request.json or {}
serial, key = data.get("serial", ""), (data.get("key") or "").strip().lower()
if not serial or key not in _SCREEN_KEYS:
return jsonify({"ok": False, "error": "缺少 serial 或不支持的按键"}), 400
try:
_screen_get_device(serial).press(key)
return jsonify({"ok": True})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503
@bp.route("/api/screen/text", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_text():
"""输入文字:{serial, text}(需焦点在输入框,u2 send_keys)。"""
data = request.json or {}
serial, text = data.get("serial", ""), (data.get("text") or "").strip()
if not serial or not text:
return jsonify({"ok": False, "error": "缺少 serial/text"}), 400
try:
_screen_get_device(serial).send_keys(text)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": f"输入失败: {e}"}), 503
@bp.route("/api/screen/size")
@perm_required(PERM_DEVICES)
def api_screen_size():
"""屏幕原生分辨率(只读,供 MCP 坐标换算:截图是缩放图,操作需原生坐标)。
返回 {ok, width, height}(如 1080x2400);离线/不可达 → 503。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
try:
d = _screen_get_device(serial)
w, h = d.window_size()
return jsonify({"ok": True, "width": w, "height": h})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"获取分辨率失败: {e}"}), 503
# ================== API:应用管理(APK 上传/安装)==================
def _current_focus_pkg(serial):
"""取设备当前前台包名(dumpsys window 解析,失败返回空串)。"""
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"],
capture_output=True, timeout=20)
out = (r.stdout or b"").decode("utf-8", errors="replace")
m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out)
return m.group(1) if m else ""
except Exception:
return ""
_screen_u2_cache = {} # serial -> (ts, device):大屏高频触控复用连接(60s TTL)
def _screen_get_device(serial):
"""远程看屏用的 u2 连接(60s 缓存复用,避免每次 tap/swipe 都重建连接)。
大屏高频操作(点击/滑动/按键)每次 u2.connect 有 1-3s 握手开销(atx-agent
探测),体验卡顿。缓存 60s 内复用;连接失败(设备离线/重启)时清缓存重建,
由调用方把异常转 503。
"""
import time
import uiautomator2 as u2
now = time.time()
cached = _screen_u2_cache.get(serial)
if cached and now - cached[0] < 60:
return cached[1]
d = u2.connect(serial)
_screen_u2_cache[serial] = (now, d)
if len(_screen_u2_cache) > 30: # 惰性清理过期项
for k in list(_screen_u2_cache):
if now - _screen_u2_cache[k][0] >= 60:
del _screen_u2_cache[k]
return d
def _screen_invalidate(serial):
"""u2 调用失败时清掉缓存(设备离线/atx-agent 重启),下次请求重新连接。"""
_screen_u2_cache.pop(serial, None)