AI_UIAutomation/scripts/reset-except.ts

270 lines
18 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 < 40; round++) {
await driver.goBackToHomepage(); await sleep(1800);
await driver.dismissPopupIfPresent();
// RN 首页懒加载,getSource 只返回当前视图 → 必须**滚动遍历**找非白名单设备,否则删完顶部就误判"只剩保留项"提前收工。
// 判到底:连续2次 getSource 完全相同(tail 单次匹配在 RN 上会误触发);步长要够大(900)。
let first: any = null; let last = ''; let same = 0;
for (let s = 0; s < 30 && !first; s++) {
const src = await driver.getSource();
const nk = androidDevices(src).filter((x: any) => !isKeptAndroid(x.name));
if (nk.length) { first = nk[0]; break; }
if (src === last) { same++; if (same >= 2) break; } else same = 0;
last = src;
await driver.scrollDown(900); await sleep(900);
}
if (!first) { console.log('滚遍首页只剩保留项,结束'); break; }
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> {
// 环境/汇总卡(非设备,绝不可选):首页顶部「Environmental data / Carbon dioxide / Smart Report」这类 Cell。
// 它们宽度也落在 100~260(如环境卡 w=172),但**高度只有 ~38**,真实设备卡 h≈104 → 用高度区分,并对 name 兜底。
const isEnvCard = (n: string) => /^(Environmental data|Carbon dioxide|Smart Report)/i.test(n);
// 枚举首页设备卡片(Cell:name=设备名+状态;设备卡 w~175 h~104,过滤掉环境卡/快捷开关/分组 banner 等非设备 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);
// 高度 >=90 才是设备卡(环境/汇总卡 h~38、快捷开关 h~56 均被排除);再按 name 兜底剔除环境汇总卡。
if (nm && !isEnvCard(nm) && r && r.width > 100 && r.width < 260 && r.height >= 90) {
out.push({ name: nm, cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2) });
}
}
return out;
};
// 房间/分组 section 头:全宽 Button(x≈0,w>300,h≈44,name=房间名,如 "Cameras"/"woan")。
// iOS 多选删除**只能在同一 section 内**,跨房间勾选非法 → 批量时按 section 带限制。
const sections = async (): Promise<{ name: string; y: number }[]> => {
const btns = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton"').catch(() => []);
const out: { name: string; y: number }[] = [];
for (const b of btns as string[]) {
const r = await driver.getElementRect(b).catch(() => null);
if (!r || r.x > 20 || r.width < 300 || r.height < 30 || r.height > 60) continue; // 排除顶栏 Add/More/换房间等窄按钮
const nm = ((await driver.getElementAttribute(b, 'name').catch(() => '')) || '') as string;
if (nm) out.push({ name: nm, y: r.y });
}
return out.sort((a, b) => a.y - b.y);
};
// 求 cy 所属 section 的 [上界y, 下界y)(上界=紧邻其上的房间头,下界=紧邻其下的房间头;无头则 ±∞ → 全首页视作一个 section)
const bandOf = (secs: { name: string; y: number }[], cy: number): [number, number] => {
let top = -Infinity, bot = Infinity;
for (const s of secs) { if (s.y <= cy && s.y > top) top = s.y; if (s.y > cy && s.y < bot) bot = s.y; }
return [top, bot];
};
const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 }));
const undeletable = new Set<string>(); // 固定插件/删不掉的项 → 记下跳过,不再死磕
const deletedOnce = new Set<string>(); // 删过的名字;若再次出现 = 删除异常 → 标记跳过
// keypad 系列**单独**处理:进详情页 → 齿轮 → Device Pairing → Unpair → 确认 → Done(解绑门锁);
// 无 "Device Pairing"(未绑定)则跳过解绑 → 回首页单选删该卡。解绑后锁自身仍在(锁在白名单)。
const isKeypad = (n: string) => /Keypad/i.test(n);
const keypadUnbindDelete = async (name: string): Promise<boolean> => {
await driver.goBackToHomepage(); await sleep(1200); await driver.dismissPopupIfPresent();
let c = (await cards()).find((x) => x.name === name);
if (!c) return true; // 已不在
await driver.tap(c.cx, c.cy); await sleep(2500); // 进详情
// 右上齿轮(x>330,y<110,窄按钮)
const btns = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton"').catch(() => [] as string[]);
for (const b of btns) { const r = await driver.getElementRect(b).catch(() => null); if (r && r.x > 330 && r.y < 110 && r.width < 60) { await driver.tapElement(b); break; } }
await sleep(2500);
// 解绑(若已绑定 = 有 Device Pairing)
const dp = await driver.findElementRaw('name', 'Device Pairing').catch(() => null);
if (dp) {
const r = await driver.getElementRect(dp); await driver.tap(195, Math.round(r.y + r.height / 2)); await sleep(2500);
const up = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Unpair"').catch(() => null);
if (up) {
await driver.tapElement(up); await sleep(1800);
const ups = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Unpair"').catch(() => [] as string[]);
let conf: string | null = null, cy = -1;
for (const u of ups) { const r2 = await driver.getElementRect(u).catch(() => null); if (r2 && r2.y > cy) { cy = r2.y; conf = u; } } // 弹框确认=y最大
if (conf) await driver.tapElement(conf);
for (let i = 0; i < 10; i++) { if (/Unpaired successfully/.test(await driver.getSource().catch(() => ''))) break; await sleep(1500); }
for (const t of ['Finish', 'Done', 'OK']) { const f = await driver.findElementRaw('predicate string', `name == "${t}"`).catch(() => null); if (f) { await driver.tapElement(f); await sleep(1500); break; } }
}
console.log(`[reset] keypad ${shortName(name)} 解绑完成`);
} else {
console.log(`[reset] keypad ${shortName(name)} 未绑定,直接删`);
}
// 回首页,单选删该卡
await driver.goBackToHomepage(); await sleep(1500); await driver.dismissPopupIfPresent();
c = (await cards()).find((x) => x.name === name);
if (!c) return true; // 解绑流程中已消失
let inSel = false;
for (let i = 0; i < 3 && !inSel; i++) { await driver.longPress(c.cx, c.cy, 1.6); await sleep(2000); inSel = /selected|Finish/.test(await driver.getSource()); }
if (!inSel) { console.log(`[reset] keypad ${shortName(name)} 长按未进多选`); return false; }
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) { console.log(`[reset] keypad ${shortName(name)} 无 Delete`); return false; }
await driver.tapElement(del); await sleep(1800);
let confirm = await driver.findElementRaw('predicate string', 'name == "Confirm"').catch(() => null)
|| await driver.findElementRaw('predicate string', 'name == "Delete" AND visible == true').catch(() => null);
if (confirm) { await driver.tapElement(confirm); await sleep(3000); }
console.log(`[reset] keypad ${shortName(name)} 已删`);
return true;
};
let total = 0;
for (let round = 0; round < 60; round++) {
await driver.goBackToHomepage(); await sleep(1500);
await driver.dismissPopupIfPresent();
// 往下滚动,找第一张**可视区内、非白名单、未标记删不掉**的设备卡
// 判到底:连续2次 getSource 完全相同(单次 names 匹配在 RN 上会误触发提前停);步长够大(900)。
let target: { name: string; cx: number; cy: number } | null = null;
let lastSrc = ''; let same = 0;
for (let s = 0; s < 30 && !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 src = await driver.getSource();
if (src === lastSrc) { same++; if (same >= 2) break; } else same = 0;
lastSrc = src;
await driver.scrollDown(900); await sleep(900);
}
if (!target) { console.log('无可删的非白名单设备,结束'); break; }
// 删过却又出现 = 删除异常(如 Keypad Vision 绑锁,弹"需先解绑"删不掉)→ 标记跳过,不再死循环
if (deletedOnce.has(target.name)) {
console.log(`${shortName(target.name)} 删除异常(删后仍在),跳过`);
undeletable.add(target.name); continue;
}
// keypad 系列**单独**处理:先进设置页解绑门锁再删(未绑定则直接删),不走批量
if (isKeypad(target.name)) {
deletedOnce.add(target.name);
const ok = await keypadUnbindDelete(target.name);
if (ok) total++; else 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;
}
// 当前屏内**同一 section(房间)**的其它可删卡也勾上,一次 Delete 批量删(快)。
// 跨房间(不同 section)的卡不能同批删 → 只勾 target 所在 section 带内的卡,其它房间留后续轮次各自 long-press。
// **排除 keypad**(!isKeypad)——keypad 须走单独解绑删除,否则绑锁的 Keypad Vision 被批量勾选会破坏多选/漏解绑。
const secs = await sections();
const [bandTop, bandBot] = bandOf(secs, target.cy);
const sameRoom = new Set(
(await cards())
.filter((o) => !isKeptIOS(o.name) && !isKeypad(o.name) && !undeletable.has(o.name)
&& o.cy > bandTop && o.cy < bandBot
&& o.cy > winH * 0.08 && o.cy < winH * 0.9)
.map((o) => o.name),
);
const batchFull = [target.name];
for (const x of (await cards()).filter((o) => sameRoom.has(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); });