import { createBluetooth } from "node-ble"; import type NodeBle from "node-ble"; import { ConnectionError } from "../../utils/errors.ts"; import { logger } from "../../utils/logger.ts"; import type { BleBackend, BleCharacteristic, DiscoveredDevice, } from "../backend.ts"; import type { AdapterInternal, BluezInterfacesMap, DbusObjectManager, } from "../../../types/node-ble.d.ts"; const POLL_INTERVAL_MS = 1000; class DBusCharacteristic implements BleCharacteristic { private valueChangedListener: ((buffer: Buffer) => void) | null = null; constructor( private characteristic: NodeBle.GattCharacteristic, public readonly uuid: string, ) {} async write(data: Buffer, withoutResponse: boolean): Promise { if (withoutResponse) { await this.characteristic.writeValueWithoutResponse(data); } else { await this.characteristic.writeValueWithResponse(data); } } async subscribe(onData: (data: Buffer) => void): Promise { this.valueChangedListener = onData; this.characteristic.on("valuechanged", onData); await this.characteristic.startNotifications(); } async unsubscribe(): Promise { if (this.valueChangedListener) { this.characteristic.removeListener( "valuechanged", this.valueChangedListener, ); this.valueChangedListener = null; } await this.characteristic.stopNotifications().catch(() => {}); } } export class DBusBackend implements BleBackend { readonly name = "dbus"; private bluetooth: NodeBle.Bluetooth | null = null; private destroy: (() => void) | null = null; private adapter: NodeBle.Adapter | null = null; private device: NodeBle.Device | null = null; private scanning = false; async init(): Promise { if (this.bluetooth) { return; } const { bluetooth, destroy } = createBluetooth(); this.bluetooth = bluetooth; this.destroy = destroy; try { this.adapter = await bluetooth.defaultAdapter(); } catch { throw new ConnectionError( "No Bluetooth adapter found (is bluetoothd running?)", ); } if (!(await this.adapter.isPowered())) { throw new ConnectionError("Bluetooth adapter is not powered on"); } } private async ensureDiscovering(): Promise { if (!this.adapter) { throw new ConnectionError("Bluetooth adapter not initialized"); } if (!(await this.adapter.isDiscovering())) { await this.adapter.startDiscovery(); } this.scanning = true; } async scanFor( matcher: (device: DiscoveredDevice) => boolean, timeoutMs: number, ): Promise { if (!this.adapter) { throw new ConnectionError("Bluetooth adapter not initialized"); } await this.ensureDiscovering(); const adapter = this.adapter; const named = new Map(); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const addresses = await adapter.devices(); for (const address of addresses) { if (named.get(address)) { continue; } let name: string | null = null; try { const remoteDevice = await adapter.getDevice(address); name = await remoteDevice.getName().catch(async () => { return await remoteDevice.getAlias().catch(() => null); }); } catch { // device vanished mid-scan?? } if (name && named.get(address) !== name) { logger.debug(`Discovered device: ${name} (${address})`); } named.set(address, name); const candidate: DiscoveredDevice = { address, name }; if (matcher(candidate)) { await this.stopScan(); this.device = await adapter.getDevice(address); return candidate; } } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } await this.stopScan(); return null; } async scanForAll( matcher: (device: DiscoveredDevice) => boolean, timeoutMs: number, onDeviceFound?: (device: DiscoveredDevice) => void, signal?: AbortSignal, ): Promise { if (!this.adapter) { throw new ConnectionError("Bluetooth adapter not initialized"); } await this.ensureDiscovering(); const adapter = this.adapter; const found = new Map(); return new Promise((resolve) => { const dbus = (adapter as AdapterInternal).dbus; let objectManager: DbusObjectManager | null = null; const finish = async () => { clearTimeout(timeoutId); signal?.removeEventListener("abort", finish); if (objectManager) { objectManager.removeAllListeners("InterfacesAdded"); } await this.stopScan(); resolve(Array.from(found.values())); }; const timeoutId = setTimeout(finish, timeoutMs); signal?.addEventListener("abort", finish, { once: true }); const handleDevice = async ( objectPath: string, interfaces: BluezInterfacesMap, ) => { if (!objectPath.includes("/dev_")) return; const iface = interfaces["org.bluez.Device1"]; if (!iface) return; const address: string = iface.Address?.value ?? objectPath.split("/dev_")[1]?.replace(/_/g, ":") ?? ""; if (!address || found.has(address)) return; const name: string | null = iface.Name?.value ?? iface.Alias?.value ?? null; if (name) { logger.debug(`Discovered device: ${name} (${address})`); } const candidate: DiscoveredDevice = { address, name }; if (matcher(candidate)) { found.set(address, candidate); if (!this.device) { this.device = await adapter.getDevice(address).catch(() => null); } onDeviceFound?.(candidate); } }; dbus .getProxyObject("org.bluez", "/") .then((rootObj) => { objectManager = rootObj.getInterface( "org.freedesktop.DBus.ObjectManager", ); objectManager.on("InterfacesAdded", handleDevice); objectManager .GetManagedObjects() .then((objects) => { for (const [objectPath, interfaces] of Object.entries(objects)) { handleDevice(objectPath, interfaces); } }) .catch(() => {}); }) .catch(() => { const poll = async () => { if (signal?.aborted) return; try { const addresses = await adapter.devices(); for (const address of addresses) { if (found.has(address)) continue; let name: string | null = null; try { const remoteDevice = await adapter.getDevice(address); name = await remoteDevice .getName() .catch(async () => remoteDevice.getAlias().catch(() => null), ); } catch { /* ignore */ } if (name) logger.debug(`Discovered device: ${name} (${address})`); const candidate: DiscoveredDevice = { address, name }; if (matcher(candidate)) { found.set(address, candidate); if (!this.device) this.device = await adapter .getDevice(address) .catch(() => null); onDeviceFound?.(candidate); } } } catch { /* ignore */ } if (!signal?.aborted) setTimeout(poll, POLL_INTERVAL_MS); }; poll(); }); }); } async stopScan(): Promise { if (this.adapter && this.scanning) { await this.adapter.stopDiscovery().catch(() => {}); this.scanning = false; } } async connect(address: string): Promise { if (!this.adapter) { throw new ConnectionError("Bluetooth adapter not initialized"); } if (!this.device) { this.device = await this.adapter.getDevice(address); } if (await this.device.isConnected().catch(() => false)) { await this.device.disconnect().catch(() => {}); await new Promise((resolve) => setTimeout(resolve, 500)); } await this.device.connect(); } onDisconnect(callback: () => void): void { this.device?.once("disconnect", callback); } async discoverCharacteristics( writeUUID: string, notifyUUID: string, normalizeUUID: (uuid: string) => string, ): Promise<{ write: BleCharacteristic; notify: BleCharacteristic }> { if (!this.device) { throw new ConnectionError("Not connected to device"); } const gattServer = await this.device.gatt(); const serviceUuids = await gattServer.services(); const normalizedWriteUuid = normalizeUUID(writeUUID); const normalizedNotifyUuid = normalizeUUID(notifyUUID); let writeChar: { char: NodeBle.GattCharacteristic; uuid: string } | null = null; let notifyChar: { char: NodeBle.GattCharacteristic; uuid: string } | null = null; for (const serviceUuid of serviceUuids) { const service = await gattServer.getPrimaryService(serviceUuid); const charUuids = await service.characteristics(); for (const charUuid of charUuids) { const normalizedCharUuid = normalizeUUID(charUuid); if (normalizedCharUuid === normalizedWriteUuid && !writeChar) { writeChar = { char: await service.getCharacteristic(charUuid), uuid: charUuid, }; } if (normalizedCharUuid === normalizedNotifyUuid && !notifyChar) { notifyChar = { char: await service.getCharacteristic(charUuid), uuid: charUuid, }; } } } if (!writeChar || !notifyChar) { throw new ConnectionError("Could not find required characteristics"); } return { write: new DBusCharacteristic(writeChar.char, writeChar.uuid), notify: new DBusCharacteristic(notifyChar.char, notifyChar.uuid), }; } async disconnect(): Promise { try { await this.stopScan(); if (this.device) { this.device.removeAllListeners(); const address = await this.device.getAddress().catch(() => null); if (await this.device.isConnected().catch(() => false)) { await this.device.disconnect().catch(() => {}); } if (address && this.adapter) { const internal = this.adapter as AdapterInternal; const devicePath = `/org/bluez/${internal.adapter}/dev_${address.replace(/:/g, "_").toUpperCase()}`; await internal.helper.callMethod("RemoveDevice", devicePath).catch(() => {}); } this.device = null; } this.destroy?.(); this.destroy = null; this.bluetooth = null; this.adapter = null; } catch { // best-effort cleanup } } }