Files
beamboxctl/src/lib/ble/backends/dbus-backend.ts
T

377 lines
11 KiB
TypeScript
Raw Normal View History

2026-06-08 22:38:42 +07:00
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";
2026-06-09 05:04:02 +07:00
import type {
AdapterInternal,
BluezInterfacesMap,
DbusObjectManager,
} from "../../../types/node-ble.d.ts";
2026-06-08 22:38:42 +07:00
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<void> {
if (withoutResponse) {
await this.characteristic.writeValueWithoutResponse(data);
} else {
await this.characteristic.writeValueWithResponse(data);
}
}
async subscribe(onData: (data: Buffer) => void): Promise<void> {
this.valueChangedListener = onData;
this.characteristic.on("valuechanged", onData);
await this.characteristic.startNotifications();
}
async unsubscribe(): Promise<void> {
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<void> {
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<void> {
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<DiscoveredDevice | null> {
if (!this.adapter) {
throw new ConnectionError("Bluetooth adapter not initialized");
}
await this.ensureDiscovering();
const adapter = this.adapter;
const named = new Map<string, string | null>();
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;
}
2026-06-09 05:04:02 +07:00
async scanForAll(
matcher: (device: DiscoveredDevice) => boolean,
timeoutMs: number,
onDeviceFound?: (device: DiscoveredDevice) => void,
signal?: AbortSignal,
): Promise<DiscoveredDevice[]> {
if (!this.adapter) {
throw new ConnectionError("Bluetooth adapter not initialized");
}
await this.ensureDiscovering();
const adapter = this.adapter;
const found = new Map<string, DiscoveredDevice>();
return new Promise<DiscoveredDevice[]>((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();
});
});
}
2026-06-08 22:38:42 +07:00
async stopScan(): Promise<void> {
if (this.adapter && this.scanning) {
await this.adapter.stopDiscovery().catch(() => {});
this.scanning = false;
}
}
async connect(address: string): Promise<void> {
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<void> {
try {
await this.stopScan();
if (this.device) {
this.device.removeAllListeners();
2026-06-09 05:04:02 +07:00
const address = await this.device.getAddress().catch(() => null);
2026-06-08 22:38:42 +07:00
if (await this.device.isConnected().catch(() => false)) {
await this.device.disconnect().catch(() => {});
}
2026-06-09 05:04:02 +07:00
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(() => {});
}
2026-06-08 22:38:42 +07:00
this.device = null;
}
this.destroy?.();
this.destroy = null;
this.bluetooth = null;
this.adapter = null;
} catch {
// best-effort cleanup
}
}
}