import { DeviceDriver } from '../../drivers/types'; import { sleep, waitForElement, waitForSource, scrollUntilFound } from './element.helper'; import { ensureHomeTab, scrollHomeToTopIOS, navigateToAddPage, navigateThroughWizard, restartAndroidApp } from './navigation.helper'; import { enterPairingMode } from './relay.helper'; import { saveAddedDevice, clearAddedDevices } from './device-registry.helper'; export interface AddDeviceOptions { categoryName: string; categoryContains?: boolean; // 品类入口默认全匹配;组合名入口(如 "Key Vision/Key Vision Pro")需设 true 用 textContains deviceKeyword: string; scanTimeout?: number; wizardButtons?: string[]; connectionKeywords?: string[]; preSelectSteps?: (driver: DeviceDriver) => Promise; postConnectionSteps?: (driver: DeviceDriver) => Promise; skipNextButton?: boolean; skipScanStep?: boolean; registryCategory?: string; // 成功后把首页实际名写入注册表的 key(供控制用例按注册名找卡片,不写死);如 'eaveLight' namePattern?: string; // 首页实际名正则(注册用真实名,如 'Permanent Outdoor Lights\\s*\\w{0,4}');不传则注册 deviceKeyword } const DEFAULT_CONNECTION_KEYWORDS = [ 'Initial Setup', 'Start Using', 'Done', 'added successfully', 'Got it', 'cloud service', 'Pick a room', 'Display Type', ]; // 'Save' 必须在表内:部分设备(如 Keypad Touch)配对后停在"输入设备名"页,需点 Save 才提交并跳首页;漏掉则设备未保存、首页校验失败。 const DEFAULT_WIZARD_BUTTONS = ['Use now', 'Start Using', 'Done', 'Save', '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', // WiFi 配网页 = 连接已开始(尤其 plug/hub:Other Pairing+Yes 后立即到配网页,不加这些会误判未连接→白跑满重试~110s) 'Configure Wi-Fi', 'Wi-Fi Settings', 'Wi-Fi password', 'Enter Wi-Fi', ]; 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 { // 添加前的"设备已存在?"预检:默认**跳过**(用户要求所有添加用例去掉添加前查找,提速——预检会滚动首页很慢, // 且 reset 已清空时纯属浪费、还引发过误 SKIP)。需要恢复(单跑想已存在即 SKIP)时设 ADD_PRECHECK=1。 // 注:添加后的首页**校验**走 addDeviceViaBLE/addDeviceWithSerialPairing 内部独立循环,不受此影响。 if (process.env.ADD_PRECHECK !== '1') return null; // 先退出可能残留的添加向导/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); await scrollHomeToTopIOS(driver); // iOS 回最顶再下滑找(HomeScene tab 不回顶,否则停在中间漏掉靠下的设备卡) const re = new RegExp(pattern); for (let i = 0; i < 14; 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, allowContains = false): Promise { if (driver.platform === 'android') { // 默认**全匹配**(exact text);仅在 allowContains=true 时回退 textContains(用于组合名品类,如 "Keypad Vision/Keypad Vision Pro")。 // 不要默认开 contains:会把 "Remote" 误匹配到 "Universal Remote" 等错误入口 → 流程卡死超时。 // 找品类滑动用**快速甩动**(0.18s)而非默认 scrollDown 的 0.5s 慢拖——慢拖是"滑动看着很慢"的根子。 // 甩动距离仍 < 一屏(900<屏高),且每次滑后都重查 → 不会跳过目标。 const sz = await driver.getWindowSize().catch(() => ({ width: 1080, height: 2280 })); const fling = async (dist = 650) => { const cx = Math.round(sz.width / 2); // 适中速度 0.3s + 距离收到 650:0.18s 过快→动量过冲、目标品类在两次 find 之间被跳过(找不到入口); // 0.5s 又太慢。0.3s 既比慢拖快、又不过冲,每次只滚约 650px(更细、不跳过)。 await driver.swipe(cx, Math.round(sz.height * 0.72), cx, Math.round(sz.height * 0.72 - dist), 0.3); }; const esc = categoryName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const find = async () => { // textMatches((?i)esc) 已是大小写不敏感的**全字**匹配,覆盖 exact → 不再单发 exact 查询(省一次 WDA 往返,每步更快); // 仍整串匹配,不会误匹配子串(如 Remote→Universal Remote)。 const ci = await driver.findElementRaw('-android uiautomator', `new UiSelector().textMatches("(?i)${esc}")`).catch(() => null); if (ci) return ci; if (allowContains) return await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${categoryName}")`); return null; }; // 品类网格异步加载:短轮询等其渲染(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 fling(); el = await find(); } let swipes = 0; for (let i = 0; i < 14 && !el; i++) { await fling(); 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 提速)`); // 防误点:元素若贴近屏幕底部(系统返回键/手势区,屏高 2280),点其中心会命中返回键→退回。 // 先小幅下滑把它抬起再点(如 Water Leak Detector 滑到底正好压在返回键上,导致选品类变成返回)。 try { const rect = await driver.getElementRect(el); if (rect && rect.y + rect.height > 2000) { await driver.scrollDown(400); await sleep(500); const el2 = await find(); if (el2) el = el2; } } catch { /* 取不到 rect 就直接点 */ } await driver.tapElement(el); // 点击品类后进入 免责/引导/扫描/连接 页(部分页标题仍为 "Add Device" 且不显示品类名, // 如 Presence/Safety Alarm 的 "Press and hold...Connect Device" 引导页)→ 轮询等渲染,命中任一标志即视为已进入。 let entered = false; for (let t = 0; t < 6 && !entered; t++) { await sleep(700); const src = await driver.getSource(); entered = src.includes('Disclaimer') || src.includes('Before Using') || src.includes('Connect Device') || src.includes('Press and hold') || src.includes('press the device') || src.includes('indicator light') || src.includes('Scanning') || src.includes('searching') || src.includes('Next') || src.includes(categoryName) || !src.includes('Add Device'); } if (!entered) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } return true; } // iOS - 先在当前屏查找,找不到才滑动。 // ★ 偶现"找不到入口"根因:iOS 的 driver.scrollDown 是 0.1s **大甩动**(忽略距离参数,固定 0.8h→0.2h), // 动量过冲 + 只等 350ms → 目标品类在两次 find 之间被甩过去(Android 早期同坑,已用 0.3s 适中速度修掉)。 // 修复(对齐 Android 稳定做法):① 进页先等目录加载;② 改**可控慢滑**(小步 ~0.22h + 0.4s + 沉降 650ms),不过冲。 const { width: catW, height: catH } = await driver.getWindowSize().catch(() => ({ width: 390, height: 844 })); const slowScroll = async () => { await driver.swipe(Math.round(catW / 2), Math.round(catH * 0.60), Math.round(catW / 2), Math.round(catH * 0.38), 0.4); await sleep(650); // 等动量沉降稳定再 find(短等待会在列表还在动时查 → 漏) }; const slowScrollUp = async () => { await driver.swipe(Math.round(catW / 2), Math.round(catH * 0.38), Math.round(catW / 2), Math.round(catH * 0.60), 0.4); await sleep(650); }; const iosFind = async (): Promise => { // 一条 predicate 覆盖 StaticText/Other/通用(name 或 label)。⚠️ 用**大小写不敏感** `==[c]`: // iOS 品类名是 Title Case(如 "RGBIC Neon Wire Rope Light"),用例常写小写 → 敏感匹配会"滚到底也找不到入口"。 let el = await driver.findElementRaw('predicate string', `name ==[c] "${categoryName}" OR label ==[c] "${categoryName}"`).catch(() => null); if (!el && allowContains) { el = await driver.findElementRaw('predicate string', `name CONTAINS[c] "${categoryName}" OR label CONTAINS[c] "${categoryName}"`).catch(() => null); } return el; }; // 进 Add Device 目录页后等其加载完(页面未渲染完就查/滑 → 偶现找不到入口)。轮询到出现任意品类项或稳定为止。 for (let i = 0; i < 8; i++) { const src = await driver.getSource(); if (/Add Manually|Scanning|Nearby|Bluetooth|Robot|Meter|Plug|Lock|Cam|Hub|Bot|Sensor|Light/i.test(src)) break; await sleep(600); } // 已知滑动次数:先快滑预滑 hint-1 次不校验(当前屏没有时才预滑),再边滑边找——省掉前几屏的查询。 if (scrollHint > 1 && !(await iosFind())) { for (let i = 0; i < scrollHint - 1; i++) { await slowScroll(); } } for (let i = 0; i < 24; i++) { const el = await iosFind(); if (el) { const rect = await driver.getElementRect(el).catch(() => null); if (rect && rect.width > 0) { // ★ WDA 会返回**屏外元素**(实测品类常在 y=1192,屏高仅 844)→ 直接点屏外坐标无效="点了没进流程"。 // 必须先把它**滚进可视区**(y 在 [0.10h, 0.82h])再点:在下方就下滑、在上方就上滑。 if (rect.y < catH * 0.10 || rect.y + rect.height > catH * 0.82) { if (rect.y > catH * 0.5) await slowScroll(); else await slowScrollUp(); continue; } await driver.tapElement(el); await sleep(2500); // ★ 校验真进了流程:**"Add Manually" 是 Add Device 目录页独有标志**,消失=已离开目录进流程 // (不能用 includes(categoryName):目录页本就列着品类名 → 误判成功)。 const after = await driver.getSource(); const entered = !/Add Manually|Scanning for Bluetooth/i.test(after) || /Next|Connect|Configuration|wizard|Before Using|press the button|hold the button|Disclaimer|配对|连接|下一步/i.test(after); if (entered) return true; console.log(`点 ${categoryName} 后仍在目录页(疑点空),重试`); await sleep(500); continue; } } await slowScroll(); } 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, registryCategory, namePattern, } = 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, 0, !!options.categoryContains); 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(连接失败时点 App 的"重试"再等,最多重试 2 次) let connected = await waitForConnection(driver, connectionKeywords); for (let r = 1; !connected && r <= 2; r++) { let retryBtn: string | null = null; for (const t of ['Retry', '重试', 'Try Again', 'Try again', 'Reconnect', 'Reconnect device', 'Connect again', 'Connect Again']) { retryBtn = driver.platform === 'android' ? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null) : await driver.findElementRaw('name', t).catch(() => null); if (retryBtn) break; } if (!retryBtn) break; // 当前页没有"重试"按钮,放弃 console.log(`连接失败,点击重试(第 ${r}/2 次)`); await driver.tapElement(retryBtn); await sleep(3000); 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 scrollHomeToTopIOS(driver); // iOS 回最顶再下滑找(否则漏掉靠下的新设备卡) await sleep(2000); const re = namePattern ? new RegExp(namePattern) : null; for (let i = 0; i < 14; i++) { const source = await driver.getSource(); // 成功:命中即把**首页实际名**(优先 namePattern 真实名,否则 deviceKeyword)写入注册表,供控制用例按注册名找卡片(不写死) if (re) { const m = source.match(re); if (m) { if (registryCategory) saveAddedDevice(registryCategory, m[0]); return true; } } if (source.includes(deviceKeyword)) { if (registryCategory) saveAddedDevice(registryCategory, deviceKeyword); return true; } await driver.scrollDown(400); await sleep(800); } return false; } export interface SerialPairingAddOptions { categoryName: string; categoryContains?: boolean; // 品类入口默认全匹配;组合名入口(如 "Key Vision/Key Vision Pro")需设 true 用 textContains // 添加目录里的品类/产品名(如 '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+');匹配到则以实际名回写注册表 preferNamePattern?: boolean; // 成功判据/回写时**优先用 namePattern 匹配的真实名**,再回退 capturedName/deviceKeyword。 // 用于"加时内联改名"的设备(curtain/灯类):默认名(capturedName)是改名后名("Curtain Auto")的子串("Curtain"), // 不开此项会被 source.includes 松匹配先存成通用名 → 注册表/型号区分丢失。 beforePairing?: (driver: DeviceDriver) => Promise; // 过免责声明后、按继电器配对前的额外步骤(如 outdoor meter 套件先点 "Add Meter") pairRetries?: number; // 配对未触发(按完没连上)时的最大尝试轮数,默认 3 pairPollIters?: number; // 每轮检测轮询次数(窗口=次数×1.5s),默认 12(~18s);remote 检测慢需调大 repressOnRetry?: boolean; // 重试时是否重按继电器;默认 true。切换式配对键(如 remote 凸+凹,按一次进/再按退)须设 false——只按一次,重试只重点 Next tapOtherPairing?: boolean; // 配对未自动触发时,点"其他配对方式/Other pairing methods"切到手动配对路径(部分 plug 自动配对不行,需手动) postConnectSteps?: (driver: DeviceDriver) => Promise; // 连接后专属流程(如 curtain 选模式+行程校验);提供则替代默认 navigateThroughWizard holdMs?: number; // 覆盖按键时长 disclaimerMaxNext?: number; // 到"引导页(按按键处)"前需点几次 Next(过免责声明),默认 1 connectionKeywords?: string[]; wizardButtons?: string[]; } /** 点"下一步/连接设备"类按钮。连接/引导页按钮文案因品类而异(Next / Connect Device / Connect Devices),依次尝试。返回是否点到。 */ async function tapNext(driver: DeviceDriver): Promise { if (driver.platform === 'android') { // 兼容 Next 与 Connect Device(部分品类如 Safety Alarm/Find Card 引导页是 "Connect Device(s)" 非 Next) for (const t of ['Next', 'Connect Device', 'Connect Devices', 'Connect device']) { const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`); if (el) { await driver.tapElement(el); return true; } } return false; } // 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; // iOS 用 value/label(不用 'text',iOS 不支持→WDA 返回对象→String 化成 "[object Object]");Android 用 text/value。 const attrs = driver.platform === 'android' ? ['text', 'value'] : ['value', 'label', 'name']; let v: string | null = null; for (const attr of attrs) { const raw = await driver.getElementAttribute(el, attr).catch(() => null); if (typeof raw === 'string' && raw.trim() && raw !== '[object Object]') { v = raw; break; } } const name = v ? v.trim() : ''; // 过滤占位符/提示文案 if (!name || /enter|name|请输入|名称|Wi-?Fi|password|密码/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, preferNamePattern = false, beforePairing, pairRetries = 3, pairPollIters = 12, repressOnRetry = true, tapOtherPairing = false, 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, !!options.categoryContains); if (!catSelected) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } const __T0 = Date.now(); const __EL = () => `${Math.round((Date.now() - __T0) / 1000)}s`; console.log(`[计时] 选品类完成 @${__EL()}`); // 过免责声明到引导页(无 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'); // 部分 plug 自动配对不触发 / iOS 弹"Add to HomeKit"页 → 点"其他配对方式/Other Pairing Methods"切到 SwitchBot 配网路径(opt-in)。 // ⚠️ 该按钮(HomeKit 页)在配对后 ~4s 才出现 → 轮询等它出现再点(否则 tapNext 后立即找会错过,且 "Connecting" 一出现就退循环再无机会)。 if (tapOtherPairing) { const otherLabels = ['Other Pairing Methods', '其他配对方式', 'Other pairing methods', 'Other pairing method', 'Connect another way', 'Pair another way', '其他方式', '其他配对']; for (let w = 0; w < 9; w++) { let clicked = false; for (const t of otherLabels) { const el = driver.platform === 'ios' ? (await driver.findElementRaw('predicate string', `label CONTAINS "${t}" OR name CONTAINS "${t}"`).catch(() => null)) : (await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`).catch(() => null)); if (el) { console.log(`点击"其他配对方式"(${t})切配网路径`); await driver.tapElement(el); await sleep(2000); // 点后弹确认框(如"切换配对方式?/是否继续?")→ 点 Yes/确定 才真正切到 SwitchBot 配网路径 for (const y of ['Yes', '是', 'Confirm', 'Continue', 'OK', '确定', '继续']) { const yb = driver.platform === 'ios' ? (await driver.findElementRaw('predicate string', `label == "${y}" OR name == "${y}"`).catch(() => null)) : (await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${y}")`).catch(() => null)); if (yb) { console.log(`确认弹框: ${y}`); await driver.tapElement(yb); await sleep(2500); break; } } clicked = true; break; } } if (clicked) break; await sleep(1500); } } // 检测轮询:出现连接关键字/设备名即视为进配对成功。窗口 = 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: 多次重试后仍未检测到连接开始,继续走后续(大概率失败)'); console.log(`[计时] 配对/连接检测完成(started=${started}) @${__EL()}`); // 收尾等待(连上则多等让其完成);连接失败弹框主动关掉,避免残留挡住后续(尤其 curtain3) await waitForConnection(driver, connectionKeywords, started ? 9000 : 3000); await driver.dismissPopupIfPresent(); console.log(`[计时] waitForConnection 完成 @${__EL()}`); // 连接成功(或失败)后通常跳到"输入设备名"页:字段里的默认名即设备名 → 最可靠的取名处 const capturedName = await captureDeviceNameFromInput(driver); if (capturedName) console.log(`从"输入设备名"页捕获默认名: "${capturedName}"`); // 连接后:有专属流程(如 curtain 选模式 + 行程校验)则走它,否则走默认向导 if (postConnectSteps) await postConnectSteps(driver); else await navigateThroughWizard(driver, wizardButtons); await driver.dismissPopupIfPresent(); console.log(`[计时] postConnect/配网+向导 完成 @${__EL()}`); // 成功判据:输入页捕获到名字 或 设备出现在首页 → 记录实际设备名作为功能页入口 await ensureHomeTab(driver); await scrollHomeToTopIOS(driver); // iOS 回最顶再下滑找(否则停在中间漏掉靠下的新设备卡,如 Hub Mini Matter) await sleep(1200); const candidates = [capturedName, deviceKeyword].filter(Boolean) as string[]; const re = namePattern ? new RegExp(namePattern) : null; await driver.dismissPopupIfPresent(); // 添加完成后的"更新/评分/新功能"弹框,开头关一次即可(原来每轮关=12次多余 getSource,拖慢 verify ~20-30s) for (let i = 0; i < 12; i++) { // 设备多时新设备常在列表底部,需充分下滑(iOS 尤其);命中即返回 if (i === 5) await driver.dismissPopupIfPresent(); // 中途补关一次(延迟弹出的评分/更新框) const source = await driver.getSource(); // preferNamePattern:加时内联改名的设备(curtain/灯类),改名后名("Curtain Auto")**含**默认名子串("Curtain"), // 若先走 candidates 的 source.includes 会松匹配存成通用名 → 优先用 namePattern 取真实(改名后)名回写。 if (preferNamePattern && re) { const m = source.match(re); if (m) { saveAddedDevice(registryCategory, m[0]); return true; } } 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(500); await sleep(300); } // 成功 = 设备**真出现在首页**(上面 loop 已覆盖 capturedName/keyword/pattern 在首页的情况)。 // 注意:连接成功/失败都会经过"输入设备名"页,capturedName 有值≠添加成功 → 不能仅凭它判通过(否则配网失败也误判 PASS)。 if (capturedName) console.log(`WARN: 捕获到名字"${capturedName}"但首页未出现该设备 → 判为未添加(配网/连接可能失败)`); 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 scrollHomeToTopIOS(driver); // iOS 回最顶再下滑找(否则漏掉靠下的新设备卡) 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); }