import { DeviceDriver } from '../../drivers/types'; import { sleep, logVisibleDeviceCards } from './element.helper'; import { ensureHomeTab, scrollHomeToTopIOS } from './navigation.helper'; export type HomeCardMatchMode = 'contains' | 'exact' | 'regex'; export interface FindHomeCardOptions { matchMode?: HomeCardMatchMode; ensureHome?: boolean; dismissPopups?: boolean; resetPosition?: 'top' | 'currentThenTop' | 'none'; logOnFail?: boolean; maxScrolls?: number; } function escapeSelectorText(s: string): string { return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } async function countHomeCards(driver: DeviceDriver): Promise { const src = await driver.getSource().catch(() => ''); return driver.platform === 'ios' ? (src.match(/XCUIElementTypeCell[^>]*?name="[^"]+"/g) || []).length : (src.match(/resource-id="[^"]*\/(nameText|nameTextMeter)"/g) || []).length; } async function resetHomeListToTop(driver: DeviceDriver): Promise { if (driver.platform === 'ios') { await scrollHomeToTopIOS(driver).catch(() => {}); return; } // Android 点 Home tab 会把首页列表恢复到顶部;用 ensureHomeTab 保持与现有导航逻辑一致。 await ensureHomeTab(driver).catch(() => {}); } function buildAndroidSelector(keyword: string, matchMode: HomeCardMatchMode): string { const k = escapeSelectorText(keyword); if (matchMode === 'exact') return `new UiSelector().text("${k}")`; if (matchMode === 'regex') return `new UiSelector().textMatches("${k}")`; return `new UiSelector().textContains("${k}")`; } function buildIOSPredicate(keyword: string, matchMode: HomeCardMatchMode): string { const k = escapeSelectorText(keyword); if (matchMode === 'exact') return `(name == "${k}" OR label == "${k}")`; if (matchMode === 'regex') return `(name MATCHES "${k}" OR label MATCHES "${k}")`; return `(name CONTAINS "${k}" OR label CONTAINS "${k}")`; } /** 卡片矩形是否完整落在可视带 [6%,92%] 内(happy-path 命中判据)。 */ function rectInBand(rect: { y: number; height: number; width: number } | null, H: number): boolean { return !!rect && rect.width > 0 && rect.y > H * 0.06 && rect.y + rect.height < H * 0.92; } /** * 兜底:用平台原生「滚到可见」把目标卡摆进视口,消除固定步长+可视带残留的三类漏检 * (贴底/贴顶才露头、卡比可视带还高、两帧之间被滚过去)。 * - Android: `UiScrollable.scrollIntoView(textContains/…)` —— RecyclerView 原生双向滚到元素完全可见。 * - iOS: 扫描期见过该 Cell(lastSeen)才有意义 —— 用 WDA `element/scroll {toVisible:true}` 把它滚进视口后按谓词重取; * 从没见过 → 视为不在数据源(设备缺席),返回 null(交回环境判断,不硬找)。 * 返回的元素已在视口内;即便是超高卡出了严格带,也返回它,由调用方 `tapElement`(元素点击)兜住。 */ async function scrollCardIntoView( driver: DeviceDriver, keyword: string, matchMode: HomeCardMatchMode, lastSeen: string | null, ): Promise { if (driver.platform === 'android') { const k = escapeSelectorText(keyword); const child = matchMode === 'exact' ? `text("${k}")` : matchMode === 'regex' ? `textMatches("${k}")` : `textContains("${k}")`; return driver.findElementRaw('-android uiautomator', `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().${child})`).catch(() => null); } // iOS if (!lastSeen || !driver.scrollElement) return null; await driver.scrollElement(lastSeen, { toVisible: true }).catch(() => {}); // 滚动后 element id 可能失效(Cell 复用),按谓词重取;取不到再退回 lastSeen。 const el = await driver.findElementRaw('predicate string', buildIOSPredicate(keyword, matchMode)).catch(() => null); return el || lastSeen; } /** * 在首页滚动找设备卡,返回可视区内的元素 id;找不到返回 null。 * 默认会回首页、收弹框、从顶部开始扫,避免热启动残留滚动位置导致只往下找漏卡。 */ export async function findHomeCard(driver: DeviceDriver, keyword: string, options: FindHomeCardOptions = {}): Promise { const matchMode = options.matchMode || 'contains'; const ensureHome = options.ensureHome !== false; const dismissPopups = options.dismissPopups !== false; const resetPosition = options.resetPosition || 'top'; const maxScrolls = options.maxScrolls ?? 26; const logOnFail = options.logOnFail !== false; const { width: W, height: H } = await driver.getWindowSize().catch(() => ({ width: driver.platform === 'ios' ? 390 : 1080, height: driver.platform === 'ios' ? 852 : 2280 })); const find = () => driver.platform === 'ios' ? driver.findElementRaw('predicate string', buildIOSPredicate(keyword, matchMode)).catch(() => null) : driver.findElementRaw('-android uiautomator', buildAndroidSelector(keyword, matchMode)).catch(() => null); if (dismissPopups) await driver.dismissPopupIfPresent().catch(() => {}); if (ensureHome) await ensureHomeTab(driver).catch(() => {}); for (let w = 0; w < 10; w++) { if (await countHomeCards(driver) > 0) break; if (dismissPopups) await driver.dismissPopupIfPresent().catch(() => {}); if (ensureHome) await ensureHomeTab(driver).catch(() => {}); await sleep(700); } if (resetPosition === 'top') await resetHomeListToTop(driver); let last = ''; let same = 0; let toppedOnce = resetPosition === 'top' || resetPosition === 'none'; let lastSeen: string | null = null; // 见过即记:哪怕出带,也别在结束时丢成 null。 for (let r = 0; r < maxScrolls; r++) { const el = await find(); if (el) { lastSeen = el; const rect = await driver.getElementRect(el).catch(() => null); if (rectInBand(rect, H)) return el; // happy path:完整落在可视带内直接返回。 } if (!toppedOnce && resetPosition === 'currentThenTop') { toppedOnce = true; await resetHomeListToTop(driver); continue; } if (r >= 3) { const src = await driver.getSource().catch(() => ''); if (src && src === last) { same++; if (same >= 2) break; } else same = 0; last = src; } if (driver.platform === 'ios') { // iOS 粗滚:300px 对称 flick(0.1s)。原 scrollDown(≈506px 大甩)一步跨太多、目标常被惯性甩过采样窗口 → // 漏检 → 触发重试回顶(表现为"下滑没找到又上滑");标定 300px 相邻两屏 tree 重叠、不跳卡。 const cy = Math.round(H * 0.5); const amp = Math.round(H * 0.177); // ±0.177H ⇒ 幅度≈300px @844 await driver.swipe(Math.round(W / 2), cy + amp, Math.round(W / 2), cy - amp, 0.1); } else { await driver.swipe(Math.round(W * 0.5), Math.round(H * 0.7), Math.round(W * 0.5), Math.round(H * 0.5), 0.5); } await sleep(1000); } // 兜底:固定步长+可视带没命中,用原生 scroll-to-visible 再救一次(贴边/超高卡/滚过头)。 const recovered = await scrollCardIntoView(driver, keyword, matchMode, lastSeen); if (recovered) return recovered; if (logOnFail) await logVisibleDeviceCards(driver, `找不到${keyword}卡片`); return null; } /** * 等卡片位置稳定后再返回其 rect。 * findHomeCard 命中卡片时,首页列表常常还在惯性滚动/回顶动画中(RN 首页尤其明显)——此刻读到的 * rect/坐标是【瞬时值】,等真正 tap 时卡片已经移动几像素,坐标打偏 → 点到卡身(跳功能页)或触发 * 快捷浮层,而不是右上角开关钮。做法:轮询 rect,连续两次完全相同即视为稳定;最多等 maxWaitMs。 * 拿不到 rect 时返回最近一次结果(不抛错,让调用方走兜底)。 */ async function settleCardRect( driver: DeviceDriver, cardId: string, maxWaitMs = 1500, ): Promise<{ x: number; y: number; width: number; height: number } | null> { let prev = await driver.getElementRect(cardId).catch(() => null); const start = Date.now(); while (Date.now() - start < maxWaitMs) { await sleep(180); const cur = await driver.getElementRect(cardId).catch(() => null); if (!cur) return prev; if (prev && cur.x === prev.x && cur.y === prev.y && cur.width === prev.width && cur.height === prev.height) { return cur; } prev = cur; } return prev; } export async function tapHomeCard(driver: DeviceDriver, keyword: string, options: FindHomeCardOptions = {}): Promise { const card = await findHomeCard(driver, keyword, options); if (!card) throw new Error(`找不到${keyword}卡片`); const rect = await settleCardRect(driver, card); await driver.tapElement(card).catch(async () => { const r = rect || await driver.getElementRect(card); await driver.tap(r.x + r.width / 2, r.y + r.height / 2); }); await sleep(800); return card; } export async function tapHomeCardPowerArea(driver: DeviceDriver, keyword: string, options: FindHomeCardOptions = {}): Promise { const card = await findHomeCard(driver, keyword, options); if (!card) throw new Error(`找不到${keyword}卡片`); const rect = (await settleCardRect(driver, card)) || await driver.getElementRect(card); await driver.tap(rect.x + rect.width - 30, rect.y + rect.height / 2); await sleep(800); return card; } /** Android 首页卡片相关 resource-id(app under test 固定包名)。 */ const ANDROID_CARD_PKG = 'com.theswitchbot.switchbot:id'; interface NodeBox { x1: number; y1: number; x2: number; y2: number; cx: number; cy: number } /** 从 page source 里解析出所有带某 resource-id 的节点 bounds(属性顺序无关)。 */ function parseBoundsByResourceId(src: string, resIdSuffix: string): NodeBox[] { const boxes: NodeBox[] = []; const idRe = new RegExp(`resource-id="[^"]*/${resIdSuffix}"`); for (const tag of src.split('<')) { if (!idRe.test(tag)) continue; const b = tag.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); if (!b) continue; const x1 = +b[1], y1 = +b[2], x2 = +b[3], y2 = +b[4]; boxes.push({ x1, y1, x2, y2, cx: (x1 + x2) / 2, cy: (y1 + y2) / 2 }); } return boxes; } interface TextNodeBox extends NodeBox { t: string } /** 解析带某 resource-id 的节点的 text + bounds(属性顺序无关)。 */ function parseTextNodesByResourceId(src: string, resIdSuffix: string): TextNodeBox[] { const out: TextNodeBox[] = []; const idRe = new RegExp(`resource-id="[^"]*/${resIdSuffix}"`); for (const tag of src.split('<')) { if (!idRe.test(tag)) continue; const b = tag.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); if (!b) continue; const x1 = +b[1], y1 = +b[2], x2 = +b[3], y2 = +b[4]; out.push({ t: (tag.match(/text="([^"]*)"/) || [])[1] || '', x1, y1, x2, y2, cx: (x1 + x2) / 2, cy: (y1 + y2) / 2 }); } return out; } /** * Android meter 卡读数:temp/湿度/CO2 在 nameTextMeter 上方、同列的 tvTemp/tvHumi/tvCo2 节点里。 * 按"同列(cx 接近)+ 卡名上方 + 最近"关联到本卡,组成 "24.5°C | 68% | 634 ppm"。无读数返回 ''。 */ function parseAndroidMeterReadings(src: string, name: NodeBox): string { const pick = (id: string): string | null => { const n = parseTextNodesByResourceId(src, id) .filter((r) => r.t && Math.abs(r.cx - name.cx) < 250 && r.cy < name.cy && name.cy - r.cy < 300) .sort((a, b) => (name.cy - a.cy) - (name.cy - b.cy))[0]; return n ? n.t.trim() : null; }; return [pick('tvTemp'), pick('tvHumi'), pick('tvCo2')].filter(Boolean).join(' | '); } /** * 点击卡片右上角的开关钮。适用于开关钮在卡片右上角的设备(如 Bot / Plug / 灯 / 风扇)。 * 两端卡片布局不同,几何分平台: * - iOS: findHomeCard 命中整张 Cell,开关钮在 Cell 右上角内缩 inset(默认 24)处。 * - Android: 首页是 2 列网格。卡片右上角是独立的可点元素 `controlBtn`(ImageView),卡名 * `nameText` 在卡片【左下方】、宽度=文字宽(≠卡宽),故不能用 name.x+name.w 推算开关钮 x。 * 实测结构:leftCard/frameContent(卡容器)含 icon(左上) / controlBtn(右上) / nameText(左下) / * desText(状态)。做法:先用 findHomeCard 命中卡名 → 定位包含卡名的卡容器 leftCard → * 取容器内的 controlBtn 直接点其中心(列/分辨率/名字长度无关)。找不到 controlBtn 时兜底点 * 卡名右上方(x+w-48, y-95,按 1080×2400/density480 标定)。 */ export async function tapHomeCardTopRight( driver: DeviceDriver, keyword: string, options: FindHomeCardOptions & { inset?: number } = {}, ): Promise { const cardId = await findHomeCard(driver, keyword, options); if (!cardId) throw new Error(`找不到${keyword}卡片`); // 等列表停稳再取坐标/source,否则惯性滚动中点偏 → 跳功能页/弹快捷浮层。 const rect = (await settleCardRect(driver, cardId)) || await driver.getElementRect(cardId); const inset = options.inset ?? 24; if (driver.platform === 'ios') { // iOS:findHomeCard 命中整张 Cell,开关钮在 Cell 右上角内缩 inset(≈24)处。 await driver.tap(rect.x + rect.width - inset, rect.y + inset); await sleep(800); return cardId; } // Android:定位卡容器内的 controlBtn 直接点(source 在 settle 之后读,bounds 已稳定)。 const nameCx = rect.x + rect.width / 2, nameCy = rect.y + rect.height / 2; const src = await driver.getSource().catch(() => ''); const containers = [ ...parseBoundsByResourceId(src, 'leftCard'), ...parseBoundsByResourceId(src, 'frameContent'), ]; // 包含卡名中心、且面积最小的卡容器(避免命中更外层的分组容器)。 const card = containers .filter(c => nameCx >= c.x1 && nameCx <= c.x2 && nameCy >= c.y1 && nameCy <= c.y2) .sort((a, b) => (a.x2 - a.x1) * (a.y2 - a.y1) - (b.x2 - b.x1) * (b.y2 - b.y1))[0]; if (card) { const btn = parseBoundsByResourceId(src, 'controlBtn') .find(b => b.cx >= card.x1 && b.cx <= card.x2 && b.cy >= card.y1 && b.cy <= card.y2); if (btn) { await driver.tap(Math.round(btn.cx), Math.round(btn.cy)); await sleep(800); return cardId; } } // 兜底:卡名右上方(controlBtn 未解析到时,如结构变化)。 await driver.tap(rect.x + rect.width - 48, rect.y - 95); await sleep(800); return cardId; } /** meter 读数片段:如 "24.5°C" / "68%" / "634 ppm"(温度/湿度/CO2)。 */ const METER_READING_RE = /(?:-?\d+(?:\.\d+)?\s*(?:℃|°C?|℉))|(?:^\d+\s*%$)|(?:\d+\s*ppm)/i; /** * 解析首页卡片副标题状态。 * Android: 普通卡状态在 nameText 后面的 desText;meter 卡(nameTextMeter)读数在上方 tvTemp/tvHumi/tvCo2。 * iOS(当前 App): 卡名与状态是同 Cell 下相邻 StaticText。 * - 普通卡:状态在卡名【正下方紧邻】(dy≈+24);故只取卡名下方【近距】(<60pt)同列 StaticText, * 避免卡自身无状态时误抓到下一行卡的名字(dy≈+114)。 * - meter 卡:读数(℃/%/ppm)在卡名右上方(dx≈+24 缩进、dy≈-24~-68 上方),按 y 升序组成读数串。 * - 都取不到再兜底旧结构(状态拼在 cell name 同一属性里,如 "Plug EUOff")。 */ export function parseHomeCardStatus(src: string, deviceName: string, isIOS: boolean): string { const esc = deviceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (isIOS) { const texts: { t: string; x: number; y: number }[] = []; const re = /]*?)\/?>/g; let mm: RegExpExecArray | null; while ((mm = re.exec(src))) { const a = mm[1]; const t = (a.match(/\bname="([^"]*)"/) || [])[1] ?? ''; const xm = a.match(/\bx="(-?\d+)"/), ym = a.match(/\by="(-?\d+)"/); if (!xm || !ym) continue; texts.push({ t, x: +xm[1], y: +ym[1] }); } const nameNode = texts.find(n => n.t === deviceName) || texts.find(n => n.t.includes(deviceName)); if (nameNode) { // meter 读数:卡名右上方(缩进 dx∈[10,60]、上方 dy∈[-100,10])且形如 ℃/%/ppm 的 StaticText,按 y 升序=温/湿/CO2。 const readings = texts .filter(n => n !== nameNode && n.t && n.x - nameNode.x >= 10 && n.x - nameNode.x <= 60 && n.y - nameNode.y >= -100 && n.y - nameNode.y <= 10 && METER_READING_RE.test(n.t.trim())) .sort((a, b) => a.y - b.y) .map(n => n.t.trim()); if (readings.length) return readings.join(' | '); // 普通卡状态:卡名【正下方紧邻】(0 n !== nameNode && n.t && n.y - nameNode.y > 0 && n.y - nameNode.y < 60 && Math.abs(n.x - nameNode.x) < 140) .sort((a, b) => (a.y - nameNode.y) - (b.y - nameNode.y))[0]; if (state) return state.t.trim(); } // 兜底:旧结构(状态拼在 cell name 同一属性里)。 const m = src.match(new RegExp(`"${esc}\\s*([^"]+)"`)); return m ? m[1].trim() : 'unknown'; } // Android:先判 meter 卡(nameTextMeter)→ 读上方 tvTemp/tvHumi/tvCo2;否则读 nameText 后的 desText。 const meterName = parseTextNodesByResourceId(src, 'nameTextMeter').find(n => n.t === deviceName) || parseTextNodesByResourceId(src, 'nameTextMeter').find(n => n.t.includes(deviceName)); if (meterName) { const readings = parseAndroidMeterReadings(src, meterName); if (readings) return readings; } // 卡名可能带尾缀(如首页真名 "Color Bulb 787E",而传入的是干净关键词 "Color Bulb")→ 用【包含】定位, // 对齐 findHomeCard/iOS 的 contains 语义(旧的精确 text="关键词" 匹配不上带尾缀名 → 一律读成 unknown)。 const nameHit = new RegExp(`text="[^"]*${esc}[^"]*"`).exec(src); if (!nameHit) return 'unknown'; const m = src.slice(nameHit.index, nameHit.index + 1500).match(/text="([^"]*)"[^>]*id\/desText"/); return m ? m[1] : 'unknown'; } /** * 卡片贴屏底时,其下方的状态副标题(Android desText 等)会被切出可视区 → source 里没有 → 读态得 unknown。 * 命中卡后若卡底超过 H*0.80,小幅上滚(内容上移)把整卡 + 下方副标题带进可视区再读。 * iOS 卡名自带状态(拼接在 name 里),一般不受影响;同样上移无害,还能保证整卡可见。 */ async function ensureCardFullyVisible(driver: DeviceDriver, cardId: string): Promise { const { height: H } = await driver.getWindowSize().catch(() => ({ height: driver.platform === 'ios' ? 852 : 2400 })); const rect = await driver.getElementRect(cardId).catch(() => null); if (!rect || rect.width <= 0 || rect.y + rect.height < H * 0.80) return; await driver.scrollDown(Math.round(H * 0.22)); // 内容上移,让卡片下方的状态行进入可视区 await sleep(600); } export async function getHomeCardStatus(driver: DeviceDriver, deviceName: string, options: FindHomeCardOptions = {}): Promise { const card = await findHomeCard(driver, deviceName, options); if (card) await ensureCardFullyVisible(driver, card); // 卡靠底时上滑露出下方状态行,避免读到 unknown const src = await driver.getSource().catch(() => ''); return parseHomeCardStatus(src, deviceName, driver.platform === 'ios'); } /** 首页卡片解析出的结构化设备状态。各字段互相独立,拿不到的字段给默认值(不抛错)。 */ export interface HomeDeviceStatus { /** 首页是否找到该设备卡。false 时其余字段为默认值。 */ found: boolean; /** 卡片副标题原始文字(如 "On | 50% | White"、"Off"、"Offline"、"Fully open")。 */ raw: string; /** 是否在线。副标题含 Offline/离线/No Response/Disconnected 时为 false。 */ online: boolean; /** 开关态。离线或无法判定时为 'unknown'。 */ power: 'on' | 'off' | 'unknown'; /** 百分比数值(亮度/电量等,取副标题里首个 NN%);meter 卡的 % 归到 humidity,此处为 null。 */ level: number | null; /** 温度数值(meter/温湿度卡,单位 ℃/°C);无则 null。 */ temperature: number | null; /** 湿度百分比(meter/温湿度卡);无则 null。 */ humidity: number | null; /** CO2 浓度(ppm,如 Meter Pro CO2);无则 null。 */ co2: number | null; /** 副标题按 "|" 切分并去空白后的各状态段。 */ tokens: string[]; } /** * 把首页卡片副标题原始文字解析成结构化状态。纯函数,便于单测/复用。 * 副标题常见形态:"Off" / "Offline" / "On | 50% | White" / "Fully open" / "24.5°C | 68%" / "634 ppm" 等。 */ export function parseDeviceStatus(raw: string): HomeDeviceStatus { const text = (raw || '').trim(); const tokens = text.split('|').map((t) => t.trim()).filter(Boolean); const online = !/off\s*-?\s*line|offline|离线|no\s*response|disconnected|未连接/i.test(text); let power: HomeDeviceStatus['power'] = 'unknown'; if (online) { const head = tokens[0] || ''; // 先判 Off(避免 "Offline" 已在上面归为离线;此处 head 为纯 "Off" 才算关)。 if (/^off\b/i.test(head)) power = 'off'; else if (/^on\b/i.test(head) || /\bon\b/i.test(text)) power = 'on'; } // meter 读数:温度(℃/°C)、CO2(ppm)。含温度/CO2 视为温湿度类卡,此时 % 归 humidity 而非 level。 const tempM = text.match(/(-?\d+(?:\.\d+)?)\s*(?:℃|°C?|℉)/i); const temperature = tempM ? Number(tempM[1]) : null; const co2M = text.match(/(\d+)\s*ppm/i); const co2 = co2M ? Number(co2M[1]) : null; const isEnv = temperature !== null || co2 !== null; const pct = text.match(/(\d+)\s*%/); const pctVal = pct ? Number(pct[1]) : null; const humidity = isEnv ? pctVal : null; const level = isEnv ? null : pctVal; return { found: text !== '' && text !== 'unknown', raw: text, online, power, level, temperature, humidity, co2, tokens }; } /** * 首页获取设备状态的通用方法:滚动定位卡片 → 读副标题 → 解析成结构化状态。 * 适用于所有在首页卡片副标题镜像设备态的设备(灯/插座/Bot/加湿器/窗帘等)。 * 需要读浮层实时态(如拖动亮度滑条时)的场景不走此方法,单独在用例里读浮层。 */ export async function getDeviceStatus(driver: DeviceDriver, deviceName: string, options: FindHomeCardOptions = {}): Promise { const card = await findHomeCard(driver, deviceName, options); if (!card) return { found: false, raw: '', online: false, power: 'unknown', level: null, temperature: null, humidity: null, co2: null, tokens: [] }; await ensureCardFullyVisible(driver, card); // 卡靠底时上滑露出下方状态行,避免读到 unknown const src = await driver.getSource().catch(() => ''); const raw = parseHomeCardStatus(src, deviceName, driver.platform === 'ios'); return parseDeviceStatus(raw); }