AI_UIAutomation/scripts/reset-except.ts

169 lines
11 KiB
TypeScript

/**
* 首页长按卡片→多选→批量删除(快),保留指定设备。安全开关:RESET_EXCEPT=1。
* Android: RESET_EXCEPT=1 PLATFORM=android npx ts-node scripts/reset-except.ts
* iOS: RESET_EXCEPT=1 PLATFORM=ios npx ts-node scripts/reset-except.ts
*
* 保留:KEEP_DEVICES(精确/子串) + KEEP_KEYWORDS(子串,默认 Cam,Doorbell)。锁/摄像头/门铃自动化无法重加,务必保留。
* 「删除所有」可传 KEEP_DEVICES="" KEEP_KEYWORDS=""(谨慎:会把锁/摄像头也删掉)。
*
* 平台差异:
* - Android:卡片靠 resource-id nameText/nameTextMeter 定位;多选底部 "Delete"(text),确认弹框 Delete/OK。
* - iOS:卡片是 XCUIElementTypeCell(name=设备名+状态拼接);长按进多选("N selected"/Finish),
* 底部 "Delete" → 确认弹框 "Delete the selected items?" 点 "Confirm"。长按偶发不进多选→重试。
*/
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
const KEEP = (process.env.KEEP_DEVICES || 'Lock 6D,Lock Pro DE,Blind Tilt 42').split(',').map(s => s.trim()).filter(Boolean);
// 关键字保留(子串匹配):摄像头/门铃/Lock Lite 名带随机后缀且品类多,用关键字一网打尽(自动化无法重加,绝不可删)。
// ⚠️ Lock Lite 必须默认保留(无法重加);此前默认漏了 Lock Lite,iOS reset(用默认)会误删 → 加入默认。
const KEEP_KW = (process.env.KEEP_KEYWORDS || 'Cam,Doorbell,Lock Lite').split(',').map(s => s.trim()).filter(Boolean);
// 例外:即使命中 KEEP_KW 也**强制可删**(如 Outdoor Pan/Tilt Cam 含 "Cam" 但可经继电器重加 → 不保留)。
const NEVER_KEEP = (process.env.DELETE_ANYWAY || 'Outdoor Pan/Tilt').split(',').map(s => s.trim()).filter(Boolean);
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
// Android 首页设备卡名 + 中心坐标(nameText / nameTextMeter)
function androidDevices(src: string): { name: string; cx: number; cy: number }[] {
const out: { name: string; cx: number; cy: number }[] = [];
for (const n of src.split('<')) {
if (!/resource-id="[^"]*\/(nameText|nameTextMeter)"/.test(n)) continue;
const t = (n.match(/text="([^"]*)"/) || [])[1];
const b = n.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
if (t && t.trim() && b) out.push({ name: t.trim(), cx: Math.round((+b[1] + +b[3]) / 2), cy: Math.round((+b[2] + +b[4]) / 2) });
}
return out;
}
// Android 精确名匹配(nameText 是纯设备名);iOS cell.name 含拼接状态 → 用子串匹配。
const isKeptAndroid = (name: string) => !NEVER_KEEP.some(k => name.includes(k)) && (KEEP.some(k => name === k) || KEEP_KW.some(k => name.includes(k)));
const isKeptIOS = (name: string) => !NEVER_KEEP.some(k => name.includes(k)) && (KEEP.some(k => name.includes(k)) || KEEP_KW.some(k => name.includes(k)));
// iOS cell.name 形如 "Hub Mini EATemperature 25.0..." → 截到状态前,仅用于日志可读
const shortName = (n: string) => n.split(/Temperature|Locked|Unlocked|Motion|No response|Not |Fully|>24|Open|Close|\d+%|\d+:\d+/)[0].trim() || n.slice(0, 20);
async function resetAndroid(driver: any): Promise<void> {
const hasDelete = () => driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
const exitSelect = async () => { const f = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")'); if (f) { await driver.tapElement(f); await sleep(700); } };
let total = 0;
for (let round = 0; round < 30; round++) {
await driver.goBackToHomepage(); await sleep(1800);
await driver.dismissPopupIfPresent();
const all = androidDevices(await driver.getSource());
const nonKept = all.filter((x: any) => !isKeptAndroid(x.name));
if (!nonKept.length) { console.log('只剩保留项,结束'); break; }
const first = nonKept[0];
await driver.longPress(first.cx, first.cy, 1.0); await sleep(1000);
if (!(await hasDelete())) { await exitSelect(); console.log(`长按 ${first.name} 未进多选,跳过本轮`); continue; }
const batch = [first.name];
for (const x of androidDevices(await driver.getSource()).filter((o: any) => !isKeptAndroid(o.name) && o.name !== first.name)) {
await driver.tap(x.cx, x.cy); await sleep(400);
if (await hasDelete()) { batch.push(x.name); }
else { await driver.tap(x.cx, x.cy); await sleep(300); }
}
const del = await hasDelete();
if (!del) { await exitSelect(); continue; }
await driver.tapElement(del); await sleep(900);
for (let r = 0; r < 4; r++) { const s = await driver.getSource(); if (/Cancel|取消|delete|删除/i.test(s)) { let c: string | null = null; for (const t of ['Delete', 'OK', '删除', 'Confirm']) { c = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`); if (c) break; } if (c) { await driver.tapElement(c); break; } } await sleep(500); }
await sleep(2000);
total += batch.length;
console.log(`本批删 ${batch.length}: ${batch.join(', ')}`);
}
await driver.goBackToHomepage(); await sleep(1500);
console.log(`\n共删 ${total}。剩余:`, androidDevices(await driver.getSource()).map((x: any) => x.name).join(', ') || '(无)');
}
async function resetIOS(driver: any): Promise<void> {
// 枚举首页设备卡片(Cell:name=设备名+状态;卡宽 ~175,过滤掉非设备 cell)
const cards = async (): Promise<{ name: string; cx: number; cy: number }[]> => {
const cells = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeCell"').catch(() => []);
const out: { name: string; cx: number; cy: number }[] = [];
for (const c of cells as string[]) {
const nm = ((await driver.getElementAttribute(c, 'name').catch(() => '')) || '') as string;
const r = await driver.getElementRect(c).catch(() => null);
if (nm && r && r.width > 100 && r.width < 260) out.push({ name: nm, cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2) });
}
return out;
};
const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 }));
const undeletable = new Set<string>(); // 固定插件/删不掉的项 → 记下跳过,不再死磕
const deletedOnce = new Set<string>(); // 删过的名字;若再次出现 = 删除异常(如 Keypad Vision 绑锁删不掉)→ 标记跳过
let total = 0;
for (let round = 0; round < 60; round++) {
await driver.goBackToHomepage(); await sleep(1500);
await driver.dismissPopupIfPresent();
// 往下滚动,找第一张**可视区内、非白名单、未标记删不掉**的设备卡
let target: { name: string; cx: number; cy: number } | null = null;
let lastNames = '';
for (let s = 0; s < 18 && !target; s++) {
const vis = (await cards()).filter((x) => !isKeptIOS(x.name) && !undeletable.has(x.name) && x.cy > winH * 0.08 && x.cy < winH * 0.9);
if (vis.length) { target = vis[0]; break; }
const names = ((await driver.getSource()).match(/XCUIElementTypeCell[^>]*?name="[^"]*"/g) || []).join('|');
if (names && names === lastNames) break; // 到底,本屏无可删项
lastNames = names;
await driver.scrollDown(500); await sleep(700);
}
if (!target) { console.log('无可删的非白名单设备,结束'); break; }
// 删过却又出现 = 删除异常(如 Keypad Vision 绑锁,弹"需先解绑"删不掉)→ 标记跳过,不再死循环
if (deletedOnce.has(target.name)) {
console.log(`${shortName(target.name)} 删除异常(删后仍在),跳过`);
undeletable.add(target.name); continue;
}
// 长按该卡进多选(自动选中它)→ **只删它一张**(不批量勾其它,避免破坏多选)→ Confirm
let inSel = false;
for (let i = 0; i < 3 && !inSel; i++) {
await driver.longPress(target.cx, target.cy, 1.6); await sleep(2000);
inSel = /selected|Finish/.test(await driver.getSource());
}
if (!inSel) { console.log(`长按 ${shortName(target.name)} 未进多选,标记跳过`); undeletable.add(target.name); continue; }
// 底部 Delete 按钮(限定 Button,轮询等渲染)
let del: string | null = null;
for (let k = 0; k < 6 && !del; k++) {
del = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Delete"').catch(() => null);
if (!del) await sleep(500);
}
if (!del) {
// 长按了但没 Delete = **固定插件/不可删项** → 退出多选 + 标记跳过(下轮选别的,不再卡它)
console.log(`${shortName(target.name)} 无 Delete(固定插件?),标记跳过`);
undeletable.add(target.name);
for (const t of ['Finish', 'Cancel', '完成', '取消']) {
const f = await driver.findElementRaw('predicate string', `name == "${t}"`).catch(() => null);
if (f) { await driver.tapElement(f); await sleep(800); break; }
}
continue;
}
// 效率优化:当前屏内其它可删卡也勾上,一次 Delete 批量删(已跳过固定插件 → 不会破坏多选)。
const batchFull = [target.name];
for (const x of (await cards()).filter((o) => !isKeptIOS(o.name) && !undeletable.has(o.name) && o.cy > winH * 0.08 && o.cy < winH * 0.9 && (Math.abs(o.cx - target.cx) + Math.abs(o.cy - target.cy) > 10))) {
await driver.tap(x.cx, x.cy); await sleep(400);
// 每勾一张校验 Delete 仍在;消失=这张破坏了多选 → 停止继续勾,删已选的
const still = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Delete"').catch(() => null);
if (!still) break;
batchFull.push(x.name);
}
batchFull.forEach((n) => deletedOnce.add(n)); // 记录;若删除异常下轮再现 → 会被识别跳过
const batch = batchFull.map(shortName);
const delNow = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Delete"').catch(() => null) || del;
await driver.tapElement(delNow); await sleep(1800);
let confirm = await driver.findElementRaw('predicate string', 'name == "Confirm"').catch(() => null);
if (!confirm) confirm = await driver.findElementRaw('predicate string', 'name == "Delete" AND visible == true').catch(() => null);
if (confirm) { await driver.tapElement(confirm); await sleep(3000); }
total += batch.length;
console.log(`本批删 ${batch.length}: ${batch.join(', ')}`);
}
await driver.goBackToHomepage(); await sleep(1500);
const left = (await cards()).filter((x) => !isKeptIOS(x.name) && !undeletable.has(x.name));
console.log(`\n共删 ${total}${undeletable.size ? '跳过(不可删):' + [...undeletable].map(shortName).join(', ') + '。' : ''}剩余可删:`, left.map((x) => shortName(x.name)).join(', ') || '(无)');
}
async function main() {
if (process.env.RESET_EXCEPT !== '1') { console.error('已阻止。RESET_EXCEPT=1 重跑。'); process.exit(2); }
const driver = createDriver();
await driver.createSession();
try {
console.log(`平台=${driver.platform} | 保留(精确/子串):`, KEEP.join(', ') || '(无)', '| 保留(关键字):', KEEP_KW.join(', ') || '(无)');
if (driver.platform === 'ios') await resetIOS(driver);
else await resetAndroid(driver);
} finally { await driver.destroySession(); }
}
main().catch(e => { console.error('ERR:', e.message); process.exit(1); });