AI_UIAutomation/utils/common/scene.helper.ts

342 lines
14 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 } from './element.helper';
import { ensureHomeTab, navigateToAddPage, navigateToManageScenes, dismissGuideOverlay, dismissSaveConfirmCancel } from './navigation.helper';
export interface SceneConfig {
name: string;
deviceKeyword: string;
action: string;
}
export async function createScene(driver: DeviceDriver, config: SceneConfig): Promise<boolean> {
const { name, deviceKeyword, action } = config;
const opened = await navigateToAddPage(driver, 'Scene');
if (!opened) return false;
await dismissGuideOverlay(driver); // 首进场景页可能有引导浮层,挡住 Add action
if (driver.platform === 'android') {
const addAction = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Add action")');
if (!addAction) { console.log('FAIL: no Add action'); return false; }
await driver.tapElement(addAction);
await sleep(2000);
const smartDevices = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Smart Devices")');
if (!smartDevices) { console.log('FAIL: no Smart Devices'); return false; }
await driver.tapElement(smartDevices);
// 找设备(滚动查找:设备列表较长,Bot 等靠后设备需下滑才可见)
let deviceEl: string | null = null;
for (let i = 0; i < 15 && !deviceEl; i++) {
deviceEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${deviceKeyword}")`);
if (!deviceEl) { await driver.scrollDown(400); await sleep(700); }
}
if (!deviceEl) { console.log(`FAIL: no ${deviceKeyword} in device list`); return false; }
await driver.tapElement(deviceEl);
await sleep(2000);
let actionEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${action}")`);
if (!actionEl) {
actionEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Presses once")');
}
if (!actionEl) { console.log('FAIL: no action option'); return false; }
await driver.tapElement(actionEl);
await sleep(2000);
const editText = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Enter Scene name")');
if (editText) {
await driver.tapElement(editText);
await sleep(300);
await driver.typeText(editText, name);
await sleep(300);
await driver.goBack();
await sleep(500);
}
const saveEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Save")');
if (!saveEl) { console.log('FAIL: no Save'); return false; }
await driver.tapElement(saveEl);
await sleep(2000);
await dismissSaveConfirmCancel(driver); // Save 后弹"立即执行一次?"确认框 → 点 Cancel(场景已保存)
await sleep(1000);
return true;
}
// iOS
const addAction = await driver.findElementRaw('predicate string', 'name BEGINSWITH "Add action" AND name CONTAINS "E.g."');
if (!addAction) { console.log('FAIL: no Add action'); return false; }
const aaRect = await driver.getElementRect(addAction);
await driver.tap(aaRect.x + aaRect.width / 2, aaRect.y + aaRect.height / 2);
const smartDevices = await waitForElement(driver, 'predicate string', 'name CONTAINS "Smart Devices"', 6000);
if (!smartDevices) { console.log('FAIL: no Smart Devices'); return false; }
await driver.tapElement(smartDevices);
let picked = false;
{
// iOS 设备列表很长(Bot 等靠后设备 y 远超窗口,如 y=1885 在 844 窗口外)。用**首页同款滑动法 driver.scrollDown**
// (居中 0.1s 快速甩动,实测稳定滚动、不误点行)循环查找:**仅当目标在可视区(窗口高内)才点中心**,
// 找到即停、到底(页面签名不再变化)即停。修复旧版"找到 off-screen 元素仍点 y=1885 屏外"导致没选中的 bug。
const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 }));
let lastSig = '';
for (let r = 0; r < 14 && !picked; r++) {
const el = await driver.findElementRaw('predicate string', `name CONTAINS "${deviceKeyword}" OR label CONTAINS "${deviceKeyword}"`).catch(() => null);
if (el) {
const rect = await driver.getElementRect(el).catch(() => null);
if (rect && rect.width > 0 && rect.y > winH * 0.11 && rect.y + rect.height < winH * 0.95) {
await driver.tap(Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2));
picked = true;
break;
}
}
const sig = (await driver.getSource()).slice(-2500); // 到底检测:滚动前后尾部签名不变 = 已到底
if (sig === lastSig) break;
lastSig = sig;
await driver.scrollDown(500);
await sleep(900);
}
}
if (!picked) { console.log(`FAIL: no ${deviceKeyword}(可视区)`); return false; }
const actionEl = await waitForElement(driver, 'name', action, 5000);
if (!actionEl) {
// Fallback: if Bot is in Press mode, "Turns off/on" won't exist
const pressEl = await driver.findElementRaw('name', 'Presses once');
if (!pressEl) { console.log('FAIL: no action ' + action); return false; }
await driver.tapElement(pressEl);
} else {
await driver.tapElement(actionEl);
}
await sleep(1500);
const tfs = await driver.findElementsRaw('class name', 'XCUIElementTypeTextField');
if (tfs.length > 0) {
await driver.tapElement(tfs[0]);
await sleep(300);
await driver.typeText(tfs[0], name);
await sleep(300);
const done = await driver.findElementRaw('name', 'Done');
if (done) { await driver.tapElement(done); }
else { await driver.tap(195, 400); }
await sleep(1000);
}
const saveEl = await driver.findElementRaw('name', 'Save');
if (!saveEl) { console.log('FAIL: no Save'); return false; }
await driver.tapElement(saveEl);
await sleep(2000);
await dismissSaveConfirmCancel(driver); // Save 后弹"立即执行一次?"确认框 → 点 Cancel(场景已保存)
await sleep(1000);
return true;
}
export async function executeSceneFromHomepage(driver: DeviceDriver, sceneName: string): Promise<boolean> {
if (driver.platform === 'android') {
await driver.goBackToHomepage();
await sleep(500);
try {
const homeTab = await driver.findElementRaw('accessibility id', 'Home');
if (homeTab) { await driver.tapElement(homeTab); await sleep(1000); }
} catch { /* stale */ }
for (let i = 0; i < 5; i++) {
const sceneEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${sceneName}")`);
if (sceneEl) {
await driver.tapElement(sceneEl);
await sleep(1500);
return true;
}
await driver.scrollDown(300);
await sleep(800);
}
return false;
}
// iOS:手动场景作为可点条目出现在首页。用首页滑动法循环查找,**仅可视区命中才点**(旧版 tapElement 会点屏外元素),找到即停/到底即停。
await driver.goBackToHomepage().catch(() => {});
await ensureHomeTab(driver);
await sleep(800);
const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 }));
let lastSig = '';
for (let i = 0; i < 14; i++) {
const el = await driver.findElementRaw('predicate string', `name CONTAINS "${sceneName}" OR label CONTAINS "${sceneName}"`).catch(() => null);
if (el) {
const rect = await driver.getElementRect(el).catch(() => null);
if (rect && rect.width > 0 && rect.y > winH * 0.08 && rect.y + rect.height < winH * 0.95) {
await driver.tap(Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2));
await sleep(1500);
return true;
}
}
const sig = (await driver.getSource()).slice(-2500);
if (sig === lastSig) break;
lastSig = sig;
await driver.scrollDown(500);
await sleep(900);
}
return false;
}
export async function deleteScene(driver: DeviceDriver, sceneName: string): Promise<boolean> {
if (driver.platform === 'android') {
try {
const homeTab = await driver.findElementRaw('accessibility id', 'Home');
if (homeTab) { await driver.tapElement(homeTab); await sleep(1000); }
} catch { /* stale */ }
const moreBtn = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/moreBto');
if (!moreBtn) return false;
await driver.tapElement(moreBtn);
await sleep(1500);
const manageEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Manage Scenes")');
if (!manageEl) return false;
await driver.tapElement(manageEl);
let sceneEl: string | null = null;
for (let i = 0; i < 15; i++) {
await sleep(1000);
sceneEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${sceneName}")`);
if (sceneEl) break;
}
if (!sceneEl) return false;
await driver.tapElement(sceneEl);
await sleep(2000);
let delEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
if (!delEl) {
await driver.scrollDown(300);
await sleep(500);
delEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
}
if (!delEl) return false;
await driver.tapElement(delEl);
await sleep(2000);
const confirmEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
if (confirmEl) {
await driver.tapElement(confirmEl);
await sleep(1500);
}
return true;
}
// iOS
await driver.tap(347, 48);
await sleep(1500);
const manageEl = await driver.findElementRaw('name', 'Manage Scenes');
if (!manageEl) return false;
await driver.tapElement(manageEl);
await sleep(2000);
const source = await driver.getSource();
if (!source.includes(sceneName)) return false;
const myScenesEl = await driver.findElementRaw('name', 'My Scenes');
if (!myScenesEl) return false;
const msRect = await driver.getElementRect(myScenesEl);
await driver.tap(195, msRect.y + msRect.height + 40);
await sleep(2000);
const editSource = await driver.getSource();
if (!editSource.includes('Edit Scene')) return false;
const delEl = await driver.findElementRaw('name', 'Delete');
if (!delEl) return false;
await driver.tapElement(delEl);
await sleep(1500);
const delConfirm = await driver.findElementRaw('name', 'Delete');
if (delConfirm) {
await driver.tapElement(delConfirm);
await sleep(1500);
}
return true;
}
export async function verifySceneInLogs(driver: DeviceDriver, sceneName: string): Promise<boolean> {
if (driver.platform === 'android') {
const navigated = await navigateToManageScenes(driver);
if (!navigated) return false;
await sleep(1500);
// 场景日志入口:Manage Scenes 右上角有两个图标(左=Create,右=日志)。id 会命中 Create,故直接点最右图标坐标(1080×2280 约 1010,175)→ 菜单 "Scene Logs"。
await driver.tap(1010, 175);
await sleep(1500);
const logsEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Scene Logs")');
if (!logsEl) { console.log('未进入 Scene Logs 菜单'); return false; }
await driver.tapElement(logsEl);
await sleep(2500);
const source = await driver.getSource();
const has = source.includes(sceneName); // 日志页含该场景名 = 已执行(记录)
console.log(`Scene Logs 有执行记录(${sceneName}): ${has}`);
return has;
}
// iOS: navigate to Manage Scenes
await driver.tap(347, 48);
await sleep(1500);
const manageEl = await driver.findElementRaw('name', 'Manage Scenes');
if (!manageEl) return false;
await driver.tapElement(manageEl);
await sleep(2000);
// Find the scene and tap to enter detail
const sceneEl = await driver.findElementRaw('predicate string', `name CONTAINS "${sceneName}"`);
if (!sceneEl) return false;
await driver.tapElement(sceneEl);
await sleep(2000);
// Tap the top-right button to see execution history/status
const navBar = await driver.findElementRaw('class name', 'XCUIElementTypeNavigationBar');
if (navBar) {
const navRect = await driver.getElementRect(navBar);
// Top-right corner of navigation bar
await driver.tap(navRect.x + navRect.width - 30, navRect.y + navRect.height / 2);
await sleep(2000);
}
const source = await driver.getSource();
return source.includes('Success') || source.includes('success') || source.includes('Executed') || source.includes(sceneName);
}
/**
* 通用动作选择(场景/自动化共用):在"Add action 后的动作来源页"点 Smart Devices → 找设备 → 选动作。
* 兼容 Bot 两模式(按压=Presses once / 开关=Turns off/Turns on)与其它设备动作。
* 调用前:已点过 "Add action"(处于动作来源选择页)。
* @param actionPref 优先尝试的动作文本(如 'Turns off' / 'Presses once');找不到按通用顺序兜底。
*/
export async function selectSmartDeviceAction(
driver: DeviceDriver,
deviceKeyword: string,
actionPref?: string
): Promise<boolean> {
const tap = async (sel: string, val: string) => {
const el = await driver.findElementRaw(sel, val);
if (el) { await driver.tapElement(el); return true; }
return false;
};
if (!(await tap('-android uiautomator', 'new UiSelector().text("Smart Devices")'))) {
console.log('FAIL: no Smart Devices'); return false;
}
await sleep(2000);
// 找设备(滚动)
let dev: string | null = null;
for (let i = 0; i < 12 && !dev; i++) {
dev = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${deviceKeyword}")`);
if (!dev) { await driver.scrollDown(400); await sleep(700); }
}
if (!dev) { console.log(`FAIL: no device ${deviceKeyword}`); return false; }
await driver.tapElement(dev);
await sleep(2000);
// 选动作:优先 actionPref,再兜底 Presses once / Turns off / Turns on
const candidates = [actionPref, 'Presses once', 'Turns off', 'Turns on'].filter(Boolean) as string[];
for (const a of candidates) {
if (await tap('-android uiautomator', `new UiSelector().text("${a}")`)) {
console.log(`选动作: ${a}`);
await sleep(2000);
return true;
}
}
console.log('FAIL: no action option'); return false;
}