AI_UIAutomation/utils/common/home-card.helper.ts

455 lines
22 KiB
TypeScript
Raw Permalink 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.

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<number> {
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<void> {
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<string | null> {
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<string | null> {
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<string> {
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<string> {
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<string> {
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 = /<XCUIElementTypeStaticText\b([^>]*?)\/?>/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<dy<60)同列 StaticText。加距离上限防误抓下一行卡名(dy≈+114)。
const state = texts
.filter(n => 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<void> {
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<string> {
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<HomeDeviceStatus> {
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);
}