/** * 首页长按卡片→多选→批量删除(快),保留指定设备。安全开关: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 { 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 { // 环境/汇总卡(非设备,绝不可选):首页顶部「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(); // 固定插件/删不掉的项 → 记下跳过,不再死磕 const deletedOnce = new Set(); // 删过的名字;若再次出现 = 删除异常 → 标记跳过 // keypad 系列**单独**处理:进详情页 → 齿轮 → Device Pairing → Unpair → 确认 → Done(解绑门锁); // 无 "Device Pairing"(未绑定)则跳过解绑 → 回首页单选删该卡。解绑后锁自身仍在(锁在白名单)。 const isKeypad = (n: string) => /Keypad/i.test(n); const keypadUnbindDelete = async (name: string): Promise => { 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); });