/** * 自动化(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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }).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 { 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 { 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 { 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 { 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 { // 先退出可能的子页(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 { 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 { 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 { 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 { 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; }