AI_UIAutomation/utils/common/automation.helper.ts

401 lines
22 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.

/**
* 自动化(Automation)通用流程 helper —— Android 实测(2026-06)。
* 自动化 = 条件(Schedules 定时)+ 动作(复用 scene 的 selectSmartDeviceAction)。
* 入口: Automations tab → Create(addBto)→ dismissGuideOverlay(首次引导)
* 条件: Add condition → Schedules → 时间轮设目标时间 → Save
* 动作: Add action → selectSmartDeviceAction(设备, 动作)
* 命名+Save → 到点触发 → 右上"..."→ Automation Logs 验证执行 → Edit Automation→Delete 删除
*
* 时间轮(1080×2280):小时列 x≈425、分钟列 x≈650,选中带中心 y≈1908,1 格=上滑 95px(慢滑,无惯性)。
*/
import { DeviceDriver } from '../../drivers/types';
import { sleep } from './element.helper';
import { dismissGuideOverlay, dismissSaveConfirmCancel } from './navigation.helper';
import { selectSmartDeviceAction } from './scene.helper';
const HOUR_X = 425;
const MIN_X = 650;
const WHEEL_TOP = 1855; // 上滑终点(中心上方一格)
const WHEEL_MID = 1950; // 上滑起点
async function tapText(driver: DeviceDriver, t: string): Promise<boolean> {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`);
if (el) { await driver.tapElement(el); return true; }
return false;
}
async function tapContains(driver: DeviceDriver, t: string): Promise<boolean> {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`);
if (el) { await driver.tapElement(el); return true; }
return false;
}
async function tapId(driver: DeviceDriver, id: string): Promise<boolean> {
const el = await driver.findElementRaw('id', `com.theswitchbot.switchbot:id/${id}`);
if (el) { await driver.tapElement(el); return true; }
return false;
}
/** 在某一列(x)上滑 n 格(n>0 增大/上滑;n<0 减小/下滑)。1 格 95px。 */
async function spinWheel(driver: DeviceDriver, x: number, n: number): Promise<void> {
const up = n > 0;
for (let i = 0; i < Math.abs(n); i++) {
if (up) await driver.swipe(x, WHEEL_MID, x, WHEEL_TOP, 0.5);
else await driver.swipe(x, WHEEL_TOP, x, WHEEL_MID, 0.5);
await sleep(250);
}
}
/**
* 设定 Schedules 时间轮到 targetH:targetM。先读 "Time HH:MM >" 行当前值作起点(新自动化默认 08:00),
* 打开时间轮 → 小时/分钟列各按差值就近方向滑动 → OK。
*/
export async function setScheduleTime(driver: DeviceDriver, targetH: number, targetM: number): Promise<void> {
const src = await driver.getSource();
const m = src.match(/text="(\d{2}):(\d{2})"/);
const startH = m ? parseInt(m[1], 10) : 8;
const startM = m ? parseInt(m[2], 10) : 0;
// 打开时间轮:点 "Time HH:MM" 行(用当前时间值文本定位)
const timeText = `${String(startH).padStart(2, '0')}:${String(startM).padStart(2, '0')}`;
if (!(await tapText(driver, timeText))) await tapContains(driver, 'Time');
await sleep(1500);
// 就近方向步数(小时 mod 24,分钟 mod 60)
const upH = ((targetH - startH) % 24 + 24) % 24;
const hSteps = upH <= 12 ? upH : -(24 - upH);
const upM = ((targetM - startM) % 60 + 60) % 60;
const mSteps = upM <= 30 ? upM : -(60 - upM);
await spinWheel(driver, HOUR_X, hSteps);
await spinWheel(driver, MIN_X, mSteps);
await sleep(500);
await tapText(driver, 'OK');
await sleep(1500);
}
export interface AutomationConfig {
name: string;
deviceKeyword: string; // 动作设备(如 'Bot 14' / 'Curtain 89')
targetHour: number; // 定时小时(由调用方按设备当前时间+偏移算出)
targetMinute: number;
action?: string; // 优先动作文本(Turns off / Presses once 等)
}
// ============ iOS 适配(实测 2026-06-23,iPhone 17.5.1,需系统 24 小时制) ============
// 入口:底部 Automations tab → Create 按钮 → (首次 coach-mark)→ Add condition → Schedules → Time 行 → 时间轮(24h 2轮)→ OK → Save
// → Add action → Smart Devices → 滚动找设备 → 动作 → 命名 → Save。时间轮按 setPickerWheelValue 整串设值。
const pad2 = (n: number) => String(n).padStart(2, '0');
/** iOS:点 name 元素(button,element click 不受键盘遮挡)。 */
async function tapNameIOS(driver: DeviceDriver, name: string): Promise<boolean> {
const el = await driver.findElementRaw('name', name).catch(() => null)
|| await driver.findElementRaw('predicate string', `name == "${name}" OR label == "${name}"`).catch(() => null);
if (el) { await driver.clickElement(el).catch(async () => { await driver.tapElement(el); }); return true; }
return false;
}
/** iOS:点 "Add condition"/"Add action" 这类行(同名有大容器+窄行两个,选窄的 width<360 点中心)。 */
async function tapNarrowRowIOS(driver: DeviceDriver, prefix: string): Promise<boolean> {
const els = await driver.findElementsRaw('predicate string', `name BEGINSWITH "${prefix}"`).catch(() => [] as string[]);
for (const el of els) {
const r = await driver.getElementRect(el).catch(() => null);
if (r && r.width > 0 && r.width < 360) { await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); return true; }
}
return false;
}
/** iOS:关首次进 Create Automation 的 coach-mark(Next/Got it 深色按钮居中、accessibility 无名字 → 按坐标点;仅有引导文字时点)。 */
async function dismissAutomationCoachIOS(driver: DeviceDriver, W: number, H: number): Promise<void> {
const MARK = ['trigger this Automation', 'Add what will', 'set dates to customize', 'will trigger', 'customize Automations'];
for (let i = 0; i < 4; i++) {
const src = await driver.getSource();
if (!MARK.some((m) => src.includes(m))) break;
await driver.tap(Math.round(W * 0.5), Math.round(H * 0.58)); // 深色 Next/Got it 按钮(实测 844 高时 ≈y489)
await sleep(1200);
}
}
/** iOS:设 Schedules 时间轮(系统 24h → 2 轮:时 00-23 / 分 00-59)。整串设值,设后轮子会 stale 需重取。 */
async function setScheduleTimeIOS(driver: DeviceDriver, targetH: number, targetM: number): Promise<void> {
const getWheels = () => driver.findElementsRaw('class name', 'XCUIElementTypePickerWheel').catch(() => [] as string[]);
const setVal = (id: string, v: string) => (driver as unknown as { setPickerWheelValue(id: string, v: string): Promise<void> }).setPickerWheelValue(id, v).catch(() => {});
let w = await getWheels();
if (w.length >= 2) { await setVal(w[0], pad2(targetH)); await sleep(1000); }
w = await getWheels(); // 设完 stale,重取
if (w.length >= 2) { await setVal(w[1], pad2(targetM)); await sleep(1000); }
}
/** iOS:动作源页 → Smart Devices → 滚动找设备(首页滑动法+可视区)→ 选动作(actionPref / Turns off|on / Presses once)。 */
async function selectSmartDeviceActionIOS(driver: DeviceDriver, deviceKeyword: string, actionPref?: string): Promise<boolean> {
const sd = await driver.findElementRaw('predicate string', 'name BEGINSWITH "Smart Devices"').catch(() => null);
if (!sd) { console.log('FAIL: no Smart Devices(iOS)'); return false; }
{ const r = await driver.getElementRect(sd); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); }
await sleep(2000);
const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 }));
let picked = false, 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}(iOS 动作设备)`); return false; }
await sleep(1500);
for (const a of [actionPref, 'Turns off', 'Turns on', 'Presses once'].filter(Boolean) as string[]) {
const el = await driver.findElementRaw('predicate string', `name == "${a}" OR label == "${a}"`).catch(() => null);
if (el) { const r = await driver.getElementRect(el); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); await sleep(1500); return true; }
}
console.log('FAIL: no action option(iOS)'); return false;
}
/** iOS:进 Automations tab 列表(底部 tab)。 */
async function gotoAutomationsTabIOS(driver: DeviceDriver, W: number, H: number): Promise<void> {
const tab = await driver.findElementRaw('predicate string', 'name == "Automations"').catch(() => null);
if (tab) { const r = await driver.getElementRect(tab); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); }
else { await driver.tap(Math.round(W * 0.5), H - 40); } // 兜底:底部中间 tab
await sleep(1500);
}
async function createAutomationIOS(driver: DeviceDriver, cfg: AutomationConfig): Promise<boolean> {
const { width: W, height: H } = await driver.getWindowSize().catch(() => ({ width: 390, height: 844 }));
await driver.restartApp?.().catch(() => {}); // 复位到干净首页(避免残留态)
await sleep(2500);
await driver.dismissPopupIfPresent().catch(() => {});
// 1) Automations tab → Create
await gotoAutomationsTabIOS(driver, W, H);
// 轮询等 Create 按钮渲染(页面切换+列表渲染可能 >1.5s);找到 element click,兜底坐标点。
let createOk = false;
for (let i = 0; i < 8 && !createOk; i++) {
const createBtn = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Create"').catch(() => null);
if (createBtn) {
const r = await driver.getElementRect(createBtn).catch(() => null);
if (r && r.width > 0) { await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); createOk = true; break; }
}
if (i === 3) await gotoAutomationsTabIOS(driver, W, H); // 中途没出来,重点一次 tab
await sleep(800);
}
if (!createOk) { console.log('FAIL: no Create(iOS)'); return false; }
await sleep(2000);
await dismissAutomationCoachIOS(driver, W, H); // 首次 coach-mark
// 2) 条件:Add condition → Schedules → Time 行 → 时间轮 → OK → Save
if (!(await tapNarrowRowIOS(driver, 'Add condition'))) { console.log('FAIL: no Add condition(iOS)'); return false; }
await sleep(1500);
const sched = await driver.findElementRaw('predicate string', 'name BEGINSWITH "Schedules"').catch(() => null);
if (!sched) { console.log('FAIL: no Schedules(iOS)'); return false; }
{ const r = await driver.getElementRect(sched); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); }
await sleep(2000);
const timeRow = await driver.findElementRaw('predicate string', 'name BEGINSWITH "Time "').catch(() => null);
if (timeRow) { const r = await driver.getElementRect(timeRow); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); await sleep(2000); }
await setScheduleTimeIOS(driver, cfg.targetHour, cfg.targetMinute);
await tapNameIOS(driver, 'OK'); await sleep(1500); // 关时间模态
await tapNameIOS(driver, 'Save'); await sleep(2000); // Schedules 页 Save
// 3) 动作:Add action → Smart Devices → 设备 → 动作
if (!(await tapNarrowRowIOS(driver, 'Add action'))) { console.log('FAIL: no Add action(iOS)'); return false; }
await sleep(2000);
if (!(await selectSmartDeviceActionIOS(driver, cfg.deviceKeyword, cfg.action))) return false;
await sleep(1500);
// 4) 命名 + Save(键盘用 Return 收;Save 用 element click 不受键盘遮挡)
const tf = await driver.findElementRaw('class name', 'XCUIElementTypeTextField').catch(() => null);
if (tf) {
await driver.tapElement(tf); await sleep(400);
await driver.typeText(tf, cfg.name); await sleep(400);
for (const k of ['Return', 'Done', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
await sleep(800);
}
await tapNameIOS(driver, 'Save'); await sleep(2500);
await dismissSaveConfirmCancel(driver).catch(() => {}); // 若弹"立即执行一次?"→ Cancel
await sleep(1500);
// 5) 校验:回 Automations 列表查名
let ok = false;
for (let i = 0; i < 6 && !ok; i++) {
await gotoAutomationsTabIOS(driver, W, H);
ok = (await driver.getSource()).includes(cfg.name);
if (!ok) await sleep(1500);
}
console.log(`创建自动化(iOS) ${cfg.name}: ${ok}`);
return ok;
}
/** 创建定时自动化:条件=Schedules(targetH:targetM)+ 动作=设备动作。返回是否创建成功(出现在 My Automations)。 */
export async function createAutomation(driver: DeviceDriver, cfg: AutomationConfig): Promise<boolean> {
if (driver.platform === 'ios') return createAutomationIOS(driver, cfg);
await driver.dismissPopupIfPresent().catch(() => {}); // 清启动 Tips/SmartThings 等弹框
const auto = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")');
if (auto) { await driver.tapElement(auto); await sleep(2500); }
await driver.dismissPopupIfPresent().catch(() => {});
if (!(await tapId(driver, 'addBto'))) { console.log('FAIL: no Create(addBto)'); return false; }
await sleep(2500);
await dismissGuideOverlay(driver); // 首次引导浮层
// 条件:Add condition → Schedules → 时间轮 → Save。条件页可能稍慢/有引导,重试等待。
let onCond = false;
for (let i = 0; i < 4 && !onCond; i++) {
await dismissGuideOverlay(driver);
await tapContains(driver, 'Add condition');
await sleep(2000);
for (let j = 0; j < 6; j++) {
if ((await driver.getSource()).includes('Schedules')) { onCond = true; break; }
await sleep(800);
}
}
if (!onCond) { console.log('FAIL: 条件类型页无 Schedules'); return false; }
await tapText(driver, 'Schedules');
await sleep(2000);
await setScheduleTime(driver, cfg.targetHour, cfg.targetMinute);
await tapText(driver, 'Save'); // Schedules 页 Save
await sleep(2500);
// 动作:Add action → Smart Devices → 设备 → 动作
await tapContains(driver, 'Add action');
await sleep(2000);
if (!(await selectSmartDeviceAction(driver, cfg.deviceKeyword, cfg.action))) return false;
// 命名 + Save
const ed = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Enter Automation name")');
if (ed) { await driver.tapElement(ed); await sleep(300); await driver.typeText(ed, cfg.name); await sleep(300); try { await driver.goBack(); } catch { /**/ } }
await tapText(driver, 'Save');
await sleep(2500);
await dismissSaveConfirmCancel(driver); // Save 后弹"立即执行一次?"确认框 → 点 Cancel(自动化已保存)
await driver.dismissPopupIfPresent();
// 校验:回 My Automations 列表重试查名(Save 后可能先 Loading/停详情页,不能立即判)
let ok = false;
for (let i = 0; i < 6 && !ok; i++) {
const a = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")');
if (a) { await driver.tapElement(a); await sleep(2000); }
ok = (await driver.getSource()).includes(cfg.name);
if (!ok) await sleep(2000);
}
console.log(`创建自动化 ${cfg.name}: ${ok}`);
return ok;
}
/** 打开某自动化(确保在 My Automations 列表后再点名字,避免在日志页点到日志条目)。 */
async function openAutomation(driver: DeviceDriver, name: string): Promise<boolean> {
// 先退出可能的子页(Automation Logs / 详情),回到能看到底部导航的页
for (let i = 0; i < 3; i++) {
const s = await driver.getSource();
if (s.includes('Automation Logs') || s.includes('Scene Logs') || s.includes('Edit Automation View')) {
await driver.goBack(); await sleep(1000);
} else break;
}
const auto = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")');
if (auto) { await driver.tapElement(auto); await sleep(2000); }
// 确认在 My Automations 列表页(非日志页)再点名字
if (!(await driver.getSource()).includes('My Automations')) {
const auto2 = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")');
if (auto2) { await driver.tapElement(auto2); await sleep(2000); }
}
return tapContains(driver, name);
}
/**
* 验证自动化已执行:**自动化管理页(My Automations)右上角图标 → 菜单 "Automation Logs"** → 看是否有执行记录。
* 注意:日志入口在管理页右上角(全局日志),不是自动化详情页。
*/
export async function verifyAutomationExecuted(driver: DeviceDriver, name: string): Promise<boolean> {
if (driver.platform === 'ios') return verifyAutomationExecutedIOS(driver, name);
const auto = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")');
if (auto) { await driver.tapElement(auto); await sleep(2500); }
// 右上角图标 → 菜单
const ic = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/top_bar_right_icon');
if (ic) await driver.tapElement(ic); else await driver.tap(1000, 180);
await sleep(1500);
if (!(await tapText(driver, 'Automation Logs'))) { console.log('未进入 Automation Logs'); return false; }
await sleep(2500);
const src = await driver.getSource();
// 有该自动化名的记录,或日志非空(非 "No logs / 暂无")
const hasLog = src.includes(name) || (!src.includes('No logs') && !src.includes('暂无') && !src.includes('No Logs'));
console.log(`Automation Logs 有执行记录(${name}): ${hasLog}`);
return hasLog;
}
/** 删除自动化:打开 → Edit Automation 的 Delete 按钮 → 确认框 Delete。返回是否已删除。 */
export async function deleteAutomation(driver: DeviceDriver, name: string): Promise<boolean> {
if (driver.platform === 'ios') return deleteAutomationIOS(driver, name);
if (!(await openAutomation(driver, name))) return true;
await sleep(2000);
// Edit Automation 页底部/右上的 Delete
if (!(await tapText(driver, 'Delete'))) {
const del = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Delete")');
if (del) await driver.tapElement(del);
}
// 等确认框("Cancel | Delete")出现,点对话框里的 Delete(最后一个)
for (let i = 0; i < 6; i++) {
const dels = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().text("Delete")');
const src = await driver.getSource();
if (src.includes('Cancel') && dels.length) { await driver.tapElement(dels[dels.length - 1]); break; }
await sleep(800);
}
await sleep(3000);
await driver.dismissPopupIfPresent();
// gone 校验:回 My Automations 列表查名
const a = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")');
if (a) { await driver.tapElement(a); await sleep(2000); }
const gone = !(await driver.getSource()).includes(name);
console.log(`删除自动化 ${name}: ${gone ? '已删' : '仍在'}`);
return gone;
}
// ============ iOS verify / delete(2026-06-23,待实跑细化:Automation Logs 入口 + 详情删除) ============
/** iOS 验证执行:Automations tab → 右上 More → Automation Logs → 看是否有该自动化执行记录。 */
async function verifyAutomationExecutedIOS(driver: DeviceDriver, name: string): Promise<boolean> {
const { width: W, height: H } = await driver.getWindowSize().catch(() => ({ width: 390, height: 844 }));
await gotoAutomationsTabIOS(driver, W, H);
const more = await driver.findElementRaw('name', 'More').catch(() => null);
if (more) { await driver.tapElement(more); await sleep(1500); }
for (const t of ['Automation Logs', 'Logs', '自动化日志', '日志']) {
const el = await driver.findElementRaw('predicate string', `name CONTAINS "${t}" OR label CONTAINS "${t}"`).catch(() => null);
if (el) { await driver.tapElement(el); break; }
}
await sleep(2500);
const src = await driver.getSource();
const has = src.includes(name) || (!src.includes('No logs') && !src.includes('No Logs') && !src.includes('暂无') && src.includes('Bot'));
console.log(`Automation Logs(iOS) 有执行记录(${name}): ${has}`);
return has;
}
/** iOS 删除:复位 → Automations tab → 点自动化名进 Edit 详情 → 右上 Delete → 确认框 Delete(宽按钮,点中心)。 */
async function deleteAutomationIOS(driver: DeviceDriver, name: string): Promise<boolean> {
const { width: W, height: H } = await driver.getWindowSize().catch(() => ({ width: 390, height: 844 }));
// verify 后常停在 Automation Logs 子页 → 先复位到干净态再进列表(否则从子页起步删除会失败)。
await driver.restartApp?.().catch(() => {});
await sleep(2500);
await driver.dismissPopupIfPresent().catch(() => {});
await gotoAutomationsTabIOS(driver, W, H);
const el = await driver.findElementRaw('predicate string', `name CONTAINS "${name}" OR label CONTAINS "${name}"`).catch(() => null);
if (!el) { console.log(`删除自动化(iOS) ${name}: 不在列表(视为已删)`); return true; }
{ const r = await driver.getElementRect(el); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); }
await sleep(2000);
// Edit Automation 页右上 "Delete"(窄,@~326,60)→ 弹确认框
const topDel = await driver.findElementRaw('predicate string', 'name == "Delete" OR label == "Delete"').catch(() => null);
if (topDel) { await driver.tapElement(topDel); await sleep(1500); }
// 确认框里有 Cancel + Delete(宽按钮 width>80);点宽 Delete 的 rect 中心(tapElement)。
for (let i = 0; i < 4; i++) {
const dels = await driver.findElementsRaw('predicate string', 'name == "Delete" OR label == "Delete"').catch(() => [] as string[]);
let tapped = false;
for (const d of dels) {
const r = await driver.getElementRect(d).catch(() => null);
if (r && r.width > 80) { await driver.tapElement(d); tapped = true; break; } // 确认按钮(非右上窄 Delete)
}
if (tapped) break;
await sleep(700);
}
await sleep(2000);
await gotoAutomationsTabIOS(driver, W, H);
const gone = !(await driver.getSource()).includes(name);
console.log(`删除自动化(iOS) ${name}: ${gone ? '已删' : '仍在'}`);
return gone;
}