配合设备端 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」。
418 lines
18 KiB
Python
418 lines
18 KiB
Python
"""设备池管理 API(本地 SQLite 清单)。"""
|
||
import threading
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
|
||
from flask import Blueprint, jsonify, request
|
||
|
||
from core import device_pool, device_discovery
|
||
from core.adb_helper import adb_connect, adb_connect_light
|
||
from core.logger import get_logger
|
||
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():
|
||
"""设备池清单(SQLite,含实时在线状态)。"""
|
||
try:
|
||
online = set(device_pool.list_online())
|
||
except Exception:
|
||
online = set()
|
||
rows = device_pool.list_devices()
|
||
for r in rows:
|
||
r["online"] = r["serial"] in online
|
||
return jsonify({"ok": True, "devices": rows})
|
||
|
||
@bp.route("/api/devices/pool/add", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_pool_add():
|
||
"""添加/更新设备:{serial, name, note?}。
|
||
|
||
**名称必填且唯一**(设备在平台里的身份标识,分组/任务/日志都靠它认设备)。
|
||
IP:5555 设备添加后立即尝试 adb connect 并读取**设备指纹**:若指纹命中池中已有
|
||
设备(同一台设备换了 IP),自动**认领**——迁移旧记录(保留原名)并同步
|
||
分组与任务里的引用,而不是新增一条。
|
||
"""
|
||
data = request.json or {}
|
||
serial = (data.get("serial") or "").strip()
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "请输入设备 serial(如 100.100.10.20:5555)"}), 400
|
||
name = (data.get("name") or "").strip()
|
||
note = (data.get("note") or "").strip()
|
||
if not name:
|
||
return jsonify({"ok": False, "error":
|
||
"请填写设备名称——名称是设备在平台里的唯一标识"
|
||
"(分组、任务目标、日志都用它认设备)"}), 400
|
||
# 重名先查(放在 adb 之前:连不上的 IP 不该让用户白等)
|
||
if device_pool.name_taken(name, exclude_serial=serial):
|
||
return jsonify({"ok": False, "error": f"名称『{name}』已被其它设备占用,请换一个"}), 400
|
||
# 轻量 connect(单次、短超时,不用 5 次重试的 adb_connect,避免不可达设备把接口卡住)
|
||
if ":" in serial:
|
||
try:
|
||
adb_connect_light(serial)
|
||
except Exception:
|
||
pass
|
||
fp = device_pool.read_fingerprint(serial) if ":" in serial else ""
|
||
claimed_old, claimed_name = device_pool.claim_device(serial, fp) if fp else (None, "")
|
||
# 认领时保留设备**原有名称**(身份优先于本次输入),没有原名才用输入值
|
||
use_name = (claimed_name or name).strip()
|
||
if use_name != name and device_pool.name_taken(use_name, exclude_serial=serial):
|
||
return jsonify({"ok": False, "error": f"名称『{use_name}』已被其它设备占用,请换一个"}), 400
|
||
is_new = device_pool.add_device(serial, name=use_name, note=note, fingerprint=fp)
|
||
# 后台采集型号 + 指纹(不阻塞添加响应)
|
||
threading.Thread(target=device_pool.refresh_info, args=(serial,), daemon=True).start()
|
||
if claimed_old:
|
||
msg = (f"已认领:这是『{use_name}』(原地址 {claimed_old}),"
|
||
f"分组与任务的引用已自动同步")
|
||
else:
|
||
msg = "已添加" if is_new else "已更新"
|
||
_log.info(f"设备池管理: {msg} {serial} name={use_name}")
|
||
return jsonify({"ok": True, "msg": msg, "is_new": is_new,
|
||
"claimed": bool(claimed_old), "old_serial": claimed_old or ""})
|
||
|
||
|
||
@bp.route("/api/devices/pool/rename", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_pool_rename():
|
||
"""重命名设备:{serial, name}。名称必填且唯一。"""
|
||
data = request.json or {}
|
||
serial = (data.get("serial") or "").strip()
|
||
name = (data.get("name") or "").strip()
|
||
if not serial or not name:
|
||
return jsonify({"ok": False, "error": "缺少 serial 或 name"}), 400
|
||
if device_pool.name_taken(name, exclude_serial=serial):
|
||
return jsonify({"ok": False, "error": f"名称『{name}』已被其它设备占用,请换一个"}), 400
|
||
if not device_pool.rename_device(serial, name):
|
||
return jsonify({"ok": False, "error": "设备不存在"}), 404
|
||
return jsonify({"ok": True, "msg": f"已重命名为『{name}』"})
|
||
|
||
@bp.route("/api/devices/pool/relocate", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_pool_relocate():
|
||
"""人工认领(换地址兜底):{old_serial, new_serial}。
|
||
|
||
用于设备换了 IP 但**旧地址已断联、指纹也没采过**、自动认领无从匹配时:
|
||
由人工指认"这条记录就是那台设备,现在在新地址",迁移并同步分组/任务引用。
|
||
"""
|
||
data = request.json or {}
|
||
old_serial = (data.get("old_serial") or "").strip()
|
||
new_serial = (data.get("new_serial") or "").strip()
|
||
if not old_serial or not new_serial:
|
||
return jsonify({"ok": False, "error": "缺少 old_serial 或 new_serial"}), 400
|
||
if ":" in new_serial:
|
||
try:
|
||
adb_connect_light(new_serial)
|
||
except Exception:
|
||
pass
|
||
fp = device_pool.read_fingerprint(new_serial) if ":" in new_serial else ""
|
||
ok, name, msg = device_pool.relocate_device(old_serial, new_serial, fingerprint=fp)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg}), 400
|
||
# 新地址若在待连接池里,一并清掉(已经是池内设备了)
|
||
try:
|
||
device_discovery.ignore_pending(new_serial)
|
||
except Exception:
|
||
pass
|
||
threading.Thread(target=device_pool.refresh_info, args=(new_serial,), daemon=True).start()
|
||
_log.info(f"设备池管理: 人工认领 {old_serial} → {new_serial}({name})")
|
||
return jsonify({"ok": True, "msg": msg, "name": name})
|
||
|
||
|
||
@bp.route("/api/devices/pool/remove", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_pool_remove():
|
||
"""从设备池删除(不再参与调度;不影响其他系统)。"""
|
||
serial = (request.json or {}).get("serial", "").strip()
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
ok = device_pool.remove_device(serial)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": "设备不存在"}), 404
|
||
return jsonify({"ok": True, "msg": "已删除"})
|
||
|
||
@bp.route("/api/devices/pool/toggle", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_pool_toggle():
|
||
"""启用/停用设备(停用后不参与调度)。"""
|
||
data = request.json or {}
|
||
serial = (data.get("serial") or "").strip()
|
||
enabled = data.get("enabled")
|
||
if not serial or enabled is None:
|
||
return jsonify({"ok": False, "error": "缺少参数"}), 400
|
||
ok = device_pool.set_enabled(serial, bool(enabled))
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": "设备不存在"}), 404
|
||
return jsonify({"ok": True, "msg": "已" + ("启用" if enabled else "停用")})
|
||
|
||
@bp.route("/api/devices/pool/reconnect", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_pool_reconnect():
|
||
"""一键重连:并发 adb connect 池内全部 IP:5555 设备(后台执行,不阻塞)。"""
|
||
def _run():
|
||
try:
|
||
serials = [s for s in device_pool.list_configured() if ":" in s]
|
||
except Exception:
|
||
return
|
||
if not serials:
|
||
return
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
with ThreadPoolExecutor(max_workers=10) as pool:
|
||
futures = {pool.submit(adb_connect, s): s for s in serials}
|
||
for _ in as_completed(futures):
|
||
pass
|
||
_log.info(f"设备池一键重连完成({len(serials)} 台)")
|
||
# 重连后顺手刷新型号
|
||
try:
|
||
device_pool.refresh_all_models()
|
||
except Exception:
|
||
pass
|
||
import threading
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
return jsonify({"ok": True, "msg": "重连已启动(后台并发,约 10-20 秒)"})
|
||
|
||
@bp.route("/api/devices/pool/refresh_models", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_pool_refresh_models():
|
||
"""批量采集池内在线设备的型号(后台执行,不阻塞)。"""
|
||
import threading
|
||
threading.Thread(target=device_pool.refresh_all_models, daemon=True).start()
|
||
return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"})
|
||
|
||
|
||
# ================== API:设备自动发现(扫描 → 待连接池 → 确认) ==================
|
||
# 扫描只把设备放进待连接池(pending),用户确认后才加入正式设备池并连接。
|
||
@bp.route("/api/devices/discovery", methods=["GET"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_discovery():
|
||
"""发现状态 + 待连接列表 + 正式池断联设备(前端 10s 轮询一次请求)。"""
|
||
try:
|
||
status = device_discovery.get_status()
|
||
pending = device_discovery.list_pending()
|
||
pool_offline = device_discovery.list_pool_offline()
|
||
except Exception as e:
|
||
_log.warning(f"discovery 状态获取失败: {e}")
|
||
return jsonify({"ok": False, "error": str(e)[:120]}), 503
|
||
status["pending"] = pending
|
||
status["pool_offline"] = pool_offline
|
||
return jsonify({"ok": True, **status})
|
||
|
||
|
||
@bp.route("/api/devices/discovery/scan", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_discovery_scan():
|
||
"""手动触发一轮扫描(后台执行,约 5-30 秒)。"""
|
||
ok, result = device_discovery.scan_once(manual=True)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": result.get("error", "扫描失败")}), 409
|
||
if "error" in result:
|
||
return jsonify({"ok": False, "error": result["error"]}), 400
|
||
return jsonify({"ok": True, "msg": "扫描已启动(后台执行,约 5-30 秒)",
|
||
"result": result})
|
||
|
||
|
||
@bp.route("/api/devices/discovery/confirm", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_discovery_confirm():
|
||
"""确认连接:待连接 → 正式设备池 + 后台 adb connect + 采型号。
|
||
|
||
名称规则:**新设备必须填名称**;若指纹命中池中已有设备(同一台换了 IP),
|
||
则不需要名称——保留原记录与原名,并把分组/任务引用同步到新地址。
|
||
"""
|
||
data = request.json or {}
|
||
serial = (data.get("serial") or "").strip()
|
||
name = (data.get("name") or "").strip()
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
if ":" in serial:
|
||
try:
|
||
adb_connect_light(serial)
|
||
except Exception:
|
||
pass
|
||
fp = device_pool.read_fingerprint(serial) if ":" in serial else ""
|
||
matched = device_pool.find_by_fingerprint(fp) if fp else None
|
||
in_pool = serial in device_pool.list_configured()
|
||
if not in_pool and not matched and not name:
|
||
return jsonify({"ok": False, "error":
|
||
"请为该设备填写名称(名称是设备在平台里的唯一标识)"}), 400
|
||
if name and device_pool.name_taken(name, exclude_serial=serial if in_pool else ""):
|
||
return jsonify({"ok": False, "error": f"名称『{name}』已被其它设备占用,请换一个"}), 400
|
||
ok, msg, is_new = device_discovery.confirm_pending(serial, name=name, fingerprint=fp)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg}), 404
|
||
if ":" in serial:
|
||
def _connect():
|
||
try:
|
||
adb_connect(serial)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
device_pool.refresh_info(serial)
|
||
except Exception:
|
||
pass
|
||
threading.Thread(target=_connect, daemon=True).start()
|
||
_log.info(f"设备池管理: 发现确认 {serial}")
|
||
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
|
||
|
||
|
||
@bp.route("/api/devices/discovery/ignore", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_discovery_ignore():
|
||
"""忽略:从待连接池删除(下轮扫描可能再次发现)。"""
|
||
serial = (request.json or {}).get("serial", "").strip()
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
ok, msg = device_discovery.ignore_pending(serial)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg}), 404
|
||
return jsonify({"ok": True, "msg": msg})
|
||
|
||
|
||
@bp.route("/api/devices/discovery/reconnect", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_discovery_reconnect():
|
||
"""手动立即重连正式池断联设备(后台 adb connect + 采型号)。
|
||
|
||
日常无需手动——发现线程每轮(默认 60s)自动重连断联设备;
|
||
本端点用于不想等下一轮扫描时立即拉起。
|
||
"""
|
||
serial = (request.json or {}).get("serial", "").strip()
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
from core import device_pool
|
||
if serial not in device_pool.list_configured():
|
||
return jsonify({"ok": False, "error": "设备不在设备池中"}), 404
|
||
|
||
def _reconnect():
|
||
try:
|
||
if ":" in serial:
|
||
adb_connect(serial)
|
||
# 无论是否连上,采一次型号(模型失败静默)
|
||
try:
|
||
device_pool.refresh_model(serial)
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
_log.warning(f"手动重连 {serial} 异常: {e}")
|
||
|
||
threading.Thread(target=_reconnect, daemon=True).start()
|
||
_log.info(f"设备池管理: 手动重连 {serial}")
|
||
return jsonify({"ok": True, "msg": "重连已启动(约 5-15 秒生效)"})
|
||
|
||
|
||
@bp.route("/api/devices/discovery/settings", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_devices_discovery_settings():
|
||
"""保存发现配置(部分字段更新):{enabled?, subnets?, interval?, port?}。"""
|
||
data = request.json or {}
|
||
ok, msg = device_discovery.save_settings(
|
||
enabled=data.get("enabled"),
|
||
subnets=data.get("subnets"),
|
||
interval=data.get("interval"),
|
||
port=data.get("port"),
|
||
auto_claim=data.get("auto_claim"))
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg}), 400
|
||
return jsonify({"ok": True, "msg": msg})
|
||
|
||
|
||
# ================== API:Tailscale 管理(仅管理员) ==================
|
||
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
|
||
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
|
||
|