mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: Implement interactive device selection
This commit is contained in:
@@ -20,6 +20,13 @@ export interface BleBackend {
|
||||
timeoutMs: number,
|
||||
): Promise<DiscoveredDevice | null>;
|
||||
|
||||
scanForAll(
|
||||
matcher: (device: DiscoveredDevice) => boolean,
|
||||
timeoutMs: number,
|
||||
onDeviceFound?: (device: DiscoveredDevice) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DiscoveredDevice[]>;
|
||||
|
||||
stopScan(): Promise<void>;
|
||||
|
||||
connect(address: string): Promise<void>;
|
||||
|
||||
@@ -7,6 +7,11 @@ import type {
|
||||
BleCharacteristic,
|
||||
DiscoveredDevice,
|
||||
} from "../backend.ts";
|
||||
import type {
|
||||
AdapterInternal,
|
||||
BluezInterfacesMap,
|
||||
DbusObjectManager,
|
||||
} from "../../../types/node-ble.d.ts";
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
@@ -96,10 +101,6 @@ export class DBusBackend implements BleBackend {
|
||||
await this.ensureDiscovering();
|
||||
|
||||
const adapter = this.adapter;
|
||||
|
||||
// Keep re-checking unnamed devices on every poll instead of giving up on first sight.
|
||||
// Not sure if this is the best way to handle this.
|
||||
|
||||
const named = new Map<string, string | null>();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
@@ -141,6 +142,127 @@ export class DBusBackend implements BleBackend {
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stopScan(): Promise<void> {
|
||||
if (this.adapter && this.scanning) {
|
||||
await this.adapter.stopDiscovery().catch(() => {});
|
||||
@@ -227,9 +349,19 @@ export class DBusBackend implements BleBackend {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export class NobleBackend implements BleBackend {
|
||||
readonly name = "noble";
|
||||
|
||||
private peripheral: Peripheral | null = null;
|
||||
private peripheralsByAddress = new Map<string, Peripheral>();
|
||||
private initialized = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -107,6 +108,7 @@ export class NobleBackend implements BleBackend {
|
||||
await this.stopScan();
|
||||
noble.removeListener("discover", onDiscover);
|
||||
this.peripheral = peripheral;
|
||||
this.peripheralsByAddress.set(device.address, peripheral);
|
||||
resolve(device);
|
||||
}
|
||||
};
|
||||
@@ -121,11 +123,65 @@ export class NobleBackend implements BleBackend {
|
||||
});
|
||||
}
|
||||
|
||||
async scanForAll(
|
||||
matcher: (device: DiscoveredDevice) => boolean,
|
||||
timeoutMs: number,
|
||||
onDeviceFound?: (device: DiscoveredDevice) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DiscoveredDevice[]> {
|
||||
return new Promise((resolve) => {
|
||||
const found = new Map<string, DiscoveredDevice>();
|
||||
|
||||
const finish = async () => {
|
||||
clearTimeout(timeoutId);
|
||||
await this.stopScan();
|
||||
noble.removeListener("discover", onDiscover);
|
||||
resolve(Array.from(found.values()));
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(finish, timeoutMs);
|
||||
|
||||
signal?.addEventListener("abort", finish, { once: true });
|
||||
|
||||
const onDiscover = async (peripheral: Peripheral) => {
|
||||
const device: DiscoveredDevice = {
|
||||
address: peripheral.address || peripheral.id,
|
||||
name: peripheral.advertisement.localName ?? null,
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
`Discovered device: ${device.name ?? "(unnamed)"} (${device.address})`,
|
||||
);
|
||||
|
||||
if (matcher(device) && !found.has(device.address)) {
|
||||
found.set(device.address, device);
|
||||
this.peripheralsByAddress.set(device.address, peripheral);
|
||||
if (!this.peripheral) {
|
||||
this.peripheral = peripheral;
|
||||
}
|
||||
onDeviceFound?.(device);
|
||||
}
|
||||
};
|
||||
|
||||
noble.on("discover", onDiscover);
|
||||
|
||||
noble.startScanningAsync([], false).catch(() => {
|
||||
clearTimeout(timeoutId);
|
||||
noble.removeListener("discover", onDiscover);
|
||||
resolve([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stopScan(): Promise<void> {
|
||||
await noble.stopScanningAsync().catch(() => {});
|
||||
}
|
||||
|
||||
async connect(address: string): Promise<void> {
|
||||
const byAddress = this.peripheralsByAddress.get(address);
|
||||
if (byAddress) {
|
||||
this.peripheral = byAddress;
|
||||
}
|
||||
if (!this.peripheral) {
|
||||
throw new ConnectionError(`No discovered peripheral for ${address}`);
|
||||
}
|
||||
|
||||
+75
-66
@@ -148,11 +148,6 @@ class NotificationHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log sent packet in verbose mode
|
||||
* @param packet Sent packet buffer
|
||||
* @param description Description of the packet
|
||||
*/
|
||||
public logSentPacket(packet: Buffer, description: string): void {
|
||||
if (this.verbose) {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -162,10 +157,6 @@ class NotificationHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all received notifications
|
||||
* @returns Array of notifications with timestamp, data, and parsed content
|
||||
*/
|
||||
public getNotifications(): Array<{
|
||||
time: number;
|
||||
data: Buffer;
|
||||
@@ -174,10 +165,6 @@ class NotificationHandler {
|
||||
return this.allNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for device status to be received
|
||||
* @returns Promise that resolves when status is received
|
||||
*/
|
||||
public waitForStatus(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.deviceStatusReceived) {
|
||||
@@ -188,38 +175,24 @@ class NotificationHandler {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for any notification from device
|
||||
* @returns Promise that resolves when a notification is received
|
||||
*/
|
||||
public waitForNotification(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.notificationResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state
|
||||
*/
|
||||
public reset(): void {
|
||||
this.notificationResolve = null;
|
||||
this.statusResolve = null;
|
||||
this.errorFlag = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the expected number of acknowledgments to wait for
|
||||
* @param count Number of packets that will be sent
|
||||
*/
|
||||
public setExpectedAckCount(count: number): void {
|
||||
this.expectedAckCount = count;
|
||||
this.packetSuccessCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages BLE connection and data transfer to BeamBox device
|
||||
*/
|
||||
export class BleUploader {
|
||||
private backend: BleBackend;
|
||||
private writeCharacteristic: BleCharacteristic | null = null;
|
||||
@@ -230,6 +203,7 @@ export class BleUploader {
|
||||
private verbose: boolean = false;
|
||||
private isInitialized: boolean = false;
|
||||
private connectedAddress: string | null = null;
|
||||
private alreadyScanned: boolean = false;
|
||||
|
||||
constructor(
|
||||
private deviceAddress: string | null,
|
||||
@@ -246,9 +220,6 @@ export class BleUploader {
|
||||
logger.debug(`Using BLE backend: ${this.backend.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Bluetooth adapter and wait for it to be ready
|
||||
*/
|
||||
private async initBluetooth(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
@@ -258,36 +229,48 @@ export class BleUploader {
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize UUID for comparison
|
||||
* Handles both full 128-bit UUIDs and short 16/32-bit UUIDs
|
||||
* Short UUIDs use the Bluetooth Base UUID: 00000000-0000-1000-8000-00805f9b34fb
|
||||
*/
|
||||
private normalizeUUID(uuid: string): string {
|
||||
// Remove dashes and lowercase
|
||||
const cleaned = uuid.replace(/-/g, "").toLowerCase();
|
||||
|
||||
// If it's a full 128-bit UUID using Bluetooth Base UUID, extract the short form
|
||||
// Bluetooth Base UUID pattern: 0000XXXX-0000-1000-8000-00805f9b34fb
|
||||
const bluetoothBasePattern = /^0000([0-9a-f]{4})00001000800000805f9b34fb$/;
|
||||
const match = cleaned.match(bluetoothBasePattern);
|
||||
if (match && match[1]) {
|
||||
return match[1]; // Return short UUID part
|
||||
}
|
||||
|
||||
// For short UUIDs (3-4 hex chars), pad with zeros to 4 chars for consistent comparison
|
||||
// This handles cases like "1f1" vs "01f1"
|
||||
if (cleaned.length <= 4) {
|
||||
return cleaned.padStart(4, "0");
|
||||
}
|
||||
|
||||
// Strip Bluetooth Base UUID wrapper: 0000XXXX-0000-1000-8000-00805f9b34fb → XXXX
|
||||
const match = cleaned.match(/^0000([0-9a-f]{4})00001000800000805f9b34fb$/);
|
||||
if (match && match[1]) return match[1];
|
||||
if (cleaned.length <= 4) return cleaned.padStart(4, "0");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for the BeamBox device
|
||||
* @returns Device address or null if not found
|
||||
*/
|
||||
public setDeviceAddress(address: string): void {
|
||||
this.deviceAddress = address;
|
||||
this.alreadyScanned = true;
|
||||
}
|
||||
|
||||
public async scanForDevices(
|
||||
onDeviceFound?: (device: { name: string | null; address: string }) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Array<{ name: string | null; address: string }>> {
|
||||
await this.initBluetooth();
|
||||
|
||||
logger.info("Starting device scan...", LogEventType.SCAN_START);
|
||||
|
||||
const targetName = this.bleConfig.deviceName.toLowerCase();
|
||||
const timeout = this.bleConfig.scanTimeout * 1000;
|
||||
|
||||
const devices = await this.backend.scanForAll(
|
||||
(device) =>
|
||||
!!device.name && device.name.toLowerCase().includes(targetName),
|
||||
timeout,
|
||||
onDeviceFound,
|
||||
signal,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Scan complete, found ${devices.length} device(s)`,
|
||||
LogEventType.DEVICES_FOUND,
|
||||
{ devices },
|
||||
);
|
||||
|
||||
return devices;
|
||||
}
|
||||
|
||||
public async findDevice(): Promise<string | null> {
|
||||
await this.initBluetooth();
|
||||
|
||||
@@ -297,9 +280,7 @@ export class BleUploader {
|
||||
const timeout = this.bleConfig.scanTimeout * 1000;
|
||||
|
||||
const found = await this.backend.scanFor((device) => {
|
||||
return (
|
||||
!!device.name && device.name.toLowerCase().includes(targetName)
|
||||
);
|
||||
return !!device.name && device.name.toLowerCase().includes(targetName);
|
||||
}, timeout);
|
||||
|
||||
if (!found) {
|
||||
@@ -324,7 +305,13 @@ export class BleUploader {
|
||||
await this.initBluetooth();
|
||||
|
||||
if (!this.connectedAddress) {
|
||||
if (!this.deviceAddress) {
|
||||
if (this.alreadyScanned && this.deviceAddress) {
|
||||
logger.info(
|
||||
`Using previously scanned device: ${this.deviceAddress}`,
|
||||
LogEventType.DEVICE_FOUND,
|
||||
{ address: this.deviceAddress },
|
||||
);
|
||||
} else if (!this.deviceAddress) {
|
||||
logger.info("Scanning for device...", LogEventType.SCAN_START);
|
||||
const address = await this.findDevice();
|
||||
if (!address) {
|
||||
@@ -516,7 +503,11 @@ export class BleUploader {
|
||||
public async sendData(
|
||||
fullData: Buffer,
|
||||
packetType: PacketType,
|
||||
onProgress?: (sendProgress: number, confirmProgress: number, status?: string) => void,
|
||||
onProgress?: (
|
||||
sendProgress: number,
|
||||
confirmProgress: number,
|
||||
status?: string,
|
||||
) => void,
|
||||
): Promise<boolean> {
|
||||
if (!this.writeCharacteristic) {
|
||||
throw new ConnectionError("BLE client not connected");
|
||||
@@ -590,8 +581,13 @@ export class BleUploader {
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
const sendProgress = ((i + 1) / totalChunks) * 100;
|
||||
const confirmProgress = (this.notificationHandler.packetSuccessCount / totalChunks) * 100;
|
||||
onProgress(sendProgress, confirmProgress, `Sending: (${i + 1}/${totalChunks} packets)`);
|
||||
const confirmProgress =
|
||||
(this.notificationHandler.packetSuccessCount / totalChunks) * 100;
|
||||
onProgress(
|
||||
sendProgress,
|
||||
confirmProgress,
|
||||
`Sending: (${i + 1}/${totalChunks} packets)`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.sleep(this.chunkDelay * 1000);
|
||||
@@ -616,8 +612,13 @@ export class BleUploader {
|
||||
|
||||
if (onProgress) {
|
||||
const sendProgress = ((i + 1) / totalChunks) * 100;
|
||||
const confirmProgress = (this.notificationHandler.packetSuccessCount / totalChunks) * 100;
|
||||
onProgress(sendProgress, confirmProgress, `Sending: (${this.notificationHandler.packetSuccessCount}/${totalChunks} packets)`);
|
||||
const confirmProgress =
|
||||
(this.notificationHandler.packetSuccessCount / totalChunks) * 100;
|
||||
onProgress(
|
||||
sendProgress,
|
||||
confirmProgress,
|
||||
`Sending: (${this.notificationHandler.packetSuccessCount}/${totalChunks} packets)`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.sleep(100);
|
||||
@@ -636,7 +637,11 @@ export class BleUploader {
|
||||
* @returns True if all acks received without error
|
||||
*/
|
||||
public async waitForResponse(
|
||||
onProgress?: (sendProgress: number, confirmProgress: number, status?: string) => void,
|
||||
onProgress?: (
|
||||
sendProgress: number,
|
||||
confirmProgress: number,
|
||||
status?: string,
|
||||
) => void,
|
||||
): Promise<boolean> {
|
||||
this.notificationHandler.waitingForAck = true;
|
||||
|
||||
@@ -668,7 +673,11 @@ export class BleUploader {
|
||||
(this.notificationHandler.packetSuccessCount /
|
||||
this.notificationHandler.expectedAckCount) *
|
||||
100;
|
||||
onProgress(100, confirmProgress, `Confirming: (${this.notificationHandler.packetSuccessCount}/${this.notificationHandler.expectedAckCount} packets)`);
|
||||
onProgress(
|
||||
100,
|
||||
confirmProgress,
|
||||
`Confirming: (${this.notificationHandler.packetSuccessCount}/${this.notificationHandler.expectedAckCount} packets)`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.sleep(100); // Check every 100ms
|
||||
|
||||
Reference in New Issue
Block a user