AI_UIAutomation/utils/common/curtain.helper.ts

246 lines
14 KiB
TypeScript

/**
* Curtain / Curtain 3 连接后专属流程(选模式 + 行程校验)。
* **curtain 与 curtain3 校准流程不同**,分开实现:
* - curtain3:已实测全程跑通(Custom Calibration:朝向→已关闭→跳磁铁→Move left/right)。
* - curtain(base):校准流程与 curtain3 不同,待该设备可连接后补全;当前先走"稍后校准"。
*
* 作为 addDeviceWithSerialPairing 的 postConnectSteps 传入。所有点击用文字/desc 定位,不写死坐标。
*/
import { DeviceDriver } from '../../drivers/types';
import { sleep, waitForSource } from './element.helper';
export interface CurtainAddOptions {
/** true=做行程校验(需设备装在真实导轨上、电机能跑);false=Install and calibrate later 跳过校验。 */
calibrate?: boolean;
/** Determine 位置每个方向的行进等待(ms),默认 15000。 */
moveWaitMs?: number;
/** Install 页内联重命名的目标名(curtain 类首页都叫 "Curtain XX",靠改名区分型号)。不传则不改名。 */
renameTo?: string;
}
async function tapText(driver: DeviceDriver, txt: string): Promise<boolean> {
if (driver.platform === 'android') {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${txt}")`).catch(() => null);
if (el) { await driver.tapElement(el); return true; }
return false;
}
// iOS:按钮常 name 为空、只有 label → 精确 name 匹配会漏。依次 name 精确 → label/name 精确(predicate) → CONTAINS。
const tries = [
['name', txt],
['predicate string', `name == "${txt}" OR label == "${txt}"`],
['predicate string', `name CONTAINS "${txt}" OR label CONTAINS "${txt}"`],
] as const;
for (const [using, val] of tries) {
const el = await driver.findElementRaw(using, val).catch(() => null);
if (el) { await driver.tapElement(el); return true; }
}
return false;
}
async function tapDesc(driver: DeviceDriver, desc: string): Promise<boolean> {
const el = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', `new UiSelector().descriptionContains("${desc}")`)
: await driver.findElementRaw('predicate string', `label CONTAINS "${desc}" OR name CONTAINS "${desc}"`);
if (el) { await driver.tapElement(el); return true; }
return false;
}
/** 按 resource-id 点击(Android)。base curtain 校准的 Move/Stop 是图标(ImageView),文字是不可点的标签,必须按 id 点图标。 */
async function tapId(driver: DeviceDriver, id: string): Promise<boolean> {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().resourceId("com.theswitchbot.switchbot:id/${id}")`);
if (el) { await driver.tapElement(el); return true; }
return false;
}
/**
* 点校准页的 Move 图标(左/右),跨平台。Android:resource-id ivToLeft/ivToRight。
* iOS:校准页 Move left/Move right 是两个自绘 XCUIElementTypeImage(name 空),按 x 位置区分(左=最小x,右=最大x)。
* 图标为 toggle:点一次开始移动(标签变 Stop),再点一次停止。
*/
async function tapMoveIcon(driver: DeviceDriver, side: 'left' | 'right'): Promise<boolean> {
if (driver.platform === 'android') {
return tapId(driver, side === 'left' ? 'ivToLeft' : 'ivToRight');
}
// iOS:校准页 Move left/right 是两个自绘 XCUIElementTypeImage(name 空)。用元素 rect(不靠源字符串正则,稳)
// 过滤:尺寸小(<80)、在中部按钮行(y∈340~680),按 x 排序取左/右。
const imgs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeImage"').catch(() => [] as string[]);
const icons: { cx: number; cy: number }[] = [];
for (const im of imgs as string[]) {
const r = await driver.getElementRect(im).catch(() => null);
if (r && r.width < 80 && r.height < 80 && r.y > 340 && r.y < 700) icons.push({ cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2) });
}
icons.sort((a, b) => a.cx - b.cx);
if (!icons.length) { console.log(`curtain(iOS): 未找到 Move 图标(${side}),共 Image ${(imgs as string[]).length}`); return false; }
const pt = side === 'left' ? icons[0] : icons[icons.length - 1];
await driver.tap(pt.cx, pt.cy);
return true;
}
async function selectModeOpenOneSide(driver: DeviceDriver): Promise<void> {
await waitForSource(driver, 'Select Mode', 60000).catch(() => {});
if (!(await tapDesc(driver, 'Open from one side'))) await tapText(driver, 'Open from one side');
await sleep(1000);
// 推进按钮:curtain3 用 "Confirm",base curtain 用 "Next" → 试 Confirm,无则 Next
if (!(await tapText(driver, 'Confirm'))) await tapText(driver, 'Next');
}
/** Curtain 3 连接后流程(含已验证的 Custom Calibration)。 */
export async function curtain3PostConnect(driver: DeviceDriver, opts: CurtainAddOptions = {}): Promise<void> {
const calibrate = opts.calibrate ?? false;
const moveWait = opts.moveWaitMs ?? 8000; // Move left 后行进时长(=全开位行程);15s 行程过长 → 缩短到 8s。可用 moveWaitMs 覆盖。
await selectModeOpenOneSide(driver);
await waitForSource(driver, 'Install', 30000).catch(() => {});
// Install 页含设备名(可编辑 EditText)+ Install / Install and calibrate later。
// 按需在**本页内联重命名**(curtain/curtain3/2025 首页都叫 "Curtain XX",靠改名区分)→ 再继续校准。
if (opts.renameTo) {
const nameEd = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")').catch(() => null)
: await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null);
if (nameEd) {
await driver.tapElement(nameEd); await sleep(500);
await driver.clearText(nameEd).catch(() => {});
await driver.typeText(nameEd, opts.renameTo); await sleep(500);
// 收键盘:Android goBack;**iOS 绝不能 goBack(会退回上一步丢 Install 页 → 重命名丢失 → 添加判失败)**,点键盘 Done/Return。
if (driver.platform === 'android') { try { await driver.goBack(); } catch { /* 收键盘 */ } }
else { for (const k of ['Done', 'Return', 'Go', 'Join', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
await sleep(700);
console.log(`curtain3: Install 页内联重命名为 "${opts.renameTo}"`);
} else {
console.log('curtain3: WARN Install 页未找到名称输入框,跳过重命名');
}
}
if (!calibrate) {
await tapText(driver, 'Install and calibrate later');
console.log('curtain3: 走 Install and calibrate later(跳过行程校验)');
return;
}
await tapText(driver, 'Install');
// Curtain Track 选轨道 → 底部 Skip
await waitForSource(driver, 'rail type', 15000).catch(() => {});
await tapText(driver, 'Skip');
// Calibrate → Custom Calibration
await waitForSource(driver, 'Custom Calibration', 15000).catch(() => {});
await tapText(driver, 'Custom Calibration');
// ① Confirm Orientation:勾选"logo 朝内"单选行 → Next
// 注意:用唯一子串 "logo is facing inwards"(单选行 "SwitchBot logo is facing inwards"),
// 不能用 "facing inwards" —— 会误匹配描述段 "...logo on your device is facing inwards",导致单选没勾上、Next 灰着。
await waitForSource(driver, 'Confirm Orientation', 15000).catch(() => {});
if (!(await tapDesc(driver, 'logo is facing inwards'))) await tapDesc(driver, 'facing inwards');
await sleep(800);
await tapText(driver, 'Next');
// ② Adjust Position:勾选"已关闭窗帘"单选行 → Start Calibration(同样用唯一子串避免误匹配描述)
await waitForSource(driver, 'fully closed', 15000).catch(() => {});
if (!(await tapDesc(driver, 'fully closed my curtains'))) await tapDesc(driver, 'fully closed');
await sleep(800);
await tapText(driver, 'Start Calibration');
// ③ Magnet Detection:Skip magnet detection → 弹框 Skip
await waitForSource(driver, 'Magnet Detection', 15000).catch(() => {});
await tapText(driver, 'Skip magnet detection');
await sleep(1500);
await tapText(driver, 'Skip');
// ④ Determine Fully open:Move left 行进 → 点"暂停"停在位 → Auto-Calibrate Close。
// Move left 点击后按钮会变成 "Pause"(窗帘行进中),必须先点 Pause 停下,否则位置没记录 → "Not calibrated"。
await waitForSource(driver, 'Move left', 20000).catch(() => {});
await tapText(driver, 'Move left');
await sleep(moveWait); // 让窗帘行进到目标全开位
// 行进中按钮变 Pause:点它停下(中英/可能为 "Pause"/"暂停")
if (!(await tapText(driver, 'Pause'))) await tapText(driver, '暂停');
await sleep(1500);
await tapText(driver, 'Auto-Calibrate Close');
await sleep(5000); // 等自动关闭校准结算(期间可能出现 "Something went wrong",仍可 Finish)
// ⑤ Finish:点一次可能不离开校验页(页面结算中),重试直到离开 Custom Calibration 页
for (let i = 0; i < 6; i++) {
const src = await driver.getSource();
if (!/Custom Calibration|Move left|Auto-Calibrate/i.test(src)) break; // 已离开校验页 = 完成
await tapText(driver, 'Finish');
await sleep(3000);
}
console.log('curtain3: Custom Calibration 完成');
}
/**
* Base Curtain 连接后流程(实测 2026-06-09 跑通)。流程与 curtain3 **不同**:
* Select Mode→Next → 命名页 Next → Installation Guide(选导轨)→ Skip Installation Guide
* → Calibrate → Manual Calibration → Confirm Orientation(勾 logo 朝向)→ Start Calibration
* → 2/3 **Move left**→Next → 3/3 **Move right**→Finish → 首页。
* 关键:左限位在 2/3、右限位在 3/3,分两页设;同页又左又右会"Route too short"。
*/
export async function curtainPostConnect(driver: DeviceDriver, opts: CurtainAddOptions = {}): Promise<void> {
const moveWait = opts.moveWaitMs ?? 5000; // Move 后等电机行进 ~5s 再 Stop 记录限位
// ① Select Mode:选"单边开合"(默认已勾)→ Next
await selectModeOpenOneSide(driver);
// ② 命名页(默认名如 "Curtain 4D"):按需**本页内联改名**(同 curtain3,首页都叫 "Curtain XX",靠改名区分型号)→ Next
await waitForSource(driver, 'Name', 30000).catch(() => {});
if (opts.renameTo) {
const nameEd = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")').catch(() => null)
: await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null);
if (nameEd) {
await driver.tapElement(nameEd); await sleep(500);
await driver.clearText(nameEd).catch(() => {});
await driver.typeText(nameEd, opts.renameTo); await sleep(500);
// 收键盘:Android goBack;**iOS 绝不能 goBack(会退回上一步丢命名页)**,点键盘 Done/Return。
if (driver.platform === 'android') { try { await driver.goBack(); } catch { /* 收键盘 */ } }
else { for (const k of ['Done', 'Return', 'Go', 'Join', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
await sleep(700);
console.log(`curtain(base): 命名页内联重命名为 "${opts.renameTo}"`);
} else {
console.log('curtain(base): WARN 命名页未找到名称输入框,跳过重命名');
}
}
await tapText(driver, 'Next');
// ③ Installation Guide(选导轨类型 I/U/Rod)→ 底部 "Skip Installation Guide"
await waitForSource(driver, 'Installation Guide', 20000).catch(() => {});
await tapText(driver, 'Skip Installation Guide');
// ④ Calibrate 页 → Manual Calibration(base curtain 此页无"稍后校准",按需求走手动)
await waitForSource(driver, 'Calibrate', 15000).catch(() => {});
await tapText(driver, 'Manual Calibration');
// ⑤ Confirm Orientation:勾"logo 朝内" → Start Calibration
await waitForSource(driver, 'Confirm Orientation', 15000).catch(() => {});
if (!(await tapText(driver, 'I confirm device logo is facing indoors.')))
await tapDesc(driver, 'facing indoors');
await sleep(800);
await tapText(driver, 'Start Calibration');
// ⑥ 2/3(Android) / 1/2(iOS) 设左限位:点 Move-left 图标开始左移 → 等 ~5s → 再点停止 → Next。
// 注意:"Move left"/"Stop" 是不可点的文字标签,真正可点的是图标(Android ivToLeft / iOS 左侧 Image)。
await waitForSource(driver, 'Move left', 20000).catch(() => {});
await tapMoveIcon(driver, 'left');
await sleep(moveWait);
await tapMoveIcon(driver, 'left'); // 再点同一图标 = Stop
await sleep(1500);
await tapText(driver, 'Next');
// ⑦ 3/3(Android) / 2/2(iOS) 设右限位:点 Move-right 图标右移 → 等 ~5s → 再点停止 → 推进。
// ⚠️ 推进按钮两端不同:Android 3/3=「Finish」;iOS 2/2=「Next」(该页无 Finish,Finish 在后面的成功页)。两个都试。
await waitForSource(driver, 'Move right', 15000).catch(() => {});
await tapMoveIcon(driver, 'right');
await sleep(moveWait);
await tapMoveIcon(driver, 'right'); // 再点同一图标 = Stop
await sleep(1500);
if (!(await tapText(driver, 'Finish'))) await tapText(driver, 'Next');
// ⑧ Calibrated successfully → 收尾。Android=「Done」;iOS=「Finish」。
await waitForSource(driver, 'Calibrated successfully', 15000).catch(() => {});
for (const t of ['Finish', 'Done', 'OK', '完成']) { if (await tapText(driver, t)) break; }
await sleep(3000);
console.log('curtain(base): Manual Calibration 完成(Move左→停→Next→Move右→停→Next/Finish→成功页 Finish/Done)');
}