211 lines
14 KiB
TypeScript
211 lines
14 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
|