AI_UIAutomation/utils/common/device-add.helper.ts

834 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void>;
postConnectionSteps?: (driver: DeviceDriver) => Promise<void>;
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<boolean> {
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<string | null> {
// 添加前的"设备已存在?"预检:默认**跳过**(用户要求所有添加用例去掉添加前查找,提速——预检会滚动首页很慢,
// 且 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<boolean> {
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 iosFind = async (): Promise<string | null> => {
// 一条 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 < 16; i++) {
let el = await iosFind();
if (el) {
const visible = await driver.getElementAttribute(el, 'visible').catch(() => 'true');
if (String(visible) !== 'true' && String(visible) !== '1') {
await slowScroll();
continue;
}
// 防贴底误点:卡片太靠下(y>700/844,易被底部栏挡或点到边缘)→ 先小幅上滑抬起,重取再点
const rect = await driver.getElementRect(el).catch(() => null);
if (rect && rect.y > 700) {
await slowScroll();
const el2 = await iosFind(); if (el2) el = el2;
}
await driver.tapElement(el); // 真实坐标 tap(比 /click 稳)
await sleep(3000);
return true;
}
await slowScroll();
}
console.log(`FAIL: no ${categoryName} option`);
return false;
}
export async function waitForDeviceInScan(
driver: DeviceDriver,
deviceKeyword: string,
timeout = 30000
): Promise<string | null> {
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<boolean> {
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<boolean> {
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<void>; // 过免责声明后、按继电器配对前的额外步骤(如 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<void>; // 连接后专属流程(如 curtain 选模式+行程校验);提供则替代默认 navigateThroughWizard
holdMs?: number; // 覆盖按键时长
disclaimerMaxNext?: number; // 到"引导页(按按键处)"前需点几次 Next(过免责声明),默认 1
connectionKeywords?: string[];
wizardButtons?: string[];
}
/** 点"下一步/连接设备"类按钮。连接/引导页按钮文案因品类而异(Next / Connect Device / Connect Devices),依次尝试。返回是否点到。 */
async function tapNext(driver: DeviceDriver): Promise<boolean> {
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<string | null> {
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<boolean> {
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<boolean> => {
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<void> {
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<string[]> {
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<boolean> {
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<string[]> {
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<boolean> {
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);
}