342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
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;
|
||
}
|