693 lines
31 KiB
TypeScript
693 lines
31 KiB
TypeScript
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);
|
||
}
|