3 Commits
12 changed files with 401 additions and 17 deletions
+1 -1
View File
@@ -252,7 +252,7 @@ self.set_progress(done=5, total=80, unit="视频",
| Tab | 功能 | 可见性 |
|-----|------|--------|
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) |
| 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 |
| 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间/**离线设备自动跳过**)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 |
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
+29 -3
View File
@@ -305,12 +305,38 @@ class ApkManager:
"设备未在本地 adb 列表中,请检查 USB 连接/驱动")
return
# 安装
self._set_item(serial, "installing", "正在安装...")
# 强制安装:绕过厂商安全防护(MIUI 等用 adb install 会弹"通过 USB 安装应用"
# 确认框需要手点;改为 推送 + pm install,shell 安装不弹窗)
# 1. 关闭 adb 安装校验(包校验器/安全守护拦截)
try:
_adb("-s", serial, "shell", "settings", "put", "global",
"verifier_verify_adb_installs", "0")
except Exception:
pass
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用)
self._set_item(serial, "installing", "正在推送 APK...")
r = subprocess.run(
[ADB_PATH, "-s", serial, "install", "-r", apk_path],
[ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"],
capture_output=True, timeout=_INSTALL_TIMEOUT
)
push_out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if r.returncode != 0 or "1 file pushed" not in push_out:
msg = push_out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})"
self._set_item(serial, "failed", msg)
_log.warning(f"[{serial}] APK 推送失败: {msg}")
return
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
self._set_item(serial, "installing", "正在安装...")
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
"/data/local/tmp/_install.apk"],
capture_output=True, timeout=_INSTALL_TIMEOUT
)
# 4. 清理设备临时文件(失败残留下次推送会覆盖,不影响)
try:
_adb("-s", serial, "shell", "rm", "-f", "/data/local/tmp/_install.apk")
except Exception:
pass
out = ((r.stdout or b"") + (r.stderr or b""))
try:
out = out.decode("utf-8", errors="replace")
+3
View File
@@ -10,6 +10,7 @@
失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。
"""
import re
import socket
import subprocess
import paramiko
@@ -91,6 +92,8 @@ def _run_paramiko(cmd, timeout):
client.close()
except paramiko.AuthenticationException:
raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)")
except socket.timeout:
raise SSHError(f"SSH 执行超时({timeout}s 内未完成,命令可能耗时较长)")
except paramiko.SSHException as e:
raise SSHError(f"SSH 连接失败: {e}")
except Exception as e:
+19
View File
@@ -160,6 +160,25 @@ class STFClient:
_log.error(f"释放 {serial} 失败: {msg}")
return released, failed
def delete_device(self, serial):
"""从 STF 池彻底删除设备记录(清理离线幽灵设备用)。
DELETE /api/v1/devices/{serial}(实测可用):断开后 STF 数据库里残留的
present=False 陈旧记录会一直显示在设备池,用此端点彻底移除。
"""
import urllib.parse
try:
r = requests.delete(
f"{self.base_url}/api/v1/devices/{urllib.parse.quote(serial, safe='')}",
headers=self.headers, timeout=_TIMEOUT)
if r.status_code in (200, 202, 204):
_log.info(f"已从 STF 池删除设备 {serial}")
return True, "已从 STF 池删除"
return False, f"HTTP {r.status_code}: {r.text[:150]}"
except requests.exceptions.RequestException as e:
_log.warning(f"删除 STF 设备 {serial} 请求异常: {e}")
return False, str(e)
# ================== 远程 ADB 隧道 ==================
def remote_connect(self, serial):
"""建立远程 ADB 隧道,返回 remoteConnectUrl。
+25
View File
@@ -131,3 +131,28 @@ def remove_device(ip):
msgs.append(out.strip() or (f"adb disconnect 执行完成(exit {code})" if code == 0 else "adb disconnect 无输出"))
_log.info(f"STF 移除设备 {ip}: {msgs}")
return msgs
def delete_device(ip, stf=None):
"""彻底删除设备:脚本移除 + disconnect + 清 STF 池记录(幽灵设备)。"""
msgs = remove_device(ip)
if stf is not None:
ok, msg = stf.delete_device(f"{_norm_ip(ip)}:5555")
msgs.append(msg)
_log.info(f"STF 彻底删除设备 {ip}: {msgs}")
return msgs
def reconnect_all():
"""一键重连:后台运行 220 的 connect_devices.sh(cron 之外的立即补连)。
脚本对未连接设备逐个 connect(不可达设备可能挂 40s+),全程可能 1-3 分钟,
因此 nohup 后台执行立即返回,不阻塞请求;结果用"刷新"看设备连接状态。
"""
cmd = (f"nohup bash {shlex.quote(STF_SCRIPT_PATH)} >/tmp/connect_devices.out 2>&1 &")
code, out, err = _ssh(cmd, timeout=15)
if code != 0:
raise StfDevError(f"启动重连失败: {err.strip() or out.strip()}")
_log.info("STF 一键重连已在 220 后台启动")
return True, ("重连已在 220 后台执行(对未连接设备逐个 connect,约 1-3 分钟),"
"稍后点\"刷新\"查看设备连接状态")
+33 -8
View File
@@ -146,15 +146,40 @@ class TaskJob:
"""根据 target 解析出实际要跑的 serial 列表。"""
mode = self.target.get("mode", "all")
if mode == "serial":
return [self.target["serial"]]
if mode == "group":
serials = [self.target["serial"]]
elif mode == "group":
g = manager.groups.get(self.target.get("group_name"))
return list(g.serials) if g else []
# all:返回所有空闲设备
try:
return [d["serial"] for d in manager.stf.list_free_devices()]
except Exception:
return []
serials = list(g.serials) if g else []
else:
# all:返回所有空闲设备(天然只含在线设备)
try:
return [d["serial"] for d in manager.stf.list_free_devices()]
except Exception:
return []
# 离线自动跳过(serial/group 模式):跳过 STF 池里不在线(present=False)或
# 未就绪(ready=False,provider 刚接入还在初始化)的设备,
# 避免对离线/未就绪设备反复尝试占用后报"设备离线"。默认开启,可在任务编辑器取消勾选。
if serials and self.params.get("skip_offline", True):
try:
devs = manager.stf.list_all_devices()
except Exception:
devs = None # STF 查询失败时不过滤,维持原行为
if devs is not None:
present = {d["serial"] for d in devs if d.get("present")}
ready = {d["serial"] for d in devs if d.get("present") and d.get("ready")}
kept, skipped = [], []
for s in serials:
if s not in present:
skipped.append((s, "离线"))
elif s not in ready:
skipped.append((s, "未就绪(provider初始化中)"))
else:
kept.append(s)
if skipped:
detail = ", ".join(f"{s}({why})" for s, why in skipped)
_log.info(f"任务 {self.name} 跳过 {len(skipped)} 台设备: {detail}")
serials = kept
return serials
# ================== 前台 App 扫描器 ==================
+4
View File
@@ -1045,6 +1045,10 @@ async function saveTask(jobId){
catch(e){showToast('参数 JSON 格式错误','error');return;}
}
// 离线自动跳过(serial/group 模式):任务触发时跳过 STF 池中不在线的设备
const skipEl=document.getElementById('f-skip_offline');
params.skip_offline = skipEl?skipEl.checked:true;
const targetMode=document.getElementById('f-target_mode').value;
const target={mode:targetMode};
if(targetMode==='group')target.group_name=document.getElementById('f-target_group').value;
+42 -3
View File
@@ -1,6 +1,7 @@
// 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况)
// ================== 设备列表排序与多选 ==================
let _selectedSerials=new Set(); // 已勾选的 serial 集合
let _locatingSerials=new Set(); // 定位中的设备(按钮显示"结束定位")
// 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue)
function _sortValue(dev,key){
@@ -71,6 +72,38 @@ async function stopSelectedDevices(){
setTimeout(loadMonitor,500);
}
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标
// 注意:打开定位页会切换设备前台,任务运行中慎用
async function locateDevice(serial){
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return;
const r=await apiPost('/api/device/locate',{serial:serial,show:true});
if(!r)return;
if(r.ok){
_locatingSerials.add(serial);
showToast(r.msg||'已定位','success');
renderDevices();
}else showToast(r.error||'定位失败','error');
}
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位"
async function stopLocate(serial){
const r=await apiPost('/api/device/locate/stop',{serial:serial});
if(!r)return;
_locatingSerials.delete(serial);
renderDevices();
if(r.ok)showToast(r.msg||'已结束定位','success');
else showToast(r.error||'结束定位失败','error');
}
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
async function screenAllDevices(mode){
if(mode==='off'&&!confirm('确定让所有在线设备息屏?\n\n运行中的任务会因屏幕熄灭而中断,请谨慎操作。'))return;
const r=await apiPost('/api/device/screen_all',{mode:mode});
if(!r)return;
if(r.ok)showToast((mode==='off'?'已息屏':'已亮屏')+' '+r.success+'/'+r.total+' 台设备','success');
else showToast(r.error||'操作失败','error');
}
// 清除单台设备的异常状态(error/failed → 未启动)
async function clearDeviceError(serial){
if(!confirm('确认清除设备 '+serial+' 的异常状态?'))return;
@@ -266,9 +299,10 @@ async function loadSummary(){
let _devicesSig='';
function renderDevices(){
if(!_statusData||!_statusData.devices){return;}
// 5秒自动刷新时若设备数据/分页/搜索/排序都没变化,跳过整表重渲染(避免DOM抖动)
// 5秒自动刷新时若设备数据/分页/搜索/排序/定位状态都没变化,跳过整表重渲染(避免DOM抖动)
const _st=_lp('devices');
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort});
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort,
l:[..._locatingSerials].sort().join(',')});
if(_sig===_devicesSig)return;
_devicesSig=_sig;
// 排序由 applyListPager 统一处理(sortValue + _lpSort)
@@ -306,10 +340,15 @@ function renderDeviceRow(dev){
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
// 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
// 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页)
const locating=_locatingSerials.has(dev.serial);
const locateBtn=canDev?(locating
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>'
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):'';
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+clearBtn+stopBtn+'</div>';
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
const devName=dev.device_name||dev.model||'';
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
// 复选框列
+4
View File
@@ -104,6 +104,10 @@ function openTaskModal(jobId){
'<div id="f-target_serial_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
'<select class="form-control" id="f-target_serial"><option value="">选择设备...</option>'+dl+'</select>'+
'</div>'+
'<div class="form-check" style="margin-top:8px">'+
'<input type="checkbox" id="f-skip_offline" '+(j.params&&j.params.skip_offline===false?'':'checked')+'>'+
'<label for="f-skip_offline">离线设备自动跳过(推荐)</label>'+
'</div>'+
'</div>'+
'<div class="form-row">'+
'<div class="form-group"><label>调度方式</label>'+
+31 -1
View File
@@ -126,7 +126,37 @@ function renderStfDevRow(x){
return '<tr><td style="font-family:monospace">'+esc(x.ip)+'</td>'+
'<td>'+(x.inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
'<td>'+stHtml+'</td>'+
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')">移除(断开+删脚本)</button></td></tr>';
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')" title="断开+删脚本">移除</button> '+
'<button class="btn btn-xs btn-danger" onclick="deleteStfDev(\''+esc(x.ip)+'\')" title="断开+删脚本+清 STF 池记录(设备从池中彻底消失)">彻底删除</button>'+
'</td></tr>';
}
// 一键重连:手动运行 220 的 connect_devices.sh,补连所有配置设备
async function reconnectStfDevices(){
if(!confirm('立即运行 220 上的 connect_devices.sh?\n\n会补连脚本里所有未连接的设备(在线设备跳过)。'))return;
const r=await apiPost('/api/stf/devmgmt/reconnect',{});
if(!r)return;
const box=document.getElementById('stfdev-reconnect-out');
if(r.ok){
box.style.display='block';
box.textContent=r.output||'执行完成';
showToast('重连完成','success');
loadStfDevMgmt();
}else{
showToast('重连失败: '+(r.error||''),'error');
}
}
// 彻底删除:脚本移除 + 断开 + 清 STF 池记录(设备从池中彻底消失)
async function deleteStfDev(ip){
if(!confirm('彻底删除 '+ip+'?\n\n1. 从 connect_devices.sh 移除\n2. 断开 220 的 adb 连接\n3. 调用 STF API 清除设备记录(设备从设备池彻底消失,需重新添加才会回来)'))return;
const r=await apiPost('/api/stf/devmgmt/delete',{ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
loadStfDevMgmt();
}else showToast('删除失败: '+(r.error||''),'error');
}
async function addStfDev(){
+4
View File
@@ -314,6 +314,8 @@ select.form-control{cursor:pointer}
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button>
<button class="btn" onclick="apiAction('/api/release')" data-perm="devices">强制释放占用</button>
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台App</button>
<button class="btn" onclick="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button>
<button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button>
<button class="btn" onclick="loadMonitor()">刷新</button>
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
@@ -640,10 +642,12 @@ select.form-control{cursor:pointer}
<input type="text" id="stfdev-ip" class="form-control" style="width:220px;font-family:monospace"
placeholder="设备 IP,如 100.100.10.20" onkeydown="if(event.key==='Enter')addStfDev()">
<button class="btn btn-primary" onclick="addStfDev()">添加设备并连接</button>
<button class="btn" onclick="reconnectStfDevices()" title="立即运行 220 的 connect_devices.sh,补连所有配置设备">一键重连</button>
<button class="btn" onclick="loadStfDevMgmt()">刷新</button>
<input class="form-control list-search" id="search-stfdev" placeholder="搜索 IP...">
<span id="stfdev-status" class="text-muted"></span>
</div>
<div class="help" id="stfdev-reconnect-out" style="display:none;background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;margin-bottom:10px;white-space:pre-wrap"></div>
<table class="table">
<thead><tr>
<th data-sort="ip" onclick="_lpSort('stfdev','ip')">设备</th>
+206 -1
View File
@@ -22,11 +22,14 @@ import functools
import shlex
import re
import subprocess
import urllib.parse
import threading
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
from flask import (Flask, jsonify, request, redirect, url_for, render_template,
Response, make_response, session)
render_template_string, Response, make_response, session)
from markupsafe import escape as _esc
from flask_login import (LoginManager, login_user, logout_user,
login_required, current_user)
@@ -547,6 +550,183 @@ def api_stop_device():
return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400
@app.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 ""
@app.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
@app.route("/api/device/screen_all", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_screen_all():
"""批量亮屏/息屏所有在线设备(并发)。mode: on=亮屏解锁 / off=息屏。
息屏会中断运行中的任务(u2 无法操作),前端已有确认提示。
"""
data = request.json or {}
mode = data.get("mode", "on")
try:
serials = [d["serial"] for d in stf.list_all_devices() if d.get("present")]
except Exception as e:
return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502
if not serials:
return jsonify({"ok": False, "error": "无在线设备"}), 400
def _do(serial):
try:
if ":" in serial:
adb_connect(serial)
if mode == "off":
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "26"],
capture_output=True, timeout=15) # KEYCODE_POWER 息屏
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)})
@app.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
@app.route("/api/stop_all", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_stop_all():
@@ -1098,6 +1278,31 @@ def api_tools_appver():
"total": len(results)})
@app.route("/api/stf/devmgmt/delete", methods=["POST"])
@admin_required
def api_stf_devmgmt_delete():
"""STF 设备管理:彻底删除(脚本移除 + disconnect + 清 STF 池记录)。"""
ip = (request.json or {}).get("ip", "").strip()
if not ip:
return jsonify({"ok": False, "error": "请输入设备 IP"}), 400
try:
msgs = stf_device_mgmt.delete_device(ip, stf=stf)
return jsonify({"ok": True, "msgs": msgs})
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@app.route("/api/stf/devmgmt/reconnect", methods=["POST"])
@admin_required
def api_stf_devmgmt_reconnect():
"""STF 设备管理:一键重连(手动运行 220 的 connect_devices.sh)。"""
try:
ok, summary = stf_device_mgmt.reconnect_all()
return jsonify({"ok": True, "output": summary})
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@app.route("/api/stf/restart", methods=["POST"])
@admin_required
def api_stf_restart():