Files
auto_control/web/devices_api.py
T
butubb beda0f3779 feat(发布计划): 改按名字发(文件名 名字_日期_编号)+ 账号管理整页下线 + 自动切号一轮发完
发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行
`标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块
「一轮把本机待发的号跑完」自动切号,一直发到发完。

· 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因),
  删掉三处"手机号必须命中台账"的校验,加索引 ix_video_plan_name_date(启动时补建)
· 新步骤三个:读本机账号名字 / 切换账号(**按名字点控件**、切完回读校验、遇登录页停本设备)/
  循环本机待发的号(试过不回头、跑一轮没进展就换号、停止·超时·上限三个干净出口)
· 发布骨架改 18 步:读名字 → 校验账号(cmp_source=account_target)→ 推送(**推送前重读昵称核对**)
  → 抖音流程 → 标记结果;push_release 的 account 默认 name
· 账号管理整页下线:删 core/ledger.py(serial_of 迁到 core/device_pool.py)、6 个 /api/ledger*、
  「账号」页与 ledger.js、设备池「账号」列、设备端账号块;发布计划升为一级页签;
  device_account 表与 42 行历史数据保留、仍在备份清单内(**停用不等于删数据**)
· 人工入口「按名字找设备」:新增 core/account_probe.py(只读探测、跳过在跑任务的设备)
  + POST /api/video_plan/push_by_account,没设备登着的号逐条报出来;
  推送时把"实际是谁发的"写进 posted_serial / posted_device_name
· 通知:新增 task.account.switch_failed / task.account.need_login
· schema v12(posted_*)/ v13(device.cur_account_name / cur_name_at)
· 文档同步:TASK_DEV / API / DATA_MODEL / ARCHITECTURE / NOTIFY / DEVICE_AGENT / DEPLOY /
  DEVELOPMENT / README / .env.example(两个抖音控件选择器抓一次填 .env)
· 新增 scripts/tests/:6 套用例 + run_all.py(临时库 + 假设备,不碰真实数据、不连真机)
2026-10-12 09:02:40 +08:00

451 lines
20 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_battery, 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:设备电量(采集/阈值/告警) ==================
# 电量本身通过 /api/status 随设备列表一起下发(大屏/监控同一份数据);
# 这里是它的配置与采集器状态。低电量告警走通知子系统(事件 device.battery.low)。
@bp.route("/api/devices/battery", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_battery():
"""电量采集器状态 + 当前配置(面板显示"上次采集 12:00 · 14/14 台")。"""
try:
return jsonify({"ok": True, **device_battery.get_status()})
except Exception as e:
_log.warning(f"电量状态获取失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
@bp.route("/api/devices/battery/settings", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_battery_settings():
"""保存电量监控配置:{enabled?, low?, critical?, skip_charging?, interval?}。"""
data = request.json or {}
ok, msg, saved = device_battery.save_settings(data)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "msg": msg, "settings": saved})
@bp.route("/api/devices/battery/scan", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_battery_scan():
"""立即采集一次(后台执行,约 1-3 秒;看「上次采集」或大屏刷新确认)。"""
if not device_battery.scan_once():
return jsonify({"ok": False, "error": "上一轮采集还没跑完,稍等一下"}), 409
return jsonify({"ok": True, "msg": "采集已启动(约 1-3 秒)"})
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。