Files
auto_control/web/devices_api.py
T
butubb 0a1b4d6122 feat: 设备身份改为「名称 + 指纹」——更换 IP 自动认领,分组/任务引用自动同步
背景:设备池原先拿 serial(IP)当身份。设备一换 IP(DHCP 重新分配)旧记录就成了连不上的
僵尸条目(表现为"断联·自动重连中"但设备并没关机),分组与 serial 模式的任务还吊着死地址。
2026-09-11 实际发生:.70 变成 .71、.72 消失,平台两个条目永远连不上。

实现
- **设备名称必填且唯一**:加入设备必须填名称;库层面用部分唯一索引兜底
  (ux_device_name / ux_device_fingerprint,WHERE 非空 → 兼容历史空值),管理页可改名
- **设备指纹**(ro.serialno):网络设备添加/确认/扫描/采集型号时自动读取;
  身份三层拆分——名称(人可读,稳定)、指纹(机器识别,稳定)、serial(当前地址,可变)
- **自动认领**:添加或确认设备时指纹命中池中已有记录 → 迁移原记录到新地址
  (名称/型号/备注/启用状态/添加时间全保留),不新增条目
- **人工认领** `POST /api/devices/pool/relocate`:旧地址已断联、指纹没采过时的兜底——
  人工指认"这条就是那台,现在在 X",迁移并同步引用
- **引用同步**:认领/迁址时把 device_group.serials 与 task_job.target.serial 的旧地址
  换成新地址。⚠️ 必须同时改**内存**:分组/任务在 TaskManager 里另有内存副本且调度用内存对象,
  只改库不重启不生效 → device_pool 迁址后回调 TaskManager.sync_device_serial
  (装配层用 set_move_hook 注册;device_pool 不能反向 import task_manager,会循环依赖)
- **前端**:待连接池新增「识别」列(指纹命中时提示"≈ 名称(原 IP)",按钮变「认领为 X」);
  设备池新增「名称/指纹」列与「改名/换地址」操作;断联设备表也加「换地址」(用户看到断联就在这里)
- 添加设备接口改用 adb_connect_light(单次短超时),避免不可达 IP 让请求卡 30s+;重名校验提前到 adb 之前

文档:API.md §6 重写(设备身份/认领/新接口)、DATA_MODEL.md(新列 + v5/v6 迁移 + 唯一索引)、
ARCHITECTURE.md §4.0(身份三层与引用同步的内存坑)、DEPLOY.md 排查表加"断联但没关机"条目

自测(全通过):名称必填/唯一/改名/重名拒绝(含库层面约束);指纹采集(真实读到 .71 的
gy7lskwkkvj7c6b6);自动认领(指纹命中→迁址+保留名称+带指纹+未命中不误判);人工认领
(迁址+名称保留+分组与任务引用同步);浏览器验证设备池/断联表/待连接池三个界面
2026-09-11 11:03:38 +08:00

323 lines
14 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(本地 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__)
@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"))
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 无法修改,列表只读展示。