280 lines
14 KiB
TypeScript
280 lines
14 KiB
TypeScript
/**
|
||
* 双协议网络前置:按 PROTO 切手机蓝牙/WiFi,用于必测项控制用例的 BLE/WiFi 两种模式。
|
||
*
|
||
* - ble 模式: 开蓝牙、关 WiFi → app 走 BLE 直连
|
||
* - wifi 模式: 关蓝牙、开 WiFi → app 走 WiFi/云
|
||
*
|
||
* 平台能力(已在三星真机实测):
|
||
* - Android WiFi : adb `svc wifi enable/disable` 可行(dumpsys 实测真关/开)
|
||
* - Android 蓝牙 : adb `svc bluetooth` 被新系统禁用(exit 137)、`cmd bluetooth_manager` 无实现、
|
||
* `settings put bluetooth_on` 不动 radio → 改为 `am start 蓝牙设置` + 点 switch_widget(实测可切 ON↔BLE_ON)
|
||
* - iOS : 无公开 API,走系统设置(com.apple.Preferences)UI;locator 需 iOS 真机校准
|
||
*/
|
||
import { execSync } from 'child_process';
|
||
import { DeviceDriver } from '../../drivers/types';
|
||
import { APP_CONFIG } from '../../config/app.config';
|
||
import { sleep } from './element.helper';
|
||
|
||
function adbShell(cmd: string): string {
|
||
return execSync(`adb shell ${cmd}`, { encoding: 'utf-8', timeout: 20000 });
|
||
}
|
||
|
||
// ---------- Android(纯 adb,已实测) ----------
|
||
async function androidSetWifi(on: boolean): Promise<void> {
|
||
adbShell(`svc wifi ${on ? 'enable' : 'disable'}`);
|
||
await sleep(2000);
|
||
}
|
||
|
||
async function androidSetBluetooth(on: boolean): Promise<void> {
|
||
adbShell('am start -a android.settings.BLUETOOTH_SETTINGS');
|
||
await sleep(2500);
|
||
adbShell('uiautomator dump /sdcard/ui.xml');
|
||
const xml = adbShell('cat /sdcard/ui.xml');
|
||
// 三星实测节点: resource-id="com.android.settings:id/switch_widget" class=Switch checked=.. bounds=[x1,y1][x2,y2]
|
||
const m = xml.match(/switch_widget"[^>]*?checked="(true|false)"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
|
||
if (!m) {
|
||
console.warn('[network] 未找到蓝牙开关 switch_widget,跳过(请真机校准 locator)');
|
||
return;
|
||
}
|
||
const isOn = m[1] === 'true';
|
||
if (isOn !== on) {
|
||
const cx = Math.round((Number(m[2]) + Number(m[4])) / 2);
|
||
const cy = Math.round((Number(m[3]) + Number(m[5])) / 2);
|
||
adbShell(`input tap ${cx} ${cy}`);
|
||
await sleep(3000);
|
||
}
|
||
}
|
||
|
||
// ---------- iOS(系统设置 UI,locator 已在 iPhone/iOS26.5 实测) ----------
|
||
async function iosSetBluetooth(driver: DeviceDriver, on: boolean): Promise<void> {
|
||
await driver.activateApp('com.apple.Preferences');
|
||
await sleep(2000);
|
||
// 进入蓝牙二级页:设置首页 cell(文案本地化,中文"蓝牙"/英文"Bluetooth")
|
||
const entry =
|
||
(await driver.findElementRaw('accessibility id', '蓝牙').catch(() => null)) ||
|
||
(await driver.findElementRaw('accessibility id', 'Bluetooth').catch(() => null));
|
||
if (entry) {
|
||
await driver.tapElement(entry);
|
||
await sleep(2000);
|
||
}
|
||
// 蓝牙开关:name="BLUETOOTH"(与语言无关),value "1"=开 / "0"=关
|
||
const sw = await driver.findElementRaw('accessibility id', 'BLUETOOTH').catch(() => null);
|
||
if (!sw) {
|
||
console.warn('[network] iOS 未找到蓝牙开关(accessibility id=BLUETOOTH),跳过');
|
||
return;
|
||
}
|
||
const val = await driver.getElementAttribute(sw, 'value').catch(() => '');
|
||
const isOn = val === '1' || val === 'true';
|
||
if (isOn !== on) {
|
||
await driver.tapElement(sw);
|
||
await sleep(2500);
|
||
}
|
||
}
|
||
|
||
// iOS WiFi(无 adb,走系统设置;locator 已在 iOS26.5 实测:开关 name="无线局域网" value 1/0)
|
||
async function iosSetWifi(driver: DeviceDriver, on: boolean): Promise<void> {
|
||
await driver.activateApp('com.apple.Preferences');
|
||
await sleep(2000);
|
||
// 进入 WiFi 页:cell 文案本地化(中文"无线局域网" / 英文"Wi-Fi"/"WLAN")
|
||
const entry =
|
||
(await driver.findElementRaw('accessibility id', '无线局域网').catch(() => null)) ||
|
||
(await driver.findElementRaw('accessibility id', 'Wi-Fi').catch(() => null)) ||
|
||
(await driver.findElementRaw('accessibility id', 'WLAN').catch(() => null));
|
||
if (entry) {
|
||
await driver.tapElement(entry);
|
||
await sleep(2000);
|
||
}
|
||
// WiFi 开关:name="无线局域网"(本地化), value "1"=开 / "0"=关
|
||
const sw =
|
||
(await driver.findElementRaw('accessibility id', '无线局域网').catch(() => null)) ||
|
||
(await driver.findElementRaw('accessibility id', 'Wi-Fi').catch(() => null));
|
||
if (!sw) {
|
||
console.warn('[network] iOS 未找到 WiFi 开关,跳过');
|
||
return;
|
||
}
|
||
const val = await driver.getElementAttribute(sw, 'value').catch(() => '');
|
||
const isOn = val === '1' || val === 'true';
|
||
if (isOn !== on) {
|
||
await driver.tapElement(sw);
|
||
await sleep(2500);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 按协议设置手机网络状态,然后切回 SwitchBot app。
|
||
* 无人值守可用(Android 全自动;iOS 走设置 UI)。
|
||
*/
|
||
export async function applyProtoNetwork(driver: DeviceDriver, proto: 'ble' | 'wifi'): Promise<void> {
|
||
const wantBluetooth = proto === 'ble';
|
||
const wantWifi = proto === 'wifi';
|
||
|
||
if (driver.platform === 'android') {
|
||
await androidSetWifi(wantWifi);
|
||
await androidSetBluetooth(wantBluetooth);
|
||
await driver.activateApp(APP_CONFIG.android.appPackage);
|
||
} else {
|
||
await iosSetWifi(driver, wantWifi);
|
||
await iosSetBluetooth(driver, wantBluetooth);
|
||
await driver.activateApp(APP_CONFIG.ios.bundleId);
|
||
}
|
||
await sleep(2000);
|
||
}
|
||
|
||
/**
|
||
* 关→开手机蓝牙,复位蓝牙栈。BLE 扫描/配对偶发卡死(吸顶灯 Pro "FAIL: connection timeout"、
|
||
* 重跑就过)的根治。用于:跑 BLE 控制前、灯类 BLE 配对添加前。失败不抛(非致命)。
|
||
*
|
||
* Android 直接用 `adb svc bluetooth disable/enable` —— 本机(三星)实测可真切 radio(bluetooth_on 1→0→1,
|
||
* exit=0),不走设置 UI、不与 Appium 冲突、前台 app 不变。(早期机器 svc 被禁才绕设置 UI,本机不需要。)
|
||
*/
|
||
export async function resetBluetooth(driver: DeviceDriver): Promise<void> {
|
||
try {
|
||
if (driver.platform === 'android') {
|
||
adbShell('svc bluetooth disable');
|
||
await sleep(2500);
|
||
adbShell('svc bluetooth enable');
|
||
await sleep(5000); // 等蓝牙栈重新就绪
|
||
} else {
|
||
// iOS 无 adb,走系统设置 UI 关再开,完成后切回 SwitchBot app
|
||
await iosSetBluetooth(driver, false);
|
||
await sleep(1500);
|
||
await iosSetBluetooth(driver, true);
|
||
await sleep(4000);
|
||
await driver.activateApp(APP_CONFIG.ios.bundleId);
|
||
await sleep(2000);
|
||
}
|
||
console.log('已重置手机蓝牙(关→开)');
|
||
} catch (e: any) {
|
||
console.log(`重置蓝牙失败(非致命): ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/** 首页"响应度":设备卡数 + 带实时状态(On/Off/℃/%/open 等)的卡数。BLE 未连上时卡片多为无状态/无响应。 */
|
||
async function homepageResponsiveness(driver: DeviceDriver): Promise<{ cards: number; stateHits: number }> {
|
||
const src = await driver.getSource().catch(() => '');
|
||
const cards = driver.platform === 'android'
|
||
? (src.match(/resource-id="[^"]*\/(nameText|nameTextMeter)"/g) || []).length
|
||
: (src.match(/XCUIElementTypeCell[^>]*?name="/g) || []).length;
|
||
// 实时状态关键词(连上 BLE 才会刷出来):开关/温湿度/窗帘/传感器等
|
||
const stateHits = (src.match(/(On\b|Off\b|Fully open|Fully closed|Partially open|Opened|Closed|℃|°C|\d%|ppm|Detected|unoccupied|Locked|Unlocked)/g) || []).length;
|
||
return { cards, stateHits };
|
||
}
|
||
|
||
/**
|
||
* 切到"仅 BLE"模式并**校验设备已连上**:关手机 WiFi → 复位蓝牙 → 下拉刷新首页 → 看卡片是否有响应。
|
||
* 大部分卡无响应(BLE 没连上)就重试整套切换(最多 maxRetries 次)。根治"中途切 BLE 后首页卡片全无响应 → BLE 控制整片失败"。
|
||
* 仅 Android(BLE 控制阶段);校验是启发式(状态关键词/卡数比例),非致命,失败只告警。
|
||
*/
|
||
export async function switchToBleAndVerify(driver: DeviceDriver, maxRetries = 3): Promise<boolean> {
|
||
if (driver.platform !== 'android') return true;
|
||
adbShell('svc wifi disable'); // 关 WiFi 强制 BLE 直连
|
||
await sleep(3000);
|
||
const { width: w, height: h } = await driver.getWindowSize().catch(() => ({ width: 1080, height: 2280 }));
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
await resetBluetooth(driver); // 关→开蓝牙复位
|
||
await driver.activateApp(APP_CONFIG.android.appPackage).catch(() => {});
|
||
await sleep(2000);
|
||
await driver.goBackToHomepage().catch(() => {});
|
||
await sleep(1500);
|
||
// 下拉刷新首页(从上方下拉),触发 App 重新建立 BLE 连接
|
||
await driver.swipe(Math.round(w / 2), Math.round(h * 0.22), Math.round(w / 2), Math.round(h * 0.72), 0.6).catch(() => {});
|
||
await sleep(10000); // 等 BLE 重连 + 状态刷新
|
||
const { cards, stateHits } = await homepageResponsiveness(driver);
|
||
// 响应度:有状态关键词数 ≥ 卡数的一半,视为大部分设备已连上
|
||
const ok = cards > 0 && stateHits >= Math.max(2, Math.ceil(cards * 0.5));
|
||
console.log(`[BLE校验] 第${attempt}/${maxRetries}次:卡片${cards}、状态命中${stateHits} → ${ok ? '已连上' : '大部分无响应,重试切换'}`);
|
||
if (ok) return true;
|
||
}
|
||
console.log('[BLE校验] 多次重试后仍大部分无响应(后续 BLE 控制可能失败,日志已标记)');
|
||
return false;
|
||
}
|
||
|
||
// ===== iOS 经桌面快捷指令切网(设置页 WiFi/蓝牙开关 locator 已失效;改用用户在主屏放的快捷指令) =====
|
||
// 关键:点快捷指令必须用坐标点(driver.tapElement→wda/tap),element/click 不触发执行。
|
||
// WiFi 状态从**状态栏**读(可靠):有 "X格无线局域网信号" 元素=ON,无=OFF。蓝牙状态无法读 → 只盲翻转(由调用方按确定性序列控制)。
|
||
|
||
/** 读 iOS WiFi 是否开(状态栏 WiFi 信号元素存在=ON)。 */
|
||
async function iosWifiIsOn(driver: DeviceDriver): Promise<boolean> {
|
||
const src = await driver.getSource().catch(() => '');
|
||
return /无线局域网信号/.test(src); // 状态栏元素 "3(共3格无线局域网信号)";关闭时该元素消失
|
||
}
|
||
|
||
/**
|
||
* 消除 iOS 系统弹框(模态,会挡住切网快捷指令)。常见:定位权限(iOS 读 WiFi SSID 需定位)、"好"信息框等。
|
||
* 点**允许/好/OK**(不点"不允许",否则定位被拒→WiFi 操作失败)。返回是否点掉了一个。
|
||
*/
|
||
async function dismissIosAlert(driver: DeviceDriver): Promise<boolean> {
|
||
for (const b of ['好', '允许', '使用App时允许', '仅在使用时允许', '允许一次', '使用 App 时允许', 'OK', 'Allow', 'Allow Once', 'Allow While Using App']) {
|
||
const el = await driver.findElementRaw('accessibility id', b).catch(() => null)
|
||
|| await driver.findElementRaw('predicate string', `label == "${b}" OR name == "${b}"`).catch(() => null);
|
||
if (el) { await driver.tapElement(el).catch(() => {}); await sleep(1200); console.log(`[iOS弹框] 已点「${b}」消除`); return true; }
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 退到 iOS 桌面(springboard):本机 home 键(wda/homescreen)不生效,改用 terminate App 可靠退桌面。 */
|
||
async function iosToSpringboard(driver: DeviceDriver): Promise<void> {
|
||
for (let i = 0; i < 3; i++) {
|
||
await dismissIosAlert(driver); // App 首页/系统模态弹框(定位"好"等)会挡住 terminate/点指令,先消掉
|
||
await (driver as any).terminateApp?.(APP_CONFIG.ios.bundleId);
|
||
await sleep(1500);
|
||
await dismissIosAlert(driver); // 退桌面后可能又弹,再消一次
|
||
const fg = await (driver as any).activeAppId?.().catch(() => '');
|
||
if (!fg || /springboard/i.test(fg)) return; // 校验:确实到了桌面
|
||
console.log(`[iOS桌面] 仍在前台 App(${fg}),消弹框+重试 terminate(第${i + 1}次)`);
|
||
}
|
||
}
|
||
|
||
/** 点主屏某快捷指令(坐标点,真正执行)。先退桌面,快捷指令可能在某一页 → 翻页查找。 */
|
||
async function tapHomeShortcut(driver: DeviceDriver, name: string): Promise<boolean> {
|
||
await iosToSpringboard(driver);
|
||
const { width: w, height: h } = await driver.getWindowSize().catch(() => ({ width: 393, height: 852 }));
|
||
for (let page = 0; page < 6; page++) {
|
||
const el = await driver.findElementRaw('accessibility id', name).catch(() => null);
|
||
if (el) { await driver.tapElement(el); return true; } // tapElement=wda/tap 坐标点,能真正触发
|
||
await driver.swipe(Math.round(w * 0.85), Math.round(h * 0.6), Math.round(w * 0.15), Math.round(h * 0.6), 0.4).catch(() => {});
|
||
await sleep(800);
|
||
}
|
||
console.log(`[快捷指令] 翻遍主屏仍未找到「${name}」`);
|
||
return false;
|
||
}
|
||
|
||
/** 把 iOS WiFi 设成目标态:退桌面读状态栏→不符就点「设定无线局域网」翻转→复读,最多 retries 次。仅 iOS。 */
|
||
export async function iosSetWifiViaShortcut(driver: DeviceDriver, on: boolean, retries = 4): Promise<boolean> {
|
||
if (driver.platform !== 'ios') return true;
|
||
for (let i = 0; i < retries; i++) {
|
||
await iosToSpringboard(driver);
|
||
if ((await iosWifiIsOn(driver)) === on) {
|
||
console.log(`[iOS-WiFi] 已是 ${on ? 'ON' : 'OFF'}`);
|
||
return true;
|
||
}
|
||
await tapHomeShortcut(driver, '设定无线局域网');
|
||
await sleep(2500);
|
||
await dismissIosAlert(driver); // 切 WiFi 会触发定位权限框,挡住生效 → 点掉它
|
||
await sleep(2500); // 等 WiFi 切换 + 状态栏刷新
|
||
}
|
||
await iosToSpringboard(driver);
|
||
const final = await iosWifiIsOn(driver);
|
||
console.log(`[iOS-WiFi] 多次后 = ${final ? 'ON' : 'OFF'}(目标 ${on ? 'ON' : 'OFF'})`);
|
||
return final === on;
|
||
}
|
||
|
||
/** 盲翻转 iOS 蓝牙(点「设定蓝牙」一次)。蓝牙状态无法读 → 由调用方按确定性序列(起始为开)控制。仅 iOS。 */
|
||
export async function iosToggleBluetoothShortcut(driver: DeviceDriver): Promise<void> {
|
||
if (driver.platform !== 'ios') return;
|
||
const ok = await tapHomeShortcut(driver, '设定蓝牙');
|
||
await sleep(4000);
|
||
console.log(ok ? '[iOS-BT] 已翻转蓝牙(设定蓝牙)' : '[iOS-BT] 未找到设定蓝牙快捷指令');
|
||
}
|
||
|
||
/**
|
||
* iOS:确保蓝牙为「开」(确定性——进设置→蓝牙页读开关 value,关才打开;不盲翻转)。
|
||
* 用于**添加阶段前置**:盲翻转的「设定蓝牙」可能把蓝牙留在关闭态,导致添加阶段所有 BLE 设备
|
||
* "not found in BLE scan" 批量失败。开跑添加前确定性地把蓝牙打开,根治此类批量假失败。
|
||
*/
|
||
export async function iosEnsureBluetoothOn(driver: DeviceDriver): Promise<void> {
|
||
if (driver.platform !== 'ios') return;
|
||
await iosSetBluetooth(driver, true);
|
||
console.log('[iOS-BT] 已确保蓝牙为开(确定性,添加前置)');
|
||
}
|