AI_UIAutomation/utils/common/hub.helper.ts

211 lines
14 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.

/**
* Hub Mini WiFi 配网 —— 连上设备后进入 "Configure Wi-Fi" 页:
* - SSID 字段:默认预填手机当前 WiFi(如 "Deco"),需清空改填目标 SSID
* - 密码字段:hint "Enter Wi-Fi password",填密码
* - 点 Next 提交,等待联网完成
* 仅支持 2.4GHz WiFi(页面有提示)。凭据来自 config/wifi.config(env 可覆盖,默认 woanmarket/woankeji)。
*/
import { DeviceDriver } from '../../drivers/types';
import { sleep } from './element.helper';
import { getWifiCredentials, WifiCredentials } from '../../config/wifi.config';
import { navigateThroughWizard } from './navigation.helper';
const EDIT = 'new UiSelector().className("android.widget.EditText")';
/** 在 "Configure Wi-Fi" 页填 SSID + 密码并 Next;随后走完向导。creds 缺省取 config/wifi.config。
* opts.renameTo:配网成功后向导的"命名设备"页内联改名(iOS 插座首页名无区域→靠改名区分 JP/US/EU,同 curtain)。 */
export async function configureHubWifi(driver: DeviceDriver, creds?: WifiCredentials, opts?: { renameTo?: string }): Promise<void> {
const { ssid, password } = creds || getWifiCredentials();
// 等配网页出现:标题(Configure Wi-Fi / Wi-Fi Settings 等)或 直接出现 ≥2 输入框(SSID+密码)即视为到达。
// BLE 连接较慢(relay 后约 24s 连上、27s 出页),窗口要够长(~37s)。
let onWifiPage = false;
for (let i = 0; i < 40; i++) {
const src = await driver.getSource();
const titleHit = /Configure Wi-?Fi|Wi-?Fi Settings|Wi-?Fi password|配置 ?Wi-?Fi/i.test(src);
const edCount = driver.platform === 'android'
? (await driver.findElementsRaw('-android uiautomator', EDIT)).length
: (await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeTextField" OR type == "XCUIElementTypeSecureTextField"')).length;
const fieldHit = edCount >= 2 && /Wi-?Fi|password|密码|2\.4\s?GHz/i.test(src);
if (titleHit || fieldHit) { onWifiPage = true; break; }
await driver.dismissPopupIfPresent();
await sleep(1500);
}
if (!onWifiPage) { console.log('WARN: 未到达 Configure Wi-Fi 页,跳过配网'); return; }
if (driver.platform === 'android') {
// Android:SSID/密码都是 EditText,直接定位元素 clear + type
const eds = await driver.findElementsRaw('-android uiautomator', EDIT);
if (eds.length < 2) { console.log('WARN: 未找到 SSID/密码输入框,跳过配网'); return; }
let ssidEl = eds[0], pwdEl = eds[1];
try { const t0 = (await driver.getElementAttribute(eds[0], 'text')) || ''; if (/password|密码/i.test(t0)) { pwdEl = eds[0]; ssidEl = eds[1]; } } catch { /* 默认顺序 */ }
await driver.clearText(ssidEl).catch(() => {});
await driver.typeText(ssidEl, ssid); await sleep(500);
// 输入 SSID 后键盘把页面顶起、遮住密码框 → 必须先收键盘才能输密码(goBack 在键盘弹起时只收键盘、不返回)
try { await driver.goBack(); } catch { /* 收键盘 */ }
await sleep(700);
// 收键盘后重新定位密码框(布局变化),密码框通常是最后一个 EditText
let pwd2 = pwdEl;
try {
const eds2 = await driver.findElementsRaw('-android uiautomator', EDIT);
if (eds2.length) pwd2 = eds2[eds2.length - 1];
} catch { /* 用原引用 */ }
await driver.typeText(pwd2, password); await sleep(500);
// 再收一次键盘,Next 才点得到
try { await driver.goBack(); } catch { /* 收键盘 */ }
await sleep(600);
} else {
// iOS:优先**元素法**(plug 等的 SSID=XCUIElementTypeTextField、密码=XCUIElementTypeSecureTextField,可定位)。
// 找不到标准输入框(如 hub 自绘 UI、无障碍不可见)再退**坐标法**(/wda/keys)。
const d = driver as any;
const TF = 'type == "XCUIElementTypeTextField"';
const SF = 'type == "XCUIElementTypeSecureTextField"';
// ⚠️ 关键:部分设备(Color Bulb 的 "Select Wi-Fi Network" 页)配网页**扫描网络期间有多个模板输入框**,
// 此时填会填错框 → 必须等页面稳定(Scanning 消失 且 密码框仅剩 1 个)再填。实测稳定后元素法填入正确、12s 配网成功。
for (let i = 0; i < 20; i++) {
const s = await driver.getSource();
const sfCount = (await driver.findElementsRaw('predicate string', SF).catch(() => [])).length;
if (!/Scanning/i.test(s) && sfCount <= 1) break;
await sleep(1500);
}
const ssidEl = await driver.findElementRaw('predicate string', TF).catch(() => null);
const pwdEl = await driver.findElementRaw('predicate string', SF).catch(() => null);
if (ssidEl && pwdEl) {
// **关键1:每个框即时重新 find 再 typeText**。填完 SSID 键盘弹出→界面树变→开头找的 pwd 元素 id 会 stale 失效。
// **关键2:SSID 框预填手机当前 WiFi(如 ASUS_D8),typeText=setValue 是追加不替换 → 不清空会变 "ASUS_D8ASUS_D8" 乱码 → 连不上卡死。
// 必须 clearText 后再 type,并校验值正确、不对则重清重填一次。密码框(SecureTextField)为空,无需清。
const fill = async (predicate: string, text: string, clearFirst: boolean) => {
const el = await driver.findElementRaw('predicate string', predicate).catch(() => null);
if (!el) { console.log(`WARN: 未找到输入框(${predicate})`); return; }
await driver.tapElement(el).catch(() => {}); // 聚焦
await sleep(400);
if (clearFirst) { await driver.clearText(el).catch(() => {}); await sleep(300); }
await driver.typeText(el, text);
await sleep(700);
if (clearFirst) {
// 校验 SSID 值正确(预填残留/追加 → 重清重填一次)
const cur = await driver.findElementRaw('predicate string', predicate).catch(() => null);
if (cur) {
const v = ((await driver.getElementAttribute(cur, 'value').catch(() => '')) || '') as string;
if (v && v !== text) {
console.log(`SSID 值异常("${v}")→ 重清重填`);
await driver.tapElement(cur).catch(() => {});
await driver.clearText(cur).catch(() => {}); await sleep(300);
await driver.typeText(cur, text); await sleep(600);
}
}
}
};
await fill(TF, ssid, true); // SSID:必须先清空预填值
await fill(SF, password, false); // 密码:空框,直接填
// 收键盘:iOS 键盘的 "Done"/"Return" 键(挡住下方 Next,必须先收;driver.tapElement 走 /wda/tap 真实点,有效)
for (const k of ['Done', 'Return', 'Go', 'Join', 'done', '完成']) {
const kb = await driver.findElementRaw('name', k).catch(() => null);
if (kb) { await driver.tapElement(kb); await sleep(900); break; }
}
} else {
// 坐标法(自绘 UI 的 hub,如旧机):坐标按 iPhone 14 Pro(393×852pt),SSID≈(118,358)、密码≈(118,443);换机型需调。
// 重要:SSID 预填手机当前 WiFi,聚焦不会全选,必须先清空——双击选中再键入;最后用 "Return" 收键盘。
await d.tap(118, 358); await sleep(800);
await d.doubleTap(118, 358); await sleep(700);
await d.sendKeys(['\b']); await sleep(300);
await d.sendKeys([ssid]); await sleep(800);
await d.tap(118, 443); await sleep(800);
await d.sendKeys([password]); await sleep(800);
const ret = await driver.findElementRaw('name', 'Return').catch(() => null);
if (ret) { await driver.tapElement(ret); await sleep(1000); }
}
}
console.log(`Hub WiFi 配网:SSID="${ssid}" 密码已填,提交...`);
// 点 Next(iOS 名为 Next;Android text=Next)
const next = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")')
: await driver.findElementRaw('name', 'Next');
if (next) await driver.tapElement(next);
// 提交后弹"要连接至设备吗?"(SwitchBot 用临时 Wi-Fi 连设备)→ 必须点确认(非取消/dismiss)。
// **iOS 按钮是"加入"/Join(系统 Wi-Fi 加入弹框)**;Android 是"连接"/Connect。两端文案都试。
await sleep(2000);
for (const t of ['加入', 'Join', '连接', 'Connect']) {
const cb = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null)
|| await driver.findElementRaw('name', t).catch(() => null);
if (cb) { await driver.tapElement(cb); console.log(`确认"要连接至设备吗"弹框: ${t}`); await sleep(2000); break; }
}
// 提交后是多步配网过程。**iOS 序列**:提交 → 弹"加入 Wi-Fi"系统框(几秒后才弹,需点"加入"/Join)→ 连接中 → "Available now"成功页(需点按钮)。
// 期间进度页不要乱点,但"加入/连接"确认框**必须轮询点**(出现即点),且有待处理动作时**不提前退出**。
const SUCCESS = [
'added successfully', 'Added successfully', 'Start Using', 'Use now', 'Pick a room',
'Set up complete', 'Setup complete', 'configured successfully', 'connected to', 'Available now',
'添加成功', '配网成功', '连接成功', '设置完成',
];
const FAIL = ['Failed', 'failed', 'incorrect', 'wrong', 'Unable', '失败', '密码错误', 'try again', 'Try Again'];
let reached = false;
let leftPage = 0; // 连续几次"已离开配网输入页"(提前结束用)
for (let t = 0; t < 30; t++) { // 最多 ~90s 等配网过程
const src = await driver.getSource();
// iOS 调试:每 ~6s dump 页面文字,定位真实序列(加入框/连接中/Available now/按钮文案)
if (driver.platform === 'ios' && t % 2 === 0) {
const lbls = [...new Set(Array.from(src.matchAll(/(?:name|label)="([^"]+)"/g)).map((m) => m[1]).filter((x) => x.trim() && x.length < 28))].slice(0, 16);
console.log(` [iOS配网页 +${t * 3}s] ${JSON.stringify(lbls)}`);
}
if (SUCCESS.some((k) => src.includes(k))) { console.log(`Hub 配网成功(到结果页, +${t * 3}s)`); reached = true; break; }
if (FAIL.some((k) => src.includes(k))) {
const vis = (src.match(/(?:text|name|label)="([^"]+)"/g) || []).map((x) => x.replace(/(?:text|name|label)="|"/g, '')).filter(Boolean).slice(0, 12).join(' | ');
console.log(`WARN: Hub 配网疑似失败(+${t * 3}s)。页面:${vis}`);
break;
}
// ★ 轮询点"加入/Join/连接/Connect"确认框(iOS 系统 Wi-Fi 加入框提交后几秒才弹,一次性查会错过)
let confirmed = false;
for (const ct of ['加入', 'Join', '连接', 'Connect']) {
const cb = await driver.findElementRaw('name', ct).catch(() => null)
|| await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${ct}")`).catch(() => null);
if (cb) { await driver.tapElement(cb).catch(() => {}); console.log(`点配网确认框: ${ct}(+${t * 3}s)`); confirmed = true; await sleep(1500); break; }
}
if (t % 3 === 0 && driver.platform !== 'ios') {
const vis = (src.match(/text="([^"]+)"/g) || []).map((x) => x.replace(/text="|"/g, '')).filter(Boolean).slice(0, 8).join(' | ');
console.log(` 配网进行中 +${t * 3}s: ${vis}`);
}
// 提前结束:离开配网输入页 = 进入配网过程。但 ① 连接中/验证中 ② 有"加入/Available"待处理动作 → 不算离开(否则被提前切断)。
const onInputPage = /Wi-?Fi Settings|Configure Wi-?Fi|Enter password|Enter Wi-?Fi|配置 ?Wi-?Fi/i.test(src);
const connecting = /Connecting|Trying to connect|connecting to your router|Verifying|连接中|正在连接|验证中/i.test(src);
const pending = confirmed || /加入|Join|Available now|连接至|Connect to/i.test(src); // 有待处理动作不早退
if (!onInputPage && !connecting && !pending && t >= 1) {
if (++leftPage >= 2) { console.log(`配网页已离开(+${t * 3}s),提前结束等待`); reached = true; break; }
} else { leftPage = 0; }
await driver.dismissPopupIfPresent();
await sleep(3000);
}
if (!reached) console.log('WARN: 未等到 Hub 配网成功页(可能 WiFi 不可达/非2.4G/密码错,或过程超 90s)');
// 到结果页后走完结尾向导(Start Using / Done / 房间等)。
// 补充:部分 plug(如 US/JP)配网成功后多一步"Save/保存"页(EU 无此步)→ 加进按钮列表,补差异不改原流程。
await driver.dismissPopupIfPresent();
// 配网成功后向导有"命名设备"页:按需内联改名(iOS 插座/Hub 首页名无区域→靠改名区分,同 curtain)。轮询等命名页出现。
if (opts?.renameTo) {
for (let i = 0; i < 8; i++) {
const src = await driver.getSource();
// 仍在 Wi-Fi 输入页则跳过(避免误把 SSID 框当名字框)
if (/Wi-?Fi Settings|Configure Wi-?Fi|Enter password/i.test(src)) { await sleep(2000); continue; }
const ed = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")').catch(() => null)
: await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null);
if (ed) {
await driver.tapElement(ed); await sleep(400);
await driver.clearText(ed).catch(() => {});
await driver.typeText(ed, opts.renameTo); await sleep(500);
// 收键盘:Android 用 goBack(收键盘);**iOS 绝不能用 goBack——它是点返回按钮会退回上一步丢失命名页**,改点键盘 Done/Return 键。
if (driver.platform === 'android') { try { await driver.goBack(); } catch { /* 收键盘 */ } }
else { for (const k of ['Done', 'Return', 'Go', 'Join', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
await sleep(700);
console.log(`配网后命名页重命名为 "${opts.renameTo}"`);
break;
}
await sleep(2000);
}
}
await navigateThroughWizard(driver, ['Available now', 'Save', '保存', 'Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip', 'Next'], 8);
}