455 lines
22 KiB
TypeScript
455 lines
22 KiB
TypeScript
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);
|
||
}
|