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

693 lines
31 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, 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<void>;
postConnectionSteps?: (driver: DeviceDriver) => Promise<void>;
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<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> {
// 先退出可能残留的添加向导/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<boolean> {
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<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,
} = 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<void>; // 过免责声明后、按继电器配对前的额外步骤(如 outdoor meter 套件先点 "Add Meter")
pairRetries?: number; // 配对未触发(按完没连上)时的最大尝试轮数,默认 3
pairPollIters?: number; // 每轮检测轮询次数(窗口=次数×1.5s),默认 12(~18s);remote 检测慢需调大
repressOnRetry?: boolean; // 重试时是否重按继电器;默认 true。切换式配对键(如 remote 凸+凹,按一次进/再按退)须设 false——只按一次,重试只重点 Next
postConnectSteps?: (driver: DeviceDriver) => Promise<void>; // 连接后专属流程(如 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<boolean> {
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<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;
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<boolean> {
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<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');
// 检测轮询:出现连接关键字/设备名即视为进配对成功。窗口 = 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<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 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);
}