diff --git a/core/device_discovery.py b/core/device_discovery.py index 1ef99c8..1e5326a 100644 --- a/core/device_discovery.py +++ b/core/device_discovery.py @@ -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 调用。""" @@ -234,12 +268,14 @@ def scan_once(manual=False): 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, "fingerprint": fp}) @@ -249,6 +285,10 @@ def scan_once(manual=False): 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 池是给 @@ -256,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] @@ -359,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], diff --git a/doc/API.md b/doc/API.md index d704d06..e786c85 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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//apps` | L | 指定设备已安装应用列表 | | GET | `/api/device/screenshot` | L | 单张设备截图(PNG) | | GET | `/api/screen/stream` | D | 远程看屏 MJPEG 流 | @@ -194,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 事件流** | @@ -429,6 +429,20 @@ | `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 自动发现 | 接口 | 请求 | 说明 | @@ -438,7 +452,7 @@ | `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 探测 + 只读校验,**不会把设备直接拉进设备池**(必须人工确认)。 > 扫描时会顺带读一遍候选设备的指纹(写入待连接池),用于提示"这台是已有设备换了地址"。 diff --git a/doc/MCP.md b/doc/MCP.md index a72c960..8cb5b26 100644 --- a/doc/MCP.md +++ b/doc/MCP.md @@ -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}]`(只读) | diff --git a/mcp_agent/agent.py b/mcp_agent/agent.py index 82aea68..8951bc4 100644 --- a/mcp_agent/agent.py +++ b/mcp_agent/agent.py @@ -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 直接给文字, diff --git a/mcp_server/mcp_server.py b/mcp_server/mcp_server.py index 2728a90..69448b2 100644 --- a/mcp_server/mcp_server.py +++ b/mcp_server/mcp_server.py @@ -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", diff --git a/static/admin/admin.js b/static/admin/admin.js index 98ccec7..63a6fea 100644 --- a/static/admin/admin.js +++ b/static/admin/admin.js @@ -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=> - '' + '' ).join(''); document.getElementById('modal-body').innerHTML= diff --git a/static/admin/agent.js b/static/admin/agent.js index 393a5aa..c6335e2 100644 --- a/static/admin/agent.js +++ b/static/admin/agent.js @@ -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 = '' + 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 ''; @@ -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 = '' - + devs.map(s=>'').join(''); + + items.map(x=>'').join(''); }); } diff --git a/static/admin/base.js b/static/admin/base.js index 2c23911..481d3de 100644 --- a/static/admin/base.js +++ b/static/admin/base.js @@ -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]));} diff --git a/static/admin/monitor.js b/static/admin/monitor.js index 8b18d1a..d9650ff 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -355,8 +355,12 @@ function renderDeviceRow(dev){ const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed') ?'':''; const btns='
'+shotBtn+locateBtn+clearBtn+stopBtn+'
'; - const devName=dev.device_name||dev.model||''; - const devLabel=(devName?''+esc(dev.serial)+'
'+esc(devName)+'':''+esc(dev.serial)+'')+offline; + // 名称优先(设备身份),地址作副标题;未命名的历史数据显示提醒 + const nm=(dev.device_name||'').trim(); + const devLabel=(nm + ? ''+esc(nm)+'
'+esc(dev.serial)+'' + : ''+esc(dev.serial)+'' + +'
未命名') + 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 '' - +esc(s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+''; + +esc(nm || s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+''; }).join(''); return '
覆盖 '+serials.length+' 台 · 在线 '+onlineN +(busyN?' · 运行中 '+busyN:'')+'' diff --git a/static/admin/tasks.js b/static/admin/tasks.js index 7bc8bbf..bf55eed 100644 --- a/static/admin/tasks.js +++ b/static/admin/tasks.js @@ -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=>'').join(''); const gl=_groupsList.map(g=>'').join(''); - const dl=_devicesList.map(s=>'').join(''); + const dl=_devicesList.map(x=>'').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||''):''; diff --git a/static/admin/tools.js b/static/admin/tools.js index 94f6b0b..0076d9c 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -51,13 +51,16 @@ 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='尚未扫描';} @@ -121,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'); diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 5b84523..d5f07b9 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -840,6 +840,10 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 秒 · 网段 + diff --git a/web/agent_api.py b/web/agent_api.py index 3f1edc0..d872599 100644 --- a/web/agent_api.py +++ b/web/agent_api.py @@ -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, diff --git a/web/devices_api.py b/web/devices_api.py index 0163ad5..cf25035 100644 --- a/web/devices_api.py +++ b/web/devices_api.py @@ -310,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}) diff --git a/web/tasks_api.py b/web/tasks_api.py index a9da591..f683149 100644 --- a/web/tasks_api.py +++ b/web/tasks_api.py @@ -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