feat: 设备配置二维码——设备池里出码,Agent 扫一下配好
配合设备端 Agent 新增的「扫码配置」:人在机架前、设备还没接进平台时,
不用插线也不用在手机上打字输地址/令牌(还容易漏指纹)。
- `GET /api/devices/qrcode?serial=`:生成配置二维码(PNG data-url),内容为
{"v":1,"server":…,"token":…,"fingerprint":…,"name":…}
* 前置:设备端商店已启用(令牌从那儿来),否则 400 并说明去哪儿开
* `warn` 非空必须显示:①用 localhost 打开平台时,二维码里的地址手机会连不上
(提示改用局域网地址,_lan_ip() 优先挑 192.168/10.x,**不用** gethostbyname ——
那会返回 Tailscale 的 100.x 或 docker 的 172.17,手机根本连不上)
②设备还没采到指纹 → 扫完平台认不出是哪台
- 设备池每行加「二维码」按钮 → 弹窗显示二维码 + 设备名/地址 + 可展开看原始内容
- requirements 加 qrcode(纯 Python,配合已有的 Pillow 出 PNG,不引前端二维码库)
- 文档:API.md §6.2.2 新增、§6.2.1 名称呈现表补齐(剪贴板/安装弹窗/版本管理/
安装进度四处);DEVICE_AGENT.md §5.3 新增二维码内容格式(设备端契约)
验证:二维码用 cv2.QRCodeDetector 解回来与 payload 完全一致;手机上实测——
扫 A07 的码 → 相机启动 → 解码 → 保存 → 立刻拉清单,平台认出「A07」。
This commit is contained in:
+24
@@ -150,6 +150,7 @@
|
||||
| POST | `/api/devices/pool/remove` | D | 从池中删除 |
|
||||
| POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) |
|
||||
| POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 |
|
||||
| GET | `/api/devices/qrcode?serial=` | D | 生成该设备的**配置二维码**(设备端 Agent 扫码即配好) |
|
||||
| POST | `/api/devices/pool/refresh_models` | D | 批量采集型号 |
|
||||
| GET | `/api/devices/discovery` | D | 发现状态 + 待连接 + 断联列表 |
|
||||
| POST | `/api/devices/discovery/scan` | D | 手动触发一轮扫描 |
|
||||
@@ -461,8 +462,31 @@
|
||||
| 任务编辑器「指定设备」下拉 | `名称 · serial` |
|
||||
| 分组编辑的设备勾选列表 | `名称 · serial` |
|
||||
| 设备池 / 待连接池 / 断联设备表 | 名称列 + 指纹列 |
|
||||
| 剪贴板注入的设备勾选列表 | `名称 · serial`(无名只显示地址) |
|
||||
| 应用管理的安装弹窗设备列表 | `名称 · serial` |
|
||||
| 应用版本管理表格的「设备」列 | `名称 · serial` |
|
||||
| 应用安装进度表的「设备」列 | 名称(取自设备池;无名退地址) |
|
||||
| MCP `de_list_devices` | 返回 `name` 字段,提示 AI 汇报时用名称 |
|
||||
|
||||
> 这几个接口的字段来源:`/api/adb/devices`、`/api/apks/install/devices` 走
|
||||
> `web/common.py:_merged_device_list()`(带 `name`);`/api/tools/appver` 把
|
||||
> `name` 塞进每个设备的结果里。
|
||||
|
||||
### 6.2.2 设备配置二维码(扫码配置)
|
||||
|
||||
| 接口 | 鉴权 | 请求 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `GET /api/devices/qrcode?serial=<地址>` | 设备权限 | — | 返回 `{png(base64 data-url), payload, warn}` |
|
||||
|
||||
给设备端 Agent 扫的配置二维码,一次写入**平台地址 + 设备令牌 + 该设备指纹**:
|
||||
|
||||
- `payload` 是二维码里的原始 JSON:`{"v":1,"server":…,"token":…,"fingerprint":…,"name":…}`
|
||||
- `warn` **非空时必须显示**给操作者,最常见两种:
|
||||
- "你是用 localhost 打开平台的" → 二维码里的地址手机会连不上,要改用局域网地址重开本页
|
||||
- "这台设备还没采集到指纹" → 扫码后平台认不出它是哪一台
|
||||
- 前置条件:先在「应用管理 → 设备端应用商店」**启用**(令牌从那儿来),否则 400
|
||||
- 设备端如何解析见 [DEVICE_AGENT.md](DEVICE_AGENT.md) §5.3
|
||||
|
||||
### 6.3 自动发现
|
||||
|
||||
| 接口 | 请求 | 说明 |
|
||||
|
||||
+22
-2
@@ -195,7 +195,7 @@ adb -s <serial> shell am start -n <pkg>/.ClipActivity --es text_b64 <base64(UTF-
|
||||
- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist`
|
||||
→ 视为"设备未安装 Agent"
|
||||
|
||||
### 5.2 设备身份显示(待设备端实现)
|
||||
### 5.2 设备身份显示(设备端已实现)
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell am start -n <pkg>/.InfoActivity \
|
||||
@@ -206,7 +206,27 @@ adb -s <serial> shell am start -n <pkg>/.InfoActivity \
|
||||
|
||||
要求:全屏大字、再次调用可覆盖内容、`close` 可关闭、不驻留前台。
|
||||
|
||||
### 5.3 一次性配置下发(建议设备端实现)
|
||||
### 5.3 扫码配置(设备端已实现)
|
||||
|
||||
平台「设备池」里点某台设备的「**二维码**」→ 生成一张二维码 → 手机上的 Agent 点
|
||||
「扫码配置」扫一下,就把 **平台地址 + 设备令牌 + 这台设备的指纹** 一次写入。
|
||||
|
||||
**二维码内容**(JSON 文本,设备端按字段名解析,多余的字段忽略):
|
||||
```json
|
||||
{"v":1,"server":"http://192.168.20.250:18050","token":"<设备令牌>",
|
||||
"fingerprint":"s8o7nrt8pbzhfadq","name":"A07"}
|
||||
```
|
||||
|
||||
- `v` = 格式版本(当前 1);`fingerprint` 可能为空串(平台还没采到指纹)
|
||||
- 设备端扫到**不是本平台的二维码**时应提示并继续扫,不要静默失败
|
||||
- 平台侧生成接口:`GET /api/devices/qrcode?serial=<设备地址>`(需登录 + 设备权限),
|
||||
返回 `{png( base64 data-url), payload, warn}`;`warn` 非空时要显示出来
|
||||
(最常见的是"你现在用 localhost 打开平台,二维码里的地址手机会连不上")
|
||||
|
||||
> 与 §5.4 的 adb 下发**等价、互为补位**:设备已经连上平台时用 adb 批量下发(零操作),
|
||||
> 人在机器旁或设备还没接进来时用扫码(不用线、不用打字)。
|
||||
|
||||
### 5.4 一次性配置下发(adb,建议设备端实现)
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell am start -n <pkg>/.ConfigActivity \
|
||||
|
||||
@@ -16,6 +16,10 @@ Flask-SQLAlchemy>=3.0,<4.0
|
||||
# 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。
|
||||
PyMySQL>=1.1
|
||||
|
||||
# 设备端 Agent 的「扫码配置」二维码(设备池面板生成,手机扫一下配好 server/token/指纹)。
|
||||
# 纯 Python,配合已有的 Pillow 出 PNG,不引前端二维码库。
|
||||
qrcode>=7.4
|
||||
|
||||
# 定时任务调度
|
||||
APScheduler>=3.10,<4.0
|
||||
|
||||
|
||||
@@ -498,6 +498,7 @@ async function loadDevPool(){
|
||||
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
|
||||
'<td style="white-space:nowrap">'+
|
||||
'<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+
|
||||
'<button class="btn btn-xs" title="生成配置二维码:设备端 Agent 扫一下即配好(平台地址/令牌/指纹)" onclick="showDevQrcode(\''+esc(d.serial)+'\')">二维码</button> '+
|
||||
(d.online ? '' :
|
||||
'<button class="btn btn-xs" title="设备换了 IP、旧地址连不上时:把这条记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(\''+esc(d.serial)+'\')">换地址</button> ') +
|
||||
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
|
||||
@@ -572,3 +573,37 @@ async function refreshPoolModels(){
|
||||
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
|
||||
else showToast(r.error,'error');
|
||||
}
|
||||
|
||||
// ================== 设备配置二维码(设备端 Agent 扫码即配好) ==================
|
||||
// 二维码内容:平台地址 + 设备令牌 + 这台设备的指纹(JSON)。
|
||||
// 手机上的 Agent 扫一下 → 保存这三项 → 不用在手机上打字、也不用连 adb。
|
||||
async function showDevQrcode(serial){
|
||||
document.getElementById('modal-title').textContent='设备配置二维码';
|
||||
document.getElementById('modal-body').innerHTML='<p class="text-muted">生成中...</p>';
|
||||
document.getElementById('modal-footer').innerHTML=
|
||||
'<button class="btn" onclick="closeModal()">关闭</button>';
|
||||
showModal();
|
||||
const r=await apiGet('/api/devices/qrcode?serial='+encodeURIComponent(serial));
|
||||
const box=document.getElementById('modal-body');
|
||||
if(!r||!r.ok){
|
||||
box.innerHTML='<div style="background:#fdecea;border:1px solid #f5c6cb;color:#b42318;'+
|
||||
'padding:10px 12px;border-radius:6px">'+esc((r&&r.error)||'生成失败')+'</div>';
|
||||
return;
|
||||
}
|
||||
const warn=r.warn?'<div style="background:#fff3cd;border:1px solid #ffda6a;color:#7a5b00;'+
|
||||
'padding:8px 12px;border-radius:6px;margin-bottom:10px;font-size:13px">⚠️ '+esc(r.warn)+'</div>':'';
|
||||
box.innerHTML=
|
||||
warn+
|
||||
'<div style="text-align:center;padding:8px 0">'+
|
||||
'<img src="'+r.png+'" style="width:240px;height:240px;image-rendering:pixelated;'+
|
||||
'background:#fff;padding:8px;border:1px solid #e5e7eb;border-radius:8px" alt="配置二维码">'+
|
||||
'<div style="margin-top:10px;font-size:15px"><strong>'+esc(r.name||'(未命名)')+
|
||||
'</strong> <span style="font-family:monospace;color:#6b7280">'+esc(r.serial)+'</span></div>'+
|
||||
'</div>'+
|
||||
'<div class="help" style="margin-top:6px">在设备上打开 <strong>设备 Agent</strong> → 点「扫码配置」→ 扫这个二维码,'+
|
||||
'平台地址、令牌、指纹一次写入,不用在手机上打字。</div>'+
|
||||
'<details style="margin-top:8px"><summary style="cursor:pointer;color:#6b7280;font-size:12px">'+
|
||||
'查看二维码内容(排查用)</summary>'+
|
||||
'<div style="font-family:monospace;font-size:11px;color:#64748b;word-break:break-all;'+
|
||||
'background:#f8fafc;padding:8px;border-radius:6px;margin-top:6px">'+esc(r.payload)+'</div></details>';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,100 @@ from web.auth import perm_required, PERM_DEVICES
|
||||
_log = get_logger("web")
|
||||
bp = Blueprint("devices", __name__)
|
||||
|
||||
|
||||
def _lan_ip():
|
||||
"""挑一个手机连得上的本机地址:优先 192.168.x,其次 10.x。
|
||||
|
||||
不能用 gethostbyname(hostname) —— 这台机器上它可能返回 Tailscale 的
|
||||
100.x(CGNAT 地址,手机不在 tailnet 里根本连不上),或 docker 的 172.17。
|
||||
"""
|
||||
import socket
|
||||
candidates = []
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip.startswith("127.") or ip.startswith("169.254."):
|
||||
continue
|
||||
candidates.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
for ip in candidates:
|
||||
if ip.startswith("192.168."):
|
||||
return ip
|
||||
for ip in candidates:
|
||||
if ip.startswith("10."):
|
||||
return ip
|
||||
return candidates[0] if candidates else "本机IP"
|
||||
|
||||
|
||||
@bp.route("/api/devices/qrcode")
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_qrcode():
|
||||
"""给某台设备生成「配置二维码」——设备端 Agent 扫一下就把自己配好。
|
||||
|
||||
二维码里是一段 JSON(平台地址 + 设备令牌 + 这台设备的指纹),扫码即完成:
|
||||
填 server/token/指纹 三件事,不用在手机上打字,也不用连 adb。
|
||||
|
||||
⚠️ 两条使用前提(都会在返回里给提示,不静默失败):
|
||||
1. 平台要先在「应用管理 → 设备端应用商店」启用(令牌是从那儿来的)
|
||||
2. **要用局域网地址打开本页面**(二维码里的地址取自当前访问地址;
|
||||
如果你是用 localhost 打开的,手机会连不上,这里会明确告诉你)
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json as _json
|
||||
|
||||
serial = (request.args.get("serial") or "").strip()
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
|
||||
dev = None
|
||||
for d in device_pool.list_devices():
|
||||
if d.get("serial") == serial:
|
||||
dev = d
|
||||
break
|
||||
if dev is None:
|
||||
return jsonify({"ok": False, "error": "设备不在池里"}), 404
|
||||
|
||||
# 令牌必须先启用设备端商店才有
|
||||
from core.db_config import meta_get
|
||||
from web.device_agent_api import store_enabled, ensure_token
|
||||
if not store_enabled():
|
||||
return jsonify({"ok": False,
|
||||
"error": "还没启用设备端商店:先到「应用管理 → 设备端应用商店」勾选启用"}), 400
|
||||
token = ensure_token()
|
||||
|
||||
server = request.host_url.rstrip("/")
|
||||
warn = ""
|
||||
if "127.0.0.1" in server or "localhost" in server:
|
||||
warn = ("你现在是用本机地址打开平台的,二维码里的地址手机会连不上 —— "
|
||||
"请改用局域网地址(如 http://%s:18050)重新打开本页再生成。"
|
||||
% _lan_ip())
|
||||
|
||||
fp = dev.get("fingerprint") or ""
|
||||
if not fp:
|
||||
warn = (warn + " " if warn else "") + \
|
||||
"这台设备还没采集到指纹,扫码后平台认不出它是哪一台(设备端会显示「未登记」);" \
|
||||
"建议先让它在线并刷新一次型号/指纹,再生成二维码。"
|
||||
|
||||
payload = {"v": 1, "server": server, "token": token,
|
||||
"fingerprint": fp, "name": dev.get("name") or ""}
|
||||
text = _json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
img = qrcode.make(text)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
png = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception as e:
|
||||
_log.error(f"生成二维码失败: {e}")
|
||||
return jsonify({"ok": False, "error": f"生成二维码失败: {e}"}), 500
|
||||
|
||||
_log.info(f"生成设备配置二维码: {dev.get('name') or serial}")
|
||||
return jsonify({"ok": True, "serial": serial, "name": dev.get("name") or "",
|
||||
"fingerprint": fp, "png": png, "payload": text, "warn": warn})
|
||||
|
||||
@bp.route("/api/devices/pool", methods=["GET"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_pool_list():
|
||||
|
||||
Reference in New Issue
Block a user