AI_UIAutomation/utils/common/network.helper.ts

280 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.

/**
* 双协议网络前置:按 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] 已确保蓝牙为开(确定性,添加前置)');
}