diff --git a/core/device_discovery.py b/core/device_discovery.py index 29ed1c3..1ef99c8 100644 --- a/core/device_discovery.py +++ b/core/device_discovery.py @@ -228,18 +228,25 @@ 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 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 = "" 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() # 6. 正式池断联设备自动重连:adb connect 会因 WiFi 波动/设备重启/ @@ -371,20 +378,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 diff --git a/core/device_pool.py b/core/device_pool.py index 7dc4dbe..14cb1fe 100644 --- a/core/device_pool.py +++ b/core/device_pool.py @@ -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 diff --git a/core/models.py b/core/models.py index d4a32f2..1264698 100644 --- a/core/models.py +++ b/core/models.py @@ -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"" @@ -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(): diff --git a/core/task_manager.py b/core/task_manager.py index 1d398d5..45c81c2 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -449,6 +449,40 @@ 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): if name in self.groups: del self.groups[name] diff --git a/doc/API.md b/doc/API.md index 8b1f03b..d704d06 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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 | 一键重连全部池内网络设备 | @@ -395,24 +397,51 @@ ## 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.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` | -> 自动发现**只做 socket 探测 + 只读校验**,不会把设备直接拉进设备池(必须人工确认)。 - ---- +> 扫描只做 socket 探测 + 只读校验,**不会把设备直接拉进设备池**(必须人工确认)。 +> 扫描时会顺带读一遍候选设备的指纹(写入待连接池),用于提示"这台是已有设备换了地址"。 ## 7. 远程看屏与设备操作 diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index d820623..e50a3b0 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -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 可用性判定 diff --git a/doc/DATA_MODEL.md b/doc/DATA_MODEL.md index a5e3a75..761783f 100644 --- a/doc/DATA_MODEL.md +++ b/doc/DATA_MODEL.md @@ -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`:对应表为空则导入,随后把文件重命名为 `.json.migrated` 归档。**库非空但 JSON 仍在 → 直接归档**,防止"用户删空数据后重启又复原"。 diff --git a/doc/DEPLOY.md b/doc/DEPLOY.md index e4a7cf5..27835a8 100644 --- a/doc/DEPLOY.md +++ b/doc/DEPLOY.md @@ -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 | diff --git a/static/admin/tools.js b/static/admin/tools.js index 1d1a714..94f6b0b 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -63,10 +63,11 @@ function loadDiscovery(){ }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='待连接池为空(扫描到的设备会出现在这里)'; + tb.innerHTML='待连接池为空(扫描到的设备会出现在这里)'; }else{ tb.innerHTML=rows.map(x=>{ const src=x.source==='tailscale' @@ -75,9 +76,17 @@ function loadDiscovery(){ const onl=x.online ?'在线' :'离线'; + // 指纹命中池中已有设备 → 提示"这台是 X 换了地址",确认即认领(不需要起新名) + const m=x.match; + const ident=m + ?'≈ ' + +esc(m.name||m.serial)+'(原 '+esc(m.serial)+')' + :'新设备'; + const btnTxt=m?'认领为『'+esc(m.name||'原设备')+'』':'确认添加'; return ''+esc(x.serial)+'' + +''+ident+'' +''+src+''+esc(x.first_seen)+''+esc(x.last_seen)+''+onl+'' - +' ' + +' ' +''; }).join(''); } @@ -90,9 +99,15 @@ function loadDiscovery(){ }else{ offTb.innerHTML=off.map(x=>{ return ''+esc(x.serial)+'' - +''+esc(x.model||'-')+''+esc(x.name||'')+'' + +''+(x.name ? esc(x.name) : + '未命名')+'' + +''+esc(x.model||'-')+'' +'断联·自动重连中' - +''; + +'' + +' ' + // 设备换了 IP 时旧地址永远连不上——给一条"换地址"的出路(迁移记录 + 同步引用) + +'' + +''; }).join(''); } } @@ -128,11 +143,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 +438,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='加载中...'; + tb.innerHTML='加载中...'; const r=await apiGet('/api/devices/pool'); - if(!r||!r.ok){tb.innerHTML='加载失败';return;} + if(!r||!r.ok){tb.innerHTML='加载失败';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 +465,36 @@ async function loadDevPool(){ const online= d.online ? '在线' : '离线'; + // 名称是设备身份:老数据可能有空的 → 标出来提示补 + const nameCell = d.name + ? esc(d.name) + : '未命名'; + const fp = d.fingerprint + ? '🔑' + : '—'; return ''+ ''+esc(d.serial)+''+ - ''+esc(d.name||'')+''+ - ''+esc(d.model||'-')+''+ + ''+nameCell+''+ + ''+esc(d.model||'-')+''+ ''+online+''+ ''+st+''+ + ''+fp+''+ ''+esc(d.created_at||'')+''+ ''+ + ' '+ + (d.online ? '' : + ' ') + ' '+ ''+ ''; - }).join('')||'设备池为空,添加第一台设备开始'; + }).join('')||'设备池为空,添加第一台设备开始'; } 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 +505,27 @@ 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){ + const nw=(prompt('把这条记录迁到新地址(设备换 IP、旧地址连不上时用):\n\n' + +'名称、型号、备注都会保留,分组与任务里的旧地址会自动同步。','')||'').trim(); + if(!nw)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}); diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 1465425..5b84523 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -845,17 +845,19 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 - - + +
serial来源首次发现最近发现在线操作
尚未扫描
serial识别来源首次发现最近发现在线操作
尚未扫描
扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入待连接池; - 确认后才加入正式设备池(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。
+ 确认后才加入正式设备池(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。
+ 「识别」列:平台按设备指纹认出这台其实就是池中某台设备换了 IP —— 确认后自动认领: + 迁移原记录(名称/型号/备注全保留),并把分组与任务里的旧地址一并换成新地址。新设备则必须填名称。
设备池断联设备 (仍在设备池内,扫描线程每轮自动重连;也可手动立即重连)
- +
serial型号备注状态操作
serial名称型号状态操作
全部在线
@@ -863,8 +865,9 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
- + @@ -876,14 +879,18 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
- + + +
serial名称型号在线状态添加时间操作serial名称型号在线状态指纹添加时间操作
新增设备流程:手机开 USB 调试 → 连上后「adb 终端」执行 tcpip 5555 转网络调试 → Tailscale 上线(同一账号)→ 在此添加 IP:5555(自动尝试连接)→ - 任务运行时 u2 自动推送 atx-agent。停用 = 不参与任务调度,删除 = 从池中移除(不影响其他系统)。 + 任务运行时 u2 自动推送 atx-agent。停用 = 不参与任务调度,删除 = 从池中移除(不影响其他系统)。
+ 设备名称必填且唯一:它是设备在平台里的身份(分组/任务/日志都按名称认设备); + 设备换了 IP 时用「换地址」把记录迁过去,分组与任务的引用会自动同步,不用重新配。
diff --git a/web/devices_api.py b/web/devices_api.py index a03649a..0163ad5 100644 --- a/web/devices_api.py +++ b/web/devices_api.py @@ -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() diff --git a/web_server.py b/web_server.py index 01000ca..bbe9e5b 100644 --- a/web_server.py +++ b/web_server.py @@ -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