"""设备池管理 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"), 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 无法修改,列表只读展示。