Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933225784b | ||
|
|
64fadde568 | ||
|
|
79cdf61ed2 | ||
|
|
0a1b4d6122 |
@@ -10,6 +10,7 @@ env/
|
||||
|
||||
# 运行时数据
|
||||
logs/*.log
|
||||
data/*.log
|
||||
data/apks/*.apk
|
||||
data/*.migrated
|
||||
data/*.db-shm
|
||||
|
||||
+88
-10
@@ -31,6 +31,8 @@ _K_ENABLED = "discovery_enabled"
|
||||
_K_SUBNETS = "discovery_subnets"
|
||||
_K_INTERVAL = "discovery_interval"
|
||||
_K_PORT = "discovery_port"
|
||||
# 指纹匹配时是否自动认领(默认关:认领会改写分组/任务引用,默认交人工确认)
|
||||
_K_AUTO_CLAIM = "discovery_auto_claim"
|
||||
|
||||
# 单网段主机数上限(防误配 /8 之类),超过截断并告警
|
||||
_MAX_HOSTS_PER_SUBNET = 1024
|
||||
@@ -72,10 +74,11 @@ def get_settings():
|
||||
"subnets": subnets,
|
||||
"interval": int(_get(_K_INTERVAL, str(DISCOVERY_INTERVAL)) or DISCOVERY_INTERVAL),
|
||||
"port": int(_get(_K_PORT, str(DISCOVERY_PORT)) or DISCOVERY_PORT),
|
||||
"auto_claim": _get(_K_AUTO_CLAIM, "0") == "1", # 默认关
|
||||
}
|
||||
|
||||
|
||||
def save_settings(enabled=None, subnets=None, interval=None, port=None):
|
||||
def save_settings(enabled=None, subnets=None, interval=None, port=None, auto_claim=None):
|
||||
"""写发现配置(部分字段更新)。subnets 逐项校验 CIDR,非法返回 (False, 原因)。"""
|
||||
if subnets is not None:
|
||||
clean = []
|
||||
@@ -109,6 +112,8 @@ def save_settings(enabled=None, subnets=None, interval=None, port=None):
|
||||
_put(_K_INTERVAL, str(int(interval)))
|
||||
if port is not None:
|
||||
_put(_K_PORT, str(int(port)))
|
||||
if auto_claim is not None:
|
||||
_put(_K_AUTO_CLAIM, "1" if auto_claim else "0")
|
||||
db.session.commit()
|
||||
return True, "已保存"
|
||||
|
||||
@@ -196,6 +201,35 @@ def _parse_adb_devices(out):
|
||||
return devices
|
||||
|
||||
|
||||
# ================== 自动认领(可选) ==================
|
||||
def _auto_claim(fps):
|
||||
"""指纹命中池中已有设备 → 自动认领:迁移记录到新地址并同步分组/任务引用。
|
||||
|
||||
仅在设置 discovery_auto_claim 打开时由扫描线程调用(默认关)。
|
||||
返回 [(old_serial, new_serial, name), ...]。
|
||||
"""
|
||||
from core import device_pool
|
||||
done = []
|
||||
for serial, fp in (fps or {}).items():
|
||||
if not fp:
|
||||
continue
|
||||
try:
|
||||
info = device_pool.find_by_fingerprint(fp)
|
||||
if not info or info.get("serial") == serial:
|
||||
continue # 没匹配到,或本来就是这条(无需迁移)
|
||||
old, name = device_pool.claim_device(serial, fp)
|
||||
if not old:
|
||||
continue
|
||||
PendingDevice.query.filter_by(serial=serial).delete()
|
||||
db.session.commit()
|
||||
done.append((old, serial, name or info.get("name") or ""))
|
||||
_log.info(f"自动认领: 『{name or info.get('name') or old}』{old} → {serial}")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_log.warning(f"自动认领 {serial} 失败: {e}")
|
||||
return done
|
||||
|
||||
|
||||
# ================== 扫描 ==================
|
||||
def scan_once(manual=False):
|
||||
"""执行一轮扫描。返回 (ok, result_dict);后台线程/API 调用。"""
|
||||
@@ -228,20 +262,33 @@ def scan_once(manual=False):
|
||||
# 4. state==device 过滤(排除 unauthorized/offline)
|
||||
verified = _parse_adb_devices(_adb("devices")) & candidates
|
||||
|
||||
# 5. 写 pending:新 → 插入;已有 → 更新 last_seen
|
||||
# 5. 写 pending:新 → 插入;已有 → 更新 last_seen(顺带刷新指纹)
|
||||
# 指纹用于认出"这台其实是设备池里某台设备换了 IP",见 list_pending 的 match
|
||||
now = _fmt()
|
||||
existing = {p.serial for p in PendingDevice.query.all()}
|
||||
added = 0
|
||||
from core import device_pool
|
||||
fps = {}
|
||||
for serial in verified:
|
||||
source = "tailscale" if serial.split(":")[0].startswith("100.") else "lan"
|
||||
try:
|
||||
fp = device_pool.read_fingerprint(serial, timeout=4)
|
||||
except Exception:
|
||||
fp = ""
|
||||
fps[serial] = fp
|
||||
if serial in existing:
|
||||
PendingDevice.query.filter_by(serial=serial).update(
|
||||
{"last_seen": now})
|
||||
{"last_seen": now, "fingerprint": fp})
|
||||
else:
|
||||
db.session.add(PendingDevice(serial=serial, source=source,
|
||||
first_seen=now, last_seen=now))
|
||||
first_seen=now, last_seen=now,
|
||||
fingerprint=fp))
|
||||
added += 1
|
||||
db.session.commit()
|
||||
# 5.5 自动认领(可选,默认关):指纹命中池中已有设备 → 直接把记录迁到新地址。
|
||||
# 默认关是因为认领会改写分组/任务引用(数据结构变动),交人工点一下更稳妥;
|
||||
# 打开后零点击完成,见 doc/API.md §6。
|
||||
claimed = _auto_claim(fps) if settings.get("auto_claim") else []
|
||||
# 6. 正式池断联设备自动重连:adb connect 会因 WiFi 波动/设备重启/
|
||||
# adb 服务重启而断开——扫描线程每轮顺带重试(幂等轻量,内部
|
||||
# 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给
|
||||
@@ -249,10 +296,13 @@ def scan_once(manual=False):
|
||||
back = _reconnect_offline(configured)
|
||||
_log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台,"
|
||||
f"新增待连接 {added} 台"
|
||||
+ (f",自动重连恢复 {len(back)} 台 {back}" if back else ""))
|
||||
+ (f",自动重连恢复 {len(back)} 台 {back}" if back else "")
|
||||
+ (f",自动认领 {len(claimed)} 台 {[c[0] + '→' + c[1] for c in claimed]}"
|
||||
if claimed else ""))
|
||||
_last_scan = (now, len(open_ips), len(verified), added)
|
||||
_last_error = ""
|
||||
return True, {"found": len(open_ips), "verified": len(verified), "new": added}
|
||||
return True, {"found": len(open_ips), "verified": len(verified),
|
||||
"new": added, "claimed": len(claimed)}
|
||||
except Exception as e:
|
||||
_log.warning(f"发现扫描异常: {e}")
|
||||
_last_error = str(e)[:200]
|
||||
@@ -352,6 +402,7 @@ def get_status():
|
||||
"subnets": settings["subnets"],
|
||||
"interval": settings["interval"],
|
||||
"port": settings["port"],
|
||||
"auto_claim": settings["auto_claim"],
|
||||
"scanning": _scanning,
|
||||
"last_scan": _last_scan[0] if _last_scan else "",
|
||||
"last_result": ({"found": _last_scan[1], "verified": _last_scan[2],
|
||||
@@ -371,20 +422,47 @@ def list_pending():
|
||||
with _ctx():
|
||||
rows = [p.to_dict() for p in PendingDevice.query.order_by(
|
||||
PendingDevice.first_seen.desc()).all()]
|
||||
return [dict(r, online=True) for r in rows if r["serial"] in online]
|
||||
out = []
|
||||
for r in rows:
|
||||
if r["serial"] not in online:
|
||||
continue
|
||||
r["online"] = True
|
||||
# 指纹匹配:这台其实就是设备池里某台设备换了地址(前端据此提示"认领")
|
||||
r["match"] = None
|
||||
if r.get("fingerprint"):
|
||||
try:
|
||||
from core import device_pool
|
||||
m = device_pool.find_by_fingerprint(r["fingerprint"])
|
||||
if m and m["serial"] != r["serial"]:
|
||||
r["match"] = {"serial": m["serial"], "name": m.get("name") or ""}
|
||||
except Exception:
|
||||
pass
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def confirm_pending(serial, name=""):
|
||||
def confirm_pending(serial, name="", fingerprint=""):
|
||||
"""确认连接:pending 行 → 正式设备池(add_device upsert)→ 删 pending。
|
||||
|
||||
返回 (ok, msg, is_new)。adb_connect + 采型号由 API 层后台线程做。
|
||||
先按指纹尝试**认领**:同一台物理设备换了地址时,把池中旧记录迁到新 serial,
|
||||
并同步分组/任务里的引用(名称等信息全部保留),而不是新增一条。
|
||||
|
||||
返回 (ok, msg, is_new)。adb_connect + 采型号/指纹由调用方(API 层后台线程)做。
|
||||
"""
|
||||
with _ctx():
|
||||
row = PendingDevice.query.get(serial)
|
||||
if not row:
|
||||
return False, "设备不在待连接列表", False
|
||||
from core import device_pool
|
||||
is_new = device_pool.add_device(serial, name=name or "")
|
||||
fp = (fingerprint or row.fingerprint or "").strip()
|
||||
claimed_old, claimed_name = device_pool.claim_device(serial, fp)
|
||||
if claimed_old:
|
||||
device_pool.add_device(serial, name=claimed_name or name, fingerprint=fp)
|
||||
PendingDevice.query.filter_by(serial=serial).delete()
|
||||
db.session.commit()
|
||||
return True, (f"已认领为『{claimed_name or claimed_old}』"
|
||||
f"(原地址 {claimed_old},分组/任务的引用已同步)"), False
|
||||
is_new = device_pool.add_device(serial, name=name or "", fingerprint=fp)
|
||||
PendingDevice.query.filter_by(serial=serial).delete()
|
||||
db.session.commit()
|
||||
return True, "已加入设备池" + ("" if is_new else "(已存在,信息已更新)"), is_new
|
||||
|
||||
+196
-5
@@ -22,6 +22,16 @@ from core.models import db, Device
|
||||
_log = get_logger("core.device_pool")
|
||||
|
||||
_app = None
|
||||
# 设备换地址(认领/迁址)后的回调:(old_serial, new_serial) -> None
|
||||
# 由 web_server 装配时注册为 TaskManager.sync_device_serial——
|
||||
# 分组/任务的内存副本在 TaskManager 里,只改库不改内存不生效(见 _move_device_row)
|
||||
_move_hook = None
|
||||
|
||||
|
||||
def set_move_hook(fn):
|
||||
"""注册"设备换地址"回调(web_server 创建完 TaskManager 后调用)。"""
|
||||
global _move_hook
|
||||
_move_hook = fn
|
||||
|
||||
|
||||
def init_app(app):
|
||||
@@ -109,8 +119,131 @@ def list_ready():
|
||||
return [s for s in list_configured() if s in online]
|
||||
|
||||
|
||||
# ================== 设备指纹(识别"同一台物理设备") ==================
|
||||
def read_fingerprint(serial, timeout=6):
|
||||
"""读取设备指纹(ro.serialno)——设备换 IP 后据此认领回原记录。
|
||||
|
||||
只对网络设备(serial 含 ":")读取:USB 设备的 serial 本身就是稳定序列号,
|
||||
不存在"换地址"问题。采集失败返回空串(静默,不影响主流程)。
|
||||
"""
|
||||
serial = (serial or "").strip()
|
||||
if not serial or ":" not in serial:
|
||||
return ""
|
||||
# 部分机型 ro.serialno 为空,退回 ro.boot.serialno
|
||||
for prop in ("ro.serialno", "ro.boot.serialno"):
|
||||
try:
|
||||
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "getprop", prop],
|
||||
capture_output=True, timeout=timeout)
|
||||
fp = (r.stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if fp and fp.isprintable():
|
||||
return fp
|
||||
return ""
|
||||
|
||||
|
||||
def find_by_fingerprint(fingerprint, exclude_serial=""):
|
||||
"""按指纹查池中设备(返回 dict 或 None),可排除指定 serial。"""
|
||||
fingerprint = (fingerprint or "").strip()
|
||||
if not fingerprint:
|
||||
return None
|
||||
with _ctx():
|
||||
row = Device.query.filter(Device.fingerprint == fingerprint).first()
|
||||
if not row or row.serial == exclude_serial:
|
||||
return None
|
||||
return row.to_dict()
|
||||
|
||||
|
||||
def name_taken(name, exclude_serial=""):
|
||||
"""名称是否已被其它设备占用(唯一约束的应用层检查,给友好提示用)。"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
with _ctx():
|
||||
q = Device.query.filter(Device.name == name)
|
||||
if exclude_serial:
|
||||
q = q.filter(Device.serial != exclude_serial)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def _move_device_row(old, new_serial, fingerprint=""):
|
||||
"""把 old 记录迁到 new_serial(调用方持 app context)。
|
||||
|
||||
只动 device 表本身;**分组/任务引用的同步交给上层**(见 set_move_hook):
|
||||
分组与任务在 TaskManager 里还有一份内存副本,调度用的是内存对象——
|
||||
只改库不改内存,不重启不生效。而 device_pool 不能反向依赖 task_manager
|
||||
(会造成循环 import),所以用回调把这件事交给装配层。
|
||||
"""
|
||||
old_serial, name = old.serial, (old.name or "")
|
||||
# 新地址上若已有记录(重复添加等),以"被认领的旧记录"为准,删掉它
|
||||
dup = Device.query.get(new_serial)
|
||||
if dup is not None and dup.serial != old.serial:
|
||||
db.session.delete(dup)
|
||||
old.serial = new_serial
|
||||
if fingerprint:
|
||||
old.fingerprint = fingerprint
|
||||
db.session.commit()
|
||||
if _move_hook is not None:
|
||||
try:
|
||||
_move_hook(old_serial, new_serial)
|
||||
except Exception as e:
|
||||
_log.warning(f"设备迁址后同步分组/任务引用失败: {e}")
|
||||
return old_serial, name
|
||||
|
||||
|
||||
def claim_device(new_serial, fingerprint):
|
||||
"""认领:同一台物理设备换了地址,把池中旧记录迁到新 serial 并同步所有引用。
|
||||
|
||||
做三件事(一个事务内):
|
||||
1. 把旧记录的 serial 改成新地址(名称/型号/备注/启用状态/添加时间全部保留)
|
||||
2. 同步 device_group.serials 里的旧 serial → 新 serial(否则分组吊着死 IP)
|
||||
3. 同步 task_job.target.serial(指定设备模式的任务目标)
|
||||
|
||||
返回 (old_serial, name);没有匹配到旧记录时返回 (None, "")。
|
||||
"""
|
||||
fingerprint = (fingerprint or "").strip()
|
||||
new_serial = (new_serial or "").strip()
|
||||
if not fingerprint or not new_serial:
|
||||
return None, ""
|
||||
with _ctx():
|
||||
old = Device.query.filter(Device.fingerprint == fingerprint,
|
||||
Device.serial != new_serial).first()
|
||||
if not old:
|
||||
return None, ""
|
||||
old_serial, name = _move_device_row(old, new_serial)
|
||||
_log.info(f"设备认领: 指纹 {fingerprint} 的『{name or old_serial}』"
|
||||
f"由 {old_serial} 迁到 {new_serial}")
|
||||
return old_serial, name
|
||||
|
||||
|
||||
def relocate_device(old_serial, new_serial, fingerprint=""):
|
||||
"""人工认领:把池中 old_serial 的记录改到 new_serial(换地址的手工兜底)。
|
||||
|
||||
用在"设备已经断联、读不到指纹"的场景:设备换了 IP 后旧地址连不上,
|
||||
指纹也没采过,自动认领无从匹配——此时由人工指认"这条就是那台,
|
||||
现在在 X",本函数负责迁移并同步分组/任务引用。
|
||||
|
||||
返回 (ok, name, msg)。
|
||||
"""
|
||||
old_serial = (old_serial or "").strip()
|
||||
new_serial = (new_serial or "").strip()
|
||||
if not old_serial or not new_serial:
|
||||
return False, "", "缺少参数"
|
||||
if old_serial == new_serial:
|
||||
return False, "", "新旧地址相同,无需迁移"
|
||||
with _ctx():
|
||||
old = Device.query.get(old_serial)
|
||||
if not old:
|
||||
return False, "", f"设备池中没有 {old_serial}"
|
||||
if Device.query.get(new_serial) is not None:
|
||||
return False, "", f"{new_serial} 已在设备池中,请先处理那条记录"
|
||||
_, name = _move_device_row(old, new_serial, fingerprint)
|
||||
_log.info(f"设备人工认领: 『{name or old_serial}』{old_serial} → {new_serial}")
|
||||
return True, name, f"『{name or old_serial}』已迁到 {new_serial},分组/任务引用已同步"
|
||||
|
||||
|
||||
# ================== 管理(CRUD) ==================
|
||||
def add_device(serial, name="", note="", enabled=True):
|
||||
def add_device(serial, name="", note="", enabled=True, fingerprint=""):
|
||||
"""添加/更新设备(upsert)。返回 True 新增 / False 已存在并更新。"""
|
||||
serial = (serial or "").strip()
|
||||
if not serial:
|
||||
@@ -119,16 +252,47 @@ def add_device(serial, name="", note="", enabled=True):
|
||||
d = Device.query.get(serial)
|
||||
if d:
|
||||
d.name, d.note, d.enabled = name or "", note or "", enabled
|
||||
if fingerprint:
|
||||
d.fingerprint = fingerprint
|
||||
db.session.commit()
|
||||
return False
|
||||
db.session.add(Device(serial=serial, name=name or "", note=note or "",
|
||||
enabled=enabled,
|
||||
enabled=enabled, fingerprint=fingerprint or "",
|
||||
created_at=time.strftime("%Y-%m-%d %H:%M")))
|
||||
db.session.commit()
|
||||
_log.info(f"设备池新增设备: {serial}")
|
||||
return True
|
||||
|
||||
|
||||
def rename_device(serial, name):
|
||||
"""重命名设备(唯一性由调用方先校验)。返回是否成功。"""
|
||||
name = (name or "").strip()
|
||||
with _ctx():
|
||||
d = Device.query.get(serial)
|
||||
if not d or not name or (d.name or "") == name:
|
||||
return False
|
||||
old = d.name or ""
|
||||
d.name = name
|
||||
db.session.commit()
|
||||
_log.info(f"设备池重命名: {old or serial} → {name}({serial})")
|
||||
return True
|
||||
|
||||
|
||||
def set_fingerprint(serial, fingerprint):
|
||||
"""补写设备指纹(认领需要;采集是后来才做的)。"""
|
||||
fingerprint = (fingerprint or "").strip()
|
||||
if not fingerprint:
|
||||
return False
|
||||
with _ctx():
|
||||
d = Device.query.get(serial)
|
||||
if not d or d.fingerprint == fingerprint:
|
||||
return False
|
||||
d.fingerprint = fingerprint
|
||||
db.session.commit()
|
||||
_log.info(f"设备池采集指纹: {serial} -> {fingerprint}")
|
||||
return True
|
||||
|
||||
|
||||
def remove_device(serial):
|
||||
"""删除设备。返回是否删除成功。"""
|
||||
with _ctx():
|
||||
@@ -194,18 +358,45 @@ def refresh_model(serial, timeout=8):
|
||||
return model
|
||||
|
||||
|
||||
def refresh_fingerprint(serial, timeout=6):
|
||||
"""读取并写回设备指纹,返回指纹(失败返回空串)。"""
|
||||
fp = read_fingerprint(serial, timeout=timeout)
|
||||
if fp:
|
||||
set_fingerprint(serial, fp)
|
||||
return fp
|
||||
|
||||
|
||||
def refresh_info(serial):
|
||||
"""采集一台设备的型号 + 指纹(指纹缺失时才读,避免每次启动都白跑一次 adb)。
|
||||
|
||||
返回 (model, fingerprint)。任一失败都静默——设备池展示用,不影响主流程。
|
||||
"""
|
||||
model = refresh_model(serial)
|
||||
fp = ""
|
||||
with _ctx():
|
||||
row = Device.query.get(serial)
|
||||
fp = (row.fingerprint or "") if row else ""
|
||||
if not fp:
|
||||
fp = refresh_fingerprint(serial)
|
||||
return model, fp
|
||||
|
||||
|
||||
def refresh_all_models():
|
||||
"""批量采集池内在线设备的型号(并发 10,后台线程调用)。返回成功数。"""
|
||||
"""批量采集池内在线设备的型号(顺带补齐缺失的设备指纹),并发 10,后台线程调用。
|
||||
|
||||
返回成功数。指纹补齐很重要:老库里的设备没有指纹,补上之后换 IP 才能被认领。
|
||||
"""
|
||||
serials = list_online()
|
||||
if not serials:
|
||||
return 0
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
ok = 0
|
||||
with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool:
|
||||
futures = {pool.submit(refresh_model, s): s for s in serials}
|
||||
futures = {pool.submit(refresh_info, s): s for s in serials}
|
||||
for fut in as_completed(futures):
|
||||
try:
|
||||
if fut.result():
|
||||
model, _fp = fut.result()
|
||||
if model:
|
||||
ok += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+46
-7
@@ -223,16 +223,20 @@ class Device(db.Model):
|
||||
model 为在线时自动采集的型号(如 Redmi 12C),供管理页/监控页区分设备。
|
||||
"""
|
||||
serial = db.Column(db.String(120), primary_key=True)
|
||||
name = db.Column(db.String(80), default="") # 备注名(可选)
|
||||
model = db.Column(db.String(120), default="") # 型号(自动采集)
|
||||
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
|
||||
note = db.Column(db.Text, default="") # 备注
|
||||
created_at = db.Column(db.String(20), default="") # 添加时间
|
||||
name = db.Column(db.String(80), default="") # 设备名(唯一,人可读标识)
|
||||
model = db.Column(db.String(120), default="") # 型号(自动采集)
|
||||
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
|
||||
note = db.Column(db.Text, default="") # 备注
|
||||
created_at = db.Column(db.String(20), default="") # 添加时间
|
||||
# 设备指纹(ro.serialno):识别"同一台物理设备"的稳定标识。
|
||||
# 网络设备(serial=IP:5555)换 IP 后靠它认领回原记录,名称/分组/任务引用都不丢。
|
||||
fingerprint = db.Column(db.String(120), default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {"serial": self.serial, "name": self.name or "",
|
||||
"model": self.model or "", "enabled": bool(self.enabled),
|
||||
"note": self.note or "", "created_at": self.created_at or ""}
|
||||
"note": self.note or "", "created_at": self.created_at or "",
|
||||
"fingerprint": self.fingerprint or ""}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Device {self.serial}>"
|
||||
@@ -247,10 +251,14 @@ class PendingDevice(db.Model):
|
||||
source = db.Column(db.String(20), default="") # lan / tailscale
|
||||
first_seen = db.Column(db.String(20), default="") # 首次发现时间
|
||||
last_seen = db.Column(db.String(20), default="") # 最近一次扫描仍可见的时间
|
||||
# 扫描时顺带读取的设备指纹:与设备池中已有记录比对,用于提示
|
||||
# 「这台其实就是 <名称>(原 IP 变了)」而不是让用户在一堆陌生 IP 里猜
|
||||
fingerprint = db.Column(db.String(120), default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {"serial": self.serial, "source": self.source or "",
|
||||
"first_seen": self.first_seen or "", "last_seen": self.last_seen or ""}
|
||||
"first_seen": self.first_seen or "", "last_seen": self.last_seen or "",
|
||||
"fingerprint": self.fingerprint or ""}
|
||||
|
||||
|
||||
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
||||
@@ -273,8 +281,22 @@ SCHEMA_MIGRATIONS = [
|
||||
"source VARCHAR(20) DEFAULT '',"
|
||||
"first_seen VARCHAR(20) DEFAULT '',"
|
||||
"last_seen VARCHAR(20) DEFAULT '')"),
|
||||
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)",
|
||||
"ALTER TABLE device ADD COLUMN fingerprint VARCHAR(120) DEFAULT ''"),
|
||||
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)",
|
||||
"ALTER TABLE pending_device ADD COLUMN fingerprint VARCHAR(120) DEFAULT ''"),
|
||||
]
|
||||
|
||||
# 唯一索引(部分索引:空值不参与唯一约束,兼容历史未命名/未采指纹的老数据)
|
||||
# 名称唯一 = 设备的人可读标识;指纹唯一 = 一台物理设备在池中只能有一条记录
|
||||
_UNIQUE_INDEXES = (
|
||||
("ux_device_name", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_name "
|
||||
"ON device(name) WHERE name IS NOT NULL AND name <> ''"),
|
||||
("ux_device_fingerprint", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_fingerprint "
|
||||
"ON device(fingerprint) WHERE fingerprint IS NOT NULL "
|
||||
"AND fingerprint <> ''"),
|
||||
)
|
||||
|
||||
|
||||
def init_db(app):
|
||||
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
|
||||
@@ -322,10 +344,27 @@ def _migrate_schema():
|
||||
{"v": str(version)})
|
||||
db.session.commit()
|
||||
_log.info(f"schema 迁移到版本 {version}: {desc}")
|
||||
_ensure_unique_indexes()
|
||||
except Exception as e:
|
||||
_log.error(f"schema 迁移失败(不阻塞启动): {e}")
|
||||
|
||||
|
||||
def _ensure_unique_indexes():
|
||||
"""建设备池的唯一索引(幂等)。
|
||||
|
||||
单独抽出来是因为索引不属于某个版本迁移:老库升级后也要补建。
|
||||
历史数据若存在重复(名称/指纹撞车),建索引会失败——只告警不回滚、
|
||||
不阻塞启动,由管理页提示用户改名(唯一约束从此刻起对新数据生效)。
|
||||
"""
|
||||
for name, sql in _UNIQUE_INDEXES:
|
||||
try:
|
||||
db.session.execute(text(sql))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
|
||||
|
||||
|
||||
def _ensure_default_admin():
|
||||
"""首次启动创建默认管理员 admin/admin123。"""
|
||||
if not User.query.filter_by(username="admin").first():
|
||||
|
||||
+56
-17
@@ -449,18 +449,55 @@ class TaskManager:
|
||||
self._save_groups()
|
||||
return g
|
||||
|
||||
def sync_device_serial(self, old_serial, new_serial):
|
||||
"""设备换地址(认领/迁址)后,把分组与任务里的引用改到新地址。
|
||||
|
||||
由 device_pool 的迁址回调触发(见 device_pool.set_move_hook)。
|
||||
**必须同时改内存与库**:调度用的是内存里的 groups/jobs,只改库不重启不生效
|
||||
—— 表现就是"分组里少了一台、指定设备的任务仍跑向不存在的地址"。
|
||||
|
||||
返回被更新的引用条数。
|
||||
"""
|
||||
if not old_serial or not new_serial or old_serial == new_serial:
|
||||
return 0
|
||||
n = 0
|
||||
for name, g in list(self.groups.items()):
|
||||
try:
|
||||
serials = list(g.serials or [])
|
||||
except Exception:
|
||||
continue
|
||||
if old_serial in serials:
|
||||
self.update_group(name, serials=[new_serial if s == old_serial else s
|
||||
for s in serials])
|
||||
n += 1
|
||||
_log.info(f"设备换地址: 分组『{name}』的 {old_serial} → {new_serial}")
|
||||
for job_id, job in list(self.jobs.items()):
|
||||
try:
|
||||
target = dict(job.target or {})
|
||||
except Exception:
|
||||
continue
|
||||
if target.get("mode") == "serial" and target.get("serial") == old_serial:
|
||||
target["serial"] = new_serial
|
||||
self.update_job(job_id, target=target)
|
||||
n += 1
|
||||
_log.info(f"设备换地址: 任务『{job.name}』目标 → {new_serial}")
|
||||
return n
|
||||
|
||||
def delete_group(self, name):
|
||||
removed = False
|
||||
if name in self.groups:
|
||||
del self.groups[name]
|
||||
self._save_groups()
|
||||
# 同上:显式删数据库行,避免删除的分组重启后复活
|
||||
with self._db():
|
||||
row = GroupRow.query.filter_by(name=name).first()
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
removed = True
|
||||
# 无论内存里有没有都尝试删库行:内存与库不一致时(手工插行、上次异常退出、
|
||||
# 上个版本漏删)也能删干净,避免"删不掉的分组"
|
||||
with self._db():
|
||||
row = GroupRow.query.filter_by(name=name).first()
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
removed = True
|
||||
return removed
|
||||
|
||||
# ---- 任务计划管理 ----
|
||||
def add_job(self, name, task_type, target, params, schedule, retry, enabled=True):
|
||||
@@ -490,18 +527,20 @@ class TaskManager:
|
||||
|
||||
def delete_job(self, job_id):
|
||||
self._remove_cron(job_id)
|
||||
removed = False
|
||||
if job_id in self.jobs:
|
||||
del self.jobs[job_id]
|
||||
self._save_jobs()
|
||||
# _save_jobs 只做 upsert 不会删行:必须显式删数据库行,
|
||||
# 否则删除的任务重启后会从数据库重新加载回来
|
||||
with self._db():
|
||||
row = JobRow.query.get(job_id)
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
removed = True
|
||||
# _save_jobs 只做 upsert 不会删行:必须显式删库行,否则删除的任务重启会复活。
|
||||
# 内存里没有(手工插行/上次异常退出)时也要删——否则会出现"删不掉的任务"
|
||||
with self._db():
|
||||
row = JobRow.query.get(job_id)
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
removed = True
|
||||
return removed
|
||||
|
||||
def toggle_job(self, job_id, enabled):
|
||||
job = self.jobs.get(job_id)
|
||||
|
||||
+58
-15
@@ -83,7 +83,7 @@
|
||||
| GET | `/api/summary` | L | 状态计数 + 异常设备列表(最多 50) |
|
||||
| GET | `/api/health` | — | 健康检查(免登录探活) |
|
||||
| POST | `/api/scan_foreground` | D | 触发前台 App 扫描 |
|
||||
| GET | `/api/devices` | L | 在线设备 serial 列表(分组表单用) |
|
||||
| GET | `/api/devices` | L | 在线设备列表:`devices`(serial 列表) + `items`([{serial,name,model}],界面显示设备名用) |
|
||||
| GET | `/api/devices/<serial>/apps` | L | 指定设备已安装应用列表 |
|
||||
| GET | `/api/device/screenshot` | L | 单张设备截图(PNG) |
|
||||
| GET | `/api/screen/stream` | D | 远程看屏 MJPEG 流 |
|
||||
@@ -143,8 +143,10 @@
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 功能 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/devices/pool` | D | 设备池清单(附实时在线状态) |
|
||||
| POST | `/api/devices/pool/add` | D | 添加/更新设备 |
|
||||
| GET | `/api/devices/pool` | D | 设备池清单(附实时在线状态 + 设备指纹) |
|
||||
| POST | `/api/devices/pool/add` | D | 添加/更新设备(**名称必填唯一**,按指纹自动认领换 IP 的设备) |
|
||||
| POST | `/api/devices/pool/rename` | D | 重命名设备(名称唯一) |
|
||||
| POST | `/api/devices/pool/relocate` | D | **人工认领**:把记录迁到新地址并同步分组/任务引用 |
|
||||
| POST | `/api/devices/pool/remove` | D | 从池中删除 |
|
||||
| POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) |
|
||||
| POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 |
|
||||
@@ -192,7 +194,7 @@
|
||||
| 方法 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| GET/POST | `/api/agent/config` | 读写 AI 配置(key 打码回显) |
|
||||
| GET | `/api/agent/devices` | AI 可用设备(含 busy 标记) |
|
||||
| GET | `/api/agent/devices` | AI 可用设备(含名称与 busy 标记) |
|
||||
| POST | `/api/agent/run` | 启动一轮 AI 会话 |
|
||||
| GET | `/api/agent/run` | 运行状态(刷新恢复用) |
|
||||
| GET | `/api/agent/stream` | **SSE 事件流** |
|
||||
@@ -395,24 +397,65 @@
|
||||
|
||||
## 6. 设备池与自动发现
|
||||
|
||||
### 6.1 设备身份:名称 + 指纹
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| **名称 `name`** | **必填且唯一**,设备在平台里的人可读标识(分组/任务/日志都按它认设备) |
|
||||
| **serial** | 设备的**当前连接地址**(`IP:5555` 或 USB 序列号)——**可变** |
|
||||
| **指纹 `fingerprint`** | `ro.serialno`,识别"同一台物理设备"的稳定标识(只对网络设备采集;USB 的 serial 本身已稳定) |
|
||||
|
||||
**为什么需要**:设备池原先拿 serial(IP)当身份,设备一换 IP 就变成"陌生新设备",
|
||||
旧记录永远连不上,分组与 serial 模式的任务还吊着死地址。现在:
|
||||
|
||||
- 添加/确认设备时读取指纹 → 若命中池中已有设备(**同一台换了地址**)→ **自动认领**
|
||||
- 认领 = 迁移原记录(名称/型号/备注/启用状态/添加时间全保留)+ 把 `device_group.serials`
|
||||
与 `task_job.target.serial` 里的旧地址**同步换成新地址**(库里与内存一起改)
|
||||
- 旧地址已断联、指纹也没采过时(自动认领无从匹配)→ 用 `pool/relocate` **人工认领**
|
||||
|
||||
> 指纹在设备在线时自动采集(添加/确认/启动刷新/「采集型号」按钮都会补);
|
||||
> 设备列表的「指纹」列:🔑 = 已采集,— = 尚未采集(离线设备采不到)。
|
||||
|
||||
### 6.2 设备池
|
||||
|
||||
| 接口 | 请求 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET /api/devices/pool` | — | 池内设备 + 实时 `online` 状态 |
|
||||
| `POST /api/devices/pool/add` | `{"serial","name?","note?"}` | upsert;`IP:5555` 立即尝试 connect;后台采型号 |
|
||||
| `GET /api/devices/pool` | — | 池内设备 + 实时 `online` + `fingerprint` |
|
||||
| `POST /api/devices/pool/add` | `{"serial","name","note"?}` | **name 必填**(空 → 400)、**唯一**(重名 → 400)。IP:5555 会轻量 `adb connect` 并读指纹,命中则**自动认领**(响应 `claimed=true` + `old_serial`) |
|
||||
| `POST /api/devices/pool/rename` | `{"serial","name"}` | 改名(唯一校验;不存在 → 404) |
|
||||
| `POST /api/devices/pool/relocate` | `{"old_serial","new_serial"}` | 人工认领:迁移记录 + 同步引用;旧地址不在池 → 400,新地址已在池 → 400 |
|
||||
| `POST /api/devices/pool/remove` | `{"serial"}` | 不存在 → 404 |
|
||||
| `POST /api/devices/pool/toggle` | `{"serial","enabled"}` | 停用则不参与调度 |
|
||||
| `POST /api/devices/pool/reconnect` | — | 后台并发 10 线程重连全部网络设备 |
|
||||
| `POST /api/devices/pool/refresh_models` | — | 后台批量采型号 |
|
||||
| `GET /api/devices/discovery` | — | 发现状态 + 待连接 + 池内断联(前端 10s 轮询) |
|
||||
| `POST /api/devices/discovery/scan` | — | 后台扫描一轮(约 5-30s);已有扫描 → **409** |
|
||||
| `POST /api/devices/discovery/confirm` | `{"serial"}` | 待连接 → 设备池 + connect + 采型号 |
|
||||
| `POST /api/devices/pool/reconnect` | — | 后台并发重连全部网络设备 |
|
||||
| `POST /api/devices/pool/refresh_models` | — | 后台批量采型号 **+ 补齐缺失指纹** |
|
||||
|
||||
### 6.2.1 名称在界面上的呈现
|
||||
|
||||
凡"选择设备 / 展示设备"的地方都以**名称**为主、地址为辅:
|
||||
|
||||
| 位置 | 呈现 |
|
||||
|------|------|
|
||||
| 监控页设备表 | 名称加粗为主行,`serial` 作副行(未命名显示橙色"未命名"提醒) |
|
||||
| 任务运行概况的覆盖设备 chip | 名称(无名才退地址),tooltip 里带完整 `serial` |
|
||||
| AI 控制台「目标设备」「观看设备」下拉 | `名称 · serial · 型号` |
|
||||
| 任务编辑器「指定设备」下拉 | `名称 · serial` |
|
||||
| 分组编辑的设备勾选列表 | `名称 · serial` |
|
||||
| 设备池 / 待连接池 / 断联设备表 | 名称列 + 指纹列 |
|
||||
| MCP `de_list_devices` | 返回 `name` 字段,提示 AI 汇报时用名称 |
|
||||
|
||||
### 6.3 自动发现
|
||||
|
||||
| 接口 | 请求 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET /api/devices/discovery` | — | 发现状态 + 待连接 + 池内断联(前端 10s 轮询)。**待连接项带 `fingerprint` 与 `match`**(`{"serial","name"}` = 指纹命中的池内设备) |
|
||||
| `POST /api/devices/discovery/scan` | — | 后台扫描一轮;已有扫描 → **409** |
|
||||
| `POST /api/devices/discovery/confirm` | `{"serial","name"?}` | 确认入池。**新设备必须有名称**(空 → 400);**指纹命中已有设备时不需要名称**——保留原记录与原名,直接认领到新地址 |
|
||||
| `POST /api/devices/discovery/ignore` | `{"serial"}` | 从待连接删除 |
|
||||
| `POST /api/devices/discovery/reconnect` | `{"serial"}` | 只接受池内设备,否则 404 |
|
||||
| `POST /api/devices/discovery/settings` | `{"enabled","subnets","interval","port"}` | 部分更新,存 `app_meta` |
|
||||
| `POST /api/devices/discovery/settings` | `{"enabled","subnets","interval","port","auto_claim"}` | 部分更新,存 `app_meta`。`auto_claim`=**指纹匹配时自动认领**(默认 **false**:认领会改写分组/任务引用,默认交人工确认;打开后扫描到"同一台设备换了地址"自动迁移) |
|
||||
|
||||
> 自动发现**只做 socket 探测 + 只读校验**,不会把设备直接拉进设备池(必须人工确认)。
|
||||
|
||||
---
|
||||
> 扫描只做 socket 探测 + 只读校验,**不会把设备直接拉进设备池**(必须人工确认)。
|
||||
> 扫描时会顺带读一遍候选设备的指纹(写入待连接池),用于提示"这台是已有设备换了地址"。
|
||||
|
||||
## 7. 远程看屏与设备操作
|
||||
|
||||
|
||||
@@ -135,6 +135,25 @@
|
||||
|
||||
## 4. 设备生命周期
|
||||
|
||||
### 4.0 设备身份:名称 + 指纹(2026-09-11)
|
||||
|
||||
设备池原以 **serial(IP)当身份**,设备一换 IP 旧记录就成了连不上的"僵尸条目",分组与
|
||||
serial 模式的任务还吊着死地址。现在拆成三层:
|
||||
|
||||
| 概念 | 是否稳定 | 作用 |
|
||||
|------|---------|------|
|
||||
| `name`(名称,**必填唯一**) | 稳定 | 人可读身份;分组/任务/日志按名称认设备 |
|
||||
| `fingerprint`(`ro.serialno`) | 稳定 | **机器识别**:认出"这是同一台设备" |
|
||||
| `serial`(IP:5555 / USB 序号) | **可变** | 当前连接地址 |
|
||||
|
||||
**认领**(`device_pool.claim_device` 自动 / `relocate_device` 人工):指纹命中或人工指认后,
|
||||
把旧记录迁到新地址(名称/型号/备注/启用状态/添加时间全保留)并同步引用。
|
||||
|
||||
> ⚠️ **引用同步必须同时改库与内存**:分组、任务在 `TaskManager` 里还有一份内存副本,
|
||||
> **调度用的是内存对象**——只改库不重启不生效(表现为"分组里少一台、任务仍跑向旧地址")。
|
||||
> 因此 `device_pool` 迁址后回调 `TaskManager.sync_device_serial`,由装配层用
|
||||
> `device_pool.set_move_hook(...)` 注册;`device_pool` 不能反向 import `task_manager`(循环依赖)。
|
||||
|
||||
### 4.1 入池(三条路径)
|
||||
|
||||
| 路径 | 入口 | 过程 |
|
||||
@@ -143,6 +162,9 @@
|
||||
| 自动发现确认 | `POST /api/devices/discovery/confirm` | 扫描写 `pending_device` → 确认后 `add_device` + 删 pending + connect + 采型号 |
|
||||
| 启动预连接 | `_preconnect_pool_devices()` | 进程启动时并发 connect 池内网络设备(仅 `IP:5555`) |
|
||||
|
||||
> 任何一条入池路径在设备可连时都会读取**设备指纹**;指纹命中池中已有设备 = 同一台换了地址
|
||||
> → 走**认领**(§4.0),不新增记录。
|
||||
|
||||
断联设备的自动重连由发现线程每轮执行(只重连 `IP:5555`)。
|
||||
|
||||
### 4.2 可用性判定
|
||||
|
||||
+18
-1
@@ -105,6 +105,7 @@
|
||||
| `enabled` | Boolean | `True` | 停用则不参与调度 |
|
||||
| `note` | Text | `""` | |
|
||||
| `created_at` | String(20) | `""` | |
|
||||
| `fingerprint` | String(120) | `""` | **设备指纹**(`ro.serialno`,迁移 v5 追加):同一台物理设备换 IP 后据此认领回原记录 |
|
||||
|
||||
### 2.7 `pending_device` — 待确认的发现设备
|
||||
|
||||
@@ -113,6 +114,7 @@
|
||||
| `serial` | String(120) | — | 主键 |
|
||||
| `source` | String(20) | `""` | `lan` / `tailscale` |
|
||||
| `first_seen` / `last_seen` | String(20) | `""` | 时间串 |
|
||||
| `fingerprint` | String(120) | `""` | 扫描时读取的设备指纹(迁移 v6 追加),用于提示"这是已有设备换了地址" |
|
||||
|
||||
> ⚠️ SQLAlchemy 模型的 `default=` 是 **Python 侧默认值**,SQLite 建表语句里没有 `DEFAULT` 子句;只有原生建表的表才有真正的 SQL DEFAULT。
|
||||
|
||||
@@ -152,6 +154,8 @@
|
||||
| 2 | 建 `device` 表(**无 `model` 列**) | 幂等(`IF NOT EXISTS`) |
|
||||
| 3 | `device` 增加 `model` | `ALTER TABLE`(同上兜底) |
|
||||
| 4 | 建 `pending_device` 表 | 幂等 |
|
||||
| 5 | `device` 增加 `fingerprint`(设备指纹) | `ALTER TABLE`(靠 duplicate column 兜底) |
|
||||
| 6 | `pending_device` 增加 `fingerprint` | 同上 |
|
||||
|
||||
执行器 `_migrate_schema()`:
|
||||
|
||||
@@ -161,7 +165,20 @@
|
||||
4. 每条迁移后 `INSERT OR REPLACE app_meta('schema_version', v)` 并提交
|
||||
5. 整体包 try:失败只记 error 日志,**不阻塞启动**
|
||||
|
||||
### 4.2 旧 JSON 迁移(一次性)
|
||||
### 4.2 唯一索引(设备身份)
|
||||
|
||||
`_migrate_schema()` 末尾会补建两个**部分唯一索引**(幂等,与版本号无关,老库升级时也会补):
|
||||
|
||||
| 索引 | 作用 |
|
||||
|------|------|
|
||||
| `ux_device_name` | 设备**名称唯一**(`WHERE name <> ''`,兼容历史空名) |
|
||||
| `ux_device_fingerprint` | **一台物理设备在池中只有一条记录**(`WHERE fingerprint <> ''`) |
|
||||
|
||||
> 用**部分索引**而不是表级约束:历史数据可能有空名称/空指纹,`<> ''` 让它们不参与唯一性判断。
|
||||
> 若历史数据里已有重复(建索引失败),只告警不回滚、不阻塞启动——约束从此刻起对新数据生效,
|
||||
> 老的重复行由管理页「改名」处理。
|
||||
|
||||
### 4.3 旧 JSON 迁移(一次性)
|
||||
|
||||
启动时若存在 `data/groups.json` / `data/jobs.json`:对应表为空则导入,随后把文件重命名为 `<name>.json.migrated` 归档。**库非空但 JSON 仍在 → 直接归档**,防止"用户删空数据后重启又复原"。
|
||||
|
||||
|
||||
@@ -258,6 +258,7 @@ docker restart python-app # 容器场景
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 设备显示离线 | 「设备池管理 → 一键重连」;确认设备在线、网络互通(生产:同 tailnet) |
|
||||
| **设备"断联"但其实没关机** | 多数是**换了 IP**(DHCP 重新分配):设备池管理点「**换地址**」把记录迁到新地址(名称与分组/任务引用自动保留);已采集指纹的设备会被扫描自动识别为"已有设备换了地址",确认即认领 |
|
||||
| 加了设备但不被调度 | 只连上 adb 不够,必须在**设备池**中且 `enabled=true` |
|
||||
| `u2.connect 超时` | atx-agent 无响应:重启设备或重新推送 atx-agent(基类有 30s 超时保护) |
|
||||
| 大量设备同时连接时超时 | Windows 端口耗尽(`WinError 10048`):代码会打 `[transient]` 并退避 120s 重试;减少并发或调整系统 TIME_WAIT |
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<平台密码> \
|
||||
|
||||
| 工具 | 参数 | 返回 / 说明 |
|
||||
|------|------|------------|
|
||||
| `de_list_devices` | — | `[{serial, model, online, task_job, worker_status, foreground_app}]`。**开局第一步** |
|
||||
| `de_list_devices` | — | `[{serial, name, model, online, task_job, worker_status, foreground_app}]`。**开局第一步**;`name` 是设备在平台里的名称(身份),**向用户汇报时用名称**(同型号多台靠它区分),调工具仍用 `serial` |
|
||||
| `de_foreground_app` | `serial` | `{foreground_app}` 当前前台包名(dumpsys,MIUI 焦点为空时兜底) |
|
||||
| `de_list_tasks` | — | 平台任务计划 `[{id,name,task_type,enabled,schedule}]`(只读) |
|
||||
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@ class _UsageUnsupported(RuntimeError):
|
||||
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
|
||||
|
||||
工作规范:
|
||||
1. 先 de_list_devices 确定目标设备(在线才可操作)
|
||||
1. 先 de_list_devices 确定目标设备(在线才可操作);设备有 name(名称)与 serial(地址),
|
||||
**给用户汇报时用名称**(同型号多台靠它区分),调工具时仍用 serial
|
||||
2. 观察屏幕:先 de_screenshot 获取截图(图像会随后给你),基于截图理解当前界面
|
||||
3. 点击定位分优先级(不要自己推算像素坐标,那是精度最差的方式):
|
||||
a) 目标有可见文字(按钮/菜单/列表标题/标签/输入框提示)→ de_tap_text 直接给文字,
|
||||
|
||||
@@ -98,9 +98,11 @@ def _ok(data):
|
||||
|
||||
@mcp.tool()
|
||||
def de_list_devices() -> dict:
|
||||
"""列出可控制设备:serial/在线状态/型号/任务状态/前台 App。
|
||||
"""列出可控制设备:名称/地址/在线状态/型号/任务状态/前台 App。
|
||||
|
||||
返回 [{serial, model, online, task_job, worker_status, foreground_app}]。
|
||||
返回 [{serial, name, model, online, task_job, worker_status, foreground_app}]。
|
||||
`name` 是设备在平台里的名称(设备池里的身份标识);对你的用户汇报时**优先用名称**
|
||||
(多台同型号设备时靠它区分)。后续所有工具仍然用 `serial` 指定设备。
|
||||
"""
|
||||
try:
|
||||
devs = platform().list_devices()
|
||||
@@ -110,7 +112,8 @@ def de_list_devices() -> dict:
|
||||
for d in devs:
|
||||
out.append({
|
||||
"serial": d.get("serial"),
|
||||
"model": d.get("model") or d.get("device_name") or "",
|
||||
"name": d.get("device_name") or "",
|
||||
"model": d.get("model") or "",
|
||||
"online": bool(d.get("present")),
|
||||
"task_job": d.get("task_job") or "",
|
||||
"worker_status": d.get("worker_status") or "idle",
|
||||
|
||||
@@ -4,7 +4,9 @@ async function loadGroups(){
|
||||
const r=await apiGet('/api/groups');if(!r)return;
|
||||
_groupsList=r.groups||[];
|
||||
const dr=await apiGet('/api/devices');
|
||||
_devicesList=dr?dr.devices||[]:[];
|
||||
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
|
||||
_devicesList=(dr&&dr.items)?dr.items
|
||||
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
|
||||
renderGroupTable();
|
||||
}
|
||||
|
||||
@@ -43,9 +45,12 @@ function openGroupModal(name){
|
||||
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
|
||||
const g=group||{name:'',description:'',serials:[]};
|
||||
const current= new Set(g.serials||[]);
|
||||
const allSerials=[...new Set([..._devicesList, ...(g.serials||[])])].sort();
|
||||
const nameOf={};
|
||||
(_devicesList||[]).forEach(x=>{ if(x&&x.serial) nameOf[x.serial]=x.name||''; });
|
||||
const allSerials=[...new Set([...(_devicesList||[]).map(x=>x.serial||x), ...(g.serials||[])])].sort();
|
||||
const deviceCheckboxes=allSerials.map(s=>
|
||||
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'+esc(s)+'</label>'
|
||||
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'
|
||||
+esc(nameOf[s]?devText(nameOf[s], s):s)+'</label>'
|
||||
).join('');
|
||||
|
||||
document.getElementById('modal-body').innerHTML=
|
||||
|
||||
+19
-6
@@ -16,6 +16,12 @@ function _friendlyAgentError(msg){
|
||||
|
||||
// ================== 初始化 ==================
|
||||
let _agentDefaultSerial = '';
|
||||
// 目标设备显示文案:名称优先(设备身份),没有名称退地址
|
||||
let _agentTargets={}; // serial -> name
|
||||
function _agentTargetText(serial){
|
||||
const nm=_agentTargets[serial];
|
||||
return nm ? (nm + ' · ' + serial) : String(serial||'');
|
||||
}
|
||||
function initAgentChat(){
|
||||
if(_agentCfgLoaded)return;
|
||||
_agentCfgLoaded = true;
|
||||
@@ -81,9 +87,11 @@ function loadAgentTargetDevices(){
|
||||
if(!sel)return;
|
||||
const cur = sel.value;
|
||||
const devs = (r.devices||[]).filter(x=>x.online);
|
||||
_agentTargets = {};
|
||||
devs.forEach(d=>{ if(d.name) _agentTargets[d.serial]=d.name; });
|
||||
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</option>'
|
||||
+ devs.map(d=>{
|
||||
const label = d.serial + (d.model ? ' · ' + d.model : '')
|
||||
const label = devText(d.name, d.serial) + (d.model ? ' · ' + d.model : '')
|
||||
+ (d.busy ? ' ⛔ 任务中:' + d.task_job : '');
|
||||
return '<option value="'+esc(d.serial)+'"' + (d.busy ? ' disabled' : '')
|
||||
+ '>' + esc(label) + '</option>';
|
||||
@@ -93,7 +101,7 @@ function loadAgentTargetDevices(){
|
||||
else if(_agentDefaultSerial && [...sel.options].some(o=>o.value===_agentDefaultSerial))
|
||||
sel.value = _agentDefaultSerial;
|
||||
const hint = document.getElementById('agent-target-hint');
|
||||
if(hint && sel.value) hint.textContent = '将操作:' + sel.value;
|
||||
if(hint && sel.value) hint.textContent = '将操作:' + _agentTargetText(sel.value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,10 +122,11 @@ function pollRunState(){
|
||||
if(hint){
|
||||
hint.textContent = running
|
||||
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
|
||||
+ (r.serial ? ' · ' + r.serial : '')
|
||||
+ (r.serial ? ' · ' + _agentTargetText(r.serial) : '')
|
||||
+ ':' + (r.prompt||'').slice(0,70))
|
||||
: (document.getElementById('agent-target-select').value
|
||||
? '将操作:' + document.getElementById('agent-target-select').value : '');
|
||||
? '将操作:' + _agentTargetText(document.getElementById('agent-target-select').value)
|
||||
: '');
|
||||
}
|
||||
document.getElementById('btn-agent-send').disabled = running;
|
||||
// 运行中同步 token(本窗口没订阅事件流时也能看到进度)
|
||||
@@ -608,9 +617,13 @@ function loadLiveDevices(){
|
||||
const sel = document.getElementById('agent-live-select');
|
||||
if(!sel)return;
|
||||
const cur = sel.value;
|
||||
const devs = (r.devices||[]).filter(x=>x.indexOf(':')>=0);
|
||||
// items 带设备名称(设备身份);老接口只有 serial 列表时兜底
|
||||
const items = (r.items && r.items.length ? r.items
|
||||
: (r.devices||[]).map(x=>({serial:x, name:''})))
|
||||
.filter(x=>String(x.serial||'').indexOf(':')>=0);
|
||||
sel.innerHTML = '<option value="">选择设备观看…</option>'
|
||||
+ devs.map(s=>'<option value="'+esc(s)+'"'+(s===cur?' selected':'')+'>'+esc(s)+'</option>').join('');
|
||||
+ items.map(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===cur?' selected':'')+'>'
|
||||
+esc(devText(x.name, x.serial))+'</option>').join('');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
|
||||
const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}};
|
||||
const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'};
|
||||
|
||||
// 设备显示文案:**名称是设备身份**,地址只是当前连接位置。
|
||||
// 有名称 → 「名称 · serial」;没名称(历史数据)→ 只显示 serial
|
||||
function devText(name, serial){
|
||||
const n=String(name||'').trim();
|
||||
return n ? (n + ' · ' + serial) : String(serial||'');
|
||||
}
|
||||
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'};
|
||||
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
|
||||
+10
-4
@@ -355,8 +355,12 @@ function renderDeviceRow(dev){
|
||||
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
|
||||
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
|
||||
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
|
||||
const devName=dev.device_name||dev.model||'';
|
||||
const devLabel=(devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>')+offline;
|
||||
// 名称优先(设备身份),地址作副标题;未命名的历史数据显示提醒
|
||||
const nm=(dev.device_name||'').trim();
|
||||
const devLabel=(nm
|
||||
? '<strong>'+esc(nm)+'</strong><br><span style="font-size:11px;color:#94a3b8;font-family:var(--mono)">'+esc(dev.serial)+'</span>'
|
||||
: '<strong style="font-family:var(--mono)">'+esc(dev.serial)+'</strong>'
|
||||
+'<br><span style="font-size:11px;color:#d97706">未命名</span>') + offline;
|
||||
// 复选框列
|
||||
const checked=_selectedSerials.has(dev.serial)?'checked':'';
|
||||
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
||||
@@ -462,11 +466,13 @@ function _jobCoverageHtml(j){
|
||||
if(online)onlineN++;
|
||||
if(busy)busyN++;
|
||||
const cls=busy?'running':(online?'online':'offline');
|
||||
const title=s+(d.model?' · '+d.model:'')
|
||||
// chip 优先显示设备名称(身份),没有名称才退回地址
|
||||
const nm=(d.device_name||'').trim();
|
||||
const title=s+(nm?' · '+nm:'')+(d.model?' · '+d.model:'')
|
||||
+(busy?' · 任务中:'+(d.task_job||''):'')
|
||||
+(online?'':' · 离线或不在设备池');
|
||||
return '<span class="cov-chip '+cls+'" title="'+esc(title)+'">'
|
||||
+esc(s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'</span>';
|
||||
+esc(nm || s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'</span>';
|
||||
}).join('');
|
||||
return '<div class="job-cov"><span class="kv">覆盖 '+serials.length+' 台 · 在线 '+onlineN
|
||||
+(busyN?' · 运行中 '+busyN:'')+'</span>'
|
||||
|
||||
@@ -12,7 +12,9 @@ async function loadTasks(){
|
||||
_jobsData=jr.jobs||[];
|
||||
_taskTypes=tr?tr.task_types||[]:[];
|
||||
_groupsList=gr?gr.groups||[]:[];
|
||||
_devicesList=dr?dr.devices||[]:[];
|
||||
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
|
||||
_devicesList=(dr&&dr.items)?dr.items
|
||||
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
|
||||
renderTaskTable();
|
||||
}
|
||||
|
||||
@@ -72,7 +74,8 @@ function openTaskModal(jobId){
|
||||
const j=job||{name:'',task_type:'generic_steps',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
|
||||
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
|
||||
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
|
||||
const dl=_devicesList.map(s=>'<option value="'+s+'"'+(s===j.target.serial?' selected':'')+'>'+esc(s)+'</option>').join('');
|
||||
const dl=_devicesList.map(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===j.target.serial?' selected':'')+'>'
|
||||
+esc(devText(x.name, x.serial))+'</option>').join('');
|
||||
const sm=j.schedule.mode;
|
||||
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
|
||||
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
|
||||
|
||||
+91
-13
@@ -51,22 +51,26 @@ function loadDiscovery(){
|
||||
const subnets=document.getElementById('discovery-subnets');
|
||||
if(!enabled)return; // 面板未渲染(离开子分栏)
|
||||
enabled.checked=!!r.enabled;
|
||||
const ac=document.getElementById('discovery-autoclaim');
|
||||
if(ac)ac.checked=!!r.auto_claim;
|
||||
interval.value=r.interval||60;
|
||||
subnets.value=(r.subnets||[]).join(', ');
|
||||
// 状态行
|
||||
if(r.scanning){status.textContent='扫描中...';}
|
||||
else if(r.last_scan){
|
||||
let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 '
|
||||
+(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0);
|
||||
+(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0)
|
||||
+(r.last_result.claimed?' · 自动认领 '+r.last_result.claimed:'');
|
||||
if(r.last_error)txt+=' · ⚠ '+esc(r.last_error);
|
||||
status.textContent=txt;
|
||||
}else{status.textContent='尚未扫描';}
|
||||
// 待连接表格:只显示在线设备(离线候选不可确认连接,隐藏;恢复在线后自动出现)
|
||||
const rows=(r.pending||[]).filter(x=>x.online);
|
||||
_discoveryRows=rows; // 供确认时判断"是不是已有设备换了地址"
|
||||
const tb=document.getElementById('tb-discovery');
|
||||
if(!tb)return;
|
||||
if(!rows.length){
|
||||
tb.innerHTML='<tr><td colspan="6" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
|
||||
tb.innerHTML='<tr><td colspan="7" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
|
||||
}else{
|
||||
tb.innerHTML=rows.map(x=>{
|
||||
const src=x.source==='tailscale'
|
||||
@@ -75,9 +79,17 @@ function loadDiscovery(){
|
||||
const onl=x.online
|
||||
?'<span class="label label-success">在线</span>'
|
||||
:'<span class="label label-warning">离线</span>';
|
||||
// 指纹命中池中已有设备 → 提示"这台是 X 换了地址",确认即认领(不需要起新名)
|
||||
const m=x.match;
|
||||
const ident=m
|
||||
?'<span class="label label-info" title="指纹一致:同一台设备换了地址;确认后会迁移原记录并同步分组/任务">≈ '
|
||||
+esc(m.name||m.serial)+'(原 '+esc(m.serial)+')</span>'
|
||||
:'<span class="text-muted">新设备</span>';
|
||||
const btnTxt=m?'认领为『'+esc(m.name||'原设备')+'』':'确认添加';
|
||||
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
|
||||
+'<td>'+ident+'</td>'
|
||||
+'<td>'+src+'</td><td>'+esc(x.first_seen)+'</td><td>'+esc(x.last_seen)+'</td><td>'+onl+'</td>'
|
||||
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">确认添加</button> '
|
||||
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">'+btnTxt+'</button> '
|
||||
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="ignoreDiscoveryDev(this.dataset.serial)">忽略</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -90,9 +102,15 @@ function loadDiscovery(){
|
||||
}else{
|
||||
offTb.innerHTML=off.map(x=>{
|
||||
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
|
||||
+'<td>'+esc(x.model||'-')+'</td><td>'+esc(x.name||'')+'</td>'
|
||||
+'<td>'+(x.name ? esc(x.name) :
|
||||
'<span class="label label-warning">未命名</span>')+'</td>'
|
||||
+'<td>'+esc(x.model||'-')+'</td>'
|
||||
+'<td><span class="label label-danger">断联·自动重连中</span></td>'
|
||||
+'<td><button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="reconnectPoolDev(this.dataset.serial)">立即重连</button></td></tr>';
|
||||
+'<td style="white-space:nowrap">'
|
||||
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="reconnectPoolDev(this.dataset.serial)">立即重连</button> '
|
||||
// 设备换了 IP 时旧地址永远连不上——给一条"换地址"的出路(迁移记录 + 同步引用)
|
||||
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" title="设备换了 IP、这个旧地址已经连不上时:把记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(this.dataset.serial)">换地址</button>'
|
||||
+'</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
@@ -106,6 +124,17 @@ function reconnectPoolDev(serial){
|
||||
}else showToast('重连失败: '+((r&&r.error)||''),'error');
|
||||
});
|
||||
}
|
||||
// 指纹匹配自动认领开关(默认关:认领会改写分组/任务引用,交人工确认更稳妥)
|
||||
function toggleAutoClaim(checked){
|
||||
const tip=checked
|
||||
? '已开启自动认领:扫描到"同一台设备换了 IP"时会自动迁移记录并同步分组/任务引用'
|
||||
: '已关闭自动认领:扫描到换 IP 的设备会提示,由你点确认';
|
||||
apiPost('/api/devices/discovery/settings',{auto_claim:checked}).then(r=>{
|
||||
if(r&&r.ok)showToast(tip,'success');
|
||||
else{showToast('设置失败: '+((r&&r.error)||''),'error');loadDiscovery();}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDiscovery(checked){
|
||||
apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{
|
||||
if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success');
|
||||
@@ -128,11 +157,23 @@ function runDiscoveryScan(){
|
||||
});
|
||||
}
|
||||
function confirmDiscoveryDev(serial){
|
||||
const name=prompt('备注名(可选,留空直接添加)','');
|
||||
if(name===null)return;
|
||||
const row=(_discoveryRows||[]).find(x=>x.serial===serial)||{};
|
||||
const m=row.match;
|
||||
let name='';
|
||||
if(m){
|
||||
// 指纹命中:这是池中某台设备换了地址 → 认领(保留原名与引用),无需再起名
|
||||
if(!confirm('这台就是『'+(m.name||m.serial)+'』(原地址 '+m.serial+')。\n\n'
|
||||
+'确认认领到 '+serial+' 吗?\n'
|
||||
+'名称、型号、备注都会保留,分组与任务里的旧地址会自动同步到新地址。'))return;
|
||||
}else{
|
||||
const input=prompt('给这台设备起个名称(必填,平台内唯一):','');
|
||||
if(input===null)return;
|
||||
name=(input||'').trim();
|
||||
if(!name){showToast('必须填写设备名称','error');return;}
|
||||
}
|
||||
apiPost('/api/devices/discovery/confirm',{serial,name}).then(r=>{
|
||||
if(r&&r.ok){
|
||||
showToast('已加入设备池'+(r.is_new?'':'(已存在,已更新)'),'success');
|
||||
showToast(r.msg||'已加入设备池','success');
|
||||
loadDevPool();loadDiscovery();
|
||||
}else showToast('确认失败: '+((r&&r.error)||''),'error');
|
||||
});
|
||||
@@ -411,19 +452,22 @@ function copyTsKey(){
|
||||
|
||||
|
||||
// ================== Tab: 工具 - 设备池管理(本地 SQLite 清单) ==================
|
||||
let _devPoolRows=[]; // 设备池最近一次结果(改名时取当前名称)
|
||||
let _discoveryRows=[]; // 待连接池最近一次结果(确认时判断是否为已有设备换了地址)
|
||||
async function loadDevPool(){
|
||||
const tb=document.getElementById('tb-devpool');
|
||||
const status=document.getElementById('devpool-status');
|
||||
if(!tb)return;
|
||||
tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>';
|
||||
tb.innerHTML='<tr><td colspan="8" class="empty">加载中...</td></tr>';
|
||||
const r=await apiGet('/api/devices/pool');
|
||||
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';return;}
|
||||
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="8" class="empty">加载失败</td></tr>';return;}
|
||||
let rows=r.devices||[];
|
||||
// 在线优先排序;「只看在线」勾选时过滤掉离线(IP 换了/长期离线的旧条目不再碍眼)
|
||||
const onlyOnline=document.getElementById('devpool-filter-online')
|
||||
? document.getElementById('devpool-filter-online').checked : false;
|
||||
rows.sort((a,b)=>(b.online?1:0)-(a.online?1:0));
|
||||
if(onlyOnline)rows=rows.filter(x=>x.online);
|
||||
_devPoolRows=rows;
|
||||
const onlineCnt=rows.filter(x=>x.online).length;
|
||||
status.textContent=onlyOnline
|
||||
? '在线 '+rows.length+' 台(共 '+(r.devices||[]).length+' 台配置)'
|
||||
@@ -435,24 +479,36 @@ async function loadDevPool(){
|
||||
const online= d.online
|
||||
? '<span class="label label-success">在线</span>'
|
||||
: '<span class="label label-warning">离线</span>';
|
||||
// 名称是设备身份:老数据可能有空的 → 标出来提示补
|
||||
const nameCell = d.name
|
||||
? esc(d.name)
|
||||
: '<span class="label label-warning" title="设备名称必填且唯一(分组/任务/日志按名称认设备);点右侧「改名」补上">未命名</span>';
|
||||
const fp = d.fingerprint
|
||||
? '<span title="设备指纹 '+esc(d.fingerprint)+'(ro.serialno):换 IP 后靠它自动认领">🔑</span>'
|
||||
: '<span class="text-muted" title="尚未采集到指纹:设备离线时采不到;采集到之后换 IP 才能自动认领">—</span>';
|
||||
return '<tr>'+
|
||||
'<td style="font-family:monospace">'+esc(d.serial)+'</td>'+
|
||||
'<td>'+esc(d.name||'')+'</td>'+
|
||||
'<td>'+esc(d.model||'<span style="color:var(--text-light)">-</span>')+'</td>'+
|
||||
'<td>'+nameCell+'</td>'+
|
||||
'<td>'+esc(d.model||'-')+'</td>'+
|
||||
'<td>'+online+'</td>'+
|
||||
'<td>'+st+'</td>'+
|
||||
'<td style="text-align:center">'+fp+'</td>'+
|
||||
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
|
||||
'<td style="white-space:nowrap">'+
|
||||
'<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+
|
||||
(d.online ? '' :
|
||||
'<button class="btn btn-xs" title="设备换了 IP、旧地址连不上时:把这条记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(\''+esc(d.serial)+'\')">换地址</button> ') +
|
||||
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
|
||||
'<button class="btn btn-xs btn-danger" onclick="removePoolDev(\''+esc(d.serial)+'\')">删除</button>'+
|
||||
'</td></tr>';
|
||||
}).join('')||'<tr><td colspan="7" class="empty">设备池为空,添加第一台设备开始</td></tr>';
|
||||
}).join('')||'<tr><td colspan="8" class="empty">设备池为空,添加第一台设备开始</td></tr>';
|
||||
}
|
||||
|
||||
async function addPoolDev(){
|
||||
const serial=document.getElementById('devpool-serial').value.trim();
|
||||
const name=document.getElementById('devpool-name').value.trim();
|
||||
if(!serial){showToast('请输入 serial','error');return;}
|
||||
if(!name){showToast('请填写设备名称(平台内唯一,用于分组/任务/日志认设备)','error');return;}
|
||||
const r=await apiPost('/api/devices/pool/add',{serial,name});
|
||||
if(!r)return;
|
||||
if(r.ok){
|
||||
@@ -463,6 +519,28 @@ async function addPoolDev(){
|
||||
}else showToast(r.error,'error');
|
||||
}
|
||||
|
||||
async function renamePoolDev(serial){
|
||||
const cur=(((_devPoolRows||[]).find(x=>x.serial===serial))||{}).name||'';
|
||||
const nw=(prompt('设备名称(必填,平台内唯一):', cur)||'').trim();
|
||||
if(!nw || nw===cur)return;
|
||||
const r=await apiPost('/api/devices/pool/rename',{serial,name:nw});
|
||||
if(!r)return;
|
||||
if(r.ok){showToast(r.msg,'success');loadDevPool();}
|
||||
else showToast(r.error,'error');
|
||||
}
|
||||
|
||||
// 换地址(人工认领):设备换了 IP、旧地址已连不上时用;名称与分组/任务引用都保留
|
||||
async function relocatePoolDev(serial){
|
||||
if(!serial){showToast('没取到设备地址,请刷新页面后重试','error');return;}
|
||||
const nw=(prompt('把记录 ' + serial + ' 迁到新地址(设备换 IP、旧地址连不上时用):\n\n'
|
||||
+'名称、型号、备注都会保留,分组与任务里的旧地址会自动同步。','')||'').trim();
|
||||
if(!nw || nw===serial)return;
|
||||
const r=await apiPost('/api/devices/pool/relocate',{old_serial:serial,new_serial:nw});
|
||||
if(!r)return;
|
||||
if(r.ok){showToast(r.msg,'success');loadDevPool();loadDiscovery();}
|
||||
else showToast(r.error,'error');
|
||||
}
|
||||
|
||||
async function removePoolDev(serial){
|
||||
if(!confirm('从设备池删除 '+serial+'?\n\n删除后不再参与任务调度(不影响设备本身与其他功能)。'))return;
|
||||
const r=await apiPost('/api/devices/pool/remove',{serial});
|
||||
|
||||
@@ -840,22 +840,28 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<span class="text-muted">秒 · 网段</span>
|
||||
<input type="text" id="discovery-subnets" class="form-control" style="width:300px;font-family:monospace"
|
||||
placeholder="192.168.20.0/24, 100.100.10.0/24" title="逗号分隔的 CIDR 网段">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;margin-left:10px"
|
||||
title="开启后:扫描发现某台设备的指纹与设备池中已有记录一致(= 同一台设备换了 IP)时,自动把它认领到新地址——迁移原记录并同步分组/任务里的引用,不再需要人工确认。默认关:认领会改写分组与任务的引用,交人工点一下更稳妥。">
|
||||
<input type="checkbox" id="discovery-autoclaim" onchange="toggleAutoClaim(this.checked)"> 指纹匹配自动认领
|
||||
</label>
|
||||
<button class="btn" onclick="saveDiscoverySettings()">保存设置</button>
|
||||
<button class="btn btn-primary" onclick="runDiscoveryScan()">立即扫描</button>
|
||||
<span id="discovery-status" class="text-muted"></span>
|
||||
</div>
|
||||
<table class="table table-hover table-sm" style="margin-top:8px">
|
||||
<thead><tr><th>serial</th><th>来源</th><th>首次发现</th><th>最近发现</th><th>在线</th><th>操作</th></tr></thead>
|
||||
<tbody id="tb-discovery"><tr><td colspan="6" class="empty">尚未扫描</td></tr></tbody>
|
||||
<thead><tr><th>serial</th><th>识别</th><th>来源</th><th>首次发现</th><th>最近发现</th><th>在线</th><th>操作</th></tr></thead>
|
||||
<tbody id="tb-discovery"><tr><td colspan="7" class="empty">尚未扫描</td></tr></tbody>
|
||||
</table>
|
||||
<div class="help">扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入<b>待连接池</b>;
|
||||
<b>确认后才加入正式设备池</b>(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。</div>
|
||||
<b>确认后才加入正式设备池</b>(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。<br>
|
||||
<b>「识别」列</b>:平台按<b>设备指纹</b>认出这台其实就是池中某台设备换了 IP —— 确认后自动<b>认领</b>:
|
||||
迁移原记录(名称/型号/备注全保留),并把分组与任务里的旧地址一并换成新地址。新设备则必须填名称。</div>
|
||||
<!-- 正式池断联设备:仍在设备池(不删除、不进待连接池),自动重连中 -->
|
||||
<div style="margin-top:10px">
|
||||
<div style="font-size:12.5px;font-weight:600;margin-bottom:6px">设备池断联设备
|
||||
<span class="text-muted" style="font-weight:400">(仍在设备池内,扫描线程每轮自动重连;也可手动立即重连)</span></div>
|
||||
<table class="table table-hover table-sm">
|
||||
<thead><tr><th>serial</th><th>型号</th><th>备注</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<thead><tr><th>serial</th><th>名称</th><th>型号</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody id="tb-pool-offline"><tr><td colspan="5" class="empty">全部在线</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -863,8 +869,9 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<div class="toolbar">
|
||||
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
|
||||
placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()">
|
||||
<input type="text" id="devpool-name" class="form-control" style="width:150px"
|
||||
placeholder="备注名(可选)" onkeydown="if(event.key==='Enter')addPoolDev()">
|
||||
<input type="text" id="devpool-name" class="form-control" style="width:170px"
|
||||
placeholder="设备名称(必填·唯一)" title="设备在平台里的标识;分组、任务目标、日志都用它认设备"
|
||||
onkeydown="if(event.key==='Enter')addPoolDev()">
|
||||
<button class="btn btn-primary" onclick="addPoolDev()">添加设备</button>
|
||||
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
|
||||
<button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button>
|
||||
@@ -876,14 +883,18 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
</div>
|
||||
<table class="table table-hover table-sm" style="margin-top:8px">
|
||||
<thead><tr>
|
||||
<th>serial</th><th>名称</th><th>型号</th><th>在线</th><th>状态</th><th>添加时间</th><th>操作</th>
|
||||
<th>serial</th><th>名称</th><th>型号</th><th>在线</th><th>状态</th>
|
||||
<th title="设备指纹(ro.serialno):采集到之后,设备换 IP 才能被自动认领">指纹</th>
|
||||
<th>添加时间</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody id="tb-devpool"></tbody>
|
||||
</table>
|
||||
<div class="help">
|
||||
新增设备流程:手机开 USB 调试 → 连上后「adb 终端」执行 <code>tcpip 5555</code> 转网络调试 →
|
||||
Tailscale 上线(同一账号)→ 在此添加 <code>IP:5555</code>(自动尝试连接)→
|
||||
任务运行时 u2 自动推送 atx-agent。<b>停用 = 不参与任务调度</b>,删除 = 从池中移除(不影响其他系统)。
|
||||
任务运行时 u2 自动推送 atx-agent。<b>停用 = 不参与任务调度</b>,删除 = 从池中移除(不影响其他系统)。<br>
|
||||
<b>设备名称必填且唯一</b>:它是设备在平台里的身份(分组/任务/日志都按名称认设备);
|
||||
设备换了 IP 时用「换地址」把记录迁过去,分组与任务的引用会自动同步,不用重新配。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1270,6 +1270,7 @@ def agent_devices():
|
||||
for d in (devs or []):
|
||||
busy = d.get("worker_status") in ("running", "connecting")
|
||||
out.append({"serial": d.get("serial"),
|
||||
"name": d.get("device_name") or "", # 设备名称(设备池里的身份标识)
|
||||
"model": d.get("model") or "",
|
||||
"online": bool(d.get("present")),
|
||||
"busy": busy,
|
||||
|
||||
+105
-19
@@ -5,7 +5,7 @@ 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
|
||||
from core.adb_helper import adb_connect, adb_connect_light
|
||||
from core.logger import get_logger
|
||||
from web.auth import perm_required, PERM_DEVICES
|
||||
|
||||
@@ -28,29 +28,97 @@ def api_devices_pool_list():
|
||||
@bp.route("/api/devices/pool/add", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_pool_add():
|
||||
"""添加/更新设备:{serial, name?, note?}。
|
||||
"""添加/更新设备:{serial, name, note?}。
|
||||
|
||||
IP:5555 设备添加后立即尝试 adb connect(不可达显示离线,不影响其他设备)。
|
||||
**名称必填且唯一**(设备在平台里的身份标识,分组/任务/日志都靠它认设备)。
|
||||
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
|
||||
is_new = device_pool.add_device(serial,
|
||||
name=(data.get("name") or "").strip(),
|
||||
note=(data.get("note") or "").strip())
|
||||
msg = "已添加" if is_new else "已更新"
|
||||
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(serial)
|
||||
adb_connect_light(serial)
|
||||
except Exception:
|
||||
pass
|
||||
# 后台采集型号(不阻塞添加响应)
|
||||
import threading
|
||||
threading.Thread(target=device_pool.refresh_model, args=(serial,),
|
||||
daemon=True).start()
|
||||
_log.info(f"设备池管理: {msg} {serial}")
|
||||
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
|
||||
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)
|
||||
@@ -147,13 +215,30 @@ def api_devices_discovery_scan():
|
||||
@bp.route("/api/devices/discovery/confirm", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_discovery_confirm():
|
||||
"""确认连接:待连接 → 正式设备池 + 后台 adb connect + 采型号。"""
|
||||
"""确认连接:待连接 → 正式设备池 + 后台 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
|
||||
ok, msg, is_new = device_discovery.confirm_pending(
|
||||
serial, name=(data.get("name") or "").strip())
|
||||
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:
|
||||
@@ -163,7 +248,7 @@ def api_devices_discovery_confirm():
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
device_pool.refresh_model(serial)
|
||||
device_pool.refresh_info(serial)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=_connect, daemon=True).start()
|
||||
@@ -225,7 +310,8 @@ def api_devices_discovery_settings():
|
||||
enabled=data.get("enabled"),
|
||||
subnets=data.get("subnets"),
|
||||
interval=data.get("interval"),
|
||||
port=data.get("port"))
|
||||
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})
|
||||
|
||||
+17
-2
@@ -41,9 +41,24 @@ def api_actions():
|
||||
@bp.route("/api/devices")
|
||||
@login_required
|
||||
def api_devices():
|
||||
"""返回所有在线设备 serial(供分组表单勾选用)。"""
|
||||
"""在线设备列表(供分组表单/任务目标/AI 的「选择设备」下拉用)。
|
||||
|
||||
- `devices`:serial 字符串列表(兼容既有调用方)
|
||||
- `items`:`[{serial, name, model}]` —— 界面显示**设备名称**用
|
||||
(名称是设备的身份,见 doc/API.md §6)
|
||||
"""
|
||||
try:
|
||||
return jsonify({"ok": True, "devices": context.mgr.list_all_serials()})
|
||||
serials = context.mgr.list_all_serials()
|
||||
info = {}
|
||||
try:
|
||||
for d in device_pool.list_devices():
|
||||
info[d["serial"]] = d
|
||||
except Exception:
|
||||
pass
|
||||
items = [{"serial": s,
|
||||
"name": (info.get(s) or {}).get("name") or "",
|
||||
"model": (info.get(s) or {}).get("model") or ""} for s in serials]
|
||||
return jsonify({"ok": True, "devices": serials, "items": items})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ device_pool.init_app(app)
|
||||
from core import device_discovery
|
||||
device_discovery.init_app(app)
|
||||
mgr = TaskManager(app=app)
|
||||
# 设备换地址(认领/迁址)后同步分组与任务的引用:它们的"内存副本"在 TaskManager 里
|
||||
# (调度用内存对象),device_pool 只改库不改内存不生效,所以在此注册回调打通
|
||||
device_pool.set_move_hook(mgr.sync_device_serial)
|
||||
apk_mgr = ApkManager(app=app)
|
||||
|
||||
# Web 层按模块拆分(web/ 蓝图包):认证/监控/任务/管理/工具/设备池/APK/Tailscale
|
||||
|
||||
Reference in New Issue
Block a user