import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const pexec = promisify(execFile); // Run bluetoothctl non-interactively. Returns combined stdout even on nonzero exit. async function btctl(args, timeoutSec = 15) { try { const { stdout } = await pexec('bluetoothctl', args, { timeout: timeoutSec * 1000, maxBuffer: 1 << 20 }); return stdout; } catch (e) { return (e.stdout || '') + (e.stderr || ''); } } function parseDevices(out) { const res = []; for (const line of out.split('\n')) { const m = line.match(/Device\s+([0-9A-F:]{17})\s+(.*)/i); if (m) res.push({ mac: m[1].toUpperCase(), name: m[2].trim() }); } return res; } // Scan for `seconds`, return all discovered devices. export async function scan(seconds = 10) { await btctl(['--timeout', '2', 'power', 'on'], 5); await btctl(['--timeout', String(seconds), 'scan', 'on'], seconds + 6); return enrich(parseDevices(await btctl(['devices']))); } // Known devices with paired/connected status. export async function knownDevices() { return enrich(parseDevices(await btctl(['devices']))); } async function enrich(devices) { const pairedMacs = new Set(parseDevices(await btctl(['paired-devices'])).map(d => d.mac)); for (const d of devices) { d.paired = pairedMacs.has(d.mac); const info = await btctl(['info', d.mac]); d.connected = /Connected:\s*yes/i.test(info); d.audio = /Icon:\s*audio|UUID:.*(Headset|Handsfree|Audio Sink)/i.test(info); } return devices; } export async function pair(mac) { const r1 = await btctl(['--timeout', '25', 'pair', mac], 30); const r2 = await btctl(['trust', mac], 5); const r3 = await btctl(['--timeout', '15', 'connect', mac], 20); const info = await btctl(['info', mac]); return { pair: r1.trim(), trust: r2.trim(), connect: r3.trim(), connected: /Connected:\s*yes/i.test(info) }; } // Full re-pair. realme (and many cheap earbuds) DISCARD their stored bond when // put back in pairing mode, so the board's kept bond goes stale → plain connect // or pair fails (AuthenticationFailed). Recipe: drop stale bond, rediscover // (scan must be its own step), then pair+trust+connect fresh. All via // bluetoothctl, so the callrec user can run it through the polkit rule. export async function repair(mac) { await btctl(['disconnect', mac], 10); await btctl(['remove', mac], 10); await btctl(['--timeout', '2', 'power', 'on'], 5); await btctl(['--timeout', '12', 'scan', 'on'], 18); // rediscover after removal const found = parseDevices(await btctl(['devices'])).some(d => d.mac === mac.toUpperCase()); if (!found) return { ok: false, step: 'scan', msg: 'Gerät nicht gefunden — im Pairing-Modus?' }; const r1 = await btctl(['--timeout', '25', 'pair', mac], 30); await btctl(['trust', mac], 5); const r3 = await btctl(['--timeout', '15', 'connect', mac], 20); const info = await btctl(['info', mac]); const connected = /Connected:\s*yes/i.test(info); return { ok: connected, pair: r1.trim(), connect: r3.trim(), connected }; } export async function connect(mac) { const out = await btctl(['--timeout', '15', 'connect', mac], 20); const info = await btctl(['info', mac]); return { out: out.trim(), connected: /Connected:\s*yes/i.test(info) }; } export async function disconnect(mac) { return { out: (await btctl(['disconnect', mac], 10)).trim() }; }