478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
import { DeviceDriver } from '../drivers/types';
|
|
import { sleep } from './common/element.helper';
|
|
import { execSync } from 'child_process';
|
|
|
|
export interface AutomationCondition {
|
|
type: string; // e.g. "Smart Devices", "Schedules", "NFC Tag", "Outdoor Weather"
|
|
device?: string; // device name if type is Smart Devices
|
|
trigger?: string; // specific trigger text to select
|
|
}
|
|
|
|
export interface AutomationAction {
|
|
type: string; // e.g. "Smart Devices", "Notifications", "Delay"
|
|
device?: string; // device name if type is Smart Devices
|
|
command?: string; // specific command text to select
|
|
}
|
|
|
|
export interface AutomationConfig {
|
|
name: string;
|
|
condition: AutomationCondition;
|
|
action: AutomationAction;
|
|
}
|
|
|
|
export class AutomationHelper {
|
|
private steps: string[] = [];
|
|
|
|
constructor(private driver: DeviceDriver) {}
|
|
|
|
private isAndroid() { return this.driver.platform === 'android'; }
|
|
|
|
private async logPageElements(source?: string): Promise<string> {
|
|
const src = source || await this.driver.getSource();
|
|
if (this.isAndroid()) {
|
|
const textRe = /text="([^"]{1,80})"/g;
|
|
const descRe = /content-desc="([^"]{1,80})"/g;
|
|
const texts: string[] = [];
|
|
const descs: string[] = [];
|
|
let m;
|
|
while ((m = textRe.exec(src)) !== null) {
|
|
if (m[1] && !texts.includes(m[1])) texts.push(m[1]);
|
|
}
|
|
while ((m = descRe.exec(src)) !== null) {
|
|
if (m[1] && !descs.includes(m[1])) descs.push(m[1]);
|
|
}
|
|
console.log('Page texts:', texts.join(' | '));
|
|
if (descs.length) console.log('Page descs:', descs.join(' | '));
|
|
}
|
|
return src;
|
|
}
|
|
|
|
private async dismissGuidePopups(): Promise<void> {
|
|
for (let i = 0; i < 5; i++) {
|
|
const gotIt = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Got it")');
|
|
if (gotIt) {
|
|
await this.driver.tapElement(gotIt);
|
|
await sleep(1500);
|
|
this.steps.push('关闭引导弹框');
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
async ensureAppRunning(): Promise<void> {
|
|
if (!this.isAndroid()) return;
|
|
try {
|
|
const src = await this.driver.getSource();
|
|
if (src.includes('com.theswitchbot.switchbot') || src.includes('SwitchBot')
|
|
|| src.includes('Home') || src.includes('Automation') || src.includes('Scene')) return;
|
|
} catch { /* app likely crashed */ }
|
|
try {
|
|
execSync('adb shell am force-stop com.theswitchbot.switchbot');
|
|
await sleep(2000);
|
|
execSync('adb shell am start -n com.theswitchbot.switchbot/.index.ui.SplashActivity');
|
|
await sleep(10000);
|
|
await this.driver.dismissPopupIfPresent();
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async navigateToAutomationTab(): Promise<boolean> {
|
|
await this.ensureAppRunning();
|
|
const src = await this.driver.getSource();
|
|
|
|
if (src.includes('My Automations') && (src.includes('Recommended') || src.includes('新自动化'))) {
|
|
this.steps.push('已在Automation页面');
|
|
return true;
|
|
}
|
|
|
|
// On creation sub-page — force restart to clear
|
|
if (src.includes('Add condition') || src.includes('Create Automation') || src.includes('Add action')) {
|
|
if (this.isAndroid()) {
|
|
execSync('adb shell am force-stop com.theswitchbot.switchbot');
|
|
await sleep(2000);
|
|
execSync('adb shell am start -n com.theswitchbot.switchbot/.index.ui.SplashActivity');
|
|
await sleep(10000);
|
|
await this.driver.dismissPopupIfPresent();
|
|
}
|
|
} else {
|
|
await this.driver.goBackToHomepage();
|
|
await sleep(2000);
|
|
await this.driver.dismissPopupIfPresent();
|
|
}
|
|
|
|
let tabEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
tabEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().description("Automations")');
|
|
if (!tabEl) tabEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Automations")');
|
|
} else {
|
|
tabEl = await this.driver.findElementRaw('predicate string', 'label == "Automations" OR label == "Automation"');
|
|
}
|
|
|
|
if (!tabEl) {
|
|
this.steps.push('未找到Automations tab');
|
|
await this.logPageElements();
|
|
return false;
|
|
}
|
|
|
|
await this.driver.tapElement(tabEl);
|
|
await sleep(3000);
|
|
this.steps.push('点击Automations tab');
|
|
|
|
await this.dismissGuidePopups();
|
|
await this.driver.dismissPopupIfPresent();
|
|
|
|
const pageSrc = await this.driver.getSource();
|
|
return pageSrc.includes('My Automations') || pageSrc.includes('Recommended');
|
|
}
|
|
|
|
async enterCreatePage(): Promise<boolean> {
|
|
if (this.isAndroid()) {
|
|
const addEl = await this.driver.findElementRaw('-android uiautomator',
|
|
'new UiSelector().resourceId("com.theswitchbot.switchbot:id/addBto")');
|
|
if (!addEl) {
|
|
this.steps.push('未找到Add按钮(addBto)');
|
|
return false;
|
|
}
|
|
await this.driver.tapElement(addEl);
|
|
await sleep(5000);
|
|
this.steps.push('点击Add按钮');
|
|
} else {
|
|
const addEl = await this.driver.findElementRaw('predicate string', 'label == "Add" OR label == "+"');
|
|
if (!addEl) {
|
|
this.steps.push('未找到Add按钮');
|
|
return false;
|
|
}
|
|
await this.driver.tapElement(addEl);
|
|
await sleep(5000);
|
|
this.steps.push('点击Add按钮');
|
|
}
|
|
|
|
await this.dismissGuidePopups();
|
|
|
|
const src = await this.driver.getSource();
|
|
if (src.includes('Create Automation') || src.includes('Add condition')) {
|
|
this.steps.push('进入创建自动化页面');
|
|
return true;
|
|
}
|
|
this.steps.push('未进入创建页面');
|
|
await this.logPageElements(src);
|
|
return false;
|
|
}
|
|
|
|
async inputAutomationName(name: string): Promise<boolean> {
|
|
let nameInput: string | null = null;
|
|
if (this.isAndroid()) {
|
|
nameInput = await this.driver.findElementRaw('-android uiautomator',
|
|
'new UiSelector().textContains("Enter Automation name")');
|
|
if (!nameInput) nameInput = await this.driver.findElementRaw('-android uiautomator',
|
|
'new UiSelector().className("android.widget.EditText").instance(0)');
|
|
} else {
|
|
nameInput = await this.driver.findElementRaw('class name', 'XCUIElementTypeTextField');
|
|
}
|
|
|
|
if (!nameInput) {
|
|
this.steps.push('未找到名称输入框');
|
|
return false;
|
|
}
|
|
|
|
await this.driver.tapElement(nameInput);
|
|
await sleep(500);
|
|
await this.driver.clearText(nameInput);
|
|
await this.driver.typeText(nameInput, name);
|
|
await sleep(1000);
|
|
// Hide keyboard
|
|
await this.driver.goBack();
|
|
await sleep(1000);
|
|
this.steps.push(`输入名称: ${name}`);
|
|
return true;
|
|
}
|
|
|
|
async selectCondition(condition: AutomationCondition): Promise<boolean> {
|
|
// Click "Add condition" card on Create Automation page
|
|
let condCard: string | null = null;
|
|
if (this.isAndroid()) {
|
|
condCard = await this.driver.findElementRaw('-android uiautomator',
|
|
'new UiSelector().descriptionContains("Add condition")');
|
|
if (!condCard) condCard = await this.driver.findElementRaw('-android uiautomator',
|
|
'new UiSelector().text("Add condition")');
|
|
}
|
|
if (!condCard) {
|
|
this.steps.push('未找到Add condition卡片');
|
|
return false;
|
|
}
|
|
|
|
await this.driver.tapElement(condCard);
|
|
await sleep(3000);
|
|
await this.dismissGuidePopups();
|
|
this.steps.push('点击Add condition');
|
|
|
|
// Now on condition type selection page
|
|
// Select condition type (e.g. "Smart Devices", "Schedules")
|
|
let typeEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
typeEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().descriptionContains("${condition.type}")`);
|
|
if (!typeEl) typeEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().text("${condition.type}")`);
|
|
}
|
|
if (!typeEl) {
|
|
this.steps.push(`未找到条件类型: ${condition.type}`);
|
|
await this.logPageElements();
|
|
return false;
|
|
}
|
|
|
|
await this.driver.tapElement(typeEl);
|
|
await sleep(3000);
|
|
this.steps.push(`选择条件类型: ${condition.type}`);
|
|
|
|
// If Smart Devices, select specific device
|
|
if (condition.type === 'Smart Devices' && condition.device) {
|
|
let devEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
devEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().textContains("${condition.device}")`);
|
|
if (!devEl) devEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().descriptionContains("${condition.device}")`);
|
|
}
|
|
if (devEl) {
|
|
await this.driver.tapElement(devEl);
|
|
await sleep(3000);
|
|
this.steps.push(`选择设备: ${condition.device}`);
|
|
} else {
|
|
this.steps.push(`未找到设备: ${condition.device}`);
|
|
await this.logPageElements();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Select specific trigger if provided
|
|
if (condition.trigger) {
|
|
let trigEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
trigEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().textContains("${condition.trigger}")`);
|
|
if (!trigEl) trigEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().descriptionContains("${condition.trigger}")`);
|
|
}
|
|
if (trigEl) {
|
|
await this.driver.tapElement(trigEl);
|
|
await sleep(3000);
|
|
this.steps.push(`选择触发条件: ${condition.trigger}`);
|
|
} else {
|
|
this.steps.push(`未找到触发条件: ${condition.trigger}`);
|
|
await this.logPageElements();
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async selectAction(action: AutomationAction): Promise<boolean> {
|
|
// Click "Add action" card on Create Automation page
|
|
let actCard: string | null = null;
|
|
if (this.isAndroid()) {
|
|
actCard = await this.driver.findElementRaw('-android uiautomator',
|
|
'new UiSelector().descriptionContains("Add action")');
|
|
if (!actCard) actCard = await this.driver.findElementRaw('-android uiautomator',
|
|
'new UiSelector().text("Add action")');
|
|
}
|
|
if (!actCard) {
|
|
this.steps.push('未找到Add action卡片');
|
|
return false;
|
|
}
|
|
|
|
await this.driver.tapElement(actCard);
|
|
await sleep(3000);
|
|
await this.dismissGuidePopups();
|
|
this.steps.push('点击Add action');
|
|
|
|
// Select action type
|
|
let typeEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
typeEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().descriptionContains("${action.type}")`);
|
|
if (!typeEl) typeEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().text("${action.type}")`);
|
|
}
|
|
if (!typeEl) {
|
|
this.steps.push(`未找到动作类型: ${action.type}`);
|
|
await this.logPageElements();
|
|
return false;
|
|
}
|
|
|
|
await this.driver.tapElement(typeEl);
|
|
await sleep(3000);
|
|
this.steps.push(`选择动作类型: ${action.type}`);
|
|
|
|
// If Smart Devices, select device
|
|
if (action.type === 'Smart Devices' && action.device) {
|
|
let devEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
devEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().textContains("${action.device}")`);
|
|
if (!devEl) devEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().descriptionContains("${action.device}")`);
|
|
}
|
|
if (devEl) {
|
|
await this.driver.tapElement(devEl);
|
|
await sleep(3000);
|
|
this.steps.push(`选择设备: ${action.device}`);
|
|
} else {
|
|
this.steps.push(`未找到设备: ${action.device}`);
|
|
await this.logPageElements();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Select specific command if provided
|
|
if (action.command) {
|
|
let cmdEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
cmdEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().textContains("${action.command}")`);
|
|
if (!cmdEl) cmdEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().descriptionContains("${action.command}")`);
|
|
}
|
|
if (cmdEl) {
|
|
await this.driver.tapElement(cmdEl);
|
|
await sleep(3000);
|
|
this.steps.push(`选择命令: ${action.command}`);
|
|
} else {
|
|
this.steps.push(`未找到命令: ${action.command}`);
|
|
await this.logPageElements();
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async saveAutomation(): Promise<boolean> {
|
|
let saveEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
saveEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Save")');
|
|
if (!saveEl) saveEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().description("Save")');
|
|
} else {
|
|
saveEl = await this.driver.findElementRaw('predicate string', 'label == "Save"');
|
|
}
|
|
|
|
if (!saveEl) {
|
|
// Scroll down to find Save
|
|
await this.driver.scrollDown(300);
|
|
await sleep(1500);
|
|
if (this.isAndroid()) {
|
|
saveEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Save")');
|
|
if (!saveEl) saveEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().description("Save")');
|
|
}
|
|
}
|
|
|
|
if (!saveEl) {
|
|
this.steps.push('未找到Save按钮');
|
|
await this.logPageElements();
|
|
return false;
|
|
}
|
|
|
|
await this.driver.tapElement(saveEl);
|
|
await sleep(5000);
|
|
this.steps.push('点击Save保存');
|
|
return true;
|
|
}
|
|
|
|
async createAutomation(config: AutomationConfig): Promise<boolean> {
|
|
// Step 1: Navigate to Automation tab
|
|
const onTab = await this.navigateToAutomationTab();
|
|
if (!onTab) return false;
|
|
|
|
// Step 2: Enter creation page
|
|
const onCreate = await this.enterCreatePage();
|
|
if (!onCreate) return false;
|
|
|
|
// Step 3: Input name
|
|
await this.inputAutomationName(config.name);
|
|
|
|
// Step 4: Select condition
|
|
const condOk = await this.selectCondition(config.condition);
|
|
if (!condOk) return false;
|
|
|
|
// Step 5: Select action
|
|
const actOk = await this.selectAction(config.action);
|
|
if (!actOk) return false;
|
|
|
|
// Step 6: Save
|
|
return await this.saveAutomation();
|
|
}
|
|
|
|
async deleteAutomation(name: string): Promise<boolean> {
|
|
const onTab = await this.navigateToAutomationTab();
|
|
if (!onTab) return false;
|
|
|
|
// Find automation by name
|
|
let targetEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
targetEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().textContains("${name}")`);
|
|
if (!targetEl) targetEl = await this.driver.findElementRaw('-android uiautomator',
|
|
`new UiSelector().descriptionContains("${name}")`);
|
|
} else {
|
|
targetEl = await this.driver.findElementRaw('predicate string', `label CONTAINS "${name}"`);
|
|
}
|
|
|
|
if (!targetEl) {
|
|
this.steps.push(`未找到自动化: ${name}`);
|
|
await this.logPageElements();
|
|
return false;
|
|
}
|
|
|
|
// Enter automation detail
|
|
await this.driver.tapElement(targetEl);
|
|
await sleep(3000);
|
|
this.steps.push(`点击自动化: ${name}`);
|
|
|
|
// Look for Delete/Remove button (may need to scroll)
|
|
let delEl: string | null = null;
|
|
if (this.isAndroid()) {
|
|
delEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
|
|
if (!delEl) delEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Remove")');
|
|
if (!delEl) delEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().description("Delete")');
|
|
}
|
|
|
|
if (!delEl) {
|
|
await this.driver.scrollDown(500);
|
|
await sleep(2000);
|
|
if (this.isAndroid()) {
|
|
delEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
|
|
if (!delEl) delEl = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Remove")');
|
|
}
|
|
}
|
|
|
|
if (!delEl) {
|
|
this.steps.push('未找到Delete按钮');
|
|
await this.logPageElements();
|
|
await this.driver.goBack();
|
|
await sleep(2000);
|
|
return false;
|
|
}
|
|
|
|
await this.driver.tapElement(delEl);
|
|
await sleep(3000);
|
|
this.steps.push('点击Delete');
|
|
|
|
// Confirm deletion dialog
|
|
if (this.isAndroid()) {
|
|
let cfm: string | null = null;
|
|
cfm = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Confirm")');
|
|
if (!cfm) cfm = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("OK")');
|
|
if (!cfm) cfm = await this.driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
|
|
if (cfm) {
|
|
await this.driver.tapElement(cfm);
|
|
await sleep(3000);
|
|
this.steps.push('确认删除');
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
getSteps(): string[] { return [...this.steps]; }
|
|
clearSteps(): void { this.steps = []; }
|
|
}
|