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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|