diff --git a/doc/API.md b/doc/API.md index ad0c9e4..18010a6 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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 自动发现 | 接口 | 请求 | 说明 | diff --git a/doc/DEVICE_AGENT.md b/doc/DEVICE_AGENT.md index 734c6bf..5fff979 100644 --- a/doc/DEVICE_AGENT.md +++ b/doc/DEVICE_AGENT.md @@ -195,7 +195,7 @@ adb -s shell am start -n /.ClipActivity --es text_b64 shell am start -n /.InfoActivity \ @@ -206,7 +206,27 @@ adb -s shell am start -n /.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 shell am start -n /.ConfigActivity \ diff --git a/requirements.txt b/requirements.txt index f263fb8..ea005f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/static/admin/tools.js b/static/admin/tools.js index 133a8a9..c5d9af9 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -498,6 +498,7 @@ async function loadDevPool(){ ''+esc(d.created_at||'')+''+ ''+ ' '+ + ' '+ (d.online ? '' : ' ') + ' '+ @@ -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='

生成中...

'; + document.getElementById('modal-footer').innerHTML= + ''; + showModal(); + const r=await apiGet('/api/devices/qrcode?serial='+encodeURIComponent(serial)); + const box=document.getElementById('modal-body'); + if(!r||!r.ok){ + box.innerHTML='
'+esc((r&&r.error)||'生成失败')+'
'; + return; + } + const warn=r.warn?'
⚠️ '+esc(r.warn)+'
':''; + box.innerHTML= + warn+ + '
'+ + '配置二维码'+ + '
'+esc(r.name||'(未命名)')+ + ' '+esc(r.serial)+'
'+ + '
'+ + '
在设备上打开 设备 Agent → 点「扫码配置」→ 扫这个二维码,'+ + '平台地址、令牌、指纹一次写入,不用在手机上打字。
'+ + '
'+ + '查看二维码内容(排查用)'+ + '
'+esc(r.payload)+'
'; +} diff --git a/web/devices_api.py b/web/devices_api.py index cf25035..22c55f9 100644 --- a/web/devices_api.py +++ b/web/devices_api.py @@ -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():