import { DeviceDriver } from '../../drivers/types'; import { sleep, waitForElement, waitForSource, scrollUntilFound } from './element.helper'; import { ensureHomeTab, navigateToAddPage, navigateThroughWizard, restartAndroidApp } from './navigation.helper'; import { enterPairingMode } from './relay.helper'; import { saveAddedDevice, clearAddedDevices } from './device-registry.helper'; export interface AddDeviceOptions { categoryName: string; deviceKeyword: string; scanTimeout?: number; wizardButtons?: string[]; connectionKeywords?: string[]; preSelectSteps?: (driver: DeviceDriver) => Promise; postConnectionSteps?: (driver: DeviceDriver) => Promise; skipNextButton?: boolean; skipScanStep?: boolean; } const DEFAULT_CONNECTION_KEYWORDS = [ 'Initial Setup', 'Start Using', 'Done', 'added successfully', 'Got it', 'cloud service', 'Pick a room', 'Display Type', ]; const DEFAULT_WIZARD_BUTTONS = ['Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip', 'Next']; // "连接已真正开始"的标志(用于判断配对是否触发);**不含引导页常驻的 Next/Got it**,避免误判已连。 const CONNECT_STARTED_KEYWORDS = [ 'Connecting', 'connected', 'Connect Device', 'Discover device', 'Initial Setup', 'added successfully', 'Added successfully', 'Pick a room', 'Select Room', 'Select Mode', ]; export async function isDeviceOnHomepage(driver: DeviceDriver, keyword: string): Promise { await ensureHomeTab(driver); let source = await driver.getSource(); if (source.includes(keyword)) return true; await driver.scrollDown(300); await sleep(800); source = await driver.getSource(); return source.includes(keyword); } /** * 在首页按正则找设备卡,返回**实际显示名**(如 "Meter 0B"),找不到返回 null。 * 用于"设备实际名与配置默认名不符"时拿到真名(写入注册表作功能页入口)。 */ export async function findDeviceNameOnHomepage(driver: DeviceDriver, pattern: string): Promise { // 先退出可能残留的添加向导/Attention 弹框(否则首页被遮挡) for (const t of ['Return Home', 'Quit', 'Exit', 'Leave']) { const el = driver.platform === 'android' ? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`) : await driver.findElementRaw('name', t); if (el) { await driver.tapElement(el); await sleep(2000); break; } } await driver.dismissPopupIfPresent(); await ensureHomeTab(driver); const re = new RegExp(pattern); for (let i = 0; i < 3; i++) { const source = await driver.getSource(); const m = source.match(re); if (m) return m[0]; await driver.scrollDown(400); await sleep(500); } return null; } export async function selectDeviceCategory(driver: DeviceDriver, categoryName: string, scrollHint = 0): Promise { if (driver.platform === 'android') { const find = () => driver.findElementRaw('-android uiautomator', `new UiSelector().text("${categoryName}")`); // 品类网格异步加载:短轮询等其渲染(idle=1s 下通常很快;Meter 等非热门品类首屏不可见,少等即可) let el = await find(); for (let i = 0; i < 2 && !el; i++) { await sleep(400); el = await find(); } // 首屏没有 → 手动下滑查找(不用慢的 UiScrollable.scrollIntoView,它每微滚都重查整棵树 ~12s)。 // 已知大概滑几次的品类(scrollHint):先快滑 hint-1 次不校验,再边滑边找——大幅省时。 if (!el && scrollHint > 1) { for (let i = 0; i < scrollHint - 1; i++) await driver.scrollDown(900); el = await find(); } let swipes = 0; for (let i = 0; i < 14 && !el; i++) { await driver.scrollDown(900); swipes++; el = await find(); // idle=1s 下 find 很快,无需额外 sleep } if (!el) { console.log(`FAIL: no ${categoryName} option`); return false; } if (scrollHint <= 1 && swipes) console.log(`[hint] ${categoryName} 实际滑动 ${(scrollHint || 0) + swipes} 次找到(可设为该品类 scrollHint 提速)`); await driver.tapElement(el); await sleep(1200); // 点击品类后通常进入 免责声明/引导/扫描 页:只要离开了品类网格即视为成功 const src = await driver.getSource(); const leftGrid = !src.includes('Add Device') || src.includes('Disclaimers') || src.includes('Next') || src.includes('Scanning') || src.includes('searching') || src.includes(categoryName); if (!leftGrid) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } return true; } // iOS - scroll to reveal category, check visibility, then click for (let i = 0; i < 8; i++) { let el = await driver.findElementRaw('predicate string', `name == "${categoryName}" AND type == "XCUIElementTypeStaticText"`); if (!el) { el = await driver.findElementRaw('predicate string', `name == "${categoryName}" AND type == "XCUIElementTypeOther"`); } if (!el) { el = await driver.findElementRaw('name', categoryName); } if (el) { const visible = await driver.getElementAttribute(el, 'visible'); if (String(visible) !== 'true' && String(visible) !== '1') { await driver.scrollDown(400); await sleep(800); continue; } await driver.clickElement(el); await sleep(3000); return true; } await driver.scrollDown(400); await sleep(800); } console.log(`FAIL: no ${categoryName} option`); return false; } export async function waitForDeviceInScan( driver: DeviceDriver, deviceKeyword: string, timeout = 30000 ): Promise { if (driver.platform === 'android') { const start = Date.now(); while (Date.now() - start < timeout) { const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${deviceKeyword}")`); if (el) return el; await sleep(1000); } return null; } // iOS return await waitForElement(driver, 'name', deviceKeyword, timeout); } export async function waitForConnection( driver: DeviceDriver, keywords?: string[], timeout = 30000 ): Promise { const connectionKeywords = keywords || DEFAULT_CONNECTION_KEYWORDS; const start = Date.now(); while (Date.now() - start < timeout) { const source = await driver.getSource(); for (const kw of connectionKeywords) { if (source.includes(kw)) return true; } await driver.dismissPopupIfPresent(); // 连接/配网过程中途突然冒出的弹框,顺手关掉 await sleep(2000); } return false; } export async function addDeviceViaBLE(driver: DeviceDriver, options: AddDeviceOptions): Promise { const { categoryName, deviceKeyword, scanTimeout = 30000, wizardButtons = DEFAULT_WIZARD_BUTTONS, connectionKeywords, preSelectSteps, postConnectionSteps, skipNextButton = false, skipScanStep = false, } = options; // Step 1: Navigate to Add Device page const addDevicePage = await navigateToAddPage(driver, 'Device'); if (!addDevicePage) { console.log('FAIL: cannot navigate to Add Device'); return false; } // Step 2: Select device category const catSelected = await selectDeviceCategory(driver, categoryName); if (!catSelected) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } // Optional: product-specific pre-select steps if (preSelectSteps) await preSelectSteps(driver); // Step 3: Wait for device in BLE scan (skip for auto-connect devices like AI Hub) if (!skipScanStep) { const deviceEl = await waitForDeviceInScan(driver, deviceKeyword, scanTimeout); if (!deviceEl) { console.log(`FAIL: ${deviceKeyword} not found in BLE scan`); return false; } await driver.tapElement(deviceEl); await sleep(500); // Step 4: Tap Next (some products skip this) if (!skipNextButton) { if (driver.platform === 'android') { const nextEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); if (nextEl) await driver.tapElement(nextEl); } else { const nextEl = await driver.findElementRaw('name', 'Next'); if (nextEl) await driver.tapElement(nextEl); } await sleep(2000); } } // Step 5: Wait for connection const connected = await waitForConnection(driver, connectionKeywords); if (!connected) { console.log('FAIL: connection timeout'); return false; } // Step 5.5: Post-connection steps (e.g. WiFi setup for cameras) if (postConnectionSteps) await postConnectionSteps(driver); // Step 6: Handle Initial Setup (iOS) if (driver.platform === 'ios') { const source = await driver.getSource(); if (source.includes('Initial Setup')) { const setupNext = await driver.findElementRaw('name', 'Next'); if (setupNext) { await driver.tapElement(setupNext); await sleep(2000); } } } // Step 7: Navigate through wizard await navigateThroughWizard(driver, wizardButtons); await driver.dismissPopupIfPresent(); // Step 8: Verify on homepage await ensureHomeTab(driver); await sleep(2000); for (let i = 0; i < 5; i++) { const source = await driver.getSource(); if (source.includes(deviceKeyword)) return true; await driver.scrollDown(400); await sleep(800); } return false; } export interface SerialPairingAddOptions { categoryName: string; // 添加目录里的品类/产品名(如 'Meter' / 'Remote') categoryScrollHint?: number; // 该品类在添加目录里大概滑动几次可见(提速:先快滑 hint-1 次再校验);日志会打印实测值 deviceKeyword: string; // 首页校验关键字(如 'Meter 3M') relayDevice: string; // config/relay.config 的设备 key(如 'meter' / 'remote') registryCategory?: string; // 成功后把设备名写入注册表的品类 key(如 'meter');默认用 relayDevice namePattern?: string; // 首页实际设备名的正则(如 'Meter \\w+');匹配到则以实际名回写注册表 beforePairing?: (driver: DeviceDriver) => Promise; // 过免责声明后、按继电器配对前的额外步骤(如 outdoor meter 套件先点 "Add Meter") pairRetries?: number; // 配对未触发(按完没连上)时的最大尝试轮数,默认 3 pairPollIters?: number; // 每轮检测轮询次数(窗口=次数×1.5s),默认 12(~18s);remote 检测慢需调大 repressOnRetry?: boolean; // 重试时是否重按继电器;默认 true。切换式配对键(如 remote 凸+凹,按一次进/再按退)须设 false——只按一次,重试只重点 Next postConnectSteps?: (driver: DeviceDriver) => Promise; // 连接后专属流程(如 curtain 选模式+行程校验);提供则替代默认 navigateThroughWizard holdMs?: number; // 覆盖按键时长 disclaimerMaxNext?: number; // 到"引导页(按按键处)"前需点几次 Next(过免责声明),默认 1 connectionKeywords?: string[]; wizardButtons?: string[]; } /** 点"下一步"类按钮。Android 用 text("Next");iOS 配对页按钮叫 "Connect device"(非 Next),故按名依次尝试。返回是否点到。 */ async function tapNext(driver: DeviceDriver): Promise { if (driver.platform === 'android') { const el = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); if (!el) return false; await driver.tapElement(el); return true; } // iOS:不同页的推进按钮名不同(免责声明="Next"、配对页="Connect device"、连接失败页="Retry"),依次尝试 for (const name of ['Next', 'Connect device', 'Connect Device', 'Retry', 'Continue', 'Done']) { const el = await driver.findElementRaw('name', name).catch(() => null); if (el) { await driver.tapElement(el); return true; } } return false; } /** * 从"输入设备名"页读取默认设备名(连接成功/失败后都会跳到该页)。 * 取输入框(Android EditText / iOS TextField)的当前文本 = 设备默认名。找不到返回 null。 */ export async function captureDeviceNameFromInput(driver: DeviceDriver): Promise { const el = driver.platform === 'android' ? await driver.findElementRaw('class name', 'android.widget.EditText') : (await driver.findElementRaw('class name', 'XCUIElementTypeTextField')) || (await driver.findElementRaw('class name', 'XCUIElementTypeTextView')); if (!el) return null; let v: string | null = null; for (const attr of ['text', 'value']) { v = await driver.getElementAttribute(el, attr).catch(() => null); if (v && String(v).trim()) break; } const name = v ? String(v).trim() : ''; // 过滤占位符/提示文案 if (!name || /enter|name|请输入|名称/i.test(name)) return null; return name; } /** * 串口配对添加流程(meter / remote 等用继电器按物理键进配对的设备): * 进添加页 → 选品类 → (有则过 Disclaimers)到引导页 → **继电器按键进配对** → 点 Next 连接 → 走完向导 → 首页校验。 * * 通用兼容:没有 Disclaimers 的设备会直接到引导页(循环里检测不到 Disclaimer 即跳过)。 * 成功判据 = **设备出现在首页**(不靠成功页关键字硬判,避免文案不符导致误判失败); * 成功后把设备名写入注册表(config/device.config 的 getDeviceName 会优先用它作功能页入口)。 */ export async function addDeviceWithSerialPairing( driver: DeviceDriver, options: SerialPairingAddOptions ): Promise { const { categoryName, categoryScrollHint = 0, deviceKeyword, relayDevice, registryCategory = relayDevice, namePattern, beforePairing, pairRetries = 3, pairPollIters = 12, repressOnRetry = true, postConnectSteps, holdMs, disclaimerMaxNext = 1, connectionKeywords, wizardButtons = DEFAULT_WIZARD_BUTTONS, } = options; const addDevicePage = await navigateToAddPage(driver, 'Device'); if (!addDevicePage) { console.log('FAIL: cannot navigate to Add Device'); return false; } const catSelected = await selectDeviceCategory(driver, categoryName, categoryScrollHint); if (!catSelected) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } // 过免责声明到引导页(无 Disclaimers 的设备自动跳过) for (let i = 0; i < disclaimerMaxNext; i++) { const src = await driver.getSource(); if (!src.includes('Disclaimer')) break; if (!(await tapNext(driver))) break; await sleep(1500); } // 配对前额外步骤(如 outdoor meter 套件:先点 "Add Meter" 进计量器子流程) if (beforePairing) { await beforePairing(driver); await sleep(2000); } // 引导页:继电器按物理键进配对 → 点 Next 触发连接。 // 注意:有些设备(如 remote 凸+凹)的配对键是"切换式"——按一次进配对、再按一次退出。 // 这类设备传 repressOnRetry=false:只按一次,重试时仅重点 Next + 延长检测,绝不重按(重按会把它切出配对)。 const startedRe = namePattern ? new RegExp(namePattern) : null; // 连接失败页标志(iOS "Failed to connect to your device" + Retry;curtain3 首连尤其易失败) const FAIL_MARKERS = ['Failed to connect', 'Failed to Connect', 'Connection failed', 'Try Again', '连接失败', '无法连接']; const pairingStarted = async (): Promise => { const src = await driver.getSource(); if (FAIL_MARKERS.some((k) => src.includes(k))) return false; // 失败页不算"已开始"(否则会跳过重试) if (CONNECT_STARTED_KEYWORDS.some((k) => src.includes(k))) return true; // 进入连接/设置页 if (startedRe && startedRe.test(src)) return true; // 设备已被发现(如 "Remote 63") return false; }; let started = false; let pressed = false; for (let attempt = 0; attempt < pairRetries && !started; attempt++) { // 第一次必按;之后是否重按取决于 repressOnRetry(切换式配对键不重按,只重点 Next) if (!pressed || repressOnRetry) { await enterPairingMode(relayDevice, holdMs ? { holdMs } : {}).catch((e) => console.log(`relay 配对按键失败(检查 64 路开发板接线/端口):${e.message}`) ); pressed = true; await sleep(800); } if (!(await tapNext(driver))) console.log('WARN: 引导页未找到 Next'); // 检测轮询:出现连接关键字/设备名即视为进配对成功。窗口 = pairPollIters×1.5s。 // remote 的 App BLE 检测延迟大且不稳(实测可达 16s+),需更长窗口(见 remote 用例 pairPollIters)。 for (let t = 0; t < pairPollIters && !started; t++) { if (await pairingStarted()) { started = true; break; } await driver.dismissPopupIfPresent(); // 配对/连接中途突然冒出的弹框,顺手关掉 await sleep(1500); } if (started) break; console.log(`配对未触发/未发现设备(第 ${attempt + 1}/${pairRetries} 次)${repressOnRetry ? ',清弹框后重按继电器重试...' : ',仅重点 Next(切换式配对键不重按)...'}`); await driver.dismissPopupIfPresent(); // 清可能的"连接失败"弹框,回到引导页再试 } if (!started) console.log('WARN: 多次重试后仍未检测到连接开始,继续走后续(大概率失败)'); // 收尾等待(连上则多等让其完成);连接失败弹框主动关掉,避免残留挡住后续(尤其 curtain3) await waitForConnection(driver, connectionKeywords, started ? 9000 : 3000); await driver.dismissPopupIfPresent(); // 连接成功(或失败)后通常跳到"输入设备名"页:字段里的默认名即设备名 → 最可靠的取名处 const capturedName = await captureDeviceNameFromInput(driver); if (capturedName) console.log(`从"输入设备名"页捕获默认名: "${capturedName}"`); // 连接后:有专属流程(如 curtain 选模式 + 行程校验)则走它,否则走默认向导 if (postConnectSteps) await postConnectSteps(driver); else await navigateThroughWizard(driver, wizardButtons); await driver.dismissPopupIfPresent(); // 成功判据:输入页捕获到名字 或 设备出现在首页 → 记录实际设备名作为功能页入口 await ensureHomeTab(driver); await sleep(1200); const candidates = [capturedName, deviceKeyword].filter(Boolean) as string[]; const re = namePattern ? new RegExp(namePattern) : null; for (let i = 0; i < 6; i++) { await driver.dismissPopupIfPresent(); // 添加完成后弹出的"更新/评分/新功能"等弹框,先关再校验 const source = await driver.getSource(); for (const c of candidates) { if (source.includes(c)) { saveAddedDevice(registryCategory, c); return true; } } if (re) { const m = source.match(re); if (m) { saveAddedDevice(registryCategory, m[0]); return true; } // 实际名(如 Meter 0B) } await driver.scrollDown(400); await sleep(600); } // 首页没匹配上,但已从输入页拿到名字 → 仍记录并视为成功(连接成功/失败都会到输入名页) if (capturedName) { saveAddedDevice(registryCategory, capturedName); return true; } return false; } // 首页设备卡容器 id(网格左/右列)。各设备类型 name 控件 id 不同(nameText / nameTextMeter…), // 但卡片容器 leftCard/rightCard 一致 → 用它通用枚举/长按删除,与设备类型无关。 const HOME_CARD_IDS = [ 'com.theswitchbot.switchbot:id/leftCard', 'com.theswitchbot.switchbot:id/rightCard', ]; /** 若处于多选模式(顶部 Finish),退出它,回到正常首页。 */ async function exitSelectMode(driver: DeviceDriver): Promise { const finish = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")'); if (finish) { await driver.tapElement(finish); await sleep(1500); } } /** 找首页第一张设备卡容器(滚动查找),返回 {el, name};没有返回 null。 */ async function firstDeviceCard(driver: DeviceDriver): Promise<{ el: string; name: string } | null> { // 轻量恢复到干净首页(退残留向导 + 关弹框 + 回首页),不做冷重启(冷启动会让首页卡片晚渲染→漏删) await exitSelectMode(driver); for (const t of ['Return Home', 'Quit', 'Exit', 'Leave']) { const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`); if (el) { await driver.tapElement(el); await sleep(1500); break; } } await driver.dismissPopupIfPresent(); await ensureHomeTab(driver); await sleep(1500); // 等首页渲染 // 充分轮询(~20s):设备卡渲染慢也能等到。优先卡片容器,兜底设备名控件(各型号 id 不同) for (let s = 0; s < 12; s++) { for (const id of HOME_CARD_IDS) { const els = await driver.findElementsRaw('id', id); if (els.length) { const nameEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceIdMatches(".*:id/name.*")').catch(() => null); const name = (nameEl && (await driver.getElementAttribute(nameEl, 'text').catch(() => ''))) || '(device)'; return { el: els[0], name }; } } const nameEls = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().resourceIdMatches(".*:id/name.*")').catch(() => []); if (nameEls && nameEls.length) { const name = (await driver.getElementAttribute(nameEls[0], 'text').catch(() => '')) || '(device)'; return { el: nameEls[0], name }; } await driver.scrollDown(500); await sleep(900); } return null; } /** 长按某设备卡 → 多选模式 → 底部 Delete → 确认(删除单个,与房间无关)。 */ /** * 批量删除:长按首张卡 → 进多选模式 → 选中本屏其它卡(同房间一次性多选)→ 一次 Delete → 确认。 * 跨房间不能一起删(点跨房间卡会被忽略),其余轮次再处理。返回本批删除的设备名。 */ async function deleteVisibleBatch(driver: DeviceDriver): Promise { const card = await firstDeviceCard(driver); if (!card) return []; const r0 = await driver.getElementRect(card.el); const cx0 = Math.round(r0.x + r0.width / 2), cy0 = Math.round(r0.y + r0.height / 2); await driver.longPress(cx0, cy0, 1.0); await sleep(1000); // 等多选模式出现 // 是否仍处于"可删除"的有效多选(底部有 Delete)。跨房间多选会让 Delete 消失。 const hasDelete = () => driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")'); const exitSelect = async () => { const finish = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")'); if (finish) { await driver.tapElement(finish); await sleep(800); } }; // 长按未进入有效多选(无 Delete)→ 退出,交给外层下一轮/恢复 if (!(await hasDelete())) { await exitSelect(); return []; } // 逐张点选本屏其它卡:点完校验 Delete 是否还在。 // 跨房间卡会让 Delete 消失 → 立即再点一次取消该卡(恢复有效选择),跳过它; // 这样一批只删"同房间"设备,其它房间留给 removeAllDevices 外层循环。 const picked = [card.name]; const others: { cx: number; cy: number }[] = []; for (const id of HOME_CARD_IDS) { for (const el of await driver.findElementsRaw('id', id)) { try { const r = await driver.getElementRect(el); const cx = Math.round(r.x + r.width / 2), cy = Math.round(r.y + r.height / 2); if (Math.abs(cx - cx0) < 12 && Math.abs(cy - cy0) < 12) continue; // 跳过长按那张 others.push({ cx, cy }); } catch { /* 卡片可能已变,忽略 */ } } } for (const { cx, cy } of others) { await driver.tap(cx, cy); await sleep(400); if (await hasDelete()) { picked.push('(device)'); // 同房间,保留选中 } else { await driver.tap(cx, cy); // 跨房间→Delete消失,取消该卡恢复有效选择 await sleep(400); } } // 点底部 Delete const delBtn = await hasDelete(); if (!delBtn) { await exitSelect(); return []; } await driver.tapElement(delBtn); await sleep(900); // 确认弹框(快速):Delete / OK / 删除 / Confirm for (let retry = 0; retry < 3; retry++) { const src = await driver.getSource(); if (/Cancel|取消|delete|删除/i.test(src)) { let confirm: string | null = null; for (const t of ['Delete', 'OK', '删除', 'Confirm']) { confirm = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`); if (confirm) break; } if (confirm) { await driver.tapElement(confirm); break; } } await sleep(500); } await sleep(1800); // 等删除生效 + 首页刷新 return picked; } /** * 首页是否还有设备卡 —— 用于 reset 后校验"是否真的清空了"。 * 只看卡片容器(leftCard/rightCard),与设备类型无关。Android 专用;iOS 待补返回 false。 */ export async function homepageHasDeviceCards(driver: DeviceDriver): Promise { if (driver.platform !== 'android') return false; await ensureHomeTab(driver); for (const id of HOME_CARD_IDS) { const els = await driver.findElementsRaw('id', id); if (els.length) return true; } return false; } /** * 清空当前平台账号下的**所有**设备 —— 必测执行前置,确保 connect 用例真正走添加流程。 * 通用按设备卡(nameText)枚举,逐个长按删除(跨房间不能一起删,逐个最稳),与设备名无关。 * 删完清空设备名注册表。返回已删除的设备名列表。 */ export async function removeAllDevices( driver: DeviceDriver, opts: { max?: number } = {} ): Promise { const removed: string[] = []; if (driver.platform === 'ios') { // iOS:逐个删(最稳)。按设备名找卡 → 长按进多选 → 底部 Delete → 弹框 "Confirm"。循环到无设备。 const NAME_RE = /(Meter|Outdoor Meter|Remote|Curtain|Hub Mini|Hub 2|Hub|Bot|Plug|Lock|Keypad|Blind Tilt)\s+[0-9A-Za-z]{2}\b/; const maxIos = opts.max ?? 50; for (let round = 0; round < maxIos; round++) { const fin = await driver.findElementRaw('name', 'Finish').catch(() => null); if (fin) { await driver.tapElement(fin); await sleep(800); } // 退出残留多选 const src = await driver.getSource(); const m = src.match(NAME_RE); if (!m) break; // 首页已无设备 const name = m[0]; const card = await driver.findElementRaw('predicate string', `name == "${name}" OR label == "${name}"`).catch(() => null); if (!card) break; const r = await driver.getElementRect(card); await driver.longPress(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2), 1.2); await sleep(1500); const del = await driver.findElementRaw('name', 'Delete').catch(() => null); if (!del) { const f2 = await driver.findElementRaw('name', 'Finish').catch(() => null); if (f2) await driver.tapElement(f2); break; } await driver.tapElement(del); await sleep(1200); const confirm = await driver.findElementRaw('name', 'Confirm').catch(() => null); // 确认框 "Delete the selected items?" → Confirm if (confirm) await driver.tapElement(confirm); await sleep(2500); removed.push(name); console.log(`已删 ${name}`); } clearAddedDevices(); console.log(`removeAllDevices[ios] 完成,共删除 ${removed.length} 个: ${removed.join(', ') || '(无)'}`); return removed; } if (driver.platform !== 'android') { console.log('removeAllDevices: 该平台未实现'); return removed; } const max = opts.max ?? 50; // 不在开头冷重启(会让首页卡片晚渲染→漏删);firstDeviceCard 内已做轻量恢复 + 充分轮询 for (let round = 0; round < max; round++) { const batch = await deleteVisibleBatch(driver); if (!batch.length) break; // 首页已无任何设备卡 removed.push(...batch); console.log(`本批删除 ${batch.length} 个: ${batch.join(', ')}`); } clearAddedDevices(); console.log(`removeAllDevices 完成,共删除 ${removed.length} 个: ${removed.join(', ') || '(无)'}`); return removed; } export async function removeDeviceFromAccount(driver: DeviceDriver, keyword: string): Promise { await ensureHomeTab(driver); if (driver.platform === 'android') { let botCard = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${keyword}")`); if (!botCard) { await driver.scrollDown(300); await sleep(800); botCard = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${keyword}")`); } if (!botCard) return false; await driver.tapElement(botCard); await sleep(2000); // Scroll to find Delete button let delEl: string | null = null; for (let i = 0; i < 5; i++) { delEl = await driver.findElementRaw('-android uiautomator', 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text("Delete"))'); if (delEl) break; await driver.scrollDown(300); await sleep(800); } if (!delEl) { console.log('Delete button not found'); return false; } await driver.tapElement(delEl); await sleep(3000); // Confirmation dialog let confirmed = false; for (let retry = 0; retry < 3; retry++) { const src = await driver.getSource(); if (src.includes('Cancel') && src.includes('deleting this device')) { const confirmEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")'); if (confirmEl) { await driver.tapElement(confirmEl); confirmed = true; break; } } await sleep(1000); } if (!confirmed) { console.log('Delete confirmation not found'); return false; } await sleep(3000); await driver.goBackToHomepage(); await sleep(1000); const homeSource = await driver.getSource(); return !homeSource.includes(keyword); } // iOS let botCard = await driver.findElementRaw('predicate string', `name CONTAINS "${keyword}"`); if (!botCard) { await driver.scrollDown(300); await sleep(800); botCard = await driver.findElementRaw('predicate string', `name CONTAINS "${keyword}"`); } if (!botCard) return false; await driver.tapElement(botCard); await sleep(1500); const settingsEl = await waitForElement(driver, 'name', 'Settings', 5000); if (!settingsEl) return false; await driver.tapElement(settingsEl); await sleep(2000); let delEl: string | null = null; for (let i = 0; i < 3; i++) { delEl = await driver.findElementRaw('predicate string', 'name == "Delete" AND visible == true'); if (delEl) break; await driver.scrollDown(300); await sleep(800); } if (!delEl) { console.log('Delete button not found'); return false; } await driver.tapElement(delEl); await sleep(2000); const okEl = await waitForElement(driver, 'name', 'OK', 5000); if (!okEl) { console.log('Delete confirmation not found'); return false; } await driver.tapElement(okEl); await sleep(3000); await driver.goBackToHomepage(); await sleep(1000); const homeSource = await driver.getSource(); return !homeSource.includes(keyword); }